1use crate::error::Result;
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21
22const MAGIC: &[u8; 4] = b"RSIX";
23const VERSION: u16 = 1;
24pub const INDEX_FILE: &str = ".rst-index";
26
27#[derive(Clone)]
28struct Entry {
29 size: u64,
30 mtime: i64,
31 chunk_size: u32,
32 hashes: Vec<[u8; 32]>,
33}
34
35#[derive(Default)]
37pub struct ChunkIndex {
38 path: PathBuf,
39 entries: HashMap<String, Entry>,
40 dirty: bool,
41}
42
43impl ChunkIndex {
44 pub fn load(root: &Path) -> Self {
50 let path = root.join(INDEX_FILE);
51 let mut index = Self {
52 path,
53 entries: HashMap::new(),
54 dirty: false,
55 };
56 let Ok(raw) = std::fs::read(&index.path) else {
57 return index;
58 };
59 if let Some(entries) = decode(&raw) {
60 index.entries = entries;
61 } else {
62 tracing::warn!(path = %index.path.display(), "chunk index unreadable; rebuilding");
63 }
64 index
65 }
66
67 pub fn get(&self, rel: &str, size: u64, mtime: i64, chunk_size: u32) -> Option<&[[u8; 32]]> {
69 let e = self.entries.get(rel)?;
70 (e.size == size && e.mtime == mtime && e.chunk_size == chunk_size)
71 .then_some(e.hashes.as_slice())
72 }
73
74 pub fn insert(
75 &mut self,
76 rel: &str,
77 size: u64,
78 mtime: i64,
79 chunk_size: u32,
80 hashes: Vec<[u8; 32]>,
81 ) {
82 self.entries.insert(
83 rel.to_string(),
84 Entry {
85 size,
86 mtime,
87 chunk_size,
88 hashes,
89 },
90 );
91 self.dirty = true;
92 }
93
94 pub fn retain(&mut self, keep: &std::collections::HashSet<String>) {
97 let before = self.entries.len();
98 self.entries.retain(|k, _| keep.contains(k));
99 if self.entries.len() != before {
100 self.dirty = true;
101 }
102 }
103
104 pub fn len(&self) -> usize {
105 self.entries.len()
106 }
107
108 pub fn is_empty(&self) -> bool {
109 self.entries.is_empty()
110 }
111
112 pub fn save(&mut self) -> Result<()> {
117 if !self.dirty {
118 return Ok(());
119 }
120 if let Some(parent) = self.path.parent() {
121 std::fs::create_dir_all(parent)?;
122 }
123 let body = encode(&self.entries);
124 let tmp = self.path.with_extension("tmp");
125 std::fs::write(&tmp, &body)?;
126 std::fs::rename(&tmp, &self.path)?;
127 self.dirty = false;
128 Ok(())
129 }
130}
131
132fn encode(entries: &HashMap<String, Entry>) -> Vec<u8> {
133 let mut body = Vec::with_capacity(entries.len() * 96);
134 body.extend_from_slice(&(entries.len() as u32).to_le_bytes());
135 for (rel, e) in entries {
136 let p = rel.as_bytes();
137 body.extend_from_slice(&(p.len() as u16).to_le_bytes());
138 body.extend_from_slice(p);
139 body.extend_from_slice(&e.size.to_le_bytes());
140 body.extend_from_slice(&e.mtime.to_le_bytes());
141 body.extend_from_slice(&e.chunk_size.to_le_bytes());
142 body.extend_from_slice(&(e.hashes.len() as u32).to_le_bytes());
143 for h in &e.hashes {
144 body.extend_from_slice(h);
145 }
146 }
147 let mut out = Vec::with_capacity(body.len() + 38);
148 out.extend_from_slice(MAGIC);
149 out.extend_from_slice(&VERSION.to_le_bytes());
150 out.extend_from_slice(blake3::hash(&body).as_bytes());
151 out.extend_from_slice(&body);
152 out
153}
154
155fn decode(raw: &[u8]) -> Option<HashMap<String, Entry>> {
156 if raw.len() < 38 || &raw[0..4] != MAGIC {
157 return None;
158 }
159 if u16::from_le_bytes(raw[4..6].try_into().ok()?) != VERSION {
160 return None;
161 }
162 let body = &raw[38..];
163 if blake3::hash(body).as_bytes() != &raw[6..38] {
165 return None;
166 }
167
168 let mut pos = 0usize;
169 let mut take = |n: usize| -> Option<&[u8]> {
170 let end = pos.checked_add(n)?;
171 let s = body.get(pos..end)?;
172 pos = end;
173 Some(s)
174 };
175 let count = u32::from_le_bytes(take(4)?.try_into().ok()?) as usize;
176 if count > 8_000_000 {
177 return None;
178 }
179 let mut map = HashMap::with_capacity(count.min(4096));
180 for _ in 0..count {
181 let plen = u16::from_le_bytes(take(2)?.try_into().ok()?) as usize;
182 let rel = String::from_utf8(take(plen)?.to_vec()).ok()?;
183 let size = u64::from_le_bytes(take(8)?.try_into().ok()?);
184 let mtime = i64::from_le_bytes(take(8)?.try_into().ok()?);
185 let chunk_size = u32::from_le_bytes(take(4)?.try_into().ok()?);
186 let n = u32::from_le_bytes(take(4)?.try_into().ok()?) as usize;
187 if n > (1 << 26) {
188 return None;
189 }
190 let mut hashes = Vec::with_capacity(n.min(4096));
191 for _ in 0..n {
192 let mut h = [0u8; 32];
193 h.copy_from_slice(take(32)?);
194 hashes.push(h);
195 }
196 map.insert(
197 rel,
198 Entry {
199 size,
200 mtime,
201 chunk_size,
202 hashes,
203 },
204 );
205 }
206 Some(map)
207}
208
209pub fn mtime_of(meta: &std::fs::Metadata) -> i64 {
217 meta.modified()
218 .ok()
219 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
220 .map(|d| d.as_nanos().min(i64::MAX as u128) as i64)
221 .unwrap_or(0)
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 fn hashes(n: usize) -> Vec<[u8; 32]> {
229 (0..n).map(|i| [i as u8; 32]).collect()
230 }
231
232 #[test]
233 fn survives_a_save_and_load() {
234 let tmp = tempfile::tempdir().unwrap();
235 let mut ix = ChunkIndex::load(tmp.path());
236 assert!(ix.is_empty());
237 ix.insert("a/b.bin", 1000, 42, 1024, hashes(4));
238 ix.save().unwrap();
239
240 let ix2 = ChunkIndex::load(tmp.path());
241 assert_eq!(ix2.len(), 1);
242 assert_eq!(ix2.get("a/b.bin", 1000, 42, 1024).unwrap().len(), 4);
243 }
244
245 #[test]
249 fn mtime_resolution_is_finer_than_a_second() {
250 let tmp = tempfile::tempdir().unwrap();
251 let p = tmp.path().join("f");
252 std::fs::write(&p, b"first").unwrap();
253 let a = mtime_of(&std::fs::metadata(&p).unwrap());
254 std::thread::sleep(std::time::Duration::from_millis(5));
255 std::fs::write(&p, b"secnd").unwrap();
256 let b = mtime_of(&std::fs::metadata(&p).unwrap());
257 assert_ne!(
258 a, b,
259 "two same-length writes 5ms apart were indistinguishable"
260 );
261 }
262
263 #[test]
264 fn a_changed_file_is_not_trusted() {
265 let tmp = tempfile::tempdir().unwrap();
266 let mut ix = ChunkIndex::load(tmp.path());
267 ix.insert("f", 1000, 42, 1024, hashes(4));
268
269 assert!(ix.get("f", 1000, 42, 1024).is_some());
270 assert!(ix.get("f", 1001, 42, 1024).is_none());
272 assert!(ix.get("f", 1000, 43, 1024).is_none());
273 assert!(ix.get("f", 1000, 42, 4096).is_none());
274 assert!(ix.get("other", 1000, 42, 1024).is_none());
275 }
276
277 #[test]
278 fn a_corrupt_index_is_discarded_rather_than_trusted() {
279 let tmp = tempfile::tempdir().unwrap();
280 let mut ix = ChunkIndex::load(tmp.path());
281 ix.insert("f", 1000, 42, 1024, hashes(8));
282 ix.save().unwrap();
283
284 let p = tmp.path().join(INDEX_FILE);
285 let mut raw = std::fs::read(&p).unwrap();
286 let last = raw.len() - 1;
287 raw[last] ^= 0xFF;
288 std::fs::write(&p, &raw).unwrap();
289
290 let ix2 = ChunkIndex::load(tmp.path());
291 assert!(ix2.is_empty(), "damaged hashes must never be trusted");
292
293 std::fs::write(&p, &raw[..20]).unwrap();
295 assert!(ChunkIndex::load(tmp.path()).is_empty());
296 std::fs::write(&p, b"not an index").unwrap();
298 assert!(ChunkIndex::load(tmp.path()).is_empty());
299 }
300
301 #[test]
302 fn retain_drops_files_that_are_gone() {
303 let tmp = tempfile::tempdir().unwrap();
304 let mut ix = ChunkIndex::load(tmp.path());
305 ix.insert("keep", 1, 1, 1024, hashes(1));
306 ix.insert("gone", 1, 1, 1024, hashes(1));
307 let keep: std::collections::HashSet<String> = ["keep".to_string()].into_iter().collect();
308 ix.retain(&keep);
309 assert_eq!(ix.len(), 1);
310 assert!(ix.get("keep", 1, 1, 1024).is_some());
311 }
312
313 #[test]
314 fn saving_is_a_no_op_when_nothing_changed() {
315 let tmp = tempfile::tempdir().unwrap();
316 let mut ix = ChunkIndex::load(tmp.path());
317 ix.save().unwrap();
318 assert!(!tmp.path().join(INDEX_FILE).exists(), "nothing to write");
319 }
320}