feat(users): per-user sealed folders users/<id>/{home,apps,data,permissions}; storage SET_USER/LOCK/PURGE; init sign-in/sign-out loop (purge fresh/removed/guest, seed from factory, session start/stop); shell logout; pm user+system app shelves; terminal SIGNED_OUT; per-program stack (gatekeeper 12p); 128K load scratch

This commit is contained in:
2026-10-28 01:08:50 +01:00
parent 29a25bd8c2
commit 91f7d28834
38 changed files with 1156 additions and 378 deletions

View File

@@ -74,11 +74,25 @@ impl FileSystem for Memory {
let (path, _) = self.open.get(&h.raw())?;
Some(self.files[path.as_str()].len())
}
/// The direct children: files, and folders made or implied by a file.
fn list_dir(&mut self, path: &[u8], entries: &mut [DirEntry]) -> Option<usize> {
let prefix = std::format!("{}/", key(path));
let dir = key(path);
let prefix = std::format!("{dir}/");
if !self.dirs.contains(&dir) && !self.files.keys().any(|k| k.starts_with(&prefix)) {
return None;
}
let mut seen: Vec<String> = Vec::new();
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 };
let names = self.files.keys().map(|k| (k, true)).chain(self.dirs.iter().map(|d| (d, false)));
for (name, file) in names.filter(|(k, _)| k.starts_with(&prefix)) {
let rest = &name[prefix.len()..];
let (child, deeper) = rest.split_once('/').map_or((rest, false), |(c, _)| (c, true));
if seen.iter().any(|s| s == child) || n == entries.len() {
continue;
}
seen.push(child.to_string());
let size = if file && !deeper { self.files[name.as_str()].len() } else { 0 };
entries[n] = DirEntry { name: FileName::from_bytes(child.as_bytes()), size, is_dir: deeper || !file };
n += 1;
}
Some(n)
@@ -87,14 +101,22 @@ impl FileSystem for Memory {
self.files.remove(&key(path)).is_some()
}
fn mkdir(&mut self, path: &[u8]) -> bool {
self.dirs.push(key(path));
let k = key(path);
if !self.dirs.contains(&k) {
self.dirs.push(k);
}
true
}
/// Only an empty folder.
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
let prefix = std::format!("{k}/");
let empty = !self.files.keys().chain(self.dirs.iter()).any(|f| f.starts_with(&prefix));
let was = self.dirs.contains(&k) || !empty;
if empty {
self.dirs.retain(|d| *d != k);
}
empty && was
}
}
@@ -102,7 +124,7 @@ const MASTER: [u8; 32] = [9; 32];
fn unlocked() -> Encrypted<Memory> {
let mut fs = Encrypted::new(Memory::default());
fs.unlock(&MASTER, &[3; 32]);
fs.unlock(&MASTER, &[3; 32], b"u1");
fs
}
@@ -129,17 +151,22 @@ fn on_disk(fs: &Encrypted<Memory>, path: &str) -> Vec<u8> {
#[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"users/u1/home/readme.txt"[..], "sealed"),
(b"/users/u1/home", "sealed"),
(b"users/u1/permissions/kos.snake", "sealed"),
(b"users/u1/data/kos.notes/a", "sealed"),
(b"users/u1/apps/kos.notes/notes", "sealed"),
(b"users/u1/data/kos.notes", "neither"),
(b"users/u1/apps", "neither"),
(b"users/u1/loose", "neither"),
(b"users/u1", "neither"),
(b"users//home", "neither"),
(b"users", "plain"),
(b"bin/shell", "plain"),
(b"apps/kos.cat/cat", "plain"),
(b"factory/home/readme.txt", "plain"),
(b"homework/x", "plain"),
(b"home/x", "plain"),
(b"usersx/u1/home/x", "plain"),
(b"selftest.txt", "plain"),
] {
assert_eq!(area_of(p), a, "{}", String::from_utf8_lossy(p));
@@ -150,9 +177,9 @@ fn areas_are_told_by_path() {
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");
put(&mut fs, b"users/u1/home/diary.txt", text);
assert_eq!(get(&mut fs, b"users/u1/home/diary.txt").unwrap(), text);
let disk = on_disk(&fs, "users/u1/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()));
@@ -170,37 +197,37 @@ fn plaintext_areas_stay_plaintext() {
fn big_files_and_writes_across_chunks_round_trip() {
let mut fs = unlocked();
let big: Vec<u8> = (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);
put(&mut fs, b"users/u1/data/kos.notes/big", &big);
assert_eq!(get(&mut fs, b"users/u1/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 h = fs.open(b"users/u1/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);
assert_eq!(get(&mut fs, b"users/u1/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();
put(&mut fs, b"users/u1/home/f", &full);
let h = fs.open(b"users/u1/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);
assert_eq!(get(&mut fs, b"users/u1/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");
put(&mut fs, b"users/u1/data/kos.notes/secret", b"notes only");
let stolen = on_disk(&fs, "users/u1/data/kos.notes/secret");
fs.inner_mut_for_test().files.insert("users/u1/data/kos.chat/secret".to_string(), stolen);
let h = fs.open(b"users/u1/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");
}
@@ -208,68 +235,117 @@ fn one_app_cannot_read_anothers_file_even_moved_into_its_folder() {
#[test]
fn locked_the_areas_refuse_everything() {
let mut fs = unlocked();
put(&mut fs, b"home/x", b"y");
put(&mut fs, b"users/u1/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"users/u1/home/x").is_none());
assert!(locked.create(b"users/u1/home/z").is_none());
assert!(!locked.delete(b"users/u1/home/x"));
assert!(!locked.mkdir(b"users/u1/data/kos.notes"));
assert!(locked.list_dir(b"users/u1/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();
other.unlock(&[1; 32], &[2; 32], b"u1");
let h = other.open(b"users/u1/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());
fs.inner_mut_for_test().files.insert("users/u1/permissions/kos.evil".to_string(), b"[decision]\ngranted = [\"network\"]\n".to_vec());
assert!(fs.open(b"users/u1/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");
put(&mut fs, b"users/u1/home/t", &data);
let good = on_disk(&fs, "users/u1/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);
fs.inner_mut_for_test().files.insert("users/u1/home/t".to_string(), bent);
assert_eq!(get(&mut fs, b"users/u1/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);
fs.inner_mut_for_test().files.insert("users/u1/home/t".to_string(), cut);
assert_eq!(get(&mut fs, b"users/u1/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);
fs.inner_mut_for_test().files.insert("users/u1/home/t".to_string(), swapped);
assert_eq!(get(&mut fs, b"users/u1/home/t"), None);
}
#[test]
fn listings_show_what_files_hold() {
let mut fs = unlocked();
put(&mut fs, b"home/a", b"12345");
put(&mut fs, b"users/u1/home/a", b"12345");
let mut entries = [DirEntry::empty(); 4];
let n = fs.list_dir(b"home", &mut entries).unwrap();
let n = fs.list_dir(b"users/u1/home", &mut entries).unwrap();
assert_eq!((n, entries[0].size), (1, 5));
}
#[test]
fn files_cannot_live_loose_in_data() {
fn files_cannot_live_loose_in_a_users_folder() {
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");
assert!(fs.create(b"users/u1/data/loose").is_none());
assert!(fs.create(b"users/u1/loose").is_none());
assert!(fs.create(b"users/loose").is_none());
assert!(fs.mkdir(b"users/u1/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();
put(&mut fs, b"users/u1/data/kos.notes/a", b"xyz");
let n = fs.list_dir(b"users/u1/data/kos.notes", &mut entries).unwrap();
assert_eq!((n, entries[0].size), (1, 3), "listing the folder shows what its files hold");
}
#[test]
fn only_the_signed_in_users_folder_opens() {
let mut fs = unlocked();
put(&mut fs, b"users/u1/home/mine", b"u1's");
let mut inner = fs.into_inner();
let theirs = inner.files["users/u1/home/mine"].clone();
let mut u2 = Encrypted::new(std::mem::take(&mut inner));
u2.unlock(&MASTER, &[3; 32], b"u2");
assert!(u2.open(b"users/u1/home/mine").is_none(), "not someone else's, even under the same key");
assert!(u2.create(b"users/u1/home/planted").is_none());
assert!(!u2.mkdir(b"users/u1/home/sub"));
assert!(u2.list_dir(b"users/u1/home", &mut [DirEntry::empty(); 4]).is_none());
assert!(!u2.delete(b"users/u1/home/mine"));
// Moved into u2's own home, it is u1's area key that sealed it.
u2.inner_mut_for_test().files.insert("users/u2/home/mine".to_string(), theirs);
let h = u2.open(b"users/u2/home/mine").unwrap();
assert_eq!(u2.read(h, &mut [0u8; 8]), None);
assert!(u2.list_dir(b"users", &mut [DirEntry::empty(); 4]).is_some(), "the folders themselves are no secret");
}
#[test]
fn locking_forgets_the_key_and_closes_sealed_files() {
let mut fs = unlocked();
put(&mut fs, b"users/u1/home/a", b"abc");
let h = fs.open(b"users/u1/home/a").unwrap();
fs.lock();
assert_eq!(fs.read(h, &mut [0u8; 4]), None, "the open file went with the key");
assert!(fs.open(b"users/u1/home/a").is_none());
fs.unlock(&MASTER, &[3; 32], b"u1");
assert_eq!(get(&mut fs, b"users/u1/home/a").unwrap(), b"abc", "and comes back with it");
}
#[test]
fn purging_removes_a_tree_without_its_key() {
let mut fs = unlocked();
put(&mut fs, b"users/u1/home/a", b"1");
put(&mut fs, b"users/u1/data/kos.notes/b", b"2");
assert!(fs.mkdir(b"users/u1/home/empty"));
fs.lock();
fs.inner_mut_for_test().files.insert("users/u2x/home/c".to_string(), b"3".to_vec());
assert!(fs.purge(b"users/u1"));
let left: Vec<&String> = fs.inner().files.keys().collect();
assert_eq!(left, ["users/u2x/home/c"]);
assert!(!fs.inner().dirs.iter().any(|d| d.starts_with("users/u1")), "folders too");
assert!(fs.purge(b"users/nobody"), "nothing there is nothing to remove");
}