diff --git a/Cargo.lock b/Cargo.lock index b17d4c6..f3c0d01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,20 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -11,6 +25,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "annotate-snippets" version = "0.12.16" @@ -46,12 +66,33 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bstr" version = "1.13.1" @@ -69,6 +110,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "cc" version = "1.4.4" @@ -125,6 +172,27 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.42" @@ -164,18 +232,50 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "find-msvc-tools" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -211,6 +311,20 @@ dependencies = [ "wasi", ] +[[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", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "globset" version = "0.4.20" @@ -276,6 +390,17 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "ignore" @@ -320,6 +445,59 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7305938cac5ee144352ceb71583ffc6cfeac7d20fcb20ef865a4d5c314e499" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99991ee391af622bda42f01a8727e12fcddbc5ace6f116eef766f060242a36a0" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31581ee58bb3f9a56c0413dd8560d4bdda6b05be0751fa6a533d5b609beb1aae" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "libc" version = "0.2.189" @@ -332,6 +510,15 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -353,6 +540,12 @@ dependencies = [ "libc", ] +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -374,6 +567,75 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -389,6 +651,41 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "phf" version = "0.11.3" @@ -484,6 +781,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.8" @@ -511,7 +814,53 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "referencing" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd75db0e860de35f9b7fb4b05fe13ba057f2548db080178009067f9ea9751d32" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", ] [[package]] @@ -571,6 +920,31 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.4", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -624,6 +998,17 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "serde_json" version = "1.0.154" @@ -699,6 +1084,27 @@ dependencies = [ "vte", ] +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "syn" version = "2.0.119" @@ -835,6 +1241,12 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -903,9 +1315,11 @@ dependencies = [ "grep-regex", "grep-searcher", "ignore", + "jsonschema", "quick-junit", "regex", "rustix", + "schemars", "serde", "serde-saphyr", "serde_json", @@ -931,6 +1345,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vte" version = "0.14.1" @@ -956,6 +1392,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -1031,6 +1476,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" version = "0.8.56" diff --git a/Cargo.toml b/Cargo.toml index e6078af..ae01a5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -135,11 +135,24 @@ chrono = { version = "0.4", default-features = false, features = ["std"] } # machine and adds no crate. uuid = { version = "1", default-features = false, features = ["v8"] } +# The JSON Schema of the policy file, derived from the structs that read it +# rather than written beside them: a hand-written schema is a second list of +# the keys, which is the drift `uphold schema` exists to remove. It reads the +# same `#[serde(...)]` attributes the deserializer does, so `deny_unknown_fields`, +# `rename` and `default` mean to the schema what they mean to the reader. +schemars = { version = "1", features = ["derive"] } + [target.'cfg(unix)'.dependencies] # Git discovers hooks with access(X_OK), which checks the caller's permission # class and ACLs. Use the same OS check through a safe API. rustix = { version = "1", features = ["fs"] } +[dev-dependencies] +# Checks the committed schema against the 2020-12 metaschema and then runs +# this repository's own policy files through it. A schema nothing validates +# against is a claim; this is the standard's own test for it. +jsonschema = { version = "0.60", default-features = false } + # `strip` is deliberately absent: stripping is a packaging step, and a stripped # binary with no build-id cannot be symbolicated from a core. `debug = 1` keeps # the line tables so a crash names the line it happened on. diff --git a/README.md b/README.md index 3b101f5..c118640 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,7 @@ uphold check # the claims in policy/upheld.toml still hold uphold check --probe # add fresh behavior evidence without changing acceptance uphold check --coverage # which rules here carry a principle uphold rules --effective # every rule inheritance resolved to, and where each runs +uphold schema # the JSON Schema of principles.toml, for editors uphold guard --stage pre-push # the guards for that Git hook uphold shim gh pr create ... # stand in front of a command, then exec uphold shim --install # link this binary under each command's name diff --git a/dist-workspace.toml b/dist-workspace.toml index 9844107..9ad41bd 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -77,3 +77,13 @@ host = "x86_64-unknown-linux-gnu" [dist.github-custom-runners.x86_64-unknown-linux-gnu] runner = "ubuntu-latest" host = "x86_64-unknown-linux-gnu" + +# The policy file's JSON Schema, as a release asset with a `.sha256` beside it, +# so a `#:schema` line in a consumer's policy can name an address that is +# immutable per release. dist writes a `.sha256` for the archives and the source +# tarball only, and its unified `sha256.sum` lists those too; an extra artifact +# gets neither, so the command writes its own. `sha256sum` prints the name as +# given, which is why it runs where the copy was written. +[[dist.extra-artifacts]] +artifacts = ["principles.schema.json", "principles.schema.json.sha256"] +build = ["sh", "-c", "cp docs/schema/principles.schema.json principles.schema.json && sha256sum principles.schema.json > principles.schema.json.sha256"] diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 4e3b7a5..bae7cff 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -17,6 +17,7 @@ namespace — that is what lets a claim in - [`uphold hooks --install` — the hooks git actually runs](#uphold-hooks---install--the-hooks-git-actually-runs) - [`uphold probe` — can each hook refuse?](#uphold-probe--can-each-hook-refuse) - [`uphold check --coverage` and `--oscal`](#uphold-check---coverage-and---oscal) +- [`uphold schema` — the policy file's JSON Schema](#uphold-schema--the-policy-files-json-schema) - [The review tier](#the-review-tier) - [Controls: can a record produce a finding at all?](#controls-can-a-record-produce-a-finding-at-all) @@ -4146,6 +4147,50 @@ along as props in this repository's namespace: the rule id, the seam, the record's `enforcement.level` and its `automatable`. The catalog itself does not cross over — see [DESIGN.md](DESIGN.md#why-oscal-and-why-only-the-mapping). +## `uphold schema` — the policy file's JSON Schema + +```sh +uphold schema > principles.schema.json +``` + +Prints a JSON Schema (draft 2020-12) for `principles.toml`, and for the bundled +sets and `inherit.paths` files, which are read by the same deserializer. It is +derived from the structs that read the file, so an unknown key is +`additionalProperties: false` because the reader refuses it, and a key's +description is the doc comment on its field. The `[override.]` table is read +by hand and is described by a stand-in type that a test holds to the reader's +keys. Output is deterministic and takes no arguments; exit `0`. + +The schema states the shape and not the whole of loading. A rule that carries +two checks, a `[set]` header outside a bundled set, an `owner` beside an +`owner_from`, and a regular expression that does not compile are refused after +the read, with the rule's id in the message. A document that validates has the +right shape; `uphold scan` or `uphold rules --effective` is what says it loads. + +The committed copy is `docs/schema/principles.schema.json`, and +`tests/schema_cli.rs` fails while it differs from what the binary prints. After +a change to a policy struct, regenerate it and read the diff: + +```sh +UPHOLD_SCHEMA_GOLDEN=bless cargo test --test schema_cli +``` + +The same test holds the file to the 2020-12 metaschema, validates this +repository's `policy/principles.toml` and every bundled set against it, and +checks that documents the deserializer refuses are refused by the schema. + +Each release carries the schema as an asset, `principles.schema.json`, with +`principles.schema.json.sha256` beside it. To get completion and diagnostics in +an editor that speaks the Even Better TOML language server (taplo), make the +first line of the policy file a schema directive naming the asset: + +```toml +#:schema https://github.com/HackingGate/uphold/releases/latest/download/principles.schema.json +``` + +Replace `latest` with a tag to pin the schema to the release a repository's +`uphold` is pinned to, so the editor and the binary agree on what is a key. + ## The review tier `enforcement.automatable` routes each record to a static rule, to a reviewer, or diff --git a/docs/schema/principles.schema.json b/docs/schema/principles.schema.json new file mode 100644 index 0000000..5ae47a1 --- /dev/null +++ b/docs/schema/principles.schema.json @@ -0,0 +1,911 @@ +{ + "$defs": { + "Collect": { + "description": "How this command's subjects are found.", + "oneOf": [ + { + "const": "flags", + "description": "Walk argv once, reading the flags the table names.", + "type": "string" + }, + { + "const": "git-refs", + "description": "Branch and tag names, which are positional. `git push origin\nfix/acme-outage` puts that name on a public forge, in the ref list, in\nthe pull request it suggests, and in every notification -- and pre-push\nis handed the refs, but the guards that read them judge the DESTINATION\nand never the name itself.", + "type": "string" + }, + { + "const": "npm-package", + "description": "A package's metadata and its tree. What `npm publish` sends is a FILE\nTREE -- README, description, keywords, everything not excluded -- so the\nsubject kinds are `text` for the metadata and `path` for the tree.", + "type": "string" + } + ] + }, + "CommandWhere": { + "additionalProperties": false, + "description": "The command lines a rule stands in front of.", + "properties": { + "before": { + "default": [], + "description": "`\"gh pr create\"`, `\"git push\"`, `\"npm publish\"` -- the command and as\nmuch of its subcommand path as should match, space-separated, as typed.\n`\"gh\"` alone matches every `gh` invocation a shim collects a subject\nfrom.\n\nBefore this field, a checker rule was consulted by EVERY shim: a check\nwritten for a pull-request body was also asked about a branch name on\n`git push` and a tarball on `npm publish`, and there was no way to say\notherwise.", + "items": { + "type": "string" + }, + "type": "array" + }, + "scope": { + "$ref": "#/$defs/Scope", + "description": "When this rule applies, overriding the `[[shim]]` table's own `scope`\nfor this rule alone. Absent means the table decides, which is every\nrule written before this field existed.\n\nThe table's scope answers for the COMMAND -- is this push going\nsomewhere public -- and one answer cannot fit every rule consulted\nbehind it. A private-name check belongs only where the destination is\npublic; host identity in a pull-request body is worth refusing\nwhatever the destination, because a private forge repository is still\nsomebody else's infrastructure with its own retention. Two workspaces\nheld that second class of rule in a hand-rolled agent hook, standing\nOUTSIDE the shim to escape its table's `public-target`, re-invoking\nthis binary for the same two rules the policy already declared --\nwhich is the same rule twice, free to disagree." + } + }, + "type": "object" + }, + "Editor": { + "description": "When an invocation that supplied no body opens an editor for one.\n\nA closed value rather than a flag list or an expression, because the\nquestion has three answers and the commands this tool stands in front of\ngive exactly those three. The answer decides two things that used to be\none: whether the shim stands in as the editor, and whether it SAYS the\neditor is the checkpoint -- which it said for `gh pr merge --squash`, where\nno editor ever opens and the forge composed the merge message unread.", + "oneOf": [ + { + "const": "without-body", + "description": "An editor opens whenever no body flag was given -- `pr create`.", + "type": "string" + }, + { + "const": "never", + "description": "No editor opens, and a body nobody gave is no body -- `pr close`.", + "type": "string" + }, + { + "const": "interactive", + "description": "An editor is offered only from an interactive prompt, and offered\nwhether or not a body flag was given -- `gh pr merge` asks \"Edit commit\nmessage\" with `--body` on the line. With no terminal to prompt on, or\nwith a flag in `editor_unless`, none opens, and a body that was not\ngiven is composed by the forge.", + "type": "string" + }, + { + "const": "forge", + "description": "No editor opens, and a body nobody gave is composed by the forge --\n`glab mr merge`, whose merge commit message GitLab writes from its own\ntemplate when no message flag is given. Said, like the `interactive`\ncase off a terminal, rather than passed over in silence.", + "type": "string" + } + ] + }, + "Files": { + "additionalProperties": false, + "description": "Where a rule runs: over files, as a git hook, in front of a command.\n\nThree tables, three vocabularies, and none of them this tool's. `[rule.git]`\ntakes hook names from githooks(5). `[rule.command]` takes command lines as\ntyped. `[rule.files]` takes ripgrep's own search scoping, because the search\nIS ripgrep -- the crates are linked in, so a glob written here means what it\nmeans to `rg --glob`.\n\nAn absent table is not a default. It is a place the rule does not run, which\nis the whole of the configuration story and the reason \"the gh shim and the\nfile scan, but install no git hook\" is now something a person can write down.", + "properties": { + "baseline": { + "type": "string" + }, + "exclude": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "exclude_cfg_test": { + "default": false, + "type": "boolean" + }, + "fixed_strings": { + "type": "boolean" + }, + "glob": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "include": { + "description": "Search roots. `[\".\"]` when absent.", + "items": { + "type": "string" + }, + "type": "array" + }, + "min_selected": { + "description": "The smallest selection this rule accepts. Fewer files than this and the\nrule fails, naming the count and the keys that produced it.\n\nHere rather than beside a check, and that is the whole placement\nargument: the count is a fact about the SELECTION, so it belongs in the\nshared selection vocabulary every check reads, and one implementation at\nthe funnel covers the check kinds nobody has written yet. `min_selected`\nrather than a `require_selection = true` boolean, because a floor of one\nis a value and not a kind of floor -- a repository whose selection must\ncover four workflow files writes `4`, and no second field has to be\ninvented for it.\n\nA written `0` is refused at load: it is satisfied by every selection\nthere is, so it declares a floor and enforces nothing, which is the\nshape `trivial_comments = false` is refused for.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "multiline": { + "default": false, + "type": "boolean" + }, + "reach": { + "$ref": "#/$defs/Reach", + "description": "Whose tracked files the selection is drawn from: this repository's own\n(`\"repository\"`, and what an absent key means), or those plus the\ncontent of every repository its index pins (`\"pinned\"`).\n\nAn `Option` rather than a defaulted value so that a rule written before\nthe field existed serializes exactly as it did, into the bundled-set\nlock and everywhere else a rule is printed." + }, + "word": { + "default": false, + "description": "Needles from this source match whole words only.", + "type": "boolean" + } + }, + "type": "object" + }, + "Git": { + "additionalProperties": false, + "description": "The five stage names a rule may run at: four of git's own, spelled as\ngithooks(5) spells them, and `manual`.", + "properties": { + "hooks": { + "default": [], + "description": "`pre-commit`, `commit-msg`, `pre-merge-commit`, `pre-push`, `manual`.\n\n`manual` is the one name here that is not git's: no git operation runs\nit, and it exists because every runner spells \"run this on purpose, in\nCI\" that way. It is where the checks too slow to sit in front of a\ncommit go.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "Inherit": { + "additionalProperties": false, + "description": "What a policy inherits, named set by set.\n\n`sets` is a list and nothing else: the old `use_default = true` shorthand\ntook every bundled set without naming one, so what a repository inherited\nwas not written in the repository. Naming the sets is cheap, and each is\na separate decision -- `unmanaged-pins` refuses a shape a repository that\nvendors deliberately has on purpose, `host-identity` shells out to read the\nrunning machine, and `captured-fixtures` refuses a script a parser's own\ntest corpus is made of. None of those arguments should stand between anyone\nand `process-residue`.", + "properties": { + "disabled_rules": { + "default": [], + "description": "Ids dropped from everything inherited. A repository's own rule of the\nsame id overrides instead, which is the other half of the same choice.", + "items": { + "type": "string" + }, + "type": "array" + }, + "paths": { + "default": [], + "description": "Extra policy files, repository-relative. Merged after the bundled sets\nand before the repository's own rules, in the order written.", + "items": { + "type": "string" + }, + "type": "array" + }, + "remote": { + "default": [], + "description": "Organization policy fetched at an immutable GitHub commit, after local paths.", + "items": { + "$ref": "#/$defs/Remote" + }, + "type": "array" + }, + "sets": { + "default": [], + "description": "Bundled rule sets, by name. [`BUNDLED`] is the list and the only one:\na copy of it here named eight sets when the array held twenty, and\nnothing checked the two against each other. `uphold rules --sets`\nprints the array, and the error a wrong name gets is built from it.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "OverrideCommand": { + "additionalProperties": false, + "description": "The `command` keys of an [`OverrideShape`].", + "properties": { + "before_append": { + "description": "Added to the inherited `command.before`.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "OverrideFiles": { + "additionalProperties": false, + "description": "The `files` keys of an [`OverrideShape`].", + "properties": { + "exclude": { + "description": "What the rule leaves out, replacing the inherited value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "glob": { + "description": "Ripgrep globs, replacing the inherited value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "include": { + "description": "Where the rule reads, replacing the inherited value.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "OverrideShape": { + "additionalProperties": false, + "description": "What an `[override.]` table is allowed to say, as a type the schema can\nbe derived from.\n\n[`Override::of_table`] reads the raw table by hand, because its refusals\nname the id and the key and a derived reader cannot. That leaves the\naccepted shape in a `match`, where `schemars` cannot see it. This is the same\nshape as a struct: never constructed or deserialized, and read only by the\nschema generator, so the keys the schema offers an editor are the keys\n`of_table` accepts. A test in this file feeds every key of this type to\n`of_table`, so a key added to one and not the other fails the build rather\nthan the first consumer.", + "properties": { + "allow": { + "description": "More allowances, added to the inherited list.", + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "$ref": "#/$defs/OverrideCommand", + "description": "Adds to the commands the inherited rule stands in front of." + }, + "files": { + "$ref": "#/$defs/OverrideFiles", + "description": "Replaces `files.include`, `files.exclude` and `files.glob` of the\ninherited rule, for the keys written." + }, + "message": { + "description": "The wording, replaced.", + "type": "string" + }, + "require_any_link": { + "description": "The floor under a `links-resolve` selection.", + "type": "boolean" + } + }, + "type": "object" + }, + "Reach": { + "description": "How far down a rule's selection reaches.\n\n`Repository` is what every rule did before the field existed: the files\nthis repository's index lists, a gitlink skipped as another repository's\ncontent. `Pinned` is a claim about the content the repository pins as well:\neach gitlink in the index is asked for its own tracked files, which are\nselected under the mount path. The pin is what makes the content this\nrepository's business -- a member never borrows upward, and a repository\nmay judge, downward, what it pins.", + "oneOf": [ + { + "const": "repository", + "description": "This repository's own tracked files.", + "type": "string" + }, + { + "const": "pinned", + "description": "This repository's own tracked files and those of every repository it\npins, at every depth, under their mount paths.", + "type": "string" + } + ] + }, + "Remote": { + "additionalProperties": false, + "description": "A consumer-selected policy blob, identified by GitHub repository, immutable\ncommit and path. The loader also uses this exact tuple as rule provenance.", + "properties": { + "path": { + "description": "Regular policy blob relative to that commit's tree.", + "type": "string" + }, + "repo": { + "description": "GitHub repository in `owner/name` form.", + "type": "string" + }, + "rev": { + "description": "Full commit object ID, never a movable branch or tag.", + "type": "string" + } + }, + "required": [ + "repo", + "rev", + "path" + ], + "type": "object" + }, + "Scope": { + "description": "Whether this invocation publishes to somewhere that matters.", + "oneOf": [ + { + "const": "public-target", + "description": "`visibility == \"public\"` on the target repository. `internal` is\ndeliberately NOT counted: it is not public to the internet but is public\nto everyone with an account, which is not a distinction worth betting a\nprivate repository's name on.", + "type": "string" + }, + { + "const": "public-registry", + "description": "The public package registry, and a package not marked private.", + "type": "string" + }, + { + "const": "always", + "description": "Always in scope. The right default for a command with no destination:\na spec that HAS a scope predicate says so, rather than inheriting a\nforge's idea of the question.", + "type": "string" + }, + { + "additionalProperties": false, + "description": "An escape hatch, for a command whose question is neither of the above.\nExit 0 to be in scope.", + "properties": { + "command": { + "properties": { + "command": { + "type": "string" + } + }, + "required": [ + "command" + ], + "type": "object" + } + }, + "required": [ + "command" + ], + "type": "object" + } + ] + }, + "SetHeader": { + "additionalProperties": false, + "description": "The ceiling on what one bundled set may install, declared in the set.\n\nA set ships compiled into the binary, so a rule added to one starts running\nin every repository that inherits it with NOTHING IN ANY TREE TO REVIEW. For\na content rule that is a finding somebody argues about; for a guard it is a\ncommit refused in up to sixty-five repositories on the strength of a version\nbump. The constraint that makes the difference safe -- \"a new guard gets a\nnew set name rather than joining an existing one\" -- was written down as a\nrisk to hold, which is another way of saying nothing enforced it.\n\nThis is the enforcement, and it is deliberately a DECLARATION rather than a\nderivation. `stages` is what this set is permitted to install, and a rule in\nit declaring any hook outside that list is refused at load: the set cannot\nquietly grow a `pre-commit` guard, because doing so means editing a line\nthat says in words what the set is allowed to do. An empty list -- the\ndefault -- is a content-only set, which is what seven of the twelve\nbundled sets are.\n\nIt is refused in a repository's own policy and in an `inherit.paths` file.\nNothing there ships compiled in, so nothing there has the problem, and a\nfield that looks like a permission but constrains nothing is worse than no\nfield at all.", + "properties": { + "commands": { + "default": [], + "description": "`command.before` lines the rules in this set may name, verbatim. Empty\nmeans none: the set stands in front of no command.\n\nThe same ceiling as `stages`, for the seam `stages` cannot see. A rule\njoining a set with `command.before = [\"gh\"]` starts being consulted in\nfront of a real command in every inheriting repository on the strength\nof a version bump, which is exactly the arrival the stage ceiling\nexists to make a visible diff. Verbatim lines rather than command\nnames, because a rule's entry is a line -- `\"git push\"` and `\"git\"`\nare different reaches, and a ceiling that matched on the first word\nwould let a set widen from one to the other without editing the line\nthat says what it may do.\n\nWhat this ceiling deliberately does NOT grant: a shim. A set's rule\nreaches a command only through a `[[shim]]` the repository declared\nitself, and inheriting a set in a repository that has not declared one\nis refused at load, naming the table to write.", + "items": { + "type": "string" + }, + "type": "array" + }, + "stages": { + "default": [], + "description": "Git hook stages the rules in this set may declare. Empty means none:\nthe set carries content rules only.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "Shim": { + "additionalProperties": false, + "properties": { + "argv_subject": { + "default": false, + "description": "Hand the rules the whole command line, after the command's own name, as\none subject of kind `argv`. For a rule whose subject is the invocation\nitself -- `release create` -- rather than any text a flag carries.", + "type": "boolean" + }, + "collect": { + "$ref": "#/$defs/Collect" + }, + "command": { + "description": "The command this stands in front of.", + "type": "string" + }, + "editor": { + "$ref": "#/$defs/Editor", + "description": "When the command opens that editor. See [`Editor`]." + }, + "editor_env": { + "description": "The environment variable this command reads to find its editor.\n\nDeclared so the shim can stand in the one place argv cannot reach: no\nbody on the command line, no `--web`, and a command about to open an\neditor. What gets typed there has not been written yet, so the shim puts\nitself in this variable, runs the user's real editor, and reads the file\nback -- the checkpoint `commit-msg` is for a commit.", + "type": "string" + }, + "editor_unless": { + "default": [], + "description": "Flags that take the editor path away under `editor = \"interactive\"`\n-- `--squash` on `gh pr merge` picks the method the prompt would have\nasked for, and no prompt means no \"Edit commit message\".", + "items": { + "type": "string" + }, + "type": "array" + }, + "file_flags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "inert_flags": { + "default": [], + "description": "Flags under which the command publishes nothing -- `--help` prints\nusage, `--disable-auto` on `gh pr merge` turns auto-merge off -- so no\neditor opens and no forge composes a message, whatever `editor` says.\nThe editor path is closed, and nothing is said about a message that\ndoes not exist.", + "items": { + "type": "string" + }, + "type": "array" + }, + "match": { + "default": [], + "description": "`verb:noun`, `verb:*`, or `*`. Named rather than pattern-matched: a shim\nthat guesses which subcommands carry text is one release away from\neither missing a new one in silence or blocking one that never carried\nany, and only the first failure is visible.", + "items": { + "type": "string" + }, + "type": "array" + }, + "message_flags": { + "default": [], + "description": "Flags whose value is a whole commit MESSAGE -- `glab mr merge\n--squash-message`. Its first line is judged as a title, because it is\nthe commit subject, and the rest as a body; a value here counts as the\nbody given.", + "items": { + "type": "string" + }, + "type": "array" + }, + "path_flags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "scope": { + "$ref": "#/$defs/Scope", + "default": "always" + }, + "skip_flags": { + "default": [], + "description": "Flags that mean a body was supplied from somewhere already checked.", + "items": { + "type": "string" + }, + "type": "array" + }, + "target": { + "$ref": "#/$defs/Target" + }, + "target_flags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "text_flags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "title_flags": { + "default": [], + "description": "Flags whose value is a TITLE -- a name the forge shows above the body.\nA separate kind rather than more `text_flags`, for both directions of\nthe difference: a format rule about titles must not be asked about a\nbody, and a title given alone must not read as \"the body was supplied\"\n-- which is what `text_flags` marks, and what used to close the editor\ncheckpoint over the body the command was about to open an editor for.", + "items": { + "type": "string" + }, + "type": "array" + }, + "unresolved": { + "$ref": "#/$defs/Unresolved", + "description": "What to do where `scope` could not be evaluated at all.\n\n`refuse` (the default) or `run`. Only `public-target` can reach this: it\nis the one predicate that asks somebody else, and a forge that cannot be\nasked has said nothing. Refused at load beside a table no reading of\nwhich can produce that answer, because a parameter nothing reads is\nconfiguration that looks like it works." + }, + "verbs": { + "description": "Flag vocabularies for verbs whose grammar differs from the table's.\n\n`target_flags` is deliberately not overridable: `-R`/`--repo` means the\nsame thing on every verb of a command, and a per-verb answer to \"which\nrepository is this going to\" would be a way to publish somewhere the\ntable did not expect.", + "items": { + "$ref": "#/$defs/VerbFlags" + }, + "type": "array" + }, + "web_flags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "command" + ], + "type": "object" + }, + "SupplyChain": { + "additionalProperties": false, + "description": "`[supply_chain]` in a repository's own policy.", + "properties": { + "forge_host": { + "description": "The forge host a first-party git dependency lives on, for the owner the\npolicy declares: `github.com` where this is not written. A git source\nunder that host and owner is checked against its remote; any other is\nrefused.", + "type": "string" + }, + "waive": { + "description": "`[[supply_chain.waive]]`: rows written for the guarddog section that\n`uphold supply-chain` used to have. Still read, so that a policy written\nbefore the section was removed keeps loading; every row is ignored, and\neach is named as ignored when the command runs. See [`Waiver`].", + "items": { + "$ref": "#/$defs/Waiver" + }, + "type": "array" + } + }, + "type": "object" + }, + "Target": { + "description": "Where an invocation is going, when that is a meaningful question at all.", + "oneOf": [ + { + "const": "forge-repo", + "description": "Ask the command itself which repository it is operating on.", + "type": "string" + }, + { + "const": "git-remote", + "description": "Parse it out of a git remote URL.", + "type": "string" + }, + { + "const": "none", + "description": "The command has no notion of a destination.", + "type": "string" + } + ] + }, + "Unresolved": { + "description": "What a shim does with a scope predicate that could not be asked.\n\n`refuse` is the default and the reading the rest of this tool takes: an\nunobserved property must not resolve to success. `run` is the older\nbehaviour, kept as an opt-in for a workspace whose forge is routinely\nunreachable and who would rather have the command than the answer -- it\nstill says on stderr that no checker ran.", + "oneOf": [ + { + "const": "refuse", + "description": "Refuse the invocation with exit 2, having published nothing.", + "type": "string" + }, + { + "const": "run", + "description": "Run the command, saying on stderr that this is not a pass.", + "type": "string" + } + ] + }, + "VerbFlags": { + "additionalProperties": false, + "description": "A flag vocabulary that holds only for the verbs it names.\n\nOne command does not have one grammar. `-c` on `gh pr review` is a boolean\n-- \"Comment on a pull request\" -- and `-c` on `gh issue close` takes a value\n-- \"Leave a closing comment\". A single `text_flags` cannot hold both: name\n`-c` there and a review's `-c` swallows the flag after it, so the body that\nreview is publishing goes unread. Leave it out and a closing comment is\npublished with nothing in front of it. Both are false negatives in the seam\nthat exists to prevent exactly that.\n\nThe lists here REPLACE the table's for the verbs this entry names rather\nthan adding to them, which is the rule `allowed_scripts` already follows for\nthe same reason: what is declared beside the narrower thing is the whole\ntruth for it, and a union would mean a vocabulary nobody wrote.", + "properties": { + "argv_subject": { + "description": "The table's `argv_subject` for these verbs, where given. An option\nrather than a list because it is one answer, and absent means the\ntable's: an entry written to change a flag vocabulary must not also\nswitch the command line on or off as a side effect.", + "type": "boolean" + }, + "editor": { + "$ref": "#/$defs/Editor", + "description": "The table's `editor` for these verbs, where given; absent means the\ntable's, for the reason `argv_subject` gives." + }, + "editor_unless": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "file_flags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "inert_flags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "match": { + "default": [], + "description": "`verb:noun` or `verb:*`, in the spelling the table's own `match` uses.\nEvery entry must also be matched by the table, because a vocabulary for\na verb the shim does not stand in front of is read by nothing.", + "items": { + "type": "string" + }, + "type": "array" + }, + "message_flags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "path_flags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "skip_flags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "text_flags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "title_flags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "web_flags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "Waiver": { + "additionalProperties": false, + "description": "A `[[supply_chain.waive]]` row written when the guarddog section existed.\n\nIt waives nothing now. It is kept loadable so a policy that still carries\none is told so, by `uphold supply-chain`, rather than refused at load: the\nrow is a leftover to delete, not a policy error.", + "properties": { + "check": { + "description": "The scanner's rule, as it named it.", + "type": "string" + }, + "package": { + "description": "`:@`.", + "type": "string" + }, + "reason": { + "description": "Why the finding was judged a false positive.", + "type": "string" + }, + "scanner": { + "description": "`guarddog`.", + "type": "string" + } + }, + "required": [ + "scanner", + "package", + "check", + "reason" + ], + "type": "object" + }, + "Written": { + "additionalProperties": false, + "description": "The flat shape a policy file writes, before it is read as one check.\n\nEvery field here is a key somebody types in `policy/principles.toml`, and\nwhat each one MEANS is documented on the type it becomes -- [`Check`] for\nthe checks and their knobs, [`Parameters`] for the settings a built-in\nreads. This struct is deliberately a bare list: it exists for the length of\none `toml::from_str`, and the moment `parse` has a rule id to name in a\nrefusal it becomes a [`Rule`], which cannot hold two checks or a knob\nnothing reads.\n\n`deny_unknown_fields` is here rather than on `Rule` for the reason the\nconversion is: an unknown key is a spelling question the deserializer can\nanswer on its own, and everything the id has to appear in is answered after.", + "properties": { + "actor": { + "type": "string" + }, + "allow": { + "items": { + "type": "string" + }, + "type": "array" + }, + "allow_outside_repo": { + "type": "boolean" + }, + "allowed_owners": { + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_repos": { + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_scripts": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "builtin": { + "type": "string" + }, + "command": { + "$ref": "#/$defs/CommandWhere" + }, + "command_sources": { + "items": { + "type": "string" + }, + "type": "array" + }, + "comment_regexp": { + "type": "string" + }, + "encoding": { + "type": "string" + }, + "exclusive": { + "type": "boolean" + }, + "exec": { + "type": "string" + }, + "files": { + "$ref": "#/$defs/Files" + }, + "forbidden_literals": { + "type": "string" + }, + "forbidden_literals_from": { + "type": "string" + }, + "foreign_hosts": { + "items": { + "type": "string" + }, + "type": "array" + }, + "git": { + "$ref": "#/$defs/Git" + }, + "ignore_literals": { + "items": { + "type": "string" + }, + "type": "array" + }, + "max_bytes": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "max_lines": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "message": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "owner_required": { + "type": "boolean" + }, + "path_regexp": { + "type": "string" + }, + "private_owners": { + "items": { + "type": "string" + }, + "type": "array" + }, + "private_owners_file": { + "type": "string" + }, + "private_owners_from": { + "type": "string" + }, + "prose_regexp": { + "type": "string" + }, + "public_repos": { + "items": { + "type": "string" + }, + "type": "array" + }, + "refuse_unknown": { + "type": "boolean" + }, + "regexp": { + "type": "string" + }, + "require_any_anchor": { + "type": "boolean" + }, + "require_any_link": { + "type": "boolean" + }, + "require_regexp": { + "type": "string" + }, + "seams": { + "items": { + "type": "string" + }, + "type": "array" + }, + "subjects": { + "items": { + "type": "string" + }, + "type": "array" + }, + "trivial_comments": { + "type": "boolean" + }, + "visibility": { + "type": "string" + }, + "visibility_required": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "A policy file: `principles.toml`, a bundled set under `policy/base/`, or an\n`inherit.paths` file. One shape for all three, because one deserializer reads\nthem; what a bundled set or an inherited file may NOT carry is refused after\nthe read, by rules a schema cannot state.", + "properties": { + "allowed_scripts": { + "default": [], + "description": "The default script constraint for every file no scoped rule selects.\nValues are UTS #24 script names, as `\\p{Script=Latin}` spells them.", + "items": { + "type": "string" + }, + "type": "array" + }, + "baselines_signed": { + "default": false, + "description": "Whether every path-baseline entry must say who excused it and why.\n\nOff by default, and the default is not neutrality -- it is what every\nexisting baseline file already is. A repository turns it on when its\nbaselines stop being one homogeneous debt with a header explaining all\nof it, and start holding entries that differ from each other. The\n`.ljust(` case is the shape: one call site should take the dependency\nand another is correct as it stands, and no pattern separates them, so\nthe entry that excuses the second has to carry the judgement or the\njudgement is nowhere.\n\nPolicy-level rather than per-rule, because \"may an exemption be\nanonymous\" is one answer a repository gives once. Per-rule it would be a\nsetting every new baseline has to remember, which is the same as not\nhaving it.", + "type": "boolean" + }, + "foreign_hosts": { + "default": [], + "description": "The hosts this repository's documents cite that carry no repository the\nprivate-name guards need an answer about.\n\nA policy field rather than a rule one for the reason\n`private_owners_from` is: a rule arriving from a bundled set cannot be\nhanded a parameter, and which hosts a repository's own bibliography\nnames is a property of the repository rather than of one rule in it.\n\nHost globs, matched case-insensitively -- `\"doi.org\"`, `\"*.sr.ht\"`. What\nis NOT named here and is not GitHub is could-not-look and exits 2,\nbecause the tool cannot tell whether `git.acme.example/acme/secret` is\nprivate and silence would read as clean.\n\nExtends [`FOREIGN_HOSTS_DEFAULT`] rather than replacing it: a host that\nserves no `owner/repo` path for anybody is not a fact about this\nrepository, and the fleet had said so in 84 identical lines.", + "items": { + "type": "string" + }, + "type": "array" + }, + "inherit": { + "$ref": "#/$defs/Inherit" + }, + "override": { + "additionalProperties": { + "$ref": "#/$defs/OverrideShape" + }, + "default": {}, + "description": "`[override.]` sections, raw, for the reason `written` is: the id is\nthe section header, and every refusal about one names it. See\n[`Override`].", + "type": "object" + }, + "owner": { + "description": "Who this repository belongs to, declared once for the whole policy.\n\nIdentity is a property of the repository and not of any one rule, and\nthis is the field that makes that true. It exists because a rule\narriving from a bundled set cannot be given a parameter -- the only way\nto set one is to write the rule out again, which is the transcription\n`no-hand-copied-base-rule` refuses. So the guard asks the POLICY who\nthis is, and inheriting a set never decides that on the repository's\nbehalf.", + "type": "string" + }, + "owner_from": { + "description": "A command whose stdout is this repository's owner.\n\nThe duplication is measured rather than asserted: across one fleet, 78\npolicy files declare an `owner` for seven distinct values -- 41 copies of\none string inside a single organisation. That is a workspace fact\ntranscribed once per repository, and it is the same shape\n[`PolicyFile::private_owners_from`] already answered by reading from\noutside the tree.\n\nIt is NOT permission to derive the owner from `origin`. Deriving it is\nthe defect rather than the fix -- repointing `origin` at somebody else's\nremote is the exact accident `prevent-public-push` exists to catch, and a\nderived allow-list is repointed by the same command. This field moves a\nDECLARATION out of the tree; it never reads one off the thing being\nguarded.\n\nRefused beside a literal `owner`, and refused in a bundled set or an\ninherited file. See [`refuse_two_statements_of_one_fact`] and\n[`refuse_inherited_declaration`].", + "type": "string" + }, + "private_owners_file": { + "description": "A file listing the owners this workspace treats as private, one per\nline, read by this binary with no shell in between.\n\nA [`FileSpec`]: `xdg:principles/private-owners` for a file under\n`$XDG_CONFIG_HOME` (else `$HOME/.config`), `home:` for one under\n`$HOME`, or an absolute path. Measured before it existed: 97 copies of\none `cat` line across 87 policy files, every one naming the same file\nunder the same directory, because the objection to a set shipping the\nline was to the SHELL and not to the declaration. A path is not a\ncommand, so a bundled set may carry this one at its top level as the\ndefault for every policy inheriting it; the policy's own line, in\neither form, overrides that default.\n\nRefused beside `private_owners_from` at the same level: two statements\nof where one list lives. See [`refuse_two_statements_of_one_fact`].", + "type": "string" + }, + "private_owners_from": { + "description": "A command whose output lists the owners this workspace treats as\nprivate, declared once for the whole policy.\n\nThe same argument again, and the measurement behind it: across the fleet\nthis was promoted from, ten repositories declared\n`private_owners_from` and every one of them declared the SAME command.\nThat is one workspace-level fact written out ten times, and a rule\narriving from a set has no other way to reach it.\n\nThe shell form. It is refused in a bundled set and in an inherited file\nfor the reason [`refuse_inherited_declaration`] gives, which is why the\nfleet went on writing it: [`PolicyFile::private_owners_file`] is the\nform a set may ship.", + "type": "string" + }, + "private_owners_optional": { + "default": false, + "description": "Whether the machine running this policy may legitimately not have the\nsource `private_owners_from` or `private_owners_file` names.\n\n`false` -- the default -- makes an unreadable source exit 2, because a\nrule with no owners refuses nothing and would report a clean tree over a\nlist it could not read.\n\n`true` exists for one shape and should be written for no other: a policy\nin a repository other people CLONE, naming a source that is one\noperator's. There the default refuses every clone's first commit, and\nthe usual workaround -- a command that swallows its own failure -- loses\nthe check silently and permanently. This is the third answer: the source\nfailing is reported, on stderr, naming the two forms that stop being\nchecked without it.", + "type": "boolean" + }, + "redact_matches": { + "default": false, + "description": "Print the RULE and the location of each hit without the matched text.\n\nFor the tree where a finding is itself the secret: a `forbidden_literals`\nrule refusing a hostname prints that hostname into a CI log every time it\nfires, and the log is read by more people than the file was. Off by\ndefault, because a finding a reader cannot see the text of is one they\nhave to reproduce locally before they can act on it.", + "type": "boolean" + }, + "rule": { + "additionalProperties": { + "$ref": "#/$defs/Written" + }, + "description": "`[rule.]` sections. A map rather than an array because the id IS\nthe key: two sections with one id are a TOML parse error, and there is\nno `[rule.files]` floating free to bind to whatever `[[rule]]` happened\nto be written above it. Iteration order is id order, which is\ndeterministic where file order was merely incidental.\nA rule is deserialized flat and READ as one check by `parse`, which is\nthe first moment an id exists to name in a refusal. Nothing outside\n`parse` looks at this map; `rules` is what the rest of the crate reads.", + "type": "object" + }, + "set": { + "$ref": "#/$defs/SetHeader", + "description": "What a BUNDLED set is allowed to install. Refused in any other file --\nsee [`SetHeader`]." + }, + "shim": { + "items": { + "$ref": "#/$defs/Shim" + }, + "type": "array" + }, + "supply_chain": { + "$ref": "#/$defs/SupplyChain", + "description": "`[supply_chain]`: what this repository has said about its own\ndependency scan. See [`SupplyChain`]." + }, + "visibility": { + "description": "Whether what this repository publishes is visible to everyone, declared\nonce for the whole policy.\n\nHere for the reason [`PolicyFile::owner`] is here, and it is the same\nkind of fact: a property of the repository rather than of any one rule\nin it, and the only form in which a rule arriving from a bundled set can\nbe told. `public`, `private` or `internal`; anything else is refused at\nload rather than at the moment a guard needs it.\n\nThe value that is NOT accepted is silence. A guard whose whole scope\ncondition is \"is this repository public\" cannot answer from a policy\nthat never says, and the fallback -- asking the forge -- is a network\ncall that answers `unknown` on a train, in CI without a token, and for\na repository whose visibility is about to change. See\n`guard::names::target_is_public`.", + "type": "string" + }, + "visibility_from": { + "description": "A command whose stdout is this repository's visibility.\n\nSame mechanism as [`PolicyFile::owner_from`], for a host the built-in\nlookup does not speak to or a workspace that would rather answer from a\ncached organisation index than a request per repository. The word it\nprints is held to `public`, `private` or `internal` exactly as a written\none is.\n\nWhat it must not become is a probe. This is a declaration read from\nsomewhere else, and a command that asks a forge what a repository is\nTODAY hands the private-name family's one scope condition to a network\ncall -- which answers nothing on a train, nothing in CI without a token,\nand answers about the visibility a repository is in the middle of\nchanging. So a command that cannot answer must fail, and failing is exit\n2: a quiet `private` would stand the whole family down, which is\nfail-open on the one rule family where fail-open is unacceptable.", + "type": "string" + } + }, + "title": "PolicyFile", + "type": "object" +} diff --git a/src/config.rs b/src/config.rs index 4926f54..a3c1c39 100644 --- a/src/config.rs +++ b/src/config.rs @@ -201,7 +201,7 @@ pub(crate) use rule::{Check, CheckKind, Literals, Rule, Written}; /// An absent table is not a default. It is a place the rule does not run, which /// is the whole of the configuration story and the reason "the gh shim and the /// file scan, but install no git hook" is now something a person can write down. -#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Files { /// Search roots. `["."]` when absent. @@ -259,7 +259,9 @@ pub(crate) struct Files { /// selected under the mount path. The pin is what makes the content this /// repository's business -- a member never borrows upward, and a repository /// may judge, downward, what it pins. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema, +)] #[serde(rename_all = "lowercase")] pub(crate) enum Reach { /// This repository's own tracked files. @@ -272,7 +274,7 @@ pub(crate) enum Reach { /// The five stage names a rule may run at: four of git's own, spelled as /// githooks(5) spells them, and `manual`. -#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Git { /// `pre-commit`, `commit-msg`, `pre-merge-commit`, `pre-push`, `manual`. @@ -286,7 +288,7 @@ pub(crate) struct Git { } /// The command lines a rule stands in front of. -#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct CommandWhere { /// `"gh pr create"`, `"git push"`, `"npm publish"` -- the command and as @@ -357,7 +359,7 @@ impl CommandWhere { /// running machine, and `captured-fixtures` refuses a script a parser's own /// test corpus is made of. None of those arguments should stand between anyone /// and `process-residue`. -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Inherit { /// Bundled rule sets, by name. [`BUNDLED`] is the list and the only one: @@ -610,8 +612,58 @@ impl Override { } } +/// What an `[override.]` table is allowed to say, as a type the schema can +/// be derived from. +/// +/// [`Override::of_table`] reads the raw table by hand, because its refusals +/// name the id and the key and a derived reader cannot. That leaves the +/// accepted shape in a `match`, where `schemars` cannot see it. This is the same +/// shape as a struct: never constructed or deserialized, and read only by the +/// schema generator, so the keys the schema offers an editor are the keys +/// `of_table` accepts. A test in this file feeds every key of this type to +/// `of_table`, so a key added to one and not the other fails the build rather +/// than the first consumer. +#[derive(Debug, schemars::JsonSchema)] +#[schemars(deny_unknown_fields)] +#[expect(dead_code, reason = "read by the schema generator, never constructed")] +pub(crate) struct OverrideShape { + /// Replaces `files.include`, `files.exclude` and `files.glob` of the + /// inherited rule, for the keys written. + files: Option, + /// Adds to the commands the inherited rule stands in front of. + command: Option, + /// More allowances, added to the inherited list. + allow: Option>, + /// The floor under a `links-resolve` selection. + require_any_link: Option, + /// The wording, replaced. + message: Option, +} + +/// The `files` keys of an [`OverrideShape`]. +#[derive(Debug, schemars::JsonSchema)] +#[schemars(deny_unknown_fields)] +#[expect(dead_code, reason = "read by the schema generator, never constructed")] +pub(crate) struct OverrideFiles { + /// Where the rule reads, replacing the inherited value. + include: Option>, + /// What the rule leaves out, replacing the inherited value. + exclude: Option>, + /// Ripgrep globs, replacing the inherited value. + glob: Option>, +} + +/// The `command` keys of an [`OverrideShape`]. +#[derive(Debug, schemars::JsonSchema)] +#[schemars(deny_unknown_fields)] +#[expect(dead_code, reason = "read by the schema generator, never constructed")] +pub(crate) struct OverrideCommand { + /// Added to the inherited `command.before`. + before_append: Option>, +} + /// `[supply_chain]` in a repository's own policy. -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct SupplyChain { /// `[[supply_chain.waive]]`: rows written for the guarddog section that @@ -639,7 +691,7 @@ const RETIRED_SCANNER: &str = "guarddog"; /// It waives nothing now. It is kept loadable so a policy that still carries /// one is told so, by `uphold supply-chain`, rather than refused at load: the /// row is a leftover to delete, not a policy error. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Waiver { /// `guarddog`. @@ -708,7 +760,11 @@ impl Origin { } } -#[derive(Debug, Clone, Default, Deserialize)] +/// A policy file: `principles.toml`, a bundled set under `policy/base/`, or an +/// `inherit.paths` file. One shape for all three, because one deserializer reads +/// them; what a bundled set or an inherited file may NOT carry is refused after +/// the read, by rules a schema cannot state. +#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct PolicyFile { /// What a BUNDLED set is allowed to install. Refused in any other file -- @@ -880,6 +936,7 @@ pub(crate) struct PolicyFile { /// the section header, and every refusal about one names it. See /// [`Override`]. #[serde(default, rename = "override")] + #[schemars(with = "BTreeMap")] written_overrides: BTreeMap, /// The same sections, each held to the three fields it may carry. Read by /// `load` and by nothing outside this file: once applied, an override is @@ -1106,7 +1163,7 @@ pub(crate) struct OwnersDeclaration { /// Nothing there ships compiled in, so nothing there has the problem, and a /// field that looks like a permission but constrains nothing is worse than no /// field at all. -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct SetHeader { /// Git hook stages the rules in this set may declare. Empty means none: @@ -2828,6 +2885,84 @@ mod tests { load(&dir, &path) } + /// The keys the schema offers an `[override.]` are the keys it reads. + /// + /// `Override::of_table` is a hand-written `match`, so the schema cannot be + /// derived from it and `OverrideShape` stands in. This is the join: every + /// leaf the shape declares is written into a table and read back, and has to + /// come out as exactly that one setting. A key added to the reader and not + /// the shape would be refused here by name; one added to the shape and not + /// the reader would be offered to editors and then refused at load. + #[test] + fn the_override_schema_offers_exactly_the_keys_of_table_reads() { + fn leaves( + schema: &serde_json::Value, + defs: &serde_json::Value, + prefix: &str, + ) -> Vec { + // An optional table is `anyOf [ref, null]` before the schema + // command strips the null; its first branch is the table. + let schema = schema.pointer("/anyOf/0").unwrap_or(schema); + let schema = schema + .get("$ref") + .and_then(serde_json::Value::as_str) + .map_or(schema, |reference| { + defs.get(reference.trim_start_matches("#/$defs/")).unwrap() + }); + let Some(properties) = schema + .get("properties") + .and_then(serde_json::Value::as_object) + else { + return vec![prefix.to_owned()]; + }; + properties + .iter() + .flat_map(|(key, child)| { + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + leaves(child, defs, &path) + }) + .collect() + } + let root = serde_json::to_value(schemars::schema_for!(OverrideShape)).unwrap(); + let mut offered = leaves(&root, &root["$defs"], ""); + offered.sort(); + let mut read = vec![ + "allow", + "command.before_append", + "files.exclude", + "files.glob", + "files.include", + "message", + "require_any_link", + ]; + read.sort_unstable(); + assert_eq!( + offered, read, + "the shape and the reader disagree on the keys" + ); + for path in read { + let text = match path { + "message" => "message = \"m\"".to_owned(), + "require_any_link" => "require_any_link = true".to_owned(), + _ => match path.split_once('.') { + Some((table, key)) => format!("[{table}]\n{key} = [\"x\"]"), + None => format!("{path} = [\"x\"]"), + }, + }; + let table: toml::Table = text.parse().unwrap(); + let parsed = Override::of_table("probe", table).unwrap(); + assert_eq!( + parsed.written(), + vec![path], + "{path} did not read back as itself" + ); + } + } + /// A bundled set's own instructions name keys the schema still has. /// /// These comments are the first thing a consumer reads, and they are diff --git a/src/config/rule.rs b/src/config/rule.rs index 020cc90..00e1377 100644 --- a/src/config/rule.rs +++ b/src/config/rule.rs @@ -168,7 +168,7 @@ impl std::fmt::Display for CheckKind { /// `deny_unknown_fields` is here rather than on `Rule` for the reason the /// conversion is: an unknown key is a spelling question the deserializer can /// answer on its own, and everything the id has to appear in is answered after. -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)] #[serde(default, deny_unknown_fields)] pub(crate) struct Written { pub message: Option, diff --git a/src/main.rs b/src/main.rs index c91cad0..bde5cd2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,6 +100,7 @@ mod remote_policy; mod report; mod runner; mod scan; +mod schema; mod selection; mod shim; mod sources; @@ -119,6 +120,7 @@ usage: run the guards that fire at STAGE uphold guard --text [FILE|-] run the text-capable guards over text [--junit FILE|DIR] + uphold schema print the JSON Schema of principles.toml uphold check [--json] [--probe] reconcile claims; optionally measure hook behavior uphold check --coverage which rules run here and carry no principle uphold audit --for-publication what a private->public flip would republish @@ -371,6 +373,13 @@ fn run() -> Result { "scan" => scan_command(rest), "guard" => guard_command(rest), "audit" => audit_command(rest), + "schema" => match rest { + [] => { + print!("{}", schema::document()?); + Ok(Exit::Clean) + } + _ => Err(Fatal::new(format!("usage: uphold schema\n\n{USAGE}"))), + }, "check" => match rest { [] => check_command(false, false, false), [flag] if flag == "--coverage" => check_command(true, false, false), diff --git a/src/remote_policy.rs b/src/remote_policy.rs index c17391a..c815e99 100644 --- a/src/remote_policy.rs +++ b/src/remote_policy.rs @@ -11,7 +11,7 @@ use crate::error::{Fatal, Result}; /// A consumer-selected policy blob, identified by GitHub repository, immutable /// commit and path. The loader also uses this exact tuple as rule provenance. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Remote { /// GitHub repository in `owner/name` form. diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..6ce6477 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,80 @@ +//! `uphold schema` -- the JSON Schema of the policy file. +//! +//! The schema is derived, not written. `schemars` reads the same `#[serde]` +//! attributes the TOML deserializer does, so `deny_unknown_fields` becomes +//! `additionalProperties: false`, `rename` becomes the key a person types, and a +//! struct field with a `default` is not required. A hand-written schema would be +//! a second list of the keys, and the repository already removed one of those +//! once for exactly that reason. +//! +//! What it cannot say, it does not claim. The deserializer's reading is only the +//! first half of loading a policy: a rule carrying two checks, a bundled-set +//! header in a repository's own file, an `owner` beside an `owner_from` and a +//! regular expression that does not compile are all refused after the read, with +//! the rule's id in the message. A document that validates here has the right +//! shape; `uphold scan` is still what says it loads. + +use crate::config::PolicyFile; +use crate::error::{Fatal, Result}; + +/// The schema as `uphold schema` prints it: pretty JSON, one trailing newline. +/// +/// The output is a function of the source tree and the locked `schemars` +/// version alone, so the committed copy can be compared byte for byte. +pub(crate) fn document() -> Result { + let schema = schemars::generate::SchemaSettings::draft2020_12() + .into_generator() + .into_root_schema_for::(); + let mut value = serde_json::to_value(&schema) + .map_err(|error| Fatal::new(format!("the policy schema did not serialize: {error}")))?; + without_null(&mut value); + let mut text = serde_json::to_string_pretty(&value) + .map_err(|error| Fatal::new(format!("the policy schema did not serialize: {error}")))?; + text.push('\n'); + Ok(text) +} + +/// Take `null` out of the schema, wherever the generator put it. +/// +/// TOML has no null, so a field that is an `Option` means "may be absent" and +/// nothing else. `schemars` 1 has no setting that stops it adding `null` to the +/// type of every such field (and `"default": null` beside it), and leaving that +/// in would tell an editor that a value this format cannot write is accepted. +/// Three shapes carry it: a `type` array, an `anyOf`/`oneOf` branch, and a null +/// default. Whatever is left with one member stops being a list. +fn without_null(value: &mut serde_json::Value) { + use serde_json::Value; + match value { + Value::Object(object) => { + if object.get("default") == Some(&Value::Null) { + object.remove("default"); + } + if let Some(Value::Array(types)) = object.get_mut("type") { + types.retain(|kind| kind != "null"); + if types.len() == 1 { + let only = types.remove(0); + object.insert("type".to_owned(), only); + } + } + for key in ["anyOf", "oneOf"] { + let Some(Value::Array(branches)) = object.get_mut(key) else { + continue; + }; + branches + .retain(|branch| branch.get("type").and_then(Value::as_str) != Some("null")); + if branches.len() == 1 { + let only = branches.remove(0); + object.remove(key); + if let Value::Object(only) = only { + object.extend(only); + } + } + } + for child in object.values_mut() { + without_null(child); + } + } + Value::Array(items) => items.iter_mut().for_each(without_null), + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } +} diff --git a/src/shim.rs b/src/shim.rs index 36a2d70..b1f3ed0 100644 --- a/src/shim.rs +++ b/src/shim.rs @@ -55,7 +55,7 @@ use checker::{consult, pattern_refusal}; use destination::{CommandContext, effective_scope}; /// Where an invocation is going, when that is a meaningful question at all. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default, schemars::JsonSchema)] #[serde(rename_all = "kebab-case")] pub(crate) enum Target { /// Ask the command itself which repository it is operating on. @@ -68,7 +68,7 @@ pub(crate) enum Target { } /// Whether this invocation publishes to somewhere that matters. -#[derive(Debug, Clone, Deserialize, serde::Serialize, Default)] +#[derive(Debug, Clone, Deserialize, serde::Serialize, Default, schemars::JsonSchema)] #[serde(rename_all = "kebab-case")] pub(crate) enum Scope { /// `visibility == "public"` on the target repository. `internal` is @@ -115,7 +115,7 @@ pub(crate) enum Standing { /// behaviour, kept as an opt-in for a workspace whose forge is routinely /// unreachable and who would rather have the command than the answer -- it /// still says on stderr that no checker ran. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default, schemars::JsonSchema)] #[serde(rename_all = "kebab-case")] pub(crate) enum Unresolved { /// Refuse the invocation with exit 2, having published nothing. @@ -133,7 +133,7 @@ pub(crate) enum Unresolved { /// one: whether the shim stands in as the editor, and whether it SAYS the /// editor is the checkpoint -- which it said for `gh pr merge --squash`, where /// no editor ever opens and the forge composed the merge message unread. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default, schemars::JsonSchema)] #[serde(rename_all = "kebab-case")] pub(crate) enum Editor { /// An editor opens whenever no body flag was given -- `pr create`. @@ -350,7 +350,7 @@ pub(crate) fn message_subjects(message: String) -> Vec { } /// How this command's subjects are found. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default, schemars::JsonSchema)] #[serde(rename_all = "kebab-case")] pub(crate) enum Collect { /// Walk argv once, reading the flags the table names. @@ -382,7 +382,7 @@ pub(crate) enum Collect { /// than adding to them, which is the rule `allowed_scripts` already follows for /// the same reason: what is declared beside the narrower thing is the whole /// truth for it, and a union would mean a vocabulary nobody wrote. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct VerbFlags { /// `verb:noun` or `verb:*`, in the spelling the table's own `match` uses. @@ -420,7 +420,7 @@ pub(crate) struct VerbFlags { pub inert_flags: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Shim { /// The command this stands in front of. diff --git a/tests/schema_cli.rs b/tests/schema_cli.rs new file mode 100644 index 0000000..50281c5 --- /dev/null +++ b/tests/schema_cli.rs @@ -0,0 +1,199 @@ +//! `docs/schema/principles.schema.json` against the binary, the standard and +//! this repository's own policy files. +//! +//! The committed file is what `uphold schema` prints, byte for byte +//! (`UPHOLD_SCHEMA_GOLDEN=bless` writes it). It is then held to the JSON Schema +//! 2020-12 metaschema, every policy file this repository ships has to validate +//! against it, and a few documents the deserializer refuses have to be refused +//! here too. A schema that only matched the binary's own output would be a +//! transcript; these are what make it a claim someone else can check. + +#![expect( + clippy::tests_outside_test_module, + clippy::unwrap_used, + reason = "A CLI test asserts on the outcome; a panic in the harness that builds the fixture IS the failure report, and there is no caller to hand a Result to" +)] + +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use serde_json::Value; + +const COMMITTED: &str = "docs/schema/principles.schema.json"; + +fn manifest() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn printed() -> String { + let output = Command::new(env!("CARGO_BIN_EXE_uphold")) + .arg("schema") + .current_dir(manifest()) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(0), "{output:?}"); + assert!(output.stderr.is_empty(), "{output:?}"); + String::from_utf8(output.stdout).unwrap() +} + +fn committed() -> Value { + serde_json::from_str(&std::fs::read_to_string(manifest().join(COMMITTED)).unwrap()).unwrap() +} + +/// A policy file as the document a schema validates: TOML read into JSON's +/// model, which is the reading an editor's language server does too. +fn policy(text: &str) -> Value { + let table: toml::Table = text.parse().unwrap(); + serde_json::to_value(table).unwrap() +} + +fn errors(text: &str) -> Vec { + let validator = jsonschema::draft202012::new(&committed()).unwrap(); + validator + .iter_errors(&policy(text)) + .map(|error| format!("{error} at {}", error.instance_path())) + .collect() +} + +#[test] +fn the_committed_schema_is_what_the_binary_prints() { + let printed = printed(); + let path = manifest().join(COMMITTED); + if std::env::var("UPHOLD_SCHEMA_GOLDEN").as_deref() == Ok("bless") { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, &printed).unwrap(); + return; + } + let committed = std::fs::read_to_string(&path).unwrap(); + assert!( + committed == printed, + "{COMMITTED} is not what `uphold schema` prints; regenerate it with \ + `UPHOLD_SCHEMA_GOLDEN=bless cargo test --test schema_cli`" + ); +} + +#[test] +fn the_schema_is_a_valid_2020_12_schema() { + let schema = committed(); + assert_eq!( + schema.get("$schema").and_then(Value::as_str).unwrap(), + "https://json-schema.org/draft/2020-12/schema" + ); + jsonschema::draft202012::meta::validate(&schema).unwrap(); +} + +#[test] +fn every_policy_file_this_repository_ships_validates() { + let mut paths = vec![manifest().join("policy/principles.toml")]; + for entry in std::fs::read_dir(manifest().join("policy/base")).unwrap() { + let path = entry.unwrap().path(); + if path + .extension() + .is_some_and(|extension| extension == "toml") + { + paths.push(path); + } + } + assert!(paths.len() > 2, "no bundled set was found: {paths:?}"); + for path in paths { + let found = errors(&std::fs::read_to_string(&path).unwrap()); + assert!( + found.is_empty(), + "{} does not validate: {found:#?}", + path.display() + ); + } +} + +#[test] +fn a_policy_that_uses_the_less_common_shapes_validates() { + // The shapes the shipped files do not exercise: a shim scope in its two + // spellings (a bare word and a one-key table), an override, a remote + // policy and a retired waiver row. + let found = errors( + r#" +owner = "acme" +visibility = "public" +[inherit] +sets = ["credentials"] +disabled_rules = ["x"] +[[inherit.remote]] +repo = "acme/policy" +rev = "0123456789abcdef0123456789abcdef01234567" +path = "policy.toml" +[override.some-rule] +message = "m" +[override.some-rule.files] +exclude = ["vendor/**"] +[rule.r] +regexp = "x" +files.reach = "pinned" +command.scope = "public-target" +command.before = ["gh pr create"] +[[shim]] +command = "gh" +match = ["pr:create"] +scope = { command = { command = "true" } } +[[shim.verbs]] +match = ["pr:create"] +editor = "never" +[[supply_chain.waive]] +scanner = "guarddog" +package = "pypi:x@1" +check = "c" +reason = "r" +"#, + ); + assert!(found.is_empty(), "{found:#?}"); +} + +#[test] +fn a_document_the_deserializer_refuses_is_refused_by_the_schema() { + // Each of these is read by `toml::from_str::` as an error, so a + // schema that accepted one would be offering an editor a policy that does + // not load. + for (what, text) in [ + ( + "an unknown key on a rule", + "[rule.r]\nregexp = \"x\"\nregex = \"y\"\n", + ), + ("an unknown top-level key", "extends = [\"x\"]\n"), + ( + "an unknown key under files", + "[rule.r]\nregexp = \"x\"\nfiles.includes = [\".\"]\n", + ), + ("a wrong type for a string", "owner = 3\n"), + ( + "a wrong type for a list", + "[rule.r]\nregexp = \"x\"\nallowed_scripts = \"Latin\"\n", + ), + ("a number where a flag belongs", "redact_matches = 1\n"), + ( + "a negative size", + "[rule.r]\nregexp = \"x\"\nmax_lines = -1\n", + ), + ( + "a reach that is not one", + "[rule.r]\nregexp = \"x\"\nfiles.reach = \"everywhere\"\n", + ), + ( + "an override key that would change the check", + "[override.r]\nregexp = \"x\"\n", + ), + ( + "an override file key that is not a scope", + "[override.r.files]\nmultiline = true\n", + ), + ( + "an unknown shim scope", + "[[shim]]\ncommand = \"gh\"\nscope = \"sometimes\"\n", + ), + ( + "a shim with no command", + "[[shim]]\nmatch = [\"pr:create\"]\n", + ), + ] { + assert!(!errors(text).is_empty(), "{what} was accepted:\n{text}"); + } +} diff --git a/tests/structural_documentation.rs b/tests/structural_documentation.rs index c21563b..0cc964b 100644 --- a/tests/structural_documentation.rs +++ b/tests/structural_documentation.rs @@ -58,7 +58,6 @@ const KNOWN: &[&str] = &[ "catalog.rs::get", "check.rs::Supply", "check.rs::installed", - "config.rs::PolicyFile", "git.rs::config_global", "git.rs::dir", "git.rs::run",