use crate::digest::{DefaultDigest, Digest, FileDigester};
use crate::input::{BuildInput, BuildInputs};
use anyhow::{anyhow, bail, Context};
use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use std::marker::PhantomData;
use thiserror::Error;
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub const CACHE_SUBDIRECTORY: &str = "manifest-cache";
pub type Inputs = Vec<BuildInput>;
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
struct InputMap(Vec<InputEntry>);
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
struct InputEntry {
key: BuildInput,
value: Option<Digest>,
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactManifest<D = DefaultDigest> {
inputs: InputMap,
output_path: Utf8PathBuf,
phantom: PhantomData<D>,
}
impl<D: FileDigester> ArtifactManifest<D> {
async fn new(inputs: &BuildInputs, output_path: Utf8PathBuf) -> anyhow::Result<Self> {
let result = Self::new_internal(inputs, output_path, None).await?;
Ok(result)
}
async fn new_internal(
inputs: &BuildInputs,
output_path: Utf8PathBuf,
compare_with: Option<&Self>,
) -> Result<Self, CacheError> {
let input_entry_tasks = inputs.0.iter().cloned().enumerate().map(|(i, input)| {
let expected_input = compare_with.map(|manifest| &manifest.inputs.0[i]);
async move {
let digest = if let Some(input_path) = input.input_path() {
Some(D::get_digest(input_path).await?)
} else {
None
};
let input = InputEntry {
key: input.clone(),
value: digest,
};
if let Some(expected_input) = expected_input {
if *expected_input != input {
CacheError::miss(format!(
"Differing build inputs.\nSaw {:#?}\nExpected {:#?})",
input, expected_input
));
}
};
Ok::<_, CacheError>(input)
}
});
let inputs = InputMap(futures::future::try_join_all(input_entry_tasks).await?);
Ok(Self {
inputs,
output_path,
phantom: PhantomData,
})
}
async fn write_to(&self, path: &Utf8PathBuf) -> anyhow::Result<()> {
let Some(extension) = path.extension() else {
bail!("Missing extension?");
};
if extension != "json" {
bail!("JSON encoding is all we know. Write to a '.json' file?");
}
let serialized =
serde_json::to_string(&self).context("Failed to serialize ArtifactManifest to JSON")?;
let mut f = File::create(path).await?;
f.write_all(serialized.as_bytes()).await?;
Ok(())
}
async fn read_from(path: &Utf8PathBuf) -> Result<Self, CacheError> {
let Some(extension) = path.extension() else {
return Err(anyhow!("Missing extension?").into());
};
if extension != "json" {
return Err(anyhow!("JSON encoding is all we know. Read from a '.json' file?").into());
}
let mut f = match File::open(path).await {
Ok(f) => f,
Err(e) => {
if matches!(e.kind(), std::io::ErrorKind::NotFound) {
return Err(CacheError::miss(format!("File {} not found", path)));
} else {
return Err(anyhow!(e).into());
}
}
};
let mut buffer = String::new();
f.read_to_string(&mut buffer)
.await
.map_err(|e| anyhow!(e))?;
let Ok(manifest) = serde_json::from_str(&buffer) else {
return Err(CacheError::miss(format!(
"Cannot parse manifest at {}",
path
)));
};
Ok(manifest)
}
}
#[derive(Error, Debug)]
pub enum CacheError {
#[error("Cache Miss: {reason}")]
CacheMiss { reason: String },
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl CacheError {
fn miss<T: Into<String>>(t: T) -> Self {
CacheError::CacheMiss { reason: t.into() }
}
}
pub struct Cache {
disabled: bool,
cache_directory: Utf8PathBuf,
}
impl Cache {
pub async fn new(output_directory: &Utf8Path) -> anyhow::Result<Self> {
let cache_directory = output_directory.join(CACHE_SUBDIRECTORY);
tokio::fs::create_dir_all(&cache_directory).await?;
Ok(Self {
disabled: false,
cache_directory,
})
}
pub fn set_disable(&mut self, disable: bool) {
self.disabled = disable;
}
pub async fn lookup(
&self,
inputs: &BuildInputs,
output_path: &Utf8Path,
) -> Result<ArtifactManifest, CacheError> {
if self.disabled {
return Err(CacheError::miss("Cache disabled"));
}
let artifact_filename = output_path
.file_name()
.ok_or_else(|| CacheError::Other(anyhow!("Output has no file name")))?;
let mut manifest_filename = String::from(artifact_filename);
manifest_filename.push_str(".json");
let manifest_path = self.cache_directory.join(manifest_filename);
let manifest = ArtifactManifest::read_from(&manifest_path).await?;
if inputs
.0
.iter()
.ne(manifest.inputs.0.iter().map(|entry| &entry.key))
{
return Err(CacheError::miss("Set of inputs has changed"));
}
if output_path != manifest.output_path {
return Err(CacheError::miss(format!(
"Output path changed from {} -> {}",
manifest.output_path, output_path,
)));
}
if !tokio::fs::try_exists(&output_path)
.await
.map_err(|e| CacheError::miss(format!("Cannot locate output artifact: {e}")))?
{
return Err(CacheError::miss("Output does not exist"));
}
let Some(observed_filename) = manifest.output_path.file_name() else {
return Err(CacheError::miss(format!(
"Missing output file name from manifest {}",
manifest.output_path
)));
};
if observed_filename != artifact_filename {
return Err(CacheError::miss(format!(
"Wrong output name in manifest (saw {}, expected {})",
observed_filename, artifact_filename
)));
}
let calculated_manifest =
ArtifactManifest::new_internal(inputs, output_path.to_path_buf(), Some(&manifest))
.await?;
if calculated_manifest != manifest {
return Err(CacheError::miss("Manifests appear different"));
}
Ok(manifest)
}
pub async fn update(
&self,
inputs: &BuildInputs,
output_path: &Utf8Path,
) -> Result<(), CacheError> {
if self.disabled {
return Ok(());
}
let manifest =
ArtifactManifest::<DefaultDigest>::new(inputs, output_path.to_path_buf()).await?;
let Some(artifact_filename) = manifest.output_path.file_name() else {
return Err(anyhow!("Bad manifest: Missing output name").into());
};
let mut manifest_filename = String::from(artifact_filename);
manifest_filename.push_str(".json");
let manifest_path = self.cache_directory.join(manifest_filename);
manifest.write_to(&manifest_path).await?;
Ok(())
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::input::MappedPath;
use camino::Utf8PathBuf;
use camino_tempfile::{tempdir, Utf8TempDir};
struct CacheTest {
_input_dir: Utf8TempDir,
output_dir: Utf8TempDir,
input_path: Utf8PathBuf,
output_path: Utf8PathBuf,
}
impl CacheTest {
fn new() -> Self {
let input_dir = tempdir().unwrap();
let output_dir = tempdir().unwrap();
let input_path = input_dir.path().join("binary.exe");
let output_path = output_dir.path().join("output.tar.gz");
Self {
_input_dir: input_dir,
output_dir,
input_path,
output_path,
}
}
async fn create_input(&self, contents: &str) {
tokio::fs::write(&self.input_path, contents).await.unwrap()
}
async fn create_output(&self, contents: &str) {
tokio::fs::write(&self.output_path, contents).await.unwrap()
}
async fn remove_output(&self) {
tokio::fs::remove_file(&self.output_path).await.unwrap()
}
}
fn expect_missing_manifest(err: &CacheError, file: &str) {
match &err {
CacheError::CacheMiss { reason } => {
let expected = format!("{file}.json not found");
assert!(reason.contains(&expected), "{}", reason);
}
_ => panic!("Unexpected error: {}", err),
}
}
fn expect_cache_disabled(err: &CacheError) {
match &err {
CacheError::CacheMiss { reason } => {
assert!(reason.contains("Cache disabled"), "{}", reason);
}
_ => panic!("Unexpected error: {}", err),
}
}
fn expect_changed_manifests(err: &CacheError) {
match &err {
CacheError::CacheMiss { reason } => {
assert!(reason.contains("Manifests appear different"), "{}", reason);
}
_ => panic!("Unexpected error: {}", err),
}
}
fn expect_missing_output(err: &CacheError) {
match &err {
CacheError::CacheMiss { reason } => {
assert!(reason.contains("Output does not exist"), "{}", reason);
}
_ => panic!("Unexpected error: {}", err),
}
}
#[tokio::test]
async fn test_cache_lookup_misses_before_update() {
let test = CacheTest::new();
test.create_input("Hi I'm the input file").await;
let inputs = BuildInputs(vec![BuildInput::add_file(MappedPath {
from: test.input_path.to_path_buf(),
to: Utf8PathBuf::from("/very/important/file"),
})
.unwrap()]);
let cache = Cache::new(test.output_dir.path()).await.unwrap();
let err = cache.lookup(&inputs, &test.output_path).await.unwrap_err();
expect_missing_manifest(&err, "output.tar.gz");
test.create_output("Hi I'm the output file").await;
let err = cache.lookup(&inputs, &test.output_path).await.unwrap_err();
expect_missing_manifest(&err, "output.tar.gz");
}
#[tokio::test]
async fn test_cache_lookup_hits_after_update() {
let test = CacheTest::new();
test.create_input("Hi I'm the input file").await;
let inputs = BuildInputs(vec![BuildInput::add_file(MappedPath {
from: test.input_path.to_path_buf(),
to: Utf8PathBuf::from("/very/important/file"),
})
.unwrap()]);
test.create_output("Hi I'm the output file").await;
let cache = Cache::new(test.output_dir.path()).await.unwrap();
cache.update(&inputs, &test.output_path).await.unwrap();
cache.lookup(&inputs, &test.output_path).await.unwrap();
test.create_input("hi i'M tHe InPuT fIlE").await;
let err = cache.lookup(&inputs, &test.output_path).await.unwrap_err();
expect_changed_manifests(&err);
}
#[tokio::test]
async fn test_cache_lookup_misses_after_removing_output() {
let test = CacheTest::new();
test.create_input("Hi I'm the input file").await;
let inputs = BuildInputs(vec![BuildInput::add_file(MappedPath {
from: test.input_path.to_path_buf(),
to: Utf8PathBuf::from("/very/important/file"),
})
.unwrap()]);
test.create_output("Hi I'm the output file").await;
let cache = Cache::new(test.output_dir.path()).await.unwrap();
cache.update(&inputs, &test.output_path).await.unwrap();
cache.lookup(&inputs, &test.output_path).await.unwrap();
test.remove_output().await;
let err = cache.lookup(&inputs, &test.output_path).await.unwrap_err();
expect_missing_output(&err);
}
#[tokio::test]
async fn test_cache_disabled_always_misses() {
let test = CacheTest::new();
test.create_input("Hi I'm the input file").await;
let inputs = BuildInputs(vec![BuildInput::add_file(MappedPath {
from: test.input_path.to_path_buf(),
to: Utf8PathBuf::from("/very/important/file"),
})
.unwrap()]);
test.create_output("Hi I'm the output file").await;
let mut cache = Cache::new(test.output_dir.path()).await.unwrap();
cache.set_disable(true);
cache.update(&inputs, &test.output_path).await.unwrap();
let err = cache.lookup(&inputs, &test.output_path).await.unwrap_err();
expect_cache_disabled(&err);
}
}