Format-preserving single-declaration edits on Spec - #374
Conversation
`Spec` can read a `secretspec.toml` and build a new one, but cannot write an existing one back out. Editing one declaration in a real file therefore means either regenerating the whole document from the parsed model -- losing every comment and reordering every profile, since `Config.profiles` is a `HashMap` -- or hand-rolling `toml_edit` outside the library. Neither produces the one-line diff that decides whether a proposed manifest change is reviewable. `Spec` now retains the text it was parsed from and gains five methods: add_secret_to_text / remove_secret_from_text -> Result<Spec> declares_secret_in_text -> bool preserved_text -> Option<&str> to_toml -> Result<String> The edits are `toml_edit` surgery on the retained text; `config` and `compiled` are then re-derived by reparsing the result through the same validated path every other `Spec` already goes through. The semantic view is always derived from the text rather than hand-mutated alongside it, so there is one synchronization point instead of one per method, the two cannot disagree, and an edit that would not validate fails at the edit. `preserved_text` and `to_toml` are deliberately separate. A single renderer whose exactness depended on hidden state would hand a silently regenerated document to exactly the callers who need the original; returning `Option` forces the question at the type level. Per the issue, `SpecBuilder`'s general edit surface is NOT made format-preserving. `replace_secret`, `profile`, `provider`, `scope` and the `Secret`/`Profile` setters have no single canonical textual form, so `into_builder()`/`to_builder()` stays a hard boundary: only `Spec` carries text. Two wrinkles, both flagged on the issue and both real: `Spec::from_toml` rejects a non-empty `project.extends`, having nowhere to resolve the paths from, so reparsing edited text through it would fail on every inheriting project. `Config::from_text_in` is the `extends`-aware variant, seeded with the `base_dir` `Spec` already records. `ConfigGraphLoader` grew an in-memory entry point and its `extends` walk is now shared with the path-based one rather than duplicated. The retained text is the ROOT document only. `Config::try_from` folds parents into the child, so retaining the merged text and writing it back would silently inline every inherited declaration into a file that had merely referenced them. A test asserts the parent's declaration does not appear in the child's text while `secrets()` still sees it. The `toml_edit` surgery moves out of `cli` into a `manifest_edit` module behind a new `manifest-edit` feature, which `cli` enables -- so nothing changes for existing users, but an embedder can take the editing surface without `clap` and `inquire`. `toml_edit` gains its `serde` feature so a whole `Secret` renders through the same representation the parser reads, keeping written and accepted keys from drifting as the schema grows; the value serializer refuses nested tables, which `ref`, `refs`, `extract`, `generate` and a presence group's `required` all produce, so declarations serialize via `to_document` and are flattened to an inline table. Requiredness is untouched: `add_secret_to_manifest` keeps its existing description-only signature, and everything richer goes through the `Secret` that `add_secret_to_text` already takes.
Opened as cachix#374 from branch spec-manifest-edit (b3637e2), built on a worktree off upstream/main rather than cherry-picked, so the PR carries only the spec/config/manifest_edit change plus a hand-written CHANGELOG entry against upstream's file. Tracked rather than left in scratch for the same reason pr362-comment.md is: a session that ends takes its scratch with it, and the exact submitted wording is what a follow-up round of review has to be read against. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dogfoods the API proposed upstream in cachix#374 on the fork's own provable-undo path: source-add and source-undeclare went through secretspec::manifest_edit directly, so nothing exercised the surface we are asking upstream to adopt. Each edit is now revalidated as a whole document, so a declaration that would not load is refused before it reaches the vault instead of surfacing at the next check. Spec::from_toml is the deliberate constructor: it never touches the filesystem and refuses project.extends, so this root process cannot be induced to read parent files while editing the manifest. Verified the live tracked template parses through it. Secret's three constructors turn out to express the tri-state the fork passed as Option<bool> exactly -- new leaves requiredness to the profile default, required/optional pin it -- so the flags survive unchanged. The undeclare template guard deliberately stays on manifest_edit. Spec::declares_secret_in_text answers bool and treats an unparseable document as "not declared", which is the fail-open answer a guard protecting names it might have failed to read must never give; and reaching it would need from_toml, which refuses extends, so an inheriting template would make undeclare refuse every name. Both edits are extracted from execute for the same reason install's guards were: that path only runs as root against a real vault. 7 tests now cover them, including the byte-exact add-then-undeclare round trip that Mutation::restore depends on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
I've put this API through a real consumer before review rather than after, so Context: the consumer is a privilege-separated broker that owns a What worked without adjustment
It's the constructor I want in a root process specifically because it never One call site I deliberately did not port A guard that asks whether a different document — a tracked template the
So that one stays on Happy to share the downstream diff if it's useful. |
Same convention as pr370-body.md and pr362-comment.md: a review round has to be read against the exact submitted wording. cachix#374 (comment) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #370.
Opening this against the shape you described on #357 — everything lands on
Spec,Configstays internal, and notoml_editorConfigappears in any public signature.What it does
Speccan read asecretspec.tomland can build a new one, but cannot write an existing one back out. So editing one declaration in a real file means either regenerating the whole document from the parsed model — losing every comment and reordering every profile, sinceConfig.profilesis aHashMap— or hand-rollingtoml_editoutside the library.Five methods, the surface proposed in the issue:
Spec::from_toml(s)?.preserved_text() == Some(s), and add-then-remove of the same key returns the original bytes.How it stays safe
The edits are
toml_editsurgery on the retained text;config/compiledare then re-derived by reparsing the result through the same validated path every otherSpecalready goes through. The semantic view is always derived from the text, never hand-mutated alongside it — one synchronization point instead of one per method, so the two cannot disagree, and an edit that would not validate fails at the edit rather than at some later load.Per the issue,
SpecBuilder's general edit surface is not made format-preserving.into_builder()/to_builder()stays a hard boundary: onlySpeccarries source text.The two wrinkles I flagged, and how they're handled
1.
extends.Spec::from_tomlrejects a non-emptyproject.extends, having nowhere to resolve paths from, so reparsing edited text through it would fail on every inheriting project.Config::from_text_inis theextends-aware variant, seeded with thebase_dirSpecalready records.ConfigGraphLoadergained an in-memory entry point, and itsextendswalk is now shared with the path-based one rather than duplicated.2. Root file only.
Config::try_fromfolds parents into the child, so retaining the merged text and writing it back would silently inline every inherited declaration into a file that had merely referenced them. A test asserts the parent's declaration does not appear in the child's text whilesecrets()still sees it.Module and feature
The
toml_editsurgery moves out ofcliinto amanifest_editmodule behind a newmanifest-editfeature, whichclienables — nothing changes for existing users, but an embedder can take the editing surface withoutclapandinquire.toml_editgains itsserdefeature so a wholeSecretrenders through the same representation the parser reads, keeping written and accepted keys from drifting as the schema grows. The value serializer refuses nested tables — whichref,refs,extract,generateand a presence group'srequiredall produce — so declarations serialize viato_documentand are flattened to an inline table.Cargo.lockgains onlyserde_coreandserde_spanned, both already in the tree viatoml; no new crates.Requiredness is untouched. As I said on the issue, #334 settled it —
add_secret_to_manifestkeeps its existing description-only signature, and anything richer goes through theSecretthatadd_secret_to_textalready takes.Two deviations from the issue's sketch, both deliberate
to_tomlreturnsResult<String>, notString. TOML serialization is fallible and I would rather surface that than unwrap inside the library.#[cfg(feature = "manifest-edit")], since they needtoml_edit.preserved_textandto_tomlare unconditional.Happy to change either, or the naming, if you'd prefer something else.
Tests
New tests cover the byte-exact round-trip; that comments, declaration order, and a full
[profiles.x.NAME]table all survive an edit; that the returnedSpecis revalidated rather than merely rewritten, and that the spec it derived from is untouched; that an invalid edit fails at the edit; that a declaration carryingproviders,as_pathand a nestedrefround-trips; duplicate-add and absent-remove errors;declares_secret_in_textparsed rather than substring-matched, so a name inside a comment or another secret's description does not fool it; and a builder-built spec reporting no text rather than inventing one.Four more cover inheritance against real files on disk: root-only retention, editing a spec that
extendsanother, an inherited declaration not being editable in the child, and the round-trip holding through inheritance.Full suite green on this branch. The 21
provider::sops::*failures in my environment are allThe 'sops' CLI is not installedand occur identically on an unmodifieddfa4b10.