#!/usr/bin/env ruby require "time" require "uri" # TODO: List changelog entries per package # TODO: List mirrors # TODO: List removed packages mentioned in changelog # TODO: Enable downloading of a packages source code too # TODO: Read input from STDIN using '-' # TODO: Rename command to slackware.com # TODO: Silence when backgrounded like 'scp' # BUG: File verification with Checksums appears to fail when QUIET is enabled, # BUG: Report readable error message to user when 'url' cannot be resolved. # BUG: Installing packages out of order may cause failure env_vars = {} ROOT = ENV["ROOT"] || "/" env_vars["ROOT"] = ["target root directory", "/"] CACHE = ENV["PACKAGE_CACHE"] || File.join(ROOT, "var/cache/packages") env_vars["PACKAGE_CACHE"] = ["local directory for downloads", "$ROOT/var/cache/packages"] QUIET = ENV["QUIET"] || "false" env_vars["QUIET"] = ["turn off informational output: 'false' or 'true'", "false"] MIRROR = ENV["SLACKWARE_MIRROR"] || "https://download.dlackware.com/slackware/slackware-current/" env_vars["SLACKWARE_MIRROR"] = ["remote root slackware package location", "https://download.dlackware.com/slackware/slackware-current/"] CHANGELOG = "ChangeLog.txt" CHECKSUMS ="CHECKSUMS.md5" PACKAGES = File.join(ROOT, "/var/log/packages") # # Errors # class DownloadError < StandardError def to_s "could not download '#{super.to_s}'" end end class VerificationError < StandardError def initialize(file_name, info) @file_name = file_name @info = info end def to_s msg = "verification failed for #{@file_name}" msg += ": #{@info}" if @info return msg end end class NotFoundError < StandardError def to_s "no '#{super.to_s}' package found" end end class SyncRequiredError < StandardError def to_s exe_name = File.basename($0) "Try running '#{exe_name} sync': #{super.to_s}" end end def exitOnError(arg) if arg.is_a? StandardError exitOnError([arg]) end return unless arg.is_a? Array errs = arg.select{|v| v.is_a? StandardError} return if errs.empty? errs.each{|err| STDERR.puts "#{err}"} exit(1) end # is_onion checks the argument is a TOR host def is_onion(host) %r{.onion$}.match(host) != nil end def verifySynced(path) unless File.exists?(path) exitOnError(SyncRequiredError.new("#{path} not found")) end end # # Domain # class Package def self.from_path(package_path) name = File.basename(package_path) ext = File.extname(name) pkg_name = name.slice(0, name.length - ext.length) return pkg_name end def self.name(package) # TODO: Check if fail reg_package=%r{^(.*?)-([^-]+)-([^-]+)-([^-]+)$} name = reg_package.match(package) return name[1] end def self.merge(arg1, arg2) # this is not needed lookup = {} arg1.each{|p| lookup[Package.name(p)] = p} filtered = arg2.select{|p| name = Package.name(p) ! lookup[name] } merged = arg1.concat(filtered) return merged end def self.patched_packages(package_paths) patch_paths = package_paths.select{|p| p.include?("/patches/")} patches = patch_paths.map{|p| Package.from_path(p)} all = package_paths.map{|p| Package.from_path(p)} return self.merge(patches, all) end end class Selector def self.is_match?(selector, pkg) pkg == selector || Package.name(pkg) == selector end end class PackagePath def self.only_base_packages(package_paths) package_paths.reject{|pkg| pkg.include?("extra/") || pkg.include?("pasture/") || pkg.include?("testing/") } end # TODO: Check if package not found # TODO: Correctly return multiple matches def self.select(package_paths, selector) if selector.include?("/") then # file path is given, lets treat it strictly found = package_paths.select{|p| p.include?(selector)} return found.first if found.length > 0 end patch_paths = package_paths.select{|p| p.include?("/patches/")} found = patch_paths.select{|pkg_path| Selector.is_match?(selector, Package.from_path(pkg_path)) } return found.first if found.length > 0 other_paths = package_paths.select{|p| !p.include?("/patches/")} found = other_paths.select{|pkg_path| Selector.is_match?(selector, Package.from_path(pkg_path)) } return found.first end end class ChangeLog BREAK="+--------------------------+\n" PACKAGE_ADDED="Added" PACKAGE_UPDATED="Updated" PACKAGE_REBUILT="Rebuilt" PACKAGE_REMOVED="Removed" PACKAGE_REG=%r{^(?\S+):\s+(?\S+).} def self.parse_entry(entry) packages = entry.scan(PACKAGE_REG) # PROBLEM: entry not properly described # PROBLEM: package names do not have consistent naming return { content: entry, packages: packages, date: Time.parse(entry.lines.first), } end def initialize(path) File.open(path, "r") do |buf| entries = buf.read().encode("UTF-8", invalid: :replace).split(BREAK) @entries = entries.map{|e| ChangeLog.parse_entry(e)} end end def new_packages_since(tim) changes = @entries.select{|entry| entry[:date] > tim} ret = changes.map{|entry| ret = entry[:packages] .select{|pkg| pkg[1] == PACKAGE_ADDED} .map{|pkg| pkg[0]} ret = PackagePath.only_base_packages(ret) }.flatten end def updates_since(tim) changes = @entries.select{|entry| entry[:date] > tim} changes.map{|entry| entry[:content]}.join(BREAK) end def guess_latest_update(installed_packages) is_installed = {} installed_packages.each{|pkg| is_installed[pkg] = true} last_install_at = @entries.find_index{|entry| entry[:packages].any?{|entry_pkg| pkg_name = Package.from_path(entry_pkg[0]) is_installed[pkg_name] } } last_install_at = @entries.last if last_install_at == nil @entries[last_install_at][:date] end end class ChecksumFile def self.package_paths(path) err = verifySynced(path) return err if err # could fail reg_package=%r{(\w+)\s+(\S+.txz)\n} contents = File.read(path) paths = contents.scan(reg_package).map{|m| m[1] } return paths end end def print_array(arr) arr.each{|i| puts i } end class Local def self.downloaded_packages() ret = Dir.glob("#{CACHE}/*.txz").map{|p| Package.from_path(p)} return ret end def self.installed_packages() Dir.glob("#{PACKAGES}/*").map{|p| File.basename(p)} end def self.packages() Package.merge(self.downloaded_packages(), self.installed_packages()) end def self.gpg_verify(file_path) ok = system("gpg2", "--verify-files", file_path, [:out, :err] => "/dev/null") ok ? nil : VerificationError.new(file_path, "GPG check failed") end def self.md5sum_verify(checksum_path, file_path) err = verifySynced(checksum_path) return err if err target_checksum = "" ok = IO.popen(["md5sum", file_path]) {|out| line = out.gets() target_checksum = line.split()[0] if line } return VerificationError.new(file_path) unless ok target_filename = "" ok = IO.popen(["grep", target_checksum, checksum_path]) {|out| line = out.gets() target_filename = line.split()[1] if line } return VerificationError.new(file_path, "bad checksum #{target_checksum}") unless ok if File.basename(target_filename) != File.basename(file_path) return VerificationError.new(file_path, "bad checksum #{target_checksum}") end return nil end def self.download(src, dst = nil) dst = File.basename(src) unless dst dst = File.join(dst, File.basename(src)) if File.directory?(dst) File.unlink(dst) if File.exists?(dst) src_uri = URI(src) command = ["wget", "-O", dst, src_uri.to_s, ] command << "--quiet" if QUIET == "true" command.unshift("torsocks") if is_onion(src_uri.host) ok = system(*command) ok ? nil : DownloadError.new(src) end def self.download_and_verify(mirror, remote_file_path) file_name = File.basename(remote_file_path) if File.extname(file_name) == ".txz" || file_name == CHECKSUMS remote_file_path_sig = "#{remote_file_path}.asc" local_file_path_sig = File.join(CACHE, "#{file_name}.asc") err = self.download(File.join(mirror, remote_file_path_sig), CACHE) File.unlink(local_file_path_sig) if err && File.exist?(local_file_path_sig) return err if err end file_name = File.basename(remote_file_path) err = self.download(File.join(mirror, remote_file_path), CACHE) return err if err local_file_path = File.join(CACHE, file_name) if File.extname(file_name) == ".txz" || file_name == CHECKSUMS err = self.gpg_verify(local_file_path_sig) File.unlink(local_file_path) if err && File.exist?(local_file_path) return err if err end unless file_name == CHECKSUMS checksum_path = File.join(CACHE, CHECKSUMS) err = self.md5sum_verify(checksum_path, local_file_path) File.unlink(local_file_path) if err && File.exist?(local_file_path) return err if err end end end # # Commands # def command_changelog(changelog_path, installed_packages) exitOnError(verifySynced(changelog_path)) changelog = ChangeLog.new(changelog_path) since = changelog.guess_latest_update(installed_packages) updates = changelog.updates_since(since) puts updates unless updates.empty? end def command_download(checksum_path, selectors) package_paths = ChecksumFile.package_paths(checksum_path) arg_paths = selectors.map{|selector| ret = PackagePath.select(package_paths, selector) ret || NotFoundError.new(selector) } exitOnError(arg_paths) res = arg_paths.map{|path| Local.download_and_verify(MIRROR, path) } exitOnError(res) end def command_list(checksum_path) package_paths = ChecksumFile.package_paths(checksum_path) remote_packages = Package.patched_packages(package_paths) print_array(remote_packages.sort()) end def command_sync(mirror) err = Local.download_and_verify(mirror, CHECKSUMS) exitOnError(err) err = Local.download_and_verify(mirror, CHANGELOG) exitOnError(err) end def command_updates(changelog_path, checksum_path, installed_packages) # TODO(MUST): protect against downgrade attacks package_paths = ChecksumFile.package_paths(checksum_path) package_paths = PackagePath.only_base_packages(package_paths) remote_packages = Package.patched_packages(package_paths) local_packages = installed_packages lookup = {} local_packages.each{|p| name = Package.name(p) lookup[name] = p } updates = remote_packages.select{|p| name = Package.name(p) lookup[name] && lookup[name] != p } changelog = ChangeLog.new(changelog_path) since = changelog.guess_latest_update(installed_packages) new_packages = changelog.new_packages_since(since) updates = updates.concat(new_packages) print_array(updates.sort()) end # # Main # class Command def initialize() @commands = {} @env_vars = {} self.add_subcommand("help", "list commands offered by this tool", ->{self.help(STDOUT)}) end def add_subcommand(name, description, command) @commands[name] = { name:name, desc: description, cmd: command} end def add_env_var(name, description, default) @env_vars[name] = { name:name, desc: description, default: default} end def run(name) cmd = @commands[name] cmd = cmd || {cmd: ->{self.help(STDERR)}} cmd[:cmd].call() end def help(fd) exe_name = File.basename($0) fd.puts "#{exe_name} [command]" fd.puts "" @commands.to_a.sort.each{|h| v = h[1] fd.puts "\t#{v[:name]}" fd.puts "\t\t- #{v[:desc]}" } fd.puts "" fd.puts "Environment Variables" fd.puts "" @env_vars.to_a.sort.each{|h| v = h[1] fd.puts "\t#{v[:name]} (#{v[:default]})" fd.puts "\t\t- #{v[:desc]}" } end end command = Command.new() env_vars.each{|h,v| command.add_env_var(h, v[0], v[1])} command.add_subcommand( "changelog", "show changes described in the Changelog since the last update", ->{command_changelog(File.join(CACHE,CHANGELOG), Local.installed_packages())}, ) command.add_subcommand( "download", "download the given packages", ->{to_download = ARGV.drop(1); command_download(File.join(CACHE, CHECKSUMS), to_download)}, ) # TODO: list w/ directory prefix and include pasture etc. command.add_subcommand( "list", "print all packages on the mirror", ->{command_list(File.join(CACHE,CHECKSUMS))}, ) command.add_subcommand( "sync", "update knowledge about mirror's content", ->{command_sync(MIRROR)}, ) command.add_subcommand( "updates", "print all new and updated packages on mirror", ->{command_updates(File.join(CACHE,CHANGELOG), File.join(CACHE, CHECKSUMS), Local.packages())} ) command.run(ARGV[0])