feat(config): add line selector for multiline secrets#446
Conversation
## Summary - adds `line: Option<usize>` to `SecretConfig`, mirroring the existing `json_path` post-processing field (serialization, `for_raw_resolve` stripping, inline-table round-trip) - 1-indexed line number; intended for providers whose entries pack multiple values into one secret (e.g. password on line 1, username on line 2) - mutually exclusive with `json_path` - post-processing wiring lives in a follow-up commit ## Test plan - [x] `mise run test:cargo` — new round-trip and raw-resolve unit tests pass alongside existing config tests 🤖 Generated with Claude Code
## Summary - adds `extract_line` helper: 1-indexed split on `\n` with explicit errors for `line = 0` and out-of-range indices - wires `line` into `apply_post_processing` alongside the existing `json_path` branch; the two are mutually exclusive (config error if both set) - intra-line whitespace is preserved so values like `username: alice` round-trip exactly - the secret value is otherwise returned unchanged when `line` is unset ## Test plan - [x] `mise run test:cargo` — 8 new unit tests cover line 1, line 2+, single-line values, whitespace preservation, `line = 0`, out-of-range, the unset-passthrough case, and the json_path/line conflict 🤖 Generated with Claude Code
## Summary - adds a "Selecting a Line" section to the password-store docs covering the new 1-indexed `line` config field on a secret - includes a worked example (password on line 1, username on line 2), a cross-link to the existing Multiline Secrets section, and the note that `line` is mutually exclusive with `json_path` and read-only via `fnox set` ## Test plan - [x] visual review of `docs/providers/password-store.md` ahead of the next docs render 🤖 Generated with Claude Code
## Summary - adds a bats test that creates a 3-line entry via `pass insert -m` and verifies `line = 1`, `line = 2`, and `line = 3` each return the matching line - adds a second bats test asserting `fnox get` fails with a clear "out of range" error when `line` exceeds the entry's line count ## Test plan - [x] `bats test/password_store.bats` — both new tests pass; pre-existing test jdx#4 ("fnox get fails with non-existent secret") was already failing on main due to an outdated error-string assertion, unrelated to this change 🤖 Generated with Claude Code
## Summary - reflows the multi-arg `assert!` in `test_extract_line_out_of_range` per `cargo fmt`; no behaviour change ## Test plan - [x] `mise run lint` — cargo-fmt and cargo-clippy clean - [x] `mise run test:cargo` — all 144 tests pass 🤖 Generated with Claude Code
## Summary
- switches `extract_line` from `split('\n')` to `str::lines()` so a
single trailing newline (common for provider output) is no longer
counted as an extra empty line, and `\r\n` line endings are stripped
- adds `test_extract_line_ignores_trailing_newline` covering
`"a\nb\nc\n"` — line 3 returns `"c"`, line 4 reports out-of-range
with `3 line(s)` rather than silently returning `""`
- adds `test_extract_line_handles_crlf` covering Windows-style line
endings to ensure no `\r` leaks into the returned line
## Test plan
- [x] `mise run test:cargo` — 146 tests pass (3 new line tests)
- [x] `mise run lint` — cargo-fmt and cargo-clippy clean
🤖 Generated with Claude Code
## Summary - adds `#[schemars(range(min = 1))]` to `SecretConfig::line`, mirroring the existing pattern on `McpConfig::exec_timeout_secs` - the runtime already rejects `line = 0` with a clear error; this propagates that constraint to schema-aware editors so `line = 0` shows up as a validation issue at edit time rather than only at `fnox get` time ## Test plan - [x] `cargo build` — compiles cleanly - [x] `mise run test:cargo` — all tests pass 🤖 Generated with Claude Code
## Summary - regenerates `docs/public/schema.json` so it reflects the new `line` property on `SecretConfig` along with its `minimum: 1` constraint - produced via `mise run render:schema` (no manual edits) ## Test plan - [x] `mise run render:schema` — runs clean, prettier formats the file - [x] diff is limited to the new `line` definition; no unrelated churn 🤖 Generated with Claude Code
## Summary - the previous comment said "Override the auto-generated entry" but the test does not override anything — it adds a separate `OUT_OF_RANGE` secret pointing at the same single-line pass entry - rewords the comment to describe what the test actually does ## Test plan - [x] `mise run test:bats -- test/password_store.bats` — both line tests still pass; comment-only change 🤖 Generated with Claude Code
## Summary - adds a warning callout under "Multiline Secrets" explaining that `fnox set` always replaces the full pass entry and cannot edit a single line in place; recommends `pass edit <entry>` or `pass insert -m -f` for line-level edits, with a cross-link to the `line` selector - tightens the closing caveat in "Selecting a Line" so it points at the new canonical note instead of duplicating it; the read-only nature of `line` is now stated once rather than twice ## Test plan - [x] visual review of `docs/providers/password-store.md` ahead of the next docs render — anchors `#multiline-secrets` and `#selecting-a-line` resolve cleanly 🤖 Generated with Claude Code
## Summary - the comment in `test_extract_line_preserves_intra_line_whitespace` referenced `username: alice` from an earlier draft of the test, but the actual value under test is `"pw\n spaced "` - rewrites the comment to describe what the assertion actually verifies (leading and trailing whitespace within a line is preserved); no behaviour change ## Test plan - [x] `mise run test:cargo` — all tests still pass 🤖 Generated with Claude Code
There was a problem hiding this comment.
Code Review
This pull request introduces a line selector for secrets, allowing users to extract specific lines from multi-line secret values, which is a common convention in tools like pass. The changes include updates to the documentation, JSON schema, and the SecretConfig structure, along with the implementation of the extraction logic in the secret resolver. Feedback was provided to optimize the extract_line function by using .nth() instead of collecting lines into a vector, which avoids unnecessary allocations and is more idiomatic in Rust.
Greptile SummaryAdds a Confidence Score: 5/5This PR is safe to merge — the feature is well-scoped, all edge cases are tested, and no existing behaviour is modified. No P0 or P1 findings. The logic is correct, the mutual-exclusion check is consistent with the codebase's lazy-validation pattern, No files require special attention. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Secret resolved from provider or default or env-var] --> B[apply_post_processing]
B --> C{json_path AND line both set?}
C -- yes --> D[Error: mutually exclusive]
C -- no --> E{json_path set?}
E -- yes --> F[extract_json_path]
E -- no --> G{line set?}
G -- yes --> H{line is zero?}
H -- yes --> I[Error: must be 1-indexed]
H -- no --> J[value.lines nth line minus 1]
J -- Some --> K[Return selected line]
J -- None --> L[Error: out of range]
G -- no --> M[Return value unchanged]
Reviews (3): Last reviewed commit: "Merge branch 'main' into feat/secret-lin..." | Re-trigger Greptile |
Avoids collecting all lines into a Vec<&str> on the happy path. The line count is computed lazily only when the requested line is out of range, where the cost doesn't matter. Suggested by Gemini in code review.
## Upstream release Bumps bundled fnox binary from 1.22.0 to 1.23.0. **Release**: https://github.com/jdx/fnox/releases/tag/v1.23.0 ## Release notes A small, focused release that adds a `line` selector for picking a single line out of a multiline secret — most useful for the `pass` convention of storing the password on line 1 and metadata (username, URL, etc.) on subsequent lines. ## Added **Line selector for multiline secrets** ([#446](jdx/fnox#446)) -- @fgrosse Secrets now accept a 1-indexed `line` field that returns just the Nth line of the resolved value. This matches the `pass show <entry> --clip=N` convention and lets you expose multiple fields from a single `pass` entry as separate secrets: ```toml [providers.pass] type = "password-store" prefix = "fnox/" [secrets] # `pass show fnox/database` returns: # <password> # <username> DB_PASSWORD = { provider = "pass", value = "database", line = 1 } DB_USERNAME = { provider = "pass", value = "database", line = 2 } ``` Details worth knowing: - `line` is mutually exclusive with `json_path`; using both on the same secret is an error. - Line splitting uses `str::lines()`, so `\r\n` endings are handled cleanly and a single trailing newline doesn't shift indices. - `line = 0` and out-of-range values produce clear error messages (e.g. `` `line = 5` is out of range; secret has 2 line(s) ``). - Post-processing applies uniformly to provider, default, and env-var sources. - `fnox sync` and `fnox reencrypt` cache the raw provider value, not the post-processed view, so the selector stays a read-only projection. - `fnox set` still overwrites the entire entry — `line` is read-only. Edit individual lines of an existing entry with `pass edit <entry>`. See the [password-store provider docs](https://fnox.jdx.dev/providers/password-store#selecting-a-line) for a full walkthrough. ## New Contributors * @fgrosse made their first contribution in [#446](jdx/fnox#446) **Full Changelog**: jdx/fnox@v1.22.0...v1.23.0 ## 💚 Sponsor fnox fnox is maintained by [@jdx](https://github.com/jdx) under [**en.dev**](https://en.dev) — a small independent studio building developer tooling like [mise](https://mise.jdx.dev/), [aube](https://aube.en.dev/), hk, and more. Keeping fnox secure, maintained, and free is funded by sponsors. If fnox is handling secrets or config for you or your team, please consider [sponsoring at en.dev](https://en.dev). Sponsorships are what let fnox stay independent and the project keep moving. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## Summary
Adds a `line` field on a secret that selects a single 1-indexed line
from the resolved value. Mostly useful for the `pass` convention of
packing the password on line 1 and metadata (username, URL, etc.) on the
lines below - the same pattern that `pass show <entry> --clip=N`
exposes.
```toml
[secrets]
DB_PASSWORD = { provider = "pass", value = "database", line = 1 }
DB_USERNAME = { provider = "pass", value = "database", line = 2 }
```
- new `Option<usize>` field on `SecretConfig`, mutually exclusive with
`json_path`, schema-constrained to `>= 1`
- post-processing applied uniformly to provider, default, and env-var
sources via the existing `apply_post_processing` pipeline
- `for_raw_resolve` strips `line` along with `json_path` so `fnox sync`
and `fnox reencrypt` cache the raw provider value, not the
post-processed view
- uses `str::lines()` so `\r\n` line endings are stripped and a single
trailing newline doesn't shift the indexing
- documented under `docs/providers/password-store.md` (cross-linked from
the existing Multiline Secrets section), since `pass` is the most
natural use case
## Test plan
- [x] `mise run test:cargo` - 146 tests pass, including new unit
coverage for happy path, single-line value, whitespace preservation,
zero rejection, out-of-range, trailing newline, CRLF, and
`apply_post_processing` mutual exclusion with `json_path`
- [x] `mise run test:bats -- test/password_store.bats` - two new bats
tests pass: multiline selection through `pass insert -m` and the
out-of-range error path
- [x] `mise run lint` - cargo-fmt and cargo-clippy clean
- [x] `mise run render:schema` - `docs/public/schema.json` reflects the
new field with `minimum: 1`
## Notes
`fnox set` still overwrites the entire pass entry - `line` is a
read-only view. Editing one line of an existing multi-line entry should
still be done via `pass edit <entry>`.
_Related to discussion https://github.com/jdx/fnox/discussions/426_
---
🤖 Generated with Claude Code
Summary
Adds a
linefield on a secret that selects a single 1-indexed line from the resolved value. Mostly useful for thepassconvention of packing the password on line 1 and metadata (username, URL, etc.) on the lines below - the same pattern thatpass show <entry> --clip=Nexposes.Option<usize>field onSecretConfig, mutually exclusive withjson_path, schema-constrained to>= 1apply_post_processingpipelinefor_raw_resolvestripslinealong withjson_pathsofnox syncandfnox reencryptcache the raw provider value, not thepost-processed view
str::lines()so\r\nline endings are stripped and a single trailing newline doesn't shift the indexingdocs/providers/password-store.md(cross-linked from the existing Multiline Secrets section), sincepassis the most natural use caseTest plan
mise run test:cargo- 146 tests pass, including new unit coverage for happy path, single-line value, whitespace preservation, zero rejection, out-of-range, trailing newline, CRLF, andapply_post_processingmutual exclusion withjson_pathmise run test:bats -- test/password_store.bats- two new bats tests pass: multiline selection throughpass insert -mand the out-of-range error pathmise run lint- cargo-fmt and cargo-clippy cleanmise run render:schema-docs/public/schema.jsonreflects the new field withminimum: 1Notes
fnox setstill overwrites the entire pass entry -lineis a read-only view. Editing one line of an existing multi-line entry should still be done viapass edit <entry>.Related to discussion #426
🤖 Generated with Claude Code