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:
swaitsandClaude Fable 5.1 committed 2026-10-29 14:38:29 -06:00
1 parent b97234ac7e
commit 5f5dd8f7da
10 files changed
+733 -43

No files matched your search

Generated
+1
View File
@@ -4049,6 +4049,7 @@ dependencies = [
"subtle",
"tempfile",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
[[package]]
+16
View File
@@ -28,6 +28,22 @@ argon2 = "0.6"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
# Owner-only DACLs, the account's SID, the known-folder API and
# MoveFileExW (P11c, D-62). The raw bindings, no runtime: the smallest
# Microsoft crate that covers them, already in the lockfile.
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
"Win32_System_Com",
"Win32_System_Pipes",
"Win32_System_SystemServices",
"Win32_System_Threading",
"Win32_UI_Shell",
] }
[dev-dependencies]
tempfile = "3"
proptest = "1"
+5
View File
@@ -188,6 +188,7 @@ impl ChainLog {
genesis_text: &str,
) -> Result<(Self, Vec<Map<String, Value>>), ChainError> {
let read = read(path, genesis_text)?;
#[cfg(unix)]
let new = !path.exists();
let mut options = OpenOptions::new();
options.create(true).append(true);
@@ -205,6 +206,10 @@ impl ChainLog {
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
}
#[cfg(windows)]
if !crate::winsec::is_private(path).unwrap_or(false) {
crate::winsec::set_owner_only(path)?;
}
if read.torn {
file.set_len(read.good_len)?;
file.sync_all()?;
+3
View File
@@ -28,6 +28,7 @@ pub mod exports;
pub mod form1099;
pub mod format;
pub mod journal;
pub mod lock;
pub mod meta;
pub mod ops;
pub mod passphrase;
@@ -40,6 +41,8 @@ pub mod schema;
pub mod snapshot;
pub mod store;
pub mod uploads;
#[cfg(windows)]
pub mod winsec;
pub use blob::BlobStore;
pub use error::StoreError;
+36
View File
@@ -0,0 +1,36 @@
//! File locks, behind one seam (P11c, D-62).
//!
//! ```text
//! GOAL: "some process holds this file" is asked and answered the same
//! way on every system.
//! SOURCE: the standard library's `File::try_lock` and `try_lock_shared`
//! (flock(2) on Unix, LockFileEx on Windows), plan/served.md 3.9
//! ("the viewer's presence is a shared lock").
//! INVARIANTS:
//! 1. Only the standard library's locks are used: no second copy of an
//! `unsafe` flock or LockFileEx call lives anywhere else.
//! 2. A lock is let go when its file is closed, by this process ending
//! or not (the system does it; nothing here keeps a record).
//! 3. A question that cannot be answered is answered "not held".
//! ```
use std::fs::{File, TryLockError};
/// Take a shared lock on `file` without waiting: whether it is now held.
pub fn try_shared(file: &File) -> bool {
file.try_lock_shared().is_ok()
}
/// Whether another holder keeps a shared lock on `file` (so that an
/// exclusive one can't be taken now). A lock taken to find out is let go
/// at once.
pub fn held_elsewhere(file: &File) -> bool {
match file.try_lock() {
Ok(()) => {
let _ = file.unlock();
false
}
Err(TryLockError::WouldBlock) => true,
Err(TryLockError::Error(_)) => false,
}
}
+20 -24
View File
@@ -238,13 +238,8 @@ impl Presence {
/// (None until it has: the viewer makes nothing).
pub fn hold(data_dir: &Path) -> Option<Presence> {
let f = std::fs::File::open(ops_dir(data_dir).join(VIEWER_LOCK)).ok()?;
#[cfg(unix)]
{
use std::os::fd::AsRawFd;
// SAFETY: flock on a descriptor this function owns.
if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } != 0 {
return None;
}
if !crate::lock::try_shared(&f) {
return None;
}
Some(Presence(f))
}
@@ -256,23 +251,7 @@ pub fn viewer_open(data_dir: &Path) -> bool {
let Ok(f) = std::fs::File::open(ops_dir(data_dir).join(VIEWER_LOCK)) else {
return false;
};
#[cfg(unix)]
{
use std::os::fd::AsRawFd;
// SAFETY: flock on a descriptor this function owns; a lock taken
// is let go at once (and with the descriptor anyway).
let free = unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0;
if free {
// SAFETY: as above.
unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_UN) };
}
!free
}
#[cfg(not(unix))]
{
drop(f);
false
}
crate::lock::held_elsewhere(&f)
}
#[cfg(test)]
@@ -308,6 +287,23 @@ mod tests {
assert_eq!(requests(dir.path()).len(), KEPT);
}
/// The lock seam on every system: a viewer's presence is seen by
/// another handle, shared between viewers, and let go with its file.
#[test]
fn a_viewer_s_presence_is_a_shared_lock_others_can_see() {
let dir = tempfile::tempdir().unwrap();
assert!(Presence::hold(dir.path()).is_none());
prepare(dir.path()).unwrap();
assert!(!viewer_open(dir.path()));
let held = Presence::hold(dir.path()).unwrap();
assert!(viewer_open(dir.path()));
let second = Presence::hold(dir.path()).unwrap();
drop(held);
assert!(viewer_open(dir.path()));
drop(second);
assert!(!viewer_open(dir.path()));
}
#[cfg(unix)]
#[test]
fn the_server_makes_the_folder_and_the_viewer_only_reads_it() {
+66 -18
View File
@@ -179,8 +179,8 @@ impl std::fmt::Display for BookId {
}
/// This account's home folder, from the password database on Unix (never
/// `$HOME`, which the process that started us chose), or the Windows
/// profile.
/// `$HOME`, which the process that started us chose), or the profile
/// folder the Windows shell reports.
pub fn account_home() -> Result<PathBuf, RootError> {
#[cfg(unix)]
{
@@ -190,11 +190,9 @@ pub fn account_home() -> Result<PathBuf, RootError> {
}
#[cfg(windows)]
{
// Read from the environment until the Windows build (P11) takes it
// from the known-folder API.
std::env::var_os("USERPROFILE")
.map(PathBuf::from)
.ok_or(RootError::NoHome)
// The known-folder API, never `%USERPROFILE%`, which the process
// that started us chose (the Unix arm's rule).
crate::winsec::profile_folder().ok_or(RootError::NoHome)
}
#[cfg(not(any(unix, windows)))]
{
@@ -256,6 +254,15 @@ fn unix_account() -> Option<(PathBuf, Vec<u8>)> {
/// The platform's data folder under `home`: where the default root sits.
pub fn platform_data_dir(home: &Path) -> PathBuf {
// This account's own home on Windows: `%LOCALAPPDATA%` as the shell
// reports it (a redirected profile included). Any other home (tests)
// gets the default place under it.
#[cfg(windows)]
if account_home().is_ok_and(|own| own == home)
&& let Some(local) = crate::winsec::local_app_data()
{
return local;
}
if cfg!(target_os = "macos") {
home.join("Library").join("Application Support")
} else if cfg!(windows) {
@@ -295,7 +302,7 @@ pub fn private_book_folder(book_dir: &Path, name: &str) -> Result<PathBuf, RootE
let dir = book_dir.join(name);
match std::fs::symlink_metadata(&dir) {
Ok(meta) => {
let open = check_private_dir(&meta, true, "a folder inside the books")?;
let open = check_private_dir(&dir, &meta, true, "a folder inside the books")?;
tighten_if(&dir, open)?;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
@@ -404,7 +411,7 @@ impl Root {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(RootError::Missing),
Err(e) => return Err(e.into()),
};
check_private_dir(&meta, false, THE_ROOT)?;
check_private_dir(path, &meta, false, THE_ROOT)?;
check_ancestors(path)?;
if !path.join(MARKER_FILE).is_file() {
return Err(RootError::Unsafe(format!(
@@ -473,7 +480,8 @@ impl Root {
Ok(meta) => {
// Checked before anything is changed: a folder that holds
// other things is left exactly as it is.
let open = check_private_dir(&meta, true, "the folder chosen for the books")?;
let open =
check_private_dir(&path, &meta, true, "the folder chosen for the books")?;
if !path.join(MARKER_FILE).is_file() && std::fs::read_dir(&path)?.next().is_some() {
return Err(RootError::Unsafe(
"the folder chosen for the books already holds other things: choose an \
@@ -562,7 +570,7 @@ impl Root {
let tightened = match std::fs::symlink_metadata(&dir) {
Ok(meta) => tighten_if(
&dir,
check_private_dir(&meta, tighten, "a folder inside it")?,
check_private_dir(&dir, &meta, tighten, "a folder inside it")?,
)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
make_private_dir(&dir)?;
@@ -1223,6 +1231,8 @@ impl Root {
)));
}
}
// Windows: a move to another disk is refused by the move
// itself (no copy fallback is asked for).
// A vault or its key among the other paths would go aside
// unchecked: each comes by its own name, and is checked.
let k = items.len();
@@ -1473,10 +1483,17 @@ fn rename_new(from: &Path, to: &Path) -> std::io::Result<()> {
return Err(e);
}
}
if std::fs::symlink_metadata(to).is_ok() {
return Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists));
// Windows: MoveFileExW without the replace flag refuses an existing
// target itself.
#[cfg(windows)]
return crate::winsec::rename_new(from, to);
#[cfg(not(windows))]
{
if std::fs::symlink_metadata(to).is_ok() {
return Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists));
}
std::fs::rename(from, to)
}
std::fs::rename(from, to)
}
/// Whether the stored files under `old` (a books' `blobs/`) are all in
@@ -1771,6 +1788,12 @@ fn recorded_root(home: &Path) -> Result<Option<PathBuf>, RootError> {
return Err(unsafe_pointer());
}
}
// Windows: this account's own file that no one else can write (the
// DACL says who can open it at all; INVARIANT 3).
#[cfg(windows)]
if !crate::winsec::is_private(&pointer).unwrap_or(false) {
return Err(unsafe_pointer());
}
let text = std::fs::read_to_string(&pointer)?;
let path = PathBuf::from(text.trim_end_matches(['\n', '\r']));
if path.is_absolute() {
@@ -1868,9 +1891,16 @@ fn set_mode(path: &Path, mode: u32) -> std::io::Result<()> {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
}
#[cfg(not(unix))]
/// Windows: the mode is the DACL, this account's alone, protected, and
/// inherited into a folder's contents (`mode` says only that the caller
/// wants it private; a folder or a file is told by what `path` is).
#[cfg(windows)]
fn set_mode(path: &Path, _mode: u32) -> std::io::Result<()> {
crate::winsec::set_owner_only(path)
}
#[cfg(not(any(unix, windows)))]
fn set_mode(_path: &Path, _mode: u32) -> std::io::Result<()> {
// Windows: the owner-only DACL comes with the Windows build (P11).
Ok(())
}
@@ -1879,10 +1909,12 @@ fn set_mode(_path: &Path, _mode: u32) -> std::io::Result<()> {
/// to make 0700 with [`tighten_if`]; without, it is refused. `what` names
/// it in messages.
fn check_private_dir(
path: &Path,
meta: &std::fs::Metadata,
tighten: bool,
what: &str,
) -> Result<bool, RootError> {
let _ = path; // Unix reads the mode from `meta`; Windows reads the DACL at `path`.
if meta.file_type().is_symlink() {
return Err(RootError::Unsafe(format!(
"{what} is a symlink: it must be a real folder"
@@ -1910,8 +1942,18 @@ fn check_private_dir(
return Ok(true);
}
}
#[cfg(not(unix))]
let _ = tighten;
// Windows: this account's own, with no one else on its access list.
#[cfg(windows)]
if !crate::winsec::is_private(path).unwrap_or(false) {
if !tighten {
return Err(RootError::Unsafe(format!(
"other users can open {what}: run `wanderbooks setup` to make it private"
)));
}
return Ok(true);
}
#[cfg(not(any(unix, windows)))]
let _ = (path, tighten);
Ok(false)
}
@@ -1929,6 +1971,11 @@ fn check_ancestors(path: &Path) -> Result<(), RootError> {
/// sticky bit (like `/tmp`); group write only when the group is this
/// account's own private group (its primary group, with no other members:
/// the usual umask-002 setup on many Linux systems).
///
/// Windows: nothing above the root is checked. The root sits under the
/// account's own profile, whose folders the system gives that account
/// and the Administrators alone; another place is the owner's choice
/// (CHOICE, P11c: no authority states a Windows rule for ancestors).
fn check_dir_and_above(dir: &Path) -> Result<(), RootError> {
#[cfg(unix)]
{
@@ -1999,6 +2046,7 @@ fn is_private_group(gid: u32) -> bool {
/// it is the account's primary group, is named as the account's login is,
/// and lists no members. A shared primary group (openSUSE's `users`) has
/// another name, so it isn't.
#[cfg(unix)]
fn private_group_rule(gid: u32, primary: u32, name: &[u8], login: &[u8], members: bool) -> bool {
gid == primary && name == login && !members
}
+461
View File
@@ -0,0 +1,461 @@
//! Windows owner-only security (P11c, D-62): what mode 0700/0600 and
//! `getuid` are on Unix.
//!
//! ```text
//! GOAL: the data root, its folders and files, and the local pipes are
//! this account's alone, and a process can say whose another
//! process is.
//! SOURCE: plan/served.md 3.4 ("the data root ... an owner-only DACL
//! where Unix has mode 0700"), 3.9 ("named pipes with the SID
//! check where Unix checks peer uid"), D-62; Microsoft's
//! documentation of SetNamedSecurityInfoW, SDDL strings,
//! GetNamedPipeClientProcessId and SHGetKnownFolderPath (cited,
//! not copied).
//! INVARIANTS:
//! 1. An owner-only DACL is protected (it takes nothing from the folder
//! above) and holds one allow entry, for this account's SID;
//! folders' entry is inherited by what is made inside them.
//! 2. A path is private when its owner is this account (or the
//! Administrators group a elevated account's files are owned by)
//! and no allow entry names anyone but this account, SYSTEM or the
//! Administrators; a missing DACL (everyone allowed) is not private.
//! 3. Every question that cannot be answered (a handle that won't open,
//! a token that won't read) is "no": nothing here fails open.
//! ```
use std::ffi::{OsStr, OsString, c_void};
use std::io;
use std::os::windows::ffi::{OsStrExt, OsStringExt};
use std::path::{Path, PathBuf};
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
use windows_sys::Win32::Security::Authorization::{
ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW,
GetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT, SetNamedSecurityInfoW,
};
use windows_sys::Win32::Security::{
ACL, DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetLengthSid, GetSecurityDescriptorDacl,
GetTokenInformation, IsWellKnownSid, OWNER_SECURITY_INFORMATION,
PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SECURITY_ATTRIBUTES,
TOKEN_QUERY, TOKEN_USER, TokenUser, WinBuiltinAdministratorsSid, WinLocalSystemSid,
};
use windows_sys::Win32::System::Com::CoTaskMemFree;
use windows_sys::Win32::System::Threading::{
GetCurrentProcess, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION,
};
use windows_sys::Win32::UI::Shell::{
FOLDERID_LocalAppData, FOLDERID_Profile, KF_FLAG_DEFAULT, SHGetKnownFolderPath,
};
/// An ACCESS_ALLOWED_ACE_TYPE entry's type byte.
const ACCESS_ALLOWED: u8 = 0;
/// A handle closed when dropped.
struct Owned(HANDLE);
impl Drop for Owned {
fn drop(&mut self) {
// SAFETY: a handle this value opened and owns, closed once.
unsafe { CloseHandle(self.0) };
}
}
/// A SID: an owned copy of its bytes.
#[derive(Clone, Debug)]
pub struct Sid(Vec<u64>);
impl Sid {
/// A copy of the SID `sid` points at.
///
/// # Safety
/// `sid` must point at a valid SID.
unsafe fn copy_of(sid: PSID) -> Sid {
// SAFETY: the caller's promise that `sid` is a valid SID.
let len = unsafe { GetLengthSid(sid) } as usize;
let mut words = vec![0u64; len.div_ceil(8)];
// SAFETY: `len` bytes of a valid SID into a buffer of at least
// `len` bytes; the buffer is 8-byte aligned.
unsafe { std::ptr::copy_nonoverlapping(sid.cast::<u8>(), words.as_mut_ptr().cast(), len) };
Sid(words)
}
fn as_psid(&self) -> PSID {
self.0.as_ptr() as PSID
}
/// The SID as the text SDDL uses (`S-1-5-21-...`).
pub fn to_text(&self) -> io::Result<String> {
let mut out: *mut u16 = std::ptr::null_mut();
// SAFETY: a valid SID, and an out pointer to receive an allocation.
if unsafe { ConvertSidToStringSidW(self.as_psid(), &mut out) } == 0 {
return Err(io::Error::last_os_error());
}
let text = from_wide_ptr(out);
// SAFETY: the allocation ConvertSidToStringSidW made, freed once.
unsafe { LocalFree(out.cast()) };
text.into_string()
.map_err(|_| io::Error::other("a SID that is not text"))
}
}
impl PartialEq for Sid {
fn eq(&self, other: &Sid) -> bool {
// SAFETY: both are valid SIDs copied from the system.
unsafe { EqualSid(self.as_psid(), other.as_psid()) != 0 }
}
}
impl Eq for Sid {}
/// The text a NUL-terminated wide string holds.
fn from_wide_ptr(p: *const u16) -> OsString {
let mut len = 0;
// SAFETY: the caller passes a NUL-terminated string the system made.
while unsafe { *p.add(len) } != 0 {
len += 1;
}
// SAFETY: `len` units were just read from it.
OsString::from_wide(unsafe { std::slice::from_raw_parts(p, len) })
}
/// A path or text as a NUL-terminated wide string.
pub fn wide(s: &OsStr) -> Vec<u16> {
s.encode_wide().chain(std::iter::once(0)).collect()
}
/// The user SID of the token `token` (TOKEN_QUERY).
fn token_user(token: HANDLE) -> io::Result<Sid> {
let mut needed = 0u32;
// SAFETY: asking only for the size; the call is expected to fail
// with "insufficient buffer" and set `needed`.
unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed) };
if needed == 0 {
return Err(io::Error::last_os_error());
}
// 8-byte aligned for TOKEN_USER's pointers.
let mut buf = vec![0u64; (needed as usize).div_ceil(8)];
// SAFETY: a buffer of at least `needed` bytes.
if unsafe {
GetTokenInformation(
token,
TokenUser,
buf.as_mut_ptr().cast(),
needed,
&mut needed,
)
} == 0
{
return Err(io::Error::last_os_error());
}
// SAFETY: the buffer now holds a TOKEN_USER whose SID points inside it.
Ok(unsafe { Sid::copy_of((*buf.as_ptr().cast::<TOKEN_USER>()).User.Sid) })
}
/// This process's account.
pub fn current_user() -> io::Result<Sid> {
let mut token: HANDLE = std::ptr::null_mut();
// SAFETY: the pseudo-handle of this process, and an out handle.
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
return Err(io::Error::last_os_error());
}
let token = Owned(token);
token_user(token.0)
}
/// The account process `pid` runs as. An error when it can't be told
/// (a process of another account usually can't be opened at all).
pub fn process_user(pid: u32) -> io::Result<Sid> {
// SAFETY: a plain open of a process by number.
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if process.is_null() {
return Err(io::Error::last_os_error());
}
let process = Owned(process);
let mut token: HANDLE = std::ptr::null_mut();
// SAFETY: a process handle just opened, and an out handle.
if unsafe { OpenProcessToken(process.0, TOKEN_QUERY, &mut token) } == 0 {
return Err(io::Error::last_os_error());
}
let token = Owned(token);
token_user(token.0)
}
/// A security descriptor the system allocated, freed when dropped.
struct Descriptor(PSECURITY_DESCRIPTOR);
impl Drop for Descriptor {
fn drop(&mut self) {
// SAFETY: an allocation of the system's, freed once.
unsafe { LocalFree(self.0) };
}
}
/// The descriptor of SDDL `text`.
fn from_sddl(text: &str) -> io::Result<Descriptor> {
let wide = wide(OsStr::new(text));
let mut sd: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
// SAFETY: a NUL-terminated string and an out pointer.
let ok = unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
wide.as_ptr(),
SDDL_REVISION_1,
&mut sd,
std::ptr::null_mut(),
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(Descriptor(sd))
}
/// The SDDL of a DACL that holds one allow entry, full access for this
/// account, protected; for a folder, inherited by what it holds.
fn owner_only_sddl(folder: bool) -> io::Result<String> {
let sid = current_user()?.to_text()?;
Ok(if folder {
format!("D:P(A;OICI;FA;;;{sid})")
} else {
format!("D:P(A;;FA;;;{sid})")
})
}
/// Make `path` this account's alone (INVARIANT 1): the mode 0700 of a
/// folder, the 0600 of a file.
pub fn set_owner_only(path: &Path) -> io::Result<()> {
let folder = std::fs::metadata(path)?.is_dir();
let sd = from_sddl(&owner_only_sddl(folder)?)?;
let mut present = 0;
let mut defaulted = 0;
let mut dacl: *mut ACL = std::ptr::null_mut();
// SAFETY: a descriptor the system made, and out values.
if unsafe { GetSecurityDescriptorDacl(sd.0, &mut present, &mut dacl, &mut defaulted) } == 0 {
return Err(io::Error::last_os_error());
}
let name = wide(path.as_os_str());
// SAFETY: a NUL-terminated path, no owner or group to set, and a DACL
// that lives in `sd` past the call.
let status = unsafe {
SetNamedSecurityInfoW(
name.as_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
dacl,
std::ptr::null(),
)
};
if status != 0 {
return Err(io::Error::from_raw_os_error(status as i32));
}
Ok(())
}
/// Whether the owner and the allow entries of `path` are this account's
/// (INVARIANT 2): the answer to "do others have access".
pub fn is_private(path: &Path) -> io::Result<bool> {
let me = current_user()?;
let name = wide(path.as_os_str());
let mut owner: PSID = std::ptr::null_mut();
let mut dacl: *mut ACL = std::ptr::null_mut();
let mut sd: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
// SAFETY: a NUL-terminated path and out pointers; the descriptor the
// call allocates holds the owner and the DACL and is freed below.
let status = unsafe {
GetNamedSecurityInfoW(
name.as_ptr(),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
&mut owner,
std::ptr::null_mut(),
&mut dacl,
std::ptr::null_mut(),
&mut sd,
)
};
if status != 0 {
return Err(io::Error::from_raw_os_error(status as i32));
}
let _free = Descriptor(sd);
// SAFETY: `owner` points into `sd`, alive here.
let owner_ok = !owner.is_null()
&& (unsafe { EqualSid(owner, me.as_psid()) } != 0
|| unsafe { IsWellKnownSid(owner, WinBuiltinAdministratorsSid) } != 0);
if !owner_ok || dacl.is_null() {
return Ok(false);
}
// SAFETY: `dacl` points into `sd`; its entries are read in place.
let count = unsafe { (*dacl).AceCount };
for i in 0..u32::from(count) {
let mut ace: *mut c_void = std::ptr::null_mut();
// SAFETY: an index below the entry count of a valid ACL.
if unsafe { GetAce(dacl, i, &mut ace) } == 0 {
return Ok(false);
}
// SAFETY: every ACE starts with its header.
let header = unsafe { *ace.cast::<windows_sys::Win32::Security::ACE_HEADER>() };
if header.AceType != ACCESS_ALLOWED {
// Deny entries only take access away; the object-specific
// allow types don't occur on files, and one is not private.
if (1..=3).contains(&header.AceType) {
continue;
}
return Ok(false);
}
// SAFETY: an ACCESS_ALLOWED_ACE's SID starts at SidStart.
let sid = unsafe {
std::ptr::addr_of_mut!(
(*ace.cast::<windows_sys::Win32::Security::ACCESS_ALLOWED_ACE>()).SidStart
)
}
.cast::<c_void>();
// SAFETY: a valid SID inside the ACE.
let ours = unsafe {
EqualSid(sid, me.as_psid()) != 0
|| IsWellKnownSid(sid, WinLocalSystemSid) != 0
|| IsWellKnownSid(sid, WinBuiltinAdministratorsSid) != 0
};
if !ours {
return Ok(false);
}
}
Ok(true)
}
/// A known folder's path, from the shell (never an environment variable
/// the process that started us chose).
fn known_folder(id: &windows_sys::core::GUID) -> Option<PathBuf> {
let mut out: *mut u16 = std::ptr::null_mut();
// SAFETY: a known-folder id, the current account's token (null), and an
// out pointer for a string the shell allocates.
let hr =
unsafe { SHGetKnownFolderPath(id, KF_FLAG_DEFAULT as u32, std::ptr::null_mut(), &mut out) };
if hr != 0 || out.is_null() {
if !out.is_null() {
// SAFETY: freed once, as the API asks even on failure.
unsafe { CoTaskMemFree(out.cast()) };
}
return None;
}
let path = PathBuf::from(from_wide_ptr(out));
// SAFETY: the shell's allocation, freed once.
unsafe { CoTaskMemFree(out.cast()) };
path.is_absolute().then_some(path)
}
/// This account's profile folder (`C:\Users\name`).
pub fn profile_folder() -> Option<PathBuf> {
known_folder(&FOLDERID_Profile)
}
/// This account's local application data folder (`%LOCALAPPDATA%`,
/// wherever it was redirected to).
pub fn local_app_data() -> Option<PathBuf> {
known_folder(&FOLDERID_LocalAppData)
}
/// Rename `from` to `to`, never over something already at `to`, and
/// written through to disk.
pub fn rename_new(from: &Path, to: &Path) -> io::Result<()> {
use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_WRITE_THROUGH, MoveFileExW};
let (f, t) = (wide(from.as_os_str()), wide(to.as_os_str()));
// SAFETY: two NUL-terminated paths; no REPLACE_EXISTING flag, so an
// existing target is an error.
if unsafe { MoveFileExW(f.as_ptr(), t.as_ptr(), MOVEFILE_WRITE_THROUGH) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
/// Security attributes that make a kernel object (a named pipe) this
/// account's alone, for a call that takes `SECURITY_ATTRIBUTES`.
pub struct OwnerOnly {
_descriptor: Descriptor,
attributes: SECURITY_ATTRIBUTES,
}
// SAFETY: the descriptor is immutable memory that nothing else touches.
unsafe impl Send for OwnerOnly {}
unsafe impl Sync for OwnerOnly {}
impl OwnerOnly {
/// A descriptor with one allow entry, full access for this account.
pub fn new() -> io::Result<OwnerOnly> {
let sddl = format!("D:P(A;;GA;;;{})", current_user()?.to_text()?);
let descriptor = from_sddl(&sddl)?;
let attributes = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: descriptor.0,
bInheritHandle: 0,
};
Ok(OwnerOnly {
_descriptor: descriptor,
attributes,
})
}
/// The pointer `create_with_security_attributes_raw` takes; good while
/// this lives.
pub fn as_raw(&self) -> *mut c_void {
std::ptr::addr_of!(self.attributes).cast_mut().cast()
}
}
/// This process's account, asked of the system once.
fn me() -> Option<&'static Sid> {
static ME: std::sync::OnceLock<Option<Sid>> = std::sync::OnceLock::new();
ME.get_or_init(|| current_user().ok()).as_ref()
}
/// Whether process `pid` runs as this account (INVARIANT 3: any doubt is
/// "no").
pub fn pid_is_us(pid: u32) -> bool {
pid != 0 && me().is_some_and(|me| process_user(pid).is_ok_and(|them| &them == me))
}
/// The process at the client end of the pipe `handle` (a server's).
pub fn pipe_client_pid(handle: std::os::windows::io::RawHandle) -> io::Result<u32> {
let mut pid = 0u32;
// SAFETY: a pipe handle the caller owns and an out value.
if unsafe { windows_sys::Win32::System::Pipes::GetNamedPipeClientProcessId(handle, &mut pid) }
== 0
{
return Err(io::Error::last_os_error());
}
Ok(pid)
}
/// The process at the server end of the pipe `handle` (a client's).
pub fn pipe_server_pid(handle: std::os::windows::io::RawHandle) -> io::Result<u32> {
let mut pid = 0u32;
// SAFETY: a pipe handle the caller owns and an out value.
if unsafe { windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId(handle, &mut pid) }
== 0
{
return Err(io::Error::last_os_error());
}
Ok(pid)
}
/// How many bytes wait to be read on the pipe `handle`, without reading
/// them. A pipe whose other end has gone is `ERROR_BROKEN_PIPE`.
pub fn pipe_bytes_waiting(handle: std::os::windows::io::RawHandle) -> io::Result<u32> {
let mut avail = 0u32;
// SAFETY: a pipe handle the caller owns; only the available count is
// asked for.
let ok = unsafe {
windows_sys::Win32::System::Pipes::PeekNamedPipe(
handle,
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
&mut avail,
std::ptr::null_mut(),
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(avail)
}
+124
View File
@@ -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-"));
}
+1 -1
View File
@@ -4,7 +4,7 @@
[tools]
# Workspace toolchain (clippy and rustfmt for `just check`).
rust = [
{ version = "1.99", components = "clippy,rustfmt" },
{ version = "1.99", components = "clippy,rustfmt", targets = "x86_64-pc-windows-msvc" },
# Fuzzing (cargo-fuzz) and Miri need nightly; pinned so runs repeat.
{ version = "nightly-2026-10-01", components = "miri,rust-src" },
]