use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
pub const DIRECTORY_SYNC_PROOF_SCHEMA: &str = "asupersync.atp.directory-sync.proof.v1";
pub const DIRECTORY_SYNC_LOG_SCHEMA: &str = "asupersync.atp.directory-sync.log.v1";
pub const DIRECTORY_EARLY_USABILITY_SCHEMA: &str = "asupersync.atp.directory-early-usability.v1";
const TREE_ROOT_DOMAIN: &[u8] = b"asupersync.atp.directory-sync.tree-root.v1\0";
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PathNormalizationRules {
pub case_sensitive: bool,
pub normalize_backslashes: bool,
pub unicode_normalized: bool,
pub reject_absolute_paths: bool,
}
impl Default for PathNormalizationRules {
fn default() -> Self {
Self {
case_sensitive: true,
normalize_backslashes: true,
unicode_normalized: false,
reject_absolute_paths: true,
}
}
}
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
pub struct DirectoryPath(String);
impl DirectoryPath {
pub fn normalize(raw: &str, rules: PathNormalizationRules) -> Result<Self, DirectorySyncError> {
if raw.is_empty() {
return Err(DirectorySyncError::EmptyPath);
}
if raw.as_bytes().contains(&0) {
return Err(DirectorySyncError::InvalidPath {
path: raw.to_string(),
reason: "nul byte",
});
}
let normalized_separators = if rules.normalize_backslashes {
raw.replace('\\', "/")
} else {
raw.to_string()
};
if rules.reject_absolute_paths
&& (normalized_separators.starts_with('/')
|| normalized_separators
.as_bytes()
.get(1)
.is_some_and(|byte| *byte == b':'))
{
return Err(DirectorySyncError::AbsolutePath(raw.to_string()));
}
let mut parts = Vec::new();
for part in normalized_separators.split('/') {
match part {
"" | "." => {}
".." => {
return Err(DirectorySyncError::ParentTraversal(raw.to_string()));
}
clean => parts.push(clean),
}
}
if parts.is_empty() {
return Err(DirectorySyncError::RootPath);
}
Ok(Self(parts.join("/")))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn case_key(&self, rules: PathNormalizationRules) -> String {
if rules.case_sensitive {
self.0.clone()
} else {
self.0.to_lowercase()
}
}
}
impl fmt::Display for DirectoryPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
pub enum DirectoryEntryKind {
File,
Directory,
Symlink,
HardLink,
SparseFile,
}
impl DirectoryEntryKind {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::File => "file",
Self::Directory => "directory",
Self::Symlink => "symlink",
Self::HardLink => "hard_link",
Self::SparseFile => "sparse_file",
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
pub enum MetadataCaveat {
UnixPermissions,
WindowsAttributes,
TimestampResolution,
SymlinkSupport,
HardLinkSupport,
SparseMetadata,
CaseSensitivity,
PathNormalization,
}
impl MetadataCaveat {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::UnixPermissions => "unix_permissions",
Self::WindowsAttributes => "windows_attributes",
Self::TimestampResolution => "timestamp_resolution",
Self::SymlinkSupport => "symlink_support",
Self::HardLinkSupport => "hard_link_support",
Self::SparseMetadata => "sparse_metadata",
Self::CaseSensitivity => "case_sensitivity",
Self::PathNormalization => "path_normalization",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
pub struct DirectoryEntryMetadata {
pub size_bytes: Option<u64>,
pub unix_mode: Option<u32>,
pub windows_attributes: Option<u32>,
pub modified_epoch_micros: Option<i64>,
pub symlink_target: Option<String>,
pub hard_link_group: Option<String>,
pub sparse_summary: Option<String>,
pub stable_identity: Option<String>,
}
impl DirectoryEntryMetadata {
#[must_use]
pub fn with_identity(identity: impl Into<String>) -> Self {
Self {
stable_identity: Some(identity.into()),
..Self::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DirectoryManifestEntry {
pub path: DirectoryPath,
pub kind: DirectoryEntryKind,
pub content_id: Option<String>,
pub metadata: DirectoryEntryMetadata,
pub caveats: BTreeSet<MetadataCaveat>,
}
impl DirectoryManifestEntry {
#[must_use]
pub fn new(
path: DirectoryPath,
kind: DirectoryEntryKind,
content_id: Option<String>,
metadata: DirectoryEntryMetadata,
) -> Self {
let mut caveats = BTreeSet::new();
if metadata.unix_mode.is_some() {
caveats.insert(MetadataCaveat::UnixPermissions);
}
if metadata.windows_attributes.is_some() {
caveats.insert(MetadataCaveat::WindowsAttributes);
}
if metadata.modified_epoch_micros.is_some() {
caveats.insert(MetadataCaveat::TimestampResolution);
}
if kind == DirectoryEntryKind::Symlink || metadata.symlink_target.is_some() {
caveats.insert(MetadataCaveat::SymlinkSupport);
}
if kind == DirectoryEntryKind::HardLink || metadata.hard_link_group.is_some() {
caveats.insert(MetadataCaveat::HardLinkSupport);
}
if kind == DirectoryEntryKind::SparseFile || metadata.sparse_summary.is_some() {
caveats.insert(MetadataCaveat::SparseMetadata);
}
Self {
path,
kind,
content_id,
metadata,
caveats,
}
}
#[must_use]
pub fn semantically_matches(&self, other: &Self) -> bool {
self.kind == other.kind
&& self.content_id == other.content_id
&& self.metadata == other.metadata
&& self.caveats == other.caveats
}
fn stable_identity(&self) -> Option<&str> {
self.metadata
.stable_identity
.as_deref()
.or(self.content_id.as_deref())
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DirectoryManifest {
pub entries: BTreeMap<DirectoryPath, DirectoryManifestEntry>,
pub path_rules: PathNormalizationRules,
}
impl DirectoryManifest {
#[must_use]
pub fn new(path_rules: PathNormalizationRules) -> Self {
Self {
entries: BTreeMap::new(),
path_rules,
}
}
pub fn insert(&mut self, entry: DirectoryManifestEntry) -> Result<(), DirectorySyncError> {
if self.entries.contains_key(&entry.path) {
return Err(DirectorySyncError::DuplicatePath(entry.path));
}
self.entries.insert(entry.path.clone(), entry);
Ok(())
}
#[must_use]
pub fn case_conflicts(&self) -> Vec<Vec<DirectoryPath>> {
let mut groups: BTreeMap<String, Vec<DirectoryPath>> = BTreeMap::new();
for path in self.entries.keys() {
groups
.entry(path.case_key(PathNormalizationRules {
case_sensitive: false,
..self.path_rules
}))
.or_default()
.push(path.clone());
}
groups
.into_values()
.filter(|paths| paths.len() > 1)
.collect()
}
#[must_use]
pub fn tree_root(&self) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(TREE_ROOT_DOMAIN);
hasher.update([u8::from(self.path_rules.case_sensitive)]);
for (path, entry) in &self.entries {
hasher.update(path.as_str().as_bytes());
hasher.update([0]);
hasher.update(entry.kind.code().as_bytes());
hasher.update([0]);
if let Some(content_id) = &entry.content_id {
hasher.update(content_id.as_bytes());
}
hasher.update([0]);
hash_metadata(&mut hasher, &entry.metadata);
for caveat in &entry.caveats {
hasher.update(caveat.code().as_bytes());
hasher.update([0]);
}
}
hasher.finalize().into()
}
#[must_use]
pub fn early_usability_report(
&self,
verified_content_ids: &BTreeSet<String>,
policy: DirectoryEarlyUsabilityPolicy,
final_commit_state: DirectoryFinalCommitState,
replay_pointer: impl Into<String>,
) -> DirectoryEarlyUsabilityReport {
let mut metadata_paths = Vec::new();
let mut small_file_paths = Vec::new();
let mut withheld_content_paths = Vec::new();
let metadata_visible = policy.expose_metadata_before_final
|| final_commit_state == DirectoryFinalCommitState::Committed;
let entries = self
.entries
.values()
.map(|entry| {
let exposure = early_entry_exposure(
entry,
verified_content_ids,
policy,
final_commit_state,
metadata_visible,
);
if exposure.metadata_visible {
metadata_paths.push(exposure.path.clone());
}
if exposure.content_visible {
small_file_paths.push(exposure.path.clone());
} else if entry.content_id.is_some() {
withheld_content_paths.push(exposure.path.clone());
}
exposure
})
.collect::<Vec<_>>();
let mut safety_caveats = Vec::new();
if final_commit_state == DirectoryFinalCommitState::Pending {
safety_caveats.push(
"final directory commit not complete; expose early entries separately".into(),
);
}
if !metadata_visible && !self.entries.is_empty() {
safety_caveats
.push("metadata exposure is disabled until final directory commit".into());
}
if !withheld_content_paths.is_empty() {
safety_caveats.push(
"some file content is withheld until verification or final commit policy allows it"
.into(),
);
}
let usability_state = if final_commit_state == DirectoryFinalCommitState::Committed {
DirectoryEarlyUsabilityState::FinalCommitted
} else if !small_file_paths.is_empty() {
DirectoryEarlyUsabilityState::SmallFilesAvailable
} else if !metadata_paths.is_empty() {
DirectoryEarlyUsabilityState::MetadataAvailable
} else {
DirectoryEarlyUsabilityState::NoEntries
};
DirectoryEarlyUsabilityReport {
schema_version: DIRECTORY_EARLY_USABILITY_SCHEMA.to_string(),
usability_state,
final_commit_state,
manifest_tree_root: hex::encode(self.tree_root()),
replay_pointer: replay_pointer.into(),
metadata_paths,
small_file_paths,
withheld_content_paths,
entries,
safety_caveats,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DirectorySyncMode {
SendOnly,
Sync,
Mirror,
Watch,
Restore,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DeletePolicy {
Never,
TombstoneOnly,
MirrorWhenExplicit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ConflictPolicy {
PreserveLocal,
Quarantine,
OverwriteWhenExplicit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SymlinkPolicy {
Skip,
PreserveAsLinkWhenExplicit,
MaterializeTargetWhenExplicit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum PermissionPolicy {
RecordOnly,
PreserveReadonly,
PreserveModeWhenExplicit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum RenamePolicy {
DetectByStableIdentity,
TreatAsDeleteCreate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DestructiveAuthorization {
pub allow_delete: bool,
pub allow_overwrite: bool,
pub allow_permission_change: bool,
pub allow_symlink_materialization: bool,
pub dry_run: bool,
}
impl Default for DestructiveAuthorization {
fn default() -> Self {
Self {
allow_delete: false,
allow_overwrite: false,
allow_permission_change: false,
allow_symlink_materialization: false,
dry_run: true,
}
}
}
impl DestructiveAuthorization {
#[must_use]
pub const fn explicit_mirror_apply() -> Self {
Self {
allow_delete: true,
allow_overwrite: true,
allow_permission_change: true,
allow_symlink_materialization: true,
dry_run: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DirectorySyncPolicy {
pub mode: DirectorySyncMode,
pub delete_policy: DeletePolicy,
pub conflict_policy: ConflictPolicy,
pub symlink_policy: SymlinkPolicy,
pub permission_policy: PermissionPolicy,
pub rename_policy: RenamePolicy,
pub authorization: DestructiveAuthorization,
}
impl Default for DirectorySyncPolicy {
fn default() -> Self {
Self {
mode: DirectorySyncMode::Sync,
delete_policy: DeletePolicy::Never,
conflict_policy: ConflictPolicy::PreserveLocal,
symlink_policy: SymlinkPolicy::Skip,
permission_policy: PermissionPolicy::RecordOnly,
rename_policy: RenamePolicy::DetectByStableIdentity,
authorization: DestructiveAuthorization::default(),
}
}
}
impl DirectorySyncPolicy {
#[must_use]
pub const fn send_only() -> Self {
Self {
mode: DirectorySyncMode::SendOnly,
delete_policy: DeletePolicy::Never,
conflict_policy: ConflictPolicy::PreserveLocal,
symlink_policy: SymlinkPolicy::Skip,
permission_policy: PermissionPolicy::RecordOnly,
rename_policy: RenamePolicy::DetectByStableIdentity,
authorization: DestructiveAuthorization {
allow_delete: false,
allow_overwrite: false,
allow_permission_change: false,
allow_symlink_materialization: false,
dry_run: true,
},
}
}
#[must_use]
pub const fn mirror_with_authorization(authorization: DestructiveAuthorization) -> Self {
Self {
mode: DirectorySyncMode::Mirror,
delete_policy: DeletePolicy::MirrorWhenExplicit,
conflict_policy: ConflictPolicy::OverwriteWhenExplicit,
symlink_policy: SymlinkPolicy::PreserveAsLinkWhenExplicit,
permission_policy: PermissionPolicy::PreserveModeWhenExplicit,
rename_policy: RenamePolicy::DetectByStableIdentity,
authorization,
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
pub enum DirectorySyncAction {
Create,
Update,
Delete,
Rename,
Preserve,
Conflict,
Skip,
Quarantine,
Restore,
PermissionChange,
SymlinkMaterialize,
}
impl DirectorySyncAction {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Create => "create",
Self::Update => "update",
Self::Delete => "delete",
Self::Rename => "rename",
Self::Preserve => "preserve",
Self::Conflict => "conflict",
Self::Skip => "skip",
Self::Quarantine => "quarantine",
Self::Restore => "restore",
Self::PermissionChange => "permission_change",
Self::SymlinkMaterialize => "symlink_materialize",
}
}
#[must_use]
pub const fn requires_explicit_authorization(self) -> bool {
matches!(
self,
Self::Delete
| Self::Update
| Self::Quarantine
| Self::Restore
| Self::PermissionChange
| Self::SymlinkMaterialize
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DirectorySyncDecision {
pub path: DirectoryPath,
pub from_path: Option<DirectoryPath>,
pub action: DirectorySyncAction,
pub authorized: bool,
pub would_apply: bool,
pub dry_run_visible: bool,
pub reason: String,
pub caveats: BTreeSet<MetadataCaveat>,
}
impl DirectorySyncDecision {
fn new(
path: DirectoryPath,
from_path: Option<DirectoryPath>,
action: DirectorySyncAction,
authorized: bool,
dry_run: bool,
reason: impl Into<String>,
caveats: BTreeSet<MetadataCaveat>,
) -> Self {
Self {
path,
from_path,
action,
authorized,
would_apply: authorized && !dry_run,
dry_run_visible: true,
reason: reason.into(),
caveats,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DirectorySyncLogEntry {
pub schema_version: String,
pub path: String,
pub from_path: Option<String>,
pub action: String,
pub authorized: bool,
pub would_apply: bool,
pub reason: String,
pub caveats: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DirectorySyncProofSummary {
pub schema_version: String,
pub mode: DirectorySyncMode,
pub metadata_policy: String,
pub destructive_actions_authorized: bool,
pub skipped_paths: Vec<String>,
pub conflict_decisions: Vec<String>,
pub final_tree_root: String,
pub replay_pointer: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DirectorySyncPlan {
pub decisions: Vec<DirectorySyncDecision>,
pub logs: Vec<DirectorySyncLogEntry>,
pub proof: DirectorySyncProofSummary,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DirectoryFinalCommitState {
Pending,
Committed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DirectoryEarlyUsabilityState {
NoEntries,
MetadataAvailable,
SmallFilesAvailable,
FinalCommitted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DirectoryEarlyUsabilityPolicy {
pub max_small_file_bytes: u64,
pub expose_metadata_before_final: bool,
}
impl Default for DirectoryEarlyUsabilityPolicy {
fn default() -> Self {
Self {
max_small_file_bytes: 1024 * 1024,
expose_metadata_before_final: true,
}
}
}
impl DirectoryEarlyUsabilityPolicy {
#[must_use]
pub const fn small_files_up_to(max_small_file_bytes: u64) -> Self {
Self {
max_small_file_bytes,
expose_metadata_before_final: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DirectoryEarlyEntryState {
MetadataOnly,
SmallFileContent,
Withheld,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DirectoryEarlyEntryExposure {
pub path: String,
pub kind: DirectoryEntryKind,
pub metadata_visible: bool,
pub content_visible: bool,
pub content_id: Option<String>,
pub size_bytes: Option<u64>,
pub state: DirectoryEarlyEntryState,
pub reason: String,
pub caveats: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DirectoryEarlyUsabilityReport {
pub schema_version: String,
pub usability_state: DirectoryEarlyUsabilityState,
pub final_commit_state: DirectoryFinalCommitState,
pub manifest_tree_root: String,
pub replay_pointer: String,
pub metadata_paths: Vec<String>,
pub small_file_paths: Vec<String>,
pub withheld_content_paths: Vec<String>,
pub entries: Vec<DirectoryEarlyEntryExposure>,
pub safety_caveats: Vec<String>,
}
fn early_entry_exposure(
entry: &DirectoryManifestEntry,
verified_content_ids: &BTreeSet<String>,
policy: DirectoryEarlyUsabilityPolicy,
final_commit_state: DirectoryFinalCommitState,
metadata_visible: bool,
) -> DirectoryEarlyEntryExposure {
let content_verified = entry
.content_id
.as_ref()
.is_some_and(|content_id| verified_content_ids.contains(content_id))
|| final_commit_state == DirectoryFinalCommitState::Committed;
let size = entry.metadata.size_bytes;
let is_regular_file = entry.kind == DirectoryEntryKind::File;
let is_small_file = size.is_some_and(|size| size <= policy.max_small_file_bytes);
let content_visible = metadata_visible && is_regular_file && content_verified && is_small_file;
let (state, reason) = if content_visible {
(
DirectoryEarlyEntryState::SmallFileContent,
"verified_small_file",
)
} else if !metadata_visible {
(
DirectoryEarlyEntryState::Withheld,
"metadata_withheld_until_final_commit",
)
} else if entry.content_id.is_none() {
(
DirectoryEarlyEntryState::MetadataOnly,
"metadata_only_no_content_id",
)
} else if !is_regular_file {
(
DirectoryEarlyEntryState::MetadataOnly,
"metadata_only_unsupported_content_kind",
)
} else if size.is_none() {
(
DirectoryEarlyEntryState::MetadataOnly,
"metadata_only_unknown_size",
)
} else if !content_verified {
(
DirectoryEarlyEntryState::MetadataOnly,
"metadata_only_content_not_verified",
)
} else {
(
DirectoryEarlyEntryState::MetadataOnly,
"metadata_only_file_exceeds_small_file_policy",
)
};
DirectoryEarlyEntryExposure {
path: entry.path.to_string(),
kind: entry.kind,
metadata_visible,
content_visible,
content_id: entry.content_id.clone(),
size_bytes: entry.metadata.size_bytes,
state,
reason: reason.to_string(),
caveats: entry
.caveats
.iter()
.map(|caveat| caveat.code().to_string())
.collect(),
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DirectorySyncError {
#[error("directory path is empty")]
EmptyPath,
#[error("directory path normalizes to root")]
RootPath,
#[error("absolute path rejected: {0}")]
AbsolutePath(String),
#[error("parent traversal rejected: {0}")]
ParentTraversal(String),
#[error("invalid path {path}: {reason}")]
InvalidPath {
path: String,
reason: &'static str,
},
#[error("duplicate manifest path: {0}")]
DuplicatePath(DirectoryPath),
}
#[must_use]
pub fn plan_directory_sync(
source: &DirectoryManifest,
destination: &DirectoryManifest,
policy: DirectorySyncPolicy,
) -> DirectorySyncPlan {
let mut decisions = Vec::new();
let mut renamed_from_paths = BTreeSet::new();
let destination_by_identity = identity_index(destination);
let source_paths = source.entries.keys().cloned().collect::<BTreeSet<_>>();
append_case_conflict_decisions(source, destination, policy, &mut decisions);
append_identity_conflict_decisions(source, destination, policy, &mut decisions);
for (path, source_entry) in &source.entries {
if source_entry.kind == DirectoryEntryKind::Symlink {
append_symlink_decision(
source_entry,
destination.entries.get(path),
policy,
&mut decisions,
);
continue;
}
match destination.entries.get(path) {
Some(destination_entry) if source_entry.semantically_matches(destination_entry) => {
decisions.push(decision(
path.clone(),
None,
DirectorySyncAction::Preserve,
policy,
"already_matches",
source_entry.caveats.clone(),
));
}
Some(destination_entry) => {
append_existing_path_decision(
source_entry,
destination_entry,
policy,
&mut decisions,
);
}
None => {
if let Some(from_path) = detect_rename(
source_entry,
&destination_by_identity,
&source_paths,
policy,
) {
renamed_from_paths.insert(from_path.clone());
decisions.push(decision(
path.clone(),
Some(from_path),
DirectorySyncAction::Rename,
policy,
"stable_identity_rename",
source_entry.caveats.clone(),
));
} else {
decisions.push(decision(
path.clone(),
None,
create_or_restore_action(policy),
policy,
"missing_destination_entry",
source_entry.caveats.clone(),
));
}
}
}
}
for (path, destination_entry) in &destination.entries {
if source.entries.contains_key(path) || renamed_from_paths.contains(path) {
continue;
}
decisions.push(delete_or_preserve_decision(path, destination_entry, policy));
}
decisions.sort_by(|left, right| {
left.path
.cmp(&right.path)
.then(left.action.cmp(&right.action))
.then(left.from_path.cmp(&right.from_path))
});
let logs = decisions.iter().map(log_entry).collect::<Vec<_>>();
let proof = proof_summary(source, destination, policy, &decisions);
DirectorySyncPlan {
decisions,
logs,
proof,
}
}
fn append_case_conflict_decisions(
source: &DirectoryManifest,
destination: &DirectoryManifest,
policy: DirectorySyncPolicy,
decisions: &mut Vec<DirectorySyncDecision>,
) {
for group in source
.case_conflicts()
.into_iter()
.chain(destination.case_conflicts())
{
for path in group {
decisions.push(decision(
path,
None,
DirectorySyncAction::Conflict,
policy,
"case_conflict",
BTreeSet::from([MetadataCaveat::CaseSensitivity]),
));
}
}
}
fn append_identity_conflict_decisions(
source: &DirectoryManifest,
destination: &DirectoryManifest,
policy: DirectorySyncPolicy,
decisions: &mut Vec<DirectorySyncDecision>,
) {
let mut conflicted_paths = identity_conflicts(source);
conflicted_paths.extend(identity_conflicts(destination));
for path in conflicted_paths {
decisions.push(decision(
path,
None,
DirectorySyncAction::Conflict,
policy,
"stable_identity_conflict",
BTreeSet::new(),
));
}
}
fn append_symlink_decision(
source_entry: &DirectoryManifestEntry,
destination_entry: Option<&DirectoryManifestEntry>,
policy: DirectorySyncPolicy,
decisions: &mut Vec<DirectorySyncDecision>,
) {
let action = match policy.symlink_policy {
SymlinkPolicy::Skip => DirectorySyncAction::Skip,
SymlinkPolicy::PreserveAsLinkWhenExplicit
| SymlinkPolicy::MaterializeTargetWhenExplicit => DirectorySyncAction::SymlinkMaterialize,
};
let reason = if destination_entry.is_some_and(|entry| entry.semantically_matches(source_entry))
{
"symlink_already_matches"
} else if action == DirectorySyncAction::Skip {
"symlink_policy_skip"
} else {
"symlink_requires_explicit_policy"
};
decisions.push(decision(
source_entry.path.clone(),
None,
action,
policy,
reason,
source_entry.caveats.clone(),
));
}
fn append_existing_path_decision(
source_entry: &DirectoryManifestEntry,
destination_entry: &DirectoryManifestEntry,
policy: DirectorySyncPolicy,
decisions: &mut Vec<DirectorySyncDecision>,
) {
if permissions_differ(source_entry, destination_entry)
&& content_and_kind_match(source_entry, destination_entry)
{
decisions.push(decision(
source_entry.path.clone(),
None,
DirectorySyncAction::PermissionChange,
policy,
"metadata_permission_delta",
source_entry.caveats.clone(),
));
return;
}
let action = match policy.conflict_policy {
ConflictPolicy::PreserveLocal => DirectorySyncAction::Conflict,
ConflictPolicy::Quarantine => DirectorySyncAction::Quarantine,
ConflictPolicy::OverwriteWhenExplicit => match policy.mode {
DirectorySyncMode::Restore => DirectorySyncAction::Restore,
_ => DirectorySyncAction::Update,
},
};
decisions.push(decision(
source_entry.path.clone(),
None,
action,
policy,
"destination_differs",
source_entry.caveats.clone(),
));
}
fn delete_or_preserve_decision(
path: &DirectoryPath,
destination_entry: &DirectoryManifestEntry,
policy: DirectorySyncPolicy,
) -> DirectorySyncDecision {
let (action, reason) = match (policy.mode, policy.delete_policy) {
(DirectorySyncMode::Mirror, DeletePolicy::MirrorWhenExplicit) => {
(DirectorySyncAction::Delete, "mirror_delete")
}
(_, DeletePolicy::TombstoneOnly) => (DirectorySyncAction::Skip, "tombstone_only_delete"),
_ => (DirectorySyncAction::Preserve, "delete_not_allowed"),
};
decision(
path.clone(),
None,
action,
policy,
reason,
destination_entry.caveats.clone(),
)
}
fn decision(
path: DirectoryPath,
from_path: Option<DirectoryPath>,
action: DirectorySyncAction,
policy: DirectorySyncPolicy,
reason: impl Into<String>,
caveats: BTreeSet<MetadataCaveat>,
) -> DirectorySyncDecision {
DirectorySyncDecision::new(
path,
from_path,
action,
action_authorized(action, policy),
policy.authorization.dry_run,
reason,
caveats,
)
}
fn action_authorized(action: DirectorySyncAction, policy: DirectorySyncPolicy) -> bool {
match action {
DirectorySyncAction::Delete => {
policy.delete_policy == DeletePolicy::MirrorWhenExplicit
&& policy.authorization.allow_delete
}
DirectorySyncAction::Update | DirectorySyncAction::Restore => {
policy.conflict_policy == ConflictPolicy::OverwriteWhenExplicit
&& policy.authorization.allow_overwrite
}
DirectorySyncAction::Quarantine => {
policy.conflict_policy == ConflictPolicy::Quarantine
&& policy.authorization.allow_overwrite
}
DirectorySyncAction::PermissionChange => {
policy.permission_policy == PermissionPolicy::PreserveModeWhenExplicit
&& policy.authorization.allow_permission_change
}
DirectorySyncAction::SymlinkMaterialize => {
policy.symlink_policy != SymlinkPolicy::Skip
&& policy.authorization.allow_symlink_materialization
}
DirectorySyncAction::Create
| DirectorySyncAction::Rename
| DirectorySyncAction::Preserve
| DirectorySyncAction::Conflict
| DirectorySyncAction::Skip => true,
}
}
fn create_or_restore_action(policy: DirectorySyncPolicy) -> DirectorySyncAction {
match policy.mode {
DirectorySyncMode::Restore => DirectorySyncAction::Restore,
_ => DirectorySyncAction::Create,
}
}
fn identity_index(manifest: &DirectoryManifest) -> BTreeMap<String, DirectoryPath> {
identity_groups(manifest)
.into_iter()
.filter_map(|(identity, paths)| {
if paths.len() == 1 {
paths.into_iter().next().map(|path| (identity, path))
} else {
None
}
})
.collect()
}
fn identity_conflicts(manifest: &DirectoryManifest) -> BTreeSet<DirectoryPath> {
identity_groups(manifest)
.into_values()
.filter(|paths| paths.len() > 1)
.flatten()
.collect()
}
fn identity_groups(manifest: &DirectoryManifest) -> BTreeMap<String, Vec<DirectoryPath>> {
let mut groups = BTreeMap::new();
for (path, entry) in &manifest.entries {
if let Some(identity) = entry.stable_identity() {
groups
.entry(identity.to_string())
.or_insert_with(Vec::new)
.push(path.clone());
}
}
groups
}
fn detect_rename(
source_entry: &DirectoryManifestEntry,
destination_by_identity: &BTreeMap<String, DirectoryPath>,
source_paths: &BTreeSet<DirectoryPath>,
policy: DirectorySyncPolicy,
) -> Option<DirectoryPath> {
if policy.rename_policy != RenamePolicy::DetectByStableIdentity {
return None;
}
let identity = source_entry.stable_identity()?;
let destination_path = destination_by_identity.get(identity)?;
(!source_paths.contains(destination_path)).then(|| destination_path.clone())
}
fn permissions_differ(
source_entry: &DirectoryManifestEntry,
destination_entry: &DirectoryManifestEntry,
) -> bool {
source_entry.metadata.unix_mode != destination_entry.metadata.unix_mode
|| source_entry.metadata.windows_attributes != destination_entry.metadata.windows_attributes
}
fn content_and_kind_match(
source_entry: &DirectoryManifestEntry,
destination_entry: &DirectoryManifestEntry,
) -> bool {
source_entry.kind == destination_entry.kind
&& source_entry.content_id == destination_entry.content_id
}
fn log_entry(decision: &DirectorySyncDecision) -> DirectorySyncLogEntry {
DirectorySyncLogEntry {
schema_version: DIRECTORY_SYNC_LOG_SCHEMA.to_string(),
path: decision.path.to_string(),
from_path: decision.from_path.as_ref().map(ToString::to_string),
action: decision.action.code().to_string(),
authorized: decision.authorized,
would_apply: decision.would_apply,
reason: decision.reason.clone(),
caveats: decision
.caveats
.iter()
.map(|caveat| caveat.code().to_string())
.collect(),
}
}
fn proof_summary(
source: &DirectoryManifest,
destination: &DirectoryManifest,
policy: DirectorySyncPolicy,
decisions: &[DirectorySyncDecision],
) -> DirectorySyncProofSummary {
let destructive_actions_authorized = decisions
.iter()
.filter(|decision| decision.action.requires_explicit_authorization())
.all(|decision| decision.authorized && decision.dry_run_visible);
let skipped_paths = decisions
.iter()
.filter(|decision| decision.action == DirectorySyncAction::Skip)
.map(|decision| decision.path.to_string())
.collect();
let conflict_decisions = decisions
.iter()
.filter(|decision| {
matches!(
decision.action,
DirectorySyncAction::Conflict | DirectorySyncAction::Quarantine
)
})
.map(|decision| format!("{}:{}", decision.path, decision.reason))
.collect();
DirectorySyncProofSummary {
schema_version: DIRECTORY_SYNC_PROOF_SCHEMA.to_string(),
mode: policy.mode,
metadata_policy: metadata_policy_code(policy),
destructive_actions_authorized,
skipped_paths,
conflict_decisions,
final_tree_root: hex::encode(projected_tree_root(source, destination, decisions)),
replay_pointer: replay_pointer(source, destination, decisions),
}
}
fn metadata_policy_code(policy: DirectorySyncPolicy) -> String {
format!(
"delete={:?};conflict={:?};symlink={:?};permission={:?};rename={:?};dry_run={}",
policy.delete_policy,
policy.conflict_policy,
policy.symlink_policy,
policy.permission_policy,
policy.rename_policy,
policy.authorization.dry_run
)
}
fn projected_tree_root(
source: &DirectoryManifest,
destination: &DirectoryManifest,
decisions: &[DirectorySyncDecision],
) -> [u8; 32] {
let mut projected = destination.clone();
for decision in decisions {
if !decision.would_apply {
continue;
}
match decision.action {
DirectorySyncAction::Create
| DirectorySyncAction::Update
| DirectorySyncAction::Restore
| DirectorySyncAction::SymlinkMaterialize => {
if let Some(entry) = source.entries.get(&decision.path) {
projected
.entries
.insert(decision.path.clone(), entry.clone());
}
}
DirectorySyncAction::Delete | DirectorySyncAction::Quarantine => {
projected.entries.remove(&decision.path);
}
DirectorySyncAction::Rename => {
if let Some(from_path) = &decision.from_path {
projected.entries.remove(from_path);
}
if let Some(entry) = source.entries.get(&decision.path) {
projected
.entries
.insert(decision.path.clone(), entry.clone());
}
}
DirectorySyncAction::PermissionChange => {
if let Some(entry) = source.entries.get(&decision.path) {
projected
.entries
.insert(decision.path.clone(), entry.clone());
}
}
DirectorySyncAction::Preserve
| DirectorySyncAction::Conflict
| DirectorySyncAction::Skip => {}
}
}
projected.tree_root()
}
fn replay_pointer(
source: &DirectoryManifest,
destination: &DirectoryManifest,
decisions: &[DirectorySyncDecision],
) -> String {
let mut hasher = Sha256::new();
hasher.update(b"asupersync.atp.directory-sync.replay.v1\0");
hasher.update(source.tree_root());
hasher.update(destination.tree_root());
for decision in decisions {
hasher.update(decision.path.as_str().as_bytes());
hasher.update(decision.action.code().as_bytes());
hasher.update([
u8::from(decision.authorized),
u8::from(decision.would_apply),
]);
}
format!("directory-sync:{}", hex::encode(hasher.finalize()))
}
fn hash_metadata(hasher: &mut Sha256, metadata: &DirectoryEntryMetadata) {
hash_opt_u64(hasher, metadata.size_bytes);
hash_opt_u32(hasher, metadata.unix_mode);
hash_opt_u32(hasher, metadata.windows_attributes);
hash_opt_i64(hasher, metadata.modified_epoch_micros);
hash_opt_str(hasher, metadata.symlink_target.as_deref());
hash_opt_str(hasher, metadata.hard_link_group.as_deref());
hash_opt_str(hasher, metadata.sparse_summary.as_deref());
hash_opt_str(hasher, metadata.stable_identity.as_deref());
}
fn hash_opt_u64(hasher: &mut Sha256, value: Option<u64>) {
if let Some(value) = value {
hasher.update(value.to_be_bytes());
}
hasher.update([0]);
}
fn hash_opt_u32(hasher: &mut Sha256, value: Option<u32>) {
if let Some(value) = value {
hasher.update(value.to_be_bytes());
}
hasher.update([0]);
}
fn hash_opt_i64(hasher: &mut Sha256, value: Option<i64>) {
if let Some(value) = value {
hasher.update(value.to_be_bytes());
}
hasher.update([0]);
}
fn hash_opt_str(hasher: &mut Sha256, value: Option<&str>) {
if let Some(value) = value {
hasher.update(value.as_bytes());
}
hasher.update([0]);
}
#[cfg(test)]
mod tests {
use super::*;
fn path(raw: &str) -> DirectoryPath {
DirectoryPath::normalize(raw, PathNormalizationRules::default()).expect("path")
}
fn file(raw: &str, content_id: &str) -> DirectoryManifestEntry {
DirectoryManifestEntry::new(
path(raw),
DirectoryEntryKind::File,
Some(content_id.to_string()),
DirectoryEntryMetadata::with_identity(content_id),
)
}
fn sized_file(raw: &str, content_id: &str, size_bytes: u64) -> DirectoryManifestEntry {
let mut metadata = DirectoryEntryMetadata::with_identity(content_id);
metadata.size_bytes = Some(size_bytes);
DirectoryManifestEntry::new(
path(raw),
DirectoryEntryKind::File,
Some(content_id.to_string()),
metadata,
)
}
fn manifest(entries: Vec<DirectoryManifestEntry>) -> DirectoryManifest {
let mut manifest = DirectoryManifest::new(PathNormalizationRules::default());
for entry in entries {
manifest.insert(entry).expect("insert");
}
manifest
}
#[test]
fn path_normalization_rejects_unsafe_paths() {
assert_eq!(path("a//./b\\c").as_str(), "a/b/c");
assert!(matches!(
DirectoryPath::normalize("../secret", PathNormalizationRules::default()),
Err(DirectorySyncError::ParentTraversal(_))
));
assert!(matches!(
DirectoryPath::normalize("/tmp/file", PathNormalizationRules::default()),
Err(DirectorySyncError::AbsolutePath(_))
));
}
#[test]
fn case_conflicts_are_classified() {
let source = manifest(vec![file("Readme.md", "a"), file("README.md", "b")]);
let plan = plan_directory_sync(
&source,
&DirectoryManifest::new(PathNormalizationRules::default()),
DirectorySyncPolicy::default(),
);
assert!(
plan.decisions
.iter()
.any(|decision| decision.reason == "case_conflict")
);
assert!(
plan.proof
.conflict_decisions
.iter()
.any(|item| item.contains("case_conflict"))
);
}
#[test]
fn rename_detection_uses_stable_identity() {
let source = manifest(vec![file("new/name.txt", "same")]);
let destination = manifest(vec![file("old/name.txt", "same")]);
let plan = plan_directory_sync(&source, &destination, DirectorySyncPolicy::default());
let rename = plan
.decisions
.iter()
.find(|decision| decision.action == DirectorySyncAction::Rename)
.expect("rename");
assert_eq!(
rename.from_path.as_ref().map(DirectoryPath::as_str),
Some("old/name.txt")
);
}
#[test]
fn rename_detection_does_not_plan_old_path_delete() {
let source = manifest(vec![file("new/name.txt", "same")]);
let destination = manifest(vec![file("old/name.txt", "same")]);
let plan = plan_directory_sync(
&source,
&destination,
DirectorySyncPolicy::mirror_with_authorization(
DestructiveAuthorization::explicit_mirror_apply(),
),
);
assert_eq!(plan.decisions.len(), 1);
assert_eq!(plan.decisions[0].action, DirectorySyncAction::Rename);
assert_eq!(plan.decisions[0].path.as_str(), "new/name.txt");
assert_eq!(
plan.decisions[0]
.from_path
.as_ref()
.map(DirectoryPath::as_str),
Some("old/name.txt")
);
}
#[test]
fn duplicate_stable_identity_blocks_rename_candidate() {
let source = manifest(vec![file("new/name.txt", "same")]);
let destination = manifest(vec![
file("old/one.txt", "same"),
file("old/two.txt", "same"),
]);
let plan = plan_directory_sync(&source, &destination, DirectorySyncPolicy::default());
assert!(
!plan
.decisions
.iter()
.any(|decision| decision.action == DirectorySyncAction::Rename)
);
assert_eq!(
plan.decisions
.iter()
.filter(|decision| decision.reason == "stable_identity_conflict")
.count(),
2
);
}
#[test]
fn symlink_policy_skips_by_default() {
let mut metadata = DirectoryEntryMetadata::default();
metadata.symlink_target = Some("target.txt".to_string());
let source = manifest(vec![DirectoryManifestEntry::new(
path("link.txt"),
DirectoryEntryKind::Symlink,
None,
metadata,
)]);
let plan = plan_directory_sync(
&source,
&DirectoryManifest::new(PathNormalizationRules::default()),
DirectorySyncPolicy::default(),
);
assert_eq!(plan.decisions[0].action, DirectorySyncAction::Skip);
assert_eq!(plan.proof.skipped_paths, vec!["link.txt"]);
}
#[test]
fn permission_changes_require_explicit_policy() {
let mut source_entry = file("run.sh", "script");
source_entry.metadata.unix_mode = Some(0o755);
let mut destination_entry = file("run.sh", "script");
destination_entry.metadata.unix_mode = Some(0o644);
let source = manifest(vec![source_entry]);
let destination = manifest(vec![destination_entry]);
let plan = plan_directory_sync(&source, &destination, DirectorySyncPolicy::default());
assert_eq!(
plan.decisions[0].action,
DirectorySyncAction::PermissionChange
);
assert!(!plan.decisions[0].authorized);
assert!(!plan.decisions[0].would_apply);
}
#[test]
fn mirror_delete_needs_authorization_and_respects_dry_run() {
let source = DirectoryManifest::new(PathNormalizationRules::default());
let destination = manifest(vec![file("stale.txt", "old")]);
let policy = DirectorySyncPolicy::mirror_with_authorization(DestructiveAuthorization {
allow_delete: true,
dry_run: true,
..DestructiveAuthorization::default()
});
let plan = plan_directory_sync(&source, &destination, policy);
assert_eq!(plan.decisions[0].action, DirectorySyncAction::Delete);
assert!(plan.decisions[0].authorized);
assert!(!plan.decisions[0].would_apply);
assert!(plan.decisions[0].dry_run_visible);
}
#[test]
fn metadata_round_trip_preserves_caveats() {
let mut metadata = DirectoryEntryMetadata::with_identity("id");
metadata.unix_mode = Some(0o600);
metadata.modified_epoch_micros = Some(1_234);
metadata.sparse_summary = Some("holes=2".to_string());
let entry = DirectoryManifestEntry::new(
path("sparse.img"),
DirectoryEntryKind::SparseFile,
Some("cid".to_string()),
metadata,
);
assert!(entry.caveats.contains(&MetadataCaveat::UnixPermissions));
assert!(entry.caveats.contains(&MetadataCaveat::TimestampResolution));
assert!(entry.caveats.contains(&MetadataCaveat::SparseMetadata));
assert_eq!(
serde_json::from_str::<DirectoryManifestEntry>(
&serde_json::to_string(&entry).expect("serialize")
)
.expect("deserialize"),
entry
);
}
#[test]
fn conflict_classification_preserves_local_by_default() {
let source = manifest(vec![file("same.txt", "new")]);
let destination = manifest(vec![file("same.txt", "old")]);
let plan = plan_directory_sync(&source, &destination, DirectorySyncPolicy::send_only());
assert_eq!(plan.decisions[0].action, DirectorySyncAction::Conflict);
assert_eq!(plan.decisions[0].reason, "destination_differs");
assert!(!plan.decisions[0].would_apply);
assert_eq!(
plan.proof.conflict_decisions,
vec!["same.txt:destination_differs"]
);
}
#[test]
fn directory_early_report_surfaces_metadata_and_verified_small_files() {
let directory = DirectoryManifestEntry::new(
path("docs"),
DirectoryEntryKind::Directory,
None,
DirectoryEntryMetadata::default(),
);
let source = manifest(vec![
directory,
sized_file("docs/README.md", "small-cid", 512),
sized_file("model.bin", "large-cid", 2 * 1024 * 1024),
]);
let verified_content_ids =
BTreeSet::from(["small-cid".to_string(), "large-cid".to_string()]);
let report = source.early_usability_report(
&verified_content_ids,
DirectoryEarlyUsabilityPolicy::small_files_up_to(1024),
DirectoryFinalCommitState::Pending,
"directory-replay:small-files",
);
assert_eq!(
report.usability_state,
DirectoryEarlyUsabilityState::SmallFilesAvailable
);
assert_eq!(
report.final_commit_state,
DirectoryFinalCommitState::Pending
);
assert_eq!(report.replay_pointer, "directory-replay:small-files");
assert_eq!(
report.metadata_paths,
vec!["docs", "docs/README.md", "model.bin"]
);
assert_eq!(report.small_file_paths, vec!["docs/README.md"]);
assert_eq!(report.withheld_content_paths, vec!["model.bin"]);
assert_eq!(report.manifest_tree_root, hex::encode(source.tree_root()));
assert!(report.safety_caveats.contains(
&"final directory commit not complete; expose early entries separately".to_string()
));
let large = report
.entries
.iter()
.find(|entry| entry.path == "model.bin")
.expect("large entry");
assert_eq!(
large.state,
DirectoryEarlyEntryState::MetadataOnly,
"large verified files must not become small-file early content"
);
assert_eq!(large.reason, "metadata_only_file_exceeds_small_file_policy");
}
#[test]
fn directory_early_report_withholds_unverified_small_file_content() {
let source = manifest(vec![sized_file("config.json", "config-cid", 128)]);
let report = source.early_usability_report(
&BTreeSet::new(),
DirectoryEarlyUsabilityPolicy::small_files_up_to(1024),
DirectoryFinalCommitState::Pending,
"directory-replay:unverified",
);
assert_eq!(
report.usability_state,
DirectoryEarlyUsabilityState::MetadataAvailable
);
assert_eq!(report.metadata_paths, vec!["config.json"]);
assert!(report.small_file_paths.is_empty());
assert_eq!(report.withheld_content_paths, vec!["config.json"]);
assert_eq!(
report.entries[0].reason,
"metadata_only_content_not_verified"
);
assert!(!report.entries[0].content_visible);
}
#[test]
fn directory_early_report_keeps_final_commit_state_separate() {
let source = manifest(vec![sized_file("done.txt", "done-cid", 32)]);
let policy = DirectoryEarlyUsabilityPolicy {
expose_metadata_before_final: false,
..DirectoryEarlyUsabilityPolicy::small_files_up_to(1024)
};
let pending = source.early_usability_report(
&BTreeSet::from(["done-cid".to_string()]),
policy,
DirectoryFinalCommitState::Pending,
"directory-replay:pending",
);
assert_eq!(
pending.usability_state,
DirectoryEarlyUsabilityState::NoEntries
);
assert!(pending.metadata_paths.is_empty());
assert!(pending.small_file_paths.is_empty());
assert_eq!(
pending.entries[0].reason,
"metadata_withheld_until_final_commit"
);
let committed = source.early_usability_report(
&BTreeSet::from(["done-cid".to_string()]),
policy,
DirectoryFinalCommitState::Committed,
"directory-replay:committed",
);
assert_eq!(
committed.usability_state,
DirectoryEarlyUsabilityState::FinalCommitted
);
assert_eq!(
committed.final_commit_state,
DirectoryFinalCommitState::Committed
);
assert_eq!(committed.metadata_paths, vec!["done.txt"]);
assert_eq!(committed.small_file_paths, vec!["done.txt"]);
assert!(!committed.safety_caveats.contains(
&"final directory commit not complete; expose early entries separately".to_string()
));
}
#[test]
fn explicit_apply_changes_projected_tree_root() {
let source = manifest(vec![file("same.txt", "new")]);
let destination = manifest(vec![file("same.txt", "old")]);
let policy = DirectorySyncPolicy::mirror_with_authorization(
DestructiveAuthorization::explicit_mirror_apply(),
);
let plan = plan_directory_sync(&source, &destination, policy);
assert!(plan.decisions[0].would_apply);
assert_eq!(plan.proof.final_tree_root, hex::encode(source.tree_root()));
assert!(plan.proof.replay_pointer.starts_with("directory-sync:"));
}
}