From 3c4ab8e3685f46e50fb1725d20030c2f9df0e1f4 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Tue, 10 Nov 2026 08:31:04 +0800 Subject: [PATCH] Complete conftest rule tests --- .mise/config.toml | 13 +- CHECKS.md | 14 +- .../policy/advisories/advisories.rego | 4 +- .../policy/ast_grep_rules/ast_grep_rules.rego | 6 +- .../policy/branch_size/branch_size.rego | 4 +- config/conftest/policy/cargo/cargo.rego | 50 +++--- .../conftest/policy/cargo/dependencies.rego | 6 +- .../policy/checks_badge/checks_badge.rego | 6 +- .../conftest/policy/checks_md/checks_md.rego | 4 +- .../conftest/policy/checksums/checksums.rego | 14 +- .../policy/cross/pyrefly_python_sync.rego | 8 +- ...ndgen-sync.rego => wasm_bindgen_sync.rego} | 6 +- .../policy/cross/wasm_bindgen_sync_test.rego | 40 +++++ .../policy/dockerfile/dockerfile.rego | 51 +++--- .../policy/dockerfile/dockerfile_test.rego | 160 ++++++++++++++++++ .../dockerfile_heredoc.rego | 13 +- .../dockerfile_heredoc_test.rego | 8 +- .../dockerfile_stages/dockerfile_stages.rego | 7 +- .../policy/dockerignore/dockerignore.rego | 4 +- .../generated_trees/generated_trees.rego | 19 +-- .../policy/gha_action/gha_action.rego | 5 +- .../policy/gha_action/gha_action_test.rego | 56 ++++++ .../policy/gha_combined/gha_combined.rego | 14 +- .../gha_combined/gha_combined_test.rego | 58 +++++++ config/conftest/policy/gha_mise/gha_mise.rego | 13 +- .../policy/gha_mise/gha_mise_test.rego | 49 ++++++ config/conftest/policy/gha_uses/gha_uses.rego | 20 +-- .../policy/gha_uses/gha_uses_test.rego | 67 ++++++++ config/conftest/policy/mise/mise.rego | 46 +++-- .../conftest/policy/mise_lock/mise_lock.rego | 6 +- .../policy/no_exemptions/no_exemptions.rego | 6 +- .../no_trailing_backslash.rego | 4 +- config/conftest/policy/paths/paths.rego | 17 ++ config/conftest/policy/paths/paths_test.rego | 25 +++ .../policy/policy_pairing/policy_pairing.rego | 32 +--- .../policy_pairing/policy_pairing_test.rego | 28 +-- .../conftest/policy/pyproject/pyproject.rego | 8 +- .../policy/pyproject/pyproject_test.rego | 50 ++++++ .../policy/tera_comment/tera_comment.rego | 4 +- config/jscpd-baseline.json | 5 +- .../rego-iterate-normalised-files.rego.yaml | 40 +++++ .../rego-iterate-normalised-files.yaml | 22 +++ 42 files changed, 809 insertions(+), 203 deletions(-) rename config/conftest/policy/cross/{wasm-bindgen-sync.rego => wasm_bindgen_sync.rego} (93%) create mode 100644 config/conftest/policy/cross/wasm_bindgen_sync_test.rego create mode 100644 config/conftest/policy/dockerfile/dockerfile_test.rego create mode 100644 config/conftest/policy/gha_action/gha_action_test.rego create mode 100644 config/conftest/policy/gha_combined/gha_combined_test.rego create mode 100644 config/conftest/policy/gha_mise/gha_mise_test.rego create mode 100644 config/conftest/policy/gha_uses/gha_uses_test.rego create mode 100644 config/conftest/policy/paths/paths.rego create mode 100644 config/conftest/policy/paths/paths_test.rego create mode 100644 config/conftest/policy/pyproject/pyproject_test.rego create mode 100644 config/semgrep-tests/rego-iterate-normalised-files.rego.yaml create mode 100644 config/semgrep/rego-iterate-normalised-files.yaml diff --git a/.mise/config.toml b/.mise/config.toml index 32a6f238..7d91b3cb 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -1594,11 +1594,14 @@ published=$(git ls-files 'verification/published/**/Dockerfile') # shellcheck disable=SC2086 conftest test --namespace dockerfile_stages -p config/conftest/policy $published # Filter to Dockerfiles that actually use heredocs before the `ignore` parser. -for f in $files; do - if rg -q '^RUN[^\\n]*<<' "$f"; then - conftest test --parser ignore --namespace dockerfile_heredoc -p config/conftest/policy "$f" - fi -done +# Combined so each file arrives as {path, contents}, the shape the heredoc policy reads its entries from. +# rg exits 1 when no file matches, which leaves nothing to check rather than an error. +# shellcheck disable=SC2086 +heredoc_files=$(rg -l '^RUN[^\\n]*<<' $files) || [ $? -eq 1 ] +if [ -n "$heredoc_files" ]; then + # shellcheck disable=SC2086 + conftest test --combine --parser ignore --namespace dockerfile_heredoc -p config/conftest/policy $heredoc_files +fi """ shell = "{{ vars.task_shell }}" diff --git a/CHECKS.md b/CHECKS.md index f814cc95..a4317d09 100644 --- a/CHECKS.md +++ b/CHECKS.md @@ -1,12 +1,12 @@ # Checks -- **Generated:** 2026-11-09 +- **Generated:** 2026-11-10 - **Checks:** 84 - **Tools:** 49 - **Shell-script checks:** 10 - **Checks with a repair pass:** 22 -- **Rules:** 378 custom local rules, or non-default strict linter settings -- **Meta rules:** 21 of those rules are rules about rules +- **Rules:** 379 custom local rules, or non-default strict linter settings +- **Meta rules:** 22 of those rules are rules about rules Every check is listed across every `MISE_ENV`, filed under the tool it runs. Each entry names the env whose config declares it, the aggregate that runs it as part of `mise run check`, the `fix` or `fmt` pass that repairs what it @@ -665,7 +665,7 @@ Rules, from `config/conftest/policy`: - `pyrefly_python_sync` (`config/conftest/policy/cross/pyrefly_python_sync.rego`): - pyrefly must pin the Python version it type-checks against. - pyrefly's pinned Python version must equal the mise `python` pin, the version every lane installs. -- `wasm-bindgen-sync` (`config/conftest/policy/cross/wasm-bindgen-sync.rego`): +- `wasm_bindgen_sync` (`config/conftest/policy/cross/wasm_bindgen_sync.rego`): - The mise wasm-bindgen pin must match the wasm-bindgen version Cargo.lock resolves. - `dockerfile` (`config/conftest/policy/dockerfile/dockerfile.rego`): - A version a Dockerfile hard-codes must match the [tools] pin for the same tool. @@ -1202,6 +1202,12 @@ Rules, from `config/semgrep`: - `python-silent-broad-except` -- Silently discarding every ordinary exception can conceal programming errors and lost work. Catch the expected exception types and explicitly report, recover from, or propagate the failure. Check-only: a safe recovery policy cannot be inferred from the handler alone. +- `rego-iterate-normalised-files` (meta) -- Iterate `paths.files` (with `import data.paths`) instead of `input`. The + policy runner reports each path with the native separator of the OS it runs on, so on Windows a rule matching + `file.path` against `.mise/config.toml` or `.github/workflows/` sees backslashes and matches nothing -- and that fails + open, with the namespace reporting clean on that lane while checking nothing. `paths.files` is the same input with + every path normalised to forward slashes, so a rule written once works on every OS. Where `input` is one document + rather than a list of files, bind it to a name that says so and iterate that. - `rego-rule-summary` (meta) -- This Rego rule has no summary comment. The generated catalogue of every check lists each policy rule by the first line of the comment above it, and refuses to build without one, so describe what the rule enforces in a comment opening at column 0 between the previous rule's closing brace and this rule. The file's header diff --git a/config/conftest/policy/advisories/advisories.rego b/config/conftest/policy/advisories/advisories.rego index 4af50679..b5e9aecc 100644 --- a/config/conftest/policy/advisories/advisories.rego +++ b/config/conftest/policy/advisories/advisories.rego @@ -11,6 +11,8 @@ # reason this cannot parse, fails immediately rather than quietly becoming the one exception with no deadline. package advisories +import data.paths + # The `reason` spelling the date is read from, anchored so trailing prose cannot hide a second date. expiry_pattern := `^expires ([0-9]{4}-[0-9]{2}-[0-9]{2})$` @@ -21,7 +23,7 @@ lapsed_msg := "config/deny.toml: advisory ignore %q lapsed on %s -- drop it, fix mixed_msg := "config/deny.toml: advisory ignores expire on %d dates, %v; a new entry takes the date the others carry" entries contains entry if { - some file in input + some file in paths.files file.path == "config/deny.toml" some entry in file.contents.advisories.ignore } diff --git a/config/conftest/policy/ast_grep_rules/ast_grep_rules.rego b/config/conftest/policy/ast_grep_rules/ast_grep_rules.rego index 937a6a0f..33ae90db 100644 --- a/config/conftest/policy/ast_grep_rules/ast_grep_rules.rego +++ b/config/conftest/policy/ast_grep_rules/ast_grep_rules.rego @@ -11,17 +11,19 @@ # meta: true package ast_grep_rules +import data.paths + severity_msg := "%s: ast-grep rule %q has severity %q; every rule here is a gate, so it declares `severity: error`" # Every rule in the input, paired with the file defining it. rules contains [file.path, rule] if { - some file in input + some file in paths.files is_object(file.contents) rule := file.contents } rules contains [file.path, rule] if { - some file in input + some file in paths.files is_array(file.contents) some rule in file.contents } diff --git a/config/conftest/policy/branch_size/branch_size.rego b/config/conftest/policy/branch_size/branch_size.rego index d12d29b9..8940550f 100644 --- a/config/conftest/policy/branch_size/branch_size.rego +++ b/config/conftest/policy/branch_size/branch_size.rego @@ -7,6 +7,8 @@ # branches that each stay at or under it. package branch_size +import data.paths + # The most files a pull request may modify and still be reviewed by CodeRabbit. max_files := 100 @@ -14,7 +16,7 @@ too_many_msg := "this branch modifies %d files, over the %d CodeRabbit reviews i # Every path in the list, blank lines aside. changed contains line.Original if { - some file in input + some file in paths.files some line in file.contents line.Original != "" } diff --git a/config/conftest/policy/cargo/cargo.rego b/config/conftest/policy/cargo/cargo.rego index 755af723..ea87be92 100644 --- a/config/conftest/policy/cargo/cargo.rego +++ b/config/conftest/policy/cargo/cargo.rego @@ -3,6 +3,8 @@ # exactly "Cargo.toml" and any other match is a member crate. package cargo +import data.paths + is_member(file) if { endswith(file.path, "Cargo.toml") file.path != "Cargo.toml" @@ -10,20 +12,20 @@ is_member(file) if { # Every dependency spec across the dep tables, as [path, name, spec] triples. dep contains [file.path, name, spec] if { - some file in input + some file in paths.files endswith(file.path, "Cargo.toml") some table in {"dependencies", "dev-dependencies", "build-dependencies"} some name, spec in file.contents[table] } dep contains [file.path, name, spec] if { - some file in input + some file in paths.files endswith(file.path, "Cargo.toml") some name, spec in file.contents.workspace.dependencies } dep contains [file.path, name, spec] if { - some file in input + some file in paths.files endswith(file.path, "Cargo.toml") some tgt in file.contents.target some table in {"dependencies", "dev-dependencies", "build-dependencies"} @@ -92,7 +94,7 @@ wildcard(spec) if { # Member [dependencies]/[dev-dependencies] must inherit via workspace = true so pins stay in [workspace.dependencies]. # (build-dependencies not covered yet.) deny contains msg if { - some file in input + some file in paths.files is_member(file) some table in {"dependencies", "dev-dependencies"} some name, spec in file.contents[table] @@ -103,7 +105,7 @@ deny contains msg if { # A crate with a [lib] must disable the doctest harness and must not rename the lib. # Runnable examples belong in tests/ files; keep the lib name as the package name. deny contains msg if { - some file in input + some file in paths.files is_member(file) file.contents.lib not file.contents.lib.doctest == false @@ -112,7 +114,7 @@ deny contains msg if { # A member crate's [lib] must not rename the library away from the package name. deny contains msg if { - some file in input + some file in paths.files is_member(file) file.contents.lib.name msg := sprintf("%s: [lib] must not set name (keep it the package name)", [file.path]) @@ -121,7 +123,7 @@ deny contains msg if { # Every member must inherit the workspace lint tables. # generated/rust-rest is exempt: progenitor's emitted source trips lints the workspace table denies. deny contains msg if { - some file in input + some file in paths.files is_member(file) file.path != "generated/rust-rest/Cargo.toml" not file.contents.lints.workspace == true @@ -131,14 +133,14 @@ deny contains msg if { # Every crate must be a registered workspace member, with no orphan crates. # Its directory must appear in the root manifest's explicit [workspace].members list. workspace_member contains m if { - some file in input + some file in paths.files file.path == "Cargo.toml" some m in file.contents.workspace.members } # Every member crate must be registered in the root [workspace].members. deny contains msg if { - some file in input + some file in paths.files is_member(file) dir := trim_suffix(file.path, "/Cargo.toml") not workspace_member[dir] @@ -150,7 +152,7 @@ deny contains msg if { inherited_package_field := {"edition", "license", "repository"} deny contains msg if { - some file in input + some file in paths.files is_member(file) some field in inherited_package_field not file.contents.package[field].workspace == true @@ -165,7 +167,7 @@ allowed_crate_name(name) if startswith(name, "et-") allowed_crate_name(name) if startswith(name, "int-") deny contains msg if { - some file in input + some file in paths.files is_member(file) name := file.contents.package.name not allowed_crate_name(name) @@ -176,7 +178,7 @@ deny contains msg if { # Publishing is the consequential choice here (a crates.io upload cannot be withdrawn), so each crate declares # its intent where a reader looks for it instead of the reader having to know the default. deny contains msg if { - some file in input + some file in paths.files is_member(file) not is_boolean(file.contents.package.publish) msg := sprintf("%s: [package] must set publish explicitly (true, or false for an int- crate)", [file.path]) @@ -187,7 +189,7 @@ deny contains msg if { # Keeping it biconditional means the crate list cannot drift into a state where a reader has to open the # manifest to learn whether a crate ships -- the name alone answers it. deny contains msg if { - some file in input + some file in paths.files is_member(file) name := file.contents.package.name contains(name, "int-") @@ -197,7 +199,7 @@ deny contains msg if { # A crate without the int- marker must set publish = true. deny contains msg if { - some file in input + some file in paths.files is_member(file) name := file.contents.package.name not contains(name, "int-") @@ -207,7 +209,7 @@ deny contains msg if { # The version each member crate declares, keyed by crate name. member_version[name] := version if { - some file in input + some file in paths.files is_member(file) name := file.contents.package.name version := file.contents.package.version @@ -218,7 +220,7 @@ member_version[name] := version if { # so a stale pin here stays invisible to check/test/clippy and first fails during `cargo publish` -- partway # through a workspace release, once earlier crates are already uploaded and cannot be withdrawn. deny contains msg if { - some file in input + some file in paths.files file.path == "Cargo.toml" some name, spec in file.contents.workspace.dependencies is_object(spec) @@ -265,7 +267,7 @@ path_dep(spec) if { } root_dep[name] := spec if { - some file in input + some file in paths.files file.path == "Cargo.toml" some name, spec in file.contents.workspace.dependencies } @@ -297,7 +299,7 @@ deny contains msg if { # cargo only warns locally, which means a missing description fails at upload time -- partway through a # workspace release, leaving some crates published at the new version and the rest behind. deny contains msg if { - some file in input + some file in paths.files is_member(file) file.contents.package.publish == true not is_string(file.contents.package.description) @@ -322,7 +324,7 @@ is_dep_name(file, name) if { # A member crate's feature must not share its name with one of its dependencies. deny contains msg if { - some file in input + some file in paths.files is_member(file) file.path != "generated/rust-rest/Cargo.toml" some feat, _ in file.contents.features @@ -334,21 +336,21 @@ dep_feature_hint := "name it for what it gives the crate's user (where it runs, # The feature names config/cargo-features.toml allows, which a human keeps. allowed_features contains name if { - some file in input + some file in paths.files file.path == "config/cargo-features.toml" some name, _ in file.contents.features } # Every feature name a member crate declares. declared_features contains name if { - some file in input + some file in paths.files is_member(file) some name, _ in file.contents.features } # A member crate's feature must be one config/cargo-features.toml lists. deny contains msg if { - some file in input + some file in paths.files is_member(file) some feat, _ in file.contents.features not allowed_features[feat] @@ -437,7 +439,7 @@ docs_rs_renders(file) if "docs" in file.contents.package.metadata.docs.rs.featur # The member crates the feature-docs contract applies to. # generated/rust-rest is exempt: et-int-gen regenerates its src/lib.rs, which carries no `document_features!` call. feature_docs_subject contains file if { - some file in input + some file in paths.files is_member(file) file.path != "generated/rust-rest/Cargo.toml" declares_feature(file) @@ -477,7 +479,7 @@ deny contains msg if { # Dependency overrides ([patch]/[replace]) belong in the root manifest, not a member crate. # There they apply workspace-wide and stay in one place; a member can't override deps. deny contains msg if { - some file in input + some file in paths.files is_member(file) some table in {"patch", "replace"} file.contents[table] diff --git a/config/conftest/policy/cargo/dependencies.rego b/config/conftest/policy/cargo/dependencies.rego index 03f7d5ec..50d38117 100644 --- a/config/conftest/policy/cargo/dependencies.rego +++ b/config/conftest/policy/cargo/dependencies.rego @@ -3,6 +3,8 @@ # `--namespace cargo` evaluation. package cargo +import data.paths + # An internal crate is one whose name carries the int- marker, as a prefix or after this repo's own `et-`. # A plain substring test would also catch third-party names such as `color-print-proc-macro`. internal_crate_name(name) if startswith(name, "int-") @@ -38,7 +40,7 @@ inherits_rename(name, spec) if { # Each root [workspace.dependencies] key that renames its package, mapped to the package it names. workspace_package_rename[name] := spec.package if { - some file in input + some file in paths.files file.path == "Cargo.toml" some name, spec in file.contents.workspace.dependencies is_object(spec) @@ -62,7 +64,7 @@ standalone_crates := {"et-actix-web-config"} # A standalone crate must not depend on any of this repo's `et-` crates. deny contains msg if { - some file in input + some file in paths.files is_member(file) crate := file.contents.package.name crate in standalone_crates diff --git a/config/conftest/policy/checks_badge/checks_badge.rego b/config/conftest/policy/checks_badge/checks_badge.rego index 5b9ff8f6..7b0019f2 100644 --- a/config/conftest/policy/checks_badge/checks_badge.rego +++ b/config/conftest/policy/checks_badge/checks_badge.rego @@ -7,6 +7,8 @@ # meta: true package checks_badge +import data.paths + # How far the rules count moves before the badge has to follow: the badge records it rounded down to this. step := 20 @@ -14,7 +16,7 @@ badge_prefix := "[checks-badge]: " # The rules count from the `- **Rules:** N ...` line of CHECKS.md's header. rules_count := to_number(match[1]) if { - some file in input + some file in paths.files endswith(file.path, "CHECKS.md") some line in file.contents some match in regex.find_all_string_submatch_n(`^- \*\*Rules:\*\* ([0-9]+) `, line.Original, 1) @@ -28,7 +30,7 @@ expected_badge := concat("", [badge_prefix, badge_url_prefix, format_int(rounded badge_url_prefix := "https://img.shields.io/badge/checks-%3E%3D" badge_lines contains line.Original if { - some file in input + some file in paths.files endswith(file.path, "README.md") some line in file.contents startswith(line.Original, badge_prefix) diff --git a/config/conftest/policy/checks_md/checks_md.rego b/config/conftest/policy/checks_md/checks_md.rego index 94ca91ed..a3b18c18 100644 --- a/config/conftest/policy/checks_md/checks_md.rego +++ b/config/conftest/policy/checks_md/checks_md.rego @@ -3,6 +3,8 @@ # its lines; the `Original` field of each is the line's text. package checks_md +import data.paths + # How many whole days may pass since generation before the document has to be regenerated. max_age_days := 7 @@ -13,7 +15,7 @@ stamp_pattern := `^- \*\*Generated:\*\* ([0-9]{4}-[0-9]{2}-[0-9]{2})$` # The date on each `- **Generated:** YYYY-MM-DD` line of CHECKS.md, one entry per line, so repeats are counted. stamps := [match[1] | - some file in input + some file in paths.files endswith(file.path, "CHECKS.md") some line in file.contents some match in regex.find_all_string_submatch_n(stamp_pattern, line.Original, 1) diff --git a/config/conftest/policy/checksums/checksums.rego b/config/conftest/policy/checksums/checksums.rego index 672cc902..e2092d57 100644 --- a/config/conftest/policy/checksums/checksums.rego +++ b/config/conftest/policy/checksums/checksums.rego @@ -11,6 +11,8 @@ # upstream-cache release before the first upload -- every other field is required. package checksums +import data.paths + is_mise(file) if startswith(file.path, ".mise/config") is_upstream_cache(file) if file.path == "config/upstream-cache/data.toml" @@ -24,7 +26,7 @@ unreferenced_msg := "is recorded but no `_asset` var in .mise/config*.toml refer # Asset filenames declared in any .mise/config*.toml's [vars]. mise_assets contains filename if { - some file in input + some file in paths.files is_mise(file) some key, filename in file.contents.vars endswith(key, "_asset") @@ -32,7 +34,7 @@ mise_assets contains filename if { # Filenames present in config/upstream-cache/data.toml's [asset.*] tables. recorded_assets contains filename if { - some file in input + some file in paths.files is_upstream_cache(file) some filename, _ in file.contents.asset } @@ -54,7 +56,7 @@ deny contains msg if { # Per-entry shape check on each asset table. # url + license are required strings; sha256 must be present (may be empty during bootstrap). deny contains msg if { - some file in input + some file in paths.files is_upstream_cache(file) some filename, entry in file.contents.asset not entry.url @@ -63,7 +65,7 @@ deny contains msg if { # Every recorded asset must name its `upstream` project URL. deny contains msg if { - some file in input + some file in paths.files is_upstream_cache(file) some filename, entry in file.contents.asset not entry.upstream @@ -72,7 +74,7 @@ deny contains msg if { # Every recorded asset must carry its `license` as an SPDX expression. deny contains msg if { - some file in input + some file in paths.files is_upstream_cache(file) some filename, entry in file.contents.asset not entry.license @@ -81,7 +83,7 @@ deny contains msg if { # Every recorded asset must carry a `sha256`, left empty only while bootstrapping. deny contains msg if { - some file in input + some file in paths.files is_upstream_cache(file) some filename, entry in file.contents.asset not entry.sha256 diff --git a/config/conftest/policy/cross/pyrefly_python_sync.rego b/config/conftest/policy/cross/pyrefly_python_sync.rego index 8acc01f2..6b3ea641 100644 --- a/config/conftest/policy/cross/pyrefly_python_sync.rego +++ b/config/conftest/policy/cross/pyrefly_python_sync.rego @@ -1,22 +1,24 @@ # Cross-file invariant, run with `--namespace cross` over conftest's `--combine` input. package cross +import data.paths + # The mise `python` pin, in either the string or the `{ version = ... }` form. mise_python := version if { - some file in input + some file in paths.files endswith(file.path, ".mise/config.toml") version := file.contents.tools.python is_string(version) } mise_python := version if { - some file in input + some file in paths.files endswith(file.path, ".mise/config.toml") version := file.contents.tools.python.version } pyrefly_files contains file if { - some file in input + some file in paths.files endswith(file.path, "config/pyrefly.toml") } diff --git a/config/conftest/policy/cross/wasm-bindgen-sync.rego b/config/conftest/policy/cross/wasm_bindgen_sync.rego similarity index 93% rename from config/conftest/policy/cross/wasm-bindgen-sync.rego rename to config/conftest/policy/cross/wasm_bindgen_sync.rego index 971c8fc3..311d420b 100644 --- a/config/conftest/policy/cross/wasm-bindgen-sync.rego +++ b/config/conftest/policy/cross/wasm_bindgen_sync.rego @@ -2,17 +2,19 @@ # Evaluated over conftest's `--combine` input (an array of {path, contents}). package cross +import data.paths + # The mise `github:wasm-bindgen` pin must equal the wasm-bindgen package version in Cargo.lock. # wasm-pack requires the wasm-bindgen CLI to match the crate version exactly; when they match it uses the on-PATH # (mise) binary, otherwise it downloads its own. Keeping them equal avoids that download. mise_pin := pin if { - some file in input + some file in paths.files endswith(file.path, ".mise/config.toml") pin := file.contents.tools["github:wasm-bindgen/wasm-bindgen"] } lock_version := ver if { - some file in input + some file in paths.files endswith(file.path, "Cargo.lock") some pkg in file.contents.package pkg.name == "wasm-bindgen" diff --git a/config/conftest/policy/cross/wasm_bindgen_sync_test.rego b/config/conftest/policy/cross/wasm_bindgen_sync_test.rego new file mode 100644 index 00000000..450f98a5 --- /dev/null +++ b/config/conftest/policy/cross/wasm_bindgen_sync_test.rego @@ -0,0 +1,40 @@ +# Unit tests for the wasm-bindgen pin cross-check, run by `conftest verify`. +# +# The real task only ever sees a pin that already agrees with Cargo.lock, which looks the same as a rule that never +# matches. +package cross_test + +import data.cross + +bindgen_input(pin, lock_version) := [ + {"path": ".mise/config.toml", "contents": {"tools": {"github:wasm-bindgen/wasm-bindgen": pin}}}, + {"path": "Cargo.lock", "contents": {"package": [ + {"name": "wasm-bindgen-shared", "version": "0.0.1"}, + {"name": "wasm-bindgen", "version": lock_version}, + ]}}, +] + +bindgen_drift(msgs) if { + some msg in msgs + startswith(msg, "wasm-bindgen: mise pin") +} + +test_a_pin_matching_the_lock_is_accepted if { + not bindgen_drift(cross.deny) with input as bindgen_input("0.2.130", "0.2.130") +} + +test_a_pin_behind_the_lock_is_flagged if { + msgs := cross.deny with input as bindgen_input("0.2.129", "0.2.130") + some msg in msgs + contains(msg, "mise pin \"0.2.129\" != Cargo.lock \"0.2.130\"") +} + +test_a_windows_config_path_is_still_read if { + files := bindgen_input("0.2.129", "0.2.130") + windows := [object.union(files[0], {"path": `.mise\config.toml`}), files[1]] + bindgen_drift(cross.deny) with input as windows +} + +test_a_pin_matching_only_a_sibling_crate_is_flagged if { + bindgen_drift(cross.deny) with input as bindgen_input("0.0.1", "0.2.130") +} diff --git a/config/conftest/policy/dockerfile/dockerfile.rego b/config/conftest/policy/dockerfile/dockerfile.rego index 6972e0aa..38501438 100644 --- a/config/conftest/policy/dockerfile/dockerfile.rego +++ b/config/conftest/policy/dockerfile/dockerfile.rego @@ -12,11 +12,12 @@ package dockerfile import data.mise +import data.paths # Collect every string argument of an ENV/RUN instruction in the Dockerfile. # A Dockerfile's parsed contents is the array of instruction objects; the TOMLs parse to objects. docker_strings contains entry if { - some file in input + some file in paths.files is_array(file.contents) some instr in file.contents instr.Cmd in {"env", "run"} @@ -46,7 +47,7 @@ deny contains msg if { # scenario images carry `ENV STAGE_TOOL="npm:..."` and similar -- and a pipx tool named in one of those would # satisfy the deny rule below without ever appearing in Nano's disable list, silently weakening the check. disabled_tools contains tool if { - some file in input + some file in paths.files is_array(file.contents) endswith(file.path, "Dockerfile.nanoserver") some instr in file.contents @@ -54,7 +55,9 @@ disabled_tools contains tool if { some value in instr.Value some token in split(value, ",") contains(token, ":") - tool := trim_space(token) + + # An ARG arrives as one `NAME=a,b` string, so the first token carries the `NAME=` that has to come off. + tool := trim_space(regex.replace(token, `^[A-Za-z_][A-Za-z0-9_]*=`, "")) } # The guest languages Nano Server enables, read from its `ENV MISE_ENV=`. @@ -67,7 +70,7 @@ disabled_tools contains tool if { # full guest list, which made this demand that every pipx tool in config.python.toml be disabled on Nano even # though Nano's own MISE_ENV names no python. nano_langs contains lang if { - some file in input + some file in paths.files is_array(file.contents) endswith(file.path, "Dockerfile.nanoserver") some instr in file.contents @@ -80,13 +83,13 @@ nano_langs contains lang if { # The pipx tools Nano can try to install come only from config.toml and its enabled guest configs. # Collect the always-loaded config.toml plus each config..toml whose lang is in MISE_ENV. pipx_scope_path contains path if { - some file in input + some file in paths.files endswith(file.path, ".mise/config.toml") path := file.path } pipx_scope_path contains path if { - some file in input + some file in paths.files some lang in nano_langs endswith(file.path, $".mise/config.{lang}.toml") path := file.path @@ -100,7 +103,7 @@ pipx_scope_path contains path if { # stayed ENABLED on Nano so the build exercised the bootstrap end-to-end) was dropped together with the bootstrap; # re-introduce when the bootstrap is fixed upstream and re-enabled by default. deny contains msg if { - some file in input + some file in paths.files file.path in pipx_scope_path some name, _ in file.contents.tools startswith(name, "pipx:") @@ -128,7 +131,7 @@ deny contains msg if { # The `set -euo pipefail` first-line check itself lives in a raw-text rule: the Dockerfile parser flattens heredoc # bodies out of the AST, so this policy never sees them. deny contains msg if { - some file in input + some file in paths.files is_array(file.contents) some instr in file.contents instr.Cmd == "run" @@ -143,7 +146,7 @@ deny contains msg if { # A RUN heredoc must quote its delimiter so bash, not the outer shell, does the expansion. deny contains msg if { - some file in input + some file in paths.files is_array(file.contents) some instr in file.contents instr.Cmd == "run" @@ -160,7 +163,7 @@ deny contains msg if { # A Dockerfile `apt-get install` must pass --no-install-recommends on the same line. deny contains msg if { - some file in input + some file in paths.files is_array(file.contents) some instr in file.contents instr.Cmd == "run" @@ -174,7 +177,7 @@ deny contains msg if { # A Dockerfile must use `apt-get`, never `apt`. deny contains msg if { - some file in input + some file in paths.files is_array(file.contents) some instr in file.contents instr.Cmd == "run" @@ -198,9 +201,9 @@ package_arg_names := {"APT_PACKAGES", "COMMON_PACKAGES", "DNF_PACKAGES"} # scenario Dockerfiles, which declare a COMMON_PACKAGES of their own, and a second value for the same key makes # this rule abort the whole evaluation with `object keys must be unique` rather than fail a test. arg_packages[name] := pkgs if { - some file in input + some file in paths.files is_array(file.contents) - replace(file.path, "\\", "/") == "Dockerfile" + file.path == "Dockerfile" some instr in file.contents instr.Cmd == "arg" some value in instr.Value @@ -212,7 +215,7 @@ arg_packages[name] := pkgs if { # The packages each manager declares in config.linux.toml's [bootstrap.packages], keyed by manager. bootstrap_packages[mgr] := pkgs if { - some file in input + some file in paths.files endswith(file.path, ".mise/config.linux.toml") some mgr in {"apt", "dnf"} pkgs := {p | @@ -263,8 +266,8 @@ deny contains msg if { # without updating a copy would otherwise leave that image loading a stale env set, and the only symptom would be # a tool the image cannot resolve much later in the build. all_langs := value if { - some file in input - endswith(replace(file.path, "\\", "/"), ".mise/config.toml") + some file in paths.files + endswith(file.path, ".mise/config.toml") value := file.contents.env.ALL_LANGS } @@ -272,7 +275,7 @@ all_langs := value if { # The parser hands an ARG back as one `NAME=value` string, and an ENV as adjacent Value elements, so both shapes # are collected separately. mise_env_decls contains entry if { - some file in input + some file in paths.files is_array(file.contents) some instr in file.contents instr.Cmd == "arg" @@ -282,7 +285,7 @@ mise_env_decls contains entry if { } mise_env_decls contains entry if { - some file in input + some file in paths.files is_array(file.contents) some instr in file.contents instr.Cmd == "env" @@ -295,7 +298,7 @@ mise_env_decls contains entry if { # A value starting with `$` is an indirection such as `ENV MISE_ENV=${MISE_ENV}`, which carries no list to check. deny contains msg if { some decl in mise_env_decls - not endswith(replace(decl.path, "\\", "/"), "Dockerfile.nanoserver") + not endswith(decl.path, "Dockerfile.nanoserver") not startswith(decl.value, "$") decl.value != all_langs msg := sprintf( @@ -311,14 +314,14 @@ deny contains msg if { # added to one image while the root build was never taught to install it, which surfaces only as a missing binary # partway through a build on whichever distro arm lacks it. common_packages_decls contains entry if { - some file in input + some file in paths.files is_array(file.contents) some instr in file.contents instr.Cmd == "arg" some value in instr.Value startswith(value, "COMMON_PACKAGES=") entry := { - "path": replace(file.path, "\\", "/"), + "path": file.path, "packages": split(trim(substring(value, count("COMMON_PACKAGES="), -1), "\""), " "), } } @@ -349,13 +352,13 @@ deny contains msg if { # one and refuses the install; anchoring each copy to min_version makes a bump a single edit that fails loudly # everywhere it was not followed through. The `v` prefix is the release tag's, not part of the version. mise_min_version := value if { - some file in input - endswith(replace(file.path, "\\", "/"), ".mise/config.toml") + some file in paths.files + endswith(file.path, ".mise/config.toml") value := file.contents.min_version } mise_version_args contains entry if { - some file in input + some file in paths.files is_array(file.contents) some instr in file.contents instr.Cmd == "arg" diff --git a/config/conftest/policy/dockerfile/dockerfile_test.rego b/config/conftest/policy/dockerfile/dockerfile_test.rego new file mode 100644 index 00000000..bbfd2252 --- /dev/null +++ b/config/conftest/policy/dockerfile/dockerfile_test.rego @@ -0,0 +1,160 @@ +# Unit tests for the Dockerfile-versus-mise cross-checks, run by `conftest verify`. +# +# The real Dockerfiles already comply, so the real task cannot tell a clean repo from a rule that stopped matching. +# Each test starts from a small repo that passes every rule and changes one thing, so a test that expects a denial +# also proves the clean baseline really is clean. +package dockerfile_test + +import data.dockerfile + +instr(cmd, values) := {"Cmd": cmd, "Value": values} + +root_instrs := [ + instr("arg", [`COMMON_PACKAGES="bash curl"`]), + instr("arg", [`APT_PACKAGES="g++"`]), + instr("arg", [`DNF_PACKAGES="gcc-c++"`]), + instr("arg", ["MISE_VERSION=v2026.10.15"]), + instr("arg", ["MISE_ENV=dart,python"]), + instr("env", ["MISE_ENV", "${MISE_ENV}"]), + instr("run", ["bash <<'EOF'"]), +] + +nano_instrs := [ + instr("arg", ["MISE_DT_BASE=pipx:cowsay,cargo:open"]), + instr("env", ["MISE_DISABLE_TOOLS", "${MISE_DT_BASE}"]), + instr("env", ["MISE_ENV", "dart"]), + instr("env", ["PATH", `C:\i\http-busybox\1.37.0;C:\Windows`]), +] + +mise_config := {"path": ".mise/config.toml", "contents": { + "min_version": "2026.10.15", + "env": {"ALL_LANGS": "dart,python"}, + "tools": {"pipx:cowsay": "latest"}, +}} + +linux_config := {"path": ".mise/config.linux.toml", "contents": {"bootstrap": {"packages": { + "apt:bash": "", + "apt:curl": "", + "apt:g++": "", + "dnf:bash": "", + "dnf:curl": "", + "dnf:gcc-c++": "", +}}}} + +repo(root, nano, extra) := array.concat( + [ + {"path": "Dockerfile", "contents": root}, + {"path": "Dockerfile.nanoserver", "contents": nano}, + mise_config, + linux_config, + {"path": ".mise/config.windows.toml", "contents": {"tools": {"http:busybox": "1.37.0"}}}, + {"path": ".mise/config.python.toml", "contents": {"tools": {"pipx:torch": "latest"}}}, + ], + extra, +) + +clean := repo(root_instrs, nano_instrs, []) + +with_root_run(line) := repo(array.concat(root_instrs, [instr("run", [line])]), nano_instrs, []) + +reports(msgs, fragment) if { + some msg in msgs + contains(msg, fragment) +} + +test_the_baseline_repo_is_clean if { + msgs := dockerfile.deny with input as clean + count(msgs) == 0 +} + +test_a_tool_named_first_in_an_arg_list_counts_as_disabled if { + not reports(dockerfile.deny, "pipx:cowsay") with input as clean +} + +test_a_pipx_tool_missing_from_the_disable_list_is_denied if { + nano := array.concat([instr("arg", ["MISE_DT_BASE=cargo:open"])], array.slice(nano_instrs, 1, 4)) + reports(dockerfile.deny, "pipx tool \"pipx:cowsay\" must be in") with input as repo(root_instrs, nano, []) +} + +test_a_pipx_tool_in_a_guest_config_nano_does_not_load_is_ignored if { + not reports(dockerfile.deny, "pipx:torch") with input as clean +} + +test_a_pipx_tool_in_a_guest_config_nano_loads_is_denied if { + dart := {"path": ".mise/config.dart.toml", "contents": {"tools": {"pipx:dartpy": "latest"}}} + reports(dockerfile.deny, "pipx:dartpy") with input as repo(root_instrs, nano_instrs, [dart]) +} + +test_a_hard_coded_install_path_at_a_stale_version_is_denied if { + nano := array.concat(array.slice(nano_instrs, 0, 3), [instr("env", ["PATH", `C:\i\http-busybox\1.36.1;C:\Windows`])]) + reports(dockerfile.deny, "version \"1.36.1\"") with input as repo(root_instrs, nano, []) +} + +test_a_heredoc_without_bash_is_denied if { + reports(dockerfile.deny, "must use bash as interpreter") with input as with_root_run("<<'EOF'") +} + +test_a_heredoc_with_an_unquoted_delimiter_is_denied if { + reports(dockerfile.deny, "must use a quoted delimiter") with input as with_root_run("bash </`, # which is selective enough by itself. deny contains msg if { - some file in input + some file in paths.files endswith(file.path, ".yaml") walk(file.contents, [_, value]) is_string(value) @@ -52,15 +53,15 @@ deny contains msg if { # that literal still matches the repo. Anchoring here instead makes ALL_LANGS the thing everything else tracks: # adding a config..toml and updating ALL_LANGS lights up every workflow that was left behind. all_langs := value if { - some file in input + some file in paths.files mise.is_mise(file) - endswith(replace(file.path, "\\", "/"), ".mise/config.toml") + endswith(file.path, ".mise/config.toml") value := file.contents.env.ALL_LANGS } # A workflow's env.MISE_ENV must match .mise/config.toml's ALL_LANGS. deny contains msg if { - some file in input + some file in paths.files endswith(file.path, ".yaml") declared := file.contents.env.MISE_ENV declared != all_langs diff --git a/config/conftest/policy/gha_mise/gha_mise_test.rego b/config/conftest/policy/gha_mise/gha_mise_test.rego new file mode 100644 index 00000000..b21f508e --- /dev/null +++ b/config/conftest/policy/gha_mise/gha_mise_test.rego @@ -0,0 +1,49 @@ +# Unit tests for the workflow-versus-mise-pin cross-checks, run by `conftest verify`. +# +# The real task only ever sees workflows that agree with the pins, which looks the same as a rule that never matches. +package gha_mise_test + +import data.gha_mise + +configs := [ + {"path": ".mise/config.toml", "contents": {"env": {"ALL_LANGS": "dart,python"}, "tools": {}}}, + {"path": ".mise/config.dotnet.toml", "contents": {"tools": {"dotnet": "10.0.100"}}}, + {"path": ".mise/config.windows.toml", "contents": {"tools": {"http:busybox": "1.37.0"}}}, +] + +with_workflow(contents) := array.concat(configs, [{"path": ".github/workflows/probe.yaml", "contents": contents}]) + +dotnet_step(version) := {"jobs": {"build": {"steps": [{"with": {"dotnet-version": version}}]}}} + +shell_job(shell) := {"jobs": {"build": {"defaults": {"run": {"shell": shell}}, "steps": []}}} + +reports(msgs, fragment) if { + some msg in msgs + contains(msg, fragment) +} + +test_a_dotnet_version_matching_the_pin_is_accepted if { + not reports(gha_mise.deny, "setup-dotnet") with input as with_workflow(dotnet_step("10.0.100")) +} + +test_a_drifted_dotnet_version_is_denied if { + reports(gha_mise.deny, "dotnet-version \"9.0.300\"") with input as with_workflow(dotnet_step("9.0.300")) +} + +test_an_install_path_at_the_pinned_version_is_accepted if { + shell := `C:\i\http-busybox\1.37.0\busybox.exe sh -e {0}` + not reports(gha_mise.deny, "embeds") with input as with_workflow(shell_job(shell)) +} + +test_an_install_path_at_a_stale_version_is_denied if { + shell := `C:\i\http-busybox\1.36.1\busybox.exe sh -e {0}` + reports(gha_mise.deny, "version \"1.36.1\"") with input as with_workflow(shell_job(shell)) +} + +test_mise_env_matching_all_langs_is_accepted if { + not reports(gha_mise.deny, "env.MISE_ENV") with input as with_workflow({"env": {"MISE_ENV": "dart,python"}}) +} + +test_mise_env_drifted_from_all_langs_is_denied if { + reports(gha_mise.deny, "env.MISE_ENV is \"dart\"") with input as with_workflow({"env": {"MISE_ENV": "dart"}}) +} diff --git a/config/conftest/policy/gha_uses/gha_uses.rego b/config/conftest/policy/gha_uses/gha_uses.rego index c6b05a24..56ba17eb 100644 --- a/config/conftest/policy/gha_uses/gha_uses.rego +++ b/config/conftest/policy/gha_uses/gha_uses.rego @@ -8,16 +8,12 @@ # `no such command: llvm-cov`.) This promotes that class of typo to a hard failure. package gha_uses -# Path separators, normalised so the Windows lane compares equal to the forward-slash paths written here. -# conftest reports each path the way the OS handed it over, so a rule splitting or ending on `/` sees -# `.github\actions\x\action.yaml` on Windows and matches nothing. That fails open: the namespace reports -# clean there while checking nothing, so a violation only ever surfaces on the Linux lanes. -normalised(path) := replace(path, "\\", "/") +import data.paths # action dir name -> set of declared input keys, one entry per parsed .github/actions//action.yaml. declared_inputs[name] := names if { - some file in input - parts := split(normalised(file.path), "/") + some file in paths.files + parts := split(file.path, "/") n := count(parts) parts[n - 1] == "action.yaml" name := parts[n - 2] @@ -26,7 +22,7 @@ declared_inputs[name] := names if { # {path, step} for every step in a workflow job (jobs..steps[]) ... caller_steps contains entry if { - some file in input + some file in paths.files some job in file.contents.jobs some step in job.steps entry := {"path": file.path, "step": step} @@ -34,7 +30,7 @@ caller_steps contains entry if { # ... and every step in a composite action's runs block (runs.steps[]). caller_steps contains entry if { - some file in input + some file in paths.files some step in file.contents.runs.steps entry := {"path": file.path, "step": step} } @@ -60,8 +56,8 @@ deny contains msg if { # shell and every step of the job dies at once on `Second path fragment must not be a drive or UNC name`, # which names neither mise nor the path at fault -- so this pins the literal to the exported value. mise_installs_dir := dir if { - some file in input - endswith(normalised(file.path), "install-mise/action.yaml") + some file in paths.files + endswith(file.path, "install-mise/action.yaml") some step in file.contents.runs.steps some m in regex.find_all_string_submatch_n(`MISE_INSTALLS_DIR=([^'"\s]+)`, step.run, -1) dir := m[1] @@ -69,7 +65,7 @@ mise_installs_dir := dir if { # Every workflow string naming a mise-installed Windows tool, keyed on the install dir of the shell we pin. windows_tool_path contains entry if { - some file in input + some file in paths.files endswith(file.path, ".yaml") walk(file.contents, [_, value]) is_string(value) diff --git a/config/conftest/policy/gha_uses/gha_uses_test.rego b/config/conftest/policy/gha_uses/gha_uses_test.rego new file mode 100644 index 00000000..21b37eff --- /dev/null +++ b/config/conftest/policy/gha_uses/gha_uses_test.rego @@ -0,0 +1,67 @@ +# Unit tests for the local-action input and install-root cross-checks, run by `conftest verify`. +# +# Every real caller already passes declared inputs, so the real task cannot tell a clean repo from a rule that +# stopped resolving actions -- which is exactly what a path-separator change once did on Windows. +package gha_uses_test + +import data.gha_uses + +action := { + "path": ".github/actions/install-mise/action.yaml", + "contents": { + "inputs": {"install-action-tools": {}}, + "runs": {"steps": [{"run": `echo 'MISE_INSTALLS_DIR=C:\i' >> "$GITHUB_ENV"`}]}, + }, +} + +calling(inputs) := [action, { + "path": ".github/workflows/probe.yaml", + "contents": {"jobs": {"build": {"steps": [{"uses": "$/.github/actions/install-mise", "with": inputs}]}}}, +}] + +undeclared(msgs) if { + some msg in msgs + contains(msg, "declares no such input") +} + +misrooted(msgs) if { + some msg in msgs + contains(msg, "must sit under the MISE_INSTALLS_DIR") +} + +test_a_declared_input_is_accepted if { + not undeclared(gha_uses.deny) with input as calling({"install-action-tools": "cargo-llvm-cov"}) +} + +test_an_undeclared_input_is_denied if { + undeclared(gha_uses.deny) with input as calling({"install-action": "cargo-llvm-cov"}) +} + +test_windows_separators_still_resolve_the_action if { + windows := object.union(action, {"path": `.github\actions\install-mise\action.yaml`}) + files := array.concat([windows], array.slice(calling({"install-action": "x"}), 1, 2)) + undeclared(gha_uses.deny) with input as files +} + +test_a_composite_action_calling_another_is_checked if { + caller := { + "path": ".github/actions/install-mise-tools/action.yaml", + "contents": {"runs": {"steps": [{"uses": "$/.github/actions/install-mise", "with": {"typo": "x"}}]}}, + } + undeclared(gha_uses.deny) with input as [action, caller] +} + +shell_workflow(shell) := [action, { + "path": ".github/workflows/probe.yaml", + "contents": {"jobs": {"build": {"defaults": {"run": {"shell": shell}}, "steps": []}}}, +}] + +test_a_busybox_path_under_the_exported_root_is_accepted if { + shell := `${{ runner.os == 'Windows' && 'C:\i\http-busybox\1.37.0\busybox.exe sh -e {0}' || 'bash' }}` + not misrooted(gha_uses.deny) with input as shell_workflow(shell) +} + +test_a_busybox_path_under_another_root_is_denied if { + shell := `C:\mise\installs\http-busybox\1.37.0\busybox.exe sh -e {0}` + misrooted(gha_uses.deny) with input as shell_workflow(shell) +} diff --git a/config/conftest/policy/mise/mise.rego b/config/conftest/policy/mise/mise.rego index 5603818b..43743fb6 100644 --- a/config/conftest/policy/mise/mise.rego +++ b/config/conftest/policy/mise/mise.rego @@ -3,16 +3,14 @@ # `--all-namespaces`). package mise -# The separator replace keeps this true on Windows-local runs. -# conftest reports native backslash paths when handed the `.mise` DIRECTORY (as conftest-check-yaml's -# gha_mise pass and conftest-check-dockerfile do), which would otherwise silently empty every cross-check -# derived from this predicate there; CI's Linux runs were unaffected. -is_mise(file) if startswith(replace(file.path, "\\", "/"), ".mise/config") +import data.paths + +is_mise(file) if startswith(file.path, ".mise/config") # A task `run` must be a string, not an array. # taplo's reorder_arrays would re-sort the commands of an array form and scramble the sequence. deny contains msg if { - some file in input + some file in paths.files is_mise(file) some name, task in file.contents.tasks is_array(task.run) @@ -28,7 +26,7 @@ deny contains msg if { allowed_run_shells := {"{{ vars.task_shell }}", "{{ vars.task_shell_trace }}"} deny contains msg if { - some file in input + some file in paths.files is_mise(file) some name, task in file.contents.tasks is_string(task.run) @@ -47,7 +45,7 @@ deny contains msg if { # Task descriptions must be single-line. deny contains msg if { - some file in input + some file in paths.files is_mise(file) some name, task in file.contents.tasks is_string(task.description) @@ -63,9 +61,9 @@ deny contains msg if { # extension-agnostic existence check, and `for f in "$prefix/bin/foo"*` to walk a literal glob (no-match leaves the # literal as the loop var). deny contains msg if { - some file in input + some file in paths.files is_mise(file) - replace(file.path, "\\", "/") != ".mise/config.maint.toml" + file.path != ".mise/config.maint.toml" some name, task in file.contents.tasks is_string(task.run) regex.match(`\bcompgen\b`, task.run) @@ -83,9 +81,9 @@ deny contains msg if { # config.maint.toml is exempt: its maintainer-only investigation/publish tasks narrate and probe specific # upstream versions by design. deny contains msg if { - some file in input + some file in paths.files is_mise(file) - replace(file.path, "\\", "/") != ".mise/config.maint.toml" + file.path != ".mise/config.maint.toml" some name, task in file.contents.tasks is_string(task.run) some m in regex.find_all_string_submatch_n(`[0-9]+\.[0-9]+\.[0-9]+`, task.run, -1) @@ -127,7 +125,7 @@ cargo_scoped_to_second_tier(spec) if { # A `cargo:` tool must not build from source unless allowlisted or os-scoped to a second-tier platform. deny contains msg if { - some file in input + some file in paths.files is_mise(file) not file.path == ".mise/config.maint.toml" some name, spec in file.contents.tools @@ -142,7 +140,7 @@ deny contains msg if { # `ubi:` is deprecated; the `http:` backend replaces it. deny contains msg if { - some file in input + some file in paths.files is_mise(file) some name, _ in file.contents.tools startswith(name, "ubi:") @@ -182,7 +180,7 @@ http_forge_msg := "%s: tool %q fetches %s over http:; use the github: backend so # An `http:` tool must not fetch from a forge URL; use the github: backend instead. deny contains msg if { - some file in input + some file in paths.files is_mise(file) some name, tool in file.contents.tools startswith(name, "http:") @@ -193,7 +191,7 @@ deny contains msg if { # No per-platform URL of an `http:` tool may point at a forge either. deny contains msg if { - some file in input + some file in paths.files is_mise(file) some name, tool in file.contents.tools startswith(name, "http:") @@ -262,7 +260,7 @@ allowed_os_scoped_tool := { # A tool must not be os-scoped unless allowlisted, since every tool must work on every OS. deny contains msg if { - some file in input + some file in paths.files is_mise(file) some name, spec in file.contents.tools is_object(spec) @@ -285,7 +283,7 @@ install_dirs(name) := {base, replace(base, "_", "-")} if { } tool_versions contains [dir, version] if { - some file in input + some file in paths.files is_mise(file) some name, version in file.contents.tools is_string(version) @@ -293,7 +291,7 @@ tool_versions contains [dir, version] if { } tool_versions contains [dir, version] if { - some file in input + some file in paths.files is_mise(file) some name, spec in file.contents.tools is_object(spec) @@ -304,7 +302,7 @@ tool_versions contains [dir, version] if { # Every [vars]/[env] string, tagged with where it came from for the message. # An entry is either the string itself or mise's directive table, whose `default` mise loads the same way. config_strings contains entry if { - some file in input + some file in paths.files is_mise(file) some kind in ["vars", "env"] some key, value in object.get(file.contents, kind, {}) @@ -377,7 +375,7 @@ tool_version_str(spec) := spec.version if is_object(spec) # mise installs it under a dir named after the request and only symlinks the X.Y alias, and that symlink isn't created # on the Windows runner -- so the py3_* interpreter paths (and the version_drift check above) need the exact patch dir. deny contains msg if { - some file in input + some file in paths.files is_mise(file) version := tool_version_str(file.contents.tools.python) not regex.match(`^[0-9]+\.[0-9]+\.[0-9]+$`, version) @@ -397,7 +395,7 @@ required_package_args := { # Each OS's preinstall task must read its package lists from the Dockerfile ARGs. deny contains msg if { - some file in input + some file in paths.files some required in required_package_args[file.path] task := file.contents.tasks.preinstall not contains(task.run, required) @@ -438,7 +436,7 @@ usage_vars(spec) := vars if { # Every usage arg a task declares must be read by its run body. deny contains msg if { - some file in input + some file in paths.files is_mise(file) some name, task in file.contents.tasks is_string(task.usage) @@ -456,7 +454,7 @@ deny contains msg if { # given" branch. Flagged wherever it appears, including in a body whose declared args are read correctly elsewhere, # so the two spellings never get mixed within one task. deny contains msg if { - some file in input + some file in paths.files is_mise(file) some name, task in file.contents.tasks is_string(task.run) diff --git a/config/conftest/policy/mise_lock/mise_lock.rego b/config/conftest/policy/mise_lock/mise_lock.rego index e564659a..0d685673 100644 --- a/config/conftest/policy/mise_lock/mise_lock.rego +++ b/config/conftest/policy/mise_lock/mise_lock.rego @@ -8,7 +8,9 @@ # both images failed with it. package mise_lock -is_lock(file) if regex.match(`^\.mise/mise(\.[a-z]+)?\.lock$`, replace(file.path, "\\", "/")) +import data.paths + +is_lock(file) if regex.match(`^\.mise/mise(\.[a-z]+)?\.lock$`, file.path) # Tools allowed to lock a glibc Linux build, each with the glibc floor it was measured to need. # @@ -40,7 +42,7 @@ gnu_allowed := { # Every (lockfile, tool, platform) whose locked Linux asset is a glibc build. gnu_locks contains [file.path, name, platform] if { - some file in input + some file in paths.files is_lock(file) some name, entries in file.contents.tools some entry in entries diff --git a/config/conftest/policy/no_exemptions/no_exemptions.rego b/config/conftest/policy/no_exemptions/no_exemptions.rego index 9eab353a..6c940a45 100644 --- a/config/conftest/policy/no_exemptions/no_exemptions.rego +++ b/config/conftest/policy/no_exemptions/no_exemptions.rego @@ -13,6 +13,8 @@ # meta: true package no_exemptions +import data.paths + # The rules no config may narrow and no comment may suppress, by id, whichever linter carries them. unexemptable := {"no-non-ascii"} @@ -28,7 +30,7 @@ missing_msg := "no semgrep rule %q flags inline suppressions of the unexemptable # Every semgrep rule in the input, paired with the file defining it. semgrep_rules contains [file.path, rule] if { - some file in input + some file in paths.files some rule in file.contents.rules } @@ -42,7 +44,7 @@ deny contains msg if { # An unexemptable ast-grep rule may not set `files` or `ignores`, the two ways ast-grep narrows a rule. deny contains msg if { - some file in input + some file in paths.files file.contents.id in unexemptable some key in {"files", "ignores"} file.contents[key] diff --git a/config/conftest/policy/no_trailing_backslash/no_trailing_backslash.rego b/config/conftest/policy/no_trailing_backslash/no_trailing_backslash.rego index 2ed4dc62..472c7a95 100644 --- a/config/conftest/policy/no_trailing_backslash/no_trailing_backslash.rego +++ b/config/conftest/policy/no_trailing_backslash/no_trailing_backslash.rego @@ -15,12 +15,14 @@ # Dockerfile RUN bodies legitimately need line continuations. package no_trailing_backslash +import data.paths + # Flag any string leaf containing `\` followed by `\n` (LF), reporting its path key in the message. # The path key is an array of indices/keys from the root of the parsed document down to the offending string. # Rego's `walk` yields [path, value] pairs over every node, so we filter to string leaves and regex-test for `\` # followed by `\n` (LF). deny contains msg if { - some file in input + some file in paths.files walk(file.contents, [path, value]) is_string(value) regex.match(`\\\n`, value) diff --git a/config/conftest/policy/paths/paths.rego b/config/conftest/policy/paths/paths.rego new file mode 100644 index 00000000..d470bfd8 --- /dev/null +++ b/config/conftest/policy/paths/paths.rego @@ -0,0 +1,17 @@ +# File paths for the conftest policies, spelled the same way on every OS. +# +# conftest reports each path with the native separator of the OS it runs on, so a Windows lane hands over +# `.github\workflows\x.yaml` where every rule is written against `.github/workflows/x.yaml`. A rule that matches on +# the raw path matches nothing there, and that fails open: the namespace reports clean on Windows while checking +# nothing. Every policy run with `--combine` therefore iterates `files` here rather than `input` itself, so no rule +# ever sees a backslash. +package paths + +# A path with its separators turned into forward slashes. +normalised(path) := replace(path, "\\", "/") + +# The files exactly as conftest hands them over, read here and nowhere else. +raw_files := input + +# Every file of a `--combine` input, as conftest hands it over but with its path normalised. +files := [object.union(file, {"path": normalised(file.path)}) | some file in raw_files] diff --git a/config/conftest/policy/paths/paths_test.rego b/config/conftest/policy/paths/paths_test.rego new file mode 100644 index 00000000..12a6e363 --- /dev/null +++ b/config/conftest/policy/paths/paths_test.rego @@ -0,0 +1,25 @@ +# Unit tests for the shared path normalisation, run by `conftest verify`. +# +# A normaliser that stopped normalising would leave every policy built on it checking nothing on Windows while +# every other lane stays green, so the Windows spelling is the input that matters here. +package paths_test + +import data.paths + +test_backslashes_become_forward_slashes if { + paths.normalised(`.github\workflows\check.yaml`) == ".github/workflows/check.yaml" +} + +test_a_forward_slash_path_is_unchanged if { + paths.normalised(".mise/config.toml") == ".mise/config.toml" +} + +test_files_normalise_the_path_and_keep_the_contents if { + files := paths.files with input as [{"path": `.mise\config.toml`, "contents": {"tools": {}}}] + files == [{"path": ".mise/config.toml", "contents": {"tools": {}}}] +} + +test_files_keep_every_file_in_order if { + files := paths.files with input as [{"path": "b", "contents": 1}, {"path": `a\b`, "contents": 2}] + [f.path | some f in files] == ["b", "a/b"] +} diff --git a/config/conftest/policy/policy_pairing/policy_pairing.rego b/config/conftest/policy/policy_pairing/policy_pairing.rego index c9eeaae6..73395a49 100644 --- a/config/conftest/policy/policy_pairing/policy_pairing.rego +++ b/config/conftest/policy/policy_pairing/policy_pairing.rego @@ -12,13 +12,11 @@ # meta: true package policy_pairing -# conftest reports native separators when it walks a directory, so a Windows lane sees backslashes. -normalised(path) := replace(path, "\\", "/") +import data.paths -rego_files contains path if { - some file in input - path := normalised(file.path) - endswith(path, ".rego") +rego_files contains file.path if { + some file in paths.files + endswith(file.path, ".rego") } # Named away from a `test_` prefix: conftest verify treats any rule so named as a unit test to execute. @@ -34,24 +32,10 @@ policy_files contains path if { expected_test(path) := sprintf("%s_test.rego", [trim_suffix(path, ".rego")]) -# Policies deliberately left without tests, each with the reason. -# -# The first group is exempt for good: their matching is direct -- a named key read and compared against another -# named key -- so a break is a rename, which turns the check red or shows up in the diff that caused it. There is -# no silent-failure mode for synthetic input to catch, and a test would only restate the rule. -# -# The second group is not a judgement, it is debt. These are pattern-driven in the same way as the policies that -# do carry tests, and the same silent-failure mode applies to them; they simply have not been written yet. Delete -# the entry as each one gets its test rather than letting the group settle in. -untested_policy := { - "config/conftest/policy/cross/wasm-bindgen-sync.rego": "one equality between two named values", - "config/conftest/policy/gha_action/gha_action.rego": "reads named keys off one action file, no matching", - "config/conftest/policy/pyproject/pyproject.rego": "direct key presence checks over one table", - "config/conftest/policy/dockerfile/dockerfile.rego": "pattern-driven; predates the rule, write tests", - "config/conftest/policy/gha_combined/gha_combined.rego": "pattern-driven; predates the rule, write tests", - "config/conftest/policy/gha_mise/gha_mise.rego": "pattern-driven; predates the rule, write tests", - "config/conftest/policy/gha_uses/gha_uses.rego": "pattern-driven; predates the rule, write tests", -} +# Policies deliberately left without tests, as path -> the reason it is safe to leave that one untested. +# Built through `object.union` so its type is a string-keyed object even while it holds no entries: a bare `{}` +# types as an object with no keys at all, and the compiler then rejects every lookup below as an undefined ref. +untested_policy := object.union({}, {}) # Every policy must have a test file beside it, or a reasoned untested_policy entry. deny contains msg if { diff --git a/config/conftest/policy/policy_pairing/policy_pairing_test.rego b/config/conftest/policy/policy_pairing/policy_pairing_test.rego index 2b77d2a0..32311e7d 100644 --- a/config/conftest/policy/policy_pairing/policy_pairing_test.rego +++ b/config/conftest/policy/policy_pairing/policy_pairing_test.rego @@ -5,9 +5,8 @@ # loudly red -- but the opposite break, a policy file that stops being recognised as one, makes the requirement # silently apply to nothing. The real run over the real tree passes either way once the tree complies. # -# A fixture holds a handful of paths rather than the whole policy tree, so every real `untested_policy` entry is -# absent from it and the stale-entry rule reports each one. That noise is expected here, and is why these assert -# on the message they are about rather than on a count. +# The tests assert on the message they are about rather than on a count, so that an entry later added to the real +# `untested_policy` map -- absent from every fixture here, and so reported as stale -- does not break them. package policy_pairing_test import data.policy_pairing @@ -66,30 +65,33 @@ test_non_rego_files_are_ignored if { not names(msgs, "README") } +# The real map is empty, so the exception tests supply an entry of their own. +excepted := "config/conftest/policy/excused/excused.rego" + +exceptions := {excepted: "fixture reason"} + # An exception excuses the policy it names, and only that one. test_excepted_policy_needs_no_test if { - msgs := policy_pairing.deny with input as files(["config/conftest/policy/gha_action/gha_action.rego"]) - not names(msgs, "add config/conftest/policy/gha_action/gha_action_test.rego") + msgs := policy_pairing.deny with input as files([excepted]) + with policy_pairing.untested_policy as exceptions + not names(msgs, "excused_test.rego") } test_exception_does_not_cover_other_policies if { - msgs := policy_pairing.deny with input as files([ - "config/conftest/policy/gha_action/gha_action.rego", - "config/conftest/policy/demo/demo.rego", - ]) + msgs := policy_pairing.deny with input as files([excepted, "config/conftest/policy/demo/demo.rego"]) + with policy_pairing.untested_policy as exceptions names(msgs, demo_wanted) } # Writing the test is what retires the entry, so an entry that outlives its answer reports. test_excepted_policy_that_gained_a_test_reports_the_stale_entry if { - msgs := policy_pairing.deny with input as files([ - "config/conftest/policy/gha_action/gha_action.rego", - "config/conftest/policy/gha_action/gha_action_test.rego", - ]) + msgs := policy_pairing.deny with input as files([excepted, "config/conftest/policy/excused/excused_test.rego"]) + with policy_pairing.untested_policy as exceptions names(msgs, "drop its untested_policy entry") } test_excepted_policy_that_was_deleted_reports_the_stale_entry if { msgs := policy_pairing.deny with input as files(["config/conftest/policy/demo/demo.rego"]) + with policy_pairing.untested_policy as exceptions names(msgs, "names a policy that no longer exists") } diff --git a/config/conftest/policy/pyproject/pyproject.rego b/config/conftest/policy/pyproject/pyproject.rego index 473b07a9..2608230d 100644 --- a/config/conftest/policy/pyproject/pyproject.rego +++ b/config/conftest/policy/pyproject/pyproject.rego @@ -2,11 +2,13 @@ # Evaluated over conftest's --combine input ({path, contents}). package pyproject +import data.paths + # A `path = ".."` uv source points at the parent directory, which isn't a package -- almost always a mistake. # Sibling packages are referenced by their specific relative path (e.g. ../../../generated/python-rest), not the # bare parent. deny contains msg if { - some file in input + some file in paths.files endswith(file.path, "pyproject.toml") some name, src in file.contents.tool.uv.sources src.path == ".." @@ -16,14 +18,14 @@ deny contains msg if { # uv_build must be pinned to exactly the mise uv version. # This makes `uv build` use the matching backend with no out-of-range warning. Bump both together when upgrading uv. uv_version := v if { - some file in input + some file in paths.files endswith(file.path, ".mise/config.toml") v := file.contents.tools.uv } # A pyproject's uv_build requirement must pin the uv version mise installs. deny contains msg if { - some file in input + some file in paths.files endswith(file.path, "pyproject.toml") some req in file.contents["build-system"].requires startswith(req, "uv_build") diff --git a/config/conftest/policy/pyproject/pyproject_test.rego b/config/conftest/policy/pyproject/pyproject_test.rego new file mode 100644 index 00000000..d227052b --- /dev/null +++ b/config/conftest/policy/pyproject/pyproject_test.rego @@ -0,0 +1,50 @@ +# Unit tests for the pyproject.toml policy, run by `conftest verify`. +# +# The real task only sees pyprojects that already comply, which looks the same as a rule that never matches. +package pyproject_test + +import data.pyproject + +mise_uv := {"path": ".mise/config.toml", "contents": {"tools": {"uv": "0.13.3"}}} + +project(contents) := [mise_uv, {"path": "modules/web/pymath1/pyproject.toml", "contents": contents}] + +parent_source(msgs) if { + some msg in msgs + contains(msg, "uses path = \"..\"") +} + +unpinned(msgs) if { + some msg in msgs + contains(msg, "to match mise's uv") +} + +test_a_source_at_the_parent_directory_is_denied if { + contents := {"tool": {"uv": {"sources": {"et": {"path": ".."}}}}} + parent_source(pyproject.deny) with input as project(contents) +} + +test_a_source_at_a_package_path_is_accepted if { + contents := {"tool": {"uv": {"sources": {"et": {"path": "../../../generated/python-rest"}}}}} + not parent_source(pyproject.deny) with input as project(contents) +} + +test_uv_build_pinned_to_the_mise_uv_is_accepted if { + contents := {"build-system": {"requires": ["uv_build==0.13.3"]}} + not unpinned(pyproject.deny) with input as project(contents) +} + +test_uv_build_pinned_to_another_version_is_denied if { + contents := {"build-system": {"requires": ["uv_build==0.13.2"]}} + unpinned(pyproject.deny) with input as project(contents) +} + +test_a_uv_build_range_is_denied if { + contents := {"build-system": {"requires": ["uv_build>=0.13,<0.14"]}} + unpinned(pyproject.deny) with input as project(contents) +} + +test_other_build_requirements_are_not_checked if { + contents := {"build-system": {"requires": ["hatchling>=1"]}} + not unpinned(pyproject.deny) with input as project(contents) +} diff --git a/config/conftest/policy/tera_comment/tera_comment.rego b/config/conftest/policy/tera_comment/tera_comment.rego index b7355dd2..840c6663 100644 --- a/config/conftest/policy/tera_comment/tera_comment.rego +++ b/config/conftest/policy/tera_comment/tera_comment.rego @@ -12,9 +12,11 @@ # the pitfall is free to spell it out -- and keeps every string mise does render, task bodies included. package tera_comment +import data.paths + # A mise config value must not contain `{#`, which Tera reads as the start of a comment. deny contains msg if { - some file in input + some file in paths.files walk(file.contents, [path, value]) is_string(value) contains(value, "{#") diff --git a/config/jscpd-baseline.json b/config/jscpd-baseline.json index c3a4d355..d2afcabc 100644 --- a/config/jscpd-baseline.json +++ b/config/jscpd-baseline.json @@ -71,6 +71,7 @@ "34b2a7c1a1a932c1": 1, "34cd0de3c97e4264": 1, "35d7f304546de8a8": 1, + "35deb288571d007e": 1, "378e3311bed8161a": 1, "389aea3e8662fb9b": 1, "39df8b0c7c0dccc1": 1, @@ -94,7 +95,6 @@ "4e1f48e8e76d2edd": 1, "4e27ef1bb20894c2": 1, "4eff6767e2aba30c": 1, - "4fdb5b8345a82b9e": 1, "50b373907a5a8f78": 1, "50f5f12046dd3030": 1, "50f8a9aba6c9bf22": 1, @@ -129,7 +129,6 @@ "5ef75b0a5da08bd0": 1, "5f2574a3a43dd64d": 1, "5f68cc98e7c1cbd8": 1, - "60386ee33d1fb547": 1, "608f556637a1f4db": 1, "61b28209f41edecf": 1, "62dd1cdba0d53531": 2, @@ -244,7 +243,6 @@ "aaeb9cdf748826bd": 1, "ab4dbe992894e239": 5, "acd9fa393bd6abf8": 1, - "acdebc833a1835f4": 1, "ad6c8d0f5db047f5": 1, "ae590b605758bec9": 1, "b04b47360d15964e": 1, @@ -295,6 +293,7 @@ "ce17c249a87973d7": 1, "ce8437d3ac124830": 1, "ce990318bfc4aa30": 1, + "cea09bff5211561a": 1, "cfe89c13ebd13c2e": 1, "d035515f00435a4a": 1, "d2c9c708a567ce7e": 1, diff --git a/config/semgrep-tests/rego-iterate-normalised-files.rego.yaml b/config/semgrep-tests/rego-iterate-normalised-files.rego.yaml new file mode 100644 index 00000000..7ab597bc --- /dev/null +++ b/config/semgrep-tests/rego-iterate-normalised-files.rego.yaml @@ -0,0 +1,40 @@ +code: | + package demo + + import data.paths + + raw contains file.path if { + # ruleid: rego-iterate-normalised-files + some file in input + } + + indexed contains file.path if { + # ruleid: rego-iterate-normalised-files + some i, file in input + } + + universal if { + # ruleid: rego-iterate-normalised-files + every file in input { + file.path != "" + } + } + + # ruleid: rego-iterate-normalised-files + inline := [file.path | some file in input] + + # ruleid: rego-iterate-normalised-files + inline_set := {file.path | some file in input} + + normalised contains file.path if { + # ok: rego-iterate-normalised-files + some file in paths.files + } + + jobs contains name if { + # ok: rego-iterate-normalised-files + some name, _ in input.jobs + } + + # ok: rego-iterate-normalised-files + first_steps := [step | some step in input[0].steps] diff --git a/config/semgrep/rego-iterate-normalised-files.yaml b/config/semgrep/rego-iterate-normalised-files.yaml new file mode 100644 index 00000000..e96a4f5b --- /dev/null +++ b/config/semgrep/rego-iterate-normalised-files.yaml @@ -0,0 +1,22 @@ +rules: + - id: rego-iterate-normalised-files + languages: [generic] + metadata: + meta: true + paths: + include: + - "/**/*.rego" + # Only iteration over `input` itself is matched, the shape a combined-input policy uses to walk its files. + # A single-document policy reading `input.jobs` or `input.runs.steps` has no file paths to normalise, so an + # `input` followed by a `.` or `[` is left alone; anything else after it -- a line end, a `{`, or the `]` or `}` + # closing a one-line comprehension -- is iteration over the raw file list. + # No autofix: the rewrite also needs an `import data.paths` added to the file, which a regex fix cannot place. + pattern-regex: '\b(?:some|every)\s+(?:\w+\s*,\s*)?\w+\s+in\s+input\b(?!\s*[.\[])' + message: >- + Iterate `paths.files` (with `import data.paths`) instead of `input`. The policy runner reports each path with + the native separator of the OS it runs on, so on Windows a rule matching `file.path` against + `.mise/config.toml` or `.github/workflows/` sees backslashes and matches nothing -- and that fails open, with + the namespace reporting clean on that lane while checking nothing. `paths.files` is the same input with every + path normalised to forward slashes, so a rule written once works on every OS. Where `input` is one document + rather than a list of files, bind it to a name that says so and iterate that. + severity: ERROR