Skip to main content

acyclic_fs/
materializer.rs

1//! Journaled pathwise application and crash recovery.
2//!
3//! Merge publication changes an authenticated workspace head atomically. A
4//! native checkout still needs pathwise host operations, which cannot be made
5//! atomic by an operating system. This module centralizes the durable protocol:
6//! capture every preimage before the first mutation, record progress after each
7//! operation, and complete or roll back deterministically after interruption.
8
9use crate::{GenerationId, OperationId};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::future::Future;
13#[cfg(not(target_arch = "wasm32"))]
14use std::path::{Path, PathBuf};
15use std::sync::Mutex;
16use thiserror::Error;
17
18const JOURNAL_VERSION: u32 = 2;
19const MAXIMUM_CAS_ATTEMPTS: u8 = 32;
20
21/// One declarative pathwise checkout edit.
22#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
23#[non_exhaustive]
24pub enum MaterializationEdit {
25    /// Install a complete backend-defined entry image at one portable path.
26    Install {
27        /// Canonical relative path.
28        path: String,
29        /// Opaque entry image interpreted by the backend.
30        image: Vec<u8>,
31    },
32    /// Remove one entry or subtree.
33    Remove {
34        /// Canonical relative path.
35        path: String,
36    },
37    /// Rename one entry without flattening its metadata or link identity.
38    Rename {
39        /// Existing canonical relative path.
40        from: String,
41        /// Destination canonical relative path.
42        to: String,
43    },
44    /// Change representable metadata without replacing a directory subtree.
45    SetMetadata {
46        /// Canonical relative path.
47        path: String,
48        /// Backend-defined declarative metadata image.
49        image: Vec<u8>,
50    },
51}
52
53/// Immutable materialization plan bound to exact SDK generations.
54#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
55pub struct MaterializationPlan {
56    /// Stable retry and recovery identity.
57    pub operation_id: OperationId,
58    /// Generation expected to be represented before application.
59    pub from: GenerationId,
60    /// Published generation represented after application.
61    pub to: GenerationId,
62    /// Ordered path edits. Destructive edits do not overlap; metadata edits may
63    /// follow edits to descendants so directory identity is preserved.
64    pub edits: Vec<MaterializationEdit>,
65}
66
67/// Opaque complete preimage for one edit.
68#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
69pub struct MaterializationPreimage {
70    /// Backend-defined bytes sufficient to restore every representable fact.
71    pub image: Vec<u8>,
72}
73
74/// Exact physical state observed while resuming one journaled edit.
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub enum MaterializationObservation {
77    /// Backend delegates idempotency to its apply/restore implementations.
78    Unverified,
79    /// The edit has not yet been applied.
80    Preimage,
81    /// The edit was applied before its progress record became durable.
82    Postimage,
83    /// An operation-owned rename sequence stopped between its durable endpoints.
84    Interrupted,
85    /// Neither durable endpoint matches; an external writer intervened.
86    Diverged,
87}
88
89/// Durable application phase.
90#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
91pub enum MaterializationPhase {
92    /// Every preimage is durable and no edit has been applied.
93    Prepared,
94    /// Forward application is in progress.
95    Applying,
96    /// Every edit is durable in the target checkout.
97    Applied,
98    /// Reverse preimage restoration is in progress.
99    RollingBack,
100    /// The original checkout has been restored.
101    RolledBack,
102}
103
104/// Complete versioned recovery journal.
105#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
106pub struct MaterializationJournal {
107    /// Serialization contract version.
108    pub version: u32,
109    /// Monotonic optimistic-concurrency revision.
110    pub revision: u64,
111    /// Immutable plan.
112    pub plan: MaterializationPlan,
113    /// Preimages in exact edit order.
114    pub preimages: Vec<MaterializationPreimage>,
115    /// Current recovery phase.
116    pub phase: MaterializationPhase,
117    /// Number of forward edits known durable.
118    pub applied: u32,
119    /// Number of applied edits restored from the end.
120    pub restored: u32,
121}
122
123/// Durable optimistic-concurrency adapter for materialization journals.
124pub trait MaterializationJournalStore: Send + Sync {
125    /// Adapter error.
126    type Error: std::error::Error + Send + Sync + 'static;
127
128    /// Loads one operation journal.
129    fn load(
130        &self,
131        operation_id: OperationId,
132    ) -> impl Future<Output = Result<Option<MaterializationJournal>, Self::Error>> + Send;
133
134    /// Atomically replaces `expected_revision`; zero creates a journal.
135    fn compare_and_swap(
136        &self,
137        operation_id: OperationId,
138        expected_revision: u64,
139        replacement: MaterializationJournal,
140    ) -> impl Future<Output = Result<bool, Self::Error>> + Send;
141}
142
143/// Backend that can capture, apply, and restore every representable host fact.
144pub trait MaterializationBackend: Send + Sync {
145    /// Backend error.
146    type Error: std::error::Error + Send + Sync + 'static;
147
148    /// Verifies that the checkout still represents the plan's exact source.
149    /// Backends without an independently observable generation may retain the
150    /// conservative default and provide equivalent fencing externally.
151    fn verify_source(
152        &self,
153        _from: GenerationId,
154    ) -> impl Future<Output = Result<bool, Self::Error>> + Send {
155        async { Ok(true) }
156    }
157
158    /// Captures a complete preimage without mutating the checkout.
159    fn capture(
160        &self,
161        edit: &MaterializationEdit,
162    ) -> impl Future<Output = Result<MaterializationPreimage, Self::Error>> + Send;
163
164    /// Classifies the current path against both durable endpoints.
165    ///
166    /// Backends that cannot independently witness physical state may retain
167    /// the default only when equivalent exclusion is provided externally.
168    fn observe(
169        &self,
170        _edit: &MaterializationEdit,
171        _preimage: &MaterializationPreimage,
172    ) -> impl Future<Output = Result<MaterializationObservation, Self::Error>> + Send {
173        async { Ok(MaterializationObservation::Unverified) }
174    }
175
176    /// Applies one declarative edit idempotently.
177    fn apply(
178        &self,
179        edit: &MaterializationEdit,
180        preimage: &MaterializationPreimage,
181    ) -> impl Future<Output = Result<(), Self::Error>> + Send;
182
183    /// Restores the exact preimage for one edit idempotently.
184    fn restore(
185        &self,
186        edit: &MaterializationEdit,
187        preimage: &MaterializationPreimage,
188    ) -> impl Future<Output = Result<(), Self::Error>> + Send;
189}
190
191/// Recovery decision for a nonterminal journal.
192#[derive(Clone, Copy, Debug, Eq, PartialEq)]
193pub enum MaterializationRecovery {
194    /// Continue applying the unpublished checkout candidate.
195    Complete,
196    /// Restore every path changed before interruption.
197    RollBack,
198}
199
200/// Materialization failure.
201#[derive(Debug, Error)]
202pub enum MaterializationError<S: std::error::Error + 'static, B: std::error::Error + 'static> {
203    /// Durable journal access failed.
204    #[error("materialization journal failed: {0}")]
205    Store(S),
206    /// Host checkout access failed.
207    #[error("materialization backend failed: {0}")]
208    Backend(B),
209    /// A retry supplied different immutable plan input.
210    #[error("materialization retry conflicts with its durable plan")]
211    PlanConflict,
212    /// Persisted progress is malformed or uses an unsupported version.
213    #[error("materialization journal is incompatible")]
214    IncompatibleJournal,
215    /// Optimistic state contention exceeded the bounded retry policy.
216    #[error("materialization journal remained contended")]
217    Contended,
218    /// Physical state no longer matches either durable endpoint.
219    #[error("materialization paused because an external path mutation was observed")]
220    ExternalMutation,
221}
222
223/// Core materializer coordinating one journal store and one host backend.
224pub struct JournaledMaterializer<S, B> {
225    store: S,
226    backend: B,
227}
228
229impl<S, B> JournaledMaterializer<S, B> {
230    /// Creates a journaled materializer.
231    #[must_use]
232    pub const fn new(store: S, backend: B) -> Self {
233        Self { store, backend }
234    }
235
236    /// Borrows the journal adapter.
237    #[must_use]
238    pub const fn store(&self) -> &S {
239        &self.store
240    }
241
242    /// Borrows the host backend.
243    #[must_use]
244    pub const fn backend(&self) -> &B {
245        &self.backend
246    }
247}
248
249impl<S: MaterializationJournalStore, B: MaterializationBackend> JournaledMaterializer<S, B> {
250    /// Captures preimages, applies the plan, and records every durable boundary.
251    pub async fn apply(
252        &self,
253        plan: MaterializationPlan,
254    ) -> Result<MaterializationJournal, MaterializationError<S::Error, B::Error>> {
255        validate_plan(&plan).map_err(|()| MaterializationError::IncompatibleJournal)?;
256        let mut journal = match self.load(plan.operation_id).await? {
257            Some(existing) => {
258                if existing.plan != plan {
259                    return Err(MaterializationError::PlanConflict);
260                }
261                existing
262            }
263            None => self.prepare(plan).await?,
264        };
265        if matches!(journal.phase, MaterializationPhase::RolledBack) {
266            return Err(MaterializationError::PlanConflict);
267        }
268        if journal.phase == MaterializationPhase::Applied {
269            self.repair_applied(&journal).await?;
270            return Ok(journal);
271        }
272        journal.phase = MaterializationPhase::Applying;
273        journal = self.persist(journal).await?;
274        while usize::try_from(journal.applied).unwrap_or(usize::MAX) < journal.plan.edits.len() {
275            let index = usize::try_from(journal.applied)
276                .map_err(|_| MaterializationError::IncompatibleJournal)?;
277            let edit = journal
278                .plan
279                .edits
280                .get(index)
281                .ok_or(MaterializationError::IncompatibleJournal)?;
282            let preimage = journal
283                .preimages
284                .get(index)
285                .ok_or(MaterializationError::IncompatibleJournal)?;
286            match self
287                .backend
288                .observe(edit, preimage)
289                .await
290                .map_err(MaterializationError::Backend)?
291            {
292                MaterializationObservation::Unverified
293                | MaterializationObservation::Preimage
294                | MaterializationObservation::Interrupted => {
295                    self.backend
296                        .apply(edit, preimage)
297                        .await
298                        .map_err(MaterializationError::Backend)?;
299                }
300                MaterializationObservation::Postimage => {}
301                MaterializationObservation::Diverged => {
302                    return Err(MaterializationError::ExternalMutation);
303                }
304            }
305            journal.applied = journal.applied.saturating_add(1);
306            journal = self.persist(journal).await?;
307        }
308        journal.phase = MaterializationPhase::Applied;
309        self.persist(journal).await
310    }
311
312    /// Recovers an interrupted operation by completing it or restoring preimages.
313    pub async fn recover(
314        &self,
315        operation_id: OperationId,
316        recovery: MaterializationRecovery,
317    ) -> Result<Option<MaterializationJournal>, MaterializationError<S::Error, B::Error>> {
318        let Some(journal) = self.load(operation_id).await? else {
319            return Ok(None);
320        };
321        match recovery {
322            MaterializationRecovery::Complete => {
323                if journal.phase == MaterializationPhase::RollingBack || journal.restored != 0 {
324                    return Err(MaterializationError::IncompatibleJournal);
325                }
326                self.apply(journal.plan.clone()).await.map(Some)
327            }
328            MaterializationRecovery::RollBack => self.rollback(journal).await.map(Some),
329        }
330    }
331
332    async fn prepare(
333        &self,
334        plan: MaterializationPlan,
335    ) -> Result<MaterializationJournal, MaterializationError<S::Error, B::Error>> {
336        validate_plan(&plan).map_err(|()| MaterializationError::IncompatibleJournal)?;
337        if !self
338            .backend
339            .verify_source(plan.from)
340            .await
341            .map_err(MaterializationError::Backend)?
342        {
343            return Err(MaterializationError::PlanConflict);
344        }
345        let mut preimages = Vec::with_capacity(plan.edits.len());
346        for edit in &plan.edits {
347            preimages.push(
348                self.backend
349                    .capture(edit)
350                    .await
351                    .map_err(MaterializationError::Backend)?,
352            );
353        }
354        let journal = MaterializationJournal {
355            version: JOURNAL_VERSION,
356            revision: 1,
357            plan,
358            preimages,
359            phase: MaterializationPhase::Prepared,
360            applied: 0,
361            restored: 0,
362        };
363        if self
364            .store
365            .compare_and_swap(journal.plan.operation_id, 0, journal.clone())
366            .await
367            .map_err(MaterializationError::Store)?
368        {
369            Ok(journal)
370        } else {
371            self.load(journal.plan.operation_id)
372                .await?
373                .ok_or(MaterializationError::Contended)
374        }
375    }
376
377    async fn rollback(
378        &self,
379        mut journal: MaterializationJournal,
380    ) -> Result<MaterializationJournal, MaterializationError<S::Error, B::Error>> {
381        if journal.phase == MaterializationPhase::RolledBack {
382            return Ok(journal);
383        }
384        journal.phase = MaterializationPhase::RollingBack;
385        journal = self.persist(journal).await?;
386        while journal.restored < journal.applied {
387            let index = journal
388                .applied
389                .checked_sub(journal.restored.saturating_add(1))
390                .and_then(|value| usize::try_from(value).ok())
391                .ok_or(MaterializationError::IncompatibleJournal)?;
392            let edit = journal
393                .plan
394                .edits
395                .get(index)
396                .ok_or(MaterializationError::IncompatibleJournal)?;
397            let preimage = journal
398                .preimages
399                .get(index)
400                .ok_or(MaterializationError::IncompatibleJournal)?;
401            match self
402                .backend
403                .observe(edit, preimage)
404                .await
405                .map_err(MaterializationError::Backend)?
406            {
407                MaterializationObservation::Unverified
408                | MaterializationObservation::Postimage
409                | MaterializationObservation::Interrupted => {
410                    self.backend
411                        .restore(edit, preimage)
412                        .await
413                        .map_err(MaterializationError::Backend)?;
414                }
415                MaterializationObservation::Preimage => {}
416                MaterializationObservation::Diverged => {
417                    return Err(MaterializationError::ExternalMutation);
418                }
419            }
420            journal.restored = journal.restored.saturating_add(1);
421            journal = self.persist(journal).await?;
422        }
423        journal.phase = MaterializationPhase::RolledBack;
424        self.persist(journal).await
425    }
426
427    async fn load(
428        &self,
429        operation_id: OperationId,
430    ) -> Result<Option<MaterializationJournal>, MaterializationError<S::Error, B::Error>> {
431        let journal = self
432            .store
433            .load(operation_id)
434            .await
435            .map_err(MaterializationError::Store)?;
436        if journal.as_ref().is_some_and(|journal| {
437            !valid_journal(journal, operation_id) || validate_plan(&journal.plan).is_err()
438        }) {
439            return Err(MaterializationError::IncompatibleJournal);
440        }
441        Ok(journal)
442    }
443
444    async fn repair_applied(
445        &self,
446        journal: &MaterializationJournal,
447    ) -> Result<(), MaterializationError<S::Error, B::Error>> {
448        for (edit, preimage) in journal.plan.edits.iter().zip(&journal.preimages) {
449            match self
450                .backend
451                .observe(edit, preimage)
452                .await
453                .map_err(MaterializationError::Backend)?
454            {
455                MaterializationObservation::Unverified | MaterializationObservation::Postimage => {}
456                MaterializationObservation::Preimage | MaterializationObservation::Interrupted => {
457                    self.backend
458                        .apply(edit, preimage)
459                        .await
460                        .map_err(MaterializationError::Backend)?;
461                }
462                MaterializationObservation::Diverged => {
463                    return Err(MaterializationError::ExternalMutation);
464                }
465            }
466        }
467        Ok(())
468    }
469
470    async fn persist(
471        &self,
472        mut journal: MaterializationJournal,
473    ) -> Result<MaterializationJournal, MaterializationError<S::Error, B::Error>> {
474        for _ in 0..MAXIMUM_CAS_ATTEMPTS {
475            let expected = journal.revision;
476            journal.revision = expected.saturating_add(1);
477            if self
478                .store
479                .compare_and_swap(journal.plan.operation_id, expected, journal.clone())
480                .await
481                .map_err(MaterializationError::Store)?
482            {
483                return Ok(journal);
484            }
485            let Some(current) = self.load(journal.plan.operation_id).await? else {
486                return Err(MaterializationError::IncompatibleJournal);
487            };
488            if current.plan != journal.plan {
489                return Err(MaterializationError::PlanConflict);
490            }
491            journal = current;
492        }
493        Err(MaterializationError::Contended)
494    }
495}
496
497fn valid_journal(journal: &MaterializationJournal, operation_id: OperationId) -> bool {
498    let edit_count = journal.plan.edits.len();
499    let applied = usize::try_from(journal.applied).unwrap_or(usize::MAX);
500    let restored = usize::try_from(journal.restored).unwrap_or(usize::MAX);
501    journal.version == JOURNAL_VERSION
502        && journal.plan.operation_id == operation_id
503        && journal.preimages.len() == edit_count
504        && applied <= edit_count
505        && restored <= applied
506        && match journal.phase {
507            MaterializationPhase::Prepared => applied == 0 && restored == 0,
508            MaterializationPhase::Applying => restored == 0,
509            MaterializationPhase::Applied => applied == edit_count && restored == 0,
510            MaterializationPhase::RollingBack => true,
511            MaterializationPhase::RolledBack => restored == applied,
512        }
513}
514
515fn validate_plan(plan: &MaterializationPlan) -> Result<(), ()> {
516    let mut paths = Vec::with_capacity(plan.edits.len().saturating_mul(2));
517    for edit in &plan.edits {
518        match edit {
519            MaterializationEdit::Install { path, .. } | MaterializationEdit::Remove { path } => {
520                validate_materialization_path(path)?;
521                paths.push(path.as_str());
522            }
523            MaterializationEdit::SetMetadata { path, .. } => {
524                validate_materialization_path(path)?;
525            }
526            MaterializationEdit::Rename { from, to } => {
527                validate_materialization_path(from)?;
528                validate_materialization_path(to)?;
529                if from == to {
530                    return Err(());
531                }
532                paths.push(from.as_str());
533                paths.push(to.as_str());
534            }
535        }
536    }
537    paths.sort_unstable();
538    for pair in paths.windows(2) {
539        let [left, right] = pair else { continue };
540        if left == right
541            || right
542                .strip_prefix(*left)
543                .is_some_and(|suffix| suffix.starts_with('/'))
544        {
545            return Err(());
546        }
547    }
548    Ok(())
549}
550
551fn validate_materialization_path(path: &str) -> Result<(), ()> {
552    if path.is_empty()
553        || path.starts_with('/')
554        || path.starts_with('\\')
555        || path.contains('\\')
556        || path
557            .split('/')
558            .any(|component| component.is_empty() || component == "." || component == "..")
559    {
560        return Err(());
561    }
562    Ok(())
563}
564
565/// Native same-volume tree publisher used for root checkout application.
566///
567/// Callers first materialize the exact target generation into `target`. The
568/// operation directory may be a sibling of the checkout so no repository
569/// marker is required, but it must not be the checkout, an ancestor, or a
570/// descendant. Each
571/// top-level binding is then exchanged with the live root independently. The
572/// displaced live binding remains in `backup` until the journal is terminal,
573/// preserving metadata, sparse allocation, links, and hard-link identity for
574/// rollback without re-encoding host state.
575#[cfg(not(target_arch = "wasm32"))]
576#[derive(Clone)]
577pub struct NativeTreeMaterializationBackend {
578    root: PathBuf,
579    target: PathBuf,
580    backup: PathBuf,
581}
582
583#[cfg(not(target_arch = "wasm32"))]
584impl NativeTreeMaterializationBackend {
585    /// Binds an existing checkout root and same-volume operation directory.
586    /// `operation_directory/target` must contain the fully materialized target
587    /// tree; `operation_directory/backup` is owned by this backend.
588    pub fn new(
589        root: impl Into<PathBuf>,
590        operation_directory: impl Into<PathBuf>,
591    ) -> Result<Self, NativeTreeMaterializationError> {
592        let root = root.into().canonicalize()?;
593        let operation_directory = operation_directory.into().canonicalize()?;
594        let target = operation_directory.join("target");
595        if !target.is_dir()
596            || operation_directory == root
597            || root.starts_with(&operation_directory)
598            || operation_directory.starts_with(&root)
599            || !same_native_volume(&root, &operation_directory)?
600        {
601            return Err(NativeTreeMaterializationError::InvalidLayout);
602        }
603        let backup = operation_directory.join("backup");
604        std::fs::create_dir_all(&backup)?;
605        Ok(Self {
606            root,
607            target,
608            backup,
609        })
610    }
611
612    #[cfg(any(feature = "native-mount", test))]
613    fn plan_paths(
614        &self,
615        operation_id: OperationId,
616        from: GenerationId,
617        to: GenerationId,
618        paths: impl IntoIterator<Item = String>,
619    ) -> Result<MaterializationPlan, NativeTreeMaterializationError> {
620        let edits = paths
621            .into_iter()
622            .map(|path| {
623                validate_materialization_path(&path)
624                    .map_err(|()| NativeTreeMaterializationError::InvalidPath(path.clone()))?;
625                if native_entry_exists(&self.target.join(&path))? {
626                    Ok(MaterializationEdit::Install {
627                        path,
628                        image: vec![1],
629                    })
630                } else {
631                    Ok(MaterializationEdit::Remove { path })
632                }
633            })
634            .collect::<Result<Vec<_>, NativeTreeMaterializationError>>()?;
635        let plan = MaterializationPlan {
636            operation_id,
637            from,
638            to,
639            edits,
640        };
641        validate_plan(&plan).map_err(|()| NativeTreeMaterializationError::OverlappingPaths)?;
642        Ok(plan)
643    }
644
645    fn paths(&self, path: &str) -> (PathBuf, PathBuf, PathBuf) {
646        (
647            self.root.join(path),
648            self.target.join(path),
649            self.backup.join(path),
650        )
651    }
652}
653
654#[cfg(all(feature = "native-mount", not(target_arch = "wasm32")))]
655fn validate_native_operation_location(
656    root: &Path,
657    operation_directory: &Path,
658) -> Result<(), NativeTreeMaterializationError> {
659    let root = root.canonicalize()?;
660    let operation = prospective_canonical_path(operation_directory)?;
661    if operation == root || operation.starts_with(&root) || root.starts_with(&operation) {
662        return Err(NativeTreeMaterializationError::InvalidLayout);
663    }
664    let existing = nearest_existing_ancestor(operation_directory)?;
665    if !same_native_volume(&root, &existing.canonicalize()?)? {
666        return Err(NativeTreeMaterializationError::InvalidLayout);
667    }
668    Ok(())
669}
670
671#[cfg(all(feature = "native-mount", not(target_arch = "wasm32")))]
672fn nearest_existing_ancestor(path: &Path) -> Result<PathBuf, std::io::Error> {
673    let mut cursor = path;
674    loop {
675        match std::fs::symlink_metadata(cursor) {
676            Ok(_) => return Ok(cursor.to_path_buf()),
677            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
678                cursor = cursor.parent().ok_or(error)?;
679            }
680            Err(error) => return Err(error),
681        }
682    }
683}
684
685#[cfg(all(feature = "native-mount", not(target_arch = "wasm32")))]
686fn prospective_canonical_path(path: &Path) -> Result<PathBuf, std::io::Error> {
687    let existing = nearest_existing_ancestor(path)?;
688    let mut canonical = existing.canonicalize()?;
689    let suffix = path
690        .strip_prefix(&existing)
691        .map_err(|_| std::io::Error::other("invalid prospective path"))?;
692    for component in suffix.components() {
693        match component {
694            std::path::Component::Normal(name) => canonical.push(name),
695            _ => return Err(std::io::Error::other("invalid prospective path component")),
696        }
697    }
698    Ok(canonical)
699}
700
701#[cfg(all(unix, not(target_arch = "wasm32")))]
702fn same_native_volume(left: &Path, right: &Path) -> Result<bool, std::io::Error> {
703    use std::os::unix::fs::MetadataExt as _;
704    Ok(std::fs::metadata(left)?.dev() == std::fs::metadata(right)?.dev())
705}
706
707#[cfg(all(windows, not(target_arch = "wasm32")))]
708fn same_native_volume(left: &Path, right: &Path) -> Result<bool, std::io::Error> {
709    use std::path::Component;
710    let prefix = |path: &Path| {
711        path.components().find_map(|component| match component {
712            Component::Prefix(prefix) => Some(prefix.as_os_str().to_os_string()),
713            _ => None,
714        })
715    };
716    Ok(prefix(left) == prefix(right))
717}
718
719#[cfg(all(not(unix), not(windows), not(target_arch = "wasm32")))]
720fn same_native_volume(_left: &Path, _right: &Path) -> Result<bool, std::io::Error> {
721    Ok(true)
722}
723
724#[cfg(not(target_arch = "wasm32"))]
725impl MaterializationBackend for NativeTreeMaterializationBackend {
726    type Error = NativeTreeMaterializationError;
727
728    async fn capture(
729        &self,
730        edit: &MaterializationEdit,
731    ) -> Result<MaterializationPreimage, Self::Error> {
732        let backend = self.clone();
733        let edit = edit.clone();
734        acyclic_native_runtime::run_blocking_io(move || {
735            let edit = &edit;
736            let path = edit_path(edit);
737            let (live, target, backup) = backend.paths(path);
738            if native_entry_exists(&backup)? {
739                return Err(NativeTreeMaterializationError::UnexpectedBackup(
740                    path.to_owned(),
741                ));
742            }
743            let before = native_entry_fingerprint_for_edit(&live, edit)?;
744            let after = match edit {
745                MaterializationEdit::Install { .. } => native_entry_fingerprint(&target)?
746                    .ok_or_else(|| NativeTreeMaterializationError::MissingTarget(path.to_owned()))?
747                    .into(),
748                MaterializationEdit::Remove { .. } => None,
749                MaterializationEdit::SetMetadata { image, .. } => {
750                    let desired = decode_native_metadata(image)?;
751                    native_entry_fingerprint_scoped(&live, false, Some(&desired))?
752                }
753                MaterializationEdit::Rename { .. } => {
754                    return Err(NativeTreeMaterializationError::UnsupportedRename);
755                }
756            };
757            let metadata = if matches!(edit, MaterializationEdit::SetMetadata { .. }) {
758                Some(native_metadata(&std::fs::symlink_metadata(&live)?))
759            } else {
760                None
761            };
762            Ok(MaterializationPreimage {
763                image: encode_native_witness(before, after, metadata)?,
764            })
765        })
766        .await?
767    }
768
769    async fn observe(
770        &self,
771        edit: &MaterializationEdit,
772        preimage: &MaterializationPreimage,
773    ) -> Result<MaterializationObservation, Self::Error> {
774        let backend = self.clone();
775        let edit = edit.clone();
776        let preimage = preimage.clone();
777        acyclic_native_runtime::run_blocking_io(move || {
778            let edit = &edit;
779            let preimage = &preimage;
780            let (before, after) = decode_native_witness(&preimage.image)?;
781            let path = edit_path(edit);
782            let (live, target, backup) = backend.paths(path);
783            let current = native_entry_fingerprint_for_edit(&live, edit)?;
784            let target_current = if matches!(edit, MaterializationEdit::Install { .. }) {
785                native_entry_fingerprint(&target)?
786            } else {
787                None
788            };
789            let backup_current = native_entry_fingerprint(&backup)?;
790            let backup_exists = backup_current.is_some();
791            if current == before && current == after && target_current.is_none() && backup_exists {
792                return Ok(MaterializationObservation::Postimage);
793            }
794            if current == before {
795                if matches!(edit, MaterializationEdit::Install { .. }) && target_current != after {
796                    return Ok(MaterializationObservation::Diverged);
797                }
798                return Ok(MaterializationObservation::Preimage);
799            }
800            if current == after {
801                if target_current.is_some() && target_current != after {
802                    return Ok(MaterializationObservation::Diverged);
803                }
804                return Ok(MaterializationObservation::Postimage);
805            }
806
807            // A crash may land between the two renames used for an install. The
808            // durable backup proves the old binding was moved by this operation;
809            // the still-present staged target means forward application can resume.
810            if current.is_none()
811                && backup_current == before
812                && matches!(edit, MaterializationEdit::Install { .. })
813            {
814                return if target_current.is_none() || target_current == after {
815                    Ok(MaterializationObservation::Interrupted)
816                } else {
817                    Ok(MaterializationObservation::Diverged)
818                };
819            }
820            Ok(MaterializationObservation::Diverged)
821        })
822        .await?
823    }
824
825    async fn apply(
826        &self,
827        edit: &MaterializationEdit,
828        preimage: &MaterializationPreimage,
829    ) -> Result<(), Self::Error> {
830        let backend = self.clone();
831        let edit = edit.clone();
832        let preimage = preimage.clone();
833        acyclic_native_runtime::run_blocking_io(move || {
834            let edit = &edit;
835            let preimage = &preimage;
836            let path = edit_path(edit);
837            let (live, target, backup) = backend.paths(path);
838            let (before, after) = decode_native_witness(&preimage.image)?;
839            if let MaterializationEdit::SetMetadata { image, .. } = edit {
840                let current = native_entry_fingerprint_for_edit(&live, edit)?;
841                if current == after {
842                    return Ok(());
843                }
844                if current != before {
845                    return Err(NativeTreeMaterializationError::ExternalMutation(
846                        path.to_owned(),
847                    ));
848                }
849                return apply_native_metadata(&live, &decode_native_metadata(image)?);
850            }
851            let target_expected = matches!(edit, MaterializationEdit::Install { .. });
852            let backup_exists = native_entry_exists(&backup)?;
853            let target_fingerprint = native_entry_fingerprint(&target)?;
854            let target_exists = target_fingerprint.is_some();
855            let live_fingerprint = native_entry_fingerprint(&live)?;
856            let live_exists = live_fingerprint.is_some();
857            if !backup_exists && !target_exists && live_fingerprint == after {
858                return Ok(());
859            }
860            if backup_exists && !target_exists && live_fingerprint == after {
861                return Ok(());
862            }
863            if backup_exists && live_fingerprint.is_some() && live_fingerprint != after {
864                return Err(NativeTreeMaterializationError::ExternalMutation(
865                    path.to_owned(),
866                ));
867            }
868            if !backup_exists && live_fingerprint != before {
869                return Err(NativeTreeMaterializationError::ExternalMutation(
870                    path.to_owned(),
871                ));
872            }
873            if target_expected && target_fingerprint != after {
874                return Err(NativeTreeMaterializationError::ExternalMutation(
875                    path.to_owned(),
876                ));
877            }
878            if backup_exists {
879                if target_expected && target_exists {
880                    remove_native_entry_durable(&live)?;
881                    durable_native_rename(&target, &live)?;
882                } else if target_expected && !live_exists {
883                    return Err(NativeTreeMaterializationError::MissingTarget(
884                        path.to_owned(),
885                    ));
886                } else if !target_expected {
887                    remove_native_entry_durable(&live)?;
888                }
889                return Ok(());
890            }
891            if live_exists {
892                if let Some(parent) = backup.parent() {
893                    std::fs::create_dir_all(parent)?;
894                }
895                durable_native_rename(&live, &backup)?;
896            }
897            if target_expected {
898                if !target_exists {
899                    return Err(NativeTreeMaterializationError::MissingTarget(
900                        path.to_owned(),
901                    ));
902                }
903                if let Some(parent) = live.parent() {
904                    std::fs::create_dir_all(parent)?;
905                }
906                if let Err(error) = durable_native_rename(&target, &live) {
907                    if native_entry_exists(&backup)? && !native_entry_exists(&live)? {
908                        durable_native_rename(&backup, &live)?;
909                    }
910                    return Err(error);
911                }
912            }
913            Ok(())
914        })
915        .await?
916    }
917
918    async fn restore(
919        &self,
920        edit: &MaterializationEdit,
921        preimage: &MaterializationPreimage,
922    ) -> Result<(), Self::Error> {
923        let backend = self.clone();
924        let edit = edit.clone();
925        let preimage = preimage.clone();
926        acyclic_native_runtime::run_blocking_io(move || {
927            let edit = &edit;
928            let preimage = &preimage;
929            let path = edit_path(edit);
930            let (live, _, backup) = backend.paths(path);
931            let (before, after) = decode_native_witness(&preimage.image)?;
932            let current = native_entry_fingerprint_for_edit(&live, edit)?;
933            if matches!(edit, MaterializationEdit::SetMetadata { .. }) {
934                if current == before {
935                    return Ok(());
936                }
937                if current != after {
938                    return Err(NativeTreeMaterializationError::ExternalMutation(
939                        path.to_owned(),
940                    ));
941                }
942                let metadata = decode_native_preimage_metadata(&preimage.image)?
943                    .ok_or(NativeTreeMaterializationError::InvalidPreimage)?;
944                return apply_native_metadata(&live, &metadata);
945            }
946            let backup_current = native_entry_fingerprint(&backup)?;
947            let backup_exists = backup_current.is_some();
948            if backup_exists && backup_current != before {
949                return Err(NativeTreeMaterializationError::ExternalMutation(
950                    path.to_owned(),
951                ));
952            }
953            if current == before && !backup_exists {
954                return Ok(());
955            }
956            if current != after && !(current.is_none() && backup_exists) {
957                return Err(NativeTreeMaterializationError::ExternalMutation(
958                    path.to_owned(),
959                ));
960            }
961            match before {
962                None => remove_native_entry_durable(&live),
963                Some(_) if backup_exists => {
964                    remove_native_entry_durable(&live)?;
965                    durable_native_rename(&backup, &live)
966                }
967                Some(expected) if current == Some(expected) => Ok(()),
968                Some(_) => Err(NativeTreeMaterializationError::MissingPreimage(
969                    path.to_owned(),
970                )),
971            }
972        })
973        .await?
974    }
975}
976
977#[cfg(not(target_arch = "wasm32"))]
978fn durable_native_rename(from: &Path, to: &Path) -> Result<(), NativeTreeMaterializationError> {
979    acyclic_native_runtime::durable_rename(from, to, acyclic_native_runtime::RenameMode::NoReplace)
980        .map_err(Into::into)
981}
982
983#[cfg(not(target_arch = "wasm32"))]
984fn remove_native_entry_durable(path: &Path) -> Result<(), NativeTreeMaterializationError> {
985    let existed = native_entry_exists(path)?;
986    remove_native_entry(path)?;
987    if existed {
988        let parent = path
989            .parent()
990            .ok_or(NativeTreeMaterializationError::InvalidPreimage)?;
991        acyclic_native_runtime::sync_parent(parent, acyclic_native_runtime::Durability::Full)?;
992    }
993    Ok(())
994}
995
996#[cfg(not(target_arch = "wasm32"))]
997fn edit_path(edit: &MaterializationEdit) -> &str {
998    match edit {
999        MaterializationEdit::Install { path, .. }
1000        | MaterializationEdit::Remove { path }
1001        | MaterializationEdit::SetMetadata { path, .. } => path,
1002        MaterializationEdit::Rename { from, .. } => from,
1003    }
1004}
1005
1006#[cfg(not(target_arch = "wasm32"))]
1007fn native_entry_exists(path: &Path) -> Result<bool, std::io::Error> {
1008    match std::fs::symlink_metadata(path) {
1009        Ok(_) => Ok(true),
1010        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1011        Err(error) => Err(error),
1012    }
1013}
1014
1015#[cfg(not(target_arch = "wasm32"))]
1016const NATIVE_WITNESS_VERSION: u8 = 1;
1017
1018#[cfg(not(target_arch = "wasm32"))]
1019#[derive(Clone, Debug, Deserialize, Serialize)]
1020struct NativeMetadataImage {
1021    readonly: bool,
1022    posix_mode: Option<u32>,
1023    windows_attributes: Option<u32>,
1024}
1025
1026#[cfg(not(target_arch = "wasm32"))]
1027type NativeFingerprint = [u8; 32];
1028
1029#[cfg(not(target_arch = "wasm32"))]
1030type NativeWitness = (Option<NativeFingerprint>, Option<NativeFingerprint>);
1031
1032#[cfg(not(target_arch = "wasm32"))]
1033fn encode_native_witness(
1034    before: Option<[u8; 32]>,
1035    after: Option<[u8; 32]>,
1036    metadata: Option<NativeMetadataImage>,
1037) -> Result<Vec<u8>, NativeTreeMaterializationError> {
1038    let mut image = Vec::with_capacity(67);
1039    image.push(NATIVE_WITNESS_VERSION);
1040    for fingerprint in [before, after] {
1041        image.push(u8::from(fingerprint.is_some()));
1042        image.extend_from_slice(&fingerprint.unwrap_or([0; 32]));
1043    }
1044    if let Some(metadata) = metadata {
1045        image.extend_from_slice(
1046            &serde_json::to_vec(&metadata)
1047                .map_err(|_| NativeTreeMaterializationError::InvalidPreimage)?,
1048        );
1049    }
1050    Ok(image)
1051}
1052
1053#[cfg(not(target_arch = "wasm32"))]
1054fn decode_native_witness(image: &[u8]) -> Result<NativeWitness, NativeTreeMaterializationError> {
1055    let (witness, _) = image
1056        .split_at_checked(67)
1057        .ok_or(NativeTreeMaterializationError::InvalidPreimage)?;
1058    let image: &[u8; 67] = witness
1059        .try_into()
1060        .map_err(|_| NativeTreeMaterializationError::InvalidPreimage)?;
1061    let (version, fields) = image
1062        .split_first()
1063        .ok_or(NativeTreeMaterializationError::InvalidPreimage)?;
1064    if *version != NATIVE_WITNESS_VERSION {
1065        return Err(NativeTreeMaterializationError::InvalidPreimage);
1066    }
1067    let decode = |present: u8, bytes: &[u8]| {
1068        let fingerprint: [u8; 32] = bytes
1069            .try_into()
1070            .map_err(|_| NativeTreeMaterializationError::InvalidPreimage)?;
1071        match present {
1072            0 if fingerprint == [0; 32] => Ok(None),
1073            1 => Ok(Some(fingerprint)),
1074            _ => Err(NativeTreeMaterializationError::InvalidPreimage),
1075        }
1076    };
1077    let (before, after) = fields
1078        .split_at_checked(33)
1079        .ok_or(NativeTreeMaterializationError::InvalidPreimage)?;
1080    let (before_present, before_bytes) = before
1081        .split_first()
1082        .ok_or(NativeTreeMaterializationError::InvalidPreimage)?;
1083    let (after_present, after_bytes) = after
1084        .split_first()
1085        .ok_or(NativeTreeMaterializationError::InvalidPreimage)?;
1086    Ok((
1087        decode(*before_present, before_bytes)?,
1088        decode(*after_present, after_bytes)?,
1089    ))
1090}
1091
1092#[cfg(not(target_arch = "wasm32"))]
1093fn decode_native_preimage_metadata(
1094    image: &[u8],
1095) -> Result<Option<NativeMetadataImage>, NativeTreeMaterializationError> {
1096    let (_, metadata) = image
1097        .split_at_checked(67)
1098        .ok_or(NativeTreeMaterializationError::InvalidPreimage)?;
1099    if metadata.is_empty() {
1100        Ok(None)
1101    } else {
1102        serde_json::from_slice(metadata)
1103            .map(Some)
1104            .map_err(|_| NativeTreeMaterializationError::InvalidPreimage)
1105    }
1106}
1107
1108#[cfg(not(target_arch = "wasm32"))]
1109fn decode_native_metadata(
1110    image: &[u8],
1111) -> Result<NativeMetadataImage, NativeTreeMaterializationError> {
1112    serde_json::from_slice(image).map_err(|_| NativeTreeMaterializationError::InvalidPreimage)
1113}
1114
1115#[cfg(not(target_arch = "wasm32"))]
1116fn native_entry_fingerprint(path: &Path) -> Result<Option<[u8; 32]>, std::io::Error> {
1117    native_entry_fingerprint_scoped(path, true, None)
1118}
1119
1120#[cfg(not(target_arch = "wasm32"))]
1121fn native_entry_fingerprint_for_edit(
1122    path: &Path,
1123    edit: &MaterializationEdit,
1124) -> Result<Option<[u8; 32]>, std::io::Error> {
1125    native_entry_fingerprint_scoped(
1126        path,
1127        !matches!(edit, MaterializationEdit::SetMetadata { .. }),
1128        None,
1129    )
1130}
1131
1132#[cfg(not(target_arch = "wasm32"))]
1133fn native_entry_fingerprint_scoped(
1134    path: &Path,
1135    include_contents: bool,
1136    override_metadata: Option<&NativeMetadataImage>,
1137) -> Result<Option<[u8; 32]>, std::io::Error> {
1138    let metadata = match std::fs::symlink_metadata(path) {
1139        Ok(metadata) => metadata,
1140        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1141        Err(error) => return Err(error),
1142    };
1143    let mut hasher = blake3::Hasher::new();
1144    if !include_contents {
1145        hash_native_metadata_edit(&metadata, override_metadata, &mut hasher);
1146        #[cfg(windows)]
1147        {
1148            // MetadataExt does not expose a stable Windows file identity.
1149            // Hold an opened binding while obtaining the identity so a path
1150            // replacement cannot masquerade as the same metadata-only edit.
1151            let identity = if metadata.is_dir() {
1152                let directory =
1153                    cap_std::fs::Dir::open_ambient_dir(path, cap_std::ambient_authority())?;
1154                Some(crate::NativeRootIdentity::from_metadata(
1155                    &directory.dir_metadata()?,
1156                )?)
1157            } else if metadata.is_file() {
1158                let file = cap_std::fs::File::from_std(std::fs::File::open(path)?);
1159                Some(crate::NativeRootIdentity::from_metadata(&file.metadata()?)?)
1160            } else {
1161                None
1162            };
1163            if let Some(identity) = identity {
1164                hasher.update(&identity.to_bytes());
1165            }
1166        }
1167        return Ok(Some(*hasher.finalize().as_bytes()));
1168    }
1169    hash_native_entry(
1170        path,
1171        &metadata,
1172        &mut hasher,
1173        override_metadata,
1174        include_contents,
1175    )?;
1176    Ok(Some(*hasher.finalize().as_bytes()))
1177}
1178
1179#[cfg(not(target_arch = "wasm32"))]
1180fn hash_native_metadata_edit(
1181    metadata: &std::fs::Metadata,
1182    desired: Option<&NativeMetadataImage>,
1183    hasher: &mut blake3::Hasher,
1184) {
1185    let file_type = metadata.file_type();
1186    hasher.update(if file_type.is_symlink() {
1187        b"link"
1188    } else if file_type.is_dir() {
1189        b"directory"
1190    } else if file_type.is_file() {
1191        b"file"
1192    } else {
1193        b"special"
1194    });
1195    #[cfg(unix)]
1196    {
1197        use std::os::unix::fs::MetadataExt as _;
1198        for value in [metadata.dev(), metadata.ino()] {
1199            hasher.update(&value.to_le_bytes());
1200        }
1201        hasher.update(
1202            &u64::from(
1203                desired
1204                    .and_then(|value| value.posix_mode)
1205                    .unwrap_or_else(|| metadata.mode()),
1206            )
1207            .to_le_bytes(),
1208        );
1209    }
1210    #[cfg(windows)]
1211    {
1212        use std::os::windows::fs::MetadataExt as _;
1213        let attributes = desired
1214            .and_then(|value| value.windows_attributes)
1215            .unwrap_or_else(|| {
1216                let attributes = metadata.file_attributes();
1217                match desired {
1218                    Some(value) if value.readonly => attributes | 1,
1219                    Some(_) => attributes & !1,
1220                    None => attributes,
1221                }
1222            });
1223        hasher.update(&attributes.to_le_bytes());
1224    }
1225    #[cfg(not(any(unix, windows)))]
1226    hasher.update(&[u8::from(desired.map_or_else(
1227        || metadata.permissions().readonly(),
1228        |value| value.readonly,
1229    ))]);
1230}
1231
1232#[cfg(not(target_arch = "wasm32"))]
1233fn hash_native_entry(
1234    path: &Path,
1235    metadata: &std::fs::Metadata,
1236    hasher: &mut blake3::Hasher,
1237    override_metadata: Option<&NativeMetadataImage>,
1238    include_contents: bool,
1239) -> Result<(), std::io::Error> {
1240    use std::io::Read as _;
1241
1242    let file_type = metadata.file_type();
1243    hasher.update(if file_type.is_symlink() {
1244        b"link"
1245    } else if file_type.is_dir() {
1246        b"directory"
1247    } else if file_type.is_file() {
1248        b"file"
1249    } else {
1250        b"special"
1251    });
1252    if include_contents {
1253        hasher.update(&metadata.len().to_le_bytes());
1254    }
1255    hasher.update(&[u8::from(override_metadata.map_or_else(
1256        || metadata.permissions().readonly(),
1257        |value| value.readonly,
1258    ))]);
1259    hash_native_metadata(metadata, override_metadata, include_contents, hasher);
1260
1261    if !include_contents {
1262        return Ok(());
1263    }
1264    if file_type.is_symlink() {
1265        hash_native_os_str(std::fs::read_link(path)?.as_os_str(), hasher);
1266    } else if file_type.is_dir() {
1267        let mut entries = std::fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
1268        entries.sort_by_key(|entry| entry.file_name());
1269        for entry in entries {
1270            hash_native_os_str(&entry.file_name(), hasher);
1271            let metadata = std::fs::symlink_metadata(entry.path())?;
1272            hash_native_entry(&entry.path(), &metadata, hasher, None, true)?;
1273        }
1274    } else if file_type.is_file() {
1275        let mut file = std::fs::File::open(path)?;
1276        let mut buffer = vec![0; 1024 * 1024];
1277        loop {
1278            let read = file.read(&mut buffer)?;
1279            if read == 0 {
1280                break;
1281            }
1282            let bytes = buffer
1283                .get(..read)
1284                .ok_or_else(|| std::io::Error::other("invalid read length"))?;
1285            hasher.update(bytes);
1286        }
1287    }
1288    Ok(())
1289}
1290
1291#[cfg(all(unix, not(target_arch = "wasm32")))]
1292fn hash_native_metadata(
1293    metadata: &std::fs::Metadata,
1294    desired: Option<&NativeMetadataImage>,
1295    include_contents: bool,
1296    hasher: &mut blake3::Hasher,
1297) {
1298    use std::os::unix::fs::MetadataExt as _;
1299    for value in [
1300        metadata.dev(),
1301        metadata.ino(),
1302        u64::from(
1303            desired
1304                .and_then(|value| value.posix_mode)
1305                .unwrap_or_else(|| metadata.mode()),
1306        ),
1307    ] {
1308        hasher.update(&value.to_le_bytes());
1309    }
1310    if include_contents {
1311        for value in [
1312            metadata.nlink(),
1313            u64::from(metadata.uid()),
1314            u64::from(metadata.gid()),
1315            metadata.rdev(),
1316        ] {
1317            hasher.update(&value.to_le_bytes());
1318        }
1319        hasher.update(&metadata.mtime().to_le_bytes());
1320        hasher.update(&metadata.mtime_nsec().to_le_bytes());
1321    }
1322}
1323
1324#[cfg(all(windows, not(target_arch = "wasm32")))]
1325fn hash_native_metadata(
1326    metadata: &std::fs::Metadata,
1327    desired: Option<&NativeMetadataImage>,
1328    include_contents: bool,
1329    hasher: &mut blake3::Hasher,
1330) {
1331    use std::os::windows::fs::MetadataExt as _;
1332    let attributes = desired.map_or_else(
1333        || metadata.file_attributes(),
1334        |value| {
1335            if let Some(attributes) = value.windows_attributes {
1336                attributes
1337            } else if value.readonly {
1338                metadata.file_attributes() | 1
1339            } else {
1340                metadata.file_attributes() & !1
1341            }
1342        },
1343    );
1344    hasher.update(&u64::from(attributes).to_le_bytes());
1345    if include_contents {
1346        for value in [metadata.last_write_time(), metadata.file_size()] {
1347            hasher.update(&value.to_le_bytes());
1348        }
1349    }
1350}
1351
1352#[cfg(all(not(unix), not(windows), not(target_arch = "wasm32")))]
1353fn hash_native_metadata(
1354    _metadata: &std::fs::Metadata,
1355    _desired: Option<&NativeMetadataImage>,
1356    _include_contents: bool,
1357    _hasher: &mut blake3::Hasher,
1358) {
1359}
1360
1361#[cfg(not(target_arch = "wasm32"))]
1362fn native_metadata(metadata: &std::fs::Metadata) -> NativeMetadataImage {
1363    NativeMetadataImage {
1364        readonly: metadata.permissions().readonly(),
1365        #[cfg(unix)]
1366        posix_mode: {
1367            use std::os::unix::fs::MetadataExt as _;
1368            Some(metadata.mode())
1369        },
1370        #[cfg(not(unix))]
1371        posix_mode: None,
1372        #[cfg(windows)]
1373        windows_attributes: {
1374            use std::os::windows::fs::MetadataExt as _;
1375            Some(metadata.file_attributes())
1376        },
1377        #[cfg(not(windows))]
1378        windows_attributes: None,
1379    }
1380}
1381
1382#[cfg(not(target_arch = "wasm32"))]
1383fn apply_native_metadata(
1384    path: &Path,
1385    desired: &NativeMetadataImage,
1386) -> Result<(), NativeTreeMaterializationError> {
1387    #[cfg(windows)]
1388    if let Some(attributes) = desired.windows_attributes {
1389        acyclic_native_runtime::set_file_attributes(path, attributes)?;
1390        return Ok(());
1391    }
1392    let mut permissions = std::fs::symlink_metadata(path)?.permissions();
1393    #[cfg(unix)]
1394    if let Some(mode) = desired.posix_mode {
1395        use std::os::unix::fs::PermissionsExt as _;
1396        permissions.set_mode(mode);
1397    }
1398    #[cfg(not(unix))]
1399    permissions.set_readonly(desired.readonly);
1400    std::fs::set_permissions(path, permissions)?;
1401    Ok(())
1402}
1403
1404#[cfg(all(feature = "native-mount", not(target_arch = "wasm32")))]
1405fn encode_native_metadata(
1406    metadata: &crate::WorkspaceMetadata,
1407) -> Result<Vec<u8>, NativeTreeMaterializationError> {
1408    let readonly = metadata
1409        .windows_attributes
1410        .is_some_and(|attributes| attributes & 1 != 0)
1411        || metadata.posix_mode.is_some_and(|mode| mode & 0o222 == 0);
1412    serde_json::to_vec(&NativeMetadataImage {
1413        readonly,
1414        posix_mode: metadata.posix_mode,
1415        windows_attributes: metadata.windows_attributes,
1416    })
1417    .map_err(|_| NativeTreeMaterializationError::InvalidPreimage)
1418}
1419
1420#[cfg(all(unix, not(target_arch = "wasm32")))]
1421fn hash_native_os_str(value: &std::ffi::OsStr, hasher: &mut blake3::Hasher) {
1422    use std::os::unix::ffi::OsStrExt as _;
1423    hasher.update(value.as_bytes());
1424}
1425
1426#[cfg(all(windows, not(target_arch = "wasm32")))]
1427fn hash_native_os_str(value: &std::ffi::OsStr, hasher: &mut blake3::Hasher) {
1428    use std::os::windows::ffi::OsStrExt as _;
1429    for code_unit in value.encode_wide() {
1430        hasher.update(&code_unit.to_le_bytes());
1431    }
1432}
1433
1434#[cfg(all(not(unix), not(windows), not(target_arch = "wasm32")))]
1435fn hash_native_os_str(value: &std::ffi::OsStr, hasher: &mut blake3::Hasher) {
1436    hasher.update(value.to_string_lossy().as_bytes());
1437}
1438
1439#[cfg(not(target_arch = "wasm32"))]
1440fn remove_native_entry(path: &Path) -> Result<(), std::io::Error> {
1441    let metadata = match std::fs::symlink_metadata(path) {
1442        Ok(metadata) => metadata,
1443        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1444        Err(error) => return Err(error),
1445    };
1446    if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() {
1447        std::fs::remove_dir_all(path)
1448    } else {
1449        std::fs::remove_file(path)
1450    }
1451}
1452
1453/// Native tree publication failure.
1454#[cfg(not(target_arch = "wasm32"))]
1455#[derive(Debug, Error)]
1456pub enum NativeTreeMaterializationError {
1457    /// Root, target, or backup layout is not same-root and operation-scoped.
1458    #[error("native materialization layout is invalid")]
1459    InvalidLayout,
1460    /// The exact target binding disappeared before application.
1461    #[error("native materialization target is missing for '{0}'")]
1462    MissingTarget(String),
1463    /// A physical endpoint changed after its durable witness was captured.
1464    #[error("native materialization observed an external mutation at '{0}'")]
1465    ExternalMutation(String),
1466    /// A durable preimage references a missing backup binding.
1467    #[error("native materialization preimage is missing for '{0}'")]
1468    MissingPreimage(String),
1469    /// Persisted preimage bytes are malformed.
1470    #[error("native materialization preimage is invalid")]
1471    InvalidPreimage,
1472    /// A backup exists before its operation journal has been created.
1473    #[error("native materialization found an unexpected backup for '{0}'")]
1474    UnexpectedBackup(String),
1475    /// Native path exchange does not yet expose a rename edit directly.
1476    #[error("native materialization rename edits are unsupported")]
1477    UnsupportedRename,
1478    /// Native filesystem operation failed.
1479    #[error(transparent)]
1480    Io(#[from] std::io::Error),
1481    /// A changed namespace path cannot be represented by this host publisher.
1482    #[error("native materialization path is invalid: '{0}'")]
1483    InvalidPath(String),
1484    /// Changed paths overlap and cannot be exchanged independently.
1485    #[error("native materialization paths overlap")]
1486    OverlappingPaths,
1487}
1488
1489/// Materializes and publishes one authenticated workspace generation to a
1490/// physical root through the core journal. Recovery resumes an existing
1491/// operation instead of rebuilding or accepting a partial checkout.
1492#[cfg(all(
1493    feature = "local",
1494    feature = "native-mount",
1495    not(target_arch = "wasm32")
1496))]
1497pub struct NativeWorkspacePublication<'a> {
1498    /// Physical checkout root.
1499    pub root: &'a Path,
1500    /// Service-owned staging and recovery directory.
1501    pub operation_directory: &'a Path,
1502    /// Stable retry identity.
1503    pub operation_id: OperationId,
1504    /// Expected physical/logical baseline.
1505    pub from: GenerationId,
1506    /// Exact target generation.
1507    pub to: GenerationId,
1508    /// Root-level names never touched by publication.
1509    pub excluded_names: &'a [&'a str],
1510    /// Native staging policy.
1511    pub options: &'a crate::MaterializeOptions,
1512    /// Cumulative planning and staging budget.
1513    pub budget: crate::WorkBudget,
1514    /// Shared cancellation boundary.
1515    pub cancellation: &'a crate::CancellationToken,
1516}
1517
1518#[cfg(all(
1519    feature = "local",
1520    feature = "native-mount",
1521    not(target_arch = "wasm32")
1522))]
1523#[allow(clippy::too_many_lines)]
1524/// Executes or resumes one bounded, journaled physical-root publication.
1525pub async fn publish_native_workspace_generation<A, O>(
1526    workspace: &crate::Workspace<A, O>,
1527    state: &crate::LocalCoreStateStore,
1528    publication: NativeWorkspacePublication<'_>,
1529) -> Result<MaterializationJournal, NativeWorkspacePublicationError>
1530where
1531    A: crate::AsyncAuthorityStore,
1532    O: crate::AsyncObjectStore,
1533{
1534    let from_generation = workspace.generation(publication.from).await?;
1535    let to_generation = workspace.generation(publication.to).await?;
1536    publish_native_generation_transition(&from_generation, &to_generation, state, publication).await
1537}
1538
1539/// Publishes a transition between two authenticated generations, including
1540/// generations owned by sibling workspaces in the same distributed filesystem.
1541///
1542/// This is the branch-switch primitive: workspace identity may change while
1543/// physical checkout work remains proportional only to changed paths.
1544#[cfg(all(
1545    feature = "local",
1546    feature = "native-mount",
1547    not(target_arch = "wasm32")
1548))]
1549#[allow(clippy::too_many_lines)]
1550pub async fn publish_native_generation_transition<A, O>(
1551    from_generation: &crate::Generation<A, O>,
1552    to_generation: &crate::Generation<A, O>,
1553    state: &crate::LocalCoreStateStore,
1554    publication: NativeWorkspacePublication<'_>,
1555) -> Result<MaterializationJournal, NativeWorkspacePublicationError>
1556where
1557    A: crate::AsyncAuthorityStore,
1558    O: crate::AsyncObjectStore,
1559{
1560    let NativeWorkspacePublication {
1561        root,
1562        operation_directory,
1563        operation_id,
1564        from,
1565        to,
1566        excluded_names,
1567        options,
1568        budget,
1569        cancellation,
1570    } = publication;
1571    if from_generation.id() != from || to_generation.id() != to {
1572        return Err(NativeWorkspacePublicationError::MismatchedJournal);
1573    }
1574    let root = root.to_path_buf();
1575    let operation_directory = operation_directory.to_path_buf();
1576    acyclic_native_runtime::run_blocking_io({
1577        let root = root.clone();
1578        let operation_directory = operation_directory.clone();
1579        move || validate_native_operation_location(&root, &operation_directory)
1580    })
1581    .await??;
1582    if let Some(existing) = MaterializationJournalStore::load(state, operation_id).await? {
1583        if existing.plan.from != from || existing.plan.to != to {
1584            return Err(NativeWorkspacePublicationError::MismatchedJournal);
1585        }
1586        let backend = acyclic_native_runtime::run_blocking_io(move || {
1587            NativeTreeMaterializationBackend::new(root, operation_directory)
1588        })
1589        .await??;
1590        return JournaledMaterializer::new(state.clone(), backend)
1591            .recover(operation_id, MaterializationRecovery::Complete)
1592            .await
1593            .map_err(NativeWorkspacePublicationError::from)?
1594            .ok_or(NativeWorkspacePublicationError::MismatchedJournal);
1595    }
1596
1597    let changes = from_generation
1598        .diff_to_bounded(to_generation, u32::MAX, budget, cancellation)
1599        .await?;
1600    let changed = changes
1601        .changed_paths_bounded(u32::MAX, budget, cancellation)
1602        .await?;
1603    let remaining_budget = changed
1604        .work
1605        .remaining(budget)
1606        .map_err(NativeWorkspacePublicationError::Work)?;
1607    let mut paths = Vec::with_capacity(changed.value.len());
1608    let mut structural_directories = Vec::new();
1609    let mut metadata_edits = Vec::new();
1610    let excluded_names = excluded_names
1611        .iter()
1612        .copied()
1613        .collect::<std::collections::BTreeSet<_>>();
1614    for change in changed.value {
1615        let mut path = String::new();
1616        for component in change.path.components() {
1617            if !path.is_empty() {
1618                path.push('/');
1619            }
1620            path.push_str(&component.unicode_text().ok_or_else(|| {
1621                NativeTreeMaterializationError::InvalidPath("<non-Unicode>".to_owned())
1622            })?);
1623        }
1624        let top = path.split('/').next().unwrap_or_default();
1625        if excluded_names.contains(top) {
1626            continue;
1627        }
1628        let before_directory = change
1629            .before
1630            .is_some_and(|record| record.kind == crate::kernel::FileKind::Directory);
1631        let after_directory = change
1632            .after
1633            .is_some_and(|record| record.kind == crate::kernel::FileKind::Directory);
1634        if before_directory && after_directory {
1635            let stat = to_generation.stat(&format!("/{path}")).await?;
1636            metadata_edits.push(MaterializationEdit::SetMetadata {
1637                path,
1638                image: encode_native_metadata(&stat.metadata)?,
1639            });
1640            continue;
1641        }
1642        if before_directory || after_directory {
1643            structural_directories.push(path.clone());
1644        }
1645        paths.push((path, change.after.is_some()));
1646    }
1647    paths.retain(|(path, _)| {
1648        !structural_directories.iter().any(|directory| {
1649            path != directory
1650                && path
1651                    .strip_prefix(directory)
1652                    .is_some_and(|suffix| suffix.starts_with('/'))
1653        })
1654    });
1655    paths.sort_unstable_by(|left, right| left.0.cmp(&right.0));
1656    let target = acyclic_native_runtime::run_blocking_io({
1657        let operation_directory = operation_directory.clone();
1658        move || {
1659            std::fs::create_dir_all(&operation_directory)?;
1660            let target = operation_directory.join("target");
1661            if target.exists() {
1662                std::fs::remove_dir_all(&target)?;
1663            }
1664            std::fs::create_dir_all(&target)?;
1665            Ok::<_, NativeTreeMaterializationError>(target)
1666        }
1667    })
1668    .await??;
1669    let mut path_options = options.clone();
1670    path_options.destination.clone_from(&target);
1671    let install_paths = paths
1672        .iter()
1673        .filter(|(_, install)| *install)
1674        .map(|(path, _)| format!("/{path}"))
1675        .collect::<Vec<_>>();
1676    if !install_paths.is_empty() {
1677        to_generation
1678            .materialize_paths(
1679                &install_paths,
1680                &path_options,
1681                remaining_budget,
1682                cancellation,
1683            )
1684            .await?;
1685    }
1686    let (backend, mut plan) = acyclic_native_runtime::run_blocking_io(move || {
1687        let backend = NativeTreeMaterializationBackend::new(root, operation_directory)?;
1688        let plan = backend.plan_paths(
1689            operation_id,
1690            from,
1691            to,
1692            paths.into_iter().map(|(path, _)| path),
1693        )?;
1694        Ok::<_, NativeTreeMaterializationError>((backend, plan))
1695    })
1696    .await??;
1697    plan.edits.extend(metadata_edits);
1698    JournaledMaterializer::new(state.clone(), backend)
1699        .apply(plan)
1700        .await
1701        .map_err(Into::into)
1702}
1703
1704/// Core native workspace publication failure.
1705#[cfg(all(
1706    feature = "local",
1707    feature = "native-mount",
1708    not(target_arch = "wasm32")
1709))]
1710#[derive(Debug, Error)]
1711pub enum NativeWorkspacePublicationError {
1712    /// Authenticated workspace access or export failed.
1713    #[error(transparent)]
1714    Workspace(#[from] crate::WorkspaceError),
1715    /// Host tree planning or publication failed.
1716    #[error(transparent)]
1717    Native(#[from] NativeTreeMaterializationError),
1718    /// Durable state access failed.
1719    #[error(transparent)]
1720    State(#[from] crate::LocalCoreStateStoreError),
1721    /// Journaled publication failed.
1722    #[error(transparent)]
1723    Materialization(
1724        #[from]
1725        MaterializationError<crate::LocalCoreStateStoreError, NativeTreeMaterializationError>,
1726    ),
1727    /// An operation identity was reused for different generations.
1728    #[error("native workspace publication journal does not match the requested generations")]
1729    MismatchedJournal,
1730    /// Native staging failed.
1731    #[error(transparent)]
1732    Io(#[from] std::io::Error),
1733    /// Publication planning or materialization exceeded its cumulative budget.
1734    #[error(transparent)]
1735    Work(#[from] crate::WorkError),
1736}
1737
1738/// Process-local materialization journal adapter.
1739#[derive(Default)]
1740pub struct MemoryMaterializationJournalStore {
1741    journals: Mutex<BTreeMap<OperationId, MaterializationJournal>>,
1742}
1743
1744impl MaterializationJournalStore for MemoryMaterializationJournalStore {
1745    type Error = MemoryMaterializationJournalStoreError;
1746
1747    async fn load(
1748        &self,
1749        operation_id: OperationId,
1750    ) -> Result<Option<MaterializationJournal>, Self::Error> {
1751        self.journals
1752            .lock()
1753            .map_err(|_| MemoryMaterializationJournalStoreError)
1754            .map(|journals| journals.get(&operation_id).cloned())
1755    }
1756
1757    async fn compare_and_swap(
1758        &self,
1759        operation_id: OperationId,
1760        expected_revision: u64,
1761        replacement: MaterializationJournal,
1762    ) -> Result<bool, Self::Error> {
1763        let mut journals = self
1764            .journals
1765            .lock()
1766            .map_err(|_| MemoryMaterializationJournalStoreError)?;
1767        let revision = journals
1768            .get(&operation_id)
1769            .map_or(0, |journal| journal.revision);
1770        if revision != expected_revision {
1771            return Ok(false);
1772        }
1773        journals.insert(operation_id, replacement);
1774        Ok(true)
1775    }
1776}
1777
1778/// Process-local materialization journal synchronization failure.
1779#[derive(Debug, Error)]
1780#[error("materialization memory store is unavailable")]
1781pub struct MemoryMaterializationJournalStoreError;
1782
1783#[cfg(test)]
1784#[allow(clippy::expect_used)]
1785mod tests {
1786    use super::*;
1787    use crate::Digest;
1788
1789    #[derive(Default)]
1790    struct Backend(Mutex<Vec<Vec<u8>>>);
1791
1792    #[derive(Debug, Error)]
1793    #[error("test backend unavailable")]
1794    struct BackendError;
1795
1796    impl MaterializationBackend for Backend {
1797        type Error = BackendError;
1798
1799        async fn capture(
1800            &self,
1801            _edit: &MaterializationEdit,
1802        ) -> Result<MaterializationPreimage, Self::Error> {
1803            Ok(MaterializationPreimage { image: vec![0] })
1804        }
1805
1806        async fn apply(
1807            &self,
1808            edit: &MaterializationEdit,
1809            _preimage: &MaterializationPreimage,
1810        ) -> Result<(), Self::Error> {
1811            let MaterializationEdit::Install { image, .. } = edit else {
1812                return Err(BackendError);
1813            };
1814            self.0.lock().map_err(|_| BackendError)?.push(image.clone());
1815            Ok(())
1816        }
1817
1818        async fn restore(
1819            &self,
1820            _edit: &MaterializationEdit,
1821            preimage: &MaterializationPreimage,
1822        ) -> Result<(), Self::Error> {
1823            self.0
1824                .lock()
1825                .map_err(|_| BackendError)?
1826                .push(preimage.image.clone());
1827            Ok(())
1828        }
1829    }
1830
1831    #[tokio::test]
1832    async fn apply_and_rollback_are_journaled_and_idempotent() {
1833        let operation_id = OperationId::new();
1834        let materializer = JournaledMaterializer::new(
1835            MemoryMaterializationJournalStore::default(),
1836            Backend::default(),
1837        );
1838        let plan = MaterializationPlan {
1839            operation_id,
1840            from: GenerationId::new(Digest::from_bytes([1; 32])),
1841            to: GenerationId::new(Digest::from_bytes([2; 32])),
1842            edits: vec![MaterializationEdit::Install {
1843                path: "file".to_owned(),
1844                image: vec![9],
1845            }],
1846        };
1847        let applied = materializer.apply(plan.clone()).await.expect("apply");
1848        assert_eq!(applied.phase, MaterializationPhase::Applied);
1849        assert_eq!(
1850            materializer.apply(plan).await.expect("retry").phase,
1851            MaterializationPhase::Applied
1852        );
1853        assert_eq!(
1854            materializer
1855                .backend()
1856                .0
1857                .lock()
1858                .expect("backend lock")
1859                .as_slice(),
1860            &[vec![9]]
1861        );
1862        let rolled_back = materializer
1863            .recover(operation_id, MaterializationRecovery::RollBack)
1864            .await
1865            .expect("recover")
1866            .expect("journal");
1867        assert_eq!(rolled_back.phase, MaterializationPhase::RolledBack);
1868    }
1869
1870    #[tokio::test]
1871    async fn interrupted_rollback_cannot_be_completed_forward() {
1872        let operation_id = OperationId::new();
1873        let store = MemoryMaterializationJournalStore::default();
1874        let plan = MaterializationPlan {
1875            operation_id,
1876            from: GenerationId::new(Digest::from_bytes([1; 32])),
1877            to: GenerationId::new(Digest::from_bytes([2; 32])),
1878            edits: vec![MaterializationEdit::Install {
1879                path: "file".to_owned(),
1880                image: vec![9],
1881            }],
1882        };
1883        store
1884            .compare_and_swap(
1885                operation_id,
1886                0,
1887                MaterializationJournal {
1888                    version: JOURNAL_VERSION,
1889                    revision: 1,
1890                    plan,
1891                    preimages: vec![MaterializationPreimage { image: vec![0] }],
1892                    phase: MaterializationPhase::RollingBack,
1893                    applied: 1,
1894                    restored: 1,
1895                },
1896            )
1897            .await
1898            .expect("store journal");
1899        let materializer = JournaledMaterializer::new(store, Backend::default());
1900        assert!(matches!(
1901            materializer
1902                .recover(operation_id, MaterializationRecovery::Complete)
1903                .await,
1904            Err(MaterializationError::IncompatibleJournal)
1905        ));
1906    }
1907
1908    #[tokio::test]
1909    async fn overlapping_or_noncanonical_plans_are_rejected_before_capture() {
1910        let materializer = JournaledMaterializer::new(
1911            MemoryMaterializationJournalStore::default(),
1912            Backend::default(),
1913        );
1914        let plan = MaterializationPlan {
1915            operation_id: OperationId::new(),
1916            from: GenerationId::new(Digest::from_bytes([1; 32])),
1917            to: GenerationId::new(Digest::from_bytes([2; 32])),
1918            edits: vec![
1919                MaterializationEdit::Remove {
1920                    path: "directory".to_owned(),
1921                },
1922                MaterializationEdit::Install {
1923                    path: "directory/file".to_owned(),
1924                    image: vec![1],
1925                },
1926            ],
1927        };
1928        assert!(matches!(
1929            materializer.apply(plan).await,
1930            Err(MaterializationError::IncompatibleJournal)
1931        ));
1932    }
1933
1934    #[cfg(not(target_arch = "wasm32"))]
1935    #[tokio::test]
1936    async fn native_tree_backend_applies_and_rolls_back_selected_paths() {
1937        let temporary = tempfile::tempdir().expect("temporary root");
1938        let root = temporary.path().join("checkout");
1939        let operation = temporary.path().join("operation");
1940        let target = operation.join("target");
1941        std::fs::create_dir_all(root.join(".git")).expect("git directory");
1942        std::fs::create_dir_all(&target).expect("target directory");
1943        std::fs::write(root.join("old.txt"), b"old").expect("old file");
1944        std::fs::write(root.join(".git/config"), b"git").expect("git file");
1945        std::fs::write(target.join("new.txt"), b"new").expect("new file");
1946        let backend =
1947            NativeTreeMaterializationBackend::new(&root, &operation).expect("native backend");
1948        let plan = backend
1949            .plan_paths(
1950                OperationId::new(),
1951                GenerationId::new(Digest::from_bytes([1; 32])),
1952                GenerationId::new(Digest::from_bytes([2; 32])),
1953                ["old.txt".to_owned(), "new.txt".to_owned()],
1954            )
1955            .expect("plan");
1956        let operation_id = plan.operation_id;
1957        let materializer =
1958            JournaledMaterializer::new(MemoryMaterializationJournalStore::default(), backend);
1959        materializer.apply(plan).await.expect("apply tree");
1960        assert!(!root.join("old.txt").exists());
1961        assert_eq!(std::fs::read(root.join("new.txt")).expect("new"), b"new");
1962        assert_eq!(
1963            std::fs::read(root.join(".git/config")).expect("git"),
1964            b"git"
1965        );
1966        materializer
1967            .recover(operation_id, MaterializationRecovery::RollBack)
1968            .await
1969            .expect("rollback");
1970        assert_eq!(std::fs::read(root.join("old.txt")).expect("old"), b"old");
1971        assert!(!root.join("new.txt").exists());
1972    }
1973
1974    #[cfg(not(target_arch = "wasm32"))]
1975    #[tokio::test]
1976    async fn native_tree_backend_pauses_before_overwriting_an_external_mutation() {
1977        let temporary = tempfile::tempdir().expect("temporary root");
1978        let root = temporary.path().join("checkout");
1979        let operation = temporary.path().join("operation");
1980        let target = operation.join("target");
1981        std::fs::create_dir_all(&root).expect("root");
1982        std::fs::create_dir_all(&target).expect("target");
1983        std::fs::write(root.join("file.txt"), b"before").expect("before");
1984        std::fs::write(target.join("file.txt"), b"after").expect("after");
1985        let backend =
1986            NativeTreeMaterializationBackend::new(&root, &operation).expect("native backend");
1987        let plan = backend
1988            .plan_paths(
1989                OperationId::new(),
1990                GenerationId::new(Digest::from_bytes([1; 32])),
1991                GenerationId::new(Digest::from_bytes([2; 32])),
1992                ["file.txt".to_owned()],
1993            )
1994            .expect("plan");
1995        let preimage = backend.capture(&plan.edits[0]).await.expect("capture");
1996        let store = MemoryMaterializationJournalStore::default();
1997        store
1998            .compare_and_swap(
1999                plan.operation_id,
2000                0,
2001                MaterializationJournal {
2002                    version: JOURNAL_VERSION,
2003                    revision: 1,
2004                    plan: plan.clone(),
2005                    preimages: vec![preimage.clone()],
2006                    phase: MaterializationPhase::Prepared,
2007                    applied: 0,
2008                    restored: 0,
2009                },
2010            )
2011            .await
2012            .expect("journal");
2013        std::fs::write(root.join("file.txt"), b"external").expect("external mutation");
2014
2015        assert!(matches!(
2016            JournaledMaterializer::new(store, backend).apply(plan).await,
2017            Err(MaterializationError::ExternalMutation)
2018        ));
2019        assert_eq!(
2020            std::fs::read(root.join("file.txt")).expect("external remains"),
2021            b"external"
2022        );
2023    }
2024
2025    #[cfg(not(target_arch = "wasm32"))]
2026    #[tokio::test]
2027    async fn native_tree_backend_rejects_tampered_or_missing_staged_targets() {
2028        for replacement in [Some(b"tampered".as_slice()), None] {
2029            let temporary = tempfile::tempdir().expect("temporary root");
2030            let root = temporary.path().join("checkout");
2031            let operation = temporary.path().join("operation");
2032            let target = operation.join("target");
2033            std::fs::create_dir_all(&root).expect("root");
2034            std::fs::create_dir_all(&target).expect("target");
2035            std::fs::write(root.join("file.txt"), b"before").expect("before");
2036            std::fs::write(target.join("file.txt"), b"after").expect("after");
2037            let backend =
2038                NativeTreeMaterializationBackend::new(&root, &operation).expect("native backend");
2039            let plan = backend
2040                .plan_paths(
2041                    OperationId::new(),
2042                    GenerationId::new(Digest::from_bytes([1; 32])),
2043                    GenerationId::new(Digest::from_bytes([2; 32])),
2044                    ["file.txt".to_owned()],
2045                )
2046                .expect("plan");
2047            let edit = plan.edits.first().expect("install edit");
2048            let preimage = backend.capture(edit).await.expect("capture");
2049            match replacement {
2050                Some(bytes) => std::fs::write(target.join("file.txt"), bytes).expect("tamper"),
2051                None => std::fs::remove_file(target.join("file.txt")).expect("delete target"),
2052            }
2053            assert_eq!(
2054                backend.observe(edit, &preimage).await.expect("observe"),
2055                MaterializationObservation::Diverged
2056            );
2057            assert_eq!(
2058                std::fs::read(root.join("file.txt")).expect("live remains"),
2059                b"before"
2060            );
2061        }
2062    }
2063
2064    #[cfg(not(target_arch = "wasm32"))]
2065    #[tokio::test]
2066    async fn native_tree_backend_rejects_missing_staged_target_during_capture() {
2067        let temporary = tempfile::tempdir().expect("temporary root");
2068        let root = temporary.path().join("checkout");
2069        let operation = temporary.path().join("operation");
2070        std::fs::create_dir_all(&root).expect("root");
2071        std::fs::create_dir_all(operation.join("target")).expect("target");
2072        std::fs::write(root.join("file.txt"), b"before").expect("before");
2073        let backend =
2074            NativeTreeMaterializationBackend::new(&root, &operation).expect("native backend");
2075        let edit = MaterializationEdit::Install {
2076            path: "file.txt".to_owned(),
2077            image: Vec::new(),
2078        };
2079
2080        assert!(matches!(
2081            backend.capture(&edit).await,
2082            Err(NativeTreeMaterializationError::MissingTarget(path)) if path == "file.txt"
2083        ));
2084        assert_eq!(
2085            std::fs::read(root.join("file.txt")).expect("live remains"),
2086            b"before"
2087        );
2088    }
2089
2090    #[cfg(not(target_arch = "wasm32"))]
2091    #[tokio::test]
2092    async fn native_tree_backend_rolls_back_interrupted_install_from_durable_backup() {
2093        let temporary = tempfile::tempdir().expect("temporary root");
2094        let root = temporary.path().join("checkout");
2095        let operation = temporary.path().join("operation");
2096        let target = operation.join("target");
2097        let backup = operation.join("backup");
2098        std::fs::create_dir_all(&root).expect("root");
2099        std::fs::create_dir_all(&target).expect("target");
2100        std::fs::create_dir_all(&backup).expect("backup");
2101        std::fs::write(root.join("file.txt"), b"before").expect("before");
2102        std::fs::write(target.join("file.txt"), b"after").expect("after");
2103        let backend =
2104            NativeTreeMaterializationBackend::new(&root, &operation).expect("native backend");
2105        let plan = backend
2106            .plan_paths(
2107                OperationId::new(),
2108                GenerationId::new(Digest::from_bytes([1; 32])),
2109                GenerationId::new(Digest::from_bytes([2; 32])),
2110                ["file.txt".to_owned()],
2111            )
2112            .expect("plan");
2113        let edit = &plan.edits[0];
2114        let preimage = backend.capture(edit).await.expect("capture");
2115
2116        std::fs::rename(root.join("file.txt"), backup.join("file.txt"))
2117            .expect("simulate crash after backup rename");
2118        assert_eq!(
2119            backend.observe(edit, &preimage).await.expect("observe"),
2120            MaterializationObservation::Interrupted
2121        );
2122        backend
2123            .restore(edit, &preimage)
2124            .await
2125            .expect("restore interrupted install");
2126        assert_eq!(
2127            std::fs::read(root.join("file.txt")).expect("restored live"),
2128            b"before"
2129        );
2130        assert!(!backup.join("file.txt").exists());
2131    }
2132
2133    #[cfg(not(target_arch = "wasm32"))]
2134    #[tokio::test]
2135    async fn native_tree_backend_revalidates_witnesses_inside_apply() {
2136        for mutate_target in [false, true] {
2137            let temporary = tempfile::tempdir().expect("temporary root");
2138            let root = temporary.path().join("checkout");
2139            let operation = temporary.path().join("operation");
2140            let target = operation.join("target");
2141            std::fs::create_dir_all(&root).expect("root");
2142            std::fs::create_dir_all(&target).expect("target");
2143            std::fs::write(root.join("file.txt"), b"before").expect("before");
2144            std::fs::write(target.join("file.txt"), b"after").expect("after");
2145            let backend =
2146                NativeTreeMaterializationBackend::new(&root, &operation).expect("native backend");
2147            let plan = backend
2148                .plan_paths(
2149                    OperationId::new(),
2150                    GenerationId::new(Digest::from_bytes([1; 32])),
2151                    GenerationId::new(Digest::from_bytes([2; 32])),
2152                    ["file.txt".to_owned()],
2153                )
2154                .expect("plan");
2155            let edit = plan.edits.first().expect("install edit");
2156            let preimage = backend.capture(edit).await.expect("capture");
2157            assert_eq!(
2158                backend.observe(edit, &preimage).await.expect("observe"),
2159                MaterializationObservation::Preimage
2160            );
2161            let changed = if mutate_target {
2162                target.join("file.txt")
2163            } else {
2164                root.join("file.txt")
2165            };
2166            std::fs::write(&changed, b"external").expect("mutate after observe");
2167            assert!(matches!(
2168                backend.apply(edit, &preimage).await,
2169                Err(NativeTreeMaterializationError::ExternalMutation(path)) if path == "file.txt"
2170            ));
2171        }
2172    }
2173
2174    #[cfg(not(target_arch = "wasm32"))]
2175    #[tokio::test]
2176    async fn native_tree_backend_refuses_to_roll_back_over_an_external_write() {
2177        let temporary = tempfile::tempdir().expect("temporary root");
2178        let root = temporary.path().join("checkout");
2179        let operation = temporary.path().join("operation");
2180        let target = operation.join("target");
2181        std::fs::create_dir_all(&root).expect("root");
2182        std::fs::create_dir_all(&target).expect("target");
2183        std::fs::write(root.join("file.txt"), b"before").expect("before");
2184        std::fs::write(target.join("file.txt"), b"after").expect("after");
2185        let backend =
2186            NativeTreeMaterializationBackend::new(&root, &operation).expect("native backend");
2187        let plan = backend
2188            .plan_paths(
2189                OperationId::new(),
2190                GenerationId::new(Digest::from_bytes([1; 32])),
2191                GenerationId::new(Digest::from_bytes([2; 32])),
2192                ["file.txt".to_owned()],
2193            )
2194            .expect("plan");
2195        let edit = plan.edits.first().expect("install edit");
2196        let preimage = backend.capture(edit).await.expect("capture");
2197        backend.apply(edit, &preimage).await.expect("apply");
2198        std::fs::write(root.join("file.txt"), b"external").expect("external write");
2199
2200        assert!(matches!(
2201            backend.restore(edit, &preimage).await,
2202            Err(NativeTreeMaterializationError::ExternalMutation(path)) if path == "file.txt"
2203        ));
2204        assert_eq!(
2205            std::fs::read(root.join("file.txt")).expect("external content survives"),
2206            b"external"
2207        );
2208    }
2209
2210    #[cfg(not(target_arch = "wasm32"))]
2211    #[tokio::test]
2212    async fn native_tree_backend_journals_directory_metadata_without_replacing_children() {
2213        let temporary = tempfile::tempdir().expect("temporary root");
2214        let root = temporary.path().join("checkout");
2215        let operation = temporary.path().join("operation");
2216        std::fs::create_dir_all(root.join("directory")).expect("directory");
2217        std::fs::create_dir_all(operation.join("target")).expect("target");
2218        std::fs::write(root.join("directory/child.txt"), b"child").expect("child");
2219        let before = native_metadata(
2220            &std::fs::symlink_metadata(root.join("directory")).expect("before metadata"),
2221        );
2222        let mut desired = before.clone();
2223        desired.readonly = !before.readonly;
2224        #[cfg(windows)]
2225        {
2226            const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
2227            desired.windows_attributes = before
2228                .windows_attributes
2229                .map(|attributes| attributes ^ FILE_ATTRIBUTE_HIDDEN);
2230            desired.readonly = desired
2231                .windows_attributes
2232                .is_some_and(|attributes| attributes & 1 != 0);
2233        }
2234        #[cfg(unix)]
2235        {
2236            desired.posix_mode = before.posix_mode.map(|mode| mode ^ 0o200);
2237        }
2238        let backend =
2239            NativeTreeMaterializationBackend::new(&root, &operation).expect("native backend");
2240        let operation_id = OperationId::new();
2241        let plan = MaterializationPlan {
2242            operation_id,
2243            from: GenerationId::new(Digest::from_bytes([1; 32])),
2244            to: GenerationId::new(Digest::from_bytes([2; 32])),
2245            edits: vec![MaterializationEdit::SetMetadata {
2246                path: "directory".to_owned(),
2247                image: serde_json::to_vec(&desired).expect("metadata image"),
2248            }],
2249        };
2250        let materializer =
2251            JournaledMaterializer::new(MemoryMaterializationJournalStore::default(), backend);
2252        materializer.apply(plan).await.expect("apply metadata");
2253        assert_eq!(
2254            std::fs::read(root.join("directory/child.txt")).expect("child remains"),
2255            b"child"
2256        );
2257        let applied = native_metadata(
2258            &std::fs::symlink_metadata(root.join("directory")).expect("applied metadata"),
2259        );
2260        assert_eq!(applied.readonly, desired.readonly);
2261        #[cfg(windows)]
2262        assert_eq!(applied.windows_attributes, desired.windows_attributes);
2263        #[cfg(unix)]
2264        assert_eq!(applied.posix_mode, desired.posix_mode);
2265
2266        materializer
2267            .recover(operation_id, MaterializationRecovery::RollBack)
2268            .await
2269            .expect("rollback metadata");
2270        let restored = native_metadata(
2271            &std::fs::symlink_metadata(root.join("directory")).expect("restored metadata"),
2272        );
2273        assert_eq!(restored.readonly, before.readonly);
2274        #[cfg(windows)]
2275        assert_eq!(restored.windows_attributes, before.windows_attributes);
2276        #[cfg(unix)]
2277        assert_eq!(restored.posix_mode, before.posix_mode);
2278    }
2279
2280    #[cfg(not(target_arch = "wasm32"))]
2281    #[tokio::test]
2282    async fn metadata_edit_preserves_concurrent_child_content() {
2283        let temporary = tempfile::tempdir().expect("temporary root");
2284        let root = temporary.path().join("checkout");
2285        let operation = temporary.path().join("operation");
2286        std::fs::create_dir_all(root.join("directory")).expect("checkout directory");
2287        std::fs::create_dir_all(operation.join("target")).expect("target directory");
2288        let child = root.join("directory/child.txt");
2289        std::fs::write(&child, b"before").expect("child file");
2290        let backend =
2291            NativeTreeMaterializationBackend::new(&root, &operation).expect("native backend");
2292        let mut desired = native_metadata(
2293            &std::fs::symlink_metadata(root.join("directory")).expect("directory metadata"),
2294        );
2295        #[cfg(unix)]
2296        {
2297            desired.posix_mode = desired.posix_mode.map(|mode| mode ^ 0o200);
2298        }
2299        #[cfg(windows)]
2300        {
2301            desired.windows_attributes =
2302                desired.windows_attributes.map(|attributes| attributes ^ 2);
2303        }
2304        let edit = MaterializationEdit::SetMetadata {
2305            path: "directory".to_owned(),
2306            image: serde_json::to_vec(&desired).expect("metadata image"),
2307        };
2308        let preimage = backend.capture(&edit).await.expect("capture metadata");
2309        std::fs::write(&child, b"concurrent child write").expect("update child");
2310        assert_eq!(
2311            backend.observe(&edit, &preimage).await.expect("observe"),
2312            MaterializationObservation::Preimage
2313        );
2314        backend
2315            .apply(&edit, &preimage)
2316            .await
2317            .expect("apply metadata");
2318        assert_eq!(
2319            std::fs::read(&child).expect("read child"),
2320            b"concurrent child write"
2321        );
2322        backend
2323            .restore(&edit, &preimage)
2324            .await
2325            .expect("restore metadata");
2326        assert_eq!(
2327            std::fs::read(&child).expect("read restored child"),
2328            b"concurrent child write"
2329        );
2330    }
2331
2332    #[cfg(not(target_arch = "wasm32"))]
2333    #[tokio::test]
2334    async fn metadata_edit_rejects_a_replaced_directory() {
2335        let temporary = tempfile::tempdir().expect("temporary root");
2336        let root = temporary.path().join("checkout");
2337        let operation = temporary.path().join("operation");
2338        let directory = root.join("directory");
2339        std::fs::create_dir_all(&directory).expect("checkout directory");
2340        std::fs::create_dir_all(operation.join("target")).expect("target directory");
2341        let backend =
2342            NativeTreeMaterializationBackend::new(&root, &operation).expect("native backend");
2343        let edit = MaterializationEdit::SetMetadata {
2344            path: "directory".to_owned(),
2345            image: serde_json::to_vec(&native_metadata(
2346                &std::fs::symlink_metadata(&directory).expect("directory metadata"),
2347            ))
2348            .expect("metadata image"),
2349        };
2350        let preimage = backend.capture(&edit).await.expect("capture metadata");
2351        std::fs::rename(&directory, root.join("displaced")).expect("replace binding");
2352        std::fs::create_dir(&directory).expect("replacement directory");
2353        assert_eq!(
2354            backend.observe(&edit, &preimage).await.expect("observe"),
2355            MaterializationObservation::Diverged
2356        );
2357    }
2358
2359    #[cfg(not(target_arch = "wasm32"))]
2360    #[tokio::test]
2361    async fn native_tree_backend_recognizes_apply_before_progress_was_persisted() {
2362        let temporary = tempfile::tempdir().expect("temporary root");
2363        let root = temporary.path().join("checkout");
2364        let operation = temporary.path().join("operation");
2365        let target = operation.join("target");
2366        std::fs::create_dir_all(&root).expect("root");
2367        std::fs::create_dir_all(&target).expect("target");
2368        std::fs::write(root.join("file.txt"), b"before").expect("before");
2369        std::fs::write(target.join("file.txt"), b"after").expect("after");
2370        let backend =
2371            NativeTreeMaterializationBackend::new(&root, &operation).expect("native backend");
2372        let plan = backend
2373            .plan_paths(
2374                OperationId::new(),
2375                GenerationId::new(Digest::from_bytes([1; 32])),
2376                GenerationId::new(Digest::from_bytes([2; 32])),
2377                ["file.txt".to_owned()],
2378            )
2379            .expect("plan");
2380        let preimage = backend.capture(&plan.edits[0]).await.expect("capture");
2381        let store = MemoryMaterializationJournalStore::default();
2382        store
2383            .compare_and_swap(
2384                plan.operation_id,
2385                0,
2386                MaterializationJournal {
2387                    version: JOURNAL_VERSION,
2388                    revision: 1,
2389                    plan: plan.clone(),
2390                    preimages: vec![preimage.clone()],
2391                    phase: MaterializationPhase::Applying,
2392                    applied: 0,
2393                    restored: 0,
2394                },
2395            )
2396            .await
2397            .expect("journal");
2398        backend
2399            .apply(&plan.edits[0], &preimage)
2400            .await
2401            .expect("physical apply");
2402
2403        let recovered = JournaledMaterializer::new(store, backend)
2404            .recover(plan.operation_id, MaterializationRecovery::Complete)
2405            .await
2406            .expect("recover")
2407            .expect("journal");
2408        assert_eq!(recovered.phase, MaterializationPhase::Applied);
2409        assert_eq!(
2410            std::fs::read(root.join("file.txt")).expect("after remains"),
2411            b"after"
2412        );
2413    }
2414
2415    #[cfg(not(target_arch = "wasm32"))]
2416    #[tokio::test]
2417    async fn native_tree_backend_pauses_rollback_after_external_mutation() {
2418        let temporary = tempfile::tempdir().expect("temporary root");
2419        let root = temporary.path().join("checkout");
2420        let operation = temporary.path().join("operation");
2421        let target = operation.join("target");
2422        std::fs::create_dir_all(&root).expect("root");
2423        std::fs::create_dir_all(&target).expect("target");
2424        std::fs::write(root.join("file.txt"), b"before").expect("before");
2425        std::fs::write(target.join("file.txt"), b"after").expect("after");
2426        let backend =
2427            NativeTreeMaterializationBackend::new(&root, &operation).expect("native backend");
2428        let plan = backend
2429            .plan_paths(
2430                OperationId::new(),
2431                GenerationId::new(Digest::from_bytes([1; 32])),
2432                GenerationId::new(Digest::from_bytes([2; 32])),
2433                ["file.txt".to_owned()],
2434            )
2435            .expect("plan");
2436        let operation_id = plan.operation_id;
2437        let materializer =
2438            JournaledMaterializer::new(MemoryMaterializationJournalStore::default(), backend);
2439        materializer.apply(plan).await.expect("apply");
2440        std::fs::write(root.join("file.txt"), b"external").expect("external mutation");
2441
2442        assert!(matches!(
2443            materializer
2444                .recover(operation_id, MaterializationRecovery::RollBack)
2445                .await,
2446            Err(MaterializationError::ExternalMutation)
2447        ));
2448        assert_eq!(
2449            std::fs::read(root.join("file.txt")).expect("external remains"),
2450            b"external"
2451        );
2452    }
2453
2454    #[cfg(unix)]
2455    #[tokio::test]
2456    async fn native_tree_backend_preserves_dangling_symlinks_on_rollback() {
2457        use std::os::unix::fs::symlink;
2458
2459        let temporary = tempfile::tempdir().expect("temporary root");
2460        let root = temporary.path().join("checkout");
2461        let operation = temporary.path().join("operation");
2462        let target = operation.join("target");
2463        std::fs::create_dir_all(&root).expect("checkout directory");
2464        std::fs::create_dir_all(&target).expect("target directory");
2465        symlink("missing-target", root.join("link")).expect("dangling symlink");
2466        let backend =
2467            NativeTreeMaterializationBackend::new(&root, &operation).expect("native backend");
2468        let plan = backend
2469            .plan_paths(
2470                OperationId::new(),
2471                GenerationId::new(Digest::from_bytes([1; 32])),
2472                GenerationId::new(Digest::from_bytes([2; 32])),
2473                ["link".to_owned()],
2474            )
2475            .expect("plan");
2476        let operation_id = plan.operation_id;
2477        let materializer =
2478            JournaledMaterializer::new(MemoryMaterializationJournalStore::default(), backend);
2479        materializer.apply(plan).await.expect("remove link");
2480        assert!(std::fs::symlink_metadata(root.join("link")).is_err());
2481        materializer
2482            .recover(operation_id, MaterializationRecovery::RollBack)
2483            .await
2484            .expect("restore link");
2485        assert_eq!(
2486            std::fs::read_link(root.join("link")).expect("restored symlink"),
2487            PathBuf::from("missing-target")
2488        );
2489    }
2490
2491    #[cfg(not(target_arch = "wasm32"))]
2492    #[test]
2493    fn native_tree_backend_rejects_operation_directory_inside_checkout() {
2494        let temporary = tempfile::tempdir().expect("temporary root");
2495        let root = temporary.path().join("checkout");
2496        let operation = root.join("operation");
2497        let sentinel = operation.join("target/sentinel");
2498        std::fs::create_dir_all(sentinel.parent().expect("sentinel parent"))
2499            .expect("target directory");
2500        std::fs::write(&sentinel, b"keep").expect("sentinel");
2501
2502        assert!(matches!(
2503            NativeTreeMaterializationBackend::new(&root, &operation),
2504            Err(NativeTreeMaterializationError::InvalidLayout)
2505        ));
2506        assert_eq!(std::fs::read(sentinel).expect("sentinel remains"), b"keep");
2507    }
2508}