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 change: ChangeStamp,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(from = "(i64, u32, u64)", into = "(i64, u32, u64)")]
pub struct ChangeStamp {
pub ctime: i64,
pub ctime_nanos: u32,
pub ino: u64,
}
impl From<(i64, u32, u64)> for ChangeStamp {
fn from((ctime, ctime_nanos, ino): (i64, u32, u64)) -> Self {
ChangeStamp { ctime, ctime_nanos, ino }
}
}
impl From<ChangeStamp> for (i64, u32, u64) {
fn from(c: ChangeStamp) -> Self {
(c.ctime, c.ctime_nanos, c.ino)
}
}
impl ChangeStamp {
pub fn of(m: &std::fs::Metadata) -> ChangeStamp {
use std::os::unix::fs::MetadataExt;
ChangeStamp { ctime: m.ctime(), ctime_nanos: m.ctime_nsec() as u32, ino: m.ino() }
}
}
pub fn stamp_moved(b: &super::state::BaselineEntry, now: &ChangeStamp) -> bool {
b.change.is_some_and(|c| c != *now)
}
pub fn later(mtime: i64, mtime_nanos: u32, change: Option<&ChangeStamp>) -> (i64, u32) {
match change {
Some(c) if (c.ctime, c.ctime_nanos) > (mtime, mtime_nanos) => (c.ctime, c.ctime_nanos),
_ => (mtime, mtime_nanos),
}
}
pub fn racy_entry(b: &super::state::BaselineEntry, at: i128) -> bool {
let (sec, nanos) = later(b.mtime_unix, b.mtime_nanos.unwrap_or(999_999_999), b.change.as_ref());
racy(sec, nanos, at)
}
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>,
held_trees: BTreeSet<String>,
held_files: BTreeSet<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Hold {
Tree,
File,
Unnamed,
}
impl Scan {
pub fn hold(&mut self, shown: String, kind: Hold) {
match kind {
Hold::Tree => {
self.held_trees.insert(shown.strip_suffix('/').unwrap_or(&shown).to_string());
}
Hold::File => {
self.held_files.insert(shown.clone());
}
Hold::Unnamed => {}
}
self.held.insert(shown);
}
pub fn holds(&self, path: &str) -> bool {
self.held_files.contains(path)
|| self
.held_trees
.iter()
.any(|base| path.strip_prefix(base.as_str()).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_trees
.iter()
.flat_map(|h| under(cited, h))
.chain(self.held_files.iter().filter_map(|f| cited.get_key_value(f.as_str()).map(|(k, _)| k)))
.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)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NameError {
Gone,
Replaced,
Refused,
Other,
}
pub fn name_error(e: &std::io::Error) -> NameError {
match e.raw_os_error() {
Some(libc::ENOENT) => NameError::Gone,
Some(libc::ENOTDIR) | Some(libc::ELOOP) => NameError::Replaced,
_ if e.kind() == std::io::ErrorKind::PermissionDenied => NameError::Refused,
_ if e.kind() == std::io::ErrorKind::InvalidInput => NameError::Replaced,
_ => NameError::Other,
}
}
#[cfg(test)]
fn walk_window(stage: &'static str, rel: impl FnOnce() -> String) {
if super::barrier::CONSUME_WINDOW_HOOK.with(|h| h.borrow().is_some()) {
super::barrier::consume_window(stage, &rel());
}
}
#[cfg(not(test))]
#[inline(always)]
fn walk_window(_: &'static str, _: impl FnOnce() -> String) {}
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 scan = Scan::default();
for f in found {
for (shown, kind) in f.held {
scan.hold(shown, kind);
}
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));
scan.entries = all.into_iter().collect();
Ok(scan)
}
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, Hold)>,
}
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;
walk_window("walk-before-open", || prefix.to_string());
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 !prefix.is_empty() => match name_error(&e) {
NameError::Gone | NameError::Replaced => return Ok(()),
NameError::Refused => {
out.held.push((prefix.to_string(), Hold::Tree));
return Ok(());
}
NameError::Other => return Err(e),
},
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;
}
walk_window("walk-before-stat", || format!("{prefix}{name}"));
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) => match name_error(&e) {
NameError::Gone | NameError::Replaced => continue,
NameError::Refused => {
out.held.push((format!("{prefix}{name}"), if keyless { Hold::Unnamed } else { Hold::Tree }));
continue;
}
NameError::Other => 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, Hold::Unnamed));
} else {
subdirs.push((dir.join(std::ffi::OsStr::from_bytes(bytes)), rel));
}
}
FileType::RegularFile if keyless => out.held.push((format!("{prefix}{name}"), Hold::Unnamed)),
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(),
change: ChangeStamp {
ctime: st.st_ctime as i64,
ctime_nanos: st.st_ctime_nsec as u32,
ino: st.st_ino as u64,
},
},
));
}
_ => {}
}
}
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)
})
}
#[derive(Debug, Clone)]
pub struct Vouched {
pub path: String,
pub change: ChangeStamp,
pub racy: bool,
}
pub fn confirm_racy(
root: &Path,
scan: &Scan,
baseline: &Baseline,
c: &mut Classified,
) -> Vec<Vouched> {
let now = now_nanos();
let mut vouched = vec![];
for (path, s) in &scan.entries {
let Some(b) = baseline.entries.get(path) else { continue };
if !(b.racy || stamp_moved(b, &s.change)) || 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()) => {
let (sec, nanos) = later(s.mtime_unix, s.mtime_nanos, Some(&s.change));
vouched.push(Vouched { path: path.clone(), change: s.change, racy: racy(sec, nanos, now) });
}
_ => {
c.uploads.insert(path.clone());
}
}
}
vouched
}
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
}