Files
vault/src/store/tests.rs
T

504 lines
20 KiB
Rust

// SPDX-License-Identifier: GPL-3.0-or-later
use super::*;
use crate::crypto::kem::SUITE_ID;
use crate::testutil::{TempDir, walk};
use std::sync::atomic::Ordering;
fn files(p: &Path) -> Vec<PathBuf> {
let mut v: Vec<_> = walk(p).into_iter().filter(|p| p.is_file()).collect();
v.sort();
v
}
pub(crate) fn identity(n: u8) -> Identity {
Identity::from_suite_seed(SUITE_ID, &[n; 32]).unwrap()
}
pub(crate) fn pool() -> Arc<Pool> {
Arc::new(Pool::new(4))
}
/// A fresh vault (filler kept small but present) and a store on it.
fn vault(tag: &str) -> (TempDir, Store) {
let d = TempDir::new(tag);
let root = d.0.join("store");
Store::init(&root, 0, pool()).unwrap();
let s = Store::open(&root, identity(1), pool()).unwrap();
(d, s)
}
fn name(n: &str) -> VaultName {
VaultName::new(n).unwrap()
}
fn add_bytes(s: &Store, cat: &mut Catalogue, n: &str, body: &[u8]) -> Added {
s.add_reader(cat, &mut &body[..], body.len() as u64, 0o640, Instant::now(), &name(n))
.unwrap()
}
fn get_bytes(s: &Store, rec: &Record, dir: &Path, out: &str) -> Result<Vec<u8>> {
let d = pfs::Dir::open(dir).unwrap();
s.extract(rec, &d, OsStr::new(out))?;
Ok(std::fs::read(dir.join(out)).unwrap())
}
fn objects(s: &Store) -> Vec<PathBuf> {
files(&s.root().join(OBJECTS))
}
#[test]
fn lifecycle() {
let (d, s) = vault("life");
let mut cat = s.catalogue().unwrap();
assert!(cat.is_empty(), "filler must be invisible");
let want = vec![b'x'; 70000];
assert!(!add_bytes(&s, &mut cat, "a/b", &want).replaced);
assert!(add_bytes(&s, &mut cat, "a/b", &want).replaced);
add_bytes(&s, &mut cat, "c", b"see");
let cat2 = s.catalogue().unwrap();
assert_eq!(cat2.names().collect::<Vec<_>>(), ["a/b", "c"]);
assert_eq!(cat2.records("a/b").len(), 1, "replace removed the old record");
let r = cat2.current("a/b").unwrap();
assert_eq!((r.size, r.mode), (70000, 0o640));
assert_eq!(get_bytes(&s, r, &d.0, "out").unwrap(), want);
let e = get_bytes(&s, r, &d.0, "out").unwrap_err();
assert!(e.msg.ends_with("already exists"), "{e}");
// Swapping one object for another is detected, and nothing appears.
let (dr, cr) = (cat2.current("a/b").unwrap(), cat2.current("c").unwrap());
std::fs::copy(s.path(OBJECTS, &cr.seed.object_id(0)), s.path(OBJECTS, &dr.seed.object_id(0))).unwrap();
let e = get_bytes(&s, dr, &d.0, "swapped").unwrap_err();
assert!(e.msg.contains("does not match"), "{e}");
assert!(!d.0.join("swapped").exists());
assert_eq!(std::fs::read_dir(&d.0).unwrap().count(), 2, "temporary file left behind");
}
#[test]
fn decoy_isolation_and_filler() {
let (_d, real) = vault("decoy");
let decoy = Store::open(real.root(), identity(2), pool()).unwrap();
for (s, n) in [(&real, "secret"), (&decoy, "shopping")] {
let mut cat = s.catalogue().unwrap();
assert!(cat.is_empty());
add_bytes(s, &mut cat, n, b"data");
}
for (s, n) in [(&real, "secret"), (&decoy, "shopping")] {
let cat = s.catalogue().unwrap();
assert_eq!(cat.names().collect::<Vec<_>>(), [n]);
}
let records = files(&real.root().join(RECORDS));
assert!(
records.len() as u64 >= FILLER_MIN + 2,
"only {} records; filler missing",
records.len()
);
// Records are all one size, and every object is within the bucket range.
let sizes: HashSet<u64> = records.iter().map(|p| p.metadata().unwrap().len()).collect();
assert_eq!(sizes.len(), 1, "records differ in size: {sizes:?}");
}
#[test]
fn filler_target() {
let d = TempDir::new("filler");
let root = d.0.join("v");
Store::init(&root, 24 << 20, pool()).unwrap();
assert!(Store::init(&root, 0, pool()).is_err(), "init twice");
let objs = files(&root.join(OBJECTS));
let total: u64 = objs.iter().map(|p| p.metadata().unwrap().len()).sum();
let largest = objs.iter().map(|p| p.metadata().unwrap().len()).max().unwrap();
assert!(total >= 24 << 20, "filler objects total {total}");
assert!(largest <= CHUNK_SIZE + CHUNK_SIZE / 64, "largest object {largest}");
for i in 0..256 {
for dir in [RECORDS, OBJECTS] {
let p = root.join(dir).join(format!("{i:02x}"));
assert_eq!(p.metadata().unwrap().modified().unwrap(), std::time::UNIX_EPOCH, "{}", p.display());
}
}
for f in files(&root.join(RECORDS)).iter().chain(&objs) {
assert_eq!(f.metadata().unwrap().modified().unwrap(), std::time::UNIX_EPOCH);
assert!(f.parent().unwrap().file_name().unwrap().to_str().unwrap() == &f.file_name().unwrap().to_str().unwrap()[..2]);
}
assert_eq!(std::fs::read(root.join(MARKER_NAME)).unwrap(), MARKER_CONTENT);
}
#[test]
fn chunks_and_reordering() {
let (d, s) = vault("chunks");
let before = objects(&s).len();
let mut want = vec![0u8; 2 * CHUNK_DATA as usize + 100];
crate::rng::os().fill(&mut want);
let mut cat = s.catalogue().unwrap();
add_bytes(&s, &mut cat, "big", &want);
let r = cat.current("big").unwrap();
let sizes: Vec<u64> = (0..object_count(r.size))
.map(|i| s.path(OBJECTS, &r.seed.object_id(i)).metadata().unwrap().len())
.collect();
assert!(sizes.len() == 3 && sizes[0] == sizes[1] && sizes[2] < sizes[0], "{sizes:?}");
assert_eq!(objects(&s).len() - before, 3);
assert_eq!(get_bytes(&s, r, &d.0, "out").unwrap(), want);
let (a, b) = (s.path(OBJECTS, &r.seed.object_id(0)), s.path(OBJECTS, &r.seed.object_id(1)));
let (ad, bd) = (std::fs::read(&a).unwrap(), std::fs::read(&b).unwrap());
std::fs::write(&a, &bd).unwrap();
std::fs::write(&b, &ad).unwrap();
assert!(get_bytes(&s, r, &d.0, "swapped").is_err());
assert!(!d.0.join("swapped").exists());
s.remove_all(&mut cat, &["big".to_string()], &mut |_| {}).unwrap();
assert_eq!(objects(&s).len(), before, "rm freed the objects");
}
#[test]
fn boundaries_round_trip() {
let (d, s) = vault("bounds");
let mut cat = s.catalogue().unwrap();
let c = CHUNK_DATA as usize;
for (i, n) in [0, 1, 65535 - 16, 65536 - 16, 65536 - 15, 65536, c - 1, c, c + 1]
.into_iter()
.enumerate()
{
let body: Vec<u8> = (0..n).map(|j| (j * 31 + i) as u8).collect();
add_bytes(&s, &mut cat, &format!("f{i}"), &body);
let r = cat.current(&format!("f{i}")).unwrap();
assert_eq!(get_bytes(&s, r, &d.0, &format!("o{i}")).unwrap(), body, "size {n}");
// Each object is padded: one bucket per object, all within range.
for k in 0..object_count(n as u64) {
let len = s.path(OBJECTS, &r.seed.object_id(k)).metadata().unwrap().len();
assert!(
len > MIN_PADDING && len <= CHUNK_SIZE + (32 << 10),
"object of {len} bytes for size {n}"
);
}
}
}
#[test]
fn remove_tree_and_copy_move() {
let (d, s) = vault("tree");
let before = objects(&s).len();
let mut cat = s.catalogue().unwrap();
let mut big = vec![0u8; CHUNK_DATA as usize + 1];
crate::rng::os().fill(&mut big);
for (n, b) in [("a/x", &big[..]), ("a/b/y", b"y"), ("ab/z", b"z"), ("b/w", b"w")] {
add_bytes(&s, &mut cat, n, b);
}
assert_eq!(objects(&s).len() - before, 5);
assert_eq!(cat.under("a"), ["a/b/y", "a/x"]);
// cp -r a c, then rm -r a: the copies keep the shared objects.
let pairs: Vec<(String, VaultName)> = cat
.under("a")
.into_iter()
.map(|n| (n.clone(), name(&format!("c{}", &n[1..]))))
.collect();
let mut copied = Vec::new();
s.copy_all(&mut cat, &pairs, false, &mut |f, t| copied.push((f.to_string(), t.to_string())))
.unwrap();
assert_eq!(copied.len(), 2);
assert_eq!(objects(&s).len() - before, 5, "copies share objects");
let mut done = Vec::new();
let names = cat.under("a");
s.remove_all(&mut cat, &names, &mut |n| done.push(n.to_string())).unwrap();
assert_eq!(done, ["a/b/y", "a/x"]);
assert_eq!(objects(&s).len() - before, 5, "objects still used by the copies stay");
let fresh = s.catalogue().unwrap();
assert_eq!(fresh.names().collect::<Vec<_>>(), ["ab/z", "b/w", "c/b/y", "c/x"]);
assert_eq!(get_bytes(&s, fresh.current("c/x").unwrap(), &d.0, "x").unwrap(), big);
// mv keeps one copy of each object; rm of the last name frees them.
let mut cat = fresh;
s.copy_all(&mut cat, &[("c/x".into(), name("moved"))], true, &mut |_, _| {})
.unwrap();
assert!(!cat.contains("c/x") && cat.contains("moved"));
let names: Vec<String> = cat.names().map(String::from).collect();
s.remove_all(&mut cat, &names, &mut |_| {}).unwrap();
assert_eq!(objects(&s).len(), before, "space freed");
}
/// The Go oracle's fixture vault opens: real files decrypt to the same
/// bytes and metadata, the decoy sees only its file, the foreign record is
/// skipped silently.
#[test]
fn reads_go_vault() {
let d = TempDir::new("govault");
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/mini-vault");
let root = d.0.join("v");
for p in walk(&src) {
let rel = p.strip_prefix(&src).unwrap();
if p.is_dir() {
std::fs::create_dir_all(root.join(rel)).unwrap();
} else {
std::fs::copy(&p, root.join(rel)).unwrap();
}
}
for dir in [RECORDS, OBJECTS] {
for i in 0..256 {
std::fs::create_dir_all(root.join(dir).join(format!("{i:02x}"))).unwrap();
}
}
let key = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/key-with-decoy.pem");
let real = crate::keyfile::unlock(&key, b"oracle-real passphrase").unwrap();
let s = Store::open(&root, real, pool()).unwrap();
let cat = s.catalogue().unwrap();
assert_eq!(cat.names().collect::<Vec<_>>(), ["dir/empty", "dir/ünïcode.bin", "hello.txt"]);
let boundary: Vec<u8> = (0..(MIN_PADDING as usize - ID_SIZE)).map(|i| (i * 7) as u8).collect();
for (n, body, mode, secs, nanos) in [
("hello.txt", &b"hello"[..], 0o644, 1700000000, 123456789),
("dir/empty", b"", 0o600, 1600000000, 0),
("dir/ünïcode.bin", &boundary[..], 0o755, 1500000000, 999999999),
] {
let r = cat.current(n).unwrap();
assert_eq!((r.mode, r.modified), (mode, Instant { secs, nanos }), "{n}");
let out = format!("out-{}", r.id.hex());
assert_eq!(get_bytes(&s, r, &d.0, &out).unwrap(), body);
let m = d.0.join(&out).metadata().unwrap();
assert_eq!(m.modified().unwrap(), Instant { secs, nanos }.to_system());
#[cfg(unix)]
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&m.permissions()) & 0o777, mode);
}
let decoy = crate::keyfile::unlock(&key, b"oracle-decoy passphrase").unwrap();
let cat = Store::open(&root, decoy, pool()).unwrap().catalogue().unwrap();
assert_eq!(cat.names().collect::<Vec<_>>(), ["shopping.txt"]);
}
/// With identical synthetic randomness, one worker and eight produce
/// byte-identical objects.
#[test]
fn sequential_and_parallel_identical() {
let mut outputs = Vec::new();
for workers in [1, 8] {
let d = TempDir::new("det");
let root = d.0.join("v");
Store::init(&root, 0, pool()).unwrap();
let s = Store::open_with(&root, identity(3), Arc::new(Pool::new(workers)), Entropy::Seeded([7; 32]));
let before: HashSet<PathBuf> = objects(&s).into_iter().collect();
let body: Vec<u8> = (0..3 * CHUNK_DATA as usize + 12345).map(|i| (i % 251) as u8).collect();
let mut cat = s.catalogue().unwrap();
add_bytes(&s, &mut cat, "f", &body);
let new: Vec<(String, Vec<u8>)> = objects(&s)
.into_iter()
.filter(|p| !before.contains(p))
.map(|p| (p.file_name().unwrap().to_string_lossy().into_owned(), std::fs::read(&p).unwrap()))
.collect();
assert_eq!(new.len(), 4);
outputs.push(new);
}
assert!(outputs[0] == outputs[1], "sequential and parallel output differ");
}
/// A failure while writing objects stores nothing and leaves no objects.
#[cfg(unix)]
#[test]
fn object_write_failure_rolls_back() {
use std::os::unix::fs::PermissionsExt;
let d = TempDir::new("fail");
let root = d.0.join("v");
Store::init(&root, 0, pool()).unwrap();
let entropy = Entropy::Seeded([9; 32]);
let s = Store::open_with(&root, identity(4), pool(), entropy.clone());
let seed = ObjectSeed::random(&mut entropy.rng(b"seed"));
let blocked = s.path(OBJECTS, &seed.object_id(2));
let shard = blocked.parent().unwrap().to_path_buf();
if seed.object_id(0).shard() == seed.object_id(2).shard() || seed.object_id(1).shard() == seed.object_id(2).shard() {
return; // the fixed seed puts two objects in one shard; nothing to test
}
std::fs::set_permissions(&shard, std::fs::Permissions::from_mode(0o500)).unwrap();
if std::fs::write(shard.join("probe"), b"").is_ok() {
return; // running as root: permissions do not bite
}
let before = objects(&s);
let mut cat = s.catalogue().unwrap();
let body = vec![1u8; 3 * CHUNK_DATA as usize];
assert!(
s.add_reader(&mut cat, &mut &body[..], body.len() as u64, 0o600, Instant::now(), &name("f"))
.is_err()
);
std::fs::set_permissions(&shard, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(objects(&s), before, "objects of a failed add were left behind");
assert!(s.catalogue().unwrap().is_empty());
assert!(
walk(&root)
.iter()
.all(|p| !p.file_name().unwrap().to_string_lossy().starts_with(".pending-"))
);
}
/// A short source stores nothing; a longer one than declared is refused.
#[test]
fn short_and_long_sources() {
let (d, s) = vault("short");
let before = objects(&s);
let mut cat = s.catalogue().unwrap();
let e = s
.add_reader(&mut cat, &mut &b"abc"[..], 10, 0o600, Instant::now(), &name("f"))
.unwrap_err();
assert_eq!(e.kind, Kind::SourceShort);
assert_eq!(objects(&s), before);
let p = d.0.join("src");
std::fs::write(&p, b"0123456789").unwrap();
s.add_file(&mut cat, &p, &name("ok")).unwrap();
assert!(s.catalogue().unwrap().contains("ok"));
}
/// A record whose sync fails after it is in place keeps its objects.
#[test]
fn failure_after_publish_keeps_objects() {
let (d, s) = vault("afterpub");
let mut cat = s.catalogue().unwrap();
s.inner.fail_after_publish.store(true, Ordering::SeqCst);
let e = s
.add_reader(&mut cat, &mut &b"precious"[..], 8, 0o600, Instant::now(), &name("f"))
.unwrap_err();
assert_eq!(e.kind, Kind::AfterPublish);
s.inner.fail_after_publish.store(false, Ordering::SeqCst);
let cat = s.catalogue().unwrap();
assert_eq!(get_bytes(&s, cat.current("f").unwrap(), &d.0, "out").unwrap(), b"precious");
}
/// Tampered, truncated, extended and foreign objects never publish output.
#[test]
fn damaged_objects_never_publish() {
let (d, s) = vault("damage");
let mut cat = s.catalogue().unwrap();
let body: Vec<u8> = (0..100_000).map(|i| i as u8).collect();
add_bytes(&s, &mut cat, "f", &body);
let r = cat.current("f").unwrap();
let obj = s.path(OBJECTS, &r.seed.object_id(0));
let orig = std::fs::read(&obj).unwrap();
let foreign = {
let mut v = Vec::new();
fury::encrypt_to(&mut v, &identity(9).recipient(), &[0u8; 1 << 17], &mut crate::rng::os());
v
};
// Same key and ID, but padding to the wrong bucket.
let short_pad = {
let mut p = r.seed.object_id(0).0.to_vec();
p.extend_from_slice(&body);
let mut v = Vec::new();
fury::encrypt_to(&mut v, &s.inner.recipient, &p, &mut crate::rng::os());
v
};
let damaged: Vec<(&str, Vec<u8>)> = vec![
("flipped", {
let mut v = orig.clone();
let n = v.len() - 100;
v[n] ^= 1;
v
}),
("truncated", orig[..orig.len() - 1].to_vec()),
("missing final chunk", orig[..orig.len() - 1000].to_vec()),
("trailing", [&orig[..], b"x"].concat()),
("empty", Vec::new()),
("foreign", foreign),
("short padding", short_pad),
];
for (what, bytes) in damaged {
std::fs::write(&obj, &bytes).unwrap();
let out = format!("out-{}", what.replace(' ', "-"));
assert!(get_bytes(&s, r, &d.0, &out).is_err(), "{what} accepted");
assert!(!d.0.join(&out).exists(), "{what} published output");
}
assert_eq!(std::fs::read_dir(&d.0).unwrap().count(), 1, "temporary files left behind");
std::fs::remove_file(&obj).unwrap();
assert!(get_bytes(&s, r, &d.0, "missing").is_err());
}
#[cfg(unix)]
#[test]
fn extraction_never_replaces_symlinks() {
let (d, s) = vault("symlink");
let mut cat = s.catalogue().unwrap();
add_bytes(&s, &mut cat, "f", b"data");
let r = cat.current("f").unwrap();
std::os::unix::fs::symlink(d.0.join("target"), d.0.join("dangling")).unwrap();
assert!(get_bytes(&s, r, &d.0, "dangling").is_err());
assert!(!d.0.join("target").exists(), "followed a dangling symlink");
std::fs::write(d.0.join("exists"), b"keep").unwrap();
assert!(get_bytes(&s, r, &d.0, "exists").is_err());
assert_eq!(std::fs::read(d.0.join("exists")).unwrap(), b"keep");
}
#[test]
fn oversized_record_names() {
let (_d, s) = vault("names");
let mut cat = s.catalogue().unwrap();
// 1024 bytes is the limit; '<' is escaped six-fold in JSON, as in Go.
let long = "a".repeat(NAME_LIMIT);
add_bytes(&s, &mut cat, &long, b"x");
let escaped = "<".repeat(NAME_LIMIT);
let e = s
.add_reader(&mut cat, &mut &b"x"[..], 1, 0o600, Instant::now(), &name(&escaped))
.unwrap_err();
assert_eq!(e.msg, "catalogue record too large");
assert!(VaultName::new(&"a".repeat(NAME_LIMIT + 1)).is_none());
assert_eq!(s.catalogue().unwrap().names().collect::<Vec<_>>(), [long.as_str()]);
}
#[test]
fn duplicate_records_recover() {
let (_d, s) = vault("dups");
let mut cat = s.catalogue().unwrap();
add_bytes(&s, &mut cat, "f", b"old");
// An interrupted replace leaves two records for one name.
let old = cat.current("f").unwrap();
let mut dup = old.copy_as(name("f"), Id::random(&mut crate::rng::os()));
dup.added = Instant {
secs: old.added.secs + 10,
nanos: 0,
};
s.inner.write_record(&dup).unwrap();
let cat = s.catalogue().unwrap();
assert_eq!(cat.records("f").len(), 2);
assert_eq!(cat.current("f").unwrap().added, dup.added, "newest first");
let mut cat = cat;
add_bytes(&s, &mut cat, "f", b"new");
assert_eq!(s.catalogue().unwrap().records("f").len(), 1, "next write cleans up");
}
/// Repeated failures mid-pipeline — a source that errors part-way, objects
/// that fail to authenticate — leave no extra threads behind and return
/// every buffer (the next large operation still gets all eight).
#[cfg(target_os = "linux")]
#[test]
fn repeated_failures_do_not_leak() {
let threads = || std::fs::read_dir("/proc/self/task").unwrap().count();
let (d, s) = vault("leak");
let mut cat = s.catalogue().unwrap();
let body = vec![3u8; 3 * CHUNK_DATA as usize];
add_bytes(&s, &mut cat, "f", &body);
let r = cat.current("f").unwrap();
let obj = s.path(OBJECTS, &r.seed.object_id(1));
let good = std::fs::read(&obj).unwrap();
let mut bad = good.clone();
let n = bad.len() / 2;
bad[n] ^= 1;
std::fs::write(&obj, &bad).unwrap();
struct Failing(usize);
impl Read for Failing {
fn read(&mut self, b: &mut [u8]) -> std::io::Result<usize> {
if self.0 == 0 {
return Err(std::io::Error::other("source failed"));
}
let k = b.len().min(self.0);
self.0 -= k;
b[..k].fill(9);
Ok(k)
}
}
let mut other = s.catalogue().unwrap();
let before = threads();
for i in 0..10 {
assert!(get_bytes(&s, r, &d.0, &format!("o{i}")).is_err());
let e = s.add_reader(
&mut other,
&mut Failing(2 * CHUNK_DATA as usize + 5),
3 * CHUNK_DATA,
0o600,
Instant::now(),
&name("g"),
);
assert!(e.is_err());
}
assert_eq!(threads(), before, "threads accumulated");
std::fs::write(&obj, &good).unwrap();
assert_eq!(get_bytes(&s, r, &d.0, "ok").unwrap(), body, "buffers were not all returned");
assert!(!s.catalogue().unwrap().contains("g"));
}