1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// Rust guideline compliant 2025-10-17
use std::path::Path;
use sha2::{Digest, Sha256};
/// Read a source file to a UTF-8 string, transparently handling UTF-16 LE/BE
/// (detected via BOM). Invalid non-BOM UTF-8 sequences are replaced with the
/// Unicode replacement character so legacy-encoded source can still be parsed.
/// Returns an IO error only when the file cannot be read or BOM-marked UTF-16
/// cannot be decoded.
pub fn read_source_file(path: &Path) -> std::io::Result<String> {
let bytes = std::fs::read(path)?;
// UTF-16 LE BOM: FF FE
if bytes.starts_with(&[0xFF, 0xFE]) {
let (pairs, _) = bytes[2..].as_chunks::<2>();
let u16s: Vec<u16> = pairs.iter().copied().map(u16::from_le_bytes).collect();
return String::from_utf16(&u16s)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
}
// UTF-16 BE BOM: FE FF
if bytes.starts_with(&[0xFE, 0xFF]) {
let (pairs, _) = bytes[2..].as_chunks::<2>();
let u16s: Vec<u16> = pairs.iter().copied().map(u16::from_be_bytes).collect();
return String::from_utf16(&u16s)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
}
// Strip UTF-8 BOM if present, then validate
let start = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
3
} else {
0
};
match String::from_utf8(bytes[start..].to_vec()) {
Ok(source) => Ok(source),
Err(error) => Ok(String::from_utf8_lossy(error.as_bytes()).into_owned()),
}
}
/// Get filesystem mtime (nanoseconds since epoch) and size for the incremental
/// sync pre-filter.
///
/// Nanosecond — not second — resolution is deliberate (#259). The pre-filter
/// treats a file as unchanged when both its stored mtime and size match, and
/// only then skips re-hashing it. At second resolution, an edit that lands in
/// the *same wall-clock second* as the last index and happens to preserve the
/// byte size (common when a build tool or editor rewrites an HTML file's
/// embedded script structure in place) is indistinguishable from no change, so
/// the stale index is served indefinitely. Nanosecond mtime shrinks that blind
/// window to effectively zero. The returned value feeds `FileRecord::modified_at`,
/// which is compared only against itself across syncs, so the unit change is
/// self-contained (older second-granular records simply trigger one re-hash on
/// the first sync after upgrade, which the content-hash check then resolves).
pub fn file_stat(path: &Path) -> Option<(i64, u64)> {
let meta = std::fs::metadata(path).ok()?;
let mtime = meta.modified().ok()?;
let nanos = mtime.duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos() as i64;
Some((nanos, meta.len()))
}
/// Compute SHA-256 content hash of file content.
pub fn content_hash(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let result = hasher.finalize();
hex::encode(result)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::time::{Duration, UNIX_EPOCH};
/// Regression for #259: `file_stat` must expose sub-second mtime resolution
/// so the incremental-sync pre-filter can tell apart two edits that land in
/// the same wall-clock second while keeping the same byte size. At the old
/// second granularity both mtimes collapsed to the same value and the second
/// edit was served stale.
#[test]
fn file_stat_distinguishes_same_second_edits() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("page.html");
// Two identical-size contents, so `size` cannot disambiguate them.
let mut f = File::create(&path).unwrap();
f.write_all(b"<b>AAAA</b>").unwrap();
f.sync_all().unwrap();
// Same second, 1 ms apart.
let base = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
f.set_modified(base).unwrap();
let (mtime_a, size_a) = file_stat(&path).unwrap();
let mut f2 = OpenOptions::new().write(true).open(&path).unwrap();
f2.write_all(b"<b>BBBB</b>").unwrap();
f2.sync_all().unwrap();
f2.set_modified(base + Duration::from_millis(1)).unwrap();
let (mtime_b, size_b) = file_stat(&path).unwrap();
assert_eq!(size_a, size_b, "sizes are equal by construction");
assert_ne!(
mtime_a, mtime_b,
"sub-second edits within the same second must yield distinct mtimes"
);
}
}