Skip to content

fix: propagate credential store errors instead of erasing them into false/absent - #138

Merged
Brooooooklyn merged 2 commits into
Brooooooklyn:mainfrom
acoliver:fix/propagate-delete-and-secret-errors
Aug 24, 2026
Merged

fix: propagate credential store errors instead of erasing them into false/absent#138
Brooooooklyn merged 2 commits into
Brooooooklyn:mainfrom
acoliver:fix/propagate-delete-and-secret-errors

Conversation

@acoliver

@acoliver acoliver commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR preserves NoEntry as the benign absence case while allowing every other provider error to reach JavaScript:

  • getSecret() returns absence only for keyring_core::Error::NoEntry; other failures throw or reject.
  • deleteCredential() and deletePassword() return or resolve true after a successful delete and false only for NoEntry; other failures throw or reject.
  • Sync and async paths share the same result-mapping helpers.
  • The async deletePassword() declaration is corrected from Promise<unknown> to Promise<boolean>.

Fixes #137.

Why

The previous .ok() and .is_ok() conversions discarded provider errors at the N-API boundary. A locked or inaccessible credential store could look like a missing secret. A failed delete could look like a credential that was already absent, leaving callers unable to determine whether the secret remained stored.

keyring-core already distinguishes NoEntry from NoStorageAccess, Ambiguous, encoding failures, and other provider errors. This change preserves that distinction through the JavaScript API.

Behavior

Operation Provider result JavaScript result
getSecret() value value
getSecret() NoEntry absent result
getSecret() any other error throw or reject
delete methods Ok(()) true
delete methods NoEntry false
delete methods any other error throw or reject

The same mapping applies to Entry and AsyncEntry, including the deletePassword() aliases.

Relationship to #136

PR #136 is now merged and owns the getPassword() error-propagation behavior. This branch is rebased onto that work. The generic optional-result helper preserves the merged behavior while serving both password and secret reads. This PR does not duplicate #136's JavaScript failure tests.

macOS provider status

The current Cargo resolution selects apple-native-keyring-store 1.0.2 through the existing compatible manifest range. Version 1.0.2 changed macOS deletion to use the checked ItemSearchOptions::delete() path, so a refused delete reaches this binding as an error. No dependency declaration or lockfile change is included here.

See apple-native-keyring-store#22.

Compatibility

Existing success and absence results remain unchanged:

  • Existing secrets return their value.
  • Missing secrets return the existing absent result.
  • Successful deletes return true.
  • Deletes of missing credentials return false.

Provider failures that previously appeared as absence or false now throw or reject. The generated declarations retain the existing common return types. The async deletePassword() alias now accurately declares Promise<boolean> instead of Promise<unknown>.

Tests

Rust unit tests cover the mapping boundary for:

  • successful values and deletes
  • NoEntry
  • NoStorageAccess
  • Ambiguous
  • binary secret payloads

Native JavaScript integration tests use the real platform store and cover:

  • sync and async deleteCredential() returning true for an existing credential and false after it is absent
  • sync and async deletePassword() alias parity
  • sync and async missing-secret reads

No mocks are used.

Local verification on macOS arm64:

cargo fmt -- --check                              pass
cargo clippy --all-targets                        pass, no warnings
cargo test                                        pass, 9 tests
node .yarn/releases/yarn-4.18.0.cjs build        pass
node .yarn/releases/yarn-4.18.0.cjs lint         pass, no warnings or errors
node .yarn/releases/yarn-4.18.0.cjs test         pass, 14 tests
cargo tree -i apple-native-keyring-store --locked resolves 1.0.2
git diff --check upstream/main...HEAD             pass

Commit structure

The first commit adds a macOS-scoped linker setting needed for cargo test. N-API symbols are supplied by Node when the addon loads, but the standalone Rust test binary otherwise fails to link them. The release native build and JavaScript integration suite pass with this setting.

The second commit contains the result mapping, generated declaration, documentation, and behavioral tests.

@acoliver

acoliver commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

One observation from writing the tests, deliberately not changed in this PR because it is a separate decision.

The async accessors declare undefined but resolve null at runtime:

// index.d.ts
getPassword(signal?): Promise<string | undefined>   // AsyncEntry - resolves null
getPassword(): string | null                        // Entry - correct

Both return Option<T>, which napi maps to null, so the sync annotations are accurate and the async ts_return_type overrides are not. Under strict, t.is(await entry.getPassword(), null) is therefore a type error even though it is exactly what happens at runtime.

The test normalises with ?? null rather than asserting either spelling, so it stays correct whichever way you decide. Happy to align the async annotations to | null here or in a follow-up if you want them fixed.

Unrelated, but noticed while checking: tsc cannot currently run against the committed tsconfig.json at all. TypeScript 7 removed moduleResolution: "node", so it fails with TS5108 before reaching any source. Nothing in CI runs tsc, so it is invisible today.

@Brooooooklyn

Copy link
Copy Markdown
Owner

@acoliver hi thanks for your contributing, please rebase the latest branch

The crate is built as a cdylib whose napi_* symbols are supplied by the
host Node process at load time. A `cargo test` binary has no such host,
so linking fails with undefined napi_* symbols and unit tests cannot run
at all.

Deferring those symbols to load time lets the test harness link, without
affecting the cdylib itself (napi_build already passes the same flag for
the library target, and the release build is unchanged).
Both getSecret and deleteCredential collapsed every backend failure
through .ok()/.is_ok(), reusing the sentinel that also means
'absent'.  A locked or inaccessible store made a delete look like a
successful removal while the secret stayed readable, and a failed read
looked like a cache miss.

The two behavior halves now share one rule via a single helper pair:

- into_optional maps Ok to Some and NoEntry to None, and propagates
  every other read error, so a null result means the credential is
  absent.
- into_deleted maps Ok to true and NoEntry to false, and propagates
  every other delete error, so false always means 'there was nothing to
  delete'.

Public JS signatures are unchanged: getPassword stays (something) or null,
getSecret stays null instead of erasing failures, and deletePassword is a
deleteCredential alias with the same boolean.  Covered under cargo test.
The unit suite already carries upstream's password error-preservation tests.
@acoliver
acoliver force-pushed the fix/propagate-delete-and-secret-errors branch from 8b3a455 to 2775381 Compare August 21, 2026 08:16
@acoliver

Copy link
Copy Markdown
Contributor Author

Rebased onto current main as requested. The branch is now a direct two-commit descendant of a8709c1, and GitHub reports it as mergeable.

I also updated the PR to account for merged #136 and apple-native-keyring-store 1.0.2. Local verification passes: Rust formatting, clippy, 9 Rust tests, native release build, JavaScript lint, and 14 native integration tests.

The new CI run is currently action_required with no jobs because this is a cross-repository contribution. Please approve the workflow run so the platform matrix can execute: https://github.com/Brooooooklyn/keyring-node/actions/runs/32462459432

@Brooooooklyn

Copy link
Copy Markdown
Owner

@codex review

@Brooooooklyn
Brooooooklyn merged commit 5df8cf5 into Brooooooklyn:main Aug 24, 2026
20 checks passed
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.

deleteCredential erases native failures into false, silently leaving credentials in the store (getSecret likewise; #136 covers only getPassword)

2 participants