|
| 1 | +# typed: true |
| 2 | +# frozen_string_literal: true |
| 3 | + |
| 4 | +# AutoHCK module |
| 5 | +module AutoHCK |
| 6 | + # PackageManager class for cross-platform package queries |
| 7 | + class PackageManager |
| 8 | + extend T::Sig |
| 9 | + |
| 10 | + class UnsupportedPlatformError < StandardError; end |
| 11 | + |
| 12 | + SUPPORTED_DISTROS = { |
| 13 | + 'ubuntu' => :debian, |
| 14 | + 'debian' => :debian, |
| 15 | + 'centos' => :rhel, |
| 16 | + 'rhel' => :rhel, |
| 17 | + 'rocky' => :rhel, |
| 18 | + 'almalinux' => :rhel, |
| 19 | + 'fedora' => :fedora, |
| 20 | + 'arch' => :arch, |
| 21 | + 'manjaro' => :arch |
| 22 | + }.freeze |
| 23 | + |
| 24 | + sig { void } |
| 25 | + def initialize |
| 26 | + @logger = nil |
| 27 | + @distro_info = detect_distro |
| 28 | + end |
| 29 | + |
| 30 | + sig { params(logger: T.untyped).void } |
| 31 | + def logger=(logger) |
| 32 | + @logger = logger |
| 33 | + end |
| 34 | + |
| 35 | + sig { params(binary_path: String).returns(T.nilable(String)) } |
| 36 | + def query_package(binary_path) |
| 37 | + return nil if binary_path.nil? || binary_path.empty? |
| 38 | + |
| 39 | + query_method = distro_query_method(@distro_info[:family]) |
| 40 | + return unsupported_platform_warning unless query_method |
| 41 | + |
| 42 | + send(query_method, binary_path) |
| 43 | + rescue StandardError => e |
| 44 | + log_warning("Failed to query package for #{binary_path}: #{e.message}") |
| 45 | + nil |
| 46 | + end |
| 47 | + |
| 48 | + private |
| 49 | + |
| 50 | + sig { params(family: Symbol).returns(T.nilable(Symbol)) } |
| 51 | + def distro_query_method(family) |
| 52 | + { |
| 53 | + debian: :query_debian_package, |
| 54 | + rhel: :query_rhel_package, |
| 55 | + fedora: :query_fedora_package, |
| 56 | + arch: :query_arch_package |
| 57 | + }[family] |
| 58 | + end |
| 59 | + |
| 60 | + sig { returns(T.nilable(String)) } |
| 61 | + def unsupported_platform_warning |
| 62 | + log_warning("Unsupported platform: #{@distro_info[:name]} (#{@distro_info[:family]})") |
| 63 | + nil |
| 64 | + end |
| 65 | + |
| 66 | + sig { returns(T::Hash[Symbol, T.untyped]) } |
| 67 | + def detect_distro |
| 68 | + # Check for common distribution identification files |
| 69 | + if File.exist?('/etc/os-release') |
| 70 | + parse_os_release |
| 71 | + elsif File.exist?('/etc/lsb-release') |
| 72 | + parse_lsb_release |
| 73 | + elsif File.exist?('/etc/redhat-release') |
| 74 | + parse_redhat_release |
| 75 | + else |
| 76 | + { name: 'unknown', family: :unknown } |
| 77 | + end |
| 78 | + end |
| 79 | + |
| 80 | + sig { returns(T::Hash[Symbol, T.untyped]) } |
| 81 | + def parse_os_release |
| 82 | + content = File.read('/etc/os-release') |
| 83 | + id = content[/^ID=(.+)/, 1]&.gsub(/["']/, '')&.downcase |
| 84 | + |
| 85 | + return { name: 'unknown', family: :unknown } unless id |
| 86 | + |
| 87 | + return parse_fedora_release(content, id) if id == 'fedora' |
| 88 | + |
| 89 | + family = SUPPORTED_DISTROS[id] || :unknown |
| 90 | + { name: id, family: family } |
| 91 | + end |
| 92 | + |
| 93 | + sig { params(content: String, id: String).returns(T::Hash[Symbol, T.untyped]) } |
| 94 | + def parse_fedora_release(content, id) |
| 95 | + # Check if it's Fedora Silverblue/Kinoite |
| 96 | + variant = content[/^VARIANT_ID=(.+)/, 1]&.gsub(/["']/, '')&.downcase |
| 97 | + family = fedora_immutable_variant?(variant) ? :fedora_immutable : :fedora |
| 98 | + { name: id, family: family, variant: variant } |
| 99 | + end |
| 100 | + |
| 101 | + sig { params(variant: T.nilable(String)).returns(T::Boolean) } |
| 102 | + def fedora_immutable_variant?(variant) |
| 103 | + return false unless variant |
| 104 | + |
| 105 | + variant.include?('silverblue') || variant.include?('kinoite') |
| 106 | + end |
| 107 | + |
| 108 | + sig { returns(T::Hash[Symbol, T.untyped]) } |
| 109 | + def parse_lsb_release |
| 110 | + content = File.read('/etc/lsb-release') |
| 111 | + id = content[/^DISTRIB_ID=(.+)/, 1]&.gsub(/["']/, '')&.downcase |
| 112 | + |
| 113 | + family = id ? SUPPORTED_DISTROS[id] || :unknown : :unknown |
| 114 | + { name: id || 'unknown', family: family } |
| 115 | + end |
| 116 | + |
| 117 | + sig { returns(T::Hash[Symbol, T.untyped]) } |
| 118 | + def parse_redhat_release |
| 119 | + content = File.read('/etc/redhat-release').downcase |
| 120 | + |
| 121 | + if content.include?('centos') |
| 122 | + { name: 'centos', family: :rhel } |
| 123 | + elsif content.include?('red hat') |
| 124 | + { name: 'rhel', family: :rhel } |
| 125 | + elsif content.include?('fedora') |
| 126 | + { name: 'fedora', family: :fedora } |
| 127 | + else |
| 128 | + { name: 'unknown', family: :unknown } |
| 129 | + end |
| 130 | + end |
| 131 | + |
| 132 | + sig { params(binary_path: String).returns(T.nilable(String)) } |
| 133 | + def query_debian_package(binary_path) |
| 134 | + # Use dpkg -S to find which package owns the file |
| 135 | + result = `dpkg -S "#{binary_path}" 2>/dev/null` |
| 136 | + return nil if $CHILD_STATUS.exitstatus != 0 || result.empty? |
| 137 | + |
| 138 | + # dpkg -S output format: "package: /path/to/file" |
| 139 | + package = result.split(':').first&.strip |
| 140 | + return nil unless package |
| 141 | + |
| 142 | + # Get the full package version |
| 143 | + version_result = `dpkg -l "#{package}" 2>/dev/null | tail -1` |
| 144 | + return package if version_result.empty? |
| 145 | + |
| 146 | + # dpkg -l output format: "ii package version arch description" |
| 147 | + parts = version_result.split |
| 148 | + return package unless parts.length >= 3 |
| 149 | + |
| 150 | + "#{package}_#{parts[2]}_#{parts[3]}" |
| 151 | + end |
| 152 | + |
| 153 | + sig { params(binary_path: String).returns(T.nilable(String)) } |
| 154 | + def query_rhel_package(binary_path) |
| 155 | + # Use rpm -qf for RHEL/CentOS |
| 156 | + result = `rpm -qf "#{binary_path}" 2>/dev/null` |
| 157 | + return nil if $CHILD_STATUS.exitstatus != 0 || result.empty? |
| 158 | + |
| 159 | + result.strip |
| 160 | + end |
| 161 | + |
| 162 | + sig { params(binary_path: String).returns(T.nilable(String)) } |
| 163 | + def query_fedora_package(binary_path) |
| 164 | + # For regular Fedora, use rpm -qf |
| 165 | + # For Fedora Silverblue/immutable, also use rpm -qf (rpm-ostree manages the rpm db) |
| 166 | + if @distro_info[:family] == :fedora_immutable |
| 167 | + log_info("Detected Fedora immutable variant (#{@distro_info[:variant]}), using rpm query") |
| 168 | + end |
| 169 | + |
| 170 | + result = `rpm -qf "#{binary_path}" 2>/dev/null` |
| 171 | + return nil if $CHILD_STATUS.exitstatus != 0 || result.empty? |
| 172 | + |
| 173 | + result.strip |
| 174 | + end |
| 175 | + |
| 176 | + sig { params(binary_path: String).returns(T.nilable(String)) } |
| 177 | + def query_arch_package(binary_path) |
| 178 | + # Use pacman -Qo for Arch Linux |
| 179 | + result = `pacman -Qo "#{binary_path}" 2>/dev/null` |
| 180 | + return nil if $CHILD_STATUS.exitstatus != 0 || result.empty? |
| 181 | + |
| 182 | + # pacman -Qo output format: "/path/to/file is owned by package version" |
| 183 | + match = result.match(/is owned by (.+)/) |
| 184 | + return nil unless match |
| 185 | + |
| 186 | + match[1].strip |
| 187 | + end |
| 188 | + |
| 189 | + sig { params(message: String).void } |
| 190 | + def log_info(message) |
| 191 | + if @logger |
| 192 | + @logger.info(message) |
| 193 | + else |
| 194 | + puts "INFO: #{message}" |
| 195 | + end |
| 196 | + end |
| 197 | + |
| 198 | + sig { params(message: String).void } |
| 199 | + def log_warning(message) |
| 200 | + if @logger |
| 201 | + @logger.warn(message) |
| 202 | + else |
| 203 | + puts "WARNING: #{message}" |
| 204 | + end |
| 205 | + end |
| 206 | + end |
| 207 | +end |
0 commit comments