1use 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
23#[non_exhaustive]
24pub enum MaterializationEdit {
25 Install {
27 path: String,
29 image: Vec<u8>,
31 },
32 Remove {
34 path: String,
36 },
37 Rename {
39 from: String,
41 to: String,
43 },
44 SetMetadata {
46 path: String,
48 image: Vec<u8>,
50 },
51}
52
53#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
55pub struct MaterializationPlan {
56 pub operation_id: OperationId,
58 pub from: GenerationId,
60 pub to: GenerationId,
62 pub edits: Vec<MaterializationEdit>,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
69pub struct MaterializationPreimage {
70 pub image: Vec<u8>,
72}
73
74#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub enum MaterializationObservation {
77 Unverified,
79 Preimage,
81 Postimage,
83 Interrupted,
85 Diverged,
87}
88
89#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
91pub enum MaterializationPhase {
92 Prepared,
94 Applying,
96 Applied,
98 RollingBack,
100 RolledBack,
102}
103
104#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
106pub struct MaterializationJournal {
107 pub version: u32,
109 pub revision: u64,
111 pub plan: MaterializationPlan,
113 pub preimages: Vec<MaterializationPreimage>,
115 pub phase: MaterializationPhase,
117 pub applied: u32,
119 pub restored: u32,
121}
122
123pub trait MaterializationJournalStore: Send + Sync {
125 type Error: std::error::Error + Send + Sync + 'static;
127
128 fn load(
130 &self,
131 operation_id: OperationId,
132 ) -> impl Future<Output = Result<Option<MaterializationJournal>, Self::Error>> + Send;
133
134 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
143pub trait MaterializationBackend: Send + Sync {
145 type Error: std::error::Error + Send + Sync + 'static;
147
148 fn verify_source(
152 &self,
153 _from: GenerationId,
154 ) -> impl Future<Output = Result<bool, Self::Error>> + Send {
155 async { Ok(true) }
156 }
157
158 fn capture(
160 &self,
161 edit: &MaterializationEdit,
162 ) -> impl Future<Output = Result<MaterializationPreimage, Self::Error>> + Send;
163
164 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 fn apply(
178 &self,
179 edit: &MaterializationEdit,
180 preimage: &MaterializationPreimage,
181 ) -> impl Future<Output = Result<(), Self::Error>> + Send;
182
183 fn restore(
185 &self,
186 edit: &MaterializationEdit,
187 preimage: &MaterializationPreimage,
188 ) -> impl Future<Output = Result<(), Self::Error>> + Send;
189}
190
191#[derive(Clone, Copy, Debug, Eq, PartialEq)]
193pub enum MaterializationRecovery {
194 Complete,
196 RollBack,
198}
199
200#[derive(Debug, Error)]
202pub enum MaterializationError<S: std::error::Error + 'static, B: std::error::Error + 'static> {
203 #[error("materialization journal failed: {0}")]
205 Store(S),
206 #[error("materialization backend failed: {0}")]
208 Backend(B),
209 #[error("materialization retry conflicts with its durable plan")]
211 PlanConflict,
212 #[error("materialization journal is incompatible")]
214 IncompatibleJournal,
215 #[error("materialization journal remained contended")]
217 Contended,
218 #[error("materialization paused because an external path mutation was observed")]
220 ExternalMutation,
221}
222
223pub struct JournaledMaterializer<S, B> {
225 store: S,
226 backend: B,
227}
228
229impl<S, B> JournaledMaterializer<S, B> {
230 #[must_use]
232 pub const fn new(store: S, backend: B) -> Self {
233 Self { store, backend }
234 }
235
236 #[must_use]
238 pub const fn store(&self) -> &S {
239 &self.store
240 }
241
242 #[must_use]
244 pub const fn backend(&self) -> &B {
245 &self.backend
246 }
247}
248
249impl<S: MaterializationJournalStore, B: MaterializationBackend> JournaledMaterializer<S, B> {
250 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 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#[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 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 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 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#[cfg(not(target_arch = "wasm32"))]
1455#[derive(Debug, Error)]
1456pub enum NativeTreeMaterializationError {
1457 #[error("native materialization layout is invalid")]
1459 InvalidLayout,
1460 #[error("native materialization target is missing for '{0}'")]
1462 MissingTarget(String),
1463 #[error("native materialization observed an external mutation at '{0}'")]
1465 ExternalMutation(String),
1466 #[error("native materialization preimage is missing for '{0}'")]
1468 MissingPreimage(String),
1469 #[error("native materialization preimage is invalid")]
1471 InvalidPreimage,
1472 #[error("native materialization found an unexpected backup for '{0}'")]
1474 UnexpectedBackup(String),
1475 #[error("native materialization rename edits are unsupported")]
1477 UnsupportedRename,
1478 #[error(transparent)]
1480 Io(#[from] std::io::Error),
1481 #[error("native materialization path is invalid: '{0}'")]
1483 InvalidPath(String),
1484 #[error("native materialization paths overlap")]
1486 OverlappingPaths,
1487}
1488
1489#[cfg(all(
1493 feature = "local",
1494 feature = "native-mount",
1495 not(target_arch = "wasm32")
1496))]
1497pub struct NativeWorkspacePublication<'a> {
1498 pub root: &'a Path,
1500 pub operation_directory: &'a Path,
1502 pub operation_id: OperationId,
1504 pub from: GenerationId,
1506 pub to: GenerationId,
1508 pub excluded_names: &'a [&'a str],
1510 pub options: &'a crate::MaterializeOptions,
1512 pub budget: crate::WorkBudget,
1514 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)]
1524pub 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#[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#[cfg(all(
1706 feature = "local",
1707 feature = "native-mount",
1708 not(target_arch = "wasm32")
1709))]
1710#[derive(Debug, Error)]
1711pub enum NativeWorkspacePublicationError {
1712 #[error(transparent)]
1714 Workspace(#[from] crate::WorkspaceError),
1715 #[error(transparent)]
1717 Native(#[from] NativeTreeMaterializationError),
1718 #[error(transparent)]
1720 State(#[from] crate::LocalCoreStateStoreError),
1721 #[error(transparent)]
1723 Materialization(
1724 #[from]
1725 MaterializationError<crate::LocalCoreStateStoreError, NativeTreeMaterializationError>,
1726 ),
1727 #[error("native workspace publication journal does not match the requested generations")]
1729 MismatchedJournal,
1730 #[error(transparent)]
1732 Io(#[from] std::io::Error),
1733 #[error(transparent)]
1735 Work(#[from] crate::WorkError),
1736}
1737
1738#[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#[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}