use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::atp::object::MetadataPolicy;
use super::streaming::{StreamingError, hex_encode};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileKind {
#[default]
Regular,
Symlink,
Directory,
Fifo,
Socket,
BlockDevice,
CharDevice,
}
impl FileKind {
const fn tag(self) -> u8 {
match self {
Self::Regular => 0,
Self::Symlink => 1,
Self::Directory => 2,
Self::Fifo => 3,
Self::Socket => 4,
Self::BlockDevice => 5,
Self::CharDevice => 6,
}
}
#[must_use]
pub const fn is_special(self) -> bool {
matches!(
self,
Self::Fifo | Self::Socket | Self::BlockDevice | Self::CharDevice
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct EntryMetadata {
#[serde(default)]
pub file_kind: FileKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unix_mode: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mtime_unix_secs: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mtime_nanos: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub symlink_target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hardlink_target: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub xattrs: BTreeMap<String, Vec<u8>>,
}
impl EntryMetadata {
#[must_use]
pub fn is_bare(&self) -> bool {
matches!(self.file_kind, FileKind::Regular)
&& self.unix_mode.is_none()
&& self.mtime_unix_secs.is_none()
&& self.mtime_nanos.is_none()
&& self.uid.is_none()
&& self.gid.is_none()
&& self.symlink_target.is_none()
&& self.hardlink_target.is_none()
&& self.xattrs.is_empty()
}
fn hash_into(&self, rel_path: &str, hasher: &mut Sha256) {
hasher.update((rel_path.len() as u64).to_be_bytes());
hasher.update(rel_path.as_bytes());
hasher.update([self.file_kind.tag()]);
hash_opt_u32(hasher, self.unix_mode);
hash_opt_i64(hasher, self.mtime_unix_secs);
hash_opt_u32(hasher, self.mtime_nanos);
hash_opt_u32(hasher, self.uid);
hash_opt_u32(hasher, self.gid);
hash_opt_str(hasher, self.symlink_target.as_deref());
hash_opt_str(hasher, self.hardlink_target.as_deref());
hash_xattrs(hasher, &self.xattrs);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct FileIdentity {
pub device: u64,
pub inode: u64,
}
impl FileIdentity {
#[must_use]
pub const fn new(device: u64, inode: u64) -> Self {
Self { device, inode }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SimilaritySignature {
pub simhash: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub minhash: Option<u64>,
}
impl SimilaritySignature {
#[must_use]
pub const fn new(simhash: u64, minhash: Option<u64>) -> Self {
Self { simhash, minhash }
}
fn distance_to(self, other: Self) -> u32 {
(self.simhash ^ other.simhash).count_ones()
}
fn matches_within(self, other: Self, max_hamming_distance: u32) -> bool {
self.minhash.zip(other.minhash).is_some_and(|(a, b)| a == b)
|| self.distance_to(other) <= max_hamming_distance
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZeroScanFingerprint {
pub file_kind: FileKind,
pub size_bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mtime_unix_secs: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mtime_nanos: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ctime_unix_secs: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ctime_nanos: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<FileIdentity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub similarity: Option<SimilaritySignature>,
}
impl ZeroScanFingerprint {
#[must_use]
pub fn from_entry_metadata(size_bytes: u64, metadata: &EntryMetadata) -> Self {
Self {
file_kind: metadata.file_kind,
size_bytes,
mtime_unix_secs: metadata.mtime_unix_secs,
mtime_nanos: metadata.mtime_nanos,
ctime_unix_secs: None,
ctime_nanos: None,
identity: None,
similarity: None,
}
}
#[must_use]
pub const fn with_ctime(mut self, secs: i64, nanos: u32) -> Self {
self.ctime_unix_secs = Some(secs);
self.ctime_nanos = Some(nanos);
self
}
#[must_use]
pub const fn with_identity(mut self, identity: FileIdentity) -> Self {
self.identity = Some(identity);
self
}
#[must_use]
pub const fn with_similarity(mut self, signature: SimilaritySignature) -> Self {
self.similarity = Some(signature);
self
}
fn ctime_available(&self) -> bool {
self.ctime_unix_secs.is_some()
}
fn mtime_matches(&self, prior: &Self) -> bool {
self.mtime_unix_secs == prior.mtime_unix_secs
&& self.mtime_nanos.unwrap_or(0) == prior.mtime_nanos.unwrap_or(0)
}
fn ctime_matches(&self, prior: &Self, policy: &ZeroScanPolicy) -> bool {
if policy.require_ctime && !(self.ctime_available() && prior.ctime_available()) {
return false;
}
match (self.ctime_unix_secs, prior.ctime_unix_secs) {
(Some(a), Some(b)) => {
a == b && self.ctime_nanos.unwrap_or(0) == prior.ctime_nanos.unwrap_or(0)
}
(None, None) => !policy.require_ctime,
_ => false,
}
}
fn same_filesystem_identity(&self, prior: &Self) -> bool {
self.identity
.zip(prior.identity)
.is_some_and(|(current, previous)| current == previous)
}
fn stat_identity_matches(&self, prior: &Self, policy: &ZeroScanPolicy) -> bool {
self.file_kind == prior.file_kind
&& self.size_bytes == prior.size_bytes
&& self.mtime_matches(prior)
&& self.ctime_matches(prior, policy)
&& match (self.identity, prior.identity) {
(Some(a), Some(b)) => a == b,
_ => true,
}
}
fn likely_same_prior_content(&self, prior: &Self, policy: &ZeroScanPolicy) -> bool {
if self.file_kind != prior.file_kind || self.size_bytes != prior.size_bytes {
return false;
}
if self.same_filesystem_identity(prior) || self.stat_identity_matches(prior, policy) {
return true;
}
self.similarity
.zip(prior.similarity)
.is_some_and(|(current, previous)| {
current.matches_within(previous, policy.max_similarity_hamming_distance)
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZeroScanEntry {
pub rel_path: String,
pub fingerprint: ZeroScanFingerprint,
}
impl ZeroScanEntry {
#[must_use]
pub fn new(rel_path: impl Into<String>, fingerprint: ZeroScanFingerprint) -> Self {
Self {
rel_path: rel_path.into(),
fingerprint,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DirtyPathSet {
paths: BTreeSet<String>,
}
impl DirtyPathSet {
#[must_use]
pub const fn new() -> Self {
Self {
paths: BTreeSet::new(),
}
}
#[must_use]
pub fn from_paths(paths: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
paths: paths.into_iter().map(Into::into).collect(),
}
}
pub fn insert(&mut self, rel_path: impl Into<String>) {
self.paths.insert(rel_path.into());
}
#[must_use]
pub fn contains(&self, rel_path: &str) -> bool {
self.paths.contains(rel_path)
}
#[must_use]
pub fn len(&self) -> usize {
self.paths.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.paths.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZeroScanPolicy {
pub require_ctime: bool,
pub max_similarity_hamming_distance: u32,
}
impl Default for ZeroScanPolicy {
fn default() -> Self {
Self {
require_ctime: true,
max_similarity_hamming_distance: 3,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ZeroScanHashReason {
NoPriorEntry,
DirtySetHit,
StatChanged,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "decision")]
pub enum ZeroScanDecision {
Unchanged {
rel_path: String,
},
ReusePriorContent {
rel_path: String,
prior_rel_path: String,
},
NeedsChunkHash {
rel_path: String,
reason: ZeroScanHashReason,
size_bytes: u64,
},
}
impl ZeroScanDecision {
fn skipped_chunk_hash(&self) -> bool {
matches!(
self,
Self::Unchanged { .. } | Self::ReusePriorContent { .. }
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZeroScanPlan {
pub decisions: Vec<ZeroScanDecision>,
pub skipped_chunk_hashes: usize,
pub scheduled_chunk_hashes: usize,
pub skipped_chunk_hash_bytes: u64,
pub estimated_content_bytes_floor: u64,
}
pub struct ZeroScanPrefilter;
impl ZeroScanPrefilter {
#[must_use]
pub fn plan(
prior: &[ZeroScanEntry],
current: &[ZeroScanEntry],
dirty_set: Option<&DirtyPathSet>,
policy: ZeroScanPolicy,
) -> ZeroScanPlan {
let prior_by_path: BTreeMap<&str, &ZeroScanEntry> = prior
.iter()
.map(|entry| (entry.rel_path.as_str(), entry))
.collect();
let mut decisions = Vec::with_capacity(current.len());
for entry in current {
let dirty = dirty_set.is_some_and(|set| set.contains(&entry.rel_path));
let decision = match prior_by_path.get(entry.rel_path.as_str()) {
Some(_) if dirty => ZeroScanDecision::NeedsChunkHash {
rel_path: entry.rel_path.clone(),
reason: ZeroScanHashReason::DirtySetHit,
size_bytes: entry.fingerprint.size_bytes,
},
Some(previous)
if entry
.fingerprint
.stat_identity_matches(&previous.fingerprint, &policy) =>
{
ZeroScanDecision::Unchanged {
rel_path: entry.rel_path.clone(),
}
}
Some(_) => ZeroScanDecision::NeedsChunkHash {
rel_path: entry.rel_path.clone(),
reason: ZeroScanHashReason::StatChanged,
size_bytes: entry.fingerprint.size_bytes,
},
None => Self::best_prior_content_match(prior, entry, &policy).map_or_else(
|| ZeroScanDecision::NeedsChunkHash {
rel_path: entry.rel_path.clone(),
reason: ZeroScanHashReason::NoPriorEntry,
size_bytes: entry.fingerprint.size_bytes,
},
|previous| ZeroScanDecision::ReusePriorContent {
rel_path: entry.rel_path.clone(),
prior_rel_path: previous.rel_path.clone(),
},
),
};
decisions.push(decision);
}
let mut skipped_chunk_hashes = 0usize;
let mut scheduled_chunk_hashes = 0usize;
let mut skipped_chunk_hash_bytes = 0u64;
let mut estimated_content_bytes_floor = 0u64;
for (entry, decision) in current.iter().zip(decisions.iter()) {
if decision.skipped_chunk_hash() {
skipped_chunk_hashes += 1;
skipped_chunk_hash_bytes =
skipped_chunk_hash_bytes.saturating_add(entry.fingerprint.size_bytes);
} else if let ZeroScanDecision::NeedsChunkHash { size_bytes, .. } = decision {
scheduled_chunk_hashes += 1;
estimated_content_bytes_floor =
estimated_content_bytes_floor.saturating_add(*size_bytes);
}
}
ZeroScanPlan {
decisions,
skipped_chunk_hashes,
scheduled_chunk_hashes,
skipped_chunk_hash_bytes,
estimated_content_bytes_floor,
}
}
fn best_prior_content_match<'a>(
prior: &'a [ZeroScanEntry],
entry: &ZeroScanEntry,
policy: &ZeroScanPolicy,
) -> Option<&'a ZeroScanEntry> {
prior
.iter()
.filter(|previous| {
entry
.fingerprint
.likely_same_prior_content(&previous.fingerprint, policy)
})
.min_by(|a, b| a.rel_path.cmp(&b.rel_path))
}
}
fn hash_opt_str(hasher: &mut Sha256, v: Option<&str>) {
match v {
Some(s) => {
hasher.update([1u8]);
hasher.update((s.len() as u64).to_be_bytes());
hasher.update(s.as_bytes());
}
None => hasher.update([0u8]),
}
}
fn hash_opt_u32(hasher: &mut Sha256, v: Option<u32>) {
match v {
Some(x) => {
hasher.update([1u8]);
hasher.update(x.to_be_bytes());
}
None => hasher.update([0u8]),
}
}
fn hash_opt_i64(hasher: &mut Sha256, v: Option<i64>) {
match v {
Some(x) => {
hasher.update([1u8]);
hasher.update(x.to_be_bytes());
}
None => hasher.update([0u8]),
}
}
fn hash_xattrs(hasher: &mut Sha256, xattrs: &BTreeMap<String, Vec<u8>>) {
hasher.update((xattrs.len() as u64).to_be_bytes());
for (name, value) in xattrs {
hasher.update((name.len() as u64).to_be_bytes());
hasher.update(name.as_bytes());
hasher.update((value.len() as u64).to_be_bytes());
hasher.update(value);
}
}
#[must_use]
pub fn metadata_commitment(entries: &[(&str, &EntryMetadata)]) -> Option<String> {
if entries.iter().all(|(_, m)| m.is_bare()) {
return None;
}
let mut sorted: Vec<&(&str, &EntryMetadata)> = entries.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(b.0));
let mut hasher = Sha256::new();
hasher.update(b"asupersync.atp.metadata-commitment.v1\0");
hasher.update((sorted.len() as u64).to_be_bytes());
for (rel_path, meta) in sorted {
meta.hash_into(rel_path, &mut hasher);
}
Some(hex_encode(&hasher.finalize()))
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MetadataApplyReport {
pub applied: Vec<&'static str>,
pub skipped: Vec<(&'static str, String)>,
}
impl MetadataApplyReport {
fn mark_applied(&mut self, field: &'static str) {
self.applied.push(field);
}
fn mark_skipped(&mut self, field: &'static str, reason: impl Into<String>) {
self.skipped.push((field, reason.into()));
}
}
#[cfg(unix)]
pub async fn read_entry_metadata(
abs_path: &Path,
policy: &MetadataPolicy,
) -> Result<EntryMetadata, StreamingError> {
let path_buf = abs_path.to_path_buf();
let policy = policy.clone();
crate::runtime::spawn_blocking(move || read_entry_metadata_sync(&path_buf, &policy)).await
}
#[cfg(unix)]
pub fn read_entry_metadata_sync(
abs_path: &Path,
policy: &MetadataPolicy,
) -> Result<EntryMetadata, StreamingError> {
use std::os::unix::fs::MetadataExt;
let lmeta = std::fs::symlink_metadata(abs_path)
.map_err(|e| StreamingError::new(format!("{}: {e}", abs_path.display())))?;
let mut meta = EntryMetadata::default();
if lmeta.is_symlink() && policy.preserve_symlinks {
meta.file_kind = FileKind::Symlink;
let target = std::fs::read_link(abs_path)
.map_err(|e| StreamingError::new(format!("{}: {e}", abs_path.display())))?;
meta.symlink_target = Some(target.to_string_lossy().into_owned());
return Ok(meta);
}
let read_xattrs_through_symlink = lmeta.is_symlink();
let effective = if read_xattrs_through_symlink {
std::fs::metadata(abs_path)
.map_err(|e| StreamingError::new(format!("{}: {e}", abs_path.display())))?
} else {
lmeta
};
if effective.is_dir() {
meta.file_kind = FileKind::Directory;
} else if !effective.is_file() {
use std::os::unix::fs::FileTypeExt;
let ft = effective.file_type();
meta.file_kind = if ft.is_fifo() {
FileKind::Fifo
} else if ft.is_socket() {
FileKind::Socket
} else if ft.is_block_device() {
FileKind::BlockDevice
} else if ft.is_char_device() {
FileKind::CharDevice
} else {
FileKind::Regular
};
}
if policy.preserve_unix_permissions {
meta.unix_mode = Some(effective.mode() & 0o7777);
}
if policy.preserve_timestamps {
meta.mtime_unix_secs = Some(effective.mtime());
meta.mtime_nanos = u32::try_from(effective.mtime_nsec().rem_euclid(1_000_000_000)).ok();
}
if policy.record_platform_metadata {
meta.uid = Some(effective.uid());
meta.gid = Some(effective.gid());
}
if policy.preserve_extended_attributes {
meta.xattrs = read_xattrs_best_effort_sync(abs_path, read_xattrs_through_symlink);
}
Ok(meta)
}
#[cfg(unix)]
fn read_xattrs_best_effort_sync(abs_path: &Path, deref_symlink: bool) -> BTreeMap<String, Vec<u8>> {
let listed = if deref_symlink {
xattr::list_deref(abs_path)
} else {
xattr::list(abs_path)
};
let Ok(names) = listed else {
return BTreeMap::new();
};
let mut attrs = BTreeMap::new();
for name in names {
let Some(name_str) = name.to_str().map(str::to_owned) else {
continue;
};
let value = if deref_symlink {
xattr::get_deref(abs_path, &name)
} else {
xattr::get(abs_path, &name)
};
if let Ok(Some(value)) = value {
attrs.insert(name_str, value);
}
}
attrs
}
#[cfg(not(unix))]
pub async fn read_entry_metadata(
abs_path: &Path,
policy: &MetadataPolicy,
) -> Result<EntryMetadata, StreamingError> {
let path_buf = abs_path.to_path_buf();
let policy = policy.clone();
crate::runtime::spawn_blocking(move || read_entry_metadata_sync(&path_buf, &policy)).await
}
#[cfg(not(unix))]
pub fn read_entry_metadata_sync(
abs_path: &Path,
_policy: &MetadataPolicy,
) -> Result<EntryMetadata, StreamingError> {
let effective = std::fs::metadata(abs_path)
.map_err(|e| StreamingError::new(format!("{}: {e}", abs_path.display())))?;
let mut meta = EntryMetadata::default();
if effective.is_dir() {
meta.file_kind = FileKind::Directory;
}
Ok(meta)
}
#[cfg(unix)]
pub async fn inode_key_if_regular(abs_path: &Path) -> Result<Option<(u64, u64)>, StreamingError> {
let path_buf = abs_path.to_path_buf();
crate::runtime::spawn_blocking(move || inode_key_if_regular_sync(&path_buf)).await
}
#[cfg(unix)]
pub fn inode_key_if_regular_sync(abs_path: &Path) -> Result<Option<(u64, u64)>, StreamingError> {
use std::os::unix::fs::MetadataExt;
let lmeta = std::fs::symlink_metadata(abs_path)
.map_err(|e| StreamingError::new(format!("{}: {e}", abs_path.display())))?;
if lmeta.is_file() {
Ok(Some((lmeta.dev(), lmeta.ino())))
} else {
Ok(None)
}
}
#[cfg(not(unix))]
pub async fn inode_key_if_regular(_abs_path: &Path) -> Result<Option<(u64, u64)>, StreamingError> {
Ok(None)
}
#[cfg(not(unix))]
pub fn inode_key_if_regular_sync(_abs_path: &Path) -> Result<Option<(u64, u64)>, StreamingError> {
Ok(None)
}
#[cfg(unix)]
pub async fn apply_entry_metadata(
out_path: &Path,
meta: &EntryMetadata,
) -> Result<MetadataApplyReport, StreamingError> {
let path_buf = out_path.to_path_buf();
let meta = meta.clone();
crate::runtime::spawn_blocking(move || apply_entry_metadata_sync(&path_buf, &meta)).await
}
#[cfg(unix)]
pub fn apply_entry_metadata_sync(
out_path: &Path,
meta: &EntryMetadata,
) -> Result<MetadataApplyReport, StreamingError> {
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, UNIX_EPOCH};
let mut report = MetadataApplyReport::default();
let special_file = meta.file_kind.is_special();
if let Some(secs) = (!special_file).then_some(meta.mtime_unix_secs).flatten() {
let mtime_nanos = meta.mtime_nanos.unwrap_or(0);
let applied = u64::try_from(secs)
.map_err(|_| "pre-epoch mtime not representable".to_string())
.and_then(|secs_u64| {
let nanos = mtime_nanos % 1_000_000_000;
let when = UNIX_EPOCH
.checked_add(Duration::new(secs_u64, nanos))
.ok_or_else(|| "mtime out of representable range".to_string())?;
let times = std::fs::FileTimes::new().set_modified(when);
std::fs::File::open(out_path)
.and_then(|f| f.set_times(times))
.map_err(|e| e.to_string())
});
match applied {
Ok(()) => report.mark_applied("mtime"),
Err(e) => report.mark_skipped("mtime", e),
}
}
if special_file && meta.mtime_unix_secs.is_some() {
report.mark_skipped(
"mtime",
"open-based timestamp apply skipped for special file".to_string(),
);
}
if !meta.xattrs.is_empty() && !special_file {
let mut any_applied = false;
for (name, value) in &meta.xattrs {
match xattr::set(out_path, name, value) {
Ok(()) => any_applied = true,
Err(e) => report.mark_skipped("xattr", format!("{name}: {e}")),
}
}
if any_applied {
report.mark_applied("xattr");
}
}
if special_file && !meta.xattrs.is_empty() {
report.mark_skipped("xattr", "xattr apply skipped for special file".to_string());
}
if let (Some(u), Some(g)) = (meta.uid, meta.gid) {
match std::os::unix::fs::chown(out_path, Some(u), Some(g)) {
Ok(()) => report.mark_applied("owner"),
Err(e) => report.mark_skipped("owner", e.to_string()),
}
}
if let Some(mode) = meta.unix_mode {
std::fs::set_permissions(out_path, std::fs::Permissions::from_mode(mode))
.map_err(|e| StreamingError::new(format!("{}: {e}", out_path.display())))?;
report.mark_applied("mode");
}
Ok(report)
}
#[cfg(not(unix))]
pub async fn apply_entry_metadata(
_out_path: &Path,
meta: &EntryMetadata,
) -> Result<MetadataApplyReport, StreamingError> {
apply_entry_metadata_sync(_out_path, meta)
}
#[cfg(not(unix))]
pub fn apply_entry_metadata_sync(
_out_path: &Path,
meta: &EntryMetadata,
) -> Result<MetadataApplyReport, StreamingError> {
let mut report = MetadataApplyReport::default();
if meta.unix_mode.is_some() {
report.mark_skipped("mode", "unix permissions unsupported on this platform");
}
if meta.mtime_unix_secs.is_some() {
report.mark_skipped("mtime", "timestamp apply unsupported on this platform");
}
if meta.uid.is_some() || meta.gid.is_some() {
report.mark_skipped("owner", "ownership unsupported on this platform");
}
if !meta.xattrs.is_empty() {
report.mark_skipped("xattr", "extended attributes unsupported on this platform");
}
Ok(report)
}
#[cfg(unix)]
pub async fn recreate_fifo(out_path: &Path, mode: u32) -> Result<(), StreamingError> {
let perm_bits = mode & 0o7777;
let path_buf = out_path.to_path_buf();
crate::runtime::spawn_blocking(move || {
use nix::sys::stat::Mode;
nix::unistd::mkfifo(
&path_buf,
Mode::from_bits_truncate(perm_bits as libc::mode_t),
)
.map_err(|e| e.to_string())
})
.await
.map_err(|e| StreamingError::new(format!("{}: mkfifo: {e}", out_path.display())))?;
crate::fs::set_permissions(out_path, crate::fs::Permissions::from_mode(perm_bits))
.await
.map_err(|e| StreamingError::new(format!("{}: {e}", out_path.display())))?;
Ok(())
}
#[cfg(not(unix))]
pub async fn recreate_fifo(_out_path: &Path, _mode: u32) -> Result<(), StreamingError> {
Err(StreamingError::new(
"FIFO recreation unsupported on this platform",
))
}
#[cfg(test)]
mod tests {
use super::*;
fn meta(mode: Option<u32>) -> EntryMetadata {
EntryMetadata {
unix_mode: mode,
..Default::default()
}
}
fn zero_scan_entry(
rel_path: &str,
size_bytes: u64,
mtime_secs: i64,
ctime_secs: Option<i64>,
identity: Option<FileIdentity>,
) -> ZeroScanEntry {
let metadata = EntryMetadata {
mtime_unix_secs: Some(mtime_secs),
mtime_nanos: Some(0),
..Default::default()
};
let mut fingerprint =
ZeroScanFingerprint::from_entry_metadata(size_bytes, &metadata).with_similarity(
SimilaritySignature::new(size_bytes.rotate_left(7), Some(size_bytes)),
);
if let Some(secs) = ctime_secs {
fingerprint = fingerprint.with_ctime(secs, 0);
}
if let Some(id) = identity {
fingerprint = fingerprint.with_identity(id);
}
ZeroScanEntry::new(rel_path, fingerprint)
}
#[test]
fn zero_scan_prefilter_skips_unchanged_tree() {
let prior = vec![
zero_scan_entry("alpha.bin", 10, 1_700_000_000, Some(1_700_000_010), None),
zero_scan_entry(
"nested/beta.bin",
20,
1_700_000_001,
Some(1_700_000_011),
None,
),
];
let current = prior.clone();
let plan = ZeroScanPrefilter::plan(&prior, ¤t, None, ZeroScanPolicy::default());
assert_eq!(plan.scheduled_chunk_hashes, 0);
assert_eq!(plan.skipped_chunk_hashes, 2);
assert_eq!(plan.skipped_chunk_hash_bytes, 30);
assert_eq!(plan.estimated_content_bytes_floor, 0);
assert!(
plan.decisions
.iter()
.all(|decision| matches!(decision, ZeroScanDecision::Unchanged { .. }))
);
}
#[test]
fn zero_scan_dirty_set_forces_chunk_hashing() {
let prior = vec![
zero_scan_entry("alpha.bin", 10, 1_700_000_000, Some(1_700_000_010), None),
zero_scan_entry(
"nested/beta.bin",
20,
1_700_000_001,
Some(1_700_000_011),
None,
),
];
let current = prior.clone();
let dirty = DirtyPathSet::from_paths(["nested/beta.bin"]);
let plan =
ZeroScanPrefilter::plan(&prior, ¤t, Some(&dirty), ZeroScanPolicy::default());
assert_eq!(plan.skipped_chunk_hashes, 1);
assert_eq!(plan.scheduled_chunk_hashes, 1);
assert_eq!(plan.estimated_content_bytes_floor, 20);
assert!(matches!(
plan.decisions[1],
ZeroScanDecision::NeedsChunkHash {
reason: ZeroScanHashReason::DirtySetHit,
..
}
));
}
#[test]
fn zero_scan_detects_rename_without_resending_content() {
let identity = FileIdentity::new(7, 42);
let prior = vec![zero_scan_entry(
"old-name.bin",
64,
1_700_000_000,
Some(1_700_000_010),
Some(identity),
)];
let current = vec![zero_scan_entry(
"new-name.bin",
64,
1_700_000_000,
Some(1_700_000_010),
Some(identity),
)];
let plan = ZeroScanPrefilter::plan(&prior, ¤t, None, ZeroScanPolicy::default());
assert_eq!(plan.scheduled_chunk_hashes, 0);
assert_eq!(plan.skipped_chunk_hashes, 1);
assert_eq!(plan.estimated_content_bytes_floor, 0);
assert_eq!(
plan.decisions,
vec![ZeroScanDecision::ReusePriorContent {
rel_path: "new-name.bin".to_string(),
prior_rel_path: "old-name.bin".to_string(),
}]
);
}
#[test]
fn zero_scan_requires_ctime_by_default() {
let prior = vec![zero_scan_entry(
"same-size-mtime.bin",
64,
1_700_000_000,
None,
None,
)];
let current = prior.clone();
let plan = ZeroScanPrefilter::plan(&prior, ¤t, None, ZeroScanPolicy::default());
assert_eq!(plan.scheduled_chunk_hashes, 1);
assert!(matches!(
plan.decisions[0],
ZeroScanDecision::NeedsChunkHash {
reason: ZeroScanHashReason::StatChanged,
..
}
));
let permissive = ZeroScanPolicy {
require_ctime: false,
..ZeroScanPolicy::default()
};
let plan = ZeroScanPrefilter::plan(&prior, ¤t, None, permissive);
assert_eq!(plan.scheduled_chunk_hashes, 0);
assert!(matches!(
plan.decisions[0],
ZeroScanDecision::Unchanged { .. }
));
}
#[test]
fn bare_metadata_yields_no_commitment() {
let bare = EntryMetadata::default();
assert!(bare.is_bare());
assert_eq!(metadata_commitment(&[("a", &bare), ("b", &bare)]), None);
}
#[test]
fn commitment_is_order_independent_and_64_hex() {
let a = meta(Some(0o644));
let b = meta(Some(0o755));
let r1 = metadata_commitment(&[("a", &a), ("b", &b)]).expect("commitment");
let r2 = metadata_commitment(&[("b", &b), ("a", &a)]).expect("commitment");
assert_eq!(r1, r2, "commitment must be order-independent");
assert_eq!(r1.len(), 64);
assert!(r1.bytes().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn changing_a_mode_changes_the_commitment() {
let before = metadata_commitment(&[("f", &meta(Some(0o644)))]).expect("c");
let after = metadata_commitment(&[("f", &meta(Some(0o600)))]).expect("c");
assert_ne!(before, after, "metadata change must change the commitment");
}
#[test]
fn changing_mtime_or_symlink_changes_the_commitment() {
let base = EntryMetadata {
unix_mode: Some(0o644),
mtime_unix_secs: Some(1000),
..Default::default()
};
let mut later = base.clone();
later.mtime_unix_secs = Some(2000);
assert_ne!(
metadata_commitment(&[("f", &base)]),
metadata_commitment(&[("f", &later)]),
);
let mut link = base.clone();
link.file_kind = FileKind::Symlink;
link.symlink_target = Some("target.txt".to_string());
assert_ne!(
metadata_commitment(&[("f", &base)]),
metadata_commitment(&[("f", &link)]),
);
}
#[test]
fn changing_xattrs_changes_the_commitment() {
let mut base = meta(Some(0o644));
base.xattrs
.insert("user.asupersync.alpha".to_string(), b"one".to_vec());
let mut changed = base.clone();
changed
.xattrs
.insert("user.asupersync.alpha".to_string(), b"two".to_vec());
assert_ne!(
metadata_commitment(&[("f", &base)]),
metadata_commitment(&[("f", &changed)]),
"xattr value changes must move the metadata commitment"
);
let mut renamed = base.clone();
renamed.xattrs.clear();
renamed
.xattrs
.insert("user.asupersync.beta".to_string(), b"one".to_vec());
assert_ne!(
metadata_commitment(&[("f", &base)]),
metadata_commitment(&[("f", &renamed)]),
"xattr name changes must move the metadata commitment"
);
}
#[test]
fn presence_distinguishes_absent_from_zero() {
let absent = EntryMetadata {
unix_mode: Some(0o644),
..Default::default()
};
let zero_uid = EntryMetadata {
unix_mode: Some(0o644),
uid: Some(0),
gid: Some(0),
..Default::default()
};
assert_ne!(
metadata_commitment(&[("f", &absent)]),
metadata_commitment(&[("f", &zero_uid)]),
"absent uid must hash differently from uid=0",
);
}
#[test]
fn entry_metadata_json_round_trips() {
let m = EntryMetadata {
file_kind: FileKind::Symlink,
unix_mode: Some(0o777),
mtime_unix_secs: Some(1_700_000_000),
mtime_nanos: Some(123),
uid: Some(1000),
gid: Some(1000),
symlink_target: Some("../t".to_string()),
hardlink_target: None,
xattrs: BTreeMap::from([("user.asupersync.note".to_string(), b"hello".to_vec())]),
};
let js = serde_json::to_string(&m).expect("ser");
let back: EntryMetadata = serde_json::from_str(&js).expect("de");
assert_eq!(m, back);
}
#[cfg(unix)]
#[test]
fn apply_metadata_huge_mtime_nanos_carry_does_not_panic() {
let meta = EntryMetadata {
mtime_unix_secs: Some(i64::MAX),
mtime_nanos: Some(1_000_000_000),
..Default::default()
};
let path = Path::new("/asupersync-metadata-mtime-overflow-regression-missing-file");
let report = futures_lite::future::block_on(apply_entry_metadata(path, &meta))
.expect("out-of-range off-wire mtime must degrade into a metadata report");
assert!(report.applied.is_empty());
assert_eq!(report.skipped.len(), 1);
assert_eq!(report.skipped[0].0, "mtime");
}
#[cfg(unix)]
#[test]
fn apply_metadata_pre_epoch_mtime_is_skipped() {
let meta = EntryMetadata {
mtime_unix_secs: Some(-1),
mtime_nanos: Some(999_999_999),
..Default::default()
};
let path = Path::new("/asupersync-metadata-pre-epoch-regression-missing-file");
let report = futures_lite::future::block_on(apply_entry_metadata(path, &meta))
.expect("pre-epoch off-wire mtime must degrade into a metadata report");
assert!(report.applied.is_empty());
assert_eq!(
report.skipped,
vec![("mtime", "pre-epoch mtime not representable".to_string())]
);
}
#[test]
fn bare_regular_omits_optional_fields_in_json() {
let m = EntryMetadata::default();
let js = serde_json::to_string(&m).expect("ser");
assert!(js.contains("file_kind"));
assert!(!js.contains("unix_mode"));
assert!(!js.contains("symlink_target"));
}
}