Skip to content
Open
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ base16ct = { version = "1", features = ["alloc"] }
# wasm in the demo Spin build). We only use `Decompressor` and
# `CompressorWriter`, unchanged across the two majors.
brotli = "8"
base64 = "0.22"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏ base64 is out of alphabetical order here (after brotli) and in crates/edgezero-adapter-fastly/Cargo.toml (listed before anyhow).

bytes = "1"
chrono = "0.4"
ctor = "1.0"
Expand Down
18 changes: 16 additions & 2 deletions crates/edgezero-adapter-axum/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,22 @@ impl Adapter for AxumCliAdapter {
fn validate_typed_secrets(&self, entries: &[TypedSecretEntry<'_>]) -> Result<(), String> {
let mut claimed: BTreeMap<String, &TypedSecretEntry<'_>> = BTreeMap::new();
for entry in entries {
let var = secret_env_var(entry.key_value)
.map_err(|err| format!("`#[secret]` field `{}`: {err}", entry.field_name))?;
let var = if let Some(native_name) = &entry.native_name {
if native_name.is_empty()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏ This accepts a leading digit (1TOKEN), which shells and most env-file loaders can't set, so the secret would silently be unreachable at runtime. Suggest also requiring the first byte to be A-Z or _. This check currently has no test either (see the coverage comment on config.rs:5708).

|| !native_name.bytes().all(|byte| {
byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'
})
{
return Err(format!(
"invalid native environment name for `{}`",
entry.field_name
));
}
native_name.clone()
} else {
secret_env_var(entry.key_value)
.map_err(|err| format!("`#[secret]` field `{}`: {err}", entry.field_name))?
};
if let Some(prev) = claimed.get(&var) {
if prev.key_value != entry.key_value {
return Err(format!(
Expand Down
2 changes: 2 additions & 0 deletions crates/edgezero-adapter-fastly/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ workspace = true
[features]
default = []
cli = [
"dep:base64",
"dep:edgezero-adapter",
"edgezero-adapter/cli",
"dep:ctor",
Expand All @@ -22,6 +23,7 @@ cli = [
fastly = ["dep:fastly", "dep:log-fastly"]

[dependencies]
base64 = { workspace = true, optional = true }
anyhow = { workspace = true }
edgezero-core = { path = "../edgezero-core" }
edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = [
Expand Down
64 changes: 57 additions & 7 deletions crates/edgezero-adapter-fastly/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
reason = "the managed deployment planning and execution state machine is kept as one cohesive block"
)]

/// Narrow host operations for reviewed secret provisioning.
#[path = "secret_operations.rs"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏ The secrets module loads secret_operations.rs via #[path]. Matching the module and file names (e.g. src/cli/secrets.rs, or renaming the module) would make it easier to find.

pub mod secrets;

use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::env;
Expand Down Expand Up @@ -30,7 +34,8 @@ use edgezero_adapter::cli_support::{
};
use edgezero_adapter::registry::{
Adapter, AdapterAction, AdapterDeployContext, AdapterPushContext, DeployOwnership,
DeployStoreIds, ProvisionStores, ReadConfigEntry, ResolvedStoreId, register_adapter,
DeployStoreIds, ProvisionStores, ReadConfigEntry, ResolvedStoreId, TypedSecretEntry,
register_adapter,
};
use edgezero_adapter::release::{VerifiedApplicationRelease, verify_application_release};
use edgezero_adapter::scaffold::{
Expand Down Expand Up @@ -2013,8 +2018,8 @@ fn owns_managed_deploy(context: &AdapterDeployContext) -> bool {
// spin requires them (variable-name regex, `[component.*]`
// discovery, flat-namespace collision). The trait surface is typed
// generically so any future adapter with similar constraints can
// override — but fastly has no equivalent platform requirements,
// so the no-op defaults are correct:
// override. Fastly inherits the first two defaults but validates explicit
// native secret names:
//
// - `validate_app_config_keys`: Fastly Config Store keys accept
// alphanumeric + `-` / `_` / `.` up to 256 chars. Any reasonable
Expand All @@ -2023,15 +2028,15 @@ fn owns_managed_deploy(context: &AdapterDeployContext) -> bool {
// `fastly compute validate` at validate-time. We keep
// `config validate` pure-Rust so it stays fast and
// tool-independent.
// - `validate_typed_secrets`: Fastly's KV / Config / Secret
// stores are independent namespaces — no spin-style flat-
// namespace collision risk to detect.
// - `validate_typed_secrets`: explicit native names must use Fastly's secret
// name alphabet. Legacy entries retain their existing behavior. The CLI
// checks collisions within each selected physical Secret Store.
//
// `single_store_kinds` IS overridden below — explicitly returns
// `&[]` for documentation, matching the inherited default.
#[expect(
clippy::missing_trait_methods,
reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`)."
reason = "see the explanatory block comment immediately above; Fastly inherits validate_app_config_keys and validate_adapter_manifest. validate_typed_secrets, read_config_entry, read_config_entry_local and single_store_kinds are overridden below."
)]
impl Adapter for FastlyCliAdapter {
fn deploy(&self, context: &AdapterDeployContext, args: &[String]) -> Result<(), String> {
Expand Down Expand Up @@ -2726,6 +2731,20 @@ impl Adapter for FastlyCliAdapter {
// unlike spin's flat-namespace single-store model.
&[]
}

fn validate_typed_secrets(&self, entries: &[TypedSecretEntry<'_>]) -> Result<(), String> {
for entry in entries {
if let Some(name) = entry.native_name.as_deref() {
secrets::validate_key(name).map_err(|_error| {
format!(
"invalid native Fastly secret name for `{}`",
entry.field_name
)
})?;
}
}
Ok(())
}
}

impl ManifestLock {
Expand Down Expand Up @@ -6797,6 +6816,37 @@ mod tests {
// `edgezero_core::test_env`; the merge with edition-2024 main replaced our
// local copy with it (its `set_var` calls are wrapped for 2024's unsafe-env).

#[test]
fn typed_secrets_validate_native_names_without_changing_legacy_mapping() {
let entries = [
TypedSecretEntry::new("default", "legacy", "legacy/key"),
TypedSecretEntry::new("default", "pull", "obsolete/key")
.with_native_name("Native-secret_123.name".to_owned()),
];
FASTLY_ADAPTER
.validate_typed_secrets(&entries)
.expect("native names use Fastly's alphabet, not the obsolete key");
}

#[test]
fn typed_secrets_reject_invalid_native_names_without_echoing_them() {
for name in [
"",
"invalid/name",
"invalid name",
"invalid\nname",
"invalid:name",
"invalid\u{540d}\u{5b57}",
] {
let entry = TypedSecretEntry::new("default", "subjects[0]", "ts1_fixture")
.with_native_name(name.to_owned());
let error = FASTLY_ADAPTER
.validate_typed_secrets(&[entry])
.expect_err("invalid native name");
assert_eq!(error, "invalid native Fastly secret name for `subjects[0]`");
}
}

// ── Fastly staging lifecycle helpers ──────────────────────────────

#[test]
Expand Down
Loading
Loading