From 6b8993fa264ee46492fdd33b277821198bfe4c07 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Wed, 21 Oct 2026 08:08:26 +0300 Subject: [PATCH 1/8] feat(repo): owner-only DACL for .sc/local-key on Windows Create the key with a protected single-ACE DACL for the current user from the first byte (windows-sys, unsafe confined to winacl.rs). A failure falls back to default permissions plus the one-time warning. Refs #214 --- .github/workflows/ci.yml | 19 +++++ Cargo.lock | 1 + crates/repo/Cargo.toml | 4 + crates/repo/src/cache.rs | 100 +++++++++++++++++++----- crates/repo/src/lib.rs | 2 + crates/repo/src/winacl.rs | 156 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 265 insertions(+), 17 deletions(-) create mode 100644 crates/repo/src/winacl.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18a9c229..b5badfc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,25 @@ jobs: - name: doc run: cargo doc --workspace --no-deps + # Advisory (not a required context): proves the Windows build and the owner-only + # ACL on .sc/local-key (#214). Deliberately not `cargo test --workspace`; the + # wider suite still has unix-only assumptions. + windows: + name: windows (check + key ACL tests) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@2fe4ca74464c5902a4f6e302d0a619b4ea911ccc # 1.96.1 + with: + components: clippy + - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + - name: check + run: cargo check --workspace --all-targets + - name: clippy + run: cargo clippy --workspace --all-targets -- -D warnings + - name: key ACL and cache tests + run: cargo test -p scl-repo --lib cache:: + desktop: name: desktop (typecheck + test + audit) runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 39bd3818..77868d17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5443,6 +5443,7 @@ dependencies = [ "serde", "thiserror 2.0.21", "toml 1.1.6+spec-1.1.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/repo/Cargo.toml b/crates/repo/Cargo.toml index d75f549c..aac1d326 100644 --- a/crates/repo/Cargo.toml +++ b/crates/repo/Cargo.toml @@ -19,5 +19,9 @@ scl-objio = { version = "0.1.0", path = "../objio" } hex = "0.4.3" blake3 = "1.8.5" +# Owner-only DACL for .sc/local-key (#214); the only unsafe is in src/winacl.rs. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading"] } + [lints] workspace = true diff --git a/crates/repo/src/cache.rs b/crates/repo/src/cache.rs index 88034aea..92c65c59 100644 --- a/crates/repo/src/cache.rs +++ b/crates/repo/src/cache.rs @@ -9,9 +9,10 @@ //! nothing — it does not reintroduce the equality oracle this phase closes. //! A lost/stale/corrupt cache degrades to spurious re-seals, never //! incorrectness and never a hard error. -//! Where the key file cannot be made owner-only (non-unix; #214) it is -//! still written with default permissions and a one-time warning is printed -//! once the cache holds a protected entry: see [`can_restrict_permissions`]. +//! Where the key file cannot be made owner-only (neither unix nor Windows, or +//! the Windows ACL call failed; #214) it is still written with default +//! permissions and a one-time warning is printed once the cache holds a +//! protected entry: see [`can_restrict_permissions`]. //! //! **The keyed tag is the sole authority for a hit.** `mtime_ns`/`size` are //! recorded for documentation (when the entry was observed) and as a @@ -34,18 +35,34 @@ use scl_core::ObjectId; use crate::error::{Error, Result}; use crate::layout::Layout; -/// Whether `.sc/local-key` can be created readable by its owner only. Only -/// unix can (`mode(0o600)` from the first byte); there is no Windows ACL path -/// yet (#214), so elsewhere the key keeps default permissions and a warning is -/// printed. Tests force `false` via `force_unrestricted_for_test`. +/// Whether `.sc/local-key` can be created readable by its owner only: unix +/// (`mode(0o600)`) and Windows (owner-only DACL, `winacl`), both from the first +/// byte. Elsewhere, or after a Windows ACL failure, the key keeps default +/// permissions and a warning is printed. Tests force `false` via +/// `force_unrestricted_for_test`. fn can_restrict_permissions() -> bool { #[cfg(test)] if FORCE_UNRESTRICTED.with(|f| f.get()) { return false; } + platform_can_restrict() +} + +#[cfg(windows)] +fn platform_can_restrict() -> bool { + !ACL_FAILED.load(std::sync::atomic::Ordering::Relaxed) +} + +#[cfg(not(windows))] +fn platform_can_restrict() -> bool { cfg!(unix) } +/// Set once a Windows owner-only create failed and the key fell back to default +/// permissions, so the unrestricted warning fires. +#[cfg(windows)] +static ACL_FAILED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + #[cfg(test)] thread_local! { static FORCE_UNRESTRICTED: std::cell::Cell = const { std::cell::Cell::new(false) }; @@ -115,29 +132,46 @@ pub(crate) fn local_key(layout: &Layout) -> Result<[u8; 32]> { .map_err(|_| Error::InvalidArgument("malformed .sc/local-key".into())) } -#[cfg(unix)] fn write_key_0600(path: &Path, bytes: &[u8]) -> std::io::Result<()> { use std::io::Write; + create_key_file(path)?.write_all(bytes) +} + +#[cfg(unix)] +fn create_key_file(path: &Path) -> std::io::Result { use std::os::unix::fs::OpenOptionsExt; - let mut f = std::fs::OpenOptions::new() + std::fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o600) - .open(path)?; - f.write_all(bytes) + .open(path) +} + +/// Windows: owner-only DACL from the first byte; if that cannot be applied, +/// fall back to default (inherited) permissions plus the warning, never fail. +#[cfg(windows)] +fn create_key_file(path: &Path) -> std::io::Result { + match crate::winacl::create_new_owner_only(path) { + Err(e) if e.kind() != std::io::ErrorKind::AlreadyExists => { + ACL_FAILED.store(true, std::sync::atomic::Ordering::Relaxed); + create_key_file_default(path) + } + r => r, + } } -/// Non-unix: default (inherited) permissions; see [`can_restrict_permissions`]. +/// Default (inherited) permissions; see [`can_restrict_permissions`]. #[cfg(not(unix))] -fn write_key_0600(path: &Path, bytes: &[u8]) -> std::io::Result<()> { - use std::io::Write; - let mut f = std::fs::OpenOptions::new() +fn create_key_file_default(path: &Path) -> std::io::Result { + std::fs::OpenOptions::new() .write(true) .create_new(true) - .open(path)?; - f.write_all(bytes) + .open(path) } +#[cfg(not(any(unix, windows)))] +use create_key_file_default as create_key_file; + #[derive(Clone, Copy, Debug, PartialEq)] struct CacheEntry { mtime_ns: u128, @@ -377,6 +411,38 @@ mod tests { cleanup(&layout); } + /// Windows: the production create path leaves a DACL with exactly one ACE, + /// the current user's, and nothing inherited (read back with `icacls`). + #[cfg(windows)] + #[test] + fn local_key_dacl_is_owner_only_on_windows() { + let layout = tmp("acl"); + let key = local_key(&layout).unwrap(); + assert_eq!(key, local_key(&layout).unwrap(), "owner can still read it"); + assert!( + can_restrict_permissions(), + "ACL path must not have fallen back" + ); + + let path = layout.local_key_path(); + let out = std::process::Command::new("icacls") + .arg(&path) + .output() + .unwrap(); + assert!(out.status.success(), "icacls failed: {out:?}"); + let text = String::from_utf8_lossy(&out.stdout).to_string(); + let acl = text.replace(path.to_str().unwrap(), ""); + let aces: Vec<&str> = acl.lines().filter(|l| l.contains(":(")).collect(); + let user = std::env::var("USERNAME").unwrap().to_lowercase(); + assert_eq!(aces.len(), 1, "exactly one ACE expected:\n{text}"); + let ace = aces[0].trim().to_lowercase(); + assert!( + ace.contains(&format!("\\{user}:(")) && ace.contains("(f)") && !ace.contains("(i)"), + "ACE must be an explicit full-control grant to {user}:\n{text}" + ); + cleanup(&layout); + } + #[test] fn unrestricted_key_still_persists_and_hits() { force_unrestricted_for_test(true); diff --git a/crates/repo/src/lib.rs b/crates/repo/src/lib.rs index dac39c8b..1371fb10 100644 --- a/crates/repo/src/lib.rs +++ b/crates/repo/src/lib.rs @@ -65,6 +65,8 @@ pub mod transport; #[cfg(test)] mod user_file_safety; pub mod walfmt; +#[cfg(windows)] +mod winacl; pub mod wire; pub mod workspace; pub mod worktree; diff --git a/crates/repo/src/winacl.rs b/crates/repo/src/winacl.rs new file mode 100644 index 00000000..b2d78e51 --- /dev/null +++ b/crates/repo/src/winacl.rs @@ -0,0 +1,156 @@ +//! Windows owner-only file creation for `.sc/local-key` (#214). +//! +//! The only `unsafe` in `scl-repo` lives here, behind one safe function: +//! [`create_new_owner_only`]. It builds a *protected* DACL holding a single +//! ACE (`GENERIC_ALL` for the current user's SID) and hands it to `CreateFileW` +//! as the file's security descriptor, so the file is owner-only from its first +//! byte: no inherited ACEs, no window at default permissions. Every Win32 +//! allocation and handle is released by a drop guard on all paths. + +use std::ffi::c_void; +use std::fs::File; +use std::io; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::FromRawHandle; +use std::path::Path; +use std::ptr; + +use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, LocalFree, ERROR_INSUFFICIENT_BUFFER, GENERIC_WRITE, HANDLE, + INVALID_HANDLE_VALUE, +}; +use windows_sys::Win32::Security::Authorization::{ + ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, +}; +use windows_sys::Win32::Security::{ + GetTokenInformation, TokenUser, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, +}; +use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_DELETE, FILE_SHARE_READ, + FILE_SHARE_WRITE, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + +/// Closes a kernel handle on drop. +struct OwnedHandle(HANDLE); + +impl Drop for OwnedHandle { + fn drop(&mut self) { + // SAFETY: the handle was returned by a successful Win32 call and is + // closed exactly once, here. + unsafe { CloseHandle(self.0) }; + } +} + +/// Frees a `LocalAlloc`-family allocation (returned by the Convert* calls). +struct LocalMem(*mut c_void); + +impl Drop for LocalMem { + fn drop(&mut self) { + // SAFETY: the pointer came from a Win32 call documented to require + // `LocalFree`, and is freed exactly once, here. + unsafe { LocalFree(self.0) }; + } +} + +/// The current user's SID as a string such as `S-1-5-21-...`. +fn current_user_sid_string() -> io::Result { + let mut token: HANDLE = ptr::null_mut(); + // SAFETY: `token` is a valid out-pointer; the pseudo-handle from + // `GetCurrentProcess` needs no closing. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let token = OwnedHandle(token); + + // First call sizes the buffer; it must fail with ERROR_INSUFFICIENT_BUFFER. + let mut len = 0u32; + // SAFETY: a null buffer of length 0 is the documented way to query the size. + unsafe { GetTokenInformation(token.0, TokenUser, ptr::null_mut(), 0, &mut len) }; + // SAFETY: reads the thread's last-error value, set by the call above. + if len == 0 || unsafe { GetLastError() } != ERROR_INSUFFICIENT_BUFFER { + return Err(io::Error::last_os_error()); + } + // u64 elements keep the buffer 8-byte aligned for TOKEN_USER. + let mut buf = vec![0u64; (len as usize).div_ceil(8)]; + // SAFETY: `buf` is at least `len` bytes, as the first call reported. + if unsafe { GetTokenInformation(token.0, TokenUser, buf.as_mut_ptr().cast(), len, &mut len) } + == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: the successful call filled `buf` with a TOKEN_USER whose SID + // pointer targets memory inside `buf`, which outlives this block. + let sid = unsafe { (*buf.as_ptr().cast::()).User.Sid }; + + let mut wide: *mut u16 = ptr::null_mut(); + // SAFETY: `sid` is a valid SID (above) and `wide` a valid out-pointer. + if unsafe { ConvertSidToStringSidW(sid, &mut wide) } == 0 { + return Err(io::Error::last_os_error()); + } + let wide = LocalMem(wide.cast()); + // SAFETY: on success `wide` points at a NUL-terminated UTF-16 string that + // stays alive until `LocalMem` drops. + let s = unsafe { + let p = wide.0.cast::(); + let mut n = 0; + while *p.add(n) != 0 { + n += 1; + } + String::from_utf16_lossy(std::slice::from_raw_parts(p, n)) + }; + Ok(s) +} + +/// Create `path` (failing with `AlreadyExists` if present) readable and +/// writable by the current user only, from the first byte. +pub(crate) fn create_new_owner_only(path: &Path) -> io::Result { + // `D:P` = protected DACL (nothing inherited); `GA` = GENERIC_ALL. + let sddl: Vec = format!("D:P(A;;GA;;;{})", current_user_sid_string()?) + .encode_utf16() + .chain([0]) + .collect(); + let wpath: Vec = path.as_os_str().encode_wide().chain([0]).collect(); + if wpath[..wpath.len() - 1].contains(&0) { + return Err(io::ErrorKind::InvalidInput.into()); + } + + let mut sd: *mut c_void = ptr::null_mut(); + // SAFETY: `sddl` is NUL-terminated; `sd` is a valid out-pointer. + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut sd, + ptr::null_mut(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let sd = LocalMem(sd); + + let attrs = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: sd.0, + bInheritHandle: 0, + }; + // SAFETY: `wpath` is NUL-terminated and `attrs` (with the descriptor `sd` + // owns) outlives the call; the share mode matches std's default. + let h = unsafe { + CreateFileW( + wpath.as_ptr(), + GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + &attrs, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL, + ptr::null_mut(), + ) + }; + if h == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: `h` is a valid, owned file handle; the `File` takes it over. + Ok(unsafe { File::from_raw_handle(h.cast()) }) +} From 709194a32c64d2a279b0c4e943c6a7d3ceccbf7c Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Wed, 21 Oct 2026 08:09:23 +0300 Subject: [PATCH 2/8] ci: quote cache:: test filter (YAML) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5badfc1..4ba767e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: - name: clippy run: cargo clippy --workspace --all-targets -- -D warnings - name: key ACL and cache tests - run: cargo test -p scl-repo --lib cache:: + run: cargo test -p scl-repo --lib "cache::" desktop: name: desktop (typecheck + test + audit) From 6fa52f7be2913bb0efb76c3a1292847f2dd972b6 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Wed, 21 Oct 2026 08:11:43 +0300 Subject: [PATCH 3/8] docs: owner-only local-key on Windows (THREAT-MODEL, ADR-0043, CLAUDE, ROADMAP, CONTRIBUTING) Refs #214 --- CLAUDE.md | 8 +++--- CONTRIBUTING.md | 5 +++- ROADMAP.md | 9 ++---- docs/THREAT-MODEL.md | 28 ++++++++++--------- .../0043-randomized-protected-encryption.md | 10 ++++--- 5 files changed, 31 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6e06b598..b53305f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,8 +236,9 @@ transport-adjacent. The rest, imperatively: (`.sc/protected-cache`, `/cache-`) are never committed and never transferred.** Cache saves are best-effort and ordered after ref moves; a cache failure may cause a spurious re-seal but never incorrectness. The key - is owner-only on unix only; non-unix keeps default permissions and warns once - (#214, ACL deferred). + is owner-only on unix (`0600`) and Windows (single-ACE protected DACL, from the first byte; + `crates/repo/src/winacl.rs`); other targets, or a failed Windows ACL call, keep default + permissions and warn once (#214). - **Revoke is standing-only.** A revoked recipient keeps decrypting ciphertext sealed before the revoke (they hold the old wraps); cryptographic cutover is `sc rewrap --rotate` at the tip (fresh key per protected blob; a plain @@ -351,8 +352,7 @@ transport-adjacent. The rest, imperatively: Deferred work is tracked in **`ROADMAP.md` → Deferred** (single source of truth — do not re-list items here); entries that name an issue mirror the open -GitHub issues. Standing entries: the non-unix -`.sc/local-key` ACL (#214), and the open +GitHub issues. Standing entry: the open user-file safety limits of the sparse guard (ADR-0034 refinement 8). ## Agent skills diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4270df19..88a1d2b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,10 @@ integration tests — if you touch a subsystem, run the relevant `demo/run_*.sh` CI (`ci.yml`) runs `fmt --check`, `clippy -D warnings`, `cargo test --workspace --all-targets` (Linux and macOS) and `cargo doc` for the Rust workspace, plus a `desktop` job (`npm ci`, `npm audit --audit-level=high`, typecheck, test). -Your PR must be green on all of them. Dependency changes also run the RustSec +An advisory `windows` job (`cargo check` and `clippy` for the workspace, plus +the `scl-repo` `cache::` tests, which verify the owner-only ACL on +`.sc/local-key`) is not a required check; keep it green when you touch +Windows-specific code. Your PR must be green on all required jobs. Dependency changes also run the RustSec `audit` workflow, and CodeQL, Scorecard, the SBOM generation, the `vendor-provenance` check and a nightly `wire_decode` fuzz run live in their own workflows. In addition: diff --git a/ROADMAP.md b/ROADMAP.md index ac21f355..5c0b3e0d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -642,7 +642,8 @@ across every phase. - Checkout restores the executable bit on the write-out paths (#242, #243); `sc resolve` after a deleted conflicted file restores it too. - A warning when the protected-cache key cannot be permission-restricted on a - non-unix target (#225; the owner-only ACL is #214). + non-unix target (#225), and an owner-only Windows ACL on `.sc/local-key`, + verified by the advisory `windows` CI job (#214). ## Active @@ -810,12 +811,6 @@ P28 review follow-ons, the port de-flake, the clippy allow-list) are recorded in - **`sc rewrap` on partial clones — refused (P37).** `Repo::rewrap` calls `refuse_on_partial`; a paths-only/streaming rebuild that would lift the refusal remains a possible follow-on. -- **Non-unix local-key permissions (#214, P33 follow-on).** `.sc/local-key` is - minted 0600-from-first-byte on unix only. On a non-unix target it keeps default - permissions and `sc` warns once when a cache holds a protected entry (#225; the - exposure is bounded, see THREAT-MODEL.md). Deferred: an owner-only Windows ACL - (`windows-sys` is already in `Cargo.lock`) and a manual check on a real Windows - host. - **fd secret injection beyond `sc run` (ADR-0052 follow-on).** `sc run --secrets-fd N` shipped. Not built: the same mode for `sc ws run` and `sc work --with-secrets`, and a stdin variant. diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index e058d82c..06630def 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -81,19 +81,21 @@ too, not just the src-control-side metadata. protected blob under a fresh key (ADR-0051), whereas a plain `sc rewrap` only replaces wrap lists and leaves the key a revoked holder already has. History keeps the old wraps and ciphertext regardless. -- **Local cache key is owner-only on unix only (P33, #214):** the - unchanged-detection cache is keyed by `.sc/local-key`, created `0600` from its - first byte. Only unix can do that; on a non-unix target the key and the cache - files are written with inherited default permissions, and `sc` prints a - one-time stderr warning once a cache holds a protected entry. The exposure is - small: checkout already writes protected plaintext into the working tree with - default permissions on every platform, and the cache holds tags only for files - whose plaintext is on disk, so a reader of the key could read that plaintext - directly. What remains is the tags of the last-committed version of a file - edited since, and stale entries until the next save. The real fix is an - owner-only Windows ACL (`windows-sys` is already a transitive dependency); it is - unverified without a Windows host and tracked in ROADMAP.md. A unix filesystem - that ignores mode bits (vfat, exFAT, some network mounts) is not detected. +- **Local cache key is owner-only on unix and Windows (P33, #214):** the + unchanged-detection cache is keyed by `.sc/local-key`, created owner-only from + its first byte: mode `0600` on unix, a protected DACL with a single + `GENERIC_ALL` ACE for the current user on Windows (no inherited entries; the + advisory `windows` CI job reads it back with `icacls`). Windows keys minted + before #214 keep their inherited DACL. On any other target, or if the Windows + ACL call fails, the key and the cache files are written with inherited default + permissions, and `sc` prints a one-time stderr warning once a cache holds a + protected entry. The exposure then is small: checkout already writes protected + plaintext into the working tree with default permissions on every platform, and + the cache holds tags only for files whose plaintext is on disk, so a reader of + the key could read that plaintext directly. What remains is the tags of the + last-committed version of a file edited since, and stale entries until the + next save. A unix filesystem that ignores mode bits (vfat, exFAT, some network + mounts) is not detected. ## Private branches (`sc branch --private` / `sc branch publish`) — ADR-0044 diff --git a/docs/adr/0043-randomized-protected-encryption.md b/docs/adr/0043-randomized-protected-encryption.md index 92bb827d..5b41db02 100644 --- a/docs/adr/0043-randomized-protected-encryption.md +++ b/docs/adr/0043-randomized-protected-encryption.md @@ -101,10 +101,12 @@ file changed. Detection is a per-checkout stat cache (`crates/repo/src/cache.rs` - `.sc/local-key`: 32 random bytes, mode **0600 from first byte** (via `OpenOptions::create_new` + `mode(0o600)`, so the file is never briefly world-readable between creation and permission-setting), created lazily, - **never committed, never transferred**. Non-unix targets cannot restrict it - (#214): it keeps default permissions with a one-time warning, because checkout - already writes the same plaintext to the working tree with default - permissions (see THREAT-MODEL.md). The per-checkout cache file itself + **never committed, never transferred**. On Windows (#214) it is created + through `CreateFileW` with a protected DACL holding one `GENERIC_ALL` ACE for + the current user, also from the first byte (`crates/repo/src/winacl.rs`). Other + targets, or a failed ACL call, keep default permissions with a one-time + warning, because checkout already writes the same plaintext to the working + tree with default permissions (see THREAT-MODEL.md). The per-checkout cache file itself (below) is written via `atomic_write_durable`. - Per-checkout cache files: `.sc/protected-cache` for the main working tree; `.sc/ws/cache-` per ws workspace (see below). Each entry is From 5c045d32d8f0b7e0622c61e8e43cf103cfc0cbb7 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Wed, 21 Oct 2026 08:17:55 +0300 Subject: [PATCH 4/8] ci: windows check keeps going to list every failure --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ba767e0..5d0869a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: components: clippy - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 - name: check - run: cargo check --workspace --all-targets + run: cargo check --workspace --all-targets --keep-going - name: clippy run: cargo clippy --workspace --all-targets -- -D warnings - name: key ACL and cache tests From c4c41222ee9e182d51ab25085fd37257a6362731 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Wed, 21 Oct 2026 08:32:22 +0300 Subject: [PATCH 5/8] fix: cfg-gate unix-only test and keygen code so the workspace checks on Windows Refs #214 --- crates/cli/src/main.rs | 1 + crates/gitio/src/bridge.rs | 2 ++ crates/repo/src/sparse_completion.rs | 1 + 3 files changed, 4 insertions(+) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 35f8ab9d..c0c34095 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1683,6 +1683,7 @@ fn run_keygen(out: Option) -> Result<()> { // Only tighten a directory we create ourselves: chmod-ing a pre-existing // parent (e.g. $HOME for `--out ~/identity`, or CWD for `--out ./key`) // would retroactively narrow the user's own dir — a footgun. + #[cfg(unix)] let parent_preexisted = parent.as_os_str().is_empty() || parent.exists(); std::fs::create_dir_all(parent)?; #[cfg(unix)] diff --git a/crates/gitio/src/bridge.rs b/crates/gitio/src/bridge.rs index 224c22a9..2be4db1b 100644 --- a/crates/gitio/src/bridge.rs +++ b/crates/gitio/src/bridge.rs @@ -291,6 +291,8 @@ mod tests { std::fs::remove_dir_all(&root).unwrap(); } + // The shim is a /bin/sh script made executable with a mode bit: unix only. + #[cfg(unix)] #[test] fn sc_git_override_and_missing_binary_error() { let _g = GIT_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); diff --git a/crates/repo/src/sparse_completion.rs b/crates/repo/src/sparse_completion.rs index 220e0736..650ca134 100644 --- a/crates/repo/src/sparse_completion.rs +++ b/crates/repo/src/sparse_completion.rs @@ -23,6 +23,7 @@ enum Op { /// A working-tree edit applied before a commit. enum Edit<'a> { Write(&'a str, &'a [u8]), + #[cfg_attr(not(unix), allow(dead_code))] // only unix tests build an executable file Exec(&'a str, &'a [u8]), Remove(&'a str), } From a44a8c650462e936c5d48224a2259dfcd47d3b1f Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Wed, 21 Oct 2026 08:49:22 +0300 Subject: [PATCH 6/8] fix(repo): review findings: no link-following on create, warn only after fallback, docs Refs #214 --- crates/repo/src/cache.rs | 5 +++-- crates/repo/src/winacl.rs | 11 +++++++---- docs/THREAT-MODEL.md | 8 +++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/repo/src/cache.rs b/crates/repo/src/cache.rs index 92c65c59..667dab10 100644 --- a/crates/repo/src/cache.rs +++ b/crates/repo/src/cache.rs @@ -81,7 +81,7 @@ fn warn_unrestricted_once(flag: &std::sync::atomic::AtomicBool) -> bool { return false; } eprintln!( - "warning: this platform cannot restrict .sc/local-key to its owner; other \ + "warning: this platform could not restrict .sc/local-key to its owner; other \ local users may be able to read it and test guesses against the \ protected-file cache tags (the protected plaintext checked out in the \ working tree is equally readable by default)" @@ -153,8 +153,9 @@ fn create_key_file(path: &Path) -> std::io::Result { fn create_key_file(path: &Path) -> std::io::Result { match crate::winacl::create_new_owner_only(path) { Err(e) if e.kind() != std::io::ErrorKind::AlreadyExists => { + let f = create_key_file_default(path)?; ACL_FAILED.store(true, std::sync::atomic::Ordering::Relaxed); - create_key_file_default(path) + Ok(f) } r => r, } diff --git a/crates/repo/src/winacl.rs b/crates/repo/src/winacl.rs index b2d78e51..6e8dc7ba 100644 --- a/crates/repo/src/winacl.rs +++ b/crates/repo/src/winacl.rs @@ -1,6 +1,6 @@ //! Windows owner-only file creation for `.sc/local-key` (#214). //! -//! The only `unsafe` in `scl-repo` lives here, behind one safe function: +//! The only Windows `unsafe` in `scl-repo` lives here, behind one safe function: //! [`create_new_owner_only`]. It builds a *protected* DACL holding a single //! ACE (`GENERIC_ALL` for the current user's SID) and hands it to `CreateFileW` //! as the file's security descriptor, so the file is owner-only from its first @@ -26,8 +26,8 @@ use windows_sys::Win32::Security::{ GetTokenInformation, TokenUser, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, }; use windows_sys::Win32::Storage::FileSystem::{ - CreateFileW, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_DELETE, FILE_SHARE_READ, - FILE_SHARE_WRITE, + CreateFileW, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, }; use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; @@ -110,6 +110,8 @@ pub(crate) fn create_new_owner_only(path: &Path) -> io::Result { .encode_utf16() .chain([0]) .collect(); + // shortcut: no `\\?\` prefix for paths of 260+ chars (std adds one), so those + // fail here and take the default-permissions fallback; upgrade if that bites. let wpath: Vec = path.as_os_str().encode_wide().chain([0]).collect(); if wpath[..wpath.len() - 1].contains(&0) { return Err(io::ErrorKind::InvalidInput.into()); @@ -144,7 +146,8 @@ pub(crate) fn create_new_owner_only(path: &Path) -> io::Result { FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, &attrs, CREATE_NEW, - FILE_ATTRIBUTE_NORMAL, + // As std does for create_new: never follow a link planted at the path. + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, ptr::null_mut(), ) }; diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index 06630def..9ca6ecdd 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -86,7 +86,9 @@ too, not just the src-control-side metadata. its first byte: mode `0600` on unix, a protected DACL with a single `GENERIC_ALL` ACE for the current user on Windows (no inherited entries; the advisory `windows` CI job reads it back with `icacls`). Windows keys minted - before #214 keep their inherited DACL. On any other target, or if the Windows + before #214 keep their inherited DACL, and the warning no longer fires for + them; delete `.sc/local-key` to have the next run recreate it owner-only (the + cost is one round of spurious re-seals). On any other target, or if the Windows ACL call fails, the key and the cache files are written with inherited default permissions, and `sc` prints a one-time stderr warning once a cache holds a protected entry. The exposure then is small: checkout already writes protected @@ -94,8 +96,8 @@ too, not just the src-control-side metadata. the cache holds tags only for files whose plaintext is on disk, so a reader of the key could read that plaintext directly. What remains is the tags of the last-committed version of a file edited since, and stale entries until the - next save. A unix filesystem that ignores mode bits (vfat, exFAT, some network - mounts) is not detected. + next save. A filesystem that ignores mode bits or ACLs (unix vfat, exFAT, some + network mounts; Windows FAT, exFAT, some SMB shares) is not detected. ## Private branches (`sc branch --private` / `sc branch publish`) — ADR-0044 From f5e81c42ad03d4143fa0b7286bc30ca1f584e408 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Wed, 21 Oct 2026 08:50:37 +0300 Subject: [PATCH 7/8] docs: list windows-sys in the third-party inventory Refs #214 --- docs/security/third-party-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/security/third-party-inventory.md b/docs/security/third-party-inventory.md index 8eea831c..8e5655bf 100644 --- a/docs/security/third-party-inventory.md +++ b/docs/security/third-party-inventory.md @@ -32,7 +32,7 @@ Architectural quarantine rules (which crate may use what) are in | `scl-vfs` | thiserror | In-RAM worktrees | | `scl-gitio` | gix, anyhow, thiserror | Git interop (gix quarantined here) | | `scl-crypto` | chacha20poly1305, x25519-dalek, ed25519-dalek, sha2, hkdf, rand_core, rand_chacha, zeroize, blake3, hex, thiserror | All cryptography (RustCrypto quarantined here) | -| `scl-repo` | blake3, hex, libc, regex, serde, thiserror, toml | Persistent store, merge, transports, policy | +| `scl-repo` | blake3, hex, libc, regex, serde, thiserror, toml, windows-sys (Windows only) | Persistent store, merge, transports, policy; `windows-sys` gives `.sc/local-key` an owner-only DACL (`winacl.rs`, #214) | | `scl-tlsio` | rustls, rustls-native-certs, rcgen, ring, rustls-pki-types, hex, thiserror | TLS (quarantined leaf); `rustls-native-certs` loads the platform roots for `SC_HTTPS_CA=system` (ADR-0054) | | `scl-objio` | aws-config, aws-sdk-s3, tokio, blake3, hex, thiserror | Object-store buckets for `sc+wal://` / `sc+s3://` (quarantined leaf; S3 SDK and its async runtime live only here, ADR-0046) | | `scl-cli` | clap, anyhow, serde, serde_json, toml, hex | The `sc` binary | From 0778eef196d630719afd1be37e926512707f4b1b Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Wed, 21 Oct 2026 09:14:12 +0300 Subject: [PATCH 8/8] fix(repo,cli): warn at ACL fallback, long-path ACL, keygen never overwrites Warn where the Windows fallback happens (not only on a later cache save), canonicalize the key's parent so 260+ char paths still get the DACL, make sc keygen refuse an existing identity on every platform, and document the pre-#214 key and SYSTEM/Administrators behaviour. Refs #214 --- ROADMAP.md | 6 +- crates/cli/src/main.rs | 30 +++--- crates/cli/tests/keygen_overwrite.rs | 33 +++++++ crates/repo/src/cache.rs | 94 ++++++++++++++++++- crates/repo/src/winacl.rs | 14 ++- docs/THREAT-MODEL.md | 7 +- .../0043-randomized-protected-encryption.md | 4 +- 7 files changed, 162 insertions(+), 26 deletions(-) create mode 100644 crates/cli/tests/keygen_overwrite.rs diff --git a/ROADMAP.md b/ROADMAP.md index 5c0b3e0d..73b2d17b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -643,7 +643,11 @@ across every phase. `sc resolve` after a deleted conflicted file restores it too. - A warning when the protected-cache key cannot be permission-restricted on a non-unix target (#225), and an owner-only Windows ACL on `.sc/local-key`, - verified by the advisory `windows` CI job (#214). + verified by the advisory `windows` CI job (#214). Windows keys created + before that keep their inherited DACL and no longer warn; delete + `.sc/local-key` to recreate it owner-only. `sc keygen` now refuses to + overwrite an existing identity on Windows too (#281 tracks owner-only + protection for the identity file and the TLS serve key). ## Active diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index c0c34095..286886ff 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1701,29 +1701,27 @@ fn run_keygen(out: Option) -> Result<()> { let sig_pk = signing.public(); // Create the key file 0600 *atomically* so the private key is never visible // group/world-readable through the umask window between write and chmod. - // `create_new(true)` refuses to clobber an existing identity. - #[cfg(unix)] + // `create_new(true)` refuses to clobber an existing identity, on every + // platform. Windows has no owner-only protection for this file (#281). { use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - let mut f = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&path) - .with_context(|| { - format!( + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600); + let mut f = opts.open(&path).map_err(|e| { + if e.kind() == std::io::ErrorKind::AlreadyExists { + anyhow::anyhow!( "identity already exists at {} (refusing to overwrite)", path.display() ) - })?; + } else { + anyhow::Error::from(e).context(format!("cannot create {}", path.display())) + } + })?; f.write_all(seed_line.as_bytes())?; } - #[cfg(not(unix))] - { - std::fs::write(&path, &seed_line)?; - } - println!("wrote identity: {} (0600)", path.display()); + println!("wrote identity: {}", path.display()); println!("public key: {}", enc_pk.to_key_string()); println!("signing key: {}", sig_pk.to_key_string()); println!("recipient id: {}", enc_pk.recipient_id()); diff --git a/crates/cli/tests/keygen_overwrite.rs b/crates/cli/tests/keygen_overwrite.rs new file mode 100644 index 00000000..37a870e4 --- /dev/null +++ b/crates/cli/tests/keygen_overwrite.rs @@ -0,0 +1,33 @@ +//! `sc keygen --out` must never overwrite an existing identity, on any +//! platform: the seed cannot be regenerated (#214 review). + +use std::process::Command; + +#[test] +fn keygen_refuses_to_overwrite_an_existing_identity() { + let dir = std::env::temp_dir().join(format!("scl-cli-keygen-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let id = dir.join("alice.id"); + let keygen = || { + Command::new(env!("CARGO_BIN_EXE_sc")) + .args(["keygen", "--out", id.to_str().unwrap()]) + .current_dir(&dir) + .output() + .expect("sc runs") + }; + + assert!(keygen().status.success()); + let first = std::fs::read(&id).unwrap(); + + let again = keygen(); + assert!(!again.status.success(), "second keygen must fail"); + assert!( + String::from_utf8_lossy(&again.stderr).contains("refusing to overwrite"), + "{again:?}" + ); + assert_eq!(std::fs::read(&id).unwrap(), first, "identity untouched"); + + std::fs::remove_dir_all(&dir).unwrap(); + assert!(!dir.exists()); +} diff --git a/crates/repo/src/cache.rs b/crates/repo/src/cache.rs index 667dab10..c6960e56 100644 --- a/crates/repo/src/cache.rs +++ b/crates/repo/src/cache.rs @@ -89,10 +89,13 @@ fn warn_unrestricted_once(flag: &std::sync::atomic::AtomicBool) -> bool { true } +/// Once-per-process guard for the unrestricted-key warning, shared by the +/// cache-save path and the key-creation fallback. +static WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + /// Warn (once per process) when a cache that holds protected entries is /// backed by a key whose permissions could not be restricted. fn warn_if_unrestricted(has_entries: bool) { - static WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); if has_entries && !can_restrict_permissions() { warn_unrestricted_once(&WARNED); } @@ -151,10 +154,34 @@ fn create_key_file(path: &Path) -> std::io::Result { /// fall back to default (inherited) permissions plus the warning, never fail. #[cfg(windows)] fn create_key_file(path: &Path) -> std::io::Result { - match crate::winacl::create_new_owner_only(path) { + key_file_or_fallback( + crate::winacl::create_new_owner_only(path), + || create_key_file_default(path), + &ACL_FAILED, + &WARNED, + ) +} + +/// The Windows fallback decision, platform-independent so unix tests can drive +/// it with an injected `restricted` result. `AlreadyExists` and success pass +/// through; any other error means the owner-only create failed, so create with +/// default permissions, record the failure in `failed`, and warn right here +/// (once per process via `warned`): a later process never sees `failed`, so +/// the failure must not depend on a cache save to be reported. +#[cfg(any(windows, test))] +fn key_file_or_fallback( + restricted: std::io::Result, + default: impl FnOnce() -> std::io::Result, + failed: &std::sync::atomic::AtomicBool, + warned: &std::sync::atomic::AtomicBool, +) -> std::io::Result { + match restricted { Err(e) if e.kind() != std::io::ErrorKind::AlreadyExists => { - let f = create_key_file_default(path)?; - ACL_FAILED.store(true, std::sync::atomic::Ordering::Relaxed); + let f = default()?; + failed.store(true, std::sync::atomic::Ordering::Relaxed); + if warn_unrestricted_once(warned) { + eprintln!(" (the owner-only ACL could not be applied: {e})"); + } Ok(f) } r => r, @@ -444,6 +471,65 @@ mod tests { cleanup(&layout); } + /// Windows: a key path past MAX_PATH still takes the ACL path, not the + /// default-permissions fallback. + #[cfg(windows)] + #[test] + fn local_key_acl_applies_to_a_path_over_260_chars() { + let mut root = std::env::temp_dir().join(format!("scl-cache-long-{}", std::process::id())); + let top = root.clone(); + let _ = std::fs::remove_dir_all(&top); + while root.as_os_str().len() < 280 { + root.push("d".repeat(40)); + } + let dot_sc = root.join(".sc"); + std::fs::create_dir_all(&dot_sc).unwrap(); + let layout = Layout::at(&root); + assert!(layout.local_key_path().as_os_str().len() > 260); + local_key(&layout).unwrap(); + assert!(layout.local_key_path().exists()); + assert!( + can_restrict_permissions(), + "long path fell back to default perms" + ); + std::fs::remove_dir_all(&top).unwrap(); + } + + #[test] + fn failed_owner_only_create_falls_back_and_warns_without_a_cache() { + use std::sync::atomic::{AtomicBool, Ordering::Relaxed}; + let layout = tmp("fallback"); + let (failed, warned) = (AtomicBool::new(false), AtomicBool::new(false)); + let path = layout.local_key_path(); + let denied = || Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)); + let default = || { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + }; + key_file_or_fallback(denied(), default, &failed, &warned).unwrap(); + assert!(path.exists(), "fallback must still create the key"); + assert!(failed.load(Relaxed), "failure recorded"); + assert!( + warned.load(Relaxed), + "warned at creation, no cache involved" + ); + + // A concurrent minter's AlreadyExists is not an ACL failure. + let (failed, warned) = (AtomicBool::new(false), AtomicBool::new(false)); + let exists = Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists)); + let r = key_file_or_fallback(exists, || unreachable!("no fallback"), &failed, &warned); + assert_eq!(r.unwrap_err().kind(), std::io::ErrorKind::AlreadyExists); + assert!(!failed.load(Relaxed) && !warned.load(Relaxed)); + + // If the default create fails too, the error surfaces and nothing is flagged. + let r = key_file_or_fallback(denied(), default, &failed, &warned); + assert_eq!(r.unwrap_err().kind(), std::io::ErrorKind::AlreadyExists); + assert!(!failed.load(Relaxed) && !warned.load(Relaxed)); + cleanup(&layout); + } + #[test] fn unrestricted_key_still_persists_and_hits() { force_unrestricted_for_test(true); diff --git a/crates/repo/src/winacl.rs b/crates/repo/src/winacl.rs index 6e8dc7ba..8f038012 100644 --- a/crates/repo/src/winacl.rs +++ b/crates/repo/src/winacl.rs @@ -110,9 +110,17 @@ pub(crate) fn create_new_owner_only(path: &Path) -> io::Result { .encode_utf16() .chain([0]) .collect(); - // shortcut: no `\\?\` prefix for paths of 260+ chars (std adds one), so those - // fail here and take the default-permissions fallback; upgrade if that bites. - let wpath: Vec = path.as_os_str().encode_wide().chain([0]).collect(); + // Canonicalizing the parent yields a `\\?\` path, so worktrees whose key path + // is 260+ chars still get the ACL (CreateFileW has no implicit long-path + // support). The file itself does not exist yet, so only the parent can be + // canonicalized. + let full = match (path.parent(), path.file_name()) { + (Some(dir), Some(name)) if !dir.as_os_str().is_empty() => { + std::fs::canonicalize(dir)?.join(name) + } + _ => path.to_path_buf(), + }; + let wpath: Vec = full.as_os_str().encode_wide().chain([0]).collect(); if wpath[..wpath.len() - 1].contains(&0) { return Err(io::ErrorKind::InvalidInput.into()); } diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index 9ca6ecdd..796e5505 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -88,7 +88,12 @@ too, not just the src-control-side metadata. advisory `windows` CI job reads it back with `icacls`). Windows keys minted before #214 keep their inherited DACL, and the warning no longer fires for them; delete `.sc/local-key` to have the next run recreate it owner-only (the - cost is one round of spurious re-seals). On any other target, or if the Windows + cost is one round of spurious re-seals). The Windows DACL grants no explicit + access to SYSTEM or Administrators, so backup and admin tooling can still take + ownership of the file, as root can read a `0600` file on unix. The `sc keygen` + identity file and the TLS serve key still have no Windows owner-only + protection (tracked in #281); keygen now refuses to overwrite an existing + identity on every platform. On any other target, or if the Windows ACL call fails, the key and the cache files are written with inherited default permissions, and `sc` prints a one-time stderr warning once a cache holds a protected entry. The exposure then is small: checkout already writes protected diff --git a/docs/adr/0043-randomized-protected-encryption.md b/docs/adr/0043-randomized-protected-encryption.md index 5b41db02..4763cd2a 100644 --- a/docs/adr/0043-randomized-protected-encryption.md +++ b/docs/adr/0043-randomized-protected-encryption.md @@ -106,7 +106,9 @@ file changed. Detection is a per-checkout stat cache (`crates/repo/src/cache.rs` the current user, also from the first byte (`crates/repo/src/winacl.rs`). Other targets, or a failed ACL call, keep default permissions with a one-time warning, because checkout already writes the same plaintext to the working - tree with default permissions (see THREAT-MODEL.md). The per-checkout cache file itself + tree with default permissions (see THREAT-MODEL.md). Windows keys created + before #214 keep their inherited DACL and no longer warn; delete + `.sc/local-key` to recreate it owner-only. The per-checkout cache file itself (below) is written via `atomic_write_durable`. - Per-checkout cache files: `.sc/protected-cache` for the main working tree; `.sc/ws/cache-` per ws workspace (see below). Each entry is