From 1d5ae23d7524b14ce34c578c8489a4e2dfefa7df Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Sun, 20 Sep 2026 20:30:10 +0000 Subject: [PATCH 1/5] chore: project capobara from Mono 8420a1f8c5fd --- .repository-projection.json | 13 + Cargo.lock | 1673 +++++++++++++++++ Cargo.toml | 113 ++ LICENSE | 94 + README.md | 51 + build.rs | 78 + src/build.rs | 206 ++ src/catalog.rs | 286 +++ src/cli/catalog.rs | 68 + src/cli/mod.rs | 56 + src/cli/project.rs | 530 ++++++ src/cli/run.rs | 524 ++++++ src/cli/transport.rs | 253 +++ src/definition.rs | 695 +++++++ src/error.rs | 44 + src/git.rs | 77 + src/lib.rs | 16 + src/main.rs | 143 ++ src/modes/copy_v1.rs | 162 ++ src/modes/mod.rs | 19 + src/modes/sdk_assembly.rs | 388 ++++ src/modes/sdk_assembly/policies.rs | 359 ++++ src/ordered.rs | 196 ++ src/preflight.rs | 358 ++++ src/receipt.rs | 140 ++ src/report.rs | 86 + src/snapshot.rs | 143 ++ ...definitions_load_and_digest_like_node.snap | 6 + src/tooldigest.rs | 107 ++ src/transport/git.rs | 659 +++++++ src/transport/github.rs | 453 +++++ src/transport/mod.rs | 21 + src/tree/fs.rs | 231 +++ src/tree/mod.rs | 8 + src/tree/order.rs | 39 + src/tree/path.rs | 201 ++ src/tree/plan.rs | 207 ++ ...fs__tests__digest_matches_node_format.snap | 6 + ...digest_orders_entries_like_javascript.snap | 6 + tests/build.rs | 275 +++ tests/catalog.rs | 248 +++ tests/cli.rs | 29 + tests/definition_coverage.rs | 180 ++ tests/equivalence.rs | 270 +++ tests/fixtures/sdk-assembly.json | 142 ++ tests/project_cli.rs | 149 ++ tests/run_cli.rs | 942 ++++++++++ tests/sdk_assembly.rs | 583 ++++++ tests/snapshot.rs | 75 + ...nce_and_bounded_changed_deleted_paths.snap | 61 + tests/standalone_build.rs | 85 + tests/support/mod.rs | 215 +++ tests/transport_git.rs | 969 ++++++++++ tests/transport_github.rs | 261 +++ 54 files changed, 13199 insertions(+) create mode 100644 .repository-projection.json create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.rs create mode 100644 src/build.rs create mode 100644 src/catalog.rs create mode 100644 src/cli/catalog.rs create mode 100644 src/cli/mod.rs create mode 100644 src/cli/project.rs create mode 100644 src/cli/run.rs create mode 100644 src/cli/transport.rs create mode 100644 src/definition.rs create mode 100644 src/error.rs create mode 100644 src/git.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/modes/copy_v1.rs create mode 100644 src/modes/mod.rs create mode 100644 src/modes/sdk_assembly.rs create mode 100644 src/modes/sdk_assembly/policies.rs create mode 100644 src/ordered.rs create mode 100644 src/preflight.rs create mode 100644 src/receipt.rs create mode 100644 src/report.rs create mode 100644 src/snapshot.rs create mode 100644 src/snapshots/capobara__definition__tests__approved_definitions_load_and_digest_like_node.snap create mode 100644 src/tooldigest.rs create mode 100644 src/transport/git.rs create mode 100644 src/transport/github.rs create mode 100644 src/transport/mod.rs create mode 100644 src/tree/fs.rs create mode 100644 src/tree/mod.rs create mode 100644 src/tree/order.rs create mode 100644 src/tree/path.rs create mode 100644 src/tree/plan.rs create mode 100644 src/tree/snapshots/capobara__tree__fs__tests__digest_matches_node_format.snap create mode 100644 src/tree/snapshots/capobara__tree__fs__tests__digest_orders_entries_like_javascript.snap create mode 100644 tests/build.rs create mode 100644 tests/catalog.rs create mode 100644 tests/cli.rs create mode 100644 tests/definition_coverage.rs create mode 100644 tests/equivalence.rs create mode 100644 tests/fixtures/sdk-assembly.json create mode 100644 tests/project_cli.rs create mode 100644 tests/run_cli.rs create mode 100644 tests/sdk_assembly.rs create mode 100644 tests/snapshot.rs create mode 100644 tests/snapshots/transport_github__pr_body_carries_complete_provenance_and_bounded_changed_deleted_paths.snap create mode 100644 tests/standalone_build.rs create mode 100644 tests/support/mod.rs create mode 100644 tests/transport_git.rs create mode 100644 tests/transport_github.rs diff --git a/.repository-projection.json b/.repository-projection.json new file mode 100644 index 0000000..69c8699 --- /dev/null +++ b/.repository-projection.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "projection": "capobara", + "projectionSchemaVersion": 1, + "sourceRepository": "dx-corp/mono", + "sourceSha": "8420a1f8c5fdd07a3d2d4d2a46e0db0bb6bbb352", + "destinationRepository": "dx-corp/capobara", + "priorProjectedBase": "9971853c8b933c9d9058535fcbcace8b9e6e5dbb", + "definitionDigest": "7826c75cbd356dad6c867f2606ebde8b79ef4361353908cf33ada720034ec488", + "toolDigest": "be5ff7bcff05b7a616180a7b5ad761c46467a12e", + "contentDigest": "82178d3b6c66e4a3584418cbadf54c31d8b490106578ba18e4c4d1f441e2cd6c", + "publicationEligible": true +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..00ad96e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1673 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "capobara" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "hex", + "insta", + "pretty_assertions", + "regex", + "reqwest", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "serde", + "similar", + "tempfile", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..32237c0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,113 @@ +[package] +name = "capobara" +version = "0.1.0" +edition = "2024" +license = "BUSL-1.1" +rust-version = "1.95" +publish = false +description = "Publishes deterministic projections of Mono into standalone repositories." +repository = "https://github.com/dx-corp/capobara" +autotests = false +build = "build.rs" + +[features] +mono-fixtures = [] +recorded-api = [] + +[[bin]] +name = "capobara" +path = "src/main.rs" +test = false + +[dependencies] +anyhow = "1.0" +clap = { version = "4.5", features = ["derive", "env"] } +hex = "0.4" +regex = "1.13" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10" +tempfile = "3.14" +thiserror = "2.0" + +[dev-dependencies] +insta = { version = "1.48", features = ["json"] } +pretty_assertions = "1.4" + +[lints.rust] +non_ascii_idents = "deny" +unsafe_code = "forbid" +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(kani)"] } +unused_lifetimes = "warn" + +[lints.clippy] +await_holding_lock = "deny" +dbg_macro = "deny" +disallowed_methods = "deny" +empty_drop = "deny" +exit = "deny" +filetype_is_file = "deny" +fn_to_numeric_cast_any = "deny" +lossy_float_literal = "deny" +mem_forget = "deny" +mutex_atomic = "deny" +rc_buffer = "deny" +rest_pat_in_fully_bound_structs = "deny" +string_add = "deny" +string_lit_as_bytes = "deny" +todo = "deny" +unchecked_time_subtraction = "deny" +undocumented_unsafe_blocks = "deny" +verbose_file_reads = "deny" + +[[test]] +name = "build" +path = "tests/build.rs" + +[[test]] +name = "catalog" +path = "tests/catalog.rs" + +[[test]] +name = "cli" +path = "tests/cli.rs" + +[[test]] +name = "equivalence" +path = "tests/equivalence.rs" + +[[test]] +name = "definition_coverage" +path = "tests/definition_coverage.rs" + +[[test]] +name = "project_cli" +path = "tests/project_cli.rs" + +[[test]] +name = "snapshot" +path = "tests/snapshot.rs" + +[[test]] +name = "sdk_assembly" +path = "tests/sdk_assembly.rs" + +[[test]] +name = "transport_git" +path = "tests/transport_git.rs" +required-features = ["recorded-api"] + +[[test]] +name = "standalone_build" +path = "tests/standalone_build.rs" + +[[test]] +name = "transport_github" +path = "tests/transport_github.rs" +required-features = ["recorded-api"] + +[[test]] +name = "run_cli" +path = "tests/run_cli.rs" +required-features = ["recorded-api"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..acb5227 --- /dev/null +++ b/LICENSE @@ -0,0 +1,94 @@ +SPDX-License-Identifier: BUSL-1.1 + +Business Source License 1.1 + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +"Business Source License" is a trademark of MariaDB Corporation Ab. + +Licensor: + +EvalOps, Inc. + +Licensed Work: + +The Licensed Work is EvalOps Mono (Deixic), including all source code and associated files +made available in this repository, and is Copyright (c) 2026 EvalOps, Inc. + +Additional Use Grant: + +None + +Change Date: + +Four years from the date each version of the Licensed Work is first publicly +distributed. + +Change License: + +GPL Version 2.0 or any later version + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited production +use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under the +terms of the Change License, and the rights granted in the paragraph above +terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works of +the Licensed Work, are subject to this License. This License applies separately +for each version of the Licensed Work and the Change Date may vary for each +version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or modified +form from a third party, the terms and conditions set forth in this License +apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other versions +of the Licensed Work. + +This License does not grant you any right in any trademark or logo of Licensor +or its affiliates (provided that you may use a trademark or logo of Licensor as +expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN +"AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS +OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + +MariaDB hereby grants you permission to use this License's text to license your +works, and to refer to it using the trademark "Business Source License", as +long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License's text and the "Business +Source License" name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where "compatible" means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text "None". + +3. To specify a Change Date. + +4. Not to modify this License in any other way. diff --git a/README.md b/README.md new file mode 100644 index 0000000..15988c6 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Capobara + +Capobara is the name of the binary, the crate, and the public repository it +publishes itself into. A capo transposes a whole tuning onto a new position +on the neck without changing the music, which is what a projection does to +a Mono tree. + +`dx-corp/mono` is the source of truth. This repository is a projection of +`rust/tools/capobara`, published here by Capobara itself through the same +transport it implements — the same transport that will publish the rest +of Mono's projection catalog after cutover. The definition is registered +in Mono's projection catalog (`config/projections/capobara.json`); wiring +it into the automated publish matrix lands in a follow-up change. + +## Build + +``` +cargo build --locked +``` + +The crate is valid both as a Mono workspace member and standalone: it +declares its edition, license, Rust version, and dependency versions +directly rather than inheriting them, and this repository carries a +`Cargo.lock` committed at the crate root. + +## Commands + +| Command | Does | +| --- | --- | +| `capobara catalog` | Validate the catalog or print the publication matrix. | +| `capobara plan` | Report the plan without changing the destination. | +| `capobara apply` | Apply the projection to the destination checkout. | +| `capobara verify` | Apply and require the stored receipt to match. | +| `capobara check` | Report drift between source and destination. | +| `capobara prepare` | Clone-side preparation of the destination branch. | +| `capobara preflight` | Recheck a prepared projection before publication. | +| `capobara publish` | Commit, push, and open or update the pull request. | +| `capobara run` | Prepare, apply, verify, preflight, publish, and prove in one process. | + +`catalog`, `prepare`, `preflight`, `publish`, and `run` are landing +incrementally; track progress in `dx-corp/mono`. + +## Contributing + +Issues are welcome here. Code changes land in `dx-corp/mono` and are +projected into this repository; pull requests opened directly against this +repository will be overwritten by the next projection. + +## License + +Business Source License 1.1 (BUSL-1.1). See [LICENSE](LICENSE). diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..91a3ca3 --- /dev/null +++ b/build.rs @@ -0,0 +1,78 @@ +//! Cargo build script: embeds this crate's own git tree id into the binary +//! (as the `CAPOBARA_TREE_ID` compile-time environment variable) so +//! `tooldigest::embedded()` can prove, at verify/check time, that the +//! projector binary running is the one committed at the source revision +//! being projected (`cli::project::run` step 6). +//! +//! Precedence: +//! 1. `CAPOBARA_TREE_ID` in the *build* environment, if set and non-empty, +//! wins outright. This is how the standalone public build (outside +//! Mono, where there is no `rust/tools/capobara` git history to read) +//! supplies a tree id directly. `scripts/equivalence.sh` deliberately +//! does *not*: it builds with `env -u CAPOBARA_TREE_ID` inside a +//! detached worktree at the revision under test, so the git lookup +//! below is the only source of the value it then checks. +//! 2. Otherwise, `git rev-parse HEAD:rust/tools/capobara` from this +//! crate's own directory (a `:` object name is resolved +//! relative to the repository root regardless of the invoking +//! directory, so this works from the crate directory without a +//! separate `--show-toplevel` lookup for that call). If `git status +//! --porcelain -- rust/tools/capobara`, run from the repository root +//! (a pathspec argument to `git status` *is* resolved relative to the +//! invoking directory, unlike the `rev-parse` object name above, so +//! this one call needs the toplevel as its `-C`), is non-empty, `-dirty` +//! is appended: a binary built from an edited working tree can then +//! never pass the "projector matches the committed revision" check, +//! matching Node's equivalent fail-closed comparison against the files +//! on disk. +//! 3. If neither is available (for example, building outside any git +//! repository), the embedded value is empty and the runtime check +//! (`build_projection` and stored-receipt validation) fails closed with +//! "Projector tree id unavailable; build inside Mono or set +//! CAPOBARA_TREE_ID". +use std::process::Command; + +#[allow( + clippy::disallowed_methods, + reason = "build script queries git for the crate tree id" +)] +fn git(dir: &str, args: &[&str]) -> Option { + let output = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout) + .ok() + .map(|s| s.trim().to_string()) +} + +fn compute_tree_id() -> Option { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?; + let id = git(&manifest_dir, &["rev-parse", "HEAD:rust/tools/capobara"])?; + let root = git(&manifest_dir, &["rev-parse", "--show-toplevel"])?; + let dirty = git( + &root, + &["status", "--porcelain", "--", "rust/tools/capobara"], + ) + .is_some_and(|s| !s.is_empty()); + Some(if dirty { format!("{id}-dirty") } else { id }) +} + +fn main() { + println!("cargo:rerun-if-env-changed=CAPOBARA_TREE_ID"); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=Cargo.toml"); + + let tree_id = std::env::var("CAPOBARA_TREE_ID") + .ok() + .filter(|s| !s.is_empty()) + .or_else(compute_tree_id) + .unwrap_or_default(); + + println!("cargo:rustc-env=CAPOBARA_TREE_ID={tree_id}"); +} diff --git a/src/build.rs b/src/build.rs new file mode 100644 index 0000000..3c51b28 --- /dev/null +++ b/src/build.rs @@ -0,0 +1,206 @@ +//! Assembles a projection's entries and deletions into a planned `Plan` plus +//! its `Provenance` receipt. Ports the shared tail of `buildProjection` and +//! `checkPublicEntry` from `scripts/projections/project.mjs`; the +//! `maestro-public-tree-v1` branch of `buildProjection` is out of scope for +//! this crate (see `definition`'s module doc comment). + +use std::path::Path; + +use crate::definition::{Definition, Mode, definition_digest}; +use crate::git::{is_sha, is_tree_id_or_digest}; +use crate::modes::{Assembled, copy_v1}; +use crate::receipt::Provenance; +use crate::tree::{Entry, Matcher, Plan, files_under, plan_tree, tree_digest}; +use crate::{Result, contract}; + +pub struct BuildInput<'a> { + pub definition: &'a Definition, + pub definition_text: &'a str, + pub source_root: &'a Path, + pub target_root: &'a Path, + pub source_sha: &'a str, + pub prior_projected_base: &'a str, + pub tool_digest: &'a str, + pub publication_eligible: bool, +} + +#[derive(Debug)] +pub struct Built { + pub plan: Plan, + pub provenance: Provenance, +} + +/// Ports `checkPublicEntry`. `regex` has no lookahead, so the private-path +/// rule is implemented by walking `path`'s `/`-separated segments instead of +/// running one regex over the whole string; see `is_private_segment` for the +/// per-segment rule (including the `.env.example`-as-last-segment +/// exception, whose Node counterpart is a negative lookahead anchored on the +/// *whole path's* end, not the segment's). +pub fn check_public_entry(path: &str, entry: &Entry, definition: &Definition) -> Result<()> { + let plugin_catalog = definition.name == "plugins" + && definition.source_repository == "dx-corp/mono" + && definition.destination.repository == "dx-corp/plugins" + && path == ".agents/plugins/marketplace.json"; + let segments: Vec<&str> = path.split('/').collect(); + let last = segments.len().saturating_sub(1); + let private = segments + .iter() + .enumerate() + .any(|(index, segment)| is_private_segment(segment, index == last)); + contract( + plugin_catalog || !private, + format!("Private path in public projection: {path}"), + )?; + + contract( + !starts_with_private_key_header(&entry.content), + format!("Private key in public projection: {path}"), + )?; + Ok(()) +} + +/// True when `content` opens with a PEM private-key header: `-----BEGIN `, +/// an optional key-type tag (`RSA `, `EC `, or `OPENSSH `), then +/// `PRIVATE KEY-----`. Built from its fragments (rather than as four +/// complete, adjacent `-----BEGIN ... PRIVATE KEY-----` literals) so the +/// source text never itself reads as a stack of PEM key blocks. +fn starts_with_private_key_header(content: &[u8]) -> bool { + let Some(rest) = content.strip_prefix(b"-----BEGIN ") else { + return false; + }; + [&b""[..], b"RSA ", b"EC ", b"OPENSSH "].iter().any(|tag| { + rest.strip_prefix(*tag) + .is_some_and(|after| after.starts_with(b"PRIVATE KEY-----")) + }) +} + +/// True when a single path segment is private on its own. `is_last` says +/// whether this is the final segment of the whole path, needed only for the +/// `.env.example` exception: Node's negative lookahead `(?!example$)` tests +/// against the end of the *path*, so `.env.example` is allowed exclusively +/// as the last segment (`src/.env.example` is fine; `src/.env.example/x`, +/// where `.env.example` is a directory, is not). +fn is_private_segment(segment: &str, is_last: bool) -> bool { + if matches!( + segment, + ".env" | "id_rsa" | "id_ed25519" | ".agents" | ".context" + ) { + return true; + } + if let Some(rest) = segment.strip_prefix("gha-creds-") + && let Some(name) = rest.strip_suffix(".json") + && !name.is_empty() + { + return true; + } + if segment.starts_with(".env.") { + return !(is_last && segment == ".env.example"); + } + false +} + +/// Ports the shared tail of `buildProjection`: given the mode-specific +/// entries and deletion candidates, validates revision/digest inputs, +/// rejects any entry or deletion that would touch a destination-owned path, +/// rejects a source path that collides with the receipt's own path, checks +/// public-visibility entries, computes `contentDigest` over the entries +/// *before* the receipt is added, inserts the receipt, and plans the tree. +/// +/// `assemble` is used only by the `sdk-assembly-v1` branch (Task 10 supplies +/// the real SDK assembler); `copy-v1` never calls it. +pub fn build_projection( + input: BuildInput, + assemble: &dyn Fn(&Path, &str) -> Result, +) -> Result { + let BuildInput { + definition, + definition_text, + source_root, + target_root, + source_sha, + prior_projected_base, + tool_digest, + publication_eligible, + } = input; + + contract( + is_sha(source_sha) && is_sha(prior_projected_base), + "Source revision and prior projected base must be full SHAs", + )?; + contract( + is_tree_id_or_digest(tool_digest), + "Missing projector implementation digest", + )?; + + let owned = Matcher::new(&definition.destination_owned)?; + + let (mut entries, mut deletions) = match definition.mode { + Mode::CopyV1 => copy_v1::collect(definition, source_root, target_root, &owned)?, + Mode::SdkAssemblyV1 => { + let assembled = assemble(source_root, &definition.name)?; + let allowed = Matcher::new(&assembled.output_include)?; + let managed = Matcher::new(&assembled.output_managed)?; + for path in assembled.entries.keys() { + contract( + allowed.matches(path) && managed.matches(path), + format!("SDK output outside reviewed policy: {path}"), + )?; + } + let deletions: Vec = + files_under(target_root, &|path: &str| owned.matches(path))? + .into_iter() + .filter(|path| managed.matches(path)) + .collect(); + (assembled.entries, deletions) + } + }; + + for (path, entry) in entries.iter() { + contract( + !owned.matches(path), + format!("Projection would overwrite destination-owned path: {path}"), + )?; + contract( + path.as_str() != definition.provenance.as_str(), + "Source collides with provenance", + )?; + if definition.visibility == "public" { + check_public_entry(path, entry, definition)?; + } + } + + let content_digest = tree_digest(&entries); + + let provenance = Provenance { + schema_version: 1, + projection: definition.name.clone(), + projection_schema_version: definition.schema_version, + source_repository: definition.source_repository.clone(), + source_sha: source_sha.to_string(), + destination_repository: definition.destination.repository.clone(), + prior_projected_base: prior_projected_base.to_string(), + definition_digest: definition_digest(definition_text)?, + tool_digest: tool_digest.to_string(), + content_digest, + publication_eligible, + }; + + entries.insert( + definition.provenance.clone(), + Entry { + content: provenance.to_receipt_bytes(), + mode: 0o644, + }, + ); + + deletions.retain(|path| !entries.contains_key(path)); + for path in &deletions { + contract( + !owned.matches(path), + format!("Projection would delete destination-owned path: {path}"), + )?; + } + + let plan = plan_tree(target_root, entries, deletions)?; + Ok(Built { plan, provenance }) +} diff --git a/src/catalog.rs b/src/catalog.rs new file mode 100644 index 0000000..247a2dc --- /dev/null +++ b/src/catalog.rs @@ -0,0 +1,286 @@ +//! The approved repository catalog, main-authorized revision checks, and +//! the publication matrix. Ports `scripts/projections/catalog.mjs` (Node). +//! Line references below are against that file as read on +//! `feat/capobara-catalog`. + +use std::collections::HashSet; +use std::path::Path; + +use serde::Serialize; +use serde_json::Value; + +use crate::definition::{LoadedDefinition, load_definition, projection_input_roots}; +use crate::git::{self, is_ancestor, is_sha}; +use crate::tree::order::sort_js; +use crate::{Error, Result, invalid}; + +/// Ports `MAIN_AUTHORITY_REF`. +pub const MAIN_AUTHORITY_REF: &str = "refs/remotes/origin/main"; + +/// Ports `PROJECTION_RUNTIME_INPUTS`, with Node's `scripts/projections` +/// replaced by this crate's own path, `rust/tools/capobara`. Until Mono +/// cuts over to this binary, CI still runs the Node projector using Node's +/// own list; this list is what a Rust shadow job uses to compute the same +/// `sourceSha`, which only happens when both lists select the same +/// projector-affecting commit -- the shadow job asserts that before +/// trusting either result (see the Task 9 brief). +pub const PROJECTION_RUNTIME_INPUTS: [&str; 9] = [ + ".github/actions/setup-mise", + ".github/workflows/repository-projections.yml", + "mise.toml", + "rust-toolchain.toml", + "scripts/ci/gcs-directory-cache.sh", + "scripts/ci/mise-cache-key.py", + "scripts/dev/mise-install-retry.sh", + "scripts/dev/prepare-mise-rust.sh", + "rust/tools/capobara", +]; + +/// Ports `SHARED_SOURCE_REVISION_GROUPS`: the examples repository installs +/// both standalone SDKs at the exact source revision recorded in its own +/// provenance, so all three artifacts share one selected `sourceSha`. +pub const SHARED_SOURCE_REVISION_GROUPS: [[&str; 3]; 1] = + [["examples", "deixic-node", "deixic-python"]]; + +const SOURCE_REPOSITORY: &str = "dx-corp/mono"; +const CATALOG_FIELDS: [&str; 3] = ["projections", "schemaVersion", "sourceRepository"]; +const REQUIRED_DESTINATION_OWNED: [&str; 2] = [".github/**", "SECURITY.md"]; + +/// The publication matrix's JSON shape, exactly +/// `{"include":[{"name":...,"repository":...,"sourceSha":...}]}` -- +/// `serde`'s struct serialization keeps declared field order (unlike +/// `serde_json::Value`'s object map, which is why this doesn't need +/// `ordered::OrderedValue`). +#[derive(Debug, Clone, Serialize)] +pub struct Matrix { + pub include: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MatrixEntry { + pub name: String, + pub repository: String, + pub source_sha: String, +} + +/// `^[a-z][a-z0-9-]*$`, written out rather than compiled as a `Regex` +/// (matching `readCatalog`'s catalog-name check; `load_definition`'s own +/// name-pattern check, on the definition's own `name` field, is separate +/// and already enforced by `validate_definition_value`). +fn is_safe_catalog_name(name: &str) -> bool { + let mut bytes = name.bytes(); + match bytes.next() { + Some(first) if first.is_ascii_lowercase() => {} + _ => return false, + } + bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +/// Ports `readCatalog`: parses and validates +/// `config/projections/repositories.json`, then loads and cross-checks each +/// named `config/projections/.json`, in the catalog's own order. +pub fn read_catalog( + root: &Path, + sdk_inputs: &dyn Fn(&str) -> Option>, +) -> Result> { + let catalog_path = root.join("config/projections/repositories.json"); + let text = std::fs::read_to_string(&catalog_path) + .map_err(|e| Error::Invalid(format!("{}: {e}", catalog_path.display())))?; + let catalog: Value = serde_json::from_str(&text) + .map_err(|e| Error::Invalid(format!("Invalid projection JSON: {e}")))?; + + let object = catalog.as_object(); + let schema_version_ok = object + .and_then(|o| o.get("schemaVersion")) + .and_then(Value::as_i64) + == Some(1); + let source_repository_ok = object + .and_then(|o| o.get("sourceRepository")) + .and_then(Value::as_str) + == Some(SOURCE_REPOSITORY); + let keys_ok = object.is_some_and(|o| { + let mut keys: Vec<&str> = o.keys().map(String::as_str).collect(); + keys.sort_unstable(); + keys == CATALOG_FIELDS + }); + let projections = object + .and_then(|o| o.get("projections")) + .and_then(Value::as_array); + let projections_ok = projections.is_some_and(|items| { + !items.is_empty() + && items + .iter() + .enumerate() + .all(|(i, item)| !items[..i].contains(item)) + }); + // The `Option` produced above is consumed by this same `match`, not by a + // separate `.expect()` after an independent `invalid()` call: a future + // edit to any of the four `_ok` booleans can no longer desynchronize the + // guard from the value it guards, because there is only one place where + // both are read together. + let items = match projections + .filter(|_| schema_version_ok && source_repository_ok && keys_ok && projections_ok) + { + Some(items) => items, + None => return Err(Error::Invalid("Invalid repository catalog".into())), + }; + + items + .iter() + .map(|item| load_catalog_entry(root, item, sdk_inputs)) + .collect() +} + +/// One iteration of `readCatalog`'s `catalog.projections.map(...)` body: the +/// unsafe-name check, then loading and validating the definition, then the +/// catalog-identity and destination-ownership cross-checks. +fn load_catalog_entry( + root: &Path, + item: &Value, + sdk_inputs: &dyn Fn(&str) -> Option>, +) -> Result { + let name = item + .as_str() + .filter(|name| is_safe_catalog_name(name)) + .ok_or_else(|| Error::Invalid("Unsafe catalog name".into()))?; + let definition_path = root.join("config/projections").join(format!("{name}.json")); + let loaded = load_definition(&definition_path, sdk_inputs)?; + let d = &loaded.definition; + let identity_ok = d.name == name + && d.source_repository == SOURCE_REPOSITORY + && d.destination.repository == format!("dx-corp/{name}") + && d.visibility == "public" + && d.destination.branch == "main" + && d.destination.sync_branch == "sync/mono-projection"; + invalid(identity_ok, format!("Catalog identity mismatch: {name}"))?; + for path in REQUIRED_DESTINATION_OWNED { + invalid( + d.destination_owned.iter().any(|owned| owned == path), + format!("Missing destination ownership: {name}: {path}"), + )?; + } + Ok(loaded) +} + +/// Ports `assertMainAuthorizedRevision`: `revision` must be a full SHA that +/// is an ancestor of `MAIN_AUTHORITY_REF`. Returns the authority SHA (not +/// `revision`) on success. +pub fn assert_main_authorized_revision(root: &Path, revision: &str) -> Result { + invalid(is_sha(revision), "Missing immutable source revision")?; + let authorized = git::git( + root, + &[ + "rev-parse", + "--verify", + &format!("{MAIN_AUTHORITY_REF}^{{commit}}"), + ], + ) + .ok() + .map(|authority| authority.trim().to_string()) + .filter(|authority| is_ancestor(root, revision, authority)); + authorized.ok_or_else(|| { + Error::Invalid(format!( + "Projection revision is not authorized by {MAIN_AUTHORITY_REF}: {revision}" + )) + }) +} + +/// Ports `sourceRevisionInputs`: the sorted, deduplicated union of input +/// roots (from `projectionInputRoots`) over `definition`'s coupled group +/// (the `SHARED_SOURCE_REVISION_GROUPS` member containing its name, else +/// the name alone), plus `PROJECTION_RUNTIME_INPUTS`, plus each coupled +/// definition's own catalog file, plus the catalog file itself. +fn source_revision_inputs( + name: &str, + definitions: &[LoadedDefinition], + sdk_inputs: &dyn Fn(&str) -> Option>, +) -> Result> { + let group: Vec<&str> = SHARED_SOURCE_REVISION_GROUPS + .iter() + .find(|names| names.contains(&name)) + .map(|names| names.to_vec()) + .unwrap_or_else(|| vec![name]); + let coupled: Vec<&LoadedDefinition> = group + .iter() + .map(|coupled_name| { + definitions + .iter() + .find(|candidate| candidate.definition.name == *coupled_name) + .ok_or_else(|| { + Error::Invalid(format!("Missing coupled projection: {coupled_name}")) + }) + }) + .collect::>>()?; + + let mut seen: HashSet = HashSet::new(); + let mut inputs: Vec = Vec::new(); + { + let mut push = |value: String| { + if seen.insert(value.clone()) { + inputs.push(value); + } + }; + for candidate in &coupled { + for root in projection_input_roots(&candidate.definition, sdk_inputs) { + push(root); + } + } + for input in PROJECTION_RUNTIME_INPUTS { + push(input.to_string()); + } + for candidate in &coupled { + push(format!( + "config/projections/{}.json", + candidate.definition.name + )); + } + push("config/projections/repositories.json".to_string()); + } + sort_js(&mut inputs); + Ok(inputs) +} + +/// Ports `publicationMatrix`: validates the catalog, authorizes `HEAD` +/// against `MAIN_AUTHORITY_REF`, then for each selected definition finds +/// the latest commit touching its (coupled) source-revision inputs and +/// authorizes that commit too. +pub fn publication_matrix( + root: &Path, + requested: &str, + sdk_inputs: &dyn Fn(&str) -> Option>, +) -> Result { + let definitions = read_catalog(root, sdk_inputs)?; + invalid( + requested == "all" || definitions.iter().any(|d| d.definition.name == requested), + format!("Unknown projection: {requested}"), + )?; + let head = git::git(root, &["rev-parse", "HEAD^{commit}"])? + .trim() + .to_string(); + assert_main_authorized_revision(root, &head)?; + + let include = definitions + .iter() + .filter(|d| requested == "all" || d.definition.name == requested) + .map(|d| { + let name = &d.definition.name; + let inputs = source_revision_inputs(name, &definitions, sdk_inputs)?; + let mut args: Vec<&str> = vec!["log", "-1", "--format=%H", "HEAD", "--"]; + args.extend(inputs.iter().map(String::as_str)); + let source_sha = git::git(root, &args)?.trim().to_string(); + invalid( + is_sha(&source_sha), + format!("Missing source revision: {name}"), + )?; + assert_main_authorized_revision(root, &source_sha)?; + Ok(MatrixEntry { + name: name.clone(), + repository: d.definition.destination.repository.clone(), + source_sha, + }) + }) + .collect::>>()?; + + Ok(Matrix { include }) +} diff --git a/src/cli/catalog.rs b/src/cli/catalog.rs new file mode 100644 index 0000000..cfad3b9 --- /dev/null +++ b/src/cli/catalog.rs @@ -0,0 +1,68 @@ +//! `catalog check` and `catalog matrix [name|all]`. Ports the +//! `["matrix", "check"]` branch of `main` at the bottom of +//! `scripts/projections/catalog.mjs`. Node's file-level `catch` there sets +//! `process.exitCode = 1` for any thrown error -- unlike +//! `scripts/projections/project.mjs`'s `plan`/`apply`/`verify`/`check`, +//! which exit 2 (see `cli::project::run`'s doc comment); `main.rs`'s +//! `exit_catalog` mirrors that here. + +use std::path::{Path, PathBuf}; + +use clap::Subcommand; + +use crate::catalog::{publication_matrix, read_catalog}; +use crate::cli::project::sdk_inputs; +use crate::{Error, Result, git}; + +#[derive(Subcommand, Debug)] +pub enum CatalogCommand { + /// Validate every catalog entry and print how many were checked. + Check, + /// Print the publication matrix (`{"include":[...]}`) for one + /// projection name, or every projection when omitted. + Matrix { + #[arg(default_value = "all")] + requested: String, + }, +} + +/// Resolves the Mono checkout `catalog` reads `config/projections/*.json` +/// from and runs git against. `catalog.mjs`'s `ROOT` is derived from the +/// script's own file location (`fileURLToPath(new URL("../../", +/// import.meta.url))`), so it is the Mono checkout containing the script +/// regardless of the process's current directory. This crate has no +/// script location to derive from, so an explicit `--root` is used as-is; +/// otherwise the checkout is found the same cwd-independent way a human +/// would from a shell: `git rev-parse --show-toplevel`, run through the +/// crate's `git` module so `Command::new` stays confined to `git.rs::run`. +pub fn resolve_root(root: Option) -> Result { + if let Some(root) = root { + return Ok(root); + } + let cwd = std::env::current_dir().map_err(Error::Io)?; + git::git(&cwd, &["rev-parse", "--show-toplevel"]) + .map(|toplevel| PathBuf::from(toplevel.trim())) + .map_err(|_| Error::Invalid("Not inside a git repository; pass --root".into())) +} + +/// Runs `catalog check` or `catalog matrix [name|all]` against `root`. +/// `root` is the Mono checkout to read `config/projections/*.json` and run +/// git commands against; the `sdk-assembly-v1` entries resolve their input +/// roots through `cli::project::sdk_inputs`, the same reviewed policy lookup +/// `plan`/`apply`/`verify`/`check` use. +pub fn run(root: &Path, command: CatalogCommand) -> Result<()> { + match command { + CatalogCommand::Check => { + let count = read_catalog(root, &sdk_inputs)?.len(); + println!("{count} repository projections validated"); + } + CatalogCommand::Matrix { requested } => { + let matrix = publication_matrix(root, &requested, &sdk_inputs)?; + let json = serde_json::to_string(&matrix).map_err(|e| { + Error::Invalid(format!("Failed to serialize publication matrix: {e}")) + })?; + println!("{json}"); + } + } + Ok(()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..47bf873 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,56 @@ +//! CLI-facing argument types. `main.rs` (the binary) parses these with +//! `clap`; `project::run` (the library function Tasks 12 and 13 also call +//! in-process) takes the plain `project::ProjectArgs` struct built from +//! them, not a `clap`-derived type, so callers that already hold the +//! individual fields never need to round-trip through argv. + +pub mod catalog; +pub mod project; +pub mod run; +pub mod transport; + +use std::path::PathBuf; + +use clap::Args; + +use project::{ProjectArgs, ProjectCommand}; + +/// Flags shared by `plan`, `apply`, `verify`, and `check`. Ports the +/// `--definition`/`--source`/`--source-sha`/`--target`/`--report`/ +/// `--status-output`/`--markdown-output`/`--draft` options parsed by +/// `main`'s hand-rolled option loop in `scripts/projections/project.mjs`. +#[derive(Args, Debug)] +pub struct ProjectCliArgs { + #[arg(long)] + pub definition: PathBuf, + #[arg(long)] + pub source: PathBuf, + #[arg(long = "source-sha")] + pub source_sha: String, + #[arg(long)] + pub target: PathBuf, + #[arg(long)] + pub report: Option, + #[arg(long = "status-output")] + pub status_output: Option, + #[arg(long = "markdown-output")] + pub markdown_output: Option, + #[arg(long)] + pub draft: bool, +} + +impl ProjectCliArgs { + pub fn into_project_args(self, command: ProjectCommand) -> ProjectArgs { + ProjectArgs { + command, + definition: self.definition, + source: self.source, + source_sha: self.source_sha, + target: self.target, + report: self.report, + status_output: self.status_output, + markdown_output: self.markdown_output, + draft: self.draft, + } + } +} diff --git a/src/cli/project.rs b/src/cli/project.rs new file mode 100644 index 0000000..a9c6c42 --- /dev/null +++ b/src/cli/project.rs @@ -0,0 +1,530 @@ +//! `plan`, `apply`, `verify`, and `check`: read, compare, and (`apply` only) +//! write a projection between a Mono source checkout and a destination +//! checkout. Ports the `"preview"|"apply"|"check"|"verify"` branch of +//! `main` from `scripts/projections/project.mjs`; the numbered comments +//! below match the eleven steps transcribed in the Task 8 brief, which in +//! turn match Node's `requireValue` calls in source order. + +use std::path::{Component, Path, PathBuf}; +use std::sync::LazyLock; + +use regex::Regex; + +use serde_json::Value; + +use crate::build::{BuildInput, build_projection}; +use crate::definition::{Definition, definition_digest, load_definition, projection_input_roots}; +use crate::git::{self, is_ancestor, is_sha, is_tree_id_or_digest}; +use crate::modes::sdk_assembly; +use crate::receipt::Provenance; +use crate::report::{Report, markdown_summary}; +use crate::snapshot::with_snapshot; +use crate::tooldigest; +use crate::tree::apply_tree; +use crate::{Error, Result, invalid}; + +/// The stored receipt's field names, exactly as Node's `keys(previous, +/// [...], "stored provenance")` lists them. Order-independent; used only +/// to compare against the receipt's actual key set. +const STORED_PROVENANCE_FIELDS: [&str; 11] = [ + "schemaVersion", + "projection", + "projectionSchemaVersion", + "sourceRepository", + "sourceSha", + "destinationRepository", + "priorProjectedBase", + "definitionDigest", + "toolDigest", + "contentDigest", + "publicationEligible", +]; + +/// Which of the four projection subcommands is running. Only `apply` +/// writes to the destination checkout; only `verify` requires the built +/// provenance to match a stored receipt; `check` and `verify` (and only +/// those two) turn detected drift into exit code 1. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectCommand { + Plan, + Apply, + Verify, + Check, +} + +/// Everything `run` needs, independent of how it was gathered: `main.rs` +/// builds this from a parsed `cli::ProjectCliArgs` plus which subcommand +/// matched (`ProjectCliArgs::into_project_args`); Tasks 12 and 13 build it +/// directly to call `run` in-process, without going through argv. +pub struct ProjectArgs { + pub command: ProjectCommand, + pub definition: PathBuf, + pub source: PathBuf, + pub source_sha: String, + pub target: PathBuf, + pub report: Option, + pub status_output: Option, + pub markdown_output: Option, + pub draft: bool, +} + +static REMOTE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(?:https://github\.com/|git@github\.com:)([^/]+/[^/]+?)(?:\.git)?$") + .expect("static regex is valid") +}); + +/// Ports `repoIdentity`: `root`'s `origin` remote URL, parsed into its +/// GitHub `owner/name` identity. +pub fn repo_identity(root: &Path) -> Result { + let remote = git::git(root, &["remote", "get-url", "origin"])?; + REMOTE + .captures(remote.trim()) + .map(|captures| captures[1].to_string()) + .ok_or_else(|| Error::Invalid("Unrecognized source/destination remote".into())) +} + +/// `path.resolve(path)`-equivalent: makes `path` absolute against the +/// current directory (if it is not already) and collapses `.`/`..` +/// components lexically, without touching the filesystem or following +/// symlinks -- matching Node's `path.resolve`, and unlike +/// `Path::canonicalize`, which does both. +fn absolutize(path: &Path) -> Result { + let joined = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().map_err(Error::Io)?.join(path) + }; + Ok(normalize_lexically(&joined)) +} + +fn normalize_lexically(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other), + } + } + out +} + +/// `sdk-assembly-v1` policy lookup: the reviewed, immutable input list of +/// one of the three policies in `modes::sdk_assembly::policies`, or `None` +/// for any other name. `definition::validate_definition` rejects an +/// `sdk-assembly-v1` definition whose policy this does not know, so every +/// caller that loads a real definition -- `plan`/`apply`/`verify`/`check` +/// here, and `catalog check`/`catalog matrix` in `cli::catalog` -- must use +/// this and not a stand-in. It stood in as a constant `None` between the +/// task that added the subcommands and the task that added the policies; +/// while it did, all three `sdk-assembly-v1` projections failed to load with +/// "Unknown SDK assembly policy". +pub(crate) fn sdk_inputs(name: &str) -> Option> { + sdk_assembly::input_roots(name) +} + +fn write_json_report(path: &Path, report: &Report) -> Result<()> { + let mut text = serde_json::to_string_pretty(report) + .map_err(|e| Error::Invalid(format!("Failed to serialize report: {e}")))?; + text.push('\n'); + std::fs::write(path, text).map_err(Error::Io) +} + +/// Loads and validates the receipt at `receipt_path`, if any. Ports the +/// `previous !== null` branch of `main` in `scripts/projections/project.mjs`, +/// including Node's two-tier `keys()` shape check: a non-object receipt +/// (an array, string, number, bool, or `null` after JSON parsing) fails +/// *first* with `Invalid stored provenance`; only a JSON object with the +/// wrong key set fails with `Unknown or missing stored provenance fields`. +/// +/// Every check after the key-set check runs on the raw `serde_json::Value` +/// fields -- exactly mirroring Node's untyped `===`/regex comparisons, +/// which simply evaluate to `false` on a wrong-typed field instead of +/// throwing -- so a receipt whose fields have the wrong JSON type (for +/// example `"schemaVersion": "1"` or `"publicationEligible": "yes"`) is +/// *not* rejected here at deserialization; it reaches the later, more +/// specific checks (identity, then SHA/digest/bool shape) exactly as it +/// would in Node. Only once every check has passed does this deserialize +/// into a typed `Provenance`, which by construction cannot fail; a +/// residual error is folded into `Malformed stored provenance` rather than +/// given its own message. +fn load_stored_receipt( + receipt_path: &Path, + definition: &Definition, + draft: bool, +) -> Result> { + if !receipt_path.exists() { + return Ok(None); + } + let bytes = std::fs::read(receipt_path).map_err(Error::Io)?; + let value: Value = serde_json::from_slice(&bytes) + .map_err(|_| Error::Invalid("Invalid stored provenance".into()))?; + let object = value + .as_object() + .ok_or_else(|| Error::Invalid("Invalid stored provenance".into()))?; + + let mut actual: Vec<&str> = object.keys().map(String::as_str).collect(); + actual.sort_unstable(); + let mut expected: Vec<&str> = STORED_PROVENANCE_FIELDS.to_vec(); + expected.sort_unstable(); + invalid( + actual == expected, + "Unknown or missing stored provenance fields", + )?; + + let identity_ok = object.get("schemaVersion").and_then(Value::as_i64) == Some(1) + && object + .get("projectionSchemaVersion") + .and_then(Value::as_i64) + == Some(i64::from(definition.schema_version)) + && object.get("projection").and_then(Value::as_str) == Some(definition.name.as_str()) + && object.get("sourceRepository").and_then(Value::as_str) + == Some(definition.source_repository.as_str()) + && object.get("destinationRepository").and_then(Value::as_str) + == Some(definition.destination.repository.as_str()); + invalid(identity_ok, "Stored provenance identity mismatch")?; + + let string_field = |key: &str| object.get(key).and_then(Value::as_str); + let publication_eligible_value = object.get("publicationEligible").and_then(Value::as_bool); + let shape_ok = string_field("sourceSha").is_some_and(is_sha) + && string_field("priorProjectedBase").is_some_and(is_sha) + && ["definitionDigest", "toolDigest", "contentDigest"] + .into_iter() + .all(|key| string_field(key).is_some_and(is_tree_id_or_digest)) + && publication_eligible_value.is_some(); + invalid(shape_ok, "Malformed stored provenance")?; + + let publication_eligible = + publication_eligible_value.expect("checked by the shape_ok invalid() call above"); + invalid( + draft || publication_eligible, + "Draft receipt cannot be a publication base", + )?; + + let previous: Provenance = serde_json::from_value(value) + .map_err(|_| Error::Invalid("Malformed stored provenance".into()))?; + Ok(Some(previous)) +} + +/// Runs one of `plan`/`apply`/`verify`/`check` and returns the process +/// exit code: 0 when clean (or for `plan`/`apply` regardless of drift), 1 +/// when `check` or `verify` reports drift. Every failure along the way is +/// an `Err`; `main` prints its message and exits 2 for all four of these +/// subcommands regardless of the `Error` variant (Node's file-level +/// `catch` sets `process.exitCode = 2` for any thrown error, including a +/// `verify` provenance mismatch). +pub fn run(args: ProjectArgs) -> Result { + // Step 1: source/target must be absolute and disjoint in one direction + // (the destination cannot contain the source repository). + let source = absolutize(&args.source)?; + let target = absolutize(&args.target)?; + invalid( + target != source && !source.starts_with(&target), + "Destination cannot contain source repository", + )?; + + // Step 2: load and validate the definition; every mapping's source + // root and the destination root must be disjoint. + let loaded = load_definition(&args.definition, &sdk_inputs)?; + let definition = &loaded.definition; + for mapping in &definition.mappings { + let mapped = normalize_lexically(&source.join(&mapping.source)); + invalid( + target != mapped && !target.starts_with(&mapped) && !mapped.starts_with(&target), + "Mapped source and destination must be disjoint", + )?; + } + + // Step 3 + invalid(is_sha(&args.source_sha), "Invalid source SHA")?; + let source_sha = args.source_sha.as_str(); + + // Step 4 + invalid( + repo_identity(&source)? == definition.source_repository + && repo_identity(&target)? == definition.destination.repository, + "Repository identity mismatch", + )?; + + // Step 5 + let head = git::git(&source, &["rev-parse", "HEAD"])?; + invalid(head.trim() == source_sha, "Source revision mismatch")?; + + // Step 6: outside draft mode, the definition and this tool's own + // sources must match what is committed at `source_sha`. + let tool_digest = tooldigest::embedded(); + if !args.draft { + let committed = git::git( + &source, + &[ + "show", + &format!("{source_sha}:config/projections/{}.json", definition.name), + ], + )?; + invalid( + committed.trim() == loaded.text.trim(), + "Definition differs from source revision", + )?; + let at_revision = tooldigest::at_revision(&source, source_sha)?; + invalid( + at_revision == tool_digest, + "Projector differs from source revision: rust/tools/capobara", + )?; + } + + // Step 7 + let target_head = git::git(&target, &["rev-parse", "HEAD^{commit}"])? + .trim() + .to_string(); + let destination_base = git::git( + &target, + &[ + "rev-parse", + &format!( + "refs/remotes/origin/{}^{{commit}}", + definition.destination.branch + ), + ], + )? + .trim() + .to_string(); + let mut prior_projected_base = destination_base.clone(); + + // Step 8: validate any stored receipt at the destination. + let receipt_path = target.join(&definition.provenance); + let stored = load_stored_receipt(&receipt_path, definition, args.draft)?; + + // Step 9: decide whether the stored receipt (if any) is for the exact + // projection revision being run now, or belongs to a prior one. + let current_definition_digest = definition_digest(&loaded.text)?; + let same_projection_revision = stored.as_ref().is_some_and(|previous| { + previous.source_sha == source_sha && previous.definition_digest == current_definition_digest + }); + if same_projection_revision { + let previous = stored + .as_ref() + .expect("same_projection_revision is true only when stored is Some"); + invalid( + target_head == destination_base + || is_ancestor(&target, &previous.prior_projected_base, &target_head), + "Stored projection base is not an ancestor of the destination", + )?; + prior_projected_base = previous.prior_projected_base.clone(); + } else { + invalid( + is_ancestor(&target, &destination_base, &target_head), + "Prepared destination does not contain the current default branch", + )?; + } + + // Step 10: build the projection from a snapshot of the source at + // `source_sha`, optionally verify it against the stored receipt, write + // any requested report/status/markdown output, and (`apply` only) + // write the projected tree. + let roots = projection_input_roots(definition, &sdk_inputs); + let publication_eligible = !args.draft; + let command = args.command; + let definition_text = loaded.text.as_str(); + + let (message, exit_code) = with_snapshot(&source, source_sha, &roots, |snapshot_root| { + let built = build_projection( + BuildInput { + definition, + definition_text, + source_root: snapshot_root, + target_root: &target, + source_sha, + prior_projected_base: prior_projected_base.as_str(), + tool_digest, + publication_eligible, + }, + &sdk_assembly::assemble, + )?; + + if command == ProjectCommand::Verify { + match &stored { + None => return Err(Error::Invalid("Invalid provenance".into())), + Some(previous) => built.provenance.verify_against(previous)?, + } + } + + let report = Report::from(&built); + for path in args.report.iter().chain(args.status_output.iter()) { + write_json_report(path, &report)?; + } + if let Some(path) = &args.markdown_output { + let markdown = markdown_summary( + definition, + source_sha, + prior_projected_base.as_str(), + &built, + &report, + ); + std::fs::write(path, markdown).map_err(Error::Io)?; + } + + if command == ProjectCommand::Apply { + apply_tree(&target, &built.plan)?; + } + + let message = format!( + "{}: {} changed, {} deleted; content {}", + definition.name, + built.plan.copied_count(), + built.plan.deleted_count(), + built.provenance.content_digest + ); + let drift = built.plan.copied_count() + built.plan.deleted_count() > 0; + let exit_code = + if matches!(command, ProjectCommand::Check | ProjectCommand::Verify) && drift { + 1 + } else { + 0 + }; + Ok((message, exit_code)) + })?; + + println!("{message}"); + Ok(exit_code) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::definition::definition_from_value; + + fn definition() -> Definition { + let raw = serde_json::json!({ + "schemaVersion": 1, "name": "sample", "class": "source-tree", "mode": "copy-v1", + "sourceRepository": "dx-corp/mono", "visibility": "public", + "mappings": [{"source": "pkg", "destination": ".", "include": ["src/**"], "exclude": []}], + "destination": {"repository": "dx-corp/sample", "branch": "main", "syncBranch": "sync/mono-projection", "holdLabel": "sync-hold"}, + "destinationOwned": [".github/**", "SECURITY.md"], + "deletion": "owned-paths", "provenance": ".repository-projection.json", "validation": "tree-v1", + "outputManaged": ["src/**"] + }); + definition_from_value(raw, &|_| None).unwrap().definition + } + + fn valid_receipt() -> Value { + serde_json::json!({ + "schemaVersion": 1, + "projection": "sample", + "projectionSchemaVersion": 1, + "sourceRepository": "dx-corp/mono", + "sourceSha": "1".repeat(40), + "destinationRepository": "dx-corp/sample", + "priorProjectedBase": "2".repeat(40), + "definitionDigest": "d".repeat(64), + "toolDigest": "3".repeat(64), + "contentDigest": "c".repeat(64), + "publicationEligible": true, + }) + } + + fn write_receipt(dir: &std::path::Path, value: &Value) -> PathBuf { + let path = dir.join("receipt.json"); + std::fs::write(&path, serde_json::to_vec(value).unwrap()).unwrap(); + path + } + + // Positive control: a well-formed receipt still loads, so the checks + // added for the malformed cases below have not made every receipt + // fail. + #[test] + fn a_well_formed_receipt_loads() { + let dir = tempfile::tempdir().unwrap(); + let path = write_receipt(dir.path(), &valid_receipt()); + let loaded = load_stored_receipt(&path, &definition(), false).unwrap(); + assert!(loaded.is_some()); + } + + #[test] + fn a_missing_receipt_is_none() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("missing.json"); + assert!( + load_stored_receipt(&path, &definition(), false) + .unwrap() + .is_none() + ); + } + + #[test] + fn a_non_object_receipt_fails_the_shape_check_before_the_key_set_check() { + let dir = tempfile::tempdir().unwrap(); + let path = write_receipt(dir.path(), &serde_json::json!([])); + let err = load_stored_receipt(&path, &definition(), false) + .unwrap_err() + .to_string(); + assert_eq!(err, "Invalid stored provenance"); + } + + #[test] + fn an_extra_key_is_unknown_or_missing_stored_provenance_fields() { + let dir = tempfile::tempdir().unwrap(); + let mut receipt = valid_receipt(); + receipt["extra"] = serde_json::json!(true); + let path = write_receipt(dir.path(), &receipt); + let err = load_stored_receipt(&path, &definition(), false) + .unwrap_err() + .to_string(); + assert_eq!(err, "Unknown or missing stored provenance fields"); + } + + #[test] + fn a_wrong_typed_identity_field_reaches_identity_mismatch_not_a_parse_failure() { + let dir = tempfile::tempdir().unwrap(); + let mut receipt = valid_receipt(); + receipt["schemaVersion"] = serde_json::json!("1"); + let path = write_receipt(dir.path(), &receipt); + let err = load_stored_receipt(&path, &definition(), false) + .unwrap_err() + .to_string(); + assert_eq!(err, "Stored provenance identity mismatch"); + } + + #[test] + fn a_malformed_digest_reaches_the_malformed_check_not_identity_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let mut receipt = valid_receipt(); + receipt["contentDigest"] = serde_json::json!("zz"); + let path = write_receipt(dir.path(), &receipt); + let err = load_stored_receipt(&path, &definition(), false) + .unwrap_err() + .to_string(); + assert_eq!(err, "Malformed stored provenance"); + } + + #[test] + fn a_wrong_typed_publication_eligible_reaches_the_malformed_check() { + let dir = tempfile::tempdir().unwrap(); + let mut receipt = valid_receipt(); + receipt["publicationEligible"] = serde_json::json!("yes"); + let path = write_receipt(dir.path(), &receipt); + let err = load_stored_receipt(&path, &definition(), false) + .unwrap_err() + .to_string(); + assert_eq!(err, "Malformed stored provenance"); + } + + #[test] + fn a_non_publication_eligible_receipt_is_rejected_outside_draft_mode() { + let dir = tempfile::tempdir().unwrap(); + let mut receipt = valid_receipt(); + receipt["publicationEligible"] = serde_json::json!(false); + let path = write_receipt(dir.path(), &receipt); + let err = load_stored_receipt(&path, &definition(), false) + .unwrap_err() + .to_string(); + assert_eq!(err, "Draft receipt cannot be a publication base"); + // Positive control: the same receipt is accepted in draft mode. + assert!( + load_stored_receipt(&path, &definition(), true) + .unwrap() + .is_some() + ); + } +} diff --git a/src/cli/run.rs b/src/cli/run.rs new file mode 100644 index 0000000..bbe8987 --- /dev/null +++ b/src/cli/run.rs @@ -0,0 +1,524 @@ +//! `capobara run`: the whole publication for one projection in a single +//! process, plus the post-publication proof. +//! +//! This is a transcription of the `sync` job's steps in +//! `.github/workflows/repository-projections.yml` -- "Clone destination and +//! inspect sync-hold", "Prepare and verify the standalone projection", +//! "Recheck and render the Copybara transport", the publication step, and +//! "Prove the published or converged destination" -- with the Copybara +//! runtime replaced by this crate's own `transport::git` publication. +//! +//! Two steps of that job have no analogue here: `render-copybara.mjs` +//! exists only to feed the Copybara runtime this command replaces, and +//! `scripts/projections/validate.mjs` (yml:160, yml:287) has not been +//! ported to this crate. +//! +//! The `validate.mjs` gap is deliberately *visible* rather than silent: +//! `Command::Run`'s `long_about` names it and `run` prints +//! `VALIDATION_NOTICE` to stderr on every invocation. It is not fail-closed +//! because the phase-1 workflow keeps Node's two validate steps around +//! `capobara run` until Task 19 ports them; see the `TODO(task-19)` markers +//! at the two sites where the calls belong. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use clap::Args; +use serde_json::Value; + +use crate::cli::project::{ProjectArgs, ProjectCommand, run as project_run}; +use crate::cli::transport::{api_from_env, published_json, resolve_definition, source_root}; +use crate::definition::Definition; +use crate::git::{self, is_ancestor}; +use crate::preflight::{Preflight, preflight_publication}; +use crate::transport::git::{read_report, verify_command_failed}; +use crate::transport::github::{GitHubApi, open_sync_pr_endpoint}; +use crate::transport::{Published, prepare_destination, publish_prepared_tree}; +use crate::{Error, Result, contract}; + +#[derive(Args, Debug)] +pub struct RunArgs { + /// The catalog projection name. + pub name: String, + /// The immutable Mono revision being projected. + #[arg(long = "source-sha")] + pub source_sha: String, + /// Where to clone the destination. Defaults to a fresh directory under + /// `RUNNER_TEMP` (the workflow's `$RUNNER_TEMP/projection-target`) or, + /// failing that, the system temporary directory. + #[arg(long)] + pub destination: Option, + /// Run everything up to and including preflight, then stop without + /// publishing or proving. + #[arg(long = "dry-run")] + pub dry_run: bool, +} + +/// The workflow's preflight-hold line (yml:179). Also used for the +/// `prepare` hold, where the workflow writes nothing but the brief requires +/// a summary. +const HOLD_SUMMARY: &str = "sync-hold appeared; remote publication skipped.\n"; + +/// The workflow's publication-hold line (yml:254). Distinct from +/// `HOLD_SUMMARY` so a runbook grep can tell the two stages apart; this +/// crate replaces the native-tree publication path, so it inherits that +/// path's wording. +const PUBLISH_HOLD_SUMMARY: &str = "sync-hold appeared; native publication skipped.\n"; + +/// Printed to stderr on every `run`. The workflow still applies the +/// projection validation policies itself (yml:160, yml:287); this command +/// does not, until Task 19 ports `scripts/projections/validate.mjs`. +pub const VALIDATION_NOTICE: &str = + "capobara run: distribution validation is not applied by this command"; + +/// Appends to `$GITHUB_STEP_SUMMARY` when the workflow set it, and does +/// nothing otherwise -- the same conditional the workflow's `>>` +/// redirections have by virtue of only running inside Actions. +fn append_step_summary(text: &str) -> Result<()> { + let Ok(path) = std::env::var("GITHUB_STEP_SUMMARY") else { + return Ok(()); + }; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(Error::Io)?; + file.write_all(text.as_bytes()).map_err(Error::Io) +} + +fn step_summary_path() -> Option { + std::env::var_os("GITHUB_STEP_SUMMARY").map(PathBuf::from) +} + +/// The URL the destination is cloned from: always +/// `https://github.com/{repository}.git`, the literal URL the workflow +/// clones, unless the debug-only test seam below replaces it. +/// +/// `CAPOBARA_DESTINATION_REMOTE` is gated on +/// `#[cfg(all(debug_assertions, feature = "recorded-api"))]` -- exactly +/// like its sibling `CAPOBARA_RECORDED_API` (`cli::transport`), and +/// deliberately narrower than `CAPOBARA_TREE_ID_OVERRIDE`, which is +/// `debug_assertions` alone. The difference matters: the tree-id override +/// only relaxes a self-check, whereas this one chooses the tree that is +/// subsequently committed and pushed to the *real* GitHub destination, so +/// an ordinary debug build with a live `GH_TOKEN` must not honor it. Its +/// only consumer, `tests/run_cli.rs`, already carries +/// `required-features = ["recorded-api"]`, so nothing is lost. +#[cfg(all(debug_assertions, feature = "recorded-api"))] +fn destination_remote(github_url: &str) -> String { + match std::env::var_os("CAPOBARA_DESTINATION_REMOTE") { + Some(remote) => remote.to_string_lossy().into_owned(), + None => github_url.to_string(), + } +} + +/// The production twin of the seam above: the destination is always cloned +/// from its GitHub URL, and the environment variable does not exist. +#[cfg(not(all(debug_assertions, feature = "recorded-api")))] +fn destination_remote(github_url: &str) -> String { + github_url.to_string() +} + +/// `git clone [--quiet] [--no-checkout] `, through +/// `crate::git`'s reviewed process boundary with `into`'s parent as the +/// working directory, exactly as the scratch clones in `transport::git` do. +/// +/// `quiet` mirrors the workflow: its destination clone (yml:139) is not +/// quiet, its proof clone (yml:275) is. +fn clone_from(remote: &str, into: &Path, quiet: bool, no_checkout: bool) -> Result<()> { + let parent = into + .parent() + .ok_or_else(|| Error::Invalid(format!("Invalid destination path: {}", into.display())))?; + std::fs::create_dir_all(parent).map_err(Error::Io)?; + let into = into + .to_str() + .ok_or_else(|| Error::Invalid(format!("Non-UTF-8 path: {}", into.display())))?; + let mut args = vec!["clone"]; + if quiet { + args.push("--quiet"); + } + if no_checkout { + args.push("--no-checkout"); + } + args.push(remote); + args.push(into); + git::git(parent, &args)?; + Ok(()) +} + +/// Makes a clone taken from the test seam's local remote indistinguishable, +/// to every production identity check, from one taken from GitHub: +/// `origin`'s URL becomes the real repository URL, and +/// `remote.origin.pushurl` points back at the local remote so a publication +/// push stays hermetic. +/// +/// In production `remote == github_url`, so this is a no-op: no `set-url` +/// runs and no `pushurl` is ever configured. +/// +/// `pushurl` -- not `url..insteadOf` -- is what makes this work. +/// `git remote get-url origin`, which both `assert_destination_checkout` +/// (porting transport.mjs:88) and `cli::project::repo_identity` read, +/// *expands* `insteadOf`, so an `insteadOf` seam would make those two +/// checks see the local path and fail. Plain `get-url` does not expand +/// `pushurl` (only `get-url --push` does). Both directions were verified +/// against git on the host before this was written; see the task-13 +/// fix-round report. +/// +/// The proof clone needs one more thing this cannot provide -- its `fetch` +/// also has to stay local -- so `prove_publication` calls this *after* the +/// fetch rather than immediately after the clone. +fn adopt_destination_origin(into: &Path, remote: &str, github_url: &str) -> Result<()> { + if remote == github_url { + return Ok(()); + } + git::git(into, &["remote", "set-url", "origin", github_url])?; + git::git(into, &["config", "remote.origin.pushurl", remote])?; + Ok(()) +} + +/// `path`, made absolute against the process's current directory without +/// touching the filesystem (`std::path::absolute`, not `canonicalize`: +/// the destination does not exist yet). +fn absolute(path: &Path) -> Result { + std::path::absolute(path).map_err(Error::Io) +} + +/// A scratch directory under `RUNNER_TEMP` when the runner provides one +/// (so it lands on the same volume the workflow uses), else the system +/// temporary directory. +fn work_dir() -> Result { + let mut builder = tempfile::Builder::new(); + let builder = builder.prefix("capobara-run-"); + match std::env::var_os("RUNNER_TEMP") { + Some(runner_temp) => builder.tempdir_in(runner_temp), + None => builder.tempdir(), + } + .map_err(Error::Io) +} + +fn github_url(definition: &Definition) -> String { + format!( + "https://github.com/{}.git", + definition.destination.repository + ) +} + +/// Runs `capobara run`, returning the process exit code (3 when a +/// sync-hold stopped the run, else 0). Every failure is an `Err`, which +/// `main.rs` prints and turns into exit 1. +pub fn run(args: RunArgs) -> Result { + eprintln!("{VALIDATION_NOTICE}"); + + let root = source_root()?; + let loaded = resolve_definition(&root, &args.name)?; + let definition = &loaded.definition; + let url = github_url(definition); + let remote = destination_remote(&url); + + let work = work_dir()?; + // Absolute: `clone_from` runs `git -C clone ... `, where + // a relative `into` would resolve against `` and nest the clone + // one level too deep. + let target = match &args.destination { + Some(path) => absolute(path)?, + None => work.path().join("projection-target"), + }; + let report_path = work.path().join("projection-plan.json"); + let definition_path = root.join(format!("config/projections/{}.json", definition.name)); + + // "Clone destination and inspect sync-hold". + clone_from(&remote, &target, false, false)?; + adopt_destination_origin(&target, &remote, &url)?; + let api = api_from_env()?; + let prepared = prepare_destination( + definition, + &loaded.text, + &target, + &args.source_sha, + api.as_ref(), + )?; + if prepared.held { + println!("{{\"held\":true}}"); + append_step_summary(HOLD_SUMMARY)?; + return Ok(3); + } + + // "Prepare and verify the standalone projection". The workflow writes + // the projection's markdown summary straight to `$GITHUB_STEP_SUMMARY` + // (truncating it, as Node's `writeFileSync` does); the receipt block at + // the end of the proof is appended after it. + project_run(ProjectArgs { + command: ProjectCommand::Apply, + definition: definition_path.clone(), + source: root.clone(), + source_sha: args.source_sha.clone(), + target: target.clone(), + report: Some(report_path.clone()), + status_output: None, + markdown_output: step_summary_path(), + draft: false, + })?; + // TODO(task-19): the workflow runs + // `node scripts/projections/validate.mjs "$PROJECTION" ` here + // (yml:160). Wire the ported call in once Task 19 lands, and drop the + // corresponding sentence from `VALIDATION_NOTICE` and `long_about`. + let verify_definition = definition_path; + let verified = project_run(ProjectArgs { + command: ProjectCommand::Verify, + definition: verify_definition.clone(), + source: root.clone(), + source_sha: args.source_sha.clone(), + target: target.clone(), + report: None, + status_output: None, + markdown_output: None, + draft: false, + })?; + // The workflow runs under `set -euo pipefail`, so `project.mjs verify` + // reporting drift (exit 1) fails the step. The workflow's own + // invocation passes no `--report`, which is why the message builder is + // given `None`. + contract( + verified == 0, + verify_command_failed(&verify_definition, &root, &args.source_sha, &target, None), + )?; + + // "Recheck and render the Copybara transport". + let preflight = preflight_publication( + definition, + &root, + &args.source_sha, + &target, + &report_path, + api.as_ref(), + )?; + println!( + "{}", + serde_json::to_string(&preflight) + .map_err(|e| Error::Invalid(format!("Failed to serialize preflight: {e}")))? + ); + if preflight.held { + append_step_summary(HOLD_SUMMARY)?; + return Ok(3); + } + + if args.dry_run { + println!("{{\"dryRun\":true}}"); + return Ok(0); + } + + // The publication step, skipped when the destination already converged. + if !preflight.unchanged { + let report = read_report(&report_path)?; + let published = publish_prepared_tree( + definition, + &root, + &args.source_sha, + &target, + &report, + api.as_ref(), + )?; + let json = published_json(&published)?; + println!("{json}"); + if matches!(published, Published::Held) { + append_step_summary(PUBLISH_HOLD_SUMMARY)?; + return Ok(3); + } + // The workflow's only operator-visible record of what was published + // (yml:258). Its companion line, the native executable's SHA-256 + // (yml:259), has no meaning here: this binary *is* the tool. + append_step_summary(&format!("Publication: {json}\n"))?; + } + + // "Prove the published or converged destination". + prove_publication( + definition, + &root, + &args.source_sha, + &preflight, + &remote, + &url, + work.path(), + api.as_ref(), + )?; + Ok(0) +} + +/// The workflow's "Prove the published or converged destination" step, +/// transcribed: a fresh `--no-checkout` clone of the destination, the +/// published (or converged) ref checked out detached, `verify` run against +/// it, and the open generated PR set required to match the outcome. +/// +/// Returns the proven destination head. +#[allow(clippy::too_many_arguments, reason = "one transcribed workflow step")] +pub fn prove_publication( + definition: &Definition, + source: &Path, + source_sha: &str, + preflight: &Preflight, + remote: &str, + github_url: &str, + work: &Path, + api: &dyn GitHubApi, +) -> Result { + let proof = work.join("projection-proof"); + clone_from(remote, &proof, true, true)?; + + let destination_ref = if preflight.unchanged { + format!("refs/remotes/origin/{}", definition.destination.branch) + } else { + let sync_branch = &definition.destination.sync_branch; + git::git( + &proof, + &[ + "fetch", + "--quiet", + "origin", + &format!("+refs/heads/{sync_branch}:refs/remotes/origin/{sync_branch}"), + ], + )?; + let published_ref = format!("refs/remotes/origin/{sync_branch}"); + // `git merge-base --is-ancestor "$PRIOR_HEAD" "$destination_ref"` + // under `set -e`: the published branch must build on the head + // preflight recorded, never replace it. + contract( + is_ancestor(&proof, &preflight.prior_head, &published_ref), + "Published destination head does not descend from the preflight head", + )?; + published_ref + }; + + // Deliberately after the fetch above, not right after the clone: under + // the test seam the fetch has to reach the local remote, and only the + // checks below (`cli::project::repo_identity`, via `verify`) need + // `origin` to read as the GitHub URL. In production this is a no-op + // either way -- `remote == github_url` -- so the ordering is invisible. + adopt_destination_origin(&proof, remote, github_url)?; + + git::git( + &proof, + &["checkout", "--quiet", "--detach", &destination_ref], + )?; + let destination_head = git::git(&proof, &["rev-parse", "HEAD"])?.trim().to_string(); + + // TODO(task-19): the workflow runs + // `node scripts/projections/validate.mjs "$PROJECTION" ` here + // (yml:287), against the published tree rather than the prepared one. + let verify_definition = source.join(format!("config/projections/{}.json", definition.name)); + let verified = project_run(ProjectArgs { + command: ProjectCommand::Verify, + definition: verify_definition.clone(), + source: source.to_path_buf(), + source_sha: source_sha.to_string(), + target: proof.clone(), + report: None, + status_output: None, + markdown_output: None, + draft: false, + })?; + contract( + verified == 0, + verify_command_failed(&verify_definition, source, source_sha, &proof, None), + )?; + + // `gh pr list --repo ... --state open --base main --head + // sync/mono-projection --json number,headRefOid,url`, through this + // crate's own REST transport. `headRefOid` is the REST API's + // `head.sha`. + let prs = api + .call("GET", &open_sync_pr_endpoint(definition), None)? + .unwrap_or(Value::Null); + let prs = prs.as_array().ok_or_else(|| { + Error::Contract("Unreadable destination PR state in the publication proof".into()) + })?; + if preflight.unchanged { + contract( + prs.is_empty(), + "Converged projection still has an open generated PR", + )?; + } else { + let unique = prs.len() == 1 + && prs[0] + .get("head") + .and_then(|head| head.get("sha")) + .and_then(Value::as_str) + == Some(destination_head.as_str()); + contract( + unique, + "Copybara PR does not uniquely match the verified destination head", + )?; + } + + append_step_summary(&format!( + "### {} Capobara receipt\n\n- Source: `{source_sha}`\n- Destination head: `{destination_head}`\n- Converged without publication: `{}`\n", + definition.name, preflight.unchanged + ))?; + Ok(destination_head) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `clone_from` runs `git -C clone ... `, so a relative + /// `--destination` must be resolved against the process's current + /// directory before it is split into parent and target -- otherwise git + /// resolves it a second time against `` and the clone lands one + /// directory too deep. + #[test] + fn a_relative_destination_resolves_against_the_current_directory() { + let cwd = std::env::current_dir().unwrap(); + assert_eq!( + absolute(Path::new("scratch/projection-target")).unwrap(), + cwd.join("scratch/projection-target") + ); + // Positive control: an absolute path is returned unchanged. + assert_eq!( + absolute(Path::new("/tmp/projection-target")).unwrap(), + PathBuf::from("/tmp/projection-target") + ); + } + + /// The destination clone URL is the literal GitHub HTTPS URL the + /// workflow clones; `github_url` is what `assert_destination_checkout` + /// later compares `origin` against, so the two must agree. + #[test] + fn the_destination_clone_url_is_the_github_https_url() { + let raw = serde_json::json!({ + "schemaVersion": 1, "name": "sample", "class": "source-tree", "mode": "copy-v1", + "sourceRepository": "dx-corp/mono", "visibility": "public", + "mappings": [{"source": "pkg", "destination": ".", "include": ["src/**"], "exclude": []}], + "destination": {"repository": "dx-corp/sample", "branch": "main", "syncBranch": "sync/mono-projection", "holdLabel": "sync-hold"}, + "destinationOwned": [".github/**", "SECURITY.md"], + "deletion": "owned-paths", "provenance": ".repository-projection.json", "validation": "tree-v1", + "outputManaged": ["src/**"] + }); + let definition = crate::definition::definition_from_value(raw, &|_| None) + .unwrap() + .definition; + assert_eq!( + github_url(&definition), + "https://github.com/dx-corp/sample.git" + ); + } + + /// `adopt_destination_origin` must be a true no-op in production, where + /// the clone already came from the GitHub URL: it may not touch + /// `origin` and must never configure a `pushurl`. A non-existent path + /// is a sufficient repository here precisely because no git command + /// should run. + #[test] + fn adopting_the_origin_is_a_no_op_when_the_clone_came_from_github() { + let url = "https://github.com/dx-corp/sample.git"; + assert!( + adopt_destination_origin(Path::new("/nonexistent/repo"), url, url).is_ok(), + "a production clone must not run any git command here" + ); + // Positive control: with a different remote it does try, and fails + // against the same non-repository path. + assert!( + adopt_destination_origin(Path::new("/nonexistent/repo"), "/somewhere/bare", url) + .is_err() + ); + } +} diff --git a/src/cli/transport.rs b/src/cli/transport.rs new file mode 100644 index 0000000..bc4a78b --- /dev/null +++ b/src/cli/transport.rs @@ -0,0 +1,253 @@ +//! `prepare`, `preflight`, and `publish`: the transport CLI entry points. +//! Ports the `main()` dispatch at the bottom of +//! `scripts/projections/transport.mjs` (`prepare`/`publish`) and of +//! `scripts/projections/copybara-preflight.mjs` (`preflight`). +//! +//! All three print one line of JSON on stdout, exit 3 when the destination +//! PR is sync-held, and exit 1 on any error -- the latter regardless of the +//! `Error` variant, because both Node scripts' file-level `catch` sets +//! `process.exitCode = 1` unconditionally (`main.rs`'s `exit_transport` +//! mirrors that; contrast `exit_project`, which always exits 2). + +use std::path::{Path, PathBuf}; + +use clap::Args; + +use crate::catalog::read_catalog; +use crate::cli::project::sdk_inputs; +use crate::definition::LoadedDefinition; +use crate::preflight::preflight_publication; +use crate::transport::git::read_report; +use crate::transport::github::{GitHubApi, RestApi}; +use crate::transport::{Published, prepare_destination, publish_prepared_tree}; +use crate::{Error, Result, git}; + +/// `prepare `. Positional, in Node's argv +/// order. +#[derive(Args, Debug)] +pub struct PrepareArgs { + /// The catalog projection name. + pub name: String, + /// The destination checkout. + pub target: PathBuf, + /// The immutable Mono revision being projected. + pub source_sha: String, +} + +/// `preflight ` and +/// `publish `. +#[derive(Args, Debug)] +pub struct ReportArgs { + /// The catalog projection name. + pub name: String, + /// The destination checkout. + pub target: PathBuf, + /// The immutable Mono revision being projected. + pub source_sha: String, + /// The report `capobara apply` wrote for this projection. + pub report: PathBuf, +} + +/// The Mono checkout `` is resolved against. Node derives its `ROOT` +/// from the script's own location (`new URL("../../", import.meta.url)`); +/// a compiled binary has no script location, so the source root is the +/// process's current directory -- which is what the workflow's +/// `$GITHUB_WORKSPACE` already is for every projection step. +/// +/// When the current directory holds no `config/projections/`, and only +/// then, this falls back to the enclosing checkout's top level the same +/// cwd-independent way `cli::catalog::resolve_root` does. That keeps the +/// brief's "current working directory as the source root" exact wherever +/// it resolves at all, and replaces an opaque `Invalid repository catalog` +/// with the obvious answer when these subcommands are run from a +/// subdirectory of Mono. A failed fallback is not itself an error: the +/// caller still reports the missing catalog against the cwd. +pub fn source_root() -> Result { + let cwd = std::env::current_dir().map_err(Error::Io)?; + if cwd.join("config/projections").is_dir() { + return Ok(cwd); + } + let toplevel = git::git(&cwd, &["rev-parse", "--show-toplevel"]) + .map(|toplevel| PathBuf::from(toplevel.trim())) + .ok() + .filter(|toplevel| toplevel.join("config/projections").is_dir()); + Ok(toplevel.unwrap_or(cwd)) +} + +/// Ports `readCatalog(ROOT).find((d) => d.name === name)` plus its +/// `Unknown projection: ${name}` guard. +pub fn resolve_definition(root: &Path, name: &str) -> Result { + read_catalog(root, &sdk_inputs)? + .into_iter() + .find(|loaded| loaded.definition.name == name) + .ok_or_else(|| Error::Contract(format!("Unknown projection: {name}"))) +} + +/// One recorded `(method, endpoint, response)` triple in a +/// `CAPOBARA_RECORDED_API` file. +#[cfg(all(debug_assertions, feature = "recorded-api"))] +#[derive(serde::Deserialize)] +struct RecordedCall { + method: String, + endpoint: String, + response: serde_json::Value, +} + +/// Debug builds with the `recorded-api` feature only: replay a recorded +/// GitHub conversation from the JSON file named by `CAPOBARA_RECORDED_API` +/// instead of calling api.github.com, so an integration test can drive +/// `run`/`prepare`/`preflight`/`publish` end to end as a child process with +/// no network and no token. Neither the variable nor `RecordedApi` itself +/// exists in a release build, so a production binary can never be talked +/// into replaying a canned GitHub response. +#[cfg(all(debug_assertions, feature = "recorded-api"))] +fn recorded_api(path: &str) -> Result { + let bytes = std::fs::read(path).map_err(Error::Io)?; + let calls: Vec = serde_json::from_slice(&bytes) + .map_err(|e| Error::Invalid(format!("Invalid recorded API file: {e}")))?; + Ok(crate::transport::RecordedApi::new( + calls + .iter() + .map(|call| { + ( + call.method.as_str(), + call.endpoint.as_str(), + call.response.clone(), + ) + }) + .collect(), + )) +} + +/// The GitHub transport for this process: the real REST client, unless a +/// debug build has been pointed at a recorded conversation (see +/// `recorded_api`). +pub fn api_from_env() -> Result> { + #[cfg(all(debug_assertions, feature = "recorded-api"))] + if let Ok(path) = std::env::var("CAPOBARA_RECORDED_API") { + return Ok(Box::new(recorded_api(&path)?)); + } + Ok(Box::new(RestApi::from_env()?)) +} + +/// Node: `JSON.stringify(result)`. +fn to_line(value: &impl serde::Serialize) -> Result { + serde_json::to_string(value) + .map_err(|e| Error::Invalid(format!("Failed to serialize result: {e}"))) +} + +/// Node: `console.log(JSON.stringify(result)); if (result.held) +/// process.exitCode = 3;` +fn print_line(value: &impl serde::Serialize) -> Result<()> { + println!("{}", to_line(value)?); + Ok(()) +} + +/// `{"held":true}` / `{"held":false}` -- `prepare`'s exact Node shape +/// (`prepareDestination` returns only that one key). +#[derive(serde::Serialize)] +struct Held { + held: bool, +} + +/// `{"held":false,"unchanged":true}`, the converged branch of `publish`. +#[derive(serde::Serialize)] +struct Unchanged { + held: bool, + unchanged: bool, +} + +/// `{"held":false,"pullRequest":...,"engine":...,"tree":...}`, the +/// published branch of `publish`, in Node's key order +/// (`transport.mjs:441-446`). A derived `Serialize` makes the struct's +/// declared field order the JSON key order, so this declaration *is* the +/// wire contract. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct PullRequestResult { + held: bool, + pull_request: String, + engine: String, + tree: String, +} + +pub fn prepare(args: PrepareArgs) -> Result { + let root = source_root()?; + let loaded = resolve_definition(&root, &args.name)?; + let api = api_from_env()?; + let prepared = prepare_destination( + &loaded.definition, + &loaded.text, + &args.target, + &args.source_sha, + api.as_ref(), + )?; + print_line(&Held { + held: prepared.held, + })?; + Ok(if prepared.held { 3 } else { 0 }) +} + +pub fn preflight(args: ReportArgs) -> Result { + let root = source_root()?; + let loaded = resolve_definition(&root, &args.name)?; + let api = api_from_env()?; + let result = preflight_publication( + &loaded.definition, + &root, + &args.source_sha, + &args.target, + &args.report, + api.as_ref(), + )?; + print_line(&result)?; + Ok(if result.held { 3 } else { 0 }) +} + +pub fn publish(args: ReportArgs) -> Result { + let root = source_root()?; + let loaded = resolve_definition(&root, &args.name)?; + let api = api_from_env()?; + let report = read_report(&args.report)?; + let published = publish_prepared_tree( + &loaded.definition, + &root, + &args.source_sha, + &args.target, + &report, + api.as_ref(), + )?; + print_published(&published)?; + Ok(if matches!(published, Published::Held) { + 3 + } else { + 0 + }) +} + +/// The three `publishPreparedTree` return shapes, serialized exactly as +/// Node prints them -- including `engine` and `tree`, which +/// `transport::git::Published::PullRequest` now carries. +/// `cli::run` needs the bytes as a value, not only on stdout, because the +/// workflow also records them in `$GITHUB_STEP_SUMMARY` (yml:258). +pub fn published_json(published: &Published) -> Result { + match published { + Published::Held => to_line(&Held { held: true }), + Published::Unchanged => to_line(&Unchanged { + held: false, + unchanged: true, + }), + Published::PullRequest { url, engine, tree } => to_line(&PullRequestResult { + held: false, + pull_request: url.clone(), + engine: engine.clone(), + tree: tree.clone(), + }), + } +} + +/// `published_json`, on stdout. +pub fn print_published(published: &Published) -> Result<()> { + println!("{}", published_json(published)?); + Ok(()) +} diff --git a/src/definition.rs b/src/definition.rs new file mode 100644 index 0000000..3ad52cb --- /dev/null +++ b/src/definition.rs @@ -0,0 +1,695 @@ +//! Projection definition schema and validation. +//! +//! Ports `validateDefinition`, `definitionDigest`, and `projectionInputRoots` +//! from `scripts/projections/project.mjs` (Node). Definitions live at +//! `config/projections/.json` in Mono. The numbered rules below match +//! the transcription in the Task 5 brief, which in turn matches the order of +//! `requireValue` calls in `validateDefinition` (lines 71-240 of project.mjs): +//! the first failing rule's message must match Node's for a given input. +//! +//! Validation runs on the raw `serde_json::Value` (object key order is +//! irrelevant to validation; `serde_json::Map` is a sorted `BTreeMap` in +//! this crate) and only *afterward* deserializes into the typed `Definition` +//! (see `validate_definition_value` and `definition_from_value`). This is +//! deliberate, not incidental: Node's `keys()` helper enforces exact field +//! sets (top level, `destination`, and each mapping) as independent checks +//! that run in source order alongside the other rules. Deserializing into a +//! typed struct first would let serde's own field-set and enum-variant +//! checks fire in JSON key order instead of Node's rule order, so a +//! definition with two problems (e.g. an unsupported `mode` *and* an unknown +//! top-level field) could report the wrong rule's message depending on which +//! key came first in the file. See "fix round 1" in the Task 5 report. +//! +//! `maestro-public-tree-v1` (a third mode Node supports) is out of scope for +//! this crate: any unrecognized `mode` string, including that one, is +//! rejected by rule 3 as an unsupported class/mode pair. + +use std::collections::HashSet; +use std::path::Path; +use std::sync::LazyLock; + +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::ordered::canonical_compact_json; +use crate::tree::{Matcher, PathOpts, has_wildcard, safe_path, sha256_hex}; +use crate::{Error, Result, invalid}; + +static NAME: LazyLock = + LazyLock::new(|| Regex::new(r"^[a-z][a-z0-9-]*$").expect("static regex is valid")); +static REPO: LazyLock = LazyLock::new(|| { + Regex::new(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$").expect("static regex is valid") +}); +static REF: LazyLock = + LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9_/-]*$").expect("static regex is valid")); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum Mode { + #[serde(rename = "copy-v1")] + CopyV1, + #[serde(rename = "sdk-assembly-v1")] + SdkAssemblyV1, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Mapping { + pub source: String, + pub destination: String, + pub include: Vec, + pub exclude: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Destination { + pub repository: String, + pub branch: String, + pub sync_branch: String, + pub hold_label: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Definition { + pub schema_version: u32, + pub name: String, + pub class: String, + pub mode: Mode, + pub source_repository: String, + pub visibility: String, + pub mappings: Vec, + pub destination: Destination, + pub destination_owned: Vec, + pub deletion: String, + pub provenance: String, + pub validation: String, + /// Required for `copy-v1`, forbidden otherwise (rule 1). `#[serde(default)]` + /// so a missing key deserializes to `None` for both modes, and + /// `skip_serializing_if` so re-serializing a `sdk-assembly-v1` definition + /// (e.g. from `validate_definition`, which round-trips through `Value`) + /// omits the key rather than writing `"outputManaged": null` -- which + /// would otherwise make `validate_definition_value`'s field-set check see + /// a key that was never in the original JSON. The conditional presence + /// per mode is enforced by `value_keys` in `validate_definition_value`, + /// not by `Option` itself, which accepts absence regardless of `mode`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_managed: Option>, +} + +#[derive(Debug)] +pub struct LoadedDefinition { + pub definition: Definition, + pub raw: Value, + pub text: String, +} + +/// Reads `path`, parses it into a `serde_json::Value`, deserializes and +/// validates it. `text` is the file's exact contents, in the file's own key +/// order; pass it to `definition_digest` for a Node-comparable digest. +pub fn load_definition( + path: &Path, + sdk_inputs: &dyn Fn(&str) -> Option>, +) -> Result { + let text = std::fs::read_to_string(path)?; + let raw: Value = serde_json::from_str(&text) + .map_err(|e| Error::Invalid(format!("Invalid projection JSON: {e}")))?; + let loaded = definition_from_value(raw, sdk_inputs)?; + Ok(LoadedDefinition { + definition: loaded.definition, + raw: loaded.raw, + text, + }) +} + +/// Builds a `LoadedDefinition` from an already-parsed `Value`: validates it +/// (on the raw value, in Node's rule order -- see the module doc comment) +/// and only then deserializes it into a `Definition`. Used by +/// `load_definition` and directly by tests. There is no source file in this +/// path, so `text` is reconstructed from `raw` via +/// `serde_json::to_string_pretty` plus a trailing newline, rather than read +/// from disk; its key order is therefore `Value`'s (sorted), not any +/// original file's. A digest computed from this `text` via +/// `definition_digest` is NOT Node-comparable -- it is for tests only, where +/// `definition_from_value` is called directly rather than via +/// `load_definition`. +pub fn definition_from_value( + raw: Value, + sdk_inputs: &dyn Fn(&str) -> Option>, +) -> Result { + validate_definition_value(&raw, sdk_inputs)?; + let definition = finalize_definition(raw.clone())?; + let text = format!( + "{}\n", + serde_json::to_string_pretty(&raw) + .map_err(|e| Error::Invalid(format!("Invalid projection JSON: {e}")))? + ); + Ok(LoadedDefinition { + definition, + raw, + text, + }) +} + +/// Deserializes an already-validated raw `Value` into a typed `Definition`. +/// By the time this runs, `validate_definition_value` has already confirmed +/// exact field sets (top level, `destination`, every mapping) and +/// rule-compliant values, so this conversion should always succeed; +/// `#[serde(deny_unknown_fields)]` on the structs remains as a second line +/// of defense, and a residual failure here is treated as unreachable in +/// normal operation rather than given its own diagnostic message. +fn finalize_definition(raw: Value) -> Result { + serde_json::from_value(raw).map_err(|_| Error::Invalid("Invalid projection".into())) +} + +/// Ports `keys()` from project.mjs: `value` must be a JSON object whose key +/// set is exactly `expected` (order-independent). Used for the top-level +/// definition, `destination`, and each mapping; the label says which one so +/// the error message names the right thing, matching Node exactly. +fn value_keys(value: &Value, expected: &[&str], label: &str) -> Result<()> { + let object = value.as_object(); + invalid(object.is_some(), format!("Invalid {label}"))?; + let object = object.expect("checked by the invalid() call above"); + let mut actual: Vec<&str> = object.keys().map(String::as_str).collect(); + actual.sort_unstable(); + let mut expected: Vec<&str> = expected.to_vec(); + expected.sort_unstable(); + invalid( + actual == expected, + format!("Unknown or missing {label} fields"), + ) +} + +/// Ports `patterns()` from project.mjs: `value` must be a JSON array (and, +/// when `nonempty`, a non-empty one) of strings, each a safe pattern +/// (`safe_path` with `pattern: true`), with no duplicates. A non-string +/// element is treated the same as the array itself being the wrong shape: +/// `Invalid {label}`. Returns the patterns as owned `String`s so callers can +/// use them further (matcher construction, membership checks, and so on). +fn value_patterns(value: &Value, label: &str, nonempty: bool) -> Result> { + let array = value.as_array(); + invalid( + array.is_some_and(|a| !nonempty || !a.is_empty()), + format!("Invalid {label}"), + )?; + let array = array.expect("checked by the invalid() call above"); + let mut patterns = Vec::with_capacity(array.len()); + for item in array { + let pattern = item + .as_str() + .ok_or_else(|| Error::Invalid(format!("Invalid {label}")))?; + safe_path( + pattern, + PathOpts { + pattern: true, + root: false, + }, + )?; + patterns.push(pattern.to_string()); + } + let unique: HashSet<&str> = patterns.iter().map(String::as_str).collect(); + invalid(unique.len() == patterns.len(), format!("Duplicate {label}"))?; + Ok(patterns) +} + +/// A mapping's fields, read off the raw `Value` once its shape has already +/// passed `value_keys`/`value_patterns`. Collected during rule 9 so rules 10 +/// and 11 (which also iterate the mappings) don't have to re-read `Value`. +struct MappingFields { + source: String, + destination: String, + include: Vec, + exclude: Vec, +} + +/// Validates a definition's raw JSON `Value` against the rules transcribed +/// from `validateDefinition` in project.mjs, checked in Node's exact order +/// so that, for any invalid input, the first rule to fail here is the same +/// rule that fails first in Node, with the same message. Reads fields +/// straight off `Value` (via `as_str`/`as_i64`/`as_array`/`as_object`) +/// rather than a typed `Definition`, mirroring the way Node's `===`/regex +/// tests simply evaluate to `false` on a wrong-shaped value instead of +/// throwing a type error. +fn validate_definition_value( + raw: &Value, + sdk_inputs: &dyn Fn(&str) -> Option>, +) -> Result<()> { + // Rule 1: top-level field set is exact. `outputManaged` is required only + // for copy-v1, checked here against the raw "mode" string before + // anything else -- exactly Node's + // `if (definition?.mode === "copy-v1") definitionFields.push("outputManaged")`. + let mut definition_fields = vec![ + "schemaVersion", + "name", + "class", + "mode", + "sourceRepository", + "visibility", + "mappings", + "destination", + "destinationOwned", + "deletion", + "provenance", + "validation", + ]; + if raw.get("mode").and_then(Value::as_str) == Some("copy-v1") { + definition_fields.push("outputManaged"); + } + value_keys(raw, &definition_fields, "projection")?; + + let schema_version_ok = raw.get("schemaVersion").and_then(Value::as_i64) == Some(1); + let name = raw.get("name").and_then(Value::as_str).unwrap_or_default(); + let class = raw.get("class").and_then(Value::as_str).unwrap_or_default(); + let mode = raw.get("mode").and_then(Value::as_str).unwrap_or_default(); + let source_repository = raw + .get("sourceRepository") + .and_then(Value::as_str) + .unwrap_or_default(); + let visibility = raw + .get("visibility") + .and_then(Value::as_str) + .unwrap_or_default(); + let deletion = raw + .get("deletion") + .and_then(Value::as_str) + .unwrap_or_default(); + let provenance = raw + .get("provenance") + .and_then(Value::as_str) + .unwrap_or_default(); + let validation = raw + .get("validation") + .and_then(Value::as_str) + .unwrap_or_default(); + + // Rule 2 + invalid( + schema_version_ok && NAME.is_match(name), + "Unsupported projection identity/schema", + )?; + + // Rule 3 + invalid( + (class == "source-tree" && mode == "copy-v1") + || (class == "generated-sdk" && mode == "sdk-assembly-v1"), + "Unsupported projection class/mode", + )?; + + // Rule 4 + invalid( + REPO.is_match(source_repository) && matches!(visibility, "public" | "private" | "customer"), + "Invalid source/visibility", + )?; + + // Rule 5 (destination field set, then destination.repository) + let destination = raw.get("destination").cloned().unwrap_or_default(); + value_keys( + &destination, + &["repository", "branch", "syncBranch", "holdLabel"], + "destination", + )?; + let destination_repository = destination + .get("repository") + .and_then(Value::as_str) + .unwrap_or_default(); + invalid( + REPO.is_match(destination_repository) && destination_repository != source_repository, + "Invalid destination repository", + )?; + + // Rule 6 + for field in ["branch", "syncBranch"] { + let ok = destination + .get(field) + .and_then(Value::as_str) + .map(|value| REF.is_match(value) && !value.contains("//") && !value.ends_with('/')) + .unwrap_or(false); + invalid(ok, "Invalid destination ref")?; + } + + // Rule 7 + let branch = destination + .get("branch") + .and_then(Value::as_str) + .unwrap_or_default(); + let sync_branch = destination + .get("syncBranch") + .and_then(Value::as_str) + .unwrap_or_default(); + let hold_label = destination + .get("holdLabel") + .and_then(Value::as_str) + .unwrap_or_default(); + invalid( + branch != sync_branch && hold_label == "sync-hold", + "Publication must use a separate PR branch and sync-hold", + )?; + + // Rule 8 + let destination_owned_value = raw.get("destinationOwned").cloned().unwrap_or_default(); + let destination_owned = value_patterns(&destination_owned_value, "ownership", false)?; + safe_path(provenance, PathOpts::default())?; + let ownership = Matcher::new(&destination_owned)?; + invalid( + !ownership.matches(provenance), + "Provenance cannot be destination-owned", + )?; + + // Rule 9 + let mappings_value = raw.get("mappings").cloned().unwrap_or_default(); + let mappings_array = mappings_value.as_array(); + invalid( + mappings_array.is_some_and(|a| !a.is_empty()), + "Mappings are required", + )?; + let mappings_array = mappings_array.expect("checked by the invalid() call above"); + let mut mapping_fields = Vec::with_capacity(mappings_array.len()); + for mapping in mappings_array { + value_keys( + mapping, + &["source", "destination", "include", "exclude"], + "mapping", + )?; + let source = mapping + .get("source") + .and_then(Value::as_str) + .unwrap_or_default(); + let mapping_destination = mapping + .get("destination") + .and_then(Value::as_str) + .unwrap_or_default(); + safe_path( + source, + PathOpts { + pattern: false, + root: true, + }, + )?; + safe_path( + mapping_destination, + PathOpts { + pattern: false, + root: true, + }, + )?; + let include_value = mapping.get("include").cloned().unwrap_or_default(); + let include = value_patterns(&include_value, "includes", true)?; + let exclude_value = mapping.get("exclude").cloned().unwrap_or_default(); + let exclude = value_patterns(&exclude_value, "excludes", false)?; + invalid( + !include.iter().any(|item| item == "**"), + "Allowlist must name explicit files or subtrees", + )?; + if source == "." { + invalid( + include + .iter() + .all(|item| !has_wildcard(item) && !item.contains('/')), + "Root mappings must name exact root files", + )?; + } + mapping_fields.push(MappingFields { + source: source.to_string(), + destination: mapping_destination.to_string(), + include, + exclude, + }); + } + + if mode == "sdk-assembly-v1" { + // Rule 10 + let policy_inputs = sdk_inputs(name); + invalid( + policy_inputs.is_some() + && source_repository == "dx-corp/mono" + && destination_repository == format!("dx-corp/{name}") + && visibility == "public" + && validation == "sdk-standalone-v1" + && deletion == "owned-paths", + "Unknown SDK assembly policy", + )?; + let mut inputs = Vec::new(); + for mapping in &mapping_fields { + invalid( + mapping.destination == "." && mapping.exclude.is_empty(), + "SDK mapping cannot alter the registered assembly", + )?; + for path in &mapping.include { + safe_path(path, PathOpts::default())?; + inputs.push(format!("{}/{}", mapping.source, path)); + } + } + inputs.sort(); + let mut policy_inputs = policy_inputs.unwrap_or_default(); + policy_inputs.sort(); + invalid( + inputs == policy_inputs, + "SDK input allowlist differs from registered policy", + )?; + } else { + // Rule 11 (mode == "copy-v1"; rule 3 admits no other value here) + invalid( + deletion == "owned-paths" && validation == "tree-v1", + "Unsupported copy policy", + )?; + let output_managed_value = raw.get("outputManaged").cloned().unwrap_or_default(); + let output_managed = value_patterns(&output_managed_value, "managed outputs", true)?; + invalid( + output_managed + .iter() + .all(|path| !has_wildcard(path) || path.ends_with("/**")), + "Managed outputs must name explicit files or subtrees", + )?; + let managed = Matcher::new(&output_managed)?; + for mapping in &mapping_fields { + for include in &mapping.include { + invalid( + !has_wildcard(include) || include.ends_with("/**"), + "Copy includes must name exact files or subtrees", + )?; + let relative = include.strip_suffix("/**").unwrap_or(include); + let output = if mapping.destination == "." { + relative.to_string() + } else { + format!("{}/{}", mapping.destination, relative) + }; + let probe_ok = !include.ends_with("/**") + || managed.matches(&format!("{output}/__managed_probe__")); + invalid( + managed.matches(&output) && probe_ok, + format!("Copy output is outside its stable managed boundary: {output}"), + )?; + } + } + } + + Ok(()) +} + +/// Typed re-validation of an already-built `Definition`, for callers (later +/// tasks) that hold one rather than a raw `Value`. Re-serializes to a +/// `Value` and delegates to `validate_definition_value` rather than +/// duplicating the rule bodies, so the two entry points can never disagree +/// on a message: this is the smaller diff, at the cost of one JSON +/// round-trip per call. `output_managed`'s `skip_serializing_if` keeps that +/// round-trip faithful to the original shape for both modes (see the field's +/// doc comment). +pub fn validate_definition( + definition: &Definition, + sdk_inputs: &dyn Fn(&str) -> Option>, +) -> Result<()> { + let raw = serde_json::to_value(definition) + .map_err(|e| Error::Invalid(format!("Invalid projection JSON: {e}")))?; + validate_definition_value(&raw, sdk_inputs) +} + +/// `sha256_hex` of `text` reparsed and recompacted through `OrderedValue`, +/// matching Node's `sha256(JSON.stringify(JSON.parse(text)))`: object keys +/// stay in `text`'s order, not a re-serialization in a different field +/// order. Callers that hold a `LoadedDefinition` pass `&loaded.text`. +pub fn definition_digest(text: &str) -> Result { + Ok(sha256_hex(canonical_compact_json(text)?.as_bytes())) +} + +/// Ports `projectionInputRoots` from project.mjs (the `sdk-assembly-v1` and +/// `copy-v1` branches only; `maestro-public-tree-v1` is out of scope). +pub fn projection_input_roots( + definition: &Definition, + sdk_inputs: &dyn Fn(&str) -> Option>, +) -> Vec { + match definition.mode { + Mode::SdkAssemblyV1 => sdk_inputs(&definition.name).unwrap_or_default(), + Mode::CopyV1 => { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for mapping in &definition.mappings { + for pattern in &mapping.include { + let wildcard = pattern.find(['*', '?', '[', ']']); + let prefix = match wildcard { + None => pattern.clone(), + Some(idx) => { + let before = &pattern[..idx]; + match before.rfind('/') { + Some(slash) => before[..slash].to_string(), + None => String::new(), + } + } + }; + let root = if mapping.source == "." { + prefix + } else if !prefix.is_empty() { + format!("{}/{}", mapping.source, prefix) + } else { + mapping.source.clone() + }; + if seen.insert(root.clone()) { + out.push(root); + } + } + } + out + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/definitions") + .join(format!("{name}.json")) + } + + fn sdk_inputs(name: &str) -> Option> { + // Task 10 replaces this with the real policy table; the fixture's include list is used here. + (name == "deixic-python").then(|| { + let raw: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(fixture(name)).unwrap()).unwrap(); + let mut inputs: Vec = raw["mappings"] + .as_array() + .unwrap() + .iter() + .flat_map(|m| { + let source = m["source"].as_str().unwrap().to_owned(); + m["include"] + .as_array() + .unwrap() + .iter() + .map(move |i| format!("{source}/{}", i.as_str().unwrap())) + }) + .collect(); + inputs.sort(); + inputs + }) + } + + #[test] + #[cfg_attr(not(feature = "mono-fixtures"), ignore)] + fn approved_definitions_load_and_digest_like_node() { + let api = load_definition(&fixture("api"), &sdk_inputs).unwrap(); + assert_eq!(api.definition.name, "api"); + assert!(matches!(api.definition.mode, Mode::CopyV1)); + // Recorded from: node -e 'import {definitionDigest} from "./scripts/projections/project.mjs"; ...' + insta::assert_snapshot!(definition_digest(&api.text).unwrap()); + } + + #[test] + #[cfg_attr(not(feature = "mono-fixtures"), ignore)] + fn malformed_manifests_and_executable_configuration_are_rejected() { + let text = std::fs::read_to_string(fixture("api")).unwrap(); + let mut raw: serde_json::Value = serde_json::from_str(&text).unwrap(); + raw["command"] = serde_json::json!("rm -rf /"); + assert!(definition_from_value(raw.clone(), &sdk_inputs).is_err()); + let mut raw: serde_json::Value = serde_json::from_str(&text).unwrap(); + raw["destination"]["holdLabel"] = serde_json::json!("other"); + assert!(definition_from_value(raw, &sdk_inputs).is_err()); + let mut raw: serde_json::Value = serde_json::from_str(&text).unwrap(); + raw["mappings"][0]["include"] = serde_json::json!(["**"]); + assert!(definition_from_value(raw, &sdk_inputs).is_err()); + let mut raw: serde_json::Value = serde_json::from_str(&text).unwrap(); + raw["mode"] = serde_json::json!("maestro-public-tree-v1"); + assert!(definition_from_value(raw, &sdk_inputs).is_err()); + } + + #[test] + #[cfg_attr(not(feature = "mono-fixtures"), ignore)] + fn root_file_mappings_cannot_expand_into_the_whole_source_repository() { + let text = std::fs::read_to_string(fixture("api")).unwrap(); + let mut raw: serde_json::Value = serde_json::from_str(&text).unwrap(); + raw["mappings"][1]["include"] = serde_json::json!(["proto/**"]); + let err = definition_from_value(raw, &sdk_inputs) + .unwrap_err() + .to_string(); + assert_eq!(err, "Root mappings must name exact root files"); + } + + #[test] + #[cfg_attr(not(feature = "mono-fixtures"), ignore)] + fn copy_outputs_must_sit_inside_the_managed_boundary() { + let text = std::fs::read_to_string(fixture("api")).unwrap(); + let mut raw: serde_json::Value = serde_json::from_str(&text).unwrap(); + raw["outputManaged"] = serde_json::json!(["README.md"]); + let err = definition_from_value(raw, &sdk_inputs) + .unwrap_err() + .to_string(); + assert!(err.starts_with("Copy output is outside its stable managed boundary: ")); + } + + #[test] + #[cfg_attr(not(feature = "mono-fixtures"), ignore)] + fn input_roots_strip_wildcards_to_their_directory() { + let api = load_definition(&fixture("api"), &sdk_inputs).unwrap(); + let roots = projection_input_roots(&api.definition, &sdk_inputs); + assert!(roots.contains(&"distributions/api/README.md".to_string())); + assert!(roots.contains(&"LICENSE".to_string())); + } + + #[test] + #[cfg_attr(not(feature = "mono-fixtures"), ignore)] + fn field_set_violations_win_over_mode_violations() { + // A definition that is wrong in two ways at once (unsupported mode, + // and an unknown top-level key appended after `mode`) must report + // the field-set violation, because Node's `keys()` check for the + // top-level object runs before the class/mode check, regardless of + // where in the JSON the offending keys sit. + let text = std::fs::read_to_string(fixture("api")).unwrap(); + let mut raw: serde_json::Value = serde_json::from_str(&text).unwrap(); + raw["mode"] = serde_json::json!("maestro-public-tree-v1"); + raw["zzz_extra"] = serde_json::json!(true); + let err = definition_from_value(raw, &sdk_inputs) + .unwrap_err() + .to_string(); + assert_eq!(err, "Unknown or missing projection fields"); + } + + #[test] + #[cfg_attr(not(feature = "mono-fixtures"), ignore)] + fn nested_field_sets_use_their_own_labels() { + let text = std::fs::read_to_string(fixture("api")).unwrap(); + + let mut raw: serde_json::Value = serde_json::from_str(&text).unwrap(); + raw["destination"]["extra"] = serde_json::json!(1); + let err = definition_from_value(raw, &sdk_inputs) + .unwrap_err() + .to_string(); + assert_eq!(err, "Unknown or missing destination fields"); + + let mut raw: serde_json::Value = serde_json::from_str(&text).unwrap(); + raw["mappings"][0]["extra"] = serde_json::json!(1); + let err = definition_from_value(raw, &sdk_inputs) + .unwrap_err() + .to_string(); + assert_eq!(err, "Unknown or missing mapping fields"); + + let mut raw: serde_json::Value = serde_json::from_str(&text).unwrap(); + raw["destinationOwned"] = serde_json::json!("not-an-array"); + let err = definition_from_value(raw, &sdk_inputs) + .unwrap_err() + .to_string(); + assert_eq!(err, "Invalid ownership"); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..67d630b --- /dev/null +++ b/src/error.rs @@ -0,0 +1,44 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + /// A publication contract was violated. Exit 1. + #[error("{0}")] + Contract(String), + /// Input or repository state is invalid or unavailable. Exit 2. + #[error("{0}")] + Invalid(String), + /// The destination PR carries the sync-hold label. Exit 3. + #[error("sync-hold is set on the destination pull request")] + Held, + #[error("{0}")] + Io(#[from] std::io::Error), +} + +impl Error { + pub fn exit_code(&self) -> i32 { + match self { + Error::Contract(_) => 1, + Error::Invalid(_) | Error::Io(_) => 2, + Error::Held => 3, + } + } +} + +pub type Result = std::result::Result; + +pub fn contract(condition: bool, message: impl Into) -> Result<()> { + if condition { + Ok(()) + } else { + Err(Error::Contract(message.into())) + } +} + +pub fn invalid(condition: bool, message: impl Into) -> Result<()> { + if condition { + Ok(()) + } else { + Err(Error::Invalid(message.into())) + } +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..9aa2ed3 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,77 @@ +use std::path::Path; +use std::process::{Command, Stdio}; + +use crate::{Error, Result}; + +/// The reviewed process boundary for git: every other module reaches git +/// only through this function (or the helpers below that call it). +#[allow( + clippy::disallowed_methods, + reason = "git is the reviewed process boundary for capobara" +)] +fn run(root: &Path, args: &[&str]) -> Result { + Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .stdin(Stdio::null()) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .map_err(Error::Io) +} + +pub fn git_bytes(root: &Path, args: &[&str]) -> Result> { + let out = run(root, args)?; + if !out.status.success() { + return Err(Error::Invalid(format!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&out.stderr).trim() + ))); + } + Ok(out.stdout) +} + +pub fn git(root: &Path, args: &[&str]) -> Result { + String::from_utf8(git_bytes(root, args)?) + .map_err(|_| Error::Invalid(format!("git {} produced non-UTF-8 output", args.join(" ")))) +} + +pub fn git_ok(root: &Path, args: &[&str]) -> bool { + run(root, args).map(|o| o.status.success()).unwrap_or(false) +} + +pub fn is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> bool { + git_ok(root, &["merge-base", "--is-ancestor", ancestor, descendant]) +} + +pub fn is_sha(s: &str) -> bool { + s.len() == 40 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +pub fn is_digest(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +/// Accepts either a 40-hex git tree id or a 64-hex digest. The projector +/// implementation digest (`toolDigest`) becomes a git tree id (40 hex) once +/// a later task computes it from the tool's own source tree; Node's +/// receipts, and this crate's tests until then, use a 64-hex SHA-256 digest. +pub fn is_tree_id_or_digest(s: &str) -> bool { + (s.len() == 40 || s.len() == 64) && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_tree_id_or_digest_accepts_40_or_64_lowercase_hex_only() { + assert!(is_tree_id_or_digest(&"a".repeat(40))); + assert!(is_tree_id_or_digest(&"a".repeat(64))); + assert!(!is_tree_id_or_digest(&"a".repeat(41))); + assert!(!is_tree_id_or_digest(&"A".repeat(40))); + assert!(!is_tree_id_or_digest(&"g".repeat(40))); + assert!(!is_tree_id_or_digest("")); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ec0dbb8 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,16 @@ +pub mod build; +pub mod catalog; +pub mod cli; +pub mod definition; +pub mod error; +pub mod git; +pub mod modes; +pub mod ordered; +pub mod preflight; +pub mod receipt; +pub mod report; +pub mod snapshot; +pub mod tooldigest; +pub mod transport; +pub mod tree; +pub use error::{Error, Result, contract, invalid}; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d061c64 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,143 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +use capobara::cli::ProjectCliArgs; +use capobara::cli::catalog::CatalogCommand; +use capobara::cli::project::ProjectCommand; +use capobara::cli::run::RunArgs; +use capobara::cli::transport::{PrepareArgs, ReportArgs}; + +#[derive(Parser)] +#[command( + name = "capobara", + version, + about = "Publishes deterministic projections of Mono into standalone repositories." +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Validate the catalog or print the publication matrix. + Catalog { + /// The Mono checkout to read config/projections/*.json from and + /// run git against. Defaults to `git rev-parse --show-toplevel` + /// from the current directory. `global = true` so it parses both + /// before and after the `check`/`matrix` subcommand. + #[arg(long, global = true)] + root: Option, + #[command(subcommand)] + command: CatalogCommand, + }, + /// Report the plan without changing the destination. + Plan(ProjectCliArgs), + /// Apply the projection to the destination checkout. + Apply(ProjectCliArgs), + /// Apply and require the stored receipt to match. + Verify(ProjectCliArgs), + /// Report drift between source and destination. + Check(ProjectCliArgs), + /// Clone-side preparation of the destination branch. + Prepare(PrepareArgs), + /// Recheck a prepared projection before publication. + Preflight(ReportArgs), + /// Commit, push, and open or update the pull request. + Publish(ReportArgs), + /// Prepare, apply, verify, preflight, publish, and prove in one process. + #[command( + long_about = "Prepare, apply, verify, preflight, publish, and prove in one process.\n\n\ + Validation policies are applied by the workflow's validate steps until they are \ + ported; `run` performs no distribution validation." + )] + Run(RunArgs), +} + +// `plan`/`apply`/`verify`/`check` exit with the code `cli::project::run` +// returns on success, or print the error and exit 2 -- regardless of the +// `Error` variant. This differs from the other subcommands' `error.exit_code()` +// contract; see `cli::project::run`'s doc comment for why (Node's +// file-level `catch` sets exit code 2 unconditionally for this command +// family, including a `verify` provenance mismatch). +#[allow(clippy::exit)] +fn exit_project(result: capobara::Result) -> ! { + match result { + Ok(code) => std::process::exit(code), + Err(error) => { + eprintln!("{error}"); + std::process::exit(2); + } + } +} + +// `prepare`/`preflight`/`publish`/`run` return their own exit code (3 when +// a sync-hold stopped the work, else 0) and exit 1 on any error, +// regardless of the `Error` variant: both `scripts/projections/transport.mjs` +// and `scripts/projections/copybara-preflight.mjs` set +// `process.exitCode = 1` in their file-level `catch`, and set 3 only from +// the returned `result.held`. This differs from `exit_project` (always 2) +// and from `Error::exit_code()`. +#[allow(clippy::exit)] +fn exit_transport(result: capobara::Result) -> ! { + match result { + Ok(code) => std::process::exit(code), + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + } +} + +// `catalog check`/`catalog matrix` exit 1 on any error, regardless of the +// `Error` variant -- Node's file-level `catch` at the bottom of +// `catalog.mjs` unconditionally sets `process.exitCode = 1`. This differs +// from both `exit_transport` (always exits 1) and `exit_project` +// (always exits 2); see `cli::catalog::run`'s doc comment. +#[allow(clippy::exit)] +fn exit_catalog(result: capobara::Result<()>) -> ! { + match result { + Ok(()) => std::process::exit(0), + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + } +} + +fn main() { + let cli = Cli::parse(); + match cli.command { + Command::Plan(args) => { + exit_project(capobara::cli::project::run( + args.into_project_args(ProjectCommand::Plan), + )); + } + Command::Apply(args) => { + exit_project(capobara::cli::project::run( + args.into_project_args(ProjectCommand::Apply), + )); + } + Command::Verify(args) => { + exit_project(capobara::cli::project::run( + args.into_project_args(ProjectCommand::Verify), + )); + } + Command::Check(args) => { + exit_project(capobara::cli::project::run( + args.into_project_args(ProjectCommand::Check), + )); + } + Command::Catalog { root, command } => { + exit_catalog( + capobara::cli::catalog::resolve_root(root) + .and_then(|root| capobara::cli::catalog::run(&root, command)), + ); + } + Command::Prepare(args) => exit_transport(capobara::cli::transport::prepare(args)), + Command::Preflight(args) => exit_transport(capobara::cli::transport::preflight(args)), + Command::Publish(args) => exit_transport(capobara::cli::transport::publish(args)), + Command::Run(args) => exit_transport(capobara::cli::run::run(args)), + } +} diff --git a/src/modes/copy_v1.rs b/src/modes/copy_v1.rs new file mode 100644 index 0000000..e9e13a3 --- /dev/null +++ b/src/modes/copy_v1.rs @@ -0,0 +1,162 @@ +//! The `copy-v1` mode: literal file mappings from a source tree onto a +//! managed subtree of the destination. Ports the `copy-v1` branch of +//! `buildProjection` and `mayContainIncluded` from +//! `scripts/projections/project.mjs`. + +use std::path::Path; + +use crate::definition::Definition; +use crate::tree::{Entries, Matcher, contained_path, files_under, read_entry}; +use crate::{Result, contract}; + +/// True when `path` names a file or directory that could contain (or itself +/// be) something matched by one of `patterns`. Used to prune `files_under`'s +/// walk of a mapping's source directory: a directory that fails this check +/// cannot hold any included file, so it is never descended into. +/// +/// A pattern matches directly when it matches `path` as a glob, or when the +/// pattern names something nested under `path` (`pattern` starts with +/// `"{path}/"`, covering literal include patterns like `"README.md"` while +/// `path` is still `""`-rooted ancestry such as `"."`... in practice, a +/// shorter directory prefix of a literal file pattern). +/// +/// When `pattern` contains a wildcard, `prefix` is the fixed text before the +/// first wildcard character with its trailing partial path segment removed +/// (the slash before that segment is kept): `"src/**"` yields `"src/"`, +/// `"*.md"` yields `""`. `path` may then be an ancestor of the pattern +/// (`path` starts with `prefix`) or a descendant of it (`prefix` starts with +/// `"{path}/"`). +pub fn may_contain_included(path: &str, patterns: &[String]) -> bool { + patterns.iter().any(|pattern| { + let single = std::slice::from_ref(pattern); + if Matcher::new(single) + .map(|m| m.matches(path)) + .unwrap_or(false) + { + return true; + } + if pattern.starts_with(&format!("{path}/")) { + return true; + } + let Some(wildcard) = pattern.find(['*', '?', '[', ']']) else { + return false; + }; + let before = &pattern[..wildcard]; + let prefix = match before.rfind('/') { + Some(slash) => &before[..=slash], + None => "", + }; + path.starts_with(prefix) || prefix.starts_with(&format!("{path}/")) + }) +} + +/// Collects the entries and candidate deletions for a `copy-v1` definition. +/// `owned` is `Matcher::new(&definition.destination_owned)`, built once by +/// the caller (`build::build_projection`) since it is needed for both the +/// mode-specific collection here and the shared build tail. +/// +/// For each mapping, candidate source paths are the mapping's own `include` +/// list verbatim when `mapping.source` is `"."` (a root mapping, restricted +/// by definition validation to exact root files), otherwise every file under +/// the mapping's source directory that is not excluded and might be +/// included (`may_contain_included`). Each candidate that is actually +/// included and not excluded is copied to `path` (or +/// `"{destination}/{path}"`); a target claimed by an earlier mapping is a +/// `Contract` error, and so is a target outside the definition's managed +/// output boundary. +/// +/// Deletion candidates are every non-owned file already under `target_root` +/// that falls inside the managed boundary; `build::build_projection` +/// narrows this further (dropping paths that are about to be (re)written) +/// and checks the rest against ownership again before planning. +pub fn collect( + definition: &Definition, + source_root: &Path, + target_root: &Path, + owned: &Matcher, +) -> Result<(Entries, Vec)> { + let output_managed = definition + .output_managed + .as_ref() + .expect("copy-v1 definitions always carry outputManaged"); + let managed = Matcher::new(output_managed)?; + + let mut entries: Entries = Entries::new(); + for mapping in &definition.mappings { + let source_dir = if mapping.source == "." { + source_root.to_path_buf() + } else { + contained_path(source_root, &mapping.source)? + }; + let included = Matcher::new(&mapping.include)?; + let excluded = Matcher::new(&mapping.exclude)?; + let candidates: Vec = if mapping.source == "." { + mapping.include.clone() + } else { + let include = &mapping.include; + files_under(&source_dir, &|path: &str| { + excluded.matches(path) || !may_contain_included(path, include) + })? + }; + for path in candidates { + if !included.matches(&path) || excluded.matches(&path) { + continue; + } + let target = if mapping.destination == "." { + path.clone() + } else { + format!("{}/{}", mapping.destination, path) + }; + contract( + !entries.contains_key(&target), + format!("Overlapping mappings: {target}"), + )?; + contract( + managed.matches(&target), + format!("Copy output outside managed boundary: {target}"), + )?; + entries.insert(target, read_entry(&source_dir, &path)?); + } + } + + let deletions: Vec = files_under(target_root, &|path: &str| owned.matches(path))? + .into_iter() + .filter(|path| managed.matches(path)) + .collect(); + + Ok((entries, deletions)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn direct_glob_and_ancestor_matches_win_without_a_wildcard_prefix() { + let patterns = vec!["README.md".to_string()]; + assert!(may_contain_included("README.md", &patterns)); + // "README.md" has no wildcard, so only the direct-match and + // ancestor-of-pattern branches can fire; a sibling never matches. + assert!(!may_contain_included("OTHER.md", &patterns)); + } + + #[test] + fn wildcard_prefix_matches_ancestors_and_descendants_of_the_fixed_text() { + let patterns = vec!["src/**".to_string()]; + // "src" is an ancestor directory of the pattern's fixed prefix "src/". + assert!(may_contain_included("src", &patterns)); + // "src/sub" also qualifies (path starts with the prefix). + assert!(may_contain_included("src/sub", &patterns)); + assert!(!may_contain_included("docs", &patterns)); + } + + #[test] + fn wildcard_with_no_fixed_directory_prefix_matches_every_path() { + // "*.md" gives an empty prefix, and every path starts with "", so + // this conservatively admits any directory as a possible ancestor of + // a matching file -- matching Node's `path.startsWith(prefix)` with + // prefix = "". + let patterns = vec!["*.md".to_string()]; + assert!(may_contain_included("anything/at/all", &patterns)); + } +} diff --git a/src/modes/mod.rs b/src/modes/mod.rs new file mode 100644 index 0000000..8c4d754 --- /dev/null +++ b/src/modes/mod.rs @@ -0,0 +1,19 @@ +//! Projection modes. Each mode collects the entries and deletions that +//! `build::build_projection` folds into a receipt and a `Plan`. + +pub mod copy_v1; +pub mod sdk_assembly; + +use crate::tree::Entries; + +/// Entries and their declared output boundaries, as produced by an +/// `sdk-assembly-v1` assembler. Task 10 supplies the real assembler (a port +/// of `assembleSdkProjection`); `build::build_projection`'s `assemble` +/// parameter returns this for now so the `sdk-assembly-v1` branch of the +/// shared build tail has a concrete type to work with. +#[derive(Debug)] +pub struct Assembled { + pub entries: Entries, + pub output_include: Vec, + pub output_managed: Vec, +} diff --git a/src/modes/sdk_assembly.rs b/src/modes/sdk_assembly.rs new file mode 100644 index 0000000..1ce87bc --- /dev/null +++ b/src/modes/sdk_assembly.rs @@ -0,0 +1,388 @@ +//! `sdk-assembly-v1`: deterministic assembly of a standalone SDK repository +//! from an immutable Mono snapshot. Ports `assembleSdkProjection` and its +//! transforms and import-closure validators from +//! `scripts/projections/sdk-assembly.mjs` (Node, the source of record). +//! +//! The three reviewed policies (`deixic-python`, `deixic-node`, `deixic-go`) +//! live in `policies`; this module owns applying them: reading each copy's +//! source bytes from the snapshot, transforming destination-identity content +//! (`apply_transform`), checking that every generated source's import graph +//! stays inside the reviewed closure (`validate_closure`), and checking that +//! the actual set of inputs read and outputs produced matches the policy's +//! registered, sorted lists exactly (`require_same_set`) before handing back +//! an `Assembled`. + +pub mod policies; + +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::LazyLock; + +use regex::{Captures, Regex}; + +pub use policies::{Closure, Copy, Policy, Transform, policy}; + +use super::Assembled; +use crate::tree::{Entries, Entry, read_entry}; +use crate::{Error, Result, contract}; + +/// The `sdk_inputs` closure `definition::load_definition`, +/// `definition::validate_definition`, and `definition::projection_input_roots` +/// take: the sorted, reviewed input paths for a named SDK assembly policy, or +/// `None` for an unknown name. +pub fn input_roots(name: &str) -> Option> { + policy(name).map(|p| p.input_roots.clone()) +} + +/// Assembles the named policy's output tree from `snapshot`. Fails closed +/// (`Error::Contract`) on a duplicate input/output, a missing snapshot input, +/// a transform whose precondition no longer holds, an import that escapes +/// the reviewed generated-dependency closure, or an actual input/output set +/// that has drifted from the policy's registered, reviewed lists. +pub fn assemble(snapshot: &Path, name: &str) -> Result { + let selected = policy(name) + .ok_or_else(|| Error::Invalid(format!("Unknown SDK assembly policy: {name}")))?; + + let mut inputs: Vec = Vec::with_capacity(selected.copies.len()); + let mut source_entries: HashMap = HashMap::with_capacity(selected.copies.len()); + let mut entries: Entries = Entries::new(); + + for mapping in &selected.copies { + contract( + !source_entries.contains_key(&mapping.source), + format!("Duplicate SDK input: {}", mapping.source), + )?; + contract( + !entries.contains_key(&mapping.output), + format!("Duplicate SDK output: {}", mapping.output), + )?; + let entry = read_entry(snapshot, &mapping.source)?; + let content = apply_transform(mapping.transform, &entry.content)?; + let mode = entry.mode; + entries.insert(mapping.output.clone(), Entry { content, mode }); + inputs.push(mapping.source.clone()); + source_entries.insert(mapping.source.clone(), entry); + } + + validate_closure(selected, &source_entries)?; + require_same_set( + &inputs, + &selected.input_roots, + &format!("{} input roots", selected.name), + )?; + let outputs: Vec = entries.keys().cloned().collect(); + require_same_set( + &outputs, + &selected.output_include, + &format!("{} output allowlist", selected.name), + )?; + + Ok(Assembled { + entries, + output_include: selected.output_include.clone(), + output_managed: selected.output_managed.clone(), + }) +} + +fn apply_transform(transform: Option, content: &[u8]) -> Result> { + let Some(transform) = transform else { + return Ok(content.to_vec()); + }; + let text = String::from_utf8_lossy(content).into_owned(); + match transform { + Transform::PythonPyproject => { + static DEPENDENCY: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?m)^ "evalops-sdk[^"]+",\n"#).expect("static regex is valid") + }); + contract( + DEPENDENCY.find_iter(&text).count() == 1, + "Python generated dependency declaration changed", + )?; + let without_dependency = DEPENDENCY.replace(&text, ""); + Ok(without_dependency + .replace( + "https://github.com/dx-corp/mono", + "https://github.com/dx-corp/deixic-python", + ) + .into_bytes()) + } + Transform::NodePackage => { + contract( + text.contains("https://github.com/dx-corp/mono"), + "Node repository metadata changed", + )?; + Ok(text + .replace( + "https://github.com/dx-corp/mono", + "https://github.com/dx-corp/deixic-node", + ) + .into_bytes()) + } + Transform::GoModule => { + const HEADER: &str = "module github.com/evalops/platform/gen/go\n"; + contract( + text.starts_with(HEADER), + "Go source module identity changed", + )?; + Ok(text + .replacen(HEADER, "module github.com/dx-corp/deixic-go\n", 1) + .into_bytes()) + } + Transform::GoSource => Ok(transform_go_imports(&text).into_bytes()), + } +} + +fn transform_go_imports(text: &str) -> String { + static IMPORT_BLOCK: LazyLock = LazyLock::new(|| { + Regex::new(r"(?m)^import \(\n((?s:.)*?)^\)\n").expect("static regex is valid") + }); + IMPORT_BLOCK + .replacen(text, 1, |caps: &Captures<'_>| { + let imports = caps[1].replace( + "github.com/evalops/platform/gen/go", + "github.com/dx-corp/deixic-go", + ); + format!("import (\n{imports})\n") + }) + .into_owned() +} + +fn validate_closure(policy: &Policy, source_entries: &HashMap) -> Result<()> { + match policy.closure { + Closure::PythonGeneratedImportsV1 => validate_python_closure(source_entries), + Closure::TypescriptCompiledImportsV1 => validate_typescript_closure(source_entries), + Closure::GoPackageImportsV1 => validate_go_closure(source_entries), + } +} + +fn validate_python_closure(source_entries: &HashMap) -> Result<()> { + static IMPORT: LazyLock = LazyLock::new(|| { + Regex::new(r"(?m)^from ([A-Za-z0-9_.]+) import ([A-Za-z0-9_]+_pb2)\b") + .expect("static regex is valid") + }); + const PREFIX: &str = "gen/python/"; + let allowed: HashSet = policies::PYTHON_GENERATED_FILES + .iter() + .map(|path| format!("{PREFIX}{path}")) + .collect(); + let mut visited: HashSet = HashSet::new(); + let mut queue: Vec = vec![format!("{PREFIX}console/v1/console_pb2.py")]; + while let Some(path) = queue.pop() { + if visited.contains(&path) { + continue; + } + contract( + allowed.contains(&path), + format!("Python generated import escapes reviewed closure: {path}"), + )?; + visited.insert(path.clone()); + let text = source_text(source_entries, &path)?; + for caps in IMPORT.captures_iter(&text) { + let package_name = &caps[1]; + let module_name = &caps[2]; + if package_name.starts_with("google.protobuf") { + continue; + } + let imported = format!( + "{PREFIX}{}/{module_name}.py", + package_name.replace('.', "/") + ); + contract( + allowed.contains(&imported), + format!("Python generated import escapes reviewed closure: {imported}"), + )?; + queue.push(imported); + } + } + require_same_set( + &visited.into_iter().collect::>(), + &allowed.into_iter().collect::>(), + "Python generated dependency closure", + ) +} + +fn validate_typescript_closure(source_entries: &HashMap) -> Result<()> { + static IMPORT: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?m)(?:from\s+|import\s*\()["']([^"']+)["']"#).expect("static regex is valid") + }); + let build_roots = [ + "sdk/deixic/typescript/src/index.ts".to_string(), + "sdk/deixic/typescript/src/tasks.ts".to_string(), + ]; + let mut allowed: HashSet = build_roots.iter().cloned().collect(); + allowed.extend( + policies::NODE_SHARED_FILES + .iter() + .filter(|path| path.ends_with(".ts")) + .map(|path| (*path).to_string()), + ); + allowed.extend( + policies::TYPESCRIPT_GENERATED_FILES + .iter() + .map(|path| format!("gen/ts/{path}")), + ); + let mut visited: HashSet = HashSet::new(); + let mut queue: Vec = build_roots.to_vec(); + while let Some(path) = queue.pop() { + if visited.contains(&path) { + continue; + } + contract( + allowed.contains(&path), + format!("TypeScript import escapes reviewed closure: {path}"), + )?; + visited.insert(path.clone()); + let text = source_text(source_entries, &path)?; + for caps in IMPORT.captures_iter(&text) { + let specifier = &caps[1]; + if !specifier.starts_with('.') { + continue; + } + let resolved_specifier = match specifier.strip_suffix(".js") { + Some(stem) => format!("{stem}.ts"), + None => specifier.to_string(), + }; + let imported = posix_normalize(&posix_join(&posix_dirname(&path), &resolved_specifier)); + contract( + !imported.starts_with("../"), + format!("TypeScript import escapes projection: {path}"), + )?; + contract( + allowed.contains(&imported), + format!("TypeScript import escapes reviewed closure: {imported}"), + )?; + queue.push(imported); + } + } + require_same_set( + &visited.into_iter().collect::>(), + &allowed.into_iter().collect::>(), + "TypeScript compiled dependency closure", + ) +} + +fn validate_go_closure(source_entries: &HashMap) -> Result<()> { + static IMPORT: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?m)"(github\.com/evalops/platform/gen/go/[^"\s]+)""#) + .expect("static regex is valid") + }); + const PREFIX: &str = "gen/go/"; + const MODULE_PREFIX: &str = "github.com/evalops/platform/gen/go/"; + let allowed: HashSet = policies::GO_GENERATED_FILES + .iter() + .map(|path| format!("{PREFIX}{path}")) + .collect(); + let mut files_by_package: HashMap> = HashMap::new(); + for path in &allowed { + let package_path = posix_dirname(&path[PREFIX.len()..]); + files_by_package + .entry(package_path) + .or_default() + .push(path.clone()); + } + let mut visited_files: HashSet = HashSet::new(); + let mut visited_packages: HashSet = HashSet::new(); + let mut queue: Vec = vec![ + "deixic/v1".to_string(), + "deixic/v1/deixicv1connect".to_string(), + ]; + while let Some(package_path) = queue.pop() { + if visited_packages.contains(&package_path) { + continue; + } + let files = files_by_package.get(&package_path); + contract( + files.is_some_and(|f| !f.is_empty()), + format!("Go import escapes reviewed closure: {package_path}"), + )?; + visited_packages.insert(package_path.clone()); + for path in files.expect("checked by the contract() call above") { + visited_files.insert(path.clone()); + let text = source_text(source_entries, path)?; + for caps in IMPORT.captures_iter(&text) { + let imported = &caps[1][MODULE_PREFIX.len()..]; + contract( + files_by_package.contains_key(imported), + format!("Go import escapes reviewed closure: {imported}"), + )?; + queue.push(imported.to_string()); + } + } + } + require_same_set( + &visited_files.into_iter().collect::>(), + &allowed.into_iter().collect::>(), + "Go generated dependency closure", + ) +} + +fn source_text(source_entries: &HashMap, path: &str) -> Result { + let entry = source_entries + .get(path) + .ok_or_else(|| Error::Contract(format!("Missing SDK assembly input: {path}")))?; + Ok(String::from_utf8_lossy(&entry.content).into_owned()) +} + +/// Minimal POSIX path helpers matching Node's `node:path/posix` behavior for +/// the forward-slash, extension-swapped specifiers the TypeScript closure +/// resolves (`posix.dirname`, `posix.join`, `posix.normalize`). +fn posix_dirname(path: &str) -> String { + match path.rfind('/') { + Some(idx) => path[..idx].to_string(), + None => ".".to_string(), + } +} + +fn posix_join(a: &str, b: &str) -> String { + if a.is_empty() || a == "." { + b.to_string() + } else if b.is_empty() { + a.to_string() + } else { + format!("{a}/{b}") + } +} + +fn posix_normalize(path: &str) -> String { + let is_absolute = path.starts_with('/'); + let mut out: Vec<&str> = Vec::new(); + for part in path.split('/') { + match part { + "" | "." => continue, + ".." => match out.last() { + Some(&last) if last != ".." => { + out.pop(); + } + _ => { + if !is_absolute { + out.push(".."); + } + } + }, + other => out.push(other), + } + } + let joined = out.join("/"); + if is_absolute { + format!("/{joined}") + } else if joined.is_empty() { + ".".to_string() + } else { + joined + } +} + +fn require_same_set(actual: &[String], expected: &[String], label: &str) -> Result<()> { + let mut actual_sorted = actual.to_vec(); + actual_sorted.sort(); + let mut expected_sorted = expected.to_vec(); + expected_sorted.sort(); + contract( + actual_sorted == expected_sorted, + format!( + "{label} changed: expected {}, received {}", + serde_json::to_string(&expected_sorted).unwrap_or_default(), + serde_json::to_string(&actual_sorted).unwrap_or_default(), + ), + ) +} diff --git a/src/modes/sdk_assembly/policies.rs b/src/modes/sdk_assembly/policies.rs new file mode 100644 index 0000000..2079de9 --- /dev/null +++ b/src/modes/sdk_assembly/policies.rs @@ -0,0 +1,359 @@ +//! The three reviewed SDK assembly policies (python, node, go), transcribed +//! verbatim (file lists and copy shapes) from +//! `scripts/projections/sdk-assembly.mjs` in Mono. `super` (`sdk_assembly.rs`) +//! owns `assemble`, the transforms, and the closure validators; this module +//! only owns the reviewed, immutable shape of each policy. + +use std::sync::LazyLock; + +// The reviewed Deixic Python SDK's own files, copied verbatim under their +// own names (mirrors `PYTHON_SDK_FILES` in sdk-assembly.mjs). +pub const PYTHON_SDK_FILES: &[&str] = &[ + "CHANGELOG.md", + "LICENSE", + "README.md", + "pyproject.toml", + "src/deixic/__init__.py", + "src/deixic/auth.py", + "src/deixic/client.py", + "src/deixic/errors.py", + "src/deixic/examples/__init__.py", + "src/deixic/examples/account_brief.py", + "src/deixic/examples/account_brief_result.py", + "src/deixic/examples/task_result.py", + "src/deixic/examples/verify_test_journey.py", + "src/deixic/py.typed", + "src/deixic/tasks.py", + "src/deixic/transport.py", + "tests/test_account_brief.py", + "tests/test_account_brief_application.py", + "tests/test_client.py", + "tests/test_http_journey.py", + "tests/test_real_journey.py", + "tests/test_recovery.py", + "tests/test_tasks.py", + "tests/test_test_journey.py", +]; + +// Generated protobuf/gRPC Python modules under `gen/python/`, copied into +// `src/` (mirrors `PYTHON_GENERATED_FILES`). +pub const PYTHON_GENERATED_FILES: &[&str] = &[ + "agentruntime/v1/runtime_pb2.py", + "agents/v1/agents_pb2.py", + "buf/validate/validate_pb2.py", + "codex/v1/codex_pb2.py", + "common/v1/analytics_pb2.py", + "common/v1/authz_pb2.py", + "common/v1/classification_pb2.py", + "common/v1/delivery_pb2.py", + "common/v1/entity_pb2.py", + "common/v1/risk_pb2.py", + "common/v1/surface_pb2.py", + "connectors/v1/connectors_pb2.py", + "console/v1/console_pb2.py", + "evalops_platform/v1/platform_pb2.py", + "google/api/annotations_pb2.py", + "google/api/http_pb2.py", + "memory/v1/memory_pb2.py", + "meter/v1/meter_pb2.py", + "objectives/v1/objectives_pb2.py", + "orbcontrol/v1/orb_control_pb2.py", + "remoterunner/v1/remoterunner_pb2.py", + "toolexecution/v1/toolexecution_pb2.py", + "traces/v1/traces_pb2.py", + "vfs/v1/filesystem_pb2.py", +]; + +// The reviewed Deixic Node package's own files (mirrors `NODE_PACKAGE_FILES`). +pub const NODE_PACKAGE_FILES: &[&str] = &[ + "CHANGELOG.md", + "LICENSE", + "README.md", + "examples/account-brief-result.d.mts", + "examples/account-brief-result.mjs", + "examples/account-brief.mjs", + "package-lock.json", + "package.json", + "scripts/check-package-exports.mjs", + "scripts/smoke-packed-package.mjs", + "src/index.ts", + "src/tasks.ts", + "test/account-brief-result.test.mjs", + "test/account-brief.test.mjs", + "test/client.test.mjs", + "test/tasks.test.mjs", + "tsconfig.json", +]; + +// index.ts is intentionally absent: the Deixic package imports this smaller +// reviewed helper closure directly instead of projecting the Maestro SDK. +pub const NODE_SHARED_FILES: &[&str] = &[ + "sdk/maestro/typescript/scripts/verify-descriptor-sources.mjs", + "sdk/maestro/typescript/src/accepted-turn.ts", + "sdk/maestro/typescript/src/app-context.ts", + "sdk/maestro/typescript/src/client.ts", + "sdk/maestro/typescript/src/errors.ts", +]; + +// Generated protobuf/gRPC TypeScript modules under `gen/ts/`, copied into +// place under the same relative path (mirrors `TYPESCRIPT_GENERATED_FILES`). +pub const TYPESCRIPT_GENERATED_FILES: &[&str] = &[ + "agentruntime/v1/runtime_pb.ts", + "agents/v1/agents_pb.ts", + "buf/validate/validate_pb.ts", + "codex/v1/codex_pb.ts", + "common/v1/analytics_pb.ts", + "common/v1/authz_pb.ts", + "common/v1/classification_pb.ts", + "common/v1/delivery_pb.ts", + "common/v1/entity_pb.ts", + "common/v1/risk_pb.ts", + "common/v1/surface_pb.ts", + "connectors/v1/connectors_pb.ts", + "console/v1/console_pb.ts", + "deixic/v1/deixic_pb.ts", + "google/api/annotations_pb.ts", + "google/api/http_pb.ts", + "memory/v1/memory_pb.ts", + "meter/v1/meter_pb.ts", + "objectives/v1/objectives_pb.ts", + "orbcontrol/v1/orb_control_pb.ts", + "platform/v1/platform_pb.ts", + "remoterunner/v1/remoterunner_pb.ts", + "toolexecution/v1/toolexecution_pb.ts", + "traces/v1/traces_pb.ts", + "vfs/v1/filesystem_pb.ts", +]; + +// Generated protobuf/gRPC Go modules under `gen/go/`, copied into place under +// the same relative path (mirrors `GO_GENERATED_FILES`). +pub const GO_GENERATED_FILES: &[&str] = &[ + "agentruntime/v1/runtime.pb.go", + "agents/v1/agents.pb.go", + "codex/v1/codex.pb.go", + "common/v1/analytics.pb.go", + "common/v1/authz.pb.go", + "common/v1/classification.pb.go", + "common/v1/delivery.pb.go", + "common/v1/entity.pb.go", + "common/v1/risk.pb.go", + "common/v1/surface.pb.go", + "connectors/v1/connectors.pb.go", + "console/v1/console.pb.go", + "deixic/v1/deixic.pb.go", + "deixic/v1/deixicv1connect/deixic.connect.go", + "memory/v1/memory.pb.go", + "meter/v1/meter.pb.go", + "objectives/v1/objectives.pb.go", + "orbcontrol/v1/orb_control.pb.go", + "platform/v1/platform.pb.go", + "remoterunner/v1/remoterunner.pb.go", + "toolexecution/v1/toolexecution.pb.go", + "traces/v1/traces.pb.go", + "vfs/v1/filesystem.pb.go", +]; + +/// A byte-for-byte transform applied to one copy's content, keyed by the +/// destination identity it must rewrite. See `sdk_assembly::apply_transform`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Transform { + PythonPyproject, + NodePackage, + GoModule, + GoSource, +} + +/// The reviewed import-closure check a policy's generated sources must +/// satisfy. See `sdk_assembly::validate_closure`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Closure { + PythonGeneratedImportsV1, + TypescriptCompiledImportsV1, + GoPackageImportsV1, +} + +/// One reviewed input -> output mapping within a policy (mirrors the `copy()` +/// helper in sdk-assembly.mjs). +#[derive(Debug, Clone)] +pub struct Copy { + pub source: String, + pub output: String, + pub transform: Option, +} + +/// A named, reviewed SDK assembly policy: the exact set of source files it +/// reads, the exact set of output files it produces, the destination +/// repository's managed boundary, and the import-closure check its generated +/// sources must pass. Built once by `policy` and never mutated afterward. +#[derive(Debug)] +pub struct Policy { + pub name: &'static str, + pub input_roots: Vec, + pub output_include: Vec, + pub output_managed: Vec, + pub closure: Closure, + pub copies: Vec, +} + +fn copy( + source: impl Into, + output: impl Into, + transform: Option, +) -> Copy { + Copy { + source: source.into(), + output: output.into(), + transform, + } +} + +fn python_copies() -> Vec { + let mut copies: Vec = PYTHON_SDK_FILES + .iter() + .map(|path| { + copy( + format!("sdk/deixic/python/{path}"), + (*path).to_string(), + (*path == "pyproject.toml").then_some(Transform::PythonPyproject), + ) + }) + .collect(); + copies.extend( + PYTHON_GENERATED_FILES + .iter() + .map(|path| copy(format!("gen/python/{path}"), format!("src/{path}"), None)), + ); + copies +} + +fn node_copies() -> Vec { + let mut copies: Vec = NODE_PACKAGE_FILES + .iter() + .map(|path| { + let full = format!("sdk/deixic/typescript/{path}"); + copy( + full.clone(), + full, + (*path == "package.json").then_some(Transform::NodePackage), + ) + }) + .collect(); + copies.extend( + NODE_SHARED_FILES + .iter() + .map(|path| copy((*path).to_string(), (*path).to_string(), None)), + ); + copies.extend(TYPESCRIPT_GENERATED_FILES.iter().map(|path| { + let full = format!("gen/ts/{path}"); + copy(full.clone(), full, None) + })); + copies +} + +fn go_copies() -> Vec { + let mut copies = vec![ + copy("sdk/deixic/go/README.md", "README.md", None), + copy("sdk/deixic/python/LICENSE", "LICENSE", None), + copy( + "sdk/deixic/go/deixic_connect_test.go.in", + "deixic/v1/deixicv1connect/projection_test.go", + None, + ), + copy("gen/go/CHANGELOG.md", "CHANGELOG.md", None), + copy("gen/go/go.mod", "go.mod", Some(Transform::GoModule)), + copy("gen/go/go.sum", "go.sum", None), + ]; + copies.extend(GO_GENERATED_FILES.iter().map(|path| { + copy( + format!("gen/go/{path}"), + (*path).to_string(), + Some(Transform::GoSource), + ) + })); + copies +} + +fn make_policy( + name: &'static str, + copies: Vec, + closure: Closure, + output_managed: &[&str], +) -> Policy { + let mut input_roots: Vec = copies.iter().map(|c| c.source.clone()).collect(); + input_roots.sort(); + let mut output_include: Vec = copies.iter().map(|c| c.output.clone()).collect(); + output_include.sort(); + let mut output_managed: Vec = output_managed.iter().map(|s| (*s).to_string()).collect(); + output_managed.sort(); + Policy { + name, + input_roots, + output_include, + output_managed, + closure, + copies, + } +} + +static POLICIES: LazyLock> = LazyLock::new(|| { + vec![ + make_policy( + "deixic-python", + python_copies(), + Closure::PythonGeneratedImportsV1, + &[ + "CHANGELOG.md", + "LICENSE", + "README.md", + "pyproject.toml", + "src/**", + "tests/**", + ], + ), + make_policy( + "deixic-node", + node_copies(), + Closure::TypescriptCompiledImportsV1, + &[ + "gen/ts/**", + "sdk/deixic/typescript/**", + "sdk/maestro/typescript/scripts/verify-descriptor-sources.mjs", + "sdk/maestro/typescript/src/**", + ], + ), + make_policy( + "deixic-go", + go_copies(), + Closure::GoPackageImportsV1, + &[ + "CHANGELOG.md", + "LICENSE", + "README.md", + "agentruntime/**", + "agents/**", + "codex/**", + "common/**", + "connectors/**", + "console/**", + "deixic/**", + "go.mod", + "go.sum", + "memory/**", + "meter/**", + "objectives/**", + "orbcontrol/**", + "platform/**", + "remoterunner/**", + "toolexecution/**", + "traces/**", + "vfs/**", + ], + ), + ] +}); + +/// Looks up one of the three reviewed, immutable SDK assembly policies by +/// name. `None` for any other name. +pub fn policy(name: &str) -> Option<&'static Policy> { + POLICIES.iter().find(|p| p.name == name) +} diff --git a/src/ordered.rs b/src/ordered.rs new file mode 100644 index 0000000..fcfea20 --- /dev/null +++ b/src/ordered.rs @@ -0,0 +1,196 @@ +//! A crate-local, order-preserving JSON value. +//! +//! `serde_json::Value`'s object representation is sorted (`BTreeMap`) +//! unless the crate enables the `preserve_order` feature -- but Cargo +//! unifies features across the whole workspace, so enabling it here would +//! switch every crate's `serde_json::Value` to an insertion-ordered map. +//! Capobara needs insertion order for exactly one thing: `definition_digest` +//! must equal Node's `sha256(JSON.stringify(JSON.parse(text)))`, where key +//! order is the file's order. `OrderedValue` provides that, with hand-written +//! `Deserialize`/`Serialize` impls, at no cost to the rest of the workspace. + +use serde::de::{self, MapAccess, SeqAccess, Visitor}; +use serde::ser::{SerializeMap, SerializeSeq}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Number; + +use crate::{Error, Result}; + +/// A JSON value whose object keys keep the order the deserializer yielded +/// them in. A duplicate key replaces the value stored at the position of +/// its *first* occurrence, matching `JSON.parse`. +#[derive(Debug, Clone, PartialEq)] +pub enum OrderedValue { + Null, + Bool(bool), + Number(Number), + String(String), + Array(Vec), + Object(Vec<(String, OrderedValue)>), +} + +impl<'de> Deserialize<'de> for OrderedValue { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(OrderedValueVisitor) + } +} + +struct OrderedValueVisitor; + +impl<'de> Visitor<'de> for OrderedValueVisitor { + type Value = OrderedValue; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a valid JSON value") + } + + fn visit_unit(self) -> std::result::Result + where + E: de::Error, + { + Ok(OrderedValue::Null) + } + + fn visit_bool(self, value: bool) -> std::result::Result + where + E: de::Error, + { + Ok(OrderedValue::Bool(value)) + } + + fn visit_i64(self, value: i64) -> std::result::Result + where + E: de::Error, + { + Ok(OrderedValue::Number(Number::from(value))) + } + + fn visit_u64(self, value: u64) -> std::result::Result + where + E: de::Error, + { + Ok(OrderedValue::Number(Number::from(value))) + } + + fn visit_f64(self, value: f64) -> std::result::Result + where + E: de::Error, + { + Number::from_f64(value) + .map(OrderedValue::Number) + .ok_or_else(|| de::Error::custom("invalid floating point number")) + } + + fn visit_str(self, value: &str) -> std::result::Result + where + E: de::Error, + { + Ok(OrderedValue::String(value.to_string())) + } + + fn visit_string(self, value: String) -> std::result::Result + where + E: de::Error, + { + Ok(OrderedValue::String(value)) + } + + fn visit_seq(self, mut seq: A) -> std::result::Result + where + A: SeqAccess<'de>, + { + let mut items = Vec::with_capacity(seq.size_hint().unwrap_or(0)); + while let Some(item) = seq.next_element()? { + items.push(item); + } + Ok(OrderedValue::Array(items)) + } + + fn visit_map(self, mut map: A) -> std::result::Result + where + A: MapAccess<'de>, + { + let mut entries: Vec<(String, OrderedValue)> = + Vec::with_capacity(map.size_hint().unwrap_or(0)); + while let Some((key, value)) = map.next_entry::()? { + match entries.iter_mut().find(|(k, _)| *k == key) { + Some(existing) => existing.1 = value, + None => entries.push((key, value)), + } + } + Ok(OrderedValue::Object(entries)) + } +} + +impl Serialize for OrderedValue { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + match self { + OrderedValue::Null => serializer.serialize_unit(), + OrderedValue::Bool(value) => serializer.serialize_bool(*value), + OrderedValue::Number(value) => value.serialize(serializer), + OrderedValue::String(value) => serializer.serialize_str(value), + OrderedValue::Array(items) => { + let mut seq = serializer.serialize_seq(Some(items.len()))?; + for item in items { + seq.serialize_element(item)?; + } + seq.end() + } + OrderedValue::Object(entries) => { + let mut map = serializer.serialize_map(Some(entries.len()))?; + for (key, value) in entries { + map.serialize_entry(key, value)?; + } + map.end() + } + } + } +} + +/// Parses `text` into an order-preserving `OrderedValue` and serializes it +/// back to compact JSON: what `JSON.stringify(JSON.parse(text))` produces +/// for this input class, i.e. object keys stay in the file's order, with a +/// duplicate key resolved to its last value at its first position. +pub fn canonical_compact_json(text: &str) -> Result { + let value: OrderedValue = serde_json::from_str(text) + .map_err(|e| Error::Invalid(format!("Invalid projection JSON: {e}")))?; + serde_json::to_string(&value) + .map_err(|e| Error::Invalid(format!("Invalid projection JSON: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_compact_json_preserves_key_and_array_order() { + let text = r#"{"b":1, "a":[true,null,"x"], "c":{"z":2,"y":3}}"#; + assert_eq!( + canonical_compact_json(text).unwrap(), + r#"{"b":1,"a":[true,null,"x"],"c":{"z":2,"y":3}}"# + ); + } + + #[test] + fn duplicate_keys_keep_the_first_position_with_the_last_value() { + let text = r#"{"a":1,"b":2,"a":3}"#; + assert_eq!(canonical_compact_json(text).unwrap(), r#"{"a":3,"b":2}"#); + } + + #[test] + fn invalid_json_is_reported_as_invalid_projection_json() { + let err = canonical_compact_json("{not json}") + .unwrap_err() + .to_string(); + assert!( + err.starts_with("Invalid projection JSON"), + "unexpected error: {err}" + ); + } +} diff --git a/src/preflight.rs b/src/preflight.rs new file mode 100644 index 0000000..bb4b3b8 --- /dev/null +++ b/src/preflight.rs @@ -0,0 +1,358 @@ +//! The immediate-pre-publication recheck. Ports +//! `scripts/projections/copybara-preflight.mjs`: +//! `publicationDisposition` and `preflightCopybaraPublication`. +//! +//! Every check here guards a publication contract over untrusted git or +//! GitHub state, so each one is a `contract()` (Node: a `requireValue` +//! throw, which the script's file-level `catch` turns into exit 1), +//! matching `transport::git` and `transport::github`. +//! +//! Node runs `project.mjs verify` and `project.mjs preview` as child +//! processes; this module calls `cli::project::run` in-process instead, +//! exactly as `transport::git::publish_prepared_tree` does. Node's +//! `preview` is this crate's `ProjectCommand::Plan` (see +//! `cli::project`'s module doc). + +use std::collections::BTreeSet; +use std::path::Path; + +use serde::ser::SerializeStruct; +use serde::{Serialize, Serializer}; + +use crate::cli::project::{ProjectArgs, ProjectCommand, run as project_run}; +use crate::definition::Definition; +use crate::git::{self, is_sha}; +use crate::transport::git::{ + assert_candidate_matches_main_projection, assert_destination_checkout, changed_paths, + read_report, require_utf8, verify_command_failed, +}; +use crate::transport::github::{GitHubApi, read_publication_state}; +use crate::{Error, Result, contract}; + +/// Ports `publicationDisposition`: `"unchanged"` only when the destination +/// working tree has no changed path, there is no open generated PR, and +/// re-projecting onto the destination's own default branch would change +/// nothing either. +pub fn publication_disposition( + changed_path_count: usize, + existing_pr: bool, + main_changed_count: usize, +) -> &'static str { + if changed_path_count == 0 && !existing_pr && main_changed_count == 0 { + "unchanged" + } else { + "publish" + } +} + +/// The result of `preflight_publication`, printed as one JSON line by +/// `capobara preflight`. +/// +/// `Serialize` is hand-written rather than derived because Node returns two +/// *different* object shapes from one function: a bare `{ held: true }` +/// when the destination PR is sync-held, and the full six-key object +/// otherwise (`copybara-preflight.mjs`: `if (first.held) return { held: +/// true };` versus the `return { held: false, unchanged, destinationFetch, +/// priorHead, fileCount, contentDigest }` at the end). The workflow step +/// that consumes this JSON only reads the other five keys on the +/// `status != 3` branch, but the printed bytes are a public interface, so +/// they are reproduced exactly. Field order matches Node's object literal; +/// `skip_serializing_if` cannot express "skip these five when `held`", +/// since a field predicate cannot see its sibling fields. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Preflight { + pub held: bool, + pub unchanged: bool, + pub destination_fetch: String, + pub prior_head: String, + pub file_count: usize, + pub content_digest: String, +} + +impl Preflight { + /// Node's `{ held: true }`: the five publication fields are never read + /// (and never serialized) on this branch. + fn held() -> Preflight { + Preflight { + held: true, + unchanged: false, + destination_fetch: String::new(), + prior_head: String::new(), + file_count: 0, + content_digest: String::new(), + } + } +} + +impl Serialize for Preflight { + fn serialize(&self, serializer: S) -> std::result::Result { + if self.held { + let mut state = serializer.serialize_struct("Preflight", 1)?; + state.serialize_field("held", &true)?; + return state.end(); + } + let mut state = serializer.serialize_struct("Preflight", 6)?; + state.serialize_field("held", &false)?; + state.serialize_field("unchanged", &self.unchanged)?; + state.serialize_field("destinationFetch", &self.destination_fetch)?; + state.serialize_field("priorHead", &self.prior_head)?; + state.serialize_field("fileCount", &self.file_count)?; + state.serialize_field("contentDigest", &self.content_digest)?; + state.end() + } +} + +/// The five report keys Node compares between the prepared report and the +/// plan regenerated against the immutable destination base, in Node's own +/// iteration order (`copybara-preflight.mjs`'s `for (const key of [...])`). +const PLAN_KEYS: [&str; 5] = [ + "copiedPaths", + "deletedPaths", + "copiedCount", + "deletedCount", + "provenance", +]; + +/// Ports `preflightCopybaraPublication`. +/// +/// The brief's abbreviated signature also lists `raw` and `tool_digest`. +/// Neither has a use here: `cli::project::run` loads and validates the +/// definition from its own path (so no raw definition text is needed -- +/// unlike `transport::git::prepare_destination`, which computes a +/// `definitionDigest` itself), and it reads `tooldigest::embedded()` +/// internally (so no tool digest needs threading through). Both omissions +/// follow the precedent `transport::git::publish_prepared_tree` set for +/// the same abbreviated-signature style in Task 12; see the task-13 report. +pub fn preflight_publication( + definition: &Definition, + source: &Path, + source_sha: &str, + target: &Path, + report_path: &Path, + api: &dyn GitHubApi, +) -> Result { + contract(is_sha(source_sha), "Missing immutable source revision")?; + assert_destination_checkout(definition, target)?; + let current_branch = git::git(target, &["branch", "--show-current"])?; + contract( + current_branch.trim() == definition.destination.sync_branch, + "Preflight requires the declared generated PR branch", + )?; + + let first = read_publication_state(definition, api)?; + if first.held { + return Ok(Preflight::held()); + } + + let scratch = tempfile::Builder::new() + .prefix("copybara-preflight-") + .tempdir() + .map_err(Error::Io)?; + + let definition_path = source.join(format!("config/projections/{}.json", definition.name)); + + // Recompute the full tree and provenance against the live destination. + let verified_path = scratch.path().join("verified.json"); + let verified_code = project_run(ProjectArgs { + command: ProjectCommand::Verify, + definition: definition_path.clone(), + source: source.to_path_buf(), + source_sha: source_sha.to_string(), + target: target.to_path_buf(), + report: Some(verified_path.clone()), + status_output: None, + markdown_output: None, + draft: false, + })?; + // Node spawns `project.mjs verify` with `execFileSync`, which throws on + // any non-zero exit -- including the exit code 1 `verify` uses to + // report drift. An in-process call reports that as a returned code + // instead of an error, so it is checked explicitly here, with the same + // message `publish_prepared_tree` reports for the same condition. + contract( + verified_code == 0, + verify_command_failed( + &definition_path, + source, + source_sha, + target, + Some(&verified_path), + ), + )?; + + let report = read_report(report_path)?; + let verified = read_report(&verified_path)?; + contract( + report.provenance == verified.provenance, + "Prepared report does not match verified projection provenance", + )?; + + // The submitted changed/deleted lists are data, not staging authority. + // Regenerate the plan against the immutable destination base before + // trusting them. + let target_abs = target.canonicalize().map_err(Error::Io)?; + let baseline = scratch.path().join("baseline"); + git::git( + scratch.path(), + &[ + "clone", + "--quiet", + "--no-hardlinks", + "--no-checkout", + require_utf8(&target_abs)?, + require_utf8(&baseline)?, + ], + )?; + let target_head = git::git(target, &["rev-parse", "HEAD"])?.trim().to_string(); + git::git(&baseline, &["checkout", "--detach", &target_head])?; + git::git( + &baseline, + &[ + "remote", + "set-url", + "origin", + &format!( + "https://github.com/{}.git", + definition.destination.repository + ), + ], + )?; + + let planned_path = scratch.path().join("planned.json"); + project_run(ProjectArgs { + command: ProjectCommand::Plan, + definition: definition_path, + source: source.to_path_buf(), + source_sha: source_sha.to_string(), + target: baseline.clone(), + report: Some(planned_path.clone()), + status_output: None, + markdown_output: None, + draft: false, + })?; + let planned = read_report(&planned_path)?; + for key in PLAN_KEYS { + let equal = match key { + "copiedPaths" => report.copied_paths == planned.copied_paths, + "deletedPaths" => report.deleted_paths == planned.deleted_paths, + "copiedCount" => report.copied_count == planned.copied_count, + "deletedCount" => report.deleted_count == planned.deleted_count, + _ => report.provenance == planned.provenance, + }; + contract( + equal, + format!("Prepared report does not match regenerated projection plan: {key}"), + )?; + } + + contract( + verified.provenance.publication_eligible, + "Prepared projection is not publication eligible", + )?; + + let candidate = + assert_candidate_matches_main_projection(definition, source, source_sha, target)?; + + let planned_paths: BTreeSet<&str> = report + .copied_paths + .iter() + .chain(report.deleted_paths.iter()) + .map(String::as_str) + .collect(); + let actual = changed_paths(target)?; + contract( + actual + .iter() + .all(|path| planned_paths.contains(path.as_str())), + "Unplanned destination modifications", + )?; + + let final_state = read_publication_state(definition, api)?; + if final_state.held { + return Ok(Preflight::held()); + } + + let remote_branch = format!("refs/remotes/origin/{}", definition.destination.sync_branch); + let branch_exists = git::git_ok(target, &["show-ref", "--verify", "--quiet", &remote_branch]); + let (destination_fetch, head_ref) = if branch_exists { + ( + definition.destination.sync_branch.clone(), + format!("{remote_branch}^{{commit}}"), + ) + } else { + ( + definition.destination.branch.clone(), + format!( + "refs/remotes/origin/{}^{{commit}}", + definition.destination.branch + ), + ) + }; + let prior_head = git::git(target, &["rev-parse", &head_ref])? + .trim() + .to_string(); + contract(is_sha(&prior_head), "Invalid destination branch head")?; + + let unchanged = publication_disposition( + actual.len(), + first.pr.is_some(), + candidate.main_changed_count, + ) == "unchanged"; + + Ok(Preflight { + held: false, + unchanged, + destination_fetch, + prior_head, + file_count: candidate.file_count, + content_digest: report.provenance.content_digest, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Node: `publicationDisposition` is `"unchanged"` only when all three + /// inputs are empty/false; every other corner is `"publish"`. + #[test] + fn disposition_is_unchanged_only_when_nothing_changed_anywhere() { + assert_eq!(publication_disposition(0, false, 0), "unchanged"); + assert_eq!(publication_disposition(1, false, 0), "publish"); + assert_eq!(publication_disposition(0, true, 0), "publish"); + assert_eq!(publication_disposition(0, false, 1), "publish"); + assert_eq!(publication_disposition(1, true, 1), "publish"); + } + + /// The held shape is Node's bare `{"held":true}`, not the full + /// six-key object with empty placeholders. + #[test] + fn a_held_preflight_serializes_to_exactly_one_key() { + assert_eq!( + serde_json::to_string(&Preflight::held()).unwrap(), + r#"{"held":true}"# + ); + } + + /// Positive control for the test above: the non-held shape carries all + /// six keys, camelCased, in Node's object-literal order. + #[test] + fn a_publishable_preflight_serializes_every_key_in_node_order() { + let preflight = Preflight { + held: false, + unchanged: false, + destination_fetch: "sync/mono-projection".into(), + prior_head: "a".repeat(40), + file_count: 3, + content_digest: "c".repeat(64), + }; + assert_eq!( + serde_json::to_string(&preflight).unwrap(), + format!( + r#"{{"held":false,"unchanged":false,"destinationFetch":"sync/mono-projection","priorHead":"{}","fileCount":3,"contentDigest":"{}"}}"#, + "a".repeat(40), + "c".repeat(64) + ) + ); + } +} diff --git a/src/receipt.rs b/src/receipt.rs new file mode 100644 index 0000000..e18b7ef --- /dev/null +++ b/src/receipt.rs @@ -0,0 +1,140 @@ +//! The projection receipt written into every projected tree, and its +//! verification. Ports `provenance` construction and `verifyProvenance` from +//! `scripts/projections/project.mjs`. + +use serde::{Deserialize, Serialize}; + +use crate::{Result, contract}; + +/// The projection receipt. Field order matches Node's `provenance` object +/// literal in `buildProjection` exactly, which (together with +/// `#[serde(rename_all = "camelCase")]`) makes `to_receipt_bytes` byte-for-byte +/// compatible with Node's `JSON.stringify(provenance, null, 2) + "\n"`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Provenance { + pub schema_version: u32, + pub projection: String, + pub projection_schema_version: u32, + pub source_repository: String, + pub source_sha: String, + pub destination_repository: String, + pub prior_projected_base: String, + pub definition_digest: String, + pub tool_digest: String, + pub content_digest: String, + pub publication_eligible: bool, +} + +impl Provenance { + /// The exact bytes written to the receipt file: two-space-indented JSON + /// (`serde_json::to_string_pretty`, the same layout as + /// `JSON.stringify(v, null, 2)`) plus a trailing newline. + pub fn to_receipt_bytes(&self) -> Vec { + let mut text = serde_json::to_string_pretty(self).unwrap_or_default(); + text.push('\n'); + text.into_bytes() + } + + /// Ports `verifyProvenance`: every field of `self` must equal the + /// corresponding field of `stored`. Node additionally checks that + /// `stored`'s key set matches exactly (`keys(actual, Object.keys(expected))`); + /// that check has no analogue here because both sides are the same typed + /// struct with `deny_unknown_fields`, so an extra or missing key can + /// never reach this comparison. Reports the first differing field, in + /// the struct's declared order, by its camelCase (receipt JSON) name to + /// match Node's `Provenance mismatch: ${key}` messages. + pub fn verify_against(&self, stored: &Provenance) -> Result<()> { + contract( + self.schema_version == stored.schema_version, + "Provenance mismatch: schemaVersion", + )?; + contract( + self.projection == stored.projection, + "Provenance mismatch: projection", + )?; + contract( + self.projection_schema_version == stored.projection_schema_version, + "Provenance mismatch: projectionSchemaVersion", + )?; + contract( + self.source_repository == stored.source_repository, + "Provenance mismatch: sourceRepository", + )?; + contract( + self.source_sha == stored.source_sha, + "Provenance mismatch: sourceSha", + )?; + contract( + self.destination_repository == stored.destination_repository, + "Provenance mismatch: destinationRepository", + )?; + contract( + self.prior_projected_base == stored.prior_projected_base, + "Provenance mismatch: priorProjectedBase", + )?; + contract( + self.definition_digest == stored.definition_digest, + "Provenance mismatch: definitionDigest", + )?; + contract( + self.tool_digest == stored.tool_digest, + "Provenance mismatch: toolDigest", + )?; + contract( + self.content_digest == stored.content_digest, + "Provenance mismatch: contentDigest", + )?; + contract( + self.publication_eligible == stored.publication_eligible, + "Provenance mismatch: publicationEligible", + )?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Provenance { + Provenance { + schema_version: 1, + projection: "sample".into(), + projection_schema_version: 1, + source_repository: "dx-corp/mono".into(), + source_sha: "1".repeat(40), + destination_repository: "dx-corp/sample".into(), + prior_projected_base: "2".repeat(40), + definition_digest: "d".repeat(64), + tool_digest: "3".repeat(64), + content_digest: "c".repeat(64), + publication_eligible: true, + } + } + + #[test] + fn to_receipt_bytes_is_pretty_json_with_declared_field_order_and_trailing_newline() { + let bytes = sample().to_receipt_bytes(); + let text = String::from_utf8(bytes).unwrap(); + assert!(text.ends_with("}\n")); + assert!(!text.ends_with("}\n\n")); + let schema_at = text.find("\"schemaVersion\"").unwrap(); + let projection_at = text.find("\"projection\"").unwrap(); + let eligible_at = text.find("\"publicationEligible\"").unwrap(); + assert!(schema_at < projection_at); + assert!(projection_at < eligible_at); + assert!(text.starts_with("{\n \"")); + } + + #[test] + fn verify_against_reports_the_first_differing_field_in_declared_order() { + let base = sample(); + let mut other = base.clone(); + other.projection_schema_version = 2; + other.source_sha = "9".repeat(40); + let err = base.verify_against(&other).unwrap_err().to_string(); + assert_eq!(err, "Provenance mismatch: projectionSchemaVersion"); + assert!(base.verify_against(&base).is_ok()); + } +} diff --git a/src/report.rs b/src/report.rs new file mode 100644 index 0000000..9dfd62a --- /dev/null +++ b/src/report.rs @@ -0,0 +1,86 @@ +//! The JSON report a projection subcommand writes (`--report`), and the +//! input to the publication body. +use crate::build::Built; +use crate::receipt::Provenance; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Report { + pub copied_paths: Vec, + pub deleted_paths: Vec, + pub copied_count: usize, + pub deleted_count: usize, + pub provenance: Provenance, + pub source_file_count: usize, + pub result: String, +} + +impl From<&Built> for Report { + fn from(built: &Built) -> Report { + let copied_count = built.plan.copied_count(); + let deleted_count = built.plan.deleted_count(); + Report { + copied_paths: built.plan.copied_paths.clone(), + deleted_paths: built.plan.deleted_paths.clone(), + copied_count, + deleted_count, + provenance: built.provenance.clone(), + // Node: `entries.size - 1` (the receipt entry is not a source file). + source_file_count: built.plan.entries.len().saturating_sub(1), + result: if copied_count + deleted_count > 0 { + "drift_detected".into() + } else { + "in_sync".into() + }, + } + } +} + +use crate::definition::Definition; + +/// Ports the markdown body written to `--markdown-output`: the exact Node +/// template from `main`'s `options["--markdown-output"]` branch in +/// `scripts/projections/project.mjs`, joined with `\n`. The final element +/// of Node's array is `""`, so the result ends with a trailing empty line +/// (a trailing `\n` once written to a file). +pub fn markdown_summary( + definition: &Definition, + source_sha: &str, + prior_base: &str, + built: &Built, + report: &Report, +) -> String { + let mut lines: Vec = vec![ + format!("## Projection: {}", definition.name), + String::new(), + format!("- Source: {}@{source_sha}", definition.source_repository), + format!("- Prior destination base: {prior_base}"), + format!("- Content SHA-256: {}", built.provenance.content_digest), + format!( + "- Result: {}; {} changed, {} deleted", + report.result, report.copied_count, report.deleted_count + ), + "- Destination-owned content is preserved. Destination CI is a separate health signal." + .to_string(), + String::new(), + ]; + lines.extend( + built + .plan + .copied_paths + .iter() + .take(20) + .map(|p| format!("- copy/update {p}")), + ); + lines.extend( + built + .plan + .deleted_paths + .iter() + .take(20) + .map(|p| format!("- delete {p}")), + ); + lines.push(String::new()); + lines.join("\n") +} diff --git a/src/snapshot.rs b/src/snapshot.rs new file mode 100644 index 0000000..0d11bb6 --- /dev/null +++ b/src/snapshot.rs @@ -0,0 +1,143 @@ +use std::collections::BTreeSet; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; + +use crate::git::{git, git_bytes, is_sha}; +use crate::tree::{PathOpts, assert_portable_paths, safe_path}; +use crate::{Error, Result, invalid}; + +/// Upper bound on the size of a `git archive` payload we will buffer in +/// memory, matching Node's `maxBuffer` cap in `project.mjs::withSnapshot`. +const MAX_ARCHIVE_BYTES: usize = 512 * 1024 * 1024; + +/// Materialize the committed content of `roots` at `sha` into a scratch +/// directory, invoke `f` with it, and remove the directory before +/// returning (on every path: success, an error from `f`, or an error from +/// this function itself). +pub fn with_snapshot( + root: &Path, + sha: &str, + roots: &[String], + f: impl FnOnce(&Path) -> Result, +) -> Result { + // Validate the shape of `sha` before it ever reaches a git argv: a + // leading-dash value would otherwise be parsed by git as an option. + invalid(is_sha(sha), "Invalid source revision")?; + let resolved = git(root, &["rev-parse", &format!("{sha}^{{commit}}")]).unwrap_or_default(); + invalid(resolved.trim() == sha, "Invalid source revision")?; + + let paths: Vec = roots + .iter() + .cloned() + .collect::>() + .into_iter() + .collect(); + for path in &paths { + safe_path(path, PathOpts::default())?; + } + + let mut args = vec!["ls-tree", "-rz", sha, "--"]; + args.extend(paths.iter().map(String::as_str)); + let listing = git(root, &args)?; + let entries: Vec<&str> = listing.split('\0').filter(|s| !s.is_empty()).collect(); + invalid( + !entries.iter().any(|e| e.starts_with("160000 ")), + "Submodules are not projection inputs", + )?; + let present: Vec<&str> = entries + .iter() + .map(|e| e.split_once('\t').map(|(_, p)| p).unwrap_or("")) + .collect(); + assert_portable_paths(present.iter().copied())?; + + let archive_roots: Vec<&str> = paths + .iter() + .map(String::as_str) + .filter(|root| { + present + .iter() + .any(|file| *file == *root || file.starts_with(&format!("{root}/"))) + }) + .collect(); + invalid( + !archive_roots.is_empty(), + "No committed projection inputs found", + )?; + + let mut args = vec!["archive", "--format=tar", sha, "--"]; + args.extend(archive_roots); + let archive = git_bytes(root, &args)?; + invalid( + archive.len() <= MAX_ARCHIVE_BYTES, + "Projection archive exceeds 512 MiB", + )?; + + let scratch = tempfile::Builder::new() + .prefix("mono-projection-") + .tempdir()?; + extract_tar(&archive, scratch.path())?; + f(scratch.path()) +} + +/// The reviewed process boundary for `tar`: the sole extraction call site +/// for materializing a git archive into a scratch directory. +/// +/// `tar`'s stderr and exit status are always collected via +/// `wait_with_output`, even when writing the archive to its stdin fails +/// (for example because `tar` exited early on a malformed archive and +/// closed its stdin, producing a broken pipe on our write). A write error +/// is remembered rather than propagated immediately, so the child is +/// always reaped and a broken pipe never shadows `tar`'s real diagnostic. +#[allow( + clippy::disallowed_methods, + reason = "tar extraction of a git archive is a reviewed process boundary" +)] +fn extract_tar(archive: &[u8], dest: &Path) -> Result<()> { + let mut tar = Command::new("tar") + .args(["-xf", "-", "-C"]) + .arg(dest) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let write_result = tar + .stdin + .take() + .ok_or_else(|| Error::Invalid("tar stdin unavailable".into())) + .and_then(|mut stdin| stdin.write_all(archive).map_err(Error::Io)); + // Drop of `write_result`'s stdin handle (above, at the end of the + // closure) happens before we wait, so tar sees EOF even if the write + // was short. + let output = tar.wait_with_output()?; + if !output.status.success() { + return Err(Error::Invalid(format!( + "tar extraction failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + write_result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_tar_reports_tar_failure_without_broken_pipe_noise() { + let dest = tempfile::tempdir().unwrap(); + // Large enough to outrun tar's early failure (tar reads and + // rejects the bogus header before we finish writing), so the + // observable failure is tar's own diagnostic, not a broken pipe. + let junk = vec![b'x'; 64 * 1024]; + let err = extract_tar(&junk, dest.path()).unwrap_err(); + let message = err.to_string(); + assert!( + message.contains("tar extraction failed"), + "unexpected message: {message}" + ); + assert!( + !message.contains("Broken pipe"), + "broken pipe leaked into the error: {message}" + ); + } +} diff --git a/src/snapshots/capobara__definition__tests__approved_definitions_load_and_digest_like_node.snap b/src/snapshots/capobara__definition__tests__approved_definitions_load_and_digest_like_node.snap new file mode 100644 index 0000000..7f87b4e --- /dev/null +++ b/src/snapshots/capobara__definition__tests__approved_definitions_load_and_digest_like_node.snap @@ -0,0 +1,6 @@ +--- +source: tools/capobara/src/definition.rs +assertion_line: 463 +expression: definition_digest(&api.raw) +--- +1b99f898b612f6adeb8261ebea5314f7e78f0d41d4667ce98cb9bb94155369f4 diff --git a/src/tooldigest.rs b/src/tooldigest.rs new file mode 100644 index 0000000..f0e8066 --- /dev/null +++ b/src/tooldigest.rs @@ -0,0 +1,107 @@ +//! The projector implementation digest: proof that the running binary is +//! built from the same `rust/tools/capobara` tree as the source revision +//! being projected. Ports the `toolDigest` half of `main` in +//! `scripts/projections/project.mjs`, but not its mechanism: Node hashes the +//! concatenated contents of a fixed `TOOL_INPUTS` file list at run time; +//! this crate instead embeds its own git tree id at *compile* time (see +//! `build.rs`) and compares that fixed value against the tree id of +//! `rust/tools/capobara` at the revision being projected. Either shape is a +//! valid "the tool that ran this matches the tool committed at this +//! revision" proof; `git::is_tree_id_or_digest` accepts both a 40-hex tree +//! id (this crate) and a 64-hex digest (Node, and any receipt Node wrote +//! before the cutover) wherever a `toolDigest` is validated. + +use std::path::Path; +use std::sync::OnceLock; + +use crate::Result; +use crate::git; + +/// The cached override value, shared by both debug-only override +/// mechanisms below: `Some(id)` once either one has run, `None` once +/// `embedded()` has checked the environment and found nothing set. A plain +/// (non-`cfg`-gated) static so it always exists as an item -- `embedded()` +/// only ever touches it inside a `cfg!(debug_assertions)`-guarded branch +/// that is dead code (and eligible for removal by the optimizer) in a +/// release build, exactly as before this override was added. +static OVERRIDE: OnceLock> = OnceLock::new(); + +/// This crate's own compiled-in tree id (or digest), as embedded by +/// `build.rs` into the `CAPOBARA_TREE_ID` compile-time environment +/// variable (empty if unavailable, `-dirty` if the working tree had +/// uncommitted changes under `rust/tools/capobara` -- see `build.rs`'s doc +/// comment for the full precedence). +/// +/// In debug builds only, two mechanisms can override that value, both +/// backed by the same process-wide `OnceLock` above (so whichever runs +/// first for a given process wins, and stays pinned for that process's +/// whole lifetime): +/// - The *process* environment variable `CAPOBARA_TREE_ID_OVERRIDE`, read +/// lazily on first use. This is what lets a spawned child process -- +/// for example the compiled binary `tests/project_cli.rs` and +/// `tests/transport_git.rs`'s `run_capobara` helper invoke -- run +/// against a synthetic source repository: the test sets the variable on +/// that one child's environment only, and the child's own fresh +/// `OnceLock` picks it up the first time it reads a digest. +/// - `override_for_tests`, called explicitly and in-process, for a test +/// that calls `cli::project::run` (or anything built on it, such as +/// `transport::git::assert_candidate_matches_main_projection` or +/// `publish_prepared_tree`) directly in the *same* process, where there +/// is no child `Command` to attach an environment variable to. +/// +/// Release builds compile neither seam: `override_for_tests` does not +/// exist at all outside `#[cfg(debug_assertions)]`, and this function's +/// only source of truth is `env!("CAPOBARA_TREE_ID")`, so a production +/// binary can never be talked into skipping the "projector differs from +/// source revision" check via an environment variable or a library call at +/// runtime. +pub fn embedded() -> &'static str { + if cfg!(debug_assertions) { + let over = OVERRIDE.get_or_init(|| std::env::var("CAPOBARA_TREE_ID_OVERRIDE").ok()); + if let Some(value) = over { + return value; + } + } + env!("CAPOBARA_TREE_ID") +} + +/// Debug builds only: pin the tree id this process reports, for tests that +/// call `cli::project::run` in-process against a synthetic source +/// repository. Must be called before the first digest read; a second call +/// with a different value panics so two fixtures cannot silently disagree. +/// +/// Two distinct causes can leave `OVERRIDE` already initialized when this +/// runs, and the panic names which one happened, with the stored value in +/// either case: (a) a second, disagreeing call to `override_for_tests` +/// itself, or (b) `embedded()` already ran once with +/// `CAPOBARA_TREE_ID_OVERRIDE` unset, caching `None` -- meaning a digest +/// was read before this function ever got a chance to install its value, +/// which is a call-order bug in the caller, not a value conflict. +#[cfg(debug_assertions)] +pub fn override_for_tests(tree_id: &str) { + if OVERRIDE.set(Some(tree_id.to_owned())).is_err() { + match OVERRIDE.get() { + Some(Some(existing)) if existing == tree_id => {} + Some(Some(existing)) => panic!( + "tooldigest::override_for_tests: already set to a different tree id (stored {existing:?}, requested {tree_id:?})" + ), + Some(None) => panic!( + "tooldigest::override_for_tests: a digest was already read with no override installed (stored value: None), so this call is too late; call override_for_tests before the first digest read. Requested tree id: {tree_id:?}" + ), + None => { + unreachable!("OVERRIDE.set just failed, so OVERRIDE.get() must return Some(..)") + } + } + } +} + +/// `git rev-parse {sha}:rust/tools/capobara` in `source_root`'s repository: +/// the tree id of this crate's directory as committed at `sha`, for +/// comparison against `embedded()`. +pub fn at_revision(source_root: &Path, sha: &str) -> Result { + let id = git::git( + source_root, + &["rev-parse", &format!("{sha}:rust/tools/capobara")], + )?; + Ok(id.trim().to_string()) +} diff --git a/src/transport/git.rs b/src/transport/git.rs new file mode 100644 index 0000000..1365557 --- /dev/null +++ b/src/transport/git.rs @@ -0,0 +1,659 @@ +//! Destination git transport: prepares, validates, and publishes a +//! projection against a destination checkout. Ports `assertDestinationCheckout`, +//! `prepareDestination`, `assertCandidateMatchesMainProjection`, and +//! `publishPreparedTree` from `scripts/projections/transport.mjs`. +//! +//! Every check in this module enforces a publication contract (untrusted git +//! or GitHub state), matching the sibling `transport::github` module's use +//! of `contract()` (exit 1) rather than `invalid()` (exit 2) throughout. +//! +//! All git access goes through `crate::git`'s reviewed process boundary; the +//! `plan`/`apply`/`verify` projection subcommands run in-process via +//! `crate::cli::project::run`, never by spawning the `capobara` binary. + +use std::collections::BTreeSet; +use std::path::Path; + +use serde_json::Value; + +use crate::cli::project::{ProjectArgs, ProjectCommand, run as project_run}; +use crate::definition::{Definition, definition_digest}; +use crate::git::{self, is_sha}; +use crate::report::Report; +use crate::transport::github::{ + GitHubApi, create_or_update_pr, publication_body, read_publication_state, +}; +use crate::tree; +use crate::{Error, Result, contract}; + +/// `pub(crate)` so `crate::preflight` can reuse the same scratch-clone +/// path handling rather than duplicating it. +pub(crate) fn require_utf8(path: &Path) -> Result<&str> { + path.to_str() + .ok_or_else(|| Error::Invalid(format!("Non-UTF-8 path: {}", path.display()))) +} + +/// `pub(crate)` so `crate::preflight` and `crate::cli::run` read a report +/// file exactly the way this module does, including the error message. +pub(crate) fn read_report(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(Error::Io)?; + serde_json::from_slice(&bytes).map_err(|e| Error::Invalid(format!("Invalid report: {e}"))) +} + +/// Ports `assertDestinationCheckout`: `target`'s `origin` remote must be the +/// GitHub HTTPS URL for `definition.destination.repository`. +pub fn assert_destination_checkout(definition: &Definition, target: &Path) -> Result<()> { + let remote = git::git(target, &["remote", "get-url", "origin"])?; + contract( + remote.trim() + == format!( + "https://github.com/{}.git", + definition.destination.repository + ), + "Unexpected destination checkout remote", + ) +} + +#[derive(Debug)] +pub struct Prepared { + pub held: bool, +} + +/// Ports `prepareDestination`. `definition_text` is the destination +/// definition's exact committed text (a `LoadedDefinition::text`, or the +/// on-disk file's contents), needed to compute a `definitionDigest` +/// comparable to the one `build_projection` wrote into any existing +/// receipt; this is a deliberate addition beyond the brief's abbreviated +/// `(definition, target, source_sha, api)` signature, documented in the +/// task-12 report. `serde_json::to_value(definition)` round-tripping (as +/// `validate_definition` does) is not a substitute here: it re-serializes +/// in the `Definition` struct's declared field order rather than the +/// original file's key order, and would not reliably match the digest +/// already embedded in a stored receipt. +/// +/// If a receipt file exists at the destination's provenance path, it must +/// parse as JSON (`Error::Invalid("Malformed stored provenance")` if not, +/// matching Node's `JSON.parse` throwing on malformed input). Once parsed, +/// its `sourceSha`/`definitionDigest` fields are read loosely -- as raw +/// `serde_json::Value` lookups, not deserialized into `Provenance` -- with +/// no key-set validation, matching Node's `previous?.sourceSha`/ +/// `previous?.definitionDigest` optional-chaining exactly: a missing or +/// wrong-typed field just fails the comparison and triggers a merge, the +/// same as no receipt at all. +pub fn prepare_destination( + definition: &Definition, + definition_text: &str, + target: &Path, + source_sha: &str, + api: &dyn GitHubApi, +) -> Result { + contract(is_sha(source_sha), "Missing immutable source revision")?; + assert_destination_checkout(definition, target)?; + let status = git::git(target, &["status", "--porcelain", "--untracked-files=all"])?; + contract( + status.trim().is_empty(), + "Prepare requires a clean destination checkout", + )?; + + let state = read_publication_state(definition, api)?; + if state.held { + return Ok(Prepared { held: true }); + } + + let d = &definition.destination; + git::git(target, &["config", "user.name", "github-actions[bot]"])?; + git::git( + target, + &[ + "config", + "user.email", + "github-actions[bot]@users.noreply.github.com", + ], + )?; + + let refs = git::git( + target, + &[ + "for-each-ref", + "--format=%(refname)", + "refs/remotes/origin/", + ], + )?; + let sync_ref = format!("refs/remotes/origin/{}", d.sync_branch); + let has_sync_branch = refs.trim().split('\n').any(|line| line == sync_ref); + + if has_sync_branch { + git::git( + target, + &["switch", "--track", &format!("origin/{}", d.sync_branch)], + )?; + if let Some(pr) = &state.pr { + let head = git::git(target, &["rev-parse", "HEAD"])?; + contract( + head.trim() == pr.head_sha, + "Destination branch advanced; retry from a fresh clone", + )?; + } + let receipt_path = target.join(&definition.provenance); + let previous: Option = if receipt_path.exists() { + let bytes = std::fs::read(&receipt_path).map_err(Error::Io)?; + Some( + serde_json::from_slice(&bytes) + .map_err(|_| Error::Invalid("Malformed stored provenance".into()))?, + ) + } else { + None + }; + let current_digest = definition_digest(definition_text)?; + // Integrate destination-owned changes only while producing a real new + // projection. An unrelated main advance never refreshes an existing PR. + // Node reads `previous?.sourceSha`/`previous?.definitionDigest` loosely + // here, with no key-set validation (unlike the stored-receipt check in + // `cli::project::run`): a missing or wrong-typed field just fails the + // comparison and triggers a merge, the same as an absent receipt. A + // receipt that isn't even parseable JSON is a harder failure + // (`Error::Invalid`), matching Node's `JSON.parse`, which throws + // rather than yielding something `?.`-safe to read fields from. + let needs_merge = previous.as_ref().is_none_or(|previous| { + previous.get("sourceSha").and_then(Value::as_str) != Some(source_sha) + || previous.get("definitionDigest").and_then(Value::as_str) + != Some(current_digest.as_str()) + }); + if needs_merge { + git::git( + target, + &["merge", "--no-edit", &format!("origin/{}", d.branch)], + )?; + } + } else { + contract(state.pr.is_none(), "Open PR branch is absent from clone")?; + git::git( + target, + &[ + "switch", + "-c", + &d.sync_branch, + &format!("origin/{}", d.branch), + ], + )?; + } + + Ok(Prepared { held: false }) +} + +#[derive(Debug)] +pub struct Candidate { + pub main_sha: String, + pub file_count: usize, + pub main_changed_count: usize, +} + +/// Ports `assertCandidateMatchesMainProjection`. Builds the destination's +/// declared main branch plus a freshly verified projection into a scratch +/// clone (`expected`), then compares every file except the provenance path +/// against `target`'s actual on-disk state, by mode and bytes. +pub fn assert_candidate_matches_main_projection( + definition: &Definition, + source: &Path, + source_sha: &str, + target: &Path, +) -> Result { + assert_destination_checkout(definition, target)?; + let target_abs = target.canonicalize().map_err(Error::Io)?; + let scratch = tempfile::Builder::new() + .prefix("projection-candidate-") + .tempdir() + .map_err(Error::Io)?; + let expected = scratch.path().join("expected"); + git::git( + scratch.path(), + &[ + "clone", + "--quiet", + "--no-hardlinks", + "--no-checkout", + require_utf8(&target_abs)?, + require_utf8(&expected)?, + ], + )?; + + let main_sha = git::git( + target, + &[ + "rev-parse", + &format!("origin/{}^{{commit}}", definition.destination.branch), + ], + )? + .trim() + .to_string(); + git::git(&expected, &["checkout", "--detach", &main_sha])?; + git::git( + &expected, + &[ + "remote", + "set-url", + "origin", + &format!( + "https://github.com/{}.git", + definition.destination.repository + ), + ], + )?; + + let expected_report_path = scratch.path().join("expected-report.json"); + project_run(ProjectArgs { + command: ProjectCommand::Apply, + definition: source.join(format!("config/projections/{}.json", definition.name)), + source: source.to_path_buf(), + source_sha: source_sha.to_string(), + target: expected.clone(), + report: Some(expected_report_path.clone()), + status_output: None, + markdown_output: None, + draft: false, + })?; + let expected_report = read_report(&expected_report_path)?; + + // Project verification already authenticates the candidate provenance. + // Its priorProjectedBase records branch history and can legitimately + // differ after a squash merge, so compare every other file and + // executable bit. + let excluded = definition.provenance.as_str(); + let actual_paths = tree::files_under(target, &|path: &str| path == excluded)?; + let expected_paths = tree::files_under(&expected, &|path: &str| path == excluded)?; + let actual_set: BTreeSet<&str> = actual_paths.iter().map(String::as_str).collect(); + let expected_set: BTreeSet<&str> = expected_paths.iter().map(String::as_str).collect(); + + let mut all_paths: Vec = actual_paths + .iter() + .cloned() + .chain(expected_paths.iter().cloned()) + .collect::>() + .into_iter() + .collect(); + tree::sort_js(&mut all_paths); + + let mut differences = Vec::new(); + for path in &all_paths { + if !actual_set.contains(path.as_str()) || !expected_set.contains(path.as_str()) { + differences.push(path.clone()); + continue; + } + let actual_entry = tree::read_entry(target, path)?; + let expected_entry = tree::read_entry(&expected, path)?; + if actual_entry.mode != expected_entry.mode + || actual_entry.content != expected_entry.content + { + differences.push(path.clone()); + } + } + if !differences.is_empty() { + let shown = differences + .iter() + .take(20) + .cloned() + .collect::>() + .join(", "); + let suffix = if differences.len() > 20 { + format!(" (+{} more)", differences.len() - 20) + } else { + String::new() + }; + return Err(Error::Contract(format!( + "Candidate tree differs from destination main plus the verified projection: {shown}{suffix}" + ))); + } + + Ok(Candidate { + main_sha, + file_count: actual_paths.len(), + main_changed_count: expected_report.copied_count + expected_report.deleted_count, + }) +} + +/// The `engine` field of Node's publication result: `engine: nativeBinary +/// ? "rust-prepared-tree" : "git-index"`, in `publishPreparedTree`'s +/// returned object literal (`scripts/projections/transport.mjs`). Capobara +/// *is* that native binary -- Node's `nativeBinary` argument is the path to +/// this very tool, passed only by its `publish-native` command, and used +/// only to call `buildNativeTree`/`assertNativeTreeMatchesIndex` -- so +/// every publication this crate makes reports `rust-prepared-tree`. +/// `git-index` names Node's own pure-git staging path and is unreachable +/// from here. +pub const PUBLICATION_ENGINE: &str = "rust-prepared-tree"; + +/// What `publishPreparedTree` resolved to: one variant per object Node +/// returns. Node's CLI prints `JSON.stringify(result)`, so each variant's +/// documented object is exactly what a `capobara publish` has to print: +/// +/// - `Held` -- `{"held":true}` (both the early and the late held read +/// return this same one-key object). +/// - `Unchanged` -- `{"held":false,"unchanged":true}`. +/// - `PullRequest` -- `{"held":false,"pullRequest":,"engine":, +/// "tree":}`, in that key order. +/// +/// `held` and `unchanged` are per-variant constants in Node, so they are +/// carried by the variant itself rather than by a field; `url`, `engine`, +/// and `tree` are the only values Node computes. +#[derive(Debug)] +pub enum Published { + Held, + Unchanged, + PullRequest { + /// Node's `pullRequest`: the created or updated PR's `html_url`. + url: String, + /// Always `PUBLICATION_ENGINE`; see its documentation. + engine: String, + /// Node's `tree`: `git rev-parse HEAD^{tree}` in `target`, trimmed. + tree: String, + }, +} + +/// The union of `git diff HEAD --name-only -z` and +/// `git ls-files --others --exclude-standard -z`: every path with an +/// uncommitted change (tracked or untracked) in `target`'s working tree. +pub fn changed_paths(target: &Path) -> Result> { + let diff = git::git(target, &["diff", "HEAD", "--name-only", "-z"])?; + let untracked = git::git( + target, + &["ls-files", "--others", "--exclude-standard", "-z"], + )?; + Ok(diff + .split('\0') + .chain(untracked.split('\0')) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect()) +} + +/// Ports the credential-scoped push inside `publishPreparedTree`, factored +/// into its own function. `GH_TOKEN` is read only to confirm it is present +/// (`RestApi::from_env`'s exact message); the pushed `git` child process +/// inherits it from this process's own environment for its credential +/// helper, and it is never written to a URL, an argv, or `.git/config`. +pub fn push_sync_branch(definition: &Definition, target: &Path) -> Result<()> { + std::env::var("GH_TOKEN").map_err(|_| Error::Invalid("Missing publication token".into()))?; + const CREDENTIAL_HELPER: &str = "!f() { if [ \"$1\" = get ]; then printf \"username=x-access-token\\npassword=%s\\n\" \"$GH_TOKEN\"; fi; }; f"; + git::git( + target, + &[ + "-c", + "credential.helper=", + "-c", + &format!("credential.helper={CREDENTIAL_HELPER}"), + "push", + "origin", + &format!("HEAD:refs/heads/{}", definition.destination.sync_branch), + ], + )?; + Ok(()) +} + +/// The message every in-process `verify` failure reports, built from the +/// argv that `verify` was actually given. Node's `execFileSync` throws +/// `Command failed: ${[file, ...args].join(" ")}` (plus the child's stderr +/// when non-empty, which for this failure is empty); see +/// `publish_prepared_tree`'s doc comment for the full analysis of which +/// tokens Rust can and cannot reproduce, and why `capobara` stands in for +/// Node's two leading absolute paths. +/// +/// One builder, one condition, one message. It is shared by every site +/// that runs `cli::project::run` with `ProjectCommand::Verify` and has to +/// fail closed on a non-zero status: `publish_prepared_tree` below, +/// `preflight::preflight_publication`, and the two transcribed workflow +/// steps in `cli::run`. `report` is `None` at the sites whose invocation +/// passes no `--report` (the workflow's `verify` steps, yml:161-164 and +/// yml:288-291), so the message always names the real argv. +pub(crate) fn verify_command_failed( + definition: &Path, + source: &Path, + source_sha: &str, + target: &Path, + report: Option<&Path>, +) -> String { + let report = match report { + Some(path) => format!(" --report {}", path.display()), + None => String::new(), + }; + format!( + "Command failed: capobara verify --definition {} --source {} --source-sha {source_sha} --target {}{report}", + definition.display(), + source.display(), + target.display() + ) +} + +/// Ports `publishPreparedTree`. +/// +/// Node runs the projector's `verify` step through `execFileSync`, which +/// throws when the child exits non-zero, so a `verify` that reports drift +/// aborts publication where it stands: before the baseline clone and plan, +/// before any staging or commit, and before any remote write. The +/// in-process `cli::project::run` reports that same drift as `Ok(1)` +/// (exit 1 for `Check`/`Verify` when `copied_count + deleted_count > 0`), +/// which `?` alone would discard, so the status is checked explicitly +/// below. +/// +/// **Residual difference from Node's thrown message.** Node's +/// `execFileSync` failure message is +/// `Command failed: ${[file, ...args].join(" ")}`, with `\n${stderr}` +/// appended only when the child wrote to stderr. For this exact failure +/// the child writes its `N changed, M deleted` line to *stdout* and sets +/// `exitCode = 1` (`scripts/projections/project.mjs`), leaving stderr +/// empty, so Node's message is the joined argv and nothing else -- and +/// Rust appends nothing either. Of that argv, Rust reproduces every token +/// from `verify` onward byte-for-byte (the same five flags with the same +/// five values). It cannot reproduce the two leading tokens: Node's are +/// the absolute `process.execPath` and the absolute path to +/// `scripts/projections/project.mjs`, and this crate runs `verify` +/// in-process with neither a node binary nor a script path in existence. +/// The single token `capobara` stands in for both. Node's CLI maps every +/// thrown error to exit 1, which is what `Error::Contract` does here. +/// +/// The sibling `plan` call below is left unchecked deliberately: +/// `cli::project::run` returns a non-zero status only for `Check` and +/// `Verify`, so `Plan` provably always returns `Ok(0)` and any real +/// failure of it arrives as an `Err` that `?` already propagates. +pub fn publish_prepared_tree( + definition: &Definition, + source: &Path, + source_sha: &str, + target: &Path, + report: &Report, + api: &dyn GitHubApi, +) -> Result { + assert_destination_checkout(definition, target)?; + let current_branch = git::git(target, &["branch", "--show-current"])?; + contract( + current_branch.trim() == definition.destination.sync_branch, + "Publication requires the declared generated PR branch", + )?; + + let first = read_publication_state(definition, api)?; + if first.held { + return Ok(Published::Held); + } + + // Recompute the full tree and provenance immediately before publication. + let scratch = tempfile::Builder::new() + .prefix("projection-publish-") + .tempdir() + .map_err(Error::Io)?; + + let definition_path = source.join(format!("config/projections/{}.json", definition.name)); + let verified_path = scratch.path().join("verified.json"); + let verify_status = project_run(ProjectArgs { + command: ProjectCommand::Verify, + definition: definition_path.clone(), + source: source.to_path_buf(), + source_sha: source_sha.to_string(), + target: target.to_path_buf(), + report: Some(verified_path.clone()), + status_output: None, + markdown_output: None, + draft: false, + })?; + // Fail closed on a drifting `verify`: `?` above propagates only an + // `Err`, while drift is `Ok(1)`. See this function's doc comment for + // the message Node throws here and what of it Rust can reproduce. + contract( + verify_status == 0, + verify_command_failed( + &definition_path, + source, + source_sha, + target, + Some(&verified_path), + ), + )?; + let verified = read_report(&verified_path)?; + contract( + report.provenance == verified.provenance && verified.provenance.publication_eligible, + "Prepared report does not match verified projection", + )?; + + // The submitted changed/deleted lists are data, not staging authority. + // Regenerate the plan against the immutable destination base before + // trusting them, so a modified report cannot smuggle an unrelated file + // into a commit. + let target_abs = target.canonicalize().map_err(Error::Io)?; + let baseline = scratch.path().join("baseline"); + git::git( + scratch.path(), + &[ + "clone", + "--quiet", + "--no-hardlinks", + "--no-checkout", + require_utf8(&target_abs)?, + require_utf8(&baseline)?, + ], + )?; + let target_head = git::git(target, &["rev-parse", "HEAD"])?.trim().to_string(); + git::git(&baseline, &["checkout", "--detach", &target_head])?; + git::git( + &baseline, + &[ + "remote", + "set-url", + "origin", + &format!( + "https://github.com/{}.git", + definition.destination.repository + ), + ], + )?; + + let planned_path = scratch.path().join("planned.json"); + project_run(ProjectArgs { + command: ProjectCommand::Plan, + definition: definition_path.clone(), + source: source.to_path_buf(), + source_sha: source_sha.to_string(), + target: baseline.clone(), + report: Some(planned_path.clone()), + status_output: None, + markdown_output: None, + draft: false, + })?; + let planned = read_report(&planned_path)?; + contract( + planned.copied_paths == report.copied_paths, + "Untrusted publication report: copiedPaths", + )?; + contract( + planned.deleted_paths == report.deleted_paths, + "Untrusted publication report: deletedPaths", + )?; + contract( + planned.copied_count == report.copied_count, + "Untrusted publication report: copiedCount", + )?; + contract( + planned.deleted_count == report.deleted_count, + "Untrusted publication report: deletedCount", + )?; + contract( + planned.provenance == report.provenance, + "Untrusted publication report: provenance", + )?; + + let candidate = + assert_candidate_matches_main_projection(definition, source, source_sha, target)?; + + let paths = git::git(target, &["status", "--porcelain", "--untracked-files=all"])?; + let paths = paths.trim(); + if paths.is_empty() && (first.pr.is_some() || candidate.main_changed_count == 0) { + return Ok(Published::Unchanged); + } + if !paths.is_empty() { + // Stage only the declared diff. Ignored generated sources may + // legitimately belong to the projection, while build outputs and + // credentials never do. + let changed: Vec = report + .copied_paths + .iter() + .chain(report.deleted_paths.iter()) + .cloned() + .collect(); + contract(!changed.is_empty(), "Unexplained destination modifications")?; + let changed_set: BTreeSet<&str> = changed.iter().map(String::as_str).collect(); + + let actual = changed_paths(target)?; + contract( + actual + .iter() + .all(|path| changed_set.contains(path.as_str())), + "Unplanned destination modifications", + )?; + + let mut add_args = vec!["add", "--force", "--"]; + add_args.extend(changed.iter().map(String::as_str)); + git::git(target, &add_args)?; + + let staged = git::git(target, &["diff", "--cached", "--name-only", "-z"])?; + let staged: BTreeSet<&str> = staged.split('\0').filter(|s| !s.is_empty()).collect(); + contract( + staged.iter().all(|path| changed_set.contains(*path)), + "Unplanned paths staged for publication", + )?; + + let commit_message = format!( + "chore: project {} from Mono {}", + definition.name, + &source_sha[..12] + ); + git::git( + target, + &[ + "-c", + "user.name=dx-corp projector", + "-c", + "user.email=noreply@dx-corp.net", + "commit", + "-m", + &commit_message, + ], + )?; + } + + let final_state = read_publication_state(definition, api)?; + if final_state.held { + return Ok(Published::Held); + } + + push_sync_branch(definition, target)?; + + let body = publication_body(definition, report); + let url = create_or_update_pr(definition, api, final_state.pr.as_ref(), &body)?; + // Node evaluates `tree` last, inside the returned object literal, after + // the PR call has already resolved. + let tree = git::git(target, &["rev-parse", "HEAD^{tree}"])? + .trim() + .to_string(); + Ok(Published::PullRequest { + url, + engine: PUBLICATION_ENGINE.to_string(), + tree, + }) +} diff --git a/src/transport/github.rs b/src/transport/github.rs new file mode 100644 index 0000000..dc7f8cf --- /dev/null +++ b/src/transport/github.rs @@ -0,0 +1,453 @@ +//! The GitHub REST client (`RestApi`), a recorded double for tests +//! (`RecordedApi`), and publication state. Ports `github`, +//! `readPublicationState`, `publicationBody`, and the PR create/update calls +//! inside `publishPreparedTree` from `scripts/projections/transport.mjs`. + +use std::time::Duration; + +use serde_json::Value; + +use crate::definition::Definition; +use crate::git::is_sha; +use crate::report::Report; +use crate::{Error, Result, contract}; + +/// The transport boundary for every GitHub REST call this crate makes. +/// `call` returns `None` for an empty response body (Node's +/// `response.trim() ? JSON.parse(response) : null`). +pub trait GitHubApi { + fn call(&self, method: &str, endpoint: &str, body: Option<&Value>) -> Result>; +} + +/// A pull request as read back from `readPublicationState`. `html_url` is +/// captured opportunistically (as Node does implicitly by forwarding the raw +/// object) and is not itself part of the malformed-PR validation; a response +/// that omits it yields an empty string rather than a validation failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PullRequest { + pub number: u64, + pub head_sha: String, + pub labels: Vec, + pub html_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublicationState { + pub pr: Option, + pub held: bool, +} + +/// Percent-encodes `input` with the same character set JavaScript's +/// `encodeURIComponent` leaves unescaped (`A-Z a-z 0-9 - _ . ! ~ * ' ( )`), +/// operating byte-by-byte over `input`'s UTF-8 encoding so a multi-byte +/// character is encoded as its constituent `%XX` bytes, matching +/// `encodeURIComponent`'s behavior on a UTF-16 string. +pub fn encode_uri_component(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for byte in input.bytes() { + match byte { + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'_' + | b'.' + | b'!' + | b'~' + | b'*' + | b'\'' + | b'(' + | b')' => out.push(byte as char), + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +/// The REST endpoint listing the destination's open generated pull +/// requests: state `open`, base the declared default branch, head +/// `{owner}:{syncBranch}`. Factored out of `read_publication_state` so the +/// post-publication proof (`cli::run`) lists the same set of PRs the +/// publication path does -- the proof needs the raw array (it +/// discriminates "none", "exactly one", and "more than one" itself), not +/// `read_publication_state`'s validated at-most-one view. +pub fn open_sync_pr_endpoint(definition: &Definition) -> String { + let repository = definition.destination.repository.as_str(); + let owner = repository.split('/').next().unwrap_or_default(); + format!( + "repos/{repository}/pulls?state=open&base={}&head={}", + encode_uri_component(&definition.destination.branch), + encode_uri_component(&format!("{owner}:{}", definition.destination.sync_branch)), + ) +} + +/// Ports `readPublicationState`. Confirms destination repository identity, +/// visibility, and archival/disabled state; confirms the calling token's +/// GitHub App installation is scoped to exactly the destination repository; +/// then reads the (at most one) open sync PR and validates its shape. +pub fn read_publication_state( + definition: &Definition, + api: &dyn GitHubApi, +) -> Result { + let repository = definition.destination.repository.as_str(); + + let info = api + .call("GET", &format!("repos/{repository}"), None)? + .unwrap_or(Value::Null); + let identity_ok = info.get("full_name").and_then(Value::as_str) == Some(repository) + && info.get("archived").and_then(Value::as_bool) == Some(false) + && info.get("disabled").and_then(Value::as_bool) == Some(false) + && info.get("default_branch").and_then(Value::as_str) + == Some(definition.destination.branch.as_str()) + && info.get("visibility").and_then(Value::as_str) == Some(definition.visibility.as_str()); + contract( + identity_ok, + format!("Destination identity or visibility mismatch: {repository}"), + )?; + + // This workflow uses a repository-scoped GitHub App installation token. + // Unlike user tokens, installation tokens do not expose a meaningful + // repository.permissions.push claim. The token-mint action has already + // required contents:write and pull_requests:write; prove here that the + // resulting token is scoped to exactly the intended destination + // repository. + let installation = api + .call("GET", "installation/repositories?per_page=100", None)? + .unwrap_or(Value::Null); + let scope_ok = installation.get("total_count").and_then(Value::as_i64) == Some(1) + && installation + .get("repositories") + .and_then(Value::as_array) + .is_some_and(|repos| { + repos.len() == 1 + && repos[0].get("full_name").and_then(Value::as_str) == Some(repository) + }); + contract( + scope_ok, + format!("Destination App token scope mismatch: {repository}"), + )?; + + let endpoint = open_sync_pr_endpoint(definition); + let prs = api.call("GET", &endpoint, None)?.unwrap_or(Value::Null); + let prs_array = prs.as_array(); + contract( + prs_array.is_some_and(|prs| prs.len() <= 1), + "Unreadable or ambiguous destination PR state", + )?; + let prs_array = prs_array.expect("checked by the contract() call above"); + + let pr = match prs_array.first() { + None => None, + Some(pr) => Some(parse_pull_request(pr, repository, definition)?), + }; + let held = pr.as_ref().is_some_and(|pr| { + pr.labels + .iter() + .any(|l| l == &definition.destination.hold_label) + }); + Ok(PublicationState { pr, held }) +} + +/// Validates and converts a single raw PR `Value` into a `PullRequest`. +/// Ports the `pr && (...)` malformed-shape check from `readPublicationState`. +fn parse_pull_request( + pr: &Value, + repository: &str, + definition: &Definition, +) -> Result { + let number = pr.get("number").and_then(Value::as_u64); + let labels = pr.get("labels").and_then(Value::as_array); + let labels_ok = labels.is_some_and(|labels| { + labels + .iter() + .all(|label| label.get("name").and_then(Value::as_str).is_some()) + }); + let head_repo_full_name = pr + .get("head") + .and_then(|head| head.get("repo")) + .and_then(|repo| repo.get("full_name")) + .and_then(Value::as_str); + let head_ref = pr + .get("head") + .and_then(|head| head.get("ref")) + .and_then(Value::as_str); + let head_sha = pr + .get("head") + .and_then(|head| head.get("sha")) + .and_then(Value::as_str) + .unwrap_or(""); + let base_ref = pr + .get("base") + .and_then(|base| base.get("ref")) + .and_then(Value::as_str); + + let ok = number.is_some() + && labels_ok + && head_repo_full_name == Some(repository) + && head_ref == Some(definition.destination.sync_branch.as_str()) + && is_sha(head_sha) + && base_ref == Some(definition.destination.branch.as_str()); + contract(ok, "Malformed destination PR or sync-hold state")?; + + let labels = labels + .expect("checked by the contract() call above") + .iter() + .filter_map(|label| label.get("name").and_then(Value::as_str).map(String::from)) + .collect(); + let html_url = pr + .get("html_url") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + Ok(PullRequest { + number: number.expect("checked by the contract() call above"), + head_sha: head_sha.to_string(), + labels, + html_url, + }) +} + +/// Ports `publicationBody` exactly: the same lines, in the same order, +/// joined by `"\n"` (the array's trailing `""` element gives the result a +/// trailing newline). The provenance JSON in the marker comment is compact +/// (`serde_json::to_string`), in `Provenance`'s declared field order, which +/// matches Node's object-literal order. +pub fn publication_body(definition: &Definition, report: &Report) -> String { + let provenance_json = serde_json::to_string(&report.provenance).unwrap_or_default(); + let mut lines = vec![ + format!(""), + String::new(), + format!("## {} projection", definition.name), + String::new(), + format!( + "Mono source: {}@{}.", + definition.source_repository, report.provenance.source_sha + ), + format!( + "Prior destination base: {}.", + report.provenance.prior_projected_base + ), + format!( + "Projected content SHA-256: {}.", + report.provenance.content_digest + ), + String::new(), + format!( + "{} changed; {} deleted.", + report.copied_count, report.deleted_count + ), + String::new(), + ]; + lines.extend( + report + .copied_paths + .iter() + .take(20) + .map(|path| format!("- copy/update {path}")), + ); + lines.extend( + report + .deleted_paths + .iter() + .take(20) + .map(|path| format!("- delete {path}")), + ); + lines.push(String::new()); + lines.push( + "Mono owns projected source. Destination-owned CI and policy are preserved.".to_string(), + ); + lines.push( + "Apply the sync-hold label to this PR to suspend generated updates during intentional destination work." + .to_string(), + ); + lines.push( + "Source-side verification does not establish destination CI health. Review destination checks before merging." + .to_string(), + ); + lines.push(String::new()); + lines.push("Change-Origin: generated".to_string()); + lines.push(String::new()); + lines.join("\n") +} + +/// Ports the PR create/update calls inside `publishPreparedTree`. Returns +/// the confirmed PR's `html_url`. +pub fn create_or_update_pr( + definition: &Definition, + api: &dyn GitHubApi, + existing: Option<&PullRequest>, + body: &str, +) -> Result { + let repository = definition.destination.repository.as_str(); + let result = match existing { + Some(pr) => { + let endpoint = format!("repos/{repository}/pulls/{}", pr.number); + api.call( + "PATCH", + &endpoint, + Some(&serde_json::json!({ "body": body })), + )? + } + None => { + let endpoint = format!("repos/{repository}/pulls"); + let payload = serde_json::json!({ + "title": format!("chore: sync {} from Mono", definition.name), + "body": body, + "head": definition.destination.sync_branch, + "base": definition.destination.branch, + }); + api.call("POST", &endpoint, Some(&payload))? + } + } + .unwrap_or(Value::Null); + + let number_ok = result.get("number").and_then(Value::as_i64).is_some(); + let html_url = result.get("html_url").and_then(Value::as_str); + contract( + number_ok && html_url.is_some(), + "GitHub did not confirm the generated PR", + )?; + Ok(html_url + .expect("checked by the contract() call above") + .to_string()) +} + +/// The real GitHub REST client: `reqwest::blocking` against +/// `https://api.github.com/`, authenticated with a bearer token read from +/// `GH_TOKEN`. +pub struct RestApi { + client: reqwest::blocking::Client, + token: String, +} + +const BASE_URL: &str = "https://api.github.com/"; + +impl RestApi { + /// Reads the publication token from `GH_TOKEN` + /// (`Invalid("Missing publication token")` when absent) and builds a + /// client with a 30 second timeout. + pub fn from_env() -> Result { + let token = std::env::var("GH_TOKEN") + .map_err(|_| Error::Invalid("Missing publication token".into()))?; + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|error| Error::Invalid(format!("Failed to build GitHub client: {error}")))?; + Ok(RestApi { client, token }) + } +} + +impl GitHubApi for RestApi { + fn call(&self, method: &str, endpoint: &str, body: Option<&Value>) -> Result> { + let verb = reqwest::Method::from_bytes(method.as_bytes()) + .map_err(|_| Error::Invalid(format!("Invalid HTTP method: {method}")))?; + let url = format!("{BASE_URL}{endpoint}"); + let mut request = self + .client + .request(verb, &url) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", self.token), + ) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header(reqwest::header::USER_AGENT, "capobara"); + if let Some(body) = body { + request = request.json(body); + } + let response = request + .send() + .map_err(|error| Error::Invalid(format!("GitHub {method} {endpoint}: {error}")))?; + let status = response.status(); + if !status.is_success() { + return Err(Error::Invalid(format!( + "GitHub {method} {endpoint}: {status}" + ))); + } + let text = response + .text() + .map_err(|error| Error::Invalid(format!("GitHub {method} {endpoint}: {error}")))?; + if text.trim().is_empty() { + Ok(None) + } else { + let value: Value = serde_json::from_str(&text) + .map_err(|error| Error::Invalid(format!("GitHub {method} {endpoint}: {error}")))?; + Ok(Some(value)) + } + } +} + +/// A test double that replays a fixed sequence of `(method, endpoint)` +/// calls, each with a canned response. Panics on any call whose +/// `(method, endpoint)` does not match the next expected pair, or on a call +/// made once the sequence is exhausted, naming the endpoint either way. +#[cfg(any(test, feature = "recorded-api"))] +pub struct RecordedApi { + expected: std::cell::RefCell>, + calls: std::cell::RefCell)>>, +} + +#[cfg(any(test, feature = "recorded-api"))] +impl RecordedApi { + pub fn new(expected: Vec<(&str, &str, Value)>) -> RecordedApi { + RecordedApi { + expected: std::cell::RefCell::new( + expected + .into_iter() + .map(|(method, endpoint, response)| { + (method.to_string(), endpoint.to_string(), response) + }) + .collect(), + ), + calls: std::cell::RefCell::new(Vec::new()), + } + } + + /// The `(method, endpoint, body)` calls made so far, in order. + pub fn calls(&self) -> Vec<(String, String, Option)> { + self.calls.borrow().clone() + } +} + +#[cfg(any(test, feature = "recorded-api"))] +impl GitHubApi for RecordedApi { + fn call(&self, method: &str, endpoint: &str, body: Option<&Value>) -> Result> { + let next = self.expected.borrow_mut().pop_front(); + let Some((expected_method, expected_endpoint, response)) = next else { + panic!("RecordedApi: unexpected call to {method} {endpoint} (no calls remain)"); + }; + if expected_method != method || expected_endpoint != endpoint { + panic!( + "RecordedApi: unexpected call to {method} {endpoint} (expected {expected_method} {expected_endpoint})" + ); + } + self.calls + .borrow_mut() + .push((method.to_string(), endpoint.to_string(), body.cloned())); + Ok(if response.is_null() { + None + } else { + Some(response) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_uri_component_matches_javascript_semantics() { + assert_eq!(encode_uri_component(":"), "%3A"); + assert_eq!(encode_uri_component("/"), "%2F"); + assert_eq!( + encode_uri_component("owner:sync/branch"), + "owner%3Async%2Fbranch" + ); + assert_eq!( + encode_uri_component("A-Za-z0-9-_.!~*'()"), + "A-Za-z0-9-_.!~*'()" + ); + assert_eq!(encode_uri_component(" "), "%20"); + } +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs new file mode 100644 index 0000000..f040079 --- /dev/null +++ b/src/transport/mod.rs @@ -0,0 +1,21 @@ +//! Destination transport: the GitHub REST API client and publication state +//! (`github`), and the destination git checkout preparation/validation/ +//! publication pipeline (`git`), used by `capobara publish`. Ports +//! `readPublicationState`, `publicationBody`, `assertDestinationCheckout`, +//! `prepareDestination`, `assertCandidateMatchesMainProjection`, and +//! `publishPreparedTree` from `scripts/projections/transport.mjs`. + +pub mod git; +pub mod github; + +pub use git::{ + Candidate, PUBLICATION_ENGINE, Prepared, Published, assert_candidate_matches_main_projection, + assert_destination_checkout, changed_paths, prepare_destination, publish_prepared_tree, + push_sync_branch, +}; +#[cfg(any(test, feature = "recorded-api"))] +pub use github::RecordedApi; +pub use github::{ + GitHubApi, PublicationState, PullRequest, RestApi, create_or_update_pr, encode_uri_component, + publication_body, read_publication_state, +}; diff --git a/src/tree/fs.rs b/src/tree/fs.rs new file mode 100644 index 0000000..cbca758 --- /dev/null +++ b/src/tree/fs.rs @@ -0,0 +1,231 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::ErrorKind; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use super::{PathOpts, js_cmp, safe_path, sort_js}; +use crate::{Error, Result}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Entry { + pub content: Vec, + pub mode: u32, +} + +pub type Entries = BTreeMap; + +pub fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +pub fn contained_path(root: &Path, path: &str) -> Result { + safe_path(path, PathOpts::default())?; + let base = root.symlink_metadata()?; + if base.file_type().is_symlink() || !base.is_dir() { + return Err(Error::Contract( + "Projection root must be a real directory".into(), + )); + } + let mut current = root.to_path_buf(); + for part in path.split('/') { + match fs::read_dir(¤t) { + Ok(entries) => { + for entry in entries { + let name = entry?.file_name(); + let name = name.to_string_lossy(); + if name != part && name.eq_ignore_ascii_case(part) { + return Err(Error::Contract(format!( + "Case-colliding filesystem path: {path}" + ))); + } + } + } + Err(e) if e.kind() == ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + current.push(part); + match fs::symlink_metadata(¤t) { + Err(e) if e.kind() == ErrorKind::NotFound => continue, + Err(e) => return Err(e.into()), + Ok(meta) => { + if meta.file_type().is_symlink() { + return Err(Error::Contract(format!( + "Symlink at projection boundary: {path}" + ))); + } + if !meta.is_dir() && !meta.is_file() { + return Err(Error::Contract(format!( + "Special file at projection boundary: {path}" + ))); + } + } + } + } + Ok(current) +} + +pub fn files_under(root: &Path, skip: &dyn Fn(&str) -> bool) -> Result> { + fn visit( + root: &Path, + prefix: &str, + skip: &dyn Fn(&str) -> bool, + out: &mut Vec, + ) -> Result<()> { + let dir = if prefix.is_empty() { + root.to_path_buf() + } else { + contained_path(root, prefix)? + }; + for entry in fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + let path = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if name == ".git" || skip(&path) { + continue; + } + contained_path(root, &path)?; + let kind = entry.file_type()?; + // `!kind.is_dir()` would silently accept device/socket/fifo entries; + // this contract intentionally rejects anything but a regular file. + #[allow(clippy::filetype_is_file)] + let is_regular_file = kind.is_file(); + if kind.is_dir() { + visit(root, &path, skip, out)?; + } else if is_regular_file { + out.push(path); + } else { + return Err(Error::Contract(format!( + "Unsupported projection entry: {path}" + ))); + } + } + Ok(()) + } + let mut out = Vec::new(); + visit(root, "", skip, &mut out)?; + sort_js(&mut out); + Ok(out) +} + +pub fn read_entry(root: &Path, path: &str) -> Result { + let absolute = contained_path(root, path)?; + let meta = fs::symlink_metadata(&absolute)?; + if !meta.is_file() { + return Err(Error::Contract(format!( + "Expected regular projection file: {path}" + ))); + } + let mode = if meta.permissions().mode() & 0o111 != 0 { + 0o755 + } else { + 0o644 + }; + Ok(Entry { + content: fs::read(&absolute)?, + mode, + }) +} + +pub fn tree_digest(entries: &Entries) -> String { + let mut hash = Sha256::new(); + let mut ordered: Vec<(&String, &Entry)> = entries.iter().collect(); + ordered.sort_by(|(a, _), (b, _)| js_cmp(a, b)); + for (path, entry) in ordered { + let line = serde_json::to_string(&(path, entry.mode, sha256_hex(&entry.content))) + .unwrap_or_default(); + hash.update(line.as_bytes()); + hash.update(b"\n"); + } + hex::encode(hash.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::os::unix::fs::{PermissionsExt, symlink}; + + #[test] + fn digest_matches_node_format() { + let mut entries = Entries::new(); + entries.insert( + "b.txt".into(), + Entry { + content: b"hi\n".to_vec(), + mode: 0o755, + }, + ); + entries.insert( + "a.txt".into(), + Entry { + content: b"".to_vec(), + mode: 0o644, + }, + ); + // Computed with: node -e 'const {treeDigest}=await import("./scripts/projections/tree.mjs"); ...' + // Recorded once from the Node implementation and frozen here. + insta::assert_snapshot!(tree_digest(&entries)); + } + + #[test] + fn digest_orders_entries_like_javascript() { + let mut entries = Entries::new(); + entries.insert( + "\u{FF01}.md".into(), + Entry { + content: b"a".to_vec(), + mode: 0o644, + }, + ); + entries.insert( + "\u{1F389}.md".into(), + Entry { + content: b"b".to_vec(), + mode: 0o644, + }, + ); + // Recorded from Node with the one-liner below; the emoji entry is hashed first. + insta::assert_snapshot!(tree_digest(&entries)); + } + + #[test] + fn symlinks_and_special_files_fail_closed() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("sub")).unwrap(); + fs::write(dir.path().join("sub/real"), b"x").unwrap(); + symlink("real", dir.path().join("sub/link")).unwrap(); + symlink("sub", dir.path().join("dirlink")).unwrap(); + assert!(contained_path(dir.path(), "sub/link").is_err()); + assert!(contained_path(dir.path(), "dirlink/real").is_err()); + assert!(contained_path(dir.path(), "sub/missing/deeper").is_ok()); + assert!(files_under(dir.path(), &|_| false).is_err()); + } + + #[test] + fn case_aliases_are_rejected_on_read() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("README.md"), b"x").unwrap(); + assert!(contained_path(dir.path(), "readme.md").is_err()); + } + + #[test] + fn read_entry_normalizes_modes_and_lists_sorted() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("z"), b"1").unwrap(); + fs::write(dir.path().join("a"), b"2").unwrap(); + fs::set_permissions(dir.path().join("a"), fs::Permissions::from_mode(0o710)).unwrap(); + assert_eq!(read_entry(dir.path(), "a").unwrap().mode, 0o755); + assert_eq!(read_entry(dir.path(), "z").unwrap().mode, 0o644); + assert_eq!(files_under(dir.path(), &|_| false).unwrap(), vec!["a", "z"]); + fs::create_dir(dir.path().join(".git")).unwrap(); + fs::write(dir.path().join(".git/HEAD"), b"ref").unwrap(); + assert_eq!(files_under(dir.path(), &|_| false).unwrap(), vec!["a", "z"]); + } +} diff --git a/src/tree/mod.rs b/src/tree/mod.rs new file mode 100644 index 0000000..70c6996 --- /dev/null +++ b/src/tree/mod.rs @@ -0,0 +1,8 @@ +pub mod fs; +pub mod order; +pub mod path; +pub mod plan; +pub use fs::{Entries, Entry, contained_path, files_under, read_entry, sha256_hex, tree_digest}; +pub use order::{js_cmp, sort_js}; +pub use path::{Matcher, PathOpts, assert_portable_paths, has_wildcard, safe_path}; +pub use plan::{Plan, apply_tree, plan_tree}; diff --git a/src/tree/order.rs b/src/tree/order.rs new file mode 100644 index 0000000..4d9cca8 --- /dev/null +++ b/src/tree/order.rs @@ -0,0 +1,39 @@ +use std::cmp::Ordering; + +/// Compare two strings the way JavaScript's default string comparison does: +/// lexicographically by UTF-16 code unit. This differs from Rust's `str: Ord` +/// (UTF-8 byte order, equivalent to code point order) exactly when one string +/// contains an astral-plane character (U+10000+, encoded as a UTF-16 surrogate +/// pair whose leading unit is in 0xD800..=0xDBFF) and the other contains a BMP +/// character at or above that leading-surrogate range (e.g. U+E000-U+FFFF). +pub fn js_cmp(a: &str, b: &str) -> Ordering { + a.encode_utf16().cmp(b.encode_utf16()) +} + +/// Sort paths in place using [`js_cmp`], matching Node's `Array.prototype.sort()` +/// default ordering on strings. +pub fn sort_js(paths: &mut [String]) { + paths.sort_by(|a, b| js_cmp(a, b)); +} + +#[cfg(test)] +mod tests { + use super::js_cmp; + + #[test] + fn js_cmp_orders_like_javascript() { + use std::cmp::Ordering; + let js_cmp_bytes = |a: &str, b: &str| a.cmp(b); + // ASCII agrees with byte order. + assert_eq!(js_cmp("a", "b"), Ordering::Less); + assert_eq!(js_cmp("a/b", "a-b"), js_cmp_bytes("a/b", "a-b")); + // BMP vs astral: U+FF01 (fullwidth !) is E0-block; U+1F389 (party popper) is astral. + // JS: "!" (0xFF01) > "🎉" (0xD83C first unit), so the emoji sorts FIRST. + // Bytes: EF BC 81 < F0 9F 8E 89, so the fullwidth char sorts first. These must differ. + assert_eq!(js_cmp("\u{1F389}.md", "\u{FF01}.md"), Ordering::Less); + assert_eq!("\u{1F389}.md".cmp("\u{FF01}.md"), Ordering::Greater); + // Prefix rule: shorter string first, like JS. + assert_eq!(js_cmp("a", "ab"), Ordering::Less); + assert_eq!(js_cmp("ab", "ab"), Ordering::Equal); + } +} diff --git a/src/tree/path.rs b/src/tree/path.rs new file mode 100644 index 0000000..93ca58c --- /dev/null +++ b/src/tree/path.rs @@ -0,0 +1,201 @@ +use std::collections::HashMap; + +use regex::Regex; + +use crate::{Error, Result}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct PathOpts { + pub pattern: bool, + pub root: bool, +} + +pub fn has_wildcard(s: &str) -> bool { + s.chars().any(|c| matches!(c, '*' | '?' | '[' | ']')) +} + +fn unsafe_path(path: &str) -> Error { + Error::Contract(format!( + "Unsafe projection path: {}", + serde_json::to_string(path).unwrap_or_default() + )) +} + +pub fn safe_path(path: &str, opts: PathOpts) -> Result<&str> { + if opts.root && path == "." { + return Ok(path); + } + let bad_char = path + .chars() + .any(|c| c == '\\' || c == ':' || (c as u32) < 0x20 || c as u32 == 0x7f); + if path.is_empty() || path.starts_with('/') || bad_char { + return Err(unsafe_path(path)); + } + if path + .split('/') + .any(|p| p.is_empty() || p == "." || p == ".." || p.eq_ignore_ascii_case(".git")) + { + return Err(unsafe_path(path)); + } + if !opts.pattern && has_wildcard(path) { + return Err(unsafe_path(path)); + } + Ok(path) +} + +struct Rule { + regex: Regex, + prefix: Option, +} + +pub struct Matcher { + rules: Vec, +} + +impl Matcher { + pub fn new(patterns: &[String]) -> Result { + let mut rules = Vec::with_capacity(patterns.len()); + for pattern in patterns { + safe_path( + pattern, + PathOpts { + pattern: true, + root: false, + }, + )?; + let mut source = String::from("^"); + let mut chars = pattern.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '*' if chars.peek() == Some(&'*') => { + chars.next(); + source.push_str(".*"); + } + '*' => source.push_str("[^/]*"), + '.' | '+' | '?' | '^' | '$' | '{' | '}' | '(' | ')' | '|' | '[' | ']' + | '\\' => { + source.push('\\'); + source.push(c); + } + other => source.push(other), + } + } + source.push('$'); + let regex = Regex::new(&source) + .map_err(|e| Error::Invalid(format!("Bad pattern {pattern}: {e}")))?; + let prefix = pattern.strip_suffix("/**").map(str::to_owned); + rules.push(Rule { regex, prefix }); + } + Ok(Matcher { rules }) + } + + pub fn matches(&self, path: &str) -> bool { + self.rules + .iter() + .any(|r| r.prefix.as_deref() == Some(path) || r.regex.is_match(path)) + } +} + +pub fn assert_portable_paths<'a>(paths: impl IntoIterator) -> Result<()> { + let mut prefixes: HashMap = HashMap::new(); + for path in paths { + safe_path(path, PathOpts::default())?; + let parts: Vec<&str> = path.split('/').collect(); + for length in 1..=parts.len() { + let prefix = parts[..length].join("/"); + let key = prefix.to_lowercase(); + match prefixes.get(&key) { + Some(existing) if existing != &prefix => { + return Err(Error::Contract(format!( + "Case-colliding projection path: {path}" + ))); + } + Some(_) => {} + None => { + prefixes.insert(key, prefix); + } + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_path_rejects_traversal_git_and_control_chars() { + for bad in [ + "", + "/abs", + "a/../b", + "a/./b", + ".git/config", + "A/.GIT/x", + "a\\b", + "a:b", + "a\u{7f}b", + "a\nb", + "glob*", + ] { + assert!( + safe_path(bad, PathOpts::default()).is_err(), + "{bad:?} accepted" + ); + } + assert_eq!( + safe_path("src/lib.rs", PathOpts::default()).unwrap(), + "src/lib.rs" + ); + assert_eq!( + safe_path( + ".", + PathOpts { + root: true, + ..Default::default() + } + ) + .unwrap(), + "." + ); + assert!(safe_path(".", PathOpts::default()).is_err()); + assert_eq!( + safe_path( + "src/**", + PathOpts { + pattern: true, + ..Default::default() + } + ) + .unwrap(), + "src/**" + ); + } + + #[test] + fn matcher_star_is_one_segment_and_doublestar_crosses() { + let m = Matcher::new(&["src/*.rs".into(), "docs/**".into(), "LICENSE".into()]).unwrap(); + assert!(m.matches("src/lib.rs")); + assert!(!m.matches("src/a/lib.rs")); + assert!(m.matches("docs/a/b/c.md")); + assert!(m.matches("docs")); + assert!(!m.matches("docs2")); + assert!(m.matches("LICENSE")); + assert!(!m.matches("LICENSE.txt")); + } + + #[test] + fn matcher_escapes_regex_metacharacters() { + let m = Matcher::new(&["a.b".into()]).unwrap(); + assert!(m.matches("a.b")); + assert!(!m.matches("aXb")); + } + + #[test] + fn portable_paths_reject_case_aliases_at_any_depth() { + assert!(assert_portable_paths(["Src/a.rs", "src/b.rs"]).is_err()); + assert!(assert_portable_paths(["README.md", "readme.md"]).is_err()); + assert!(assert_portable_paths(["a/b", "a/c", "d"]).is_ok()); + } +} diff --git a/src/tree/plan.rs b/src/tree/plan.rs new file mode 100644 index 0000000..fc66e47 --- /dev/null +++ b/src/tree/plan.rs @@ -0,0 +1,207 @@ +use std::collections::HashSet; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use super::{Entries, assert_portable_paths, contained_path, read_entry, sort_js}; +use crate::{Error, Result}; + +#[derive(Debug)] +pub struct Plan { + pub entries: Entries, + pub copied_paths: Vec, + pub deleted_paths: Vec, +} + +impl Plan { + pub fn copied_count(&self) -> usize { + self.copied_paths.len() + } + pub fn deleted_count(&self) -> usize { + self.deleted_paths.len() + } +} + +pub fn plan_tree(target: &Path, entries: Entries, deletions: Vec) -> Result { + assert_portable_paths( + entries + .keys() + .map(String::as_str) + .chain(deletions.iter().map(String::as_str)), + )?; + let mut copied_paths = Vec::new(); + let mut lower = HashSet::new(); + for (path, entry) in &entries { + if !lower.insert(path.to_lowercase()) { + return Err(Error::Contract(format!( + "Case-colliding projection path: {path}" + ))); + } + let absolute = contained_path(target, path)?; + if entry.mode != 0o644 && entry.mode != 0o755 { + return Err(Error::Contract(format!("Invalid entry: {path}"))); + } + let old = if absolute.exists() { + Some(read_entry(target, path)?) + } else { + None + }; + if old.as_ref() != Some(entry) { + copied_paths.push(path.clone()); + } + } + for path in entries.keys() { + let mut parts: Vec<&str> = path.split('/').collect(); + while parts.len() > 1 { + parts.pop(); + if lower.contains(&parts.join("/").to_lowercase()) { + return Err(Error::Contract(format!( + "Projection file/directory collision: {path}" + ))); + } + } + } + let mut deleted_paths: Vec = deletions + .into_iter() + .collect::>() + .into_iter() + .collect(); + sort_js(&mut deleted_paths); + for path in &deleted_paths { + let absolute = contained_path(target, path)?; + if let Ok(meta) = fs::symlink_metadata(&absolute) + && !meta.is_file() + { + return Err(Error::Contract(format!( + "Cannot delete non-file projection path: {path}" + ))); + } + if entries.contains_key(path) { + return Err(Error::Contract(format!( + "Projection both writes and deletes {path}" + ))); + } + } + sort_js(&mut copied_paths); + Ok(Plan { + entries, + copied_paths, + deleted_paths, + }) +} + +pub fn apply_tree(target: &Path, plan: &Plan) -> Result<()> { + for path in plan.copied_paths.iter().chain(&plan.deleted_paths) { + contained_path(target, path)?; + } + for path in &plan.copied_paths { + let absolute = contained_path(target, path)?; + let entry = plan + .entries + .get(path) + .ok_or_else(|| Error::Invalid(format!("Plan lost entry {path}")))?; + if let Some(parent) = absolute.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&absolute, &entry.content)?; + fs::set_permissions(&absolute, fs::Permissions::from_mode(entry.mode))?; + } + if !plan.deleted_paths.is_empty() { + let target = target.canonicalize()?; + for path in &plan.deleted_paths { + let absolute = contained_path(&target, path)?; + if absolute.exists() { + fs::remove_file(&absolute)?; + } + let mut parent = absolute.parent().map(Path::to_path_buf); + while let Some(dir) = parent { + if dir == target || !dir.exists() || fs::read_dir(&dir)?.next().is_some() { + break; + } + fs::remove_dir(&dir)?; + parent = dir.parent().map(Path::to_path_buf); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tree::Entry; + use std::fs; + use std::os::unix::fs::PermissionsExt; + + fn entry(bytes: &[u8], mode: u32) -> Entry { + Entry { + content: bytes.to_vec(), + mode, + } + } + + #[test] + fn deterministic_mapping_binary_bytes_executable_bits_stale_deletion() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("same"), b"same").unwrap(); + fs::write(dir.path().join("stale"), b"old").unwrap(); + fs::create_dir_all(dir.path().join("deep/dir")).unwrap(); + fs::write(dir.path().join("deep/dir/gone"), b"x").unwrap(); + let mut entries = Entries::new(); + entries.insert("same".into(), entry(b"same", 0o644)); + entries.insert("bin/tool".into(), entry(&[0, 255, 10, 13], 0o755)); + let plan = plan_tree( + dir.path(), + entries, + vec!["deep/dir/gone".into(), "stale".into(), "stale".into()], + ) + .unwrap(); + assert_eq!(plan.copied_paths, vec!["bin/tool"]); + assert_eq!(plan.deleted_paths, vec!["deep/dir/gone", "stale"]); + apply_tree(dir.path(), &plan).unwrap(); + assert_eq!( + fs::read(dir.path().join("bin/tool")).unwrap(), + vec![0, 255, 10, 13] + ); + assert_eq!( + fs::metadata(dir.path().join("bin/tool")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o755 + ); + assert!(!dir.path().join("stale").exists()); + assert!(!dir.path().join("deep").exists()); + let plan2 = plan_tree(dir.path(), plan.entries.clone(), vec![]).unwrap(); + assert!(plan2.copied_paths.is_empty()); + } + + #[test] + fn file_directory_collisions_and_write_delete_conflicts_fail_before_writes() { + let dir = tempfile::tempdir().unwrap(); + let mut entries = Entries::new(); + entries.insert("a".into(), entry(b"", 0o644)); + entries.insert("a/b".into(), entry(b"", 0o644)); + assert!(plan_tree(dir.path(), entries, vec![]).is_err()); + let mut entries = Entries::new(); + entries.insert("x".into(), entry(b"", 0o644)); + assert!(plan_tree(dir.path(), entries, vec!["x".into()]).is_err()); + let mut entries = Entries::new(); + entries.insert("Y".into(), entry(b"", 0o644)); + entries.insert("y".into(), entry(b"", 0o644)); + assert!(plan_tree(dir.path(), entries, vec![]).is_err()); + assert!(fs::read_dir(dir.path()).unwrap().next().is_none()); + } + + #[test] + fn apply_tree_no_ops_on_empty_plan_without_touching_missing_target() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("missing"); + let plan = plan_tree(&target, Entries::new(), vec![]).unwrap(); + assert!(plan.copied_paths.is_empty()); + assert!(plan.deleted_paths.is_empty()); + assert!(apply_tree(&target, &plan).is_ok()); + assert!(!target.exists()); + } +} diff --git a/src/tree/snapshots/capobara__tree__fs__tests__digest_matches_node_format.snap b/src/tree/snapshots/capobara__tree__fs__tests__digest_matches_node_format.snap new file mode 100644 index 0000000..4bb06a5 --- /dev/null +++ b/src/tree/snapshots/capobara__tree__fs__tests__digest_matches_node_format.snap @@ -0,0 +1,6 @@ +--- +source: tools/capobara/src/tree/fs.rs +assertion_line: 172 +expression: tree_digest(&entries) +--- +98b3c58790e4620dce413ec843332f4c4b534e20cbb943a706b82c433afa9ba5 diff --git a/src/tree/snapshots/capobara__tree__fs__tests__digest_orders_entries_like_javascript.snap b/src/tree/snapshots/capobara__tree__fs__tests__digest_orders_entries_like_javascript.snap new file mode 100644 index 0000000..e7bffc5 --- /dev/null +++ b/src/tree/snapshots/capobara__tree__fs__tests__digest_orders_entries_like_javascript.snap @@ -0,0 +1,6 @@ +--- +source: tools/capobara/src/tree/fs.rs +assertion_line: 195 +expression: tree_digest(&entries) +--- +7f591253c9c2a6e8dcd19e6acc4503a7d6041554e65c831792d4f79c6cd3df8d diff --git a/tests/build.rs b/tests/build.rs new file mode 100644 index 0000000..67838c8 --- /dev/null +++ b/tests/build.rs @@ -0,0 +1,275 @@ +mod support; +use std::fs; +use std::path::Path; + +use capobara::build::{BuildInput, build_projection, check_public_entry}; +use capobara::definition::definition_from_value; +use capobara::tree::{Entry, apply_tree}; + +const SHA: &str = "1111111111111111111111111111111111111111"; +const BASE: &str = "2222222222222222222222222222222222222222"; +const TOOL: &str = "3333333333333333333333333333333333333333333333333333333333333333"; + +fn definition() -> serde_json::Value { + serde_json::json!({ + "schemaVersion": 1, "name": "sample", "class": "source-tree", "mode": "copy-v1", + "sourceRepository": "dx-corp/mono", "visibility": "public", + "mappings": [ + {"source": "pkg", "destination": ".", "include": ["src/**", "README.md"], "exclude": ["src/secret/**"]}, + {"source": ".", "destination": ".", "include": ["LICENSE"], "exclude": []} + ], + "destination": {"repository": "dx-corp/sample", "branch": "main", "syncBranch": "sync/mono-projection", "holdLabel": "sync-hold"}, + "destinationOwned": [".github/**", "SECURITY.md"], + "deletion": "owned-paths", "provenance": ".repository-projection.json", "validation": "tree-v1", + "outputManaged": ["src/**", "README.md", "LICENSE"] + }) +} + +fn no_sdk(_: &str) -> Option> { + None +} + +fn build( + source: &Path, + target: &Path, + raw: serde_json::Value, +) -> capobara::Result { + let loaded = definition_from_value(raw, &no_sdk)?; + build_projection( + BuildInput { + definition: &loaded.definition, + definition_text: &loaded.text, + source_root: source, + target_root: target, + source_sha: SHA, + prior_projected_base: BASE, + tool_digest: TOOL, + publication_eligible: true, + }, + &|_, _| unreachable!("copy-v1 never assembles"), + ) +} + +#[test] +fn deterministic_mapping_stale_deletion_and_destination_ownership() { + let source = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + for (p, b) in [ + ("pkg/src/a.rs", "a"), + ("pkg/src/secret/k", "k"), + ("pkg/README.md", "r"), + ("pkg/ignored.txt", "i"), + ("LICENSE", "l"), + ] { + let full = source.path().join(p); + fs::create_dir_all(full.parent().unwrap()).unwrap(); + fs::write(full, b).unwrap(); + } + for (p, b) in [ + ("src/stale.rs", "old"), + (".github/workflows/ci.yml", "owned"), + ("SECURITY.md", "owned"), + ("unrelated.txt", "kept"), + ] { + let full = target.path().join(p); + fs::create_dir_all(full.parent().unwrap()).unwrap(); + fs::write(full, b).unwrap(); + } + let built = build(source.path(), target.path(), definition()).unwrap(); + assert_eq!( + built.plan.copied_paths, + vec![ + ".repository-projection.json", + "LICENSE", + "README.md", + "src/a.rs" + ] + ); + assert_eq!(built.plan.deleted_paths, vec!["src/stale.rs"]); + assert!(!built.plan.entries.contains_key("src/secret/k")); + apply_tree(target.path(), &built.plan).unwrap(); + assert_eq!( + fs::read_to_string(target.path().join(".github/workflows/ci.yml")).unwrap(), + "owned" + ); + assert_eq!( + fs::read_to_string(target.path().join("unrelated.txt")).unwrap(), + "kept" + ); + let receipt: serde_json::Value = serde_json::from_slice( + &fs::read(target.path().join(".repository-projection.json")).unwrap(), + ) + .unwrap(); + assert_eq!(receipt["sourceSha"], SHA); + assert_eq!(receipt["contentDigest"], built.provenance.content_digest); + let again = build(source.path(), target.path(), definition()).unwrap(); + assert!(again.plan.copied_paths.is_empty() && again.plan.deleted_paths.is_empty()); + assert_eq!(again.provenance, built.provenance); +} + +#[test] +fn ownership_conflicts_and_overlapping_mappings_fail_before_a_write() { + let source = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + fs::create_dir_all(source.path().join("pkg/src")).unwrap(); + fs::write(source.path().join("pkg/src/a.rs"), "a").unwrap(); + fs::write(source.path().join("pkg/README.md"), "r").unwrap(); + fs::write(source.path().join("LICENSE"), "l").unwrap(); + let mut raw = definition(); + raw["destinationOwned"] = serde_json::json!(["README.md"]); + let err = build(source.path(), target.path(), raw) + .unwrap_err() + .to_string(); + assert_eq!( + err, + "Projection would overwrite destination-owned path: README.md" + ); + let mut raw = definition(); + raw["mappings"].as_array_mut().unwrap().push(serde_json::json!({"source": "pkg", "destination": ".", "include": ["README.md"], "exclude": []})); + let err = build(source.path(), target.path(), raw) + .unwrap_err() + .to_string(); + assert_eq!(err, "Overlapping mappings: README.md"); + assert!(fs::read_dir(target.path()).unwrap().next().is_none()); +} + +#[test] +fn private_files_credentials_and_keys_cannot_cross_a_public_boundary() { + let source = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + fs::create_dir_all(source.path().join("pkg/src")).unwrap(); + fs::write(source.path().join("pkg/README.md"), "r").unwrap(); + fs::write(source.path().join("LICENSE"), "l").unwrap(); + fs::write(source.path().join("pkg/src/.env"), "SECRET=1").unwrap(); + let err = build(source.path(), target.path(), definition()) + .unwrap_err() + .to_string(); + assert_eq!(err, "Private path in public projection: src/.env"); + fs::remove_file(source.path().join("pkg/src/.env")).unwrap(); + fs::write(source.path().join("pkg/src/.env.example"), "SECRET=").unwrap(); + fs::write( + source.path().join("pkg/src/key.pem"), + "-----BEGIN OPENSSH PRIVATE KEY-----\n", + ) + .unwrap(); + let err = build(source.path(), target.path(), definition()) + .unwrap_err() + .to_string(); + assert_eq!(err, "Private key in public projection: src/key.pem"); + + // Segment-boundary cases for the private-path rule (task-7 review + // addendum). The test definition's outputManaged is `src/**`, + // `README.md`, `LICENSE`, so every probe that must flow through a full + // build lives under `pkg/src/`; each case starts from a freshly emptied + // `pkg/src` so earlier probe files never leak into a later assertion. + let reset_src = || { + let _ = fs::remove_dir_all(source.path().join("pkg/src")); + fs::create_dir_all(source.path().join("pkg/src")).unwrap(); + }; + + // src/.env.example alone is allowed: the sole exception, and only when + // it is the path's last segment. + reset_src(); + fs::write(source.path().join("pkg/src/.env.example"), "x").unwrap(); + build(source.path(), target.path(), definition()).unwrap(); + + // src/.env.example/x is private: the exception is for the exact last + // segment, not for paths beneath a directory of that name. + reset_src(); + fs::create_dir_all(source.path().join("pkg/src/.env.example")).unwrap(); + fs::write(source.path().join("pkg/src/.env.example/x"), "x").unwrap(); + let err = build(source.path(), target.path(), definition()) + .unwrap_err() + .to_string(); + assert_eq!(err, "Private path in public projection: src/.env.example/x"); + + // src/.env.examples is private: not an exact match for the exception. + reset_src(); + fs::write(source.path().join("pkg/src/.env.examples"), "x").unwrap(); + let err = build(source.path(), target.path(), definition()) + .unwrap_err() + .to_string(); + assert_eq!(err, "Private path in public projection: src/.env.examples"); + + // src/.env.example.bak is private: same reason. + reset_src(); + fs::write(source.path().join("pkg/src/.env.example.bak"), "x").unwrap(); + let err = build(source.path(), target.path(), definition()) + .unwrap_err() + .to_string(); + assert_eq!( + err, + "Private path in public projection: src/.env.example.bak" + ); + + // src/.envrc is allowed: it neither is the exact ".env" segment nor + // starts with the ".env." prefix. + reset_src(); + fs::write(source.path().join("pkg/src/.envrc"), "x").unwrap(); + build(source.path(), target.path(), definition()).unwrap(); + + // src/id_rsa.pub is allowed: only the exact "id_rsa" segment is private. + reset_src(); + fs::write(source.path().join("pkg/src/id_rsa.pub"), "x").unwrap(); + build(source.path(), target.path(), definition()).unwrap(); + + // src/id_rsa is private. + reset_src(); + fs::write(source.path().join("pkg/src/id_rsa"), "x").unwrap(); + let err = build(source.path(), target.path(), definition()) + .unwrap_err() + .to_string(); + assert_eq!(err, "Private path in public projection: src/id_rsa"); + + // src/gha-creds-abc.json is private. + reset_src(); + fs::write(source.path().join("pkg/src/gha-creds-abc.json"), "x").unwrap(); + let err = build(source.path(), target.path(), definition()) + .unwrap_err() + .to_string(); + assert_eq!( + err, + "Private path in public projection: src/gha-creds-abc.json" + ); + + // .agents/x is private, but it can never reach a full build here: it + // matches no mapping's include list and outputManaged does not cover it, + // so it is never a candidate entry. check_public_entry enforces the rule + // directly instead, independent of the managed-boundary check. + let def = definition_from_value(definition(), &no_sdk) + .unwrap() + .definition; + let entry = Entry { + content: b"x".to_vec(), + mode: 0o644, + }; + let err = check_public_entry(".agents/x", &entry, &def) + .unwrap_err() + .to_string(); + assert_eq!(err, "Private path in public projection: .agents/x"); +} + +#[test] +fn source_and_destination_symlinks_fail_closed_without_touching_outside_files() { + use std::os::unix::fs::symlink; + let source = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + fs::write(outside.path().join("victim"), "v").unwrap(); + fs::create_dir_all(source.path().join("pkg/src")).unwrap(); + fs::write(source.path().join("pkg/README.md"), "r").unwrap(); + fs::write(source.path().join("LICENSE"), "l").unwrap(); + symlink( + outside.path().join("victim"), + source.path().join("pkg/src/link.rs"), + ) + .unwrap(); + assert!(build(source.path(), target.path(), definition()).is_err()); + fs::remove_file(source.path().join("pkg/src/link.rs")).unwrap(); + symlink(outside.path(), target.path().join("src")).unwrap(); + assert!(build(source.path(), target.path(), definition()).is_err()); + assert_eq!( + fs::read_to_string(outside.path().join("victim")).unwrap(), + "v" + ); +} diff --git a/tests/catalog.rs b/tests/catalog.rs new file mode 100644 index 0000000..1d418e1 --- /dev/null +++ b/tests/catalog.rs @@ -0,0 +1,248 @@ +mod support; +use capobara::catalog::{assert_main_authorized_revision, publication_matrix, read_catalog}; +use support::Repo; + +fn no_sdk(_: &str) -> Option> { + None +} + +fn definition(name: &str) -> Vec { + serde_json::json!({ + "schemaVersion": 1, "name": name, "class": "source-tree", "mode": "copy-v1", + "sourceRepository": "dx-corp/mono", "visibility": "public", + "mappings": [{"source": format!("pkgs/{name}"), "destination": ".", "include": ["src/**"], "exclude": []}], + "destination": {"repository": format!("dx-corp/{name}"), "branch": "main", "syncBranch": "sync/mono-projection", "holdLabel": "sync-hold"}, + "destinationOwned": [".github/**", "SECURITY.md"], + "deletion": "owned-paths", "provenance": ".repository-projection.json", "validation": "tree-v1", + "outputManaged": ["src/**"] + }).to_string().into_bytes() +} + +#[test] +fn publication_matrix_selects_latest_relevant_revision_and_rejects_unauthorized_heads() { + let repo = Repo::init("https://github.com/dx-corp/mono.git"); + repo.write( + "config/projections/repositories.json", + br#"{"schemaVersion":1,"sourceRepository":"dx-corp/mono","projections":["alpha","beta"]}"#, + ); + repo.write("config/projections/alpha.json", &definition("alpha")); + repo.write("config/projections/beta.json", &definition("beta")); + repo.write("pkgs/alpha/src/a.rs", b"a"); + repo.write("pkgs/beta/src/b.rs", b"b"); + let first = repo.commit("both"); + repo.write("pkgs/beta/src/b.rs", b"b2"); + let second = repo.commit("beta only"); + repo.write("unrelated.txt", b"x"); + let third = repo.commit("unrelated"); + repo.set_remote_main(&third); + assert_eq!(read_catalog(repo.path(), &no_sdk).unwrap().len(), 2); + let matrix = publication_matrix(repo.path(), "all", &no_sdk).unwrap(); + let by_name = |n: &str| { + matrix + .include + .iter() + .find(|e| e.name == n) + .unwrap() + .source_sha + .clone() + }; + assert_eq!(by_name("alpha"), first); + assert_eq!(by_name("beta"), second); + + // Pin the exact serialized JSON: key order, camelCase, compactness, + // and entry order. `matrix.include`'s order is catalog-file order + // (alpha, beta), matching Node's `definitions.filter(...).map(...)`. + // Nothing else in this suite asserts on the wire bytes; a later + // refactor (e.g. to `serde_json::Value`/`OrderedValue`, or an added + // field) could otherwise silently reorder or rename keys. + assert_eq!( + serde_json::to_string(&matrix).unwrap(), + format!( + r#"{{"include":[{{"name":"alpha","repository":"dx-corp/alpha","sourceSha":"{first}"}},{{"name":"beta","repository":"dx-corp/beta","sourceSha":"{second}"}}]}}"# + ) + ); + + // `assert_main_authorized_revision` returns the authority SHA, not its + // argument -- Node asserts this explicitly (catalog.test.mjs:82). + assert_eq!( + assert_main_authorized_revision(repo.path(), &first).unwrap(), + third + ); + + // Match the exact message, not just "is an error": either assertion + // below would also pass for an unrelated Error::Io (e.g. a typo'd + // fixture path or a missing git binary). + let unknown = publication_matrix(repo.path(), "gamma", &no_sdk).unwrap_err(); + assert_eq!(unknown.to_string(), "Unknown projection: gamma"); + + // HEAD ahead of remote main is not authorized. + repo.write("pkgs/alpha/src/a.rs", b"a2"); + repo.commit("unpublished"); + let unauthorized = publication_matrix(repo.path(), "all", &no_sdk).unwrap_err(); + assert!( + unauthorized + .to_string() + .starts_with("Projection revision is not authorized by refs/remotes/origin/main: "), + "{unauthorized}" + ); +} + +/// The shared-source-revision-group algorithm (`examples`, `deixic-node`, +/// `deixic-python`) has no coverage in the brief's Step-1 test above, +/// whose two fixture names (`alpha`, `beta`) never form a group. +/// Transcribed from Node's "examples and both SDKs select one latest +/// relevant source snapshot" (`catalog.test.mjs:112-200`): a change to any +/// one coupled member's inputs selects that revision for all three +/// members; an uncoupled projection is unaffected; a group member absent +/// from the catalog fails with Node's exact `Missing coupled projection: +/// ` message (`catalog.mjs:34`). +#[test] +fn coupled_projections_share_one_source_revision_and_require_every_member_present() { + let repo = Repo::init("https://github.com/dx-corp/mono.git"); + let names = ["examples", "deixic-node", "deixic-python", "other"]; + repo.write( + "config/projections/repositories.json", + br#"{"schemaVersion":1,"sourceRepository":"dx-corp/mono","projections":["examples","deixic-node","deixic-python","other"]}"#, + ); + for name in names { + repo.write( + &format!("config/projections/{name}.json"), + &definition(name), + ); + repo.write(&format!("pkgs/{name}/src/lib.rs"), name.as_bytes()); + } + let base = repo.commit("base"); + repo.set_remote_main(&base); + + // Only `examples`'s own input changes. `deixic-node` and + // `deixic-python` did not change, but they select the same new + // revision because `SHARED_SOURCE_REVISION_GROUPS` couples all three. + // `other` is not in the group and keeps selecting `base`. + repo.write("pkgs/examples/src/lib.rs", b"examples v2"); + let examples_change = repo.commit("examples input"); + repo.set_remote_main(&examples_change); + + let matrix = publication_matrix(repo.path(), "all", &no_sdk).unwrap(); + let sha = |n: &str| { + matrix + .include + .iter() + .find(|e| e.name == n) + .unwrap() + .source_sha + .clone() + }; + assert_eq!(sha("examples"), examples_change); + assert_eq!(sha("deixic-node"), examples_change); + assert_eq!(sha("deixic-python"), examples_change); + assert_eq!(sha("other"), base); + + // Drop `deixic-node` from the catalog. It is still one of + // `examples`'s coupled group members -- the group comes from + // `SHARED_SOURCE_REVISION_GROUPS`, not from the catalog's current + // membership -- so it is "missing", not merely "unpublished". + repo.write( + "config/projections/repositories.json", + br#"{"schemaVersion":1,"sourceRepository":"dx-corp/mono","projections":["examples","deixic-python","other"]}"#, + ); + let dropped = repo.commit("drop deixic-node from the catalog"); + repo.set_remote_main(&dropped); + let err = publication_matrix(repo.path(), "examples", &no_sdk).unwrap_err(); + assert_eq!(err.to_string(), "Missing coupled projection: deixic-node"); +} + +#[allow( + clippy::disallowed_methods, + reason = "integration test executes the capobara binary" +)] +fn capobara() -> std::process::Command { + std::process::Command::new(env!("CARGO_BIN_EXE_capobara")) +} + +/// `catalog check`/`catalog matrix`'s `--root` resolution. Node's `ROOT` +/// (`catalog.mjs:6`) is derived from the script's own file location, so +/// it works from any current directory; this crate has no script location +/// to derive from, so it resolves the same way a human would from a +/// shell -- `git rev-parse --show-toplevel` -- unless `--root` is given +/// explicitly, and reports a clean message when neither is available. +#[test] +fn catalog_root_defaults_to_the_git_toplevel_and_can_be_overridden_or_fail_cleanly() { + let repo = Repo::init("https://github.com/dx-corp/mono.git"); + repo.write( + "config/projections/repositories.json", + br#"{"schemaVersion":1,"sourceRepository":"dx-corp/mono","projections":["alpha"]}"#, + ); + repo.write("config/projections/alpha.json", &definition("alpha")); + repo.write("pkgs/alpha/src/a.rs", b"a"); + let sha = repo.commit("alpha"); + repo.set_remote_main(&sha); + + // Default: run from a subdirectory of the checkout, no --root. + let subdir = repo.path().join("pkgs/alpha/src"); + let out = capobara() + .args(["catalog", "check"]) + .current_dir(&subdir) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "1 repository projections validated" + ); + + // An explicit --root overrides the git lookup and works from an + // unrelated current directory. `--root` precedes the subcommand here. + let unrelated = tempfile::tempdir().unwrap(); + let out = capobara() + .args(["catalog", "--root"]) + .arg(repo.path()) + .arg("check") + .current_dir(unrelated.path()) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + + // `--root` is `global = true`, so it also parses trailing the + // subcommand: `capobara catalog check --root

`. + let out = capobara() + .args(["catalog", "check", "--root"]) + .arg(repo.path()) + .current_dir(unrelated.path()) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + + // Neither --root nor a git checkout to fall back to: the ruling's + // exact message, exit 1 (catalog's own exit-code convention). + // `GIT_CEILING_DIRECTORIES` stops `git rev-parse --show-toplevel` from + // walking past the tempdir into whatever git repository (if any) + // happens to contain the host's temp directory, so this assertion + // does not depend on the host's temp directory being outside any git + // work tree. + let out = capobara() + .args(["catalog", "check"]) + .current_dir(unrelated.path()) + .env("GIT_CEILING_DIRECTORIES", std::env::temp_dir()) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert_eq!( + String::from_utf8_lossy(&out.stderr).trim(), + "Not inside a git repository; pass --root" + ); +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..3a95472 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,29 @@ +use std::process::Command; + +fn bin() -> Command { + #[allow( + clippy::disallowed_methods, + reason = "integration test executes the capobara binary" + )] + Command::new(env!("CARGO_BIN_EXE_capobara")) +} + +#[test] +fn help_lists_every_subcommand() { + let out = bin().arg("--help").output().unwrap(); + assert!(out.status.success()); + let text = String::from_utf8(out.stdout).unwrap(); + for name in [ + "catalog", + "plan", + "apply", + "verify", + "check", + "prepare", + "preflight", + "publish", + "run", + ] { + assert!(text.contains(name), "missing subcommand {name}"); + } +} diff --git a/tests/definition_coverage.rs b/tests/definition_coverage.rs new file mode 100644 index 0000000..44074d0 --- /dev/null +++ b/tests/definition_coverage.rs @@ -0,0 +1,180 @@ +//! Guards against a crate file silently falling outside the self-projection +//! (M3: "a later `src/bin/`, `benches/`, `rust-toolchain.toml`, or a data +//! file `build.rs` reads would be omitted from the definition, omitted from +//! the test's scratch copy, and omitted from the lock script ... and no +//! check exists that could have said anything"). These tests are the check. +//! Gated on `mono-fixtures` because they read files that only exist inside +//! `dx-corp/mono` (the crate's own git history, `config/projections/`). + +mod support; + +/// Paths under `rust/tools/capobara/` that are git-tracked but deliberately +/// NOT part of the public self-projection, with the reason each is +/// excluded. Every other tracked path must be matched by +/// `config/projections/capobara.json`'s include/exclude globs. +const UNPROJECTED: &[(&str, &str)] = &[ + ( + "scripts/standalone-lock.sh", + "Mono-only maintenance script; it regenerates the standalone Cargo.lock \ + from rust/Cargo.lock, the shared workspace lock, which does not exist \ + in the standalone public repository.", + ), + ( + "scripts/equivalence.sh", + "Mono-only equivalence harness; it builds Capobara at Mono revisions and \ + diffs against the Node projector, which the public repository lacks.", + ), + ( + "EQUIVALENCE.md", + "Record of the Mono-side equivalence runs (Mono SHAs, destination clones); \ + internal evidence, not part of the published crate.", + ), +]; + +#[test] +#[cfg_attr(not(feature = "mono-fixtures"), ignore)] +fn every_tracked_crate_file_is_projected_or_explicitly_unprojected() { + let crate_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let definition = support::capobara_definition(); + let mapping = support::crate_mapping(&definition); + let tracked = support::git_ls_files(&crate_dir); + assert!( + !tracked.is_empty(), + "git ls-files returned nothing under {}; is this a git checkout?", + crate_dir.display() + ); + + // "Handled by the definition" means some include pattern names the path + // at all -- whether it ends up projected (included and not excluded) or + // deliberately excluded (included, then excluded; e.g. + // tests/fixtures/definitions/**, which the definition excludes on + // purpose). Only a path NO include pattern names at all falls outside + // what the definition's machinery can even decide about, and that's the + // gap UNPROJECTED exists to name explicitly. + let allowlisted: Vec<&str> = UNPROJECTED.iter().map(|(path, _)| *path).collect(); + let unmatched: Vec = tracked + .iter() + .filter(|path| { + !support::is_named_by_include(mapping, path) && !allowlisted.contains(&path.as_str()) + }) + .cloned() + .collect(); + assert!( + unmatched.is_empty(), + "these git-tracked files under rust/tools/capobara/ are named by \ + none of config/projections/capobara.json's include patterns and \ + are not in tests/definition_coverage.rs's UNPROJECTED allowlist \ + -- add each one to whichever is correct: {unmatched:?}" + ); + + for (path, _reason) in UNPROJECTED { + assert!( + tracked.iter().any(|tracked_path| tracked_path == path), + "UNPROJECTED names {path}, which is no longer a tracked file; remove the stale entry" + ); + // The allowlist's whole invariant is "no include pattern names this + // path" -- a path an include pattern DOES name (whether ultimately + // projected, or deliberately excluded, like + // tests/fixtures/definitions/**) is already handled by the + // definition itself and needs no allowlist entry. `is_projected` + // alone would miss the excluded-but-named case (it's `false` for + // both reasons), so this checks `is_named_by_include`, not + // `is_projected`. + assert!( + !support::is_named_by_include(mapping, path), + "{path} is in UNPROJECTED but an include pattern in \ + config/projections/capobara.json already names it (whether or \ + not an exclude pattern then removes it) -- the definition \ + already handles this path; remove the stale allowlist entry" + ); + } +} + +#[test] +#[cfg_attr(not(feature = "mono-fixtures"), ignore)] +fn standalone_lock_script_copies_exactly_the_projected_source_set() { + // scripts/standalone-lock.sh keeps its own literal copy list (a root + // `for f in ...` loop plus `cp -R` directory lines) instead of deriving + // it dynamically -- see the fix-round-2 report for why. This test is + // the mechanical check that list owes in exchange: its declared + // top-level copy set, normalized ("dir" for a `cp -R ... dir` line, + // "dir/**" is then compared against the definition's own directory + // patterns), must equal the definition's top-level include set minus + // `Cargo.lock` -- the one entry the script deliberately does not copy + // from the crate, because regenerating it *is* the script's job. + let crate_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let script = std::fs::read_to_string(crate_dir.join("scripts/standalone-lock.sh")).unwrap(); + + let mut script_files: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let for_line = script + .lines() + .map(str::trim_start) + .find(|line| line.starts_with("for f in ")) + .expect("scripts/standalone-lock.sh has no `for f in ...; do` root-file copy loop"); + let names = for_line + .trim_start_matches("for f in ") + .split(';') + .next() + .expect("`for f in ...; do` line has no `;`"); + for name in names.split_whitespace() { + script_files.insert(name.to_owned()); + } + for line in script.lines() { + let line = line.trim_start(); + if let Some(rest) = line.strip_prefix("cp -R \"$crate_dir/") { + let dir = rest + .split('"') + .next() + .expect("`cp -R \"$crate_dir/...\"` line has no closing quote"); + script_files.insert(format!("{dir}/**")); + } + } + assert!( + script.contains("$scratch/Cargo.lock"), + "scripts/standalone-lock.sh no longer seeds Cargo.lock from the workspace lock; \ + update this test's Cargo.lock special-casing if that's intentional" + ); + + let definition = support::capobara_definition(); + let mapping = support::crate_mapping(&definition); + let mut projected_top_level: std::collections::BTreeSet = + mapping.include.iter().cloned().collect(); + projected_top_level.remove("Cargo.lock"); + + assert_eq!( + script_files, projected_top_level, + "scripts/standalone-lock.sh's copy list has drifted from \ + config/projections/capobara.json's include list (Cargo.lock is \ + correctly excluded from both sides -- the script regenerates it \ + instead of copying it)" + ); + + // The script also deletes what the definition excludes + // (`rm -rf "$scratch/tests/fixtures/definitions"`, matching + // `exclude: ["tests/fixtures/definitions/**"]`) so its scratch copy is + // the true projected set, not a superset -- cross-check that too, the + // same way: parse the script's `rm -rf "$scratch/..."` lines and + // compare against `mapping.exclude` with each pattern's trailing + // `/**` stripped. + let mut script_deleted: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for line in script.lines() { + let line = line.trim_start(); + if let Some(rest) = line.strip_prefix("rm -rf \"$scratch/") { + let dir = rest + .split('"') + .next() + .expect("`rm -rf \"$scratch/...\"` line has no closing quote"); + script_deleted.insert(dir.to_owned()); + } + } + let excluded_top_level: std::collections::BTreeSet = mapping + .exclude + .iter() + .map(|pattern| pattern.strip_suffix("/**").unwrap_or(pattern).to_owned()) + .collect(); + assert_eq!( + script_deleted, excluded_top_level, + "scripts/standalone-lock.sh's `rm -rf` deletions have drifted from \ + config/projections/capobara.json's exclude list" + ); +} diff --git a/tests/equivalence.rs b/tests/equivalence.rs new file mode 100644 index 0000000..18d6bcd --- /dev/null +++ b/tests/equivalence.rs @@ -0,0 +1,270 @@ +//! Runs `scripts/equivalence.sh` and requires a clean table. +//! +//! The harness clones ten public destination repositories, adds a detached +//! Mono worktree per source revision and builds a release binary in each, so +//! it is not part of the ordinary crate suite: it is `#[ignore]`d and the +//! source revisions must be named explicitly. +//! +//! ```sh +//! CAPOBARA_EQUIVALENCE_SOURCE_SHAS=" " \ +//! cargo test --manifest-path rust/Cargo.toml --locked -p capobara \ +//! --test equivalence -- --ignored --nocapture +//! ``` +//! +//! **This writes to the Mono checkout it is run from**: the script registers +//! a detached worktree per revision under that repository's `.git/worktrees/` +//! and removes them again in an `EXIT` trap. `CAPOBARA_EQUIVALENCE_KEEP=1` +//! suppresses the cleanup for debugging; this test never sets it, so a run +//! that fails still leaves the checkout as it found it. +//! `CAPOBARA_EQUIVALENCE_SCRATCH` chooses where the worktrees, clones and +//! per-run artefacts live; see the script's header for the rest. +//! +//! The script's own exit status is the assertion: it exits 1 if any row +//! differs in anything but the two sanctioned differences, the receipt's +//! `toolDigest` and the catalog matrix's `sourceSha` (see `EQUIVALENCE.md`). + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// `rust/tools/capobara` -> the Mono checkout containing it. The harness +/// needs the checkout, not the crate: it reads `config/projections/` and +/// `scripts/projections/` from it and adds worktrees to it. +fn mono_root() -> PathBuf { + let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + crate_dir + .ancestors() + .nth(3) + .expect("the crate lives at /rust/tools/capobara") + .to_path_buf() +} + +/// The data rows of the table whose header line starts with `header`, i.e. +/// every `| ` line after that header and its `| --- |` separator, stopping at +/// the first line that is not a row. The two tables are scoped separately on +/// purpose: catalog rows also contain `| |`, so counting `| {short} |` +/// across the whole output silently mixes them into the projection count. +fn rows_of<'a>(table: &'a str, header: &str) -> Vec<&'a str> { + table + .lines() + .skip_while(|line| !line.starts_with(header)) + .skip(2) + .take_while(|line| line.starts_with("| ")) + .collect() +} + +fn projection_rows(table: &str) -> Vec<&str> { + rows_of(table, "| projection | sha |") +} + +fn catalog_rows(table: &str) -> Vec<&str> { + rows_of(table, "| sha | catalog command |") +} + +/// One `key=value` field of the script's `EQUIVALENCE-SUMMARY` line. +fn summary_field(table: &str, key: &str) -> usize { + let line = table + .lines() + .find(|line| line.starts_with("EQUIVALENCE-SUMMARY ")) + .unwrap_or_else(|| panic!("no EQUIVALENCE-SUMMARY line in:\n{table}")); + line.split_whitespace() + .find_map(|field| field.strip_prefix(&format!("{key}="))) + .and_then(|value| value.parse().ok()) + .unwrap_or_else(|| panic!("no numeric {key} in: {line}")) +} + +/// Checks one run's output against its own `EQUIVALENCE-SUMMARY` line. Shared +/// by the live harness test below and by the recorded-output test, so the +/// counting the gate depends on is exercised by the ordinary crate suite and +/// cannot rot while the harness test stays `#[ignore]`d. +fn check_table(table: &str, revisions: &[&str]) { + let projections = summary_field(table, "projections"); + let cases = summary_field(table, "cases"); + let rows = projection_rows(table); + assert_eq!( + rows.len(), + summary_field(table, "rows"), + "projection table has {} rows, summary says {}\n{table}", + rows.len(), + summary_field(table, "rows") + ); + assert_eq!(rows.len(), projections * cases * revisions.len(), "{table}"); + assert_eq!( + catalog_rows(table).len(), + summary_field(table, "catalog_rows"), + "{table}" + ); + + // Each revision must occupy its own share of the PROJECTION rows, so a run + // that projected one revision twice cannot masquerade as a run of two. + for revision in revisions { + let short = &revision[..12]; + let mine = rows + .iter() + .filter(|line| line.contains(&format!("| {short} |"))) + .count(); + assert_eq!( + mine, + projections * cases, + "revision {short} occupies {mine} projection rows, expected {}\n{table}", + projections * cases + ); + } +} + +/// Fenced blocks of `EQUIVALENCE.md` that hold a recorded harness run. +fn recorded_runs(markdown: &str) -> Vec { + let mut runs = Vec::new(); + let mut current: Option> = None; + for line in markdown.lines() { + if line.starts_with("```") { + match current.take() { + Some(block) => { + let text = block.join("\n"); + if text.contains("EQUIVALENCE-SUMMARY ") { + runs.push(text); + } + } + None => current = Some(Vec::new()), + } + } else if let Some(block) = current.as_mut() { + block.push(line); + } + } + runs +} + +/// The row counting the live gate relies on, run against the real recorded +/// output committed in `EQUIVALENCE.md`. This is the regression guard: the +/// live assertion is unreachable until a run goes fully green (the exit-status +/// assert fires first), so without this the counting could be wrong for months +/// and surface only at cutover -- which is exactly how it was wrong before. +#[test] +// `EQUIVALENCE.md` is Mono-internal and deliberately not projected (see +// `tests/definition_coverage.rs`'s UNPROJECTED allowlist), but this file IS +// projected -- `config/projections/capobara.json` includes `tests/**`. Without +// this gate the published `dx-corp/capobara` would carry a test that reads a +// file its tree does not contain, and `cargo test` there would fail on a fresh +// clone against a destination whose `main` requires a `ci` check. +// +// Gating rather than excluding the file: `Cargo.toml` is projected verbatim and +// declares `[[test]] name = "equivalence"` with an explicit `path`, and the +// crate sets `autotests = false`. Removing only the file leaves that entry +// dangling, and `cargo test` then fails with `can't find integration-test +// 'equivalence'` -- the same outcome by a different route. Both were measured; +// see the task-17 fix-round-2 report. +#[cfg_attr( + not(feature = "mono-fixtures"), + ignore = "reads EQUIVALENCE.md, which exists only inside dx-corp/mono" +)] +fn the_recorded_tables_satisfy_the_assertions_the_live_gate_makes() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("EQUIVALENCE.md"); + let markdown = std::fs::read_to_string(&path).expect("EQUIVALENCE.md is committed"); + let runs = recorded_runs(&markdown); + assert_eq!( + runs.len(), + 2, + "expected two recorded runs in EQUIVALENCE.md" + ); + + for run in &runs { + // The revisions of a recorded run are whichever short SHAs its + // projection rows name, in first-seen order. + let mut revisions: Vec<&str> = Vec::new(); + for row in projection_rows(run) { + let short = row.split('|').nth(2).map(str::trim).unwrap_or_default(); + if short.len() == 12 && !revisions.contains(&short) { + revisions.push(short); + } + } + assert_eq!(revisions.len(), 2, "expected two revisions in:\n{run}"); + check_table(run, &revisions); + + // A recorded run must also have catalog rows, or the catalog gate was + // not exercised when the table was taken. + assert!(catalog_rows(run).len() >= 2, "{run}"); + } + + // Positive control: the counting is discriminating, not vacuous. Dropping + // one projection row must break it. Without this, every assertion above + // could be trivially satisfiable and the test would still pass. + let dropped = "| private-runner | 3abd945b72f4 |"; + let mutilated: String = runs[0] + .lines() + .filter(|line| !line.starts_with(dropped)) + .collect::>() + .join("\n"); + assert_eq!( + mutilated.lines().count() + 1, + runs[0].lines().count(), + "the control removed {} lines, expected exactly 1", + runs[0].lines().count() - mutilated.lines().count() + ); + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let caught = + std::panic::catch_unwind(|| check_table(&mutilated, &["3abd945b72f4", "7d84ae1d5871"])); + std::panic::set_hook(previous); + assert!( + caught.is_err(), + "check_table accepted a table with a projection row removed" + ); +} + +#[test] +#[ignore = "clones ten repositories and builds a release binary per source revision; \ + set CAPOBARA_EQUIVALENCE_SOURCE_SHAS and run with --ignored"] +fn node_and_capobara_apply_produce_identical_trees_and_reports() { + let shas = std::env::var("CAPOBARA_EQUIVALENCE_SOURCE_SHAS").unwrap_or_default(); + let shas: Vec<&str> = shas.split_whitespace().collect(); + assert_eq!( + shas.len(), + 2, + "set CAPOBARA_EQUIVALENCE_SOURCE_SHAS=\" \" to two full Mono commit SHAs" + ); + + let root = mono_root(); + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/equivalence.sh"); + assert!(script.is_file(), "missing {}", script.display()); + + #[allow( + clippy::disallowed_methods, + reason = "integration test executes the equivalence harness" + )] + let mut command = Command::new("bash"); + let output = command + .arg(&script) + .arg(&root) + .arg(shas[0]) + .arg(shas[1]) + .output() + .expect("failed to run scripts/equivalence.sh"); + + let table = String::from_utf8_lossy(&output.stdout); + println!("{table}"); + assert!( + output.status.success(), + "equivalence harness reported an unsanctioned difference\n{table}\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + // Positive controls. A harness that produced no rows, or ran one revision + // twice, would also exit 0; neither may pass. `check_table` scopes the + // counting to the projection table -- catalog rows carry `| |` + // too -- and is the same function `the_recorded_tables_...` exercises + // against the committed output. + let projections: usize = + std::fs::read_to_string(root.join("config/projections/repositories.json")) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|value| value["projections"].as_array().map(Vec::len)) + .expect("config/projections/repositories.json lists the catalog"); + assert_eq!(summary_field(&table, "projections"), projections, "{table}"); + assert_eq!(summary_field(&table, "failures"), 0, "{table}"); + assert_eq!(summary_field(&table, "catalog_failures"), 0, "{table}"); + assert_eq!( + catalog_rows(table.as_ref()).len(), + 2 * shas.len(), + "{table}" + ); + check_table(table.as_ref(), &shas); +} diff --git a/tests/fixtures/sdk-assembly.json b/tests/fixtures/sdk-assembly.json new file mode 100644 index 0000000..2cb33f4 --- /dev/null +++ b/tests/fixtures/sdk-assembly.json @@ -0,0 +1,142 @@ +{ + "schemaVersion": 1, + "outputs": { + "deixic-python": [ + "CHANGELOG.md", + "LICENSE", + "README.md", + "pyproject.toml", + "src/agentruntime/v1/runtime_pb2.py", + "src/agents/v1/agents_pb2.py", + "src/buf/validate/validate_pb2.py", + "src/codex/v1/codex_pb2.py", + "src/common/v1/analytics_pb2.py", + "src/common/v1/authz_pb2.py", + "src/common/v1/classification_pb2.py", + "src/common/v1/delivery_pb2.py", + "src/common/v1/entity_pb2.py", + "src/common/v1/risk_pb2.py", + "src/common/v1/surface_pb2.py", + "src/connectors/v1/connectors_pb2.py", + "src/console/v1/console_pb2.py", + "src/deixic/__init__.py", + "src/deixic/auth.py", + "src/deixic/client.py", + "src/deixic/errors.py", + "src/deixic/examples/__init__.py", + "src/deixic/examples/account_brief.py", + "src/deixic/examples/account_brief_result.py", + "src/deixic/examples/task_result.py", + "src/deixic/examples/verify_test_journey.py", + "src/deixic/py.typed", + "src/deixic/tasks.py", + "src/deixic/transport.py", + "src/evalops_platform/v1/platform_pb2.py", + "src/google/api/annotations_pb2.py", + "src/google/api/http_pb2.py", + "src/memory/v1/memory_pb2.py", + "src/meter/v1/meter_pb2.py", + "src/objectives/v1/objectives_pb2.py", + "src/orbcontrol/v1/orb_control_pb2.py", + "src/remoterunner/v1/remoterunner_pb2.py", + "src/toolexecution/v1/toolexecution_pb2.py", + "src/traces/v1/traces_pb2.py", + "src/vfs/v1/filesystem_pb2.py", + "tests/test_account_brief.py", + "tests/test_account_brief_application.py", + "tests/test_client.py", + "tests/test_http_journey.py", + "tests/test_real_journey.py", + "tests/test_recovery.py", + "tests/test_tasks.py", + "tests/test_test_journey.py" + ], + "deixic-node": [ + "gen/ts/agentruntime/v1/runtime_pb.ts", + "gen/ts/agents/v1/agents_pb.ts", + "gen/ts/buf/validate/validate_pb.ts", + "gen/ts/codex/v1/codex_pb.ts", + "gen/ts/common/v1/analytics_pb.ts", + "gen/ts/common/v1/authz_pb.ts", + "gen/ts/common/v1/classification_pb.ts", + "gen/ts/common/v1/delivery_pb.ts", + "gen/ts/common/v1/entity_pb.ts", + "gen/ts/common/v1/risk_pb.ts", + "gen/ts/common/v1/surface_pb.ts", + "gen/ts/connectors/v1/connectors_pb.ts", + "gen/ts/console/v1/console_pb.ts", + "gen/ts/deixic/v1/deixic_pb.ts", + "gen/ts/google/api/annotations_pb.ts", + "gen/ts/google/api/http_pb.ts", + "gen/ts/memory/v1/memory_pb.ts", + "gen/ts/meter/v1/meter_pb.ts", + "gen/ts/objectives/v1/objectives_pb.ts", + "gen/ts/orbcontrol/v1/orb_control_pb.ts", + "gen/ts/platform/v1/platform_pb.ts", + "gen/ts/remoterunner/v1/remoterunner_pb.ts", + "gen/ts/toolexecution/v1/toolexecution_pb.ts", + "gen/ts/traces/v1/traces_pb.ts", + "gen/ts/vfs/v1/filesystem_pb.ts", + "sdk/deixic/typescript/CHANGELOG.md", + "sdk/deixic/typescript/LICENSE", + "sdk/deixic/typescript/README.md", + "sdk/deixic/typescript/examples/account-brief-result.d.mts", + "sdk/deixic/typescript/examples/account-brief-result.mjs", + "sdk/deixic/typescript/examples/account-brief.mjs", + "sdk/deixic/typescript/package-lock.json", + "sdk/deixic/typescript/package.json", + "sdk/deixic/typescript/scripts/check-package-exports.mjs", + "sdk/deixic/typescript/scripts/smoke-packed-package.mjs", + "sdk/deixic/typescript/src/index.ts", + "sdk/deixic/typescript/src/tasks.ts", + "sdk/deixic/typescript/test/account-brief-result.test.mjs", + "sdk/deixic/typescript/test/account-brief.test.mjs", + "sdk/deixic/typescript/test/client.test.mjs", + "sdk/deixic/typescript/test/tasks.test.mjs", + "sdk/deixic/typescript/tsconfig.json", + "sdk/maestro/typescript/scripts/verify-descriptor-sources.mjs", + "sdk/maestro/typescript/src/accepted-turn.ts", + "sdk/maestro/typescript/src/app-context.ts", + "sdk/maestro/typescript/src/client.ts", + "sdk/maestro/typescript/src/errors.ts" + ], + "deixic-go": [ + "CHANGELOG.md", + "LICENSE", + "README.md", + "agentruntime/v1/runtime.pb.go", + "agents/v1/agents.pb.go", + "codex/v1/codex.pb.go", + "common/v1/analytics.pb.go", + "common/v1/authz.pb.go", + "common/v1/classification.pb.go", + "common/v1/delivery.pb.go", + "common/v1/entity.pb.go", + "common/v1/risk.pb.go", + "common/v1/surface.pb.go", + "connectors/v1/connectors.pb.go", + "console/v1/console.pb.go", + "deixic/v1/deixic.pb.go", + "deixic/v1/deixicv1connect/deixic.connect.go", + "deixic/v1/deixicv1connect/projection_test.go", + "go.mod", + "go.sum", + "memory/v1/memory.pb.go", + "meter/v1/meter.pb.go", + "objectives/v1/objectives.pb.go", + "orbcontrol/v1/orb_control.pb.go", + "platform/v1/platform.pb.go", + "remoterunner/v1/remoterunner.pb.go", + "toolexecution/v1/toolexecution.pb.go", + "traces/v1/traces.pb.go", + "vfs/v1/filesystem.pb.go" + ] + }, + "executables": { + "deixic-python": [], + "deixic-node": [ + "sdk/deixic/typescript/examples/account-brief.mjs" + ], + "deixic-go": [] + } +} diff --git a/tests/project_cli.rs b/tests/project_cli.rs new file mode 100644 index 0000000..ea168dd --- /dev/null +++ b/tests/project_cli.rs @@ -0,0 +1,149 @@ +mod support; +use std::process::Command; +use support::Repo; + +#[allow( + clippy::disallowed_methods, + reason = "integration test executes the capobara binary" +)] +fn capobara() -> Command { + Command::new(env!("CARGO_BIN_EXE_capobara")) +} + +fn definition_json(name: &str) -> String { + serde_json::json!({ + "schemaVersion": 1, "name": name, "class": "source-tree", "mode": "copy-v1", + "sourceRepository": "dx-corp/mono", "visibility": "public", + "mappings": [{"source": "pkg", "destination": ".", "include": ["src/**"], "exclude": []}], + "destination": {"repository": format!("dx-corp/{name}"), "branch": "main", "syncBranch": "sync/mono-projection", "holdLabel": "sync-hold"}, + "destinationOwned": [".github/**", "SECURITY.md"], + "deletion": "owned-paths", "provenance": ".repository-projection.json", "validation": "tree-v1", + "outputManaged": ["src/**"] + }).to_string() +} + +#[test] +fn production_cli_verifies_committed_definition_reports_drift_and_rejects_malformed_prior_provenance() + { + let source = Repo::init("https://github.com/dx-corp/mono.git"); + source.write( + "config/projections/sample.json", + definition_json("sample").as_bytes(), + ); + source.write("pkg/src/a.rs", b"a"); + source.write( + "rust/tools/capobara/src/lib.rs", + b"// stand-in for the crate tree", + ); + let sha = source.commit("source"); + let tree_id = source + .git(&["rev-parse", "HEAD:rust/tools/capobara"]) + .trim() + .to_owned(); + let target = Repo::init("https://github.com/dx-corp/sample.git"); + target.write("SECURITY.md", b"owned"); + let base = target.commit("destination"); + target.set_remote_main(&base); + let definition = source.path().join("config/projections/sample.json"); + let run = |cmd: &str| { + capobara() + .env("CAPOBARA_TREE_ID_OVERRIDE", &tree_id) + .args([cmd, "--definition"]) + .arg(&definition) + .arg("--source") + .arg(source.path()) + .arg("--source-sha") + .arg(&sha) + .arg("--target") + .arg(target.path()) + .output() + .unwrap() + }; + let check = run("check"); + assert_eq!( + check.status.code(), + Some(1), + "{}", + String::from_utf8_lossy(&check.stderr) + ); + let apply = run("apply"); + assert_eq!( + apply.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&apply.stderr) + ); + assert_eq!(std::fs::read(target.path().join("src/a.rs")).unwrap(), b"a"); + let check = run("check"); + assert_eq!(check.status.code(), Some(0)); + let verify = run("verify"); + assert_eq!(verify.status.code(), Some(0)); + // Tamper with the stored receipt: verify fails, check reports drift on the receipt. + let receipt = target.path().join(".repository-projection.json"); + let original: serde_json::Value = + serde_json::from_slice(&std::fs::read(&receipt).unwrap()).unwrap(); + let mut stored = original.clone(); + stored["contentDigest"] = serde_json::json!("0".repeat(64)); + std::fs::write( + &receipt, + format!("{}\n", serde_json::to_string_pretty(&stored).unwrap()), + ) + .unwrap(); + let verify = run("verify"); + assert_eq!(verify.status.code(), Some(2)); + // A non-object receipt fails Node's `keys()` shape check before the key + // set is even looked at. + std::fs::write(&receipt, "[]\n").unwrap(); + let check = run("check"); + assert_eq!(check.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&check.stderr).contains("Invalid stored provenance")); + // A wrong-typed identity field reaches the identity-mismatch check, not + // a deserialize failure. + let mut wrong_type = original.clone(); + wrong_type["schemaVersion"] = serde_json::json!("1"); + std::fs::write( + &receipt, + format!("{}\n", serde_json::to_string_pretty(&wrong_type).unwrap()), + ) + .unwrap(); + let check = run("check"); + assert_eq!(check.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&check.stderr).contains("Stored provenance identity mismatch")); + // A malformed digest reaches the malformed check, not the identity check. + let mut malformed = original.clone(); + malformed["contentDigest"] = serde_json::json!("zz"); + std::fs::write( + &receipt, + format!("{}\n", serde_json::to_string_pretty(&malformed).unwrap()), + ) + .unwrap(); + let check = run("check"); + assert_eq!(check.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&check.stderr).contains("Malformed stored provenance")); + // Restore the tampered-content-digest receipt before continuing. + std::fs::write( + &receipt, + format!("{}\n", serde_json::to_string_pretty(&stored).unwrap()), + ) + .unwrap(); + stored["extra"] = serde_json::json!(true); + std::fs::write( + &receipt, + format!("{}\n", serde_json::to_string_pretty(&stored).unwrap()), + ) + .unwrap(); + let check = run("check"); + assert_eq!(check.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&check.stderr).contains("stored provenance")); + // A definition that differs from the committed one is rejected outside draft mode. + std::fs::write( + &definition, + definition_json("sample").replace("\"exclude\":[]", "\"exclude\":[\"x\"]"), + ) + .unwrap(); + let check = run("check"); + assert_eq!(check.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&check.stderr).contains("Definition differs from source revision") + ); +} diff --git a/tests/run_cli.rs b/tests/run_cli.rs new file mode 100644 index 0000000..3bbf25f --- /dev/null +++ b/tests/run_cli.rs @@ -0,0 +1,942 @@ +//! Integration tests for `capobara run` (and, through it, `prepare`, +//! `preflight`, and the post-publication proof), driven end to end as a +//! child process against a local bare remote. +//! +//! Ports two tests from `scripts/projections/copybara-preflight.test.mjs`: +//! `stops_before_projection_work_when_the_generated_pr_is_held` and +//! `converged_projection_without_an_open_pr_is_unchanged`; adds +//! `run_dry_run_publishes_nothing_and_prints_the_plan`. +//! +//! Three seams keep this hermetic, all honored only in debug builds (see +//! `cli::transport::api_from_env` and `cli::run::destination_remote`): +//! - `CAPOBARA_TREE_ID_OVERRIDE` pins `tooldigest::embedded()` in the child, +//! the same way `tests/project_cli.rs` and `tests/transport_git.rs` do. +//! - `CAPOBARA_RECORDED_API` points at a JSON recording replayed by +//! `transport::github::RecordedApi`, so no GitHub call is ever made. The +//! recording is an ordered sequence, so a run that calls an unexpected +//! `(method, endpoint)` -- or calls at all once the recording is spent -- +//! panics in the child. Note the asymmetry: `RecordedApi` does not assert +//! that the recording was fully consumed, so these tests pin the order +//! and the identity of every call, and an upper bound on their number, +//! but not a lower bound. +//! - `CAPOBARA_DESTINATION_REMOTE` replaces the `https://github.com/...` +//! clone URL with the local bare repository, for both the destination +//! clone and the post-publication proof clone. `origin`'s URL is then +//! rewritten back to the GitHub URL, so every production check that +//! inspects the remote (`assert_destination_checkout`, +//! `cli::project::repo_identity`) sees exactly what it would in CI, while +//! `remote.origin.pushurl` keeps the publication push local and the proof +//! clone's `fetch` runs before the rewrite. That is what lets +//! `publish_and_prove_pushes_the_sync_branch_and_matches_the_published_head` +//! drive the real `push_sync_branch` and the real published-branch proof +//! without a network. The bare repository is inspected directly (never +//! through a remote name) whenever a test needs to prove what was or was +//! not pushed. + +// `cli::run`'s and `cli::transport`'s test seams, and the in-process +// `tooldigest` override they depend on, exist only in debug builds; compile +// this target to an empty binary under `cargo test --release` rather than +// failing. +#![cfg(debug_assertions)] + +mod support; + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::{Value, json}; + +use support::Repo; + +const NAME: &str = "fixture"; +const REPO: &str = "dx-corp/fixture"; +const SYNC_BRANCH: &str = "sync/mono-projection"; +const STAND_IN: &[u8] = b"// stand-in for the crate tree"; + +const REPOS_ENDPOINT: &str = "repos/dx-corp/fixture"; +const INSTALLATION_ENDPOINT: &str = "installation/repositories?per_page=100"; +const PULLS_ENDPOINT: &str = + "repos/dx-corp/fixture/pulls?state=open&base=main&head=dx-corp%3Async%2Fmono-projection"; + +fn definition_json() -> Value { + json!({ + "schemaVersion": 1, "name": NAME, "class": "source-tree", "mode": "copy-v1", + "sourceRepository": "dx-corp/mono", "visibility": "public", + "mappings": [ + {"source": "pkg", "destination": ".", "include": ["src/**", "README.md"], "exclude": []} + ], + "destination": { + "repository": REPO, "branch": "main", "syncBranch": SYNC_BRANCH, "holdLabel": "sync-hold" + }, + "destinationOwned": [".github/**", "SECURITY.md"], + "deletion": "owned-paths", "provenance": ".repository-projection.json", "validation": "tree-v1", + "outputManaged": ["src/**", "README.md"] + }) +} + +fn info_response() -> Value { + json!({ + "full_name": REPO, "archived": false, "disabled": false, + "default_branch": "main", "visibility": "public", + }) +} + +fn installation_response() -> Value { + json!({"total_count": 1, "repositories": [{"full_name": REPO}]}) +} + +fn call(method: &str, endpoint: &str, response: Value) -> Value { + json!({"method": method, "endpoint": endpoint, "response": response}) +} + +/// The three calls `read_publication_state` makes, with no open PR. +fn no_pr_state() -> Vec { + vec![ + call("GET", REPOS_ENDPOINT, info_response()), + call("GET", INSTALLATION_ENDPOINT, installation_response()), + call("GET", PULLS_ENDPOINT, json!([])), + ] +} + +/// The three calls `read_publication_state` makes with one open, +/// sync-held PR. +fn held_pr_state() -> Vec { + let pr = json!({ + "number": 7, + "labels": [{"name": "sync-hold"}], + "head": {"repo": {"full_name": REPO}, "ref": SYNC_BRANCH, "sha": "1".repeat(40)}, + "base": {"ref": "main"}, + }); + vec![ + call("GET", REPOS_ENDPOINT, info_response()), + call("GET", INSTALLATION_ENDPOINT, installation_response()), + call("GET", PULLS_ENDPOINT, json!([pr])), + ] +} + +fn write_recording(dir: &Path, calls: Vec) -> PathBuf { + let path = dir.join("recorded-api.json"); + std::fs::write(&path, serde_json::to_vec(&Value::Array(calls)).unwrap()).unwrap(); + path +} + +#[allow( + clippy::disallowed_methods, + reason = "integration test executes the capobara binary" +)] +fn capobara() -> Command { + Command::new(env!("CARGO_BIN_EXE_capobara")) +} + +#[allow( + clippy::disallowed_methods, + reason = "integration tests drive scratch git repositories outside the support::Repo helper" +)] +fn run_git(path: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).unwrap() +} + +#[allow( + clippy::disallowed_methods, + reason = "integration test inspects a scratch bare git repository's refs" +)] +fn ref_sha(path: &Path, refname: &str) -> Option { + Command::new("git") + .arg("-C") + .arg(path) + .args(["rev-parse", "--verify", "--quiet", refname]) + .output() + .ok() + .filter(|out| out.status.success()) + .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned()) +} + +/// The whole rig: a Mono source checkout with a one-entry catalog, a bare +/// destination remote, and a seeded `main` on that remote. +struct Fixture { + source: Repo, + source_sha: String, + tree_id: String, + bare: PathBuf, + work: tempfile::TempDir, +} + +impl Fixture { + fn new() -> Fixture { + let source = Repo::init("https://github.com/dx-corp/mono.git"); + source.write( + "config/projections/repositories.json", + br#"{"schemaVersion":1,"sourceRepository":"dx-corp/mono","projections":["fixture"]}"#, + ); + source.write( + "config/projections/fixture.json", + serde_json::to_string(&definition_json()) + .unwrap() + .as_bytes(), + ); + source.write("pkg/src/client.txt", b"public code\n"); + source.write("pkg/README.md", b"SDK\n"); + source.write("rust/tools/capobara/src/lib.rs", STAND_IN); + let source_sha = source.commit("source"); + source.set_remote_main(&source_sha); + let tree_id = source + .git(&["rev-parse", "HEAD:rust/tools/capobara"]) + .trim() + .to_owned(); + + // `.keep()` deliberately leaks the bare remote for the test + // process's lifetime, matching `support::Repo::into_path` and + // `tests/transport_git.rs`'s own convention. + let bare = tempfile::tempdir().unwrap().keep(); + run_git(&bare, &["init", "-q", "--bare", "-b", "main"]); + + let seed = Repo::init(bare.to_str().unwrap()); + seed.write("SECURITY.md", b"owned\n"); + seed.write(".github/workflows/ci.yml", b"ci\n"); + seed.commit("destination"); + seed.git(&["push", "-q", "origin", "main"]); + + Fixture { + source, + source_sha, + tree_id, + bare, + work: tempfile::tempdir().unwrap(), + } + } + + /// Projects the fixture into the bare remote's `main`, so a later run + /// finds an already-converged destination. + fn converge_main(&self) { + let seed = Repo::init(self.bare.to_str().unwrap()); + seed.git(&["fetch", "-q", "origin", "main"]); + seed.git(&["reset", "-q", "--hard", "origin/main"]); + seed.git(&[ + "remote", + "set-url", + "origin", + &format!("https://github.com/{REPO}.git"), + ]); + let out = capobara() + .env("CAPOBARA_TREE_ID_OVERRIDE", &self.tree_id) + .args(["apply", "--definition"]) + .arg(self.source.path().join("config/projections/fixture.json")) + .arg("--source") + .arg(self.source.path()) + .arg("--source-sha") + .arg(&self.source_sha) + .arg("--target") + .arg(seed.path()) + .output() + .unwrap(); + assert!( + out.status.success(), + "seed apply failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + seed.commit("projection"); + seed.git(&["remote", "set-url", "origin", self.bare.to_str().unwrap()]); + seed.git(&["push", "-q", "origin", "main"]); + } + + fn destination(&self) -> PathBuf { + self.work.path().join("projection-target") + } + + /// Each `run` clones into a fresh directory: `git clone` refuses a + /// non-empty target, so a test with several phases cannot reuse one. + fn destination_named(&self, name: &str) -> PathBuf { + self.work.path().join(name) + } + + fn sync_tip(&self) -> Option { + ref_sha(&self.bare, &format!("refs/heads/{SYNC_BRANCH}")) + } + + /// `capobara run` with an explicit destination directory, an optional + /// `GH_TOKEN`, and an optional `$GITHUB_STEP_SUMMARY`. + fn run_phase( + &self, + recording: &Path, + destination: &Path, + token: Option<&str>, + summary: Option<&Path>, + ) -> Output { + let mut command = capobara(); + command + .current_dir(self.source.path()) + .env("CAPOBARA_TREE_ID_OVERRIDE", &self.tree_id) + .env("CAPOBARA_RECORDED_API", recording) + .env("CAPOBARA_DESTINATION_REMOTE", &self.bare) + .env_remove("GH_TOKEN") + .env_remove("GITHUB_STEP_SUMMARY") + .args([ + "run", + NAME, + "--source-sha", + &self.source_sha, + "--destination", + ]) + .arg(destination); + if let Some(token) = token { + command.env("GH_TOKEN", token); + } + if let Some(summary) = summary { + command.env("GITHUB_STEP_SUMMARY", summary); + } + command.output().unwrap() + } + + /// `capobara run fixture --source-sha --destination

[...]`, + /// from the source checkout, with every test seam wired up. + fn run(&self, recording: &Path, extra: &[&str]) -> Output { + let mut command = capobara(); + command + .current_dir(self.source.path()) + .env("CAPOBARA_TREE_ID_OVERRIDE", &self.tree_id) + .env("CAPOBARA_RECORDED_API", recording) + .env("CAPOBARA_DESTINATION_REMOTE", &self.bare) + .env_remove("GH_TOKEN") + .env_remove("GITHUB_STEP_SUMMARY") + .args([ + "run", + NAME, + "--source-sha", + &self.source_sha, + "--destination", + ]) + .arg(self.destination()) + .args(extra); + command.output().unwrap() + } +} + +fn stdout(out: &Output) -> String { + String::from_utf8_lossy(&out.stdout).into_owned() +} + +fn stderr(out: &Output) -> String { + String::from_utf8_lossy(&out.stderr).into_owned() +} + +/// An open, non-held PR whose head is `head_sha`. +fn open_pr_state(head_sha: &str) -> Vec { + let pr = json!({ + "number": 7, + "labels": [], + "head": {"repo": {"full_name": REPO}, "ref": SYNC_BRANCH, "sha": head_sha}, + "base": {"ref": "main"}, + "html_url": "https://github.com/dx-corp/fixture/pull/7", + }); + vec![ + call("GET", REPOS_ENDPOINT, info_response()), + call("GET", INSTALLATION_ENDPOINT, installation_response()), + call("GET", PULLS_ENDPOINT, json!([pr])), + ] +} + +/// The `POST repos/{repo}/pulls` that `create_or_update_pr` makes when no +/// PR is open yet. +fn create_pr_call() -> Value { + call( + "POST", + "repos/dx-corp/fixture/pulls", + json!({"number": 7, "html_url": "https://github.com/dx-corp/fixture/pull/7"}), + ) +} + +fn detail(out: &Output) -> String { + format!( + "status={:?}\nstdout={}\nstderr={}", + out.status.code(), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) +} + +/// `copybara-preflight.test.mjs`: "stops before projection work when the +/// generated PR is held". A sync-hold on the open destination PR stops the +/// run at `prepare`, before any projection work: the destination checkout +/// never receives a projected file, and the remote is untouched. +#[test] +fn stops_before_projection_work_when_the_generated_pr_is_held() { + let f = Fixture::new(); + let recording = write_recording(f.work.path(), held_pr_state()); + let main_before = ref_sha(&f.bare, "refs/heads/main"); + + let out = f.run(&recording, &[]); + + assert_eq!(out.status.code(), Some(3), "{}", detail(&out)); + assert_eq!(stdout(&out), "{\"held\":true}\n", "{}", detail(&out)); + assert!( + !f.destination().join("src/client.txt").exists(), + "projection work ran despite the hold" + ); + assert!( + !f.destination().join(".repository-projection.json").exists(), + "a receipt was written despite the hold" + ); + assert_eq!( + ref_sha(&f.bare, "refs/heads/main"), + main_before, + "the remote's main moved" + ); + assert_eq!( + ref_sha(&f.bare, &format!("refs/heads/{SYNC_BRANCH}")), + None, + "a sync branch was pushed despite the hold" + ); +} + +/// `copybara-preflight.test.mjs`: "converged projection without an open PR +/// is unchanged" -- both the pure disposition table and the end-to-end +/// consequence. A destination whose `main` already carries the projection, +/// with no open generated PR, preflights as `unchanged`, publishes nothing, +/// and still proves itself through the post-publication proof. +#[test] +fn converged_projection_without_an_open_pr_is_unchanged() { + use capobara::preflight::publication_disposition; + assert_eq!(publication_disposition(0, false, 0), "unchanged"); + assert_eq!(publication_disposition(0, true, 0), "publish"); + assert_eq!(publication_disposition(1, false, 0), "publish"); + assert_eq!(publication_disposition(0, false, 1), "publish"); + + let f = Fixture::new(); + f.converge_main(); + let main_before = ref_sha(&f.bare, "refs/heads/main"); + + // prepare (3) + preflight's two publication-state reads (6) + the + // proof's open-PR listing (1). No publication call is recorded, so a + // run that tried to publish would panic in the child. + let mut calls = no_pr_state(); + calls.extend(no_pr_state()); + calls.extend(no_pr_state()); + calls.push(call("GET", PULLS_ENDPOINT, json!([]))); + let recording = write_recording(f.work.path(), calls); + + let summary = f.work.path().join("step-summary.md"); + let out = capobara() + .current_dir(f.source.path()) + .env("CAPOBARA_TREE_ID_OVERRIDE", &f.tree_id) + .env("CAPOBARA_RECORDED_API", &recording) + .env("CAPOBARA_DESTINATION_REMOTE", &f.bare) + .env("GITHUB_STEP_SUMMARY", &summary) + .args(["run", NAME, "--source-sha", &f.source_sha, "--destination"]) + .arg(f.destination()) + .output() + .unwrap(); + + assert_eq!(out.status.code(), Some(0), "{}", detail(&out)); + let text = stdout(&out); + assert!( + text.contains("\"held\":false,\"unchanged\":true,"), + "{}", + detail(&out) + ); + assert!( + text.contains("\"destinationFetch\":\"main\""), + "a converged destination must fetch the default branch, not the sync branch: {}", + detail(&out) + ); + assert_eq!( + ref_sha(&f.bare, "refs/heads/main"), + main_before, + "the remote's main moved" + ); + assert_eq!( + ref_sha(&f.bare, &format!("refs/heads/{SYNC_BRANCH}")), + None, + "a converged run pushed a sync branch" + ); + + let head = ref_sha(&f.bare, "refs/heads/main").unwrap(); + let receipt = std::fs::read_to_string(&summary).unwrap(); + assert!( + receipt.ends_with(&format!( + "### {NAME} Capobara receipt\n\n- Source: `{}`\n- Destination head: `{head}`\n- Converged without publication: `true`\n", + f.source_sha + )), + "unexpected step summary tail: {receipt}" + ); +} + +/// A run that would publish, stopped by `--dry-run` immediately after +/// preflight: the plan is printed, `{"dryRun":true}` marks the skip, and +/// the bare remote's refs are byte-identical to before the run. +#[test] +fn run_dry_run_publishes_nothing_and_prints_the_plan() { + let f = Fixture::new(); + let main_before = ref_sha(&f.bare, "refs/heads/main"); + + // prepare (3) + preflight's two publication-state reads (6). Nothing + // else may be called. + let mut calls = no_pr_state(); + calls.extend(no_pr_state()); + calls.extend(no_pr_state()); + let recording = write_recording(f.work.path(), calls); + + let out = f.run(&recording, &["--dry-run"]); + + assert_eq!(out.status.code(), Some(0), "{}", detail(&out)); + let text = stdout(&out); + assert!( + text.contains("\"held\":false,\"unchanged\":false,"), + "a destination without the projection must preflight as publishable: {}", + detail(&out) + ); + assert!( + text.contains("\"destinationFetch\":\"main\""), + "{}", + detail(&out) + ); + assert!(text.ends_with("{\"dryRun\":true}\n"), "{}", detail(&out)); + + // The projection really was produced locally -- otherwise "nothing was + // pushed" would hold for a run that simply did nothing. + assert!( + f.destination().join("src/client.txt").exists(), + "the projection was never applied, so this test proves nothing" + ); + assert_eq!( + run_git(&f.destination(), &["branch", "--show-current"]).trim(), + SYNC_BRANCH + ); + + // Review fix round 1 (I3): assert the origin rewrite directly rather + // than inferring it from `assert_destination_checkout` having passed. + // `get-url` must read as GitHub (what every production identity check + // sees) while `get-url --push` reads as the bare remote (what keeps a + // publication push hermetic). An `insteadOf` seam cannot produce this + // split -- `get-url` expands `insteadOf` -- which is why `pushurl` is + // the mechanism. + assert_eq!( + run_git(&f.destination(), &["remote", "get-url", "origin"]).trim(), + "https://github.com/dx-corp/fixture.git" + ); + assert_eq!( + run_git(&f.destination(), &["remote", "get-url", "--push", "origin"]).trim(), + f.bare.to_str().unwrap() + ); + + assert_eq!( + ref_sha(&f.bare, "refs/heads/main"), + main_before, + "the remote's main moved" + ); + assert_eq!( + ref_sha(&f.bare, &format!("refs/heads/{SYNC_BRANCH}")), + None, + "--dry-run pushed a sync branch" + ); +} + +/// Review fix round 1 (I1): the publish -> push -> prove lane, end to end +/// and hermetically, in three phases against one bare remote. +/// +/// Before the `remote.origin.pushurl` seam this lane had no coverage at +/// all: every earlier test stops before `push_sync_branch` and before the +/// proof's `fetch`, both of which addressed `origin` -- which +/// `clone_destination` had already rewritten to `https://github.com/...`. +#[test] +fn publish_and_prove_pushes_the_sync_branch_and_matches_the_published_head() { + let f = Fixture::new(); + let main_tip = ref_sha(&f.bare, "refs/heads/main").unwrap(); + + // Phase A -- no GH_TOKEN. Everything up to the push runs; the push + // itself refuses. Proves the assertions in phase B are not vacuous: + // the sync branch appears there because a push happened, not because + // the fixture created it. + let recording = write_recording(f.work.path(), { + let mut calls = no_pr_state(); // prepare + calls.extend(no_pr_state()); // preflight, first + calls.extend(no_pr_state()); // preflight, final + calls.extend(no_pr_state()); // publish, first + calls.extend(no_pr_state()); // publish, final + calls + }); + let out = f.run_phase(&recording, &f.destination_named("phase-a"), None, None); + assert_eq!(out.status.code(), Some(1), "{}", detail(&out)); + assert!( + stderr(&out).contains("Missing publication token"), + "{}", + detail(&out) + ); + assert_eq!( + f.sync_tip(), + None, + "a sync branch was pushed without a token" + ); + + // Phase B -- with a token, but the proof is told about a PR whose head + // is some other commit. The push must happen and the proof must reject + // the mismatch with the runbook-grep string, verbatim. + let recording = write_recording(f.work.path(), { + let mut calls = no_pr_state(); + calls.extend(no_pr_state()); + calls.extend(no_pr_state()); + calls.extend(no_pr_state()); + calls.extend(no_pr_state()); + calls.push(create_pr_call()); + // The proof's `gh pr list` equivalent, answered with a stale head. + calls.push(call( + "GET", + PULLS_ENDPOINT, + json!([{"number": 7, "head": {"sha": "9".repeat(40)}}]), + )); + calls + }); + let out = f.run_phase( + &recording, + &f.destination_named("phase-b"), + Some("x-token"), + None, + ); + assert_eq!(out.status.code(), Some(1), "{}", detail(&out)); + assert_eq!( + stderr(&out).lines().next_back(), + Some("Copybara PR does not uniquely match the verified destination head"), + "{}", + detail(&out) + ); + // Node's full publication object, in Node's key order + // (transport.mjs:441-446). `tree` is `HEAD^{tree}` of the commit that + // was actually pushed, read back out of the bare remote rather than + // copied from stdout. + let pushed_for_tree = ref_sha(&f.bare, &format!("refs/heads/{SYNC_BRANCH}")) + .expect("push_sync_branch did not push"); + let pushed_tree = run_git( + &f.bare, + &["rev-parse", &format!("{pushed_for_tree}^{{tree}}")], + ) + .trim() + .to_owned(); + assert!( + stdout(&out).contains(&format!( + "{{\"held\":false,\"pullRequest\":\"https://github.com/dx-corp/fixture/pull/7\",\"engine\":\"rust-prepared-tree\",\"tree\":\"{pushed_tree}\"}}" + )), + "{}", + detail(&out) + ); + assert_eq!( + capobara::transport::PUBLICATION_ENGINE, + "rust-prepared-tree" + ); + + // push_sync_branch really ran, against the bare remote. + let pushed = f.sync_tip().expect("push_sync_branch did not push"); + assert_ne!(pushed, main_tip, "the pushed head is just main"); + assert_eq!( + ref_sha(&f.bare, "refs/heads/main").unwrap(), + main_tip, + "publication moved the destination's default branch" + ); + + // Phase C -- the destination now carries the published sync branch and + // an open PR at exactly that head. The proof must pass, and the receipt + // must name that head. + let summary = f.work.path().join("phase-c-summary.md"); + let recording = write_recording(f.work.path(), { + let mut calls = open_pr_state(&pushed); // prepare + calls.extend(open_pr_state(&pushed)); // preflight, first + calls.extend(open_pr_state(&pushed)); // preflight, final + calls.extend(open_pr_state(&pushed)); // publish, first (converges) + calls.push(call( + "GET", + PULLS_ENDPOINT, + json!([{"number": 7, "head": {"sha": pushed}}]), + )); + calls + }); + let out = f.run_phase( + &recording, + &f.destination_named("phase-c"), + Some("x-token"), + Some(&summary), + ); + assert_eq!(out.status.code(), Some(0), "{}", detail(&out)); + let text = stdout(&out); + assert!( + text.contains(&format!("\"destinationFetch\":\"{SYNC_BRANCH}\"")), + "an existing remote sync branch must be the fetch ref: {}", + detail(&out) + ); + assert!( + text.contains("\"held\":false,\"unchanged\":false,"), + "an open PR means the projection is still publishable: {}", + detail(&out) + ); + // Nothing new to commit, so publication converges on the existing PR. + assert!( + text.contains("{\"held\":false,\"unchanged\":true}"), + "{}", + detail(&out) + ); + assert_eq!(f.sync_tip().as_deref(), Some(pushed.as_str())); + + let receipt = std::fs::read_to_string(&summary).unwrap(); + assert!( + receipt.ends_with(&format!( + "### {NAME} Capobara receipt\n\n- Source: `{}`\n- Destination head: `{pushed}`\n- Converged without publication: `false`\n", + f.source_sha + )), + "unexpected step summary tail: {receipt}" + ); + assert!( + receipt.contains("Publication: {\"held\":false,\"unchanged\":true}\n"), + "the publication result was not recorded in the step summary: {receipt}" + ); +} + +/// Review fix round 1 (I2): `run` does not apply the workflow's +/// `validate.mjs` policies, and says so on every invocation rather than +/// only in a source comment. +#[test] +fn run_announces_that_it_applies_no_distribution_validation() { + let f = Fixture::new(); + let recording = write_recording(f.work.path(), held_pr_state()); + let out = f.run(&recording, &[]); + assert_eq!( + stderr(&out).lines().next(), + Some("capobara run: distribution validation is not applied by this command"), + "{}", + detail(&out) + ); + + // The same gap is named in `capobara run --help`, which is where an + // operator swapping the workflow's steps for this command would look. + let help = capobara().args(["run", "--help"]).output().unwrap(); + assert!(help.status.success()); + let help = String::from_utf8_lossy(&help.stdout).into_owned(); + assert!( + help.contains("performs no distribution validation"), + "run --help does not disclose the validation gap: {help}" + ); +} + +// --------------------------------------------------------------------- +// sdk-assembly-v1 through `run` +// +// Fix round 2 integration. `cli::project` carried a constant-`None` policy +// lookup and an `unreachable!()` assembler until Task 15's +// `ec116b6bc40d`; with that stand-in, `capobara run` could not project any +// of the three `sdk-assembly-v1` repositories at all -- it failed at the +// catalog load with "Unknown SDK assembly policy" before reaching a single +// git command. Every other test in this file uses a `copy-v1` fixture and +// would have passed throughout. +// --------------------------------------------------------------------- + +const SDK_NAME: &str = "deixic-python"; +const SDK_REPO: &str = "dx-corp/deixic-python"; +const SDK_PULLS_ENDPOINT: &str = + "repos/dx-corp/deixic-python/pulls?state=open&base=main&head=dx-corp%3Async%2Fmono-projection"; + +fn sdk_info_response() -> Value { + json!({ + "full_name": SDK_REPO, "archived": false, "disabled": false, + "default_branch": "main", "visibility": "public", + }) +} + +/// `read_publication_state`'s three calls for the SDK destination, with no +/// open PR. +fn sdk_no_pr_state() -> Vec { + vec![ + call("GET", "repos/dx-corp/deixic-python", sdk_info_response()), + call( + "GET", + INSTALLATION_ENDPOINT, + json!({"total_count": 1, "repositories": [{"full_name": SDK_REPO}]}), + ), + call("GET", SDK_PULLS_ENDPOINT, json!([])), + ] +} + +/// The committed `deixic-python` definition, as this crate's fixture copy +/// records it. Written into the source checkout verbatim, because +/// `cli::project::run` compares the on-disk definition byte-for-byte +/// against `git show {sha}:config/projections/{name}.json`. +fn sdk_definition_text() -> String { + std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/definitions/deixic-python.json"), + ) + .unwrap() +} + +/// Every file in `root` except `.git`, relative and sorted. +fn tracked_files(root: &Path) -> Vec { + let mut found = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.file_name().is_some_and(|name| name == ".git") { + continue; + } + if path.is_dir() { + stack.push(path); + } else { + found.push( + path.strip_prefix(root) + .unwrap() + .to_string_lossy() + .into_owned(), + ); + } + } + } + found.sort(); + found +} + +/// `capobara run` against a real `sdk-assembly-v1` definition and the real +/// reviewed `deixic-python` policy -- the smallest of the three -- driven +/// to a converged destination so the run completes through the +/// post-publication proof and exits 0. +/// +/// The assembler is exercised four times over one run (the seeding +/// `apply`, `run`'s own `apply`, preflight's `verify`, and the candidate +/// rebuild inside `assert_candidate_matches_main_projection`), and the +/// destination is required to hold exactly the policy's registered outputs. +#[test] +fn run_projects_a_real_sdk_assembly_definition_end_to_end() { + use capobara::modes::sdk_assembly::policies; + + let source = Repo::init("https://github.com/dx-corp/mono.git"); + support::populate_python_snapshot(source.path()); + source.write( + "config/projections/repositories.json", + br#"{"schemaVersion":1,"sourceRepository":"dx-corp/mono","projections":["deixic-python"]}"#, + ); + source.write( + "config/projections/deixic-python.json", + sdk_definition_text().as_bytes(), + ); + source.write("rust/tools/capobara/src/lib.rs", STAND_IN); + let source_sha = source.commit("source"); + source.set_remote_main(&source_sha); + let tree_id = source + .git(&["rev-parse", "HEAD:rust/tools/capobara"]) + .trim() + .to_owned(); + + let bare = tempfile::tempdir().unwrap().keep(); + run_git(&bare, &["init", "-q", "--bare", "-b", "main"]); + let seed = Repo::init(bare.to_str().unwrap()); + seed.write("SECURITY.md", b"owned\n"); + seed.write(".github/workflows/ci.yml", b"ci\n"); + let base = seed.commit("destination"); + seed.git(&["push", "-q", "origin", "main"]); + + // Converge `main` on the assembled projection, so the run under test + // reaches the proof rather than a publication. + seed.git(&["update-ref", "refs/remotes/origin/main", &base]); + seed.git(&[ + "remote", + "set-url", + "origin", + &format!("https://github.com/{SDK_REPO}.git"), + ]); + let seeded = capobara() + .env("CAPOBARA_TREE_ID_OVERRIDE", &tree_id) + .args(["apply", "--definition"]) + .arg(source.path().join("config/projections/deixic-python.json")) + .arg("--source") + .arg(source.path()) + .arg("--source-sha") + .arg(&source_sha) + .arg("--target") + .arg(seed.path()) + .output() + .unwrap(); + assert!( + seeded.status.success(), + "seed apply failed: {}", + String::from_utf8_lossy(&seeded.stderr) + ); + seed.commit("projection"); + seed.git(&["remote", "set-url", "origin", bare.to_str().unwrap()]); + seed.git(&["push", "-q", "origin", "main"]); + let main_before = ref_sha(&bare, "refs/heads/main"); + + let work = tempfile::tempdir().unwrap(); + let mut calls = sdk_no_pr_state(); // prepare + calls.extend(sdk_no_pr_state()); // preflight, first + calls.extend(sdk_no_pr_state()); // preflight, final + calls.push(call("GET", SDK_PULLS_ENDPOINT, json!([]))); // the proof + let recording = write_recording(work.path(), calls); + let destination = work.path().join("projection-target"); + let summary = work.path().join("step-summary.md"); + + let out = capobara() + .current_dir(source.path()) + .env("CAPOBARA_TREE_ID_OVERRIDE", &tree_id) + .env("CAPOBARA_RECORDED_API", &recording) + .env("CAPOBARA_DESTINATION_REMOTE", &bare) + .env("GITHUB_STEP_SUMMARY", &summary) + .env_remove("GH_TOKEN") + .args([ + "run", + SDK_NAME, + "--source-sha", + &source_sha, + "--destination", + ]) + .arg(&destination) + .output() + .unwrap(); + + assert_eq!(out.status.code(), Some(0), "{}", detail(&out)); + assert!( + stdout(&out).contains("\"held\":false,\"unchanged\":true,"), + "a converged SDK destination must preflight as unchanged: {}", + detail(&out) + ); + + // The assembled destination holds exactly the reviewed policy's + // registered outputs, plus the receipt and the destination-owned files + // it started with -- nothing extra, nothing missing. With the old + // `unreachable!()` assembler this list would have been unreachable + // code; with the constant-`None` lookup the run would not have started. + let policy = policies::policy(SDK_NAME).unwrap(); + let mut expected: Vec = policy.output_include.clone(); + expected.push(".repository-projection.json".to_owned()); + expected.push(".github/workflows/ci.yml".to_owned()); + expected.push("SECURITY.md".to_owned()); + expected.sort(); + assert_eq!(tracked_files(&destination), expected); + + // The destination-identity transform ran on the assembled manifest. + let pyproject = std::fs::read_to_string(destination.join("pyproject.toml")).unwrap(); + assert!(!pyproject.contains("dx-corp/mono"), "{pyproject}"); + + assert_eq!( + ref_sha(&bare, "refs/heads/main"), + main_before, + "a converged run moved the destination's default branch" + ); + assert_eq!( + ref_sha(&bare, &format!("refs/heads/{SYNC_BRANCH}")), + None, + "a converged run pushed a sync branch" + ); + + let head = main_before.unwrap(); + let receipt = std::fs::read_to_string(&summary).unwrap(); + assert!( + receipt.ends_with(&format!( + "### {SDK_NAME} Capobara receipt\n\n- Source: `{source_sha}`\n- Destination head: `{head}`\n- Converged without publication: `true`\n" + )), + "unexpected step summary tail: {receipt}" + ); +} diff --git a/tests/sdk_assembly.rs b/tests/sdk_assembly.rs new file mode 100644 index 0000000..8f6c767 --- /dev/null +++ b/tests/sdk_assembly.rs @@ -0,0 +1,583 @@ +//! Ports the four tests in `scripts/projections/sdk-assembly.test.mjs` +//! (Node, the source of record) plus one integration test proving +//! `definition::load_definition` accepts a real `sdk-assembly-v1` definition +//! when wired to `sdk_assembly::input_roots`. +//! +//! Node's tests read fixtures straight out of the live Mono tree (the +//! projection source itself). This crate has no such tree to read from, so +//! each test here builds a synthetic snapshot directory under a tempdir +//! containing every input file every policy names, with just enough content +//! to satisfy each policy's import-closure check: the Python closure's BFS +//! root (`console_pb2.py`) imports every other generated Python module +//! directly; the TypeScript closure's two roots (`index.ts`, `tasks.ts`) +//! re-export every shared and generated TypeScript module by a relative, +//! `.js`-suffixed specifier (mirroring the real compiled-output convention +//! the closure's extension swap expects); and the Go closure's `deixic/v1` +//! root imports every other generated Go package by its full module path. + +mod support; + +use std::collections::BTreeSet; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::Command; + +use capobara::modes::sdk_assembly::{self, policies}; +use support::Repo; + +// --------------------------------------------------------------------- +// Synthetic snapshot construction +// --------------------------------------------------------------------- + +fn write(root: &Path, path: &str, contents: &[u8]) { + let full = root.join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(&full, contents).unwrap(); +} + +fn make_executable(root: &Path, path: &str) { + let full = root.join(path); + let mut perms = std::fs::metadata(&full).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&full, perms).unwrap(); +} + +/// The relative, POSIX-style specifier `from_file` would use to import +/// `to_file` (both full repo-relative paths, `to_file` still carrying its +/// real extension) -- e.g. `sdk/deixic/typescript/src/index.ts` importing +/// `gen/ts/buf/validate/validate_pb.ts` yields +/// `../../../../gen/ts/buf/validate/validate_pb.ts`. +fn relative_specifier(from_file: &str, to_file: &str) -> String { + let mut from_dir: Vec<&str> = from_file.split('/').collect(); + from_dir.pop(); + let mut to_parts: Vec<&str> = to_file.split('/').collect(); + let to_name = to_parts.pop().unwrap(); + let mut common = 0; + while common < from_dir.len() && common < to_parts.len() && from_dir[common] == to_parts[common] + { + common += 1; + } + let ups = from_dir.len() - common; + let mut segments: Vec = (0..ups).map(|_| "..".to_string()).collect(); + segments.extend(to_parts[common..].iter().map(|s| s.to_string())); + segments.push(to_name.to_string()); + segments.join("/") +} + +/// Swaps a `.ts` specifier for the `.js` one the real compiled output (and +/// therefore the closure's own extension-swap resolution) expects. +fn js_specifier(ts_path: &str) -> String { + match ts_path.strip_suffix(".ts") { + Some(stem) => format!("{stem}.js"), + None => ts_path.to_string(), + } +} + +fn python_package_and_module(generated_path: &str) -> (String, String) { + let without_ext = generated_path.strip_suffix(".py").unwrap(); + match without_ext.rsplit_once('/') { + Some((dir, module)) => (dir.replace('/', "."), module.to_string()), + None => (String::new(), without_ext.to_string()), + } +} + +fn go_package_of(path: &str) -> String { + match path.rfind('/') { + Some(idx) => path[..idx].to_string(), + None => String::new(), + } +} + +const PYPROJECT_TOML: &str = concat!( + "[project]\n", + "name = \"deixic\"\n", + "version = \"0.1.0\"\n", + "dependencies = [\n", + " \"httpx>=0.27\",\n", + " \"evalops-sdk-core==1.2.3\",\n", + "]\n", + "\n", + "[project.urls]\n", + "Repository = \"https://github.com/dx-corp/mono\"\n", +); + +const PACKAGE_JSON: &str = concat!( + "{\n", + " \"name\": \"@dx-corp/deixic\",\n", + " \"version\": \"0.1.0\",\n", + " \"repository\": {\n", + " \"type\": \"git\",\n", + " \"url\": \"https://github.com/dx-corp/mono\"\n", + " }\n", + "}\n", +); + +const GO_MOD: &str = "module github.com/evalops/platform/gen/go\n\ngo 1.21\n"; + +/// Builds a tempdir snapshot containing every input every policy names, +/// shaped so all three closures pass. Reused by every test below except the +/// closure-escape test, which starts from this and mutates one file. +fn build_snapshot() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + populate_snapshot(dir.path()); + dir +} + +/// Writes every input file every policy names into `root`. Split out of +/// `build_snapshot` so the end-to-end `apply` test below can populate a real +/// git working tree instead of a bare tempdir. +fn populate_snapshot(root: &Path) { + // ---- Python ---- + for path in policies::PYTHON_SDK_FILES { + let full = format!("sdk/deixic/python/{path}"); + if *path == "pyproject.toml" { + write(root, &full, PYPROJECT_TOML.as_bytes()); + } else { + write(root, &full, format!("# {path}\n").as_bytes()); + } + } + for path in policies::PYTHON_GENERATED_FILES { + let full = format!("gen/python/{path}"); + if *path == "console/v1/console_pb2.py" { + let mut content = String::from("# generated console module\n"); + for other in policies::PYTHON_GENERATED_FILES { + if *other == *path { + continue; + } + let (package, module) = python_package_and_module(other); + content.push_str(&format!("from {package} import {module}\n")); + } + write(root, &full, content.as_bytes()); + } else { + write(root, &full, b"# generated\n"); + } + } + + // ---- Node / TypeScript ---- + let index_path = "sdk/deixic/typescript/src/index.ts"; + for path in policies::NODE_PACKAGE_FILES { + let full = format!("sdk/deixic/typescript/{path}"); + match *path { + "package.json" => write(root, &full, PACKAGE_JSON.as_bytes()), + "src/index.ts" => {} // written below, once all specifiers are known + "src/tasks.ts" => write(root, &full, b"export {};\n"), + "examples/account-brief.mjs" => { + write(root, &full, b"#!/usr/bin/env node\nconsole.log(\"ok\");\n"); + make_executable(root, &full); + } + other => write(root, &full, format!("// {other}\n").as_bytes()), + } + } + let mut index_content = String::new(); + for shared in policies::NODE_SHARED_FILES + .iter() + .filter(|path| path.ends_with(".ts")) + { + let spec = js_specifier(&relative_specifier(index_path, shared)); + index_content.push_str(&format!("export * from \"{spec}\";\n")); + } + for generated in policies::TYPESCRIPT_GENERATED_FILES { + let full = format!("gen/ts/{generated}"); + let spec = js_specifier(&relative_specifier(index_path, &full)); + index_content.push_str(&format!("export * from \"{spec}\";\n")); + } + write(root, index_path, index_content.as_bytes()); + for path in policies::NODE_SHARED_FILES { + write(root, path, format!("// {path}\n").as_bytes()); + } + for path in policies::TYPESCRIPT_GENERATED_FILES { + write(root, &format!("gen/ts/{path}"), b"// generated\n"); + } + + // ---- Go ---- + write(root, "sdk/deixic/go/README.md", b"# deixic-go\n"); + write( + root, + "sdk/deixic/go/deixic_connect_test.go.in", + b"package deixicv1connect_test\n\nfunc TestProjection(t *testing.T) {}\n", + ); + write(root, "gen/go/CHANGELOG.md", b"# changelog\n"); + write(root, "gen/go/go.mod", GO_MOD.as_bytes()); + write(root, "gen/go/go.sum", b"\n"); + + let hub_package: &str = "deixic/v1"; + let mut other_packages: BTreeSet = policies::GO_GENERATED_FILES + .iter() + .map(|path| go_package_of(path)) + .collect(); + other_packages.remove(hub_package); + other_packages.remove("deixic/v1/deixicv1connect"); + let mut import_block = String::new(); + for package in &other_packages { + import_block.push_str(&format!( + "\t\"github.com/evalops/platform/gen/go/{package}\"\n" + )); + } + let deixic_pb_go = + format!("package deixicv1\n\nimport (\n{import_block})\n\ntype Placeholder struct{{}}\n"); + for path in policies::GO_GENERATED_FILES { + let full = format!("gen/go/{path}"); + if *path == "deixic/v1/deixic.pb.go" { + write(root, &full, deixic_pb_go.as_bytes()); + } else { + write(root, &full, b"// generated\n"); + } + } +} + +fn fixture(name: &str) -> serde_json::Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name); + let text = std::fs::read_to_string(path).unwrap(); + serde_json::from_str(&text).unwrap() +} + +fn assert_unique(values: &[String]) { + let unique: BTreeSet<&String> = values.iter().collect(); + assert_eq!(unique.len(), values.len(), "duplicates in {values:?}"); +} + +const POLICY_NAMES: [&str; 3] = ["deixic-python", "deixic-node", "deixic-go"]; + +// --------------------------------------------------------------------- +// Ported Node tests +// --------------------------------------------------------------------- + +#[test] +fn sdk_policies_expose_reviewed_immutable_inputs_and_fixture_outputs() { + let fixture = fixture("sdk-assembly.json"); + assert_eq!(fixture["schemaVersion"], 1); + + let mut policy_names: Vec<&str> = POLICY_NAMES.to_vec(); + policy_names.sort_unstable(); + let mut fixture_names: Vec = fixture["outputs"] + .as_object() + .unwrap() + .keys() + .cloned() + .collect(); + fixture_names.sort(); + assert_eq!(policy_names, fixture_names); + + for name in POLICY_NAMES { + let policy = sdk_assembly::policy(name).unwrap(); + let expected: Vec = fixture["outputs"][name] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + assert_eq!(policy.output_include, expected); + assert_unique(&policy.input_roots); + assert_unique(&policy.output_include); + assert_unique(&policy.output_managed); + } +} + +#[test] +fn sdk_assembly_is_byte_deterministic_and_preserves_normalized_modes() { + let snapshot = build_snapshot(); + let fixture = fixture("sdk-assembly.json"); + + for name in POLICY_NAMES { + let policy = sdk_assembly::policy(name).unwrap(); + let first = sdk_assembly::assemble(snapshot.path(), name).unwrap(); + let second = sdk_assembly::assemble(snapshot.path(), name).unwrap(); + assert_eq!( + capobara::tree::tree_digest(&first.entries), + capobara::tree::tree_digest(&second.entries), + ); + let mut keys: Vec = first.entries.keys().cloned().collect(); + keys.sort(); + assert_eq!(keys, policy.output_include); + + let mut executables: Vec = Vec::new(); + for (path, entry) in &first.entries { + assert!( + entry.mode == 0o644 || entry.mode == 0o755, + "unexpected mode {:o} for {path}", + entry.mode + ); + if entry.mode == 0o755 { + executables.push(path.clone()); + } + } + executables.sort(); + let expected_executables: Vec = fixture["executables"][name] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + assert_eq!(executables, expected_executables, "policy {name}"); + } +} + +#[test] +fn language_specific_assembly_removes_unpublished_and_mono_only_identities() { + let snapshot = build_snapshot(); + + let python = sdk_assembly::assemble(snapshot.path(), "deixic-python").unwrap(); + let pyproject = String::from_utf8_lossy(&python.entries["pyproject.toml"].content).into_owned(); + assert!(!pyproject.contains("evalops-sdk")); + assert!(pyproject.contains("github.com/dx-corp/deixic-python")); + + let node = sdk_assembly::assemble(snapshot.path(), "deixic-node").unwrap(); + let package_json = + String::from_utf8_lossy(&node.entries["sdk/deixic/typescript/package.json"].content) + .into_owned(); + assert!(package_json.contains("github.com/dx-corp/deixic-node")); + assert!( + node.entries + .keys() + .filter(|path| path.starts_with("sdk/maestro/")) + .all(|path| path.contains("/typescript/src/") + || path.ends_with("verify-descriptor-sources.mjs")) + ); + assert!( + !node + .entries + .keys() + .any(|path| path.starts_with("products/")) + ); + + let go = sdk_assembly::assemble(snapshot.path(), "deixic-go").unwrap(); + let policy = sdk_assembly::policy("deixic-go").unwrap(); + assert!( + policy + .input_roots + .contains(&"sdk/deixic/go/deixic_connect_test.go.in".to_string()) + ); + let go_test_template = std::fs::read( + snapshot + .path() + .join("sdk/deixic/go/deixic_connect_test.go.in"), + ) + .unwrap(); + assert_eq!( + go.entries["deixic/v1/deixicv1connect/projection_test.go"].content, + go_test_template + ); + let go_mod = String::from_utf8_lossy(&go.entries["go.mod"].content).into_owned(); + assert!(go_mod.starts_with("module github.com/dx-corp/deixic-go\n")); + let leaked = regex::Regex::new( + r#"(?m)^(\s*(?:[_A-Za-z][A-Za-z0-9_]*\s+)?")github\.com/evalops/platform/gen/go"#, + ) + .unwrap(); + for (path, entry) in &go.entries { + if path.ends_with(".go") { + let text = String::from_utf8_lossy(&entry.content); + assert!(!leaked.is_match(&text), "{path}"); + } + } +} + +#[test] +fn generated_dependency_closure_fails_closed_when_an_import_escapes() { + let snapshot = build_snapshot(); + let console_path = snapshot.path().join("gen/python/console/v1/console_pb2.py"); + let mut content = std::fs::read_to_string(&console_path).unwrap(); + content.push_str("from private.v1 import staff_pb2\n"); + std::fs::write(&console_path, content).unwrap(); + + let err = sdk_assembly::assemble(snapshot.path(), "deixic-python").unwrap_err(); + assert_eq!( + err.to_string(), + "Python generated import escapes reviewed closure: gen/python/private/v1/staff_pb2.py" + ); +} + +// --------------------------------------------------------------------- +// definition.rs wiring: proves sdk_assembly::input_roots is a drop-in +// sdk_inputs closure for a real, reviewed definition. Gated because it +// reads a definition fixture whose input allowlist must match the real +// policy exactly; run with `--features mono-fixtures`. +// --------------------------------------------------------------------- + +#[test] +#[cfg_attr(not(feature = "mono-fixtures"), ignore)] +fn deixic_python_definition_loads_against_the_real_sdk_assembly_policy() { + let path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/definitions/deixic-python.json"); + let loaded = capobara::definition::load_definition(&path, &sdk_assembly::input_roots).unwrap(); + assert_eq!(loaded.definition.name, "deixic-python"); + assert!(matches!( + loaded.definition.mode, + capobara::definition::Mode::SdkAssemblyV1 + )); +} + +// --------------------------------------------------------------------- +// cli wiring: the production binary must assemble an `sdk-assembly-v1` +// projection through these reviewed policies. +// +// Regression. Between the task that added `plan`/`apply`/`verify`/`check` +// and the task that added the policies, `cli::project` carried a constant +// `None` policy lookup and an `unreachable!()` assembler. Nothing replaced +// them when the policies landed, so on `origin/main` every one of the three +// `sdk-assembly-v1` projections -- deixic-python, deixic-node, deixic-go -- +// exited 2 with "Unknown SDK assembly policy" while Node's `project.mjs` +// applied them, and `capobara catalog check` failed outright where Node +// validated all ten entries. The equivalence harness found it; this test +// replays it against the real binary, the real reviewed policy, and the real +// committed definition, so the two holes cannot reopen independently: a +// stand-in lookup fails the definition load, and a stand-in assembler panics +// in the `sdk-assembly-v1` branch of `build_projection`. +// --------------------------------------------------------------------- + +#[allow( + clippy::disallowed_methods, + reason = "integration test executes the capobara binary" +)] +fn capobara() -> Command { + Command::new(env!("CARGO_BIN_EXE_capobara")) +} + +// Debug-only, like `tests/project_cli.rs`: the synthetic source repository's +// `rust/tools/capobara` tree id is generated fresh here and can never match +// this build's embedded one, so the test supplies it through +// `CAPOBARA_TREE_ID_OVERRIDE`, which `tooldigest::embedded()` honours only +// under `cfg!(debug_assertions)`. Under `cargo test --release` this fails with +// "projector differs from source revision"; that is the override being +// compiled out of release builds on purpose, not a broken test. +#[test] +// Reads `tests/fixtures/definitions/deixic-python.json`, which +// `config/projections/capobara.json` deliberately EXCLUDES from the +// projection. The file therefore exists in Mono and not in +// `dx-corp/capobara`, so without this gate `cargo test` on a fresh clone of +// the published repository fails here with `No such file or directory` -- +// the same defect class as the equivalence test's `EQUIVALENCE.md` read. +// `tests/standalone_build.rs` now runs the projected suite rather than only +// compiling it, which is what surfaced this. +#[cfg_attr( + not(feature = "mono-fixtures"), + ignore = "reads tests/fixtures/definitions/, which the projection excludes" +)] +fn apply_assembles_a_real_sdk_assembly_projection_through_the_reviewed_policy() { + let definition_text = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/definitions/deixic-python.json"), + ) + .unwrap(); + + let source = Repo::init("https://github.com/dx-corp/mono.git"); + populate_snapshot(source.path()); + source.write( + "config/projections/deixic-python.json", + definition_text.as_bytes(), + ); + source.write( + "rust/tools/capobara/src/lib.rs", + b"// stand-in for the crate tree", + ); + let sha = source.commit("source"); + let tree_id = source + .git(&["rev-parse", "HEAD:rust/tools/capobara"]) + .trim() + .to_owned(); + + let target = Repo::init("https://github.com/dx-corp/deixic-python.git"); + target.write("SECURITY.md", b"destination owned"); + let base = target.commit("destination"); + target.set_remote_main(&base); + + let output = capobara() + .env("CAPOBARA_TREE_ID_OVERRIDE", &tree_id) + .args(["apply", "--definition"]) + .arg(source.path().join("config/projections/deixic-python.json")) + .arg("--source") + .arg(source.path()) + .arg("--source-sha") + .arg(&sha) + .arg("--target") + .arg(target.path()) + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(0), + "apply failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // The destination now holds exactly the reviewed policy's outputs, the + // receipt, and the destination-owned file it started with -- nothing + // else, and nothing missing. + let policy = policies::policy("deixic-python").unwrap(); + let mut expected: Vec = policy.output_include.clone(); + expected.push(".repository-projection.json".to_owned()); + expected.push("SECURITY.md".to_owned()); + expected.sort(); + let mut actual: Vec = Vec::new(); + let mut stack = vec![target.path().to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.file_name().is_some_and(|name| name == ".git") { + continue; + } + if path.is_dir() { + stack.push(path); + } else { + actual.push( + path.strip_prefix(target.path()) + .unwrap() + .to_string_lossy() + .into_owned(), + ); + } + } + } + actual.sort(); + assert_eq!(actual, expected); + + // A generated module is copied verbatim under `src/`, the destination + // identity transform has run on `pyproject.toml`, and the + // destination-owned file is untouched. + assert_eq!( + std::fs::read(target.path().join("src/meter/v1/meter_pb2.py")).unwrap(), + std::fs::read(source.path().join("gen/python/meter/v1/meter_pb2.py")).unwrap(), + ); + let pyproject = std::fs::read_to_string(target.path().join("pyproject.toml")).unwrap(); + assert!(!pyproject.contains("dx-corp/mono"), "{pyproject}"); + assert_eq!( + std::fs::read(target.path().join("SECURITY.md")).unwrap(), + b"destination owned", + ); + + // The receipt records the projection that actually ran. + let receipt: serde_json::Value = serde_json::from_slice( + &std::fs::read(target.path().join(".repository-projection.json")).unwrap(), + ) + .unwrap(); + assert_eq!(receipt["projection"], "deixic-python"); + assert_eq!(receipt["sourceSha"], sha); + assert_eq!(receipt["destinationRepository"], "dx-corp/deixic-python"); + + // Positive control: the same binary, same source, same destination, but + // an unregistered policy name is still rejected -- the lookup is the + // reviewed one, not "accept anything". + let renamed = definition_text.replace("deixic-python", "deixic-perl"); + source.write("config/projections/deixic-perl.json", renamed.as_bytes()); + let renamed_sha = source.commit("unregistered policy"); + let renamed_target = Repo::init("https://github.com/dx-corp/deixic-perl.git"); + let renamed_base = renamed_target.commit("destination"); + renamed_target.set_remote_main(&renamed_base); + let output = capobara() + .env("CAPOBARA_TREE_ID_OVERRIDE", &tree_id) + .args(["apply", "--definition"]) + .arg(source.path().join("config/projections/deixic-perl.json")) + .arg("--source") + .arg(source.path()) + .arg("--source-sha") + .arg(&renamed_sha) + .arg("--target") + .arg(renamed_target.path()) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + assert_eq!( + String::from_utf8_lossy(&output.stderr).trim(), + "Unknown SDK assembly policy" + ); +} diff --git a/tests/snapshot.rs b/tests/snapshot.rs new file mode 100644 index 0000000..90ceedc --- /dev/null +++ b/tests/snapshot.rs @@ -0,0 +1,75 @@ +mod support; +use capobara::snapshot::with_snapshot; +use support::Repo; + +#[test] +fn git_snapshot_ignores_untracked_and_dirty_content_and_rejects_unknown_revisions() { + let repo = Repo::init("https://github.com/dx-corp/mono.git"); + repo.write("proto/a.proto", b"syntax = \"proto3\";\n"); + repo.write("LICENSE", b"BUSL\n"); + let sha = repo.commit("initial"); + repo.write("proto/a.proto", b"dirty\n"); + repo.write("proto/untracked.proto", b"new\n"); + let seen = with_snapshot( + repo.path(), + &sha, + &["proto".into(), "LICENSE".into(), "missing".into()], + |dir| { + assert_eq!( + std::fs::read(dir.join("proto/a.proto")).unwrap(), + b"syntax = \"proto3\";\n" + ); + assert!(!dir.join("proto/untracked.proto").exists()); + assert!(dir.join("LICENSE").exists()); + Ok(dir.to_path_buf()) + }, + ) + .unwrap(); + assert!(!seen.exists(), "snapshot directory must be removed"); + let bogus = "0".repeat(40); + let unknown_revision_err = + with_snapshot(repo.path(), &bogus, &["proto".into()], |_| Ok(())).unwrap_err(); + assert_eq!(unknown_revision_err.to_string(), "Invalid source revision"); + let no_inputs_err = + with_snapshot(repo.path(), &sha, &["nowhere".into()], |_| Ok(())).unwrap_err(); + assert_eq!( + no_inputs_err.to_string(), + "No committed projection inputs found" + ); +} + +#[test] +fn snapshot_rejects_dash_prefixed_revision_before_touching_git() { + // `root` is a plain tempdir, not a git repository at all: any call to + // git here would fail differently (e.g. "not a git repository"), so an + // exact "Invalid source revision" proves is_sha() short-circuited + // before `--exec-path` ever reached a git argv. + let not_a_repo = tempfile::tempdir().unwrap(); + let err = with_snapshot(not_a_repo.path(), "--exec-path", &["proto".into()], |_| { + Ok(()) + }) + .unwrap_err(); + assert_eq!(err.to_string(), "Invalid source revision"); +} + +#[test] +fn git_snapshot_rejects_submodules() { + let repo = Repo::init("https://github.com/dx-corp/mono.git"); + repo.write("vendor/keep.txt", b"placeholder\n"); + let commit_sha = repo.commit("initial"); + // Register a gitlink (mode 160000) pointing at an arbitrary commit, + // without needing a real nested repository: stage it directly with + // update-index, then commit the index as-is (not via Repo::commit, + // which runs `git add -A` and would try to reconcile the working tree + // against the now-missing vendor/sub directory). + repo.git(&[ + "update-index", + "--add", + "--cacheinfo", + &format!("160000,{commit_sha},vendor/sub"), + ]); + repo.git(&["commit", "-q", "-m", "add gitlink"]); + let head = repo.head(); + let err = with_snapshot(repo.path(), &head, &["vendor".into()], |_| Ok(())).unwrap_err(); + assert_eq!(err.to_string(), "Submodules are not projection inputs"); +} diff --git a/tests/snapshots/transport_github__pr_body_carries_complete_provenance_and_bounded_changed_deleted_paths.snap b/tests/snapshots/transport_github__pr_body_carries_complete_provenance_and_bounded_changed_deleted_paths.snap new file mode 100644 index 0000000..7ac1c86 --- /dev/null +++ b/tests/snapshots/transport_github__pr_body_carries_complete_provenance_and_bounded_changed_deleted_paths.snap @@ -0,0 +1,61 @@ +--- +source: tools/capobara/tests/transport_github.rs +assertion_line: 203 +expression: body +--- + + +## sample projection + +Mono source: dx-corp/mono@1111111111111111111111111111111111111111. +Prior destination base: 2222222222222222222222222222222222222222. +Projected content SHA-256: 5555555555555555555555555555555555555555555555555555555555555555. + +25 changed; 25 deleted. + +- copy/update c0 +- copy/update c1 +- copy/update c2 +- copy/update c3 +- copy/update c4 +- copy/update c5 +- copy/update c6 +- copy/update c7 +- copy/update c8 +- copy/update c9 +- copy/update c10 +- copy/update c11 +- copy/update c12 +- copy/update c13 +- copy/update c14 +- copy/update c15 +- copy/update c16 +- copy/update c17 +- copy/update c18 +- copy/update c19 +- delete d0 +- delete d1 +- delete d2 +- delete d3 +- delete d4 +- delete d5 +- delete d6 +- delete d7 +- delete d8 +- delete d9 +- delete d10 +- delete d11 +- delete d12 +- delete d13 +- delete d14 +- delete d15 +- delete d16 +- delete d17 +- delete d18 +- delete d19 + +Mono owns projected source. Destination-owned CI and policy are preserved. +Apply the sync-hold label to this PR to suspend generated updates during intentional destination work. +Source-side verification does not establish destination CI health. Review destination checks before merging. + +Change-Origin: generated diff --git a/tests/standalone_build.rs b/tests/standalone_build.rs new file mode 100644 index 0000000..e035032 --- /dev/null +++ b/tests/standalone_build.rs @@ -0,0 +1,85 @@ +// If this test fails, the committed Cargo.lock is stale. In dx-corp/mono, +// regenerate it with `rust/tools/capobara/scripts/standalone-lock.sh` -- +// do NOT run `cargo generate-lockfile` from inside `rust/tools/capobara`. +// That crate directory is a member of the `rust/` Cargo workspace and has +// no `[workspace]` table of its own, so Cargo walks up and finds +// `rust/Cargo.lock` instead; running `cargo generate-lockfile` from the +// crate directory silently rewrites the SHARED workspace lock (every other +// crate in Mono) and creates no `rust/tools/capobara/Cargo.lock` at all. +// See the script's own comment for the recipe that actually produces a +// standalone lock in step with the workspace's resolved versions. (The +// script itself is Mono-only tooling and is not projected into this +// repository; if you are reading this file in dx-corp/capobara, the crate +// here already stands alone and plain `cargo generate-lockfile` works.) +// +// This test is Mono-only, like tests/definition_coverage.rs: it reads +// `config/projections/capobara.json` (three directories above the crate) +// and runs `git ls-files` to compute which files to copy, and neither of +// those exists in dx-corp/capobara. Run it only from within dx-corp/mono, +// with `--features mono-fixtures`; run elsewhere (or without the feature) +// it stays `#[ignore]`d either way (it always requires an explicit +// `-- --ignored`, feature or not, since it compiles the crate a second +// time), but forcing it to run anyway will panic in `load_definition` +// rather than doing anything useful. + +mod support; + +use std::process::Command; + +#[test] +#[cfg_attr( + feature = "mono-fixtures", + ignore = "compiles the crate a second time; run explicitly in Mono CI and before any Cargo.toml change" +)] +#[cfg_attr( + not(feature = "mono-fixtures"), + ignore = "requires --features mono-fixtures: reads config/projections/capobara.json and git ls-files, neither of which exists outside dx-corp/mono" +)] +fn crate_builds_outside_the_workspace_with_the_committed_lockfile() { + let crate_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let scratch = tempfile::tempdir().unwrap(); + + // Copy exactly the files config/projections/capobara.json projects -- + // the same include/exclude match `modes::copy_v1::collect` uses in + // production -- rather than a second, independently-maintained file + // list that could silently drift from the definition (see + // tests/definition_coverage.rs). + let definition = support::capobara_definition(); + let mapping = support::crate_mapping(&definition); + for path in support::git_ls_files(&crate_dir) { + if !support::is_projected(mapping, &path) { + continue; + } + let from = crate_dir.join(&path); + let to = scratch.path().join(&path); + std::fs::create_dir_all(to.parent().expect("projected paths are never bare roots")) + .unwrap(); + std::fs::copy(&from, &to).unwrap(); + } + + #[allow( + clippy::disallowed_methods, + reason = "test builds the copied crate standalone, outside the workspace" + )] + let status = Command::new("cargo") + // Deliberately NOT `--no-run`. Compiling the projected tree proves + // the lockfile is in step with the manifest, but it cannot see a test + // that *runs* against a file the projection does not carry -- and + // that class of defect is exactly what ships a red `ci` to + // `dx-corp/capobara`, whose `main` requires that check. Two instances + // existed when this line was changed: the equivalence test read + // `EQUIVALENCE.md` (UNPROJECTED) and the SDK-assembly test read + // `tests/fixtures/definitions/` (excluded by the definition); both + // now carry a `mono-fixtures` gate, and this executes the suite so + // the next one cannot land unnoticed. + .args(["test", "--locked", "--offline"]) + .current_dir(scratch.path()) + .env("CAPOBARA_TREE_ID", "0".repeat(40)) + .env("CARGO_TARGET_DIR", scratch.path().join("target")) + .status() + .unwrap(); + assert!( + status.success(), + "standalone build failed; the committed Cargo.lock is stale. In dx-corp/mono, regenerate it with rust/tools/capobara/scripts/standalone-lock.sh. Do NOT run `cargo generate-lockfile` inside rust/tools/capobara: the crate is a member of the rust/ workspace and cargo will rewrite rust/Cargo.lock instead." + ); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 0000000..4cd561d --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,215 @@ +#![allow(dead_code)] +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub struct Repo { + dir: tempfile::TempDir, +} + +impl Repo { + pub fn init(remote_url: &str) -> Repo { + let dir = tempfile::tempdir().unwrap(); + let repo = Repo { dir }; + repo.git(&["init", "-q", "-b", "main"]); + repo.git(&["config", "user.name", "test"]); + repo.git(&["config", "user.email", "test@example.com"]); + repo.git(&["config", "commit.gpgsign", "false"]); + repo.git(&["remote", "add", "origin", remote_url]); + repo + } + pub fn path(&self) -> &Path { + self.dir.path() + } + #[allow( + clippy::disallowed_methods, + reason = "integration tests drive a scratch git repository" + )] + pub fn git(&self, args: &[&str]) -> String { + let out = Command::new("git") + .arg("-C") + .arg(self.path()) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).unwrap() + } + pub fn write(&self, path: &str, bytes: &[u8]) { + let full = self.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, bytes).unwrap(); + } + pub fn commit(&self, message: &str) -> String { + self.git(&["add", "-A"]); + self.git(&["commit", "-q", "--allow-empty", "-m", message]); + self.git(&["rev-parse", "HEAD"]).trim().to_owned() + } + pub fn set_remote_main(&self, sha: &str) { + self.git(&["update-ref", "refs/remotes/origin/main", sha]); + } + pub fn head(&self) -> String { + self.git(&["rev-parse", "HEAD"]).trim().to_owned() + } + pub fn into_path(self) -> PathBuf { + self.dir.keep() + } +} + +/// Writes every input file the reviewed `deixic-python` SDK assembly +/// policy names into `root`, with just enough content to satisfy that +/// policy's import-closure check: the BFS root (`console/v1/console_pb2.py`) +/// imports every other generated Python module directly. +/// +/// The file *lists* come from `policies::PYTHON_SDK_FILES` and +/// `policies::PYTHON_GENERATED_FILES`, the same constants the policy is +/// built from, so this cannot drift from the policy it feeds. +/// +/// `tests/sdk_assembly.rs` builds the same Python snapshot inline, as part +/// of a larger one covering all three policies. That duplication is +/// deliberate: folding its Python block into this helper would mean editing +/// a file another task is actively revising. See the task-13 fix-round +/// report. +pub fn populate_python_snapshot(root: &Path) { + use capobara::modes::sdk_assembly::policies; + + const PYPROJECT_TOML: &str = concat!( + "[project]\n", + "name = \"deixic\"\n", + "version = \"0.1.0\"\n", + "dependencies = [\n", + " \"httpx>=0.27\",\n", + " \"evalops-sdk-core==1.2.3\",\n", + "]\n", + "\n", + "[project.urls]\n", + "Repository = \"https://github.com/dx-corp/mono\"\n", + ); + + fn write(root: &Path, path: &str, contents: &[u8]) { + let full = root.join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(&full, contents).unwrap(); + } + + fn package_and_module(generated_path: &str) -> (String, String) { + let without_ext = generated_path.strip_suffix(".py").unwrap(); + match without_ext.rsplit_once('/') { + Some((dir, module)) => (dir.replace('/', "."), module.to_string()), + None => (String::new(), without_ext.to_string()), + } + } + + for path in policies::PYTHON_SDK_FILES { + let full = format!("sdk/deixic/python/{path}"); + if *path == "pyproject.toml" { + write(root, &full, PYPROJECT_TOML.as_bytes()); + } else { + write(root, &full, format!("# {path}\n").as_bytes()); + } + } + for path in policies::PYTHON_GENERATED_FILES { + let full = format!("gen/python/{path}"); + if *path == "console/v1/console_pb2.py" { + let mut content = String::from("# generated console module\n"); + for other in policies::PYTHON_GENERATED_FILES { + if *other == *path { + continue; + } + let (package, module) = package_and_module(other); + content.push_str(&format!("from {package} import {module}\n")); + } + write(root, &full, content.as_bytes()); + } else { + write(root, &full, b"# generated\n"); + } + } +} + +// --------------------------------------------------------------------- +// The crate's own projection definition, for tests that check the crate +// tree against it rather than a synthetic fixture (definition_coverage.rs, +// standalone_build.rs). Reads the real, committed +// `config/projections/capobara.json`, three directories above the crate +// (`rust/tools/capobara` -> `rust/tools` -> `rust` -> repo root), and +// reuses `capobara::definition::load_definition` -- the same validation +// and parsing the real tool uses -- rather than parsing the JSON by hand a +// second time. +// --------------------------------------------------------------------- + +/// Loads and validates the real `config/projections/capobara.json`. +pub fn capobara_definition() -> capobara::definition::Definition { + let path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../config/projections/capobara.json"); + capobara::definition::load_definition(&path, &|_| None) + .unwrap_or_else(|e| panic!("load_definition({}): {e}", path.display())) + .definition +} + +/// The definition's mapping for the crate directory itself (as opposed to +/// the repo-root `LICENSE` mapping, whose `source` is `"."`). +pub fn crate_mapping( + definition: &capobara::definition::Definition, +) -> &capobara::definition::Mapping { + definition + .mappings + .iter() + .find(|m| m.source == "rust/tools/capobara") + .expect("capobara.json has no rust/tools/capobara mapping") +} + +/// Whether `path` (relative to the crate root) is actually projected by +/// `mapping` -- the same `Matcher`-based predicate `modes::copy_v1::collect` +/// uses in production (`included.matches(&path) && !excluded.matches(&path)`), +/// not a reimplementation of it. `false` for a path no include pattern +/// names at all *and* for a path an include pattern names but an exclude +/// pattern deliberately removes (see `is_named_by_include`, which tells +/// those two `false` cases apart). +pub fn is_projected(mapping: &capobara::definition::Mapping, path: &str) -> bool { + let included = capobara::tree::Matcher::new(&mapping.include).unwrap(); + let excluded = capobara::tree::Matcher::new(&mapping.exclude).unwrap(); + included.matches(path) && !excluded.matches(path) +} + +/// Whether some pattern in `mapping.include` names `path` at all, regardless +/// of whether `mapping.exclude` subsequently removes it. A file the +/// definition's include patterns never mention (e.g. `scripts/**`, which +/// none of `Cargo.toml`/`Cargo.lock`/`README.md`/`build.rs`/`src/**`/ +/// `tests/**` matches) is not "handled" by the definition at all and needs +/// an explicit `UNPROJECTED` allowlist entry; a file an include pattern +/// names but `exclude` then removes (e.g. `tests/fixtures/definitions/**`) +/// *is* handled -- excluding it is a decision the definition itself makes, +/// not an omission -- so it needs no allowlist entry even though +/// `is_projected` is `false` for it too. +pub fn is_named_by_include(mapping: &capobara::definition::Mapping, path: &str) -> bool { + capobara::tree::Matcher::new(&mapping.include) + .unwrap() + .matches(path) +} + +/// `git ls-files` under `dir`, relative to `dir`. +#[allow( + clippy::disallowed_methods, + reason = "test enumerates the crate's own git-tracked files" +)] +pub fn git_ls_files(dir: &Path) -> Vec { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(["ls-files"]) + .output() + .unwrap(); + assert!( + out.status.success(), + "git ls-files: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout) + .unwrap() + .lines() + .map(str::to_owned) + .collect() +} diff --git a/tests/transport_git.rs b/tests/transport_git.rs new file mode 100644 index 0000000..82c0b8e --- /dev/null +++ b/tests/transport_git.rs @@ -0,0 +1,969 @@ +//! Integration tests for `capobara::transport::git`, using `RecordedApi` +//! (available here via the `recorded-api` feature). Ports, by name, six +//! tests from `scripts/projections/transport.test.mjs`: +//! `complete_candidate_rejects_committed_historical_additions_and_destination_owned_changes`, +//! `complete_candidate_accepts_stable_branch_reuse_after_a_squash_merge`, +//! `squash_merged_stable_branch_is_unchanged_when_main_already_has_the_projection`, +//! `a_new_revision_replaces_an_unreachable_sync_branch_base_with_durable_main`, +//! `publisher_rejects_report_tampering_and_unrelated_staged_files_before_committing`, +//! `a_hold_appearing_after_local_validation_stops_every_remote_write`. +//! +//! The destination checkout (`target`) is a clone of a local bare +//! repository (`bare`). `assert_destination_checkout` requires `origin` to +//! be the GitHub HTTPS URL for the destination repository, so `target`'s +//! `origin` is rewritten to that fake URL immediately after the initial +//! seed push; `bare` itself is inspected directly (never through a remote +//! name) whenever a test needs to prove nothing was pushed. This is the +//! smaller diff against `push_sync_branch`'s two-parameter signature (it +//! always pushes to `origin`, matching `transport.mjs`). None of the six +//! ported tests reaches a successful push (each either errors out, or +//! resolves to `Unchanged`/`Held` beforehand), so `push_sync_branch` is +//! covered separately by `push_sync_branch_body` below, which points +//! `origin` back at `bare` first so the push stays offline. +//! +//! Two hermetic mechanisms keep `tooldigest::embedded()` matching this +//! file's synthetic `rust/tools/capobara` stand-in, without ever needing +//! `CAPOBARA_TREE_ID_OVERRIDE` exported before `cargo test` starts (this +//! crate forbids unsafe code, so the same-process `std::env::set_var` -- +//! `unsafe` as of this toolchain -- is not an option either): +//! - `Fixture`'s own `plan`/`apply`/`verify` steps run by spawning the +//! compiled `capobara` binary (`run_capobara`, the same pattern +//! `tests/project_cli.rs` uses), with `CAPOBARA_TREE_ID_OVERRIDE` set on +//! that one child process only. +//! - Every other call goes through `transport::git`'s production functions +//! (`assert_candidate_matches_main_projection`, `publish_prepared_tree`), +//! which call `cli::project::run` in-process by design (never spawning a +//! binary themselves) -- there is no `Command` for those to attach an +//! env override to. `Fixture::new` instead calls +//! `capobara::tooldigest::override_for_tests(&tree_id)` once per fixture, +//! which pins this *test* process's own `tooldigest::embedded()` before +//! any in-process call can read it. + +// The in-process tooldigest override (`tooldigest::override_for_tests`) this +// file's fixture relies on exists only in debug builds; compile this whole +// test target to an empty binary under any profile with debug_assertions +// off (e.g. `cargo test --release`) instead of failing to compile. +#![cfg(debug_assertions)] +#![allow(dead_code)] +mod support; + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::{Value, json}; + +use capobara::definition::{Definition, definition_from_value}; +use capobara::receipt::Provenance; +use capobara::report::Report; +use capobara::transport::{ + Published, RecordedApi, assert_candidate_matches_main_projection, prepare_destination, + publish_prepared_tree, push_sync_branch, +}; +use support::Repo; + +const REPO: &str = "test/public"; +const SYNC_BRANCH: &str = "sync/mono-projection"; +const STAND_IN: &[u8] = b"// stand-in for the crate tree"; + +const REPOS_ENDPOINT: &str = "repos/test/public"; +const INSTALLATION_ENDPOINT: &str = "installation/repositories?per_page=100"; +const PULLS_ENDPOINT: &str = + "repos/test/public/pulls?state=open&base=main&head=test%3Async%2Fmono-projection"; + +fn no_sdk(_: &str) -> Option> { + None +} + +fn definition_json() -> Value { + json!({ + "schemaVersion": 1, "name": "fixture", "class": "source-tree", "mode": "copy-v1", + "sourceRepository": "test/mono", "visibility": "public", + "mappings": [ + {"source": "source", "destination": ".", "include": ["src/**", "README.md"], "exclude": []} + ], + "destination": { + "repository": REPO, "branch": "main", "syncBranch": SYNC_BRANCH, "holdLabel": "sync-hold" + }, + "destinationOwned": [".github/**", "SECURITY.md"], + "deletion": "owned-paths", "provenance": ".repository-projection.json", "validation": "tree-v1", + "outputManaged": ["src/**", "README.md"] + }) +} + +fn info_response() -> Value { + json!({ + "full_name": REPO, "archived": false, "disabled": false, + "default_branch": "main", "visibility": "public", + }) +} + +fn installation_response() -> Value { + json!({"total_count": 1, "repositories": [{"full_name": REPO}]}) +} + +fn no_pr_api() -> RecordedApi { + RecordedApi::new(vec![ + ("GET", REPOS_ENDPOINT, info_response()), + ("GET", INSTALLATION_ENDPOINT, installation_response()), + ("GET", PULLS_ENDPOINT, json!([])), + ]) +} + +/// An open, non-held PR whose head is exactly `head_sha` -- used to exercise +/// `prepare_destination`'s "destination branch advanced" check on the path +/// that reuses an existing sync branch. +fn open_pr_api(head_sha: &str) -> RecordedApi { + let pr = json!({ + "number": 1, + "labels": [], + "head": { + "repo": {"full_name": REPO}, + "ref": SYNC_BRANCH, + "sha": head_sha, + }, + "base": {"ref": "main"}, + }); + RecordedApi::new(vec![ + ("GET", REPOS_ENDPOINT, info_response()), + ("GET", INSTALLATION_ENDPOINT, installation_response()), + ("GET", PULLS_ENDPOINT, json!([pr])), + ]) +} + +fn read_report(path: &Path) -> Report { + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() +} + +fn write_report(path: &Path, report: &Report) { + std::fs::write(path, serde_json::to_string(report).unwrap()).unwrap(); +} + +#[allow( + clippy::disallowed_methods, + reason = "integration test executes the capobara binary" +)] +fn capobara() -> Command { + Command::new(env!("CARGO_BIN_EXE_capobara")) +} + +/// Runs `capobara --definition ... --source ... --source-sha ... +/// --target ... --report ...` as a child process, with +/// `CAPOBARA_TREE_ID_OVERRIDE` set on that child only (see the module doc +/// comment for why an in-process call cannot do this hermetically). +/// Asserts the child exited successfully, including its stderr in the +/// panic message otherwise -- exactly `tests/project_cli.rs`'s pattern. +fn run_capobara( + command: &str, + definition: &Path, + source: &Path, + source_sha: &str, + target: &Path, + report: &Path, + tree_id: &str, +) { + let output = capobara() + .env("CAPOBARA_TREE_ID_OVERRIDE", tree_id) + .args([command, "--definition"]) + .arg(definition) + .arg("--source") + .arg(source) + .arg("--source-sha") + .arg(source_sha) + .arg("--target") + .arg(target) + .arg("--report") + .arg(report) + .output() + .unwrap(); + assert!( + output.status.success(), + "capobara {command} failed (status {:?}): stderr={} stdout={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); +} + +#[allow( + clippy::disallowed_methods, + reason = "integration tests drive scratch git repositories outside the support::Repo helper" +)] +fn run_git(path: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).unwrap() +} + +#[allow( + clippy::disallowed_methods, + reason = "integration test drives a scratch git repository outside the support::Repo helper" +)] +fn clone_no_local(src: &Path, dest: &Path) { + let out = Command::new("git") + .args(["clone", "--quiet", "--no-local"]) + .arg(src) + .arg(dest) + .output() + .unwrap(); + assert!( + out.status.success(), + "git clone --no-local: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[allow( + clippy::disallowed_methods, + reason = "integration test probes object reachability in a scratch git repository" +)] +fn commit_exists(path: &Path, sha: &str) -> bool { + Command::new("git") + .arg("-C") + .arg(path) + .args(["cat-file", "-e", &format!("{sha}^{{commit}}")]) + .output() + .map(|out| out.status.success()) + .unwrap_or(false) +} + +#[allow( + clippy::disallowed_methods, + reason = "integration test inspects a scratch bare git repository's refs" +)] +fn ref_exists(path: &Path, refname: &str) -> bool { + Command::new("git") + .arg("-C") + .arg(path) + .args(["show-ref", "--verify", "--quiet", refname]) + .output() + .map(|out| out.status.success()) + .unwrap_or(false) +} + +/// A plain git checkout outside the `support::Repo` helper: used for the +/// fresh clone in test 4, which is created by `git clone` itself rather +/// than `Repo::init`. +struct RawRepo(PathBuf); + +impl RawRepo { + fn path(&self) -> &Path { + &self.0 + } + fn git(&self, args: &[&str]) -> String { + run_git(&self.0, args) + } +} + +struct Fixture { + definition: Definition, + definition_text: String, + source: Repo, + source_sha: String, + /// `rust/tools/capobara`'s tree id at `source_sha`, computed once (the + /// stand-in file never changes across any commit any test makes to + /// `source`, so one value is valid for every `run_capobara` call this + /// fixture makes). + tree_id: String, + target: Repo, + bare: PathBuf, + report_path: PathBuf, + base: String, +} + +impl Fixture { + fn new() -> Fixture { + // `.keep()` deliberately leaks these scratch directories for the + // test process's lifetime (matching `support::Repo::into_path`'s + // own convention), so `Fixture` never needs to hold a `TempDir` + // field purely to keep it alive. + let bare = tempfile::tempdir().unwrap().keep(); + run_git(&bare, &["init", "-q", "--bare", "-b", "main"]); + + let source = Repo::init("https://github.com/test/mono.git"); + source.write("source/src/client.txt", b"public code\n"); + source.write("source/README.md", b"SDK\n"); + let definition_text = serde_json::to_string(&definition_json()).unwrap(); + source.write( + "config/projections/fixture.json", + definition_text.as_bytes(), + ); + source.write("rust/tools/capobara/src/lib.rs", STAND_IN); + let source_sha = source.commit("source"); + let tree_id = source + .git(&["rev-parse", "HEAD:rust/tools/capobara"]) + .trim() + .to_owned(); + // Pins this process's `tooldigest::embedded()` before any in-process + // `cli::project::run` call -- including the ones made internally by + // `assert_candidate_matches_main_projection`/`publish_prepared_tree`, + // which have no `Command` to attach an env override to. Every + // fixture in this file writes the same `STAND_IN` bytes, so every + // call computes the same `tree_id` and this is a no-op after the + // first fixture in this test binary's process. + capobara::tooldigest::override_for_tests(&tree_id); + + let target = Repo::init(bare.to_str().unwrap()); + target.write("SECURITY.md", b"owned\n"); + target.write(".github/workflows/ci.yml", b"ci\n"); + let base = target.commit("destination"); + target.git(&["push", "-q", "origin", "main"]); + target.git(&["update-ref", "refs/remotes/origin/main", &base]); + target.git(&[ + "remote", + "set-url", + "origin", + &format!("https://github.com/{REPO}.git"), + ]); + target.git(&["switch", "-c", SYNC_BRANCH]); + + let definition = definition_from_value(definition_json(), &no_sdk) + .unwrap() + .definition; + + let report_path = tempfile::tempdir().unwrap().keep().join("report.json"); + + run_capobara( + "apply", + &source.path().join("config/projections/fixture.json"), + source.path(), + &source_sha, + target.path(), + &report_path, + &tree_id, + ); + + Fixture { + definition, + definition_text, + source, + source_sha, + tree_id, + target, + bare, + report_path, + base, + } + } + + fn apply(&self, source_sha: &str) { + run_capobara( + "apply", + &self.source.path().join("config/projections/fixture.json"), + self.source.path(), + source_sha, + self.target.path(), + &self.report_path, + &self.tree_id, + ); + } +} + +#[test] +fn complete_candidate_rejects_committed_historical_additions_and_destination_owned_changes() { + for (label, path, content) in [ + ("unmanaged source", "historical.txt", &b"unreviewed\n"[..]), + ( + "destination policy", + ".github/workflows/ci.yml", + &b"sync-only policy\n"[..], + ), + ] { + let f = Fixture::new(); + f.target.commit("initial projection"); + f.target.write(path, content); + f.target.commit("historical change"); + let err = assert_candidate_matches_main_projection( + &f.definition, + f.source.path(), + &f.source_sha, + f.target.path(), + ) + .unwrap_err() + .to_string(); + assert!( + err.starts_with( + "Candidate tree differs from destination main plus the verified projection" + ), + "{label}: unexpected error: {err}" + ); + } +} + +#[test] +fn complete_candidate_accepts_stable_branch_reuse_after_a_squash_merge() { + let mut f = Fixture::new(); + let sync_head = f.target.commit("initial projection"); + f.target.git(&["switch", "main"]); + f.target.git(&["checkout", SYNC_BRANCH, "--", "."]); + let squash_head = f.target.commit("squash fixture"); + f.target + .git(&["update-ref", "refs/remotes/origin/main", &squash_head]); + f.target.git(&[ + "update-ref", + &format!("refs/remotes/origin/{SYNC_BRANCH}"), + &sync_head, + ]); + f.target.git(&["branch", "-D", SYNC_BRANCH]); + + f.source.write("source/src/client.txt", b"public code v2\n"); + f.source_sha = f.source.commit("second source revision"); + + let prepared = prepare_destination( + &f.definition, + &f.definition_text, + f.target.path(), + &f.source_sha, + &no_pr_api(), + ) + .unwrap(); + assert!(!prepared.held); + + f.apply(&f.source_sha); + + if let Err(e) = assert_candidate_matches_main_projection( + &f.definition, + f.source.path(), + &f.source_sha, + f.target.path(), + ) { + panic!("{e}"); + } +} + +#[test] +fn squash_merged_stable_branch_is_unchanged_when_main_already_has_the_projection() { + let f = Fixture::new(); + let sync_head = f.target.commit("initial projection"); + f.target.git(&["switch", "main"]); + f.target.git(&["checkout", SYNC_BRANCH, "--", "."]); + let squash_head = f.target.commit("squash fixture"); + f.target + .git(&["update-ref", "refs/remotes/origin/main", &squash_head]); + f.target.git(&[ + "update-ref", + &format!("refs/remotes/origin/{SYNC_BRANCH}"), + &sync_head, + ]); + f.target.git(&["branch", "-D", SYNC_BRANCH]); + + let prepared = prepare_destination( + &f.definition, + &f.definition_text, + f.target.path(), + &f.source_sha, + &no_pr_api(), + ) + .unwrap(); + assert!(!prepared.held); + + f.apply(&f.source_sha); + + let report = read_report(&f.report_path); + let result = publish_prepared_tree( + &f.definition, + f.source.path(), + &f.source_sha, + f.target.path(), + &report, + &no_pr_api(), + ) + .unwrap(); + assert!(matches!(result, Published::Unchanged)); +} + +#[test] +fn a_new_revision_replaces_an_unreachable_sync_branch_base_with_durable_main() { + let mut f = Fixture::new(); + f.target.commit("first projection"); + f.target.git(&["switch", "main"]); + f.target.git(&["checkout", SYNC_BRANCH, "--", "."]); + f.target.commit("first squash merge"); + + f.target.git(&["switch", SYNC_BRANCH]); + f.target.git(&["merge", "--no-ff", "--no-edit", "main"]); + let transient_base = f.target.git(&["rev-parse", "HEAD"]).trim().to_owned(); + let receipt_path = f.target.path().join(&f.definition.provenance); + // Round-trip through the typed `Provenance` (not a raw `Value`) and + // re-serialize with its own `to_receipt_bytes`, so the rewritten + // receipt is byte-identical, field order included, to what + // `build_projection` itself would write for these field values. A + // `Value`-based rewrite is not safe here: `serde_json::Value`'s object + // map does not preserve the original file's key order, so a later + // `verify` rebuild -- which writes a receipt via `to_receipt_bytes` -- + // would (correctly) detect that on-disk byte sequence as drift, even + // though every logical field matches. + let mut receipt: Provenance = + serde_json::from_slice(&std::fs::read(&receipt_path).unwrap()).unwrap(); + receipt.prior_projected_base = transient_base.clone(); + std::fs::write(&receipt_path, receipt.to_receipt_bytes()).unwrap(); + f.target.commit("projection with a transient base"); + + f.target.git(&["switch", "main"]); + f.target.git(&["checkout", SYNC_BRANCH, "--", "."]); + let squash_head = f.target.commit("second squash merge"); + f.target.git(&["branch", "-D", SYNC_BRANCH]); + + let fresh_dir = tempfile::tempdir().unwrap(); + let fresh_path = fresh_dir.path().join("fresh-public"); + clone_no_local(f.target.path(), &fresh_path); + let fresh = RawRepo(fresh_path); + fresh.git(&[ + "remote", + "set-url", + "origin", + &format!("https://github.com/{REPO}.git"), + ]); + + // Exact main remains an authoritative verification boundary even though + // the historical sync-branch merge object is absent from a clean clone. + assert!( + !commit_exists(fresh.path(), &transient_base), + "transient base unexpectedly reachable in a fresh clone" + ); + + run_capobara( + "verify", + &f.source.path().join("config/projections/fixture.json"), + f.source.path(), + &f.source_sha, + fresh.path(), + &f.report_path, + &f.tree_id, + ); + + f.source.write("source/src/client.txt", b"public code v2\n"); + f.source_sha = f.source.commit("second source revision"); + + let prepared = prepare_destination( + &f.definition, + &f.definition_text, + fresh.path(), + &f.source_sha, + &no_pr_api(), + ) + .unwrap(); + assert!(!prepared.held); + + run_capobara( + "apply", + &f.source.path().join("config/projections/fixture.json"), + f.source.path(), + &f.source_sha, + fresh.path(), + &f.report_path, + &f.tree_id, + ); + + let updated: Provenance = serde_json::from_slice( + &std::fs::read(fresh.path().join(&f.definition.provenance)).unwrap(), + ) + .unwrap(); + assert_eq!(updated.prior_projected_base, squash_head); +} + +#[test] +fn publisher_rejects_report_tampering_and_unrelated_staged_files_before_committing() { + let f = Fixture::new(); + let report = read_report(&f.report_path); + + f.target.write("unreviewed.txt", b"not an approved output"); + let mut tampered = report.clone(); + tampered.copied_paths.push("unreviewed.txt".to_string()); + tampered.copied_count += 1; + write_report(&f.report_path, &tampered); + + let err = publish_prepared_tree( + &f.definition, + f.source.path(), + &f.source_sha, + f.target.path(), + &tampered, + &no_pr_api(), + ) + .unwrap_err() + .to_string(); + assert!( + err.starts_with("Untrusted publication report"), + "unexpected error: {err}" + ); + assert_eq!(f.target.head(), f.base); + + write_report(&f.report_path, &report); + f.target.git(&["add", "unreviewed.txt"]); + let err = publish_prepared_tree( + &f.definition, + f.source.path(), + &f.source_sha, + f.target.path(), + &report, + &no_pr_api(), + ) + .unwrap_err() + .to_string(); + assert!( + err.starts_with( + "Candidate tree differs from destination main plus the verified projection" + ), + "unexpected error: {err}" + ); + assert_eq!(f.target.head(), f.base); +} + +#[test] +fn a_hold_appearing_after_local_validation_stops_every_remote_write() { + let f = Fixture::new(); + let report = read_report(&f.report_path); + + let held_pr = json!({ + "number": 1, + "labels": [{"name": "sync-hold"}], + "head": { + "repo": {"full_name": REPO}, + "ref": SYNC_BRANCH, + "sha": "a".repeat(40), + }, + "base": {"ref": "main"}, + }); + let held_api = RecordedApi::new(vec![ + ("GET", REPOS_ENDPOINT, info_response()), + ("GET", INSTALLATION_ENDPOINT, installation_response()), + ("GET", PULLS_ENDPOINT, json!([])), + ("GET", REPOS_ENDPOINT, info_response()), + ("GET", INSTALLATION_ENDPOINT, installation_response()), + ("GET", PULLS_ENDPOINT, json!([held_pr])), + ]); + + let result = publish_prepared_tree( + &f.definition, + f.source.path(), + &f.source_sha, + f.target.path(), + &report, + &held_api, + ) + .unwrap(); + assert!(matches!(result, Published::Held)); + assert_eq!( + held_api.calls().len(), + 6, + "no write call should be attempted" + ); + assert_ne!( + f.target.head(), + f.base, + "the local commit still happens before the second (held) read" + ); + + assert!( + !ref_exists(&f.bare, &format!("refs/heads/{SYNC_BRANCH}")), + "sync branch unexpectedly present on the bare remote: push_sync_branch must not have run" + ); +} + +/// Fix round 1: a stored receipt that isn't parseable JSON at all is a hard +/// failure (`Error::Invalid`), matching Node's `JSON.parse` throwing -- +/// never silently treated as "no previous receipt" (which would otherwise +/// force an unconditional, unexamined merge). +#[test] +fn prepare_rejects_a_malformed_stored_receipt() { + let f = Fixture::new(); + f.target.commit("initial projection"); + let receipt_path = f.target.path().join(&f.definition.provenance); + std::fs::write(&receipt_path, b"{not json").unwrap(); + let corrupt_head = f.target.commit("corrupt receipt"); + + f.target.git(&[ + "update-ref", + &format!("refs/remotes/origin/{SYNC_BRANCH}"), + &corrupt_head, + ]); + + // Advance origin/main past corrupt_head's own history first: without + // this, origin/main is still an ancestor of corrupt_head, so even a + // wrongly-attempted merge would be a no-op ("Already up to date") and + // leave HEAD unchanged regardless of whether prepare_destination + // correctly refused to merge -- the assertion below would hold either + // way and prove nothing. With a real, divergent commit on main, a + // wrongly-attempted merge would produce a new merge commit and move + // HEAD, so the assertion actually discriminates. + f.target.git(&["switch", "main"]); + f.target.write("NOTICE.md", b"main advanced\n"); + let main_tip = f.target.commit("advance main"); + f.target + .git(&["update-ref", "refs/remotes/origin/main", &main_tip]); + f.target.git(&["branch", "-D", SYNC_BRANCH]); + + let err = prepare_destination( + &f.definition, + &f.definition_text, + f.target.path(), + &f.source_sha, + &open_pr_api(&corrupt_head), + ) + .unwrap_err() + .to_string(); + assert_eq!(err, "Malformed stored provenance"); + assert_eq!( + f.target.head(), + corrupt_head, + "no merge should have happened after a hard parse failure" + ); +} + +/// Fix round 1: a stored receipt that parses as JSON but is missing +/// `sourceSha` reads as `None` (not a match for the current source SHA), +/// which -- exactly like Node's `previous?.sourceSha !== sourceSha` -- +/// triggers the merge, rather than being rejected outright (Node performs +/// no key-set validation in `prepareDestination`). +#[test] +fn prepare_merges_main_when_the_stored_receipt_lacks_a_source_sha() { + let f = Fixture::new(); + f.target.commit("initial projection"); + + let receipt_path = f.target.path().join(&f.definition.provenance); + let mut receipt: Value = + serde_json::from_slice(&std::fs::read(&receipt_path).unwrap()).unwrap(); + receipt.as_object_mut().unwrap().remove("sourceSha"); + std::fs::write(&receipt_path, serde_json::to_vec(&receipt).unwrap()).unwrap(); + let stale_head = f.target.commit("receipt missing sourceSha"); + f.target.git(&[ + "update-ref", + &format!("refs/remotes/origin/{SYNC_BRANCH}"), + &stale_head, + ]); + + f.target.git(&["switch", "main"]); + f.target.write("NOTICE.md", b"main advanced\n"); + let main_tip = f.target.commit("advance main"); + f.target + .git(&["update-ref", "refs/remotes/origin/main", &main_tip]); + f.target.git(&["branch", "-D", SYNC_BRANCH]); + + let prepared = prepare_destination( + &f.definition, + &f.definition_text, + f.target.path(), + &f.source_sha, + &no_pr_api(), + ) + .unwrap(); + assert!(!prepared.held); + + assert_ne!( + f.target.head(), + stale_head, + "a merge commit should have been created" + ); + assert!( + f.target.path().join("NOTICE.md").exists(), + "main's tip should have been merged into the sync branch" + ); +} + +/// `git ls-remote `, run from `from`: the SHA the bare +/// repository currently advertises for `refname`, or `None` when it has no +/// such ref. Reads the remote the way a third party would, never through +/// `target`'s remote-tracking refs (which a push updates locally too). +fn ls_remote_sha(from: &Path, remote: &Path, refname: &str) -> Option { + let out = run_git(from, &["ls-remote", remote.to_str().unwrap(), refname]); + out.split_whitespace().next().map(str::to_owned) +} + +#[allow( + clippy::disallowed_methods, + reason = "integration test re-executes its own test binary with GH_TOKEN set" +)] +fn self_exe() -> Command { + Command::new(std::env::current_exe().unwrap()) +} + +/// Fix round 5 (D1): Node runs the projector's `verify` through +/// `execFileSync`, which throws on a non-zero exit, so a `verify` that +/// reports drift aborts publication before the baseline plan, before any +/// staging or commit, and before any remote write. The Rust port runs +/// `verify` in-process, where drift arrives as `Ok(1)` rather than as an +/// `Err` -- a status `?` alone discards. +#[test] +fn a_drifting_verify_aborts_publication_before_any_remote_write() { + let f = Fixture::new(); + let report = read_report(&f.report_path); + + // `Fixture::new`'s `apply` left the projection in the working tree and + // a matching report on disk. Rewriting one projected file now is pure + // drift: `verify` rebuilds the plan against this tree, finds one file + // to re-copy, and exits 1 -- while still writing a report whose + // provenance matches `report`'s, so the provenance equality check + // downstream would not notice a thing. + f.target + .write("src/client.txt", b"tampered by the destination\n"); + + let api = no_pr_api(); + let err = publish_prepared_tree( + &f.definition, + f.source.path(), + &f.source_sha, + f.target.path(), + &report, + &api, + ) + .unwrap_err() + .to_string(); + + // Asserting the *verify* message, not merely `Err`: with the exit + // status discarded this same tampering still fails, but much later, at + // `assert_candidate_matches_main_projection` ("Candidate tree differs + // from destination main plus the verified projection") -- after a + // baseline clone and a second full projection. A bare `is_err()` could + // not tell the two apart, so only this prefix proves publication + // stopped at `verify`. + assert!( + err.starts_with("Command failed: capobara verify --definition "), + "unexpected error: {err}" + ); + + assert_eq!( + f.target.head(), + f.base, + "nothing may be committed once verify has failed" + ); + assert!( + !ref_exists(&f.bare, &format!("refs/heads/{SYNC_BRANCH}")), + "sync branch unexpectedly present on the bare remote: no push may happen" + ); + let calls = api.calls(); + assert_eq!( + calls.len(), + 3, + "only the first readPublicationState triple may run: {calls:?}" + ); + assert!( + calls.iter().all(|(method, _, _)| method == "GET"), + "no PR may be created or updated: {calls:?}" + ); +} + +/// Fix round 5 (D3): `push_sync_branch` reads `GH_TOKEN` from *this* +/// process's environment, and this crate forbids `unsafe`, so the +/// same-process `std::env::set_var` is not available to install it. This +/// test therefore re-executes this same test binary for the body below, +/// with `GH_TOKEN` set on that one child process only -- the same shape as +/// `run_capobara`'s `CAPOBARA_TREE_ID_OVERRIDE` handling, and the reason +/// the body carries `#[ignore]` (so it never runs in this parent process, +/// where the variable may be absent). +#[test] +fn push_sync_branch_is_covered_by_a_child_process_with_a_token() { + let output = self_exe() + .env("GH_TOKEN", "x-test-token") + .args([ + "--exact", + "--ignored", + "--nocapture", + "--test-threads=1", + "push_sync_branch_body", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "child test binary failed (status {:?}): stdout={stdout} stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + // libtest exits 0 when a filter matches nothing, so a typo in the name + // above would make this a test that cannot fail. Require that the body + // actually ran. + assert!( + stdout.contains("1 passed"), + "the child did not run push_sync_branch_body: {stdout}" + ); +} + +/// The body of the test above; see it for why this runs in a child +/// process. Drives `publish_prepared_tree` to the late-hold outcome, which +/// leaves exactly the state `push_sync_branch` exists to publish: a local +/// commit on the sync branch that the bare remote does not have. +#[test] +#[ignore = "re-executed with GH_TOKEN by push_sync_branch_is_covered_by_a_child_process_with_a_token"] +fn push_sync_branch_body() { + let f = Fixture::new(); + let report = read_report(&f.report_path); + + let held_pr = json!({ + "number": 1, + "labels": [{"name": "sync-hold"}], + "head": { + "repo": {"full_name": REPO}, + "ref": SYNC_BRANCH, + "sha": "a".repeat(40), + }, + "base": {"ref": "main"}, + }); + let held_api = RecordedApi::new(vec![ + ("GET", REPOS_ENDPOINT, info_response()), + ("GET", INSTALLATION_ENDPOINT, installation_response()), + ("GET", PULLS_ENDPOINT, json!([])), + ("GET", REPOS_ENDPOINT, info_response()), + ("GET", INSTALLATION_ENDPOINT, installation_response()), + ("GET", PULLS_ENDPOINT, json!([held_pr])), + ]); + let result = publish_prepared_tree( + &f.definition, + f.source.path(), + &f.source_sha, + f.target.path(), + &report, + &held_api, + ) + .unwrap(); + assert!(matches!(result, Published::Held)); + + let tip = f.target.head(); + assert_ne!(tip, f.base, "the local commit happens before the held read"); + let refname = format!("refs/heads/{SYNC_BRANCH}"); + // Negative control for the ls-remote instrument below: the same call + // returns nothing here, before the push, and the tip afterwards. + assert_eq!( + ls_remote_sha(f.target.path(), &f.bare, &refname), + None, + "the held publication must not have pushed" + ); + + // `push_sync_branch` always pushes to `origin` (matching + // `transport.mjs`), and this fixture's `origin` is the fake GitHub + // HTTPS URL `assert_destination_checkout` requires. Point it back at + // the bare remote so the push stays local; the credential helper is + // inert for a path remote. + f.target + .git(&["remote", "set-url", "origin", f.bare.to_str().unwrap()]); + + push_sync_branch(&f.definition, f.target.path()).unwrap(); + assert_eq!( + ls_remote_sha(f.target.path(), &f.bare, &refname).as_deref(), + Some(tip.as_str()), + "the sync branch should now be at the local tip on the bare remote" + ); + + // A second push of an unchanged tip is a no-op ("Everything + // up-to-date", exit 0), not an error and not a ref change. + push_sync_branch(&f.definition, f.target.path()).unwrap(); + assert_eq!( + ls_remote_sha(f.target.path(), &f.bare, &refname).as_deref(), + Some(tip.as_str()), + "a second push must leave the remote ref where it was" + ); + assert_eq!( + f.target.head(), + tip, + "a second push must not move the local branch either" + ); +} diff --git a/tests/transport_github.rs b/tests/transport_github.rs new file mode 100644 index 0000000..ff7678f --- /dev/null +++ b/tests/transport_github.rs @@ -0,0 +1,261 @@ +//! Integration tests for `capobara::transport::github`, using `RecordedApi` +//! (available here via the `recorded-api` feature). Ports, by name, three +//! tests from `scripts/projections/transport.test.mjs`: +//! `publication_checks_destination_identity_visibility_ownership_and_unreadable_hold_state`, +//! `publication_requires_an_app_token_scoped_to_exactly_the_destination`, and +//! `pr_body_carries_complete_provenance_and_bounded_changed_deleted_paths`. + +use serde_json::{Value, json}; + +use capobara::definition::{Definition, definition_from_value}; +use capobara::receipt::Provenance; +use capobara::report::Report; +use capobara::transport::{ + RecordedApi, create_or_update_pr, publication_body, read_publication_state, +}; + +fn no_sdk(_: &str) -> Option> { + None +} + +fn base_definition_json() -> Value { + json!({ + "schemaVersion": 1, "name": "fixture", "class": "source-tree", "mode": "copy-v1", + "sourceRepository": "test/mono", "visibility": "public", + "mappings": [ + {"source": "source", "destination": ".", "include": ["src/**", "README.md"], "exclude": []} + ], + "destination": {"repository": "test/public", "branch": "main", "syncBranch": "sync/fixture", "holdLabel": "sync-hold"}, + "destinationOwned": [".github/**", "SECURITY.md"], + "deletion": "owned-paths", "provenance": ".repository-projection.json", "validation": "tree-v1", + "outputManaged": ["src/**", "README.md"] + }) +} + +fn definition() -> Definition { + definition_from_value(base_definition_json(), &no_sdk) + .unwrap() + .definition +} + +fn info() -> Value { + json!({ + "full_name": "test/public", + "archived": false, + "disabled": false, + "default_branch": "main", + "visibility": "public", + }) +} + +fn installation() -> Value { + json!({"total_count": 1, "repositories": [{"full_name": "test/public"}]}) +} + +fn pr() -> Value { + json!({ + "number": 1, + "labels": [], + "head": { + "repo": {"full_name": "test/public"}, + "ref": "sync/fixture", + "sha": "a".repeat(40), + }, + "base": {"ref": "main"}, + "html_url": "https://github.com/test/public/pull/1", + }) +} + +const REPOS_ENDPOINT: &str = "repos/test/public"; +const INSTALLATION_ENDPOINT: &str = "installation/repositories?per_page=100"; +const PULLS_ENDPOINT: &str = + "repos/test/public/pulls?state=open&base=main&head=test%3Async%2Ffixture"; + +#[test] +fn publication_checks_destination_identity_visibility_ownership_and_unreadable_hold_state() { + let d = definition(); + + // No open PR: not held. + let api = RecordedApi::new(vec![ + ("GET", REPOS_ENDPOINT, info()), + ("GET", INSTALLATION_ENDPOINT, installation()), + ("GET", PULLS_ENDPOINT, json!([])), + ]); + assert!(!read_publication_state(&d, &api).unwrap().held); + + // An open PR carrying the hold label: held. + let mut held_pr = pr(); + held_pr["labels"] = json!([{"name": "sync-hold"}]); + let api = RecordedApi::new(vec![ + ("GET", REPOS_ENDPOINT, info()), + ("GET", INSTALLATION_ENDPOINT, installation()), + ("GET", PULLS_ENDPOINT, json!([held_pr])), + ]); + assert!(read_publication_state(&d, &api).unwrap().held); + + // Unreadable or malformed PR-list responses each fail. + let mut labels_null = pr(); + labels_null["labels"] = json!(null); + let mut label_no_name = pr(); + label_no_name["labels"] = json!([{}]); + let mut bad_sha = pr(); + bad_sha["head"]["sha"] = json!("bad"); + + for payload in [ + json!(null), + json!({}), + json!([pr(), pr()]), + json!([labels_null]), + json!([label_no_name]), + json!([bad_sha]), + ] { + let api = RecordedApi::new(vec![ + ("GET", REPOS_ENDPOINT, info()), + ("GET", INSTALLATION_ENDPOINT, installation()), + ("GET", PULLS_ENDPOINT, payload), + ]); + assert!(read_publication_state(&d, &api).is_err()); + } + + // Destination identity/visibility mismatches fail before the + // installation-scope or PR-list calls are ever made. + for (key, value) in [ + ("visibility", json!("private")), + ("full_name", json!("another/repo")), + ("archived", json!(true)), + ] { + let mut patched = info(); + patched[key] = value; + let api = RecordedApi::new(vec![("GET", REPOS_ENDPOINT, patched)]); + let err = read_publication_state(&d, &api).unwrap_err().to_string(); + assert_eq!( + err, + "Destination identity or visibility mismatch: test/public" + ); + } +} + +#[test] +fn publication_requires_an_app_token_scoped_to_exactly_the_destination() { + let d = definition(); + for scope in [ + json!(null), + json!({}), + json!({"total_count": 0, "repositories": []}), + json!({"total_count": 1, "repositories": [{"full_name": "test/other"}]}), + json!({ + "total_count": 2, + "repositories": [{"full_name": "test/public"}, {"full_name": "test/other"}], + }), + ] { + let api = RecordedApi::new(vec![ + ("GET", REPOS_ENDPOINT, info()), + ("GET", INSTALLATION_ENDPOINT, scope), + ]); + let err = read_publication_state(&d, &api).unwrap_err().to_string(); + assert_eq!(err, "Destination App token scope mismatch: test/public"); + } +} + +fn pr_body_definition() -> Definition { + let raw = json!({ + "schemaVersion": 1, "name": "sample", "class": "source-tree", "mode": "copy-v1", + "sourceRepository": "dx-corp/mono", "visibility": "public", + "mappings": [ + {"source": "pkg", "destination": ".", "include": ["src/**"], "exclude": []} + ], + "destination": {"repository": "dx-corp/sample", "branch": "main", "syncBranch": "sync/mono-projection", "holdLabel": "sync-hold"}, + "destinationOwned": [".github/**"], + "deletion": "owned-paths", "provenance": ".repository-projection.json", "validation": "tree-v1", + "outputManaged": ["src/**"] + }); + definition_from_value(raw, &no_sdk).unwrap().definition +} + +#[test] +fn pr_body_carries_complete_provenance_and_bounded_changed_deleted_paths() { + let d = pr_body_definition(); + let provenance = Provenance { + schema_version: 1, + projection: "sample".into(), + projection_schema_version: 1, + source_repository: "dx-corp/mono".into(), + source_sha: "1".repeat(40), + destination_repository: "dx-corp/sample".into(), + prior_projected_base: "2".repeat(40), + definition_digest: "3".repeat(64), + tool_digest: "4".repeat(64), + content_digest: "5".repeat(64), + publication_eligible: true, + }; + let report = Report { + copied_paths: (0..25).map(|i| format!("c{i}")).collect(), + deleted_paths: (0..25).map(|i| format!("d{i}")).collect(), + copied_count: 25, + deleted_count: 25, + provenance, + source_file_count: 0, + result: "drift_detected".into(), + }; + + let body = publication_body(&d, &report); + // Verified byte-for-byte against Node's `publicationBody` output before + // this snapshot was accepted; see the task-11 report for the recorded + // command and its output. + insta::assert_snapshot!(body); +} + +#[test] +fn create_or_update_pr_posts_a_new_pr_and_returns_its_html_url() { + let d = definition(); + let api = RecordedApi::new(vec![( + "POST", + "repos/test/public/pulls", + json!({"number": 7, "html_url": "https://github.com/test/public/pull/7"}), + )]); + let url = create_or_update_pr(&d, &api, None, "body text").unwrap(); + assert_eq!(url, "https://github.com/test/public/pull/7"); + let calls = api.calls(); + assert_eq!(calls.len(), 1); + assert_eq!( + calls[0].2, + Some(json!({ + "title": "chore: sync fixture from Mono", + "body": "body text", + "head": "sync/fixture", + "base": "main", + })) + ); +} + +#[test] +fn create_or_update_pr_patches_an_existing_pr() { + let d = definition(); + let existing = capobara::transport::PullRequest { + number: 7, + head_sha: "a".repeat(40), + labels: vec![], + html_url: "https://github.com/test/public/pull/7".to_string(), + }; + let api = RecordedApi::new(vec![( + "PATCH", + "repos/test/public/pulls/7", + json!({"number": 7, "html_url": "https://github.com/test/public/pull/7"}), + )]); + let url = create_or_update_pr(&d, &api, Some(&existing), "updated body").unwrap(); + assert_eq!(url, "https://github.com/test/public/pull/7"); + let calls = api.calls(); + assert_eq!(calls[0].2, Some(json!({"body": "updated body"}))); +} + +#[test] +fn create_or_update_pr_rejects_an_unconfirmed_response() { + let d = definition(); + for response in [json!({}), json!({"number": 7}), json!({"html_url": "x"})] { + let api = RecordedApi::new(vec![("POST", "repos/test/public/pulls", response)]); + let err = create_or_update_pr(&d, &api, None, "body") + .unwrap_err() + .to_string(); + assert_eq!(err, "GitHub did not confirm the generated PR"); + } +} From 47cbca10a4750d59bcdd1829cc7b2049ef9d4945 Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Sun, 20 Sep 2026 21:38:33 +0000 Subject: [PATCH 2/5] chore: project capobara from Mono e123b3f67c39 --- .repository-projection.json | 8 ++++---- Cargo.lock | 30 ------------------------------ Cargo.toml | 2 -- 3 files changed, 4 insertions(+), 36 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 69c8699..491ff34 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "capobara", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "8420a1f8c5fdd07a3d2d4d2a46e0db0bb6bbb352", + "sourceSha": "e123b3f67c391eba55f89dbcc052e28f3d59ccc4", "destinationRepository": "dx-corp/capobara", - "priorProjectedBase": "9971853c8b933c9d9058535fcbcace8b9e6e5dbb", + "priorProjectedBase": "b633333649ad4fc8013e2f13eb1ed8fdc7a99d1e", "definitionDigest": "7826c75cbd356dad6c867f2606ebde8b79ef4361353908cf33ada720034ec488", - "toolDigest": "be5ff7bcff05b7a616180a7b5ad761c46467a12e", - "contentDigest": "82178d3b6c66e4a3584418cbadf54c31d8b490106578ba18e4c4d1f441e2cd6c", + "toolDigest": "8a4412f3a379ca0bd1cfea9bff69cc46773c6d1f", + "contentDigest": "1777077598eefc1c640e427b21a039aae8e35277fcee017ed834e3b7690d9da8", "publicationEligible": true } diff --git a/Cargo.lock b/Cargo.lock index 00ad96e..50689ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,12 +61,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "anyhow" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" - [[package]] name = "atomic-waker" version = "1.1.2" @@ -110,11 +104,9 @@ checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" name = "capobara" version = "0.1.0" dependencies = [ - "anyhow", "clap", "hex", "insta", - "pretty_assertions", "regex", "reqwest", "serde", @@ -222,12 +214,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - [[package]] name = "digest" version = "0.10.7" @@ -727,16 +713,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -1557,12 +1533,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 32237c0..8ef2e60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,6 @@ path = "src/main.rs" test = false [dependencies] -anyhow = "1.0" clap = { version = "4.5", features = ["derive", "env"] } hex = "0.4" regex = "1.13" @@ -33,7 +32,6 @@ thiserror = "2.0" [dev-dependencies] insta = { version = "1.48", features = ["json"] } -pretty_assertions = "1.4" [lints.rust] non_ascii_idents = "deny" From d3323f7f8eacf6702731231d984845c28c8fa8b0 Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Mon, 21 Sep 2026 00:26:49 +0000 Subject: [PATCH 3/5] chore: project capobara from Mono 9b559bc70e7d --- .repository-projection.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 491ff34..166bdc0 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,9 +3,9 @@ "projection": "capobara", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "e123b3f67c391eba55f89dbcc052e28f3d59ccc4", + "sourceSha": "9b559bc70e7d8afc723d2ac67c8c62fe0cfbdeeb", "destinationRepository": "dx-corp/capobara", - "priorProjectedBase": "b633333649ad4fc8013e2f13eb1ed8fdc7a99d1e", + "priorProjectedBase": "13b7d41367e33c757e1db9332edb6854ea62be9d", "definitionDigest": "7826c75cbd356dad6c867f2606ebde8b79ef4361353908cf33ada720034ec488", "toolDigest": "8a4412f3a379ca0bd1cfea9bff69cc46773c6d1f", "contentDigest": "1777077598eefc1c640e427b21a039aae8e35277fcee017ed834e3b7690d9da8", From 93ea3331f1d7a328b683977b16e60175d02d7b6b Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Mon, 21 Sep 2026 02:30:43 +0000 Subject: [PATCH 4/5] chore: project capobara from Mono 9a003786d767 --- .repository-projection.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 166bdc0..64f81d1 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,9 +3,9 @@ "projection": "capobara", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "9b559bc70e7d8afc723d2ac67c8c62fe0cfbdeeb", + "sourceSha": "9a003786d7679e9029fa077557bc7ab4d00eb48f", "destinationRepository": "dx-corp/capobara", - "priorProjectedBase": "13b7d41367e33c757e1db9332edb6854ea62be9d", + "priorProjectedBase": "21c9a0d4eb3d6d01bb19b44b6ad019026271ad43", "definitionDigest": "7826c75cbd356dad6c867f2606ebde8b79ef4361353908cf33ada720034ec488", "toolDigest": "8a4412f3a379ca0bd1cfea9bff69cc46773c6d1f", "contentDigest": "1777077598eefc1c640e427b21a039aae8e35277fcee017ed834e3b7690d9da8", From c6a4c2155178d5a1c330208f4517e7ff4cdad772 Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Mon, 21 Sep 2026 03:32:21 +0000 Subject: [PATCH 5/5] chore: project capobara from Mono 511bd2305f5a --- .repository-projection.json | 8 +++---- build.rs | 8 +++---- src/git.rs | 8 +++---- src/snapshot.rs | 8 +++---- tests/catalog.rs | 8 +++---- tests/project_cli.rs | 8 +++---- tests/run_cli.rs | 24 +++++++++---------- tests/sdk_assembly.rs | 8 +++---- tests/support/mod.rs | 16 ++++++------- tests/transport_git.rs | 48 ++++++++++++++++++------------------- 10 files changed, 72 insertions(+), 72 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 64f81d1..ad87d00 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "capobara", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "9a003786d7679e9029fa077557bc7ab4d00eb48f", + "sourceSha": "511bd2305f5a92cfbedb91208d7bc0820fda29b5", "destinationRepository": "dx-corp/capobara", - "priorProjectedBase": "21c9a0d4eb3d6d01bb19b44b6ad019026271ad43", + "priorProjectedBase": "01e475cef45ab0d58e52c9c54ece7ff6cff35d03", "definitionDigest": "7826c75cbd356dad6c867f2606ebde8b79ef4361353908cf33ada720034ec488", - "toolDigest": "8a4412f3a379ca0bd1cfea9bff69cc46773c6d1f", - "contentDigest": "1777077598eefc1c640e427b21a039aae8e35277fcee017ed834e3b7690d9da8", + "toolDigest": "e9fc82741fdf4a797b076d29d51e14aced32368b", + "contentDigest": "6966b9fe44e8b9c45addee6ee807e2fd03993de04f3763fe6b7f3d319db00f8b", "publicationEligible": true } diff --git a/build.rs b/build.rs index 91a3ca3..bec531b 100644 --- a/build.rs +++ b/build.rs @@ -32,11 +32,11 @@ //! CAPOBARA_TREE_ID". use std::process::Command; -#[allow( - clippy::disallowed_methods, - reason = "build script queries git for the crate tree id" -)] fn git(dir: &str, args: &[&str]) -> Option { + #[allow( + clippy::disallowed_methods, + reason = "build script queries git for the crate tree id" + )] let output = Command::new("git") .arg("-C") .arg(dir) diff --git a/src/git.rs b/src/git.rs index 9aa2ed3..b16e195 100644 --- a/src/git.rs +++ b/src/git.rs @@ -5,11 +5,11 @@ use crate::{Error, Result}; /// The reviewed process boundary for git: every other module reaches git /// only through this function (or the helpers below that call it). -#[allow( - clippy::disallowed_methods, - reason = "git is the reviewed process boundary for capobara" -)] fn run(root: &Path, args: &[&str]) -> Result { + #[allow( + clippy::disallowed_methods, + reason = "git is the reviewed process boundary for capobara" + )] Command::new("git") .arg("-C") .arg(root) diff --git a/src/snapshot.rs b/src/snapshot.rs index 0d11bb6..fb648df 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -89,11 +89,11 @@ pub fn with_snapshot( /// closed its stdin, producing a broken pipe on our write). A write error /// is remembered rather than propagated immediately, so the child is /// always reaped and a broken pipe never shadows `tar`'s real diagnostic. -#[allow( - clippy::disallowed_methods, - reason = "tar extraction of a git archive is a reviewed process boundary" -)] fn extract_tar(archive: &[u8], dest: &Path) -> Result<()> { + #[allow( + clippy::disallowed_methods, + reason = "tar extraction of a git archive is a reviewed process boundary" + )] let mut tar = Command::new("tar") .args(["-xf", "-", "-C"]) .arg(dest) diff --git a/tests/catalog.rs b/tests/catalog.rs index 1d418e1..509cfba 100644 --- a/tests/catalog.rs +++ b/tests/catalog.rs @@ -151,11 +151,11 @@ fn coupled_projections_share_one_source_revision_and_require_every_member_presen assert_eq!(err.to_string(), "Missing coupled projection: deixic-node"); } -#[allow( - clippy::disallowed_methods, - reason = "integration test executes the capobara binary" -)] fn capobara() -> std::process::Command { + #[allow( + clippy::disallowed_methods, + reason = "integration test executes the capobara binary" + )] std::process::Command::new(env!("CARGO_BIN_EXE_capobara")) } diff --git a/tests/project_cli.rs b/tests/project_cli.rs index ea168dd..0478abe 100644 --- a/tests/project_cli.rs +++ b/tests/project_cli.rs @@ -2,11 +2,11 @@ mod support; use std::process::Command; use support::Repo; -#[allow( - clippy::disallowed_methods, - reason = "integration test executes the capobara binary" -)] fn capobara() -> Command { + #[allow( + clippy::disallowed_methods, + reason = "integration test executes the capobara binary" + )] Command::new(env!("CARGO_BIN_EXE_capobara")) } diff --git a/tests/run_cli.rs b/tests/run_cli.rs index 3bbf25f..23ac08b 100644 --- a/tests/run_cli.rs +++ b/tests/run_cli.rs @@ -120,19 +120,19 @@ fn write_recording(dir: &Path, calls: Vec) -> PathBuf { path } -#[allow( - clippy::disallowed_methods, - reason = "integration test executes the capobara binary" -)] fn capobara() -> Command { + #[allow( + clippy::disallowed_methods, + reason = "integration test executes the capobara binary" + )] Command::new(env!("CARGO_BIN_EXE_capobara")) } -#[allow( - clippy::disallowed_methods, - reason = "integration tests drive scratch git repositories outside the support::Repo helper" -)] fn run_git(path: &Path, args: &[&str]) -> String { + #[allow( + clippy::disallowed_methods, + reason = "integration tests drive scratch git repositories outside the support::Repo helper" + )] let out = Command::new("git") .arg("-C") .arg(path) @@ -147,11 +147,11 @@ fn run_git(path: &Path, args: &[&str]) -> String { String::from_utf8(out.stdout).unwrap() } -#[allow( - clippy::disallowed_methods, - reason = "integration test inspects a scratch bare git repository's refs" -)] fn ref_sha(path: &Path, refname: &str) -> Option { + #[allow( + clippy::disallowed_methods, + reason = "integration test inspects a scratch bare git repository's refs" + )] Command::new("git") .arg("-C") .arg(path) diff --git a/tests/sdk_assembly.rs b/tests/sdk_assembly.rs index 8f6c767..db732cd 100644 --- a/tests/sdk_assembly.rs +++ b/tests/sdk_assembly.rs @@ -426,11 +426,11 @@ fn deixic_python_definition_loads_against_the_real_sdk_assembly_policy() { // in the `sdk-assembly-v1` branch of `build_projection`. // --------------------------------------------------------------------- -#[allow( - clippy::disallowed_methods, - reason = "integration test executes the capobara binary" -)] fn capobara() -> Command { + #[allow( + clippy::disallowed_methods, + reason = "integration test executes the capobara binary" + )] Command::new(env!("CARGO_BIN_EXE_capobara")) } diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 4cd561d..6171c4a 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -20,11 +20,11 @@ impl Repo { pub fn path(&self) -> &Path { self.dir.path() } - #[allow( - clippy::disallowed_methods, - reason = "integration tests drive a scratch git repository" - )] pub fn git(&self, args: &[&str]) -> String { + #[allow( + clippy::disallowed_methods, + reason = "integration tests drive a scratch git repository" + )] let out = Command::new("git") .arg("-C") .arg(self.path()) @@ -191,11 +191,11 @@ pub fn is_named_by_include(mapping: &capobara::definition::Mapping, path: &str) } /// `git ls-files` under `dir`, relative to `dir`. -#[allow( - clippy::disallowed_methods, - reason = "test enumerates the crate's own git-tracked files" -)] pub fn git_ls_files(dir: &Path) -> Vec { + #[allow( + clippy::disallowed_methods, + reason = "test enumerates the crate's own git-tracked files" + )] let out = Command::new("git") .arg("-C") .arg(dir) diff --git a/tests/transport_git.rs b/tests/transport_git.rs index 82c0b8e..2f15688 100644 --- a/tests/transport_git.rs +++ b/tests/transport_git.rs @@ -138,11 +138,11 @@ fn write_report(path: &Path, report: &Report) { std::fs::write(path, serde_json::to_string(report).unwrap()).unwrap(); } -#[allow( - clippy::disallowed_methods, - reason = "integration test executes the capobara binary" -)] fn capobara() -> Command { + #[allow( + clippy::disallowed_methods, + reason = "integration test executes the capobara binary" + )] Command::new(env!("CARGO_BIN_EXE_capobara")) } @@ -184,11 +184,11 @@ fn run_capobara( ); } -#[allow( - clippy::disallowed_methods, - reason = "integration tests drive scratch git repositories outside the support::Repo helper" -)] fn run_git(path: &Path, args: &[&str]) -> String { + #[allow( + clippy::disallowed_methods, + reason = "integration tests drive scratch git repositories outside the support::Repo helper" + )] let out = Command::new("git") .arg("-C") .arg(path) @@ -203,11 +203,11 @@ fn run_git(path: &Path, args: &[&str]) -> String { String::from_utf8(out.stdout).unwrap() } -#[allow( - clippy::disallowed_methods, - reason = "integration test drives a scratch git repository outside the support::Repo helper" -)] fn clone_no_local(src: &Path, dest: &Path) { + #[allow( + clippy::disallowed_methods, + reason = "integration test drives a scratch git repository outside the support::Repo helper" + )] let out = Command::new("git") .args(["clone", "--quiet", "--no-local"]) .arg(src) @@ -221,11 +221,11 @@ fn clone_no_local(src: &Path, dest: &Path) { ); } -#[allow( - clippy::disallowed_methods, - reason = "integration test probes object reachability in a scratch git repository" -)] fn commit_exists(path: &Path, sha: &str) -> bool { + #[allow( + clippy::disallowed_methods, + reason = "integration test probes object reachability in a scratch git repository" + )] Command::new("git") .arg("-C") .arg(path) @@ -235,11 +235,11 @@ fn commit_exists(path: &Path, sha: &str) -> bool { .unwrap_or(false) } -#[allow( - clippy::disallowed_methods, - reason = "integration test inspects a scratch bare git repository's refs" -)] fn ref_exists(path: &Path, refname: &str) -> bool { + #[allow( + clippy::disallowed_methods, + reason = "integration test inspects a scratch bare git repository's refs" + )] Command::new("git") .arg("-C") .arg(path) @@ -778,11 +778,11 @@ fn ls_remote_sha(from: &Path, remote: &Path, refname: &str) -> Option { out.split_whitespace().next().map(str::to_owned) } -#[allow( - clippy::disallowed_methods, - reason = "integration test re-executes its own test binary with GH_TOKEN set" -)] fn self_exe() -> Command { + #[allow( + clippy::disallowed_methods, + reason = "integration test re-executes its own test binary with GH_TOKEN set" + )] Command::new(std::env::current_exe().unwrap()) }