Skip to main content

asupersync/atp/sync/
mod.rs

1//! Directory sync semantics for ATP object graphs.
2//!
3//! The model in this module is deliberately declarative. It builds manifests,
4//! classifies differences, and emits dry-run-safe decisions plus proof records;
5//! it does not mutate the local filesystem.
6
7use sha2::{Digest, Sha256};
8use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10
11/// Stable schema for directory sync proof summaries.
12pub const DIRECTORY_SYNC_PROOF_SCHEMA: &str = "asupersync.atp.directory-sync.proof.v1";
13
14/// Stable schema for one directory sync log entry.
15pub const DIRECTORY_SYNC_LOG_SCHEMA: &str = "asupersync.atp.directory-sync.log.v1";
16
17/// Stable schema for directory early-usability reports.
18pub 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/// Platform path normalization rules used while building a manifest.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
24pub struct PathNormalizationRules {
25    /// Whether path comparison is case-sensitive.
26    pub case_sensitive: bool,
27    /// Whether backslashes are normalized to `/`.
28    pub normalize_backslashes: bool,
29    /// Whether Unicode normalization was applied before manifesting.
30    pub unicode_normalized: bool,
31    /// Whether absolute paths are rejected.
32    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/// Canonical relative path inside a directory manifest.
47#[derive(
48    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
49)]
50pub struct DirectoryPath(String);
51
52impl DirectoryPath {
53    /// Normalize and validate a manifest-relative path.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`DirectorySyncError`] when the path is empty, absolute,
58    /// contains NUL bytes, traverses via `..`, or normalizes to the root.
59    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    /// Borrow the canonical relative path.
104    #[must_use]
105    pub fn as_str(&self) -> &str {
106        &self.0
107    }
108
109    /// Return the path key used for case-conflict detection.
110    #[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/// Object kind represented by one directory manifest entry.
127#[derive(
128    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
129)]
130pub enum DirectoryEntryKind {
131    /// Regular file.
132    File,
133    /// Directory.
134    Directory,
135    /// Symbolic link.
136    Symlink,
137    /// Hard-link reference when the source platform can expose one.
138    HardLink,
139    /// Sparse file with recorded sparse metadata.
140    SparseFile,
141}
142
143impl DirectoryEntryKind {
144    /// Stable kind code for logs and proof summaries.
145    #[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/// Metadata caveat attached to an entry when platform semantics are lossy.
158#[derive(
159    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
160)]
161pub enum MetadataCaveat {
162    /// Unix mode bits are platform-specific.
163    UnixPermissions,
164    /// Windows attributes are platform-specific.
165    WindowsAttributes,
166    /// Timestamp precision or clock semantics are lossy.
167    TimestampResolution,
168    /// Symlink preservation depends on destination policy and platform support.
169    SymlinkSupport,
170    /// Hard links are not portable across all destinations.
171    HardLinkSupport,
172    /// Sparse extents are advisory metadata.
173    SparseMetadata,
174    /// Case-insensitive destinations may collapse distinct paths.
175    CaseSensitivity,
176    /// Path normalization changed the source spelling.
177    PathNormalization,
178}
179
180impl MetadataCaveat {
181    /// Stable caveat code for logs and proof summaries.
182    #[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/// Portable metadata recorded for one directory entry.
198#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
199pub struct DirectoryEntryMetadata {
200    /// Content length for file-like entries.
201    pub size_bytes: Option<u64>,
202    /// Unix mode bits when policy records them.
203    pub unix_mode: Option<u32>,
204    /// Windows attribute bitset when policy records it.
205    pub windows_attributes: Option<u32>,
206    /// Modified time as Unix epoch microseconds when policy records it.
207    pub modified_epoch_micros: Option<i64>,
208    /// Symlink target as manifest-relative text when applicable.
209    pub symlink_target: Option<String>,
210    /// Stable hard-link group when applicable.
211    pub hard_link_group: Option<String>,
212    /// Sparse extent digest or summary when applicable.
213    pub sparse_summary: Option<String>,
214    /// Stable identity used to detect renames.
215    pub stable_identity: Option<String>,
216}
217
218impl DirectoryEntryMetadata {
219    /// Build metadata containing only a stable identity.
220    #[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/// One entry in a directory manifest.
230#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
231pub struct DirectoryManifestEntry {
232    /// Canonical relative path.
233    pub path: DirectoryPath,
234    /// Entry kind.
235    pub kind: DirectoryEntryKind,
236    /// Optional content id or digest for file-like entries.
237    pub content_id: Option<String>,
238    /// Portable and platform-specific metadata.
239    pub metadata: DirectoryEntryMetadata,
240    /// Platform caveats attached to the metadata.
241    pub caveats: BTreeSet<MetadataCaveat>,
242}
243
244impl DirectoryManifestEntry {
245    /// Build one manifest entry.
246    #[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    /// Return true when kind, content, and metadata match.
282    #[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/// Directory manifest with deterministic path ordering.
299#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
300pub struct DirectoryManifest {
301    /// Manifest entries keyed by canonical relative path.
302    pub entries: BTreeMap<DirectoryPath, DirectoryManifestEntry>,
303    /// Path normalization rules used to build this manifest.
304    pub path_rules: PathNormalizationRules,
305}
306
307impl DirectoryManifest {
308    /// Create an empty manifest.
309    #[must_use]
310    pub fn new(path_rules: PathNormalizationRules) -> Self {
311        Self {
312            entries: BTreeMap::new(),
313            path_rules,
314        }
315    }
316
317    /// Insert one manifest entry.
318    ///
319    /// # Errors
320    ///
321    /// Returns [`DirectorySyncError::DuplicatePath`] when the canonical path
322    /// already exists.
323    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    /// Return case-insensitive collisions according to this manifest's rules.
332    #[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    /// Compute a deterministic root over paths, entry kinds, content ids, and metadata.
351    #[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    /// Build an early-usability report for verified directory metadata and small files.
375    #[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/// High-level directory operation mode.
457#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
458pub enum DirectorySyncMode {
459    /// Copy source entries into an empty or unrelated destination without deletes.
460    SendOnly,
461    /// Sync source changes while preserving unrelated destination entries.
462    Sync,
463    /// Make destination match source when destructive delete policy allows it.
464    Mirror,
465    /// Watch-mode semantics: report changes without implicit destructive action.
466    Watch,
467    /// Restore source state, quarantining conflicts by default.
468    Restore,
469}
470
471/// Policy for entries present at destination but not in source.
472#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
473pub enum DeletePolicy {
474    /// Never plan a local delete.
475    Never,
476    /// Emit a tombstone/skip record but do not delete bytes.
477    TombstoneOnly,
478    /// Plan delete only when explicit destructive authorization is present.
479    MirrorWhenExplicit,
480}
481
482/// Policy for conflicting destination entries.
483#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
484pub enum ConflictPolicy {
485    /// Keep destination content and report a conflict.
486    PreserveLocal,
487    /// Move the destination entry into quarantine when explicitly authorized.
488    Quarantine,
489    /// Overwrite only when explicit destructive authorization is present.
490    OverwriteWhenExplicit,
491}
492
493/// Policy for symlink entries.
494#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
495pub enum SymlinkPolicy {
496    /// Skip symlinks and record the reason.
497    Skip,
498    /// Preserve symlinks only when symlink authorization is explicit.
499    PreserveAsLinkWhenExplicit,
500    /// Materialize the symlink target only when explicitly authorized.
501    MaterializeTargetWhenExplicit,
502}
503
504/// Policy for permission metadata.
505#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
506pub enum PermissionPolicy {
507    /// Record permissions in manifests but do not change destination permissions.
508    RecordOnly,
509    /// Preserve read-only metadata without mode changes.
510    PreserveReadonly,
511    /// Apply mode changes only with explicit authorization.
512    PreserveModeWhenExplicit,
513}
514
515/// Policy for rename detection.
516#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
517pub enum RenamePolicy {
518    /// Detect renames by stable identity or content id.
519    DetectByStableIdentity,
520    /// Treat moved entries as delete plus create.
521    TreatAsDeleteCreate,
522}
523
524/// Explicit authorization gates for destructive or sensitive actions.
525#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
526pub struct DestructiveAuthorization {
527    /// Whether deletes may execute.
528    pub allow_delete: bool,
529    /// Whether overwrites may execute.
530    pub allow_overwrite: bool,
531    /// Whether permission changes may execute.
532    pub allow_permission_change: bool,
533    /// Whether symlinks may be created or materialized.
534    pub allow_symlink_materialization: bool,
535    /// Whether decisions are dry-run only.
536    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    /// Authorization that permits mirror deletes but still exposes dry-run visibility.
553    #[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/// Complete policy for planning a directory sync.
566#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
567pub struct DirectorySyncPolicy {
568    /// High-level sync mode.
569    pub mode: DirectorySyncMode,
570    /// Delete behavior.
571    pub delete_policy: DeletePolicy,
572    /// Conflict behavior.
573    pub conflict_policy: ConflictPolicy,
574    /// Symlink behavior.
575    pub symlink_policy: SymlinkPolicy,
576    /// Permission behavior.
577    pub permission_policy: PermissionPolicy,
578    /// Rename behavior.
579    pub rename_policy: RenamePolicy,
580    /// Explicit authorization gates.
581    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    /// Conservative send-only policy.
600    #[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    /// Explicit mirror policy for dry-run or apply previews.
620    #[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/// Planned operation kind for one path.
635#[derive(
636    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
637)]
638pub enum DirectorySyncAction {
639    /// Create a missing destination entry.
640    Create,
641    /// Update an existing destination entry.
642    Update,
643    /// Delete a destination entry absent from the source.
644    Delete,
645    /// Rename an existing destination entry to the source path.
646    Rename,
647    /// Preserve an existing destination entry.
648    Preserve,
649    /// Report an unresolved conflict.
650    Conflict,
651    /// Skip an unsupported or policy-denied entry.
652    Skip,
653    /// Quarantine a conflicting destination entry.
654    Quarantine,
655    /// Restore source metadata/content.
656    Restore,
657    /// Apply a permission-only change.
658    PermissionChange,
659    /// Create or materialize a symlink.
660    SymlinkMaterialize,
661}
662
663impl DirectorySyncAction {
664    /// Stable action code for logs and proof summaries.
665    #[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    /// Whether the action can destroy or change existing destination state.
683    #[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/// One dry-run-visible decision for a path.
698#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
699pub struct DirectorySyncDecision {
700    /// Destination/source path affected by the decision.
701    pub path: DirectoryPath,
702    /// Optional original path for rename decisions.
703    pub from_path: Option<DirectoryPath>,
704    /// Operation kind.
705    pub action: DirectorySyncAction,
706    /// Whether all required explicit authorization gates are present.
707    pub authorized: bool,
708    /// Whether the action would execute under current dry-run settings.
709    pub would_apply: bool,
710    /// Whether this decision is visible in dry-run/proof output.
711    pub dry_run_visible: bool,
712    /// Stable reason code.
713    pub reason: String,
714    /// Metadata caveats attached to the source/destination entry.
715    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/// Structured log record for one directory sync decision.
742#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
743pub struct DirectorySyncLogEntry {
744    /// Stable log schema.
745    pub schema_version: String,
746    /// Path affected by this decision.
747    pub path: String,
748    /// Optional original path for renames.
749    pub from_path: Option<String>,
750    /// Operation code.
751    pub action: String,
752    /// Whether the action is authorized.
753    pub authorized: bool,
754    /// Whether the action would apply now.
755    pub would_apply: bool,
756    /// Stable reason code.
757    pub reason: String,
758    /// Caveat codes.
759    pub caveats: Vec<String>,
760}
761
762/// Proof summary emitted by directory sync planning.
763#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
764pub struct DirectorySyncProofSummary {
765    /// Stable proof schema.
766    pub schema_version: String,
767    /// Sync mode used for the plan.
768    pub mode: DirectorySyncMode,
769    /// Policy tuple recorded for replay.
770    pub metadata_policy: String,
771    /// Whether every destructive decision is explicitly authorized.
772    pub destructive_actions_authorized: bool,
773    /// Paths skipped by policy.
774    pub skipped_paths: Vec<String>,
775    /// Conflict decisions recorded by path.
776    pub conflict_decisions: Vec<String>,
777    /// Destination tree root after applying executable decisions in the model.
778    pub final_tree_root: String,
779    /// Deterministic replay pointer for journal/proof bundles.
780    pub replay_pointer: String,
781}
782
783/// Full directory sync plan.
784#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
785pub struct DirectorySyncPlan {
786    /// Decisions in deterministic path/action order.
787    pub decisions: Vec<DirectorySyncDecision>,
788    /// Structured logs.
789    pub logs: Vec<DirectorySyncLogEntry>,
790    /// Proof summary for replay and audit bundles.
791    pub proof: DirectorySyncProofSummary,
792}
793
794/// Final commit state reported separately from directory early usability.
795#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
796pub enum DirectoryFinalCommitState {
797    /// The directory manifest is still being assembled or verified.
798    Pending,
799    /// The final directory manifest has committed.
800    Committed,
801}
802
803/// Usable-early state for a directory transfer.
804#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
805pub enum DirectoryEarlyUsabilityState {
806    /// No entries are visible under the current policy.
807    NoEntries,
808    /// Verified metadata can be surfaced, but no file content is visible yet.
809    MetadataAvailable,
810    /// One or more verified small files can be surfaced before final commit.
811    SmallFilesAvailable,
812    /// The directory has reached final committed state.
813    FinalCommitted,
814}
815
816/// Policy for surfacing directory entries before final commit.
817#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
818pub struct DirectoryEarlyUsabilityPolicy {
819    /// Largest regular file that may be exposed early when its content is verified.
820    pub max_small_file_bytes: u64,
821    /// Whether metadata may be exposed before final commit.
822    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    /// Build a policy that exposes verified files up to `max_small_file_bytes`.
836    #[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/// Per-entry early exposure state.
846#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
847pub enum DirectoryEarlyEntryState {
848    /// Only metadata is visible.
849    MetadataOnly,
850    /// Verified small-file content is visible.
851    SmallFileContent,
852    /// Metadata and content are withheld under the current policy.
853    Withheld,
854}
855
856/// Early exposure decision for one directory entry.
857#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
858pub struct DirectoryEarlyEntryExposure {
859    /// Canonical manifest path.
860    pub path: String,
861    /// Entry kind.
862    pub kind: DirectoryEntryKind,
863    /// Whether metadata can be surfaced.
864    pub metadata_visible: bool,
865    /// Whether file content can be surfaced.
866    pub content_visible: bool,
867    /// Entry content identifier, if any.
868    pub content_id: Option<String>,
869    /// Entry size in bytes, if known.
870    pub size_bytes: Option<u64>,
871    /// Early exposure state.
872    pub state: DirectoryEarlyEntryState,
873    /// Stable reason for the decision.
874    pub reason: String,
875    /// Caveat codes callers must surface with this entry.
876    pub caveats: Vec<String>,
877}
878
879/// Directory early-usability report for SDK/CLI/proof artifacts.
880#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
881pub struct DirectoryEarlyUsabilityReport {
882    /// Stable report schema.
883    pub schema_version: String,
884    /// Current usable-early state.
885    pub usability_state: DirectoryEarlyUsabilityState,
886    /// Final commit state reported independently from early usability.
887    pub final_commit_state: DirectoryFinalCommitState,
888    /// Manifest tree root for replay and proof artifacts.
889    pub manifest_tree_root: String,
890    /// Deterministic replay pointer.
891    pub replay_pointer: String,
892    /// Paths whose metadata is visible.
893    pub metadata_paths: Vec<String>,
894    /// Paths whose verified small-file content is visible.
895    pub small_file_paths: Vec<String>,
896    /// Content-bearing paths withheld under the current policy.
897    pub withheld_content_paths: Vec<String>,
898    /// Per-entry exposure decisions.
899    pub entries: Vec<DirectoryEarlyEntryExposure>,
900    /// Safety caveats callers must show before early consumption.
901    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/// Directory sync model errors.
976#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
977pub enum DirectorySyncError {
978    /// Path was empty.
979    #[error("directory path is empty")]
980    EmptyPath,
981    /// Path normalized to the manifest root.
982    #[error("directory path normalizes to root")]
983    RootPath,
984    /// Absolute path was rejected.
985    #[error("absolute path rejected: {0}")]
986    AbsolutePath(String),
987    /// Parent traversal was rejected.
988    #[error("parent traversal rejected: {0}")]
989    ParentTraversal(String),
990    /// Path was invalid for the given rules.
991    #[error("invalid path {path}: {reason}")]
992    InvalidPath {
993        /// Raw path.
994        path: String,
995        /// Stable reason.
996        reason: &'static str,
997    },
998    /// Duplicate canonical path.
999    #[error("duplicate manifest path: {0}")]
1000    DuplicatePath(DirectoryPath),
1001}
1002
1003/// Plan a directory sync without mutating the filesystem.
1004#[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}