Record the finite dependency search, Wi-Fi maintenance blocker, and pinned icon provenance. Add bounded host orchestration and fixture coverage, and update release documentation with current evidence.
253 lines
10 KiB
Python
253 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: GPL-3.0-only
|
|
"""Offline, bounded notice assembly for the reviewed installed dependency snapshot."""
|
|
|
|
import argparse
|
|
from contextlib import contextmanager
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import re
|
|
import stat
|
|
import sys
|
|
|
|
CATALOG = "third_party/release-notices/inputs.json"
|
|
MAX_FILE = 4 * 1024 * 1024
|
|
MAX_TOTAL = 32 * 1024 * 1024
|
|
MAX_ENTRIES = 128
|
|
ROOTS = {"project", "sdk", "toolchain"}
|
|
DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
FILE_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC
|
|
INTRO = """Release notice bundle — scoped engineering aid, NOT legal clearance
|
|
|
|
Start with inputs/project/third_party/release-notices/README.md.
|
|
manifest.json records logical input paths, exact source hashes, byte ranges,
|
|
and output hashes. No host paths or timestamps are recorded. Texts are retained
|
|
verbatim, including mixed grants and the wolfSSH package/header discrepancy.
|
|
|
|
This is NOT corresponding source, a complete SBOM, an archive of the SDK/tools,
|
|
or proof of notice delivery to firmware/device/browser recipients. Radio-blob
|
|
corresponding-source/System Library questions remain unresolved. Exact icon SVG
|
|
source evidence and manual-derivative limits are in inputs/project/docs/icon_provenance.md.
|
|
WolfSSH packaging clarification, bootloader/runtime attribution, source delivery
|
|
and Installation Information still require release review. No legal clearance.
|
|
""".encode("utf-8")
|
|
|
|
|
|
class NoticeError(Exception):
|
|
"""A failed precondition; never silently omit a required input."""
|
|
|
|
|
|
def digest(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def json_bytes(value):
|
|
return (json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode("utf-8")
|
|
|
|
|
|
def relative_parts(value):
|
|
if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
|
|
raise NoticeError("invalid relative input path")
|
|
parts = value.split("/")
|
|
if PurePosixPath(value).is_absolute() or any(p in ("", ".", "..") for p in parts):
|
|
raise NoticeError("unsafe relative input path")
|
|
return parts
|
|
|
|
|
|
def absolute_path(value):
|
|
path = Path(value)
|
|
if ".." in path.parts:
|
|
raise NoticeError("parent traversal is not allowed")
|
|
# Do not resolve(): it would hide symlinks from the descriptor walk.
|
|
return Path(os.path.abspath(path))
|
|
|
|
|
|
@contextmanager
|
|
def directory(path):
|
|
"""Pin every directory component, rejecting symlinks (including ancestors)."""
|
|
path = absolute_path(path)
|
|
fd = os.open(path.anchor, DIR_FLAGS)
|
|
try:
|
|
for part in path.parts[1:]:
|
|
child = os.open(part, DIR_FLAGS, dir_fd=fd)
|
|
os.close(fd)
|
|
fd = child
|
|
yield fd
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def read_bounded(root_fd, path, limit=MAX_FILE):
|
|
parts = relative_parts(path)
|
|
fd = os.dup(root_fd)
|
|
try:
|
|
for part in parts[:-1]:
|
|
child = os.open(part, DIR_FLAGS, dir_fd=fd)
|
|
os.close(fd)
|
|
fd = child
|
|
source = os.open(parts[-1], FILE_FLAGS, dir_fd=fd)
|
|
try:
|
|
info = os.fstat(source)
|
|
if not stat.S_ISREG(info.st_mode) or not 0 < info.st_size <= limit:
|
|
raise NoticeError(f"not a nonempty bounded regular file: {path}")
|
|
with os.fdopen(source, "rb", closefd=False) as stream:
|
|
data = stream.read(limit + 1)
|
|
if len(data) != info.st_size or len(data) > limit:
|
|
raise NoticeError(f"input changed size or exceeded limit: {path}")
|
|
return data
|
|
finally:
|
|
os.close(source)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def validate_catalog(catalog):
|
|
if not isinstance(catalog, dict) or set(catalog) != {"schema", "snapshot", "inputs"}:
|
|
raise NoticeError("invalid catalog structure")
|
|
if catalog["schema"] != 1 or not isinstance(catalog["snapshot"], dict):
|
|
raise NoticeError("unsupported catalog schema")
|
|
entries = catalog["inputs"]
|
|
if not isinstance(entries, list) or not 0 < len(entries) <= MAX_ENTRIES:
|
|
raise NoticeError("invalid catalog input count")
|
|
seen = set()
|
|
total = 0
|
|
for entry in entries:
|
|
if not isinstance(entry, dict) or set(entry) != {
|
|
"root", "path", "size", "sha256", "range", "output_sha256", "purpose"
|
|
}:
|
|
raise NoticeError("invalid catalog entry")
|
|
if entry["root"] not in ROOTS:
|
|
raise NoticeError("unknown input root")
|
|
relative_parts(entry["path"])
|
|
key = (entry["root"], entry["path"])
|
|
if key in seen:
|
|
raise NoticeError("duplicate input")
|
|
seen.add(key)
|
|
size = entry["size"]
|
|
if type(size) is not int or not 0 < size <= MAX_FILE:
|
|
raise NoticeError("invalid input size")
|
|
total += size
|
|
for field in ("sha256", "output_sha256"):
|
|
if not isinstance(entry[field], str) or not re.fullmatch(r"[0-9a-f]{64}", entry[field]):
|
|
raise NoticeError("invalid digest")
|
|
span = entry["range"]
|
|
if span is not None and (
|
|
not isinstance(span, list) or len(span) != 2
|
|
or any(type(n) is not int for n in span)
|
|
or not 0 <= span[0] < span[1] <= size
|
|
):
|
|
raise NoticeError("invalid byte range")
|
|
if not isinstance(entry["purpose"], str) or not entry["purpose"]:
|
|
raise NoticeError("missing input purpose")
|
|
if total > MAX_TOTAL:
|
|
raise NoticeError("catalog exceeds total read budget")
|
|
|
|
|
|
def assemble(roots, output, catalog_data):
|
|
"""Preflight all inputs before creating output. Catalog is trusted reviewed policy."""
|
|
if set(roots) != ROOTS:
|
|
raise NoticeError("all three input roots are required")
|
|
roots = {name: absolute_path(path) for name, path in roots.items()}
|
|
output = absolute_path(output)
|
|
for root in roots.values():
|
|
if output == root or root in output.parents:
|
|
raise NoticeError("output must be outside every input root")
|
|
if len(catalog_data) > MAX_FILE:
|
|
raise NoticeError("catalog exceeds size bound")
|
|
catalog = json.loads(catalog_data)
|
|
validate_catalog(catalog)
|
|
payloads = {}
|
|
records = []
|
|
for entry in sorted(catalog["inputs"], key=lambda e: (e["root"], e["path"])):
|
|
logical = f"{entry['root']}/{entry['path']}"
|
|
try:
|
|
with directory(roots[entry["root"]]) as root_fd:
|
|
source = read_bounded(root_fd, entry["path"], entry["size"])
|
|
except OSError as error:
|
|
raise NoticeError(f"cannot read required input: {logical} ({error.strerror})") from error
|
|
if len(source) != entry["size"] or digest(source) != entry["sha256"]:
|
|
raise NoticeError(f"source hash/size mismatch: {logical}; review drift, do not auto-repin")
|
|
span = entry["range"]
|
|
data = source if span is None else source[span[0]:span[1]]
|
|
if digest(data) != entry["output_sha256"]:
|
|
raise NoticeError(f"excerpt hash mismatch: {logical}")
|
|
try:
|
|
data.decode("utf-8")
|
|
except UnicodeDecodeError as error:
|
|
raise NoticeError(f"non-UTF-8 notice: {logical}") from error
|
|
if b"\x00" in data:
|
|
raise NoticeError(f"binary notice: {logical}")
|
|
target = "inputs/" + logical + (".notice.txt" if span is not None else "")
|
|
if target in payloads:
|
|
raise NoticeError("output collision")
|
|
payloads[target] = data
|
|
records.append({**entry, "output": target, "output_size": len(data)})
|
|
|
|
payloads["README.txt"] = INTRO
|
|
manifest = {
|
|
"schema": 1,
|
|
"snapshot": catalog["snapshot"],
|
|
"catalog_sha256": digest(catalog_data),
|
|
"inputs": records,
|
|
"generated": [{"path": "README.txt", "sha256": digest(INTRO), "size": len(INTRO)}],
|
|
}
|
|
# Written last as the completion marker. The manifest does not hash itself.
|
|
payloads["manifest.json"] = json_bytes(manifest)
|
|
with directory(output.parent) as parent_fd:
|
|
# Even an empty existing directory or dangling symlink is an error.
|
|
os.mkdir(output.name, mode=0o700, dir_fd=parent_fd)
|
|
out_fd = os.open(output.name, DIR_FLAGS, dir_fd=parent_fd)
|
|
try:
|
|
for name, data in payloads.items():
|
|
write_new(out_fd, name, data)
|
|
finally:
|
|
os.close(out_fd)
|
|
return manifest
|
|
|
|
|
|
def write_new(root_fd, path, data):
|
|
fd = os.dup(root_fd)
|
|
try:
|
|
parts = relative_parts(path)
|
|
for part in parts[:-1]:
|
|
try:
|
|
os.mkdir(part, mode=0o700, dir_fd=fd)
|
|
except FileExistsError:
|
|
pass
|
|
child = os.open(part, DIR_FLAGS, dir_fd=fd)
|
|
os.close(fd)
|
|
fd = child
|
|
target = os.open(parts[-1], os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
| os.O_NOFOLLOW | os.O_CLOEXEC, 0o600, dir_fd=fd)
|
|
with os.fdopen(target, "wb") as stream:
|
|
stream.write(data)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--project-root", type=Path, default=Path(__file__).absolute().parent.parent)
|
|
parser.add_argument("--sdk-root", type=Path, required=True)
|
|
parser.add_argument("--toolchain-root", type=Path, required=True)
|
|
parser.add_argument("--output", type=Path, required=True,
|
|
help="new directory outside input roots; parent must already exist")
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
# No user-supplied catalog, discovery, network, install, build, or repin mode.
|
|
with directory(Path(__file__).absolute().parent.parent) as policy_fd:
|
|
catalog_data = read_bounded(policy_fd, CATALOG)
|
|
manifest = assemble({"project": args.project_root, "sdk": args.sdk_root,
|
|
"toolchain": args.toolchain_root}, args.output, catalog_data)
|
|
except (NoticeError, OSError, ValueError, TypeError) as error:
|
|
print(f"release notices: FAILED: {error}", file=sys.stderr)
|
|
return 1
|
|
print(f"PASS: {len(manifest['inputs'])} pinned notice/provenance inputs; no release clearance")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|