From b8ec4e1f66ca5892d26ae66a50f19f070ff9161c Mon Sep 17 00:00:00 2001 From: Evelyn Chen Date: Sat, 29 Aug 2026 22:42:37 +0000 Subject: [PATCH] fix(site-publish): close split migration boundaries Authored-By: OpenAI (GPT-5) --- README.md | 2 +- scripts/deploy.py | 10 ++++++++-- scripts/utils.py | 10 +++++++++- tests/test_contract.py | 11 +++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6a2c886..8048262 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ provisional cache policy or a pointer to a missing immutable target. Artifact input directories must be pairwise disjoint after filesystem resolution. Publication stops before build or upload if one contains another or -escapes the repository. Descendant symlinks are also rejected, preventing +escapes the repository. Symlinked roots, components, and descendants are also rejected, preventing protected input from entering a public artifact through dereference. Split storage endpoints are pinned to Garage, and each website authority is derived from its bucket; a site cannot expose an arbitrary backend. diff --git a/scripts/deploy.py b/scripts/deploy.py index 025189d..e10a8d0 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -204,12 +204,18 @@ def s3_sync(artifact, route, site_dir, credential_env_names=None): # so a fresh upload always carries the right MIME type. specific_paths = [rule["path"] for rule in artifact["cache_rules"] if rule["path"]] default_filters = [arg for path in specific_paths for arg in ("--exclude", f"{path}/*")] + # A move from a nested route to `/` makes the old partition fall inside the + # new sync scope. Preserve declared immutable subtrees at every retired + # prefix without enumerating the bucket. + retired_immutable_filters = [ + arg for path in immutable_paths for arg in ("--exclude", f"*/{path}/*") + ] run(["aws", "--endpoint-url", endpoint, "s3", "cp", f"{html_dir}/", destination, "--recursive", "--only-show-errors", "--cache-control", default_cache, - *default_filters, *exclude_args], env=aws_env) + *default_filters, *retired_immutable_filters, *exclude_args], env=aws_env) run(["aws", "--endpoint-url", endpoint, "s3", "sync", f"{html_dir}/", destination, "--delete", "--only-show-errors", "--cache-control", default_cache, - *default_filters, *exclude_args], env=aws_env) + *default_filters, *retired_immutable_filters, *exclude_args], env=aws_env) for rule in artifact["cache_rules"]: if not rule["path"]: continue diff --git a/scripts/utils.py b/scripts/utils.py index 8376cd5..b12a079 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -432,7 +432,15 @@ def validate_artifact_inputs(site_dir, cfg): root = Path(site_dir).resolve() sources = [] for artifact in cfg["artifacts"]: - source = (root / artifact["content_dir"]).resolve() + declared = root + for component in Path(artifact["content_dir"]).parts: + declared /= component + if declared.is_symlink(): + raise ConfigError( + f"artifact {artifact['name']} content_dir contains symlink component: " + f"{declared.relative_to(root)}" + ) + source = declared.resolve() if source != root and root not in source.parents: raise ConfigError( f"artifact {artifact['name']} content_dir resolves outside the repository" diff --git a/tests/test_contract.py b/tests/test_contract.py index a8c239f..080f93e 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -230,6 +230,16 @@ class ConfigContractTests(unittest.TestCase): with self.assertRaisesRegex(ConfigError, "build input contains symlink"): validate_artifact_inputs(root, cfg) + def test_artifact_root_symlink_is_rejected_before_resolution(self): + cfg = normalize_site_config(self.raw, "baseline.fritzlab.net") + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "dist-real").mkdir() + (root / "dist").symlink_to(root / "dist-real") + (root / "portal" / "build").mkdir(parents=True) + with self.assertRaisesRegex(ConfigError, "content_dir contains symlink component"): + validate_artifact_inputs(root, cfg) + class GenerationTests(unittest.TestCase): def render(self, raw): @@ -417,6 +427,7 @@ class PublishingTests(unittest.TestCase): rendered = [" ".join(command) for command, _ in commands] self.assertIn("s3://baseline-dist/dist/", rendered[0]) self.assertIn("releases/*", rendered[0]) + self.assertIn("*/releases/*", rendered[0]) self.assertNotIn("--delete", rendered[0]) self.assertIn("--delete", rendered[1]) self.assertTrue(all("s3://baseline-dist/dist/" in command for command in rendered[1:]))