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