feat(store): the data root under LOCALAPPDATA with an owner-only DACL, one lock seam, MoveFileEx (P11c)
Windows step 1 and 2 of D-62. Where Unix keeps the books' private folder at mode 0700 and refuses one others can open, Windows now makes the same promise with a DACL: `winsec::set_owner_only` gives the folder (and, inherited, everything made in it) one protected allow entry for this account's SID; `winsec::is_private` reads the effective DACL and owner back and answers false for anything else (a missing DACL, an entry for Everyone), so `Root::open` refuses a loosened root and `Root::setup` makes it private again, exactly as the 0700 checks do. Every question the system cannot answer is "not private". The root itself comes from the shell's known folders (`SHGetKnownFolderPath`: the profile, and LocalAppData wherever it was redirected), never from environment variables the process that started us chose. `rename_new` (the move that must never go over what is there, renameat2 RENAME_NOREPLACE on Linux) is `MoveFileExW` without the replace flag on Windows, written through. File locking sat behind `libc::flock` in ops.rs alone (the rest already used std's File::try_lock, which is LockFileEx on Windows). It now goes through `lock.rs`, which uses only std, so there is no second unsafe copy of either call. New dependency: windows-sys 0.61 behind cfg(windows), the raw bindings only (no runtime), already in the lockfile through other crates; the smallest Microsoft crate that covers SIDs, DACLs, the known-folder API, pipe process ids and MoveFileExW. mise.toml adds the Windows target to the pinned toolchain so `cargo check --target x86_64-pc-windows-msvc` type-checks this arm on Linux. CHOICE (P11c): nothing above the root is checked on Windows (the Unix rule about folders above it being swappable has no Windows authority; the profile's folders belong to the account and the Administrators). Tests: tests/windows_root.rs (Windows job only), the cross-platform `a_viewer_s_presence_is_a_shared_lock_others_can_see` in ops.rs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
1 parent
b97234ac7e
commit
5f5dd8f7da
10 files changed
+733
-43
No files matched your search
@@ -0,0 +1,124 @@
|
||||
//! The data root on Windows (P11c, D-62): the same promises as Unix's
|
||||
//! mode 0700, kept with an owner-only DACL. Runs on the Windows job only.
|
||||
#![cfg(windows)]
|
||||
|
||||
use ledger_store::root::{Root, RootError, default_root};
|
||||
use ledger_store::winsec;
|
||||
use std::path::Path;
|
||||
|
||||
fn tmp(tag: &str) -> tempfile::TempDir {
|
||||
tempfile::Builder::new()
|
||||
.prefix(&format!("wb-winroot-{tag}-"))
|
||||
.tempdir()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_root_sits_under_local_app_data() {
|
||||
let home = tmp("default");
|
||||
assert_eq!(
|
||||
default_root(home.path()),
|
||||
home.path()
|
||||
.join("AppData")
|
||||
.join("Local")
|
||||
.join("wanderbooks")
|
||||
);
|
||||
// This account's own home: the shell's LocalAppData, wherever it is.
|
||||
let own = ledger_store::root::account_home().unwrap();
|
||||
assert_eq!(
|
||||
default_root(&own),
|
||||
winsec::local_app_data().unwrap().join("wanderbooks")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_makes_a_private_root_and_its_folders() {
|
||||
let home = tmp("setup");
|
||||
let (root, report) = Root::setup(home.path(), None, None).unwrap();
|
||||
assert_eq!(root.path(), default_root(home.path()));
|
||||
assert!(report.created && !report.tightened && !report.recorded);
|
||||
assert!(winsec::is_private(root.path()).unwrap());
|
||||
for sub in ["books", "run", "legacy"] {
|
||||
assert!(winsec::is_private(&root.path().join(sub)).unwrap(), "{sub}");
|
||||
}
|
||||
// Again: nothing to do.
|
||||
let (_, again) = Root::setup(home.path(), None, None).unwrap();
|
||||
assert!(!again.created && !again.tightened && !again.recorded);
|
||||
assert!(Root::find_in(home.path(), None).is_ok());
|
||||
}
|
||||
|
||||
/// Let everyone into `path` and what it holds, as `icacls` does.
|
||||
fn open_to_everyone(path: &Path) {
|
||||
let out = std::process::Command::new("icacls")
|
||||
.arg(path)
|
||||
.args(["/grant", "*S-1-1-0:(OI)(CI)F"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_others_can_open_is_refused_and_setup_makes_it_private_again() {
|
||||
let home = tmp("loose");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
open_to_everyone(root.path());
|
||||
assert!(!winsec::is_private(root.path()).unwrap());
|
||||
assert!(matches!(Root::open(root.path()), Err(RootError::Unsafe(_))));
|
||||
let (_, report) = Root::setup(home.path(), None, None).unwrap();
|
||||
assert!(report.tightened);
|
||||
assert!(winsec::is_private(root.path()).unwrap());
|
||||
assert!(Root::open(root.path()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_made_in_a_private_folder_is_private() {
|
||||
let home = tmp("inherit");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
let file = root.path().join("books").join("note.txt");
|
||||
std::fs::write(&file, b"x").unwrap();
|
||||
assert!(winsec::is_private(&file).unwrap());
|
||||
let sub = root.path().join("books").join("inner");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
assert!(winsec::is_private(&sub).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_made_owner_only_is_private_and_a_loosened_one_is_not() {
|
||||
let dir = tmp("file");
|
||||
let file = dir.path().join("f");
|
||||
std::fs::write(&file, b"x").unwrap();
|
||||
winsec::set_owner_only(&file).unwrap();
|
||||
assert!(winsec::is_private(&file).unwrap());
|
||||
let out = std::process::Command::new("icacls")
|
||||
.arg(&file)
|
||||
.args(["/grant", "*S-1-1-0:R"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
assert!(!winsec::is_private(&file).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_never_goes_over_what_is_there() {
|
||||
let dir = tmp("rename");
|
||||
let (a, b, c) = (
|
||||
dir.path().join("a"),
|
||||
dir.path().join("b"),
|
||||
dir.path().join("c"),
|
||||
);
|
||||
std::fs::write(&a, b"a").unwrap();
|
||||
std::fs::write(&b, b"b").unwrap();
|
||||
let err = winsec::rename_new(&a, &b).unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists, "{err:?}");
|
||||
assert_eq!(std::fs::read(&b).unwrap(), b"b");
|
||||
winsec::rename_new(&a, &c).unwrap();
|
||||
assert_eq!(std::fs::read(&c).unwrap(), b"a");
|
||||
assert!(!a.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_process_runs_as_the_account_that_the_token_says() {
|
||||
let me = winsec::current_user().unwrap();
|
||||
assert_eq!(winsec::process_user(std::process::id()).unwrap(), me);
|
||||
assert!(me.to_text().unwrap().starts_with("S-1-5-"));
|
||||
}
|
||||
Reference in new issue
Block a user