The native Desktop payload now enters the same public candidate and receipt as the Linux and server artifacts. Source-Sha: 550aff16577a07f888f3ca150749265726072048 Policy-Sha: 550aff16577a07f888f3ca150749265726072048 Tree-Digest: f18eb172754ead927788c5aca2f3ad78b37103a21b657eb1a3328eb07cef0c7c
202 lines
11 KiB
Python
202 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""RedFlag policy adapter for the unmodified shared release contract.
|
|
|
|
scripts/vendor/release-contract-core.py is byte-identical to
|
|
bin/release-contract-core.py from the shared release contract at commit
|
|
9fc81398b6696ee858468b774c66c0cba44a22b3. Provenance is held by that SHA
|
|
rather than by the name of the infrastructure that hosts it.
|
|
|
|
Three identities are kept apart, and must not be collapsed into one:
|
|
the public source repository that a release is built from and signed
|
|
against; the control repository whose reviewed workflow drives the build;
|
|
and the invocation that a particular run happened under. Only the first
|
|
belongs in public source, so the other two are supplied by the build
|
|
environment and never committed here.
|
|
|
|
The builder ID denotes the declared workflow lane, not a hardware attestation.
|
|
"""
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
from types import SimpleNamespace
|
|
|
|
SPEC = importlib.util.spec_from_file_location("release_contract_core",
|
|
Path(__file__).parent / "vendor/release-contract-core.py")
|
|
CONTRACT = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(CONTRACT)
|
|
SOURCE_REPOSITORY = "https://forge.caseytunturi.com/Fimeg/RedFlag"
|
|
BUILD_TYPE = "https://caseytunturi.com/release-contract/build-types/gitea-actions/v1"
|
|
WORKFLOW = "Fimeg/RedFlag/.gitea/workflows/release.yml"
|
|
TARGETS = {"linux-amd64", "linux-arm64", "windows-amd64", "darwin-arm64"}
|
|
|
|
|
|
def control_repository():
|
|
# The repository whose reviewed workflow drives the build. It is read from
|
|
# the build environment rather than committed, so that public source never
|
|
# has to name the forge that development is hosted on. Verification is not
|
|
# weakened: the trusted stage supplies this and the value is still pinned.
|
|
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
|
repository = os.environ.get("GITHUB_REPOSITORY", "")
|
|
require(server and repository, "GITHUB_SERVER_URL and GITHUB_REPOSITORY are required")
|
|
return server + "/" + repository
|
|
|
|
|
|
def builder_id():
|
|
return control_repository() + "/.gitea/workflows/release.yml#stage_package"
|
|
|
|
|
|
def require(condition, message):
|
|
if not condition:
|
|
raise CONTRACT.ContractError(message)
|
|
|
|
|
|
def receipt(candidate, check_files=True):
|
|
entries = CONTRACT.parse_receipt(candidate)
|
|
require("manifest.json" in entries and "provenance.json" in entries, "receipt lacks evidence")
|
|
require("files.sha256" not in entries, "receipt cannot name itself")
|
|
for name, digest in entries.items():
|
|
require(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+-]*", name), "unsafe transport filename")
|
|
if not check_files:
|
|
continue
|
|
path = candidate / name
|
|
require(path.is_file() and not path.is_symlink(), "missing or unsafe receipt file: " + name)
|
|
require(CONTRACT.sha256(path) == digest, "receipt digest mismatch: " + name)
|
|
if check_files:
|
|
require({p.name for p in candidate.iterdir()} == set(entries) | {"files.sha256"}, "unreceipted candidate files")
|
|
return entries
|
|
|
|
|
|
def verify(candidate, version, commit, tag):
|
|
receipt(candidate)
|
|
CONTRACT.verify(SimpleNamespace(candidate=str(candidate), expect_project="redflag",
|
|
expect_source_commit=commit, expect_source_tag=tag, expect_builder_id=builder_id(),
|
|
expect_workflow_id=WORKFLOW + "@" + commit))
|
|
manifest = CONTRACT.load_json(candidate / "manifest.json")
|
|
provenance = CONTRACT.load_json(candidate / "provenance.json")
|
|
require(manifest["source"]["repository"] == SOURCE_REPOSITORY, "unexpected source repository")
|
|
require(manifest["version"] == version and tag == "v" + version, "unexpected version")
|
|
require(manifest.get("tag") == tag and manifest.get("source_sha") == commit, "legacy source fields disagree")
|
|
require(provenance["predicate"]["buildDefinition"]["externalParameters"] ==
|
|
{"project": "redflag", "sourceTag": tag, "version": version}, "provenance parameters disagree")
|
|
invocation = manifest["build"]["invocation_id"]
|
|
require(re.fullmatch(re.escape(control_repository()) + r"/actions/runs/[1-9][0-9]*", invocation),
|
|
"invalid invocation identity")
|
|
files = {a["filename"]: a for a in manifest["artifacts"]}
|
|
require(all(a["target_os"] + "-" + a["target_arch"] in TARGETS for a in files.values()), "unexpected artifact target")
|
|
for target in TARGETS:
|
|
os_name, arch = target.split("-")
|
|
for component in ("server", "agent", "helper"):
|
|
if component == "helper" and os_name == "windows":
|
|
continue
|
|
name = f"redflag-{component}-{target}" + (".exe" if os_name == "windows" else "")
|
|
require(name in files, "missing required artifact: " + name)
|
|
item = files[name]
|
|
platform = os_name if component == "agent" else component + "-" + os_name
|
|
require(item.get("platform") == platform and item.get("architecture") == arch,
|
|
"legacy target mismatch: " + name)
|
|
require(item["target_os"] == os_name and item["target_arch"] == arch,
|
|
"contract target mismatch: " + name)
|
|
require(f"redflag_{version}_amd64.deb" in files, "missing Debian package")
|
|
require("RedFlagSetup-windows-amd64.msi" in files, "missing Windows installer")
|
|
desktop = "redflag-desktop-windows-amd64.exe"
|
|
require(desktop in files, "missing native Windows Desktop: " + desktop)
|
|
require(files[desktop].get("platform") == "desktop-windows" and
|
|
files[desktop].get("architecture") == "amd64", "native Windows Desktop target mismatch")
|
|
require(f"redflag-{version}-windows-desktop-amd64.zip" in files,
|
|
"missing complete native Windows Desktop payload")
|
|
|
|
|
|
def assemble(args):
|
|
source = Path(args.artifacts)
|
|
candidate = Path(args.candidate)
|
|
require(not candidate.exists() and not candidate.is_symlink(), "candidate output already exists; choose a fresh directory")
|
|
CONTRACT.validate_commit(args.commit)
|
|
require(re.fullmatch(r"[0-9]+(?:\.[0-9]+){2,3}(?:[.-][0-9A-Za-z]+)*", args.version), "invalid version")
|
|
require(args.tag == "v" + args.version, "version/tag mismatch")
|
|
generated = int(subprocess.check_output(["git", "show", "-s", "--format=%ct", args.commit], text=True))
|
|
descriptors, legacy = [], {}
|
|
for target in sorted(TARGETS):
|
|
directory = source / ("release-" + target)
|
|
snippet = directory / ("release-" + target + ".artifacts.json")
|
|
for item in CONTRACT.load_json(snippet):
|
|
name = CONTRACT.safe_filename(item["filename"])
|
|
require(name not in legacy, "duplicate snippet filename: " + name)
|
|
path = directory / name
|
|
require(path.is_file() and not path.is_symlink(), "snippet payload missing: " + name)
|
|
require(CONTRACT.sha256(path) == item["sha256"] and path.stat().st_size == item["size"], "snippet digest mismatch: " + name)
|
|
legacy[name] = item
|
|
for path in sorted(directory.iterdir()):
|
|
if path == snippet:
|
|
continue
|
|
require(path.is_file() and not path.is_symlink(), "unsafe payload")
|
|
require(path.name not in {"manifest.json", "provenance.json", "files.sha256"}, "reserved payload name")
|
|
os_name, arch = target.split("-")
|
|
media, package = "application/octet-stream", "binary"
|
|
for suffix, kind, mime in ((".tar.gz", "generic", "application/gzip"), (".zip", "generic", "application/zip"),
|
|
(".deb", "deb", "application/vnd.debian.binary-package"), (".msi", "msi", "application/x-msi"),
|
|
(".txt", "generic", "text/plain")):
|
|
if path.name.endswith(suffix):
|
|
media, package = mime, kind
|
|
break
|
|
descriptors.append(dict(path=str(path), filename=path.name, target_os=os_name,
|
|
target_arch=arch, media_type=media, package_type=package))
|
|
with tempfile.TemporaryDirectory(prefix="redflag-contract-") as temporary:
|
|
temporary = Path(temporary)
|
|
declaration = temporary / "release.toml"
|
|
declaration.write_text('schema_version = 1\nproject = "redflag"\nversion = ' + json.dumps(args.version)
|
|
+ '\nsource_repository = ' + json.dumps(SOURCE_REPOSITORY)
|
|
+ '\nbuild_type = ' + json.dumps(BUILD_TYPE) + '\n')
|
|
descriptor_path = temporary / "artifacts.json"
|
|
CONTRACT.write_json(descriptor_path, descriptors)
|
|
CONTRACT.assemble(SimpleNamespace(declaration=str(declaration), artifacts=str(descriptor_path),
|
|
output=str(candidate), source_commit=args.commit, source_tag=args.tag, builder_id=builder_id(),
|
|
workflow_id=WORKFLOW + "@" + args.commit,
|
|
invocation_id=control_repository() + "/actions/runs/" + args.run_id))
|
|
manifest = CONTRACT.load_json(candidate / "manifest.json")
|
|
manifest.update(tag=args.tag, source_sha=args.commit, generated_at=generated, key_id="")
|
|
manifest["components"] = [dict(name=name, kind="embedded" if name == "web" else "binary",
|
|
required=name not in {"desktop", "installer"}, **({"version_cmd": "--version"} if name not in {"web", "installer"} else {}))
|
|
for name in ("server", "agent", "helper", "desktop", "web", "installer")]
|
|
manifest["components"][3]["provisioning"] = ["autostart_entry", "redflag-local_group", "desktop_user_membership"]
|
|
for item in manifest["artifacts"]:
|
|
if item["filename"] in legacy:
|
|
item.update(legacy[item["filename"]])
|
|
CONTRACT.write_json(candidate / "manifest.json", manifest)
|
|
names = [item["filename"] for item in manifest["artifacts"]] + ["manifest.json", "provenance.json"]
|
|
(candidate / "files.sha256").write_text("".join(f"{CONTRACT.sha256(candidate / name)} {name}\n" for name in sorted(names)))
|
|
verify(candidate, args.version, args.commit, args.tag)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
for command in ("assemble", "verify", "receipt", "names"):
|
|
child = sub.add_parser(command)
|
|
child.add_argument("candidate", type=Path)
|
|
if command not in {"receipt", "names"}:
|
|
child.add_argument("version")
|
|
child.add_argument("commit")
|
|
child.add_argument("tag")
|
|
if command == "assemble":
|
|
child.add_argument("artifacts")
|
|
child.add_argument("run_id")
|
|
args = parser.parse_args()
|
|
try:
|
|
if args.command == "assemble":
|
|
assemble(args)
|
|
elif args.command == "verify":
|
|
verify(args.candidate, args.version, args.commit, args.tag)
|
|
else:
|
|
for name in receipt(args.candidate, check_files=args.command != "names"):
|
|
print(name)
|
|
except (CONTRACT.ContractError, OSError, ValueError, KeyError, TypeError) as error:
|
|
parser.exit(1, f"release contract: {error}\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|