use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
const RELEASE_MARKER_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct ReleaseFile {
path: String,
bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct ReleaseMarker {
version: u32,
release_id: String,
inputs: Vec<(String, String)>,
files: Vec<ReleaseFile>,
}
pub struct ReleaseBuild {
directory: tempfile::TempDir,
files: BTreeMap<String, ReleaseFile>,
inputs: Vec<(String, String)>,
}
impl ReleaseBuild {
pub fn new(releases_root: &Path) -> anyhow::Result<Self> {
std::fs::create_dir_all(releases_root)
.with_context(|| format!("create releases root {}", releases_root.display()))?;
let directory = tempfile::Builder::new()
.prefix(".building-")
.tempdir_in(releases_root)
.context("create private release staging")?;
Ok(Self {
directory,
files: BTreeMap::new(),
inputs: Vec::new(),
})
}
pub fn path(&self) -> &Path {
self.directory.path()
}
pub fn record_input(&mut self, kind: &str, identity: &str) {
self.inputs.push((kind.to_string(), identity.to_string()));
self.inputs.sort();
self.inputs.dedup();
}
pub fn finalize_tree(&mut self, relative_root: &str) -> anyhow::Result<()> {
let root = self.directory.path().join(relative_root);
let staged = crate::store::inventory_tree(&root, &[])?;
for file in staged {
let relative = format!("{relative_root}/{}", file.path);
if self.files.contains_key(&relative) {
continue;
}
self.files.insert(
relative.clone(),
ReleaseFile {
path: relative,
bytes: file.bytes,
},
);
}
Ok(())
}
pub fn inputs(&self) -> &[(String, String)] {
&self.inputs
}
pub fn publish(
&mut self,
releases_root: &Path,
release_id: &str,
) -> anyhow::Result<PublishedRelease> {
let destination = releases_root.join(release_id);
if destination.exists() {
let existing = PublishedRelease::open(&destination)?;
match existing.validate() {
Ok(()) => return Ok(existing),
Err(error) => {
tracing::warn!(
"release {release_id} exists but is incomplete or corrupt ({error:#}); replacing it"
);
std::fs::remove_dir_all(&destination).with_context(|| {
format!("remove corrupt release {}", destination.display())
})?;
}
}
}
let marker = ReleaseMarker {
version: RELEASE_MARKER_VERSION,
release_id: release_id.to_string(),
inputs: self.inputs.clone(),
files: self.files.values().cloned().collect(),
};
let marker_bytes = serde_json::to_vec_pretty(&marker)?;
crate::cas::atomic_write(&self.directory.path().join("release.json"), &marker_bytes)?;
crate::cas::seal_read_only(self.directory.path()).context("seal release files")?;
std::fs::rename(self.directory.path(), &destination).with_context(|| {
format!("publish release {release_id} to {}", destination.display())
})?;
Ok(PublishedRelease {
path: destination,
marker,
})
}
}
pub fn release_id(repo_did: &str, inputs: &[(String, String)]) -> String {
let mut hasher = Sha256::new();
hasher.update(RELEASE_ID_VERSION.to_le_bytes());
hasher.update(b"repo-release");
hasher.update((repo_did.len() as u64).to_le_bytes());
hasher.update(repo_did.as_bytes());
for (kind, identity) in inputs {
hasher.update((kind.len() as u64).to_le_bytes());
hasher.update(kind.as_bytes());
hasher.update((identity.len() as u64).to_le_bytes());
hasher.update(identity.as_bytes());
}
crate::cas::Digest::from_raw(hasher.finalize().into()).hex()
}
/// bump when the marker changes. older binaries fail to reuse one they can't parse.
const RELEASE_ID_VERSION: u32 = 2;
pub struct PublishedRelease {
path: PathBuf,
marker: ReleaseMarker,
}
impl PublishedRelease {
pub fn open(path: &Path) -> anyhow::Result<Self> {
let bytes = std::fs::read(path.join("release.json"))
.with_context(|| format!("read release marker in {}", path.display()))?;
let marker: ReleaseMarker = serde_json::from_slice(&bytes)
.with_context(|| format!("parse release marker in {}", path.display()))?;
anyhow::ensure!(
marker.version == RELEASE_MARKER_VERSION,
"unsupported release marker version {}",
marker.version
);
Ok(Self {
path: path.to_path_buf(),
marker,
})
}
#[cfg(test)]
pub fn path(&self) -> &Path {
&self.path
}
#[cfg(test)]
fn marker(&self) -> &ReleaseMarker {
&self.marker
}
pub fn validate(&self) -> anyhow::Result<()> {
anyhow::ensure!(
self.marker.release_id == self.file_name(),
"release marker id does not match directory name"
);
for file in &self.marker.files {
let path = self.path.join(&file.path);
let metadata = std::fs::symlink_metadata(&path)
.with_context(|| format!("release file {} is missing", path.display()))?;
anyhow::ensure!(
metadata.is_file() && metadata.len() == file.bytes,
"release file {} length mismatch",
path.display()
);
}
Ok(())
}
fn file_name(&self) -> String {
self.path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn releases_root() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("releases");
std::fs::create_dir_all(&root).unwrap();
(dir, root)
}
fn stage_export(build: &mut ReleaseBuild, name: &str, contents: &[u8]) {
let path = build.path().join("export").join(name);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
build.finalize_tree("export").unwrap();
}
#[test]
fn publish_makes_a_complete_validated_release() {
let (_tmp, root) = releases_root();
let mut build = ReleaseBuild::new(&root).unwrap();
build.record_input("export", "fingerprint-1");
stage_export(&mut build, "HEAD", b"ref: refs/heads/main\n");
let destination = build.publish(&root, "rel-1").unwrap();
let validated = PublishedRelease::open(destination.path()).unwrap();
validated.validate().unwrap();
let marker = validated.marker();
assert_eq!(
marker.inputs,
vec![("export".into(), "fingerprint-1".into())]
);
assert_eq!(marker.files.len(), 1);
assert_eq!(marker.files[0].path, "export/HEAD");
assert_eq!(marker.files[0].bytes, 21);
assert!(destination.path().join("release.json").is_file());
crate::cas::sealed::assert_sealed(destination.path());
}
#[test]
fn validation_checks_length_and_never_rehashes() {
let (_tmp, root) = releases_root();
let mut build = ReleaseBuild::new(&root).unwrap();
stage_export(&mut build, "a.bin", b"0123456789");
let destination = build.publish(&root, "rel-1").unwrap();
let release = PublishedRelease::open(destination.path()).unwrap();
// validate only checks existence and length. same-length corruption passes
let file = destination.path().join("export/a.bin");
let corrupt = |bytes: &[u8]| {
std::fs::remove_file(&file).unwrap();
std::fs::write(&file, bytes).unwrap();
};
corrupt(b"XXXXXXXXXX");
release.validate().unwrap();
corrupt(b"abc");
assert!(release.validate().is_err());
}
#[test]
fn failed_publication_leaves_prior_release_unchanged() {
let (_tmp, root) = releases_root();
let mut build = ReleaseBuild::new(&root).unwrap();
stage_export(&mut build, "data.bin", b"first-release");
let destination = build.publish(&root, "rel-1").unwrap();
let before = destination.marker().clone();
let mut abandoned = ReleaseBuild::new(&root).unwrap();
stage_export(&mut abandoned, "data.bin", b"first-release");
drop(abandoned);
assert_eq!(before, *destination.marker());
assert!(destination.path().join("export/data.bin").is_file());
let mut again = ReleaseBuild::new(&root).unwrap();
stage_export(&mut again, "data.bin", b"first-release");
let reused = again.publish(&root, "rel-1").unwrap();
assert_eq!(reused.path(), destination.path());
assert_eq!(before, *destination.marker());
}
#[test]
fn finalize_tree_records_freshly_written_files_once() {
let (_tmp, root) = releases_root();
let mut build = ReleaseBuild::new(&root).unwrap();
stage_export(&mut build, "objects/pack/p.pack", b"pack");
stage_export(&mut build, "HEAD", b"ref: refs/heads/main\n");
let destination = build.publish(&root, "rel-1").unwrap();
let paths: Vec<String> = destination
.marker()
.files
.iter()
.map(|file| file.path.clone())
.collect();
assert_eq!(paths, ["export/HEAD", "export/objects/pack/p.pack"]);
}
#[test]
fn finalize_tree_never_reads_file_contents() {
use std::os::unix::fs::PermissionsExt as _;
let (_tmp, root) = releases_root();
let mut build = ReleaseBuild::new(&root).unwrap();
let pack = build.path().join("export/objects/pack/p.pack");
std::fs::create_dir_all(pack.parent().unwrap()).unwrap();
std::fs::write(&pack, b"pack").unwrap();
std::fs::set_permissions(&pack, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::File::open(&pack).is_ok() {
return;
}
build.finalize_tree("export").unwrap();
let destination = build.publish(&root, "rel-1").unwrap();
assert_eq!(destination.marker().files[0].bytes, 4);
}
#[test]
fn markers_with_digests_still_validate() {
let (_tmp, root) = releases_root();
let mut build = ReleaseBuild::new(&root).unwrap();
stage_export(&mut build, "HEAD", b"x");
let destination = build.publish(&root, "rel-1").unwrap();
let path = destination.path().join("release.json");
let mut marker: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
marker["files"][0]["sha256"] = "00".repeat(32).into();
std::fs::remove_file(&path).unwrap();
std::fs::write(&path, serde_json::to_vec(&marker).unwrap()).unwrap();
PublishedRelease::open(destination.path())
.unwrap()
.validate()
.unwrap();
}
#[test]
fn release_id_directory_holds_the_marker() {
let (_tmp, root) = releases_root();
let mut build = ReleaseBuild::new(&root).unwrap();
stage_export(&mut build, "HEAD", b"x");
let destination = build.publish(&root, "aaaa1111").unwrap();
assert!(destination.path().is_dir());
assert_eq!(destination.path().file_name().unwrap(), "aaaa1111");
}
}