blob: 31ed5568e3b8b7028259339dbbc6147dfc75c830 [file] [edit]
#! /usr/bin/env ruby
# frozen_string_literal: true
# Check an installation or unpacked distribution against its manifest.
# Each entry records its ls-style file type and permissions, a forward-slash
# path, and (for symlinks) the link target. The root directory is listed too.
require 'find'
require 'pathname'
require_relative 'UpdateCommon'
def file_mode(stat)
# Use the permission bits reported by lstat on every platform. On Windows,
# Ruby derives them from the read-only attribute and filename extension:
# ordinary writable files are 0644, directories and executables are 0755.
# These synthesized bits are not a representation of Windows ACLs.
type = {'file' => '-', 'directory' => 'd', 'link' => 'l', 'fifo' => 'p',
'socket' => 's', 'characterSpecial' => 'c', 'blockSpecial' => 'b'}.fetch(stat.ftype)
permissions = (0...9).map {|index| (stat.mode & (0400 >> index)) != 0 ? 'rwx'[index % 3] : '-' }.join
# setuid/setgid/sticky replace the corresponding execute bit, just as in ls.
[[04000, 2, 's', 'S'], [02000, 5, 's', 'S'], [01000, 8, 't', 'T']].each do |bit, index, on, off|
permissions[index] = permissions[index] == 'x' ? on : off if (stat.mode & bit) != 0
end
type + permissions
end
def installed_files(input_dir, root_path)
root = Pathname.new(File.realpath(input_dir))
abort "#{input_dir}: not a directory" unless root.directory?
entries = []
# Never follow child symlinks or silently skip unreadable directories.
Find.find(root.to_s, ignore_error: false) do |path|
relative = Pathname.new(path).relative_path_from(root).to_s
display = relative == '.' ? root_path : "#{root_path}/#{relative}"
stat = File.lstat(path)
line = "#{file_mode(stat)} #{display}"
line += " -> #{File.readlink(path)}" if stat.symlink?
entries << [display, line]
end
# Sort complete paths by UTF-8 bytes, like LC_ALL=C, not by locale or mode.
# This also puts a directory before its contents. Paths containing spaces
# need no special handling.
entries.sort_by {|path, _line| path.encode('UTF-8').b }.map(&:last)
end
def resolve_unversioned_targets(line, target)
line.gsub(
/@LIB_(?:8|16|32|POSIX)_UNVERSIONED_TARGET@/
) {|placeholder| placeholder.sub('UNVERSIONED_TARGET', target) }
end
def expand_library_versions(line, library_versions)
line.gsub(/@LIB_(?<library>8|16|32|POSIX)_(?<kind>VERSION|SONAME)@/) do |placeholder|
library = Regexp.last_match(:library)
kind = Regexp.last_match(:kind)
version = library_versions.fetch(library) do
raise "Missing #{library} #{kind} value required by #{placeholder}"
end
kind == 'VERSION' ? version.version : version.soname
end
end
def expected_files(lines, producer, build_type, library_versions)
# An unpacked distribution has its own exact manifest, not an install layout.
if producer == 'tarball'
return lines
end
# The common install manifests include both CMake and libtool metadata. Adjust
# only the expected listing: unexpected installed files must still fail the
# check, even if another build system would legitimately install them.
lines.each_with_object([]) do |line, result|
if producer != 'cmake'
# Only CMake installs find_package(CONFIG) metadata and exported targets.
# Autoconf and Meson provide pkg-config metadata, but not these CMake files.
#
# NOTE: This CMake style is actually rather frustrating, because it exposes
# CMake-specific metadata into the package's public installation layout.
# Linux distributions probably should *not* distribute these files as a
# result. The pkg-config metadata is more neutral.
#
# Exclude these CMake-specific files from the expected listing.
next if line.match?(%r{/lib/cmake(?:/pcre2)?$})
next if line.match?(%r{/lib/cmake/pcre2/pcre2-(?:config(?:-version)?|targets(?:-@CMAKE_BUILD@)?)\.cmake$})
# The manifests use CMake's native ELF shared-library modes, which can be
# 0644. Autoconf and Meson install the real .so files as 0755 instead.
# Accept these native defaults; only real files, not symlinks, are adjusted.
#
# NOTE: There's a story here about why ELF permissions differ between
# systems. Debian insists that shared libraries should not be executable,
# whereas Fedora/RPM required them to be executable. So, there is no one
# right answer on Linux, and the different build systems land in different
# camps. For now, we are simply documenting and accepting whatever CMake,
# Autoconf, and Meson do on Ubuntu (our CI base system).
line = line.sub(/\A-rw-r--r-- /, '-rwxr-xr-x ') if line.match?(%r{/libpcre2-(?:8|16|32|posix)\.so\.@LIB_(?:8|16|32|POSIX)_VERSION@$})
end
if producer != 'autoconf'
# Only Autoconf's libtool build installs .la archives containing link metadata.
next if line.match?(%r{/lib/libpcre2-(?:8|16|32|posix)\.la$})
end
if producer == 'meson'
# On macOS, Autoconf and CMake install a full-version dylib plus major-version
# and unversioned symlinks. Meson's major-version file is real, so remove the
# major-version symlink entry and rename the full-version file entry.
# The unversioned link stays; current/compatibility versions remain in the
# Mach-O metadata rather than requiring a full-version filename.
#
# CMake and Autoconf:
# -rwxr-xr-x /prefix/lib/libpcre2-8.VERSION.dylib
# lrwxr-xr-x /prefix/lib/libpcre2-8.SONAME.dylib -> libpcre2-8.VERSION.dylib
# lrwxr-xr-x /prefix/lib/libpcre2-8.dylib -> libpcre2-8.SONAME.dylib
#
# Meson:
# -rwxr-xr-x /prefix/lib/libpcre2-8.SONAME.dylib
# lrwxr-xr-x /prefix/lib/libpcre2-8.dylib -> libpcre2-8.SONAME.dylib
#
# Paper over this by storing the CMake/Autoconf convention in the manifest
# on disc and rewriting it in the Meson case.
next if line.match?(%r{\Al[^ ]* .*/libpcre2-(?:8|16|32|posix)\.@LIB_(?:8|16|32|POSIX)_SONAME@\.dylib -> })
if line.match?(%r{\A-[^ ]* .*/libpcre2-(?:8|16|32|posix)\.@LIB_(?:8|16|32|POSIX)_VERSION@\.dylib$})
line = line.sub(/\.@LIB_(8|16|32|POSIX)_VERSION@\.dylib$/, '.@LIB_\1_SONAME@.dylib')
end
end
# CMake's configuration name is lower-case in its exported target filename.
line = line.gsub('@CMAKE_BUILD@', build_type)
# There's a difference in the symlink structures used by Autoconf and CMake/Meson.
# We model this by using an UNVERSIONED_TARGET placeholder in the manifest, and
# replace it by either SONAME or VERSION depending on whether the unversioned
# library symlink points at the SONAME or full-VERSION file.
#
# Autoconf:
# lrwxrwxrwx /prefix/lib/libpcre2-8.so -> libpcre2-8.so.VERSION
# lrwxrwxrwx /prefix/lib/libpcre2-8.so.SONAME -> libpcre2-8.so.VERSION
# -rwxr-xr-x /prefix/lib/libpcre2-8.so.VERSION
#
# CMake/Meson:
# lrwxrwxrwx /prefix/lib/libpcre2-8.so -> libpcre2-8.so.SONAME
# lrwxrwxrwx /prefix/lib/libpcre2-8.so.SONAME -> libpcre2-8.so.VERSION
# -rwxr-xr-x /prefix/lib/libpcre2-8.so.VERSION
line = resolve_unversioned_targets(line, producer == 'autoconf' ? 'VERSION' : 'SONAME')
# Finally, replace all the version placeholders with the expected version numbers.
line = expand_library_versions(line, library_versions)
result << line
end
end
def main
unless (3..4).cover?(ARGV.length)
abort "Usage: ruby #{$PROGRAM_NAME} <dir> <manifest name> <producer> [<build type>]"
end
input_dir, manifest, producer, build_type = ARGV
abort "Unknown producer: #{producer} (expected autoconf, cmake, meson, or tarball)" unless %w[autoconf cmake meson tarball].include?(producer)
lines = File.readlines(manifest, encoding: 'UTF-8').map(&:chomp)
abort "Empty manifest: #{manifest}" if lines.empty?
# Anchor the listing at the manifest's root, regardless of the real install
# prefix or DESTDIR. All manifests include this first directory entry.
# For example, an Autoconf install staged at install-dir/usr/local must have
# the same manifest paths as a CMake or Meson install at install-dir.
_mode, root_path = lines.first.split(' ', 2)
abort "Missing root path in #{manifest}" unless root_path
actual = installed_files(input_dir, root_path)
versions = Pcre2Maintenance.library_filename_versions
expected = expected_files(lines, producer, build_type || 'release', versions)
base = File.basename(manifest)
File.binwrite("#{base}.expected", expected.join("\n") + "\n")
File.binwrite("#{base}.actual", actual.join("\n") + "\n")
if expected != actual
warn 'Missing or changed entries (-), unexpected entries (+):'
(expected - actual).each {|line| warn "- #{line}" }
(actual - expected).each {|line| warn "+ #{line}" }
abort "Installed files differ from expected; compare #{base}.expected and #{base}.actual"
end
puts 'Installed files match expected'
File.delete("#{base}.expected", "#{base}.actual")
end
main if $PROGRAM_NAME == __FILE__