use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::ForgeError;
use crate::fsutil::{acquire_named_lock, atomic_write_file, create_dir_all, remove_dir_all};
use crate::paths::app_home;
use crate::state::journal::load_pending;
use crate::state::read_registry;
use crate::util::{now_secs, valid_storage_id};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct VerifiedArtifact {
pub(crate) id: String,
pub(crate) root: PathBuf,
pub(crate) binaries: BTreeMap<String, PathBuf>,
pub(crate) sha256: String,
pub(crate) provenance: ArtifactProvenance,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ArtifactProvenance {
pub(crate) backend: String,
pub(crate) source: String,
pub(crate) revision: Option<String>,
pub(crate) fingerprint: String,
}
pub(crate) struct ArtifactStore {
root: PathBuf,
}
pub(crate) struct ArtifactLookup {
pub(crate) artifact: Option<VerifiedArtifact>,
pub(crate) quarantined: bool,
}
impl ArtifactStore {
pub(crate) fn new(root: Option<PathBuf>) -> Result<Self, ForgeError> {
let root = root.unwrap_or_else(|| app_home().join("artifacts"));
create_dir_all(&root)?;
Ok(Self { root })
}
pub(crate) fn put(&self, artifact: &VerifiedArtifact) -> Result<PathBuf, ForgeError> {
if !artifact.root.is_dir() {
return Err(ForgeError::Config(format!(
"verified artifact root is not a directory: {}",
artifact.root.display()
)));
}
validate_artifact_id(&artifact.id)?;
let _lease = acquire_named_lock("artifacts", &artifact.id)?;
let destination = self.root.join(&artifact.id);
if destination.exists() {
verify_existing(&destination, artifact)?;
return Ok(destination);
}
let temporary = self
.root
.join(format!(".{}.staging-{}", artifact.id, std::process::id()));
if temporary.exists() {
remove_dir_all(&temporary)?;
}
let result = (|| {
copy_tree(&artifact.root, &temporary)?;
write_manifest(&temporary, artifact)?;
make_read_only(&temporary)?;
fs::rename(&temporary, &destination).map_err(|source| ForgeError::Io {
path: destination.clone(),
source,
})?;
Ok(destination)
})();
if result.is_err() && temporary.exists() {
make_writable(&temporary);
let _ = remove_dir_all(&temporary);
}
result
}
pub(crate) fn get(
&self,
id: &str,
provenance: &ArtifactProvenance,
expected_binaries: &[String],
) -> Result<ArtifactLookup, ForgeError> {
validate_artifact_id(id)?;
let _lease = acquire_named_lock("artifacts", id)?;
let root = self.root.join(id);
if !root.exists() {
return Ok(ArtifactLookup {
artifact: None,
quarantined: false,
});
}
match self.load_verified(id, provenance, expected_binaries, &root) {
Ok(artifact) => Ok(ArtifactLookup {
artifact: Some(artifact),
quarantined: false,
}),
Err(error) if !artifact_is_referenced(id)? => {
quarantine_artifact(&root, id, &error.to_string())?;
Ok(ArtifactLookup {
artifact: None,
quarantined: true,
})
}
Err(error) => Err(error),
}
}
fn load_verified(
&self,
id: &str,
provenance: &ArtifactProvenance,
expected_binaries: &[String],
root: &Path,
) -> Result<VerifiedArtifact, ForgeError> {
if !root.is_dir() {
return Err(ForgeError::Config(format!(
"artifact cache is not a directory: {}",
root.display()
)));
}
let manifest_path = root.join("manifest.json");
let bytes = fs::read(&manifest_path).map_err(|source| ForgeError::Io {
path: manifest_path.clone(),
source,
})?;
let manifest: VerifiedArtifact = serde_json::from_slice(&bytes).map_err(|error| {
ForgeError::Parse(format!(
"artifact manifest {} is invalid: {error}",
manifest_path.display()
))
})?;
if manifest.id != id || &manifest.provenance != provenance {
return Err(ForgeError::Config(format!(
"artifact cache identity does not match: {}",
root.display()
)));
}
let mut expected = expected_binaries.to_vec();
expected.sort();
expected.dedup();
let actual = manifest.binaries.keys().cloned().collect::<Vec<_>>();
if !expected.is_empty() && actual != expected {
return Err(ForgeError::Config(format!(
"artifact cache binary set does not match: {}",
root.display()
)));
}
let mut binaries = BTreeMap::new();
for (name, path) in &manifest.binaries {
let relative = artifact_relative_path(&manifest.root, path)?;
let stored = root.join(&relative);
if !stored.is_file() {
return Err(ForgeError::Config(format!(
"artifact cache is missing binary: {}",
stored.display()
)));
}
binaries.insert(name.clone(), stored);
}
if digest_tree_ignoring_manifest(root)? != manifest.sha256 {
return Err(ForgeError::Config(format!(
"artifact cache checksum does not match: {}",
root.display()
)));
}
Ok(VerifiedArtifact {
id: manifest.id,
root: root.to_path_buf(),
binaries,
sha256: manifest.sha256,
provenance: manifest.provenance,
})
}
}
fn artifact_is_referenced(id: &str) -> Result<bool, ForgeError> {
if read_registry()?.iter().any(|entry| {
entry.artifact_id.as_deref() == Some(id)
|| entry.previous_artifact_id.as_deref() == Some(id)
}) {
return Ok(true);
}
Ok(load_pending()?.iter().any(|checkpoint| {
checkpoint.artifact_id.as_deref() == Some(id)
|| checkpoint.previous_artifact_id.as_deref() == Some(id)
}))
}
fn quarantine_artifact(root: &Path, id: &str, reason: &str) -> Result<(), ForgeError> {
let directory = app_home().join("cache").join("quarantine");
create_dir_all(&directory)?;
let destination = directory.join(format!(
"artifact-{id}-{}-{}",
std::process::id(),
now_secs()
));
fs::rename(root, &destination).map_err(|source| ForgeError::Io {
path: destination.clone(),
source,
})?;
let metadata = serde_json::json!({
"kind": "artifact",
"identity": id,
"reason": reason,
"quarantined_at": now_secs(),
});
atomic_write_file(
&destination.with_extension("json"),
&serde_json::to_vec_pretty(&metadata).map_err(|error| {
ForgeError::Parse(format!("failed to serialize quarantine metadata: {error}"))
})?,
)
}
fn artifact_relative_path(root: &Path, path: &Path) -> Result<PathBuf, ForgeError> {
let relative = if path.is_absolute() {
path.strip_prefix(root).map_err(|_| {
ForgeError::Config(format!(
"artifact binary escapes the root: {}",
path.display()
))
})?
} else {
path
};
if relative.components().any(|component| {
matches!(
component,
std::path::Component::ParentDir | std::path::Component::RootDir
)
}) {
return Err(ForgeError::Config(format!(
"artifact binary path is unsafe: {}",
path.display()
)));
}
Ok(relative.to_path_buf())
}
fn verify_existing(root: &Path, artifact: &VerifiedArtifact) -> Result<(), ForgeError> {
if digest_tree_ignoring_manifest(root)? != artifact.sha256 {
return Err(ForgeError::Config(format!(
"artifact cache checksum does not match: {}",
root.display()
)));
}
for path in artifact.binaries.values() {
let relative = if path.is_absolute() {
path.strip_prefix(&artifact.root).map_err(|_| {
ForgeError::Config(format!(
"artifact binary escapes the root: {}",
path.display()
))
})?
} else {
path.as_path()
};
if !root.join(relative).is_file() {
return Err(ForgeError::Config(format!(
"artifact cache is missing binary: {}",
root.join(relative).display()
)));
}
}
Ok(())
}
fn validate_artifact_id(id: &str) -> Result<(), ForgeError> {
if !valid_storage_id(id) {
return Err(ForgeError::Config(format!("invalid artifact id: {id}")));
}
Ok(())
}
fn copy_tree(source: &Path, destination: &Path) -> Result<(), ForgeError> {
create_dir_all(destination)?;
for entry in fs::read_dir(source).map_err(|error| ForgeError::Io {
path: source.to_path_buf(),
source: error,
})? {
let entry = entry.map_err(|error| ForgeError::Io {
path: source.to_path_buf(),
source: error,
})?;
let from = entry.path();
let to = destination.join(entry.file_name());
let metadata = fs::symlink_metadata(&from).map_err(|source| ForgeError::Io {
path: from.clone(),
source,
})?;
if metadata.file_type().is_symlink() {
return Err(ForgeError::Config(format!(
"artifact cannot contain a symbolic link: {}",
from.display()
)));
}
if metadata.is_dir() {
copy_tree(&from, &to)?;
} else if metadata.is_file() {
fs::copy(&from, &to).map_err(|source| ForgeError::Io { path: to, source })?;
}
}
Ok(())
}
fn write_manifest(root: &Path, artifact: &VerifiedArtifact) -> Result<(), ForgeError> {
let manifest = serde_json::to_vec_pretty(artifact).map_err(|error| {
ForgeError::Parse(format!("failed to serialize artifact manifest: {error}"))
})?;
let path = root.join("manifest.json");
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&path)
.map_err(|source| ForgeError::Io {
path: path.clone(),
source,
})?;
std::io::Write::write_all(&mut file, &manifest)
.map_err(|source| ForgeError::Io { path, source })?;
file.sync_all().map_err(|source| ForgeError::Io {
path: root.join("manifest.json"),
source,
})
}
fn make_read_only(root: &Path) -> Result<(), ForgeError> {
for entry in fs::read_dir(root).map_err(|source| ForgeError::Io {
path: root.to_path_buf(),
source,
})? {
let entry = entry.map_err(|source| ForgeError::Io {
path: root.to_path_buf(),
source,
})?;
let path = entry.path();
let metadata = fs::metadata(&path).map_err(|source| ForgeError::Io {
path: path.clone(),
source,
})?;
if metadata.is_dir() {
make_read_only(&path)?;
} else {
let mut permissions = metadata.permissions();
permissions.set_readonly(true);
fs::set_permissions(&path, permissions)
.map_err(|source| ForgeError::Io { path, source })?;
}
}
Ok(())
}
#[cfg(windows)]
#[allow(clippy::permissions_set_readonly_false)]
fn make_writable(root: &Path) {
let Ok(metadata) = fs::symlink_metadata(root) else {
return;
};
if metadata.is_dir()
&& let Ok(entries) = fs::read_dir(root)
{
for entry in entries.flatten() {
make_writable(&entry.path());
}
}
let mut permissions = metadata.permissions();
if permissions.readonly() {
permissions.set_readonly(false);
let _ = fs::set_permissions(root, permissions);
}
}
#[cfg(not(windows))]
fn make_writable(_: &Path) {}
pub(crate) fn digest_tree(root: &Path) -> Result<String, ForgeError> {
digest_tree_with_filter(root, false)
}
fn digest_tree_ignoring_manifest(root: &Path) -> Result<String, ForgeError> {
digest_tree_with_filter(root, true)
}
fn digest_tree_with_filter(root: &Path, ignore_manifest: bool) -> Result<String, ForgeError> {
let mut files = Vec::new();
collect_files(root, root, &mut files)?;
if ignore_manifest {
files.retain(|(relative, _)| relative != Path::new("manifest.json"));
}
files.sort();
let mut hasher = Sha256::new();
for (relative, path) in files {
hasher.update(relative.as_os_str().to_string_lossy().as_bytes());
hasher.update([0]);
hasher.update(executable_mode(&path)?.to_le_bytes());
hasher.update([0]);
hasher.update(fs::read(&path).map_err(|source| ForgeError::Io {
path: path.clone(),
source,
})?);
hasher.update([0]);
}
Ok(hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect())
}
#[cfg(unix)]
fn executable_mode(path: &Path) -> Result<u32, ForgeError> {
fs::metadata(path)
.map(|metadata| metadata.permissions().mode() & 0o111)
.map_err(|source| ForgeError::Io {
path: path.to_path_buf(),
source,
})
}
#[cfg(not(unix))]
fn executable_mode(_: &Path) -> Result<u32, ForgeError> {
Ok(0)
}
fn collect_files(
root: &Path,
current: &Path,
files: &mut Vec<(PathBuf, PathBuf)>,
) -> Result<(), ForgeError> {
for entry in fs::read_dir(current).map_err(|source| ForgeError::Io {
path: current.to_path_buf(),
source,
})? {
let entry = entry.map_err(|source| ForgeError::Io {
path: current.to_path_buf(),
source,
})?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path).map_err(|source| ForgeError::Io {
path: path.clone(),
source,
})?;
if metadata.file_type().is_symlink() {
return Err(ForgeError::Config(format!(
"artifact cannot contain a symbolic link: {}",
path.display()
)));
}
if metadata.is_dir() {
collect_files(root, &path, files)?;
} else if metadata.is_file() {
files.push((path.strip_prefix(root).unwrap_or(&path).to_path_buf(), path));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::{PermissionsExt, symlink};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::artifact::{ArtifactProvenance, ArtifactStore, VerifiedArtifact, digest_tree};
fn temporary(name: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("bot-forge-artifact-{name}-{nonce}"))
}
#[test]
fn stores_verified_artifact_with_manifest_and_read_only_payload() {
let staging = temporary("source");
let store_root = temporary("store");
fs::create_dir_all(&staging).unwrap();
fs::write(staging.join("demo"), b"binary").unwrap();
let artifact = VerifiedArtifact {
id: "demo-fingerprint".into(),
root: staging.clone(),
binaries: BTreeMap::from([("demo".into(), PathBuf::from("demo"))]),
sha256: digest_tree(&staging).unwrap(),
provenance: ArtifactProvenance {
backend: "test".into(),
source: "fixture".into(),
revision: Some("1".into()),
fingerprint: "fingerprint".into(),
},
};
let stored = ArtifactStore::new(Some(store_root.clone()))
.unwrap()
.put(&artifact)
.unwrap();
assert!(stored.join("manifest.json").is_file());
assert!(
fs::metadata(stored.join("demo"))
.unwrap()
.permissions()
.readonly()
);
assert_eq!(
ArtifactStore::new(Some(store_root.clone()))
.unwrap()
.put(&artifact)
.unwrap(),
stored
);
let loaded = ArtifactStore::new(Some(store_root.clone()))
.unwrap()
.get("demo-fingerprint", &artifact.provenance, &["demo".into()])
.unwrap()
.artifact
.unwrap();
assert_eq!(loaded.sha256, artifact.sha256);
assert_eq!(loaded.binaries["demo"], stored.join("demo"));
fs::remove_dir_all(staging).unwrap();
fs::remove_dir_all(store_root).unwrap();
}
#[test]
#[allow(clippy::permissions_set_readonly_false)]
fn treats_tampered_unreferenced_artifact_as_a_miss() {
let staging = temporary("tampered-source");
let store_root = temporary("tampered-store");
fs::create_dir_all(&staging).unwrap();
fs::write(staging.join("demo"), b"binary").unwrap();
let artifact = VerifiedArtifact {
id: "tampered-fingerprint".into(),
root: staging.clone(),
binaries: BTreeMap::from([("demo".into(), staging.join("demo"))]),
sha256: digest_tree(&staging).unwrap(),
provenance: ArtifactProvenance {
backend: "cargo".into(),
source: "demo".into(),
revision: None,
fingerprint: "fingerprint".into(),
},
};
let store = ArtifactStore::new(Some(store_root.clone())).unwrap();
let stored = store.put(&artifact).unwrap();
#[cfg(unix)]
{
fs::set_permissions(stored.join("demo"), fs::Permissions::from_mode(0o600)).unwrap();
}
#[cfg(windows)]
{
let mut permissions = fs::metadata(stored.join("demo")).unwrap().permissions();
permissions.set_readonly(false);
fs::set_permissions(stored.join("demo"), permissions).unwrap();
}
fs::write(stored.join("demo"), b"tampered").unwrap();
let lookup = store
.get(&artifact.id, &artifact.provenance, &["demo".into()])
.unwrap();
assert!(lookup.artifact.is_none());
assert!(lookup.quarantined);
fs::remove_dir_all(staging).unwrap();
fs::remove_dir_all(store_root).unwrap();
}
#[test]
#[allow(clippy::permissions_set_readonly_false)]
fn quarantines_unreferenced_corrupt_artifact_as_a_cache_miss() {
let staging = temporary("quarantine-source");
let store_root = temporary("quarantine-store");
fs::create_dir_all(&staging).unwrap();
fs::write(staging.join("demo"), b"binary").unwrap();
let artifact = VerifiedArtifact {
id: "quarantine-fingerprint".into(),
root: staging.clone(),
binaries: BTreeMap::from([("demo".into(), PathBuf::from("demo"))]),
sha256: digest_tree(&staging).unwrap(),
provenance: ArtifactProvenance {
backend: "test".into(),
source: "fixture".into(),
revision: None,
fingerprint: "quarantine".into(),
},
};
let store = ArtifactStore::new(Some(store_root.clone())).unwrap();
let stored = store.put(&artifact).unwrap();
#[cfg(unix)]
{
fs::set_permissions(stored.join("demo"), fs::Permissions::from_mode(0o600)).unwrap();
}
#[cfg(windows)]
{
let mut permissions = fs::metadata(stored.join("demo")).unwrap().permissions();
permissions.set_readonly(false);
fs::set_permissions(stored.join("demo"), permissions).unwrap();
}
fs::write(stored.join("demo"), b"corrupt").unwrap();
let lookup = store
.get(&artifact.id, &artifact.provenance, &["demo".into()])
.unwrap();
assert!(lookup.artifact.is_none());
assert!(lookup.quarantined);
assert!(!stored.exists());
fs::remove_dir_all(staging).unwrap();
fs::remove_dir_all(store_root).unwrap();
}
#[cfg(unix)]
#[test]
fn executable_mode_is_part_of_artifact_integrity() {
let root = temporary("mode-digest");
fs::create_dir_all(&root).unwrap();
let binary = root.join("demo");
fs::write(&binary, b"same bytes").unwrap();
fs::set_permissions(&binary, fs::Permissions::from_mode(0o644)).unwrap();
let plain = digest_tree(&root).unwrap();
fs::set_permissions(&binary, fs::Permissions::from_mode(0o755)).unwrap();
let executable = digest_tree(&root).unwrap();
assert_ne!(plain, executable);
fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
fn rejects_symlink_in_verified_artifact() {
let staging = temporary("symlink-source");
let store_root = temporary("symlink-store");
fs::create_dir_all(&staging).unwrap();
symlink("/tmp", staging.join("escape")).unwrap();
let artifact = VerifiedArtifact {
id: "unsafe".into(),
root: staging.clone(),
binaries: BTreeMap::new(),
sha256: String::new(),
provenance: ArtifactProvenance {
backend: "test".into(),
source: "fixture".into(),
revision: None,
fingerprint: "unsafe".into(),
},
};
let error = ArtifactStore::new(Some(store_root.clone()))
.unwrap()
.put(&artifact)
.unwrap_err()
.to_string();
assert!(error.contains("symlink"), "{error}");
assert_eq!(fs::read_dir(&store_root).unwrap().count(), 0);
fs::remove_dir_all(staging).unwrap();
fs::remove_dir_all(store_root).unwrap();
}
}