1use crate::atomic::atomic_write_private;
20use crate::error::{FsError, Result};
21use crate::lock::{FileLock, LockOptions};
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeMap;
24use std::fs::File;
25use std::io::{Read, Seek, SeekFrom};
26use std::path::{Path, PathBuf};
27use std::time::{Duration, SystemTime, UNIX_EPOCH};
28
29const LEDGER_FILE: &str = "identity-ledger.json";
30const LOCK_FILE: &str = "identity-ledger.lock";
31const SAMPLE: u64 = 64 * 1024;
32const LEDGER_VERSION: u32 = 1;
33
34pub type StableId = String;
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
38pub struct FileKey {
39 pub volume: u64,
40 pub index: u64,
41}
42
43impl FileKey {
44 pub fn of_path(path: &Path) -> Result<Self> {
45 let file = File::open(path).map_err(|e| FsError::io(path, e))?;
46 Self::of_file(&file, path)
47 }
48
49 pub fn of_file(file: &File, shown: &Path) -> Result<Self> {
50 #[cfg(unix)]
51 {
52 use std::os::unix::fs::MetadataExt;
53 let meta = file.metadata().map_err(|e| FsError::io(shown, e))?;
54 Ok(Self {
55 volume: meta.dev(),
56 index: meta.ino(),
57 })
58 }
59 #[cfg(windows)]
60 {
61 windows_key(file).map_err(|e| FsError::io(shown, e))
62 }
63 #[cfg(not(any(unix, windows)))]
64 {
65 let _ = (file, shown);
66 Err(FsError::Unavailable("file identity"))
67 }
68 }
69
70 fn of_meta(meta: &std::fs::Metadata, path: &Path) -> Option<Self> {
71 #[cfg(unix)]
72 {
73 let _ = path;
74 use std::os::unix::fs::MetadataExt;
75 Some(Self {
76 volume: meta.dev(),
77 index: meta.ino(),
78 })
79 }
80 #[cfg(not(unix))]
81 {
82 let _ = meta;
83 Self::of_path(path).ok()
84 }
85 }
86}
87
88#[cfg(windows)]
89fn windows_key(file: &File) -> std::io::Result<FileKey> {
90 use std::os::windows::io::AsRawHandle;
91 #[repr(C)]
92 struct FileTime {
93 low: u32,
94 high: u32,
95 }
96 #[repr(C)]
97 struct ByHandleFileInformation {
98 attributes: u32,
99 creation: FileTime,
100 access: FileTime,
101 write: FileTime,
102 volume_serial: u32,
103 size_high: u32,
104 size_low: u32,
105 links: u32,
106 index_high: u32,
107 index_low: u32,
108 }
109 #[link(name = "kernel32")]
110 unsafe extern "system" {
111 fn GetFileInformationByHandle(
112 handle: *mut std::ffi::c_void,
113 info: *mut ByHandleFileInformation,
114 ) -> i32;
115 }
116 let mut info = std::mem::MaybeUninit::<ByHandleFileInformation>::zeroed();
117 let ok = unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), info.as_mut_ptr()) };
119 if ok == 0 {
120 return Err(std::io::Error::last_os_error());
121 }
122 let info = unsafe { info.assume_init() };
124 Ok(FileKey {
125 volume: u64::from(info.volume_serial),
126 index: (u64::from(info.index_high) << 32) | u64::from(info.index_low),
127 })
128}
129
130#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
133pub struct Fingerprint {
134 pub size: u64,
135 pub sample_hash: u64,
136}
137
138impl Fingerprint {
139 pub fn of_path(path: &Path) -> Result<Self> {
140 let mut file = File::open(path).map_err(|e| FsError::io(path, e))?;
141 let size = file.metadata().map_err(|e| FsError::io(path, e))?.len();
142 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
143 let mut feed = |bytes: &[u8]| {
144 for b in bytes {
145 hash ^= u64::from(*b);
146 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
147 }
148 };
149 let mut buf = vec![0u8; SAMPLE as usize];
150 let head = size.min(SAMPLE) as usize;
151 file.read_exact(&mut buf[..head])
152 .map_err(|e| FsError::io(path, e))?;
153 feed(&buf[..head]);
154 if size > SAMPLE {
155 let tail = size.min(SAMPLE) as usize;
156 file.seek(SeekFrom::Start(size - tail as u64))
157 .map_err(|e| FsError::io(path, e))?;
158 file.read_exact(&mut buf[..tail])
159 .map_err(|e| FsError::io(path, e))?;
160 feed(&buf[..tail]);
161 }
162 Ok(Self {
163 size,
164 sample_hash: hash,
165 })
166 }
167}
168
169#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
170pub struct Tracked {
171 pub id: StableId,
172 pub key: FileKey,
173 pub last_path: PathBuf,
174 pub fingerprint: Fingerprint,
175 pub tracked_at_ms: u64,
176}
177
178#[derive(Clone, Debug, PartialEq, Eq)]
179pub enum Resolution {
180 Unchanged(PathBuf),
182 Moved(PathBuf),
184 Replaced(PathBuf),
186 Missing,
187}
188
189impl Resolution {
190 pub fn path(&self) -> Option<&Path> {
191 match self {
192 Self::Unchanged(p) | Self::Moved(p) | Self::Replaced(p) => Some(p),
193 Self::Missing => None,
194 }
195 }
196}
197
198#[derive(Clone, Debug)]
199pub struct SearchLimits {
200 pub max_entries: usize,
201 pub max_depth: usize,
202}
203
204impl Default for SearchLimits {
205 fn default() -> Self {
206 Self {
207 max_entries: 200_000,
208 max_depth: 12,
209 }
210 }
211}
212
213#[derive(Default, Serialize, Deserialize)]
214struct Ledger {
215 version: u32,
216 entries: BTreeMap<StableId, Tracked>,
217}
218
219pub struct IdentityStore {
220 dir: PathBuf,
221 limits: SearchLimits,
222}
223
224impl IdentityStore {
225 pub fn open(dir: &Path) -> Result<Self> {
227 crate::private::ensure_private_dir(dir)?;
228 Ok(Self {
229 dir: dir.to_path_buf(),
230 limits: SearchLimits::default(),
231 })
232 }
233
234 pub fn with_limits(mut self, limits: SearchLimits) -> Self {
235 self.limits = limits;
236 self
237 }
238
239 fn locked<T>(&self, f: impl FnOnce(&mut Ledger) -> Result<(T, bool)>) -> Result<T> {
240 let _lock = FileLock::acquire(
241 &self.dir.join(LOCK_FILE),
242 &LockOptions::wait(Duration::from_secs(10)).labeled("identity-ledger"),
243 )?;
244 let path = self.dir.join(LEDGER_FILE);
245 let mut ledger = match std::fs::read(&path) {
246 Ok(bytes) => {
247 serde_json::from_slice::<Ledger>(&bytes).map_err(|e| FsError::Corrupt {
248 path: path.clone(),
249 reason: e.to_string(),
250 })?
251 }
252 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ledger {
253 version: LEDGER_VERSION,
254 ..Ledger::default()
255 },
256 Err(e) => return Err(FsError::io(&path, e)),
257 };
258 let (out, dirty) = f(&mut ledger)?;
259 if dirty {
260 let bytes = serde_json::to_vec_pretty(&ledger).expect("ledger serializes");
261 atomic_write_private(&path, &bytes)?;
262 }
263 Ok(out)
264 }
265
266 pub fn track(&self, path: &Path) -> Result<StableId> {
268 let abs = absolute(path)?;
269 let key = FileKey::of_path(&abs)?;
270 let fingerprint = Fingerprint::of_path(&abs)?;
271 let id = new_id()?;
272 let entry = Tracked {
273 id: id.clone(),
274 key,
275 last_path: abs,
276 fingerprint,
277 tracked_at_ms: now_ms(),
278 };
279 self.locked(|ledger| {
280 ledger.entries.insert(id.clone(), entry);
281 Ok(((), true))
282 })?;
283 Ok(id)
284 }
285
286 pub fn track_or_get(&self, path: &Path) -> Result<StableId> {
289 let abs = absolute(path)?;
290 let key = FileKey::of_path(&abs)?;
291 let fingerprint = Fingerprint::of_path(&abs)?;
292 self.locked(|ledger| {
293 if let Some(existing) = ledger.entries.values().find(|t| t.key == key) {
294 return Ok((existing.id.clone(), false));
295 }
296 let id = new_id()?;
297 ledger.entries.insert(
298 id.clone(),
299 Tracked {
300 id: id.clone(),
301 key,
302 last_path: abs,
303 fingerprint,
304 tracked_at_ms: now_ms(),
305 },
306 );
307 Ok((id, true))
308 })
309 }
310
311 pub fn lookup(&self, id: &str) -> Result<Option<Tracked>> {
312 self.locked(|ledger| Ok((ledger.entries.get(id).cloned(), false)))
313 }
314
315 pub fn forget(&self, id: &str) -> Result<bool> {
316 self.locked(|ledger| {
317 let removed = ledger.entries.remove(id).is_some();
318 Ok((removed, removed))
319 })
320 }
321
322 pub fn rebind(&self, id: &str, path: &Path) -> Result<()> {
324 let abs = absolute(path)?;
325 let key = FileKey::of_path(&abs)?;
326 let fingerprint = Fingerprint::of_path(&abs)?;
327 self.locked(|ledger| {
328 let entry = ledger
329 .entries
330 .get_mut(id)
331 .ok_or_else(|| FsError::UnknownId(id.to_string()))?;
332 entry.key = key;
333 entry.fingerprint = fingerprint;
334 entry.last_path = abs;
335 Ok(((), true))
336 })
337 }
338
339 pub fn resolve(&self, id: &str, roots: &[PathBuf]) -> Result<Resolution> {
342 let tracked = self
343 .lookup(id)?
344 .ok_or_else(|| FsError::UnknownId(id.to_string()))?;
345
346 if let Ok(key) = FileKey::of_path(&tracked.last_path) {
348 if key == tracked.key {
349 return Ok(Resolution::Unchanged(tracked.last_path));
350 }
351 }
352
353 let mut candidates: Vec<PathBuf> = Vec::new();
355 let mut moved: Option<PathBuf> = None;
356 let mut budget = self.limits.max_entries;
357 for root in roots {
358 walk(
359 root,
360 0,
361 self.limits.max_depth,
362 &mut budget,
363 &mut |path, meta| {
364 if FileKey::of_meta(meta, path) == Some(tracked.key) {
365 moved = Some(path.to_path_buf());
366 return true;
367 }
368 if meta.len() == tracked.fingerprint.size && candidates.len() < 64 {
369 candidates.push(path.to_path_buf());
370 }
371 false
372 },
373 );
374 if moved.is_some() {
375 break;
376 }
377 }
378 if let Some(path) = moved {
379 self.update_path(id, &path, None)?;
380 return Ok(Resolution::Moved(path));
381 }
382
383 let mut shortlist = Vec::new();
385 if tracked.last_path.is_file() {
386 shortlist.push(tracked.last_path.clone());
387 }
388 shortlist.extend(candidates);
389 for path in shortlist {
390 if Fingerprint::of_path(&path).ok() == Some(tracked.fingerprint) {
391 let key = FileKey::of_path(&path)?;
392 self.update_path(id, &path, Some(key))?;
393 return Ok(Resolution::Replaced(path));
394 }
395 }
396 Ok(Resolution::Missing)
397 }
398
399 fn update_path(&self, id: &str, path: &Path, new_key: Option<FileKey>) -> Result<()> {
400 self.locked(|ledger| {
401 if let Some(entry) = ledger.entries.get_mut(id) {
402 entry.last_path = path.to_path_buf();
403 if let Some(key) = new_key {
404 entry.key = key;
405 }
406 }
407 Ok(((), true))
408 })
409 }
410}
411
412fn walk(
413 dir: &Path,
414 depth: usize,
415 max_depth: usize,
416 budget: &mut usize,
417 visit: &mut dyn FnMut(&Path, &std::fs::Metadata) -> bool,
418) -> bool {
419 let Ok(entries) = std::fs::read_dir(dir) else {
420 return false;
421 };
422 for entry in entries.flatten() {
423 if *budget == 0 {
424 return true;
425 }
426 *budget -= 1;
427 let Ok(meta) = entry.metadata() else { continue };
429 let path = entry.path();
430 if meta.file_type().is_symlink() {
431 continue;
432 }
433 if meta.is_file() {
434 if visit(&path, &meta) {
435 return true;
436 }
437 } else if meta.is_dir()
438 && depth < max_depth
439 && walk(&path, depth + 1, max_depth, budget, visit)
440 {
441 return true;
442 }
443 }
444 false
445}
446
447fn absolute(path: &Path) -> Result<PathBuf> {
448 if path.is_absolute() {
449 Ok(path.to_path_buf())
450 } else {
451 Ok(std::env::current_dir()
452 .map_err(|e| FsError::io(path, e))?
453 .join(path))
454 }
455}
456
457fn now_ms() -> u64 {
458 SystemTime::now()
459 .duration_since(UNIX_EPOCH)
460 .map(|d| d.as_millis() as u64)
461 .unwrap_or(0)
462}
463
464fn new_id() -> Result<StableId> {
465 let mut bytes = [0u8; 16];
466 random_bytes(&mut bytes)?;
467 Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
468}
469
470fn random_bytes(buf: &mut [u8]) -> Result<()> {
471 #[cfg(unix)]
472 {
473 File::open("/dev/urandom")
474 .and_then(|mut f| f.read_exact(buf))
475 .map_err(|e| FsError::io("/dev/urandom", e))
476 }
477 #[cfg(windows)]
478 {
479 #[link(name = "bcrypt")]
480 unsafe extern "system" {
481 fn BCryptGenRandom(
482 algorithm: *mut std::ffi::c_void,
483 buffer: *mut u8,
484 length: u32,
485 flags: u32,
486 ) -> i32;
487 }
488 let status =
490 unsafe { BCryptGenRandom(std::ptr::null_mut(), buf.as_mut_ptr(), buf.len() as u32, 2) };
491 if status >= 0 {
492 Ok(())
493 } else {
494 Err(FsError::io(
495 "BCryptGenRandom",
496 std::io::Error::other("RNG failure"),
497 ))
498 }
499 }
500 #[cfg(not(any(unix, windows)))]
501 {
502 let _ = buf;
503 Err(FsError::Unavailable("random bytes"))
504 }
505}