1 Commits
Author SHA1 Message Date
Evelyn Chen b8ec4e1f66 fix(site-publish): close split migration boundaries
Test / contract (pull_request) Successful in 6s
Authored-By: OpenAI (GPT-5) <noreply@openai.com>
2026-08-29 22:42:37 +00:00
4 changed files with 29 additions and 4 deletions
+1 -1
View File
@@ -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.
+8 -2
View File
@@ -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
+9 -1
View File
@@ -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"
+11
View File
@@ -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:]))