1use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload};
21use chacha20poly1305::{XChaCha20Poly1305, XNonce};
22use hmac::{Hmac, Mac};
23use sha2::{Digest, Sha256};
24use std::collections::{BTreeMap, BTreeSet};
25use std::io::{BufReader, BufWriter, Read, Write};
26use std::path::{Path, PathBuf};
27
28pub const LARGEST: u64 = 64 << 20;
30pub const UNUSED_DAYS: i64 = 14;
41pub const UNUSED_DAYS_BEFORE: i64 = 90;
48pub const KEEPING: u32 = KEEPING_MOVED;
52pub const KEEPING_TWO_WEEKS: u32 = 1;
55pub const KEEPING_MOVED: u32 = 2;
61pub const FRESH_DAYS: i64 = UNUSED_DAYS / 2;
67const PIECE: usize = 1 << 20;
69const INFLATED: usize = 64 << 10;
71const MAGIC: &[u8] = b"sioul-blob 1\n";
73const END: u32 = 1 << 31;
75const AROUND: usize = 24 + 16;
77
78#[derive(Debug, PartialEq, Eq)]
80pub enum Fault {
81 Missing,
83 Broken,
85 Local(String),
87 Room,
89}
90
91fn keys(key: &[u8; 32]) -> ([u8; 32], [u8; 32]) {
94 let made = hkdf::Hkdf::<Sha256>::new(None, key);
95 let (mut sealing, mut naming) = ([0u8; 32], [0u8; 32]);
96 made.expand(b"sioul blobs 1: sealing", &mut sealing).expect("32 bytes is a length HKDF gives");
97 made.expand(b"sioul blobs 1: naming", &mut naming).expect("32 bytes is a length HKDF gives");
98 (sealing, naming)
99}
100
101pub fn name(key: &[u8; 32], hash: &str) -> String {
104 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(&keys(key).1).expect("HMAC takes any key");
105 mac.update(hash.as_bytes());
106 hex(&mac.finalize().into_bytes())
107}
108
109pub fn path(folder: &Path, key: &[u8; 32], hash: &str) -> PathBuf {
110 folder.join("blobs").join(name(key, hash))
111}
112
113fn hex(bytes: &[u8]) -> String {
114 bytes.iter().map(|b| format!("{b:02x}")).collect()
115}
116
117#[cfg(test)]
118thread_local! {
119 pub(crate) static HASHED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
121}
122
123pub fn hash_file(path: &Path) -> std::io::Result<(String, u64)> {
125 #[cfg(test)]
126 HASHED.with(|n| n.set(n.get() + 1));
127 let mut reading = Hashing { inner: std::fs::File::open(path)?, hasher: Sha256::new(), size: 0 };
128 std::io::copy(&mut reading, &mut std::io::sink())?;
129 Ok((hex(&reading.hasher.finalize()), reading.size))
130}
131
132pub fn fingerprint(meta: &std::fs::Metadata) -> (u64, u64, u64) {
136 let modified = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos() as u64);
137 #[cfg(unix)]
138 let other = {
139 use std::os::unix::fs::MetadataExt;
140 meta.ino().wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ (meta.ctime() as u64).wrapping_mul(1_000_000_000).wrapping_add(meta.ctime_nsec() as u64)
141 };
142 #[cfg(not(unix))]
143 let other = meta.created().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos() as u64);
144 (meta.len(), modified, other)
145}
146
147struct Hashing<T> {
149 inner: T,
150 hasher: Sha256,
151 size: u64,
152}
153
154impl<R: Read> Read for Hashing<R> {
155 fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
156 let n = self.inner.read(buffer)?;
157 self.hasher.update(&buffer[..n]);
158 self.size += n as u64;
159 Ok(n)
160 }
161}
162
163impl<W: Write> Write for Hashing<W> {
164 fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
165 let n = self.inner.write(buffer)?;
166 self.hasher.update(&buffer[..n]);
167 self.size += n as u64;
168 Ok(n)
169 }
170
171 fn flush(&mut self) -> std::io::Result<()> {
172 self.inner.flush()
173 }
174}
175
176fn bound(name: &str, index: u64, end: bool) -> String {
178 format!("blob\u{1f}{name}\u{1f}{index}{}", if end { "\u{1f}end" } else { "" })
179}
180
181fn fill(reader: &mut impl Read, buffer: &mut [u8]) -> std::io::Result<usize> {
183 let mut filled = 0;
184 while filled < buffer.len() {
185 match reader.read(&mut buffer[filled..]) {
186 Ok(0) => break,
187 Ok(n) => filled += n,
188 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
189 Err(e) => return Err(e),
190 }
191 }
192 Ok(filled)
193}
194
195pub fn put(folder: &Path, key: &[u8; 32], source: &Path, hash: &str) -> Result<Option<u64>, String> {
203 let target = path(folder, key, hash);
204 if let Ok(meta) = std::fs::metadata(&target)
205 && meta.len() > 0
206 {
207 let old = meta.modified().ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > FRESH_DAYS as u64 * 86_400);
208 if old {
209 let _ = std::fs::File::options().write(true).open(&target).and_then(|f| f.set_modified(std::time::SystemTime::now()));
210 }
211 return Ok(None);
212 }
213 seal_into(folder, key, source, hash).map(Some)
214}
215
216pub fn put_again(folder: &Path, key: &[u8; 32], source: &Path, hash: &str) -> Result<u64, String> {
219 seal_into(folder, key, source, hash)
220}
221
222fn seal_into(folder: &Path, key: &[u8; 32], source: &Path, hash: &str) -> Result<u64, String> {
223 let target = path(folder, key, hash);
224 let name = name(key, hash);
225 let dir = folder.join("blobs");
226 crate::share::private_dirs(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
227 let temporary = crate::share::temporary(&target);
230 let sealed = (|| {
231 let fail = |e: std::io::Error| format!("{}: {e}", temporary.display());
232 let mut reading = Hashing { inner: std::fs::File::open(source).map_err(|e| format!("{}: {e}", source.display()))?, hasher: Sha256::new(), size: 0 };
233 let mut packed = flate2::read::GzEncoder::new(&mut reading, flate2::Compression::default());
234 let mut out = BufWriter::new(crate::share::new_private(&temporary).map_err(fail)?);
235 out.write_all(MAGIC).map_err(fail)?;
236 let cipher = XChaCha20Poly1305::new((&keys(key).0).into());
237 let mut piece = vec![0u8; PIECE];
238 let mut index = 0;
239 loop {
240 let n = fill(&mut packed, &mut piece).map_err(|e| format!("{}: {e}", source.display()))?;
241 if n == 0 {
242 break;
243 }
244 if packed.get_ref().size > LARGEST {
245 return Err(format!("{}: changed while it was sealed", source.display()));
246 }
247 write_piece(&mut out, &cipher, &name, index, false, &piece[..n]).map_err(fail)?;
248 index += 1;
249 }
250 write_piece(&mut out, &cipher, &name, index, true, &[]).map_err(fail)?;
251 drop(packed);
252 if hex(&reading.hasher.finalize()) != hash {
253 return Err(format!("{}: changed while it was sealed", source.display()));
254 }
255 let file = out.into_inner().map_err(|e| fail(e.into_error()))?;
256 file.sync_all().map_err(fail)?;
257 let size = file.metadata().map_err(fail)?.len();
258 std::fs::rename(&temporary, &target).map_err(|e| format!("{}: {e}", target.display()))?;
259 Ok(size)
260 })();
261 if sealed.is_err() {
262 let _ = std::fs::remove_file(&temporary);
263 }
264 sealed
265}
266
267fn write_piece(out: &mut impl Write, cipher: &XChaCha20Poly1305, name: &str, index: u64, end: bool, plain: &[u8]) -> std::io::Result<()> {
269 let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
270 let sealed = cipher.encrypt(&nonce, Payload { msg: plain, aad: bound(name, index, end).as_bytes() }).map_err(|_| std::io::Error::other("sealing"))?;
271 let length = (nonce.len() + sealed.len()) as u32 | if end { END } else { 0 };
272 out.write_all(&length.to_be_bytes())?;
273 out.write_all(&nonce)?;
274 out.write_all(&sealed)
275}
276
277pub fn get(folder: &Path, key: &[u8; 32], hash: &str, target: &Path) -> Result<(), Fault> {
285 get_unless(folder, key, hash, target, false)
286}
287
288pub(crate) fn get_unless(folder: &Path, key: &[u8; 32], hash: &str, target: &Path, damaged: bool) -> Result<(), Fault> {
291 let here = if damaged { Err(Fault::Broken) } else { get_from(folder, key, hash, target) };
292 let Err(fault @ (Fault::Missing | Fault::Broken)) = here else { return here };
293 let Some(fetched) = crate::remote::overlay(folder) else { return Err(fault) };
294 match get_from(&fetched, key, hash, target) {
295 Ok(()) => Ok(()),
296 Err(Fault::Broken) => {
297 let _ = std::fs::remove_file(path(&fetched, key, hash));
298 Err(fault)
299 }
300 Err(Fault::Missing) => Err(fault),
301 Err(other) => Err(other),
302 }
303}
304
305fn get_from(folder: &Path, key: &[u8; 32], hash: &str, target: &Path) -> Result<(), Fault> {
306 let opened = open_into(folder, key, hash, target);
307 if opened.is_err() {
308 let _ = std::fs::remove_file(target);
309 }
310 opened
311}
312
313pub fn check(path: &Path, key: &[u8; 32], hash: &str) -> Result<(), Fault> {
317 let Some(file) = present(path) else { return Err(Fault::Missing) };
318 let mut reader = BufReader::new(file);
319 begins(&mut reader)?;
320 unseal(reader, key, &name(key, hash), hash, std::io::sink(), &|e| Fault::Local(format!("{}: {e}", path.display()))).map(|_| ())
321}
322
323fn present(path: &Path) -> Option<std::fs::File> {
326 let file = std::fs::File::open(path).ok()?;
327 (file.metadata().map_or(0, |m| m.len()) > 0).then_some(file)
328}
329
330fn begins(reader: &mut impl Read) -> Result<(), Fault> {
332 let mut magic = [0u8; MAGIC.len()];
333 if reader.read_exact(&mut magic).is_err() || magic != MAGIC {
334 return Err(Fault::Broken);
335 }
336 Ok(())
337}
338
339fn open_into(folder: &Path, key: &[u8; 32], hash: &str, target: &Path) -> Result<(), Fault> {
340 let name = name(key, hash);
341 let Some(file) = present(&folder.join("blobs").join(&name)) else { return Err(Fault::Missing) };
342 let mut reader = BufReader::new(file);
343 begins(&mut reader)?;
344 let local = |e: std::io::Error| Fault::Local(format!("{}: {e}", target.display()));
345 let out = BufWriter::new(crate::share::new_private(target).map_err(local)?);
347 let out = unseal(reader, key, &name, hash, out, &local)?;
348 out.into_inner().map_err(|e| local(e.into_error()))?.sync_all().map_err(local)
349}
350
351fn unseal<W: Write>(mut reader: impl Read, key: &[u8; 32], name: &str, hash: &str, out: W, local: &dyn Fn(std::io::Error) -> Fault) -> Result<W, Fault> {
354 let mut unpacked = flate2::write::GzDecoder::new(Hashing { inner: out, hasher: Sha256::new(), size: 0 });
355 let cipher = XChaCha20Poly1305::new((&keys(key).0).into());
356 let fault = |e: std::io::Error| if matches!(e.kind(), std::io::ErrorKind::InvalidInput | std::io::ErrorKind::InvalidData | std::io::ErrorKind::WriteZero) { Fault::Broken } else { local(e) };
359 let mut index = 0;
360 loop {
361 let mut length = [0u8; 4];
362 reader.read_exact(&mut length).map_err(|_| Fault::Broken)?;
364 let length = u32::from_be_bytes(length);
365 let (end, length) = (length & END != 0, (length & !END) as usize);
366 if !(AROUND..=PIECE + AROUND).contains(&length) {
367 return Err(Fault::Broken);
368 }
369 let mut sealed = vec![0u8; length];
370 reader.read_exact(&mut sealed).map_err(|_| Fault::Broken)?;
371 let (nonce, body) = sealed.split_at(24);
372 let plain = cipher.decrypt(XNonce::from_slice(nonce), Payload { msg: body, aad: bound(name, index, end).as_bytes() }).map_err(|_| Fault::Broken)?;
373 if end {
374 break;
375 }
376 for part in plain.chunks(INFLATED) {
378 unpacked.write_all(part).map_err(fault)?;
379 if unpacked.get_ref().size > LARGEST {
380 return Err(Fault::Broken);
381 }
382 }
383 index += 1;
384 }
385 if reader.read(&mut [0u8; 1]).map_or(true, |n| n > 0) {
387 return Err(Fault::Broken);
388 }
389 let Hashing { inner, hasher, .. } = unpacked.finish().map_err(fault)?;
390 if hex(&hasher.finalize()) != hash {
391 return Err(Fault::Broken);
392 }
393 Ok(inner)
394}
395
396pub fn sweep(folder: &Path, mine: &mut BTreeMap<String, i64>, used: &BTreeSet<String>, now_ms: i64) {
402 sweep_after(folder, mine, used, now_ms, UNUSED_DAYS);
403}
404
405pub fn sweep_after(folder: &Path, mine: &mut BTreeMap<String, i64>, used: &BTreeSet<String>, now_ms: i64, days: i64) {
408 let unused = days * 86_400_000;
409 let day = 86_400_000;
416 mine.retain(|name, last| {
417 if used.contains(name) {
418 if now_ms - *last >= day {
419 *last = now_ms;
420 }
421 return true;
422 }
423 if now_ms - *last < unused {
424 return true;
425 }
426 let path = folder.join("blobs").join(name);
427 let fresh = std::fs::metadata(&path).ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).is_some_and(|age| (age.as_millis() as i64) < unused);
428 if fresh {
429 *last = now_ms;
430 return true;
431 }
432 let _ = std::fs::remove_file(path);
433 false
434 });
435}
436
437#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
445pub struct Wanted {
446 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
448 pub blobs: BTreeMap<String, Want>,
449 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
454 pub media: BTreeMap<String, Want>,
455 #[serde(default)]
457 pub large_at: i64,
458}
459
460#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
462pub struct Want {
463 #[serde(default)]
465 pub hash: String,
466 #[serde(default)]
468 pub size: u64,
469 #[serde(default)]
471 pub since: i64,
472 #[serde(default)]
474 pub tries: u32,
475 #[serde(default)]
476 pub again: i64,
477}
478
479impl Wanted {
480 pub fn path(memory: &Path) -> PathBuf {
482 memory.with_file_name("wanted.toml")
483 }
484
485 pub fn load(memory: &Path) -> Wanted {
486 std::fs::read_to_string(Wanted::path(memory)).ok().and_then(|text| toml::from_str(&text).ok()).unwrap_or_default()
487 }
488
489 pub fn change(memory: &Path, change: impl FnOnce(&mut Wanted)) -> Result<(), String> {
492 let path = Wanted::path(memory);
493 sioul_core::filelock::with_lock(&path, || {
494 let before = Wanted::load(memory);
495 let mut wanted = before.clone();
496 change(&mut wanted);
497 if wanted == before {
498 return Ok(());
499 }
500 if wanted.blobs.is_empty() && wanted.media.is_empty() {
501 return match std::fs::remove_file(&path) {
502 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{}: {e}", path.display())),
503 _ => Ok(()),
504 };
505 }
506 let text = toml::to_string(&wanted).map_err(|e| e.to_string())?;
507 crate::share::write_atomically(&path, format!("# Sioul: the others' sealed notes and papers this device waits for (docs/database.md). Never shared.\n{text}").as_bytes())
508 })
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 fn scratch(name: &str) -> PathBuf {
517 let dir = std::env::temp_dir().join(format!("sioul-blobs-{name}-{}", std::process::id()));
518 let _ = std::fs::remove_dir_all(&dir);
519 std::fs::create_dir_all(&dir).unwrap();
520 dir
521 }
522
523 fn temporary(base: &Path) -> PathBuf {
524 crate::share::temporary(&base.join("scan.pdf"))
525 }
526
527 #[test]
528 fn sealed_whole_and_opened_whole() {
529 let base = scratch("whole");
530 let folder = base.join("vault");
531 let key = [3u8; 32];
532 let mut bytes = Vec::new();
534 let mut x = 7u32;
535 while bytes.len() < 2 * PIECE + 1000 {
536 x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
537 bytes.extend_from_slice(&x.to_le_bytes());
538 }
539 let source = base.join("scan.pdf");
540 std::fs::write(&source, &bytes).unwrap();
541 let (hash, size) = hash_file(&source).unwrap();
542 assert_eq!(size, bytes.len() as u64);
543 let sealed_size = put(&folder, &key, &source, &hash).unwrap().unwrap();
544 assert_eq!(std::fs::metadata(path(&folder, &key, &hash)).unwrap().len(), sealed_size);
545 assert_eq!(put(&folder, &key, &source, &hash).unwrap(), None, "a content already there is not sealed again");
546 let target = temporary(&base);
547 get(&folder, &key, &hash, &target).unwrap();
548 assert_eq!(std::fs::read(&target).unwrap(), bytes);
549 let other = temporary(&base);
551 assert_eq!(get(&folder, &[4u8; 32], &hash, &other), Err(Fault::Missing), "another key names it otherwise");
552 let sealed = path(&folder, &key, &hash);
553 std::fs::copy(&sealed, path(&folder, &[4u8; 32], &hash)).unwrap();
554 assert_eq!(get(&folder, &[4u8; 32], &hash, &other), Err(Fault::Broken));
555 assert!(!other.exists(), "nothing half written is left");
556 let whole = std::fs::read(&sealed).unwrap();
558 let mut damaged = whole.clone();
559 damaged[whole.len() / 2] ^= 0x40;
560 for (bytes, fault) in [(damaged, Fault::Broken), (whole[..whole.len() - 50].to_vec(), Fault::Broken), ([whole.clone(), b"more".to_vec()].concat(), Fault::Broken), (Vec::new(), Fault::Missing)] {
561 std::fs::write(&sealed, &bytes).unwrap();
562 assert_eq!(get(&folder, &key, &hash, &temporary(&base)), Err(fault));
563 }
564 put_again(&folder, &key, &source, &hash).unwrap();
566 get(&folder, &key, &hash, &temporary(&base)).unwrap();
567 assert!(!name(&key, &hash).contains(&hash[..16]));
569 let mut plain = <Hmac<Sha256> as Mac>::new_from_slice(&key).unwrap();
570 plain.update(hash.as_bytes());
571 assert_ne!(name(&key, &hash), hex(&plain.finalize().into_bytes()));
572 let _ = std::fs::remove_dir_all(&base);
573 }
574
575 #[test]
578 fn a_copy_is_checked_whole_without_being_written() {
579 let base = scratch("check");
580 let folder = base.join("vault");
581 let key = [6u8; 32];
582 let source = base.join("note.md");
583 std::fs::write(&source, "a note\n".repeat(300)).unwrap();
584 let (hash, _) = hash_file(&source).unwrap();
585 put(&folder, &key, &source, &hash).unwrap();
586 let sealed = path(&folder, &key, &hash);
587 assert_eq!(check(&sealed, &key, &hash), Ok(()));
588 assert_eq!(check(&sealed, &key, &"0".repeat(64)), Err(Fault::Broken), "another content");
589 let whole = std::fs::read(&sealed).unwrap();
590 let mut damaged = whole.clone();
591 damaged[whole.len() - 30] ^= 0x01;
592 std::fs::write(&sealed, &damaged).unwrap();
593 assert_eq!(check(&sealed, &key, &hash), Err(Fault::Broken));
594 std::fs::write(&sealed, b"").unwrap();
595 assert_eq!(check(&sealed, &key, &hash), Err(Fault::Missing));
596 assert_eq!(check(&base.join("none"), &key, &hash), Err(Fault::Missing));
597 assert_eq!(std::fs::read_dir(folder.join("blobs")).unwrap().count(), 1, "nothing written beside");
598 let _ = std::fs::remove_dir_all(&base);
599 }
600
601 #[test]
602 fn a_bomb_stops_at_the_largest_file() {
603 let base = scratch("bomb");
604 let folder = base.join("vault");
605 let key = [5u8; 32];
606 let zeros = vec![0u8; 3 * PIECE];
608 let mut packed = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
609 for _ in 0..30 {
610 packed.write_all(&zeros).unwrap();
611 }
612 let packed = packed.finish().unwrap();
613 let hash = "0".repeat(64);
614 let name = name(&key, &hash);
615 let cipher = XChaCha20Poly1305::new((&keys(&key).0).into());
616 let mut out = MAGIC.to_vec();
617 for (index, piece) in packed.chunks(PIECE).enumerate() {
618 write_piece(&mut out, &cipher, &name, index as u64, false, piece).unwrap();
619 }
620 write_piece(&mut out, &cipher, &name, packed.chunks(PIECE).count() as u64, true, &[]).unwrap();
621 std::fs::create_dir_all(folder.join("blobs")).unwrap();
622 std::fs::write(folder.join("blobs").join(&name), &out).unwrap();
623 let target = temporary(&base);
624 assert_eq!(get(&folder, &key, &hash, &target), Err(Fault::Broken));
625 assert!(!target.exists());
626 let _ = std::fs::remove_dir_all(&base);
627 }
628
629 #[test]
630 fn a_file_changed_while_sealed_is_not_sent() {
631 let base = scratch("changed");
632 let folder = base.join("vault");
633 let source = base.join("note.md");
634 std::fs::write(&source, "before").unwrap();
635 let (hash, _) = hash_file(&source).unwrap();
636 std::fs::write(&source, "after, longer").unwrap();
637 assert!(put(&folder, &[1u8; 32], &source, &hash).is_err());
638 assert!(!path(&folder, &[1u8; 32], &hash).exists());
639 assert_eq!(std::fs::read_dir(folder.join("blobs")).unwrap().count(), 0, "no hidden file left behind");
640 let _ = std::fs::remove_dir_all(&base);
641 }
642
643 #[test]
646 fn unused_ones_go_after_two_weeks() {
647 let base = scratch("sweep");
648 let folder = base.join("vault");
649 std::fs::create_dir_all(folder.join("blobs")).unwrap();
650 let old = std::time::SystemTime::now() - std::time::Duration::from_secs(100 * 86_400);
651 for name in ["used", "unused", "recent", "fresh", "just-past"] {
652 std::fs::write(folder.join("blobs").join(name), "x").unwrap();
653 if name != "fresh" {
654 std::fs::File::options().write(true).open(folder.join("blobs").join(name)).unwrap().set_modified(old).unwrap();
655 }
656 }
657 let day = 86_400_000;
658 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as i64;
659 let mut mine: BTreeMap<String, i64> = [
660 ("used".to_string(), now - 100 * day),
661 ("unused".to_string(), now - 100 * day),
662 ("recent".to_string(), now - (UNUSED_DAYS - 1) * day),
663 ("fresh".to_string(), now - 100 * day),
664 ("just-past".to_string(), now - UNUSED_DAYS * day - 1),
665 ]
666 .into();
667 sweep(&folder, &mut mine, &["used".to_string()].into(), now);
668 assert!(folder.join("blobs/used").exists() && folder.join("blobs/recent").exists());
669 assert!(folder.join("blobs/fresh").exists(), "sealed or named again lately: kept");
670 assert!(!folder.join("blobs/unused").exists());
671 assert!(!folder.join("blobs/just-past").exists(), "two weeks and a moment: gone");
672 assert_eq!(mine.get("used"), Some(&now));
673 let _ = std::fs::remove_dir_all(&base);
674 }
675
676 #[test]
681 fn a_content_used_again_is_made_fresh_within_the_sweeps_time() {
682 const { assert!(FRESH_DAYS < UNUSED_DAYS && FRESH_DAYS > 0) };
683 let base = scratch("fresh");
684 let folder = base.join("vault");
685 let key = [8u8; 32];
686 let day = std::time::Duration::from_secs(86_400);
687 for (n, age, touched) in [(1, FRESH_DAYS + 1, true), (2, FRESH_DAYS - 1, false)] {
688 let source = base.join(format!("note-{n}.md"));
689 std::fs::write(&source, format!("note {n}\n").repeat(50)).unwrap();
690 let (hash, _) = hash_file(&source).unwrap();
691 assert!(put(&folder, &key, &source, &hash).unwrap().is_some());
692 let sealed = path(&folder, &key, &hash);
693 let then = std::time::SystemTime::now() - day * age as u32;
694 std::fs::File::options().write(true).open(&sealed).unwrap().set_modified(then).unwrap();
695 assert_eq!(put(&folder, &key, &source, &hash).unwrap(), None, "there already: not sealed again");
696 let modified = std::fs::metadata(&sealed).unwrap().modified().unwrap();
697 assert_eq!(modified > then + day, touched, "sealed {age} days ago");
698 let mut mine: BTreeMap<String, i64> = [(name(&key, &hash), 0)].into();
700 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as i64;
701 sweep(&folder, &mut mine, &BTreeSet::new(), now);
702 assert!(sealed.exists(), "sealed {age} days ago, used again: kept");
703 }
704 let _ = std::fs::remove_dir_all(&base);
705 }
706}