Skip to content

feat(cli): declare secret requiredness at add time, extracting manifest_edit - #356

Open
djbclark wants to merge 2 commits into
cachix:mainfrom
frdminc:upstream-pr/manifest-edit
Open

feat(cli): declare secret requiredness at add time, extracting manifest_edit#356
djbclark wants to merge 2 commits into
cachix:mainfrom
frdminc:upstream-pr/manifest-edit

Conversation

@djbclark

@djbclark djbclark commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Summary

secretspec add could only ever write a declaration that inherits
[defaults] required from its profile — it emits a description key and
nothing else. A secret that only some environments need had no CLI path to
being declared optional, short of editing the manifest by hand.

That is not cosmetic: check fails on a required-but-unset secret and passes
on an optional-but-unset one, so the missing flag decided whether check, and
anything gating on it, goes red in an environment that legitimately does not
set the secret.

Two commits, kept separate so the refactor stays readable:

  1. refactor(cli): extract manifest_edit module — moves
    add_secret_to_manifest (and validate_add_secret_name) out of cli into
    a new manifest_edit module behind its own manifest-edit feature, which
    cli still enables. Behavior unchanged; a caller that only needs to add a
    declaration can take that feature alone, without cli's shell-completion
    tooling (clap_complete, clap_complete_nushell, is_executable).
  2. feat(cli): declare secret requiredness — adds --optional and
    --required.

They are in one PR deliberately: the second commit adds a parameter to
add_secret_to_manifest, and the first is what first makes that function
pub. Landing them together means no published signature ever changes.

Requiredness is tri-state

None omits the key entirely (unchanged behavior, byte-identical output),
Some(true)/Some(false) write it explicitly.

Collapsing that to "omitting the flag means required" would be wrong, and this
is the part worth checking: a profile carrying defaults = { required = false }
makes an omitted key resolve to optional. So --optional on its own would
leave "declare a required secret in that profile" unreachable — hence
--required as well. The two are conflicts_with, so the handler never has to
silently pick a winner.

No flag is offered for the at_least_one/exactly_one presence-group form of
required: it spans several secrets at once and does not fit a single-secret
declaration.

Validation

  • cargo test --package secretspec --all-features -- manifest_edit — 7 passed
  • cargo test --package secretspec --all-features cli::tests::add — 5 passed
  • cargo test --package secretspec --all-features --lib — 1256 passed, 26
    pre-existing failures (21 provider::sops::* from the sops CLI not being
    installed, 5 cli::completion::tests that depend on the working directory).
    Both sets fail identically on this branch with the second commit reverted.
  • cargo fmt --all -- --check
  • cargo clippy -p secretspec --no-default-features --features cli — no new
    warnings

Tests cover the emitted text for all three states, that an explicit
requiredness survives into the loaded Config rather than only into the bytes,
that a document mixing a presence group, a full table and a quoted dotted key
is left untouched, and that the two flags are refused together.

add_secret_to_manifest (and its validate_add_secret_name helper) lived
in the cli module behind the cli feature, which also pulls in
clap_complete, clap_complete_nushell, and is_executable for shell
completions. A caller that only wants to add a secret declaration to
a manifest has no way to take that capability without the completion
tooling too.

Moved both functions into a new manifest_edit module behind its own
manifest-edit feature (which cli continues to enable, so cli's
behavior is unchanged). manifest-edit depends on toml_edit alone.
djbclark added a commit to frdminc/sudo-secretspec that referenced this pull request Aug 16, 2026
`add` could only ever write a required declaration. It emits a `description`
key and nothing else, and a declaration with no `required` key resolves to
required -- so a secret that only some hosts need had no CLI path to being
optional short of hand-editing the manifest. Downstream that manifest is
root-owned, which puts it outside the companion's mediated surface entirely.

This is not cosmetic: `check` fails on a required-but-unset secret and passes
on an optional-but-unset one, so the gap decided whether `check` (and anything
gating on it) goes red on hosts that legitimately do not set the secret.

`--optional` writes an explicit `required = false`, the same shape
`generate_toml_with_comments` already emits for an optional secret in `init`.
Omitting the flag writes exactly the bytes `add` wrote before, so no existing
manifest edit changes shape.

The flag is a bool rather than `--required <bool>` because omission already
means required; a value-taking flag would make the common case noisier
without adding a state. There is deliberately no flag for the
`at_least_one`/`exactly_one` presence-group form of `required`: those span
several secrets at once and do not fit a single-secret declare.

Threaded through all three layers -- `add_secret_to_manifest`, the engine's
`secretspec add`, and downstream `sudo-secretspec add` -> broker `source-add`.

Tests: the optional path is asserted to round-trip byte-identically through
`undeclare`, not just to parse. That is the property `template-check` rests
on, and `--optional` writes a longer inline table than the path the existing
round-trip test covered, so it needed its own proof.

Verified against a clean-tree baseline: 1206 -> 1210 passing (the 4 new
tests), with an identical 21-failure set both times, all of them
`provider::sops::*` from the sops CLI not being installed here. fmt clean;
clippy reports nothing new (the two `cli/mod.rs` findings are pre-existing
orphaned doc comments on sudo-main, absent from upstream PR cachix#356).
djbclark added a commit to frdminc/sudo-secretspec that referenced this pull request Aug 16, 2026
Amends the `--optional` work in 1139f1a. Two independent AI reviews
(gemini via agy, grok via cursor-agent) were run on the upstream-bound
subset; both found the same real defect, from opposite directions.

The premise "omitting the flag means required" was FALSE. A profile
carrying `defaults = { required = false }` makes an omitted `required`
key resolve to *optional* (config.rs:2500 -> manifest.rs:57). So with
only `--optional`, both paths produced an optional secret and there was
no way to declare a required one in such a profile at all. Verified
empirically, not just by reading: a secret added there came back
`(optional)` from a real `check`, and now comes back `(required)`.

So requiredness is tri-state, and the library takes `Option<bool>`:
None omits the key (byte-identical to what `add` wrote before), Some(v)
writes it explicitly. The CLI keeps `--optional` as the common case and
adds `--required` for the defaults-optional profile, marked
`conflicts_with` so the two cannot both be set and the handler never has
to silently pick a winner.

Reviewer claims verified rather than taken: agy called the signature
change "a hard SemVer break for external consumers", which is false
here -- `add_secret_to_manifest` is a PRIVATE fn on upstream/main
(cli/mod.rs:619) and only becomes `pub` in our own unmerged PR cachix#356, so
no published signature exists to break. cursor stated the correct
conditional (fine "if this lands in the same PR that introduces
manifest_edit upstream"), which is satisfiable by folding this into
cachix#356. No options struct is needed on those grounds.

Also from the reviews:
- docs: the `# 0.18+` synopsis was carrying a 0.20 flag; split it.
- docs: the `check` bullet overclaimed ("passes regardless") -- false
  when another secret is missing, or when profile defaults already made
  it optional. Reworded to what is actually true.
- test: `contains("required")` over the whole document was a weak
  assertion under an overclaiming name; now asserts the emitted
  declaration.
- test: dropped a duplicate in cli/mod.rs that the manifest_edit test
  already covers more strongly.
- test: added the product claim nothing proved -- that an optional
  declaration actually RESOLVES as optional through the engine, not
  merely that the text says so.
- test: added a round-trip fixture mixing presence groups, a full
  table and a dotted key, since those are what toml_edit is likeliest
  to reserialize and the byte-exact claim rested on inline tables only.

Full suite vs a stashed clean-tree baseline: 1206 -> 1214 passing, zero
non-sops failures both runs, identical 21-failure sops set (sops CLI not
installed here). fmt clean; no new clippy findings.
…uired`

`add` emitted only a `description` key, so every declaration it wrote
inherited `[defaults] required` from its profile with no way to say
otherwise. A secret only some environments need had no CLI path to being
optional short of editing the manifest by hand.

That matters beyond cosmetics: `check` fails on a required-but-unset
secret and passes on an optional-but-unset one, so the missing flag
decided whether `check` -- and anything gating on it -- goes red in an
environment that legitimately does not set the secret.

Requiredness is tri-state rather than a bool, because "no flag" is a
distinct and useful state from either explicit value:

  None       omit the key, inherit `[defaults] required` (unchanged)
  Some(true)  write `required = true`
  Some(false) write `required = false`

Collapsing that to "omitting means required" would be wrong. A profile
carrying `defaults = { required = false }` makes an omitted key resolve
to *optional* (config.rs `merge_secret` -> manifest.rs `CompiledSecret`),
so `--required` is the only way to declare a required secret there.
`--optional` alone would leave that case unreachable.

Omitting both flags emits byte-identical output to before, so no
existing manifest edit changes shape. The flags are `conflicts_with` so
the handler never has to silently pick a winner between them.

No flag is offered for the `at_least_one`/`exactly_one` presence-group
form of `required`: it spans several secrets at once and does not fit a
single-secret declaration.

`add_secret_to_manifest` gains the parameter in the same PR that first
makes it `pub`, so no published signature changes.

Tests cover the emitted text for all three states, that an explicit
requiredness survives into the loaded `Config` (the actual product
claim, rather than just the bytes), that a document mixing a presence
group, a full table and a quoted dotted key is left untouched, and that
the two flags are refused together.
@djbclark djbclark changed the title refactor(cli): extract manifest_edit module feat(cli): declare secret requiredness at add time, extracting manifest_edit Aug 16, 2026
@djbclark

Copy link
Copy Markdown
Contributor Author

@domenkozar when you get a chance, could someone approve the workflow runs on this PR? Every run is sitting in action_required and CI has never actually executed here — across both the original push and the amend — so there's no signal on it either way. That's also what's keeping the PR in UNSTABLE rather than a clean state; nothing is failing.

For context on what's here now: this carries two commits, kept separate so the refactor stays reviewable on its own.

  • 8025fbb extracts the manifest editing out of cli/mod.rs into a manifest_edit module (the original scope of this PR).
  • e917888 builds on it to let add declare requiredness, via --optional / --required.

They're together deliberately. The second changes the signature of add_secret_to_manifest, which is private on main and only becomes pub in the first — so landing them in one PR means no published signature ever changes. Splitting them would create exactly the break that merging them avoids.

Locally: cargo test -p secretspec passes, with the new manifest_edit tests covering round-tripping a document that mixes a presence group, a full table, and a quoted dotted key.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant