use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::{Condvar, Mutex};
use super::{state::Baseline, LeanResult, CONTROL_DIR, STATE_DIR};
use flint_store::crc64_to_b64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScanEntry {
pub size: u64,
pub mtime_unix: i64,
pub mtime_nanos: u32,
pub mode: u32,
pub seen_nanos: i128,
}
pub const RACY_WINDOW_NANOS: i128 = 2_000_000_000;
pub fn now_nanos() -> i128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as i128)
.unwrap_or(0)
}
pub fn racy(mtime_unix: i64, mtime_nanos: u32, at_nanos: i128) -> bool {
at_nanos - (mtime_unix as i128 * 1_000_000_000 + mtime_nanos as i128) < RACY_WINDOW_NANOS
}
pub const TMP_SUFFIX: &str = ".flint-sync-tmp";
pub const PERM_BITS: u32 = 0o7777;
pub fn mode_differs(a: u32, b: u32) -> bool {
cfg!(unix) && (a ^ b) & PERM_BITS != 0
}
pub fn stat_changed(b: &super::state::BaselineEntry, size: u64, mtime: i64, nanos: u32, mode: u32) -> bool {
b.size != size
|| b.mtime_unix != mtime
|| b.mtime_nanos.map(|n| n != nanos).unwrap_or(false)
|| b.mode.is_some_and(|m| mode_differs(m, mode))
}
#[derive(Debug, Default)]
pub struct Scan {
pub entries: BTreeMap<String, ScanEntry>,
pub held: BTreeSet<String>,
}
impl Scan {
pub fn holds(&self, path: &str) -> bool {
!self.held.is_empty()
&& self.held.iter().any(|h| {
let base = h.strip_suffix('/').unwrap_or(h);
path.strip_prefix(base).is_some_and(|r| r.is_empty() || r.starts_with('/'))
})
}
pub fn present<'a, V>(&'a self, cited: &'a BTreeMap<String, V>) -> impl Iterator<Item = &'a String> + 'a {
let mut unseen: Vec<&'a String> = self.held.iter().flat_map(|h| under(cited, h)).collect();
unseen.sort_unstable();
unseen.dedup();
let mut seen = self.entries.keys().peekable();
let mut unseen = unseen.into_iter().peekable();
std::iter::from_fn(move || match (seen.peek(), unseen.peek()) {
(Some(a), Some(b)) => match a.cmp(b) {
std::cmp::Ordering::Less => seen.next(),
std::cmp::Ordering::Greater => unseen.next(),
std::cmp::Ordering::Equal => {
unseen.next();
seen.next()
}
},
(Some(_), None) => seen.next(),
(None, _) => unseen.next(),
})
}
}
fn under<'a, V>(cited: &'a BTreeMap<String, V>, held: &'a str) -> impl Iterator<Item = &'a String> + 'a {
use std::ops::Bound;
let base = held.strip_suffix('/').unwrap_or(held);
let dir = format!("{base}/");
let exact = cited.get_key_value(base).map(|(k, _)| k);
let below: Vec<&'a String> = cited
.range::<str, _>((Bound::Included(dir.as_str()), Bound::Unbounded))
.map(|(k, _)| k)
.take_while(|k| k.starts_with(dir.as_str()))
.collect();
exact.into_iter().chain(below)
}
pub fn may_read(root: &Path, rel: &str) -> bool {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
let Ok(c) = std::ffi::CString::new(root.join(rel).as_os_str().as_bytes()) else { return true };
if unsafe { libc::access(c.as_ptr(), libc::R_OK) } == 0 {
return true;
}
std::io::Error::last_os_error().kind() != std::io::ErrorKind::PermissionDenied
}
#[cfg(not(unix))]
{
let _ = (root, rel);
true
}
}
pub fn scan(root: &Path) -> LeanResult<Scan> {
scan_with(root, default_threads())
}
pub fn default_threads() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1).min(8)
}
pub fn scan_with(root: &Path, threads: usize) -> LeanResult<Scan> {
let queue = Mutex::new(Queue { dirs: vec![(root.to_path_buf(), String::new())], busy: 0, failed: None });
let wake = Condvar::new();
let found: Vec<Found> = if threads <= 1 {
vec![walk_worker(&queue, &wake)]
} else {
std::thread::scope(|s| {
let workers: Vec<_> = (0..threads).map(|_| s.spawn(|| walk_worker(&queue, &wake))).collect();
workers.into_iter().map(|w| w.join().expect("a scan worker panicked")).collect()
})
};
if let Some(e) = queue.into_inner().unwrap_or_else(|p| p.into_inner()).failed {
return Err(e.into());
}
let mut all: Vec<(String, ScanEntry)> = Vec::with_capacity(found.iter().map(|f| f.files.len()).sum());
let mut held = BTreeSet::new();
for f in found {
held.extend(f.held);
let mut start = 0;
for (end, e) in f.files {
all.push((f.names[start..end].to_string(), e));
start = end;
}
}
all.sort_unstable_by(|a, b| a.0.cmp(&b.0));
Ok(Scan { entries: all.into_iter().collect(), held })
}
struct Queue {
dirs: Vec<(PathBuf, String)>,
busy: usize,
failed: Option<std::io::Error>,
}
#[derive(Default)]
struct Found {
names: String,
files: Vec<(usize, ScanEntry)>,
held: Vec<String>,
}
fn walk_worker(queue: &Mutex<Queue>, wake: &Condvar) -> Found {
let mut out = Found::default();
loop {
let (dir, prefix) = {
let mut q = queue.lock().unwrap_or_else(|p| p.into_inner());
loop {
if q.failed.is_some() {
return out;
}
if let Some(next) = q.dirs.pop() {
q.busy += 1;
break next;
}
if q.busy == 0 {
wake.notify_all();
return out;
}
q = wake.wait(q).unwrap_or_else(|p| p.into_inner());
}
};
let mut subdirs = vec![];
let read = read_dir_entries(&dir, &prefix, &mut out, &mut subdirs);
let mut q = queue.lock().unwrap_or_else(|p| p.into_inner());
q.busy -= 1;
match read {
Ok(()) => q.dirs.extend(subdirs),
Err(e) => {
if q.failed.is_none() {
q.failed = Some(e);
}
}
}
wake.notify_all();
}
}
fn read_dir_entries(
dir: &Path,
prefix: &str,
out: &mut Found,
subdirs: &mut Vec<(PathBuf, String)>,
) -> std::io::Result<()> {
use rustix::fs::{AtFlags, FileType, Mode, OFlags};
use std::os::unix::ffi::OsStrExt;
let opened = rustix::fs::open(dir, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty());
let fd = match opened.map_err(std::io::Error::from) {
Ok(fd) => fd,
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied && !prefix.is_empty() => {
out.held.push(prefix.to_string());
return Ok(());
}
Err(e) => return Err(e),
};
let mut entries = rustix::fs::Dir::new(fd)?;
while let Some(entry) = entries.read() {
let entry = entry?;
let bytes = entry.file_name().to_bytes();
if bytes == b"." || bytes == b".." {
continue;
}
let name = String::from_utf8_lossy(bytes);
let keyless = matches!(name, std::borrow::Cow::Owned(_));
if prefix.is_empty() {
if name == STATE_DIR || name == CONTROL_DIR {
continue;
}
}
if name.ends_with(TMP_SUFFIX) {
continue;
}
let stat = rustix::fs::statat(entries.fd()?, entry.file_name(), AtFlags::SYMLINK_NOFOLLOW);
let st = match stat.map_err(std::io::Error::from) {
Ok(st) => st,
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
out.held.push(format!("{prefix}{name}"));
continue;
}
Err(e) => return Err(e),
};
match FileType::from_raw_mode(st.st_mode as _) {
FileType::Directory => {
let rel = format!("{prefix}{name}/");
if keyless {
out.held.push(rel);
} else {
subdirs.push((dir.join(std::ffi::OsStr::from_bytes(bytes)), rel));
}
}
FileType::RegularFile if keyless => out.held.push(format!("{prefix}{name}")),
FileType::RegularFile => {
let (mtime_unix, mtime_nanos) = if st.st_mtime >= 0 && (st.st_mtime_nsec as u64) < 1_000_000_000 {
(st.st_mtime as i64, st.st_mtime_nsec as u32)
} else {
(0, 0)
};
out.names.push_str(prefix);
out.names.push_str(&name);
out.files.push((
out.names.len(),
ScanEntry {
size: st.st_size as u64,
mtime_unix,
mtime_nanos,
mode: st.st_mode as u32,
seen_nanos: now_nanos(),
},
));
}
_ => {}
}
}
Ok(())
}
#[derive(Debug, Default)]
pub struct Classified {
pub uploads: BTreeSet<String>,
pub deletes: BTreeSet<String>,
pub first_absence: BTreeSet<String>,
}
pub fn is_control_path(path: &str) -> bool {
[CONTROL_DIR, STATE_DIR].iter().any(|d| {
path == *d || path.strip_prefix(d).map(|r| r.starts_with('/')).unwrap_or(false)
})
}
pub fn confirm_racy(
root: &Path,
scan: &Scan,
baseline: &Baseline,
c: &mut Classified,
) -> Vec<String> {
let now = now_nanos();
let mut cleared = vec![];
for (path, s) in &scan.entries {
let Some(b) = baseline.entries.get(path) else { continue };
if !b.racy || c.uploads.contains(path) {
continue;
}
match super::barrier::file_crc(root, path) {
Ok(crc) if b.crc64_b64.as_deref() == Some(crc64_to_b64(crc).as_str()) => {
if !racy(s.mtime_unix, s.mtime_nanos, now) {
cleared.push(path.clone());
}
}
_ => {
c.uploads.insert(path.clone());
}
}
}
cleared
}
pub fn classify(scan: &Scan, baseline: &Baseline) -> Classified {
let mut c = Classified::default();
for (path, s) in &scan.entries {
match baseline.entries.get(path) {
None => {
c.uploads.insert(path.clone());
}
Some(b) => {
if stat_changed(b, s.size, s.mtime_unix, s.mtime_nanos, s.mode) {
c.uploads.insert(path.clone());
}
}
}
}
for path in baseline.entries.keys() {
if scan.entries.contains_key(path) {
continue;
}
if scan.holds(path) {
continue;
}
if is_control_path(path) {
continue;
}
if baseline.prev_scan.contains(path) {
c.first_absence.insert(path.clone());
} else {
c.deletes.insert(path.clone());
}
}
c
}