diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 652bc34933..3f40cbbe4b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,31 @@ Keep a pull request in draft while required checks or known changes remain. Before requesting review, inspect the complete diff, resolve all failures, and state any platform path that could not be exercised. +### Pre-commit URL-host linter + +`ts dev lint domains` flags non-allowlisted URL hosts in source, config, and +docs. Install the managed hook once per checkout so staged changes are checked +before every commit: + +```bash +ts dev install-hooks +``` + +This writes a `pre-commit` hook into `.git/hooks`, shared by every linked +worktree of the clone. It never edits git configuration and never writes into +the working tree. It refuses to replace a hook it did not write (`--force` +replaces it and prints the backup path), and refuses when `core.hooksPath` is +set, since git then runs hooks from that directory instead. To uninstall, delete +`.git/hooks/pre-commit` and restore any `pre-commit.bak.*` backup. + +To scan on demand, `ts dev lint domains` audits the whole working tree and +`--changed-vs ` limits it to lines added against a ref. The full audit +reports pre-existing violations, so a non-zero exit is expected today. + +To allow a new host, add it to the allowlist constants in +`crates/trusted-server-cli/src/commands/dev/lint/domains.rs`. To suppress a +single line, append `// allow-domain: ` in a comment. + ## Commits Write concise, imperative, sentence-case subjects without semantic prefixes or diff --git a/Cargo.lock b/Cargo.lock index f1fff157d9..1978230db5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,6 +131,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -176,6 +185,21 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "astral-tokio-tar" version = "0.5.6" @@ -518,6 +542,17 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "build-print" version = "1.0.1" @@ -767,6 +802,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "cmake" version = "0.1.58" @@ -989,6 +1033,12 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crunchy" version = "0.2.4" @@ -1160,6 +1210,20 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -1285,6 +1349,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.9.0" @@ -1764,6 +1834,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + [[package]] name = "fastly" version = "0.12.1" @@ -1889,6 +1969,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1920,172 +2009,1035 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" name = "futf" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[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-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[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", + "rand_core 0.10.1", +] + +[[package]] +name = "gix" +version = "0.83.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce52001b946a6249d5d0d3011df0a042ac3f8a4d013460db6476577b0b9c567" +dependencies = [ + "gix-actor", + "gix-archive", + "gix-attributes", + "gix-blame", + "gix-command", + "gix-commitgraph", + "gix-config", + "gix-date", + "gix-diff", + "gix-dir", + "gix-discover", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-ignore", + "gix-index", + "gix-lock", + "gix-merge", + "gix-negotiate", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-pathspec", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-status", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "gix-worktree-state", + "gix-worktree-stream", + "nonempty", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-actor" +version = "0.41.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-archive" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a20ec244b733338d4cb60e5e05eac700dab7fcc689647b1d1daa9396b119342" +dependencies = [ + "bstr", + "gix-date", + "gix-error", + "gix-object", + "gix-worktree-stream", +] + +[[package]] +name = "gix-attributes" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39b40888d0ed415c0744a6cdc61eebf0304c9d26ab726725b718443c322e5ba4" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "kstring", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd1d118d0f5d88b96e6f6e13b566475fef4797ead4a02c26fed36c1375066f7" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-blame" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dab9a942ab54a9661ded7397c3bf927274e7afa94494db0d75cfcbde02ca0a" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-diff", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "gix-trace", + "gix-traverse", + "gix-worktree", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-chunk" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4363accdf6ef7ba861871d2d521ab7418a04aaaed919fadb022af71d379b12" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f675d0df484a7f6a47e64bd6f311af489d947c0323b0564f36d14f3d7762abb" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c01848aebd21c67f6ba41f1de8efd46ae96df21f001954a3c9e1517e514d410" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", +] + +[[package]] +name = "gix-config-value" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed42168329552f6c2e5df09665c104199d45d84bedb53683738a49b57fe1baab" +dependencies = [ + "bitflags 2.13.0", + "bstr", + "gix-path", + "libc", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-date" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e47b9e8cdc688296609b706428de570f88b1e0eed7156dde7b4a89d26fa4567" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-diff" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc08e0fa1a91ff5f24affeab052f198056645e1de004910bde7b82b50ea5982a" +dependencies = [ + "bstr", + "gix-command", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-imara-diff", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-dir" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a0fc06e9e1e430cbf0a313666976d90f822f461a6525320427aa9b8af5236c" +dependencies = [ + "bstr", + "gix-discover", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-discover" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17852e6a501e688a1702b24ebe5b3761d4719455bc869fd29f38b0b859bcad34" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-error" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1849ae154d38bc403185be14fa871e38e3c93ee606875d94e207fdb9fba52dbc" +dependencies = [ + "bytes", + "crc32fast", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "prodash", + "thiserror 2.0.18", + "walkdir", + "zlib-rs", +] + +[[package]] +name = "gix-filter" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac917dbe9653c9b615d248db91907a365bd779750c9e1b457a9d9fdeece3a08" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-fs" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cdff46db8798e47e2f727d84b9379aac5add3dd3d9d0b07bb4d7d5d640771fe" +dependencies = [ + "bstr", + "fastrand", + "gix-features", + "gix-path", + "gix-utils", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-glob" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1fcb8ef5b16bcf874abe9b68d8abb3c0493c876d367ab824151f30a0f3f3756" +dependencies = [ + "bitflags 2.13.0", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0926d3819c837750b4e03c7754901e73f68b8c9b690753a6372a1bed4eedce" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-hashtable" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e261d54091f0d1c729bc83f54548c071bdec60a697de1e58e88bdfd7a99d24e" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-ignore" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d491bab9bf2c9f341dc754f425c31d5d3f63aca615312167b82e1deeaca97d8d" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-imara-diff" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c91d8cffac8849493a82233811bd02b2b183b8cf39bf704de0fa0841b737595" +dependencies = [ + "bstr", + "hashbrown 0.17.1", +] + +[[package]] +name = "gix-index" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54c3ef97ad08121e4327a6226bd63fed6b9e3c6b976d48bddd4356d9d41191db" +dependencies = [ + "bitflags 2.13.0", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.16.1", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-lock" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9dedd9e90b0d47624d2ed241d394e09294118364e87b9b7e5f1fe755f3c2c" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-merge" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74bbcdcc52b70a32f0a151b024dff9d0fcf56ee48f00d9503e735af9d99ea881" +dependencies = [ + "bstr", + "gix-command", + "gix-diff", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-imara-diff", + "gix-index", + "gix-object", + "gix-path", + "gix-quote", + "gix-revision", + "gix-revwalk", + "gix-tempfile", + "gix-trace", + "gix-worktree", + "nonempty", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-negotiate" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103d42bfade1b8a96ca5005933127bdad461ce588d92422b2c2daa3ff20d780c" +dependencies = [ + "bitflags 2.13.0", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-object", + "gix-revwalk", +] + +[[package]] +name = "gix-object" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a38075a95d7cc5df8afd38e72c617026c1456952207a4120a7f55a3fbf93b4d7" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-odb" +version = "0.80.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeeda12a9663120418735ecdc1250d06eeab0be75700e47b3402a981331716ba" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "memmap2", + "parking_lot", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-pack" +version = "0.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf02e6f5c8f07a069c9ea5245f40d9b14856ada4086091dc99941b49002b4fa" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "memmap2", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-packetline" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b217dd0ee0c4021ecf169a4a519b1b4f80d15e3f3765f3dc466223dc0ac891d7" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-path" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38fc6f029ea67de83cbcbd33fd98c05a48360d2932b39d9fdacbc6eae802d475" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-pathspec" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3050783b41ee11511e1e8fb35623df81806194f4030395f14f48ea37c2798c9f" +dependencies = [ + "bitflags 2.13.0", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-glob", + "gix-path", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-protocol" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4bee82db63ec635996b96efae71cf467c155fa3f34a556184373224a26c4fd" +dependencies = [ + "bstr", + "gix-date", + "gix-features", + "gix-hash", + "gix-ref", + "gix-shallow", + "gix-transport", + "gix-utils", + "maybe-async", + "nonempty", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-quote" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8ba9cc15f558b274c99349b83130f5ec83459660828fde9718bbbb43a726167" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-refspec" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61755b27d57edc8940a1b1593c8c61548ca8e4c02da1ed8d5bfeda9eb2a6b761" dependencies = [ - "mac", - "new_debug_unreachable", + "bstr", + "gix-error", + "gix-glob", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror 2.0.18", ] [[package]] -name = "futures" -version = "0.3.32" +name = "gix-revision" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "1fb5288fac706d3ea3e4e2ba9ec38b78743b8c02f422e18cb342299cfd6ab7e8" dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", + "bitflags 2.13.0", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "gix-trace", + "nonempty", ] [[package]] -name = "futures-channel" -version = "0.3.32" +name = "gix-revwalk" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "313813706b073a12ff7f9b2896bf3e6504cdac7cfbc97b1920114724705069f0" dependencies = [ - "futures-core", - "futures-sink", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror 2.0.18", ] [[package]] -name = "futures-core" -version = "0.3.32" +name = "gix-sec" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f" +dependencies = [ + "bitflags 2.13.0", + "gix-path", + "libc", + "windows-sys 0.61.2", +] [[package]] -name = "futures-executor" -version = "0.3.32" +name = "gix-shallow" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "a292fc2fe548c5dfa575479d16b445b0ddf1dd2f56f1fec6aed386f82553cd97" dependencies = [ - "futures-core", - "futures-task", - "futures-util", + "bstr", + "gix-hash", + "gix-lock", + "nonempty", + "thiserror 2.0.18", ] [[package]] -name = "futures-io" -version = "0.3.32" +name = "gix-status" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "68c6d2a8c521ffa205fe7e268c82e6d1378ba37cd826ca10ab6129fdc29a4b65" +dependencies = [ + "bstr", + "filetime", + "gix-diff", + "gix-dir", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-worktree", + "portable-atomic", + "thiserror 2.0.18", +] [[package]] -name = "futures-macro" -version = "0.3.32" +name = "gix-submodule" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fd5fc8692890bd71a596e540fd4c364f8460eaa82c4eaaedebde6e1e3eb4d91" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", + "bstr", + "gix-config", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", + "thiserror 2.0.18", ] [[package]] -name = "futures-sink" -version = "0.3.32" +name = "gix-tempfile" +version = "23.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "6ef60812443484e67bf84e444cc71b4c78ae62deb822221774a4fa0c57fdb17f" +dependencies = [ + "dashmap", + "gix-fs", + "libc", + "parking_lot", + "tempfile", +] [[package]] -name = "futures-task" -version = "0.3.32" +name = "gix-trace" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" [[package]] -name = "futures-timer" -version = "3.0.4" +name = "gix-transport" +version = "0.57.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" +checksum = "186874f7ad1fb2f9a2f2aa9c2dabc7f9dd087bef74c1a0eee2b4a9cf0248fcb3" +dependencies = [ + "bstr", + "gix-command", + "gix-features", + "gix-packetline", + "gix-quote", + "gix-sec", + "gix-url", + "thiserror 2.0.18", +] [[package]] -name = "futures-util" -version = "0.3.32" +name = "gix-traverse" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a14b7052c0786676c03e71fcfde7d7f0f8e8316e642b5cec6bb3998719b2ce5c" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", + "bitflags 2.13.0", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror 2.0.18", ] [[package]] -name = "fxhash" -version = "0.2.1" +name = "gix-url" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +checksum = "57d68e70e96da0e5f9c871f1566349e0fd0e1a20bb483c7f54af1dd0b85b4b29" dependencies = [ - "byteorder", + "bstr", + "gix-path", + "percent-encoding", + "thiserror 2.0.18", ] [[package]] -name = "generic-array" -version = "0.14.7" +name = "gix-utils" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "0da1c46491b49458a446cc76f0085860f8164c2290742e0aa8c653ce67240a97" dependencies = [ - "typenum", - "version_check", - "zeroize", + "bstr", + "fastrand", + "getrandom 0.4.3", + "unicode-normalization", ] [[package]] -name = "getopts" -version = "0.2.24" +name = "gix-validate" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +checksum = "4dae8780f63ed8a803b8bdabbd7aa5f5c5d74592c8b50eed875c1bb4f6545a6a" dependencies = [ - "unicode-width", + "bstr", ] [[package]] -name = "getrandom" -version = "0.2.17" +name = "gix-worktree" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "d69955eb5e2910832f88d041964b809eee01dadd579237e0b55efec58fd406fd" dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", + "bstr", + "gix-attributes", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", ] [[package]] -name = "getrandom" -version = "0.3.4" +name = "gix-worktree-state" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "8a96dccbcf9e8fe0291c55f06e08da93ebb2e691c1311276f541eefcc6d70800" dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", + "bstr", + "gix-features", + "gix-filter", + "gix-fs", + "gix-index", + "gix-object", + "gix-path", + "gix-worktree", + "io-close", + "thiserror 2.0.18", ] [[package]] -name = "getrandom" -version = "0.4.3" +name = "gix-worktree-stream" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "9a8444b8ed4662e1a0c97f3eceda29630001a1bbb2632201e50312623e594213" dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot", ] [[package]] @@ -2151,6 +3103,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2172,6 +3133,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -2204,6 +3167,16 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -2609,6 +3582,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "io-close" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2664,10 +3647,12 @@ checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" dependencies = [ "defmt", "jiff-static", + "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "windows-link", ] [[package]] @@ -2681,6 +3666,21 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -2798,6 +3798,15 @@ dependencies = [ "serde", ] +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "static_assertions", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2972,6 +3981,17 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" +[[package]] +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "md5" version = "0.8.1" @@ -2984,6 +4004,15 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "mime" version = "0.3.17" @@ -3057,6 +4086,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "num" version = "0.4.3" @@ -3600,6 +4641,36 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -3650,6 +4721,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "parking_lot", +] + [[package]] name = "prost" version = "0.13.5" @@ -4622,6 +5702,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1", +] + [[package]] name = "sha2" version = "0.9.9" @@ -4646,6 +5736,12 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -4790,6 +5886,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -4957,6 +6059,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "testcontainers" version = "0.25.2" @@ -5472,6 +6580,7 @@ dependencies = [ name = "trusted-server-cli" version = "0.1.0" dependencies = [ + "assert_cmd", "base64", "bytes", "chromiumoxide", @@ -5483,12 +6592,14 @@ dependencies = [ "error-stack", "flate2", "futures", + "gix", "glob", "http", "http-body-util", "hyper", "hyper-util", "log", + "predicates", "rand 0.8.6", "rcgen", "regex", @@ -5675,12 +6786,27 @@ dependencies = [ "web-time", ] +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -5844,6 +6970,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -6647,6 +7782,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index de05ca5413..f5c81ecf4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ version = "0.1.0" [workspace.dependencies] anyhow = "1" +assert_cmd = "2" async-stream = "0.3" async-trait = "0.1" axum = "0.8" @@ -68,6 +69,10 @@ fastly = "0.12" fern = "0.7.1" flate2 = "1.1" futures = "0.3" +# `ts dev lint domains` / `install-hooks` drive all git access through +# gitoxide (no `git` subprocess). Verified via `cargo tree -p gix` to +# avoid duplicate versions in the lock. +gix = { version = "0.83", default-features = false } glob = "0.3" getrandom = "0.2" hex = "0.4.3" @@ -86,6 +91,7 @@ log-fastly = "0.12" lol_html = "2.7.2" matchit = "0.9" mime = "0.3" +predicates = "3" prost-build = "0.13" rand = "0.8" rcgen = { version = "0.13", features = ["x509-parser"] } diff --git a/README.md b/README.md index 701ec2e9bd..add4009eee 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,14 @@ cargo test-fastly # Fastly/WASM (requires Viceroy) cargo test-axum # Axum native adapter cargo test-cloudflare # Cloudflare Workers adapter (native host) cargo test-spin # Spin adapter (native host) + +# Install the pre-commit URL-host linter hook (once per checkout) +ts dev install-hooks ``` -See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. +Contributors should run `ts dev install-hooks` to enable the pre-commit +URL-host linter; see [CONTRIBUTING.md](CONTRIBUTING.md) for that and the rest of +the contribution guidelines. ## License diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml index a876e03e03..ce97919c81 100644 --- a/crates/trusted-server-cli/Cargo.toml +++ b/crates/trusted-server-cli/Cargo.toml @@ -61,6 +61,19 @@ url = { workspace = true } uuid = { workspace = true } which = { workspace = true } +# `ts dev lint domains` and `ts dev install-hooks` drive all git access through +# gitoxide (no `git` subprocess). Cross-host: the linter runs in CI on every +# target. Version pins live in the root `[workspace.dependencies]`; the feature +# set is crate-local. +gix = { workspace = true, features = [ + "blob-diff", + "index", + "revision", + "sha1", + # Fixture repos in the unit tests construct trees via gix-only APIs. + "tree-editor", +] } + # `ts dev sandbox-probe` needs a real HTTP/1.1 client with keep-alive. These # are shared with the macOS/Linux proxy but scoped to every non-wasm host, so # the probe builds on any host target. The exclusion still keeps @@ -98,7 +111,9 @@ x509-parser = { workspace = true } x509-parser = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +assert_cmd = { workspace = true } flate2 = { workspace = true } +predicates = { workspace = true } temp-env = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/trusted-server-cli/src/commands/dev/install_hooks.rs b/crates/trusted-server-cli/src/commands/dev/install_hooks.rs new file mode 100644 index 0000000000..6e5ebe50be --- /dev/null +++ b/crates/trusted-server-cli/src/commands/dev/install_hooks.rs @@ -0,0 +1,677 @@ +//! `ts dev install-hooks` — installs the pre-commit hook that runs +//! `ts dev lint domains --staged`. +//! +//! Design: docs/superpowers/specs/2026-05-18-check-domains-design.md +//! +//! The hook is written into git's own hook directory +//! (`/hooks/pre-commit`), never into the working tree, +//! and the command never edits git configuration. Versioned content can +//! therefore never become an executable hook through this tool, and a +//! linked worktree shares the hook with its main checkout the same way +//! it shares every other hook. All git access goes through `gix`; the +//! hook file itself is a tiny shell wrapper (git's hook contract +//! requires an executable artifact) that carries the absolute path of +//! the `ts` binary so it works from GUI git tools that do not inherit +//! the shell `PATH`. + +use core::error::Error; +use std::env; +use std::fs; +use std::io; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use derive_more::Display; +use error_stack::{Report, ResultExt as _}; + +use crate::commands::dev::InstallHooksArgs; +use crate::error::CliError; +use crate::output::write_stderr_line; +use crate::output::write_stdout_line; + +/// Marker line written into managed hook files. `is_managed` looks +/// for this to decide whether overwriting is safe. +const MANAGED_MARKER: &str = "# ts-install-hooks: managed"; + +/// Errors raised by `ts dev install-hooks`. +#[derive(Debug, Display)] +pub enum InstallHooksError { + /// Opening the git repository failed. + #[display("failed to open git repository")] + OpenRepo, + /// The repository has no working directory (bare repo). + #[display("repository has no working directory")] + NoWorkdir, + /// The path of the running executable could not be determined. + #[display("failed to determine the path of the ts executable")] + CurrentExe, + /// Writing the hook file failed. + #[display("failed to write the pre-commit hook")] + WriteHook, + /// An existing, unmanaged pre-commit hook would be overwritten. + #[display("refusing to overwrite existing hook at `{}`", path.display())] + WouldClobber { + /// The existing hook file. + path: PathBuf, + }, + /// `core.hooksPath` is set in some config scope, so git would not + /// run a hook installed into `.git/hooks`. + #[display( + "core.hooksPath is set to `{current}`; git runs hooks from there, not from .git/hooks" + )] + ForeignHooksPath { + /// The effective `core.hooksPath` value. + current: String, + }, + /// The hook directory is a symlink; writing through it could land + /// the hook anywhere on the filesystem. + #[display("refusing to install into symlinked hooks directory `{}`", path.display())] + SymlinkedHooksDir { + /// The symlinked directory. + path: PathBuf, + }, +} + +impl Error for InstallHooksError {} + +/// POSIX single-quote escaping: wrap in `'...'`, and replace every +/// embedded single quote with `'\''` (close, escaped quote, reopen). +fn shell_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + for c in s.chars() { + if c == '\'' { + out.push_str(r"'\''"); + } else { + out.push(c); + } + } + out.push('\''); + out +} + +/// Render the pre-commit hook script that runs the linter against +/// staged changes. The `ts` path is shell-quoted and absolute. +fn render_hook(ts_path: &Path) -> String { + format!( + "#!/usr/bin/env bash\n\ + # Installed by `ts dev install-hooks`. DO NOT EDIT.\n\ + {MANAGED_MARKER}\n\ + exec {} dev lint domains --staged\n", + shell_quote(&ts_path.to_string_lossy()), + ) +} + +/// Whether the regular file at `hook_path` is a hook this tool +/// previously installed, detected by the [`MANAGED_MARKER`] line near +/// the top of the file. Non-UTF-8 content is simply not managed. +fn is_managed(hook_path: &Path) -> Result> { + let bytes = fs::read(hook_path).change_context(InstallHooksError::WriteHook)?; + Ok(String::from_utf8_lossy(&bytes) + .lines() + .take(10) + .any(|line| line.trim() == MANAGED_MARKER)) +} + +/// Unique sibling temp / backup suffix. Nanosecond precision keeps two +/// installs within the same second from colliding. +fn unique_suffix() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{}.{nanos}", std::process::id()) +} + +/// Write the executable hook `content` to `path` atomically: create a +/// sibling temp file with mode `0755`, fsync it, then rename it over +/// the target. The temp file is removed if any step fails. +fn write_hook(path: &Path, content: &[u8]) -> Result<(), Report> { + let dir = path + .parent() + .ok_or_else(|| Report::new(InstallHooksError::WriteHook))?; + let tmp = dir.join(format!(".ts-install-hooks.tmp.{}", unique_suffix())); + let written = write_then_rename(&tmp, path, content); + if written.is_err() { + let _ = fs::remove_file(&tmp); + } + written.change_context(InstallHooksError::WriteHook) +} + +fn write_then_rename(tmp: &Path, path: &Path, content: &[u8]) -> io::Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(tmp)?; + file.write_all(content)?; + file.sync_all()?; + set_executable(&file)?; + drop(file); + fs::rename(tmp, path) +} + +/// Mark `file` executable (Unix `0755`; a no-op elsewhere, where git +/// treats every hook file as executable). +#[cfg(unix)] +fn set_executable(file: &fs::File) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt as _; + file.set_permissions(fs::Permissions::from_mode(0o755)) +} + +#[cfg(not(unix))] +fn set_executable(_file: &fs::File) -> io::Result<()> { + Ok(()) +} + +/// The effective `core.hooksPath`, from any config scope git would +/// consult (system, global, includes, repo, worktree). An empty value +/// is a configured override, not an unset key: git still stops reading +/// `.git/hooks`, so it is reported like any other foreign path. +fn effective_hooks_path(repo: &gix::Repository) -> Option { + repo.config_snapshot() + .string("core.hooksPath") + .map(|value| value.to_string()) +} + +/// Make sure `dir` is a real directory: create it if absent, refuse a +/// symlink, and fail on any other kind of entry. +fn ensure_hooks_dir(dir: &Path) -> Result<(), Report> { + match fs::symlink_metadata(dir) { + Ok(meta) if meta.file_type().is_symlink() => { + Err(Report::new(InstallHooksError::SymlinkedHooksDir { + path: dir.to_path_buf(), + })) + } + Ok(meta) if meta.is_dir() => Ok(()), + Ok(_) => Err(Report::new(InstallHooksError::WriteHook) + .attach(format!("`{}` exists but is not a directory", dir.display()))), + Err(e) if e.kind() == io::ErrorKind::NotFound => { + fs::create_dir_all(dir).change_context(InstallHooksError::WriteHook) + } + Err(e) => Err(Report::new(InstallHooksError::WriteHook).attach(e.to_string())), + } +} + +/// Decide what to do with whatever already sits at `hook_path`. +/// +/// Nothing there: proceed. Under `--force`: rename it to a backup +/// without inspecting it (it may be a symlink, a binary, or non-UTF-8) +/// and return the backup path. Otherwise only a regular file carrying +/// the managed marker may be overwritten; anything else is refused. +fn displace_existing_hook( + hook_path: &Path, + force: bool, +) -> Result, Report> { + let meta = match fs::symlink_metadata(hook_path) { + Ok(meta) => meta, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => { + return Err(Report::new(InstallHooksError::WriteHook).attach(e.to_string())); + } + }; + if force { + let backup = hook_path.with_extension(format!("bak.{}", unique_suffix())); + fs::rename(hook_path, &backup).change_context(InstallHooksError::WriteHook)?; + return Ok(Some(backup)); + } + if meta.is_file() && is_managed(hook_path)? { + return Ok(None); + } + Err(Report::new(InstallHooksError::WouldClobber { + path: hook_path.to_path_buf(), + }) + .attach("re-run with --force to replace it (the existing hook is backed up)")) +} + +/// Install the pre-commit hook into the repository containing +/// `repo_path`. +/// +/// Writes `/hooks/pre-commit`. Refuses to clobber an +/// unmanaged hook unless `force` is set, and refuses outright when +/// `core.hooksPath` is set anywhere, since git would then never run +/// the installed hook. +/// +/// # Errors +/// +/// Returns [`InstallHooksError`] on any failure; see the variants. +pub fn install_hooks(repo_path: &Path, force: bool) -> Result<(), Report> { + // `gix::discover` walks upward to the repository root, so the command + // works from any subdirectory (git invokes hooks at the worktree root, + // but a developer running `ts dev install-hooks` may be anywhere). + let repo = gix::discover(repo_path).change_context(InstallHooksError::OpenRepo)?; + install_into(&repo, force) +} + +/// [`install_hooks`] on an already-opened repository. +fn install_into(repo: &gix::Repository, force: bool) -> Result<(), Report> { + if repo.workdir().is_none() { + return Err(Report::new(InstallHooksError::NoWorkdir)); + } + let ts_path = env::current_exe().change_context(InstallHooksError::CurrentExe)?; + + if let Some(current) = effective_hooks_path(repo) { + return Err( + Report::new(InstallHooksError::ForeignHooksPath { current }).attach(format!( + "add `exec {} dev lint domains --staged` to the pre-commit hook in that \ + directory, or unset core.hooksPath and re-run", + shell_quote(&ts_path.to_string_lossy()) + )), + ); + } + + // `common_dir` is the main repository's git dir even from a linked + // worktree; git reads hooks from there for every worktree. + let hooks_dir = repo.common_dir().join("hooks"); + ensure_hooks_dir(&hooks_dir)?; + let hook_path = hooks_dir.join("pre-commit"); + let backup = displace_existing_hook(&hook_path, force)?; + write_hook(&hook_path, render_hook(&ts_path).as_bytes())?; + + write_stdout_line(format!( + "Installed: pre-commit hook -> {} (runs {})", + hook_path.display(), + ts_path.display(), + )) + .change_context(InstallHooksError::WriteHook)?; + if let Some(backup) = backup { + write_stderr_line(format!( + "note: previous pre-commit hook backed up to {}", + backup.display() + )) + .change_context(InstallHooksError::WriteHook)?; + } + Ok(()) +} + +/// `ts dev install-hooks` entry point. +/// +/// # Errors +/// +/// Returns [`CliError::EnvironmentError`] on any install failure — +/// every install-hooks failure is an environment / configuration +/// issue. +pub fn run(args: &InstallHooksArgs) -> Result<(), Report> { + install_hooks(Path::new("."), args.force).change_context(CliError::EnvironmentError) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_quote_plain_path() { + assert_eq!(shell_quote("/usr/bin/ts"), "'/usr/bin/ts'"); + } + + #[test] + fn shell_quote_path_with_spaces() { + assert_eq!( + shell_quote("/Users/Alice Q/.cargo/bin/ts"), + "'/Users/Alice Q/.cargo/bin/ts'" + ); + } + + #[test] + fn shell_quote_path_with_single_quote() { + // close, escaped quote, reopen + assert_eq!(shell_quote("/path/o'brien/ts"), r"'/path/o'\''brien/ts'"); + } + + #[test] + fn shell_quote_path_with_dollar_backtick_backslash() { + // $, backtick, backslash are all literal inside single quotes. + assert_eq!(shell_quote("/opt/$HOME/ts"), "'/opt/$HOME/ts'"); + assert_eq!(shell_quote("/opt/`x`/ts"), "'/opt/`x`/ts'"); + assert_eq!(shell_quote(r"/opt/a\b/ts"), r"'/opt/a\b/ts'"); + } + + #[test] + fn render_hook_quotes_path_and_carries_marker() { + let hook = render_hook(Path::new("/Users/Alice Q/.cargo/bin/ts")); + assert!( + hook.contains("exec '/Users/Alice Q/.cargo/bin/ts' dev lint domains --staged"), + "hook should exec the quoted ts path: {hook}" + ); + assert!( + hook.lines().any(|l| l == MANAGED_MARKER), + "hook should carry the managed marker: {hook}" + ); + assert!(hook.starts_with("#!/usr/bin/env bash\n")); + } + + #[test] + fn is_managed_detects_marker() { + let temp = tempfile::tempdir().expect("should create tempdir"); + let managed = temp.path().join("managed"); + fs::write(&managed, render_hook(Path::new("/usr/bin/ts"))) + .expect("should write managed hook"); + assert!(is_managed(&managed).expect("should read managed hook")); + + let foreign = temp.path().join("foreign"); + fs::write(&foreign, "#!/bin/sh\necho hi\n").expect("should write foreign hook"); + assert!(!is_managed(&foreign).expect("should read foreign hook")); + + let binary = temp.path().join("binary"); + fs::write(&binary, [0xff, 0xfe, 0x00, b'\n']).expect("should write binary hook"); + assert!(!is_managed(&binary).expect("non-UTF-8 hook reads as not managed")); + } + + #[test] + fn write_hook_writes_executable_and_leaves_no_temp() { + let temp = tempfile::tempdir().expect("should create tempdir"); + let target = temp.path().join("file"); + write_hook(&target, b"hello").expect("should write atomically"); + assert_eq!( + fs::read(&target).expect("should read written file"), + b"hello" + ); + assert!(no_temp_files(temp.path()), "no temp file should remain"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = fs::metadata(&target) + .expect("should stat hook") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o755, "hook should be mode 0755"); + } + } + + #[test] + fn write_hook_removes_temp_when_rename_fails() { + let temp = tempfile::tempdir().expect("should create tempdir"); + // Renaming a file over a non-empty directory fails. + let target = temp.path().join("dir"); + fs::create_dir_all(target.join("child")).expect("should create blocking dir"); + assert!( + write_hook(&target, b"hello").is_err(), + "rename over a non-empty directory should fail" + ); + assert!(no_temp_files(temp.path()), "temp file should be cleaned up"); + } + + pub(super) fn no_temp_files(dir: &Path) -> bool { + !fs::read_dir(dir) + .expect("should read dir") + .filter_map(Result::ok) + .any(|e| { + e.file_name() + .to_string_lossy() + .contains(".ts-install-hooks.tmp.") + }) + } +} + +#[cfg(test)] +mod install_hooks_tests { + use super::tests::no_temp_files; + use super::*; + use crate::commands::dev::lint::test_support; + + fn hook_path(repo: &gix::Repository) -> PathBuf { + repo.common_dir().join("hooks").join("pre-commit") + } + + fn backups(repo: &gix::Repository) -> Vec { + fs::read_dir(repo.common_dir().join("hooks")) + .expect("should read hooks dir") + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| { + p.file_name() + .is_some_and(|n| n.to_string_lossy().starts_with("pre-commit.bak.")) + }) + .collect() + } + + fn write_unmanaged_hook(path: &Path) { + fs::write(path, "#!/bin/sh\necho custom\n").expect("should write unmanaged hook"); + } + + #[test] + fn fresh_repo_installs_into_git_hooks_without_touching_config_or_worktree() { + let temp = tempfile::tempdir().expect("should create tempdir"); + let repo = test_support::init_repo(temp.path()); + let config_path = repo.git_dir().join("config"); + let config_before = fs::read(&config_path).expect("should read config"); + + install_hooks(temp.path(), false).expect("should install into a fresh repo"); + + let content = fs::read_to_string(hook_path(&repo)).expect("should read hook"); + assert!( + content.contains(MANAGED_MARKER), + "hook should carry the marker" + ); + assert!( + content.contains("dev lint domains --staged"), + "hook should exec the linter" + ); + assert_eq!( + fs::read(&config_path).expect("should re-read config"), + config_before, + ".git/config must be byte-identical after install" + ); + assert!( + !temp.path().join(".githooks").exists(), + "nothing may be written into the working tree" + ); + let reopened = gix::open(temp.path()).expect("should reopen repo"); + assert_eq!( + effective_hooks_path(&reopened), + None, + "install must never set core.hooksPath" + ); + } + + #[test] + fn re_running_is_idempotent() { + let temp = tempfile::tempdir().expect("should create tempdir"); + let repo = test_support::init_repo(temp.path()); + + install_hooks(temp.path(), false).expect("first install should succeed"); + install_hooks(temp.path(), false).expect("re-install should be idempotent"); + assert!( + backups(&repo).is_empty(), + "managed re-install makes no backup" + ); + assert!(no_temp_files(&repo.common_dir().join("hooks"))); + } + + #[test] + fn refuses_to_clobber_unmanaged_hook() { + let temp = tempfile::tempdir().expect("should create tempdir"); + let repo = test_support::init_repo(temp.path()); + fs::create_dir_all(repo.common_dir().join("hooks")).expect("should create hooks dir"); + write_unmanaged_hook(&hook_path(&repo)); + + let err = install_hooks(temp.path(), false) + .expect_err("should refuse to clobber an unmanaged hook"); + assert!( + matches!( + err.current_context(), + InstallHooksError::WouldClobber { .. } + ), + "should be WouldClobber: {err:?}" + ); + assert_eq!( + fs::read_to_string(hook_path(&repo)).expect("should read hook"), + "#!/bin/sh\necho custom\n", + "refused install leaves the existing hook untouched" + ); + } + + #[test] + fn force_backs_up_unmanaged_hook() { + let temp = tempfile::tempdir().expect("should create tempdir"); + let repo = test_support::init_repo(temp.path()); + fs::create_dir_all(repo.common_dir().join("hooks")).expect("should create hooks dir"); + write_unmanaged_hook(&hook_path(&repo)); + + install_hooks(temp.path(), true).expect("force should overwrite"); + + let content = fs::read_to_string(hook_path(&repo)).expect("should read new hook"); + assert!(content.contains(MANAGED_MARKER)); + let backups = backups(&repo); + assert_eq!(backups.len(), 1, "the displaced hook should be backed up"); + assert_eq!( + fs::read_to_string(&backups[0]).expect("should read backup"), + "#!/bin/sh\necho custom\n" + ); + } + + /// `--force` must not read the existing hook: a non-UTF-8 binary + /// hook is backed up byte-for-byte, not parsed. + #[test] + fn force_backs_up_non_utf8_hook_without_parsing_it() { + let temp = tempfile::tempdir().expect("should create tempdir"); + let repo = test_support::init_repo(temp.path()); + fs::create_dir_all(repo.common_dir().join("hooks")).expect("should create hooks dir"); + let binary = [0x7f, b'E', b'L', b'F', 0xff, 0xfe, 0x00]; + fs::write(hook_path(&repo), binary).expect("should write binary hook"); + + let err = install_hooks(temp.path(), false) + .expect_err("a non-UTF-8 hook is unmanaged and refused without --force"); + assert!(matches!( + err.current_context(), + InstallHooksError::WouldClobber { .. } + )); + + install_hooks(temp.path(), true).expect("force should back up and replace"); + let backups = backups(&repo); + assert_eq!(backups.len(), 1); + assert_eq!( + fs::read(&backups[0]).expect("should read backup"), + binary, + "backup must be byte-identical" + ); + } + + /// A dangling symlink is still "something there": refused without + /// `--force`, moved aside (as a symlink) with it. + #[cfg(unix)] + #[test] + fn broken_symlink_hook_is_refused_then_backed_up_under_force() { + let temp = tempfile::tempdir().expect("should create tempdir"); + let repo = test_support::init_repo(temp.path()); + fs::create_dir_all(repo.common_dir().join("hooks")).expect("should create hooks dir"); + std::os::unix::fs::symlink(temp.path().join("missing"), hook_path(&repo)) + .expect("should create dangling symlink"); + + let err = install_hooks(temp.path(), false).expect_err("dangling symlink is refused"); + assert!(matches!( + err.current_context(), + InstallHooksError::WouldClobber { .. } + )); + + install_hooks(temp.path(), true).expect("force should displace the symlink"); + assert!( + hook_path(&repo).is_file(), + "a regular managed hook replaces the symlink" + ); + let backups = backups(&repo); + assert_eq!(backups.len(), 1); + assert!( + fs::symlink_metadata(&backups[0]) + .expect("should stat backup") + .file_type() + .is_symlink(), + "the backup is the moved symlink" + ); + } + + #[cfg(unix)] + #[test] + fn refuses_symlinked_hooks_directory() { + let temp = tempfile::tempdir().expect("should create tempdir"); + let repo = test_support::init_repo(temp.path()); + let elsewhere = tempfile::tempdir().expect("should create link target"); + let hooks_dir = repo.common_dir().join("hooks"); + if hooks_dir.exists() { + fs::remove_dir_all(&hooks_dir).expect("should remove default hooks dir"); + } + std::os::unix::fs::symlink(elsewhere.path(), &hooks_dir).expect("should symlink hooks dir"); + + let err = install_hooks(temp.path(), false).expect_err("symlinked hooks dir is refused"); + assert!( + matches!( + err.current_context(), + InstallHooksError::SymlinkedHooksDir { .. } + ), + "should be SymlinkedHooksDir: {err:?}" + ); + assert!( + !elsewhere.path().join("pre-commit").exists(), + "nothing may be written through the symlink" + ); + } + + #[test] + fn refuses_when_repo_config_sets_hooks_path() { + let temp = tempfile::tempdir().expect("should create tempdir"); + let repo = test_support::init_repo(temp.path()); + let config_path = repo.git_dir().join("config"); + let mut config = fs::read_to_string(&config_path).expect("should read config"); + config.push_str("[core]\n\thooksPath = .husky\n"); + fs::write(&config_path, config).expect("should write config"); + + let err = install_hooks(temp.path(), false).expect_err("foreign core.hooksPath is refused"); + match err.current_context() { + InstallHooksError::ForeignHooksPath { current } => assert_eq!(current, ".husky"), + other => panic!("should be ForeignHooksPath, got {other:?}"), + } + assert!(!hook_path(&repo).exists(), "refused install writes nothing"); + } + + /// An explicitly empty `core.hooksPath` is a configured override, + /// not an unset key: git stops reading `.git/hooks` entirely, so + /// installing there would claim success while doing nothing. + #[test] + fn refuses_when_hooks_path_is_explicitly_empty() { + let temp = tempfile::tempdir().expect("should create tempdir"); + let repo = test_support::init_repo(temp.path()); + let mut config = + fs::read_to_string(temp.path().join(".git/config")).expect("should read repo config"); + config.push_str("[core]\n\thooksPath = \n"); + fs::write(temp.path().join(".git/config"), config).expect("should write repo config"); + + let err = install_hooks(temp.path(), false).expect_err("empty core.hooksPath is refused"); + match err.current_context() { + InstallHooksError::ForeignHooksPath { current } => assert_eq!(current, ""), + other => panic!("should be ForeignHooksPath, got {other:?}"), + } + assert!(!hook_path(&repo).exists(), "refused install writes nothing"); + + let err = install_hooks(temp.path(), true).expect_err("--force does not override config"); + assert!(matches!( + err.current_context(), + InstallHooksError::ForeignHooksPath { .. } + )); + } + + /// `core.hooksPath` from outside the repo config (global or + /// included) is just as effective; the preflight must see it too. + #[test] + fn refuses_when_hooks_path_comes_from_another_scope() { + let temp = tempfile::tempdir().expect("should create tempdir"); + let _repo = test_support::init_repo(temp.path()); + let repo = gix::open_opts( + temp.path(), + gix::open::Options::isolated().config_overrides(["core.hooksPath=/tmp/globalhooks"]), + ) + .expect("should open with overrides"); + + let err = install_into(&repo, false).expect_err("global core.hooksPath is refused"); + match err.current_context() { + InstallHooksError::ForeignHooksPath { current } => { + assert_eq!(current, "/tmp/globalhooks"); + } + other => panic!("should be ForeignHooksPath, got {other:?}"), + } + let err = install_into(&repo, true).expect_err("--force does not override config"); + assert!(matches!( + err.current_context(), + InstallHooksError::ForeignHooksPath { .. } + )); + } +} diff --git a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs new file mode 100644 index 0000000000..8978af61c0 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs @@ -0,0 +1,3161 @@ +//! `ts dev lint domains` — URL-host linter. +//! +//! Design: docs/superpowers/specs/2026-05-18-check-domains-design.md + +use core::error::Error; +use core::ops::ControlFlow; +use std::borrow::Cow; +use std::cell::RefCell; +use std::collections::{BTreeSet, HashSet}; +use std::env; +use std::fs; +use std::io::{self, ErrorKind}; +use std::net::IpAddr; +use std::path::{Component, Path, PathBuf}; +use std::str::from_utf8; +use std::sync::OnceLock; + +use derive_more::Display; +use error_stack::{Report, ResultExt as _}; +use gix::ObjectId; +use gix::diff::Rewrites; +use gix::diff::blob::{Algorithm, Diff, InternedInput}; +use gix::index::entry::Mode as IndexEntryMode; +use gix::index::entry::Stage; +use gix::object::tree::EntryKind; +use gix::object::tree::diff::Change; +use regex::Regex; +use serde::Serialize; +use serde_json::json; +use url::Url; + +use crate::commands::dev::lint::{DomainsArgs, OutputFormat}; +use crate::error::CliError; +use crate::output::{write_json, write_stderr_line, write_stdout_line}; + +/// Integration proxies and loopback hosts that must match exactly. +/// Subdomains are NOT allowed (e.g., `anything.api.privacy-center.org` +/// is disallowed). See spec §"Exact-match hosts" for the policy. +pub const EXACT_HOSTS: &[&str] = &[ + // Loopback + "127.0.0.1", + "::1", + "localhost", + // didomi + "api.privacy-center.org", + "sdk.privacy-center.org", + // sourcepoint + "cdn.privacy-mgmt.com", + // lockr + "aim.loc.kr", + "identity.loc.kr", + // datadome + "js.datadome.co", + "api-js.datadome.co", + // aps / Amazon + "aax.amazon-adsystem.com", + "aax-events.amazon-adsystem.com", + // permutive + "api.permutive.com", + "secure-signals.permutive.app", + "cdn.permutive.com", + // Google Tag Manager / Analytics + "www.googletagmanager.com", + "www.google-analytics.com", + "analytics.google.com", + // adserver mock + "securepubads.g.doubleclick.net", + "origin-mocktioneer.cdintel.com", + // Prebid CDN + "cdn.prebid.org", + // Fastly platform + "api.fastly.com", +]; + +/// Hosts where exact match AND any subdomain (`*.host`) is allowed. +/// See spec §"Subdomain-permitting hosts" and §"Allowlist +/// Maintenance Policy" for the bar to add an entry here. +pub const SUBDOMAIN_HOSTS: &[&str] = &[ + // IANA RFC 2606 reserved + "example.com", + "example.net", + "example.org", + // Permutive: runtime host is {organization_id}.edge.permutive.app + "edge.permutive.app", +]; + +/// Well-known documentation and specification sources. Exact-match, +/// allowed in every scanned file. See spec §"Reference / doc hosts" +/// for the curated list (seeded from a sampling; expected to grow +/// during Stage 1 doc cleanup). +pub const REFERENCE_HOSTS: &[&str] = &[ + // Git / GitHub + "github.com", + "docs.github.com", + "help.github.com", + "token.actions.githubusercontent.com", + // Git commit conventions + "chris.beams.io", + // Rust + "docs.rs", + "doc.rust-lang.org", + "crates.io", + // Web / W3C standards + "www.w3.org", + "schema.org", + // Versioning / changelogs + "semver.org", + "keepachangelog.com", + // IAB Tech Lab + "iab.com", + "iabtechlab.com", + "iabtechlab.github.io", + "iabeurope.github.io", + // Specs (supply chain) + "in-toto.io", + "rslstandard.org", + // Specs (other) + "webassembly.org", + // Fastly docs + "www.fastly.com", + "developer.fastly.com", + "manage.fastly.com", + // Cloudflare docs + "developers.cloudflare.com", + // Vendor docs + "docs.datadome.co", + "docs.prebid.org", + // Tooling docs + "vitepress.dev", + "playwright.dev", + "testcontainers.com", + "grafana.com", + "docsearch.algolia.com", +]; + +/// IANA RFC 2606 reserved TLDs. Any host ending in one of these is allowed. +pub const RESERVED_TLDS: &[&str] = &[".example", ".test", ".invalid", ".localhost"]; + +/// Errors raised by the domains linter. +#[derive(Debug, Display)] +pub enum DomainsLintError { + /// Opening the git repository failed. + #[display("failed to open git repository")] + OpenRepo, + /// Reading the git index failed. + #[display("failed to read git index")] + Index, + /// Computing a blob or tree diff failed. + #[display("failed to compute diff")] + Diff, + /// A git reference could not be resolved. + #[display("failed to resolve reference `{_0}`")] + Reference(String), + /// No merge-base exists between the base ref and HEAD. + #[display("failed to compute merge-base of `{base}` and HEAD")] + MergeBase { + /// The base reference that was requested. + base: String, + }, + /// A file could not be read. + #[display("failed to read file `{}`", _0.display())] + ReadFile(PathBuf), + /// An explicitly-named path does not exist. + #[display("path not found: `{}`", _0.display())] + PathNotFound(PathBuf), + /// An explicitly-named path could not be read for permission reasons. + #[display("permission denied reading `{}`", _0.display())] + PermissionDenied(PathBuf), + /// Failure writing a warning to stderr (broken pipe, etc.). + /// + /// Used by the in-module [`warn`] helper so collectors can call + /// [`crate::output::write_stderr_line`] and still return + /// `Report` consistently. + #[display("I/O error writing warning to stderr")] + WriteWarning, +} + +impl Error for DomainsLintError {} + +/// In-module warning helper. +/// +/// Wraps the CLI's [`crate::output::write_stderr_line`] (which +/// returns `Report`) so callers inside `domains` can stay +/// on `Report` without inventing custom `?` +/// conversions at every call site. +/// +/// # Errors +/// +/// Returns [`DomainsLintError::WriteWarning`] if writing to stderr +/// fails (e.g., a broken pipe). +fn warn(msg: impl Into) -> Result<(), Report> { + write_stderr_line(msg.into()).change_context(DomainsLintError::WriteWarning) +} + +/// Normalise an extracted URL host: strip bracketed-IPv6 `[ ]`, +/// drop any trailing FQDN dot, and lowercase. Pure function; no I/O. +fn normalise_host(raw: &str) -> String { + let trimmed = raw + .trim_start_matches('[') + .trim_end_matches(']') + .trim_end_matches('.'); + trimmed.to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalise_lowercases() { + assert_eq!(normalise_host("EXAMPLE.COM"), "example.com"); + assert_eq!(normalise_host("Foo.Example.Com"), "foo.example.com"); + } + + #[test] + fn normalise_strips_ipv6_brackets() { + assert_eq!(normalise_host("[::1]"), "::1"); + assert_eq!(normalise_host("[2001:DB8::1]"), "2001:db8::1"); + } + + #[test] + fn normalise_passthrough_for_plain_hosts() { + assert_eq!(normalise_host("test.com"), "test.com"); + assert_eq!(normalise_host("127.0.0.1"), "127.0.0.1"); + } + + #[test] + fn normalise_trims_trailing_fqdn_dot() { + assert_eq!(normalise_host("example.com."), "example.com"); + assert_eq!(normalise_host("Example.Com."), "example.com"); + } +} + +/// Decide whether a normalised host is allowed. +/// +/// Order: per-line suppression set, reserved-TLD suffix, exact match +/// against [`EXACT_HOSTS`] and [`REFERENCE_HOSTS`], then the subdomain +/// rule against [`SUBDOMAIN_HOSTS`]. +fn is_allowed(host: &str, suppressed_on_line: &HashSet) -> bool { + if suppressed_on_line.contains(host) { + return true; + } + if RESERVED_TLDS.iter().any(|t| host.ends_with(t)) { + return true; + } + if EXACT_HOSTS.contains(&host) { + return true; + } + if REFERENCE_HOSTS.contains(&host) { + return true; + } + if SUBDOMAIN_HOSTS + .iter() + .any(|e| host == *e || host.ends_with(&format!(".{e}"))) + { + return true; + } + false +} + +#[cfg(test)] +mod allow_check_tests { + use super::*; + + fn nothing_suppressed() -> HashSet { + HashSet::new() + } + + #[test] + fn exact_match_allows() { + assert!(is_allowed("api.fastly.com", ¬hing_suppressed())); + assert!(is_allowed("127.0.0.1", ¬hing_suppressed())); + } + + #[test] + fn exact_only_rejects_subdomain() { + // EXACT_HOSTS entries are exact-only: a subdomain of an + // exact host is NOT allowed. + assert!(!is_allowed("v2.api.fastly.com", ¬hing_suppressed())); + assert!(!is_allowed( + "anything.api.privacy-center.org", + ¬hing_suppressed() + )); + } + + #[test] + fn subdomain_list_allows_apex_and_subdomains() { + assert!(is_allowed("example.com", ¬hing_suppressed())); + assert!(is_allowed("foo.example.com", ¬hing_suppressed())); + assert!(is_allowed("a.b.example.com", ¬hing_suppressed())); + assert!(is_allowed("example.net", ¬hing_suppressed())); + assert!(is_allowed("assets.example.net", ¬hing_suppressed())); + } + + #[test] + fn lookalike_attack_rejected() { + // example.com.evil.com is not a subdomain of example.com. + assert!(!is_allowed("example.com.evil.com", ¬hing_suppressed())); + assert!(!is_allowed("notexample.com", ¬hing_suppressed())); + } + + #[test] + fn reserved_tld_allows() { + assert!(is_allowed("testlight.example", ¬hing_suppressed())); + assert!(is_allowed("something.test", ¬hing_suppressed())); + assert!(is_allowed("thing.invalid", ¬hing_suppressed())); + assert!(is_allowed("my.localhost", ¬hing_suppressed())); + } + + #[test] + fn reference_hosts_allowed_everywhere() { + assert!(is_allowed("github.com", ¬hing_suppressed())); + assert!(is_allowed("docs.rs", ¬hing_suppressed())); + // But NOT subdomains of REFERENCE_HOSTS (exact-match). + assert!(!is_allowed("other.github.com", ¬hing_suppressed())); + } + + #[test] + fn suppression_set_allows() { + let mut suppressed = HashSet::new(); + suppressed.insert("evil.com".to_string()); + assert!(is_allowed("evil.com", &suppressed)); + } + + #[test] + fn rejects_unrelated_host() { + assert!(!is_allowed("test.com", ¬hing_suppressed())); + assert!(!is_allowed("1.2.3.4", ¬hing_suppressed())); + assert!(!is_allowed("192.168.1.1", ¬hing_suppressed())); + } +} + +/// Characters that can form a URL authority (host and optional port) +/// as browsers and resolvers read it. +/// +/// This is a *delimiter* class, not an allow-list: the authority runs +/// to the first character that genuinely ends it in a URL or in +/// surrounding source (`/`, `?`, `#`, whitespace, quotes, brackets, +/// backslash, and the like). An allow-list of "host-ish" characters +/// stops early on anything it forgot -- an IDNA-mapped symbol, a +/// percent escape -- and hands the allowlist a prefix of the real +/// host, so the full token is taken here and judged after +/// canonicalisation by [`canonical_host`]. +/// +/// For the same reason the class holds only characters that end an +/// authority for *every* reader of the line. `!`, `$`, `&`, `=`, `*`, +/// `;`, `,`, `(`, and `)` are not among them: the WHATWG parser accepts +/// all of them inside a hostname, so +/// `https://github.com!unapproved.internal/` and +/// `https://example.com;unapproved.internal/` name hosts a browser will +/// really resolve. Terminating on them handed the allowlist the +/// `github.com` / `example.com` prefix and passed the URL. +/// +/// `'` is the exception, and it is a terminator despite being legal in a +/// hostname. Leaving it out of the class let one match run past the end +/// of its own string and consume the *next* URL's scheme: +/// `['https://example.com','https://unapproved.internal']` captured +/// `example.com','https:` as a single authority. Trimming at the quote +/// afterwards recovered `example.com`, but `captures_iter` cannot +/// revisit input an earlier match already consumed, so the second host +/// was never examined at all -- a silent false negative, which is the +/// one outcome this linter must not have. A URL inside a quoted string +/// cannot contain the quote anyway, so terminating there loses nothing +/// real. +/// +/// What remains are the characters that genuinely cannot be in a host +/// (`/`, `?`, `#`, whitespace, `<`, `>`, `|`, `\`, `"`, backtick) plus +/// the bracket and brace pairs. `[` and `]` are kept because the URL +/// parser rejects them outright outside an IPv6 literal, which the +/// regex captures with a dedicated branch. `{` and `}` are kept because +/// they enclose interpolation rather than appearing in hosts: dropping +/// them turned every `format!("https://{host}{path}")` in the workspace +/// into a violation (measured: 115 new false positives across a +/// full-repo scan, against 3 real hosts recovered). +/// +/// The characters that were removed are instead trimmed from the *end* +/// of a capture by [`trim_trailing_source_punctuation`], which is where +/// source punctuation lands, so an embedded one still reaches the +/// allowlist while a trailing one does not become part of the host. +/// +/// The inner text of that class (no enclosing brackets), so callers can +/// splice it into either a negated or a positive character class. +const AUTHORITY_TERMINATOR_INNER: &str = r#"/?\#\s"'`{}\[\]<>|\\"#; + +/// Optional RFC 3986 `userinfo@` prefix, as an inner regex fragment. +/// +/// Skipping this span is what makes the captured authority the real one +/// rather than a deceiving `user@` part: without it, +/// `https://github.com@test.example/path` extracts the allowlisted +/// `github.com` and misses `test.example`. +/// +/// The span stops at the characters that close a source string, so it +/// cannot reach out of the URL token it belongs to and swallow a later +/// field. `{"url":"https://192.0.2.1","email":"a@example.com"}` used to +/// match through `","email":"a@` and check only the allowlisted +/// `example.com`, hiding the IP address entirely. +const USERINFO_INNER: &str = r#"(?:[^/?\s#"'`<>\\]+@)?"#; + +/// Regex for absolute `http(s)://` URLs. Captures the whole authority +/// (bracketed IPv6, or a host that starts with a letter or digit so +/// placeholders like `https://...` are rejected) for +/// [`canonical_host`] to parse; matching never stops early inside a +/// host. +/// +/// [`USERINFO_INNER`] skips any RFC 3986 `userinfo@` prefix so the +/// captured authority is the real one, not a deceiving `user@` part. +/// +/// The bracketed branch admits `.` as well as hex and `:` so an IPv6 +/// literal with an embedded IPv4 part -- `https://[::ffff:192.0.2.1]/` +/// -- is captured and left for the URL parser to validate and +/// canonicalise. A hex-and-colon-only branch skipped those URLs +/// outright, since the other branch cannot begin with `[`. +fn absolute_url_regex() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| { + Regex::new(&format!( + r"(?i)https?://{USERINFO}(\[[0-9a-fA-F:.]+\]|[^{TERM}]+)", + USERINFO = USERINFO_INNER, + TERM = AUTHORITY_TERMINATOR_INNER + )) + .expect("should compile absolute URL regex") + }) +} + +/// Undo JSON's optional solidus escape so `https:\/\/host` is seen as +/// the URL it decodes to. +fn unescape_solidus(line: &str) -> Cow<'_, str> { + if line.contains("\\/") { + Cow::Owned(line.replace("\\/", "/")) + } else { + Cow::Borrowed(line) + } +} + +/// Strip a leading `*.` wildcard label from a captured authority. +/// +/// `*` is legal in a WHATWG hostname, so it cannot terminate the +/// authority capture without reopening the prefix bypass. A leading +/// `*.` in source is a config pattern rather than a host, though: +/// `"https://*.googletagmanager.com"` names the allowlisted host it +/// patterns over, and reporting it verbatim flagged a string the +/// allowlist already covers. Judging the wildcard by the host it +/// expands to also keeps `*.disallowed.test` reportable. +/// +/// Only a leading label is stripped; a `*` elsewhere in the authority +/// stays, so `github.com*unapproved.internal` is still reported whole. +fn strip_wildcard_label(authority: &str) -> &str { + authority.strip_prefix("*.").unwrap_or(authority) +} + +/// Punctuation trimmed from the end of a captured authority. +/// +/// Every one of these is legal inside a WHATWG hostname, so none can +/// terminate the authority capture without handing the allowlist a +/// prefix of the real host. In source, though, they overwhelmingly +/// appear *after* a URL rather than inside one: +/// +/// - `$` opens a `${...}` interpolation whose brace stops the capture, +/// leaving the `$` stranded (`http://127.0.0.1:${PORT}`). +/// - `)` closes a Markdown link or a call (`[docs](https://a.example)`). +/// - `,` and `;` separate list items and statements. +/// - `(` and `*` round out the pairs so a trailing one is not read as +/// part of the host. +/// +/// Trimming is deliberately restricted to the *end* of the capture: an +/// occurrence inside the authority is preserved and still reaches the +/// allowlist, so `example.com;unapproved.internal` is reported whole +/// and the prefix bypass stays closed. +const TRAILING_SOURCE_PUNCTUATION: &[char] = &['$', '#', '*', '(', ')', ',', ';', '!', '&', '=']; + +/// Trim trailing source punctuation from a captured authority. +/// +/// See [`TRAILING_SOURCE_PUNCTUATION`] for why each character is +/// admitted into the capture and then removed here rather than being +/// treated as a terminator. +/// +/// `'` needs no handling here: it is an [`AUTHORITY_TERMINATOR_INNER`] +/// character, so a capture never contains one. An earlier revision cut +/// the authority at the first quote instead of terminating there, which +/// left the regex free to run past the end of its own string and swallow +/// the next URL's scheme. +fn trim_trailing_source_punctuation(authority: &str) -> &str { + authority.trim_end_matches(TRAILING_SOURCE_PUNCTUATION) +} + +/// Reduce a captured authority to the canonical hostname a browser +/// would connect to: percent-decoded, IDNA-mapped to ASCII, +/// lowercased, port dropped, brackets and trailing dot stripped. An +/// authority the WHATWG parser rejects is reported as written rather +/// than trimmed to an allowlisted prefix. +fn canonical_host(authority: &str) -> String { + let authority = strip_wildcard_label(trim_trailing_source_punctuation(authority)); + let parsed = Url::parse(&format!("http://{authority}")); + match parsed.as_ref().ok().and_then(Url::host_str) { + Some(host) => normalise_host(host), + None => normalise_host(authority), + } +} + +/// Extract and normalise every host from absolute URLs on `line`. +/// +/// The scheme itself marks these as URLs, so an authority that merely +/// fails to parse is still reported as written (a bare prefix would be +/// worse). Only tokens with no alphanumeric start -- `https://...` and +/// friends -- are dropped as placeholders. +fn extract_absolute_hosts(line: &str) -> Vec { + let line = unescape_solidus(line); + absolute_url_regex() + .captures_iter(&line) + .filter_map(|c| c.get(1).map(|m| canonical_host(m.as_str()))) + .filter(|host| host.chars().any(char::is_alphanumeric)) + .collect() +} + +#[cfg(test)] +mod absolute_url_tests { + use super::*; + + #[test] + fn extracts_plain() { + assert_eq!( + extract_absolute_hosts("see https://example.com/path here"), + vec!["example.com"] + ); + } + + #[test] + fn extracts_bracketed_ipv6() { + assert_eq!( + extract_absolute_hosts("dial http://[::1]:8080/"), + vec!["::1"] + ); + } + + #[test] + fn extracts_uppercase_normalised() { + assert_eq!( + extract_absolute_hosts("HTTPS://Example.COM/x"), + vec!["example.com"] + ); + } + + #[test] + fn percent_encoded_label_is_decoded_not_truncated() { + // `%65vil` is `evil`; the authority starts with a percent escape, + // so a first-character letter/digit requirement would miss it. + assert_eq!( + extract_absolute_hosts(r#"const url = "https://%65vil.com/x";"#), + vec!["evil.com"] + ); + } + + #[test] + fn idna_symbol_does_not_truncate_to_allowlisted_prefix() { + // The pile-of-poo is `So`, outside any "host-ish" letter class; + // stopping there would report the allowlisted `github.com`. + assert_eq!( + extract_absolute_hosts("const url = \"https://github.com\u{1F4A9}.com/x\";"), + vec!["github.xn--com-md23b.com"] + ); + } + + #[test] + fn rejects_dots_only_placeholder() { + assert!(extract_absolute_hosts("see https://... for an example").is_empty()); + } + + #[test] + fn handles_punctuation_wrapping() { + for s in [ + "\"https://example.com\",", + "(https://example.com)", + "", + ] { + assert_eq!(extract_absolute_hosts(s), vec!["example.com"], "input: {s}"); + } + } + + #[test] + fn extracts_multiple_per_line() { + assert_eq!( + extract_absolute_hosts("see [a](https://github.com/x) and [b](https://example.com/y)"), + vec!["github.com", "example.com"] + ); + } + + /// Regression for the userinfo-bypass: an allowlisted host placed + /// in the userinfo position must not hide the real authority. + #[test] + fn userinfo_bypass_extracts_real_host() { + assert_eq!( + extract_absolute_hosts("fetch(\"https://github.com@test.com/path\")"), + vec!["test.com"] + ); + // user:password@host form + assert_eq!( + extract_absolute_hosts("fetch(\"https://user:pw@evil.example/path\")"), + vec!["evil.example"] + ); + // Multiple @ in userinfo — last @ is the authority boundary. + assert_eq!( + extract_absolute_hosts("fetch(\"https://a@b@c.evil/path\")"), + vec!["c.evil"] + ); + } + + #[test] + fn no_userinfo_still_works() { + assert_eq!( + extract_absolute_hosts("https://example.com:8080/path"), + vec!["example.com"] + ); + } + + /// The authority must be canonicalised as a whole: every form + /// below resolves to a host under `evil.com`, and a prefix match + /// on `github.com` would have let each one through. + #[test] + fn lookalike_authorities_are_canonicalised_not_prefix_matched() { + for (input, expected) in [ + ("https://github.com.evil.com/x", "github.com.evil.com"), + ("https://github.com%2eevil.com/x", "github.com.evil.com"), + ("https://github.com%2Eevil.com/x", "github.com.evil.com"), + ("https://GITHUB.COM%2Eevil.com/x", "github.com.evil.com"), + ( + "https://github.com\u{3002}evil.com/x", + "github.com.evil.com", + ), + ( + "https://github.com\u{FF0E}evil.com/x", + "github.com.evil.com", + ), + ("https://github.com_evil.com/x", "github.com_evil.com"), + ] { + assert_eq!( + extract_absolute_hosts(input), + vec![expected], + "input: {input}" + ); + } + } + + #[test] + fn escaped_json_solidus_is_decoded() { + assert_eq!( + extract_absolute_hosts(r#"{"url": "https:\/\/partner.com\/v1"}"#), + vec!["partner.com"] + ); + } + + #[test] + fn idna_lookalike_reports_punycode_not_allowlisted_name() { + // Dotless i: looks like github.com in many fonts. + let hosts = extract_absolute_hosts("https://g\u{131}thub.com/x"); + assert_eq!(hosts.len(), 1); + assert!( + hosts[0].starts_with("xn--"), + "should be punycode: {hosts:?}" + ); + assert_ne!(hosts[0], "github.com"); + } + + #[test] + fn trailing_dot_and_port_are_dropped() { + assert_eq!( + extract_absolute_hosts("see https://example.com. Then"), + vec!["example.com"] + ); + assert_eq!( + extract_absolute_hosts("https://example.com:"), + vec!["example.com"] + ); + } + + /// Regression: the userinfo span must not reach past the end of the + /// URL's own source string. It used to match through + /// `","email":"a@` and check only the allowlisted `example.com`, + /// hiding the IP address. + #[test] + fn userinfo_does_not_cross_source_string_boundary() { + assert_eq!( + extract_absolute_hosts(r#"{"url":"https://192.0.2.1","email":"a@example.com"}"#), + vec!["192.0.2.1"] + ); + assert_eq!( + extract_absolute_hosts("const u = 'https://192.0.2.1'; const e = 'a@example.com';"), + vec!["192.0.2.1"] + ); + // A backtick template and an angle-bracketed attribute close + // the span too. + assert_eq!( + extract_absolute_hosts("`https://192.0.2.1`, mail a@example.com"), + vec!["192.0.2.1"] + ); + } + + /// Regression: an IPv6 literal carrying an embedded IPv4 part is a + /// valid non-loopback authority. The bracketed branch excluded `.` + /// and the other branch cannot start with `[`, so the whole URL was + /// skipped. + #[test] + fn extracts_ipv6_with_embedded_ipv4() { + assert_eq!( + extract_absolute_hosts("const a = \"https://[::ffff:192.0.2.1]/\";"), + vec!["::ffff:c000:201"] + ); + assert_eq!( + extract_absolute_hosts("https://[::ffff:127.0.0.1]/"), + vec!["::ffff:7f00:1"] + ); + } + + /// Regression: a match must not run past the end of its own source + /// string. With `'` outside the terminator class the first capture + /// swallowed the next URL's scheme + /// (`example.com','https:`), and because `captures_iter` cannot + /// revisit consumed input the second host was never examined -- the + /// linter exited 0 on a disallowed host. + #[test] + fn consecutive_single_quoted_urls_are_all_examined() { + assert_eq!( + extract_absolute_hosts( + "const urls = ['https://example.com','https://unapproved.internal'];" + ), + vec!["example.com", "unapproved.internal"] + ); + // Every host in the list must be reported, not just the first. + assert_eq!( + extract_absolute_hosts( + "const a = ['https://one.internal','https://two.internal','https://three.internal'];" + ), + vec!["one.internal", "two.internal", "three.internal"] + ); + // Double quotes were already safe; keep them that way. + assert_eq!( + extract_absolute_hosts( + "const b = [\"https://one.internal\",\"https://two.internal\"];" + ), + vec!["one.internal", "two.internal"] + ); + // Mixed quoting styles on one line. + assert_eq!( + extract_absolute_hosts("const c = ['https://one.internal', \"https://two.internal\"];"), + vec!["one.internal", "two.internal"] + ); + } + + /// Regression: `;`, `,`, `(` and `)` are accepted inside a hostname + /// by the WHATWG parser, so terminating the authority on them handed + /// the allowlist an `example.com` prefix and passed a URL naming a + /// host a browser really resolves. (`'` is excluded from this set on + /// purpose; see + /// [`consecutive_single_quoted_urls_are_all_examined`].) + #[test] + fn source_punctuation_does_not_truncate_to_allowlisted_prefix() { + for (input, expected) in [ + ( + "https://example.com;unapproved.internal/", + "example.com;unapproved.internal", + ), + ( + "https://example.com,unapproved.internal/", + "example.com,unapproved.internal", + ), + ( + "https://example.com(unapproved.internal/", + "example.com(unapproved.internal", + ), + ( + "https://example.com)unapproved.internal/", + "example.com)unapproved.internal", + ), + ] { + assert_eq!( + extract_absolute_hosts(input), + vec![expected], + "input: {input}" + ); + } + } + + /// A multi-byte host must survive extraction and canonicalisation + /// intact, whether or not the URL sits in a quoted string. IDNA makes + /// multi-byte authorities reachable rather than theoretical, and the + /// trimming and stripping helpers operate on byte slices. + #[test] + fn multibyte_hosts_survive_extraction() { + assert_eq!( + extract_absolute_hosts("u('https://service.\u{6d4b}\u{8bd5}').href"), + vec!["service.xn--0zwm56d"] + ); + // A multi-byte label with no quote at all. + assert_eq!( + extract_absolute_hosts("https://service.\u{6d4b}\u{8bd5}/path"), + vec!["service.xn--0zwm56d"] + ); + } + + /// The same characters trailing a URL are source punctuation, not + /// host, and must not be reported as part of it. These are the + /// shapes that a full-repo scan turned up. + #[test] + fn trailing_source_punctuation_is_not_part_of_the_host() { + for input in [ + // Markdown link. + "see [the console](https://example.com) for details", + // List and statement separators. + r#"let hosts = ["https://example.com", "x"];"#, + "let u = \"https://example.com\";", + // Single-quoted string, then more code on the same line. + "expect(new URL('https://example.com').href).toBe(x)", + "expect(u('https://example.com')).toBeDefined()", + ] { + assert_eq!( + extract_absolute_hosts(input), + vec!["example.com"], + "input: {input}" + ); + } + } + + /// A `$` or `*` that opens source interpolation or a config + /// wildcard is not part of the host, but one inside the authority + /// still is -- otherwise trimming would reopen the prefix bypass. + #[test] + fn interpolation_and_wildcard_prefixes_do_not_become_hosts() { + // `${...}`: the capture stops at `{`, stranding the `$`. + assert_eq!( + extract_absolute_hosts("await ready(`http://127.0.0.1:${ORIGIN_PORT}`);"), + vec!["127.0.0.1"] + ); + assert_eq!( + extract_absolute_hosts("`http://publisher.example${RENDER_PATH}`"), + vec!["publisher.example"] + ); + // A leading `*.` is a config pattern for the host it expands to. + assert_eq!( + extract_absolute_hosts(r#"let u = "https://*.unapproved.internal";"#), + vec!["unapproved.internal"] + ); + // But neither trim may swallow an interior character. + assert_eq!( + extract_absolute_hosts("https://github.com$unapproved.internal/path"), + vec!["github.com$unapproved.internal"] + ); + assert_eq!( + extract_absolute_hosts("https://github.com*unapproved.internal/path"), + vec!["github.com*unapproved.internal"] + ); + } + + /// Regression: `!`, `$`, `&`, `=`, and `*` are accepted inside a + /// hostname by the WHATWG parser, so terminating the authority on + /// them handed the allowlist a `github.com` prefix and passed a URL + /// naming a host a browser really resolves. + #[test] + fn url_valid_punctuation_does_not_truncate_to_allowlisted_prefix() { + for (input, expected) in [ + ( + "https://github.com!unapproved.internal/path", + "github.com!unapproved.internal", + ), + ( + "https://github.com$unapproved.internal/path", + "github.com$unapproved.internal", + ), + ( + "https://github.com&unapproved.internal/path", + "github.com&unapproved.internal", + ), + ( + "https://github.com=unapproved.internal/path", + "github.com=unapproved.internal", + ), + ( + "https://github.com*unapproved.internal/path", + "github.com*unapproved.internal", + ), + ] { + assert_eq!( + extract_absolute_hosts(input), + vec![expected], + "input: {input}" + ); + } + } + + #[test] + fn unparseable_authority_is_reported_as_written() { + // A dangling percent sign is rejected by the host parser; the + // raw authority is reported instead of a trimmed prefix. + assert_eq!( + extract_absolute_hosts("https://github.com%zz/x"), + vec!["github.com%zz"] + ); + } +} + +/// Regex for protocol-relative `//host/...` URLs. The `//` must be +/// preceded by a boundary character (start-of-line, whitespace, +/// quote, paren, `=`, `<`, `>`, `{`, `,`, `[`, `]`, backtick) — but +/// NOT `:`, which would double-match the `//` in an absolute URL. +/// [`USERINFO_INNER`] skips any RFC 3986 userinfo so a deceiving +/// `//user@evil.example` pattern reports `evil.example`, not `user`. +/// The authority takes the same two branches as absolute URLs -- +/// bracketed IPv6, or a run up to the first +/// [`AUTHORITY_TERMINATOR_INNER`] character -- and the dotted +/// TLD-like suffix that filters out code comment dividers is applied +/// afterwards, by [`is_reportable_host`], where an address literal can +/// be recognised instead. +fn protocol_relative_regex() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| { + Regex::new(&format!( + r#"(?i)(?:^|[\s"'(=<>{{,\[\]`])//{USERINFO}(\[[0-9a-fA-F:.]+\]|[^{TERM}]+)"#, + USERINFO = USERINFO_INNER, + TERM = AUTHORITY_TERMINATOR_INNER + )) + .expect("should compile protocol-relative URL regex") + }) +} + +/// Whether a canonical host is an IP address literal rather than a +/// name. +/// +/// [`normalise_host`] has already stripped the brackets from an IPv6 +/// literal, so the IPv6 form arrives here bare. +fn is_address_literal(host: &str) -> bool { + host.parse::().is_ok() +} + +/// Whether a canonical host is a real registrable name rather than a +/// code-comment divider or a placeholder. +/// +/// The protocol-relative pattern cannot demand a dotted suffix while +/// matching: a trailing `\.[A-Za-z]{2,}` backtracks into an allowlisted +/// prefix when the real suffix is percent-encoded (`//github.com%2eevil%2ecom` +/// would report `github.com`). So the whole token is captured and the +/// dotted-suffix rule is applied here, to the canonical host, where +/// `%2e` has already become `.`. +/// +/// Address literals are exempt from that rule and always reportable. +/// `//192.0.2.1/path` and `//[2001:db8::1]/path` are valid +/// protocol-relative URLs naming a direct endpoint, but an IPv4 address +/// has no alphabetic suffix and an IPv6 literal has no dot at all, so +/// the dotted-suffix filter dropped both -- letting exactly the kind of +/// hardcoded non-loopback endpoint this linter exists to catch pass +/// every scan mode. +/// +/// An IDNA top-level domain is reportable too. Canonicalisation maps a +/// Unicode TLD to its punycode form, which carries `xn--` and so +/// contains hyphens and digits: an ASCII-alphabetic-only test discarded +/// it, and `//service./path` and its +/// `//service.xn--