Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -37,6 +37,7 @@ async-stream = "0.3"
async-trait = "0.1"
axum = { version = "0.8", default-features = true }
base16ct = { version = "1", features = ["alloc"] }
base64 = "0.22"
# Held at 8 while `async-compression` still resolves brotli 8: taking 9
# directly does not replace that copy, it adds a second complete
# allocator/decompressor stack to every adapter graph (~40 KB of extra
Expand Down
47 changes: 45 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,25 @@ 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
.bytes()
.next()
.is_some_and(|byte| byte.is_ascii_uppercase() || byte == b'_')
|| !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 Expand Up @@ -1525,6 +1542,32 @@ mod tests {
assert!(err.contains("`EDGEZERO__SECRETS__UPSTREAM_TOKEN`"), "{err}");
}

#[test]
fn validate_typed_secrets_checks_exact_native_environment_names() {
for name in ["A", "A1", "_TOKEN", "_"] {
let entries = [TypedSecretEntry::new("default", "owner", "obsolete/key")
.with_native_name(name.to_owned())];
AXUM_ADAPTER
.validate_typed_secrets(&entries)
.expect("portable native name");
}
for name in [
"",
"1TOKEN",
"lowercase",
"WITH-HYPHEN",
"WITH SPACE",
"CAF\u{c9}",
] {
let entries = [TypedSecretEntry::new("default", "owner", "obsolete/key")
.with_native_name(name.to_owned())];
let error = AXUM_ADAPTER
.validate_typed_secrets(&entries)
.expect_err("invalid native environment name");
assert_eq!(error, "invalid native environment name for `owner`");
}
}

#[test]
fn validate_typed_secrets_accepts_same_key_in_two_fields() {
let entries = [
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 @@ -29,6 +30,7 @@ edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = [
] }
async-stream = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true, optional = true }
brotli = { workspace = true }
bytes = { workspace = true }
ctor = { workspace = true, optional = true }
Expand Down
98 changes: 83 additions & 15 deletions crates/edgezero-adapter-fastly/src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
#![expect(
clippy::self_named_module_files,
reason = "Workspace also denies mod_module_files; follow Spin's cli.rs plus cli/ submodule convention instead of contradictory module-file lints"
)]
#![expect(
clippy::arbitrary_source_item_ordering,
reason = "the managed deployment planning and execution state machine is kept as one cohesive block"
)]

/// Narrow host operations for reviewed secret provisioning.
pub mod secrets;

use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::env;
Expand Down Expand Up @@ -30,7 +37,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 @@ -385,7 +393,7 @@ struct LinkReconciliation {
delete_link_ids: Vec<String>,
}

#[derive(Clone, Debug)]
#[derive(Clone, Debug, Default)]
struct StoreInventory {
by_name: BTreeMap<String, String>,
}
Expand Down Expand Up @@ -471,14 +479,14 @@ impl ResourceInventories {
})
}

fn resolve(&self, kind: ResourceKind, name: &str) -> Result<String, String> {
fn resolve(&self, kind: ResourceKind, logical_id: &str, name: &str) -> Result<String, String> {
self.for_kind(kind)
.by_name
.get(name)
.cloned()
.ok_or_else(|| {
format!(
"selected Fastly {} `{name}` does not exist in the complete provider inventory",
"selected Fastly {} `{name}` (logical `{logical_id}`) does not exist in the complete provider inventory",
kind.display_name()
)
})
Expand Down Expand Up @@ -637,7 +645,7 @@ fn desired_resource_links(
let selected_name = environment
.store_name_checked(kind.selector_kind(), logical_id)
.map_err(|error| format!("invalid Fastly deploy environment: {error}"))?;
let resource_id = inventories.resolve(kind, &selected_name)?;
let resource_id = inventories.resolve(kind, logical_id, &selected_name)?;
let desired = DesiredResourceLink {
kind,
alias: logical_id.clone(),
Expand Down Expand Up @@ -2013,8 +2021,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 +2031,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 +2734,24 @@ impl Adapter for FastlyCliAdapter {
// unlike spin's flat-namespace single-store model.
&[]
}

fn secret_names_scoped_per_store(&self) -> bool {
true
}

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 @@ -6771,7 +6797,7 @@ mod tests {
use edgezero_adapter::cli_support::read_package_name;
use edgezero_core::env_config::EnvConfig;
#[cfg(unix)]
use edgezero_core::test_env::{EnvOverride, PathPrepend};
use edgezero_core::test_env::{EnvOverride, PathPrepend, env_lock};
#[cfg(unix)]
use std::collections::BTreeMap;
use std::collections::HashSet;
Expand All @@ -6780,6 +6806,8 @@ mod tests {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
#[cfg(unix)]
use std::ptr;
#[cfg(unix)]
use std::sync::Mutex;
use tempfile::tempdir;

Expand All @@ -6797,6 +6825,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 Expand Up @@ -10310,9 +10369,18 @@ exit 1
/// test threads don't race on the environment variable.
#[cfg(unix)]
fn path_mutation_guard() -> &'static Mutex<()> {
use std::sync::OnceLock;
static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
GUARD.get_or_init(|| Mutex::new(()))
env_lock()
}

#[cfg(unix)]
#[test]
fn path_mutation_guard_uses_the_shared_environment_lock() {
assert!(ptr::eq(path_mutation_guard(), env_lock()));
}

#[test]
fn native_secret_names_are_scoped_per_physical_store() {
assert!(FastlyCliAdapter.secret_names_scoped_per_store());
}

#[cfg(unix)]
Expand Down
Loading
Loading