#!/usr/bin/env python3 """Detect mixed source trees and preserve the known obsolete 0.0.1 auth module.""" from __future__ import annotations import argparse import hashlib import re from pathlib import Path import sys LEGACY_AUTH = "4df9d473c9214365d91c83072f97aafab6ede4edcf4e1369b5c3679ded5cff85" LEGACY_PANEL_CSS = "38716fae968aeb8f6a8510f28bb9054a73a438a969c3b69f9c9d921acfed6e06" def validate_panel_css_build(root: Path) -> None: """Check this project's build.rs startup contract, not arbitrary Rust code.""" source = (root / "build.rs").read_text(encoding="utf-8") # Ignore ordinary strings and comments, including nested block comments. # The required call precedes the first nested block in the shipped main(). code: list[str] = [] position = 0 token = re.compile(r'"(?:\\.|[^"\\])*"|//[^\n]*|/\*', re.DOTALL) while match := token.search(source, position): code.extend((source[position:match.start()], " ")) position = match.end() if match.group() == "/*": depth = 1 while depth: boundary = re.search(r'/\*|\*/', source[position:]) if boundary is None: position = len(source) break depth += 1 if boundary.group() == "/*" else -1 position += boundary.end() code.append(source[position:]) main = re.search(r'\bfn\s+main\s*\(\s*\)[^{]*\{([^{}]*)', "".join(code)) if main is None or re.search( r'\bpanel_styles\s*\(\s*&root\s*,\s*&out\s*,?\s*\)\s*\?\s*;', main.group(1), ) is None: raise ValueError( "Mixed modular panel CSS source tree: build.rs is missing the expected " "panel_styles(&root, &out)? call in main's startup block. Restore build.rs " "from the complete matching source archive, preserving your local edits, " "then run 'touch build.rs' and retry ./dev.sh. " "Automatic repair does not rewrite build.rs." ) def preserve_legacy_panel_css(root: Path, repair_known: bool = False) -> list[str]: old = root / "src/admin/web/style.css" manifest = root / "src/admin/web/css/manifest.json" if not manifest.is_file() or not (old.exists() or old.is_symlink()): return [] if old.is_symlink() or hashlib.sha256(old.read_bytes()).hexdigest() != LEGACY_PANEL_CSS: raise ValueError( "Obsolete src/admin/web/style.css remains beside the modular CSS sources and " "contains unknown/custom content. Preserve it outside src/admin/web, review " "your changes and port them into the CSS modules before rebuilding. " "Automatic repair does not move or discard custom styles." ) if not repair_known: raise ValueError( "Known obsolete 0.34.9 src/admin/web/style.css remains beside the modular CSS sources. " "Run ./dev.sh repair to preserve it under .upgrade-backup/panel-css/." ) target = root / ".upgrade-backup/panel-css/style.css.0.34.9" if target.exists() or target.is_symlink(): if target.is_symlink() or target.read_bytes() != old.read_bytes(): raise ValueError("Legacy CSS backup already contains different data; refusing to overwrite it") old.unlink() # The exact original is already backed up. else: target.parent.mkdir(parents=True, exist_ok=True) old.rename(target) return [f"Preserved obsolete panel CSS as {target.relative_to(root)}"] def check(root: Path, repair_known: bool = False) -> list[str]: messages: list[str] = preserve_legacy_panel_css(root, repair_known) obsolete_split_files = ( "src/admin/web/app.js", "src/admin/web/vhosts.js", "src/web_server.rs", ) split_markers = { "src/admin/web/app.js": "src/admin/web/app/core.js", "src/admin/web/vhosts.js": "src/admin/web/vhosts/core.js", "src/web_server.rs": "src/web_server/mod.rs", } for relative in obsolete_split_files: obsolete = root / relative marker = root / split_markers[relative] if not obsolete.exists() or not marker.is_file(): continue if not repair_known: raise ValueError( f"Obsolete 0.25.0 source monolith {relative} remains beside the 0.25.1 split tree. " "Run ./dev.sh repair (or any normal ./dev.sh command) to preserve it under .upgrade-backup/." ) backup_root = root / ".upgrade-backup" / "0.25.1-split" target = backup_root / relative target.parent.mkdir(parents=True, exist_ok=True) if target.exists(): if target.read_bytes() != obsolete.read_bytes(): raise ValueError( f"Backup target {target.relative_to(root)} already contains different data; " f"move {relative} outside the source tree manually before rebuilding." ) obsolete.unlink() else: obsolete.rename(target) messages.append( f"Preserved obsolete {relative} as {target.relative_to(root)}" ) legacy = root / "src/auth.rs" if legacy.exists() and (root / "src/auth/mod.rs").is_file(): if legacy.is_symlink() or hashlib.sha256(legacy.read_bytes()).hexdigest() != LEGACY_AUTH: raise ValueError("Both src/auth.rs and src/auth/mod.rs exist. The single-file module is not the known 0.0.1 file. Preserve your edits and move it outside src before rebuilding. See docs/MIGRATION.md.") if not repair_known: raise ValueError("Known obsolete src/auth.rs from 0.0.1 found. Run ./dev.sh repair or start ./dev.sh to preserve it in .upgrade-backup/legacy-auth/.") directory = root / ".upgrade-backup/legacy-auth" directory.mkdir(parents=True, exist_ok=True) target = directory / "auth.rs.0.0.1" if target.exists(): if hashlib.sha256(target.read_bytes()).hexdigest() != LEGACY_AUTH: raise ValueError("Legacy backup path contains a different file; refusing to overwrite it.") legacy.unlink() # Byte-identical content was already preserved. else: legacy.rename(target) messages.append("Preserved obsolete src/auth.rs as .upgrade-backup/legacy-auth/auth.rs.0.0.1") for module in (root / "src").rglob("mod.rs"): flat = module.parent.with_suffix(".rs") if flat.is_file(): raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}") for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression/mod.rs", "src/diagnostics.rs", "src/traffic/mod.rs", "src/system_stats/mod.rs", "src/system_stats/linux/mod.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security/mod.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages/mod.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth/mod.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "CHANGELOG.md", "ROADMAP.md", "scripts/runtime/acme_provider.py", "src/basic_auth/mod.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/MONITORING.md", "docs/CUSTOM_LOCATIONS.md", "grafana/rustproxy-overview.json"]: if not (root / path).is_file(): raise ValueError(f"Incomplete RustProxy 0.34.2 source tree: missing {path}") for path in ("scripts/__init__.py", "scripts/build/__init__.py", "scripts/build/panel_css.py", "src/admin/web/css/manifest.json"): if not (root / path).is_file(): raise ValueError(f"Incomplete modular panel CSS source tree: missing {path}") validate_panel_css_build(root) if __package__: from .build.panel_css import bundle else: from build.panel_css import bundle bundle(root) # Validate the manifest and every module without generating files. registry = root / "src/admin/documentation/documents.rs" if not registry.is_file(): raise ValueError("Incomplete RustProxy 0.34.2 source tree: missing src/admin/documentation/documents.rs") embedded_docs = set(re.findall( r'"(docs/[A-Za-z0-9_.-]+\.md)"', registry.read_text(encoding="utf-8") )) for doc_path in sorted(embedded_docs): if not (root / doc_path).is_file(): raise ValueError(f"Missing embedded Reader document: {doc_path}; restore the file or remove its Reader registration") for parent in (root / "src").rglob("*.rs"): source = parent.read_text(encoding="utf-8") for relative in re.findall(r'\binclude!\s*\(\s*"([^"\n]+\.rs)"\s*\)', source): fragment = parent.parent / relative if not fragment.is_file(): raise ValueError(f"Missing included Rust source: {fragment.relative_to(root)}") if fragment.read_text(encoding="utf-8").lstrip().startswith(("//!", "/*!")): raise ValueError( f"{fragment.relative_to(root)} starts with an inner doc comment; " "replace //! with // or /// (Rust E0753)" ) text = (root / "src/lib.rs").read_text(encoding="utf-8") if '#[path = "auth/mod.rs"]' not in text: raise ValueError("src/lib.rs does not match the expected entry point; extract the complete package, not individual files.") return messages def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) parser.add_argument("--repair-known", action="store_true") args = parser.parse_args() try: for line in check(args.root.resolve(), args.repair_known): print(line, file=sys.stderr) except (OSError, ValueError) as error: parser.exit(2, f"Source preflight failed: {error}\n") if __name__ == "__main__": main()