Skip to content

Format-preserving single-declaration edits on Spec - #374

Open
djbclark wants to merge 1 commit into
cachix:mainfrom
frdminc:spec-manifest-edit
Open

Format-preserving single-declaration edits on Spec#374
djbclark wants to merge 1 commit into
cachix:mainfrom
frdminc:spec-manifest-edit

Conversation

@djbclark

Copy link
Copy Markdown
Contributor

Closes #370.

Opening this against the shape you described on #357 — everything lands on Spec, Config stays internal, and no toml_edit or Config appears in any public signature.

What it does

Spec can read a secretspec.toml and 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, since Config.profiles is a HashMap — or hand-rolling toml_edit outside the library.

Five methods, the surface proposed in the issue:

impl Spec {
    pub fn add_secret_to_text(&self, profile: &str, name: &str, secret: Secret) -> Result<Spec>;
    pub fn remove_secret_from_text(&self, profile: &str, name: &str) -> Result<Spec>;
    pub fn declares_secret_in_text(&self, profile: &str, name: &str) -> bool;
    pub fn preserved_text(&self) -> Option<&str>;
    pub fn to_toml(&self) -> Result<String>;
}

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_edit surgery on the retained text; config/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, 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: only Spec carries source text.

The two wrinkles I flagged, and how they're handled

1. extends. Spec::from_toml rejects a non-empty project.extends, having nowhere to resolve 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 gained an in-memory entry point, and its extends walk is now shared with the path-based one rather than duplicated.

2. Root file 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.

Module and feature

The toml_edit surgery moves out of cli into a manifest_edit module behind a new manifest-edit feature, which cli enables — 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. Cargo.lock gains only serde_core and serde_spanned, both already in the tree via toml; no new crates.

Requiredness is untouched. As I said on the issue, #334 settled it — add_secret_to_manifest keeps its existing description-only signature, and anything richer goes through the Secret that add_secret_to_text already takes.

Two deviations from the issue's sketch, both deliberate

  • to_toml returns Result<String>, not String. TOML serialization is fallible and I would rather surface that than unwrap inside the library.
  • The three editing methods are #[cfg(feature = "manifest-edit")], since they need toml_edit. preserved_text and to_toml are 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 returned Spec is 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 carrying providers, as_path and a nested ref round-trips; duplicate-add and absent-remove errors; declares_secret_in_text parsed 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 extends another, 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 all The 'sops' CLI is not installed and occur identically on an unmodified dfa4b10.

`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.
djbclark added a commit to frdminc/sudo-secretspec that referenced this pull request Aug 17, 2026
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>
djbclark added a commit to frdminc/sudo-secretspec that referenced this pull request Aug 17, 2026
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>
@djbclark

Copy link
Copy Markdown
Contributor Author

I've put this API through a real consumer before review rather than after, so
here's a dogfooding report from a downstream fork that maintains a
root-privileged manifest editor.

Context: the consumer is a privilege-separated broker that owns a
root-owned secretspec.toml in a protected vault. Its add/undeclare verbs
were calling the old manifest_edit helpers directly. I ported them to the
Spec surface this PR proposes.

What worked without adjustment

  • Secret::new / Secret::required / Secret::optional turn out to express
    exactly the tri-state I had been passing around as Option<bool> — leave
    requiredness to the profile default, or pin it either way. I had assumed
    adopting the Secret-shaped API would cost me that distinction, and it
    doesn't. Worth noting for anyone else weighing the same migration.
  • The byte-exactness holds under the test I care about most: add a declaration
    and remove it again, and the document is restored byte for byte. My undo path
    compares manifests as bytes, so a round trip that merely preserved meaning
    would be useless to me. This is now asserted downstream.
  • Revalidating the edited document as a whole is a real improvement for a
    privileged writer: a declaration that would not load is refused before
    anything reaches the vault, instead of surfacing at the next check.

Spec::from_toml is load-bearing for a privileged editor, somewhat by accident

It's the constructor I want in a root process specifically because it never
touches the filesystem and refuses project.extends — so an edit cannot be
induced into reading whatever a parent path points at. Right now that property
falls out of the extends refusal rather than being stated as a guarantee. It
might be worth saying so in the doc comment: "does not read the filesystem" is
the reason a caller in a privileged context picks this over
TryFrom<&Path>, and it's currently something you have to derive by reading
reparse.

One call site I deliberately did not port

A guard that asks whether a different document — a tracked template the
editor doesn't hold as a Spec — declares a given name, and which must fail
closed: an unparseable template is not evidence that a name is absent from it,
and the guard exists to protect exactly the names it might have failed to read.

Spec::declares_secret_in_text answers bool, so an unrepresentable document
reads as "not declared". In practice that branch is near-unreachable, since
constructing the Spec already parsed the source — this is not a bug report.
But the predicate is about the spec's own text, and my question is about
someone else's; routing it through a Spec would also mean full validation and
an extends refusal on a document that is allowed to inherit.

So that one stays on manifest_edit::declares_secret, which takes text and
returns Result. This works only because the PR promotes manifest_edit to a
public module behind its own feature. Flagging it in case that surface is ever
considered for narrowing: the Spec methods and the text functions serve
genuinely different callers, and at least one real consumer needs both.

Happy to share the downstream diff if it's useful.

djbclark added a commit to frdminc/sudo-secretspec that referenced this pull request Aug 17, 2026
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>
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.

Format-preserving single-declaration edits on Spec — following up on your note in #356/#357

1 participant