#![deny(missing_docs)]
use serde::{Deserialize, Serialize};
use sha2::Digest as _;
use std::collections::BTreeMap;
use std::fmt;
use std::fs::File;
use std::io::Read;
use std::path::Path;
pub const PROTOCOL_VERSION: u8 = 1;
pub const PROTOCOL_HEADER: &str = "mbx-cache-protocol";
pub const NAMESPACE_HEADER: &str = "mbx-cache-namespace";
pub const ACTION_RESULT_MEDIA_TYPE: &str = "application/vnd.mbx.cache-action-result.v1+json";
pub const DIRECTORY_MEDIA_TYPE: &str = "application/vnd.mbx.cache-directory.v1+json";
pub const CLIENT_METADATA_MEDIA_TYPE: &str = "application/vnd.mbx.cache-client-metadata.v1+json";
pub const TASK_ACTION_MANIFEST_MEDIA_TYPE: &str =
"application/vnd.mbx.cache-task-action-manifest.v1+json";
pub const BLOB_MEDIA_TYPE: &str = "application/octet-stream";
pub const BLOB_PACK_MEDIA_TYPE: &str = "application/vnd.mbx.cache-blob-pack.v1";
pub const DIGEST_LIST_MEDIA_TYPE: &str = "application/vnd.mbx.cache-digests.v1+json";
pub const ACTION_RESULT_BATCH_MEDIA_TYPE: &str =
"application/vnd.mbx.cache-action-result-batch.v1+json";
pub const BLOB_PACK_RECEIPT_MEDIA_TYPE: &str =
"application/vnd.mbx.cache-blob-pack-receipt.v1+json";
pub const BLOB_PACK_BLOBS_HEADER: &str = "mbx-cache-pack-blobs";
pub const BLOB_PACK_BYTES_HEADER: &str = "mbx-cache-pack-bytes";
pub const BLOB_PACK_MAGIC: &[u8; 8] = b"MBXPACK1";
pub const BLOB_PACK_HEADER_BYTES: u64 = 1 + 32 + 8;
pub const MAX_BATCH_ITEMS: usize = 10_000;
pub const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
pub const MAX_ACTION_PREDICTION_PAYLOAD: usize = 256 * 1024;
pub fn canonical_json(value: &impl Serialize) -> serde_json::Result<Vec<u8>> {
serde_json_canonicalizer::to_vec(value)
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Digest {
pub algorithm: String,
pub hash: String,
pub size: u64,
}
impl Digest {
pub fn blake3(bytes: &[u8]) -> Self {
Self {
algorithm: DigestAlgorithm::Blake3.into(),
hash: blake3::hash(bytes).to_hex().to_string(),
size: bytes.len() as u64,
}
}
pub fn blake3_file(path: &Path) -> eyre::Result<Self> {
let (hash, size) = hash_file(path, DigestAlgorithm::Blake3)?;
Ok(Self {
algorithm: DigestAlgorithm::Blake3.into(),
hash,
size,
})
}
pub fn validate(&self) -> eyre::Result<()> {
self.algorithm_kind()?;
if self.hash.len() != 64
|| !self
.hash
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
eyre::bail!("invalid remote cache digest");
}
Ok(())
}
pub fn matches_bytes(&self, bytes: &[u8]) -> eyre::Result<bool> {
self.validate()?;
if self.size != bytes.len() as u64 {
return Ok(false);
}
let hash = match self.algorithm_kind()? {
DigestAlgorithm::Blake3 => blake3::hash(bytes).to_hex().to_string(),
DigestAlgorithm::Sha256 => hex::encode(sha2::Sha256::digest(bytes)),
};
Ok(self.hash == hash)
}
pub fn matches_file(&self, path: &Path) -> eyre::Result<bool> {
self.validate()?;
let (hash, size) = hash_file(path, self.algorithm_kind()?)?;
Ok(self.size == size && self.hash == hash)
}
pub fn key(&self) -> String {
format!("{}/{}/{}", self.algorithm, self.hash, self.size)
}
pub fn algorithm_kind(&self) -> eyre::Result<DigestAlgorithm> {
Ok(self.algorithm.parse()?)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DigestAlgorithm {
Blake3,
Sha256,
}
impl DigestAlgorithm {
pub const fn as_str(self) -> &'static str {
match self {
Self::Blake3 => "blake3",
Self::Sha256 => "sha256",
}
}
}
impl fmt::Display for DigestAlgorithm {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl std::str::FromStr for DigestAlgorithm {
type Err = ParseDigestAlgorithmError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"blake3" => Ok(Self::Blake3),
"sha256" => Ok(Self::Sha256),
_ => Err(ParseDigestAlgorithmError),
}
}
}
impl From<DigestAlgorithm> for String {
fn from(algorithm: DigestAlgorithm) -> Self {
algorithm.as_str().into()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ParseDigestAlgorithmError;
impl fmt::Display for ParseDigestAlgorithmError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("unsupported remote cache digest algorithm")
}
}
impl std::error::Error for ParseDigestAlgorithmError {}
fn hash_file(path: &Path, algorithm: DigestAlgorithm) -> eyre::Result<(String, u64)> {
let mut file = File::open(path)?;
let mut buffer = [0; 64 * 1024];
let mut size = 0;
let mut blake3 = blake3::Hasher::new();
let mut sha256 = sha2::Sha256::new();
loop {
let count = file.read(&mut buffer)?;
if count == 0 {
break;
}
match algorithm {
DigestAlgorithm::Blake3 => {
blake3.update(&buffer[..count]);
}
DigestAlgorithm::Sha256 => {
sha256.update(&buffer[..count]);
}
}
size += count as u64;
}
let hash = match algorithm {
DigestAlgorithm::Blake3 => blake3.finalize().to_hex().to_string(),
DigestAlgorithm::Sha256 => hex::encode(sha256.finalize()),
};
Ok((hash, size))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ActionResult {
pub action: Digest,
#[serde(default)]
pub metadata: Option<Digest>,
#[serde(default)]
pub output_root: Option<Digest>,
pub version: u8,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Directory {
pub directories: Vec<DirectoryNode>,
pub files: Vec<FileNode>,
pub symlinks: Vec<SymlinkNode>,
pub version: u8,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DirectoryNode {
pub digest: Digest,
pub mode: u32,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileNode {
pub digest: Digest,
pub executable: bool,
pub mode: u32,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SymlinkNode {
pub mode: u32,
pub name: String,
pub target: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RustcMetadata {
pub version: u8,
pub kind: String,
pub stdout: Digest,
pub stderr: Digest,
}
impl RustcMetadata {
pub fn validate(&self) -> bool {
self.version == 1
&& self.kind == "rustc"
&& self.stdout.validate().is_ok()
&& self.stderr.validate().is_ok()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CcMetadata {
pub version: u8,
pub kind: String,
pub stdout: Digest,
pub stderr: Digest,
}
impl CcMetadata {
pub fn validate(&self) -> bool {
self.version == 1
&& self.kind == "cc"
&& self.stdout.validate().is_ok()
&& self.stderr.validate().is_ok()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ActionPrediction {
pub invocation: Digest,
pub action: Digest,
pub adapter: String,
pub payload: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TaskActionManifest {
pub version: u8,
pub task: String,
pub predictions: Vec<ActionPrediction>,
}
#[derive(Serialize)]
struct TaskActionManifestSelector<'a> {
kind: &'static str,
task: &'a str,
version: u8,
}
impl TaskActionManifest {
pub fn validate(&self) -> bool {
let mut invocations = std::collections::BTreeSet::new();
self.version == 1
&& valid_task_identity(&self.task)
&& self.predictions.len() <= MAX_TASK_ACTION_PREDICTIONS
&& self.predictions.iter().all(|prediction| {
prediction.validate() && invocations.insert(&prediction.invocation)
})
}
pub fn selector_digest(&self) -> Digest {
Self::selector(&self.task)
.expect("manifest task identity must be valid")
.1
}
pub fn selector(task: &str) -> eyre::Result<(Vec<u8>, Digest)> {
if !valid_task_identity(task) {
eyre::bail!("invalid task action manifest identity");
}
let selector = canonical_json(&TaskActionManifestSelector {
kind: "task_action_manifest",
task,
version: 1,
})?;
let digest = Digest::blake3(&selector);
Ok((selector, digest))
}
}
impl ActionPrediction {
pub fn validate(&self) -> bool {
self.action.algorithm == DigestAlgorithm::Blake3.as_str()
&& self.action.validate().is_ok()
&& self.invocation.algorithm == DigestAlgorithm::Blake3.as_str()
&& self.invocation.validate().is_ok()
&& !self.adapter.is_empty()
&& self
.adapter
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
&& self.payload.len() <= MAX_ACTION_PREDICTION_PAYLOAD
&& serde_json::from_str::<serde_json::Value>(&self.payload).is_ok()
}
}
fn valid_task_identity(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CapabilityProtocol {
pub major: u8,
#[serde(default)]
pub minor: u8,
}
impl CapabilityProtocol {
pub fn new(major: u8, minor: u8) -> Self {
Self { major, minor }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ActionKindCapability {
pub action_schema: u8,
pub metadata_schema: u8,
}
impl ActionKindCapability {
pub fn new(action_schema: u8, metadata_schema: u8) -> Self {
Self {
action_schema,
metadata_schema,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CapabilityFeatures {
#[serde(default)]
pub action_manifests: bool,
#[serde(default)]
pub batch: bool,
#[serde(default)]
pub action_batch: bool,
#[serde(default)]
pub blob_packs: bool,
#[serde(default)]
pub blob_pack_uploads: bool,
#[serde(default)]
pub resumable_uploads: bool,
#[serde(default)]
pub delegated_transfers: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CapabilityLimits {
#[serde(default)]
pub max_batch_items: u64,
#[serde(default)]
pub max_inline_blob_bytes: u64,
#[serde(default)]
pub max_blob_bytes: u64,
#[serde(default)]
pub max_pack_bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Capabilities {
pub protocol: CapabilityProtocol,
#[serde(default)]
pub digest_algorithms: Vec<String>,
#[serde(default)]
pub compressors: Vec<String>,
#[serde(default)]
pub action_kinds: BTreeMap<String, ActionKindCapability>,
#[serde(default)]
pub features: CapabilityFeatures,
#[serde(default)]
pub limits: CapabilityLimits,
}
impl Capabilities {
pub fn new(protocol: CapabilityProtocol) -> Self {
Self {
protocol,
digest_algorithms: Vec::new(),
compressors: Vec::new(),
action_kinds: BTreeMap::new(),
features: CapabilityFeatures::default(),
limits: CapabilityLimits::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn digest_validation_is_exact() {
let valid = Digest {
algorithm: DigestAlgorithm::Blake3.into(),
hash: "a".repeat(64),
size: 42,
};
assert!(valid.validate().is_ok());
assert!(
Digest {
hash: "A".repeat(64),
..valid.clone()
}
.validate()
.is_err()
);
assert!(
Digest {
algorithm: "md5".into(),
..valid
}
.validate()
.is_err()
);
}
#[test]
fn canonical_json_is_independent_of_map_insertion_order() {
#[derive(Serialize)]
struct ZThenA {
z: u8,
a: bool,
}
#[derive(Serialize)]
struct AThenZ {
a: bool,
z: u8,
}
assert_eq!(
canonical_json(&ZThenA { z: 1, a: true }).unwrap(),
canonical_json(&AThenZ { a: true, z: 1 }).unwrap()
);
}
}