diff --git a/Cargo.lock b/Cargo.lock index 364bd40..041cad8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -591,6 +591,7 @@ name = "storage-server" version = "0.1.0" dependencies = [ "driver-block-virtio", + "filecrypt", "libkos", "port-block-device", "server-framework", diff --git a/crates/servers/storage-server/Cargo.toml b/crates/servers/storage-server/Cargo.toml index 71c2991..4727c4d 100644 --- a/crates/servers/storage-server/Cargo.toml +++ b/crates/servers/storage-server/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" types = { workspace = true } server-framework = { workspace = true } +filecrypt = { workspace = true } port-block-device = { workspace = true } driver-block-virtio = { workspace = true } libkos = { workspace = true } diff --git a/crates/servers/storage-server/src/encrypted.rs b/crates/servers/storage-server/src/encrypted.rs new file mode 100644 index 0000000..baa0ebc --- /dev/null +++ b/crates/servers/storage-server/src/encrypted.rs @@ -0,0 +1,273 @@ +//! The encrypted areas (see the vision's Encryption note), over any file +//! system: each app's `data//`, the owner's `home/`, and `system/` are +//! kept as `filecrypt` files under their own keys, derived from the +//! master key; the rest (programs, `factory/`) is plaintext. Before the key +//! arrives the encrypted areas refuse everything, and a plaintext file in +//! one is refused too: nothing there is trusted unless it was sealed. + +use filecrypt::{CHUNK, Drbg, HEADER, Header, Key, OVERHEAD, PLAIN_CHUNK, area_key, chunk_offset, chunks, plain_len}; + +use crate::filesystem::{DirEntry, FileHandle, FileSystem}; + +/// The inner file system's handles this layer can track. +const MAX_HANDLES: usize = 16; + +/// Where a path falls. +#[derive(Debug, PartialEq, Eq)] +enum Area<'a> { + Plain, + /// Encrypted under the key of the area with this name. + Sealed(&'a [u8]), + /// `data/` itself, or an app's folder in it: folders, never files. + Neither, +} + +fn area(path: &[u8]) -> Area<'_> { + let path = path.strip_prefix(b"/").unwrap_or(path); + let mut parts = path.splitn(3, |b| *b == b'/'); + match (parts.next(), parts.next(), parts.next()) { + (Some(b"home" | b"system"), _, _) => Area::Sealed(&path[..path.iter().position(|b| *b == b'/').unwrap_or(path.len())]), + (Some(b"data"), Some(id), Some(rest)) if !id.is_empty() && !rest.is_empty() => Area::Sealed(&path[..5 + id.len()]), + (Some(b"data"), _, _) => Area::Neither, + _ => Area::Plain, + } +} + +/// An open encrypted file. +struct Open { + key: Key, + /// Plaintext bytes in it. + size: usize, + /// Where the next read or write starts, in plaintext. + pos: usize, +} + +pub struct Encrypted { + inner: F, + /// The master key and randomness, once the gatekeeper has unlocked. + master: Option, + rng: Option, + open: [Option; MAX_HANDLES], +} + +impl Encrypted { + pub fn new(inner: F) -> Self { + Self { inner, master: None, rng: None, open: [const { None }; MAX_HANDLES] } + } + + pub fn inner(&self) -> &F { + &self.inner + } + + #[cfg(test)] + pub fn inner_mut_for_test(&mut self) -> &mut F { + &mut self.inner + } + + pub fn into_inner(self) -> F { + self.inner + } + + /// The key of `path`'s area, if it is encrypted and the store unlocked: + /// `Ok(None)` for a plaintext path, `Err` for one it must refuse. + fn key_for(&self, path: &[u8]) -> Result, ()> { + match area(path) { + Area::Plain => Ok(None), + Area::Neither => Err(()), + Area::Sealed(name) => self.master.as_ref().map(|m| Some(area_key(m, name))).ok_or(()), + } + } + + /// Whether `path` may be used as a directory (made, listed, removed). + fn dir_allowed(&self, path: &[u8]) -> bool { + match area(path) { + Area::Plain => true, + _ => self.master.is_some(), + } + } + + fn slot(&mut self, h: FileHandle) -> Option<&mut Option> { + self.open.get_mut(h.raw() as usize) + } + + /// Chunk `index` of a file of `size` bytes, opened into `out`; its length. + fn read_chunk(inner: &mut F, h: FileHandle, key: &Key, size: usize, index: usize, out: &mut [u8; PLAIN_CHUNK]) -> Option { + let last = index + 1 == chunks(size); + let plain = if last { size - index * PLAIN_CHUNK } else { PLAIN_CHUNK }; + let mut sealed = [0u8; CHUNK]; + let sealed = &mut sealed[..plain + OVERHEAD]; + if !inner.seek(h, chunk_offset(index)) || inner.read(h, sealed)? != sealed.len() { + return None; + } + filecrypt::open(key, index as u64, last, sealed, out) + } + + fn write_chunk(&mut self, h: FileHandle, key: &Key, index: usize, last: bool, plain: &[u8]) -> bool { + let Some(rng) = self.rng.as_mut() else { return false }; + let mut sealed = [0u8; CHUNK]; + let n = filecrypt::seal(key, index as u64, last, plain, &mut sealed, rng); + self.inner.seek(h, chunk_offset(index)) && self.inner.write(h, &sealed[..n]) == Some(n) + } +} + +impl FileSystem for Encrypted { + fn writable(&self) -> bool { + self.inner.writable() + } + + fn unlock(&mut self, master: &[u8; 32], seed: &[u8; 32]) { + if self.master.is_none() { + self.master = Some(Key(*master)); + self.rng = Some(Drbg::new(*seed)); + } + } + + fn open(&mut self, path: &[u8]) -> Option { + let key = self.key_for(path).ok()?; + let h = self.inner.open(path)?; + let Some(area) = key else { return Some(h) }; + let mut head = [0u8; HEADER]; + let header = (self.inner.read(h, &mut head) == Some(HEADER)).then(|| Header::decode(&head)).flatten(); + let size = self.inner.file_size(h).and_then(plain_len); + let (Some(header), Some(size)) = (header, size) else { + // Not sealed (planted, or cut short): not one of ours. + self.inner.close(h); + return None; + }; + let key = header.file_key(&area); + *self.slot(h)? = Some(Open { key, size, pos: 0 }); + Some(h) + } + + fn create(&mut self, path: &[u8]) -> Option { + let key = self.key_for(path).ok()?; + if key.is_some() && self.rng.is_none() { + return None; + } + let h = self.inner.create(path)?; + let Some(area) = key else { return Some(h) }; + let header = Header::new(self.rng.as_mut()?); + let file_key = header.file_key(&area); + // A header and an empty last chunk: a whole, empty file. + let ok = self.inner.write(h, &header.encode()) == Some(HEADER) && self.write_chunk(h, &file_key, 0, true, &[]); + if !ok { + self.inner.close(h); + let _ = self.inner.delete(path); + return None; + } + *self.slot(h)? = Some(Open { key: file_key, size: 0, pos: 0 }); + Some(h) + } + + fn read(&mut self, h: FileHandle, buf: &mut [u8]) -> Option { + let Some(Some(f)) = self.open.get(h.raw() as usize) else { return self.inner.read(h, buf) }; + let (key, size, mut pos) = (f.key.clone(), f.size, f.pos); + let mut done = 0; + let mut chunk = [0u8; PLAIN_CHUNK]; + while done < buf.len() && pos < size { + let index = pos / PLAIN_CHUNK; + let n = Self::read_chunk(&mut self.inner, h, &key, size, index, &mut chunk)?; + let from = pos % PLAIN_CHUNK; + let take = (n - from).min(buf.len() - done); + buf[done..done + take].copy_from_slice(&chunk[from..from + take]); + done += take; + pos += take; + } + chunk.fill(0); + self.slot(h)?.as_mut()?.pos = pos; + Some(done) + } + + fn write(&mut self, h: FileHandle, buf: &[u8]) -> Option { + let Some(Some(f)) = self.open.get(h.raw() as usize) else { return self.inner.write(h, buf) }; + let (key, old_size, pos) = (f.key.clone(), f.size, f.pos); + let new_size = old_size.max(pos + buf.len()); + let (old_last, new_last) = (chunks(old_size) - 1, chunks(new_size) - 1); + let first = pos / PLAIN_CHUNK; + // The old last chunk is no longer last: resealed so, even when the + // write itself does not touch it. + let resealed = (old_last < first && old_last != new_last).then_some(old_last); + let mut chunk = [0u8; PLAIN_CHUNK]; + for index in resealed.into_iter().chain(first..=new_last) { + let start = index * PLAIN_CHUNK; + let mut len = match index <= old_last { + true => Self::read_chunk(&mut self.inner, h, &key, old_size, index, &mut chunk)?, + false => 0, + }; + // The part of `buf` that falls in this chunk. + let (from, to) = (pos.max(start), (pos + buf.len()).min(start + PLAIN_CHUNK)); + if from < to { + chunk[from - start..to - start].copy_from_slice(&buf[from - pos..to - pos]); + len = len.max(to - start); + } + if !self.write_chunk(h, &key, index, index == new_last, &chunk[..len]) { + return None; + } + } + chunk.fill(0); + let f = self.slot(h)?.as_mut()?; + f.size = new_size; + f.pos = pos + buf.len(); + Some(buf.len()) + } + + fn seek(&mut self, h: FileHandle, offset: usize) -> bool { + match self.slot(h) { + Some(Some(f)) if offset <= f.size => { + f.pos = offset; + true + } + Some(Some(_)) => false, + _ => self.inner.seek(h, offset), + } + } + + fn close(&mut self, h: FileHandle) { + if let Some(slot) = self.slot(h) { + *slot = None; + } + self.inner.close(h); + } + + fn file_size(&mut self, h: FileHandle) -> Option { + match self.open.get(h.raw() as usize) { + Some(Some(f)) => Some(f.size), + _ => self.inner.file_size(h), + } + } + + fn list_dir(&mut self, path: &[u8], entries: &mut [DirEntry]) -> Option { + if !self.dir_allowed(path) { + return None; + } + let n = self.inner.list_dir(path, entries)?; + // In an encrypted area a file's size is what it holds, not its disk size. + // (Listing an app's own folder, `data/`, shows its sealed files.) + let sealed = area(path) != Area::Plain; + for e in entries[..n].iter_mut().filter(|e| sealed && !e.is_dir) { + e.size = plain_len(e.size).unwrap_or(0); + } + Some(n) + } + + fn delete(&mut self, path: &[u8]) -> bool { + self.key_for(path).is_ok() && self.inner.delete(path) + } + + fn mkdir(&mut self, path: &[u8]) -> bool { + self.dir_allowed(path) && self.inner.mkdir(path) + } + + fn rmdir(&mut self, path: &[u8]) -> bool { + self.dir_allowed(path) && self.inner.rmdir(path) + } +} + +#[cfg(test)] +pub(crate) fn area_of(path: &[u8]) -> &'static str { + match area(path) { + Area::Plain => "plain", + Area::Sealed(_) => "sealed", + Area::Neither => "neither", + } +} diff --git a/crates/servers/storage-server/src/filesystem.rs b/crates/servers/storage-server/src/filesystem.rs index 892a38e..aa171c1 100644 --- a/crates/servers/storage-server/src/filesystem.rs +++ b/crates/servers/storage-server/src/filesystem.rs @@ -78,6 +78,9 @@ pub trait FileSystem { fn seek(&mut self, handle: FileHandle, offset: usize) -> bool; /// Removes the directory at `path`, which must be empty. fn rmdir(&mut self, path: &[u8]) -> bool; + /// The master key and a fresh random seed, once the owner is in; only + /// an encrypting file system has a use for them. + fn unlock(&mut self, _master: &[u8; 32], _seed: &[u8; 32]) {} /// Whether anything can be written at all; the server refuses /// writing requests up front when not. fn writable(&self) -> bool { diff --git a/crates/servers/storage-server/src/lib.rs b/crates/servers/storage-server/src/lib.rs index 12b72d9..19c26ef 100644 --- a/crates/servers/storage-server/src/lib.rs +++ b/crates/servers/storage-server/src/lib.rs @@ -4,6 +4,7 @@ extern crate alloc; pub mod adapters; +pub mod encrypted; pub mod filesystem; pub mod fs; pub mod grants; diff --git a/crates/servers/storage-server/src/tests/encrypted.rs b/crates/servers/storage-server/src/tests/encrypted.rs new file mode 100644 index 0000000..5e82875 --- /dev/null +++ b/crates/servers/storage-server/src/tests/encrypted.rs @@ -0,0 +1,275 @@ +extern crate std; + +use crate::encrypted::{Encrypted, area_of}; +use crate::filesystem::{DirEntry, FileHandle, FileName, FileSystem}; +use std::collections::BTreeMap; +use std::string::{String, ToString}; +use std::vec; +use std::vec::Vec; + +/// Files in memory, with positions: what the layer sits on in tests. +#[derive(Default)] +struct Memory { + files: BTreeMap>, + dirs: Vec, + open: BTreeMap, + next: u16, +} + +fn key(path: &[u8]) -> String { + String::from_utf8_lossy(path.strip_prefix(b"/").unwrap_or(path)).into_owned() +} + +impl Memory { + fn handle(&mut self, path: String) -> FileHandle { + self.next = (self.next + 1) % 16; + self.open.insert(self.next, (path, 0)); + FileHandle::new(self.next) + } +} + +impl FileSystem for Memory { + fn open(&mut self, path: &[u8]) -> Option { + let k = key(path); + self.files.contains_key(&k).then(|| self.handle(k)) + } + fn create(&mut self, path: &[u8]) -> Option { + let k = key(path); + if self.files.contains_key(&k) { + return None; + } + self.files.insert(k.clone(), Vec::new()); + Some(self.handle(k)) + } + fn read(&mut self, h: FileHandle, buf: &mut [u8]) -> Option { + let (path, pos) = self.open.get_mut(&h.raw())?; + let data = &self.files[path.as_str()]; + let n = buf.len().min(data.len().saturating_sub(*pos)); + buf[..n].copy_from_slice(&data[*pos..*pos + n]); + *pos += n; + Some(n) + } + fn write(&mut self, h: FileHandle, buf: &[u8]) -> Option { + let (path, pos) = self.open.get_mut(&h.raw())?; + let data = self.files.get_mut(path.as_str())?; + if data.len() < *pos + buf.len() { + data.resize(*pos + buf.len(), 0); + } + data[*pos..*pos + buf.len()].copy_from_slice(buf); + *pos += buf.len(); + Some(buf.len()) + } + fn seek(&mut self, h: FileHandle, offset: usize) -> bool { + let Some((path, pos)) = self.open.get_mut(&h.raw()) else { return false }; + if offset > self.files[path.as_str()].len() { + return false; + } + *pos = offset; + true + } + fn close(&mut self, h: FileHandle) { + self.open.remove(&h.raw()); + } + fn file_size(&mut self, h: FileHandle) -> Option { + let (path, _) = self.open.get(&h.raw())?; + Some(self.files[path.as_str()].len()) + } + fn list_dir(&mut self, path: &[u8], entries: &mut [DirEntry]) -> Option { + let prefix = std::format!("{}/", key(path)); + let mut n = 0; + for (name, data) in self.files.iter().filter(|(k, _)| k.starts_with(&prefix)) { + entries[n] = DirEntry { name: FileName::from_bytes(name[prefix.len()..].as_bytes()), size: data.len(), is_dir: false }; + n += 1; + } + Some(n) + } + fn delete(&mut self, path: &[u8]) -> bool { + self.files.remove(&key(path)).is_some() + } + fn mkdir(&mut self, path: &[u8]) -> bool { + self.dirs.push(key(path)); + true + } + fn rmdir(&mut self, path: &[u8]) -> bool { + let k = key(path); + let before = self.dirs.len(); + self.dirs.retain(|d| *d != k); + self.dirs.len() < before + } +} + +const MASTER: [u8; 32] = [9; 32]; + +fn unlocked() -> Encrypted { + let mut fs = Encrypted::new(Memory::default()); + fs.unlock(&MASTER, &[3; 32]); + fs +} + +fn put(fs: &mut impl FileSystem, path: &[u8], data: &[u8]) { + let h = fs.create(path).expect("create"); + assert_eq!(fs.write(h, data), Some(data.len())); + fs.close(h); +} + +fn get(fs: &mut impl FileSystem, path: &[u8]) -> Option> { + let h = fs.open(path)?; + let size = fs.file_size(h)?; + let mut out = vec![0u8; size + 10]; + let n = fs.read(h, &mut out); + fs.close(h); + out.truncate(n?); + Some(out) +} + +fn on_disk(fs: &Encrypted, path: &str) -> Vec { + fs.inner().files[path].clone() +} + +#[test] +fn areas_are_told_by_path() { + for (p, a) in [ + (&b"home/readme.txt"[..], "sealed"), + (b"/home", "sealed"), + (b"system/permissions/kos.snake", "sealed"), + (b"data/kos.notes/a", "sealed"), + (b"data/kos.notes", "neither"), + (b"data", "neither"), + (b"data/", "neither"), + (b"bin/shell", "plain"), + (b"apps/kos.cat/cat", "plain"), + (b"factory/home/readme.txt", "plain"), + (b"homework/x", "plain"), + (b"selftest.txt", "plain"), + ] { + assert_eq!(area_of(p), a, "{}", String::from_utf8_lossy(p)); + } +} + +#[test] +fn sealed_files_read_back_and_are_noise_on_disk() { + let mut fs = unlocked(); + let text = b"Dear diary, nobody else may read this."; + put(&mut fs, b"home/diary.txt", text); + assert_eq!(get(&mut fs, b"home/diary.txt").unwrap(), text); + let disk = on_disk(&fs, "home/diary.txt"); + assert!(disk.starts_with(b"KOSFILE1")); + assert!(!disk.windows(5).any(|w| w == b"diary"), "the words are not on the disk"); + assert_eq!(disk.len(), filecrypt::disk_len(text.len())); +} + +#[test] +fn plaintext_areas_stay_plaintext() { + let mut fs = unlocked(); + put(&mut fs, b"bin/tool", b"\x7fELF"); + assert_eq!(on_disk(&fs, "bin/tool"), b"\x7fELF"); + assert_eq!(get(&mut fs, b"bin/tool").unwrap(), b"\x7fELF"); +} + +#[test] +fn big_files_and_writes_across_chunks_round_trip() { + let mut fs = unlocked(); + let big: Vec = (0..3 * filecrypt::PLAIN_CHUNK + 123).map(|i| (i % 251) as u8).collect(); + put(&mut fs, b"data/kos.notes/big", &big); + assert_eq!(get(&mut fs, b"data/kos.notes/big").unwrap(), big); + // Overwrite a stretch spanning two chunks in the middle. + let h = fs.open(b"data/kos.notes/big").unwrap(); + let at = filecrypt::PLAIN_CHUNK - 10; + assert!(fs.seek(h, at)); + assert_eq!(fs.write(h, &[0xAA; 30]), Some(30)); + fs.close(h); + let mut expect = big.clone(); + expect[at..at + 30].fill(0xAA); + assert_eq!(get(&mut fs, b"data/kos.notes/big").unwrap(), expect); + // Grow exactly from a chunk boundary: the old last chunk stops being last. + let mut fs = unlocked(); + let full = vec![7u8; filecrypt::PLAIN_CHUNK]; + put(&mut fs, b"home/f", &full); + let h = fs.open(b"home/f").unwrap(); + assert!(fs.seek(h, filecrypt::PLAIN_CHUNK)); + assert_eq!(fs.write(h, b"more"), Some(4)); + fs.close(h); + let mut expect = full.clone(); + expect.extend_from_slice(b"more"); + assert_eq!(get(&mut fs, b"home/f").unwrap(), expect); +} + +#[test] +fn one_app_cannot_read_anothers_file_even_moved_into_its_folder() { + let mut fs = unlocked(); + put(&mut fs, b"data/kos.notes/secret", b"notes only"); + let stolen = on_disk(&fs, "data/kos.notes/secret"); + fs.inner_mut_for_test().files.insert("data/kos.chat/secret".to_string(), stolen); + let h = fs.open(b"data/kos.chat/secret").expect("it looks like a sealed file"); + let mut out = [0u8; 64]; + assert_eq!(fs.read(h, &mut out), None, "but it opens only under kos.notes' key"); +} + +#[test] +fn locked_the_areas_refuse_everything() { + let mut fs = unlocked(); + put(&mut fs, b"home/x", b"y"); + let mut locked = Encrypted::new(fs.into_inner()); + assert!(locked.open(b"home/x").is_none()); + assert!(locked.create(b"home/z").is_none()); + assert!(!locked.delete(b"home/x")); + assert!(!locked.mkdir(b"data/kos.notes")); + assert!(locked.list_dir(b"home", &mut [DirEntry::empty(); 4]).is_none()); + assert!(locked.open(b"bin/anything").is_none(), "(absent) but plaintext areas are not refused"); + // Another master key (a wiped and re-set-up device) reads noise. + let mut other = Encrypted::new(locked.into_inner()); + other.unlock(&[1; 32], &[2; 32]); + let h = other.open(b"home/x").unwrap(); + assert_eq!(other.read(h, &mut [0u8; 8]), None); +} + +#[test] +fn plaintext_planted_in_an_encrypted_area_is_refused() { + let mut fs = unlocked(); + fs.inner_mut_for_test().files.insert("system/permissions/kos.evil".to_string(), b"[decision]\ngranted = [\"network\"]\n".to_vec()); + assert!(fs.open(b"system/permissions/kos.evil").is_none()); +} + +#[test] +fn tampering_and_truncation_are_caught() { + let mut fs = unlocked(); + let data = vec![5u8; 2 * filecrypt::PLAIN_CHUNK + 50]; + put(&mut fs, b"home/t", &data); + let good = on_disk(&fs, "home/t"); + // One flipped byte. + let mut bent = good.clone(); + bent[100] ^= 1; + fs.inner_mut_for_test().files.insert("home/t".to_string(), bent); + assert_eq!(get(&mut fs, b"home/t"), None); + // Cut at a chunk boundary: the remaining last chunk was not sealed as last. + let cut = good[..filecrypt::chunk_offset(2)].to_vec(); + fs.inner_mut_for_test().files.insert("home/t".to_string(), cut); + assert_eq!(get(&mut fs, b"home/t"), None); + // Chunks swapped. + let (a, b) = (filecrypt::chunk_offset(0), filecrypt::chunk_offset(1)); + let mut swapped = good.clone(); + swapped[a..b].copy_from_slice(&good[b..b + filecrypt::CHUNK]); + swapped[b..b + filecrypt::CHUNK].copy_from_slice(&good[a..b]); + fs.inner_mut_for_test().files.insert("home/t".to_string(), swapped); + assert_eq!(get(&mut fs, b"home/t"), None); +} + +#[test] +fn listings_show_what_files_hold() { + let mut fs = unlocked(); + put(&mut fs, b"home/a", b"12345"); + let mut entries = [DirEntry::empty(); 4]; + let n = fs.list_dir(b"home", &mut entries).unwrap(); + assert_eq!((n, entries[0].size), (1, 5)); +} + +#[test] +fn files_cannot_live_loose_in_data() { + let mut fs = unlocked(); + assert!(fs.create(b"data/loose").is_none()); + assert!(fs.mkdir(b"data/kos.notes"), "an app's folder is fine"); + let mut entries = [DirEntry::empty(); 2]; + put(&mut fs, b"data/kos.notes/a", b"xyz"); + let n = fs.list_dir(b"data/kos.notes", &mut entries).unwrap(); + assert_eq!((n, entries[0].size), (1, 3), "listing the folder shows what its files hold"); +} diff --git a/crates/servers/storage-server/src/tests/ext4_real.rs b/crates/servers/storage-server/src/tests/ext4_real.rs index e01f3fc..06d9732 100644 --- a/crates/servers/storage-server/src/tests/ext4_real.rs +++ b/crates/servers/storage-server/src/tests/ext4_real.rs @@ -218,3 +218,29 @@ fn writes_land_where_seek_says() { let (clean, report) = fsck_clean(&dir, &fs.into_device().0); assert!(clean, "e2fsck: {report}"); } + +#[test] +fn sealed_files_on_real_ext4_read_back_and_pass_e2fsck() { + use crate::encrypted::Encrypted; + let Some((dir, img)) = setup("sealed") else { return }; + let mut fs = Encrypted::new(Ext4::mount(FileDisk(img)).expect("mount")); + fs.unlock(&[4; 32], &[5; 32]); + assert!(fs.mkdir(b"/home")); + let text: Vec = b"the quick brown fox ".iter().copied().cycle().take(3 * filecrypt::PLAIN_CHUNK + 99).collect(); + let h = fs.create(b"/home/fox.txt").expect("create"); + assert_eq!(fs.write(h, &text), Some(text.len())); + assert!(fs.seek(h, 5000)); + assert_eq!(fs.write(h, b"LAZY DOG"), Some(8)); + fs.close(h); + let mut expect = text.clone(); + expect[5000..5008].copy_from_slice(b"LAZY DOG"); + let h = fs.open(b"/home/fox.txt").expect("open"); + let mut back = std::vec![0u8; expect.len()]; + assert_eq!(fs.read(h, &mut back), Some(expect.len())); + fs.close(h); + assert_eq!(back, expect); + let disk = fs.into_inner().into_device().0; + assert!(!disk.windows(9).any(|w| w == b"quick bro"), "no plaintext on the disk"); + let (clean, report) = fsck_clean(&dir, &disk); + assert!(clean, "e2fsck: {report}"); +} diff --git a/crates/servers/storage-server/src/tests/mod.rs b/crates/servers/storage-server/src/tests/mod.rs index 8e2d350..4d84710 100644 --- a/crates/servers/storage-server/src/tests/mod.rs +++ b/crates/servers/storage-server/src/tests/mod.rs @@ -1,3 +1,4 @@ +mod encrypted; mod ext4; mod ext4_real; mod protocol;