blob: 22a833dfee1bb8b69d4053e6cec7cc97192dd217 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Reject macOS packages that load external third-party dynamic libraries."""
import argparse
from pathlib import Path
import re
import subprocess
LOAD_COMMANDS = {
"LC_LOAD_DYLIB",
"LC_LOAD_WEAK_DYLIB",
"LC_REEXPORT_DYLIB",
"LC_LAZY_LOAD_DYLIB",
"LC_LOAD_UPWARD_DYLIB",
}
MACHO_MAGICS = {
bytes.fromhex(value)
for value in (
"feedface",
"cefaedfe",
"feedfacf",
"cffaedfe",
"cafebabe",
"bebafeca",
"cafebabf",
"bfbafeca",
)
}
def parse_dependencies(output):
imports = set()
for block in re.split(r"(?m)^Load command \d+\s*$", output):
command = re.search(r"^\s*cmd (LC_\w+)\s*$", block, re.M)
if command is None or command[1] not in LOAD_COMMANDS:
continue
name = re.search(r"^\s*name (.+?) \(offset \d+\)\s*$", block, re.M)
if name is None:
raise ValueError("otool reported a dylib load command without a name")
imports.add(name[1])
if not imports:
raise ValueError("otool did not report any dylib dependencies")
return imports
def validate_dependencies(binary, imports, prefix):
binary = Path(binary).resolve()
prefix = Path(prefix).resolve()
for name in sorted(imports):
if name.startswith(("/usr/lib/", "/System/Library/")):
continue
# BUNDLED codecs must not remain separate dylibs, even when installed
# on the runner. The only non-system dylib is TsFile itself.
if not re.fullmatch(r"libtsfile(?:\.[0-9]+)*\.dylib", Path(name).name):
raise ValueError(
f"{binary.name} needs an external third-party dylib: {name}"
)
if name.startswith("@rpath/"):
suffix = name.removeprefix("@rpath/")
candidates = [prefix / "lib" / suffix, prefix / suffix]
elif name.startswith("@loader_path/"):
candidates = [binary.parent / name.removeprefix("@loader_path/")]
elif name.startswith("@executable_path/") and binary.name == "tsfile-cli":
candidates = [binary.parent / name.removeprefix("@executable_path/")]
elif Path(name).is_absolute():
candidates = [Path(name)]
else:
candidates = []
if not any(
path.is_file() and prefix in path.resolve().parents for path in candidates
):
raise ValueError(f"{binary.name} needs an unshipped TsFile dylib: {name}")
def verify_prefix(prefix):
prefix = Path(prefix).resolve()
binaries = set()
for path in prefix.rglob("*"):
if path.is_file():
with path.open("rb") as stream:
if stream.read(4) in MACHO_MAGICS:
binaries.add(path.resolve())
if not binaries:
raise ValueError(f"No Mach-O binaries found under {prefix}")
for binary in sorted(binaries):
output = subprocess.run(
["otool", "-l", str(binary)],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
).stdout
imports = parse_dependencies(output)
validate_dependencies(binary, imports, prefix)
print(
f"{binary.name}: all dylib dependencies are macOS system libraries or packaged TsFile"
)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("prefix", type=Path)
verify_prefix(parser.parse_args().prefix)
if __name__ == "__main__":
main()