1use sha2::{Digest, Sha256};
8use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10
11pub const DIRECTORY_SYNC_PROOF_SCHEMA: &str = "asupersync.atp.directory-sync.proof.v1";
13
14pub const DIRECTORY_SYNC_LOG_SCHEMA: &str = "asupersync.atp.directory-sync.log.v1";
16
17pub const DIRECTORY_EARLY_USABILITY_SCHEMA: &str = "asupersync.atp.directory-early-usability.v1";
19
20const TREE_ROOT_DOMAIN: &[u8] = b"asupersync.atp.directory-sync.tree-root.v1\0";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
24pub struct PathNormalizationRules {
25 pub case_sensitive: bool,
27 pub normalize_backslashes: bool,
29 pub unicode_normalized: bool,
31 pub reject_absolute_paths: bool,
33}
34
35impl Default for PathNormalizationRules {
36 fn default() -> Self {
37 Self {
38 case_sensitive: true,
39 normalize_backslashes: true,
40 unicode_normalized: false,
41 reject_absolute_paths: true,
42 }
43 }
44}
45
46#[derive(
48 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
49)]
50pub struct DirectoryPath(String);
51
52impl DirectoryPath {
53 pub fn normalize(raw: &str, rules: PathNormalizationRules) -> Result<Self, DirectorySyncError> {
60 if raw.is_empty() {
61 return Err(DirectorySyncError::EmptyPath);
62 }
63 if raw.as_bytes().contains(&0) {
64 return Err(DirectorySyncError::InvalidPath {
65 path: raw.to_string(),
66 reason: "nul byte",
67 });
68 }
69
70 let normalized_separators = if rules.normalize_backslashes {
71 raw.replace('\\', "/")
72 } else {
73 raw.to_string()
74 };
75 if rules.reject_absolute_paths
76 && (normalized_separators.starts_with('/')
77 || normalized_separators
78 .as_bytes()
79 .get(1)
80 .is_some_and(|byte| *byte == b':'))
81 {
82 return Err(DirectorySyncError::AbsolutePath(raw.to_string()));
83 }
84
85 let mut parts = Vec::new();
86 for part in normalized_separators.split('/') {
87 match part {
88 "" | "." => {}
89 ".." => {
90 return Err(DirectorySyncError::ParentTraversal(raw.to_string()));
91 }
92 clean => parts.push(clean),
93 }
94 }
95
96 if parts.is_empty() {
97 return Err(DirectorySyncError::RootPath);
98 }
99
100 Ok(Self(parts.join("/")))
101 }
102
103 #[must_use]
105 pub fn as_str(&self) -> &str {
106 &self.0
107 }
108
109 #[must_use]
111 pub fn case_key(&self, rules: PathNormalizationRules) -> String {
112 if rules.case_sensitive {
113 self.0.clone()
114 } else {
115 self.0.to_lowercase()
116 }
117 }
118}
119
120impl fmt::Display for DirectoryPath {
121 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 f.write_str(&self.0)
123 }
124}
125
126#[derive(
128 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
129)]
130pub enum DirectoryEntryKind {
131 File,
133 Directory,
135 Symlink,
137 HardLink,
139 SparseFile,
141}
142
143impl DirectoryEntryKind {
144 #[must_use]
146 pub const fn code(self) -> &'static str {
147 match self {
148 Self::File => "file",
149 Self::Directory => "directory",
150 Self::Symlink => "symlink",
151 Self::HardLink => "hard_link",
152 Self::SparseFile => "sparse_file",
153 }
154 }
155}
156
157#[derive(
159 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
160)]
161pub enum MetadataCaveat {
162 UnixPermissions,
164 WindowsAttributes,
166 TimestampResolution,
168 SymlinkSupport,
170 HardLinkSupport,
172 SparseMetadata,
174 CaseSensitivity,
176 PathNormalization,
178}
179
180impl MetadataCaveat {
181 #[must_use]
183 pub const fn code(self) -> &'static str {
184 match self {
185 Self::UnixPermissions => "unix_permissions",
186 Self::WindowsAttributes => "windows_attributes",
187 Self::TimestampResolution => "timestamp_resolution",
188 Self::SymlinkSupport => "symlink_support",
189 Self::HardLinkSupport => "hard_link_support",
190 Self::SparseMetadata => "sparse_metadata",
191 Self::CaseSensitivity => "case_sensitivity",
192 Self::PathNormalization => "path_normalization",
193 }
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
199pub struct DirectoryEntryMetadata {
200 pub size_bytes: Option<u64>,
202 pub unix_mode: Option<u32>,
204 pub windows_attributes: Option<u32>,
206 pub modified_epoch_micros: Option<i64>,
208 pub symlink_target: Option<String>,
210 pub hard_link_group: Option<String>,
212 pub sparse_summary: Option<String>,
214 pub stable_identity: Option<String>,
216}
217
218impl DirectoryEntryMetadata {
219 #[must_use]
221 pub fn with_identity(identity: impl Into<String>) -> Self {
222 Self {
223 stable_identity: Some(identity.into()),
224 ..Self::default()
225 }
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
231pub struct DirectoryManifestEntry {
232 pub path: DirectoryPath,
234 pub kind: DirectoryEntryKind,
236 pub content_id: Option<String>,
238 pub metadata: DirectoryEntryMetadata,
240 pub caveats: BTreeSet<MetadataCaveat>,
242}
243
244impl DirectoryManifestEntry {
245 #[must_use]
247 pub fn new(
248 path: DirectoryPath,
249 kind: DirectoryEntryKind,
250 content_id: Option<String>,
251 metadata: DirectoryEntryMetadata,
252 ) -> Self {
253 let mut caveats = BTreeSet::new();
254 if metadata.unix_mode.is_some() {
255 caveats.insert(MetadataCaveat::UnixPermissions);
256 }
257 if metadata.windows_attributes.is_some() {
258 caveats.insert(MetadataCaveat::WindowsAttributes);
259 }
260 if metadata.modified_epoch_micros.is_some() {
261 caveats.insert(MetadataCaveat::TimestampResolution);
262 }
263 if kind == DirectoryEntryKind::Symlink || metadata.symlink_target.is_some() {
264 caveats.insert(MetadataCaveat::SymlinkSupport);
265 }
266 if kind == DirectoryEntryKind::HardLink || metadata.hard_link_group.is_some() {
267 caveats.insert(MetadataCaveat::HardLinkSupport);
268 }
269 if kind == DirectoryEntryKind::SparseFile || metadata.sparse_summary.is_some() {
270 caveats.insert(MetadataCaveat::SparseMetadata);
271 }
272 Self {
273 path,
274 kind,
275 content_id,
276 metadata,
277 caveats,
278 }
279 }
280
281 #[must_use]
283 pub fn semantically_matches(&self, other: &Self) -> bool {
284 self.kind == other.kind
285 && self.content_id == other.content_id
286 && self.metadata == other.metadata
287 && self.caveats == other.caveats
288 }
289
290 fn stable_identity(&self) -> Option<&str> {
291 self.metadata
292 .stable_identity
293 .as_deref()
294 .or(self.content_id.as_deref())
295 }
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
300pub struct DirectoryManifest {
301 pub entries: BTreeMap<DirectoryPath, DirectoryManifestEntry>,
303 pub path_rules: PathNormalizationRules,
305}
306
307impl DirectoryManifest {
308 #[must_use]
310 pub fn new(path_rules: PathNormalizationRules) -> Self {
311 Self {
312 entries: BTreeMap::new(),
313 path_rules,
314 }
315 }
316
317 pub fn insert(&mut self, entry: DirectoryManifestEntry) -> Result<(), DirectorySyncError> {
324 if self.entries.contains_key(&entry.path) {
325 return Err(DirectorySyncError::DuplicatePath(entry.path));
326 }
327 self.entries.insert(entry.path.clone(), entry);
328 Ok(())
329 }
330
331 #[must_use]
333 pub fn case_conflicts(&self) -> Vec<Vec<DirectoryPath>> {
334 let mut groups: BTreeMap<String, Vec<DirectoryPath>> = BTreeMap::new();
335 for path in self.entries.keys() {
336 groups
337 .entry(path.case_key(PathNormalizationRules {
338 case_sensitive: false,
339 ..self.path_rules
340 }))
341 .or_default()
342 .push(path.clone());
343 }
344 groups
345 .into_values()
346 .filter(|paths| paths.len() > 1)
347 .collect()
348 }
349
350 #[must_use]
352 pub fn tree_root(&self) -> [u8; 32] {
353 let mut hasher = Sha256::new();
354 hasher.update(TREE_ROOT_DOMAIN);
355 hasher.update([u8::from(self.path_rules.case_sensitive)]);
356 for (path, entry) in &self.entries {
357 hasher.update(path.as_str().as_bytes());
358 hasher.update([0]);
359 hasher.update(entry.kind.code().as_bytes());
360 hasher.update([0]);
361 if let Some(content_id) = &entry.content_id {
362 hasher.update(content_id.as_bytes());
363 }
364 hasher.update([0]);
365 hash_metadata(&mut hasher, &entry.metadata);
366 for caveat in &entry.caveats {
367 hasher.update(caveat.code().as_bytes());
368 hasher.update([0]);
369 }
370 }
371 hasher.finalize().into()
372 }
373
374 #[must_use]
376 pub fn early_usability_report(
377 &self,
378 verified_content_ids: &BTreeSet<String>,
379 policy: DirectoryEarlyUsabilityPolicy,
380 final_commit_state: DirectoryFinalCommitState,
381 replay_pointer: impl Into<String>,
382 ) -> DirectoryEarlyUsabilityReport {
383 let mut metadata_paths = Vec::new();
384 let mut small_file_paths = Vec::new();
385 let mut withheld_content_paths = Vec::new();
386 let metadata_visible = policy.expose_metadata_before_final
387 || final_commit_state == DirectoryFinalCommitState::Committed;
388
389 let entries = self
390 .entries
391 .values()
392 .map(|entry| {
393 let exposure = early_entry_exposure(
394 entry,
395 verified_content_ids,
396 policy,
397 final_commit_state,
398 metadata_visible,
399 );
400
401 if exposure.metadata_visible {
402 metadata_paths.push(exposure.path.clone());
403 }
404 if exposure.content_visible {
405 small_file_paths.push(exposure.path.clone());
406 } else if entry.content_id.is_some() {
407 withheld_content_paths.push(exposure.path.clone());
408 }
409
410 exposure
411 })
412 .collect::<Vec<_>>();
413
414 let mut safety_caveats = Vec::new();
415 if final_commit_state == DirectoryFinalCommitState::Pending {
416 safety_caveats.push(
417 "final directory commit not complete; expose early entries separately".into(),
418 );
419 }
420 if !metadata_visible && !self.entries.is_empty() {
421 safety_caveats
422 .push("metadata exposure is disabled until final directory commit".into());
423 }
424 if !withheld_content_paths.is_empty() {
425 safety_caveats.push(
426 "some file content is withheld until verification or final commit policy allows it"
427 .into(),
428 );
429 }
430
431 let usability_state = if final_commit_state == DirectoryFinalCommitState::Committed {
432 DirectoryEarlyUsabilityState::FinalCommitted
433 } else if !small_file_paths.is_empty() {
434 DirectoryEarlyUsabilityState::SmallFilesAvailable
435 } else if !metadata_paths.is_empty() {
436 DirectoryEarlyUsabilityState::MetadataAvailable
437 } else {
438 DirectoryEarlyUsabilityState::NoEntries
439 };
440
441 DirectoryEarlyUsabilityReport {
442 schema_version: DIRECTORY_EARLY_USABILITY_SCHEMA.to_string(),
443 usability_state,
444 final_commit_state,
445 manifest_tree_root: hex::encode(self.tree_root()),
446 replay_pointer: replay_pointer.into(),
447 metadata_paths,
448 small_file_paths,
449 withheld_content_paths,
450 entries,
451 safety_caveats,
452 }
453 }
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
458pub enum DirectorySyncMode {
459 SendOnly,
461 Sync,
463 Mirror,
465 Watch,
467 Restore,
469}
470
471#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
473pub enum DeletePolicy {
474 Never,
476 TombstoneOnly,
478 MirrorWhenExplicit,
480}
481
482#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
484pub enum ConflictPolicy {
485 PreserveLocal,
487 Quarantine,
489 OverwriteWhenExplicit,
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
495pub enum SymlinkPolicy {
496 Skip,
498 PreserveAsLinkWhenExplicit,
500 MaterializeTargetWhenExplicit,
502}
503
504#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
506pub enum PermissionPolicy {
507 RecordOnly,
509 PreserveReadonly,
511 PreserveModeWhenExplicit,
513}
514
515#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
517pub enum RenamePolicy {
518 DetectByStableIdentity,
520 TreatAsDeleteCreate,
522}
523
524#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
526pub struct DestructiveAuthorization {
527 pub allow_delete: bool,
529 pub allow_overwrite: bool,
531 pub allow_permission_change: bool,
533 pub allow_symlink_materialization: bool,
535 pub dry_run: bool,
537}
538
539impl Default for DestructiveAuthorization {
540 fn default() -> Self {
541 Self {
542 allow_delete: false,
543 allow_overwrite: false,
544 allow_permission_change: false,
545 allow_symlink_materialization: false,
546 dry_run: true,
547 }
548 }
549}
550
551impl DestructiveAuthorization {
552 #[must_use]
554 pub const fn explicit_mirror_apply() -> Self {
555 Self {
556 allow_delete: true,
557 allow_overwrite: true,
558 allow_permission_change: true,
559 allow_symlink_materialization: true,
560 dry_run: false,
561 }
562 }
563}
564
565#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
567pub struct DirectorySyncPolicy {
568 pub mode: DirectorySyncMode,
570 pub delete_policy: DeletePolicy,
572 pub conflict_policy: ConflictPolicy,
574 pub symlink_policy: SymlinkPolicy,
576 pub permission_policy: PermissionPolicy,
578 pub rename_policy: RenamePolicy,
580 pub authorization: DestructiveAuthorization,
582}
583
584impl Default for DirectorySyncPolicy {
585 fn default() -> Self {
586 Self {
587 mode: DirectorySyncMode::Sync,
588 delete_policy: DeletePolicy::Never,
589 conflict_policy: ConflictPolicy::PreserveLocal,
590 symlink_policy: SymlinkPolicy::Skip,
591 permission_policy: PermissionPolicy::RecordOnly,
592 rename_policy: RenamePolicy::DetectByStableIdentity,
593 authorization: DestructiveAuthorization::default(),
594 }
595 }
596}
597
598impl DirectorySyncPolicy {
599 #[must_use]
601 pub const fn send_only() -> Self {
602 Self {
603 mode: DirectorySyncMode::SendOnly,
604 delete_policy: DeletePolicy::Never,
605 conflict_policy: ConflictPolicy::PreserveLocal,
606 symlink_policy: SymlinkPolicy::Skip,
607 permission_policy: PermissionPolicy::RecordOnly,
608 rename_policy: RenamePolicy::DetectByStableIdentity,
609 authorization: DestructiveAuthorization {
610 allow_delete: false,
611 allow_overwrite: false,
612 allow_permission_change: false,
613 allow_symlink_materialization: false,
614 dry_run: true,
615 },
616 }
617 }
618
619 #[must_use]
621 pub const fn mirror_with_authorization(authorization: DestructiveAuthorization) -> Self {
622 Self {
623 mode: DirectorySyncMode::Mirror,
624 delete_policy: DeletePolicy::MirrorWhenExplicit,
625 conflict_policy: ConflictPolicy::OverwriteWhenExplicit,
626 symlink_policy: SymlinkPolicy::PreserveAsLinkWhenExplicit,
627 permission_policy: PermissionPolicy::PreserveModeWhenExplicit,
628 rename_policy: RenamePolicy::DetectByStableIdentity,
629 authorization,
630 }
631 }
632}
633
634#[derive(
636 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
637)]
638pub enum DirectorySyncAction {
639 Create,
641 Update,
643 Delete,
645 Rename,
647 Preserve,
649 Conflict,
651 Skip,
653 Quarantine,
655 Restore,
657 PermissionChange,
659 SymlinkMaterialize,
661}
662
663impl DirectorySyncAction {
664 #[must_use]
666 pub const fn code(self) -> &'static str {
667 match self {
668 Self::Create => "create",
669 Self::Update => "update",
670 Self::Delete => "delete",
671 Self::Rename => "rename",
672 Self::Preserve => "preserve",
673 Self::Conflict => "conflict",
674 Self::Skip => "skip",
675 Self::Quarantine => "quarantine",
676 Self::Restore => "restore",
677 Self::PermissionChange => "permission_change",
678 Self::SymlinkMaterialize => "symlink_materialize",
679 }
680 }
681
682 #[must_use]
684 pub const fn requires_explicit_authorization(self) -> bool {
685 matches!(
686 self,
687 Self::Delete
688 | Self::Update
689 | Self::Quarantine
690 | Self::Restore
691 | Self::PermissionChange
692 | Self::SymlinkMaterialize
693 )
694 }
695}
696
697#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
699pub struct DirectorySyncDecision {
700 pub path: DirectoryPath,
702 pub from_path: Option<DirectoryPath>,
704 pub action: DirectorySyncAction,
706 pub authorized: bool,
708 pub would_apply: bool,
710 pub dry_run_visible: bool,
712 pub reason: String,
714 pub caveats: BTreeSet<MetadataCaveat>,
716}
717
718impl DirectorySyncDecision {
719 fn new(
720 path: DirectoryPath,
721 from_path: Option<DirectoryPath>,
722 action: DirectorySyncAction,
723 authorized: bool,
724 dry_run: bool,
725 reason: impl Into<String>,
726 caveats: BTreeSet<MetadataCaveat>,
727 ) -> Self {
728 Self {
729 path,
730 from_path,
731 action,
732 authorized,
733 would_apply: authorized && !dry_run,
734 dry_run_visible: true,
735 reason: reason.into(),
736 caveats,
737 }
738 }
739}
740
741#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
743pub struct DirectorySyncLogEntry {
744 pub schema_version: String,
746 pub path: String,
748 pub from_path: Option<String>,
750 pub action: String,
752 pub authorized: bool,
754 pub would_apply: bool,
756 pub reason: String,
758 pub caveats: Vec<String>,
760}
761
762#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
764pub struct DirectorySyncProofSummary {
765 pub schema_version: String,
767 pub mode: DirectorySyncMode,
769 pub metadata_policy: String,
771 pub destructive_actions_authorized: bool,
773 pub skipped_paths: Vec<String>,
775 pub conflict_decisions: Vec<String>,
777 pub final_tree_root: String,
779 pub replay_pointer: String,
781}
782
783#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
785pub struct DirectorySyncPlan {
786 pub decisions: Vec<DirectorySyncDecision>,
788 pub logs: Vec<DirectorySyncLogEntry>,
790 pub proof: DirectorySyncProofSummary,
792}
793
794#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
796pub enum DirectoryFinalCommitState {
797 Pending,
799 Committed,
801}
802
803#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
805pub enum DirectoryEarlyUsabilityState {
806 NoEntries,
808 MetadataAvailable,
810 SmallFilesAvailable,
812 FinalCommitted,
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
818pub struct DirectoryEarlyUsabilityPolicy {
819 pub max_small_file_bytes: u64,
821 pub expose_metadata_before_final: bool,
823}
824
825impl Default for DirectoryEarlyUsabilityPolicy {
826 fn default() -> Self {
827 Self {
828 max_small_file_bytes: 1024 * 1024,
829 expose_metadata_before_final: true,
830 }
831 }
832}
833
834impl DirectoryEarlyUsabilityPolicy {
835 #[must_use]
837 pub const fn small_files_up_to(max_small_file_bytes: u64) -> Self {
838 Self {
839 max_small_file_bytes,
840 expose_metadata_before_final: true,
841 }
842 }
843}
844
845#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
847pub enum DirectoryEarlyEntryState {
848 MetadataOnly,
850 SmallFileContent,
852 Withheld,
854}
855
856#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
858pub struct DirectoryEarlyEntryExposure {
859 pub path: String,
861 pub kind: DirectoryEntryKind,
863 pub metadata_visible: bool,
865 pub content_visible: bool,
867 pub content_id: Option<String>,
869 pub size_bytes: Option<u64>,
871 pub state: DirectoryEarlyEntryState,
873 pub reason: String,
875 pub caveats: Vec<String>,
877}
878
879#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
881pub struct DirectoryEarlyUsabilityReport {
882 pub schema_version: String,
884 pub usability_state: DirectoryEarlyUsabilityState,
886 pub final_commit_state: DirectoryFinalCommitState,
888 pub manifest_tree_root: String,
890 pub replay_pointer: String,
892 pub metadata_paths: Vec<String>,
894 pub small_file_paths: Vec<String>,
896 pub withheld_content_paths: Vec<String>,
898 pub entries: Vec<DirectoryEarlyEntryExposure>,
900 pub safety_caveats: Vec<String>,
902}
903
904fn early_entry_exposure(
905 entry: &DirectoryManifestEntry,
906 verified_content_ids: &BTreeSet<String>,
907 policy: DirectoryEarlyUsabilityPolicy,
908 final_commit_state: DirectoryFinalCommitState,
909 metadata_visible: bool,
910) -> DirectoryEarlyEntryExposure {
911 let content_verified = entry
912 .content_id
913 .as_ref()
914 .is_some_and(|content_id| verified_content_ids.contains(content_id))
915 || final_commit_state == DirectoryFinalCommitState::Committed;
916 let size = entry.metadata.size_bytes;
917 let is_regular_file = entry.kind == DirectoryEntryKind::File;
918 let is_small_file = size.is_some_and(|size| size <= policy.max_small_file_bytes);
919 let content_visible = metadata_visible && is_regular_file && content_verified && is_small_file;
920
921 let (state, reason) = if content_visible {
922 (
923 DirectoryEarlyEntryState::SmallFileContent,
924 "verified_small_file",
925 )
926 } else if !metadata_visible {
927 (
928 DirectoryEarlyEntryState::Withheld,
929 "metadata_withheld_until_final_commit",
930 )
931 } else if entry.content_id.is_none() {
932 (
933 DirectoryEarlyEntryState::MetadataOnly,
934 "metadata_only_no_content_id",
935 )
936 } else if !is_regular_file {
937 (
938 DirectoryEarlyEntryState::MetadataOnly,
939 "metadata_only_unsupported_content_kind",
940 )
941 } else if size.is_none() {
942 (
943 DirectoryEarlyEntryState::MetadataOnly,
944 "metadata_only_unknown_size",
945 )
946 } else if !content_verified {
947 (
948 DirectoryEarlyEntryState::MetadataOnly,
949 "metadata_only_content_not_verified",
950 )
951 } else {
952 (
953 DirectoryEarlyEntryState::MetadataOnly,
954 "metadata_only_file_exceeds_small_file_policy",
955 )
956 };
957
958 DirectoryEarlyEntryExposure {
959 path: entry.path.to_string(),
960 kind: entry.kind,
961 metadata_visible,
962 content_visible,
963 content_id: entry.content_id.clone(),
964 size_bytes: entry.metadata.size_bytes,
965 state,
966 reason: reason.to_string(),
967 caveats: entry
968 .caveats
969 .iter()
970 .map(|caveat| caveat.code().to_string())
971 .collect(),
972 }
973}
974
975#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
977pub enum DirectorySyncError {
978 #[error("directory path is empty")]
980 EmptyPath,
981 #[error("directory path normalizes to root")]
983 RootPath,
984 #[error("absolute path rejected: {0}")]
986 AbsolutePath(String),
987 #[error("parent traversal rejected: {0}")]
989 ParentTraversal(String),
990 #[error("invalid path {path}: {reason}")]
992 InvalidPath {
993 path: String,
995 reason: &'static str,
997 },
998 #[error("duplicate manifest path: {0}")]
1000 DuplicatePath(DirectoryPath),
1001}
1002
1003#[must_use]
1005pub fn plan_directory_sync(
1006 source: &DirectoryManifest,
1007 destination: &DirectoryManifest,
1008 policy: DirectorySyncPolicy,
1009) -> DirectorySyncPlan {
1010 let mut decisions = Vec::new();
1011 let mut renamed_from_paths = BTreeSet::new();
1012 let destination_by_identity = identity_index(destination);
1013 let source_paths = source.entries.keys().cloned().collect::<BTreeSet<_>>();
1014
1015 append_case_conflict_decisions(source, destination, policy, &mut decisions);
1016 append_identity_conflict_decisions(source, destination, policy, &mut decisions);
1017
1018 for (path, source_entry) in &source.entries {
1019 if source_entry.kind == DirectoryEntryKind::Symlink {
1020 append_symlink_decision(
1021 source_entry,
1022 destination.entries.get(path),
1023 policy,
1024 &mut decisions,
1025 );
1026 continue;
1027 }
1028
1029 match destination.entries.get(path) {
1030 Some(destination_entry) if source_entry.semantically_matches(destination_entry) => {
1031 decisions.push(decision(
1032 path.clone(),
1033 None,
1034 DirectorySyncAction::Preserve,
1035 policy,
1036 "already_matches",
1037 source_entry.caveats.clone(),
1038 ));
1039 }
1040 Some(destination_entry) => {
1041 append_existing_path_decision(
1042 source_entry,
1043 destination_entry,
1044 policy,
1045 &mut decisions,
1046 );
1047 }
1048 None => {
1049 if let Some(from_path) = detect_rename(
1050 source_entry,
1051 &destination_by_identity,
1052 &source_paths,
1053 policy,
1054 ) {
1055 renamed_from_paths.insert(from_path.clone());
1056 decisions.push(decision(
1057 path.clone(),
1058 Some(from_path),
1059 DirectorySyncAction::Rename,
1060 policy,
1061 "stable_identity_rename",
1062 source_entry.caveats.clone(),
1063 ));
1064 } else {
1065 decisions.push(decision(
1066 path.clone(),
1067 None,
1068 create_or_restore_action(policy),
1069 policy,
1070 "missing_destination_entry",
1071 source_entry.caveats.clone(),
1072 ));
1073 }
1074 }
1075 }
1076 }
1077
1078 for (path, destination_entry) in &destination.entries {
1079 if source.entries.contains_key(path) || renamed_from_paths.contains(path) {
1080 continue;
1081 }
1082 decisions.push(delete_or_preserve_decision(path, destination_entry, policy));
1083 }
1084
1085 decisions.sort_by(|left, right| {
1086 left.path
1087 .cmp(&right.path)
1088 .then(left.action.cmp(&right.action))
1089 .then(left.from_path.cmp(&right.from_path))
1090 });
1091 let logs = decisions.iter().map(log_entry).collect::<Vec<_>>();
1092 let proof = proof_summary(source, destination, policy, &decisions);
1093
1094 DirectorySyncPlan {
1095 decisions,
1096 logs,
1097 proof,
1098 }
1099}
1100
1101fn append_case_conflict_decisions(
1102 source: &DirectoryManifest,
1103 destination: &DirectoryManifest,
1104 policy: DirectorySyncPolicy,
1105 decisions: &mut Vec<DirectorySyncDecision>,
1106) {
1107 for group in source
1108 .case_conflicts()
1109 .into_iter()
1110 .chain(destination.case_conflicts())
1111 {
1112 for path in group {
1113 decisions.push(decision(
1114 path,
1115 None,
1116 DirectorySyncAction::Conflict,
1117 policy,
1118 "case_conflict",
1119 BTreeSet::from([MetadataCaveat::CaseSensitivity]),
1120 ));
1121 }
1122 }
1123}
1124
1125fn append_identity_conflict_decisions(
1126 source: &DirectoryManifest,
1127 destination: &DirectoryManifest,
1128 policy: DirectorySyncPolicy,
1129 decisions: &mut Vec<DirectorySyncDecision>,
1130) {
1131 let mut conflicted_paths = identity_conflicts(source);
1132 conflicted_paths.extend(identity_conflicts(destination));
1133
1134 for path in conflicted_paths {
1135 decisions.push(decision(
1136 path,
1137 None,
1138 DirectorySyncAction::Conflict,
1139 policy,
1140 "stable_identity_conflict",
1141 BTreeSet::new(),
1142 ));
1143 }
1144}
1145
1146fn append_symlink_decision(
1147 source_entry: &DirectoryManifestEntry,
1148 destination_entry: Option<&DirectoryManifestEntry>,
1149 policy: DirectorySyncPolicy,
1150 decisions: &mut Vec<DirectorySyncDecision>,
1151) {
1152 let action = match policy.symlink_policy {
1153 SymlinkPolicy::Skip => DirectorySyncAction::Skip,
1154 SymlinkPolicy::PreserveAsLinkWhenExplicit
1155 | SymlinkPolicy::MaterializeTargetWhenExplicit => DirectorySyncAction::SymlinkMaterialize,
1156 };
1157 let reason = if destination_entry.is_some_and(|entry| entry.semantically_matches(source_entry))
1158 {
1159 "symlink_already_matches"
1160 } else if action == DirectorySyncAction::Skip {
1161 "symlink_policy_skip"
1162 } else {
1163 "symlink_requires_explicit_policy"
1164 };
1165 decisions.push(decision(
1166 source_entry.path.clone(),
1167 None,
1168 action,
1169 policy,
1170 reason,
1171 source_entry.caveats.clone(),
1172 ));
1173}
1174
1175fn append_existing_path_decision(
1176 source_entry: &DirectoryManifestEntry,
1177 destination_entry: &DirectoryManifestEntry,
1178 policy: DirectorySyncPolicy,
1179 decisions: &mut Vec<DirectorySyncDecision>,
1180) {
1181 if permissions_differ(source_entry, destination_entry)
1182 && content_and_kind_match(source_entry, destination_entry)
1183 {
1184 decisions.push(decision(
1185 source_entry.path.clone(),
1186 None,
1187 DirectorySyncAction::PermissionChange,
1188 policy,
1189 "metadata_permission_delta",
1190 source_entry.caveats.clone(),
1191 ));
1192 return;
1193 }
1194
1195 let action = match policy.conflict_policy {
1196 ConflictPolicy::PreserveLocal => DirectorySyncAction::Conflict,
1197 ConflictPolicy::Quarantine => DirectorySyncAction::Quarantine,
1198 ConflictPolicy::OverwriteWhenExplicit => match policy.mode {
1199 DirectorySyncMode::Restore => DirectorySyncAction::Restore,
1200 _ => DirectorySyncAction::Update,
1201 },
1202 };
1203 decisions.push(decision(
1204 source_entry.path.clone(),
1205 None,
1206 action,
1207 policy,
1208 "destination_differs",
1209 source_entry.caveats.clone(),
1210 ));
1211}
1212
1213fn delete_or_preserve_decision(
1214 path: &DirectoryPath,
1215 destination_entry: &DirectoryManifestEntry,
1216 policy: DirectorySyncPolicy,
1217) -> DirectorySyncDecision {
1218 let (action, reason) = match (policy.mode, policy.delete_policy) {
1219 (DirectorySyncMode::Mirror, DeletePolicy::MirrorWhenExplicit) => {
1220 (DirectorySyncAction::Delete, "mirror_delete")
1221 }
1222 (_, DeletePolicy::TombstoneOnly) => (DirectorySyncAction::Skip, "tombstone_only_delete"),
1223 _ => (DirectorySyncAction::Preserve, "delete_not_allowed"),
1224 };
1225 decision(
1226 path.clone(),
1227 None,
1228 action,
1229 policy,
1230 reason,
1231 destination_entry.caveats.clone(),
1232 )
1233}
1234
1235fn decision(
1236 path: DirectoryPath,
1237 from_path: Option<DirectoryPath>,
1238 action: DirectorySyncAction,
1239 policy: DirectorySyncPolicy,
1240 reason: impl Into<String>,
1241 caveats: BTreeSet<MetadataCaveat>,
1242) -> DirectorySyncDecision {
1243 DirectorySyncDecision::new(
1244 path,
1245 from_path,
1246 action,
1247 action_authorized(action, policy),
1248 policy.authorization.dry_run,
1249 reason,
1250 caveats,
1251 )
1252}
1253
1254fn action_authorized(action: DirectorySyncAction, policy: DirectorySyncPolicy) -> bool {
1255 match action {
1256 DirectorySyncAction::Delete => {
1257 policy.delete_policy == DeletePolicy::MirrorWhenExplicit
1258 && policy.authorization.allow_delete
1259 }
1260 DirectorySyncAction::Update | DirectorySyncAction::Restore => {
1261 policy.conflict_policy == ConflictPolicy::OverwriteWhenExplicit
1262 && policy.authorization.allow_overwrite
1263 }
1264 DirectorySyncAction::Quarantine => {
1265 policy.conflict_policy == ConflictPolicy::Quarantine
1266 && policy.authorization.allow_overwrite
1267 }
1268 DirectorySyncAction::PermissionChange => {
1269 policy.permission_policy == PermissionPolicy::PreserveModeWhenExplicit
1270 && policy.authorization.allow_permission_change
1271 }
1272 DirectorySyncAction::SymlinkMaterialize => {
1273 policy.symlink_policy != SymlinkPolicy::Skip
1274 && policy.authorization.allow_symlink_materialization
1275 }
1276 DirectorySyncAction::Create
1277 | DirectorySyncAction::Rename
1278 | DirectorySyncAction::Preserve
1279 | DirectorySyncAction::Conflict
1280 | DirectorySyncAction::Skip => true,
1281 }
1282}
1283
1284fn create_or_restore_action(policy: DirectorySyncPolicy) -> DirectorySyncAction {
1285 match policy.mode {
1286 DirectorySyncMode::Restore => DirectorySyncAction::Restore,
1287 _ => DirectorySyncAction::Create,
1288 }
1289}
1290
1291fn identity_index(manifest: &DirectoryManifest) -> BTreeMap<String, DirectoryPath> {
1292 identity_groups(manifest)
1293 .into_iter()
1294 .filter_map(|(identity, paths)| {
1295 if paths.len() == 1 {
1296 paths.into_iter().next().map(|path| (identity, path))
1297 } else {
1298 None
1299 }
1300 })
1301 .collect()
1302}
1303
1304fn identity_conflicts(manifest: &DirectoryManifest) -> BTreeSet<DirectoryPath> {
1305 identity_groups(manifest)
1306 .into_values()
1307 .filter(|paths| paths.len() > 1)
1308 .flatten()
1309 .collect()
1310}
1311
1312fn identity_groups(manifest: &DirectoryManifest) -> BTreeMap<String, Vec<DirectoryPath>> {
1313 let mut groups = BTreeMap::new();
1314 for (path, entry) in &manifest.entries {
1315 if let Some(identity) = entry.stable_identity() {
1316 groups
1317 .entry(identity.to_string())
1318 .or_insert_with(Vec::new)
1319 .push(path.clone());
1320 }
1321 }
1322 groups
1323}
1324
1325fn detect_rename(
1326 source_entry: &DirectoryManifestEntry,
1327 destination_by_identity: &BTreeMap<String, DirectoryPath>,
1328 source_paths: &BTreeSet<DirectoryPath>,
1329 policy: DirectorySyncPolicy,
1330) -> Option<DirectoryPath> {
1331 if policy.rename_policy != RenamePolicy::DetectByStableIdentity {
1332 return None;
1333 }
1334 let identity = source_entry.stable_identity()?;
1335 let destination_path = destination_by_identity.get(identity)?;
1336 (!source_paths.contains(destination_path)).then(|| destination_path.clone())
1337}
1338
1339fn permissions_differ(
1340 source_entry: &DirectoryManifestEntry,
1341 destination_entry: &DirectoryManifestEntry,
1342) -> bool {
1343 source_entry.metadata.unix_mode != destination_entry.metadata.unix_mode
1344 || source_entry.metadata.windows_attributes != destination_entry.metadata.windows_attributes
1345}
1346
1347fn content_and_kind_match(
1348 source_entry: &DirectoryManifestEntry,
1349 destination_entry: &DirectoryManifestEntry,
1350) -> bool {
1351 source_entry.kind == destination_entry.kind
1352 && source_entry.content_id == destination_entry.content_id
1353}
1354
1355fn log_entry(decision: &DirectorySyncDecision) -> DirectorySyncLogEntry {
1356 DirectorySyncLogEntry {
1357 schema_version: DIRECTORY_SYNC_LOG_SCHEMA.to_string(),
1358 path: decision.path.to_string(),
1359 from_path: decision.from_path.as_ref().map(ToString::to_string),
1360 action: decision.action.code().to_string(),
1361 authorized: decision.authorized,
1362 would_apply: decision.would_apply,
1363 reason: decision.reason.clone(),
1364 caveats: decision
1365 .caveats
1366 .iter()
1367 .map(|caveat| caveat.code().to_string())
1368 .collect(),
1369 }
1370}
1371
1372fn proof_summary(
1373 source: &DirectoryManifest,
1374 destination: &DirectoryManifest,
1375 policy: DirectorySyncPolicy,
1376 decisions: &[DirectorySyncDecision],
1377) -> DirectorySyncProofSummary {
1378 let destructive_actions_authorized = decisions
1379 .iter()
1380 .filter(|decision| decision.action.requires_explicit_authorization())
1381 .all(|decision| decision.authorized && decision.dry_run_visible);
1382 let skipped_paths = decisions
1383 .iter()
1384 .filter(|decision| decision.action == DirectorySyncAction::Skip)
1385 .map(|decision| decision.path.to_string())
1386 .collect();
1387 let conflict_decisions = decisions
1388 .iter()
1389 .filter(|decision| {
1390 matches!(
1391 decision.action,
1392 DirectorySyncAction::Conflict | DirectorySyncAction::Quarantine
1393 )
1394 })
1395 .map(|decision| format!("{}:{}", decision.path, decision.reason))
1396 .collect();
1397
1398 DirectorySyncProofSummary {
1399 schema_version: DIRECTORY_SYNC_PROOF_SCHEMA.to_string(),
1400 mode: policy.mode,
1401 metadata_policy: metadata_policy_code(policy),
1402 destructive_actions_authorized,
1403 skipped_paths,
1404 conflict_decisions,
1405 final_tree_root: hex::encode(projected_tree_root(source, destination, decisions)),
1406 replay_pointer: replay_pointer(source, destination, decisions),
1407 }
1408}
1409
1410fn metadata_policy_code(policy: DirectorySyncPolicy) -> String {
1411 format!(
1412 "delete={:?};conflict={:?};symlink={:?};permission={:?};rename={:?};dry_run={}",
1413 policy.delete_policy,
1414 policy.conflict_policy,
1415 policy.symlink_policy,
1416 policy.permission_policy,
1417 policy.rename_policy,
1418 policy.authorization.dry_run
1419 )
1420}
1421
1422fn projected_tree_root(
1423 source: &DirectoryManifest,
1424 destination: &DirectoryManifest,
1425 decisions: &[DirectorySyncDecision],
1426) -> [u8; 32] {
1427 let mut projected = destination.clone();
1428 for decision in decisions {
1429 if !decision.would_apply {
1430 continue;
1431 }
1432 match decision.action {
1433 DirectorySyncAction::Create
1434 | DirectorySyncAction::Update
1435 | DirectorySyncAction::Restore
1436 | DirectorySyncAction::SymlinkMaterialize => {
1437 if let Some(entry) = source.entries.get(&decision.path) {
1438 projected
1439 .entries
1440 .insert(decision.path.clone(), entry.clone());
1441 }
1442 }
1443 DirectorySyncAction::Delete | DirectorySyncAction::Quarantine => {
1444 projected.entries.remove(&decision.path);
1445 }
1446 DirectorySyncAction::Rename => {
1447 if let Some(from_path) = &decision.from_path {
1448 projected.entries.remove(from_path);
1449 }
1450 if let Some(entry) = source.entries.get(&decision.path) {
1451 projected
1452 .entries
1453 .insert(decision.path.clone(), entry.clone());
1454 }
1455 }
1456 DirectorySyncAction::PermissionChange => {
1457 if let Some(entry) = source.entries.get(&decision.path) {
1458 projected
1459 .entries
1460 .insert(decision.path.clone(), entry.clone());
1461 }
1462 }
1463 DirectorySyncAction::Preserve
1464 | DirectorySyncAction::Conflict
1465 | DirectorySyncAction::Skip => {}
1466 }
1467 }
1468 projected.tree_root()
1469}
1470
1471fn replay_pointer(
1472 source: &DirectoryManifest,
1473 destination: &DirectoryManifest,
1474 decisions: &[DirectorySyncDecision],
1475) -> String {
1476 let mut hasher = Sha256::new();
1477 hasher.update(b"asupersync.atp.directory-sync.replay.v1\0");
1478 hasher.update(source.tree_root());
1479 hasher.update(destination.tree_root());
1480 for decision in decisions {
1481 hasher.update(decision.path.as_str().as_bytes());
1482 hasher.update(decision.action.code().as_bytes());
1483 hasher.update([
1484 u8::from(decision.authorized),
1485 u8::from(decision.would_apply),
1486 ]);
1487 }
1488 format!("directory-sync:{}", hex::encode(hasher.finalize()))
1489}
1490
1491fn hash_metadata(hasher: &mut Sha256, metadata: &DirectoryEntryMetadata) {
1492 hash_opt_u64(hasher, metadata.size_bytes);
1493 hash_opt_u32(hasher, metadata.unix_mode);
1494 hash_opt_u32(hasher, metadata.windows_attributes);
1495 hash_opt_i64(hasher, metadata.modified_epoch_micros);
1496 hash_opt_str(hasher, metadata.symlink_target.as_deref());
1497 hash_opt_str(hasher, metadata.hard_link_group.as_deref());
1498 hash_opt_str(hasher, metadata.sparse_summary.as_deref());
1499 hash_opt_str(hasher, metadata.stable_identity.as_deref());
1500}
1501
1502fn hash_opt_u64(hasher: &mut Sha256, value: Option<u64>) {
1503 if let Some(value) = value {
1504 hasher.update(value.to_be_bytes());
1505 }
1506 hasher.update([0]);
1507}
1508
1509fn hash_opt_u32(hasher: &mut Sha256, value: Option<u32>) {
1510 if let Some(value) = value {
1511 hasher.update(value.to_be_bytes());
1512 }
1513 hasher.update([0]);
1514}
1515
1516fn hash_opt_i64(hasher: &mut Sha256, value: Option<i64>) {
1517 if let Some(value) = value {
1518 hasher.update(value.to_be_bytes());
1519 }
1520 hasher.update([0]);
1521}
1522
1523fn hash_opt_str(hasher: &mut Sha256, value: Option<&str>) {
1524 if let Some(value) = value {
1525 hasher.update(value.as_bytes());
1526 }
1527 hasher.update([0]);
1528}
1529
1530#[cfg(test)]
1531mod tests {
1532 use super::*;
1533
1534 fn path(raw: &str) -> DirectoryPath {
1535 DirectoryPath::normalize(raw, PathNormalizationRules::default()).expect("path")
1536 }
1537
1538 fn file(raw: &str, content_id: &str) -> DirectoryManifestEntry {
1539 DirectoryManifestEntry::new(
1540 path(raw),
1541 DirectoryEntryKind::File,
1542 Some(content_id.to_string()),
1543 DirectoryEntryMetadata::with_identity(content_id),
1544 )
1545 }
1546
1547 fn sized_file(raw: &str, content_id: &str, size_bytes: u64) -> DirectoryManifestEntry {
1548 let mut metadata = DirectoryEntryMetadata::with_identity(content_id);
1549 metadata.size_bytes = Some(size_bytes);
1550 DirectoryManifestEntry::new(
1551 path(raw),
1552 DirectoryEntryKind::File,
1553 Some(content_id.to_string()),
1554 metadata,
1555 )
1556 }
1557
1558 fn manifest(entries: Vec<DirectoryManifestEntry>) -> DirectoryManifest {
1559 let mut manifest = DirectoryManifest::new(PathNormalizationRules::default());
1560 for entry in entries {
1561 manifest.insert(entry).expect("insert");
1562 }
1563 manifest
1564 }
1565
1566 #[test]
1567 fn path_normalization_rejects_unsafe_paths() {
1568 assert_eq!(path("a//./b\\c").as_str(), "a/b/c");
1569 assert!(matches!(
1570 DirectoryPath::normalize("../secret", PathNormalizationRules::default()),
1571 Err(DirectorySyncError::ParentTraversal(_))
1572 ));
1573 assert!(matches!(
1574 DirectoryPath::normalize("/tmp/file", PathNormalizationRules::default()),
1575 Err(DirectorySyncError::AbsolutePath(_))
1576 ));
1577 }
1578
1579 #[test]
1580 fn case_conflicts_are_classified() {
1581 let source = manifest(vec![file("Readme.md", "a"), file("README.md", "b")]);
1582 let plan = plan_directory_sync(
1583 &source,
1584 &DirectoryManifest::new(PathNormalizationRules::default()),
1585 DirectorySyncPolicy::default(),
1586 );
1587
1588 assert!(
1589 plan.decisions
1590 .iter()
1591 .any(|decision| decision.reason == "case_conflict")
1592 );
1593 assert!(
1594 plan.proof
1595 .conflict_decisions
1596 .iter()
1597 .any(|item| item.contains("case_conflict"))
1598 );
1599 }
1600
1601 #[test]
1602 fn rename_detection_uses_stable_identity() {
1603 let source = manifest(vec![file("new/name.txt", "same")]);
1604 let destination = manifest(vec![file("old/name.txt", "same")]);
1605 let plan = plan_directory_sync(&source, &destination, DirectorySyncPolicy::default());
1606
1607 let rename = plan
1608 .decisions
1609 .iter()
1610 .find(|decision| decision.action == DirectorySyncAction::Rename)
1611 .expect("rename");
1612 assert_eq!(
1613 rename.from_path.as_ref().map(DirectoryPath::as_str),
1614 Some("old/name.txt")
1615 );
1616 }
1617
1618 #[test]
1619 fn rename_detection_does_not_plan_old_path_delete() {
1620 let source = manifest(vec![file("new/name.txt", "same")]);
1621 let destination = manifest(vec![file("old/name.txt", "same")]);
1622 let plan = plan_directory_sync(
1623 &source,
1624 &destination,
1625 DirectorySyncPolicy::mirror_with_authorization(
1626 DestructiveAuthorization::explicit_mirror_apply(),
1627 ),
1628 );
1629
1630 assert_eq!(plan.decisions.len(), 1);
1631 assert_eq!(plan.decisions[0].action, DirectorySyncAction::Rename);
1632 assert_eq!(plan.decisions[0].path.as_str(), "new/name.txt");
1633 assert_eq!(
1634 plan.decisions[0]
1635 .from_path
1636 .as_ref()
1637 .map(DirectoryPath::as_str),
1638 Some("old/name.txt")
1639 );
1640 }
1641
1642 #[test]
1643 fn duplicate_stable_identity_blocks_rename_candidate() {
1644 let source = manifest(vec![file("new/name.txt", "same")]);
1645 let destination = manifest(vec![
1646 file("old/one.txt", "same"),
1647 file("old/two.txt", "same"),
1648 ]);
1649 let plan = plan_directory_sync(&source, &destination, DirectorySyncPolicy::default());
1650
1651 assert!(
1652 !plan
1653 .decisions
1654 .iter()
1655 .any(|decision| decision.action == DirectorySyncAction::Rename)
1656 );
1657 assert_eq!(
1658 plan.decisions
1659 .iter()
1660 .filter(|decision| decision.reason == "stable_identity_conflict")
1661 .count(),
1662 2
1663 );
1664 }
1665
1666 #[test]
1667 fn symlink_policy_skips_by_default() {
1668 let mut metadata = DirectoryEntryMetadata::default();
1669 metadata.symlink_target = Some("target.txt".to_string());
1670 let source = manifest(vec![DirectoryManifestEntry::new(
1671 path("link.txt"),
1672 DirectoryEntryKind::Symlink,
1673 None,
1674 metadata,
1675 )]);
1676 let plan = plan_directory_sync(
1677 &source,
1678 &DirectoryManifest::new(PathNormalizationRules::default()),
1679 DirectorySyncPolicy::default(),
1680 );
1681
1682 assert_eq!(plan.decisions[0].action, DirectorySyncAction::Skip);
1683 assert_eq!(plan.proof.skipped_paths, vec!["link.txt"]);
1684 }
1685
1686 #[test]
1687 fn permission_changes_require_explicit_policy() {
1688 let mut source_entry = file("run.sh", "script");
1689 source_entry.metadata.unix_mode = Some(0o755);
1690 let mut destination_entry = file("run.sh", "script");
1691 destination_entry.metadata.unix_mode = Some(0o644);
1692 let source = manifest(vec![source_entry]);
1693 let destination = manifest(vec![destination_entry]);
1694 let plan = plan_directory_sync(&source, &destination, DirectorySyncPolicy::default());
1695
1696 assert_eq!(
1697 plan.decisions[0].action,
1698 DirectorySyncAction::PermissionChange
1699 );
1700 assert!(!plan.decisions[0].authorized);
1701 assert!(!plan.decisions[0].would_apply);
1702 }
1703
1704 #[test]
1705 fn mirror_delete_needs_authorization_and_respects_dry_run() {
1706 let source = DirectoryManifest::new(PathNormalizationRules::default());
1707 let destination = manifest(vec![file("stale.txt", "old")]);
1708 let policy = DirectorySyncPolicy::mirror_with_authorization(DestructiveAuthorization {
1709 allow_delete: true,
1710 dry_run: true,
1711 ..DestructiveAuthorization::default()
1712 });
1713 let plan = plan_directory_sync(&source, &destination, policy);
1714
1715 assert_eq!(plan.decisions[0].action, DirectorySyncAction::Delete);
1716 assert!(plan.decisions[0].authorized);
1717 assert!(!plan.decisions[0].would_apply);
1718 assert!(plan.decisions[0].dry_run_visible);
1719 }
1720
1721 #[test]
1722 fn metadata_round_trip_preserves_caveats() {
1723 let mut metadata = DirectoryEntryMetadata::with_identity("id");
1724 metadata.unix_mode = Some(0o600);
1725 metadata.modified_epoch_micros = Some(1_234);
1726 metadata.sparse_summary = Some("holes=2".to_string());
1727 let entry = DirectoryManifestEntry::new(
1728 path("sparse.img"),
1729 DirectoryEntryKind::SparseFile,
1730 Some("cid".to_string()),
1731 metadata,
1732 );
1733
1734 assert!(entry.caveats.contains(&MetadataCaveat::UnixPermissions));
1735 assert!(entry.caveats.contains(&MetadataCaveat::TimestampResolution));
1736 assert!(entry.caveats.contains(&MetadataCaveat::SparseMetadata));
1737 assert_eq!(
1738 serde_json::from_str::<DirectoryManifestEntry>(
1739 &serde_json::to_string(&entry).expect("serialize")
1740 )
1741 .expect("deserialize"),
1742 entry
1743 );
1744 }
1745
1746 #[test]
1747 fn conflict_classification_preserves_local_by_default() {
1748 let source = manifest(vec![file("same.txt", "new")]);
1749 let destination = manifest(vec![file("same.txt", "old")]);
1750 let plan = plan_directory_sync(&source, &destination, DirectorySyncPolicy::send_only());
1751
1752 assert_eq!(plan.decisions[0].action, DirectorySyncAction::Conflict);
1753 assert_eq!(plan.decisions[0].reason, "destination_differs");
1754 assert!(!plan.decisions[0].would_apply);
1755 assert_eq!(
1756 plan.proof.conflict_decisions,
1757 vec!["same.txt:destination_differs"]
1758 );
1759 }
1760
1761 #[test]
1762 fn directory_early_report_surfaces_metadata_and_verified_small_files() {
1763 let directory = DirectoryManifestEntry::new(
1764 path("docs"),
1765 DirectoryEntryKind::Directory,
1766 None,
1767 DirectoryEntryMetadata::default(),
1768 );
1769 let source = manifest(vec![
1770 directory,
1771 sized_file("docs/README.md", "small-cid", 512),
1772 sized_file("model.bin", "large-cid", 2 * 1024 * 1024),
1773 ]);
1774 let verified_content_ids =
1775 BTreeSet::from(["small-cid".to_string(), "large-cid".to_string()]);
1776
1777 let report = source.early_usability_report(
1778 &verified_content_ids,
1779 DirectoryEarlyUsabilityPolicy::small_files_up_to(1024),
1780 DirectoryFinalCommitState::Pending,
1781 "directory-replay:small-files",
1782 );
1783
1784 assert_eq!(
1785 report.usability_state,
1786 DirectoryEarlyUsabilityState::SmallFilesAvailable
1787 );
1788 assert_eq!(
1789 report.final_commit_state,
1790 DirectoryFinalCommitState::Pending
1791 );
1792 assert_eq!(report.replay_pointer, "directory-replay:small-files");
1793 assert_eq!(
1794 report.metadata_paths,
1795 vec!["docs", "docs/README.md", "model.bin"]
1796 );
1797 assert_eq!(report.small_file_paths, vec!["docs/README.md"]);
1798 assert_eq!(report.withheld_content_paths, vec!["model.bin"]);
1799 assert_eq!(report.manifest_tree_root, hex::encode(source.tree_root()));
1800 assert!(report.safety_caveats.contains(
1801 &"final directory commit not complete; expose early entries separately".to_string()
1802 ));
1803
1804 let large = report
1805 .entries
1806 .iter()
1807 .find(|entry| entry.path == "model.bin")
1808 .expect("large entry");
1809 assert_eq!(
1810 large.state,
1811 DirectoryEarlyEntryState::MetadataOnly,
1812 "large verified files must not become small-file early content"
1813 );
1814 assert_eq!(large.reason, "metadata_only_file_exceeds_small_file_policy");
1815 }
1816
1817 #[test]
1818 fn directory_early_report_withholds_unverified_small_file_content() {
1819 let source = manifest(vec![sized_file("config.json", "config-cid", 128)]);
1820 let report = source.early_usability_report(
1821 &BTreeSet::new(),
1822 DirectoryEarlyUsabilityPolicy::small_files_up_to(1024),
1823 DirectoryFinalCommitState::Pending,
1824 "directory-replay:unverified",
1825 );
1826
1827 assert_eq!(
1828 report.usability_state,
1829 DirectoryEarlyUsabilityState::MetadataAvailable
1830 );
1831 assert_eq!(report.metadata_paths, vec!["config.json"]);
1832 assert!(report.small_file_paths.is_empty());
1833 assert_eq!(report.withheld_content_paths, vec!["config.json"]);
1834 assert_eq!(
1835 report.entries[0].reason,
1836 "metadata_only_content_not_verified"
1837 );
1838 assert!(!report.entries[0].content_visible);
1839 }
1840
1841 #[test]
1842 fn directory_early_report_keeps_final_commit_state_separate() {
1843 let source = manifest(vec![sized_file("done.txt", "done-cid", 32)]);
1844 let policy = DirectoryEarlyUsabilityPolicy {
1845 expose_metadata_before_final: false,
1846 ..DirectoryEarlyUsabilityPolicy::small_files_up_to(1024)
1847 };
1848
1849 let pending = source.early_usability_report(
1850 &BTreeSet::from(["done-cid".to_string()]),
1851 policy,
1852 DirectoryFinalCommitState::Pending,
1853 "directory-replay:pending",
1854 );
1855 assert_eq!(
1856 pending.usability_state,
1857 DirectoryEarlyUsabilityState::NoEntries
1858 );
1859 assert!(pending.metadata_paths.is_empty());
1860 assert!(pending.small_file_paths.is_empty());
1861 assert_eq!(
1862 pending.entries[0].reason,
1863 "metadata_withheld_until_final_commit"
1864 );
1865
1866 let committed = source.early_usability_report(
1867 &BTreeSet::from(["done-cid".to_string()]),
1868 policy,
1869 DirectoryFinalCommitState::Committed,
1870 "directory-replay:committed",
1871 );
1872 assert_eq!(
1873 committed.usability_state,
1874 DirectoryEarlyUsabilityState::FinalCommitted
1875 );
1876 assert_eq!(
1877 committed.final_commit_state,
1878 DirectoryFinalCommitState::Committed
1879 );
1880 assert_eq!(committed.metadata_paths, vec!["done.txt"]);
1881 assert_eq!(committed.small_file_paths, vec!["done.txt"]);
1882 assert!(!committed.safety_caveats.contains(
1883 &"final directory commit not complete; expose early entries separately".to_string()
1884 ));
1885 }
1886
1887 #[test]
1888 fn explicit_apply_changes_projected_tree_root() {
1889 let source = manifest(vec![file("same.txt", "new")]);
1890 let destination = manifest(vec![file("same.txt", "old")]);
1891 let policy = DirectorySyncPolicy::mirror_with_authorization(
1892 DestructiveAuthorization::explicit_mirror_apply(),
1893 );
1894 let plan = plan_directory_sync(&source, &destination, policy);
1895
1896 assert!(plan.decisions[0].would_apply);
1897 assert_eq!(plan.proof.final_tree_root, hex::encode(source.tree_root()));
1898 assert!(plan.proof.replay_pointer.starts_with("directory-sync:"));
1899 }
1900}