use std::fs;
use std::path::Path;
use prikk_error::{PrikkError, Result};
use prikk_object::{BlobPayload, ObjectType};
use crate::checkout::prepare_snapshot_checkout_plan;
use crate::fsutil::{sync_directory_best_effort, write_file_atomically};
use crate::layout::RepositoryLayout;
use crate::object_store::{FileObjectStore, ObjectReader};
use crate::snapshot::{SnapshotEntry, SnapshotManifest};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotMaterializationReport {
pub ref_name: String,
pub planned_files: usize,
pub written_files: usize,
pub unchanged_files: usize,
pub total_content_bytes: u64,
pub paths: Vec<String>,
}
pub fn materialize_snapshot_checkout(
layout: &RepositoryLayout,
ref_name: &str,
) -> Result<SnapshotMaterializationReport> {
let plan = prepare_snapshot_checkout_plan(layout, ref_name)?;
let manifest = load_snapshot_manifest(layout, plan.snapshot_blob_id)?;
let write_report = materialize_manifest_entries(layout.root(), &manifest)?;
Ok(SnapshotMaterializationReport {
ref_name: ref_name.to_string(),
planned_files: manifest.files.len(),
written_files: write_report.written_files,
unchanged_files: write_report.unchanged_files,
total_content_bytes: manifest.total_content_bytes(),
paths: manifest
.files
.iter()
.map(|entry| entry.path.as_str().to_string())
.collect(),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ManifestMaterializationReport {
pub(crate) written_files: usize,
pub(crate) unchanged_files: usize,
}
pub(crate) fn materialize_manifest_entries(
root: &Path,
manifest: &SnapshotManifest,
) -> Result<ManifestMaterializationReport> {
let mut written_files = 0_usize;
let mut unchanged_files = 0_usize;
for entry in &manifest.files {
match materialize_entry(root, entry)? {
EntryWriteOutcome::Written => written_files += 1,
EntryWriteOutcome::Unchanged => unchanged_files += 1,
}
}
Ok(ManifestMaterializationReport {
written_files,
unchanged_files,
})
}
fn load_snapshot_manifest(
layout: &RepositoryLayout,
snapshot_blob_id: prikk_object::ObjectId,
) -> Result<SnapshotManifest> {
let object_store = FileObjectStore::new(layout.clone());
let Some(envelope) = object_store.read_object(snapshot_blob_id)? else {
return Err(PrikkError::Integrity(format!(
"snapshot Blob {snapshot_blob_id} is missing"
)));
};
if envelope.object_type != ObjectType::Blob {
return Err(PrikkError::ObjectTypeMismatch {
expected: ObjectType::Blob.to_string(),
actual: envelope.object_type.to_string(),
});
}
let blob = BlobPayload::decode_canonical(&envelope.canonical_payload)?;
SnapshotManifest::decode(&blob.bytes)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EntryWriteOutcome {
Written,
Unchanged,
}
fn materialize_entry(root: &Path, entry: &SnapshotEntry) -> Result<EntryWriteOutcome> {
let target = entry.path.join_to_root(root);
ensure_target_is_inside_root(root, &target)?;
ensure_parent_directory(root, entry.path.as_str())?;
if target.exists() {
let metadata = fs::symlink_metadata(&target)?;
if metadata.file_type().is_symlink() {
return Err(PrikkError::Integrity(format!(
"refusing to overwrite symlink target: {}",
target.display()
)));
}
if !metadata.is_file() {
return Err(PrikkError::Integrity(format!(
"refusing to overwrite non-file target: {}",
target.display()
)));
}
let current = fs::read(&target)?;
if current == entry.bytes {
return Ok(EntryWriteOutcome::Unchanged);
}
return Err(PrikkError::Integrity(format!(
"refusing to overwrite existing file with different content: {}",
target.display()
)));
}
write_file_atomically(&target, &entry.bytes)?;
Ok(EntryWriteOutcome::Written)
}
fn ensure_parent_directory(root: &Path, repo_path: &str) -> Result<()> {
let mut current = root.to_path_buf();
let mut components = repo_path.split('/').peekable();
while let Some(component) = components.next() {
if components.peek().is_none() {
break;
}
current.push(component);
if current.exists() {
let metadata = fs::symlink_metadata(¤t)?;
if metadata.file_type().is_symlink() {
return Err(PrikkError::Integrity(format!(
"refusing to traverse symlink directory: {}",
current.display()
)));
}
if !metadata.is_dir() {
return Err(PrikkError::Integrity(format!(
"worktree parent path is not a directory: {}",
current.display()
)));
}
} else {
fs::create_dir(¤t)?;
sync_directory_best_effort(parent_or_root(¤t, root))?;
}
}
Ok(())
}
fn ensure_target_is_inside_root(root: &Path, target: &Path) -> Result<()> {
if !target.starts_with(root) {
return Err(PrikkError::Integrity(format!(
"materialization target escaped repository root: {}",
target.display()
)));
}
Ok(())
}
fn parent_or_root<'a>(path: &'a Path, root: &'a Path) -> &'a Path {
path.parent().unwrap_or(root)
}