From 68f20c4e1497a9ea8cd044db340a0b16947a61e7 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 30 Sep 2026 11:28:05 -0700 Subject: [PATCH 1/5] feat!: cache credentials per proxy URL so staging and prod coexist The cache key was the role ARN alone, and both environments default to `_default`, so a staging login overwrote the prod entry (and `creds` would silently serve/refresh staging credentials for a prod profile). Key entries by `/` and let `creds` take `--proxy-url` / `SOURCE_PROXY_URL` to select the environment. BREAKING CHANGE: existing cached logins are no longer found; run `source-coop login` once after upgrading. Co-Authored-By: Claude Opus 5.5 --- README.md | 27 ++++++++++++++++++--- src/cache.rs | 67 ++++++++++++++++++++++++++++++++++------------------ src/main.rs | 17 ++++++++----- 3 files changed, 79 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 2d0143c..a748698 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,27 @@ credential_process = source-coop creds --role-arn admin-role endpoint_url = https://data.source.coop ``` +### Multiple environments (e.g. staging) + +Credentials are cached per proxy URL, so production and staging logins live side by side: + +```bash +source-coop login +( source .env.staging && source-coop login ) +``` + +`creds` reads `SOURCE_PROXY_URL` (or `--proxy-url`) to pick the environment: + +```ini +[profile source-coop] +credential_process = source-coop creds +endpoint_url = https://data.source.coop + +[profile source-coop-staging] +credential_process = source-coop creds --proxy-url https://data.staging.source.coop +endpoint_url = https://data.staging.source.coop +``` + ### Login options | Flag | Env var | Default | Description | @@ -175,7 +196,7 @@ The CLI caches temporary STS credentials so that `creds` can output them without ### OS keyring (default) -Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by role ARN: +Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by proxy host and role ARN (e.g. `data.source.coop/_default`): | Platform | Backend | |----------|---------| @@ -189,8 +210,8 @@ When the OS keyring is unavailable (headless servers, containers, CI), the CLI f | Platform | Path | |----------|------| -| macOS | `~/Library/Caches/source-coop/credentials/.json` | -| Linux | `~/.cache/source-coop/credentials/.json` | +| macOS | `~/Library/Caches/source-coop/credentials/_.json` | +| Linux | `~/.cache/source-coop/credentials/_.json` | | Windows | `%LocalAppData%\source-coop\credentials\.json` | The fallback is automatic — no configuration is needed. diff --git a/src/cache.rs b/src/cache.rs index b7c5bad..0bb7759 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; const KEYRING_SERVICE: &str = "source-coop-cli"; -/// What gets cached per role: the STS credentials plus, when the IdP issued +/// What gets cached per proxy + role: the STS credentials plus, when the IdP issued /// one, what's needed to mint fresh credentials without a browser login. /// `flatten` keeps caches written by older versions (bare credentials) readable. #[derive(Serialize, Deserialize)] @@ -39,10 +39,19 @@ fn is_keyring_unavailable(err: &keyring::Error) -> bool { ) } +/// Cache key for a role at a given proxy, e.g. `data.source.coop/_default`, so +/// logins against different environments (prod, staging) don't overwrite each other. +pub fn key(proxy_url: &str, role_arn: &str) -> String { + let host = proxy_url + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_end_matches('/'); + format!("{host}/{role_arn}") +} + /// Replace any character that isn't alphanumeric, `-`, or `_` with `_`. -fn sanitize_role_arn(role_arn: &str) -> String { - role_arn - .chars() +fn sanitize_key(key: &str) -> String { + key.chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c @@ -53,24 +62,24 @@ fn sanitize_role_arn(role_arn: &str) -> String { .collect() } -/// Full path to the credentials cache file for a given role. +/// Full path to the credentials cache file for a given cache key. /// Uses the OS-idiomatic cache directory (`~/Library/Caches` on macOS, /// `~/.cache` on Linux, `%LocalAppData%` on Windows). -fn cache_path(role_arn: &str) -> Result { +fn cache_path(key: &str) -> Result { let cache_dir = dirs::cache_dir().ok_or("Could not determine cache directory")?; - let sanitized = sanitize_role_arn(role_arn); + let sanitized = sanitize_key(key); Ok(cache_dir .join("source-coop") .join("credentials") .join(format!("{sanitized}.json"))) } -/// Take an exclusive per-role lock, held until the returned file is dropped. +/// Take an exclusive per-key lock, held until the returned file is dropped. /// Refresh tokens rotate on use, and the IdP may revoke the whole token family /// if an old one is replayed, so concurrent `creds` calls must not refresh in /// parallel. -pub fn lock(role_arn: &str) -> Result { - let path = cache_path(role_arn)?.with_extension("lock"); +pub fn lock(key: &str) -> Result { + let path = cache_path(key)?.with_extension("lock"); let dir = path.parent().unwrap(); fs::create_dir_all(dir) .map_err(|e| format!("Failed to create cache directory {}: {e}", dir.display()))?; @@ -82,8 +91,8 @@ pub fn lock(role_arn: &str) -> Result { } /// Write credentials to a cache file. Returns the file path as a string. -fn write_credentials_file(role_arn: &str, creds: &CacheEntry) -> Result { - let path = cache_path(role_arn)?; +fn write_credentials_file(key: &str, creds: &CacheEntry) -> Result { + let path = cache_path(key)?; let dir = path.parent().unwrap(); fs::create_dir_all(dir) @@ -106,8 +115,8 @@ fn write_credentials_file(role_arn: &str, creds: &CacheEntry) -> Result Result, String> { - let path = cache_path(role_arn)?; +fn read_credentials_file(key: &str) -> Result, String> { + let path = cache_path(key)?; match fs::read_to_string(&path) { Ok(contents) => { let creds: CacheEntry = serde_json::from_str(&contents) @@ -124,11 +133,11 @@ fn read_credentials_file(role_arn: &str) -> Result, String> { /// Write credentials, trying the OS keyring first with file fallback. /// Returns a human-readable description of where credentials were stored. -pub fn write_credentials(role_arn: &str, creds: &CacheEntry) -> Result { +pub fn write_credentials(key: &str, creds: &CacheEntry) -> Result { let json = serde_json::to_string(creds) .map_err(|e| format!("Failed to serialize credentials: {e}"))?; - let entry = keyring::Entry::new(KEYRING_SERVICE, role_arn) + let entry = keyring::Entry::new(KEYRING_SERVICE, key) .map_err(|e| format!("Failed to create keyring entry: {e}")); if let Ok(entry) = entry { @@ -145,13 +154,13 @@ pub fn write_credentials(role_arn: &str, creds: &CacheEntry) -> Result Result, String> { - let entry = keyring::Entry::new(KEYRING_SERVICE, role_arn) +pub fn read_credentials(key: &str) -> Result, String> { + let entry = keyring::Entry::new(KEYRING_SERVICE, key) .map_err(|e| format!("Failed to create keyring entry: {e}")); if let Ok(entry) = entry { @@ -173,7 +182,7 @@ pub fn read_credentials(role_arn: &str) -> Result, String> { } } - read_credentials_file(role_arn) + read_credentials_file(key) } /// Check if credentials are expired or will expire within a 60-second buffer. @@ -204,22 +213,34 @@ mod tests { } } + #[test] + fn key_separates_environments() { + assert_eq!( + key("https://data.source.coop", "_default"), + "data.source.coop/_default" + ); + assert_eq!( + key("https://data.staging.source.coop/", "_default"), + "data.staging.source.coop/_default" + ); + } + #[test] fn sanitize_simple_name() { - assert_eq!(sanitize_role_arn("source-coop-user"), "source-coop-user"); + assert_eq!(sanitize_key("source-coop-user"), "source-coop-user"); } #[test] fn sanitize_arn_with_special_chars() { assert_eq!( - sanitize_role_arn("arn:aws:iam::123:role/Foo"), + sanitize_key("arn:aws:iam::123:role/Foo"), "arn_aws_iam__123_role_Foo" ); } #[test] fn sanitize_preserves_underscores() { - assert_eq!(sanitize_role_arn("my_role-name"), "my_role-name"); + assert_eq!(sanitize_key("my_role-name"), "my_role-name"); } #[test] diff --git a/src/main.rs b/src/main.rs index 6516a00..594cf59 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,6 +107,10 @@ struct LoginArgs { #[derive(Parser)] struct CredsArgs { + /// S3 proxy URL the credentials were issued by (selects the cache entry) + #[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)] + proxy_url: String, + /// Role ARN to read cached credentials for #[arg(long, env = "SOURCE_ROLE_ARN", default_value = defaults::ROLE_ARN)] role_arn: String, @@ -189,6 +193,7 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { "No refresh token issued; run 'source-coop login' again when credentials expire." ); } + let key = cache::key(&args.proxy_url, &args.role_arn); let entry = cache::CacheEntry { creds, refresh: tokens @@ -201,7 +206,7 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { duration: args.duration, }), }; - let location = cache::write_credentials(&args.role_arn, &entry)?; + let location = cache::write_credentials(&key, &entry)?; eprintln!("Credentials cached to {location}"); eprintln!("Run 'source-coop creds' to print them."); } @@ -211,7 +216,8 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { const NOT_FOUND: &str = "No cached credentials found. Run 'source-coop login' first."; - let mut entry = cache::read_credentials(&args.role_arn)?.ok_or(NOT_FOUND)?; + let key = cache::key(&args.proxy_url, &args.role_arn); + let mut entry = cache::read_credentials(&key)?.ok_or(NOT_FOUND)?; if cache::is_expired(&entry.creds)? { if entry.refresh.is_none() { @@ -220,11 +226,10 @@ async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { ); } // Re-read under the lock: another process may have refreshed already. - let _lock = cache::lock(&args.role_arn)?; - entry = cache::read_credentials(&args.role_arn)?.ok_or(NOT_FOUND)?; + let _lock = cache::lock(&key)?; + entry = cache::read_credentials(&key)?.ok_or(NOT_FOUND)?; if cache::is_expired(&entry.creds)? { - let save = - |e: &cache::CacheEntry| cache::write_credentials(&args.role_arn, e).map(drop); + let save = |e: &cache::CacheEntry| cache::write_credentials(&key, e).map(drop); entry = refresh(&args.role_arn, entry, verbose, save).await.map_err(|e| { format!("Cached credentials have expired and refresh failed ({e}). Run 'source-coop login'.") })?; From aa7c9378ff35b7322ecca803be8dffde5d344dc3 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 30 Sep 2026 11:31:10 -0700 Subject: [PATCH 2/5] fix: keep bare role cache key for the production proxy Existing prod caches (keyed by role ARN alone) stay readable, so upgrading needs no re-login. Only non-production proxies get the host prefix. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- src/cache.rs | 21 ++++++++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a748698..feb2455 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ The CLI caches temporary STS credentials so that `creds` can output them without ### OS keyring (default) -Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by proxy host and role ARN (e.g. `data.source.coop/_default`): +Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by role ARN (e.g. `_default`), prefixed with the proxy host for non-production proxies (e.g. `data.staging.source.coop/_default`): | Platform | Backend | |----------|---------| @@ -210,8 +210,8 @@ When the OS keyring is unavailable (headless servers, containers, CI), the CLI f | Platform | Path | |----------|------| -| macOS | `~/Library/Caches/source-coop/credentials/_.json` | -| Linux | `~/.cache/source-coop/credentials/_.json` | +| macOS | `~/Library/Caches/source-coop/credentials/.json` (non-production: `_.json`) | +| Linux | `~/.cache/source-coop/credentials/.json` (non-production: `_.json`) | | Windows | `%LocalAppData%\source-coop\credentials\.json` | The fallback is automatic — no configuration is needed. diff --git a/src/cache.rs b/src/cache.rs index 0bb7759..9ced128 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -39,14 +39,23 @@ fn is_keyring_unavailable(err: &keyring::Error) -> bool { ) } -/// Cache key for a role at a given proxy, e.g. `data.source.coop/_default`, so -/// logins against different environments (prod, staging) don't overwrite each other. +/// Production proxy host. Its entries keep the bare role ARN as the cache key, +/// so caches written before keys included the host remain readable. +const PROD_HOST: &str = "data.source.coop"; + +/// Cache key for a role at a given proxy, so logins against different +/// environments (prod, staging) don't overwrite each other: `_default` for +/// production, `data.staging.source.coop/_default` for any other proxy. pub fn key(proxy_url: &str, role_arn: &str) -> String { let host = proxy_url .trim_start_matches("https://") .trim_start_matches("http://") .trim_end_matches('/'); - format!("{host}/{role_arn}") + if host == PROD_HOST { + role_arn.to_string() + } else { + format!("{host}/{role_arn}") + } } /// Replace any character that isn't alphanumeric, `-`, or `_` with `_`. @@ -215,10 +224,8 @@ mod tests { #[test] fn key_separates_environments() { - assert_eq!( - key("https://data.source.coop", "_default"), - "data.source.coop/_default" - ); + assert_eq!(key("https://data.source.coop", "_default"), "_default"); + assert_eq!(key("https://data.source.coop/", "_default"), "_default"); assert_eq!( key("https://data.staging.source.coop/", "_default"), "data.staging.source.coop/_default" From 642d931d467796b02aea847db1e3bc380d483822 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 13 Oct 2026 10:28:58 -0700 Subject: [PATCH 3/5] feat: give each proxy environment its own login session and cache Key the login session by environment as well as role credentials, so a staging login no longer replaces production's session. Normalize proxy URLs with url::Url, keep non-default environments in their own cache directory, ignore cached entries minted at another environment's proxy, and add --proxy-url to 'auth token'. Co-Authored-By: Claude Opus 5.5 --- .env.staging | 1 + README.md | 14 ++-- src/cache.rs | 218 ++++++++++++++++++++++++++++++++++--------------- src/main.rs | 46 +++++++---- src/session.rs | 15 ++-- 5 files changed, 197 insertions(+), 97 deletions(-) diff --git a/.env.staging b/.env.staging index 4524918..448c8c6 100644 --- a/.env.staging +++ b/.env.staging @@ -11,3 +11,4 @@ export SOURCE_OIDC_ISSUER=https://auth.staging.source.coop export SOURCE_OIDC_CLIENT_ID=a79c9537-be78-454a-9ea1-b96a1be811cc export SOURCE_PROXY_URL=https://data.staging.source.coop +export SOURCE_API_AUDIENCE=https://staging.source.coop diff --git a/README.md b/README.md index 6ae228a..c15be66 100644 --- a/README.md +++ b/README.md @@ -127,14 +127,14 @@ endpoint_url = https://data.source.coop ### Multiple environments (e.g. staging) -Credentials are cached per proxy URL, so production and staging logins live side by side: +Each proxy host is its own environment, with its own login session and credentials, so production and staging logins live side by side: ```bash source-coop login ( source .env.staging && source-coop login ) ``` -`creds` reads `SOURCE_PROXY_URL` (or `--proxy-url`) to pick the environment: +`creds` and `auth token` read `SOURCE_PROXY_URL` (or `--proxy-url`) to pick the environment: ```ini [profile source-coop] @@ -152,7 +152,7 @@ endpoint_url = https://data.staging.source.coop |------|---------|---------|-------------| | `--issuer` | `SOURCE_OIDC_ISSUER` | `https://auth.source.coop` | OIDC issuer URL | | `--client-id` | `SOURCE_OIDC_CLIENT_ID` | `d037d00b-...` | OAuth2 client ID | -| `--proxy-url` | `SOURCE_PROXY_URL` | `https://data.source.coop` | S3 proxy URL for STS | +| `--proxy-url` | `SOURCE_PROXY_URL` | `https://data.source.coop` | S3 proxy URL for STS; also selects the environment the login is cached under | | `--role-arn` | `SOURCE_ROLE_ARN` | `source-coop-user` | Role ARN to assume | | `--format` | | `credential-process` | Output format: `credential-process`, `env`, or `aws-credentials` | | `--profile` | | `source-coop` | Profile name for `--format aws-credentials` | @@ -203,7 +203,7 @@ The CLI caches the login session (the Ory refresh, ID and access tokens) and the ### OS keyring (default) -Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by role ARN (e.g. `_default`), prefixed with the proxy host for non-production proxies (e.g. `data.staging.source.coop/_default`), with the session under `@session`: +Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by role ARN (e.g. `_default`), with the session under `@session`. Other environments prefix both with their proxy host (e.g. `data.staging.source.coop/_default`, `data.staging.source.coop/@session`): | Platform | Backend | |----------|---------| @@ -217,11 +217,11 @@ When the OS keyring is unavailable (headless servers, containers, CI), the CLI f | Platform | Path | |----------|------| -| macOS | `~/Library/Caches/source-coop/credentials/.json` (non-production: `_.json`) | -| Linux | `~/.cache/source-coop/credentials/.json` (non-production: `_.json`) | +| macOS | `~/Library/Caches/source-coop/credentials/.json` | +| Linux | `~/.cache/source-coop/credentials/.json` | | Windows | `%LocalAppData%\source-coop\credentials\.json` | -The session sits beside them, at `source-coop/session.json`. +The session sits beside them, at `source-coop/session.json`. Other environments keep the same layout under `source-coop/env//` (e.g. `source-coop/env/data.staging.source.coop/session.json`). The fallback is automatic — no configuration is needed. diff --git a/src/cache.rs b/src/cache.rs index 917ebe9..0655c09 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -25,6 +25,19 @@ pub struct CacheEntry { pub refresh: Option, } +impl CacheEntry { + /// Whether this entry was minted at a proxy in `env`. Bare credentials + /// record no proxy and can't be renewed, so they're taken as they are. + fn belongs_to(&self, env: Option<&str>) -> bool { + let proxy_url = match (&self.sts, &self.refresh) { + (Some(sts), _) => &sts.proxy_url, + (None, Some(refresh)) => &refresh.proxy_url, + (None, None) => return true, + }; + self::env(proxy_url).is_ok_and(|e| e.as_deref() == env) + } +} + #[derive(Serialize, Deserialize, Clone)] pub struct StsSettings { pub proxy_url: String, @@ -47,25 +60,72 @@ struct Slot { path: PathBuf, } +/// The OS-idiomatic cache directory (`~/Library/Caches` on macOS, `~/.cache` +/// on Linux, `%LocalAppData%` on Windows). fn cache_dir() -> Result { Ok(dirs::cache_dir() .ok_or("Could not determine cache directory")? .join("source-coop")) } -fn role_slot(key: &str) -> Result { +/// The environment a proxy URL belongs to: `None` for this build's default +/// proxy, whose slots keep the names they had before environments existed, +/// otherwise the proxy's host (and port, if not the scheme's default). +pub fn env(proxy_url: &str) -> Result, String> { + let host = host(proxy_url)?; + Ok((host != host_of_default()).then_some(host)) +} + +fn host(proxy_url: &str) -> Result { + let url = + url::Url::parse(proxy_url).map_err(|e| format!("Invalid proxy URL {proxy_url}: {e}"))?; + let host = url + .host_str() + .ok_or_else(|| format!("Proxy URL {proxy_url} has no host"))?; + Ok(match url.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }) +} + +fn host_of_default() -> String { + host(crate::defaults::PROXY_URL).expect("default proxy URL is valid") +} + +/// The keyring account for `name` in `env`. Hosts can't contain `/`, so a +/// prefixed account never equals a default-environment one. +fn account(env: Option<&str>, name: &str) -> String { + match env { + None => name.to_string(), + Some(env) => format!("{env}/{name}"), + } +} + +/// The directory `env`'s cache files live in. Each non-default environment +/// gets its own, so their files can't collide with each other's or prod's. +fn env_dir(env: Option<&str>) -> Result { + let dir = cache_dir()?; + Ok(match env { + None => dir, + Some(env) => dir.join("env").join(sanitize(env, &['-', '.'])), + }) +} + +fn role_slot(env: Option<&str>, role_arn: &str) -> Result { Ok(Slot { - account: key.to_string(), - path: cache_path(key)?, + account: account(env, role_arn), + path: env_dir(env)? + .join("credentials") + .join(format!("{}.json", sanitize(role_arn, &['-', '_']))), }) } /// The login session's slot. `@` can't begin a role ARN or role name, so its /// keyring account never collides with a role's. -fn session_slot() -> Result { +fn session_slot(env: Option<&str>) -> Result { Ok(Slot { - account: "@session".to_string(), - path: cache_dir()?.join("session.json"), + account: account(env, "@session"), + path: env_dir(env)?.join("session.json"), }) } @@ -81,30 +141,11 @@ fn is_keyring_unavailable(err: &keyring::Error) -> bool { ) } -/// Production proxy host. Its entries keep the bare role ARN as the cache key, -/// so caches written before keys included the host remain readable. -const PROD_HOST: &str = "data.source.coop"; - -/// Cache key for a role at a given proxy, so logins against different -/// environments (prod, staging) don't overwrite each other: `_default` for -/// production, `data.staging.source.coop/_default` for any other proxy. -pub fn key(proxy_url: &str, role_arn: &str) -> String { - let host = proxy_url - .trim_start_matches("https://") - .trim_start_matches("http://") - .trim_end_matches('/'); - if host == PROD_HOST { - role_arn.to_string() - } else { - format!("{host}/{role_arn}") - } -} - -/// Replace any character that isn't alphanumeric, `-`, or `_` with `_`. -fn sanitize_key(key: &str) -> String { - key.chars() +/// Replace any character that isn't alphanumeric or in `keep` with `_`. +fn sanitize(s: &str, keep: &[char]) -> String { + s.chars() .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + if c.is_ascii_alphanumeric() || keep.contains(&c) { c } else { '_' @@ -113,27 +154,17 @@ fn sanitize_key(key: &str) -> String { .collect() } -/// Full path to the credentials cache file for a given role. -/// Uses the OS-idiomatic cache directory (`~/Library/Caches` on macOS, -/// `~/.cache` on Linux, `%LocalAppData%` on Windows). -fn cache_path(key: &str) -> Result { - let sanitized = sanitize_key(key); - Ok(cache_dir()? - .join("credentials") - .join(format!("{sanitized}.json"))) -} - /// Take an exclusive per-role lock, held until the returned file is dropped, /// so concurrent `creds` calls for one role mint once. -pub fn lock(key: &str) -> Result { - lock_slot(&role_slot(key)?) +pub fn lock(env: Option<&str>, role_arn: &str) -> Result { + lock_slot(&role_slot(env, role_arn)?) } /// Take the login session's lock. Refresh tokens rotate on use, and the IdP /// may revoke the whole token family if an old one is replayed, so nothing may /// refresh the session in parallel. Taken after a role's lock, never before. -pub fn lock_session() -> Result { - lock_slot(&session_slot()?) +pub fn lock_session(env: Option<&str>) -> Result { + lock_slot(&session_slot(env)?) } fn lock_slot(slot: &Slot) -> Result { @@ -150,22 +181,29 @@ fn lock_slot(slot: &Slot) -> Result { /// Write credentials, trying the OS keyring first with file fallback. /// Returns a human-readable description of where credentials were stored. -pub fn write_credentials(key: &str, creds: &CacheEntry) -> Result { - write_slot(&role_slot(key)?, creds) +pub fn write_credentials( + env: Option<&str>, + role_arn: &str, + creds: &CacheEntry, +) -> Result { + write_slot(&role_slot(env, role_arn)?, creds) } /// Read credentials, trying the OS keyring first with file fallback. -/// Returns `None` if no cached credentials are found in either location. -pub fn read_credentials(key: &str) -> Result, String> { - read_slot(&role_slot(key)?) +/// Returns `None` if no cached credentials are found in either location, or +/// if they were minted at another environment's proxy: versions before +/// environments existed cached every proxy's credentials in the default slot. +pub fn read_credentials(env: Option<&str>, role_arn: &str) -> Result, String> { + let entry: Option = read_slot(&role_slot(env, role_arn)?)?; + Ok(entry.filter(|e| e.belongs_to(env))) } -pub fn write_session(session: &Session) -> Result { - write_slot(&session_slot()?, session) +pub fn write_session(env: Option<&str>, session: &Session) -> Result { + write_slot(&session_slot(env)?, session) } -pub fn read_session() -> Result, String> { - read_slot(&session_slot()?) +pub fn read_session(env: Option<&str>) -> Result, String> { + read_slot(&session_slot(env)?) } fn write_slot(slot: &Slot, value: &T) -> Result { @@ -250,31 +288,81 @@ mod tests { } #[test] - fn key_separates_environments() { - assert_eq!(key("https://data.source.coop", "_default"), "_default"); - assert_eq!(key("https://data.source.coop/", "_default"), "_default"); + fn env_normalizes_proxy_urls() { + let default = crate::defaults::PROXY_URL; + assert_eq!(env(default).unwrap(), None); + assert_eq!(env(&format!("{default}/")).unwrap(), None); + assert_eq!(env(&default.to_uppercase()).unwrap(), None); + assert_eq!(env(&format!("{default}:443")).unwrap(), None); + assert_eq!( + env("https://Data.Example.com/").unwrap().as_deref(), + Some("data.example.com") + ); + assert_eq!( + env("http://localhost:8080").unwrap().as_deref(), + Some("localhost:8080") + ); + assert!(env("not a url").is_err()); + } + + #[test] + fn slots_never_collide_across_environments() { + let default = cache_dir().unwrap(); + let prod = role_slot(None, "_default").unwrap(); + assert_eq!(prod.account, "_default"); + assert_eq!(prod.path, default.join("credentials").join("_default.json")); + assert_eq!(session_slot(None).unwrap().account, "@session"); + + // A role whose sanitized name looks like an environment's still lands elsewhere. + let local = role_slot(Some("localhost:8080"), "_default").unwrap(); + let lookalike = role_slot(None, "localhost_8080__default").unwrap(); + assert_eq!(local.account, "localhost:8080/_default"); assert_eq!( - key("https://data.staging.source.coop/", "_default"), - "data.staging.source.coop/_default" + local.path, + default + .join("env") + .join("localhost_8080") + .join("credentials") + .join("_default.json") + ); + assert_ne!(local.path, lookalike.path); + + let staging = session_slot(Some("data.staging.source.coop")).unwrap(); + assert_eq!(staging.account, "data.staging.source.coop/@session"); + assert_eq!( + staging.path, + default + .join("env") + .join("data.staging.source.coop") + .join("session.json") ); } #[test] - fn sanitize_simple_name() { - assert_eq!(sanitize_key("source-coop-user"), "source-coop-user"); + fn entries_belong_to_their_proxys_environment() { + let entry = |proxy_url: &str| CacheEntry { + creds: sample_creds("2026-03-01T00:00:00Z"), + sts: Some(StsSettings { + proxy_url: proxy_url.to_string(), + duration: None, + }), + refresh: None, + }; + let other = entry("https://data.example.com"); + assert!(other.belongs_to(Some("data.example.com"))); + assert!(!other.belongs_to(None)); + assert!(entry(crate::defaults::PROXY_URL).belongs_to(None)); + let bare = CacheEntry { sts: None, ..other }; + assert!(bare.belongs_to(None)); } #[test] fn sanitize_arn_with_special_chars() { assert_eq!( - sanitize_key("arn:aws:iam::123:role/Foo"), + sanitize("arn:aws:iam::123:role/Foo", &['-', '_']), "arn_aws_iam__123_role_Foo" ); - } - - #[test] - fn sanitize_preserves_underscores() { - assert_eq!(sanitize_key("my_role-name"), "my_role-name"); + assert_eq!(sanitize("my_role-name", &['-', '_']), "my_role-name"); } #[test] diff --git a/src/main.rs b/src/main.rs index 6fbf1d9..5e57cef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,7 +71,14 @@ enum Commands { enum AuthCommand { /// Print an access token for the source.coop API, refreshing it if expired /// (e.g. `curl -H "Authorization: Bearer $(source-coop auth token)" ...`) - Token, + Token(TokenArgs), +} + +#[derive(Parser)] +struct TokenArgs { + /// S3 proxy URL of the environment whose login session to use + #[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)] + proxy_url: String, } #[derive(Parser)] @@ -129,7 +136,7 @@ struct CredsArgs { #[arg(long, env = "SOURCE_ROLE_ARN", default_value = defaults::ROLE_ARN)] role_arn: String, - /// S3 proxy URL for STS, for a role with nothing cached yet + /// S3 proxy URL: selects the environment whose cache and login session to use #[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)] proxy_url: String, @@ -171,8 +178,8 @@ async fn main() { std::process::exit(1); } } - Commands::Auth(AuthCommand::Token) => { - if let Err(e) = run_auth_token(verbose).await { + Commands::Auth(AuthCommand::Token(args)) => { + if let Err(e) = run_auth_token(args, verbose).await { eprintln!("Error: {e}"); std::process::exit(1); } @@ -180,8 +187,9 @@ async fn main() { } } -async fn run_auth_token(verbose: bool) -> Result<(), String> { - let token = session::token(session::Want::Access, verbose) +async fn run_auth_token(args: TokenArgs, verbose: bool) -> Result<(), String> { + let env = cache::env(&args.proxy_url)?; + let token = session::token(env.as_deref(), session::Want::Access, verbose) .await? .ok_or("No access token cached. Run 'source-coop login' (with an API audience).")?; println!("{token}"); @@ -189,6 +197,8 @@ async fn run_auth_token(verbose: bool) -> Result<(), String> { } async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { + let env = cache::env(&args.proxy_url)?; + // 1. OIDC Discovery eprintln!("Discovering OIDC endpoints..."); let endpoints = oidc::discover(&args.issuer, verbose).await?; @@ -249,10 +259,9 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { chrono::Utc::now().timestamp(), ); { - let _lock = cache::lock_session()?; - cache::write_session(&session)?; + let _lock = cache::lock_session(env.as_deref())?; + cache::write_session(env.as_deref(), &session)?; } - let key = cache::key(&args.proxy_url, &args.role_arn); let entry = cache::CacheEntry { creds, sts: Some(cache::StsSettings { @@ -261,23 +270,24 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { }), refresh: None, }; - let location = cache::write_credentials(&key, &entry)?; + let location = cache::write_credentials(env.as_deref(), &args.role_arn, &entry)?; eprintln!("Credentials cached to {location}"); eprintln!("Run 'source-coop creds' to print them."); Ok(()) } async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { - let key = cache::key(&args.proxy_url, &args.role_arn); - let cached = cache::read_credentials(&key)?; + let env = cache::env(&args.proxy_url)?; + let env = env.as_deref(); + let cached = cache::read_credentials(env, &args.role_arn)?; let entry = match cached { Some(entry) if !cache::is_expired(&entry.creds)? => entry, _ => { // Re-check under the lock: another process may have minted already. - let _lock = cache::lock(&key)?; - match cache::read_credentials(&key)? { + let _lock = cache::lock(env, &args.role_arn)?; + match cache::read_credentials(env, &args.role_arn)? { Some(entry) if !cache::is_expired(&entry.creds)? => entry, - stale => mint(&args, stale, verbose).await.map_err(|e| { + stale => mint(&args, env, stale, verbose).await.map_err(|e| { format!("Couldn't get new credentials ({e}). Run 'source-coop login'.") })?, } @@ -297,11 +307,11 @@ async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { /// else exchanges the session's ID token, refreshing the session if need be. async fn mint( args: &CredsArgs, + env: Option<&str>, stale: Option, verbose: bool, ) -> Result { - let key = cache::key(&args.proxy_url, &args.role_arn); - let save = |e: &cache::CacheEntry| cache::write_credentials(&key, e).map(drop); + let save = |e: &cache::CacheEntry| cache::write_credentials(env, &args.role_arn, e).map(drop); match stale { Some(entry) if entry.sts.is_none() && entry.refresh.is_some() => { refresh(&args.role_arn, entry, verbose, save).await @@ -311,7 +321,7 @@ async fn mint( proxy_url: args.proxy_url.clone(), duration: None, }); - let id_token = session::token(session::Want::Id, verbose) + let id_token = session::token(env, session::Want::Id, verbose) .await? .ok_or("not logged in")?; let creds = sts::assume_role( diff --git a/src/session.rs b/src/session.rs index f349aca..8753316 100644 --- a/src/session.rs +++ b/src/session.rs @@ -143,22 +143,23 @@ pub async fn ensure( .map_err(|()| "The refreshed token has already expired; check the system clock.".into()) } -/// The wanted token from the cached session, or `None` if there is no session -/// (or it can't have that token). Refreshes under the session lock when needed. -pub async fn token(want: Want, verbose: bool) -> Result, String> { - let Some(session) = cache::read_session()? else { +/// The wanted token from `env`'s cached session, or `None` if there is no +/// session (or it can't have that token). Refreshes under the session lock +/// when needed. +pub async fn token(env: Option<&str>, want: Want, verbose: bool) -> Result, String> { + let Some(session) = cache::read_session(env)? else { return Ok(None); }; if let Ok(token) = session.current(want, now()) { return Ok(token.map(String::from)); } // Re-read under the lock: another process may have refreshed already. - let _lock = cache::lock_session()?; - let Some(mut session) = cache::read_session()? else { + let _lock = cache::lock_session(env)?; + let Some(mut session) = cache::read_session(env)? else { return Ok(None); }; ensure(&mut session, want, now(), verbose, |s| { - cache::write_session(s).map(drop) + cache::write_session(env, s).map(drop) }) .await } From 1e8c8d0ac7a3cac05dfeb1b4c2ac461fa3356d37 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 13 Oct 2026 10:41:02 -0700 Subject: [PATCH 4/5] fix: pin the bare cache slots to production and check sessions' environment - The bare (pre-environment) slots now always mean data.source.coop, so staging and production builds sharing the keyring can't read each other's caches. - Sessions record their proxy and are ignored under another environment. - An environment with nothing cached adopts its entry from the production slot, where versions before environments stored every proxy's. - Ports get their own cache-directory segment, since '_' is legal in hosts. - login writes credentials under the role lock. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- src/cache.rs | 111 +++++++++++++++++++++++++++++++------------------ src/main.rs | 4 ++ src/session.rs | 7 ++++ 4 files changed, 84 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index c15be66..c3f8b33 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ source-coop login ( source .env.staging && source-coop login ) ``` -`creds` and `auth token` read `SOURCE_PROXY_URL` (or `--proxy-url`) to pick the environment: +`creds` and `auth token` read `SOURCE_PROXY_URL` (or `--proxy-url`) to pick the environment, and without either they use production. A non-production profile must pass `--proxy-url`; one that relied on whichever environment was logged in last will now get production credentials: ```ini [profile source-coop] @@ -221,7 +221,7 @@ When the OS keyring is unavailable (headless servers, containers, CI), the CLI f | Linux | `~/.cache/source-coop/credentials/.json` | | Windows | `%LocalAppData%\source-coop\credentials\.json` | -The session sits beside them, at `source-coop/session.json`. Other environments keep the same layout under `source-coop/env//` (e.g. `source-coop/env/data.staging.source.coop/session.json`). +The session sits beside them, at `source-coop/session.json`. Other environments keep the same layout under `source-coop/env//`, or `source-coop/env///` for a non-default port (e.g. `source-coop/env/data.staging.source.coop/session.json`). The fallback is automatic — no configuration is needed. diff --git a/src/cache.rs b/src/cache.rs index 0655c09..ac7c208 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -34,10 +34,15 @@ impl CacheEntry { (None, Some(refresh)) => &refresh.proxy_url, (None, None) => return true, }; - self::env(proxy_url).is_ok_and(|e| e.as_deref() == env) + in_env(proxy_url, env) } } +/// Whether `proxy_url` is in `env`. An unparseable URL is in none. +fn in_env(proxy_url: &str, env: Option<&str>) -> bool { + self::env(proxy_url).is_ok_and(|e| e.as_deref() == env) +} + #[derive(Serialize, Deserialize, Clone)] pub struct StsSettings { pub proxy_url: String, @@ -68,30 +73,28 @@ fn cache_dir() -> Result { .join("source-coop")) } -/// The environment a proxy URL belongs to: `None` for this build's default -/// proxy, whose slots keep the names they had before environments existed, -/// otherwise the proxy's host (and port, if not the scheme's default). -pub fn env(proxy_url: &str) -> Result, String> { - let host = host(proxy_url)?; - Ok((host != host_of_default()).then_some(host)) -} +/// The production proxy's host. Its slots keep the names they had before +/// environments existed, in every build: tying them to the build's default +/// proxy would let a staging build and a production build, which share the +/// keyring, read each other's caches as their own. +const PROD_HOST: &str = "data.source.coop"; -fn host(proxy_url: &str) -> Result { - let url = - url::Url::parse(proxy_url).map_err(|e| format!("Invalid proxy URL {proxy_url}: {e}"))?; +/// The environment a proxy URL belongs to: `None` for production, otherwise +/// the proxy's host (and port, if not the scheme's default). +pub fn env(proxy_url: &str) -> Result, String> { + let url = url::Url::parse(proxy_url).map_err(|e| { + format!("Invalid proxy URL {proxy_url:?} (from --proxy-url or SOURCE_PROXY_URL): {e}") + })?; let host = url .host_str() - .ok_or_else(|| format!("Proxy URL {proxy_url} has no host"))?; + .ok_or_else(|| format!("Proxy URL {proxy_url:?} has no host"))?; Ok(match url.port() { - Some(port) => format!("{host}:{port}"), - None => host.to_string(), + Some(port) => Some(format!("{host}:{port}")), + None if host == PROD_HOST => None, + None => Some(host.to_string()), }) } -fn host_of_default() -> String { - host(crate::defaults::PROXY_URL).expect("default proxy URL is valid") -} - /// The keyring account for `name` in `env`. Hosts can't contain `/`, so a /// prefixed account never equals a default-environment one. fn account(env: Option<&str>, name: &str) -> String { @@ -101,14 +104,19 @@ fn account(env: Option<&str>, name: &str) -> String { } } -/// The directory `env`'s cache files live in. Each non-default environment -/// gets its own, so their files can't collide with each other's or prod's. +/// The directory `env`'s cache files live in: `env/[/]`. Each +/// non-production environment gets its own, so their files can't collide with +/// each other's or prod's. The port is its own segment because `_`, which +/// sanitizing produces, is allowed in hosts. fn env_dir(env: Option<&str>) -> Result { let dir = cache_dir()?; - Ok(match env { - None => dir, - Some(env) => dir.join("env").join(sanitize(env, &['-', '.'])), - }) + let Some(env) = env else { return Ok(dir) }; + let (host, port) = env + .rsplit_once(':') + .filter(|(_, port)| port.bytes().all(|b| b.is_ascii_digit())) + .unwrap_or((env, "")); + let dir = dir.join("env").join(sanitize(host, &['-', '.', '_'])); + Ok(if port.is_empty() { dir } else { dir.join(port) }) } fn role_slot(env: Option<&str>, role_arn: &str) -> Result { @@ -191,19 +199,34 @@ pub fn write_credentials( /// Read credentials, trying the OS keyring first with file fallback. /// Returns `None` if no cached credentials are found in either location, or -/// if they were minted at another environment's proxy: versions before -/// environments existed cached every proxy's credentials in the default slot. +/// if they were minted at another environment's proxy. +/// +/// Versions before environments existed cached every proxy's credentials in +/// the production slot, so an environment with nothing of its own takes its +/// entry from there, copying it into its own slot. pub fn read_credentials(env: Option<&str>, role_arn: &str) -> Result, String> { - let entry: Option = read_slot(&role_slot(env, role_arn)?)?; - Ok(entry.filter(|e| e.belongs_to(env))) + let slot = role_slot(env, role_arn)?; + let entry: Option = read_slot(&slot)?; + if entry.is_some() || env.is_none() { + return Ok(entry.filter(|e| e.belongs_to(env))); + } + let legacy: Option = read_slot(&role_slot(None, role_arn)?)?; + let Some(legacy) = legacy.filter(|e| e.belongs_to(env)) else { + return Ok(None); + }; + write_slot(&slot, &legacy)?; + Ok(Some(legacy)) } pub fn write_session(env: Option<&str>, session: &Session) -> Result { write_slot(&session_slot(env)?, session) } +/// Read `env`'s login session, or `None` if there is none or it belongs to +/// another environment's proxy. pub fn read_session(env: Option<&str>) -> Result, String> { - read_slot(&session_slot(env)?) + let session: Option = read_slot(&session_slot(env)?)?; + Ok(session.filter(|s| s.proxy_url.as_deref().is_none_or(|p| in_env(p, env)))) } fn write_slot(slot: &Slot, value: &T) -> Result { @@ -289,14 +312,18 @@ mod tests { #[test] fn env_normalizes_proxy_urls() { - let default = crate::defaults::PROXY_URL; - assert_eq!(env(default).unwrap(), None); - assert_eq!(env(&format!("{default}/")).unwrap(), None); - assert_eq!(env(&default.to_uppercase()).unwrap(), None); - assert_eq!(env(&format!("{default}:443")).unwrap(), None); + // Production is the bare environment in every build, staging never is. + assert_eq!(env("https://data.source.coop").unwrap(), None); + assert_eq!(env("https://data.source.coop/").unwrap(), None); + assert_eq!(env("HTTPS://DATA.SOURCE.COOP").unwrap(), None); + assert_eq!(env("https://data.source.coop:443").unwrap(), None); + assert_eq!( + env("https://Data.Staging.Source.Coop/").unwrap().as_deref(), + Some("data.staging.source.coop") + ); assert_eq!( - env("https://Data.Example.com/").unwrap().as_deref(), - Some("data.example.com") + env("https://data.source.coop:8443").unwrap().as_deref(), + Some("data.source.coop:8443") ); assert_eq!( env("http://localhost:8080").unwrap().as_deref(), @@ -321,11 +348,15 @@ mod tests { local.path, default .join("env") - .join("localhost_8080") + .join("localhost") + .join("8080") .join("credentials") .join("_default.json") ); assert_ne!(local.path, lookalike.path); + // `_` is legal in hosts, so the port can't be joined to the host with one. + let underscored = role_slot(Some("localhost_8080"), "_default").unwrap(); + assert_ne!(local.path, underscored.path); let staging = session_slot(Some("data.staging.source.coop")).unwrap(); assert_eq!(staging.account, "data.staging.source.coop/@session"); @@ -348,10 +379,10 @@ mod tests { }), refresh: None, }; - let other = entry("https://data.example.com"); - assert!(other.belongs_to(Some("data.example.com"))); + let other = entry("https://data.staging.source.coop"); + assert!(other.belongs_to(Some("data.staging.source.coop"))); assert!(!other.belongs_to(None)); - assert!(entry(crate::defaults::PROXY_URL).belongs_to(None)); + assert!(entry("https://data.source.coop").belongs_to(None)); let bare = CacheEntry { sts: None, ..other }; assert!(bare.belongs_to(None)); } diff --git a/src/main.rs b/src/main.rs index 5e57cef..4612ecb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -253,6 +253,7 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { } let session = session::Session::new( tokens, + args.proxy_url.clone(), args.client_id, endpoints.token_endpoint, audience.map(String::from), @@ -270,6 +271,9 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { }), refresh: None, }; + // Under the role's lock, so a `creds` minting from the old session can't + // overwrite this entry afterwards. + let _lock = cache::lock(env.as_deref(), &args.role_arn)?; let location = cache::write_credentials(env.as_deref(), &args.role_arn, &entry)?; eprintln!("Credentials cached to {location}"); eprintln!("Run 'source-coop creds' to print them."); diff --git a/src/session.rs b/src/session.rs index 8753316..ffcd5a2 100644 --- a/src/session.rs +++ b/src/session.rs @@ -18,6 +18,10 @@ const SKEW_SECONDS: i64 = 60; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { + /// The proxy `login` was given, which names the session's environment. + /// Absent in sessions written before environments existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy_url: Option, pub client_id: String, pub token_endpoint: String, /// What the access token was requested for, if anything. @@ -45,12 +49,14 @@ pub enum Want { impl Session { pub fn new( tokens: oidc::Tokens, + proxy_url: String, client_id: String, token_endpoint: String, audience: Option, now: i64, ) -> Self { let mut session = Session { + proxy_url: Some(proxy_url), client_id, token_endpoint, audience, @@ -191,6 +197,7 @@ mod tests { fn session(server: &MockServer, t: oidc::Tokens) -> Session { Session::new( t, + "https://data.source.coop".into(), "cid".into(), format!("{}/oauth2/token", server.uri()), Some("https://source.coop".into()), From 30686be622f10988f4b47eb9a85a8b5f19c22a16 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 13 Oct 2026 13:35:36 -0700 Subject: [PATCH 5/5] fix: never migrate bare legacy credentials into another environment Bare entries record no proxy, so the ones in the production slot are production's; copying them into an empty staging slot served prod credentials as staging until they expired. Co-Authored-By: Claude Opus 5.5 --- src/cache.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index ac7c208..792393f 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -26,16 +26,22 @@ pub struct CacheEntry { } impl CacheEntry { - /// Whether this entry was minted at a proxy in `env`. Bare credentials - /// record no proxy and can't be renewed, so they're taken as they are. - fn belongs_to(&self, env: Option<&str>) -> bool { + /// Whether this entry records that it was minted at a proxy in `env`. + fn minted_in(&self, env: Option<&str>) -> bool { let proxy_url = match (&self.sts, &self.refresh) { (Some(sts), _) => &sts.proxy_url, (None, Some(refresh)) => &refresh.proxy_url, - (None, None) => return true, + (None, None) => return false, }; in_env(proxy_url, env) } + + /// Whether this entry can be used in its own slot in `env`. Bare + /// credentials record no proxy and can't be renewed, so they're taken as + /// they are. + fn belongs_to(&self, env: Option<&str>) -> bool { + (self.sts.is_none() && self.refresh.is_none()) || self.minted_in(env) + } } /// Whether `proxy_url` is in `env`. An unparseable URL is in none. @@ -211,7 +217,8 @@ pub fn read_credentials(env: Option<&str>, role_arn: &str) -> Result = read_slot(&role_slot(None, role_arn)?)?; - let Some(legacy) = legacy.filter(|e| e.belongs_to(env)) else { + // Only an entry that records its proxy: bare ones there are production's. + let Some(legacy) = legacy.filter(|e| e.minted_in(env)) else { return Ok(None); }; write_slot(&slot, &legacy)?; @@ -383,8 +390,11 @@ mod tests { assert!(other.belongs_to(Some("data.staging.source.coop"))); assert!(!other.belongs_to(None)); assert!(entry("https://data.source.coop").belongs_to(None)); + assert!(other.minted_in(Some("data.staging.source.coop"))); let bare = CacheEntry { sts: None, ..other }; assert!(bare.belongs_to(None)); + // A bare entry stays where it is: migration never hands it to another environment. + assert!(!bare.minted_in(Some("data.staging.source.coop"))); } #[test]