Files
2026-07-13 12:32:21 +08:00

218 lines
7.0 KiB
Python
Executable File

#!/usr/bin/env python3
import os
import sys
import glob
import time
import atexit
import difflib
import pathlib
import argparse
import tempfile
from io import StringIO
from typing import List
from pathlib import Path
import config
from explain import ExplainOpts, FileInfo, ElfFileInfo, NginxInfo
from expect import ExpectChain, glob_match_ignore_slash
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument(
"--path", "-p", help="Path to the directory, binary package or docker image tag to compare")
parser.add_argument(
"--image", help="Docker image tag to compare")
parser.add_argument(
"--output", "-o", help="Path to output manifest, use - to write to stdout")
parser.add_argument(
"--suite", "-s", help="Expect suite name to test, defined in config.py")
parser.add_argument(
"--file_list", "-f", help="Path to the files list to explain for manifest; " +
"each line in the file should be a glob pattern of full path")
parser.add_argument(
"--owners", help="Display owner and group", action="store_true")
parser.add_argument(
"--mode", help="Display mode", action="store_true")
parser.add_argument(
"--size", help="Display size", action="store_true")
parser.add_argument("--arch",
help="Display ELF architecture", action="store_true")
parser.add_argument("--merge_rpaths_runpaths",
help="Treate RPATH and RUNPATH as same", action="store_true")
parser.add_argument(
"--imported_symbols", help="Display imported symbols", action="store_true")
parser.add_argument(
"--exported_symbols", help="Display exported symbols", action="store_true")
parser.add_argument("--version_requirement",
help="Display exported symbols",
action="store_true")
return parser.parse_args()
def read_glob(path: str):
if not path:
return ["**"]
with open(path, "r") as f:
return f.read().splitlines()
def gather_files(path: str, image: str):
if image:
t = tempfile.TemporaryDirectory()
atexit.register(t.cleanup)
code = os.system("docker pull {img} && docker create --name={name} {img} && docker export {name} | tar xf - -C {tmp} && docker rm -f {name}".format(
img=image,
name="explain_manifest_%d" % time.time(),
tmp=t.name
))
if code != 0:
raise Exception("Failed to extract image %s" % image)
return t.name
ext = os.path.splitext(path)[1]
if ext in (".deb", ".rpm") or path.endswith(".apk.tar.gz"):
t = tempfile.TemporaryDirectory()
atexit.register(t.cleanup)
if ext == ".deb":
code = os.system(
"ar p %s data.tar.gz | tar -C %s -xz" % (path, t.name))
elif ext == ".rpm":
# rpm2cpio is needed
# rpm2archive ships with rpm2cpio on debians
# https://github.com/rpm-software-management/rpm/commit/37b963fa51d6ad31086a6e345ce6701afda5afff
# rpm2archive has changed the behaviour to extract to stdout if stdout is not tty
code = os.system(
"""
rpm2archive %s | tar -C %s -xz
""" % (path, t.name))
elif ext == ".gz":
code = os.system("tar -C %s -xf %s" % (t.name, path))
if code != 0:
raise Exception("Failed to extract %s" % path)
return t.name
elif not Path(path).is_dir():
raise Exception("Don't know how to process \"%s\"" % path)
return path
def walk_files(path: str, globs: List[str]):
results = []
# use pathlib instead of glob.glob to avoid recurse into symlink dir
for file in sorted(pathlib.Path(path).rglob("*")):
full_path = str(file)
file = str(file.relative_to(path))
if globs and not glob_match_ignore_slash(file, globs):
continue
if not file.startswith("/") and not file.startswith("./"):
file = '/' + file # prettifier
if file.endswith("sbin/nginx"):
f = NginxInfo(full_path, file)
elif os.path.splitext(file)[1] == ".so" or os.path.basename(os.path.dirname(file)) in ("bin", "lib", "lib64", "sbin"):
p = Path(full_path)
if p.is_symlink():
continue
f = ElfFileInfo(full_path, file)
else:
f = FileInfo(full_path, file)
config.transform(f)
results.append(f)
return results
def write_manifest(title: str, results: List[FileInfo], globs: List[str], opts: ExplainOpts):
f = StringIO()
for result in results:
if not glob_match_ignore_slash(result.relpath, globs):
continue
entries = result.explain(opts)
ident = 2
first = True
for k, v in entries:
if isinstance(v, list):
v = ("\n" + " " * ident + "- ").join([""] + v)
else:
v = " %s" % v
if first:
f.write("-" + (" " * (ident-1)))
first = False
else:
f.write(" " * ident)
f.write("%-10s:%s\n" % (k, v))
f.write("\n")
f.flush()
return f.getvalue().encode("utf-8")
if __name__ == "__main__":
args = parse_args()
if not args.suite and not args.output:
raise Exception("At least one of --suite or --output is required")
if not args.path and not args.image:
raise Exception("At least one of --path or --image is required")
if args.image and os.getuid() != 0:
raise Exception("Running as root is required to explain an image")
if args.path and Path(args.path).is_dir():
raise Exception(
"suite mode only works with archive files (deb, rpm, apk.tar.gz, etc.")
directory = gather_files(args.path, args.image)
globs = read_glob(args.file_list)
# filter by filelist only when explaining an image to reduce time
infos = walk_files(directory, globs=globs if args.image else None)
if args.image:
title = "contents in image %s" % args.image
elif Path(args.path).is_file():
title = "contents in archive %s" % args.path
else:
title = "contents in directory %s" % args.path
manifest = write_manifest(title, infos, globs, ExplainOpts.from_args(args))
if args.suite:
if args.suite not in config.targets:
closest = difflib.get_close_matches(
config.targets.keys(), args.suite, 1)
maybe = ""
if closest:
maybe = ", maybe you meant %s" % closest[0]
raise Exception("Unknown suite %s%s" % (args.suite, maybe))
E = ExpectChain(infos)
E.compare_manifest(config.targets[args.suite], manifest)
E.run(config.targets[args.suite])
if args.output:
if args.output == "-":
f = sys.stdout
manifest = manifest.decode("utf-8")
else:
f = open(args.output, "wb")
f.write(manifest)
if args.output != "-":
f.close()