Skip to main content

prikk_store/
worktree.rs

1//! Safe worktree materialization helpers.
2//!
3//! PR-017 adds an opt-in snapshot materializer for snapshot-backed blocks. It writes only
4//! repository-validated snapshot entries, refuses conflicting existing files, refuses symlinked
5//! parents/targets, and never removes files.
6
7use std::path::Path;
8
9use prikk_error::{PrikkError, Result};
10use prikk_object::ObjectType;
11
12use crate::checkout::prepare_snapshot_checkout_plan;
13use crate::fsutil::{
14    ensure_directory_required, read_file_if_exists, set_regular_file_mode_required,
15    stat_file_state_if_exists, sync_directory_required, write_worktree_file_atomically,
16};
17use crate::layout::RepositoryLayout;
18use crate::object_store::{FileObjectStore, ObjectReader};
19use crate::patch_replay::{ReplayManifest, ReplayManifestEntry};
20use crate::path::join_repo_path_to_root;
21use crate::snapshot::{SnapshotEntry, SnapshotManifest};
22use crate::worktree_marker::{clear_worktree_dirty, mark_worktree_dirty};
23
24/// Result of an opt-in snapshot worktree materialization.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct SnapshotMaterializationReport {
27    /// Human-readable ref name.
28    pub ref_name: String,
29    /// Number of files described by the snapshot manifest.
30    pub planned_files: usize,
31    /// Number of files written by this invocation.
32    pub written_files: usize,
33    /// Number of files already present with identical bytes.
34    pub unchanged_files: usize,
35    /// Total content bytes represented by the snapshot manifest.
36    pub total_content_bytes: u64,
37    /// Repository-relative paths in materialization order.
38    pub paths: Vec<String>,
39}
40
41/// Materialize a snapshot-backed checkout into the repository worktree.
42///
43/// This operation is intentionally conservative:
44///
45/// - it accepts only blocks with a validated snapshot manifest;
46/// - it refuses to overwrite an existing file with different bytes;
47/// - it refuses symlinked parents and symlinked target files;
48/// - it never removes extra worktree files;
49/// - it relies on `RepoPath` validation to keep writes inside the worktree.
50pub fn materialize_snapshot_checkout(
51    layout: &RepositoryLayout,
52    ref_name: &str,
53) -> Result<SnapshotMaterializationReport> {
54    layout.require_current_format()?;
55    let plan = prepare_snapshot_checkout_plan(layout, ref_name)?;
56    let manifest = load_snapshot_manifest(layout, plan.snapshot_blob_id)?;
57    // RFC 102 Stage 1: dirty before the first possible worktree write, cleared only after every
58    // write in this call has durably completed -- see `worktree_marker`'s own doc for why the
59    // ordering, not just the primitive, is what closes T12.
60    mark_worktree_dirty(layout)?;
61    let write_report = materialize_manifest_entries(layout, &manifest)?;
62    clear_worktree_dirty(layout)?;
63    Ok(SnapshotMaterializationReport {
64        ref_name: ref_name.to_string(),
65        planned_files: manifest.files.len(),
66        written_files: write_report.written_files,
67        unchanged_files: write_report.unchanged_files,
68        total_content_bytes: manifest.total_content_bytes(),
69        paths: manifest
70            .files
71            .iter()
72            .map(|entry| entry.path.as_str().to_string())
73            .collect(),
74    })
75}
76
77/// Result of materializing a validated manifest into a worktree.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub(crate) struct ManifestMaterializationReport {
80    /// Number of files written by this invocation.
81    pub(crate) written_files: usize,
82    /// Number of files already present with identical bytes.
83    pub(crate) unchanged_files: usize,
84}
85
86/// Materialize a validated manifest without deleting extra files.
87pub(crate) fn materialize_manifest_entries(
88    layout: &RepositoryLayout,
89    manifest: &SnapshotManifest,
90) -> Result<ManifestMaterializationReport> {
91    let mut written_files = 0_usize;
92    let mut unchanged_files = 0_usize;
93    for entry in &manifest.files {
94        match materialize_entry(layout, entry)? {
95            EntryWriteOutcome::Written => written_files += 1,
96            EntryWriteOutcome::Unchanged => unchanged_files += 1,
97        }
98    }
99    Ok(ManifestMaterializationReport {
100        written_files,
101        unchanged_files,
102    })
103}
104
105/// Materialize a mode-aware replay manifest without deleting extra files (DC-73). Otherwise
106/// identical to [`materialize_manifest_entries`] — kept as a separate function rather than a
107/// generic one because the two manifest types are deliberately not unified (see
108/// `ReplayManifestEntry`'s doc comment).
109pub(crate) fn materialize_replay_manifest_entries(
110    layout: &RepositoryLayout,
111    manifest: &ReplayManifest,
112) -> Result<ManifestMaterializationReport> {
113    let mut written_files = 0_usize;
114    let mut unchanged_files = 0_usize;
115    for entry in &manifest.files {
116        match materialize_replay_entry(layout, entry)? {
117            EntryWriteOutcome::Written => written_files += 1,
118            EntryWriteOutcome::Unchanged => unchanged_files += 1,
119        }
120    }
121    Ok(ManifestMaterializationReport {
122        written_files,
123        unchanged_files,
124    })
125}
126
127fn materialize_replay_entry(
128    layout: &RepositoryLayout,
129    entry: &ReplayManifestEntry,
130) -> Result<EntryWriteOutcome> {
131    let root = layout.root();
132    let target = join_repo_path_to_root(&entry.path, root);
133    ensure_target_is_inside_root(root, &target)?;
134    ensure_parent_directory(layout, entry.path.as_str())?;
135    let relative = Path::new(entry.path.as_str());
136    if let Some(current) = read_file_if_exists(layout.worktree_mutation_root(), relative)? {
137        if current != entry.bytes {
138            return Err(PrikkError::Integrity(format!(
139                "refusing to overwrite existing file with different content: {}",
140                target.display()
141            )));
142        }
143        // `stat.mode` is `None` on a platform with no observable POSIX mode (DC-87 §3.3/§4.3), in
144        // which case `current_mode` is `None` here too: the comparison below never matches, the
145        // skip-optimization never fires, and `set_regular_file_mode_required` always runs — `entry`'s
146        // already-decided mode, not this stat, is what ends up on disk either way.
147        let current_mode = stat_file_state_if_exists(layout.worktree_mutation_root(), relative)?
148            .and_then(|stat| stat.mode)
149            .map(|mode| mode & 0o7777);
150        if current_mode == Some(entry.mode & 0o7777) {
151            sync_directory_required(layout.worktree_mutation_root(), relative)?;
152            return Ok(EntryWriteOutcome::Unchanged);
153        }
154        set_regular_file_mode_required(layout.worktree_mutation_root(), relative, entry.mode)?;
155        return Ok(EntryWriteOutcome::Written);
156    }
157    write_worktree_file_atomically(layout.worktree_mutation_root(), relative, &entry.bytes)?;
158    set_regular_file_mode_required(layout.worktree_mutation_root(), relative, entry.mode)?;
159    Ok(EntryWriteOutcome::Written)
160}
161
162fn load_snapshot_manifest(
163    layout: &RepositoryLayout,
164    snapshot_blob_id: prikk_object::ObjectId,
165) -> Result<SnapshotManifest> {
166    let object_store = FileObjectStore::new(layout.clone());
167    let Some(envelope) = object_store.read_object(snapshot_blob_id)? else {
168        return Err(PrikkError::Integrity(format!(
169            "snapshot Blob {snapshot_blob_id} is missing"
170        )));
171    };
172    if envelope.object_type != ObjectType::Blob {
173        return Err(PrikkError::ObjectTypeMismatch {
174            expected: ObjectType::Blob.to_string(),
175            actual: envelope.object_type.to_string(),
176        });
177    }
178    let snapshot_content = crate::blob_access::decode_snapshot_blob(&envelope.canonical_payload)?;
179    SnapshotManifest::decode(&snapshot_content)
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183enum EntryWriteOutcome {
184    Written,
185    Unchanged,
186}
187
188fn materialize_entry(
189    layout: &RepositoryLayout,
190    entry: &SnapshotEntry,
191) -> Result<EntryWriteOutcome> {
192    let root = layout.root();
193    let target = join_repo_path_to_root(&entry.path, root);
194    ensure_target_is_inside_root(root, &target)?;
195    ensure_parent_directory(layout, entry.path.as_str())?;
196    let relative = Path::new(entry.path.as_str());
197    if let Some(current) = read_file_if_exists(layout.worktree_mutation_root(), relative)? {
198        if current == entry.bytes {
199            sync_directory_required(layout.worktree_mutation_root(), relative)?;
200            return Ok(EntryWriteOutcome::Unchanged);
201        }
202        return Err(PrikkError::Integrity(format!(
203            "refusing to overwrite existing file with different content: {}",
204            target.display()
205        )));
206    }
207    write_worktree_file_atomically(layout.worktree_mutation_root(), relative, &entry.bytes)?;
208    Ok(EntryWriteOutcome::Written)
209}
210
211fn ensure_parent_directory(layout: &RepositoryLayout, repo_path: &str) -> Result<()> {
212    let relative = Path::new(repo_path);
213    let parent = relative.parent().unwrap_or_else(|| Path::new(""));
214    ensure_directory_required(layout.worktree_mutation_root(), parent)
215}
216
217fn ensure_target_is_inside_root(root: &Path, target: &Path) -> Result<()> {
218    if !target.starts_with(root) {
219        return Err(PrikkError::Integrity(format!(
220            "materialization target escaped repository root: {}",
221            target.display()
222        )));
223    }
224    Ok(())
225}