Skip to content

feat(config): add line selector for multiline secrets#446

Merged
jdx merged 13 commits intojdx:mainfrom
fgrosse:feat/secret-line-selector
Apr 26, 2026
Merged

feat(config): add line selector for multiline secrets#446
jdx merged 13 commits intojdx:mainfrom
fgrosse:feat/secret-line-selector

Conversation

@fgrosse
Copy link
Copy Markdown
Contributor

@fgrosse fgrosse commented Apr 26, 2026

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.

[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

  • 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
  • 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
  • mise run lint - cargo-fmt and cargo-clippy clean
  • 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 #426


🤖 Generated with Claude Code

fgrosse added 11 commits April 26, 2026 15:38
## 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
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/secret_resolver.rs Outdated
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 26, 2026

Greptile Summary

Adds a line field to SecretConfig that selects a single 1-indexed line from the resolved secret value, enabling the common pass convention of packing password + metadata into one entry. The implementation is clean: extract_line uses str::lines() for correct CRLF and trailing-newline handling, the mutual-exclusion guard with json_path fires at resolution time (consistent with the codebase's lazy-validation approach), and for_raw_resolve correctly strips line so sync/reencrypt cache the raw provider value.

Confidence Score: 5/5

This 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, for_raw_resolve correctly strips the new field, and test coverage is thorough (unit + integration, including zero rejection, CRLF, trailing-newline, and out-of-range).

No files require special attention.

Important Files Changed

Filename Overview
src/secret_resolver.rs Adds extract_line helper and wires it into apply_post_processing; well-guarded with mutual-exclusion check and comprehensive unit tests including zero rejection, CRLF, and trailing-newline edge cases.
src/config.rs Adds line: Option<usize> field to SecretConfig with schema constraint, strips it in for_raw_resolve, serializes it in to_inline_table, and tests round-trip parsing; all consistent with existing patterns.
docs/providers/password-store.md Adds "Selecting a Line" section with usage example and a warning callout about editing multi-line entries; cross-links to existing Multiline Secrets section correctly.
docs/public/schema.json Adds line property with minimum: 1, nullable integer type, and uint format annotation; matches the Rust #[schemars(range(min = 1))] declaration.
test/password_store.bats Two new integration tests: happy-path multi-line selection via pass insert -m and out-of-range error path; follows existing bats test conventions correctly.

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]
Loading

Reviews (3): Last reviewed commit: "Merge branch 'main' into feat/secret-lin..." | Re-trigger Greptile

Comment thread src/config.rs
fgrosse and others added 2 commits April 26, 2026 20:41
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.
@jdx jdx merged commit dee0480 into jdx:main Apr 26, 2026
14 checks passed
@fgrosse fgrosse deleted the feat/secret-line-selector branch April 26, 2026 19:33
fullerzz pushed a commit to fullerzz/fnox-py that referenced this pull request Apr 26, 2026
## 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>
NorthIsUp pushed a commit to NorthIsUp/fnox that referenced this pull request Apr 28, 2026
## 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
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.

2 participants