Skip to content

feat(provider): per-provider Cargo feature flags (RFC, KeePass as first migration)#502

Draft
pofallon wants to merge 10 commits into
jdx:mainfrom
pofallon:feat/per-provider-feature-flags
Draft

feat(provider): per-provider Cargo feature flags (RFC, KeePass as first migration)#502
pofallon wants to merge 10 commits into
jdx:mainfrom
pofallon:feat/per-provider-feature-flags

Conversation

@pofallon
Copy link
Copy Markdown

Summary

Introduces opt-in Cargo feature flags for individual providers in
fnox-core. Downstream embedders that don't ship every provider can
drop the unused ones to shrink their binary. Defaults are unchanged —
cargo build continues to compile every provider, so this is
backward-compatible for current users and packagers.

This PR lands the infrastructure plus KeePass as the first
migration
as a proof of pattern. If the design is acceptable, the
other heavy providers (Google Cloud, Azure, YubiKey/CTAP, D-Bus
keyring backend) are mechanical follow-ups using the same recipe;
I've sized that work at ~6–8h and would be happy to send the full set
as a follow-up PR.

Marking draft to invite design feedback before the rest of the
migration lands.

Motivation

Working on a downstream daemon (remo-broker) that embeds
fnox-core and only needs a couple of providers. The current binary
ends up at ~32 MiB largely because every provider compiles in
unconditionally, dragging in (per cargo bloat):

  • google-cloud-secretmanager-v1 (610 KiB), google-cloud-auth (396 KiB),
    tonic (132 KiB), google_cloud_gax* (~200 KiB)
  • openssl-sys (2.0 MiB, pulled by the Google + Azure paths via
    reqwest@0.12native-tls)
  • azure_core + azure_identity + typespec_client_core (~130 KiB),
    quick_xml (~100 KiB, mostly Azure)
  • keepass (235 KiB) + blake2b_simd
  • ctap-hid-fido2 (62 KiB) + the hidapi/libudev chain on Linux
  • dbus + libdbus_sys + dbus_secret_service* (~340 KiB) for the
    Linux keyring backend
  • jsonwebtoken + rsa + num_bigint_dig (~180 KiB)

Conservative attribution across the top-100 crates (per-crate
cargo tree -i walks confirming each is reachable only through
unused-provider chains) puts the savings at ~5.4 MiB of .text
section / ~19–22% of the stripped binary for the minimal AWS+age
profile — see remo-broker's binary-size analysis for the
full breakdown.

The same win is available to anyone who builds a slim CLI, packages
fnox for a constrained distro, or vendors fnox-core into a
different binary.

Design

Three pieces, one commit each, each commit's build a no-op except
where its predecessor's infrastructure is exercised:

1. Build-script plumbing (4700298)

New optional cargo_feature: Option<String> field in the provider
TOML descriptor schema. The codegen reads it and, for providers that
set it, wraps every piece of generated code that references them in
#[cfg(feature = \"<name>\")]: the ProviderConfig /
ResolvedProviderConfig enum variants, every match arm in the
methods / resolver / instantiator generators, the
use super::super::<module> statements, and the wizard entry.

ALL_WIZARD_INFO changes shape from &[WizardInfo] to
LazyLock<Vec<WizardInfo>> so the wizard entries can be
individually cfg-gated — #[cfg] attributes aren't permitted inside
an array literal, but they are on statements. Consumers using
ALL_WIZARD_INFO.iter() keep working unchanged thanks to LazyLock's
Deref<Target = Vec<...>>.

This commit alone is a build no-op: no provider sets cargo_feature
yet, so every emitted cfg-attr is the empty token stream.

2. Cargo features scaffolding (ad23715)

Adds [features] blocks to both crates/fnox-core/Cargo.toml and the
binary's Cargo.toml:

  • fnox-core: default = [\"all-providers\"] with all-providers
    expanding to the per-provider list.
  • fnox binary: mirror passthrough so binary code that references
    cfg-gated provider types (ProviderType::KeePass etc.) stays
    consistent. fnox-core is pulled with default-features = false
    so the feature selection actually flows; the binary's default
    feature is [\"all-providers\"], which chains through to
    [\"keepass\"] and from there to [\"fnox-core/keepass\"].

Per-provider features are declared but empty (no deps moved to
optional = true yet). This commit is also a no-op for the build
artifact — its purpose is to put the feature surface up for review
in isolation before the first migration.

3. KeePass migration (3435dae)

The proof of pattern. KeePass first because it's the simplest gated
provider (no cross-provider references, all kept inside fnox-core).
The recipe documented in Cargo.toml's [features] block:

  1. Set cargo_feature = \"keepass\" in keepass.toml.
  2. Mark the keepass dep optional = true and wire
    keepass = [\"dep:keepass\"] in [features].
  3. Cfg-gate pub mod keepass; in crates/fnox-core/src/providers/mod.rs
    plus the corresponding line in the explicit
    use super::super::{...} block.
  4. Binary side: cfg-gate ProviderType::KeePass (clap variant) and the
    ProviderType::KeePass => ProviderConfig::KeePass { ... } arm in
    src/commands/provider/add.rs.
  5. Drift test (provider_add_types_match_provider_definitions)
    updated to read the descriptor's cargo_feature and skip entries
    whose feature isn't enabled, so it stays meaningful across feature
    combos.

Verified:

  • cargo build and cargo build --no-default-features both succeed.
  • cargo test and cargo test --no-default-features both pass.
  • The slim build's compile log confirms the keepass crate is not built.

4. CI feature-combos matrix (fd9c44a)

New feature-combos job builds + tests with three flag sets in
parallel: --no-default-features, --no-default-features --features keepass,
--all-features. Catches cfg regressions in either direction. Added
to final's needs list. Add a matrix entry whenever a new
per-provider feature lands.

README gains a ## Cargo features section with the slim-build
recipe and a forward-looking note about what migrates next.

What's not in this PR (proposed follow-ups)

To keep this PR reviewable I deliberately stopped at one migrated
provider. If you're happy with the shape, the rest is mechanical:

  • googlegcp_auth, google-cloud-kms, google-cloud-secretmanager-v1
    (plus their transitive openssl-sys, tonic, reqwest@0.12 chain
    — the single biggest binary-size win)
  • azureazure_core, azure_identity, azure_security_keyvault_*
  • yubikeyctap-hid-fido2 (already target-gated for non-musl)
  • linux-keyringdbus, dbus-secret-service-keyring-store,
    the Linux-target openssl-sys

Open to either: I send each as a separate follow-up PR, or I send all
of them in a single sweep PR after this one merges. Your call.

Questions for review

  1. Naming. I picked keepass to match serde_rename; happy to
    use any convention you prefer (e.g., provider-keepass).
  2. default = [\"all-providers\"] preserves current behavior but
    means cargo build --no-default-features becomes the explicit
    slim path. Alternative: leave default = [] and require everyone
    (including the current build) to opt in via all-providers. I
    chose the backward-compatible shape; flag if you'd rather break
    compat in this change.
  3. LazyLock<Vec<WizardInfo>> — the cleanest workaround I found
    for cfg-gated array elements. Open to alternatives if you have one.
  4. Feature granularity — current shape is one feature per
    provider. Some providers naturally group (Azure has two,
    yubikey/fido2 share deps). Should I keep one-feature-per-provider
    or introduce group features (azure, google) up front?
  5. Binary feature mirror — every gated fnox-core feature has a
    matching feature in the fnox binary. Acceptable maintenance
    burden, or would you prefer a macro / build-script approach?

Test plan

  • cargo build (default) compiles
  • cargo build --no-default-features compiles
  • cargo test passes (8 passed)
  • cargo test --no-default-features passes (8 passed)
  • Slim build's compile log shows the keepass crate is absent
  • CI feature-combos matrix runs green (will validate when this is
    opened)
  • mise run ci (I don't have mise/hk set up locally in this
    environment; relying on upstream CI to catch any lint issues)

This PR was authored by Claude Code per the AGENTS.md instruction;
the design and motivation are based on downstream
remo-broker's binary-size analysis.

pofallon added 4 commits May 25, 2026 00:11
Adds an optional `cargo_feature: Option<String>` field to provider
descriptor TOMLs (`crates/fnox-core/providers/*.toml`). When set, the
build script emits `#[cfg(feature = "<name>")]` around every piece of
generated code that references that provider: the ProviderConfig +
ResolvedProviderConfig enum variants, every match arm in the methods
/ resolver / instantiator generators, the `use super::super::<module>`
statements, and the wizard entry.

To support cfg-gating individual wizard entries (Rust doesn't allow
`#[cfg]` attributes inside array literals), `ALL_WIZARD_INFO` becomes
a `LazyLock<Vec<WizardInfo>>` populated by cfg-gated `v.push(...)`
statements. Consumers calling `ALL_WIZARD_INFO.iter()` continue to
work unchanged via `LazyLock`'s `Deref` to `Vec<WizardInfo>`; lifetime
behavior is the same because the `LazyLock` itself is `static`.

This commit alone is a no-op for the build: no provider sets
`cargo_feature` yet, so every cfg attribute the codegen emits is the
empty token stream and the generated code is byte-equivalent. The
follow-up commits use this infrastructure to make individual
providers optional.
Adds a [features] block to both `crates/fnox-core/Cargo.toml` and the
top-level `fnox` binary crate's Cargo.toml. Per-provider features are
declared but empty for now (no deps are moved to optional yet) — this
commit's purpose is to put the feature surface up for review in
isolation before any provider migration changes behavior.

- fnox-core: default = ["all-providers"]; all-providers includes
  every per-provider feature.
- fnox binary: passes through to fnox-core, plus sets
  `default-features = false` on its `fnox-core` dep so the feature
  selection actually flows.

Behavior is unchanged today: every provider's dep is still
non-optional, so every feature flag is a no-op and the artifact
produced by `cargo build` and `cargo build --no-default-features` is
identical. The size savings land as each provider is migrated to
`optional = true` deps in follow-up commits.

The block is heavily commented with the three-step recipe for adding
a new provider feature, so future contributors don't have to
reverse-engineer the build-script + Cargo.toml + mod.rs interaction.
KeePass is the first provider migrated to the per-provider feature
flag infrastructure introduced in the previous two commits. With
default features the build is byte-equivalent to before (the keepass
crate is still compiled in). With `--no-default-features` it drops
out, taking the `keepass` dependency and its transitives (the kdbx
parser, blake2b_simd, etc.) with it.

Changes per the three-step recipe in fnox-core/Cargo.toml's [features]
block:

1. `crates/fnox-core/providers/keepass.toml` — set
   `cargo_feature = "keepass"` so the build script wraps every piece
   of generated provider-registration code in
   `#[cfg(feature = "keepass")]`.
2. `crates/fnox-core/Cargo.toml` — mark the `keepass` dep
   `optional = true` and wire `keepass = ["dep:keepass"]` under
   `[features]`.
3. `crates/fnox-core/src/providers/mod.rs` — gate the `pub mod keepass;`
   declaration and split the explicit `use super::super::{...}` list
   so `keepass` lives in its own cfg block (so adding the next gated
   provider doesn't reshape this list).

Binary-side mirror, gated by the binary's own `keepass` feature (which
itself enables `fnox-core/keepass`):

- `src/commands/provider/mod.rs`: cfg-gate the `ProviderType::KeePass`
  clap variant + its strum attribute.
- `src/commands/provider/add.rs`: cfg-gate the
  `ProviderType::KeePass => …` arm that builds a `ProviderConfig::KeePass`
  default scaffold. Both have to be gated together because the
  ProviderConfig variant itself is now gated.

Test update:

- `provider_add_types_match_provider_definitions` (the drift test that
  compares clap's `ProviderType` variants to the on-disk TOML
  descriptors) now reads the descriptor's `cargo_feature` field and
  skips entries whose feature isn't enabled in the current build.
  Without this, the test fails under `--no-default-features` because
  the TOML side still lists keepass but clap correctly doesn't.

Verified:
- `cargo build` and `cargo build --no-default-features` both succeed.
- `cargo test` and `cargo test --no-default-features` both pass.
- The slim build's compile log confirms `keepass` crate is not built.

(The pre-existing `providers::keychain::tests::test_keychain_set_and_get`
fails in environments without a Secret Service / D-Bus keyring — same
failure on `main`. Unrelated to this change.)
CI: new `feature-combos` job builds + tests with three Cargo flag
sets in parallel:
  - `--no-default-features` (no providers from the new feature set)
  - `--no-default-features --features keepass`
  - `--all-features`
Catches cfg-gating regressions in either direction. Skips the
keychain test that needs an OS keyring (already covered by ci-bats).
The new job is added to `final`'s needs list. Add a matrix entry
whenever a new per-provider feature lands.

README: new `## Cargo features` section between `Installation` and
`Development`. Documents how to opt out of providers, the
default-on convention, an example `default-features = false` snippet
for library embedders, and a forward-looking note that more providers
will be feature-gated in follow-up work (the binary-size win for
embedders is several MiB once google/azure/keepass/yubikey/dbus all
move).
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 per-provider Cargo features to enable slimmer binary builds by allowing users to opt out of specific providers, starting with KeePass. The changes include updating the build script to conditionally generate provider-related code, modifying fnox-core and the CLI to respect these feature flags, and adding documentation. Feedback includes a suggestion to maintain backward compatibility for the ALL_WIZARD_INFO public API by using Box::leak to keep it as a slice, and a recommendation to use toml_edit instead of manual string parsing for more robust testing of provider descriptors.

Comment on lines +963 to +967
pub static ALL_WIZARD_INFO: LazyLock<Vec<WizardInfo>> = LazyLock::new(|| {
let mut v: Vec<WizardInfo> = Vec::new();
#(#wizard_info_entries)*
v
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Changing ALL_WIZARD_INFO from a slice to LazyLock<Vec<WizardInfo>> is a breaking change for the public API of fnox-core. While LazyLock is necessary to support #[cfg] attributes on individual entries, you can maintain the original &'static [WizardInfo] type by using Box::leak on the initialized Vec. This preserves backward compatibility for consumers expecting a slice and better reflects the read-only nature of this static data.

Suggested change
pub static ALL_WIZARD_INFO: LazyLock<Vec<WizardInfo>> = LazyLock::new(|| {
let mut v: Vec<WizardInfo> = Vec::new();
#(#wizard_info_entries)*
v
});
pub static ALL_WIZARD_INFO: LazyLock<&'static [WizardInfo]> = LazyLock::new(|| {
let mut v: Vec<WizardInfo> = Vec::new();
#(#wizard_info_entries)*
Box::leak(v.into_boxed_slice())
});

Comment on lines +163 to +172
let cargo_feature = content
.lines()
.find_map(|line| {
let line = line.trim();
let rest = line.strip_prefix("cargo_feature")?;
let rest = rest.trim_start().strip_prefix('=')?.trim();
rest.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.map(str::to_owned)
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The manual string-based parsing of the provider TOML files is fragile and may fail if the file contains comments on the same line as cargo_feature or uses single quotes. Since toml_edit is already a dependency in the workspace and used in the fnox binary, it should be used here for more robust and idiomatic parsing.

        let cargo_feature = content.parse::<toml_edit::DocumentMut>().ok()
            .and_then(|doc| doc.get("cargo_feature").and_then(|v| v.as_str()).map(|s| s.to_owned()));

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 25, 2026

Greptile Summary

This PR introduces per-provider Cargo feature flags to fnox-core, starting with KeePass as the proof of pattern. The build script now reads an optional cargo_feature field from each provider's TOML descriptor and wraps all generated references (ProviderConfig variants, match arms, use imports, wizard entries) in #[cfg(feature = \"...\")]. Default behavior is preserved via default = [\"all-providers\"].

  • Build-script codegen (generate_providers.rs): new provider_cfg_attr helper threads feature gates through every generator; ALL_WIZARD_INFO moves to LazyLock<&'static [WizardInfo]> so cfg-guarded v.push(...) statements can be used in place of array-literal elements.
  • Cargo scaffolding (Cargo.toml, crates/fnox-core/Cargo.toml): binary and library each declare matching [features] blocks; the binary depends on fnox-core with default-features = false so the feature chain flows correctly end-to-end.
  • CI matrix (.github/workflows/ci.yml): new feature-combos job builds and tests three flag sets in parallel; included in final.needs; the binary's drift test (provider_add_types_match_provider_definitions) runs under each combination to catch cfg regressions.

Confidence Score: 5/5

Safe to merge — the change is additive and backward-compatible; cargo build with defaults compiles and tests identically to before.

All three layers (codegen, Cargo features, CI) are internally consistent. The binary's drift test explicitly accounts for feature-gated providers and runs across all three CI feature combos. No logic regressions were found across the changed paths.

No files require special attention.

Important Files Changed

Filename Overview
crates/fnox-core/build/generate_providers.rs Core codegen change: adds cargo_feature field to provider descriptors and wraps every generated reference (enum variants, match arms, use imports, wizard entries) in #[cfg(feature = "...")]. The ALL_WIZARD_INFO static changes from &'static [WizardInfo] to LazyLock<&'static [WizardInfo]> (a type-level breaking change for explicit bindings, acknowledged in the PR).
crates/fnox-core/Cargo.toml Adds [features] block with default = ["all-providers"], all-providers = ["keepass"], and keepass = ["dep:keepass"]; marks the keepass dep optional = true. Feature scaffolding is clean and backward-compatible.
Cargo.toml Binary-side feature mirror: adds matching [features] block and switches fnox-core dependency to default-features = false. Chain default to all-providers to keepass to fnox-core/keepass is correct.
.github/workflows/ci.yml Adds feature-combos matrix job with three slug-keyed combinations (no-default, no-default+keepass, all-features). Does not use --lib, so the binary's drift test runs in each combination. Added to final.needs.
src/commands/provider/mod.rs Adds provider_feature_enabled helper to the drift test, which reads each provider TOML and skips it when its cargo_feature is absent from the build. Adds Some("keepass") => cfg!(feature = "keepass") arm. The #[cfg(feature = "keepass")] attribute is correctly placed on the KeePass clap variant.
src/commands/provider/add.rs Adds #[cfg(feature = "keepass")] to the KeePass match arm in the provider-add command. Exhaustiveness is maintained since the variant is also conditionally compiled out.
crates/fnox-core/src/providers/mod.rs Cfg-gates pub mod keepass and the corresponding use super::super::keepass import in the instantiate module. Handled correctly with matching #[cfg] attributes.
crates/fnox-core/providers/keepass.toml Adds cargo_feature = "keepass" to the KeePass descriptor. Minimal, correct change.
AGENTS.md Documents the three-location sync requirement for gated providers, including the reason cfg!() requires a hand-written match arm. Useful reference for future contributors.
README.md Adds a Cargo features section with corrected examples and a features table. Content is accurate.

Reviews (3): Last reviewed commit: "ci(provider): drop --lib so the drift te..." | Re-trigger Greptile

Comment thread README.md Outdated
Comment thread .github/workflows/ci.yml Outdated
Comment thread src/commands/provider/mod.rs
pofallon added 5 commits May 25, 2026 01:00
The previous example claimed to "skip the KeePass provider" but
passed `--features all-providers`, which (per fnox-core's Cargo.toml)
expands to `["keepass"]` — so the command actually enabled KeePass
and was equivalent to the default build. Following the doc gave zero
binary-size benefit.

Also drops the parenthetical "`all-providers` minus any feature you
don't pass": Cargo doesn't support feature subtraction. The new wording
explains the actual model — `--no-default-features` turns everything
off, then each `--features <name>` opts a single provider back in —
and shows both the all-off and pick-one-back patterns.
Per PR review (gemini-code-assist): the original change to
`LazyLock<Vec<WizardInfo>>` was a larger API break than necessary
because it changed the inner element type from `[WizardInfo]` (slice)
to `Vec<WizardInfo>`. Downstream consumers that took `&[WizardInfo]`
arguments or stored `&'static [WizardInfo]` bindings would break.

Switch to `LazyLock<&'static [WizardInfo]>` with
`Box::leak(v.into_boxed_slice())` inside the initializer. The Vec is
allocated once at first access, leaked into a `'static` slice, and
the LazyLock thereafter hands out `&'static [WizardInfo]` —
restoring the original element type.

The only observable change vs the pre-feature-gate API is the outer
`LazyLock` wrapper; `ALL_WIZARD_INFO.iter()` works unchanged via
`Deref`, and bindings like `let x: &'static [WizardInfo] = …` need
one explicit `*` deref to compile. That's the minimum break consistent
with letting individual entries be cfg-gated.
Per PR review (gemini-code-assist): the previous string-based
`strip_prefix("cargo_feature")` / `strip_suffix('"')` parser was
fragile against inline comments, single-quoted strings, multi-line
values, and `cargo_feature` keys nested inside other tables. Replace
with the canonical `toml_edit::DocumentMut::parse()` + `.get(...)`
walk — `toml_edit` is already a workspace dep and the binary
already pulls it as a runtime dep, so no new dependency is added.

Behavior preserved: returns the parsed string if `cargo_feature` is
declared at the top level, returns `None` otherwise. The `match`
that maps the parsed name to `cfg!(feature = "…")` is unchanged.

Also expands the doc comment to explain why the match has to be
updated by hand for every new gated provider — `cfg!()` requires a
string literal so the test can't go fully generic the way the build
script can.
Per PR review (greptile-apps): the previous matrix interpolated raw
flag strings — including spaces — into the rust-cache `shared-key`.
GH Actions tolerates spaces in cache keys but some cache backends
behave oddly with them. Switch the matrix from a bare `flags` list
to `combo.{name, flags}` pairs, where `name` is a lowercase
dash-separated slug used in the cache key and the job name. `flags`
keeps the actual cargo arguments.

Adds a comment documenting the slug convention so future contributors
keep the pattern when extending the matrix.
Per PR review (greptile-apps): the drift test and the build script
both have to be updated when a new gated provider lands, but the
requirement isn't called out anywhere. Add cross-references so future
contributors (human or agent) catch it:

- `crates/fnox-core/build/generate_providers.rs`: the `cargo_feature`
  field's doc comment now points at the drift test and explains why
  `cfg!()`'s string-literal constraint means the test can't be
  generic the way the build script is.
- `AGENTS.md`: new "Cargo features" section documents the full
  three-place recipe (provider TOML, fnox-core Cargo.toml, binary
  side + drift test + CI matrix slug). Slotted before
  "GitHub Interactions" to keep that section's prominence.

No code changes; tests and build unaffected.
Comment thread .github/workflows/ci.yml Outdated
Per PR review (greptile-apps P1): the previous CI step ran
`cargo test --lib ...`, which restricts test execution to library
crates only. That excluded the binary crate's
`provider_add_types_match_provider_definitions` drift test — the
very test updated in this PR to skip feature-gated providers via
`provider_feature_enabled`. Without it running across feature combos,
the matrix only verified "things compile under each combo"; it didn't
catch the regression it was added to guard against (forgetting to
update the test's match arm when adding a new gated provider).

`--lib` was originally there to avoid the keychain integration test
that needs an OS keyring service. That's already handled by the
existing `--skip providers::keychain::tests::test_keychain_set_and_get`
filter, so `--lib` was redundant in addition to being harmful.

Removing `--lib` adds 8 more tests to each matrix entry (the binary's
unit + drift tests). All four combos verified locally:
  default          : 8 binary tests pass
  --no-default-features            : 8 binary tests pass
  --no-default-features --features keepass : 8 binary tests pass
  --all-features                   : 8 binary tests pass

Includes an inline comment in the workflow explaining why `--lib`
is deliberately absent so a future contributor doesn't put it back.
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