Skip to content
Merged
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 @@ -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"
Expand Down
76 changes: 73 additions & 3 deletions src/cache/aster_srv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,19 @@ impl AsterSrvBackend {
pub fn from_env(url: Option<&str>, repo: Option<&str>) -> Option<Self> {
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(
Expand Down Expand Up @@ -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() => {
Expand Down Expand Up @@ -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 {
Expand All @@ -610,6 +629,7 @@ fn decode_hit(entry: &Value) -> Result<Option<Hit>> {
.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);
};
Expand Down Expand Up @@ -753,6 +773,7 @@ mod tests {
uploads: Arc<AtomicUsize>,
commits: Arc<AtomicUsize>,
objects: Arc<Mutex<HashMap<String, Vec<u8>>>>,
manifests: Arc<Mutex<HashMap<String, Vec<u8>>>>,
commit_status: Arc<Mutex<u16>>,
/// When set, a lookup without Proxy-Authorization is an IAP challenge.
challenge: Arc<AtomicBool>,
Expand Down Expand Up @@ -879,6 +900,7 @@ mod tests {
uploads,
commits,
objects,
manifests,
commit_status,
challenge,
proxy_seen,
Expand Down Expand Up @@ -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())
}
}
99 changes: 99 additions & 0 deletions src/cache/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HashMap<String, Vec<u8>>>,
key: Option<String>,
}

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<Option<Manifest>> {
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<Vec<Digest>> {
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<bool> {
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());
}
}
10 changes: 8 additions & 2 deletions src/cache/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<String>,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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.
Expand Down
Loading
Loading