Skip to content

Commit 6fac526

Browse files
domenkozarclaude
andcommitted
refactor: dedupe provider bootstrap plumbing and CI release steps
Cleanups from a reuse/simplification/efficiency/altitude review of the branch, with no behavior change: - ProviderAlias.env is a plain map (empty means no bootstrap credentials), removing the Option normalization and the Some/None handling at every consumer - one registry scheme lookup (registration_for_scheme) shared by spec checks, bootstrap_vars, display names, and provider construction - resolve_bootstrap_overlay looks the alias up once and passes env down; sorted_bootstrap_entries is the single ordering rule for fetches, validation errors, and login prompts - store_bootstrap_credential audits through audit_write_result like every other write path - provider bootstrap variable names are consts shared between the registration and the read sites so the two cannot drift - both release workflows publish assets via scripts/upload-release-asset.sh; the PHP extension build/stage mapping lives only in secretspec-php/scripts/build-ext.sh (now honoring CARGO_TARGET_DIR), called by php-ext.yml and ci-sdks.sh Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c225743 commit 6fac526

14 files changed

Lines changed: 179 additions & 196 deletions

File tree

.github/workflows/ffi-build.yml

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,24 +83,14 @@ jobs:
8383
# On a version tag, attach the cdylib (named for its target, with a sha256
8484
# sidecar) to the GitHub Release so the PHP SDK's `secretspec-install-lib`
8585
# command can fetch the right one for a plain `composer require` install.
86-
# cargo-dist's release.yml creates the release for this same tag, so wait
87-
# for it to exist, then upload with --clobber (both workflows may retry).
8886
- name: Publish cdylib to the release
8987
if: startsWith(github.ref, 'refs/tags/v')
9088
shell: bash
9189
env:
9290
GH_TOKEN: ${{ github.token }}
9391
run: |
9492
set -euo pipefail
95-
tag="${GITHUB_REF_NAME}"
9693
ext="${{ matrix.artifact }}"; ext="${ext##*.}"
9794
asset="secretspec-ffi-${{ matrix.target }}.${ext}"
9895
cp "target/${{ matrix.target }}/release/${{ matrix.artifact }}" "$asset"
99-
# sha256 sidecar (BSD shasum on macOS, GNU sha256sum on Linux; both here).
100-
if command -v sha256sum >/dev/null; then sha256sum "$asset" > "$asset.sha256"
101-
else shasum -a 256 "$asset" > "$asset.sha256"; fi
102-
# Wait for cargo-dist to create the release (concurrent workflow).
103-
for _ in $(seq 1 30); do
104-
gh release view "$tag" >/dev/null 2>&1 && break || sleep 20
105-
done
106-
gh release upload "$tag" "$asset" "$asset.sha256" --clobber
96+
bash scripts/upload-release-asset.sh "${GITHUB_REF_NAME}" "$asset"

.github/workflows/php-ext.yml

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -71,23 +71,17 @@ jobs:
7171
- name: Install Rust (pinned by rust-toolchain.toml)
7272
run: rustup toolchain install
7373

74-
- name: Build the extension
75-
# Each runner is the target's native arch, so a plain release build emits
76-
# the target's binary (no cross-compilation).
77-
run: cargo build --release -p secretspec-php-native
78-
79-
- name: Stage the extension under its distribution name
74+
- name: Build and stage the extension under its distribution name
8075
id: stage
8176
shell: bash
77+
# Each runner is the target's native arch, so a plain release build emits
78+
# the target's binary (no cross-compilation). build-ext.sh owns the
79+
# platform-to-library-name mapping and stages lib/secretspec.so.
8280
run: |
8381
set -euo pipefail
84-
case "$(uname -s)" in
85-
Darwin) built="libsecretspec_php_native.dylib" ;;
86-
*) built="libsecretspec_php_native.so" ;;
87-
esac
88-
# dlopen resolves by path, not suffix, so the macOS .dylib ships as .so.
82+
bash secretspec-php/scripts/build-ext.sh
8983
asset="secretspec-php-native-${{ matrix.php }}-nts-${{ matrix.target }}.so"
90-
cp "target/release/$built" "$asset"
84+
cp secretspec-php/lib/secretspec.so "$asset"
9185
echo "asset=$asset" >> "$GITHUB_OUTPUT"
9286
9387
- name: Smoke test (load the extension, check it registers)
@@ -110,13 +104,4 @@ jobs:
110104
shell: bash
111105
env:
112106
GH_TOKEN: ${{ github.token }}
113-
run: |
114-
set -euo pipefail
115-
asset="${{ steps.stage.outputs.asset }}"
116-
if command -v sha256sum >/dev/null; then sha256sum "$asset" > "$asset.sha256"
117-
else shasum -a 256 "$asset" > "$asset.sha256"; fi
118-
# cargo-dist's release.yml creates the release for this tag; wait for it.
119-
for _ in $(seq 1 30); do
120-
gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1 && break || sleep 20
121-
done
122-
gh release upload "${GITHUB_REF_NAME}" "$asset" "$asset.sha256" --clobber
107+
run: bash scripts/upload-release-asset.sh "${GITHUB_REF_NAME}" "${{ steps.stage.outputs.asset }}"

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6767
instead" message — the same hard error any other invalid primary gets —
6868
instead of warning and falling through to the rest of the chain. As a
6969
fallback entry it is still skipped with a warning, like any broken link.
70+
- Rust SDK: `ProviderAlias::env` is a plain map whose empty state means "no
71+
bootstrap credentials", rather than an `Option`, so the two ways of spelling
72+
an alias without credentials cannot diverge.
7073

7174
### Fixed
7275
- Provider fallback chains (`providers = [...]`) are now tried strictly in

scripts/ci-sdks.sh

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,8 @@ echo "==> PHP (ext-ffi fallback, dlopens the cdylib via SECRETSPEC_FFI_LIB)"
9898
echo "==> PHP (secretspec-php-native extension, ext-php-rs)"
9999
# Build the extension in debug and load it directly; when it is present the SDK
100100
# prefers it over ext-ffi. This also proves the extension registers its functions.
101-
cargo build -p secretspec-php-native
102-
case "$(uname -s)" in
103-
Darwin) php_ext="$target_dir/debug/libsecretspec_php_native.dylib" ;;
104-
*) php_ext="$target_dir/debug/libsecretspec_php_native.so" ;;
105-
esac
106-
( cd secretspec-php && php -d extension="$php_ext" ./vendor/bin/phpunit )
101+
CARGO_TARGET_DIR="$target_dir" SECRETSPEC_PHP_PROFILE=debug \
102+
bash secretspec-php/scripts/build-ext.sh
103+
( cd secretspec-php && php -d extension="$PWD/lib/secretspec.so" ./vendor/bin/phpunit )
107104

108105
echo "==> All SDK suites passed"

scripts/upload-release-asset.sh

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Attach release assets (each with a sha256 sidecar) to the GitHub Release for
4+
# a tag:
5+
#
6+
# upload-release-asset.sh <tag> <asset> [asset...]
7+
#
8+
# cargo-dist's release.yml creates the release for the same tag concurrently,
9+
# so wait for it to exist, then upload with --clobber (either workflow may
10+
# retry). Needs GH_TOKEN with contents: write.
11+
set -euo pipefail
12+
13+
tag="$1"
14+
shift
15+
16+
files=()
17+
for asset in "$@"; do
18+
# sha256 sidecar (BSD shasum on macOS, GNU sha256sum on Linux; both here).
19+
if command -v sha256sum >/dev/null; then sha256sum "$asset" > "$asset.sha256"
20+
else shasum -a 256 "$asset" > "$asset.sha256"; fi
21+
files+=("$asset" "$asset.sha256")
22+
done
23+
24+
# Wait for cargo-dist to create the release (concurrent workflow).
25+
for _ in $(seq 1 30); do
26+
gh release view "$tag" >/dev/null 2>&1 && break || sleep 20
27+
done
28+
gh release upload "$tag" "${files[@]}" --clobber

secretspec-php/scripts/build-ext.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ set -euo pipefail
1212
pkg_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
1313
repo_root="$(cd "$pkg_dir/.." && pwd)"
1414
profile="${SECRETSPEC_PHP_PROFILE:-release}"
15+
target_dir="${CARGO_TARGET_DIR:-$repo_root/target}"
1516

1617
case "$(uname -s)" in
1718
Darwin) built="libsecretspec_php_native.dylib"; staged="secretspec.so" ;;
@@ -26,5 +27,5 @@ build_flag=()
2627
mkdir -p "$pkg_dir/lib"
2728
# dlopen (which PHP uses to load extensions) resolves by path, not by suffix, so
2829
# staging the macOS .dylib under a .so name loads fine.
29-
cp -f "$repo_root/target/$profile/$built" "$pkg_dir/lib/$staged"
30+
cp -f "$target_dir/$profile/$built" "$pkg_dir/lib/$staged"
3031
echo "built $pkg_dir/lib/$staged"

secretspec/src/cli/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ pub fn main() -> Result<()> {
528528
// as a bare-string alias.
529529
let alias_value = crate::config::ProviderAlias {
530530
uri: uri.clone(),
531-
env: (!bootstrap.is_empty()).then_some(bootstrap),
531+
env: bootstrap,
532532
};
533533

534534
// Load or create config

secretspec/src/config.rs

Lines changed: 24 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -172,16 +172,17 @@ pub struct ProviderAlias {
172172
/// The provider URI (e.g. `keyring://`, `bws://project-uuid`).
173173
pub uri: String,
174174
/// Bootstrap credentials: environment variable name to the [`BootstrapSource`]
175-
/// that supplies it. `None` for a bare string alias.
176-
pub env: Option<HashMap<String, BootstrapSource>>,
175+
/// that supplies it. Empty for a bare string alias, so "declares no
176+
/// bootstrap credentials" has exactly one representation.
177+
pub env: HashMap<String, BootstrapSource>,
177178
}
178179

179180
impl ProviderAlias {
180181
/// A bare alias carrying only a URI and no bootstrap credentials.
181182
pub fn from_uri(uri: impl Into<String>) -> Self {
182183
Self {
183184
uri: uri.into(),
184-
env: None,
185+
env: HashMap::new(),
185186
}
186187
}
187188
}
@@ -201,10 +202,8 @@ impl From<&str> for ProviderAlias {
201202
impl std::fmt::Display for ProviderAlias {
202203
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203204
write!(f, "{}", self.uri)?;
204-
if let Some(env) = &self.env
205-
&& !env.is_empty()
206-
{
207-
let mut vars: Vec<&str> = env.keys().map(String::as_str).collect();
205+
if !self.env.is_empty() {
206+
let mut vars: Vec<&str> = self.env.keys().map(String::as_str).collect();
208207
vars.sort();
209208
write!(f, " (bootstrap: {})", vars.join(", "))?;
210209
}
@@ -214,17 +213,16 @@ impl std::fmt::Display for ProviderAlias {
214213

215214
impl Serialize for ProviderAlias {
216215
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
217-
match &self.env {
216+
if self.env.is_empty() {
218217
// A bare alias serializes back to the plain-string form, so an alias
219218
// that was written as a string round-trips unchanged.
220-
None => serializer.serialize_str(&self.uri),
221-
Some(env) => {
222-
use serde::ser::SerializeStruct;
223-
let mut table = serializer.serialize_struct("ProviderAlias", 2)?;
224-
table.serialize_field("uri", &self.uri)?;
225-
table.serialize_field("env", env)?;
226-
table.end()
227-
}
219+
serializer.serialize_str(&self.uri)
220+
} else {
221+
use serde::ser::SerializeStruct;
222+
let mut table = serializer.serialize_struct("ProviderAlias", 2)?;
223+
table.serialize_field("uri", &self.uri)?;
224+
table.serialize_field("env", &self.env)?;
225+
table.end()
228226
}
229227
}
230228
}
@@ -261,12 +259,7 @@ impl<'de> Deserialize<'de> for ProviderAlias {
261259
let table = Table::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
262260
Ok(ProviderAlias {
263261
uri: table.uri,
264-
// Normalize an explicit empty table (`env = {}`) to `None`:
265-
// "declares no bootstrap credentials" then has a single
266-
// representation, so consumers checking `env.is_some()`
267-
// (e.g. the one-hop rule) cannot disagree with consumers
268-
// iterating the entries.
269-
env: table.env.filter(|env| !env.is_empty()),
262+
env: table.env.unwrap_or_default(),
270263
})
271264
}
272265
}
@@ -2092,7 +2085,7 @@ mod provider_alias_tests {
20922085
fn bare_string_parses_as_uri_without_env() {
20932086
let map = parse(r#"keyring = "keyring://""#);
20942087
assert_eq!(map["keyring"], ProviderAlias::from("keyring://"));
2095-
assert_eq!(map["keyring"].env, None);
2088+
assert!(map["keyring"].env.is_empty());
20962089
}
20972090

20982091
#[test]
@@ -2102,8 +2095,7 @@ mod provider_alias_tests {
21022095
assert_eq!(alias.uri, "bws://proj");
21032096
let source = alias
21042097
.env
2105-
.as_ref()
2106-
.and_then(|env| env.get("BWS_ACCESS_TOKEN"))
2098+
.get("BWS_ACCESS_TOKEN")
21072099
.expect("env carries the variable");
21082100
assert_eq!(source, &BootstrapSource::from("keyring"));
21092101
}
@@ -2113,7 +2105,7 @@ mod provider_alias_tests {
21132105
let map = parse(
21142106
r#"vault = { uri = "vault://kv", env = { VAULT_ROLE_ID = { provider = "onepassword", ref = { vault = "Infra", item = "approle", field = "role_id" } } } }"#,
21152107
);
2116-
let source = map["vault"].env.as_ref().unwrap()["VAULT_ROLE_ID"].clone();
2108+
let source = map["vault"].env["VAULT_ROLE_ID"].clone();
21172109
assert_eq!(source.provider, "onepassword");
21182110
let reference = source.reference.expect("ref present");
21192111
assert_eq!(reference.vault.as_deref(), Some("Infra"));
@@ -2135,10 +2127,7 @@ mod provider_alias_tests {
21352127
for source in [bare, with_ref] {
21362128
let alias = ProviderAlias {
21372129
uri: "vault://kv".to_string(),
2138-
env: Some(HashMap::from([(
2139-
"VAULT_ROLE_ID".to_string(),
2140-
source.clone(),
2141-
)])),
2130+
env: HashMap::from([("VAULT_ROLE_ID".to_string(), source.clone())]),
21422131
};
21432132
let map = HashMap::from([("vault".to_string(), alias.clone())]);
21442133
let serialized = toml::to_string(&map).unwrap();
@@ -2154,12 +2143,10 @@ mod provider_alias_tests {
21542143

21552144
#[test]
21562145
fn empty_env_table_is_equivalent_to_no_env() {
2157-
// `env = {}` declares nothing, so it must normalize to `None`: the
2158-
// one-hop rule checks `env.is_some()` and would otherwise reject the
2159-
// alias as a bootstrap source while login reports nothing to store.
2146+
// `env = {}` declares nothing: the alias equals its bare-string form
2147+
// and serializes back to it.
21602148
let map = parse(r#"keyring = { uri = "keyring://", env = {} }"#);
21612149
assert_eq!(map["keyring"], ProviderAlias::from("keyring://"));
2162-
assert_eq!(map["keyring"].env, None);
21632150
}
21642151

21652152
#[test]
@@ -2188,10 +2175,10 @@ mod provider_alias_tests {
21882175
fn alias_with_env_round_trips_through_toml() {
21892176
let alias = ProviderAlias {
21902177
uri: "bws://proj".to_string(),
2191-
env: Some(HashMap::from([(
2178+
env: HashMap::from([(
21922179
"BWS_ACCESS_TOKEN".to_string(),
21932180
BootstrapSource::from("keyring"),
2194-
)])),
2181+
)]),
21952182
};
21962183
let map = HashMap::from([("bws".to_string(), alias.clone())]);
21972184
let serialized = toml::to_string(&map).unwrap();
@@ -2218,12 +2205,6 @@ API_KEY = { description = "key", required = true }
22182205
let providers = config.providers.expect("[providers] present");
22192206
assert_eq!(providers["keyring"], ProviderAlias::from("keyring://"));
22202207
assert_eq!(providers["bws"].uri, "bws://proj");
2221-
assert!(
2222-
providers["bws"]
2223-
.env
2224-
.as_ref()
2225-
.unwrap()
2226-
.contains_key("BWS_ACCESS_TOKEN")
2227-
);
2208+
assert!(providers["bws"].env.contains_key("BWS_ACCESS_TOKEN"));
22282209
}
22292210
}

secretspec/src/provider/bws.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,19 @@ pub struct BwsProvider {
114114
bootstrap_env: BootstrapEnv,
115115
}
116116

117+
/// The credential variable this provider reads through the bootstrap overlay.
118+
/// Shared between the registration's `bootstrap_vars` and the read site, so
119+
/// the declared list cannot drift from what the provider actually consults.
120+
const BWS_ACCESS_TOKEN: &str = "BWS_ACCESS_TOKEN";
121+
117122
crate::register_provider! {
118123
struct: BwsProvider,
119124
config: BwsConfig,
120125
name: "bws",
121126
description: "Bitwarden Secrets Manager",
122127
schemes: ["bws"],
123128
examples: ["bws://a9230ec4-5507-4870-b8b5-b3f500587e4c"],
124-
bootstrap_vars: ["BWS_ACCESS_TOKEN"],
129+
bootstrap_vars: [BWS_ACCESS_TOKEN],
125130
}
126131

127132
impl BwsProvider {
@@ -137,7 +142,7 @@ impl BwsProvider {
137142

138143
/// Resolves the BWS access token from the environment or bootstrap overlay.
139144
fn access_token(&self) -> Option<String> {
140-
env_or_overlay_var(&self.bootstrap_env, "BWS_ACCESS_TOKEN")
145+
env_or_overlay_var(&self.bootstrap_env, BWS_ACCESS_TOKEN)
141146
}
142147

143148
/// Strips the BWS access token from error messages to avoid leaking credentials.

secretspec/src/provider/mod.rs

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,15 @@ fn split_spec(spec: &str) -> (&str, &str) {
412412
}
413413
}
414414

415+
/// The registry entry whose schemes contain `scheme`. The one definition of
416+
/// "which registration a scheme resolves to", shared by every lookup below and
417+
/// by [`provider_from_url`], so they cannot drift on the matching rule.
418+
fn registration_for_scheme(scheme: &str) -> Option<&'static ProviderRegistration> {
419+
PROVIDER_REGISTRY
420+
.iter()
421+
.find(|reg| reg.schemes.contains(&scheme))
422+
}
423+
415424
/// Whether `spec` names a registered provider: a bare name (`keyring`), a
416425
/// `scheme:path` shorthand (`dotenv:.env.production`), or a full URI. Checks
417426
/// the leading scheme token against the registry without constructing a
@@ -430,9 +439,7 @@ pub(crate) fn spec_names_known_provider(spec: &str) -> Result<bool> {
430439
.to_string(),
431440
));
432441
}
433-
Ok(PROVIDER_REGISTRY
434-
.iter()
435-
.any(|reg| reg.schemes.contains(&scheme)))
442+
Ok(registration_for_scheme(scheme).is_some())
436443
}
437444

438445
/// The bootstrap variables the provider named by `spec` reads through the
@@ -441,11 +448,7 @@ pub(crate) fn spec_names_known_provider(spec: &str) -> Result<bool> {
441448
/// provider would silently ignore.
442449
pub(crate) fn bootstrap_vars_for_spec(spec: &str) -> &'static [&'static str] {
443450
let (scheme, _) = split_spec(spec);
444-
PROVIDER_REGISTRY
445-
.iter()
446-
.find(|reg| reg.schemes.contains(&scheme))
447-
.map(|reg| reg.bootstrap_vars)
448-
.unwrap_or(&[])
451+
registration_for_scheme(scheme).map_or(&[], |reg| reg.bootstrap_vars)
449452
}
450453

451454
/// The registered display name for the provider `spec` names, falling back to
@@ -454,9 +457,7 @@ pub(crate) fn bootstrap_vars_for_spec(spec: &str) -> &'static [&'static str] {
454457
/// bootstrap credentials, so a display-only build could fail or do I/O).
455458
pub(crate) fn provider_display_name_for_spec(spec: &str) -> String {
456459
let (scheme, _) = split_spec(spec);
457-
PROVIDER_REGISTRY
458-
.iter()
459-
.find(|reg| reg.schemes.contains(&scheme))
460+
registration_for_scheme(scheme)
460461
.map(|reg| reg.info.name.to_string())
461462
.unwrap_or_else(|| scheme.to_string())
462463
}
@@ -1103,10 +1104,7 @@ pub(crate) fn provider_from_url(
11031104
) -> Result<Box<dyn Provider>> {
11041105
let scheme = url.scheme();
11051106

1106-
// Find the provider registration for this scheme
1107-
let registration = PROVIDER_REGISTRY
1108-
.iter()
1109-
.find(|reg| reg.schemes.contains(&scheme))
1107+
let registration = registration_for_scheme(scheme)
11101108
.ok_or_else(|| SecretSpecError::ProviderNotFound(scheme.to_string()))?;
11111109

11121110
let pwp = (registration.factory)(url, bootstrap)?;

0 commit comments

Comments
 (0)