Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,14 @@ jobs:
if: env.AW_RESEARCH_EDITION == 'true'
run: python3 scripts/patch_research_edition_config.py aw-watcher-window/aw_watcher_window/config.py

# Watchers store buckets as `{client}_{hostname}`. That hostname is a
# participant identifier on named machines, and /api/0/export copies it
# into the map key, embedded id, and hostname field. Standard builds
# must not change; this patch is Research-only and fail-closed.
- name: Patch research edition export hostname sanitizer
if: env.AW_RESEARCH_EDITION == 'true'
run: python3 scripts/patch_research_edition_export.py

# The watcher rewrites `app` to a study category before storing, but
# aw-webui categorises client-side with its own defaults and never sees
# the watcher's map -- so without this the Categories panel reads
Expand Down Expand Up @@ -698,6 +706,14 @@ jobs:
if: env.AW_RESEARCH_EDITION == 'true'
run: python3 scripts/patch_research_edition_config.py aw-watcher-window/aw_watcher_window/config.py

# Watchers store buckets as `{client}_{hostname}`. That hostname is a
# participant identifier on named machines, and /api/0/export copies it
# into the map key, embedded id, and hostname field. Standard builds
# must not change; this patch is Research-only and fail-closed.
- name: Patch research edition export hostname sanitizer
if: env.AW_RESEARCH_EDITION == 'true'
run: python3 scripts/patch_research_edition_export.py

# The watcher rewrites `app` to a study category before storing, but
# aw-webui categorises client-side with its own defaults and never sees
# the watcher's map -- so without this the Categories panel reads
Expand Down
112 changes: 112 additions & 0 deletions scripts/patch_research_edition_export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Patch aw-server-rust export endpoints for the Research Edition build.

Run as part of the CI build for research edition:

python3 scripts/patch_research_edition_export.py [repo-root]

Standard builds never run this script, so `/api/0/export` stays byte-for-byte
unchanged outside Research Edition. The patch is fail-closed: missing markers
abort the build rather than shipping an unsanitized artifact.
"""
from __future__ import annotations

import pathlib
import shutil
import sys

MARKER = "RESEARCH_EDITION_EXPORT_SANITIZE"

EXPORT_INSERT_NEEDLE = """ export.buckets.insert(bid, bucket);
}

Ok(export.into())
"""

EXPORT_INSERT_REPLACEMENT = f""" export.buckets.insert(bid, bucket);
}}

// {MARKER}
let export = match super::export_sanitize::sanitize_buckets_export(export) {{
Ok(export) => export,
Err(err) => {{
return Err(HttpErrorJson::new(rocket::http::Status::Conflict, err))
}}
}};
Ok(export.into())
"""

BUCKET_INSERT_NEEDLE = """ export.buckets.insert(bucket_id.into(), bucket);

Ok(export.into())
"""

BUCKET_INSERT_REPLACEMENT = f""" export.buckets.insert(bucket_id.into(), bucket);

// {MARKER}
let export = match super::export_sanitize::sanitize_buckets_export(export) {{
Ok(export) => export,
Err(err) => return Err(HttpErrorJson::new(Status::Conflict, err)),
}};
Ok(export.into())
"""

MOD_NEEDLE = "mod export;\n"
MOD_REPLACEMENT = "mod export;\nmod export_sanitize;\n"


def repo_root_from_args(argv: list[str]) -> pathlib.Path:
if len(argv) > 1:
return pathlib.Path(argv[1]).resolve()
return pathlib.Path.cwd().resolve()


def _replace_once(path: pathlib.Path, needle: str, replacement: str, already_ok: str) -> None:
text = path.read_text(encoding="utf-8")
if already_ok in text:
return
count = text.count(needle)
if count != 1:
raise ValueError(
f"{path}: expected exactly one export-sanitizer insertion point, found {count}"
)
path.write_text(text.replace(needle, replacement, 1), encoding="utf-8")


def patch_tree(repo_root: pathlib.Path) -> None:
script_dir = pathlib.Path(__file__).resolve().parent
source = script_dir / "research_edition" / "export_sanitize.rs"
if not source.is_file():
raise FileNotFoundError(f"missing sanitizer module: {source}")

endpoints = (
repo_root / "aw-server-rust" / "aw-server" / "src" / "endpoints"
)
export_rs = endpoints / "export.rs"
bucket_rs = endpoints / "bucket.rs"
mod_rs = endpoints / "mod.rs"
dest = endpoints / "export_sanitize.rs"

for required in (export_rs, bucket_rs, mod_rs):
if not required.is_file():
raise FileNotFoundError(f"expected Rust export source at {required}")

shutil.copyfile(source, dest)

_replace_once(mod_rs, MOD_NEEDLE, MOD_REPLACEMENT, "mod export_sanitize;")
_replace_once(export_rs, EXPORT_INSERT_NEEDLE, EXPORT_INSERT_REPLACEMENT, MARKER)
_replace_once(bucket_rs, BUCKET_INSERT_NEEDLE, BUCKET_INSERT_REPLACEMENT, MARKER)


def main() -> None:
repo_root = repo_root_from_args(sys.argv)
try:
patch_tree(repo_root)
except (OSError, ValueError) as error:
print(f"Error: {error}", file=sys.stderr)
sys.exit(1)
print(f"Patched Research Edition export sanitizer under {repo_root / 'aw-server-rust'}")


if __name__ == "__main__":
main()
Loading