Skip to content

Accept safe's own escaped paths as input - #15

Merged
wayneeseguin merged 4 commits into
developfrom
fix-cli-escaped-paths
Jul 29, 2026
Merged

Accept safe's own escaped paths as input#15
wayneeseguin merged 4 commits into
developfrom
fix-cli-escaped-paths

Conversation

@wayneeseguin

Copy link
Copy Markdown
Contributor

safe prints paths in its own escaped syntax, so its output should be
usable as its own input. It is not:

$ safe paths secret
secret/od\:d/leaf

$ safe tree 'secret/od\:d'
!! no secret exists at path `secret/od\:d`

Cause

#10 and #12 established the vocabulary split — Read, Write, and
deleteEntireSecret take the escaped mini-language because they call
ParsePath; Versions, client.*, and ConstructSecrets take literal
Vault paths — and fixed the handoffs inside pkg/vault. The CLI layer
was never converted. Six call sites still pass raw argv to a consumer
that wants a literal path.

Measured, Vault 1.13.2 KV v2

Fixture is one secret at the literal Vault path secret/od:d, which the
mini-language writes as secret/od\:d, plus a secret/od sibling that
must not be swept in.

Command Before After
tree 'secret/od\:d' no secret exists, rc=1 walks it
paths 'secret/od\:d' no secret exists, rc=1 lists it
export 'secret/od\:d' no secret exists, rc=1 exports it
values -p 'secret/od\:d' v no secret exists, rc=1 searches it
versions 'secret/od\:d' no secret exists, rc=1 rc=0
undelete --all 'secret/od\:d' rc=0, restored nothing restores it
versions 'secret/foo:mykey' misleading no secret exists Specifying key to versions is not supported

undelete --all is the one to look at. It resolved the version list
correctly against the parsed path, then issued the undelete against the
original argument, so it reported success and exit 0 while every version
stayed deleted. Verified against a live server by reading the KV metadata
directly, with a colon-free control undeleting correctly in the same run:

COLON     after delete   : v1 deleted=true
COLON     after undelete : v1 deleted=true      <- before
CONTROL   after delete   : v1 deleted=true
CONTROL   after undelete : v1 deleted=false

Two things worth reviewer attention

Secrets.Draw wants a literal path, not an escaped one. It compares
its root against walked paths and applies EscapePathSegment only when
printing, so passing the already-escaped argument escaped it twice
(od\\:d). Today that is masked because the walk errors out first; it
would have become visible the moment the walk was fixed. Passing the
resolved root to both fixes them together.

A key or version in a walk root is now refused, not dropped. safe tree 'secret/od\:d:leaf' previously would have silently walked the whole
subtree, ignoring the key. It now errors, matching what DeleteTree does
for the same reason. export already refused both; versions refused a
version but not a key, and now refuses both.

Verification

  • make check green. Nine new internal/cli tests drive the real command
    handlers against the fake Vault, which stores paths verbatim. All of
    them fail with the fix neutered and pass with it — including a test
    that feeds safe paths output back into safe paths.
  • Integration suite, Vault 1.13.2: 1725 passing, 0 failing
    unchanged from develop.
  • Every case above re-run against a live Vault paired with a colon-free
    control, so the fix is specific and does not disturb ordinary paths.

Not covered by a test

versions and undelete --all need a KV v2 mount to reach their
success paths, and the CLI fake serves v1 only. Their guards are unit
tested; the success paths are covered by the live v2 run above. Extending
the fake to v2 is worth doing separately — it would also benefit
tree, paths, and get.

safe prints paths in its own escaped syntax, but the commands that walk
a subtree passed the argument straight to the walk, which talks to Vault
in literal paths. A root pasted back from safe's own output therefore
404'd on the backslash: `safe tree 'secret/od\:d'` reported no such
secret for a path safe had just printed.

Resolve the root first. Secrets.Draw wants the literal path too, since
it compares against walked paths and escapes only when printing, so it
now gets the same value and stops escaping an already-escaped root a
second time. A key or a version cannot scope a recursive walk, so
naming one is refused rather than quietly dropped.
export validated the argument through ParsePath but then walked the
original string, so exporting a root pasted back from safe's own output
failed on the literal backslash. Keep the parsed path, which is already
canonicalized, and drop the now-redundant Canonicalize call.
versions parsed its argument to reject a version, then handed the
unparsed string to the client, which takes literal Vault paths. An
escaped path therefore 404'd. A key was not rejected at all: it stayed
part of the path and came back as a misleading "no secret exists".

Pass the parsed path and refuse a key the way a version already was.
undelete --all looked the version list up under the parsed path, then
issued the undelete against the original argument. On an escaped path
that reached Vault with a literal backslash: the command reported
success and exit 0 while every version stayed deleted.

Issue the undelete against the same path the versions came from.
@wayneeseguin
wayneeseguin merged commit de55abe into develop Jul 29, 2026
@wayneeseguin
wayneeseguin deleted the fix-cli-escaped-paths branch July 29, 2026 01:31
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