use std::collections::BTreeMap;
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
File = 0,
Dir = 1,
}
impl EntryKind {
pub(crate) fn from_u8(v: u8) -> Option<EntryKind> {
match v {
0 => Some(EntryKind::File),
1 => Some(EntryKind::Dir),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeEntry {
pub rel_path: String,
pub kind: EntryKind,
pub size: u64,
pub mtime_secs: u64,
pub mtime_nanos: u32,
}
impl TreeEntry {
pub fn from_metadata(rel_path: String, kind: EntryKind, meta: &std::fs::Metadata) -> Self {
let mtime = meta.modified().ok();
let (mtime_secs, mtime_nanos) = mtime
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| (d.as_secs(), d.subsec_nanos()))
.unwrap_or((0, 0));
Self {
rel_path,
kind,
size: if kind == EntryKind::File {
meta.len()
} else {
0
},
mtime_secs,
mtime_nanos,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TreeManifest {
entries: BTreeMap<String, TreeEntry>,
}
impl TreeManifest {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, entry: TreeEntry) {
self.entries.insert(entry.rel_path.clone(), entry);
}
pub fn get(&self, rel_path: &str) -> Option<&TreeEntry> {
self.entries.get(rel_path)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn entries(&self) -> impl Iterator<Item = &TreeEntry> {
self.entries.values()
}
pub fn total_file_bytes(&self) -> u64 {
self.entries
.values()
.filter(|e| e.kind == EntryKind::File)
.map(|e| e.size)
.sum()
}
pub fn scan(root: &Path) -> io::Result<TreeManifest> {
let mut manifest = TreeManifest::new();
let root_meta = std::fs::metadata(root)?;
if !root_meta.is_dir() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("tree root is not a directory: {}", root.display()),
));
}
Self::scan_dir(root, "", &mut manifest)?;
Ok(manifest)
}
fn scan_dir(dir: &Path, prefix: &str, out: &mut TreeManifest) -> io::Result<()> {
let mut entries: Vec<(String, PathBuf, std::fs::Metadata)> = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let meta = match entry.metadata() {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
Err(e) => return Err(e),
};
let name = entry.file_name().to_string_lossy().into_owned();
let rel = if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
};
entries.push((rel, entry.path(), meta));
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (rel, path, meta) in entries {
if meta.is_dir() {
out.insert(TreeEntry::from_metadata(rel.clone(), EntryKind::Dir, &meta));
Self::scan_dir(&path, &rel, out)?;
} else if meta.is_file() {
if rel.ends_with(".ffsync-index") || rel.ends_with(".ffsync-tmp") {
continue;
}
out.insert(TreeEntry::from_metadata(rel, EntryKind::File, &meta));
} else {
eprintln!("warning: skipping non-regular file: {}", path.display());
}
}
Ok(())
}
}
pub const TREE_FLAG_CHECKSUM: u16 = 1 << 0; pub const TREE_FLAG_NO_DELETE: u16 = 1 << 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Triage {
Skip,
Sync,
Transfer,
CreateDir,
Delete,
Conflict {
sender_kind: EntryKind,
remove_receiver_entry: bool,
},
}
impl fmt::Display for Triage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Triage::Skip => write!(f, "skip"),
Triage::Sync => write!(f, "sync"),
Triage::Transfer => write!(f, "transfer"),
Triage::CreateDir => write!(f, "create-dir"),
Triage::Delete => write!(f, "delete"),
Triage::Conflict { .. } => write!(f, "conflict"),
}
}
}
pub fn triage(
_rel_path: &str,
sender: Option<&TreeEntry>,
receiver: Option<&TreeEntry>,
checksum: bool,
) -> Triage {
match (sender, receiver) {
(None, None) => Triage::Skip, (Some(s), None) => match s.kind {
EntryKind::File => Triage::Transfer,
EntryKind::Dir => Triage::CreateDir,
},
(None, Some(_r)) => {
Triage::Delete
}
(Some(s), Some(r)) => {
match (s.kind, r.kind) {
(EntryKind::Dir, EntryKind::Dir) => Triage::Skip,
(EntryKind::File, EntryKind::File) => {
if !checksum
&& s.size == r.size
&& s.mtime_secs == r.mtime_secs
&& s.mtime_nanos == r.mtime_nanos
{
Triage::Skip
} else {
Triage::Sync
}
}
_ => Triage::Conflict {
sender_kind: s.kind,
remove_receiver_entry: true,
},
}
}
}
}
pub fn validate_rel_path(p: &str) -> Result<(), String> {
if p.is_empty() {
return Err("empty path".to_string());
}
if p.starts_with('/') || p.ends_with('/') {
return Err(format!("leading or trailing slash: {p:?}"));
}
if p.contains('\\') {
return Err(format!(
"backslash in path (wire form is /-separated): {p:?}"
));
}
if p.split('/').any(|c| c.is_empty() || c == "." || c == "..") {
return Err(format!("empty, '.', or '..' path component: {p:?}"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(path: &str, kind: EntryKind, size: u64, secs: u64, nanos: u32) -> TreeEntry {
TreeEntry {
rel_path: path.to_string(),
kind,
size,
mtime_secs: secs,
mtime_nanos: nanos,
}
}
#[test]
fn triage_metadata_match_skips() {
let s = entry("a.bin", EntryKind::File, 100, 1600000000, 500);
let r = entry("a.bin", EntryKind::File, 100, 1600000000, 500);
assert_eq!(triage("a.bin", Some(&s), Some(&r), false), Triage::Skip);
assert_eq!(triage("a.bin", Some(&s), Some(&r), true), Triage::Sync);
}
#[test]
fn triage_size_or_mtime_difference_syncs() {
let s = entry("a.bin", EntryKind::File, 100, 1600000000, 500);
let r1 = entry("a.bin", EntryKind::File, 101, 1600000000, 500);
assert_eq!(triage("a.bin", Some(&s), Some(&r1), false), Triage::Sync);
let r2 = entry("a.bin", EntryKind::File, 100, 1600000000, 501);
assert_eq!(triage("a.bin", Some(&s), Some(&r2), false), Triage::Sync);
let r3 = entry("a.bin", EntryKind::File, 100, 1600000001, 500);
assert_eq!(triage("a.bin", Some(&s), Some(&r3), false), Triage::Sync);
}
#[test]
fn triage_one_sided_and_kind_conflicts() {
let f = entry("x", EntryKind::File, 5, 1, 0);
let d = entry("x", EntryKind::Dir, 0, 1, 0);
assert_eq!(triage("x", Some(&f), None, false), Triage::Transfer);
assert_eq!(triage("x", Some(&d), None, false), Triage::CreateDir);
assert_eq!(triage("x", None, Some(&f), false), Triage::Delete);
assert_eq!(triage("x", None, Some(&d), false), Triage::Delete);
assert_eq!(
triage("x", Some(&f), Some(&d), false),
Triage::Conflict {
sender_kind: EntryKind::File,
remove_receiver_entry: true
}
);
assert_eq!(
triage("x", Some(&d), Some(&f), false),
Triage::Conflict {
sender_kind: EntryKind::Dir,
remove_receiver_entry: true
}
);
}
#[test]
fn rel_path_validation() {
assert!(validate_rel_path("a").is_ok());
assert!(validate_rel_path("a/b/c.txt").is_ok());
assert!(validate_rel_path("").is_err());
assert!(validate_rel_path("/abs").is_err());
assert!(validate_rel_path("a/").is_err());
assert!(validate_rel_path("a//b").is_err());
assert!(validate_rel_path("a/../b").is_err());
assert!(validate_rel_path("a/./b").is_err());
assert!(validate_rel_path("..").is_err());
assert!(validate_rel_path("a\\b").is_err());
}
#[test]
fn manifest_scan_and_canonical_order() {
let dir = std::env::temp_dir().join(format!("ffsync_tree_scan_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub/deep")).unwrap();
std::fs::create_dir_all(dir.join("empty_dir")).unwrap();
std::fs::write(dir.join("b.txt"), b"bbb").unwrap();
std::fs::write(dir.join("a.txt"), b"a").unwrap();
std::fs::write(dir.join("sub/one.bin"), b"1111").unwrap();
std::fs::write(dir.join("sub/deep/two.bin"), b"22").unwrap();
let m = TreeManifest::scan(&dir).unwrap();
let paths: Vec<&str> = m.entries().map(|e| e.rel_path.as_str()).collect();
assert_eq!(
paths,
vec![
"a.txt",
"b.txt",
"empty_dir",
"sub",
"sub/deep",
"sub/deep/two.bin",
"sub/one.bin"
]
);
assert_eq!(m.get("a.txt").unwrap().size, 1);
assert_eq!(m.get("sub/one.bin").unwrap().size, 4);
assert_eq!(m.get("empty_dir").unwrap().kind, EntryKind::Dir);
assert_eq!(m.total_file_bytes(), 10);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scan_rejects_non_directory_root() {
let f = std::env::temp_dir().join(format!("ffsync_tree_notadir_{}", std::process::id()));
std::fs::write(&f, b"x").unwrap();
assert!(TreeManifest::scan(&f).is_err());
let _ = std::fs::remove_file(&f);
}
}