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
Generated
+1
@@ -4049,6 +4049,7 @@ dependencies = [
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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-"));
|
||||
}
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user