diff --git a/Cargo.lock b/Cargo.lock index eb174a4d..5d2171ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,6 +171,7 @@ dependencies = [ "getrandom 0.4.3", "git2", "globset", + "hmac", "http-body-util", "humantime", "hyper", diff --git a/Cargo.toml b/Cargo.toml index 706c5599..607499a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ getrandom = "0.4" flate2 = "1" fs2 = "0.4" sha2 = "0.11" +hmac = "0.13" zstd = "0.13" tempfile = "3" walkdir = "2" diff --git a/src/cache/aster_srv.rs b/src/cache/aster_srv.rs index c0c3a798..6fbb446a 100644 --- a/src/cache/aster_srv.rs +++ b/src/cache/aster_srv.rs @@ -115,14 +115,19 @@ impl AsterSrvBackend { pub fn from_env(url: Option<&str>, repo: Option<&str>) -> Option { let env = |name: &str| std::env::var(name).ok(); let workspace = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - Self::from_parts( + let backend = Self::from_parts( url, repo, Credentials::from_env(&env), proxy_token_from_env(&env), iap::token_path(&workspace), env(iap::IAP_CLIENT_ID_ENV), - ) + )?; + if let Some(notice) = signature_exclusion_notice(super::signature::signing_key().is_some()) + { + eprintln!("{notice}"); + } + Some(backend) } fn from_parts( @@ -502,11 +507,14 @@ impl CacheBackend for AsterSrvBackend { if self.read_only() { return Ok(()); } + let encoded = manifest.encode(); let body = json!({ "repo": self.repo, "key": manifest.key, - "manifest": BASE64.encode(manifest.encode()), + "manifest": BASE64.encode(&encoded), }); + // Do not attach a signature. aster-srv drops unknown commit fields + // and lookup does not return one, so a required sig would miss every hit. match self.post("/v1/cache/commit", &body, true) { Ok(_) => Ok(()), Err(error) if self.read_only() => { @@ -595,6 +603,17 @@ impl CacheBackend for AsterSrvBackend { } } +/// aster-srv is excluded from manifest signing: the server drops a `sig` +/// on commit and never returns one, so lookups trust its authenticated +/// API instead. When a key is set, say so once rather than skip the check +/// silently. +fn signature_exclusion_notice(key_set: bool) -> Option<&'static str> { + key_set.then_some( + "[aster] Note: ASTER_REMOTE_CACHE_SIGNATURE_KEY does not apply to the aster-srv cache; \ + its manifests are not signature-verified and are trusted from the authenticated server", + ) +} + /// Downcast is unavailable across crate errors, so 409 is matched on the /// message `post` builds. The status is the only token in that message. fn is_conflict(error: &anyhow::Error) -> bool { @@ -610,6 +629,7 @@ fn decode_hit(entry: &Value) -> Result> { .decode(encoded.trim()) .context("lookup manifest is not base64")?; // An undecodable manifest is a miss, same as a local decode failure. + // aster-srv does not store or return a signature (see put_manifest). let Some(manifest) = Manifest::decode(&bytes) else { return Ok(None); }; @@ -753,6 +773,7 @@ mod tests { uploads: Arc, commits: Arc, objects: Arc>>>, + manifests: Arc>>>, commit_status: Arc>, /// When set, a lookup without Proxy-Authorization is an IAP challenge. challenge: Arc, @@ -879,6 +900,7 @@ mod tests { uploads, commits, objects, + manifests, commit_status, challenge, proxy_seen, @@ -1217,4 +1239,52 @@ mod tests { let again = reader.get_manifest("anything").unwrap_err(); assert!(again.to_string().contains("not signed in"), "{again:#}"); } + + #[test] + fn signature_key_does_not_change_the_aster_srv_commit() { + let _guard = env_lock(); + let previous = std::env::var_os("ASTER_REMOTE_CACHE_SIGNATURE_KEY"); + std::env::set_var("ASTER_REMOTE_CACHE_SIGNATURE_KEY", "srv-test-key"); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let fake = Fake::spawn(); + let digest = Digest::of_bytes(b"signed"); + let stored = manifest(&"99".repeat(32), &digest); + backend(&fake).put_manifest(&stored).unwrap(); + let saved = fake + .manifests + .lock() + .unwrap() + .get(&stored.key) + .cloned() + .unwrap(); + assert_eq!(saved, stored.encode()); + + let reader = backend(&fake); + reader.prefetch(std::slice::from_ref(&stored.key)).unwrap(); + assert_eq!(reader.get_manifest(&stored.key).unwrap().unwrap(), stored); + })); + match previous { + Some(value) => std::env::set_var("ASTER_REMOTE_CACHE_SIGNATURE_KEY", value), + None => std::env::remove_var("ASTER_REMOTE_CACHE_SIGNATURE_KEY"), + } + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } + } + + #[test] + fn signature_key_on_aster_srv_is_reported_not_silently_skipped() { + assert_eq!(signature_exclusion_notice(false), None); + let notice = signature_exclusion_notice(true).unwrap(); + assert!( + notice.contains("ASTER_REMOTE_CACHE_SIGNATURE_KEY"), + "{notice}" + ); + assert!(notice.contains("not signature-verified"), "{notice}"); + } + + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } } diff --git a/src/cache/backend.rs b/src/cache/backend.rs index 0649aa08..84ec1743 100644 --- a/src/cache/backend.rs +++ b/src/cache/backend.rs @@ -1035,4 +1035,103 @@ mod tests { self } } + + /// Remote that stores the HTTP envelope. Used to prove a key rejects an + /// unsigned manifest before any blob is restored. + struct SignedBackend { + objects: Mutex>>, + key: Option, + } + + impl SignedBackend { + fn new(key: Option<&str>) -> Self { + Self { + objects: Mutex::new(HashMap::new()), + key: key.map(str::to_string), + } + } + } + + impl CacheBackend for SignedBackend { + fn get_manifest(&self, key: &str) -> Result> { + let objects = self.objects.lock().unwrap(); + let Some(bytes) = objects.get(key) else { + return Ok(None); + }; + Ok(crate::cache::signature::open(self.key.as_deref(), bytes)) + } + + fn put_manifest(&self, manifest: &Manifest) -> Result<()> { + let bytes = crate::cache::signature::seal(self.key.as_deref(), manifest); + self.objects + .lock() + .unwrap() + .insert(manifest.key.clone(), bytes); + Ok(()) + } + + fn find_missing(&self, digests: &[Digest]) -> Result> { + let objects = self.objects.lock().unwrap(); + Ok(digests + .iter() + .filter(|digest| !objects.contains_key(digest.as_str())) + .cloned() + .collect()) + } + + fn get_blob(&self, digest: &Digest, writer: &mut dyn Write) -> Result { + let objects = self.objects.lock().unwrap(); + let Some(bytes) = objects.get(digest.as_str()) else { + return Ok(false); + }; + writer.write_all(bytes)?; + Ok(true) + } + + fn put_blob(&self, digest: &Digest, reader: &mut dyn Read) -> Result<()> { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes)?; + self.objects + .lock() + .unwrap() + .insert(digest.as_str().to_string(), bytes); + Ok(()) + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } + } + + #[test] + fn unsigned_remote_manifest_is_a_miss_when_a_key_is_set() { + let tmp = tempfile::tempdir().unwrap(); + let key = "77".repeat(32); + let digest = Digest::of_bytes(b"should-not-restore"); + let remote = SignedBackend::new(Some("tier-key")); + remote + .put_blob(&digest, &mut &b"should-not-restore"[..]) + .unwrap(); + // Plant the historical unsigned body under a key the reader requires. + remote + .objects + .lock() + .unwrap() + .insert(key.clone(), manifest(&key, Some(&digest)).encode()); + let tier = TieredCache::with_remote( + LocalStore::new(tmp.path().to_path_buf()), + CacheMode::ReadWrite, + Arc::new(remote), + ); + + assert!(tier.lookup(&key).unwrap().is_none()); + assert!(!tier.local_store().has_blob(&digest)); + assert!(tier.local_store().get_manifest(&key).unwrap().is_none()); + // A bad signature is a miss, not a remote failure. + assert!(tier.remote_enabled()); + for _ in 0..FAILURES_BEFORE_DISABLE { + assert!(tier.lookup(&key).unwrap().is_none()); + } + assert!(tier.remote_enabled()); + } } diff --git a/src/cache/http.rs b/src/cache/http.rs index b8f239d4..4149d673 100644 --- a/src/cache/http.rs +++ b/src/cache/http.rs @@ -30,6 +30,7 @@ use ureq::Body; use super::artifact::{Digest, Manifest}; use super::backend::CacheBackend; +use super::signature; /// `ASTER_REMOTE_CACHE_URL`. Base URL of a bazel-remote-compatible server. pub const REMOTE_CACHE_URL_ENV: &str = "ASTER_REMOTE_CACHE_URL"; @@ -49,6 +50,11 @@ const RECV_BODY_TIMEOUT: Duration = Duration::from_secs(3600); const AC_KEY_LEN: usize = 64; /// HTTP cache selected by `ASTER_REMOTE_CACHE=http`. +/// +/// When [`super::signature::SIGNATURE_KEY_ENV`] is set, action-cache +/// objects are a JSON envelope signed over [`super::artifact::Manifest::encode`]. +/// A missing or wrong signature is a miss. Blobs stay unsigned: the manifest +/// names their digests, and restore checks those digests. pub struct HttpCache { base: String, token: Option, @@ -257,14 +263,14 @@ impl CacheBackend for HttpCache { Get::Hit(response) => { let bytes = read_limited(response, MANIFEST_LIMIT).context("read remote manifest")?; - Ok(Manifest::decode(&bytes)) + Ok(signature::open(signature::signing_key().as_deref(), &bytes)) } } } fn put_manifest(&self, manifest: &Manifest) -> Result<()> { validate_ac_key(&manifest.key)?; - let bytes = manifest.encode(); + let bytes = signature::seal(signature::signing_key().as_deref(), manifest); self.put_bytes( &self.object_url("ac", &manifest.key), &bytes, diff --git a/src/cache/mod.rs b/src/cache/mod.rs index b673360c..ef188f30 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -19,6 +19,7 @@ pub mod matcher; mod outputs; mod prune; mod runtime; +mod signature; mod store; pub use artifact::{Digest, Manifest, OutputEntry, OutputKind, MANIFEST_SCHEMA_VERSION}; @@ -35,6 +36,7 @@ pub use matcher::TargetInputMatcher; pub use outputs::{pack, restore}; pub use prune::{prune_cache, prune_if_over_cap, PruneLimits, DEFAULT_MAX_AGE, DEFAULT_MAX_SIZE}; pub use runtime::RuntimeInputCache; +pub use signature::SIGNATURE_KEY_ENV; pub use store::CacheStore; /// Environment variable that overrides the cache directory. diff --git a/src/cache/signature.rs b/src/cache/signature.rs new file mode 100644 index 00000000..33447f3a --- /dev/null +++ b/src/cache/signature.rs @@ -0,0 +1,231 @@ +//! HMAC-SHA256 over remote manifest bytes. +//! +//! Local manifests stay unsigned: the disk is trusted. A remote object is +//! signed only when [`SIGNATURE_KEY_ENV`] is set. The MAC covers the exact +//! [`super::artifact::Manifest::encode`] bytes, which already name every blob +//! digest, so a swapped blob fails restore even if the envelope is rewritten. +//! +//! HTTP action-cache objects become a JSON envelope so bazel-remote still +//! sees `application/json`. The envelope carries `signed: true`, which a +//! [`Manifest`] does not, so an unsigned reader cannot decode the wrapper +//! as a manifest and ignore `sig`. aster-srv stores the encode bytes in +//! Postgres and does not return unknown fields, so this client does not +//! sign that path: a signature the server drops would make every lookup a +//! miss. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use hmac::{Hmac, KeyInit, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +use super::artifact::Manifest; + +/// `ASTER_REMOTE_CACHE_SIGNATURE_KEY`. Unset or empty means remote manifests +/// are unsigned, which is the historical wire format. +/// +/// Applies to the HTTP backend only. aster-srv is explicitly excluded (it +/// cannot store a signature) and prints a notice when this key is set. +pub const SIGNATURE_KEY_ENV: &str = "ASTER_REMOTE_CACHE_SIGNATURE_KEY"; + +type HmacSha256 = Hmac; + +static WARNED: AtomicBool = AtomicBool::new(false); + +/// Trimmed key, or `None` when unset or whitespace. +pub fn signing_key() -> Option { + std::env::var(SIGNATURE_KEY_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +/// Lowercase hex HMAC-SHA256 of `bytes` under `key`. +pub fn sign(key: &str, bytes: &[u8]) -> String { + let mut mac = HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC accepts any key length"); + mac.update(bytes); + hex_encode(&mac.finalize().into_bytes()) +} + +/// Constant-time check that `sig` is the hex HMAC of `bytes` under `key`. +pub fn verify(key: &str, bytes: &[u8], sig: &str) -> bool { + let Ok(expected) = hex_decode(sig.trim()) else { + return false; + }; + let mut mac = HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC accepts any key length"); + mac.update(bytes); + mac.verify_slice(&expected).is_ok() +} + +/// HTTP action-cache body. `manifest` is the UTF-8 of [`Manifest::encode`]. +/// +/// `signed` is required and must be `true`. [`Manifest`] has no such field, +/// so [`Manifest::decode`] rejects this object instead of skipping `sig`. +#[derive(Debug, Serialize, Deserialize)] +struct Envelope { + manifest: String, + sig: Option, + signed: bool, +} + +/// Bytes to `PUT` for `manifest`. Unsigned when `key` is `None`. +pub fn seal(key: Option<&str>, manifest: &Manifest) -> Vec { + let encoded = manifest.encode(); + let Some(key) = key else { + return encoded; + }; + let envelope = Envelope { + manifest: String::from_utf8(encoded.clone()).expect("manifest JSON is UTF-8"), + sig: Some(sign(key, &encoded)), + signed: true, + }; + serde_json::to_vec(&envelope).expect("signature envelope is JSON") +} + +/// Manifest inside a remote body, or a miss. +/// +/// With no key, a plain manifest decodes and an envelope is a miss: the +/// historical reader does not understand the wrapper. With a key, a missing +/// or wrong `sig` is a miss and warns once. The key is never included in the +/// warning. +pub fn open(key: Option<&str>, bytes: &[u8]) -> Option { + match key { + None => Manifest::decode(bytes), + Some(key) => open_signed(key, bytes), + } +} + +fn open_signed(key: &str, bytes: &[u8]) -> Option { + let envelope = match serde_json::from_slice::(bytes) { + Ok(envelope) if envelope.signed => envelope, + _ => { + warn_once(); + return None; + } + }; + let Some(sig) = envelope.sig.as_deref().filter(|sig| !sig.is_empty()) else { + warn_once(); + return None; + }; + let manifest_bytes = envelope.manifest.as_bytes(); + if !verify(key, manifest_bytes, sig) { + warn_once(); + return None; + } + Manifest::decode(manifest_bytes) +} + +fn warn_once() { + if WARNED + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + eprintln!( + "[aster] Warning: remote cache manifest signature is missing or invalid; treating it as a cache miss" + ); + } +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0xf) as usize] as char); + } + out +} + +fn hex_decode(sig: &str) -> Result, ()> { + if sig.len() != 64 || !sig.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(()); + } + let mut out = Vec::with_capacity(32); + let bytes = sig.as_bytes(); + for index in (0..64).step_by(2) { + let high = hex_value(bytes[index])?; + let low = hex_value(bytes[index + 1])?; + out.push((high << 4) | low); + } + Ok(out) +} + +fn hex_value(byte: u8) -> Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + b'A'..=b'F' => Ok(byte - b'A' + 10), + _ => Err(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::artifact::{Digest, OutputEntry, OutputKind, MANIFEST_SCHEMA_VERSION}; + + fn manifest() -> Manifest { + Manifest { + schema: MANIFEST_SCHEMA_VERSION, + key: "ab".repeat(32), + target: "//app:build".to_string(), + exit_code: 0, + duration_ms: 12, + created_at: "2026-10-20T00:00:00Z".to_string(), + outputs: vec![OutputEntry { + path: "out/app".to_string(), + kind: OutputKind::File, + digest: Some(Digest::of_bytes(b"blob")), + mode: 0o644, + target: None, + }], + log: None, + aster_version: "0.21.0".to_string(), + } + } + + /// Fresh per-run key, so no secret-shaped literal lives in source. + fn random_key() -> String { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).expect("OS randomness"); + hex_encode(&bytes) + } + + #[test] + fn hmac_sha256_matches_rfc4231_case_1() { + // RFC 4231 test case 1. Key is 20 bytes of 0x0b; data is "Hi There". + let key = "\u{0b}".repeat(20); + assert_eq!( + sign(&key, b"Hi There"), + "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" + ); + } + + #[test] + fn valid_signature_hits_and_tamper_or_wrong_key_misses() { + let key = random_key(); + let other_key = random_key(); + let manifest = manifest(); + let bytes = manifest.encode(); + let sealed = seal(Some(&key), &manifest); + let hit = open(Some(&key), &sealed).expect("valid signature is a hit"); + assert_eq!(hit, manifest); + + let mut tampered: serde_json::Value = serde_json::from_slice(&sealed).unwrap(); + tampered["manifest"] = + serde_json::Value::String(String::from_utf8(bytes.clone()).unwrap() + " "); + let tampered_bytes = serde_json::to_vec(&tampered).unwrap(); + assert!(open(Some(&key), &tampered_bytes).is_none()); + + assert!(open(Some(&other_key), &sealed).is_none()); + assert!(open(Some(&key), &bytes).is_none()); + + // No key keeps the historical unsigned wire, and does not accept an + // envelope it cannot unwrap. Manifest::decode must not either: the + // envelope has no manifest fields, only a string named `manifest`. + assert_eq!(seal(None, &manifest), bytes); + assert_eq!(open(None, &bytes), Some(manifest)); + assert!(open(None, &sealed).is_none()); + assert!(Manifest::decode(&sealed).is_none()); + } +} diff --git a/tests/cache_http.rs b/tests/cache_http.rs index c35f511d..f8d1ae77 100644 --- a/tests/cache_http.rs +++ b/tests/cache_http.rs @@ -301,6 +301,7 @@ fn client(server: &Server) -> HttpCache { #[test] fn upload_from_a_hits_from_b() { + let _guard = env_lock(); let server = Server::spawn(); let bytes = b"artifact-from-a"; let digest = Digest::of_bytes(bytes); @@ -329,6 +330,7 @@ fn upload_from_a_hits_from_b() { #[test] fn get_404_is_a_miss() { + let _guard = env_lock(); let server = Server::spawn(); let cache = client(&server); let key = "cd".repeat(32); @@ -341,6 +343,7 @@ fn get_404_is_a_miss() { #[test] fn put_409_is_success() { + let _guard = env_lock(); let server = Server::spawn(); let bytes = b"already-there"; let digest = Digest::of_bytes(bytes); @@ -357,8 +360,94 @@ fn put_409_is_success() { assert_eq!(downloaded, bytes); } +#[test] +fn signed_manifest_round_trips_and_tamper_or_wrong_key_is_a_miss() { + let _guard = env_lock(); + let _restore = EnvRestore::capture(); + std::env::set_var(aster::cache::SIGNATURE_KEY_ENV, "http-test-key"); + + let server = Server::spawn(); + let bytes = b"signed-artifact"; + let digest = Digest::of_bytes(bytes); + let key = "33".repeat(32); + let stored = manifest(&key, &digest); + let writer = client(&server); + writer.put_blob(&digest, &mut bytes.as_slice()).unwrap(); + writer.put_manifest(&stored).unwrap(); + + let stored_body = server + .objects + .lock() + .unwrap() + .get(&format!("/ac/{key}")) + .cloned() + .unwrap(); + let envelope: serde_json::Value = serde_json::from_slice(&stored_body).unwrap(); + assert!(envelope["sig"].as_str().unwrap().len() == 64); + assert_ne!(stored_body, stored.encode()); + + let reader = client(&server); + assert_eq!(reader.get_manifest(&key).unwrap().unwrap(), stored); + + // Tamper the signed manifest bytes. The envelope still parses. + let mut tampered = envelope.clone(); + let inner = tampered["manifest"].as_str().unwrap().to_string() + " "; + tampered["manifest"] = serde_json::Value::String(inner); + server + .objects + .lock() + .unwrap() + .insert(format!("/ac/{key}"), serde_json::to_vec(&tampered).unwrap()); + assert!(reader.get_manifest(&key).unwrap().is_none()); + + // A different key does not restore the original envelope. + server + .objects + .lock() + .unwrap() + .insert(format!("/ac/{key}"), stored_body); + std::env::set_var(aster::cache::SIGNATURE_KEY_ENV, "other-key"); + assert!(reader.get_manifest(&key).unwrap().is_none()); + + // Unsigned JSON is a miss while a key is configured. + server + .objects + .lock() + .unwrap() + .insert(format!("/ac/{key}"), stored.encode()); + std::env::set_var(aster::cache::SIGNATURE_KEY_ENV, "http-test-key"); + assert!(reader.get_manifest(&key).unwrap().is_none()); +} + +struct EnvRestore { + previous: Option, +} + +fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +impl EnvRestore { + fn capture() -> Self { + Self { + previous: std::env::var_os(aster::cache::SIGNATURE_KEY_ENV), + } + } +} + +impl Drop for EnvRestore { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => std::env::set_var(aster::cache::SIGNATURE_KEY_ENV, value), + None => std::env::remove_var(aster::cache::SIGNATURE_KEY_ENV), + } + } +} + #[test] fn put_403_degrades_to_read_only() { + let _guard = env_lock(); let server = Server::spawn(); let bytes = b"readable"; let digest = Digest::of_bytes(bytes); @@ -426,6 +515,7 @@ fn large_blob_is_streamed() { #[test] fn tier_upload_from_a_hits_from_b() { + let _guard = env_lock(); let server = Server::spawn(); let bytes = b"tier-bytes"; let digest = Digest::of_bytes(bytes); @@ -456,6 +546,7 @@ fn tier_upload_from_a_hits_from_b() { #[test] fn tier_403_does_not_publish_a_manifest() { + let _guard = env_lock(); let server = Server::spawn(); let bytes = b"not-uploaded"; let digest = Digest::of_bytes(bytes); @@ -486,6 +577,7 @@ fn tier_403_does_not_publish_a_manifest() { #[test] fn env_selects_http_backend() { + let _guard = env_lock(); let server = Server::spawn(); let previous_kind = std::env::var_os("ASTER_REMOTE_CACHE"); let previous_url = std::env::var_os("ASTER_REMOTE_CACHE_URL");