From 79190bfd466a06f7cd6ca1eb7805cae4d9854e4d Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Sun, 25 Oct 2026 12:03:31 +0800 Subject: [PATCH 01/37] feat(persistence): Windows-native Store writer lane, volume guard and open The Store engine runs natively on Windows (owner direction 2026-10-25, route 2: Windows-native Store, no WSL2): - The cross-process writer lane is a named kernel mutex (Local\layerfs-writer-lane-): CreateMutexW + INFINITE wait; WAIT_ABANDONED also takes ownership, so a dead writer's lane is released by the kernel and the next writer proceeds. - The kernel guard reads the volume filesystem via GetVolumeInformationW and allows local NTFS volumes only; other filesystems and non-disk prefixes refuse Unsupported, matching the Linux ext4/xfs/btrfs allowlist discipline. - SQLite opens on Windows through bundled rusqlite; the open/profile cfg lists widen from macos/linux to include windows. Linux and macOS branches are unchanged. - The installer drops the unix-only mode bits and the daemons-uid check on Windows; destination and exclusivity checks are unchanged. - Boundary guard: audited-FFI entries become per-crate tuples and gain persistence backend/sqlite/windows_lane.rs; path matching is as_posix so the audited-module check works on Windows hosts (this repairs two false positives the separator bug produced there). Verification at this commit: cargo check --offline --locked -p layerfs-persistence -p layerfs-store-service (Windows, rustc 1.90.0) Finished; the guard's 24 self-tests pass; the guard reports only the four test-only-attribute findings that HEAD already reports, with no new violation; tests/windows_store_native.rs (new) drives create + two-session writes + two re-exec'd child processes through the lane with bounded waits: 1 passed. Production LOC: core 96783 -> 96948 (delta +165), sdk 3090 -> 3090 (delta 0); combined 109056 -> 109221 (delta +165). Counter: tools/production_loc.py --detail (pinned at this tree), scopes core and sdk, tests/tools/docs excluded. reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- core/Cargo.lock | 1 + core/crates/layerfs-persistence/Cargo.toml | 6 + .../src/backend/sqlite/connection.rs | 6 +- .../src/backend/sqlite/mod.rs | 5 + .../src/backend/sqlite/windows_lane.rs | 117 ++++++++++++++++ .../src/backend/sqlite/writer_admission.rs | 31 ++++- core/crates/layerfs-persistence/src/lib.rs | 3 + .../layerfs-persistence/src/store/open.rs | 12 +- .../tests/windows_store_native.rs | 130 ++++++++++++++++++ .../layerfs-store-service/src/install.rs | 48 ++++--- .../layerfs-store-service/src/kernel_guard.rs | 42 ++++-- core/tools/check_product_boundary.py | 9 +- core/tools/test_check_product_boundary.py | 5 +- 13 files changed, 381 insertions(+), 34 deletions(-) create mode 100644 core/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs create mode 100644 core/crates/layerfs-persistence/tests/windows_store_native.rs diff --git a/core/Cargo.lock b/core/Cargo.lock index 307a94e17..0ad436ac4 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -655,6 +655,7 @@ dependencies = [ "layerfs-telemetry", "nix", "rusqlite", + "windows-sys", ] [[package]] diff --git a/core/crates/layerfs-persistence/Cargo.toml b/core/crates/layerfs-persistence/Cargo.toml index 8a34236d2..adbf3e37d 100644 --- a/core/crates/layerfs-persistence/Cargo.toml +++ b/core/crates/layerfs-persistence/Cargo.toml @@ -24,3 +24,9 @@ nix = { version = "=0.31.3", default-features = false, features = ["fs", "pthrea [target.'cfg(target_os = "linux")'.dependencies] rusqlite = { version = "=0.40.2", features = ["bundled"] } + +[target.'cfg(target_os = "windows")'.dependencies] +# Windows has no system SQLite; the Store bundles the same version. The +# writer lane and volume probe FFI use windows-sys, already in the locked graph. +rusqlite = { version = "=0.40.2", features = ["bundled"] } +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading", "Win32_Storage_FileSystem"] } diff --git a/core/crates/layerfs-persistence/src/backend/sqlite/connection.rs b/core/crates/layerfs-persistence/src/backend/sqlite/connection.rs index f06d1e573..d1b874cec 100644 --- a/core/crates/layerfs-persistence/src/backend/sqlite/connection.rs +++ b/core/crates/layerfs-persistence/src/backend/sqlite/connection.rs @@ -154,7 +154,11 @@ impl Session { creation_layout: crate::SqlitePackLayout, creation_acquisition: crate::SqliteAcquisitionSchema, ) -> Result { - if !cfg!(any(target_os = "macos", target_os = "linux")) { + if !cfg!(any( + target_os = "macos", + target_os = "linux", + target_os = "windows" + )) { return Err(BackendError::Integrity); } let flags = if writable { diff --git a/core/crates/layerfs-persistence/src/backend/sqlite/mod.rs b/core/crates/layerfs-persistence/src/backend/sqlite/mod.rs index 6d9612135..4b570c8a9 100644 --- a/core/crates/layerfs-persistence/src/backend/sqlite/mod.rs +++ b/core/crates/layerfs-persistence/src/backend/sqlite/mod.rs @@ -5,6 +5,11 @@ pub(crate) mod connection; #[cfg(target_os = "macos")] #[allow(unsafe_code)] mod file_control; +#[cfg(target_os = "windows")] +#[allow(unsafe_code)] +mod windows_lane; +#[cfg(target_os = "windows")] +pub use windows_lane::windows_volume_filesystem; pub(crate) mod lane; pub(crate) mod metadata_allocation; pub(crate) mod metadata_locations; diff --git a/core/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs b/core/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs new file mode 100644 index 000000000..f16b365bf --- /dev/null +++ b/core/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs @@ -0,0 +1,117 @@ +//! The Windows Store's one audited FFI surface. +//! +//! This is the Windows companion of the macOS seal file-control module: one +//! owner-authorized module of raw Win32 calls, allowed by the boundary guard +//! and compiled on Windows only; no other module in this crate may use +//! unsafe Rust. The writer lane is one named kernel mutex per Store +//! directory: the kernel releases it when a holder process exits (the next +//! waiter wakes on the abandoned mutex), which matches the Linux directory +//! flock's exit release, and it never opens a second descriptor to SQLite's +//! main/WAL/SHM files. The handle is held as an integer so `Writer` stays +//! plain data. The volume probe reports the filesystem of the directory's +//! local volume for the kernel guard's allowlist. +#![allow(unsafe_code)] +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::io; +use std::path::Path; +use windows_sys::Win32::Foundation::{CloseHandle, WAIT_ABANDONED, WAIT_OBJECT_0}; +use windows_sys::Win32::System::Threading::{ + CreateMutexW, ReleaseMutex, WaitForSingleObject, INFINITE, +}; + +/// The Store directory's cooperative writer lane. +pub(crate) struct LaneMutex { + handle: usize, +} + +impl LaneMutex { + /// One blocking kernel wait on the directory's named mutex, taken after + /// the Session's FIFO turn, so at most one thread per Session waits here. + /// Creation only opens the shared kernel object. Exits: the lane (a + /// holder process that exits releases it); a failed wait. + pub(crate) fn acquire(directory: &Path) -> Result { + let mut hasher = DefaultHasher::new(); + match std::fs::canonicalize(directory) { + Ok(canonical) => canonical.hash(&mut hasher), + Err(_) => directory.hash(&mut hasher), + } + let name: Vec = format!("Local\\layerfs-writer-lane-{:016x}", hasher.finish()) + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let handle = unsafe { CreateMutexW(std::ptr::null(), 0, name.as_ptr()) }; + if handle.is_null() { + return Err(()); + } + let waited = unsafe { WaitForSingleObject(handle, INFINITE) }; + if waited == WAIT_OBJECT_0 || waited == WAIT_ABANDONED { + Ok(Self { + handle: handle as usize, + }) + } else { + unsafe { CloseHandle(handle) }; + Err(()) + } + } + + /// Releases the lane and destroys this handle; a failed release returns + /// the retained lane unchanged. + pub(crate) fn release(self) -> Result<(), Self> { + let handle = self.handle as windows_sys::Win32::Foundation::HANDLE; + if unsafe { ReleaseMutex(handle) } == 0 { + return Err(Self { + handle: self.handle, + }); + } + unsafe { CloseHandle(handle) }; + Ok(()) + } +} + +/// The filesystem name of the local volume holding `directory`, for the +/// kernel guard's allowlist. Only a local drive letter is examined: UNC, +/// device and verbatim prefixes are network or device paths whose locks do +/// not coordinate Store writers. +pub fn windows_volume_filesystem(directory: &Path) -> io::Result { + use std::path::{Component, Prefix}; + let root = match directory.components().next() { + Some(Component::Prefix(prefix)) => match prefix.kind() { + Prefix::Disk(letter) => format!("{}:\\", letter as char), + _ => { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "a network or device path is not a local volume", + )) + } + }, + _ => { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "the directory has no volume prefix", + )) + } + }; + let wide: Vec = root.encode_utf16().chain(std::iter::once(0)).collect(); + let mut filesystem = [0u16; 16]; + let examined = unsafe { + windows_sys::Win32::Storage::FileSystem::GetVolumeInformationW( + wide.as_ptr(), + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + filesystem.as_mut_ptr(), + filesystem.len() as u32, + ) + }; + if examined == 0 { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + format!("the volume {root} cannot be examined"), + )); + } + let end = filesystem.iter().position(|&wide| wide == 0).unwrap_or(0); + Ok(String::from_utf16_lossy(&filesystem[..end])) +} diff --git a/core/crates/layerfs-persistence/src/backend/sqlite/writer_admission.rs b/core/crates/layerfs-persistence/src/backend/sqlite/writer_admission.rs index c7d42a3f8..c66aa1504 100644 --- a/core/crates/layerfs-persistence/src/backend/sqlite/writer_admission.rs +++ b/core/crates/layerfs-persistence/src/backend/sqlite/writer_admission.rs @@ -13,6 +13,8 @@ use { pub(crate) struct Writer { #[cfg(target_os = "linux")] lock: Flock, + #[cfg(target_os = "windows")] + lane: super::windows_lane::LaneMutex, } impl Writer { @@ -54,7 +56,28 @@ impl Writer { let _ = path; Ok(Self {}) } - #[cfg(not(any(target_os = "linux", target_os = "macos")))] + #[cfg(target_os = "windows")] + { + // Windows has no flock on a directory handle. One named kernel + // mutex per Store directory is the equivalent coarse lane: it + // never opens another descriptor to SQLite's main/WAL/SHM files, + // and a holder process that exits releases it. Multiple Store + // files in one directory share this coarse lane, as on Linux. + let directory = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + if !std::fs::metadata(directory) + .map(|metadata| metadata.is_dir()) + .unwrap_or(false) + { + return Err(BackendError::Integrity); + } + let lane = super::windows_lane::LaneMutex::acquire(directory) + .map_err(|()| BackendError::Integrity)?; + Ok(Self { lane }) + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { let _ = path; Err(BackendError::Integrity) @@ -72,10 +95,14 @@ impl Writer { Err((lock, _)) => Err(Self { lock }), } } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "macos")] { Ok(()) } + #[cfg(target_os = "windows")] + { + self.lane.release().map_err(|lane| Self { lane }) + } } } diff --git a/core/crates/layerfs-persistence/src/lib.rs b/core/crates/layerfs-persistence/src/lib.rs index 70ecdf986..29184b59b 100644 --- a/core/crates/layerfs-persistence/src/lib.rs +++ b/core/crates/layerfs-persistence/src/lib.rs @@ -11,6 +11,9 @@ pub use backend::sqlite::connection::{ConnectionProfile, SqlWork}; pub use backend::sqlite::reclamation::{SpaceReclamation, RECLAMATION_PAGE_LIMIT}; pub use backend::sqlite::statement_work::StatementPhaseWork; pub use backend::sqlite::thread_work::thread_work; +#[cfg(target_os = "windows")] +#[cfg(target_os = "windows")] +pub use backend::sqlite::windows_volume_filesystem; pub use config::{ BackendSelection, PersistenceConfig, SqliteAcquisitionSchema, SqlitePackLayout, SqlitePersistenceProfile, diff --git a/core/crates/layerfs-persistence/src/store/open.rs b/core/crates/layerfs-persistence/src/store/open.rs index 2cb9e9827..e851ed679 100644 --- a/core/crates/layerfs-persistence/src/store/open.rs +++ b/core/crates/layerfs-persistence/src/store/open.rs @@ -18,7 +18,11 @@ impl Handles { history: &HistoryCatalogConfig, ) -> Result { if config.backend != BackendSelection::Sqlite - || !cfg!(any(target_os = "macos", target_os = "linux")) + || !cfg!(any( + target_os = "macos", + target_os = "linux", + target_os = "windows" + )) { return Err(PersistenceError::BackendUnavailable); } @@ -70,7 +74,11 @@ impl Handles { writable: bool, ) -> Result { if config.backend != BackendSelection::Sqlite - || !cfg!(any(target_os = "macos", target_os = "linux")) + || !cfg!(any( + target_os = "macos", + target_os = "linux", + target_os = "windows" + )) { return Err(PersistenceError::BackendUnavailable); } diff --git a/core/crates/layerfs-persistence/tests/windows_store_native.rs b/core/crates/layerfs-persistence/tests/windows_store_native.rs new file mode 100644 index 000000000..254425045 --- /dev/null +++ b/core/crates/layerfs-persistence/tests/windows_store_native.rs @@ -0,0 +1,130 @@ +//! The Windows-native Store route: creation and two concurrent sessions in +//! one process, then two foreign processes serialized through the writer +//! lane, all through public Stores and bounded waits. +#![cfg(windows)] +mod support; +use layerfs_history::HistoryCatalogConfig; +use layerfs_persistence::{Handles, PersistenceConfig, SqlitePersistenceProfile}; +use layerfs_storage::{port::*, StoragePolicy}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const CHILD_STORE: &str = "LAYERFS_WINDOWS_STORE_CHILD_STORE"; +const CHILD_BINDING: &str = "LAYERFS_WINDOWS_STORE_CHILD_BINDING"; + +fn config(path: &Path) -> PersistenceConfig { + PersistenceConfig::sqlite(path).with_sqlite_profile(SqlitePersistenceProfile::Disposable) +} + +fn reserve() -> Reserve { + Reserve { + packs: 1, + ordinals: 1, + } +} + +fn authority(name: &[u8]) -> HistoryCatalogConfig { + HistoryCatalogConfig { + binding_key: name.to_vec(), + cursor_key: [67; 32], + incarnation: 1, + } +} + +/// One child process's whole work: open the Store writable and write once. +fn child(path: PathBuf, binding: Vec) { + let opened = Handles::open_writable(config(&path), &binding, [67; 32]) + .expect("the child opens the Store"); + opened.storage.reserve(reserve()).expect("the child writes"); +} + +/// When this binary was re-executed as a lane child, run its one job and +/// leave; otherwise this is the parent test run and work continues below. +fn dispatch_child() { + let Some(path) = std::env::var_os(CHILD_STORE) else { + return; + }; + let binding = std::env::var(CHILD_BINDING) + .expect("the child binding") + .into_bytes(); + child(PathBuf::from(path), binding); + std::process::exit(0); +} + +/// Bounded completion of one foreign child; a hung child fails the test. +fn finish(mut child: std::process::Child) { + let until = Instant::now() + Duration::from_secs(60); + loop { + match child.try_wait().expect("child poll") { + Some(status) => { + assert!(status.success(), "a child writer failed: {status}"); + return; + } + None => assert!(Instant::now() < until, "a child writer is hung"), + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn windows_native_store_serves_sessions_and_serializes_foreign_processes() { + dispatch_child(); + let temp = support::Temp::new("windows-store-native"); + let path = temp.join("store.sqlite"); + let catalog = authority(b"windows-store-native"); + let first = Handles::create(config(&path), StoragePolicy::frozen_default(), &catalog).unwrap(); + let second = + Handles::open_writable(config(&path), &catalog.binding_key, catalog.cursor_key).unwrap(); + first.storage.reserve(reserve()).unwrap(); + second.storage.reserve(reserve()).unwrap(); + first.storage.reserve(reserve()).unwrap(); + second.storage.reserve(reserve()).unwrap(); + drop(first); + drop(second); + + let lane = temp.join("lane"); + std::fs::create_dir_all(&lane).unwrap(); + let lane_path = lane.join("store.sqlite"); + let binding = b"windows-lane-processes".to_vec(); + let lane_catalog = authority(&binding); + let seed = Handles::create( + config(&lane_path), + StoragePolicy::frozen_default(), + &lane_catalog, + ) + .unwrap(); + seed.storage.reserve(reserve()).unwrap(); + drop(seed); + let executable = std::env::current_exe().unwrap(); + let mut children = Vec::new(); + for _ in 0..2 { + children.push( + Command::new(&executable) + .env(CHILD_STORE, &lane_path) + .env(CHILD_BINDING, "windows-lane-processes") + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(), + ); + } + for child in children { + finish(child); + } + let reopened = + Handles::open_writable(config(&lane_path), &binding, lane_catalog.cursor_key).unwrap(); + reopened.storage.reserve(reserve()).unwrap(); + if let Some(snapshot) = std::env::var_os("LAYERFS_WINDOWS_STORE_SNAPSHOT_DIR") { + // Copies the lane Store for external verification runs; the sessions + // above are all dropped, so the database is closed when this runs. + let snapshot = PathBuf::from(snapshot); + std::fs::create_dir_all(&snapshot).unwrap(); + std::fs::copy(&lane_path, snapshot.join("store.sqlite")).unwrap(); + for side in ["store.sqlite-wal", "store.sqlite-shm"] { + if let Ok(bytes) = std::fs::read(lane.with_file_name(side)) { + std::fs::write(snapshot.join(side), bytes).unwrap(); + } + } + } +} diff --git a/core/crates/layerfs-store-service/src/install.rs b/core/crates/layerfs-store-service/src/install.rs index 850570a33..bde143c77 100644 --- a/core/crates/layerfs-store-service/src/install.rs +++ b/core/crates/layerfs-store-service/src/install.rs @@ -21,6 +21,7 @@ use std::sync::Arc; /// The daemon's user inside its Sandbox (the runtime's `User` is `0:0`). A /// daemon opens only a Store file and directory it owns, so an installer /// must run as this user too. +#[cfg(not(target_os = "windows"))] const DAEMON_UID: u32 = 0; /// The destination and its exclusive temporary name. @@ -162,11 +163,18 @@ pub fn install_local( installer_user()?; let text = |error: io::Error| error.to_string(); if !directory.exists() { - use std::os::unix::fs::DirBuilderExt; - fs::DirBuilder::new() - .mode(0o700) - .create(directory) - .map_err(text)?; + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new() + .mode(0o700) + .create(directory) + .map_err(text)?; + } + #[cfg(not(unix))] + { + fs::DirBuilder::new().create(directory).map_err(text)?; + } } let (target, temporary) = paths(destination).map_err(text)?; let mut file = claim(&target, &temporary).map_err(text)?; @@ -204,16 +212,24 @@ pub fn install_local( /// Refuses an installer that is not the daemons' user. fn installer_user() -> Result<(), String> { - use std::os::unix::fs::MetadataExt; - // the owner of /proc/self is this process's effective user - let uid = fs::metadata("/proc/self") - .map_err(|error| format!("this process's user is unknown: {error}"))? - .uid(); - if uid != DAEMON_UID { - return Err(format!( - "the Store install runs as user {uid}; it must run as user {DAEMON_UID}, the \ - daemons' user, which opens only a Store it owns" - )); + #[cfg(target_os = "windows")] + { + // Windows has no daemons' uid to check here; the destination's + // local-volume discipline is the kernel guard's, which still applies. + Ok(()) + } + #[cfg(not(target_os = "windows"))] + { + use std::os::unix::fs::MetadataExt; + // the owner of /proc/self is this process's effective user + let uid = fs::metadata("/proc/self") + .map_err(|error| format!("this process's user is unknown: {error}"))? + .uid(); + if uid != DAEMON_UID { + return Err(format!( + "the Store install runs as user {uid}; it must run as user {DAEMON_UID}, the daemons' user, which opens only a Store it owns" + )); + } + Ok(()) } - Ok(()) } diff --git a/core/crates/layerfs-store-service/src/kernel_guard.rs b/core/crates/layerfs-store-service/src/kernel_guard.rs index 49489cac1..3858730bf 100644 --- a/core/crates/layerfs-store-service/src/kernel_guard.rs +++ b/core/crates/layerfs-store-service/src/kernel_guard.rs @@ -1,12 +1,14 @@ //! Whether this process may open the global Store directly (#352 F-020). //! -//! Every opener of one Store must share one Linux kernel and the local -//! filesystem that holds it: SQLite's WAL locks and the writers' admission -//! lane (a `flock` on the Store's directory) coordinate only within one -//! kernel. The guard checks what this process can verify before it opens any -//! file: the platform, and the type of the filesystem holding the Store's -//! directory, from an allowlist of local filesystems. Anything else is -//! `Unsupported` with its reason; there is no other route to fall back to. +//! Every opener of one Store must share one kernel and the local filesystem +//! that holds it: SQLite's WAL locks and the writers' admission lane +//! coordinate only within one kernel. On Linux that is the daemons' kernel +//! over ext4, xfs or btrfs; on Windows it is this machine's kernel over +//! NTFS on a local volume. The guard checks what this process can verify +//! before it opens any file: the platform, and the type of the filesystem +//! holding the Store's directory, from an allowlist of local filesystems. +//! Anything else is `Unsupported` with its reason; there is no other route +//! to fall back to. use std::path::Path; /// Filesystems that carry the Store's locks: ext4, xfs and btrfs. @@ -56,7 +58,31 @@ pub fn check(store: &Path) -> Result<&'static str, Unsupported> { )) }) } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "windows")] + { + // The allowlist is the Store's own discipline on Windows: only NTFS + // on a local volume carries the byte-range locks SQLite's WAL needs + // between processes. The audited probe lives in the persistence + // crate's Windows FFI module; network and device paths are refused + // there before a volume is examined. + let directory = store + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + match layerfs_persistence::windows_volume_filesystem(directory) { + Ok(filesystem) if filesystem == "NTFS" => Ok("ntfs"), + Ok(filesystem) => Err(Unsupported(format!( + "filesystem {filesystem} at {} does not carry the Store's locks between \ + processes; direct Store access needs NTFS", + directory.display() + ))), + Err(error) => Err(Unsupported(format!( + "the Store's directory {} cannot be examined: {error}", + directory.display() + ))), + } + } + #[cfg(not(any(target_os = "linux", target_os = "windows")))] { let _ = store; Err(Unsupported( diff --git a/core/tools/check_product_boundary.py b/core/tools/check_product_boundary.py index 7b761f3ce..ef0f35a1d 100644 --- a/core/tools/check_product_boundary.py +++ b/core/tools/check_product_boundary.py @@ -20,8 +20,11 @@ # comment in a lint name (`unsafe_code`) is not the bare word, so attr lines # never trip the scan once comments are stripped. UNSAFE_AUDITED_MODULE = { - "layerfs-storage": "src/encoding/codec.rs", - "layerfs-persistence": "src/backend/sqlite/file_control.rs", + "layerfs-storage": ("src/encoding/codec.rs",), + "layerfs-persistence": ( + "src/backend/sqlite/file_control.rs", + "src/backend/sqlite/windows_lane.rs", + ), } UNSAFE_FREE_CRATES = ("layerfs-content", "layerfs-telemetry", "layerfs-project", "layerfs-overlay", "layerfs-workspace", "layerfs-store-service", "layerfs-fuse", "layerfs-daemon", "layerfs-api", "layerfs-bridge", "layerfs-sandbox") UNSAFE_ROOT_ATTR = { @@ -165,7 +168,7 @@ def unsafe_violations(path, source): relative = Path(*path.parts[path.parts.index("src"):]) code = "\n".join(line.split("//", 1)[0] for line in source.splitlines()) if UNSAFE.search(code): - if crate in UNSAFE_FREE_CRATES or str(relative) != UNSAFE_AUDITED_MODULE[crate]: + if crate in UNSAFE_FREE_CRATES or relative.as_posix() not in UNSAFE_AUDITED_MODULE[crate]: found.append((1, "unsafe outside the audited module boundary; see core/AGENTS.md")) if relative == Path("src/lib.rs") and UNSAFE_ROOT_ATTR[crate] not in source: found.append((1, f"crate root must declare {UNSAFE_ROOT_ATTR[crate]}")) diff --git a/core/tools/test_check_product_boundary.py b/core/tools/test_check_product_boundary.py index 9769a3178..955f18349 100644 --- a/core/tools/test_check_product_boundary.py +++ b/core/tools/test_check_product_boundary.py @@ -50,6 +50,7 @@ def test_persistence_seal_ffi_is_limited_to_the_authorized_module(self): base = Path("core/crates/layerfs-persistence/src") ffi = "fn seal() { unsafe { file_control(); } }" self.assertFalse(unsafe_violations(base / "backend/sqlite/file_control.rs", ffi)) + self.assertFalse(unsafe_violations(base / "backend/sqlite/windows_lane.rs", ffi)) for sibling in ["backend/sqlite/seal.rs", "store/seal.rs", "backend/sqlite/connection.rs"]: self.assertTrue(unsafe_violations(base / sibling, ffi)) self.assertFalse(unsafe_violations(base / "lib.rs", "#![deny(unsafe_code)]")) @@ -279,7 +280,7 @@ def test_shipped_sql_and_external_tests_scope(self): path = core / "crates" / "store" / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text("") - self.assertEqual([str(p.relative_to(core / "crates" / "store")) + self.assertEqual([p.relative_to(core / "crates" / "store").as_posix() for p in production_files(core)], ["sql/schema.sql", "src/lib.rs", "src/sql/query.sql"]) @@ -290,7 +291,7 @@ def test_flat_api_product_scope(self): path = core / "crates/layerfs-api" / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text("") - self.assertEqual([str(p.relative_to(core / "crates/layerfs-api")) + self.assertEqual([p.relative_to(core / "crates/layerfs-api").as_posix() for p in production_files(core)], ["src/fs/read.rs", "src/lib.rs"]) From 0becc9bc2d869b0c92fbaaca6d9252e343cd3727 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Sun, 25 Oct 2026 12:05:25 +0800 Subject: [PATCH 02/37] feat(sdk): Windows-native project Init, overlay identity and the Store face The SDK's in-process Store face and the construction libraries it needs run natively on Windows (route 2, owner direction 2026-10-25): - layerfs-project Init: host byte encoding moves to import/host.rs; Windows carries names as UTF-8 bytes (lossy outside UTF-8, a recorded limitation) and synthesizes the mode from the read-only attribute. Acquisition identity is the real volume serial and file index from GetFileInformationByHandle in the audited import/windows_identity.rs; zeroed identities made every entry collide onto one dev+ino key and Init refused with "the entry changed while Init read it". The cfg(unix) raw-byte and stat paths are unchanged. - layerfs-overlay: audited database/windows_identity.rs provides handle and path identity (volume serial, file index, link count) and GetDiskFreeSpaceExW free space; physical allocation checks and the startup gate widen to windows. store-service reads overlay backing identity through it instead of the (0,0,0) placeholder. - The napi addon builds on Windows: Store discovery (discovery_windows) uses a named kernel mutex with save/load record files; fs, branch, project and operator calls run in-process through the #352 direct route; workspace, exec and daemon calls refuse Unsupported - FUSE and the daemon stay Linux-only. exec/output/pool/sandbox/workspace stay unix modules; the unix dispatch table is unchanged. - Boundary guard: audited FFI adds project import/windows_identity.rs and overlay database/windows_identity.rs; both crate roots move forbid->deny. Guard self-tests cover both. No tests were deleted or weakened; store-service keeps HEAD's Windows cfg asymmetry warnings no longer (backing delete lets and identity fields are cfg-scoped). Verification at this commit: cargo check --offline --locked for layerfs-persistence, -store-service, -project, -overlay and -node passes on Windows (rustc 1.90.0, only HEAD's pre-existing warnings in untouched crates) and in the rust:1.85.1-bookworm container (zero warnings for all five); tests/windows_store_native.rs 1 passed; guard self-tests 24/24; the lifecycle smoke (init -> fork dev -> one-Commit fs.write -> read back on dev and main -> list_commits -> close, no WSL2, no daemon) passes against this commit's addon. Production LOC: core 96948 -> 97232 (delta +284), sdk 3090 -> 3455 (delta +365); combined 109221 -> 109870 (delta +649). Counter: tools/production_loc.py --detail (pinned at this tree), scopes core and sdk; overlay +146, project +126, store-service +12, layerfs-node +365. reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- core/Cargo.lock | 3 + core/crates/layerfs-overlay/Cargo.toml | 6 + .../layerfs-overlay/src/database/mod.rs | 3 + .../layerfs-overlay/src/database/physical.rs | 65 +++- .../layerfs-overlay/src/database/startup.rs | 6 +- .../src/database/windows_identity.rs | 99 ++++++ core/crates/layerfs-overlay/src/lib.rs | 4 +- core/crates/layerfs-project/Cargo.toml | 4 + .../layerfs-project/src/import/backing.rs | 36 +-- .../layerfs-project/src/import/error.rs | 6 - .../layerfs-project/src/import/files.rs | 18 +- .../crates/layerfs-project/src/import/host.rs | 111 +++++++ .../crates/layerfs-project/src/import/init.rs | 18 +- core/crates/layerfs-project/src/import/mod.rs | 10 +- .../crates/layerfs-project/src/import/scan.rs | 35 ++- .../layerfs-project/src/import/source.rs | 49 +-- .../src/import/windows_identity.rs | 70 +++++ core/crates/layerfs-project/src/lib.rs | 7 +- .../src/overlay/backing.rs | 30 +- core/tools/check_product_boundary.py | 8 +- core/tools/test_check_product_boundary.py | 2 + sdk/js/native/Cargo.toml | 4 + sdk/js/native/src/direct.rs | 2 + sdk/js/native/src/discovery_windows.rs | 281 ++++++++++++++++++ sdk/js/native/src/dispatch.rs | 166 ++++++++--- sdk/js/native/src/lib.rs | 9 + sdk/js/native/src/project.rs | 1 + sdk/js/native/src/registry.rs | 19 +- sdk/js/native/src/trace.rs | 16 +- 29 files changed, 932 insertions(+), 156 deletions(-) create mode 100644 core/crates/layerfs-overlay/src/database/windows_identity.rs create mode 100644 core/crates/layerfs-project/src/import/host.rs create mode 100644 core/crates/layerfs-project/src/import/windows_identity.rs create mode 100644 sdk/js/native/src/discovery_windows.rs diff --git a/core/Cargo.lock b/core/Cargo.lock index 0ad436ac4..1a06122f7 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -633,6 +633,7 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "windows-sys", ] [[package]] @@ -642,6 +643,7 @@ dependencies = [ "blake3", "nix", "rusqlite", + "windows-sys", ] [[package]] @@ -668,6 +670,7 @@ dependencies = [ "layerfs-storage", "layerfs-telemetry", "sha2 0.11.0", + "windows-sys", ] [[package]] diff --git a/core/crates/layerfs-overlay/Cargo.toml b/core/crates/layerfs-overlay/Cargo.toml index a0de58150..ba602451a 100644 --- a/core/crates/layerfs-overlay/Cargo.toml +++ b/core/crates/layerfs-overlay/Cargo.toml @@ -13,3 +13,9 @@ rusqlite = { version = "=0.40.2", features = ["cache", "limits", "blob"] } [target.'cfg(target_os = "linux")'.dependencies] rusqlite = { version = "=0.40.2", features = ["bundled"] } + +[target.'cfg(target_os = "windows")'.dependencies] +# Windows has no system SQLite, and the audited identity module needs the +# Win32 file-information calls; windows-sys is already in the locked graph. +rusqlite = { version = "=0.40.2", features = ["bundled"] } +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } diff --git a/core/crates/layerfs-overlay/src/database/mod.rs b/core/crates/layerfs-overlay/src/database/mod.rs index 17948fa35..9da175d21 100644 --- a/core/crates/layerfs-overlay/src/database/mod.rs +++ b/core/crates/layerfs-overlay/src/database/mod.rs @@ -5,5 +5,8 @@ pub(crate) mod startup; pub(crate) mod statements; pub(crate) mod physical; +#[cfg(target_os = "windows")] +#[allow(unsafe_code)] +pub(crate) mod windows_identity; pub(crate) mod accounting; diff --git a/core/crates/layerfs-overlay/src/database/physical.rs b/core/crates/layerfs-overlay/src/database/physical.rs index 65b7e9b7e..0d1601397 100644 --- a/core/crates/layerfs-overlay/src/database/physical.rs +++ b/core/crates/layerfs-overlay/src/database/physical.rs @@ -94,11 +94,56 @@ impl Allocation { work: self.work.get(), }) } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(target_os = "windows")] + pub(crate) fn new(file: File, path: &Path) -> OverlayResult { + let identity = super::windows_identity::windows_handle_identity(&file)?; + if identity.links != 1 { + return Err(OverlayError::Invalid("allocation file identity")); + } + let len = file.metadata()?.len(); + Ok(Self { + file, + device: identity.device, + inode: identity.index, + path: path.to_owned(), + work: Cell::new(AllocationWork::default()), + high_water: Cell::new(len), + }) + } + #[cfg(target_os = "windows")] + pub(crate) fn state(&self) -> OverlayResult { + let mut work = self.work.get(); + work.observations = work.observations.saturating_add(1); + self.work.set(work); + let meta = self.file.metadata()?; + let by_path = super::windows_identity::windows_path_identity(&self.path)?; + let by_handle = super::windows_identity::windows_handle_identity(&self.file)?; + if by_path.device != by_handle.device + || by_path.index != by_handle.index + || by_handle.device != self.device + || by_handle.index != self.inode + || by_handle.links != 1 + { + return Err(OverlayError::Invalid("allocation file identity")); + } + // Windows exposes no allocated-blocks count; the logical length + // stands in for it (the disposable overlay never preallocates). + let allocated = meta.len(); + self.high_water.set(self.high_water.get().max(allocated)); + Ok(AllocationState { + logical_bytes: meta.len(), + allocated_bytes: allocated, + high_water_allocated_bytes: self.high_water.get(), + reserved_tail_bytes: 0, + cleanup_headroom_bytes: 0, + work: self.work.get(), + }) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] pub(crate) fn new(_file: File, _path: &Path) -> OverlayResult { Err(OverlayError::UnsupportedPlatform) } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] pub(crate) fn state(&self) -> OverlayResult { Err(OverlayError::UnsupportedPlatform) } @@ -119,7 +164,21 @@ impl Allocation { // those are not ordinary writable capacity. Retain the original cause. Ok(filesystem.blocks_available() == 0) } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(target_os = "windows")] + pub(crate) fn device_full(&self) -> OverlayResult { + self.state()?; + let mut work = self.work.get(); + work.capacity_checks = work.capacity_checks.saturating_add(1); + self.work.set(work); + let directory = self + .path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")); + let free = super::windows_identity::windows_free_bytes(directory) + .map_err(|_| OverlayError::Invalid("capacity observation"))?; + Ok(free == 0) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] pub(crate) fn device_full(&self) -> OverlayResult { Err(OverlayError::UnsupportedPlatform) } diff --git a/core/crates/layerfs-overlay/src/database/startup.rs b/core/crates/layerfs-overlay/src/database/startup.rs index 2e7a33f8a..db39ac8e3 100644 --- a/core/crates/layerfs-overlay/src/database/startup.rs +++ b/core/crates/layerfs-overlay/src/database/startup.rs @@ -66,7 +66,11 @@ fn initialize(path: &Path, config: ProfileConfig, template: Option<&Path>) -> Cr let mut work = CreationWork::default(); let mut allocation = None; let result = (|| { - if !cfg!(any(target_os = "macos", target_os = "linux")) { + if !cfg!(any( + target_os = "macos", + target_os = "linux", + target_os = "windows" + )) { return Err(crate::OverlayError::UnsupportedPlatform); } let mut options = OpenOptions::new(); diff --git a/core/crates/layerfs-overlay/src/database/windows_identity.rs b/core/crates/layerfs-overlay/src/database/windows_identity.rs new file mode 100644 index 000000000..87fe4ba32 --- /dev/null +++ b/core/crates/layerfs-overlay/src/database/windows_identity.rs @@ -0,0 +1,99 @@ +//! The overlay's one audited Windows FFI surface: a file's real identity. +//! +//! This follows the audited-module precedent of the storage codec, the +//! persistence file-control and Windows lanes, and the project host probe: +//! one owner-authorized module of raw Win32 calls, allowed by the boundary +//! guard and compiled on Windows only; no other module in this crate may +//! use unsafe Rust. The allocation's identity checks need the volume serial +//! number, the file index and the link count, which std does not expose. +#![allow(unsafe_code)] +use std::fs::File; +use std::io; +use std::os::windows::io::AsRawHandle; +use std::path::Path; +use windows_sys::Win32::Storage::FileSystem::BY_HANDLE_FILE_INFORMATION; + +/// One entry's Windows identity. +pub struct WindowsIdentity { + pub device: u64, + pub index: u64, + pub links: u64, +} + +/// The identity of the entry an open file already holds, without opening +/// anything new. +pub fn windows_handle_identity(file: &File) -> io::Result { + use windows_sys::Win32::Storage::FileSystem::GetFileInformationByHandle; + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + let read = unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) }; + finish(read, information) +} + +/// The identity of `path`'s entry, read as attributes only: no data access, +/// no link traversal; directories need the backup-semantics flag. +pub(crate) fn windows_path_identity(path: &Path) -> io::Result { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, GetFileInformationByHandle, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, + FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, + }; + let wide: Vec = path + .as_os_str() + .to_string_lossy() + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + let read = unsafe { GetFileInformationByHandle(handle, &mut information) }; + unsafe { CloseHandle(handle) }; + finish(read, information) +} + +fn finish(read: i32, information: BY_HANDLE_FILE_INFORMATION) -> io::Result { + if read == 0 { + return Err(io::Error::last_os_error()); + } + Ok(WindowsIdentity { + device: u64::from(information.dwVolumeSerialNumber), + index: (u64::from(information.nFileIndexHigh) << 32) | u64::from(information.nFileIndexLow), + links: u64::from(information.nNumberOfLinks), + }) +} + +/// The bytes available to the caller on `directory`'s volume. +pub(crate) fn windows_free_bytes(directory: &Path) -> io::Result { + use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; + let wide: Vec = directory + .as_os_str() + .to_string_lossy() + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let mut free: u64 = 0; + let read = unsafe { + GetDiskFreeSpaceExW( + wide.as_ptr(), + &mut free, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if read == 0 { + return Err(io::Error::last_os_error()); + } + Ok(free) +} diff --git a/core/crates/layerfs-overlay/src/lib.rs b/core/crates/layerfs-overlay/src/lib.rs index 1c38d3418..f4be6e390 100644 --- a/core/crates/layerfs-overlay/src/lib.rs +++ b/core/crates/layerfs-overlay/src/lib.rs @@ -3,7 +3,7 @@ //! The engine owns SQL and profiling. Filesystem validation, scheduling, kernel //! replies and process lifetimes belong to its callers. Windows bound each job; //! they do not limit total files, changes, payload or Workspace lifetime. -#![forbid(unsafe_code)] +#![deny(unsafe_code)] mod contract; mod database; @@ -46,6 +46,8 @@ pub use contract::kernel_directory::{ pub use database::accounting::{Resources, StoredCounts}; pub use database::physical::{AllocationState, AllocationWork, ClosedFile}; pub use database::startup::{Creation, CreationWork}; +#[cfg(target_os = "windows")] +pub use database::windows_identity::windows_handle_identity; pub use db::Overlay; pub use diagnostics::payload::PayloadWork; pub use error::{CloseFailure, OverlayError, OverlayResult}; diff --git a/core/crates/layerfs-project/Cargo.toml b/core/crates/layerfs-project/Cargo.toml index 36cc2a5fc..8338c36bd 100644 --- a/core/crates/layerfs-project/Cargo.toml +++ b/core/crates/layerfs-project/Cargo.toml @@ -12,6 +12,10 @@ layerfs-storage = { path = "../layerfs-storage" } layerfs-history = { path = "../layerfs-history", default-features = false } layerfs-telemetry = { path = "../layerfs-telemetry" } +[target.'cfg(target_os = "windows")'.dependencies] +# The audited host-identity probe; already in the locked graph. +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } + [dev-dependencies] layerfs-persistence = { path = "../layerfs-persistence" } sha2 = "=0.11.0" diff --git a/core/crates/layerfs-project/src/import/backing.rs b/core/crates/layerfs-project/src/import/backing.rs index 0684a071d..31906e98b 100644 --- a/core/crates/layerfs-project/src/import/backing.rs +++ b/core/crates/layerfs-project/src/import/backing.rs @@ -13,10 +13,7 @@ use layerfs_storage::port::acquisition::{ WRITE_ROW_BYTES, WRITE_WINDOW_BYTES, WRITE_WINDOW_ROWS, }; use layerfs_storage::port::PersistenceError; -use std::{ - cell::Cell, collections::VecDeque, ffi::OsString, fs::Metadata, os::unix::ffi::OsStringExt, - os::unix::fs::MetadataExt, path::PathBuf, -}; +use std::{cell::Cell, collections::VecDeque, fs::Metadata}; /// Rows the job queue or one resident child buffer may hold. pub(crate) const WINDOW_ROWS: usize = 512; @@ -34,21 +31,24 @@ pub(crate) struct Stamp { pub ctime_nsec: i64, } impl Stamp { - pub(crate) fn of(metadata: &Metadata) -> Self { - Self { - dev: metadata.dev(), - ino: metadata.ino(), - len: metadata.len(), - mode: metadata.mode(), - mtime: metadata.mtime(), - mtime_nsec: metadata.mtime_nsec(), - ctime: metadata.ctime(), - ctime_nsec: metadata.ctime_nsec(), - } + pub(crate) fn of(path: &std::path::Path, metadata: &Metadata) -> std::io::Result { + let host = super::host::HostStat::of(path, metadata)?; + Ok(Self { + dev: host.dev, + ino: host.ino, + len: host.len, + mode: host.mode, + mtime: host.mtime, + mtime_nsec: host.mtime_nsec, + ctime: host.ctime, + ctime_nsec: host.ctime_nsec, + }) } /// True while `metadata` is still the same unchanged regular file. - pub(crate) fn matches_file(&self, metadata: &Metadata) -> bool { - metadata.is_file() && !metadata.file_type().is_symlink() && *self == Self::of(metadata) + pub(crate) fn matches_file(&self, path: &std::path::Path, metadata: &Metadata) -> bool { + metadata.is_file() + && !metadata.file_type().is_symlink() + && Self::of(path, metadata).is_ok_and(|now| now == *self) } /// The identity and byte-exact evidence every path of this inode must share. pub(crate) fn identity(&self) -> HostIdentity { @@ -377,7 +377,7 @@ pub(crate) fn window_full(rows: usize, held: usize, bytes: usize) -> bool { fn changed_source(error: AcquisitionError, path: impl Fn(u64) -> Option>) -> Failure { if let AcquisitionError::Changed { position } = error { if let Some(path) = path(position) { - let path = PathBuf::from(OsString::from_vec(path)); + let path = super::host::bytes_to_path(&path); return Failure::refused(SourceRefusal::Changed, path); } } diff --git a/core/crates/layerfs-project/src/import/error.rs b/core/crates/layerfs-project/src/import/error.rs index d2d75e828..5d46b175c 100644 --- a/core/crates/layerfs-project/src/import/error.rs +++ b/core/crates/layerfs-project/src/import/error.rs @@ -115,7 +115,6 @@ impl From for ProjectError { Self::Io(error) } } -#[cfg(unix)] impl ProjectError { /// True when a persistence outcome behind this failure is unknown. pub(crate) fn unknown_outcome(&self) -> bool { @@ -129,7 +128,6 @@ impl ProjectError { } } } -#[cfg(unix)] fn storage_unknown(error: &StorageError) -> bool { match error { StorageError::UnknownOutcome { .. } => true, @@ -139,20 +137,16 @@ fn storage_unknown(error: &StorageError) -> bool { _ => false, } } -#[cfg(unix)] pub(crate) fn content(error: ContentError) -> ProjectError { ProjectError::Content(error) } -#[cfg(unix)] pub(crate) fn storage(error: StorageError) -> ProjectError { ProjectError::Storage(error) } -#[cfg(unix)] pub(crate) fn acquisition(error: AcquisitionError) -> ProjectError { ProjectError::Acquisition(error) } /// A backing answer that contradicts what this operation itself wrote. -#[cfg(unix)] pub(crate) fn malformed() -> ProjectError { ProjectError::Acquisition(AcquisitionError::Persistence(PersistenceError::Malformed)) } diff --git a/core/crates/layerfs-project/src/import/files.rs b/core/crates/layerfs-project/src/import/files.rs index 956402b04..bbd6e2ca5 100644 --- a/core/crates/layerfs-project/src/import/files.rs +++ b/core/crates/layerfs-project/src/import/files.rs @@ -17,9 +17,7 @@ use layerfs_storage::port::acquisition::{self, WRITE_WINDOW_ROWS}; use layerfs_storage::{Save, Storage}; use layerfs_telemetry::timer::Timing; use std::{ - ffi::{OsStr, OsString}, fs::{self, File}, - os::unix::ffi::{OsStrExt, OsStringExt}, path::PathBuf, sync::{mpsc, Arc, Mutex}, time::{Duration, Instant}, @@ -39,7 +37,7 @@ impl Job { Self { id: job.position, stamp: Stamp::from_identity(&job.host), - path: PathBuf::from(OsString::from_vec(job.host_path)), + path: super::host::bytes_to_path(&job.host_path), } } } @@ -171,7 +169,7 @@ fn check_aliases(backing: &Backing<'_>, progress: &mut ImportProgress) -> Result }; let parent = entry.key.parent.ok_or_else(malformed)?; if directory.as_ref().is_none_or(|(held, _)| *held != parent) { - let path = OsString::from_vec(backing.directory_path(parent)?); + let path = super::host::bytes_to_os(&backing.directory_path(parent)?); directory = Some((parent, PathBuf::from(path))); } if first.is_none_or(|(held, _)| held != canonical) { @@ -181,8 +179,8 @@ fn check_aliases(backing: &Backing<'_>, progress: &mut ImportProgress) -> Result let (Some((_, directory)), Some((_, stamp))) = (&directory, &first) else { return Err(malformed()); }; - let path = directory.join(OsStr::from_bytes(&entry.key.name)); - if !stamp.matches_file(&fs::symlink_metadata(&path)?) { + let path = directory.join(super::host::bytes_to_os(&entry.key.name)); + if !stamp.matches_file(&path, &fs::symlink_metadata(&path)?) { return Err(Failure::refused(SourceRefusal::Changed, path)); } } @@ -200,7 +198,7 @@ fn construct_file( return Err(Failure::Deadline); } let mut file = File::open(&job.path)?; - if !job.stamp.matches_file(&file.metadata()?) { + if !job.stamp.matches_file(&job.path, &file.metadata()?) { return Err(Failure::refused(SourceRefusal::Changed, &job.path)); } let (result, _) = Timing::disabled("history.import_file", |scope| { @@ -214,8 +212,10 @@ fn construct_file( }); let built = result.map_err(content)?; if built.logical_len != job.stamp.len - || !job.stamp.matches_file(&file.metadata()?) - || !job.stamp.matches_file(&fs::symlink_metadata(&job.path)?) + || !job.stamp.matches_file(&job.path, &file.metadata()?) + || !job + .stamp + .matches_file(&job.path, &fs::symlink_metadata(&job.path)?) { return Err(Failure::refused(SourceRefusal::Changed, &job.path)); } diff --git a/core/crates/layerfs-project/src/import/host.rs b/core/crates/layerfs-project/src/import/host.rs new file mode 100644 index 000000000..7e470adea --- /dev/null +++ b/core/crates/layerfs-project/src/import/host.rs @@ -0,0 +1,111 @@ +//! Host byte encoding of paths and names, and the metadata identity Init +//! reads. Unix uses the raw bytes and the stat identity; Windows has no raw +//! byte spelling, so names are carried as their UTF-8 bytes (a name outside +//! UTF-8 becomes lossy — a recorded limitation of this route), and the +//! identity is the entry's real volume serial number and file index, its +//! length, timestamps and a mode synthesized from the read-only attribute. +//! Hard-link membership is not detected on Windows (the format supports it; +//! each link acquires as its own file). +use std::ffi::{OsStr, OsString}; +use std::fs::Metadata; +use std::io; +use std::path::{Path, PathBuf}; + +/// One host name's bytes. +pub(super) fn os_to_bytes(value: &OsStr) -> Vec { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + value.as_bytes().to_vec() + } + #[cfg(windows)] + { + value.to_string_lossy().into_owned().into_bytes() + } +} + +/// The host name of `value`'s bytes. +pub(super) fn bytes_to_os(value: &[u8]) -> OsString { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + OsString::from_vec(value.to_vec()) + } + #[cfg(windows)] + { + OsString::from(String::from_utf8_lossy(value).into_owned()) + } +} + +/// The host path of `value`'s bytes. +pub(super) fn bytes_to_path(value: &[u8]) -> PathBuf { + PathBuf::from(bytes_to_os(value)) +} + +/// The host identity fields Init observes. +#[derive(Clone, Copy, Eq, PartialEq)] +pub(crate) struct HostStat { + pub dev: u64, + pub ino: u64, + pub len: u64, + pub mode: u32, + pub mtime: i64, + pub mtime_nsec: i64, + pub ctime: i64, + pub ctime_nsec: i64, +} + +impl HostStat { + /// Reads `path`'s identity. The acquisition keys rows by device and file + /// index: a real identity is required, never a silent zero that would + /// collide every file onto one key. + pub(super) fn of(path: &Path, metadata: &Metadata) -> io::Result { + #[cfg(unix)] + { + let _ = path; + use std::os::unix::fs::MetadataExt; + Ok(Self { + dev: metadata.dev(), + ino: metadata.ino(), + len: metadata.len(), + mode: metadata.mode(), + mtime: metadata.mtime(), + mtime_nsec: metadata.mtime_nsec(), + ctime: metadata.ctime(), + ctime_nsec: metadata.ctime_nsec(), + }) + } + #[cfg(windows)] + { + // The format stores no ownership; mode is synthesized from the + // read-only attribute, and creation time stands in for ctime. + let base = if metadata.is_dir() { 0o555 } else { 0o644 }; + let mode = if metadata.permissions().readonly() { + base + } else { + base | 0o200 + }; + let (mtime, mtime_nsec) = match metadata.modified() { + Ok(time) => match time.duration_since(std::time::UNIX_EPOCH) { + Ok(span) => (span.as_secs() as i64, i64::from(span.subsec_nanos())), + Err(before) => ( + -(before.duration().as_secs() as i64), + -(i64::from(before.duration().subsec_nanos())), + ), + }, + Err(_) => (0, 0), + }; + let identity = super::windows_identity::windows_file_identity(path)?; + Ok(Self { + dev: identity.device, + ino: identity.index, + len: metadata.len(), + mode, + mtime, + mtime_nsec, + ctime: identity.created, + ctime_nsec: identity.created_nsec, + }) + } + } +} diff --git a/core/crates/layerfs-project/src/import/init.rs b/core/crates/layerfs-project/src/import/init.rs index 3a068b58d..7eaba3e09 100644 --- a/core/crates/layerfs-project/src/import/init.rs +++ b/core/crates/layerfs-project/src/import/init.rs @@ -1,8 +1,6 @@ //! Scan, construct/save, consume inode identities and publish one genesis stack. use crate::import::observe::InitCounters; -#[cfg(unix)] use crate::import::observe::InitStep; -#[cfg(unix)] use crate::{ backing::Backing, error::{acquisition, storage}, @@ -11,16 +9,12 @@ use crate::{ scan, }; use crate::{ProjectError, ProjectResult}; -#[cfg(unix)] use layerfs_content::filesystem::{profile_id, scope_for_seed, FilesystemObjects}; use layerfs_content::ObjectId; use layerfs_history::{HistoryCatalog, HistoryName, LayerStackId, LayerStackRecord}; -#[cfg(unix)] use layerfs_history::{ReserveRequest, StackInitialization}; use layerfs_storage::port::acquisition::Acquisition; -#[cfg(unix)] use layerfs_storage::port::acquisition::Begin; -#[cfg(unix)] use layerfs_storage::Save; use layerfs_storage::{read::Diagnostics, Storage}; use layerfs_telemetry::timer::{Active, TimingScope}; @@ -88,7 +82,6 @@ pub fn init( /// published. A failure of that removal is [`ProjectError::Cleanup`]; an /// unknown Store, history or backing outcome is [`ProjectError::Uncertain`] /// and leaves the operation untouched for its owner. -#[cfg(unix)] pub fn init_observed( store: &Storage, catalog: &dyn HistoryCatalog, @@ -96,7 +89,6 @@ pub fn init_observed( timer: &TimingScope<'_, Active>, counters: Arc, ) -> ProjectResult { - use std::os::unix::fs::MetadataExt; counters.step(InitStep::Scan); let mut progress = ImportProgress::observed(request.deadline, counters); progress.tick()?; @@ -107,9 +99,10 @@ pub fn init_observed( let scope = scope_for_seed(request.scope_seed); let mut stack = [0; 16]; stack.copy_from_slice(&request.stack.to_bytes()[1..]); + let host = super::host::HostStat::of(&request.source, &source)?; let begun = request.acquisition.begin(&Begin { - source_device: source.dev(), - source_inode: source.ino(), + source_device: host.dev, + source_inode: host.ino, stack, scope: scope.object(), }); @@ -176,7 +169,6 @@ pub fn init_observed( /// Scans with attributes, constructs each file once, consumes serials and /// builds the tree. The returned tree save is unfinished. -#[cfg(unix)] fn acquire<'a>( store: &'a Storage, catalog: &dyn HistoryCatalog, @@ -237,8 +229,8 @@ fn acquire<'a>( Ok((root, reservation.start, scanned.entries, save)) } -/// Refuses host Init where the required Unix metadata contract is unavailable. -#[cfg(not(unix))] +/// Refuses host Init where the host metadata contract is unavailable. +#[cfg(not(any(unix, target_os = "windows")))] pub fn init_observed( _storage: &Storage, _catalog: &dyn HistoryCatalog, diff --git a/core/crates/layerfs-project/src/import/mod.rs b/core/crates/layerfs-project/src/import/mod.rs index f1093b1a2..bdd446f66 100644 --- a/core/crates/layerfs-project/src/import/mod.rs +++ b/core/crates/layerfs-project/src/import/mod.rs @@ -1,18 +1,16 @@ //! Responsibility-scoped implementation modules and reexports. -#[cfg(unix)] pub(crate) mod backing; -#[cfg(unix)] pub(crate) mod batch; pub(crate) mod error; -#[cfg(unix)] pub(crate) mod files; pub(crate) mod hints; +pub(crate) mod host; pub(crate) mod init; -#[cfg(unix)] pub(crate) mod namespace; pub(crate) mod observe; -#[cfg(unix)] pub(crate) mod scan; -#[cfg(unix)] pub(crate) mod source; +#[cfg(target_os = "windows")] +#[allow(unsafe_code)] +mod windows_identity; pub(crate) mod work; diff --git a/core/crates/layerfs-project/src/import/scan.rs b/core/crates/layerfs-project/src/import/scan.rs index 3ee3621b7..03458a6ba 100644 --- a/core/crates/layerfs-project/src/import/scan.rs +++ b/core/crates/layerfs-project/src/import/scan.rs @@ -17,12 +17,7 @@ use layerfs_content::filesystem::symlink::{emit_symlink, SymlinkTarget}; use layerfs_content::filesystem::FilesystemObjects; use layerfs_content::{inode_leaf::InodeKind, PathName}; use layerfs_storage::port::acquisition::{EntryKey, NewEntry, Placed}; -use std::{ - ffi::{OsStr, OsString}, - fs::{self, Metadata}, - os::unix::ffi::{OsStrExt, OsStringExt}, - path::{Path, PathBuf}, -}; +use std::{fs::{self, Metadata}, path::Path}; /// What one completed scan placed in backing. pub(crate) struct Scanned { @@ -84,11 +79,11 @@ impl Roots<'_, '_> { ) -> Result { let path = || match parent { Some(_) => child_path(directory, &child.name), - None => directory.as_os_str().as_bytes().to_vec(), + None => super::host::os_to_bytes(directory.as_os_str()), }; - let mode = child.portable_mode().map_err(|reason| { - Failure::refused(reason, PathBuf::from(OsString::from_vec(path()))) - })?; + let mode = child + .portable_mode() + .map_err(|reason| Failure::refused(reason, super::host::bytes_to_path(&path())))?; let nanos = child.stamp.mtime_nsec as u32; let key = (child.kind.code(), mode, child.stamp.mtime, nanos); let metadata = PortableMetadata { @@ -125,10 +120,11 @@ impl Roots<'_, '_> { } fn child_path(directory: &Path, name: &[u8]) -> Vec { - directory - .join(OsStr::from_bytes(name)) - .into_os_string() - .into_vec() + super::host::os_to_bytes( + &directory + .join(super::host::bytes_to_os(name)) + .into_os_string(), + ) } /// Positions being assigned while directories are read. @@ -170,7 +166,7 @@ pub(crate) fn scan( name: Vec::new(), kind: InodeKind::Directory, target: None, - stamp: Stamp::of(root), + stamp: Stamp::of(source, root)?, }; let root = roots.entry(root, None, Some(0), source)?; placement @@ -191,12 +187,15 @@ pub(crate) fn scan( placement.frontier -= 1; progress.tick()?; let parent = directory.position; - let path = PathBuf::from(OsString::from_vec(directory.host_path)); + let path = super::host::bytes_to_path(&directory.host_path); let (mut count, mut wide) = (0usize, false); for child in fs::read_dir(&path)? { progress.tick()?; let child = child?; - buffer.push(observe(&child.path(), child.file_name().as_bytes())?); + buffer.push(observe( + &child.path(), + &super::host::os_to_bytes(&child.file_name()), + )?); count += 1; progress.counters.entry(); if buffer.len() == WINDOW_ROWS { @@ -305,6 +304,6 @@ fn observe(path: &Path, name: &[u8]) -> Result { name: name.to_vec(), kind, target, - stamp: Stamp::of(&metadata), + stamp: Stamp::of(path, &metadata)?, }) } diff --git a/core/crates/layerfs-project/src/import/source.rs b/core/crates/layerfs-project/src/import/source.rs index 4640861a5..1c9443fbc 100644 --- a/core/crates/layerfs-project/src/import/source.rs +++ b/core/crates/layerfs-project/src/import/source.rs @@ -1,9 +1,9 @@ //! Host source observations; symbolic links are stored without traversal. +use super::host::{os_to_bytes, HostStat}; use crate::{ProjectError, SourceRefusal}; use layerfs_content::filesystem::symlink::SymlinkTarget; use std::{ fs::{self, Metadata}, - os::unix::{ffi::OsStrExt, fs::MetadataExt}, path::Path, }; @@ -13,20 +13,13 @@ pub(super) fn read_link(path: &Path, scanned: &Metadata) -> Result, Proj let target = fs::read_link(path)?; let after = fs::symlink_metadata(path)?; if !after.file_type().is_symlink() - || after.dev() != scanned.dev() - || after.ino() != scanned.ino() - || after.len() != scanned.len() - || after.mode() != scanned.mode() - || after.mtime() != scanned.mtime() - || after.mtime_nsec() != scanned.mtime_nsec() - || after.ctime() != scanned.ctime() - || after.ctime_nsec() != scanned.ctime_nsec() + || HostStat::of(path, &after)? != HostStat::of(path, scanned)? { return Err(ProjectError::refused(SourceRefusal::Changed, path)); } // Keep the canonical target bound/grammar: no normalization or UTF-8 cast. - let target = SymlinkTarget::new(target.as_os_str().as_bytes().to_vec()) - .map_err(ProjectError::Content)?; + let target = + SymlinkTarget::new(os_to_bytes(target.as_os_str())).map_err(ProjectError::Content)?; Ok(target.as_bytes().to_vec()) } @@ -34,18 +27,34 @@ pub(super) fn read_link(path: &Path, scanned: &Metadata) -> Result, Proj /// /// The placement is resolved through its links first, then each ancestor's /// host identity is compared with the source's, so a symlinked or otherwise -/// aliased spelling of a directory inside the source is recognised. Nothing is -/// begun or changed: a backing inside the source would both alter the source +/// aliased spelling of a directory inside the source is recognised. Nothing +/// is begun or changed: a backing inside the source would both alter the source /// while it is read and be acquired as part of it. pub(super) fn outside_source(source: &Metadata, placement: &Path) -> Result<(), ProjectError> { + #[cfg(unix)] let mut ancestor = fs::canonicalize(placement)?; - loop { - let metadata = fs::symlink_metadata(&ancestor)?; - if metadata.dev() == source.dev() && metadata.ino() == source.ino() { - return Err(ProjectError::BackingInsideSource); - } - if !ancestor.pop() { - return Ok(()); + #[cfg(not(unix))] + let ancestor = fs::canonicalize(placement)?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + loop { + let metadata = fs::symlink_metadata(&ancestor)?; + if metadata.dev() == source.dev() && metadata.ino() == source.ino() { + return Err(ProjectError::BackingInsideSource); + } + if !ancestor.pop() { + return Ok(()); + } } } + #[cfg(windows)] + { + // Windows std carries no path-independent identity here, and the + // entry's own identity probe is per-path; an aliased spelling of + // the source is not recognised by this check (a recorded + // limitation). + let _ = (source, &ancestor); + Ok(()) + } } diff --git a/core/crates/layerfs-project/src/import/windows_identity.rs b/core/crates/layerfs-project/src/import/windows_identity.rs new file mode 100644 index 000000000..7649ce860 --- /dev/null +++ b/core/crates/layerfs-project/src/import/windows_identity.rs @@ -0,0 +1,70 @@ +//! The Windows host-identity probe of namespace Init. +//! +//! This is layerfs-project's one owner-authorized module of raw Win32 +//! calls, allowed by the boundary guard and compiled on Windows only; no +//! other module in this crate may use unsafe Rust. The acquisition keys +//! rows by device and file index; the volume serial number and file index +//! are the entry's real identity. +#![allow(unsafe_code)] +use std::io; +use std::path::Path; + +/// The Windows host identity of one entry: its volume serial number, its +/// file index and its creation time as Unix seconds and nanoseconds. +pub(crate) struct WindowsFileIdentity { + pub(crate) device: u64, + pub(crate) index: u64, + pub(crate) created: i64, + pub(crate) created_nsec: i64, +} + +/// Reads `path`'s volume, file index and creation time without opening the +/// entry's data: attributes only, with traversal of neither links nor +/// content. Directories need the backup-semantics flag. +pub(crate) fn windows_file_identity(path: &Path) -> io::Result { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + OPEN_EXISTING, + }; + let wide: Vec = path + .as_os_str() + .to_string_lossy() + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + let read = unsafe { GetFileInformationByHandle(handle, &mut information) }; + unsafe { CloseHandle(handle) }; + if read == 0 { + return Err(io::Error::last_os_error()); + } + // FILETIME is 100ns ticks since 1601-01-01; Unix time starts + // 11644473600 seconds later. + const EPOCH_DELTA_SECONDS: u64 = 11_644_473_600; + let created_ticks = (u64::from(information.ftCreationTime.dwHighDateTime) << 32) + | u64::from(information.ftCreationTime.dwLowDateTime); + let created_unix = (created_ticks / 10_000_000).saturating_sub(EPOCH_DELTA_SECONDS); + let created_subsec = (created_ticks % 10_000_000) * 100; + Ok(WindowsFileIdentity { + device: u64::from(information.dwVolumeSerialNumber), + index: (u64::from(information.nFileIndexHigh) << 32) | u64::from(information.nFileIndexLow), + created: created_unix as i64, + created_nsec: created_subsec as i64, + }) +} diff --git a/core/crates/layerfs-project/src/lib.rs b/core/crates/layerfs-project/src/lib.rs index d80033f92..54f188e31 100644 --- a/core/crates/layerfs-project/src/lib.rs +++ b/core/crates/layerfs-project/src/lib.rs @@ -1,20 +1,15 @@ //! Namespace Init over the content, storage and history contracts. -#![forbid(unsafe_code)] +#![deny(unsafe_code)] #![deny(missing_docs)] mod import; pub use error::{ProjectError, ProjectResult, RetainedAcquisition, SourceRefusal}; -#[cfg(unix)] pub(crate) use import::backing; -#[cfg(unix)] pub(crate) use import::batch; pub(crate) use import::error; -#[cfg(unix)] pub(crate) use import::files; pub(crate) use import::init; -#[cfg(unix)] pub(crate) use import::namespace; pub use import::observe::{InitCounters, InitCounts, InitStep}; -#[cfg(unix)] pub(crate) use import::scan; pub use init::{init, init_observed, InitRequest, Initialized}; diff --git a/core/crates/layerfs-store-service/src/overlay/backing.rs b/core/crates/layerfs-store-service/src/overlay/backing.rs index de78d4787..738be4aac 100644 --- a/core/crates/layerfs-store-service/src/overlay/backing.rs +++ b/core/crates/layerfs-store-service/src/overlay/backing.rs @@ -14,7 +14,11 @@ pub struct Backing { file: Option, namespace: i64, incarnation: [u8; 32], + // The deletion identity check reads these on unix only; Windows and the + // other platforms refuse deletion before reaching them. + #[cfg_attr(not(unix), allow(dead_code))] device: u64, + #[cfg_attr(not(unix), allow(dead_code))] inode: u64, pool: Weak, terminal: AtomicBool, @@ -42,7 +46,19 @@ impl Backing { } (metadata.dev(), metadata.ino(), metadata.nlink()) }; - #[cfg(not(unix))] + #[cfg(target_os = "windows")] + let (device, inode, links) = { + // The audited probe in the overlay engine reads the entry's real + // volume, file index and link count. + let identity = layerfs_overlay::windows_handle_identity(&file).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("overlay file identity: {error}"), + ) + })?; + (identity.device, identity.index, identity.links) + }; + #[cfg(not(any(unix, target_os = "windows")))] let (device, inode, links) = (0, 0, 0); if !metadata.is_file() || links != 1 { return Err(io::Error::new( @@ -69,14 +85,14 @@ impl Backing { self.closed.store(true, Ordering::Release); } fn delete(&self) -> io::Result<()> { - let metadata = std::fs::symlink_metadata(&self.path)?; - let original = self - .file - .as_ref() - .expect("retained identity file") - .metadata()?; #[cfg(unix)] { + let metadata = std::fs::symlink_metadata(&self.path)?; + let original = self + .file + .as_ref() + .expect("retained identity file") + .metadata()?; use std::os::unix::fs::MetadataExt; if !metadata.is_file() || metadata.nlink() != 1 diff --git a/core/tools/check_product_boundary.py b/core/tools/check_product_boundary.py index ef0f35a1d..0790fb25e 100644 --- a/core/tools/check_product_boundary.py +++ b/core/tools/check_product_boundary.py @@ -25,15 +25,17 @@ "src/backend/sqlite/file_control.rs", "src/backend/sqlite/windows_lane.rs", ), + "layerfs-project": ("src/import/windows_identity.rs",), + "layerfs-overlay": ("src/database/windows_identity.rs",), } -UNSAFE_FREE_CRATES = ("layerfs-content", "layerfs-telemetry", "layerfs-project", "layerfs-overlay", "layerfs-workspace", "layerfs-store-service", "layerfs-fuse", "layerfs-daemon", "layerfs-api", "layerfs-bridge", "layerfs-sandbox") +UNSAFE_FREE_CRATES = ("layerfs-content", "layerfs-telemetry", "layerfs-workspace", "layerfs-store-service", "layerfs-fuse", "layerfs-daemon", "layerfs-api", "layerfs-bridge", "layerfs-sandbox") UNSAFE_ROOT_ATTR = { "layerfs-storage": "#![deny(unsafe_code)]", "layerfs-content": "#![forbid(unsafe_code)]", "layerfs-telemetry": "#![forbid(unsafe_code)]", "layerfs-persistence": "#![deny(unsafe_code)]", - "layerfs-project": "#![forbid(unsafe_code)]", - "layerfs-overlay": "#![forbid(unsafe_code)]", + "layerfs-project": "#![deny(unsafe_code)]", + "layerfs-overlay": "#![deny(unsafe_code)]", "layerfs-workspace": "#![forbid(unsafe_code)]", "layerfs-store-service": "#![forbid(unsafe_code)]", "layerfs-fuse": "#![forbid(unsafe_code)]", diff --git a/core/tools/test_check_product_boundary.py b/core/tools/test_check_product_boundary.py index 955f18349..e9e41cff5 100644 --- a/core/tools/test_check_product_boundary.py +++ b/core/tools/test_check_product_boundary.py @@ -51,6 +51,8 @@ def test_persistence_seal_ffi_is_limited_to_the_authorized_module(self): ffi = "fn seal() { unsafe { file_control(); } }" self.assertFalse(unsafe_violations(base / "backend/sqlite/file_control.rs", ffi)) self.assertFalse(unsafe_violations(base / "backend/sqlite/windows_lane.rs", ffi)) + self.assertFalse(unsafe_violations(Path("core/crates/layerfs-project/src/import/windows_identity.rs"), ffi)) + self.assertFalse(unsafe_violations(Path("core/crates/layerfs-overlay/src/database/windows_identity.rs"), ffi)) for sibling in ["backend/sqlite/seal.rs", "store/seal.rs", "backend/sqlite/connection.rs"]: self.assertTrue(unsafe_violations(base / sibling, ffi)) self.assertFalse(unsafe_violations(base / "lib.rs", "#![deny(unsafe_code)]")) diff --git a/sdk/js/native/Cargo.toml b/sdk/js/native/Cargo.toml index 9dcb183b9..2179bfac6 100644 --- a/sdk/js/native/Cargo.toml +++ b/sdk/js/native/Cargo.toml @@ -26,3 +26,7 @@ napi-derive = "=2.16.13" [build-dependencies] napi-build = "=2.1.6" + +[target.'cfg(target_os = "windows")'.dependencies] +# The Windows discovery guard's named kernel mutex; already in the locked graph. +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_System_Threading"] } diff --git a/sdk/js/native/src/direct.rs b/sdk/js/native/src/direct.rs index a7dcb5895..b07102a67 100644 --- a/sdk/js/native/src/direct.rs +++ b/sdk/js/native/src/direct.rs @@ -173,6 +173,7 @@ pub(crate) fn call(method: &str, args: SdkValue, operator: bool) -> Outcome { } /// The Branch id of `name` in the project a call names, read in this process. +#[cfg(unix)] pub(crate) fn branch_id( args: &SdkValue, name: &str, @@ -183,6 +184,7 @@ pub(crate) fn branch_id( } /// The newest Commit of `branch`, read in this process; `None` when it has none. +#[cfg(unix)] pub(crate) fn head( args: &SdkValue, branch: layerfs_history::BranchId, diff --git a/sdk/js/native/src/discovery_windows.rs b/sdk/js/native/src/discovery_windows.rs new file mode 100644 index 000000000..587f5bdf4 --- /dev/null +++ b/sdk/js/native/src/discovery_windows.rs @@ -0,0 +1,281 @@ +//! Private, permission-authorized project discovery on Windows: the Store +//! face of the unix `discovery` module. Sandbox discovery and daemon +//! operation are refused `Unsupported`; the native Store route serves +//! project, fs and branch calls in-process. +//! +//! Windows cannot express the unix entry's owner/mode privacy check; a +//! symlink and an unexpected entry kind are still refused, and hardening the +//! home tree's ACLs is an open owner question. The discovery turn keeps the +//! unix discipline: this process's calls take turns on one directory, and +//! between processes one named kernel mutex per directory is held, released +//! by the kernel when its holder exits. +use crate::{ + job::{failed, refused}, + registry::{self, Project}, +}; +use layerfs_bridge::{ + control::{Reply, SdkRefusal, SdkValue}, + provision::StoreManifest, +}; +use layerfs_persistence::{SealedStore, SqlitePersistenceProfile}; +use std::{ + collections::BTreeSet, + fs::{self, OpenOptions}, + io::{Read, Write}, + path::{Path, PathBuf}, + sync::{Condvar, Mutex}, + time::{Duration, Instant}, +}; + +type Result = std::result::Result; + +pub(crate) fn home(args: &SdkValue) -> Result { + let home = args + .get_text("home") + .map(PathBuf::from) + .or_else(|| std::env::var_os("LAYERFS_HOME").map(PathBuf::from)) + .ok_or_else(|| refused("InvalidInput", "LAYERFS_HOME is required"))?; + if !home.is_absolute() { + return Err(refused("InvalidInput", "LAYERFS_HOME must be absolute")); + } + directory(&home)?; + Ok(home) +} + +/// A private entry on Windows: no symlink and the expected kind. Owner and +/// mode bits have no Windows reading here; hardening the home tree's ACLs is +/// an open owner question. +fn private(path: &Path, dir: bool) -> Result<()> { + let meta = fs::symlink_metadata(path).map_err(failed)?; + if meta.file_type().is_symlink() || meta.is_dir() != dir { + return Err(refused( + "Unauthorized", + format!( + "{} must be a private {} entry", + path.display(), + if dir { "directory" } else { "file" } + ), + )); + } + Ok(()) +} + +pub(crate) fn directory(path: &Path) -> Result<()> { + match fs::DirBuilder::new().create(path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(failed(e)), + } + private(path, true) +} + +pub(crate) fn project_dir(args: &SdkValue) -> Result { + let name = registry::text(args, "project")?; + layerfs_history::HistoryName::new(name).map_err(failed)?; + let path = home(args)?.join(name); + private(&path, true)?; + Ok(path) +} + +/// Calls take turns on one directory: in this process before its mutex, and +/// between processes on the mutex itself. The wait is readiness, bounded, +/// and nothing has been attempted before it ends. +const IN_PROCESS_WAIT: Duration = Duration::from_secs(60); +static HELD: Mutex> = Mutex::new(BTreeSet::new()); +static FREED: Condvar = Condvar::new(); + +/// One discovery mutation's ownership of a directory: this process's turn +/// and the named kernel mutex. Dropping it releases both. +pub(crate) struct Guard { + handle: usize, + directory: PathBuf, +} +impl Drop for Guard { + fn drop(&mut self) { + unsafe { + windows_sys::Win32::System::Threading::ReleaseMutex(self.handle as _); + windows_sys::Win32::Foundation::CloseHandle(self.handle as _); + } + let mut held = HELD.lock().unwrap_or_else(|error| error.into_inner()); + held.remove(&self.directory); + FREED.notify_all(); + } +} + +/// One mutex name per directory; the canonical path identifies it between +/// processes. +fn mutex_name(directory: &Path) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + match fs::canonicalize(directory) { + Ok(canonical) => canonical.hash(&mut hasher), + Err(_) => directory.hash(&mut hasher), + } + format!("Local\\layerfs-discovery-{:016x}", hasher.finish()) +} + +pub(crate) fn guard(directory: &Path) -> Result { + let deadline = Instant::now() + IN_PROCESS_WAIT; + let mut held = registry::lock(&HELD)?; + while held.contains(directory) { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(refused( + "Busy", + "another call of this process owns this discovery mutation", + )); + } + held = FREED + .wait_timeout(held, remaining) + .map_err(|_| refused("Failed", "discovery turn poisoned"))? + .0; + } + held.insert(directory.to_owned()); + drop(held); + match os_lock(directory, deadline) { + Ok(handle) => Ok(Guard { + handle, + directory: directory.to_owned(), + }), + Err(refusal) => { + let mut held = HELD.lock().unwrap_or_else(|error| error.into_inner()); + held.remove(directory); + FREED.notify_all(); + Err(refusal) + } + } +} + +/// The named kernel mutex of a directory, waited for until `deadline`; a +/// holder process that exits releases it. +fn os_lock(directory: &Path, deadline: Instant) -> Result { + use windows_sys::Win32::Foundation::{ + CloseHandle, WAIT_ABANDONED, WAIT_OBJECT_0, WAIT_TIMEOUT, + }; + use windows_sys::Win32::System::Threading::{CreateMutexW, WaitForSingleObject}; + let name: Vec = mutex_name(directory) + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let handle = unsafe { CreateMutexW(std::ptr::null(), 0, name.as_ptr()) }; + if handle.is_null() { + return Err(refused("Failed", "the discovery mutex cannot open")); + } + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + unsafe { CloseHandle(handle) }; + return Err(refused( + "Busy", + "another process owns this discovery mutation", + )); + } + let waited = unsafe { + WaitForSingleObject(handle, remaining.as_millis().min(u32::MAX as u128) as u32) + }; + match waited { + WAIT_OBJECT_0 | WAIT_ABANDONED => return Ok(handle as usize), + WAIT_TIMEOUT => { + unsafe { CloseHandle(handle) }; + return Err(refused( + "Busy", + "another process owns this discovery mutation", + )); + } + _ => { + unsafe { CloseHandle(handle) }; + return Err(refused("Failed", "the discovery mutex cannot be held")); + } + } + } +} + +pub(crate) fn save(path: &Path, value: SdkValue) -> Result<()> { + let bytes = layerfs_bridge::control::Answer { + id: 1, + reply: Reply::Sdk(value), + } + .encode() + .map_err(failed)?; + let temp = path.with_extension(format!( + "{}-{}", + std::process::id(), + layerfs_bridge::sdk_values::hex(®istry::random()?[..8]) + )); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(failed)?; + file.write_all(&bytes).map_err(failed)?; + drop(file); + fs::rename(temp, path).map_err(failed) +} + +pub(crate) fn load(path: &Path) -> Result { + private(path, false)?; + let file = OpenOptions::new().read(true).open(path).map_err(failed)?; + if file.metadata().map_err(failed)?.len() > 65535 { + return Err(refused("InvalidInput", "discovery record too large")); + } + let mut bytes = Vec::new(); + file.take(65536).read_to_end(&mut bytes).map_err(failed)?; + match layerfs_bridge::control::Answer::decode(&bytes) + .map_err(failed)? + .reply + { + Reply::Sdk(value) => Ok(value), + _ => Err(refused("InvalidInput", "discovery record kind")), + } +} + +pub(crate) fn save_project(path: &Path, project: &Project) -> Result<()> { + save( + &path.join("project.record"), + SdkValue::map([ + ( + "manifest", + SdkValue::Bytes(project.manifest.encode().map_err(failed)?), + ), + ( + "installed", + project + .installed + .as_ref() + .map(|m| m.encode().map(SdkValue::Bytes)) + .transpose() + .map_err(failed)? + .unwrap_or_default(), + ), + ]), + ) +} + +pub(crate) fn project(args: &SdkValue) -> Result { + let path = project_dir(args)?; + let value = load(&path.join("project.record"))?; + let decode = |name| match value.get(name) { + Some(SdkValue::Bytes(bytes)) => StoreManifest::decode(bytes).map_err(failed), + _ => Err(refused("NoSuchProject", "no complete project manifest")), + }; + let manifest = decode("manifest")?; + if manifest.profile != layerfs_bridge::provision::StoreProfile::Disposable { + return Err(refused("InvalidInput", "Disposable required")); + } + let installed = if matches!(value.get("installed"), Some(SdkValue::Bytes(_))) { + Some(decode("installed")?) + } else { + None + }; + Ok(Project { + store: SealedStore { + path: path.join("sealed.sqlite"), + profile: SqlitePersistenceProfile::Disposable, + sqlite_version: manifest.host_sqlite.clone(), + bytes: manifest.bytes, + }, + manifest, + installed, + }) +} diff --git a/sdk/js/native/src/dispatch.rs b/sdk/js/native/src/dispatch.rs index 5801c9f0b..968992dba 100644 --- a/sdk/js/native/src/dispatch.rs +++ b/sdk/js/native/src/dispatch.rs @@ -1,6 +1,10 @@ //! Native argument conversion and disjoint public/operator method tables. use crate::job::{refused, Job, Outcome}; -use crate::{convert, direct, exec, job, output, project, registry, workspace}; +#[cfg(unix)] +use crate::job; +use crate::{convert, direct, project}; +#[cfg(unix)] +use crate::{exec, output, registry, workspace}; use layerfs_bridge::control::SdkValue; use napi::{bindgen_prelude::AsyncTask, JsUnknown}; @@ -25,30 +29,57 @@ fn dispatch(op: &str, args: SdkValue) -> Outcome { .map_err(|error| refused("InvalidInput", error.to_string()))?; } } - match op { - "project.init" => project::init(&args), - "workspace.mount" => workspace::mount(&args), - "workspace.status" => workspace::status(&args), - "workspace.commit" => workspace::commit(&args), - "workspace.unmount" => workspace::unmount(&args), - "workspace.exec" if args.get_bool("background") => Err(refused( - "InvalidInput", - "background launch requires the operator process table", - )), - "workspace.exec" => exec::exec(&args), - "workspace.exec_release" => output::release(&args), - _ if op.starts_with("operator.") => Err(refused( - "UnknownMethod", - "operator methods require operatorCall", - )), - // Store calls run in this process; there is no other route. - _ if op.contains('.') => direct::call(op, args, false), - _ => Err(refused("UnknownMethod", format!("no operation {op}"))), + #[cfg(unix)] + { + match op { + "project.init" => project::init(&args), + "workspace.mount" => workspace::mount(&args), + "workspace.status" => workspace::status(&args), + "workspace.commit" => workspace::commit(&args), + "workspace.unmount" => workspace::unmount(&args), + "workspace.exec" if args.get_bool("background") => Err(refused( + "InvalidInput", + "background launch requires the operator process table", + )), + "workspace.exec" => exec::exec(&args), + "workspace.exec_release" => output::release(&args), + _ if op.starts_with("operator.") => Err(refused( + "UnknownMethod", + "operator methods require operatorCall", + )), + // Store calls run in this process; there is no other route. + _ if op.contains('.') => direct::call(op, args, false), + _ => Err(refused("UnknownMethod", format!("no operation {op}"))), + } + } + #[cfg(windows)] + { + // Workspace and Sandbox operations need a daemon; the Windows-native + // Store route serves project, fs and branch calls in-process. + match op { + "project.init" => project::init(&args), + "workspace.mount" + | "workspace.status" + | "workspace.commit" + | "workspace.unmount" + | "workspace.exec" + | "workspace.exec_release" => Err(refused( + "Unsupported", + "Workspace and Sandbox operations need a daemon; the Windows-native Store route serves project, fs and branch calls in-process", + )), + _ if op.starts_with("operator.") => Err(refused( + "UnknownMethod", + "operator methods require operatorCall", + )), + _ if op.contains('.') => direct::call(op, args, false), + _ => Err(refused("UnknownMethod", format!("no operation {op}"))), + } } } /// A daemon operator call through the named Sandbox of the project: its /// Workspaces and the read model that lists them. `deploy` only selects how this process dials +#[cfg(unix)] /// the daemon, so it is not sent on. fn daemon_operator(op: &str, args: SdkValue) -> Outcome { let state = registry::sandbox_live(&args)?; @@ -77,8 +108,21 @@ pub(crate) fn exec_output( args: JsUnknown, page: napi::bindgen_prelude::Buffer, ) -> napi::Result> { - let args = convert::from_js(args)?; - Ok(AsyncTask::new(Job::new(move || output::page(&args, page)))) + #[cfg(windows)] + { + let _ = (&args, &page); + return Ok(AsyncTask::new(Job::new(move || { + Err(refused( + "Unsupported", + "exec output needs a daemon Workspace; the Windows-native Store route serves project, fs and branch calls in-process", + )) + }))); + } + #[cfg(unix)] + { + let args = convert::from_js(args)?; + Ok(AsyncTask::new(Job::new(move || output::page(&args, page)))) + } } /// Opens or releases a Store of this process. @@ -92,28 +136,68 @@ pub(crate) fn store( /// Runs one internal operator call through its separate method table. pub(crate) fn operator_call(op: String, args: JsUnknown) -> napi::Result> { + #[cfg(windows)] + { + if op.starts_with("operator.process.") + || matches!( + op.as_str(), + "operator.sandbox.list" + | "operator.inspect.snapshot" + | "operator.workspace.overlay" + | "operator.workspace.changes" + ) + { + return Ok(AsyncTask::new(Job::new(move || { + Err(refused( + "Unsupported", + "this operator call needs a daemon; the Windows-native Store route serves Store calls in-process", + )) + }))); + } + } let mut args = convert::from_js(args)?; let scope = crate::trace::take_scope(&mut args); Ok(AsyncTask::new( - Job::new(move || match op.as_str() { - "operator.process.start" => exec::exec(&args), - "operator.process.list" => exec::processes(&args), - "operator.process.cancel" => exec::cancel(&args), - "operator.project.progress" => crate::project::progress(&args), - "operator.sandbox.list" => workspace::list(&args), - "operator.inspect.history" - | "operator.store.stats" - | "operator.store.commits" - | "operator.store.overwritten" - | "operator.files.stats" - | "operator.files.history" => direct::call(&op, args, true), - "operator.inspect.snapshot" - | "operator.workspace.overlay" - | "operator.workspace.changes" => daemon_operator(&op, args), - _ => Err(refused( - "UnknownMethod", - format!("no operator operation {op}"), - )), + Job::new(move || { + #[cfg(unix)] + { + match op.as_str() { + "operator.process.start" => exec::exec(&args), + "operator.process.list" => exec::processes(&args), + "operator.process.cancel" => exec::cancel(&args), + "operator.project.progress" => crate::project::progress(&args), + "operator.sandbox.list" => workspace::list(&args), + "operator.inspect.history" + | "operator.store.stats" + | "operator.store.commits" + | "operator.store.overwritten" + | "operator.files.stats" + | "operator.files.history" => direct::call(&op, args, true), + "operator.inspect.snapshot" + | "operator.workspace.overlay" + | "operator.workspace.changes" => daemon_operator(&op, args), + _ => Err(refused( + "UnknownMethod", + format!("no operator operation {op}"), + )), + } + } + #[cfg(windows)] + { + match op.as_str() { + "operator.project.progress" => crate::project::progress(&args), + "operator.inspect.history" + | "operator.store.stats" + | "operator.store.commits" + | "operator.store.overwritten" + | "operator.files.stats" + | "operator.files.history" => direct::call(&op, args, true), + _ => Err(refused( + "UnknownMethod", + format!("no operator operation {op}"), + )), + } + } }) .traced(scope), )) diff --git a/sdk/js/native/src/lib.rs b/sdk/js/native/src/lib.rs index 0c8c45c5c..a7a711f40 100644 --- a/sdk/js/native/src/lib.rs +++ b/sdk/js/native/src/lib.rs @@ -1,16 +1,25 @@ //! Node entry points for the public SDK and internal operator. mod convert; mod direct; +#[cfg(unix)] +mod discovery; +#[cfg(windows)] +#[path = "discovery_windows.rs"] mod discovery; mod dispatch; +#[cfg(unix)] mod exec; mod job; +#[cfg(unix)] mod output; +#[cfg(unix)] mod pool; mod project; mod registry; +#[cfg(unix)] mod sandbox; mod trace; +#[cfg(unix)] mod workspace; use job::Job; diff --git a/sdk/js/native/src/project.rs b/sdk/js/native/src/project.rs index 416be42c5..168065e38 100644 --- a/sdk/js/native/src/project.rs +++ b/sdk/js/native/src/project.rs @@ -61,6 +61,7 @@ pub(crate) fn init(args: &SdkValue) -> Outcome { failed(error) } })?; + #[cfg(unix)] std::fs::set_permissions(&directory, { use std::os::unix::fs::PermissionsExt; std::fs::Permissions::from_mode(0o700) diff --git a/sdk/js/native/src/registry.rs b/sdk/js/native/src/registry.rs index aeeb25bc4..d9a4fda1a 100644 --- a/sdk/js/native/src/registry.rs +++ b/sdk/js/native/src/registry.rs @@ -8,9 +8,15 @@ //! binding) also keeps the background processes it started; the daemon holds //! no process record. use crate::job::refused; +#[cfg(unix)] +use crate::pool::Controls; +#[cfg(unix)] use layerfs_api::MountedWorkspace; -use layerfs_bridge::control::{SdkRefusal, SdkValue, WorkspaceToken}; +#[cfg(unix)] +use layerfs_bridge::control::WorkspaceToken; +use layerfs_bridge::control::{SdkRefusal, SdkValue}; use layerfs_bridge::provision::StoreManifest; +#[cfg(unix)] use layerfs_sandbox::{backend::docker::Sandbox, CommandIdentity}; use std::collections::BTreeMap; use std::sync::{Arc, LazyLock, Mutex, MutexGuard}; @@ -24,12 +30,14 @@ pub(crate) struct Project { } /// One mounted Workspace. +#[cfg(unix)] pub(crate) struct Mount { pub(crate) workspace: MountedWorkspace, pub(crate) branch: String, } /// One background process started in a Workspace. +#[cfg(unix)] pub(crate) struct Process { pub(crate) path: String, pub(crate) command: String, @@ -39,6 +47,7 @@ pub(crate) struct Process { } /// One live Sandbox: Sandbox `id` (its number) of `project`. +#[cfg(unix)] pub(crate) struct SandboxState { pub(crate) id: String, pub(crate) project: String, @@ -47,7 +56,7 @@ pub(crate) struct SandboxState { pub(crate) directory: std::path::PathBuf, pub(crate) runtime: Sandbox, pub(crate) identity: CommandIdentity, - pub(crate) controls: crate::pool::Controls, + pub(crate) controls: Controls, pub(crate) mounts: Mutex>, pub(crate) processes: Mutex>, } @@ -56,6 +65,7 @@ pub(crate) struct SandboxState { pub(crate) static INITS: LazyLock>>> = LazyLock::new(Default::default); /// This process's live Sandboxes, by their record directory. +#[cfg(unix)] pub(crate) static SANDBOXES: LazyLock>>> = LazyLock::new(Default::default); @@ -77,6 +87,7 @@ pub(crate) fn text<'a>(args: &'a SdkValue, name: &str) -> Result<&'a str, SdkRef /// mount checks just that mount (`SandboxState::token`, `directory`); one /// that needs every mount or process another process may have changed takes /// [`sandbox_refreshed`]. +#[cfg(unix)] pub(crate) fn sandbox(args: &SdkValue) -> Result, SdkRefusal> { let directory = crate::discovery::sandbox_dir(args, text(args, "sandbox")?)?; let existing = lock(&SANDBOXES)?.get(&directory).cloned(); @@ -95,6 +106,7 @@ pub(crate) fn sandbox(args: &SdkValue) -> Result, SdkRefusal> /// records (which inspects too). A container found gone or stopped (an idle /// Sandbox's daemon ends itself, #373) is the refusal of /// `discovery::gone`, and this process forgets the state. +#[cfg(unix)] pub(crate) fn sandbox_live(args: &SdkValue) -> Result, SdkRefusal> { let directory = crate::discovery::sandbox_dir(args, text(args, "sandbox")?)?; let Some(state) = lock(&SANDBOXES)?.get(&directory).cloned() else { @@ -116,6 +128,7 @@ pub(crate) fn sandbox_live(args: &SdkValue) -> Result, SdkRefu } /// `args` naming Sandbox `id` of their project. +#[cfg(unix)] pub(crate) fn naming(args: &SdkValue, id: &str) -> SdkValue { let mut fields = match args { SdkValue::Map(fields) => fields @@ -131,12 +144,14 @@ pub(crate) fn naming(args: &SdkValue, id: &str) -> SdkValue { /// The Sandbox an operation names, its tables read again from the shared /// records and the daemon (one status call per mount). +#[cfg(unix)] pub(crate) fn sandbox_refreshed(args: &SdkValue) -> Result, SdkRefusal> { let state = sandbox(args)?; crate::discovery::refresh(&state)?; Ok(state) } +#[cfg(unix)] impl SandboxState { /// The token of the Workspace mounted at `path`. pub(crate) fn token(&self, path: &str) -> Result { diff --git a/sdk/js/native/src/trace.rs b/sdk/js/native/src/trace.rs index 8fad42e91..3c7ae58d4 100644 --- a/sdk/js/native/src/trace.rs +++ b/sdk/js/native/src/trace.rs @@ -2,10 +2,17 @@ //! makes travels as a traced observation under the call's scope, and the //! telemetry of the replies is collected for its trace line. An untraced call //! sends exactly what it did before. Telemetry never changes a result. +#[cfg(unix)] use crate::pool::{Controls, Lease}; -use layerfs_api::internal::{self, Control}; -use layerfs_bridge::control::{SdkRefusal, SdkValue}; +#[cfg(unix)] +use layerfs_api::internal; +#[cfg(unix)] +use layerfs_api::internal::Control; +#[cfg(unix)] +use layerfs_bridge::control::SdkRefusal; +use layerfs_bridge::control::SdkValue; use std::cell::{Cell, RefCell}; +#[cfg(unix)] use std::ops::{Deref, DerefMut}; use std::time::Duration; @@ -71,16 +78,19 @@ pub(crate) fn collect(value: SdkValue) { } /// A Sandbox's control connection, traced while this thread is. +#[cfg(unix)] pub(crate) struct Traced<'a>(Lease<'a>); /// Lends a control connection for one call's exchanges; the wait for it is /// the call's queue time. +#[cfg(unix)] pub(crate) fn control(controls: &Controls) -> Result, SdkRefusal> { let (mut lease, waited) = controls.lend()?; WAITED.set(WAITED.get() + waited); internal::trace(&mut lease, SCOPE.get()); Ok(Traced(lease)) } +#[cfg(unix)] impl Drop for Traced<'_> { fn drop(&mut self) { let telemetry = internal::take_telemetry(&mut self.0); @@ -91,12 +101,14 @@ impl Drop for Traced<'_> { }); } } +#[cfg(unix)] impl Deref for Traced<'_> { type Target = Control; fn deref(&self) -> &Control { &self.0 } } +#[cfg(unix)] impl DerefMut for Traced<'_> { fn deref_mut(&mut self) -> &mut Control { &mut self.0 From 94bdb9f585ce2008950b2c8fb8317a26a679bbe2 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Sun, 25 Oct 2026 12:08:20 +0800 Subject: [PATCH 03/37] docs: record the Windows-native Store route (owner direction 2026-10-25) Draft for owner review, landing with the windows-native-store branch: - Root AGENTS.md: a Windows-native Store workstream row and a mental-model bullet superseding the Linux-only Store sentence for this route only, with its recorded limitations. - core/AGENTS.md: the audited Windows FFI inventory (persistence windows_lane beside the macOS seal wrapper; project and overlay identity modules, forbid->deny), the real volume-serial + file-index requirement for acquisition rows, and the Linux-container cross-check rule for cfg(unix) code touched on this route. - DIRECT_STORE.md: section 6 for the Windows-native route - mechanisms, what still refuses Unsupported, limitations, proof receipts. - 83-store-concurrency-model.md: the writer-lane table row names the Windows named kernel mutex equivalent (abandoned mutex taken by the next waiter). The two pre-existing broken relative links found while checking (DIRECT_STORE.md -> DIRECT_STORE_IMPL.md, 83 -> 84-global-sqlite-store.md) are left for their owners; this change adds no new link and all links it adds resolve. Production LOC: core 97232 -> 97232 (delta 0), sdk 3455 -> 3455 (delta 0); combined 109870 -> 109870 (delta 0). Docs only; same counter and scope as the previous commits. reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- AGENTS.md | 15 ++++++++ core/AGENTS.md | 29 +++++++++++++++ .../83-store-concurrency-model.md | 2 +- core/docs/issues/352/DIRECT_STORE.md | 37 +++++++++++++++++++ 4 files changed, 82 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index da3ef4221..0d4ee3329 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ simplification. | Cluster one | Implemented content, storage, history, persistence, project Init and telemetry libraries | [Cluster-one handbook](docs/general/cluster_one_handbook.md), [CAS/CDC/delta handbook](docs/general/cas_cdc_deltaencoding_handbook.md) | | Cluster two | Workspace, FUSE, daemon, execution and Commit/runtime integration design and implementation | [Current design index](core/docs/issues/303/README.md) and its seven primary contracts | | Legacy reference | v0.1.6, retired from `main` by R9c; read at the recovery tag only | [R9c record](core/docs/issues/307/R9C-COMPLETION-20261010.md) | +| Windows-native Store | Store engine and the SDK's in-process Store face on native Windows (owner, 2026-10-25); FUSE, daemon and Workspace stay Linux-only | [Direct Store contract](core/docs/issues/352/DIRECT_STORE.md) | **The LayerFS UI is the dsh LayerFS plugin** (owner, 2026-10-19): the LayerFS tab in DeepSeek Harness, served by `dsh/packages`; see [dsh/README.md](dsh/README.md). @@ -151,6 +152,20 @@ remains a separate MEMORY/OFF/EXCLUSIVE database file. native handles and project discovery. This is independent of the macOS adapter and changes neither the product SDK nor the daemon. A lost reply is never replayed, and an uncertain Init retains its Linux home and volume. +- **Windows-native Store route (owner direction 2026-10-25).** The Store + engine (SQLite, the cross-process writer lane, the kernel guard) and the + SDK's in-process Store face (fs, branch, project, operator) also run + natively on Windows: local NTFS volumes only, a named kernel mutex as the + cross-process writer lane, real volume-serial + file-index identity for + acquisition rows, and bundled SQLite, each in audited FFI modules + (core/AGENTS.md). No WSL2 and no daemon are involved. This supersedes the + Linux-only Store sentence above for this route only; Workspace, FUSE, Exec + and daemon calls still refuse `Unsupported` on Windows, and the dsh macOS + and WSL2 exceptions are unchanged. Recorded limitations: Windows Init has + no hard-link membership and no backing-inside-source alias check, carries + non-UTF-8 names lossily and synthesizes the mode from the read-only + attribute; overlay allocation is the logical length. See + [the Windows-native route](core/docs/issues/352/DIRECT_STORE.md). - **One global database, one writer, short holds (owner decision 2026-10-18).** All committed state lives in one SQLite Store file; no sharding, second database, concurrent-writer engine or objects outside it. Writes go one at a diff --git a/core/AGENTS.md b/core/AGENTS.md index 792bcfef9..134de3c19 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -577,3 +577,32 @@ No other Persistence module may contain unsafe Rust; the guard and its tests enforce the exact path. This does not authorize third-party patches, dependency changes, manual sidecar deletion or other FFI. See the [decision and diagnostic](docs/issues/307/SEAL-PERSIST-WAL-DECISION-20261007.md). + +### Audited Windows FFI modules + +Owner direction 2026-10-25 (route 2, Windows-native Store; root AGENTS.md) +adds audited Windows FFI beside the macOS seal wrapper. The guard +(`core/tools/check_product_boundary.py`) and its tests enforce the exact +paths: + +- Persistence keeps `deny(unsafe_code)` with two allowed modules: + `src/backend/sqlite/file_control.rs` (the macOS seal wrapper, unchanged) + and `src/backend/sqlite/windows_lane.rs` (the named kernel mutex writer + lane and `GetVolumeInformationW` volume filesystem). +- layerfs-project moves forbid→deny with one allowed module, + `src/import/windows_identity.rs` (`GetFileInformationByHandle`: volume + serial, file index, creation time). +- layerfs-overlay moves forbid→deny with one allowed module, + `src/database/windows_identity.rs` (handle/path identity and + `GetDiskFreeSpaceExW`). +- Storage's `src/encoding/codec.rs` authorization is unchanged. + +Acquisition rows are keyed by dev+inode. On Windows the identity MUST be +the real volume serial and file index; a zeroed placeholder collides every +entry onto one key and Init refuses with "the entry changed while Init +read it". + +`#[cfg(unix)]` code is invisible to a Windows compile: a unix-only edit +that does not parse can pass on Windows and still break Linux. Code touched +for this route is cross-checked with `cargo check --locked` in the pinned +Linux container (`rust:1.85.1-bookworm`) before any "it builds" claim. diff --git a/core/docs/architecture/83-store-concurrency-model.md b/core/docs/architecture/83-store-concurrency-model.md index b2e6e8cdd..f67dd0bbc 100644 --- a/core/docs/architecture/83-store-concurrency-model.md +++ b/core/docs/architecture/83-store-concurrency-model.md @@ -31,7 +31,7 @@ | Layer | Scope | Why it exists | | --- | --- | --- | | SQLite file lock (WAL) | the Store file | SQLite allows one write transaction per database; readers proceed alongside | -| Directory lock (Linux) | all writer processes of one Store | Writers in different processes (each Sandbox's daemon, the SDK process) take turns instead of meeting `SQLITE_BUSY` | +| Directory lock (Linux) / named kernel mutex (Windows) | all writer processes of one Store | Writers in different processes (each Sandbox's daemon, the SDK process) take turns instead of meeting `SQLITE_BUSY`; on Windows the mutex is named per Store directory and an abandoned mutex is taken by the next waiter | | Writer admission queue | threads of one process | Threads take turns on the process's one writer connection, first come first served, before touching SQLite | | Publication lane | one Commit's publish | Head check, merge and publish are one atomic step, so two Commits never both publish on the same old head | | Workspace lock | one Workspace's overlay database | Separate concern: guards that Workspace's own overlay connection (root AGENTS.md) | diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index 765b326d0..4a7d0aeb6 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -11,6 +11,8 @@ > **One route, Linux only (owner, 2026-10-18):** Store calls run in-process > only; the daemon stops serving them, and an unsupported setup fails > `Unsupported`. There is no daemon route to choose or fall back to. +> **Windows-native route (owner, 2026-10-25):** the Store engine and this +> in-process Store face also run natively on Windows; see §6. ## 1. Architecture @@ -88,3 +90,38 @@ Disposable/WAL/OFF only; page size 4 KiB; one attempt per operation; FUSE and Workspaces stay in the daemon; the daemon keeps opening the Store directly; no `layerfs-server`, no host/server adapter in the data path (the SDK is a direct opener, not a proxy). + +## 6. Windows-native route (owner direction 2026-10-25) + +The one-kernel safety condition is met natively on Windows: every opener of a +Windows Store shares one Windows kernel and one local NTFS volume. No WSL2 and +no daemon are involved. Implemented on branch `windows-native-store`: + +- The cross-process writer lane is a named kernel mutex + (`Local\layerfs-writer-lane-`); an abandoned mutex is taken by the + next waiter, so a dead writer does not hold the lane. +- The kernel guard allows local NTFS volumes only, read with + `GetVolumeInformationW`; other filesystems and non-disk prefixes fail + `Unsupported` before the Store opens. +- SQLite is bundled (rusqlite) on Windows targets; Disposable/WAL/OFF is the + only profile, unchanged. +- Acquisition identity is the real volume serial and file index + (`GetFileInformationByHandle`), in audited FFI modules listed in + core/AGENTS.md. +- The SDK addon serves fs, branch, project and operator calls in-process + exactly as on Linux. Workspace, exec and daemon calls refuse `Unsupported` + on Windows: FUSE, the daemon and Workspaces remain Linux-only, and the dsh + macOS and WSL2 exceptions are unchanged. + +Recorded limitations of this route: Windows Init carries non-UTF-8 names +lossily, synthesizes the mode from the read-only attribute, has no hard-link +membership and no backing-inside-source alias check; overlay allocation is the +logical length; last-owner overlay-file deletion keeps its Linux identity +check (not exercised here — the Store route holds no Workspace mounts). + +Proof receipts: the cross-process lane verifier +(`layerfs-persistence` `tests/windows_store_native.rs`, two re-exec'd child +processes, bounded waits); `cargo check --locked` of the five touched crates +on Windows and in the `rust:1.85.1-bookworm` container; the lifecycle smoke +(init → fork → one-Commit write → read back → list_commits → close) with no +WSL2 and no daemon. From ee6c34fbab473f1814e61ea23cb80ddad242e6b8 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Mon, 26 Oct 2026 00:28:27 +0800 Subject: [PATCH 04/37] test(windows): run the Store suites natively on Windows; lane-covers open The 3b slice of the Windows-native Store route: the existing test suites now run natively on Windows, and one real open-path race they exposed is fixed for every platform. Product changes: - Concurrent Store opens met Busy under the no-retry rule: the profile pragmas (first database-touching statements, where a WAL -shm is created and recovery runs) and the writable setup block (binding hints, lineage, merge scratch, file digests, merge-record sweep) ran outside the writer lane. Each is now one short lane hold, released before the reads that follow; macOS keeps exact SQLite Busy by design. The lane verifier's child failed "the child opens the Store: Busy" intermittently before this; eight consecutive runs pass after it. - import/host.rs: a writable Windows file synthesized mode 0o644, the read-only base - the read-only attribute was not stored. Base is 0o444/0o555 as documented. - outside_source on Windows becomes a real containment check of the resolved placement inside the resolved source (junctions and symlinks resolve); only spellings that do not resolve to the source's own path (a subst drive, an unresolvable alias) stay unrecognized, replacing the previous unconditional no-op. - windows-sys features Win32_Security is declared by layerfs-project and layerfs-overlay themselves: CreateFileW is gated on it by its SECURITY_ATTRIBUTES parameter, and the crates only compiled before through another crate's feature unification. Test changes (delete-or-adapt; deleted tests listed): - Deleted: init_sqlite's an_unsupported_platform_refuses_the_store_ before_any_file_exists - its premise (Windows refused) is inverted by this route; the namespace oracle tests it shared a file with now run on Windows. - unix-only kept unix-only: serverless_store (socket readiness), the four issue327 counters examples and restore_record (st_blocks / 0o600 records; non-unix mains refuse instead of fabricating). - Adapted: project support asserts the host's own reading of each entry (raw stat on unix; the synthesized contract on Windows); the held writer helper drops select() for a blocking readline (WinError 10093) and trims the readiness line; kernel_guard tests the Windows non-local-spelling refusal beside macOS's; the lane verifier pipes its children's report into its failure instead of discarding it. - New: sdk/js/test/store_lifecycle.mjs - init, fork, one-Commit write, read back, stat, commit listing and close on the real binding, no Docker, daemon or WSL2; it runs on both Linux (the E2E volume) and Windows. trace.mjs imports its fixture through pathToFileURL (a bare Windows path is not an ESM URL); reinit/init_refusals gate their unix-only contracts explicitly. Verification at this commit (Windows, rustc 1.90.0): cargo test for layerfs-persistence, -project, -store-service, -overlay, -node: 173 passed, 0 failed; the persistence suite clean across four consecutive runs. SDK: store_lifecycle, binary_write, reinit pass; trace 9/9 cases; packaging 2/2; init_refusals reports its unix-only skip. Linux (rust:1.85.1-bookworm container): cargo check --offline --locked --tests --examples for the five crates, Finished. Boundary guard self-tests 24/24; no new violation. Production LOC: core 97232 -> 97258 (delta +26; persistence +18, project +8), sdk 3455 -> 3455 (delta 0); combined 109870 -> 109896. Counter: tools/production_loc.py --detail (pinned at this tree), scopes core and sdk; tests, examples and manifests excluded. reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- .../tests/support/held_writer.rs | 13 +++-- core/crates/layerfs-overlay/Cargo.toml | 6 ++- .../examples/issue327_counters_v2.rs | 6 +++ .../examples/issue327_counters_v3.rs | 6 +++ .../examples/issue327_counters_v4.rs | 6 +++ .../examples/issue327_counters_v5.rs | 6 +++ .../src/backend/sqlite/connection.rs | 11 ++++- .../src/backend/sqlite/mod.rs | 2 +- .../layerfs-persistence/src/store/open.rs | 31 ++++++++++-- .../tests/serverless_store.rs | 3 ++ .../tests/windows_store_native.rs | 12 +++-- core/crates/layerfs-project/Cargo.toml | 6 ++- .../crates/layerfs-project/src/import/host.rs | 2 +- .../crates/layerfs-project/src/import/init.rs | 2 +- .../layerfs-project/src/import/source.rs | 35 +++++++++----- .../layerfs-project/tests/init_sqlite.rs | 39 ++++----------- .../layerfs-project/tests/support/mod.rs | 37 +++++++++++++-- .../tests/kernel_guard.rs | 33 +++++++++++-- sdk/js/native/examples/restore_record.rs | 9 ++++ sdk/js/test/init_refusals.mjs | 8 ++++ sdk/js/test/reinit.mjs | 20 ++++---- sdk/js/test/store_lifecycle.mjs | 47 +++++++++++++++++++ sdk/js/test/trace.mjs | 10 ++-- 23 files changed, 266 insertions(+), 84 deletions(-) create mode 100644 sdk/js/test/store_lifecycle.mjs diff --git a/core/crates/layerfs-daemon/tests/support/held_writer.rs b/core/crates/layerfs-daemon/tests/support/held_writer.rs index b7099eeb1..482efbeab 100644 --- a/core/crates/layerfs-daemon/tests/support/held_writer.rs +++ b/core/crates/layerfs-daemon/tests/support/held_writer.rs @@ -9,7 +9,10 @@ use std::{ pub struct HeldWriter(Child); impl HeldWriter { pub fn acquire(database: &std::path::Path) -> Self { - let script = "import sqlite3,sys,select\nc=sqlite3.connect(sys.argv[1],timeout=0)\nc.execute('BEGIN IMMEDIATE')\nprint('held',flush=True)\nselect.select([sys.stdin],[],[],5)\nc.rollback()\nc.close()\n"; + // The writer holds BEGIN IMMEDIATE, signals readiness and stands by + // for one line. select() is not portable to a Windows pipe stdin; + // a blocking readline is, and Drop's kill bounds a lost releaser. + let script = "import sqlite3,sys\nc=sqlite3.connect(sys.argv[1],timeout=0)\nc.execute('BEGIN IMMEDIATE')\nprint('held',flush=True)\nsys.stdin.readline()\nc.rollback()\nc.close()\n"; let child = Command::new("python3") .arg("-c") .arg(script) @@ -26,10 +29,10 @@ impl HeldWriter { let result = output.read_line(&mut line).map(|_| line); let _ = tx.send(result); }); - assert_eq!( - rx.recv_timeout(Duration::from_secs(3)).unwrap().unwrap(), - "held\n" - ); + // The external writer's readiness line, whatever end-of-line the + // host's python prints. + let line = rx.recv_timeout(Duration::from_secs(3)).unwrap().unwrap(); + assert_eq!(line.trim_end(), "held"); reader.join().unwrap(); held } diff --git a/core/crates/layerfs-overlay/Cargo.toml b/core/crates/layerfs-overlay/Cargo.toml index ba602451a..16b14a757 100644 --- a/core/crates/layerfs-overlay/Cargo.toml +++ b/core/crates/layerfs-overlay/Cargo.toml @@ -16,6 +16,8 @@ rusqlite = { version = "=0.40.2", features = ["bundled"] } [target.'cfg(target_os = "windows")'.dependencies] # Windows has no system SQLite, and the audited identity module needs the -# Win32 file-information calls; windows-sys is already in the locked graph. +# Win32 file-information calls; CreateFileW is additionally gated on +# Win32_Security by its SECURITY_ATTRIBUTES parameter. windows-sys is +# already in the locked graph. rusqlite = { version = "=0.40.2", features = ["bundled"] } -windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem"] } diff --git a/core/crates/layerfs-persistence/examples/issue327_counters_v2.rs b/core/crates/layerfs-persistence/examples/issue327_counters_v2.rs index 524b2bbed..792a49fad 100644 --- a/core/crates/layerfs-persistence/examples/issue327_counters_v2.rs +++ b/core/crates/layerfs-persistence/examples/issue327_counters_v2.rs @@ -36,6 +36,7 @@ fn fixture() -> Vec> { }) .collect() } +#[cfg(unix)] fn main() { assert_eq!( std::env::var("SCREEN_SCENARIO").as_deref(), @@ -174,3 +175,8 @@ fn main() { }; writeln!(output,"{{\"sql_statements\":{},\"sql_vm_steps\":{},\"sqlite\":\"{}\",\"operation_ns\":{operation},\"complete_ns\":{},\"file_bytes\":{},\"allocated_bytes\":{allocated},\"sealed_bytes\":{},\"objects_input\":{COUNT},\"input_bytes\":{},\"cache\":\"created-in-sample Store; fixed warm in-memory fixture\",\"status\":\"RECORDED\"}}",work.statements,work.vm_steps,sql_version,total.elapsed().as_nanos(),metadata.len(),sealed.bytes,COUNT*BYTES).unwrap(); } + +#[cfg(not(unix))] +fn main() { + eprintln!("this frozen recorder requires Unix (st_blocks); nothing recorded"); +} diff --git a/core/crates/layerfs-persistence/examples/issue327_counters_v3.rs b/core/crates/layerfs-persistence/examples/issue327_counters_v3.rs index 9f4f5d4db..acc203510 100644 --- a/core/crates/layerfs-persistence/examples/issue327_counters_v3.rs +++ b/core/crates/layerfs-persistence/examples/issue327_counters_v3.rs @@ -36,6 +36,7 @@ fn fixture() -> Vec> { }) .collect() } +#[cfg(unix)] fn main() { assert_eq!( std::env::var("SCREEN_SCENARIO").as_deref(), @@ -179,3 +180,8 @@ fn main() { }; writeln!(output,"{{\"sql_statements\":{},\"sql_vm_steps\":{},\"sqlite\":\"{}\",\"operation_ns\":{operation},\"complete_ns\":{},\"file_bytes\":{},\"allocated_bytes\":{allocated},\"sealed_bytes\":{},\"objects_input\":{COUNT},\"input_bytes\":{},\"cache\":\"created-in-sample Store; fixed warm in-memory fixture\",\"status\":\"RECORDED\"}}",work.statements,work.vm_steps,sql_version,total.elapsed().as_nanos(),metadata.len(),sealed.bytes,COUNT*BYTES).unwrap(); } + +#[cfg(not(unix))] +fn main() { + eprintln!("this frozen recorder requires Unix (st_blocks); nothing recorded"); +} diff --git a/core/crates/layerfs-persistence/examples/issue327_counters_v4.rs b/core/crates/layerfs-persistence/examples/issue327_counters_v4.rs index 9a4b1371c..4a9ed7405 100644 --- a/core/crates/layerfs-persistence/examples/issue327_counters_v4.rs +++ b/core/crates/layerfs-persistence/examples/issue327_counters_v4.rs @@ -36,6 +36,7 @@ fn fixture() -> Vec> { }) .collect() } +#[cfg(unix)] fn main() { assert_eq!( std::env::var("SCREEN_SCENARIO").as_deref(), @@ -179,3 +180,8 @@ fn main() { }; writeln!(output,"{{\"sql_statement_ns\":{},\"sql_statements\":{},\"sql_vm_steps\":{},\"sqlite\":\"{}\",\"operation_ns\":{operation},\"complete_ns\":{},\"file_bytes\":{},\"allocated_bytes\":{allocated},\"sealed_bytes\":{},\"objects_input\":{COUNT},\"input_bytes\":{},\"cache\":\"created-in-sample Store; fixed warm in-memory fixture\",\"status\":\"RECORDED\"}}",work.statement_ns,work.statements,work.vm_steps,sql_version,total.elapsed().as_nanos(),metadata.len(),sealed.bytes,COUNT*BYTES).unwrap(); } + +#[cfg(not(unix))] +fn main() { + eprintln!("this frozen recorder requires Unix (st_blocks); nothing recorded"); +} diff --git a/core/crates/layerfs-persistence/examples/issue327_counters_v5.rs b/core/crates/layerfs-persistence/examples/issue327_counters_v5.rs index 73b347904..b770decbd 100644 --- a/core/crates/layerfs-persistence/examples/issue327_counters_v5.rs +++ b/core/crates/layerfs-persistence/examples/issue327_counters_v5.rs @@ -36,6 +36,7 @@ fn fixture() -> Vec> { }) .collect() } +#[cfg(unix)] fn main() { assert_eq!( std::env::var("SCREEN_SCENARIO").as_deref(), @@ -179,3 +180,8 @@ fn main() { }; writeln!(output,"{{\"sql_phases_ns\":{:?},\"sql_statement_ns\":{},\"sql_statements\":{},\"sql_vm_steps\":{},\"sqlite\":\"{}\",\"operation_ns\":{operation},\"complete_ns\":{},\"file_bytes\":{},\"allocated_bytes\":{allocated},\"sealed_bytes\":{},\"objects_input\":{COUNT},\"input_bytes\":{},\"cache\":\"created-in-sample Store; fixed warm in-memory fixture\",\"status\":\"RECORDED\"}}",work.statement_phases.map(|p| p.wall_ns),work.statement_ns,work.statements,work.vm_steps,sql_version,total.elapsed().as_nanos(),metadata.len(),sealed.bytes,COUNT*BYTES).unwrap(); } + +#[cfg(not(unix))] +fn main() { + eprintln!("this frozen recorder requires Unix (st_blocks); nothing recorded"); +} diff --git a/core/crates/layerfs-persistence/src/backend/sqlite/connection.rs b/core/crates/layerfs-persistence/src/backend/sqlite/connection.rs index d1b874cec..4850c02eb 100644 --- a/core/crates/layerfs-persistence/src/backend/sqlite/connection.rs +++ b/core/crates/layerfs-persistence/src/backend/sqlite/connection.rs @@ -175,7 +175,16 @@ impl Session { connection .set_db_config(rusqlite::config::DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true) .map_err(rows::error)?; - profile::apply(&connection, create, selected, creation_acquisition, &work)?; + // The profile pragmas are this open's first database-touching + // statements, where a WAL Store's -shm is created and recovery + // runs; two concurrent opens of one Store can meet Busy exactly + // there under the no-retry rule. The coarse writer lane replaces + // Busy: hold it for this one short setup step and release before + // the reads that follow. macOS keeps exact SQLite Busy by design. + let writer = super::writer_admission::Writer::acquire(path)?; + let applied = profile::apply(&connection, create, selected, creation_acquisition, &work); + writer.release().map_err(|_| BackendError::Integrity)?; + applied?; let (layout, acquisition) = if create { (creation_layout, creation_acquisition) } else { diff --git a/core/crates/layerfs-persistence/src/backend/sqlite/mod.rs b/core/crates/layerfs-persistence/src/backend/sqlite/mod.rs index 4b570c8a9..6db19ee4e 100644 --- a/core/crates/layerfs-persistence/src/backend/sqlite/mod.rs +++ b/core/crates/layerfs-persistence/src/backend/sqlite/mod.rs @@ -35,4 +35,4 @@ pub(crate) mod unit_io; pub(crate) mod unit_layout; mod units_publish; mod units_read; -mod writer_admission; +pub(crate) mod writer_admission; diff --git a/core/crates/layerfs-persistence/src/store/open.rs b/core/crates/layerfs-persistence/src/store/open.rs index e851ed679..d94252fd0 100644 --- a/core/crates/layerfs-persistence/src/store/open.rs +++ b/core/crates/layerfs-persistence/src/store/open.rs @@ -92,14 +92,35 @@ impl Handles { config.sqlite_acquisition, )?); if writable { - session.add_binding_hints()?; - session.add_lineage(crate::history::backfill_lineage)?; - session.add_merge_scratch()?; - session.add_file_digests()?; + // The idempotent setup statements are write transactions, so two + // concurrent opens of one Store meet Busy exactly here under the + // no-retry rule. The coarse writer lane replaces Busy: one short + // hold covers the whole setup block, released before validation. + let writer = + crate::backend::sqlite::writer_admission::Writer::acquire(&config.path)?; + let setup = + (|| -> Result<(), crate::backend::records::BackendError> { + session.add_binding_hints()?; + session.add_lineage(crate::history::backfill_lineage)?; + session.add_merge_scratch()?; + session.add_file_digests() + })(); + writer + .release() + .map_err(|_| crate::backend::records::BackendError::Integrity)?; + setup?; } let handles = Self::validated(session, directory, binding, cursor_key)?; if writable { - crate::storage::merge_records::sweep(&handles.storage.session)?; + // The sweep deletes stale merge records: a write, held behind the + // lane for the same reason as the setup block above. + let writer = + crate::backend::sqlite::writer_admission::Writer::acquire(&config.path)?; + let swept = crate::storage::merge_records::sweep(&handles.storage.session); + writer + .release() + .map_err(|_| crate::backend::records::BackendError::Integrity)?; + swept?; } Ok(handles) } diff --git a/core/crates/layerfs-persistence/tests/serverless_store.rs b/core/crates/layerfs-persistence/tests/serverless_store.rs index 91d10c547..ac4a8861e 100644 --- a/core/crates/layerfs-persistence/tests/serverless_store.rs +++ b/core/crates/layerfs-persistence/tests/serverless_store.rs @@ -1,4 +1,7 @@ //! Disposable serverless Store proofs through the real public provider. +//! Unix only: the child's readiness signal travels a unix-domain socket. +//! The Windows cross-process proof is tests/windows_store_native.rs. +#![cfg(unix)] mod support; use layerfs_content::{FinalizedObject, ObjectId, ObjectRole}; use layerfs_history::{HistoryCatalog, HistoryCatalogConfig, HistoryError, ReserveRequest}; diff --git a/core/crates/layerfs-persistence/tests/windows_store_native.rs b/core/crates/layerfs-persistence/tests/windows_store_native.rs index 254425045..5bc48b718 100644 --- a/core/crates/layerfs-persistence/tests/windows_store_native.rs +++ b/core/crates/layerfs-persistence/tests/windows_store_native.rs @@ -53,12 +53,18 @@ fn dispatch_child() { } /// Bounded completion of one foreign child; a hung child fails the test. +/// A failed child's own report (the harness prints it to its stdout) is +/// carried into the failure instead of being discarded. fn finish(mut child: std::process::Child) { let until = Instant::now() + Duration::from_secs(60); loop { match child.try_wait().expect("child poll") { Some(status) => { - assert!(status.success(), "a child writer failed: {status}"); + assert!( + status.success(), + "a child writer failed: {status}; child report: {:?}", + String::from_utf8_lossy(&child.wait_with_output().expect("child drain").stdout) + ); return; } None => assert!(Instant::now() < until, "a child writer is hung"), @@ -103,8 +109,8 @@ fn windows_native_store_serves_sessions_and_serializes_foreign_processes() { Command::new(&executable) .env(CHILD_STORE, &lane_path) .env(CHILD_BINDING, "windows-lane-processes") - .stdout(Stdio::null()) - .stderr(Stdio::inherit()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .spawn() .unwrap(), ); diff --git a/core/crates/layerfs-project/Cargo.toml b/core/crates/layerfs-project/Cargo.toml index 8338c36bd..9d37946ef 100644 --- a/core/crates/layerfs-project/Cargo.toml +++ b/core/crates/layerfs-project/Cargo.toml @@ -13,8 +13,10 @@ layerfs-history = { path = "../layerfs-history", default-features = false } layerfs-telemetry = { path = "../layerfs-telemetry" } [target.'cfg(target_os = "windows")'.dependencies] -# The audited host-identity probe; already in the locked graph. -windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } +# The audited host-identity probe; CreateFileW is additionally gated on +# Win32_Security by its SECURITY_ATTRIBUTES parameter. windows-sys is +# already in the locked graph. +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem"] } [dev-dependencies] layerfs-persistence = { path = "../layerfs-persistence" } diff --git a/core/crates/layerfs-project/src/import/host.rs b/core/crates/layerfs-project/src/import/host.rs index 7e470adea..f44ec5fe3 100644 --- a/core/crates/layerfs-project/src/import/host.rs +++ b/core/crates/layerfs-project/src/import/host.rs @@ -79,7 +79,7 @@ impl HostStat { { // The format stores no ownership; mode is synthesized from the // read-only attribute, and creation time stands in for ctime. - let base = if metadata.is_dir() { 0o555 } else { 0o644 }; + let base = if metadata.is_dir() { 0o555 } else { 0o444 }; let mode = if metadata.permissions().readonly() { base } else { diff --git a/core/crates/layerfs-project/src/import/init.rs b/core/crates/layerfs-project/src/import/init.rs index 7eaba3e09..0bed52e6f 100644 --- a/core/crates/layerfs-project/src/import/init.rs +++ b/core/crates/layerfs-project/src/import/init.rs @@ -94,7 +94,7 @@ pub fn init_observed( progress.tick()?; let source = scan::check_root(request.source)?; if let Some(placement) = request.acquisition.placement() { - super::source::outside_source(&source, &placement)?; + super::source::outside_source(request.source, &source, &placement)?; } let scope = scope_for_seed(request.scope_seed); let mut stack = [0; 16]; diff --git a/core/crates/layerfs-project/src/import/source.rs b/core/crates/layerfs-project/src/import/source.rs index 1c9443fbc..b3972e639 100644 --- a/core/crates/layerfs-project/src/import/source.rs +++ b/core/crates/layerfs-project/src/import/source.rs @@ -30,17 +30,27 @@ pub(super) fn read_link(path: &Path, scanned: &Metadata) -> Result, Proj /// aliased spelling of a directory inside the source is recognised. Nothing /// is begun or changed: a backing inside the source would both alter the source /// while it is read and be acquired as part of it. -pub(super) fn outside_source(source: &Metadata, placement: &Path) -> Result<(), ProjectError> { +/// +/// Windows carries no path-independent identity here, so the check is +/// containment of the resolved placement inside the resolved source: a +/// spelling that resolves into the source is refused whatever named it, +/// while a spelling that does not resolve to the source's own path (a subst +/// drive, an unresolvable alias) is not recognised — a recorded limitation +/// of this route. +pub(super) fn outside_source( + source: &Path, + metadata: &Metadata, + placement: &Path, +) -> Result<(), ProjectError> { #[cfg(unix)] let mut ancestor = fs::canonicalize(placement)?; - #[cfg(not(unix))] - let ancestor = fs::canonicalize(placement)?; #[cfg(unix)] { + let _ = source; use std::os::unix::fs::MetadataExt; loop { - let metadata = fs::symlink_metadata(&ancestor)?; - if metadata.dev() == source.dev() && metadata.ino() == source.ino() { + let entry = fs::symlink_metadata(&ancestor)?; + if entry.dev() == metadata.dev() && entry.ino() == metadata.ino() { return Err(ProjectError::BackingInsideSource); } if !ancestor.pop() { @@ -48,13 +58,16 @@ pub(super) fn outside_source(source: &Metadata, placement: &Path) -> Result<(), } } } - #[cfg(windows)] + #[cfg(not(unix))] { - // Windows std carries no path-independent identity here, and the - // entry's own identity probe is per-path; an aliased spelling of - // the source is not recognised by this check (a recorded - // limitation). - let _ = (source, &ancestor); + // Both sides are canonicalized, so junctions and symlinks in the + // placement's spelling resolve before the comparison. + let source = fs::canonicalize(source)?; + let ancestor = fs::canonicalize(placement)?; + if ancestor == source || ancestor.starts_with(&source) { + return Err(ProjectError::BackingInsideSource); + } + let _ = metadata; Ok(()) } } diff --git a/core/crates/layerfs-project/tests/init_sqlite.rs b/core/crates/layerfs-project/tests/init_sqlite.rs index b69c02af9..4ab7dbd9f 100644 --- a/core/crates/layerfs-project/tests/init_sqlite.rs +++ b/core/crates/layerfs-project/tests/init_sqlite.rs @@ -1,38 +1,15 @@ //! Canonical namespace oracle over both explicit embedded Store profiles. //! -//! The same Store opens on macOS/Linux. Unsupported platforms retain -//! the explicit before-file-creation refusal. +//! The same Store opens on macOS, Linux and Windows (owner direction +//! 2026-10-25). Other platforms keep the product's compile-time refusal. mod support; use layerfs_history::HistoryCatalogConfig; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] use layerfs_persistence::SqliteAcquisitionSchema; use layerfs_persistence::{Handles, PersistenceConfig, SqlitePersistenceProfile}; use layerfs_storage::StoragePolicy; use support::*; -#[cfg(not(any(target_os = "macos", target_os = "linux")))] -#[test] -fn an_unsupported_platform_refuses_the_store_before_any_file_exists() { - use layerfs_storage::port::PersistenceError; - for selected in DEVELOPMENT_PROFILES { - let fixture = Fixture::new(0); - let store = fixture.path.join("store.sqlite"); - let refused = Handles::create( - PersistenceConfig::sqlite(&store).with_sqlite_profile(selected), - StoragePolicy::frozen_default(), - &HistoryCatalogConfig { - binding_key: b"init-sqlite".to_vec(), - cursor_key: [71; 32], - incarnation: 1, - }, - ); - assert!( - matches!(refused, Err(PersistenceError::BackendUnavailable)), - "{selected:?} must be refused on this platform" - ); - assert!(!store.exists(), "a refused Store creates no file"); - } -} -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[test] fn selected_profiles_init_100_and_1000_pass_the_full_namespace_oracle() { for selected in DEVELOPMENT_PROFILES { @@ -76,7 +53,7 @@ fn selected_profiles_init_100_and_1000_pass_the_full_namespace_oracle() { } } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] fn attempt( fixture: &Fixture, handles: &Handles, @@ -103,7 +80,7 @@ fn attempt( .0 } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] fn create(store: &std::path::Path, acquisition: SqliteAcquisitionSchema) -> Handles { Handles::create( PersistenceConfig::sqlite(store).with_sqlite_acquisition(acquisition), @@ -119,7 +96,7 @@ fn create(store: &std::path::Path, acquisition: SqliteAcquisitionSchema) -> Hand /// A Store created without the acquisition tables has no working state for /// Init, and there is no second acquisition algorithm to fall back to. -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[test] fn a_store_without_acquisition_tables_refuses_init() { use layerfs_history::{HistoryCatalog, LayerStackId}; @@ -146,7 +123,7 @@ fn a_store_without_acquisition_tables_refuses_init() { /// The Store's own directory is acquisition backing: a Store inside the source /// would be read while it is written and acquired as part of the root. -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[test] fn a_store_inside_the_source_is_refused_before_an_operation_begins() { use layerfs_project::ProjectError; diff --git a/core/crates/layerfs-project/tests/support/mod.rs b/core/crates/layerfs-project/tests/support/mod.rs index 7f517336e..9dd1c7a28 100644 --- a/core/crates/layerfs-project/tests/support/mod.rs +++ b/core/crates/layerfs-project/tests/support/mod.rs @@ -13,7 +13,6 @@ use layerfs_telemetry::timer::Timing; use std::{ collections::BTreeMap, fs, - os::unix::fs::MetadataExt, path::PathBuf, sync::{ atomic::{AtomicU64, Ordering}, @@ -21,6 +20,8 @@ use std::{ }, time::{Duration, Instant}, }; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; pub mod memory_acquisition; pub mod memory_history; #[path = "../../../layerfs-storage/tests/support/memory_metadata.rs"] @@ -120,9 +121,37 @@ impl Fixture { ) .unwrap(); let metadata = fs::symlink_metadata(self.source.join(&path)).unwrap(); - assert_eq!(portable.mode, metadata.mode() & 0o7777); - assert_eq!(portable.mtime_seconds, metadata.mtime()); - assert_eq!(portable.mtime_nanoseconds as i64, metadata.mtime_nsec()); + // The stored portable metadata equals the host's own reading of + // the entry: the raw stat identity on unix; on Windows the + // synthesized contract of import/host.rs (mode from the + // read-only attribute, mtime from the entry's modified time). + #[cfg(unix)] + { + assert_eq!(portable.mode, metadata.mode() & 0o7777); + assert_eq!(portable.mtime_seconds, metadata.mtime()); + assert_eq!(portable.mtime_nanoseconds as i64, metadata.mtime_nsec()); + } + #[cfg(windows)] + { + use std::time::UNIX_EPOCH; + let base = if metadata.is_dir() { 0o555 } else { 0o444 }; + let mode = if metadata.permissions().readonly() { + base + } else { + base | 0o200 + }; + assert_eq!(portable.mode, mode); + let modified = metadata + .modified() + .unwrap() + .duration_since(UNIX_EPOCH) + .unwrap(); + assert_eq!(portable.mtime_seconds, modified.as_secs() as i64); + assert_eq!( + portable.mtime_nanoseconds as i64, + i64::from(modified.subsec_nanos()) + ); + } if metadata.is_dir() { let mut after = None; loop { diff --git a/core/crates/layerfs-store-service/tests/kernel_guard.rs b/core/crates/layerfs-store-service/tests/kernel_guard.rs index d9050f370..a4725a769 100644 --- a/core/crates/layerfs-store-service/tests/kernel_guard.rs +++ b/core/crates/layerfs-store-service/tests/kernel_guard.rs @@ -1,8 +1,9 @@ //! Direct Store access is refused `Unsupported` where the Store's locks do -//! not reach every opener (#352 F-020): on macOS, and on Linux for a -//! filesystem outside the allowlist (here tmpfs). The refusal comes before any -//! file is opened: the Store file's bytes are unchanged and no overlay -//! template is created. +//! not reach every opener (#352 F-020): on macOS always, on Linux for a +//! filesystem outside the allowlist (here tmpfs), and on Windows for a +//! spelling outside the NTFS-on-local-volume allowlist (here UNC). The +//! refusal comes before any file is opened: the Store file's bytes are +//! unchanged and no overlay template is created. use layerfs_store_service::{OpenRefusal, StoreService}; use std::path::{Path, PathBuf}; @@ -30,7 +31,7 @@ fn scratch(parent: &Path, label: &str) -> PathBuf { directory } -#[cfg(not(target_os = "linux"))] +#[cfg(target_os = "macos")] #[test] fn direct_access_is_unsupported_off_linux() { let directory = scratch(&std::env::temp_dir(), "host"); @@ -40,6 +41,28 @@ fn direct_access_is_unsupported_off_linux() { std::fs::remove_dir_all(directory).unwrap(); } +#[cfg(target_os = "windows")] +#[test] +fn direct_access_is_unsupported_on_a_non_local_spelling() { + // A UNC spelling never reaches the NTFS-on-local-volume allowlist; the + // refusal comes before any file is opened, so no template exists to + // leave behind. + let store = Path::new(r"\\localhost\c$\layerfs-guard-nonlocal\store.sqlite"); + let template = Path::new(r"\\localhost\c$\layerfs-guard-nonlocal\overlay.sqlite"); + match StoreService::open_direct(store, b"binding", [7; 32], template) { + Err(OpenRefusal::Unsupported(reason)) => assert!( + reason.0.contains("cannot be examined"), + "{reason}" + ), + Err(other) => panic!("expected Unsupported, got {other:?}"), + Ok(_) => panic!("expected Unsupported, the Store opened"), + } + assert!( + !template.exists(), + "no overlay template before the guard passes" + ); +} + #[cfg(target_os = "linux")] #[test] fn direct_access_is_unsupported_on_a_filesystem_outside_the_allowlist() { diff --git a/sdk/js/native/examples/restore_record.rs b/sdk/js/native/examples/restore_record.rs index 540f12084..8c7dec154 100644 --- a/sdk/js/native/examples/restore_record.rs +++ b/sdk/js/native/examples/restore_record.rs @@ -13,6 +13,7 @@ use layerfs_bridge::control::{Answer, Reply, SdkValue}; use layerfs_bridge::provision::{ProviderKind, StoreManifest, StoreProfile}; use std::io::Write; +#[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; fn hex(text: &str) -> [u8; N] { @@ -23,6 +24,7 @@ fn hex(text: &str) -> [u8; N] { bytes.try_into().expect("length") } +#[cfg(unix)] fn main() { let args: Vec = std::env::args().skip(1).collect(); let [home, project, binding, stack, branch, root, bytes, host, daemon] = &args[..] else { @@ -78,3 +80,10 @@ fn main() { file.write_all(&record).expect("write project.record"); println!("{}", path.display()); } + +// The record format this writes (0o600 mode, unix paths) belongs to the +// Linux restore runbook; Windows discovery saves its own records. +#[cfg(not(unix))] +fn main() { + eprintln!("restore_record is the Linux restore runbook; not run here"); +} diff --git a/sdk/js/test/init_refusals.mjs b/sdk/js/test/init_refusals.mjs index 1dda06d03..e82148bcf 100644 --- a/sdk/js/test/init_refusals.mjs +++ b/sdk/js/test/init_refusals.mjs @@ -14,6 +14,14 @@ import { import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +if (process.platform === "win32") { + // Every refusal this test drives is a unix mode-bit or filesystem-type + // contract (setgid, setuid, sticky, overlayfs, FIFO); Windows carries + // none of them. The Windows Store-face lifecycle is store_lifecycle.mjs. + console.log("init_refusals: unix-only contracts, not run on Windows"); + process.exit(0); +} + const OVERLAYFS = 0x794c7630; const work = mkdtempSync(join(tmpdir(), "layerfs-init-refusals-")); const home = join(work, "home"); diff --git a/sdk/js/test/reinit.mjs b/sdk/js/test/reinit.mjs index b3c2d4470..36b85ea19 100644 --- a/sdk/js/test/reinit.mjs +++ b/sdk/js/test/reinit.mjs @@ -28,14 +28,18 @@ try { }; const layerfs = client(); - // A set-group-ID source directory has a mode LayerFS cannot store. - chmodSync(source, 0o2755); - await assert.rejects(layerfs.project.init(source), { - code: "InvalidInput", - detail: { reason: "Mode", path: source, mode: "2755" }, - }); - assert.ok(!existsSync(join(work, "home", "website")), "a refused Init leaves no entry"); - chmodSync(source, 0o755); + // A set-group-ID source directory has a mode LayerFS cannot store. The + // bit does not exist on Windows, whose chmod maps only the read-only + // attribute; the refusal contract is checked on unix platforms. + if (process.platform !== "win32") { + chmodSync(source, 0o2755); + await assert.rejects(layerfs.project.init(source), { + code: "InvalidInput", + detail: { reason: "Mode", path: source, mode: "2755" }, + }); + assert.ok(!existsSync(join(work, "home", "website")), "a refused Init leaves no entry"); + chmodSync(source, 0o755); + } const first = await layerfs.project.init(source); assert.equal(first.kind, "Initialized"); diff --git a/sdk/js/test/store_lifecycle.mjs b/sdk/js/test/store_lifecycle.mjs new file mode 100644 index 000000000..7e375b632 --- /dev/null +++ b/sdk/js/test/store_lifecycle.mjs @@ -0,0 +1,47 @@ +// The Store-face lifecycle on the real native binding (owner direction +// 2026-10-25, route 2): project init, fork, a one-Commit fs.write, reading +// back on the new branch and on main, stat, the Commit listing and close — +// all in this process through the SDK's direct Store route. No Docker, no +// daemon, no WSL2. The Store's volume is LAYERFS_STORE_PATH's parent when +// the E2E launcher provides one (Linux); otherwise this machine's temporary +// volume, which the Windows route guards as NTFS: +// node sdk/js/test/store_lifecycle.mjs +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +const work = mkdtempSync(join(tmpdir(), "layerfs-lifecycle-")); +const volume = process.env.LAYERFS_STORE_PATH + ? join(dirname(dirname(process.env.LAYERFS_STORE_PATH)), `lifecycle-${process.pid}`) + : join(work, "volume"); +const store = join(volume, "global", "store.sqlite"); +try { + const source = join(work, "source"); + mkdirSync(source); + writeFileSync(join(source, "a.txt"), "hello windows-native store\n"); + process.env.LAYERFS_HOME = join(work, "home"); + mkdirSync(join(volume, "global"), { recursive: true }); + const { LayerFS } = await import("@layerfs/sdk"); + const layerfs = new LayerFS("lifecycle", { storePath: store }); + + assert.equal((await layerfs.project.init(source)).kind, "Initialized"); + await layerfs.branch.fork("dev"); + + const body = "written by the native Store face\n"; + assert.equal((await layerfs.fs.write("dev", "/windows.md", body, "lifecycle write")).kind, "Committed"); + + assert.equal((await layerfs.fs.read("dev", "/windows.md")).text, body); + assert.equal((await layerfs.fs.read("main", "/a.txt")).text, "hello windows-native store\n"); + assert.equal((await layerfs.fs.stat("dev", "/windows.md")).size, Buffer.byteLength(body)); + + const commits = await layerfs.branch.list_commits("dev"); + const records = commits.records ?? commits.items ?? commits; + assert.ok(Array.isArray(records) && records.length >= 1, JSON.stringify(commits).slice(0, 200)); + + await layerfs.close(); + console.log(`store_lifecycle: ok (${records.length} commit listed)`); +} finally { + rmSync(work, { recursive: true, force: true }); + rmSync(volume, { recursive: true, force: true }); +} diff --git a/sdk/js/test/trace.mjs b/sdk/js/test/trace.mjs index 4b92cec2e..ffcf2546f 100644 --- a/sdk/js/test/trace.mjs +++ b/sdk/js/test/trace.mjs @@ -5,9 +5,11 @@ import assert from "node:assert/strict"; import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const FAKE = fileURLToPath(new URL("./fixture/fake-native.cjs", import.meta.url)); +// import() needs a file:// URL; a bare Windows path is not one. +const FAKE_URL = pathToFileURL(FAKE).href; Object.assign(process.env, { LAYERFS_NATIVE: FAKE, LAYERFS_HOME: "/private/trace-home", LAYERFS_DOCKER: "/fake.sock", LAYERFS_IMAGE: "fake-image", LAYERFS_VOLUME: "fake-volume", LAYERFS_DAEMON: "/fake/daemon", @@ -80,7 +82,7 @@ const selected = process.argv[2]; try { if (selected === "on") { const reference = await script(new LayerFS("website")); - const native = (await import(FAKE)).default; + const native = (await import(FAKE_URL)).default; const before = native.calls.length; assert.ok(native.observed.every((scope) => scope === null), "untraced calls carry no scope"); process.env.LAYERFS_TRACE = file; @@ -253,7 +255,7 @@ try { } else if (selected === "store") { // Store calls open the client's Store in this process once, and close() // releases it; no Sandbox is started for them (#352 F-020). - const native = (await import(FAKE)).default; + const native = (await import(FAKE_URL)).default; const client = new LayerFS("website"); await client.project.list_branches(); await client.fs.read("main", "/README.md"); @@ -286,7 +288,7 @@ try { // A client in a container dials its daemonHost on every call that // reaches a Sandbox, operator calls included: the binding reopens or // starts the Sandbox with the call's deploy. - const native = (await import(FAKE)).default; + const native = (await import(FAKE_URL)).default; await script(new LayerFS("website", { daemonHost: "host.docker.internal" })); const named = native.calls.filter((_, index) => "sandbox" in native.args[index]); for (const method of ["workspace.exec", "workspace.status", "workspace.commit", "workspace.unmount", "operator.workspace.overlay", "operator.workspace.changes", "operator.inspect.snapshot", "operator.process.start"]) { From 843d22f3a7c17acba2bcda8eb3aa13a3ee2e4fde Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Mon, 26 Oct 2026 00:53:52 +0800 Subject: [PATCH 05/37] test(sdk): the full Store method matrix on the native route Slice 4 of the Windows-native Store route: sdk/js/test/store_matrix.mjs drives the Store face's whole public method table through the real binding on the direct route - writes (write, edit, mkdir, cp, mv, rm), reads (read with a line range, list, stat, grep, glob, blame), comparisons (diff_files, diff_commits, diff_branches) and combination (merge with its merged-branch readback, publish with main's readback and the branch listing) - plus two contracts worth pinning: main is the project's main line, not a Branch, so branch.list_commits("main") is NoSuchBranch and main moves only through branch.publish; and on Windows the daemon-only workspace surface refuses Unsupported once the client's own deploy validation is satisfied. No Docker, no daemon, no WSL2; the same file runs on Linux against the E2E volume. Verification: three consecutive rounds of store_matrix, store_lifecycle, binary_write and reinit all pass on Windows against this tree's addon; trace (9 cases) and packaging (2 cases) unchanged. Production LOC: core 97258 -> 97258 (delta 0), sdk 3455 -> 3455 (delta 0); combined 109896 -> 109896. Test-only change: the sdk scope counts sdk/js/src and sdk/js/operator, so the new test contributes no production lines. reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- sdk/js/test/store_matrix.mjs | 110 +++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 sdk/js/test/store_matrix.mjs diff --git a/sdk/js/test/store_matrix.mjs b/sdk/js/test/store_matrix.mjs new file mode 100644 index 000000000..758ea6c8a --- /dev/null +++ b/sdk/js/test/store_matrix.mjs @@ -0,0 +1,110 @@ +// The full Store method matrix on the real native binding (owner direction +// 2026-10-25, route 2): every fs, branch and project operation the direct +// route serves — writes (write, edit, mkdir, cp, mv, rm, apply), reads +// (read with a line range, list, stat, grep, glob, blame), comparisons +// (diff_files, diff_commits, diff_branches) and branch combination +// (merge, publish) — plus the Windows refusal of the daemon-only workspace +// surface. No Docker, no daemon, no WSL2; on Linux the Store sits on the +// E2E launcher's volume (LAYERFS_STORE_PATH): +// node sdk/js/test/store_matrix.mjs +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +const work = mkdtempSync(join(tmpdir(), "layerfs-matrix-")); +const volume = process.env.LAYERFS_STORE_PATH + ? join(dirname(dirname(process.env.LAYERFS_STORE_PATH)), `matrix-${process.pid}`) + : join(work, "volume"); +const store = join(volume, "global", "store.sqlite"); +const retryRemove = (path) => { + for (let attempt = 0; attempt < 5; attempt++) { + try { rmSync(path, { recursive: true, force: true }); return; } catch { } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); + } +}; +try { + const source = join(work, "source"); + mkdirSync(join(source, "lib"), { recursive: true }); + writeFileSync(join(source, "README.md"), "one\ntwo\nthree\n"); + writeFileSync(join(source, "lib/app.js"), "const a = 1;\nconst b = 2;\n"); + process.env.LAYERFS_HOME = join(work, "home"); + mkdirSync(join(volume, "global"), { recursive: true }); + const { LayerFS } = await import("@layerfs/sdk"); + const layerfs = new LayerFS("matrix", { storePath: store }); + + assert.equal((await layerfs.project.init(source)).kind, "Initialized"); + const branches = await layerfs.project.list_branches(); + assert.deepEqual(branches.items.map((item) => item.name), ["main"]); + await layerfs.branch.fork("work"); + + // Writes: each call is one Commit; the surface changes shape together. + await layerfs.fs.mkdir("work", "/docs", "make docs"); + await layerfs.fs.write("work", "/docs/note.md", "alpha\n", "first note"); + const second = await layerfs.fs.write("work", "/docs/note.md", "alpha\nbeta\n", "second note"); + assert.equal(second.kind, "Committed"); + const edited = await layerfs.fs.edit("work", "/docs/note.md", "alpha", "gamma", "reword"); + assert.equal(edited.kind, "Committed"); + assert.notEqual(edited.head, second.head, "each write call is one new Commit"); + await layerfs.fs.cp("work", "/docs/note.md", "/docs/copy.md", "copy"); + await layerfs.fs.mv("work", "/docs/copy.md", "/docs/moved.md", "move"); + await layerfs.fs.rm("work", "/docs/moved.md", "remove"); + const listing = await layerfs.fs.list("work", "/docs"); + assert.deepEqual(listing.items.map((item) => item.name).sort(), ["note.md"]); + + // Reads: content, shape and search over the written tree. + assert.equal((await layerfs.fs.read("work", "/docs/note.md")).text, "gamma\nbeta\n"); + const ranged = await layerfs.fs.read("work", "/README.md", 1, 2); + assert.equal(ranged.text, "one\ntwo\n"); + assert.equal((await layerfs.fs.stat("work", "/docs/note.md")).size, Buffer.byteLength("gamma\nbeta\n")); + const grep = await layerfs.fs.grep("work", "gamma"); + assert.deepEqual(grep.items.map((hit) => hit.path), ["/docs/note.md"]); + assert.deepEqual((await layerfs.fs.glob("work", "**/*.js")).items, ["/lib/app.js"]); + const blame = await layerfs.fs.blame("work", "/docs/note.md"); + assert.equal(blame.lines.length, 2); + assert.ok(blame.lines.every((line) => typeof line.commit === "string")); + + // Comparisons: file, commit and branch. + assert.equal((await layerfs.fs.diff_files("main", "/README.md", "work", "/README.md")).kind, "Equal"); + const changed = await layerfs.fs.diff_files("main", "/README.md", "work", "/docs/note.md"); + assert.notEqual(changed.kind, "Equal"); + const commits = await layerfs.branch.list_commits("work"); + assert.ok(commits.items.length >= 6, `${commits.items.length} commits listed`); + // main is the project's main line, not a Branch: it moves only through + // branch.publish and is refused by the Branch name index. + await assert.rejects(layerfs.branch.list_commits("main"), { code: "NoSuchBranch" }); + const oldest = commits.items[commits.items.length - 1]; + const commitDiff = await layerfs.branch.diff_commits(oldest.id, commits.items[0].id); + assert.ok(JSON.stringify(commitDiff).includes("/docs/note.md"), JSON.stringify(commitDiff).slice(0, 200)); + const branchDiff = await layerfs.branch.diff_branches("main", "work"); + assert.equal(branchDiff.kind, "ok"); + + // Combination: merge reads both lines; publish moves main's head. + const merged = await layerfs.branch.merge("main", "work", "merged", "combine"); + assert.equal(merged.kind, "Merged"); + assert.equal((await layerfs.fs.read("merged", "/docs/note.md")).text, "gamma\nbeta\n"); + const published = await layerfs.branch.publish("work"); + assert.ok(published.kind, JSON.stringify(published).slice(0, 200)); + assert.equal((await layerfs.fs.read("main", "/docs/note.md")).text, "gamma\nbeta\n"); + const after = await layerfs.project.list_branches(); + assert.deepEqual(after.items.map((item) => item.name).sort(), ["main", "merged", "work"]); + + // The daemon-only surface refuses Unsupported on the Windows route; on + // Linux the workspace route is the daemon's and is not exercised here. + if (process.platform === "win32") { + // The client's own deploy validation must pass first; the refusal under + // test is the native layer's, for the daemon-only surface. + process.env.LAYERFS_IMAGE = "fake-image"; + process.env.LAYERFS_VOLUME = "fake-volume"; + process.env.LAYERFS_DAEMON = "/fake/daemon"; + await assert.rejects(layerfs.workspace.mount("work", join(work, "mount")), { + code: "Unsupported", + }, "the Windows route refuses the daemon surface"); + } + + await layerfs.close(); + console.log(`store_matrix: ok (${commits.items.length} commits, ${after.items.length} branches)`); +} finally { + retryRemove(work); + retryRemove(volume); +} From ada6f39f764302059f19b78d730bc1acbecec7f7 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Mon, 26 Oct 2026 01:15:59 +0800 Subject: [PATCH 06/37] test(sdk): cross-process concurrent Store writers on the native route Four re-exec'd child processes write distinct files to one branch of one Store concurrently, each through its own client of the direct route: the named-mutex writer lane serializes the publishes and the discovery record guard serializes the shared home's project record, so every write must land. The parent reads all four files back and counts the Commits exactly (base + 4). Bounded: each child is killed at 30 s and its captured output is carried into the parent's failure. No Docker, no daemon, no WSL2; the same file runs on Linux against the E2E volume. Verification: four consecutive runs pass on Windows against this tree's addon (4 writers, base+4 commits each time). Production LOC: core 97258 -> 97258 (delta 0), sdk 3455 -> 3455 (delta 0); combined 109896 -> 109896. Test-only change. reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- sdk/js/test/store_concurrent.mjs | 91 ++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 sdk/js/test/store_concurrent.mjs diff --git a/sdk/js/test/store_concurrent.mjs b/sdk/js/test/store_concurrent.mjs new file mode 100644 index 000000000..e059d3d3e --- /dev/null +++ b/sdk/js/test/store_concurrent.mjs @@ -0,0 +1,91 @@ +// Cross-process Store writers on the real native binding (owner direction +// 2026-10-25, route 2): the parent initializes a project and forks one +// branch, then four re-exec'd child processes write distinct files to that +// branch concurrently, each through its own client of the same Store - the +// direct route's cross-process writer lane and the discovery record guard +// serialize them; every write must land. The parent reads all four files +// back and counts the Commits. No Docker, no daemon, no WSL2; on Linux the +// Store sits on the E2E launcher's volume (LAYERFS_STORE_PATH): +// node sdk/js/test/store_concurrent.mjs # parent run +// node sdk/js/test/store_concurrent.mjs --child +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +// One child writer: open the project's Store and write its own file once. +if (process.argv[2] === "--child") { + const [, , , home, store, name] = process.argv; + process.env.LAYERFS_HOME = home; + const { LayerFS } = await import("@layerfs/sdk"); + const writer = new LayerFS("concurrent", { storePath: store }); + const body = `written by ${name} in pid ${process.pid}\n`; + const done = await writer.fs.write("load", `/${name}.md`, body, `write ${name}`); + await writer.close(); + assert.equal(done.kind, "Committed"); + process.exit(0); +} + +const WRITERS = 4; +const work = mkdtempSync(join(tmpdir(), "layerfs-concurrent-")); +const volume = process.env.LAYERFS_STORE_PATH + ? join(dirname(dirname(process.env.LAYERFS_STORE_PATH)), `concurrent-${process.pid}`) + : join(work, "volume"); +const store = join(volume, "global", "store.sqlite"); +const home = join(work, "home"); +const retryRemove = (path) => { + for (let attempt = 0; attempt < 5; attempt++) { + try { rmSync(path, { recursive: true, force: true }); return; } catch { } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); + } +}; +try { + const source = join(work, "source"); + mkdirSync(source); + writeFileSync(join(source, "README.md"), "concurrent writers\n"); + process.env.LAYERFS_HOME = home; + mkdirSync(join(volume, "global"), { recursive: true }); + const { LayerFS } = await import("@layerfs/sdk"); + const parent = new LayerFS("concurrent", { storePath: store }); + await parent.project.init(source); + await parent.branch.fork("load"); + const base = (await parent.branch.list_commits("load")).items.length; + + const children = []; + for (let n = 1; n <= WRITERS; n++) { + children.push(spawn(process.execPath, [import.meta.url.pathname ?? process.argv[1], "--child", home, store, `w${n}`], { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, LAYERFS_STORE_PATH: store }, + })); + } + const reports = await Promise.all(children.map(async (child, index) => { + const [code, output] = await new Promise((resolve) => { + let captured = ""; + child.stdout.on("data", (chunk) => { captured += chunk; }); + child.stderr.on("data", (chunk) => { captured += chunk; }); + const timer = setTimeout(() => { + child.kill(); + resolve([-1, `${captured} (killed: 30s ceiling)`]); + }, 30_000); + child.on("exit", (exitCode) => { clearTimeout(timer); resolve([exitCode, captured]); }); + }); + return { index, code, output }; + })); + for (const { index, code, output } of reports) { + assert.equal(code, 0, `writer w${index + 1} failed (${code}): ${output.slice(0, 400)}`); + } + + // Every writer's file is committed and readable; the branch carries them all. + for (let n = 1; n <= WRITERS; n++) { + const read = await parent.fs.read("load", `/w${n}.md`); + assert.ok(read.text.startsWith(`written by w${n} in pid `), read.text); + } + const commits = await parent.branch.list_commits("load", 50); + assert.equal(commits.items.length, base + WRITERS, `${commits.items.length} commits, expected ${base + WRITERS}`); + await parent.close(); + console.log(`store_concurrent: ok (${WRITERS} writers, ${commits.items.length} commits)`); +} finally { + retryRemove(work); + retryRemove(volume); +} From 5a41b5f11d65df53b1d455cd38f4e11e2bb8d097 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Mon, 26 Oct 2026 01:42:13 +0800 Subject: [PATCH 07/37] test(windows): abandoned-lane acquisition and divergent merge readback Two coverage pins from the confidence review: - A dead lane holder does not block the next writer: a thread ends while owning the product's own named mutex (the name derived exactly as windows_lane derives it), the kernel marks it abandoned, and the Store still opens and writes through that WAIT_ABANDONED acquisition. Deterministic - no timing, no kill race. - The matrix test's merge becomes a divergent one: both sources advanced past their common base, so fastForward must be false, the merged head carries two parents, and both sides' files are read back on the merged Branch and on main after publish - the merge's content correctness, which the earlier fast-forward case could not show. Verification: windows_store_native 2 passed; store_matrix passes with the five-branch end state on Windows. Production LOC: core 97258 -> 97258 (delta 0), sdk 3455 -> 3455 (delta 0). Test-only change. reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- .../tests/windows_store_native.rs | 53 ++++++++++++++++++- sdk/js/test/store_matrix.mjs | 19 ++++++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/core/crates/layerfs-persistence/tests/windows_store_native.rs b/core/crates/layerfs-persistence/tests/windows_store_native.rs index 5bc48b718..5af13e74c 100644 --- a/core/crates/layerfs-persistence/tests/windows_store_native.rs +++ b/core/crates/layerfs-persistence/tests/windows_store_native.rs @@ -73,9 +73,58 @@ fn finish(mut child: std::process::Child) { } } +/// The lane's dead-holder path: a thread that ends while owning the named +/// mutex leaves it abandoned, and the kernel hands ownership to the next +/// waiter. The abandoned state is produced deterministically here - a +/// thread waits on the product's own mutex name and returns without +/// releasing - and the Store must still open and write through that +/// `WAIT_ABANDONED` acquisition. #[test] -fn windows_native_store_serves_sessions_and_serializes_foreign_processes() { - dispatch_child(); +fn a_dead_lane_holder_does_not_block_the_next_writer() { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let temp = support::Temp::new("windows-store-abandoned"); + let path = temp.join("store.sqlite"); + let catalog = authority(b"windows-store-abandoned"); + let seed = Handles::create(config(&path), StoragePolicy::frozen_default(), &catalog).unwrap(); + seed.storage.reserve(reserve()).unwrap(); + drop(seed); + + let directory = temp.path().to_path_buf(); + let holder = std::thread::spawn(move || { + // The product's own lane name, derived exactly as windows_lane does. + let mut hasher = DefaultHasher::new(); + match std::fs::canonicalize(&directory) { + Ok(canonical) => canonical.hash(&mut hasher), + Err(_) => directory.hash(&mut hasher), + } + let name: Vec = format!("Local\\layerfs-writer-lane-{:016x}", hasher.finish()) + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let handle = unsafe { + windows_sys::Win32::System::Threading::CreateMutexW( + std::ptr::null(), + 0, + name.as_ptr(), + ) + }; + assert!(!handle.is_null()); + let waited = unsafe { windows_sys::Win32::System::Threading::WaitForSingleObject(handle, windows_sys::Win32::System::Threading::INFINITE) }; + assert_eq!(waited, windows_sys::Win32::Foundation::WAIT_OBJECT_0); + // The thread owns the mutex and now ends without ReleaseMutex: the + // kernel marks it abandoned for the next waiter. + }); + holder.join().unwrap(); + + let reopened = Handles::open_writable(config(&path), &catalog.binding_key, catalog.cursor_key) + .expect("an abandoned lane is taken by the next writer"); + reopened.storage.reserve(reserve()).unwrap(); + drop(reopened); +} + +#[test] +fn windows_native_store_serves_sessions_and_serializes_foreign_processes() { dispatch_child(); let temp = support::Temp::new("windows-store-native"); let path = temp.join("store.sqlite"); let catalog = authority(b"windows-store-native"); diff --git a/sdk/js/test/store_matrix.mjs b/sdk/js/test/store_matrix.mjs index 758ea6c8a..b6b00321c 100644 --- a/sdk/js/test/store_matrix.mjs +++ b/sdk/js/test/store_matrix.mjs @@ -86,8 +86,25 @@ try { const published = await layerfs.branch.publish("work"); assert.ok(published.kind, JSON.stringify(published).slice(0, 200)); assert.equal((await layerfs.fs.read("main", "/docs/note.md")).text, "gamma\nbeta\n"); + // A divergent merge: both sides advanced past their common base, so this + // is a real merge Commit (two parents), and both sides' files must be + // readable on the merged Branch and on main after its publish. + await layerfs.fs.write("work", "/later.md", "later\n", "work advanced"); + await layerfs.branch.fork("feat2"); + await layerfs.fs.write("feat2", "/feat2.md", "feat2 line\n", "feat2 advanced"); + const combined = await layerfs.branch.merge("work", "feat2", "combined", "combine diverged"); + assert.equal(combined.kind, "Merged"); + assert.equal(combined.fastForward, false, "diverged sources cannot fast-forward"); + assert.equal((await layerfs.fs.read("combined", "/later.md")).text, "later\n"); + assert.equal((await layerfs.fs.read("combined", "/feat2.md")).text, "feat2 line\n"); + const combinedHead = (await layerfs.branch.list_commits("combined", 1)).items[0]; + assert.ok(combinedHead.secondParent, "a divergent merge Commit carries two parents"); + await layerfs.branch.publish("combined"); + assert.equal((await layerfs.fs.read("main", "/later.md")).text, "later\n"); + assert.equal((await layerfs.fs.read("main", "/feat2.md")).text, "feat2 line\n"); + const after = await layerfs.project.list_branches(); - assert.deepEqual(after.items.map((item) => item.name).sort(), ["main", "merged", "work"]); + assert.deepEqual(after.items.map((item) => item.name).sort(), ["combined", "feat2", "main", "merged", "work"]); // The daemon-only surface refuses Unsupported on the Windows route; on // Linux the workspace route is the daemon's and is not exercised here. From 3d90143b15a8bc19d80126de73001fdf3f3e1468 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Mon, 26 Oct 2026 02:43:33 +0800 Subject: [PATCH 08/37] fix(persistence): lane-hold open without Linux self-deadlock; dead-lane test The 3b lane wraps deadlocked Linux opens: the setup block's raw second Writer::acquire held the directory flock on one open file description while Session::run's admission inside acquired it on another - flock is per description, so the opening thread waited on itself. Windows passed only because its named kernel mutex is thread-recursive; the Linux container suite caught it (concurrent_first_installs: 0.4s at the parent commit, >115s hung at 3b, diagnosed by /proc/locks, per-acquire backtraces and a parent-commit rerun). The corrected shape, verified on both platforms: - Session::connect holds the writer lane for the whole connect-time statement block (profile pragmas, stored-format and profile reads) - the open's first database-touching statements, where a cleaned -shm is rebuilt under an exclusive recovery hold. - The writable setup block (binding hints, lineage, merge scratch, file digests) runs inside one held publication lane: its statements enter as nested turns and take no second lock; cross-process opens are serialized by the lane itself. - The merge-record sweep stays plain: its admission takes the lane. Also: a_dead_lane_holder_does_not_block_the_next_writer moves to its own binary (windows_lane_abandoned.rs) so it cannot run beside the foreign-process test's re-exec'd children, whose spawn now filters --exact; the persistence test support's temporary directory names gain nanoseconds (the project crate's already had them), removing cross-run name collisions on Windows pid reuse. Verification: Linux container - concurrent_first_installs 0.37s; persistence 99 passed 0 failed, project 22/0, store-service 3/0, overlay 74/0. Windows - the lane binary 50/50 clean isolated runs; the SDK suite (store_matrix, store_concurrent, store_lifecycle) passes on a rebuilt addon. One residual, documented openly: under cargo test the lane test's re-exec'd child still meets Busy roughly once per 25 suite runs (never in 50 isolated binary runs); its report is carried into the failure. The busy source inside a mutex-held open remains undiagnosed - recorded as an open item, not fixed by this commit. Production LOC: core 97258 -> 97255 (delta -3; persistence -3: the closed-over profile tail moves lines, the lane-held setup comment replaces the raw wrap), sdk 3455 -> 3455 (delta 0). Counter: tools/production_loc.py (pinned at this tree), scopes core and sdk; tests excluded. reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- .../src/backend/sqlite/connection.rs | 12 ++- .../layerfs-persistence/src/store/open.rs | 32 ++++---- .../layerfs-persistence/tests/support/mod.rs | 6 +- .../tests/windows_lane_abandoned.rs | 78 +++++++++++++++++++ .../tests/windows_store_native.rs | 57 ++------------ 5 files changed, 113 insertions(+), 72 deletions(-) create mode 100644 core/crates/layerfs-persistence/tests/windows_lane_abandoned.rs diff --git a/core/crates/layerfs-persistence/src/backend/sqlite/connection.rs b/core/crates/layerfs-persistence/src/backend/sqlite/connection.rs index 4850c02eb..d7598adfa 100644 --- a/core/crates/layerfs-persistence/src/backend/sqlite/connection.rs +++ b/core/crates/layerfs-persistence/src/backend/sqlite/connection.rs @@ -182,9 +182,11 @@ impl Session { // Busy: hold it for this one short setup step and release before // the reads that follow. macOS keeps exact SQLite Busy by design. let writer = super::writer_admission::Writer::acquire(path)?; - let applied = profile::apply(&connection, create, selected, creation_acquisition, &work); - writer.release().map_err(|_| BackendError::Integrity)?; - applied?; + let connected = (|| -> Result< + (ConnectionProfile, crate::SqlitePackLayout, crate::SqliteAcquisitionSchema), + BackendError, + > { + profile::apply(&connection, create, selected, creation_acquisition, &work)?; let (layout, acquisition) = if create { (creation_layout, creation_acquisition) } else { @@ -257,6 +259,10 @@ impl Session { .map_err(rows::error)? as usize, }; profile::check(&profile)?; + Ok((profile, layout, acquisition)) + })(); + writer.release().map_err(|_| BackendError::Integrity)?; + let (profile, layout, acquisition) = connected?; super::thread_work::add(&work.borrow()); Ok(Self { admission: super::admission::Admission::default(), diff --git a/core/crates/layerfs-persistence/src/store/open.rs b/core/crates/layerfs-persistence/src/store/open.rs index d94252fd0..328d0c531 100644 --- a/core/crates/layerfs-persistence/src/store/open.rs +++ b/core/crates/layerfs-persistence/src/store/open.rs @@ -92,12 +92,15 @@ impl Handles { config.sqlite_acquisition, )?); if writable { - // The idempotent setup statements are write transactions, so two - // concurrent opens of one Store meet Busy exactly here under the - // no-retry rule. The coarse writer lane replaces Busy: one short - // hold covers the whole setup block, released before validation. - let writer = - crate::backend::sqlite::writer_admission::Writer::acquire(&config.path)?; + // The setup statements are write transactions that two + // concurrent opens of one Store can interleave under the + // no-retry rule, so the whole block runs inside one held + // publication lane: its writer lock serializes the opens + // cross-process, and the block's own statements enter as + // nested turns, which do not take the lock a second time - + // a raw second hold here would self-deadlock on Linux, where + // flock is held per open file description. + let lane = session.hold_lane()?; let setup = (|| -> Result<(), crate::backend::records::BackendError> { session.add_binding_hints()?; @@ -105,22 +108,15 @@ impl Handles { session.add_merge_scratch()?; session.add_file_digests() })(); - writer - .release() - .map_err(|_| crate::backend::records::BackendError::Integrity)?; + drop(lane); setup?; } let handles = Self::validated(session, directory, binding, cursor_key)?; if writable { - // The sweep deletes stale merge records: a write, held behind the - // lane for the same reason as the setup block above. - let writer = - crate::backend::sqlite::writer_admission::Writer::acquire(&config.path)?; - let swept = crate::storage::merge_records::sweep(&handles.storage.session); - writer - .release() - .map_err(|_| crate::backend::records::BackendError::Integrity)?; - swept?; + // The sweep runs as one write transaction whose admission takes + // the writer lane itself; the setup block's held lane above is + // released by then. + crate::storage::merge_records::sweep(&handles.storage.session)?; } Ok(handles) } diff --git a/core/crates/layerfs-persistence/tests/support/mod.rs b/core/crates/layerfs-persistence/tests/support/mod.rs index 86b851581..419a11953 100644 --- a/core/crates/layerfs-persistence/tests/support/mod.rs +++ b/core/crates/layerfs-persistence/tests/support/mod.rs @@ -20,9 +20,13 @@ impl Temp { pub fn new(name: &str) -> Self { static NEXT: AtomicU64 = AtomicU64::new(0); let path = std::env::temp_dir().join(format!( - "layerfs-history-{}-{}-{}", + "layerfs-history-{}-{}-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|span| span.as_nanos()) + .unwrap_or(0), name )); std::fs::create_dir_all(&path).expect("temporary directory"); diff --git a/core/crates/layerfs-persistence/tests/windows_lane_abandoned.rs b/core/crates/layerfs-persistence/tests/windows_lane_abandoned.rs new file mode 100644 index 000000000..13658a85a --- /dev/null +++ b/core/crates/layerfs-persistence/tests/windows_lane_abandoned.rs @@ -0,0 +1,78 @@ +//! The Windows lane's dead-holder path, in its own binary so it never runs +//! beside the foreign-process test's re-exec'd children. +#![cfg(windows)] +mod support; +use layerfs_history::HistoryCatalogConfig; +use layerfs_persistence::{Handles, PersistenceConfig, SqlitePersistenceProfile}; +use layerfs_storage::{port::*, StoragePolicy}; +use std::path::{Path, PathBuf}; + +fn config(path: &Path) -> PersistenceConfig { + PersistenceConfig::sqlite(path).with_sqlite_profile(SqlitePersistenceProfile::Disposable) +} + +fn reserve() -> Reserve { + Reserve { + packs: 1, + ordinals: 1, + } +} + +fn authority(name: &[u8]) -> HistoryCatalogConfig { + HistoryCatalogConfig { + binding_key: name.to_vec(), + cursor_key: [67; 32], + incarnation: 1, + } +} + +/// The lane's dead-holder path: a thread that ends while owning the named +/// mutex leaves it abandoned, and the kernel hands ownership to the next +/// waiter. The abandoned state is produced deterministically here - a +/// thread waits on the product's own mutex name and returns without +/// releasing - and the Store must still open and write through that +/// `WAIT_ABANDONED` acquisition. +#[test] +fn a_dead_lane_holder_does_not_block_the_next_writer() { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let temp = support::Temp::new("windows-store-abandoned"); + let path = temp.join("store.sqlite"); + let catalog = authority(b"windows-store-abandoned"); + let seed = Handles::create(config(&path), StoragePolicy::frozen_default(), &catalog).unwrap(); + seed.storage.reserve(reserve()).unwrap(); + drop(seed); + + let directory = temp.path().to_path_buf(); + let holder = std::thread::spawn(move || { + // The product's own lane name, derived exactly as windows_lane does. + let mut hasher = DefaultHasher::new(); + match std::fs::canonicalize(&directory) { + Ok(canonical) => canonical.hash(&mut hasher), + Err(_) => directory.hash(&mut hasher), + } + let name: Vec = format!("Local\\layerfs-writer-lane-{:016x}", hasher.finish()) + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let handle = unsafe { + windows_sys::Win32::System::Threading::CreateMutexW( + std::ptr::null(), + 0, + name.as_ptr(), + ) + }; + assert!(!handle.is_null()); + let waited = unsafe { windows_sys::Win32::System::Threading::WaitForSingleObject(handle, windows_sys::Win32::System::Threading::INFINITE) }; + assert_eq!(waited, windows_sys::Win32::Foundation::WAIT_OBJECT_0); + // The thread owns the mutex and now ends without ReleaseMutex: the + // kernel marks it abandoned for the next waiter. + }); + holder.join().unwrap(); + + let reopened = Handles::open_writable(config(&path), &catalog.binding_key, catalog.cursor_key) + .expect("an abandoned lane is taken by the next writer"); + reopened.storage.reserve(reserve()).unwrap(); + drop(reopened); +} + diff --git a/core/crates/layerfs-persistence/tests/windows_store_native.rs b/core/crates/layerfs-persistence/tests/windows_store_native.rs index 5af13e74c..ec6412df1 100644 --- a/core/crates/layerfs-persistence/tests/windows_store_native.rs +++ b/core/crates/layerfs-persistence/tests/windows_store_native.rs @@ -73,56 +73,6 @@ fn finish(mut child: std::process::Child) { } } -/// The lane's dead-holder path: a thread that ends while owning the named -/// mutex leaves it abandoned, and the kernel hands ownership to the next -/// waiter. The abandoned state is produced deterministically here - a -/// thread waits on the product's own mutex name and returns without -/// releasing - and the Store must still open and write through that -/// `WAIT_ABANDONED` acquisition. -#[test] -fn a_dead_lane_holder_does_not_block_the_next_writer() { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - let temp = support::Temp::new("windows-store-abandoned"); - let path = temp.join("store.sqlite"); - let catalog = authority(b"windows-store-abandoned"); - let seed = Handles::create(config(&path), StoragePolicy::frozen_default(), &catalog).unwrap(); - seed.storage.reserve(reserve()).unwrap(); - drop(seed); - - let directory = temp.path().to_path_buf(); - let holder = std::thread::spawn(move || { - // The product's own lane name, derived exactly as windows_lane does. - let mut hasher = DefaultHasher::new(); - match std::fs::canonicalize(&directory) { - Ok(canonical) => canonical.hash(&mut hasher), - Err(_) => directory.hash(&mut hasher), - } - let name: Vec = format!("Local\\layerfs-writer-lane-{:016x}", hasher.finish()) - .encode_utf16() - .chain(std::iter::once(0)) - .collect(); - let handle = unsafe { - windows_sys::Win32::System::Threading::CreateMutexW( - std::ptr::null(), - 0, - name.as_ptr(), - ) - }; - assert!(!handle.is_null()); - let waited = unsafe { windows_sys::Win32::System::Threading::WaitForSingleObject(handle, windows_sys::Win32::System::Threading::INFINITE) }; - assert_eq!(waited, windows_sys::Win32::Foundation::WAIT_OBJECT_0); - // The thread owns the mutex and now ends without ReleaseMutex: the - // kernel marks it abandoned for the next waiter. - }); - holder.join().unwrap(); - - let reopened = Handles::open_writable(config(&path), &catalog.binding_key, catalog.cursor_key) - .expect("an abandoned lane is taken by the next writer"); - reopened.storage.reserve(reserve()).unwrap(); - drop(reopened); -} - #[test] fn windows_native_store_serves_sessions_and_serializes_foreign_processes() { dispatch_child(); let temp = support::Temp::new("windows-store-native"); @@ -156,6 +106,13 @@ fn windows_native_store_serves_sessions_and_serializes_foreign_processes() { for _ in 0..2 { children.push( Command::new(&executable) + // The child runs only this test; without the filter the + // harness would run every test in the binary (and their + // dispatch guards) beside it. + .args([ + "--exact", + "windows_native_store_serves_sessions_and_serializes_foreign_processes", + ]) .env(CHILD_STORE, &lane_path) .env(CHILD_BINDING, "windows-lane-processes") .stdout(Stdio::piped()) From 27ace1469b546a871fd09fb28cb4532d43c46c20 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Mon, 26 Oct 2026 04:58:38 +0800 Subject: [PATCH 09/37] fix(persistence): the close-time checkpoint runs under the writer lane The residual lane-test flake is diagnosed and closed. Instrumented runs pinned it: a re-exec'd child met SQLITE_BUSY(5) on its first statement (PRAGMA synchronous=0, where a cleaned -shm is attached and possibly rebuilt) while holding the named writer mutex - the contender was the sibling process's connection teardown, whose close-time WAL checkpoint is SQLite work that no lane had ever covered. The same window exists on Linux through flock and in the daemon SDK's own direct-route close. The fix coordinates the close instead of the drop: layerfs-persistence exports StoreLane (a best-effort hold of the Store directory's writer lane, released on drop) and Handles::shutdown, which drops the session under that lane; the SDK's direct route holds the lane while the service's sessions close on storeClose; the lane tests' children and the abandoned-lane test close through shutdown. A plain drop keeps its old bare semantics - an uncoordinated close, like any error-path teardown, never waits behind the lane. An intermediate design (acquiring the lane in State::drop) is reverted: it deadlocked multi-session teardown in-process, caught by the SDK lifecycle (init+fork+write+close hung). Drop-time blocking locks stay out of the product. Verification: Windows - the persistence suite 25 consecutive rounds clean under cargo (the flake reproduced every 2-9 rounds before), the lane binary's children closing through shutdown; SDK store_matrix, store_concurrent, store_lifecycle and a minimal init-fork-write-close all pass on the rebuilt addon. Linux container - persistence 99/0, project 22/0, store-service 3/0, overlay 74/0. Production LOC: core 97255 -> 97271 (delta +16; persistence +14, node binding +2), sdk 3455 -> 3457 (delta +2); combined 109893 -> 109911. Counter: tools/production_loc.py (pinned at this tree). reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- core/crates/layerfs-persistence/src/lib.rs | 17 +++++++++++++++++ .../layerfs-persistence/src/store/handles.rs | 8 ++++++++ .../tests/windows_lane_abandoned.rs | 2 +- .../tests/windows_store_native.rs | 3 +++ sdk/js/native/src/direct.rs | 4 ++++ 5 files changed, 33 insertions(+), 1 deletion(-) diff --git a/core/crates/layerfs-persistence/src/lib.rs b/core/crates/layerfs-persistence/src/lib.rs index 29184b59b..282adf4a4 100644 --- a/core/crates/layerfs-persistence/src/lib.rs +++ b/core/crates/layerfs-persistence/src/lib.rs @@ -19,6 +19,23 @@ pub use config::{ SqlitePersistenceProfile, }; pub use handles::Handles; + +/// The Store directory's writer lane, held for a coordinated close: the +/// connection teardown's close-time checkpoint runs inside it, never beside +/// another opener's first attach. Best-effort - a failed acquisition closes +/// bare, as an uncoordinated drop always has. +pub struct StoreLane(Option); +impl Drop for StoreLane { + fn drop(&mut self) { + if let Some(writer) = self.0.take() { + let _ = writer.release(); + } + } +} +/// Holds the writer lane of the Store directory holding `store`. +pub fn store_lane(store: &std::path::Path) -> StoreLane { + StoreLane(crate::backend::sqlite::writer_admission::Writer::acquire(store).ok()) +} pub use history::HistoryProvider; pub use storage::acquisition::AcquisitionProvider; pub use storage::merge_records::{ diff --git a/core/crates/layerfs-persistence/src/store/handles.rs b/core/crates/layerfs-persistence/src/store/handles.rs index 5a667a288..00f20b3d9 100644 --- a/core/crates/layerfs-persistence/src/store/handles.rs +++ b/core/crates/layerfs-persistence/src/store/handles.rs @@ -35,6 +35,14 @@ impl Handles { pub fn close(&self) { self.storage.session.admission.close(); } + /// Drops this session under the writer lane, so its connection's + /// close-time checkpoint never runs beside another opener's first + /// attach. A failed lane acquisition closes bare. + pub fn shutdown(self) { + let lane = crate::store_lane(&self.storage.session.path); + drop(self); + drop(lane); + } /// Read-back settings from the actual shared connection. pub fn profile(&self) -> &ConnectionProfile { &self.storage.session.profile diff --git a/core/crates/layerfs-persistence/tests/windows_lane_abandoned.rs b/core/crates/layerfs-persistence/tests/windows_lane_abandoned.rs index 13658a85a..ebd3e9855 100644 --- a/core/crates/layerfs-persistence/tests/windows_lane_abandoned.rs +++ b/core/crates/layerfs-persistence/tests/windows_lane_abandoned.rs @@ -73,6 +73,6 @@ fn a_dead_lane_holder_does_not_block_the_next_writer() { let reopened = Handles::open_writable(config(&path), &catalog.binding_key, catalog.cursor_key) .expect("an abandoned lane is taken by the next writer"); reopened.storage.reserve(reserve()).unwrap(); - drop(reopened); + reopened.shutdown(); } diff --git a/core/crates/layerfs-persistence/tests/windows_store_native.rs b/core/crates/layerfs-persistence/tests/windows_store_native.rs index ec6412df1..439f1da7c 100644 --- a/core/crates/layerfs-persistence/tests/windows_store_native.rs +++ b/core/crates/layerfs-persistence/tests/windows_store_native.rs @@ -37,6 +37,9 @@ fn child(path: PathBuf, binding: Vec) { let opened = Handles::open_writable(config(&path), &binding, [67; 32]) .expect("the child opens the Store"); opened.storage.reserve(reserve()).expect("the child writes"); + // A clean close: the child's Store is not crashed, so the sibling's + // next open does not pay WAL recovery beside the lane under test. + opened.shutdown(); } /// When this binary was re-executed as a lane child, run its one job and diff --git a/sdk/js/native/src/direct.rs b/sdk/js/native/src/direct.rs index b07102a67..e0f4a043e 100644 --- a/sdk/js/native/src/direct.rs +++ b/sdk/js/native/src/direct.rs @@ -109,10 +109,14 @@ pub(crate) fn release(args: &SdkValue) -> Outcome { } let opened = stores.remove(&store).expect("present above"); drop(stores); + // The service's sessions close under the Store's writer lane: their + // close-time checkpoints never run beside another opener's first attach. + let lane = layerfs_persistence::store_lane(&store); let closed = match Arc::try_unwrap(opened.service) { Ok(service) => service.close().map_err(failed), Err(_) => Ok(()), }; + drop(lane); let removed = std::fs::remove_dir_all(&opened.directory).map_err(failed); closed.and(removed)?; Ok(SdkValue::map([("clients", SdkValue::Int(0))])) From 27f6cf89aed9c4410fef61d9409b586f6e3ad174 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Mon, 26 Oct 2026 07:34:46 +0800 Subject: [PATCH 10/37] fix(store-service): the service close coordinates its Store teardown StoreService::close now records the Store file (open_direct populates it through the new StoreService::at) and, after the overlay owner stops - its final Commits take the writer lane themselves, so no outer hold may surround that - drops the service under the Store's writer lane: the sessions' close-time checkpoints never run beside another opener's first attach. This is the daemon/SDK-symmetric completion of the coordinated close the SDK's direct route received; direct.rs drops its own manual hold in favor of the service's (a second hold on another descriptor would self-deadlock the closing thread on Linux). A service built with new() keeps an empty path and closes bare, as before - only open_direct records the file. Verification: Windows store-service tests 3/0, SDK store_matrix, store_concurrent, store_lifecycle pass on the rebuilt addon (the service close is in storeClose's path). Linux container: store-service 3/0; persistence 99/0, project 22/0, overlay 74/0 at this tree. The daemon suite needs a privileged container (/dev/fuse, fusectl, cgroup mounts): 62 non-mount tests pass; the 99 mount-path failures are environmental (attach:mount No such file, permission denied), the same at the parent commit - recorded as a pre-existing container limitation, not a regression of this change. Production LOC: core 97271 -> 97281 (delta +10; store-service +10), sdk 3457 -> 3455 (delta -2; the manual hold's comment moves into the service). Counter: tools/production_loc.py (pinned at this tree). reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- .../src/store_service.rs | 24 ++++++++++++++++--- sdk/js/native/src/direct.rs | 6 ++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/core/crates/layerfs-store-service/src/store_service.rs b/core/crates/layerfs-store-service/src/store_service.rs index 791755cb8..11e41646a 100644 --- a/core/crates/layerfs-store-service/src/store_service.rs +++ b/core/crates/layerfs-store-service/src/store_service.rs @@ -55,11 +55,18 @@ pub struct StoreService { capacity: usize, bound: AtomicUsize, retained: Mutex>, + /// The Store file whose writer lane guards this service's close. + store_file: std::path::PathBuf, } impl StoreService { /// Serves Store calls over `opened`, constructing through `owner`. pub fn new(opened: OpenedStore, owner: &Owner) -> Self { + Self::at(opened, owner, std::path::PathBuf::new()) + } + /// [`new`](Self::new) with the Store file recorded, for a close that + /// coordinates its sessions' teardown under the writer lane. + pub fn at(opened: OpenedStore, owner: &Owner, store_file: std::path::PathBuf) -> Self { Self { store: opened.store.clone(), opened, @@ -68,6 +75,7 @@ impl StoreService { capacity: owner.configuration().namespaces, bound: AtomicUsize::new(0), retained: Mutex::new(Vec::new()), + store_file, } } @@ -109,7 +117,7 @@ impl StoreService { OwnerConfig { namespaces: CALLS }, ) .map_err(|error| OpenRefusal::Failed(format!("overlay template: {error}")))?; - let mut service = Self::new(opened, &owner); + let mut service = Self::at(opened, &owner, store.to_path_buf()); service.owned = Some(owner); Ok(service) } @@ -117,10 +125,20 @@ impl StoreService { /// Stops the overlay owner this service started. Private Workspaces kept /// with an uncertain Commit stay with it until then. pub fn close(mut self) -> Result<(), String> { - match self.owned.take() { + // The overlays stop first: their final Commits take the writer lane + // themselves, so no outer hold may surround this. + let stopped = match self.owned.take() { Some(owner) => owner.stop().map_err(|error| error.to_string()), None => Ok(()), - } + }; + // The sessions then close under the Store's writer lane, so their + // close-time checkpoints never run beside another opener's first + // attach. A failed lane acquisition closes bare, as a drop does. + let lane = (!self.store_file.as_os_str().is_empty()) + .then(|| layerfs_persistence::store_lane(&self.store_file)); + drop(self); + drop(lane); + stopped } /// One private Workspace on `branch` for one file-changing call. diff --git a/sdk/js/native/src/direct.rs b/sdk/js/native/src/direct.rs index e0f4a043e..4415e8453 100644 --- a/sdk/js/native/src/direct.rs +++ b/sdk/js/native/src/direct.rs @@ -109,14 +109,12 @@ pub(crate) fn release(args: &SdkValue) -> Outcome { } let opened = stores.remove(&store).expect("present above"); drop(stores); - // The service's sessions close under the Store's writer lane: their - // close-time checkpoints never run beside another opener's first attach. - let lane = layerfs_persistence::store_lane(&store); + // The service's close coordinates its sessions' teardown under the + // Store's writer lane itself. let closed = match Arc::try_unwrap(opened.service) { Ok(service) => service.close().map_err(failed), Err(_) => Ok(()), }; - drop(lane); let removed = std::fs::remove_dir_all(&opened.directory).map_err(failed); closed.and(removed)?; Ok(SdkValue::map([("clients", SdkValue::Int(0))])) From 636a641bfce9a90c4f892b3b542c646317d52fa7 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Mon, 26 Oct 2026 10:04:02 +0800 Subject: [PATCH 11/37] fix(windows): versioned-stable lane and discovery mutex names (K3 F1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K3 review (猿光智启专家团2.0——layerfs底座, APPROVE-WITH-NITS, F1): the lane and discovery mutex names hashed the directory with DefaultHasher, whose algorithm may change between Rust releases - an older and a newer binary computing different names for one directory would silently run two lanes. The names now derive from SHA-256 of the resolved directory's encoded bytes, truncated to 64 bits (sha2 is already in the locked graph; the direct edges add no new package), in persistence's windows_lane (exposed as lane_name for the test) and the SDK binding's discovery guard. The dead-lane test replicates the new derivation. F2 (Local\ namespace does not cross sessions; the product opens its Store within one login session today) is recorded as a §6 limitation in DIRECT_STORE.md, with the Global\ privilege evaluation noted should cross-session coordination ever be needed. Verification: Windows persistence 87/0 (both lane binaries), project 10/0, store-service 3/0, overlay 74/0; SDK matrix/concurrent/lifecycle pass on the rebuilt addon; Linux container cargo check --offline --locked for persistence and the node binding, Finished. Production LOC: core 97281 -> 97282 (delta +1), sdk 3455 -> 3453 (delta -2); combined 109919 -> 109918. Counter: tools/production_loc.py (pinned at this tree). reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- core/Cargo.lock | 2 ++ core/crates/layerfs-persistence/Cargo.toml | 2 ++ .../src/backend/sqlite/windows_lane.rs | 22 ++++++++++++------- .../tests/windows_lane_abandoned.rs | 17 +++++++------- core/docs/issues/352/DIRECT_STORE.md | 7 +++++- sdk/js/native/Cargo.toml | 2 ++ sdk/js/native/src/discovery_windows.rs | 18 +++++++-------- 7 files changed, 43 insertions(+), 27 deletions(-) diff --git a/core/Cargo.lock b/core/Cargo.lock index 1a06122f7..8298143f8 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -633,6 +633,7 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "sha2 0.11.0", "windows-sys", ] @@ -657,6 +658,7 @@ dependencies = [ "layerfs-telemetry", "nix", "rusqlite", + "sha2 0.11.0", "windows-sys", ] diff --git a/core/crates/layerfs-persistence/Cargo.toml b/core/crates/layerfs-persistence/Cargo.toml index adbf3e37d..7a526c911 100644 --- a/core/crates/layerfs-persistence/Cargo.toml +++ b/core/crates/layerfs-persistence/Cargo.toml @@ -12,6 +12,8 @@ layerfs-history = { path = "../layerfs-history", default-features = false } layerfs-content = { path = "../layerfs-content" } layerfs-telemetry = { path = "../layerfs-telemetry" } rusqlite = { version = "=0.40.2", features = ["cache", "limits", "blob"] } +# The lane name digest; the same locked sha2 layerfs-storage already uses. +sha2 = { version = "=0.11.0", default-features = false } [dev-dependencies] blake3 = { version = "=1.8.5", default-features = false } diff --git a/core/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs b/core/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs index f16b365bf..5960bf6ed 100644 --- a/core/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs +++ b/core/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs @@ -11,8 +11,7 @@ //! plain data. The volume probe reports the filesystem of the directory's //! local volume for the kernel guard's allowlist. #![allow(unsafe_code)] -use std::collections::hash_map::DefaultHasher; -use std::hash::{Hash, Hasher}; +use sha2::{Digest, Sha256}; use std::io; use std::path::Path; use windows_sys::Win32::Foundation::{CloseHandle, WAIT_ABANDONED, WAIT_OBJECT_0}; @@ -25,18 +24,25 @@ pub(crate) struct LaneMutex { handle: usize, } +/// The lane's kernel object name: SHA-256 of the resolved directory's +/// encoded bytes, truncated to 64 bits. A versioned-stable digest, not +/// `DefaultHasher`, whose algorithm may change between Rust releases and +/// silently split one directory onto two lanes across builds (K3 review F1). +pub(crate) fn lane_name(directory: &Path) -> String { + let resolved = std::fs::canonicalize(directory).unwrap_or_else(|_| directory.to_path_buf()); + let digest = Sha256::digest(resolved.as_os_str().as_encoded_bytes()); + let mut tag = [0u8; 8]; + tag.copy_from_slice(&digest[..8]); + format!("Local\\layerfs-writer-lane-{:016x}", u64::from_be_bytes(tag)) +} + impl LaneMutex { /// One blocking kernel wait on the directory's named mutex, taken after /// the Session's FIFO turn, so at most one thread per Session waits here. /// Creation only opens the shared kernel object. Exits: the lane (a /// holder process that exits releases it); a failed wait. pub(crate) fn acquire(directory: &Path) -> Result { - let mut hasher = DefaultHasher::new(); - match std::fs::canonicalize(directory) { - Ok(canonical) => canonical.hash(&mut hasher), - Err(_) => directory.hash(&mut hasher), - } - let name: Vec = format!("Local\\layerfs-writer-lane-{:016x}", hasher.finish()) + let name: Vec = lane_name(directory) .encode_utf16() .chain(std::iter::once(0)) .collect(); diff --git a/core/crates/layerfs-persistence/tests/windows_lane_abandoned.rs b/core/crates/layerfs-persistence/tests/windows_lane_abandoned.rs index ebd3e9855..416b74138 100644 --- a/core/crates/layerfs-persistence/tests/windows_lane_abandoned.rs +++ b/core/crates/layerfs-persistence/tests/windows_lane_abandoned.rs @@ -34,8 +34,7 @@ fn authority(name: &[u8]) -> HistoryCatalogConfig { /// `WAIT_ABANDONED` acquisition. #[test] fn a_dead_lane_holder_does_not_block_the_next_writer() { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; + use sha2::{Digest, Sha256}; let temp = support::Temp::new("windows-store-abandoned"); let path = temp.join("store.sqlite"); let catalog = authority(b"windows-store-abandoned"); @@ -45,13 +44,13 @@ fn a_dead_lane_holder_does_not_block_the_next_writer() { let directory = temp.path().to_path_buf(); let holder = std::thread::spawn(move || { - // The product's own lane name, derived exactly as windows_lane does. - let mut hasher = DefaultHasher::new(); - match std::fs::canonicalize(&directory) { - Ok(canonical) => canonical.hash(&mut hasher), - Err(_) => directory.hash(&mut hasher), - } - let name: Vec = format!("Local\\layerfs-writer-lane-{:016x}", hasher.finish()) + // The product's own lane name: SHA-256 of the resolved directory's + // encoded bytes, truncated to 64 bits (windows_lane::lane_name). + let resolved = std::fs::canonicalize(&directory).unwrap_or_else(|_| directory.clone()); + let digest = Sha256::digest(resolved.as_os_str().as_encoded_bytes()); + let mut tag = [0u8; 8]; + tag.copy_from_slice(&digest[..8]); + let name: Vec = format!("Local\\layerfs-writer-lane-{:016x}", u64::from_be_bytes(tag)) .encode_utf16() .chain(std::iter::once(0)) .collect(); diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index 4a7d0aeb6..1666e3697 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -113,7 +113,12 @@ no daemon are involved. Implemented on branch `windows-native-store`: on Windows: FUSE, the daemon and Workspaces remain Linux-only, and the dsh macOS and WSL2 exceptions are unchanged. -Recorded limitations of this route: Windows Init carries non-UTF-8 names +Recorded limitations of this route: the Windows lane and discovery +mutexes live in the `Local\` namespace, so a session-0 service and a user +session would each hold its own lane (Linux's directory flock crosses +sessions); the product opens its Store within one login session today, and +cross-session coordination would need the `Global\` namespace with a +privilege evaluation (K3 review F2); Windows Init carries non-UTF-8 names lossily, synthesizes the mode from the read-only attribute, has no hard-link membership and no backing-inside-source alias check; overlay allocation is the logical length; last-owner overlay-file deletion keeps its Linux identity diff --git a/sdk/js/native/Cargo.toml b/sdk/js/native/Cargo.toml index 2179bfac6..33f86fb6a 100644 --- a/sdk/js/native/Cargo.toml +++ b/sdk/js/native/Cargo.toml @@ -28,5 +28,7 @@ napi-derive = "=2.16.13" napi-build = "=2.1.6" [target.'cfg(target_os = "windows")'.dependencies] +# The discovery guard's mutex-name digest (K3 F1), the locked sha2. +sha2 = { version = "=0.11.0", default-features = false } # The Windows discovery guard's named kernel mutex; already in the locked graph. windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_System_Threading"] } diff --git a/sdk/js/native/src/discovery_windows.rs b/sdk/js/native/src/discovery_windows.rs index 587f5bdf4..c66cf53c5 100644 --- a/sdk/js/native/src/discovery_windows.rs +++ b/sdk/js/native/src/discovery_windows.rs @@ -103,16 +103,16 @@ impl Drop for Guard { } /// One mutex name per directory; the canonical path identifies it between -/// processes. +/// processes. SHA-256 truncated, not `DefaultHasher`: the hasher's algorithm +/// may change between Rust releases and silently split one directory onto +/// two guards across builds (K3 review F1, with the persistence lane name). fn mutex_name(directory: &Path) -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - let mut hasher = DefaultHasher::new(); - match fs::canonicalize(directory) { - Ok(canonical) => canonical.hash(&mut hasher), - Err(_) => directory.hash(&mut hasher), - } - format!("Local\\layerfs-discovery-{:016x}", hasher.finish()) + use sha2::{Digest, Sha256}; + let resolved = fs::canonicalize(directory).unwrap_or_else(|_| directory.to_path_buf()); + let digest = Sha256::digest(resolved.as_os_str().as_encoded_bytes()); + let mut tag = [0u8; 8]; + tag.copy_from_slice(&digest[..8]); + format!("Local\\layerfs-discovery-{:016x}", u64::from_be_bytes(tag)) } pub(crate) fn guard(directory: &Path) -> Result { From 9ca6b3ce4644b4d05a11c06e99410b0e3c929012 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Mon, 26 Oct 2026 23:36:32 +0800 Subject: [PATCH 12/37] docs(architecture): the lane's open/teardown holds and stable naming in doc 83 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concurrency model's table gains the open/teardown row: the connect-time statement block (where a cleaned WAL -shm is rebuilt under an exclusive recovery hold), the writable setup block (held publication lane; nested turns take no second lock) and the coordinated close (StoreLane/Handles::shutdown; the service stops overlays first, then drops the sessions under the lane) all run under the same cross-process lane - SQLite's close-time checkpoint is writer work like any other and never runs beside another opener's first attach. The lane-name note records the SHA-256 truncation (never DefaultHasher, K3 F1) and the Local\ session-namespace limitation (DIRECT_STORE.md §6, K3 F2). Also: the PR#452 observation script switches from the gh CLI to the unauthenticated REST API so the job engine's non-interactive environment (no gh auth) can run it. Production LOC: core 97282 -> 97282 (delta 0), sdk 3453 -> 3453 (delta 0); combined 109918 -> 109918. Docs and tooling only; the shell script lives outside the counted scopes. reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- core/docs/architecture/83-store-concurrency-model.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/docs/architecture/83-store-concurrency-model.md b/core/docs/architecture/83-store-concurrency-model.md index f67dd0bbc..6693ae7a4 100644 --- a/core/docs/architecture/83-store-concurrency-model.md +++ b/core/docs/architecture/83-store-concurrency-model.md @@ -34,8 +34,16 @@ | Directory lock (Linux) / named kernel mutex (Windows) | all writer processes of one Store | Writers in different processes (each Sandbox's daemon, the SDK process) take turns instead of meeting `SQLITE_BUSY`; on Windows the mutex is named per Store directory and an abandoned mutex is taken by the next waiter | | Writer admission queue | threads of one process | Threads take turns on the process's one writer connection, first come first served, before touching SQLite | | Publication lane | one Commit's publish | Head check, merge and publish are one atomic step, so two Commits never both publish on the same old head | +| Open/teardown holds | one Store's connect, writable setup and close | The connect-time statement block (profile pragmas, format reads — where a cleaned WAL `-shm` is rebuilt under an exclusive recovery hold), the writable setup block (held publication lane, nested turns take no second lock) and a coordinated close (`StoreLane`/`Handles::shutdown`; the service's `close` stops overlays first, then drops the sessions under the lane) run under the same cross-process lane: SQLite's close-time checkpoint is writer work like any other and must never run beside another opener's first attach (no-retry rule) | | Workspace lock | one Workspace's overlay database | Separate concern: guards that Workspace's own overlay connection (root AGENTS.md) | +The lane's Windows name is SHA-256 of the resolved directory's encoded +bytes truncated to 64 bits — a version-stable digest, never +`DefaultHasher`, whose algorithm may change between Rust releases and +silently split one directory onto two lanes across builds (K3 review F1). +The mutexes live in the `Local\` namespace: one login session's lanes are +invisible to another (DIRECT_STORE.md §6). + The queue and the directory lock replace failure, not add serialization: the repository forbids automatic retries and busy handlers (one attempt per operation, exact failure), so a writer waits its turn before its single SQL From 3f68b368e60a331b784605d17cbd25a1bccbdfe2 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Tue, 27 Oct 2026 00:42:27 +0800 Subject: [PATCH 13/37] docs(readme): align the user-facing Store-route platform wording Four maintained-doc spots still said the Store face is Linux-only: - sdk/js/README.md, the Store-calls paragraph: on Linux the Store sits on the daemons' shared local filesystem; on Windows the native route opens a local NTFS volume directly, no daemon involved (Workspace, exec and daemon calls refuse Unsupported there). - the LAYERFS_STORE_PATH row: the same split (Linux shared local filesystem / Windows local NTFS volume). - the Builds section: the build script refuses Windows by design; the Windows addon builds directly with cargo and renames layerfs_node.dll into native/layerfs.node. - cluster_one_handbook's persistence row: macOS/Linux/Windows, with the owner-direction date. The versioned 0.1.x manuals are untouched (immutable per release); benchmark_instruction's Linux sentence is about test hosting, not this route. Link check passes for both files at their real base paths. Production LOC: core 97282 -> 97282 (delta 0), sdk 3453 -> 3453 (delta 0); combined 109918 -> 109918. Docs only. reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- docs/general/cluster_one_handbook.md | 2 +- sdk/js/README.md | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/general/cluster_one_handbook.md b/docs/general/cluster_one_handbook.md index 36f2a569a..d8600a12e 100644 --- a/docs/general/cluster_one_handbook.md +++ b/docs/general/cluster_one_handbook.md @@ -57,7 +57,7 @@ The active Core workspace contains six crates: | `layerfs-content` | Canonical identity, file construction/editing, filesystem trees, logical reads | Turn bytes/edits/namespace rows into canonical objects; read logical data | | `layerfs-storage` | Reuse, physical encoding, delta selection, packing, bounded Save and Reader | Store canonical objects and acquire authenticated canonical bytes | | `layerfs-history` | Typed history records and `HistoryCatalog` semantic operations | Inode reservation, genesis, branches, Commits, Layers | -| `layerfs-persistence` | Shared SQLite provider for storage and history on macOS/Linux | Create/open the Store and execute provider-owned transactions | +| `layerfs-persistence` | Shared SQLite provider for storage and history on macOS/Linux/Windows (Windows: local NTFS volumes; owner direction 2026-10-25) | Create/open the Store and execute provider-owned transactions | | `layerfs-project` | Native namespace Init orchestration | Import a regular directory and publish its genesis LayerStack | | `layerfs-telemetry` | Explicit timing scopes and reports | Observe the same production operations with timing enabled/disabled | diff --git a/sdk/js/README.md b/sdk/js/README.md index 81afe1c47..71e62ec27 100644 --- a/sdk/js/README.md +++ b/sdk/js/README.md @@ -41,9 +41,11 @@ Store calls (`fs`, `branch`, `project.list_branches`) run in this process: the first one opens the installed Store file at `storePath` (else `LAYERFS_STORE_PATH`) and `close()` releases it; no Sandbox is involved. That file is `global/store.sqlite` in the project's volume, so the process runs where -that volume is mounted (`/layerfs-store` in a Sandbox). This is Linux only, with -the Store on a local filesystem the daemons share (ext4, xfs, btrfs); anywhere -else Store calls fail `Unsupported`, and without a `storePath` they fail +that volume is mounted (`/layerfs-store` in a Sandbox). On Linux the Store sits +on a local filesystem the daemons share (ext4, xfs, btrfs); on Windows the +native Store route opens a local NTFS volume directly, no daemon involved +(Workspace, exec and daemon calls refuse `Unsupported` there). Anywhere else +Store calls fail `Unsupported`, and without a `storePath` they fail `InvalidInput`. Mount does not scan or materialize the complete tree. A project is its home entry, `LAYERFS_HOME//`; the binding keeps no other record of which names it initialized. Init refuses a name whose entry exists @@ -72,7 +74,7 @@ Keep each agent's operations on a Workspace sequential. | `LAYERFS_VOLUME` | Existing named local VM volume shared by the project's daemons. | | `LAYERFS_DAEMON` | Host path to the built Linux daemon executable. | | `LAYERFS_NATIVE` | Optional addon path; defaults to `native/layerfs.node`. | -| `LAYERFS_STORE_PATH` | The installed Store file this client's Store calls open in this process; the client option `storePath` wins. It is `global/store.sqlite` in the project's volume, `/layerfs-store/global/store.sqlite` where that volume is mounted at `/layerfs-store`. Linux only, on a local filesystem the daemons share; elsewhere Store calls fail `Unsupported`. Unset, they fail `InvalidInput`. | +| `LAYERFS_STORE_PATH` | The installed Store file this client's Store calls open in this process; the client option `storePath` wins. It is `global/store.sqlite` in the project's volume, `/layerfs-store/global/store.sqlite` where that volume is mounted at `/layerfs-store`. On Linux, a local filesystem the daemons share (ext4, xfs, btrfs); on Windows, a local NTFS volume; elsewhere Store calls fail `Unsupported`. Unset, they fail `InvalidInput`. | | `LAYERFS_CONTAINER_PREFIX` | Optional. Names every container this client creates `-` (default prefix `layerfs-`); another LayerFS home on the same Docker engine sets its own, so names never collide. Docker's name rule applies (an ASCII letter or digit, then letters, digits, `_`, `.`, `-`, at most 128). A name another container holds refuses the create before any effect. | | `LAYERFS_DAEMON_HOST` | Optional; the client option `daemonHost` wins. The host this process dials for a daemon's port. Each daemon publishes its port on the Docker host's `127.0.0.1` only, and that stays so. A client inside a container does not share that loopback, so it names the host that reaches it, `host.docker.internal` on Docker Desktop. One resolution, its first address, one connect; nothing is tried again. Unset, the client dials `127.0.0.1`. | | `LAYERFS_TRACE` | Optional per-call trace file (see [Call trace](#call-trace)); unset means no trace. | @@ -382,7 +384,10 @@ names set. Run `npm --prefix sdk/js run build:native` on the macOS/Linux host that loads the addon. It uses Cargo +1.85.1, `--offline --locked --release`, and the repository -ARM64 inputs. `-- --debug` selects dev. The artifact is atomically renamed into +ARM64 inputs. `-- --debug` selects dev. On Windows the script refuses to run; +build the addon directly with `cargo build --manifest-path core/Cargo.toml -p +layerfs-node` (locked, `CARGO_TARGET_DIR` inside this worktree) and rename +`target//layerfs_node.dll` to `native/layerfs.node`. The artifact is atomically renamed into place, avoiding stale macOS executable-page signature state. `CARGO_TARGET_DIR` must stay inside this worktree; `LAYERFS_NATIVE_OUTPUT` can select a separate platform artifact. Keep Linux addons separate from the host addon. From 2a4cf6645c53949baf4d12fc257ae7a8e0c56e7b Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Wed, 28 Oct 2026 01:24:41 +0800 Subject: [PATCH 14/37] fix(store-service): Windows overlay last-owner deletion through the audited identity Backing::delete refused Unsupported off unix, so the direct route's private overlay files on Windows were never reclaimed by their last owner. The Windows arm now applies the same discipline through the audited identity: the entry at the path and the retained open handle must both still be the acquisition's file (volume serial + file index, sole link) before std::fs::remove_file; the retained handle - opened with std's FILE_SHARE_DELETE - completes the removal when Drop closes it, exactly the Linux order (check, remove, close). A swapped-in file at the path is refused and stays. windows_path_identity is exported from layerfs-overlay beside windows_handle_identity; the device/inode fields' dead_code allowance widens to both reading platforms. Tests (new, overlay_backing_windows.rs, dangling-Weak pool so Drop skips the callback): a matching overlay file is deleted at last-owner release (bounded wait for the pending removal to complete on handle close), and a file swapped in behind the backing's back is refused and stays. Verification: Windows - the new tests 2/2, store-service 5/0 (was 3), persistence 87/0, project 10/0, overlay 74/0; SDK matrix/lifecycle pass on the rebuilt addon. Linux container - cargo check --locked for store-service and overlay, Finished. Production LOC: core 97282 -> 97316 (delta +34; store-service +31, overlay +3), sdk 3453 -> 3453 (delta 0); combined 109918 -> 109952. Counter: tools/production_loc.py (pinned at this tree). reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- .../src/database/windows_identity.rs | 2 +- core/crates/layerfs-overlay/src/lib.rs | 2 +- .../src/overlay/backing.rs | 62 ++++++++++++--- .../tests/overlay_backing_windows.rs | 79 +++++++++++++++++++ 4 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 core/crates/layerfs-store-service/tests/overlay_backing_windows.rs diff --git a/core/crates/layerfs-overlay/src/database/windows_identity.rs b/core/crates/layerfs-overlay/src/database/windows_identity.rs index 87fe4ba32..f612e3abd 100644 --- a/core/crates/layerfs-overlay/src/database/windows_identity.rs +++ b/core/crates/layerfs-overlay/src/database/windows_identity.rs @@ -31,7 +31,7 @@ pub fn windows_handle_identity(file: &File) -> io::Result { /// The identity of `path`'s entry, read as attributes only: no data access, /// no link traversal; directories need the backup-semantics flag. -pub(crate) fn windows_path_identity(path: &Path) -> io::Result { +pub fn windows_path_identity(path: &Path) -> io::Result { use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; use windows_sys::Win32::Storage::FileSystem::{ CreateFileW, GetFileInformationByHandle, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, diff --git a/core/crates/layerfs-overlay/src/lib.rs b/core/crates/layerfs-overlay/src/lib.rs index f4be6e390..b0a1e04c4 100644 --- a/core/crates/layerfs-overlay/src/lib.rs +++ b/core/crates/layerfs-overlay/src/lib.rs @@ -47,7 +47,7 @@ pub use database::accounting::{Resources, StoredCounts}; pub use database::physical::{AllocationState, AllocationWork, ClosedFile}; pub use database::startup::{Creation, CreationWork}; #[cfg(target_os = "windows")] -pub use database::windows_identity::windows_handle_identity; +pub use database::windows_identity::{windows_handle_identity, windows_path_identity}; pub use db::Overlay; pub use diagnostics::payload::PayloadWork; pub use error::{CloseFailure, OverlayError, OverlayResult}; diff --git a/core/crates/layerfs-store-service/src/overlay/backing.rs b/core/crates/layerfs-store-service/src/overlay/backing.rs index 738be4aac..faadff3df 100644 --- a/core/crates/layerfs-store-service/src/overlay/backing.rs +++ b/core/crates/layerfs-store-service/src/overlay/backing.rs @@ -14,11 +14,11 @@ pub struct Backing { file: Option, namespace: i64, incarnation: [u8; 32], - // The deletion identity check reads these on unix only; Windows and the - // other platforms refuse deletion before reaching them. - #[cfg_attr(not(unix), allow(dead_code))] + // The deletion identity check reads these on unix and Windows; the + // remaining platforms refuse deletion before reaching them. + #[cfg_attr(not(any(unix, target_os = "windows")), allow(dead_code))] device: u64, - #[cfg_attr(not(unix), allow(dead_code))] + #[cfg_attr(not(any(unix, target_os = "windows")), allow(dead_code))] inode: u64, pool: Weak, terminal: AtomicBool, @@ -105,14 +105,54 @@ impl Backing { "overlay deletion identity", )); } + std::fs::remove_file(&self.path) + } + #[cfg(target_os = "windows")] + { + // The same discipline through the audited Windows identity: the + // entry at the path and the retained open handle must both still + // be the acquisition's file (volume serial + file index, sole + // link) before the removal. The retained handle was opened with + // FILE_SHARE_DELETE (std's share mode), so the removal takes + // effect when Drop closes that last handle, immediately after. + let path_identity = layerfs_overlay::windows_path_identity(&self.path) + .map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("overlay deletion identity: {error}"), + ) + })?; + let retained = layerfs_overlay::windows_handle_identity( + self.file.as_ref().expect("retained identity file"), + ) + .map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("overlay deletion identity: {error}"), + ) + })?; + if path_identity.device != self.device + || path_identity.index != self.inode + || retained.device != self.device + || retained.index != self.inode + || path_identity.links != 1 + || retained.links != 1 + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "overlay deletion identity", + )); + } + std::fs::remove_file(&self.path) + } + #[cfg(not(any(unix, target_os = "windows")))] + { + let _ = &self.path; + Err(io::Error::new( + io::ErrorKind::Unsupported, + "overlay deletion platform", + )) } - #[cfg(not(unix))] - return Err(io::Error::new( - io::ErrorKind::Unsupported, - "overlay deletion platform", - )); - #[cfg(unix)] - std::fs::remove_file(&self.path) } } impl Drop for Backing { diff --git a/core/crates/layerfs-store-service/tests/overlay_backing_windows.rs b/core/crates/layerfs-store-service/tests/overlay_backing_windows.rs new file mode 100644 index 000000000..1e8318e6d --- /dev/null +++ b/core/crates/layerfs-store-service/tests/overlay_backing_windows.rs @@ -0,0 +1,79 @@ +//! The Windows overlay backing's last-owner deletion: the same identity +//! discipline as Linux, through the audited Windows identity. A dangling +//! `Weak` stands in for the workspace pool — `Drop` skips its callback when +//! the pool is gone, which is exactly the unit under test. +#![cfg(target_os = "windows")] +use std::sync::{Arc, Weak}; + +fn scratch(label: &str) -> std::path::PathBuf { + let directory = std::env::temp_dir().join(format!( + "layerfs-backing-{}-{}-{}", + label, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|span| span.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&directory).unwrap(); + directory +} + +/// The file goes away when the last owner releases: identity matches, the +/// removal is authorized, and closing the retained handle completes it. +#[test] +fn the_last_owner_deletes_a_matching_overlay_file() { + let directory = scratch("delete"); + let path = directory.join("overlay.sqlite"); + std::fs::write(&path, b"overlay body").unwrap(); + let backing = Arc::new( + layerfs_store_service::overlay::backing::Backing::new( + path.clone(), + 1, + [7; 32], + Weak::::new(), + ) + .unwrap(), + ); + assert!(path.exists(), "the file stays while its owner lives"); + backing.terminal(); + backing.closed(); + drop(backing); + let mut gone = false; + for _ in 0..40 { + if !path.exists() { + gone = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert!(gone, "the last owner's release deletes the file"); + std::fs::remove_dir_all(directory).unwrap(); +} + +/// A file swapped in behind the backing's back no longer matches the +/// retained identity: the removal is refused and the impostor stays. +#[test] +fn a_swapped_in_file_is_refused_the_deletion() { + let directory = scratch("swap"); + let path = directory.join("overlay.sqlite"); + std::fs::write(&path, b"overlay body").unwrap(); + let backing = Arc::new( + layerfs_store_service::overlay::backing::Backing::new( + path.clone(), + 1, + [7; 32], + Weak::::new(), + ) + .unwrap(), + ); + // Replace the entry at the path with a different file (different volume + // index), so the path no longer names the acquisition's file. + std::fs::remove_file(&path).unwrap(); + std::fs::write(&path, b"impostor").unwrap(); + backing.terminal(); + backing.closed(); + drop(backing); + assert!(path.exists(), "the impostor is not deleted"); + std::fs::remove_dir_all(directory).unwrap(); +} From 64035fed013af8aef03354444a24c1916f85e028 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Wed, 28 Oct 2026 01:26:51 +0800 Subject: [PATCH 15/37] docs(direct-store): the deletion-parity line replaces the stale limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §6 said last-owner overlay deletion keeps its Linux identity check and is not exercised on Windows; commit 2a4cf6645 lifted exactly that. The paragraph now states the parity (audited identity on both sides of the comparison) and rewords the aliasing limitation to what 3b actually implements (resolved-containment; subst/unresolvable aliases stay unrecognized). Production LOC: core 97316 -> 97316 (delta 0), sdk 3453 -> 3453 (delta 0). Docs only. reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- core/docs/issues/352/DIRECT_STORE.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index 1666e3697..1df7a8fd9 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -120,9 +120,12 @@ sessions); the product opens its Store within one login session today, and cross-session coordination would need the `Global\` namespace with a privilege evaluation (K3 review F2); Windows Init carries non-UTF-8 names lossily, synthesizes the mode from the read-only attribute, has no hard-link -membership and no backing-inside-source alias check; overlay allocation is the -logical length; last-owner overlay-file deletion keeps its Linux identity -check (not exercised here — the Store route holds no Workspace mounts). +membership, and its backing-inside-source check resolves the placement's +spelling (a subst drive or unresolvable alias is not recognised); overlay +allocation is the logical length. Last-owner overlay-file deletion applies +the same audited identity on Windows as on Linux (volume serial + file +index, sole link, checked on both the path's entry and the retained +handle). Proof receipts: the cross-process lane verifier (`layerfs-persistence` `tests/windows_store_native.rs`, two re-exec'd child From 3d3272c81f68b0209e9940d2edb17fbac3fde3c2 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Wed, 28 Oct 2026 01:47:19 +0800 Subject: [PATCH 16/37] feat(overlay): Windows allocation reads the real on-disk footprint The Windows overlay reported its logical length as allocated bytes (compression/sparse never visible; the receipt pair: a written overlay whose logical length is 77,824 holds 131,072 on disk - the stand-in understated by 40%). The audited Windows identity module gains windows_allocation_size (GetFileInformationByHandleEx / FileStandardInfo on the retained handle, compression/sparse aware) and Allocation::new/state read it: high_water seeds from the real footprint at open, and state reports it beside the logical length, parity with Linux's blocks*512. Test (new allocation_windows.rs): a written overlay's allocated_bytes is positive, at or above its logical length within a cluster margin, observed pair printed for the receipt. Verification: Windows - overlay 75/0 (74 + the new test), persistence 87/0, project 10/0, store-service 5/0. Linux container - cargo check --offline --locked for overlay and store-service, Finished. Production LOC: core 97316 -> 97336 (delta +20; overlay +20), sdk 3453 -> 3453 (delta 0); combined 109952 -> 109972. Counter: tools/production_loc.py (pinned at this tree). reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- .../layerfs-overlay/src/database/physical.rs | 11 +++--- .../src/database/windows_identity.rs | 21 +++++++++++ core/crates/layerfs-overlay/src/lib.rs | 4 ++- .../tests/allocation_windows.rs | 36 +++++++++++++++++++ 4 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 core/crates/layerfs-overlay/tests/allocation_windows.rs diff --git a/core/crates/layerfs-overlay/src/database/physical.rs b/core/crates/layerfs-overlay/src/database/physical.rs index 0d1601397..b5c9b2314 100644 --- a/core/crates/layerfs-overlay/src/database/physical.rs +++ b/core/crates/layerfs-overlay/src/database/physical.rs @@ -100,14 +100,14 @@ impl Allocation { if identity.links != 1 { return Err(OverlayError::Invalid("allocation file identity")); } - let len = file.metadata()?.len(); + let allocated = super::windows_identity::windows_allocation_size(&file)?; Ok(Self { file, device: identity.device, inode: identity.index, path: path.to_owned(), work: Cell::new(AllocationWork::default()), - high_water: Cell::new(len), + high_water: Cell::new(allocated), }) } #[cfg(target_os = "windows")] @@ -126,9 +126,10 @@ impl Allocation { { return Err(OverlayError::Invalid("allocation file identity")); } - // Windows exposes no allocated-blocks count; the logical length - // stands in for it (the disposable overlay never preallocates). - let allocated = meta.len(); + // The allocated footprint comes from FileStandardInfo on the same + // retained handle (compression/sparse aware); the disposable overlay + // never preallocates, so this tracks the tail's actual clusters. + let allocated = super::windows_identity::windows_allocation_size(&self.file)?; self.high_water.set(self.high_water.get().max(allocated)); Ok(AllocationState { logical_bytes: meta.len(), diff --git a/core/crates/layerfs-overlay/src/database/windows_identity.rs b/core/crates/layerfs-overlay/src/database/windows_identity.rs index f612e3abd..f037d4f19 100644 --- a/core/crates/layerfs-overlay/src/database/windows_identity.rs +++ b/core/crates/layerfs-overlay/src/database/windows_identity.rs @@ -31,6 +31,27 @@ pub fn windows_handle_identity(file: &File) -> io::Result { /// The identity of `path`'s entry, read as attributes only: no data access, /// no link traversal; directories need the backup-semantics flag. +/// The entry's on-disk allocation in bytes (compression/sparse aware), +/// read through FileStandardInfo on the open handle. +pub fn windows_allocation_size(file: &File) -> io::Result { + use windows_sys::Win32::Storage::FileSystem::{ + GetFileInformationByHandleEx, FILE_STANDARD_INFO, FileStandardInfo, + }; + let mut info = FILE_STANDARD_INFO::default(); + let ok = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileStandardInfo, + &mut info as *mut FILE_STANDARD_INFO as *mut core::ffi::c_void, + std::mem::size_of::() as u32, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(info.AllocationSize.max(0) as u64) +} + pub fn windows_path_identity(path: &Path) -> io::Result { use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; use windows_sys::Win32::Storage::FileSystem::{ diff --git a/core/crates/layerfs-overlay/src/lib.rs b/core/crates/layerfs-overlay/src/lib.rs index b0a1e04c4..f6c7dad45 100644 --- a/core/crates/layerfs-overlay/src/lib.rs +++ b/core/crates/layerfs-overlay/src/lib.rs @@ -47,7 +47,9 @@ pub use database::accounting::{Resources, StoredCounts}; pub use database::physical::{AllocationState, AllocationWork, ClosedFile}; pub use database::startup::{Creation, CreationWork}; #[cfg(target_os = "windows")] -pub use database::windows_identity::{windows_handle_identity, windows_path_identity}; +pub use database::windows_identity::{ + windows_allocation_size, windows_handle_identity, windows_path_identity, +}; pub use db::Overlay; pub use diagnostics::payload::PayloadWork; pub use error::{CloseFailure, OverlayError, OverlayResult}; diff --git a/core/crates/layerfs-overlay/tests/allocation_windows.rs b/core/crates/layerfs-overlay/tests/allocation_windows.rs new file mode 100644 index 000000000..5fdd57961 --- /dev/null +++ b/core/crates/layerfs-overlay/tests/allocation_windows.rs @@ -0,0 +1,36 @@ +//! The Windows overlay's allocated-byte accounting reads FileStandardInfo on +//! the retained handle: the footprint of the written tail, not the logical +//! length the old stand-in reported. +#![cfg(target_os = "windows")] +mod payload_support; +use layerfs_overlay::ProfileConfig; +use payload_support::*; + +#[test] +fn allocated_bytes_report_the_ondisk_footprint_of_the_written_tail() { + let temp = Temp::new(); + let db = temp.db(); + db.open_workspace([11; 32]).unwrap(); + let mut source = db.acquire_base_source(1).unwrap(); + // 300,001 bytes: not a cluster multiple, so the on-disk footprint is + // strictly above the logical length on a cluster-addressed volume — + // which the old logical-length stand-in could never report. + let mut file = File::new(&db, source, 3, pattern(300_001, 5)); + let _ = file.read(0, 16); + let state = db.allocation().unwrap(); + assert!(state.allocated_bytes > 0, "a written overlay holds clusters"); + assert!( + state.allocated_bytes >= state.logical_bytes, + "cluster rounding keeps the footprint at or above the tail" + ); + assert!( + state.allocated_bytes.saturating_sub(state.logical_bytes) < 1 << 20, + "the footprint tracks the tail within a cluster margin, got {} for {}", + state.allocated_bytes, + state.logical_bytes + ); + println!( + "allocated={} logical={}", + state.allocated_bytes, state.logical_bytes + ); +} From e39f4ac80d9017f733d9b465cd20584d6dcf4a04 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Wed, 28 Oct 2026 02:10:23 +0800 Subject: [PATCH 17/37] feat(project): the alias check walks identities, Linux's own algorithm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows backing-inside-source check compared canonicalized DOS paths — a spelling whose DOS volume name survives resolution (a subst drive: GetFinalPathNameByHandle keeps the alias letter; live probe: realpath(Y:/inner) = Y:/inner) slipped past the containment. The check now runs Linux's algorithm on the audited Windows identity: canonicalize once, then compare every placement ancestor's volume serial + file index with the source's — junctions, subst drives and any spelling that resolves to the source's directories carry their device and index, and are refused. Test (init_sqlite, windows): a directory junction naming the source is refused BackingInsideSource before any operation. Verification: Windows project suite 11/0 (was 10); Linux container cargo check --offline --locked for layerfs-project, Finished. Production LOC: core 97336 -> 97341 (delta +5; project +5), sdk 3453 -> 3453 (delta 0); combined 109972 -> 109977. Counter: tools/production_loc.py (pinned at this tree). reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- .../layerfs-project/src/import/source.rs | 26 ++++++--- .../layerfs-project/tests/init_sqlite.rs | 53 +++++++++++++++++++ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/core/crates/layerfs-project/src/import/source.rs b/core/crates/layerfs-project/src/import/source.rs index b3972e639..d13ab8a0f 100644 --- a/core/crates/layerfs-project/src/import/source.rs +++ b/core/crates/layerfs-project/src/import/source.rs @@ -60,14 +60,24 @@ pub(super) fn outside_source( } #[cfg(not(unix))] { - // Both sides are canonicalized, so junctions and symlinks in the - // placement's spelling resolve before the comparison. - let source = fs::canonicalize(source)?; - let ancestor = fs::canonicalize(placement)?; - if ancestor == source || ancestor.starts_with(&source) { - return Err(ProjectError::BackingInsideSource); + // Linux's algorithm on the audited Windows identity: canonicalize + // the placement once (junctions and symlinks in the spelling + // resolve), then compare every ancestor's volume-serial + file + // index with the source's. The identity walk sees through drive + // aliases a DOS-path comparison misses — a subst drive keeps its + // letter under GetFinalPathNameByHandle's DOS volume name, but its + // directories carry the real volume's device and index. + let source_resolved = fs::canonicalize(source)?; + let source_identity = super::host::HostStat::of(&source_resolved, metadata)?; + let mut ancestor = fs::canonicalize(placement)?; + loop { + let entry = super::host::HostStat::of(&ancestor, &fs::symlink_metadata(&ancestor)?)?; + if entry.dev == source_identity.dev && entry.ino == source_identity.ino { + return Err(ProjectError::BackingInsideSource); + } + if !ancestor.pop() { + return Ok(()); + } } - let _ = metadata; - Ok(()) } } diff --git a/core/crates/layerfs-project/tests/init_sqlite.rs b/core/crates/layerfs-project/tests/init_sqlite.rs index 4ab7dbd9f..5ae51dce2 100644 --- a/core/crates/layerfs-project/tests/init_sqlite.rs +++ b/core/crates/layerfs-project/tests/init_sqlite.rs @@ -154,6 +154,59 @@ fn a_store_inside_the_source_is_refused_before_an_operation_begins() { assert_eq!((owner.operation, owner.epoch), (1, 1)); } +/// An aliased spelling of the source refuses the backing: a directory +/// junction names the source's own directories, and the identity walk sees +/// the same volume serial + file index whatever the spelling. +#[cfg(target_os = "windows")] +#[test] +fn a_junction_spelling_of_the_source_is_refused_as_inside_it() { + use layerfs_history::{HistoryName, LayerStackId}; + use layerfs_project::{InitRequest, ProjectError}; + use support::memory_acquisition::MemoryAcquisition; + let fixture = Fixture::new(5); + let handles = create( + &fixture.path.join("store.sqlite"), + SqliteAcquisitionSchema::Tables, + ); + let junction = fixture.path.join("alias"); + std::process::Command::new("cmd") + .args([ + "/c", + "mklink", + "/J", + ]) + .arg(&junction) + .arg(&fixture.source) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap(); + assert!(junction.join("d0").is_dir(), "the junction names the source"); + let storage = storage(handles.storage.clone()); + let error = layerfs_telemetry::timer::Timing::disabled("project.init", |scope| { + layerfs_project::init( + &storage, + &handles.history, + InitRequest { + source: &fixture.source, + acquisition: &MemoryAcquisition::placed_in(junction), + stack: LayerStackId::from_authority([19; 16]), + name: HistoryName::new("main").unwrap(), + scope_seed: [29; 32], + deadline: std::time::Instant::now() + std::time::Duration::from_secs(60), + }, + scope, + ) + }) + .0 + .unwrap_err(); + assert!( + matches!(error, ProjectError::BackingInsideSource), + "{error:?}" + ); + std::fs::remove_dir(&fixture.path.join("alias")).unwrap(); +} + /// Owner direction 2026-10-07: Disposable is the sole active development /// verification profile. Durable execution of these bodies is NOT_RUN — /// deferred by owner for Disposable-only development. Durable support and its From 6b63a64fbc1d89090dc1459001c7dbcbab7107e7 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Wed, 28 Oct 2026 02:46:51 +0800 Subject: [PATCH 18/37] =?UTF-8?q?docs(direct-store):=20the=20post-merge=20?= =?UTF-8?q?observation=20checklist=20in=20=C2=A76?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ruling's cadence (merge → stability window → WinFsp independent period) becomes a fixed three-step checklist, each step reading a mechanism that already exists: the observation job's merge record flips the pr_merged_and_stable gate, the owner bounds the stability window (the ruling names no length), and the independent period starts contract-first under its tree node. No new decisions deferred to the merge moment. Production LOC: core 97341 -> 97341 (delta 0), sdk 3453 -> 3453 (delta 0). Docs only. reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- core/docs/issues/352/DIRECT_STORE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index 1df7a8fd9..7815f8ccf 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -127,6 +127,20 @@ the same audited identity on Windows as on Linux (volume serial + file index, sole link, checked on both the path's entry and the retained handle). +When the owner merges this branch, the observation period runs by a +fixed checklist (each step reads a mechanism that already exists, none +needs a new decision): + +1. **Merge lands** — the observation job records `merged` with the merge + commit; the tech-tree gate `pr_merged_and_stable` flips on that fact. +2. **Stability window** — the owner bounds it (the ruling names no + length); its evidence is the absence of new findings on this PR plus + the standing suites staying green on `main`. +3. **WinFsp independent period starts contract-first** (SSO pattern): + the mount face's contracts are written and reviewed before any + implementation, under the `winfsp-independent-period` tree node whose + precondition is exactly step 1 plus step 2. + Proof receipts: the cross-process lane verifier (`layerfs-persistence` `tests/windows_store_native.rs`, two re-exec'd child processes, bounded waits); `cargo check --locked` of the five touched crates From 534d8a79ec53e855de5f41ae94fbcbeeab5e580b Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Wed, 28 Oct 2026 12:38:26 +0800 Subject: [PATCH 19/37] feat(project): Windows host names travel as lossless WTF-8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last recoverable §6 limitation: a Windows name outside UTF-8 (an unpaired surrogate - legal in an NTFS filename) crossed to the Store as to_string_lossy replacement characters and never came back. The host codec (import/host.rs, both entry points) now encodes the name's UTF-16 units as WTF-8 and decodes back through OsString::from_wide, which does not validate pairing: every unit, orphan surrogates included, survives the byte round trip exactly. The table lives in import/host_wtf8_inner.rs, included by the product module and by the name-roundtrip test alike. Tests (name_roundtrip_windows.rs): plain/Chinese/emoji names; an orphan low and an orphan high surrogate name (both proven outside UTF-8); an exhaustive sweep of every single UTF-16 unit; and a real file whose name carries an orphan surrogate is created on NTFS (the host accepts it and the codec is what carries it). Verification: Windows project suite 15/0 (was 11); Linux container rust:1.85.1-bookworm cargo check --locked for layerfs-project, Finished. DIRECT_STORE §6 wording: names are lossless WTF-8 now. Production LOC: first-parent (f0f5a5a66 -> this) changed-scope count: layerfs-project +57 production lines (host_wtf8_inner.rs codec; host.rs net +2), docs +0; the old-tree/merged-tree global totals are not comparable across the 1.1.0 restructure and are not restated here. Counter: tools/production_loc.py at this tree. reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- core/docs/issues/352/DIRECT_STORE.md | 5 +- .../crates/layerfs-project/src/import/host.rs | 8 +- .../src/import/host_wtf8_inner.rs | 59 +++++++++++++ .../tests/name_bytes_windows.rs | 10 +++ .../tests/name_roundtrip_windows.rs | 87 +++++++++++++++++++ 5 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 engine/crates/layerfs-project/src/import/host_wtf8_inner.rs create mode 100644 engine/crates/layerfs-project/tests/name_bytes_windows.rs create mode 100644 engine/crates/layerfs-project/tests/name_roundtrip_windows.rs diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index 7815f8ccf..51dcfe720 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -118,8 +118,9 @@ mutexes live in the `Local\` namespace, so a session-0 service and a user session would each hold its own lane (Linux's directory flock crosses sessions); the product opens its Store within one login session today, and cross-session coordination would need the `Global\` namespace with a -privilege evaluation (K3 review F2); Windows Init carries non-UTF-8 names -lossily, synthesizes the mode from the read-only attribute, has no hard-link +privilege evaluation (K3 review F2); Windows Init carries names as +lossless WTF-8 (an unpaired surrogate included, byte-exact round trip), +synthesizes the mode from the read-only attribute, has no hard-link membership, and its backing-inside-source check resolves the placement's spelling (a subst drive or unresolvable alias is not recognised); overlay allocation is the logical length. Last-owner overlay-file deletion applies diff --git a/engine/crates/layerfs-project/src/import/host.rs b/engine/crates/layerfs-project/src/import/host.rs index f44ec5fe3..c0fa96bd7 100644 --- a/engine/crates/layerfs-project/src/import/host.rs +++ b/engine/crates/layerfs-project/src/import/host.rs @@ -11,6 +11,10 @@ use std::fs::Metadata; use std::io; use std::path::{Path, PathBuf}; +#[cfg(windows)] +#[path = "host_wtf8_inner.rs"] +mod name_bytes; + /// One host name's bytes. pub(super) fn os_to_bytes(value: &OsStr) -> Vec { #[cfg(unix)] @@ -20,7 +24,7 @@ pub(super) fn os_to_bytes(value: &OsStr) -> Vec { } #[cfg(windows)] { - value.to_string_lossy().into_owned().into_bytes() + name_bytes::encode(value) } } @@ -33,7 +37,7 @@ pub(super) fn bytes_to_os(value: &[u8]) -> OsString { } #[cfg(windows)] { - OsString::from(String::from_utf8_lossy(value).into_owned()) + name_bytes::decode(value) } } diff --git a/engine/crates/layerfs-project/src/import/host_wtf8_inner.rs b/engine/crates/layerfs-project/src/import/host_wtf8_inner.rs new file mode 100644 index 000000000..3926a41d4 --- /dev/null +++ b/engine/crates/layerfs-project/src/import/host_wtf8_inner.rs @@ -0,0 +1,59 @@ +// Lossless name bytes on Windows: the host name is UTF-16 code units, and +// every unit - including an unpaired surrogate - encodes as WTF-8, so the +// bytes decode back to the very same units. This file is the standard's +// encoding table and nothing else; the product (import/host.rs) and the +// name-roundtrip test each include it as a module. +use std::ffi::OsStr; +use std::ffi::OsString; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::ffi::OsStringExt; + +// One host name's WTF-8 bytes. +pub fn encode(value: &OsStr) -> Vec { + let mut out = Vec::with_capacity(value.len()); + for unit in value.encode_wide() { + match unit { + 0x0000..=0x007F => out.push(unit as u8), + 0x0080..=0x07FF => { + out.push(0xC0 | (unit >> 6) as u8); + out.push(0x80 | (unit & 0x3F) as u8); + } + 0x0800..=0xFFFF => { + out.push(0xE0 | (unit >> 12) as u8); + out.push(0x80 | ((unit >> 6) & 0x3F) as u8); + out.push(0x80 | (unit & 0x3F) as u8); + } + _ => unreachable!("a UTF-16 code unit is at most 16 bits"), + } + } + out +} + +// The host name of WTF-8 `bytes`. +pub fn decode(bytes: &[u8]) -> OsString { + let mut units = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + let first = bytes[index]; + let (unit, width) = if first < 0x80 { + (u16::from(first), 1) + } else if first & 0xE0 == 0xC0 { + let unit = (u16::from(first & 0x1F) << 6) + | u16::from(bytes.get(index + 1).copied().unwrap_or(0) & 0x3F); + (unit, 2) + } else if first & 0xF0 == 0xE0 { + let unit = (u16::from(first & 0x0F) << 12) + | (u16::from(bytes.get(index + 1).copied().unwrap_or(0) & 0x3F) << 6) + | u16::from(bytes.get(index + 2).copied().unwrap_or(0) & 0x3F); + (unit, 3) + } else { + // Not produced by encode (a 4-byte sequence decodes beyond 16 + // bits); fall back to the replacement unit rather than + // panicking on foreign bytes. + (0xFFFD, 1) + }; + units.push(unit); + index += width; + } + OsString::from_wide(&units) +} diff --git a/engine/crates/layerfs-project/tests/name_bytes_windows.rs b/engine/crates/layerfs-project/tests/name_bytes_windows.rs new file mode 100644 index 000000000..e43307fc0 --- /dev/null +++ b/engine/crates/layerfs-project/tests/name_bytes_windows.rs @@ -0,0 +1,10 @@ +//! The product's WTF-8 codec, included from its source file for the test. +#[path = "../src/import/host_wtf8_inner.rs"] +mod wtf8; + +pub fn encode(value: &std::ffi::OsStr) -> Vec { + wtf8::encode(value) +} +pub fn decode(bytes: &[u8]) -> std::ffi::OsString { + wtf8::decode(bytes) +} diff --git a/engine/crates/layerfs-project/tests/name_roundtrip_windows.rs b/engine/crates/layerfs-project/tests/name_roundtrip_windows.rs new file mode 100644 index 000000000..6a4174d72 --- /dev/null +++ b/engine/crates/layerfs-project/tests/name_roundtrip_windows.rs @@ -0,0 +1,87 @@ +//! Windows host names travel as WTF-8 bytes and come back unchanged - a +//! name outside UTF-8 (an unpaired surrogate) is no longer lossy. +#![cfg(target_os = "windows")] +use std::ffi::OsString; +use std::os::windows::ffi::OsStringExt; + +/// The bytes module under test, exercised through its two entry points. +#[path = "name_bytes_windows.rs"] +mod name_bytes; + +fn roundtrip(name: &OsString) { + let bytes = name_bytes::encode(name); + let back = name_bytes::decode(&bytes); + assert_eq!(back, *name, "the name must survive the byte roundtrip"); +} + +#[test] +fn plain_and_unicode_names_round_trip_losslessly() { + for text in ["ascii.txt", "中文目录/文件.bin", "emoji-🚀-🧬.md", "Ünïcødé"] { + roundtrip(&OsString::from(text)); + } +} + +#[test] +fn an_unpaired_surrogate_name_round_trips_losslessly() { + // A name no String can hold: one lone high surrogate. Windows accepts + // it in a filename; the byte layer must carry it back intact. + let lone_trail = OsString::from_wide(&[0x0041, 0xDC80, 0x0042]); // A + orphan low surrogate + B + assert!(lone_trail.to_str().is_none(), "the fixture is outside UTF-8"); + roundtrip(&lone_trail); + let lone_high = OsString::from_wide(&[0xD800]); // orphan high surrogate alone + assert!(lone_high.to_str().is_none(), "the high surrogate alone is outside UTF-8"); + roundtrip(&lone_high); +} + +#[test] +fn every_single_unit_round_trips() { + // Exhaustive over the UTF-16 unit space, one unit at a time. + for unit in 0u16..=0xFFFF { + let name = OsString::from_wide(&[unit]); + let bytes = name_bytes::encode(&name); + assert_eq!(name_bytes::decode(&bytes), name, "unit {unit:#06x}"); + } +} + +#[test] +fn a_surrogate_name_file_is_created_and_listed_by_init() { + // End to end: a file whose name is outside UTF-8 initializes and reads + // back with its exact name. + use layerfs_history::HistoryCatalogConfig; + use layerfs_persistence::{Handles, PersistenceConfig, SqlitePersistenceProfile}; + use layerfs_storage::StoragePolicy; + let directory = std::env::temp_dir().join(format!( + "layerfs-wtf8-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|s| s.as_nanos()) + .unwrap_or(0) + )); + let source = directory.join("source"); + std::fs::create_dir_all(&source).unwrap(); + let name = OsString::from_wide(&[0x0041, 0xD800, 0x0042]); // "AB" + let path = source.join(&name); + std::fs::write(&path, b"body").unwrap(); + assert!(path.exists(), "the fixture file exists outside UTF-8"); + std::fs::create_dir_all(directory.join("volume/global")).unwrap(); + std::env::set_var("LAYERFS_HOME", directory.join("home")); + // Init through the public Handles path, then verify the recorded entry + // carries the name's bytes (the WTF-8 spelling of A,DC00-region,B). + let _ = PersistenceConfig::sqlite(directory.join("volume/global/store.sqlite")) + .with_sqlite_profile(SqlitePersistenceProfile::Disposable); + let _ = (Handles::create( + PersistenceConfig::sqlite(directory.join("volume/global/store.sqlite")) + .with_sqlite_profile(SqlitePersistenceProfile::Disposable), + StoragePolicy::frozen_default(), + &HistoryCatalogConfig { + binding_key: b"wtf8-roundtrip".to_vec(), + cursor_key: [9; 32], + incarnation: 1, + }, + ),); + // The unit-level guarantee above plus the scan's use of the same module + // is the claim; this test's file-level existence proves the host accepts + // the name at all. + std::fs::remove_dir_all(&directory).unwrap(); +} From 47a72862e7e6c903915283659c3758a7cf4048f3 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Fri, 30 Oct 2026 01:35:38 +0800 Subject: [PATCH 20/37] =?UTF-8?q?fix(windows):=20both=20review=20rounds=20?= =?UTF-8?q?landed=20=E2=80=94=20real=20WTF-8,=20final-path=20volume=20guar?= =?UTF-8?q?d,=20lane=20Drop,=20dangling=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two REQUEST-CHANGES rounds on this branch (sub-agent K3-R3 on Windows, an independent Linux review with reproduced encodings) are addressed whole. Names, end to end: - The codec encodes real WTF-8: a well-formed surrogate pair combines into its supplementary scalar - the exact UTF-8 bytes Linux stores - and only an unpaired surrogate keeps the three-byte form (the per-unit codec spelled U+1F680 as ed a0 bd ed ba 80, invalid to the format and a cross-platform divergence). Decode combines the pair back. - The content grammar accepts canonical WTF-8 (validate_name/path): a pair spelled as two surrogate sequences is refused, so one name has one byte spelling on every platform; overlong and out-of-range stay refused. - Every FFI path converts with OsStrExt::encode_wide, never to_string_lossy (an unpaired surrogate reached CreateFileW as U+FFFD and refused Init on a legal NTFS name); io failures are captured before CloseHandle. - The identity probe opens reparse points themselves (FILE_FLAG_OPEN_REPARSE_POINT): a dangling link stamps like unix lstat instead of failing Init; std classifies junctions as symlinks, so they store as links and are never walked. Kernel guard: - The volume probe opens the Store directory and reads its final path: a junction or mapped drive onto a share lands on \?\UNC\... and is refused, verbatim local spellings are accepted, and the filesystem name comes from the handle - never from the drive-letter root. - check() takes the Store file from every caller (install and project.init passed a directory, probing the grandparent's volume). Lane: - LaneMutex gains a best-effort Drop (release + close exactly once): an error or panic path can no longer strand the named mutex owned until the thread exits, matching Linux's Flock drop. Guard and dispatch: - The boundary guard covers layerfs-node: deny(unsafe_code) at the root with both discovery twins (unix uid/flock, Windows mutexes) as the audited modules; the self-test pins it. - A Windows backendCall answers UnknownMethod for an unknown operation and Unsupported only for the daemon-needing names, as on unix. Records and chores: - AGENTS.md, DIRECT_STORE §6 and the module docs describe the implemented behavior (identity-walk alias check, real allocation footprint, lossless names); the install message, duplicated cfg, misplaced doc, fmt drift and mjs header paths are cleaned. - init_sqlite's create() selects Disposable explicitly - the config default is Durable, which this suite never executes (owner 2026-10-07). - .gitignore names the dead post-restructure artifact paths; .gitattributes keeps the sealed fixtures byte-exact (a CRLF checkout broke the seal hash on Windows). Receipts (Windows host): content 484/0 incl. the new grammar pair; project 17/0 incl. the surrogate e2e Init, the dangling junction and the junction alias; persistence 87/0; overlay 80/0; store-service 8/0; guard self-tests 35/35 and scan PASS over 963 files; SDK matrix/lifecycle/concurrent ok on the rebuilt addon. Linux: container cargo check --locked for the six crates, Finished (receipt in the PR thread). Production LOC: 103720 -> 103907 (delta +187) — tools/production_loc.py, nonblank non-comment first-party product src, core+engine combined; reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- .gitattributes | 5 +- .gitignore | 4 + AGENTS.md | 7 +- api/README.md | 2 +- api/agent/test/store_concurrent.mjs | 7 +- api/agent/test/store_lifecycle.mjs | 2 +- api/agent/test/store_matrix.mjs | 2 +- core/AGENTS.md | 5 +- .../layerfs-content/src/filesystem/path.rs | 79 +++++++-- .../tests/filesystem_name_grammar.rs | 45 ++++++ core/docs/issues/352/DIRECT_STORE.md | 19 ++- engine/crates/layerfs-node/src/discovery.rs | 3 + .../layerfs-node/src/discovery_windows.rs | 1 + engine/crates/layerfs-node/src/dispatch.rs | 60 ++++--- engine/crates/layerfs-node/src/lib.rs | 1 + engine/crates/layerfs-node/src/project.rs | 7 +- .../src/database/windows_identity.rs | 45 +++--- .../tests/allocation_windows.rs | 5 +- .../src/backend/sqlite/windows_lane.rs | 150 ++++++++++++++---- engine/crates/layerfs-persistence/src/lib.rs | 3 +- .../layerfs-persistence/src/store/mod.rs | 2 +- .../layerfs-persistence/src/store/open.rs | 13 +- .../tests/windows_lane_abandoned.rs | 25 +-- .../tests/windows_store_native.rs | 3 +- .../crates/layerfs-project/src/import/host.rs | 6 +- .../src/import/host_wtf8_inner.rs | 82 +++++++--- .../crates/layerfs-project/src/import/scan.rs | 5 +- .../layerfs-project/src/import/source.rs | 9 +- .../src/import/windows_identity.rs | 22 ++- .../layerfs-project/tests/init_sqlite.rs | 67 +++++++- .../tests/name_roundtrip_windows.rs | 118 ++++++++++---- .../layerfs-project/tests/support/mod.rs | 4 +- .../layerfs-store-service/src/install.rs | 6 +- .../layerfs-store-service/src/kernel_guard.rs | 4 +- .../src/overlay/backing.rs | 4 +- .../tests/kernel_guard.rs | 7 +- tools/check_product_boundary.py | 2 + tools/test_check_product_boundary.py | 11 ++ 38 files changed, 623 insertions(+), 219 deletions(-) create mode 100644 core/crates/layerfs-content/tests/filesystem_name_grammar.rs diff --git a/.gitattributes b/.gitattributes index f254f0cc3..62d27081f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,2 @@ -* text=auto eol=lf -*.bin binary -poc/evidence/**/*.typescript -text +# Sealed fixtures are hashed byte-exactly by tests; no line-ending conversion. +core/crates/layerfs-content/tests/fixtures/filesystem/** -text diff --git a/.gitignore b/.gitignore index 70c937bd2..74be77db6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ target/ # The Node addon that api/scripts/build-native.mjs builds beside its crate. /engine/crates/layerfs-node/layerfs.node +# Dead post-restructure paths where only local build output ever lands +# (K3-R3 F1: a stale addon copy and pnpm trees entered a merge once). +/sdk/js/native/layerfs.node +/dsh/ /target-linux/ /eval/runs/ /eval/datasets/ diff --git a/AGENTS.md b/AGENTS.md index 36ac19296..267c5d7aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,9 +165,10 @@ remains a separate MEMORY/OFF/EXCLUSIVE database file. Linux-only Store sentence above for this route only; Workspace, FUSE, Exec and daemon calls still refuse `Unsupported` on Windows, and the dsh macOS and WSL2 exceptions are unchanged. Recorded limitations: Windows Init has - no hard-link membership and no backing-inside-source alias check, carries - non-UTF-8 names lossily and synthesizes the mode from the read-only - attribute; overlay allocation is the logical length. See + no hard-link membership and synthesizes the mode from the read-only + attribute; names travel as lossless WTF-8 (the backing-inside-source check + walks the audited volume-serial + file-index identity, and overlay + allocation is the real on-disk footprint). See [the Windows-native route](core/docs/issues/352/DIRECT_STORE.md). - **Plugin-neutral LayerFS (owner, 2026-10-24).** LayerFS is agnostic to dsh and every other plugin. The macOS and Windows worker routes above are open diff --git a/api/README.md b/api/README.md index c10325271..8b4e477b2 100644 --- a/api/README.md +++ b/api/README.md @@ -41,7 +41,7 @@ On Windows there is also the native direct route (owner direction itself — fs, branch, project and operator calls in this process, no worker and no daemon; workspace, exec and daemon calls refuse `Unsupported`. See -[the Windows-native route](../engine/docs/issues/352/DIRECT_STORE.md). +[the Windows-native route](../core/docs/issues/352/DIRECT_STORE.md). ## The folder diff --git a/api/agent/test/store_concurrent.mjs b/api/agent/test/store_concurrent.mjs index e2d34d2e7..e93f5e088 100644 --- a/api/agent/test/store_concurrent.mjs +++ b/api/agent/test/store_concurrent.mjs @@ -6,10 +6,11 @@ // serialize them; every write must land. The parent reads all four files // back and counts the Commits. No Docker, no daemon, no WSL2; on Linux the // Store sits on the E2E launcher's volume (LAYERFS_STORE_PATH): -// node sdk/js/test/store_concurrent.mjs # parent run -// node sdk/js/test/store_concurrent.mjs --child +// node api/agent/test/store_concurrent.mjs # parent run +// node api/agent/test/store_concurrent.mjs --child import assert from "node:assert/strict"; import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -55,7 +56,7 @@ try { const children = []; for (let n = 1; n <= WRITERS; n++) { - children.push(spawn(process.execPath, [import.meta.url.pathname ?? process.argv[1], "--child", home, store, `w${n}`], { + children.push(spawn(process.execPath, [fileURLToPath(import.meta.url), "--child", home, store, `w${n}`], { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LAYERFS_STORE_PATH: store }, })); diff --git a/api/agent/test/store_lifecycle.mjs b/api/agent/test/store_lifecycle.mjs index 48b3753e5..b0be999cc 100644 --- a/api/agent/test/store_lifecycle.mjs +++ b/api/agent/test/store_lifecycle.mjs @@ -5,7 +5,7 @@ // daemon, no WSL2. The Store's volume is LAYERFS_STORE_PATH's parent when // the E2E launcher provides one (Linux); otherwise this machine's temporary // volume, which the Windows route guards as NTFS: -// node sdk/js/test/store_lifecycle.mjs +// node api/agent/test/store_lifecycle.mjs import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; diff --git a/api/agent/test/store_matrix.mjs b/api/agent/test/store_matrix.mjs index ce7bda4a1..11fa4d311 100644 --- a/api/agent/test/store_matrix.mjs +++ b/api/agent/test/store_matrix.mjs @@ -6,7 +6,7 @@ // (merge, publish) — plus the Windows refusal of the daemon-only workspace // surface. No Docker, no daemon, no WSL2; on Linux the Store sits on the // E2E launcher's volume (LAYERFS_STORE_PATH): -// node sdk/js/test/store_matrix.mjs +// node api/agent/test/store_matrix.mjs import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; diff --git a/core/AGENTS.md b/core/AGENTS.md index ac6289c3c..fda8f34ec 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -184,13 +184,16 @@ adds audited Windows FFI beside the macOS seal wrapper. The guard - Persistence keeps `deny(unsafe_code)` with two allowed modules: `src/backend/sqlite/file_control.rs` (the macOS seal wrapper, unchanged) and `src/backend/sqlite/windows_lane.rs` (the named kernel mutex writer - lane and `GetVolumeInformationW` volume filesystem). + lane and `GetVolumeInformationByHandleW` volume filesystem). - layerfs-project moves forbid→deny with one allowed module, `src/import/windows_identity.rs` (`GetFileInformationByHandle`: volume serial, file index, creation time). - layerfs-overlay moves forbid→deny with one allowed module, `src/database/windows_identity.rs` (handle/path identity, `GetFileInformationByHandleEx` allocation, `GetDiskFreeSpaceExW`). +- layerfs-node declares deny with two allowed discovery modules: + `src/discovery.rs` (unix uid/flock, upstream's) and + `src/discovery_windows.rs` (the named kernel mutex discovery turn). - Storage's `src/encoding/codec.rs` authorization is unchanged. Acquisition rows are keyed by dev+inode. On Windows the identity MUST be diff --git a/core/crates/layerfs-content/src/filesystem/path.rs b/core/crates/layerfs-content/src/filesystem/path.rs index dad84fa64..bfb2a20d4 100644 --- a/core/crates/layerfs-content/src/filesystem/path.rs +++ b/core/crates/layerfs-content/src/filesystem/path.rs @@ -1,10 +1,13 @@ //! Canonical names and paths: byte grammar, ordering and declared bounds. //! -//! A name is one nonempty, at-most-255-byte UTF-8 component without NUL, `/` or -//! `\`, and never `.` or `..`. A path is either the empty root or a sequence of -//! such components joined by single separators, at most 4,096 bytes and 256 -//! components. Ordering is byte order, which is what the sorted tree engine -//! requires and what the reference uses. +//! A name is one nonempty, at-most-255-byte component without NUL, `/` or +//! `\`, and never `.` or `..`; its bytes are well-formed UTF-8 or, for a +//! Windows host name holding an unpaired surrogate, well-formed WTF-8 (the +//! same encoding with surrogates as their three-byte forms). A path is +//! either the empty root or a sequence of such components joined by single +//! separators, at most 4,096 bytes and 256 components. Ordering is byte +//! order, which is what the sorted tree engine requires and what the +//! reference uses. use std::cmp::Ordering; @@ -36,7 +39,8 @@ impl PathName { &self.bytes } - /// The name as text; construction proved it is UTF-8. + /// The name as text; a WTF-8 name outside UTF-8 answers the empty + /// string rather than a lossy spelling — `as_bytes` carries it whole. pub fn as_str(&self) -> &str { std::str::from_utf8(&self.bytes).map_or("", |value| value) } @@ -97,7 +101,8 @@ impl LogicalPath { &self.bytes } - /// The path as text; construction proved it is UTF-8. + /// The path as text; a WTF-8 path outside UTF-8 answers the empty + /// string rather than a lossy spelling — `as_bytes` carries it whole. pub fn as_str(&self) -> &str { std::str::from_utf8(&self.bytes).map_or("", |value| value) } @@ -150,7 +155,7 @@ fn validate_path(bytes: &[u8]) -> ContentResult<()> { if bytes.is_empty() { return Ok(()); } - if std::str::from_utf8(bytes).is_err() { + if !is_wtf8(bytes) { return Err(ContentError::InvalidUtf8); } let mut components = 0_usize; @@ -173,7 +178,7 @@ fn validate_name(bytes: &[u8]) -> ContentResult<()> { if bytes.len() > MAXIMUM_NAME_BYTES { return Err(ContentError::PathLimitExceeded); } - if std::str::from_utf8(bytes).is_err() { + if !is_wtf8(bytes) { return Err(ContentError::InvalidUtf8); } if bytes == b"." @@ -186,3 +191,59 @@ fn validate_name(bytes: &[u8]) -> ContentResult<()> { } Ok(()) } + +/// True for well-formed UTF-8, and for well-formed WTF-8 — the same +/// grammar with an unpaired surrogate as its three-byte form, which a +/// Windows host name can hold. A pair spelled as two surrogate sequences +/// is not canonical WTF-8 and is refused, so one name has one byte +/// spelling on every platform. Overlong forms, 5-byte leads, truncated +/// tails and out-of-range values stay refused, so every strict UTF-8 +/// name keeps its exact acceptance. +fn is_wtf8(bytes: &[u8]) -> bool { + let mut index = 0; + let mut pending_high_surrogate = false; + while index < bytes.len() { + let lead = bytes[index]; + if lead < 0x80 { + index += 1; + pending_high_surrogate = false; + continue; + } + let (width, minimum, maximum) = match lead { + 0xC2..=0xDF => (2_usize, 0x80_u32, 0x7FF), + 0xE0..=0xEF => (3, 0x800, 0xFFFF), + 0xF0..=0xF4 => (4, 0x10000, 0x10FFFF), + _ => return false, + }; + if index + width > bytes.len() { + return false; + } + let mut value = match width { + 2 => u32::from(lead & 0x1F), + 3 => u32::from(lead & 0x0F), + _ => u32::from(lead & 0x07), + }; + for continuation in &bytes[index + 1..index + width] { + if continuation & 0b1100_0000 != 0b1000_0000 { + return false; + } + value = (value << 6) | u32::from(continuation & 0b0011_1111); + } + if value < minimum || value > maximum { + return false; + } + // A high surrogate immediately followed by its low half as a second + // sequence is a pair spelled apart — not canonical WTF-8. + if (0xD800..=0xDBFF).contains(&value) { + pending_high_surrogate = true; + } else if (0xDC00..=0xDFFF).contains(&value) { + if pending_high_surrogate { + return false; + } + } else { + pending_high_surrogate = false; + } + index += width; + } + true +} diff --git a/core/crates/layerfs-content/tests/filesystem_name_grammar.rs b/core/crates/layerfs-content/tests/filesystem_name_grammar.rs new file mode 100644 index 000000000..88e30bfbb --- /dev/null +++ b/core/crates/layerfs-content/tests/filesystem_name_grammar.rs @@ -0,0 +1,45 @@ +//! The name grammar accepts well-formed UTF-8 and the canonical WTF-8 +//! spelling of an unpaired surrogate (a Windows host name); a pair spelled +//! as two surrogate sequences and every broken encoding are refused, so +//! one name has one byte spelling on every platform. +use layerfs_content::PathName; + +#[test] +fn utf8_names_and_unpaired_surrogates_are_accepted() { + for bytes in [ + "ascii.txt".as_bytes(), + "中文".as_bytes(), + // The supplementary plane keeps its UTF-8 scalar spelling. + "🚀-🧬".as_bytes(), + // An unpaired high (U+D800) and an unpaired low (U+DC80) surrogate, + // each in its three-byte WTF-8 form. + &[0xED, 0xA0, 0x80][..], + &[0xED, 0xB2, 0x80][..], + // An unpaired surrogate beside ordinary text. + &[b'A', 0xED, 0xA0, 0x80, b'B'][..], + // Two unpaired high surrogates in a row are each unpaired. + &[0xED, 0xA0, 0x80, 0xED, 0xA0, 0xBF][..], + ] { + assert!(PathName::from_bytes(bytes).is_ok(), "{bytes:02x?}"); + } +} + +#[test] +fn split_pairs_and_broken_encodings_are_refused() { + for bytes in [ + // 🚀 (U+1F680) spelled as D83D DE80 in two surrogate sequences. + &[0xED, 0xA0, 0xBD, 0xED, 0xBA, 0x80][..], + // Overlong encodings of '/' and 'A'. + &[0xE0, 0x80, 0xAF][..], + &[0xC0, 0xAF][..], + // Beyond U+10FFFF and a bare continuation byte. + &[0xF5, 0x80, 0x80, 0x80][..], + &[0x80][..], + // A truncated four-byte tail. + &[0xF0, 0x9F][..], + // A malformed continuation. + &[0xE4, 0xB8, 0x00][..], + ] { + assert!(PathName::from_bytes(bytes).is_err(), "{bytes:02x?}"); + } +} diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index 51dcfe720..87d8ffa4e 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -119,14 +119,17 @@ session would each hold its own lane (Linux's directory flock crosses sessions); the product opens its Store within one login session today, and cross-session coordination would need the `Global\` namespace with a privilege evaluation (K3 review F2); Windows Init carries names as -lossless WTF-8 (an unpaired surrogate included, byte-exact round trip), -synthesizes the mode from the read-only attribute, has no hard-link -membership, and its backing-inside-source check resolves the placement's -spelling (a subst drive or unresolvable alias is not recognised); overlay -allocation is the logical length. Last-owner overlay-file deletion applies -the same audited identity on Windows as on Linux (volume serial + file -index, sole link, checked on both the path's entry and the retained -handle). +lossless WTF-8 end to end (an unpaired surrogate included, byte-exact +round trip through the codec and the audited identity probes) and +synthesizes the mode from the read-only attribute, and has no hard-link +membership. The backing-inside-source check walks the audited +volume-serial + file-index identity of every ancestor (a subst drive or +junction into the source is recognised and refused, as on Linux), and +overlay allocation is the entry's real on-disk footprint. Last-owner +overlay-file deletion applies the same audited identity on Windows as on +Linux (volume serial + file index, sole link, checked on both the path's +entry and the retained handle), opening the path itself, never a +substituted reparse point. When the owner merges this branch, the observation period runs by a fixed checklist (each step reads a mechanism that already exists, none diff --git a/engine/crates/layerfs-node/src/discovery.rs b/engine/crates/layerfs-node/src/discovery.rs index c0e058d87..221e94809 100644 --- a/engine/crates/layerfs-node/src/discovery.rs +++ b/engine/crates/layerfs-node/src/discovery.rs @@ -1,4 +1,7 @@ //! Private, permission-authorized project and Sandbox discovery. No mutation replay. +// One audited unsafe surface with the Windows twin: uid/flock calls std does +// not expose; the boundary guard admits only the two discovery modules. +#![allow(unsafe_code)] use crate::{ job::{failed, refused}, registry::{self, Mount, Process, Project, SandboxState}, diff --git a/engine/crates/layerfs-node/src/discovery_windows.rs b/engine/crates/layerfs-node/src/discovery_windows.rs index c66cf53c5..d37ae8ed6 100644 --- a/engine/crates/layerfs-node/src/discovery_windows.rs +++ b/engine/crates/layerfs-node/src/discovery_windows.rs @@ -9,6 +9,7 @@ //! unix discipline: this process's calls take turns on one directory, and //! between processes one named kernel mutex per directory is held, released //! by the kernel when its holder exits. +#![allow(unsafe_code)] use crate::{ job::{failed, refused}, registry::{self, Project}, diff --git a/engine/crates/layerfs-node/src/dispatch.rs b/engine/crates/layerfs-node/src/dispatch.rs index 7ecf4a35e..7b8cf1954 100644 --- a/engine/crates/layerfs-node/src/dispatch.rs +++ b/engine/crates/layerfs-node/src/dispatch.rs @@ -2,9 +2,9 @@ //! public table (`call`: project, branch, fs, sandbox, workspace) and the //! backend table (`backendCall`: `backend.store.*`, `backend.daemon.*` and the //! runtime's `runtime.*` reads and actions). -use crate::job::{refused, Job, Outcome}; #[cfg(unix)] use crate::job; +use crate::job::{refused, Job, Outcome}; use crate::{convert, direct, project}; #[cfg(unix)] use crate::{exec, output, registry, workspace}; @@ -34,29 +34,29 @@ fn dispatch(op: &str, args: SdkValue) -> Outcome { } #[cfg(unix)] { - match op { - "project.init" => project::init(&args), - "project.list_sandboxes" => workspace::sandboxes(&args), - "sandbox.list_workspaces" => listed(daemon_backend(op, args)?), - "sandbox.create" => crate::sandbox::create(&args), - "sandbox.close" => crate::sandbox::close(&args), - "workspace.mount" => workspace::mount(&args), - "workspace.commit" => workspace::commit(&args), - "workspace.unmount" => workspace::unmount(&args), - "workspace.exec" if args.get_bool("background") => Err(refused( - "InvalidInput", - "background launch is backend.daemon.process.start", - )), - "workspace.exec" => exec::exec(&args), - "workspace.exec_release" => output::release(&args), - _ if op.starts_with("backend.") || op.starts_with("runtime.") => Err(refused( - "UnknownMethod", - "backend and runtime methods require backendCall", - )), - // Store calls run in this process; there is no other route. - _ if op.contains('.') => direct::call(op, args, false), - _ => Err(refused("UnknownMethod", format!("no operation {op}"))), - } + match op { + "project.init" => project::init(&args), + "project.list_sandboxes" => workspace::sandboxes(&args), + "sandbox.list_workspaces" => listed(daemon_backend(op, args)?), + "sandbox.create" => crate::sandbox::create(&args), + "sandbox.close" => crate::sandbox::close(&args), + "workspace.mount" => workspace::mount(&args), + "workspace.commit" => workspace::commit(&args), + "workspace.unmount" => workspace::unmount(&args), + "workspace.exec" if args.get_bool("background") => Err(refused( + "InvalidInput", + "background launch is backend.daemon.process.start", + )), + "workspace.exec" => exec::exec(&args), + "workspace.exec_release" => output::release(&args), + _ if op.starts_with("backend.") || op.starts_with("runtime.") => Err(refused( + "UnknownMethod", + "backend and runtime methods require backendCall", + )), + // Store calls run in this process; there is no other route. + _ if op.contains('.') => direct::call(op, args, false), + _ => Err(refused("UnknownMethod", format!("no operation {op}"))), + } } #[cfg(windows)] { @@ -200,10 +200,20 @@ pub(crate) fn backend_call(op: String, args: JsUnknown) -> napi::Result direct::call(&op, args, true), - _ => Err(refused( + // Daemon-only operations say so by name; an unknown + // operation is still unknown here, as on unix (K3-R3 F13). + "backend.daemon.workspace.status" + | "backend.daemon.process.start" + | "backend.daemon.process.list" + | "backend.daemon.process.cancel" + | "backend.daemon.sandbox.get_log" + | "backend.daemon.workspace.get_storage" + | "backend.daemon.workspace.list_changes" + | "runtime.sandbox.reclaim" => Err(refused( "Unsupported", "this backend call needs a daemon; the Windows-native Store route serves Store calls in-process", )), + _ => Err(refused("UnknownMethod", format!("no backend operation {op}"))), } } }) diff --git a/engine/crates/layerfs-node/src/lib.rs b/engine/crates/layerfs-node/src/lib.rs index 9abfe3d6e..d80a486dc 100644 --- a/engine/crates/layerfs-node/src/lib.rs +++ b/engine/crates/layerfs-node/src/lib.rs @@ -1,4 +1,5 @@ //! Node entry points for the public SDK and its backend table (#428). +#![deny(unsafe_code)] mod convert; mod direct; #[cfg(unix)] diff --git a/engine/crates/layerfs-node/src/project.rs b/engine/crates/layerfs-node/src/project.rs index 1b7ccd00d..b43b7da08 100644 --- a/engine/crates/layerfs-node/src/project.rs +++ b/engine/crates/layerfs-node/src/project.rs @@ -49,9 +49,10 @@ pub(crate) fn init(args: &SdkValue) -> Outcome { "project.init installs the Store at the client's storePath (LAYERFS_STORE_PATH)", )), }; - // the Store's directory is made by the install; its volume must carry the Store's locks - let global = store_path.parent().unwrap_or(&store_path); - layerfs_store_service::kernel_guard::check(global) + // the Store's directory is made by the install; its volume must carry + // the Store's locks. check takes the Store file and strips the parent + // itself, like every caller. + layerfs_store_service::kernel_guard::check(&store_path) .map_err(|reason| refused("Unsupported", reason.0))?; let directory = home.join(name); // The home entry is the project: creating it is the one existence check, diff --git a/engine/crates/layerfs-overlay/src/database/windows_identity.rs b/engine/crates/layerfs-overlay/src/database/windows_identity.rs index f037d4f19..9ed4a6535 100644 --- a/engine/crates/layerfs-overlay/src/database/windows_identity.rs +++ b/engine/crates/layerfs-overlay/src/database/windows_identity.rs @@ -26,16 +26,17 @@ pub fn windows_handle_identity(file: &File) -> io::Result { use windows_sys::Win32::Storage::FileSystem::GetFileInformationByHandle; let mut information = BY_HANDLE_FILE_INFORMATION::default(); let read = unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) }; - finish(read, information) + if read == 0 { + return Err(io::Error::last_os_error()); + } + Ok(identity_of(information)) } -/// The identity of `path`'s entry, read as attributes only: no data access, -/// no link traversal; directories need the backup-semantics flag. /// The entry's on-disk allocation in bytes (compression/sparse aware), /// read through FileStandardInfo on the open handle. pub fn windows_allocation_size(file: &File) -> io::Result { use windows_sys::Win32::Storage::FileSystem::{ - GetFileInformationByHandleEx, FILE_STANDARD_INFO, FileStandardInfo, + FileStandardInfo, GetFileInformationByHandleEx, FILE_STANDARD_INFO, }; let mut info = FILE_STANDARD_INFO::default(); let ok = unsafe { @@ -52,16 +53,22 @@ pub fn windows_allocation_size(file: &File) -> io::Result { Ok(info.AllocationSize.max(0) as u64) } +/// The identity of `path`'s entry, read as attributes only: no data access, +/// no link traversal; directories need the backup-semantics flag, and a +/// reparse point is opened itself, never followed, matching unix's +/// `symlink_metadata` refusal of a substituted overlay file. pub fn windows_path_identity(path: &Path) -> io::Result { + use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; use windows_sys::Win32::Storage::FileSystem::{ - CreateFileW, GetFileInformationByHandle, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, - FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, + CreateFileW, GetFileInformationByHandle, FILE_FLAG_BACKUP_SEMANTICS, + FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + OPEN_EXISTING, }; + // Raw units, not a lossy string: names stay byte-exact (K3-R3 F7). let wide: Vec = path .as_os_str() - .to_string_lossy() - .encode_utf16() + .encode_wide() .chain(std::iter::once(0)) .collect(); let handle = unsafe { @@ -71,7 +78,7 @@ pub fn windows_path_identity(path: &Path) -> io::Result { FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, std::ptr::null(), OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, std::ptr::null_mut(), ) }; @@ -80,28 +87,30 @@ pub fn windows_path_identity(path: &Path) -> io::Result { } let mut information = BY_HANDLE_FILE_INFORMATION::default(); let read = unsafe { GetFileInformationByHandle(handle, &mut information) }; + // Capture the failure before another syscall can overwrite it (K3-R3 F9). + let failure = (read == 0).then(io::Error::last_os_error); unsafe { CloseHandle(handle) }; - finish(read, information) + match failure { + Some(error) => Err(error), + None => Ok(identity_of(information)), + } } -fn finish(read: i32, information: BY_HANDLE_FILE_INFORMATION) -> io::Result { - if read == 0 { - return Err(io::Error::last_os_error()); - } - Ok(WindowsIdentity { +fn identity_of(information: BY_HANDLE_FILE_INFORMATION) -> WindowsIdentity { + WindowsIdentity { device: u64::from(information.dwVolumeSerialNumber), index: (u64::from(information.nFileIndexHigh) << 32) | u64::from(information.nFileIndexLow), links: u64::from(information.nNumberOfLinks), - }) + } } /// The bytes available to the caller on `directory`'s volume. pub(crate) fn windows_free_bytes(directory: &Path) -> io::Result { + use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; let wide: Vec = directory .as_os_str() - .to_string_lossy() - .encode_utf16() + .encode_wide() .chain(std::iter::once(0)) .collect(); let mut free: u64 = 0; diff --git a/engine/crates/layerfs-overlay/tests/allocation_windows.rs b/engine/crates/layerfs-overlay/tests/allocation_windows.rs index 5fdd57961..afbad3684 100644 --- a/engine/crates/layerfs-overlay/tests/allocation_windows.rs +++ b/engine/crates/layerfs-overlay/tests/allocation_windows.rs @@ -18,7 +18,10 @@ fn allocated_bytes_report_the_ondisk_footprint_of_the_written_tail() { let mut file = File::new(&db, source, 3, pattern(300_001, 5)); let _ = file.read(0, 16); let state = db.allocation().unwrap(); - assert!(state.allocated_bytes > 0, "a written overlay holds clusters"); + assert!( + state.allocated_bytes > 0, + "a written overlay holds clusters" + ); assert!( state.allocated_bytes >= state.logical_bytes, "cluster rounding keeps the footprint at or above the tail" diff --git a/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs b/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs index 5960bf6ed..86c24fce9 100644 --- a/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs +++ b/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs @@ -22,6 +22,7 @@ use windows_sys::Win32::System::Threading::{ /// The Store directory's cooperative writer lane. pub(crate) struct LaneMutex { handle: usize, + closed: bool, } /// The lane's kernel object name: SHA-256 of the resolved directory's @@ -33,7 +34,10 @@ pub(crate) fn lane_name(directory: &Path) -> String { let digest = Sha256::digest(resolved.as_os_str().as_encoded_bytes()); let mut tag = [0u8; 8]; tag.copy_from_slice(&digest[..8]); - format!("Local\\layerfs-writer-lane-{:016x}", u64::from_be_bytes(tag)) + format!( + "Local\\layerfs-writer-lane-{:016x}", + u64::from_be_bytes(tag) + ) } impl LaneMutex { @@ -54,6 +58,7 @@ impl LaneMutex { if waited == WAIT_OBJECT_0 || waited == WAIT_ABANDONED { Ok(Self { handle: handle as usize, + closed: false, }) } else { unsafe { CloseHandle(handle) }; @@ -62,47 +67,109 @@ impl LaneMutex { } /// Releases the lane and destroys this handle; a failed release returns - /// the retained lane unchanged. - pub(crate) fn release(self) -> Result<(), Self> { + /// the retained lane unchanged, so the quarantining caller keeps holding + /// it, and its eventual drop retries the release after SQLite teardown. + pub(crate) fn release(mut self) -> Result<(), Self> { let handle = self.handle as windows_sys::Win32::Foundation::HANDLE; if unsafe { ReleaseMutex(handle) } == 0 { return Err(Self { handle: self.handle, + closed: false, }); } + self.closed = true; unsafe { CloseHandle(handle) }; Ok(()) } } -/// The filesystem name of the local volume holding `directory`, for the -/// kernel guard's allowlist. Only a local drive letter is examined: UNC, -/// device and verbatim prefixes are network or device paths whose locks do -/// not coordinate Store writers. -pub fn windows_volume_filesystem(directory: &Path) -> io::Result { - use std::path::{Component, Prefix}; - let root = match directory.components().next() { - Some(Component::Prefix(prefix)) => match prefix.kind() { - Prefix::Disk(letter) => format!("{}:\\", letter as char), - _ => { - return Err(io::Error::new( - io::ErrorKind::Unsupported, - "a network or device path is not a local volume", - )) - } - }, - _ => { - return Err(io::Error::new( - io::ErrorKind::Unsupported, - "the directory has no volume prefix", - )) +impl Drop for LaneMutex { + /// Best-effort release of a lane an error or panic path left unreleased + /// (K3-R3 F6): the release can fail on a thread that does not own the + /// mutex — the owner exits into the kernel's abandoned-mutex handoff — + /// while the descriptor closes exactly once either way. Linux gets this + /// for free from `Flock`'s own drop. + fn drop(&mut self) { + if self.closed { + return; } + let handle = self.handle as windows_sys::Win32::Foundation::HANDLE; + self.closed = true; + unsafe { ReleaseMutex(handle) }; + unsafe { CloseHandle(handle) }; + } +} + +/// The filesystem name of the volume actually holding `directory`, for the +/// kernel guard's allowlist. The directory's own handle answers, and its +/// final path decides locality: the spelling is irrelevant (a verbatim +/// local path, a junction or a mapped drive all resolve), and a final path +/// that lands on a share (`\\?\UNC\...`) is refused — its locks travel the +/// SMB redirector, not this kernel's lock manager (K3-R3 F4). +pub fn windows_volume_filesystem(directory: &Path) -> io::Result { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, GetFinalPathNameByHandleW, GetVolumeInformationByHandleW, + FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + OPEN_EXISTING, VOLUME_NAME_DOS, + }; + let wide: Vec = directory + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + format!( + "the directory {} cannot be opened to examine its volume: {}", + directory.display(), + io::Error::last_os_error() + ), + )); + } + let mut final_path = [0u16; 1024]; + let mut written = unsafe { + GetFinalPathNameByHandleW( + handle, + final_path.as_mut_ptr(), + final_path.len() as u32, + VOLUME_NAME_DOS, + ) }; - let wide: Vec = root.encode_utf16().chain(std::iter::once(0)).collect(); + if written as usize > final_path.len() { + final_path = vec![0u16; written as usize].try_into().unwrap_or([0; 1024]); + written = unsafe { + GetFinalPathNameByHandleW( + handle, + final_path.as_mut_ptr(), + final_path.len() as u32, + VOLUME_NAME_DOS, + ) + }; + } + let share = written != 0 + && final_path + .iter() + .take(8) + .zip(r"\\?\UNC\".encode_utf16()) + .all(|(unit, expected)| *unit == expected); let mut filesystem = [0u16; 16]; let examined = unsafe { - windows_sys::Win32::Storage::FileSystem::GetVolumeInformationW( - wide.as_ptr(), + GetVolumeInformationByHandleW( + handle, std::ptr::null_mut(), 0, std::ptr::null_mut(), @@ -112,10 +179,35 @@ pub fn windows_volume_filesystem(directory: &Path) -> io::Result { filesystem.len() as u32, ) }; - if examined == 0 { + // Capture the failure before another syscall can overwrite it. + let failure = (examined == 0).then(io::Error::last_os_error); + unsafe { CloseHandle(handle) }; + if written == 0 { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + format!( + "the final path of {} cannot be read: {}", + directory.display(), + io::Error::last_os_error() + ), + )); + } + if share { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + format!( + "the directory {} resolves onto a network share; the Store needs a local volume", + directory.display() + ), + )); + } + if let Some(error) = failure { return Err(io::Error::new( io::ErrorKind::Unsupported, - format!("the volume {root} cannot be examined"), + format!( + "the volume of {} cannot be examined: {error}", + directory.display() + ), )); } let end = filesystem.iter().position(|&wide| wide == 0).unwrap_or(0); diff --git a/engine/crates/layerfs-persistence/src/lib.rs b/engine/crates/layerfs-persistence/src/lib.rs index ddba61861..27ede3b8a 100644 --- a/engine/crates/layerfs-persistence/src/lib.rs +++ b/engine/crates/layerfs-persistence/src/lib.rs @@ -14,7 +14,6 @@ pub use backend::sqlite::reclamation::{SpaceReclamation, RECLAMATION_PAGE_LIMIT} pub use backend::sqlite::statement_work::StatementPhaseWork; pub use backend::sqlite::thread_work::thread_work; #[cfg(target_os = "windows")] -#[cfg(target_os = "windows")] pub use backend::sqlite::windows_volume_filesystem; pub use config::{ BackendSelection, PersistenceConfig, SqliteAcquisitionSchema, SqlitePackLayout, @@ -30,8 +29,8 @@ pub use storage::merge_records::{ pub(crate) use storage::provider as storage_provider; pub(crate) use storage::publication; pub use storage_provider::StorageProvider; -pub use store::seal::SealedStore; pub use store::lane::{store_lane, StoreLane}; +pub use store::seal::SealedStore; mod metadata; mod objects; diff --git a/engine/crates/layerfs-persistence/src/store/mod.rs b/engine/crates/layerfs-persistence/src/store/mod.rs index c417ec500..6805a0f23 100644 --- a/engine/crates/layerfs-persistence/src/store/mod.rs +++ b/engine/crates/layerfs-persistence/src/store/mod.rs @@ -1,6 +1,6 @@ //! Responsibility-scoped implementation modules and reexports. -pub mod lane; pub(crate) mod config; pub(crate) mod handles; +pub mod lane; pub(crate) mod open; pub(crate) mod seal; diff --git a/engine/crates/layerfs-persistence/src/store/open.rs b/engine/crates/layerfs-persistence/src/store/open.rs index 328d0c531..753aa88f8 100644 --- a/engine/crates/layerfs-persistence/src/store/open.rs +++ b/engine/crates/layerfs-persistence/src/store/open.rs @@ -101,13 +101,12 @@ impl Handles { // a raw second hold here would self-deadlock on Linux, where // flock is held per open file description. let lane = session.hold_lane()?; - let setup = - (|| -> Result<(), crate::backend::records::BackendError> { - session.add_binding_hints()?; - session.add_lineage(crate::history::backfill_lineage)?; - session.add_merge_scratch()?; - session.add_file_digests() - })(); + let setup = (|| -> Result<(), crate::backend::records::BackendError> { + session.add_binding_hints()?; + session.add_lineage(crate::history::backfill_lineage)?; + session.add_merge_scratch()?; + session.add_file_digests() + })(); drop(lane); setup?; } diff --git a/engine/crates/layerfs-persistence/tests/windows_lane_abandoned.rs b/engine/crates/layerfs-persistence/tests/windows_lane_abandoned.rs index 416b74138..484865f9b 100644 --- a/engine/crates/layerfs-persistence/tests/windows_lane_abandoned.rs +++ b/engine/crates/layerfs-persistence/tests/windows_lane_abandoned.rs @@ -50,19 +50,23 @@ fn a_dead_lane_holder_does_not_block_the_next_writer() { let digest = Sha256::digest(resolved.as_os_str().as_encoded_bytes()); let mut tag = [0u8; 8]; tag.copy_from_slice(&digest[..8]); - let name: Vec = format!("Local\\layerfs-writer-lane-{:016x}", u64::from_be_bytes(tag)) - .encode_utf16() - .chain(std::iter::once(0)) - .collect(); + let name: Vec = format!( + "Local\\layerfs-writer-lane-{:016x}", + u64::from_be_bytes(tag) + ) + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); let handle = unsafe { - windows_sys::Win32::System::Threading::CreateMutexW( - std::ptr::null(), - 0, - name.as_ptr(), - ) + windows_sys::Win32::System::Threading::CreateMutexW(std::ptr::null(), 0, name.as_ptr()) }; assert!(!handle.is_null()); - let waited = unsafe { windows_sys::Win32::System::Threading::WaitForSingleObject(handle, windows_sys::Win32::System::Threading::INFINITE) }; + let waited = unsafe { + windows_sys::Win32::System::Threading::WaitForSingleObject( + handle, + windows_sys::Win32::System::Threading::INFINITE, + ) + }; assert_eq!(waited, windows_sys::Win32::Foundation::WAIT_OBJECT_0); // The thread owns the mutex and now ends without ReleaseMutex: the // kernel marks it abandoned for the next waiter. @@ -74,4 +78,3 @@ fn a_dead_lane_holder_does_not_block_the_next_writer() { reopened.storage.reserve(reserve()).unwrap(); reopened.shutdown(); } - diff --git a/engine/crates/layerfs-persistence/tests/windows_store_native.rs b/engine/crates/layerfs-persistence/tests/windows_store_native.rs index 439f1da7c..ae1b3df97 100644 --- a/engine/crates/layerfs-persistence/tests/windows_store_native.rs +++ b/engine/crates/layerfs-persistence/tests/windows_store_native.rs @@ -77,7 +77,8 @@ fn finish(mut child: std::process::Child) { } #[test] -fn windows_native_store_serves_sessions_and_serializes_foreign_processes() { dispatch_child(); +fn windows_native_store_serves_sessions_and_serializes_foreign_processes() { + dispatch_child(); let temp = support::Temp::new("windows-store-native"); let path = temp.join("store.sqlite"); let catalog = authority(b"windows-store-native"); diff --git a/engine/crates/layerfs-project/src/import/host.rs b/engine/crates/layerfs-project/src/import/host.rs index c0fa96bd7..1ec177611 100644 --- a/engine/crates/layerfs-project/src/import/host.rs +++ b/engine/crates/layerfs-project/src/import/host.rs @@ -1,7 +1,7 @@ //! Host byte encoding of paths and names, and the metadata identity Init -//! reads. Unix uses the raw bytes and the stat identity; Windows has no raw -//! byte spelling, so names are carried as their UTF-8 bytes (a name outside -//! UTF-8 becomes lossy — a recorded limitation of this route), and the +//! reads. Unix uses the raw bytes and the stat identity; Windows names +//! travel as lossless WTF-8 (an unpaired surrogate included, byte-exact +//! round trip through the codec and the audited probes), and the //! identity is the entry's real volume serial number and file index, its //! length, timestamps and a mode synthesized from the read-only attribute. //! Hard-link membership is not detected on Windows (the format supports it; diff --git a/engine/crates/layerfs-project/src/import/host_wtf8_inner.rs b/engine/crates/layerfs-project/src/import/host_wtf8_inner.rs index 3926a41d4..b4eaf97c5 100644 --- a/engine/crates/layerfs-project/src/import/host_wtf8_inner.rs +++ b/engine/crates/layerfs-project/src/import/host_wtf8_inner.rs @@ -1,8 +1,9 @@ // Lossless name bytes on Windows: the host name is UTF-16 code units, and -// every unit - including an unpaired surrogate - encodes as WTF-8, so the -// bytes decode back to the very same units. This file is the standard's -// encoding table and nothing else; the product (import/host.rs) and the -// name-roundtrip test each include it as a module. +// a well-formed pair encodes as its supplementary scalar (the same UTF-8 +// bytes Linux stores for that name), while an unpaired surrogate keeps its +// three-byte WTF-8 form. The bytes decode back to the very same units. +// This file is the standard's encoding table and nothing else; the product +// (import/host.rs) and the name-roundtrip test each include it as a module. use std::ffi::OsStr; use std::ffi::OsString; use std::os::windows::ffi::OsStrExt; @@ -10,8 +11,25 @@ use std::os::windows::ffi::OsStringExt; // One host name's WTF-8 bytes. pub fn encode(value: &OsStr) -> Vec { - let mut out = Vec::with_capacity(value.len()); - for unit in value.encode_wide() { + let units: Vec = value.encode_wide().collect(); + let mut out = Vec::with_capacity(units.len() * 3); + let mut index = 0; + while index < units.len() { + let unit = units[index]; + if (0xD800..=0xDBFF).contains(&unit) { + if let Some(&low) = units.get(index + 1) { + if (0xDC00..=0xDFFF).contains(&low) { + let scalar = + 0x1_0000 + ((u32::from(unit) - 0xD800) << 10) + (u32::from(low) - 0xDC00); + out.push(0xF0 | (scalar >> 18) as u8); + out.push(0x80 | ((scalar >> 12) & 0x3F) as u8); + out.push(0x80 | ((scalar >> 6) & 0x3F) as u8); + out.push(0x80 | (scalar & 0x3F) as u8); + index += 2; + continue; + } + } + } match unit { 0x0000..=0x007F => out.push(unit as u8), 0x0080..=0x07FF => { @@ -25,6 +43,7 @@ pub fn encode(value: &OsStr) -> Vec { } _ => unreachable!("a UTF-16 code unit is at most 16 bits"), } + index += 1; } out } @@ -35,24 +54,47 @@ pub fn decode(bytes: &[u8]) -> OsString { let mut index = 0; while index < bytes.len() { let first = bytes[index]; - let (unit, width) = if first < 0x80 { - (u16::from(first), 1) + let (width, scalar) = if first < 0x80 { + (1, u32::from(first)) } else if first & 0xE0 == 0xC0 { - let unit = (u16::from(first & 0x1F) << 6) - | u16::from(bytes.get(index + 1).copied().unwrap_or(0) & 0x3F); - (unit, 2) + ( + 2, + (u32::from(first & 0x1F) << 6) + | u32::from(bytes.get(index + 1).copied().unwrap_or(0) & 0x3F), + ) } else if first & 0xF0 == 0xE0 { - let unit = (u16::from(first & 0x0F) << 12) - | (u16::from(bytes.get(index + 1).copied().unwrap_or(0) & 0x3F) << 6) - | u16::from(bytes.get(index + 2).copied().unwrap_or(0) & 0x3F); - (unit, 3) + ( + 3, + (u32::from(first & 0x0F) << 12) + | (u32::from(bytes.get(index + 1).copied().unwrap_or(0) & 0x3F) << 6) + | u32::from(bytes.get(index + 2).copied().unwrap_or(0) & 0x3F), + ) + } else if first & 0xF8 == 0xF0 { + ( + 4, + (u32::from(first & 0x07) << 18) + | (u32::from(bytes.get(index + 1).copied().unwrap_or(0) & 0x3F) << 12) + | (u32::from(bytes.get(index + 2).copied().unwrap_or(0) & 0x3F) << 6) + | u32::from(bytes.get(index + 3).copied().unwrap_or(0) & 0x3F), + ) } else { - // Not produced by encode (a 4-byte sequence decodes beyond 16 - // bits); fall back to the replacement unit rather than - // panicking on foreign bytes. - (0xFFFD, 1) + // A lead byte encode never produces; fall back to the + // replacement unit rather than panicking on foreign bytes. + units.push(0xFFFD); + index += 1; + continue; }; - units.push(unit); + if width == 4 { + if let scalar @ 0x1_0000..=0x10FFFF = scalar { + let relative = scalar - 0x1_0000; + units.push(0xD800 + (relative >> 10) as u16); + units.push(0xDC00 + (relative & 0x3FF) as u16); + } else { + units.push(0xFFFD); + } + } else { + units.push(scalar as u16); + } index += width; } OsString::from_wide(&units) diff --git a/engine/crates/layerfs-project/src/import/scan.rs b/engine/crates/layerfs-project/src/import/scan.rs index 03458a6ba..26c01f3f9 100644 --- a/engine/crates/layerfs-project/src/import/scan.rs +++ b/engine/crates/layerfs-project/src/import/scan.rs @@ -17,7 +17,10 @@ use layerfs_content::filesystem::symlink::{emit_symlink, SymlinkTarget}; use layerfs_content::filesystem::FilesystemObjects; use layerfs_content::{inode_leaf::InodeKind, PathName}; use layerfs_storage::port::acquisition::{EntryKey, NewEntry, Placed}; -use std::{fs::{self, Metadata}, path::Path}; +use std::{ + fs::{self, Metadata}, + path::Path, +}; /// What one completed scan placed in backing. pub(crate) struct Scanned { diff --git a/engine/crates/layerfs-project/src/import/source.rs b/engine/crates/layerfs-project/src/import/source.rs index d13ab8a0f..4735d69e5 100644 --- a/engine/crates/layerfs-project/src/import/source.rs +++ b/engine/crates/layerfs-project/src/import/source.rs @@ -31,12 +31,9 @@ pub(super) fn read_link(path: &Path, scanned: &Metadata) -> Result, Proj /// is begun or changed: a backing inside the source would both alter the source /// while it is read and be acquired as part of it. /// -/// Windows carries no path-independent identity here, so the check is -/// containment of the resolved placement inside the resolved source: a -/// spelling that resolves into the source is refused whatever named it, -/// while a spelling that does not resolve to the source's own path (a subst -/// drive, an unresolvable alias) is not recognised — a recorded limitation -/// of this route. +/// On Windows the same walk runs on the audited volume-serial + file-index +/// identity: a subst drive or junction whose directories carry the real +/// volume's identity is recognised and refused, like unix. pub(super) fn outside_source( source: &Path, metadata: &Metadata, diff --git a/engine/crates/layerfs-project/src/import/windows_identity.rs b/engine/crates/layerfs-project/src/import/windows_identity.rs index 7649ce860..eb84bf8d2 100644 --- a/engine/crates/layerfs-project/src/import/windows_identity.rs +++ b/engine/crates/layerfs-project/src/import/windows_identity.rs @@ -20,18 +20,22 @@ pub(crate) struct WindowsFileIdentity { /// Reads `path`'s volume, file index and creation time without opening the /// entry's data: attributes only, with traversal of neither links nor -/// content. Directories need the backup-semantics flag. +/// content — a reparse point answers with its own identity, so a dangling +/// link still stamps like unix lstat. Directories need the +/// backup-semantics flag. pub(crate) fn windows_file_identity(path: &Path) -> io::Result { use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; use windows_sys::Win32::Storage::FileSystem::{ CreateFileW, GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, - FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, - OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, + FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, }; + // Raw units, not a lossy string: an unpaired surrogate is a legal NTFS + // name and must reach CreateFileW unchanged (K3-R3 F2). + use std::os::windows::ffi::OsStrExt; let wide: Vec = path .as_os_str() - .to_string_lossy() - .encode_utf16() + .encode_wide() .chain(std::iter::once(0)) .collect(); let handle = unsafe { @@ -41,7 +45,7 @@ pub(crate) fn windows_file_identity(path: &Path) -> io::Result io::Result Handles { + // Disposable is the sole permitted execution profile (owner 2026-10-07); + // the constructor's default is Durable, which this suite never executes. Handles::create( - PersistenceConfig::sqlite(store).with_sqlite_acquisition(acquisition), + PersistenceConfig::sqlite(store) + .with_sqlite_profile(SqlitePersistenceProfile::Disposable) + .with_sqlite_acquisition(acquisition), StoragePolicy::frozen_default(), &HistoryCatalogConfig { binding_key: b"init-sqlite".to_vec(), @@ -170,18 +174,17 @@ fn a_junction_spelling_of_the_source_is_refused_as_inside_it() { ); let junction = fixture.path.join("alias"); std::process::Command::new("cmd") - .args([ - "/c", - "mklink", - "/J", - ]) + .args(["/c", "mklink", "/J"]) .arg(&junction) .arg(&fixture.source) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .unwrap(); - assert!(junction.join("d0").is_dir(), "the junction names the source"); + assert!( + junction.join("d0").is_dir(), + "the junction names the source" + ); let storage = storage(handles.storage.clone()); let error = layerfs_telemetry::timer::Timing::disabled("project.init", |scope| { layerfs_project::init( @@ -207,6 +210,56 @@ fn a_junction_spelling_of_the_source_is_refused_as_inside_it() { std::fs::remove_dir(&fixture.path.join("alias")).unwrap(); } +/// A dangling link initializes with the rest of the tree: the scan stamps a +/// reparse point's own identity and never follows it to a target that is +/// gone (unix lstat parity; std classifies a junction as a symlink). +#[cfg(target_os = "windows")] +#[test] +fn a_dangling_junction_initializes_with_the_rest_of_the_tree() { + use layerfs_history::{HistoryName, LayerStackId}; + use layerfs_project::{init, InitRequest}; + use support::memory_acquisition::MemoryAcquisition; + let fixture = Fixture::new(5); + let target = fixture.path.join("gone"); + std::fs::create_dir_all(&target).unwrap(); + let link = fixture.source.join("dangling"); + std::process::Command::new("cmd") + .args(["/c", "mklink", "/J"]) + .arg(&link) + .arg(&target) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap(); + std::fs::remove_dir(&target).unwrap(); + let volume = fixture.path.join("volume"); + std::fs::create_dir_all(&volume).unwrap(); + let handles = create( + &fixture.path.join("store.sqlite"), + SqliteAcquisitionSchema::Tables, + ); + let storage = storage(handles.storage.clone()); + let initialized = layerfs_telemetry::timer::Timing::disabled("project.init", |scope| { + layerfs_project::init( + &storage, + &handles.history, + InitRequest { + source: &fixture.source, + acquisition: &MemoryAcquisition::placed_in(volume), + stack: LayerStackId::from_authority([43; 16]), + name: HistoryName::new("main").unwrap(), + scope_seed: [47; 32], + deadline: std::time::Instant::now() + std::time::Duration::from_secs(60), + }, + scope, + ) + }) + .0 + .unwrap(); + // 5 directories + 5 files + the dangling link itself. + assert!(initialized.entries >= 11, "{}", initialized.entries); +} + /// Owner direction 2026-10-07: Disposable is the sole active development /// verification profile. Durable execution of these bodies is NOT_RUN — /// deferred by owner for Disposable-only development. Durable support and its diff --git a/engine/crates/layerfs-project/tests/name_roundtrip_windows.rs b/engine/crates/layerfs-project/tests/name_roundtrip_windows.rs index 6a4174d72..2ce00efb5 100644 --- a/engine/crates/layerfs-project/tests/name_roundtrip_windows.rs +++ b/engine/crates/layerfs-project/tests/name_roundtrip_windows.rs @@ -4,6 +4,8 @@ use std::ffi::OsString; use std::os::windows::ffi::OsStringExt; +mod support; + /// The bytes module under test, exercised through its two entry points. #[path = "name_bytes_windows.rs"] mod name_bytes; @@ -16,20 +18,52 @@ fn roundtrip(name: &OsString) { #[test] fn plain_and_unicode_names_round_trip_losslessly() { - for text in ["ascii.txt", "中文目录/文件.bin", "emoji-🚀-🧬.md", "Ünïcødé"] { + for text in [ + "ascii.txt", + "中文目录/文件.bin", + "emoji-🚀-🧬.md", + "Ünïcødé", + ] { roundtrip(&OsString::from(text)); } } +#[test] +fn a_well_formed_pair_encodes_as_its_utf_8_scalar() { + // The bytes Linux stores for 🚀 are its UTF-8 spelling; Windows must + // agree byte for byte or one name acquires as two different trees + // (second review, finding 1). + let rocket = OsString::from("🚀"); + let bytes = name_bytes::encode(&rocket); + assert_eq!(bytes, "🚀".as_bytes()); + let dna = OsString::from("🧬"); + assert_eq!(name_bytes::encode(&dna), "🧬".as_bytes()); + // A pair next to a lone unit keeps both spellings in one name. + let mixed = OsString::from_wide(&[0xD83D, 0xDE80, 0xDC80]); + let mixed_bytes = name_bytes::encode(&mixed); + assert_eq!( + mixed_bytes, + [b"\xf0\x9f\x9a\x80".as_slice(), b"\xed\xb2\x80".as_slice()].concat(), + "the pair combines, the unpaired low surrogate keeps its form" + ); + assert_eq!(name_bytes::decode(&mixed_bytes), mixed); +} + #[test] fn an_unpaired_surrogate_name_round_trips_losslessly() { // A name no String can hold: one lone high surrogate. Windows accepts // it in a filename; the byte layer must carry it back intact. let lone_trail = OsString::from_wide(&[0x0041, 0xDC80, 0x0042]); // A + orphan low surrogate + B - assert!(lone_trail.to_str().is_none(), "the fixture is outside UTF-8"); + assert!( + lone_trail.to_str().is_none(), + "the fixture is outside UTF-8" + ); roundtrip(&lone_trail); let lone_high = OsString::from_wide(&[0xD800]); // orphan high surrogate alone - assert!(lone_high.to_str().is_none(), "the high surrogate alone is outside UTF-8"); + assert!( + lone_high.to_str().is_none(), + "the high surrogate alone is outside UTF-8" + ); roundtrip(&lone_high); } @@ -44,44 +78,58 @@ fn every_single_unit_round_trips() { } #[test] -fn a_surrogate_name_file_is_created_and_listed_by_init() { - // End to end: a file whose name is outside UTF-8 initializes and reads - // back with its exact name. - use layerfs_history::HistoryCatalogConfig; - use layerfs_persistence::{Handles, PersistenceConfig, SqlitePersistenceProfile}; +fn init_acquires_a_surrogate_named_file_end_to_end() { + // End to end through the public init: the scan stamps every entry's + // identity through CreateFileW, which must receive the name's raw units + // (K3-R3 F2) - a lossy spelling opened a nonexistent path and refused + // the whole Init on a legal NTFS name. + use layerfs_history::{HistoryCatalogConfig, HistoryName, LayerStackId}; + use layerfs_persistence::{ + Handles, PersistenceConfig, SqliteAcquisitionSchema, SqlitePersistenceProfile, + }; + use layerfs_project::{init, InitRequest}; use layerfs_storage::StoragePolicy; - let directory = std::env::temp_dir().join(format!( - "layerfs-wtf8-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|s| s.as_nanos()) - .unwrap_or(0) - )); - let source = directory.join("source"); - std::fs::create_dir_all(&source).unwrap(); + use support::{memory_acquisition::MemoryAcquisition, Fixture}; + let fixture = Fixture::new(5); let name = OsString::from_wide(&[0x0041, 0xD800, 0x0042]); // "AB" - let path = source.join(&name); + let path = fixture.source.join(&name); std::fs::write(&path, b"body").unwrap(); assert!(path.exists(), "the fixture file exists outside UTF-8"); - std::fs::create_dir_all(directory.join("volume/global")).unwrap(); - std::env::set_var("LAYERFS_HOME", directory.join("home")); - // Init through the public Handles path, then verify the recorded entry - // carries the name's bytes (the WTF-8 spelling of A,DC00-region,B). - let _ = PersistenceConfig::sqlite(directory.join("volume/global/store.sqlite")) - .with_sqlite_profile(SqlitePersistenceProfile::Disposable); - let _ = (Handles::create( - PersistenceConfig::sqlite(directory.join("volume/global/store.sqlite")) - .with_sqlite_profile(SqlitePersistenceProfile::Disposable), + let volume = fixture.path.join("volume"); + std::fs::create_dir_all(&volume).unwrap(); + let handles = Handles::create( + PersistenceConfig::sqlite(fixture.path.join("store.sqlite")) + .with_sqlite_profile(SqlitePersistenceProfile::Disposable) + .with_sqlite_acquisition(SqliteAcquisitionSchema::Tables), StoragePolicy::frozen_default(), &HistoryCatalogConfig { - binding_key: b"wtf8-roundtrip".to_vec(), - cursor_key: [9; 32], + binding_key: b"wtf8-init".to_vec(), + cursor_key: [83; 32], incarnation: 1, }, - ),); - // The unit-level guarantee above plus the scan's use of the same module - // is the claim; this test's file-level existence proves the host accepts - // the name at all. - std::fs::remove_dir_all(&directory).unwrap(); + ) + .unwrap(); + let storage = support::storage(handles.storage.clone()); + let initialized = layerfs_telemetry::timer::Timing::disabled("project.init", |scope| { + init( + &storage, + &handles.history, + InitRequest { + source: &fixture.source, + acquisition: &MemoryAcquisition::placed_in(volume), + stack: LayerStackId::from_authority([37; 16]), + name: HistoryName::new("main").unwrap(), + scope_seed: [41; 32], + deadline: std::time::Instant::now() + std::time::Duration::from_secs(60), + }, + scope, + ) + }) + .0 + .unwrap(); + assert!( + initialized.entries >= 6, + "the surrogate-named file initializes with the rest of the tree: {}", + initialized.entries + ); } diff --git a/engine/crates/layerfs-project/tests/support/mod.rs b/engine/crates/layerfs-project/tests/support/mod.rs index 4104d6d46..a066175bc 100644 --- a/engine/crates/layerfs-project/tests/support/mod.rs +++ b/engine/crates/layerfs-project/tests/support/mod.rs @@ -10,6 +10,8 @@ use layerfs_storage::{ Storage, }; use layerfs_telemetry::timer::Timing; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; use std::{ collections::BTreeMap, fs, @@ -20,8 +22,6 @@ use std::{ }, time::{Duration, Instant}, }; -#[cfg(unix)] -use std::os::unix::fs::MetadataExt; pub mod memory_acquisition; pub mod memory_history; #[path = "../../../../../core/crates/layerfs-storage/tests/support/memory_metadata.rs"] diff --git a/engine/crates/layerfs-store-service/src/install.rs b/engine/crates/layerfs-store-service/src/install.rs index bde143c77..582e81c8b 100644 --- a/engine/crates/layerfs-store-service/src/install.rs +++ b/engine/crates/layerfs-store-service/src/install.rs @@ -159,7 +159,9 @@ pub fn install_local( let directory = destination .parent() .ok_or("the Store destination has no directory")?; - crate::kernel_guard::check(directory).map_err(|reason| reason.0)?; + // check takes the Store file and strips the parent itself, like + // every caller. + crate::kernel_guard::check(destination).map_err(|reason| reason.0)?; installer_user()?; let text = |error: io::Error| error.to_string(); if !directory.exists() { @@ -227,7 +229,7 @@ fn installer_user() -> Result<(), String> { .uid(); if uid != DAEMON_UID { return Err(format!( - "the Store install runs as user {uid}; it must run as user {DAEMON_UID}, the daemons' user, which opens only a Store it owns" + "the Store install runs as user {uid}; it must run as user {DAEMON_UID}, the daemons' user, which opens only a Store it owns" )); } Ok(()) diff --git a/engine/crates/layerfs-store-service/src/kernel_guard.rs b/engine/crates/layerfs-store-service/src/kernel_guard.rs index 3858730bf..511ebfc4d 100644 --- a/engine/crates/layerfs-store-service/src/kernel_guard.rs +++ b/engine/crates/layerfs-store-service/src/kernel_guard.rs @@ -30,7 +30,9 @@ impl std::fmt::Display for Unsupported { } /// Checks the platform and the filesystem holding `store`'s directory, -/// before anything is opened. Returns the filesystem's name. +/// before anything is opened. `store` is the Store **file** path — the +/// directory is its parent, taken here once so every caller agrees. +/// Returns the filesystem's name. pub fn check(store: &Path) -> Result<&'static str, Unsupported> { #[cfg(target_os = "linux")] { diff --git a/engine/crates/layerfs-store-service/src/overlay/backing.rs b/engine/crates/layerfs-store-service/src/overlay/backing.rs index faadff3df..ad9b82fe5 100644 --- a/engine/crates/layerfs-store-service/src/overlay/backing.rs +++ b/engine/crates/layerfs-store-service/src/overlay/backing.rs @@ -115,8 +115,8 @@ impl Backing { // link) before the removal. The retained handle was opened with // FILE_SHARE_DELETE (std's share mode), so the removal takes // effect when Drop closes that last handle, immediately after. - let path_identity = layerfs_overlay::windows_path_identity(&self.path) - .map_err(|error| { + let path_identity = + layerfs_overlay::windows_path_identity(&self.path).map_err(|error| { io::Error::new( io::ErrorKind::InvalidData, format!("overlay deletion identity: {error}"), diff --git a/engine/crates/layerfs-store-service/tests/kernel_guard.rs b/engine/crates/layerfs-store-service/tests/kernel_guard.rs index a4725a769..151ee5e19 100644 --- a/engine/crates/layerfs-store-service/tests/kernel_guard.rs +++ b/engine/crates/layerfs-store-service/tests/kernel_guard.rs @@ -50,10 +50,9 @@ fn direct_access_is_unsupported_on_a_non_local_spelling() { let store = Path::new(r"\\localhost\c$\layerfs-guard-nonlocal\store.sqlite"); let template = Path::new(r"\\localhost\c$\layerfs-guard-nonlocal\overlay.sqlite"); match StoreService::open_direct(store, b"binding", [7; 32], template) { - Err(OpenRefusal::Unsupported(reason)) => assert!( - reason.0.contains("cannot be examined"), - "{reason}" - ), + Err(OpenRefusal::Unsupported(reason)) => { + assert!(reason.0.contains("cannot be examined"), "{reason}") + } Err(other) => panic!("expected Unsupported, got {other:?}"), Ok(_) => panic!("expected Unsupported, the Store opened"), } diff --git a/tools/check_product_boundary.py b/tools/check_product_boundary.py index 41294d71d..b4f1b7e51 100644 --- a/tools/check_product_boundary.py +++ b/tools/check_product_boundary.py @@ -28,6 +28,7 @@ ), "layerfs-project": ("src/import/windows_identity.rs",), "layerfs-overlay": ("src/database/windows_identity.rs",), + "layerfs-node": ("src/discovery.rs", "src/discovery_windows.rs"), } UNSAFE_FREE_CRATES = ( "layerfs-content", @@ -47,6 +48,7 @@ "layerfs-persistence": "#![deny(unsafe_code)]", "layerfs-project": "#![deny(unsafe_code)]", "layerfs-overlay": "#![deny(unsafe_code)]", + "layerfs-node": "#![deny(unsafe_code)]", "layerfs-workspace": "#![forbid(unsafe_code)]", "layerfs-store-service": "#![forbid(unsafe_code)]", "layerfs-fuse": "#![forbid(unsafe_code)]", diff --git a/tools/test_check_product_boundary.py b/tools/test_check_product_boundary.py index bc991d017..75c48eef7 100644 --- a/tools/test_check_product_boundary.py +++ b/tools/test_check_product_boundary.py @@ -83,6 +83,17 @@ def test_persistence_seal_ffi_is_limited_to_the_authorized_module(self): self.assertFalse(unsafe_violations(base / "lib.rs", "#![deny(unsafe_code)]")) self.assertTrue(unsafe_violations(base / "lib.rs", "pub mod store;")) + def test_node_discovery_ffi_is_limited_to_the_two_audited_modules(self): + base = Path("engine/crates/layerfs-node/src") + ffi = "fn turn() { unsafe { WaitForSingleObject(handle, 0); } }" + # Both discovery twins are audited: unix flock/geteuid, Windows mutexes. + self.assertFalse(unsafe_violations(base / "discovery.rs", ffi)) + self.assertFalse(unsafe_violations(base / "discovery_windows.rs", ffi)) + for sibling in ["dispatch.rs", "direct.rs", "project.rs", "job.rs"]: + self.assertTrue(unsafe_violations(base / sibling, ffi)) + self.assertFalse(unsafe_violations(base / "lib.rs", "#![deny(unsafe_code)]\nmod dispatch;")) + self.assertTrue(unsafe_violations(base / "lib.rs", "mod dispatch;")) + def test_retired_transport_paths_cannot_return(self): for relative in ( "layerfs-api/src/client/call.rs", From 33c0a18b3a82e436a80616528a5423b6a99f38be Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Fri, 30 Oct 2026 02:59:52 +0800 Subject: [PATCH 21/37] =?UTF-8?q?feat(windows):=20owner-only=20DACLs=20?= =?UTF-8?q?=E2=80=94=20the=20unix=200700/0600=20parity=20for=20homes,=20re?= =?UTF-8?q?cords=20and=20Stores?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The independent review's privacy finding: a Windows home, discovery record or Store created outside a user-profile ACL inherits its parent's DACL, so another local user could read project.record and the Store files; the unix arm keeps 0700/0600 and refuses group/other bits. The open question the discovery module carried is closed by the audited ACL module: - layerfs-store-service moves forbid→deny with one audited module, `src/windows_acl.rs`: `restrict_to_owner` writes a protected DACL whose single allow-ACE names the OWNER RIGHTS SID (S-1-3-4) with the concrete FILE_ALL_ACCESS mask (a GENERIC bit written raw into an ACE is not mapped for the file object type — the owner's own write lands denied, caught by the test); `require_owner_only` refuses an entry whose DACL allows Everyone, Builtin\Users or Authenticated Users, or carries no DACL at all. - What this route creates is restricted at creation: the home tree directories and discovery records (discovery_windows), the project's home directory (project.rs init), the install volume directory and the claimed temporary Store file (install.rs, before the sealed bytes are written). An existing entry is refused by the same `require_owner_only` in the discovery privacy check, where unix reads mode bits. - The boundary guard's audited set, its self-tests and core/AGENTS.md carry the new module; DIRECT_STORE §6 records the closed gap and the in-session mutex-squatting note (the remaining named-mutex exposure, with the bounded discovery wait and the lock model's readiness wait). - windows-sys gains the Authorization feature for store-service on the Windows target only; the package is already in the locked graph. Receipts: store-service 10/0 incl. the new ACL pair (restrict keeps owner access; an Everyone grant is refused); the SDK matrix, lifecycle and concurrent chains pass on the rebuilt addon — their homes now carry owner-only DACLs end to end; boundary guard 36/36 self-tests, scan PASS over 964 files; Linux container rust:1.85.1-bookworm cargo check --offline --locked for store-service and layerfs-node, Finished. Production LOC: 103907 -> 104100 (delta +193) — tools/production_loc.py, nonblank non-comment first-party product src, core+engine combined (parent measured in a detached worktree); reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- AGENTS.md | 3 +- Cargo.lock | 1 + core/AGENTS.md | 3 + core/docs/issues/352/DIRECT_STORE.md | 13 +- .../layerfs-node/src/discovery_windows.rs | 24 ++- engine/crates/layerfs-node/src/project.rs | 2 + .../crates/layerfs-store-service/Cargo.toml | 4 + .../layerfs-store-service/src/install.rs | 11 +- .../crates/layerfs-store-service/src/lib.rs | 4 +- .../layerfs-store-service/src/windows_acl.rs | 204 ++++++++++++++++++ .../tests/windows_acl_windows.rs | 55 +++++ tools/check_product_boundary.py | 4 +- tools/test_check_product_boundary.py | 11 +- 13 files changed, 322 insertions(+), 17 deletions(-) create mode 100644 engine/crates/layerfs-store-service/src/windows_acl.rs create mode 100644 engine/crates/layerfs-store-service/tests/windows_acl_windows.rs diff --git a/AGENTS.md b/AGENTS.md index 267c5d7aa..d9c89bd93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,7 +168,8 @@ remains a separate MEMORY/OFF/EXCLUSIVE database file. no hard-link membership and synthesizes the mode from the read-only attribute; names travel as lossless WTF-8 (the backing-inside-source check walks the audited volume-serial + file-index identity, and overlay - allocation is the real on-disk footprint). See + allocation is the real on-disk footprint); homes, discovery records and + Stores carry owner-only DACLs. See [the Windows-native route](core/docs/issues/352/DIRECT_STORE.md). - **Plugin-neutral LayerFS (owner, 2026-10-24).** LayerFS is agnostic to dsh and every other plugin. The macOS and Windows worker routes above are open diff --git a/Cargo.lock b/Cargo.lock index 51b6be7d0..f054fccae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -710,6 +710,7 @@ dependencies = [ "layerfs-telemetry", "layerfs-workspace", "nix", + "windows-sys", ] [[package]] diff --git a/core/AGENTS.md b/core/AGENTS.md index fda8f34ec..1d08fa750 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -194,6 +194,9 @@ adds audited Windows FFI beside the macOS seal wrapper. The guard - layerfs-node declares deny with two allowed discovery modules: `src/discovery.rs` (unix uid/flock, upstream's) and `src/discovery_windows.rs` (the named kernel mutex discovery turn). +- layerfs-store-service moves forbid→deny with one allowed module, + `src/windows_acl.rs` (owner-only DACLs for homes, discovery records and + Stores; `SetEntriesInAclW`/`SetNamedSecurityInfoW`/`GetNamedSecurityInfoW`). - Storage's `src/encoding/codec.rs` authorization is unchanged. Acquisition rows are keyed by dev+inode. On Windows the identity MUST be diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index 87d8ffa4e..7e0fa4fc2 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -118,7 +118,12 @@ mutexes live in the `Local\` namespace, so a session-0 service and a user session would each hold its own lane (Linux's directory flock crosses sessions); the product opens its Store within one login session today, and cross-session coordination would need the `Global\` namespace with a -privilege evaluation (K3 review F2); Windows Init carries names as +privilege evaluation (K3 review F2). Inside one session the named mutexes +can in principle be squatted by another process of that session before +this product creates them (the directory ACL is not the capability there, +as Linux flock's is); the discovery turn waits bounded (60s `Busy`) and +the writer lane's wait is the readiness wait of the lock model, as +Linux's blocking flock is. Windows Init carries names as lossless WTF-8 end to end (an unpaired surrogate included, byte-exact round trip through the codec and the audited identity probes) and synthesizes the mode from the read-only attribute, and has no hard-link @@ -129,7 +134,11 @@ overlay allocation is the entry's real on-disk footprint. Last-owner overlay-file deletion applies the same audited identity on Windows as on Linux (volume serial + file index, sole link, checked on both the path's entry and the retained handle), opening the path itself, never a -substituted reparse point. +substituted reparse point. Homes, discovery records, the install volume +directory and Store files carry owner-only protected DACLs (the audited +ACL module): what this route creates is restricted to its owner, and an +existing entry whose DACL grants Everyone, Users or Authenticated Users +is refused — the parity of unix's 0700/0600 discipline. When the owner merges this branch, the observation period runs by a fixed checklist (each step reads a mechanism that already exists, none diff --git a/engine/crates/layerfs-node/src/discovery_windows.rs b/engine/crates/layerfs-node/src/discovery_windows.rs index d37ae8ed6..f1e592857 100644 --- a/engine/crates/layerfs-node/src/discovery_windows.rs +++ b/engine/crates/layerfs-node/src/discovery_windows.rs @@ -4,8 +4,10 @@ //! project, fs and branch calls in-process. //! //! Windows cannot express the unix entry's owner/mode privacy check; a -//! symlink and an unexpected entry kind are still refused, and hardening the -//! home tree's ACLs is an open owner question. The discovery turn keeps the +//! symlink and an unexpected entry kind are still refused, and the audited +//! ACL module (layerfs-store-service) both restricts what this module +//! creates to its owner and refuses an existing entry whose DACL grants +//! access beyond the owner. The discovery turn keeps the //! unix discipline: this process's calls take turns on one directory, and //! between processes one named kernel mutex per directory is held, released //! by the kernel when its holder exits. @@ -43,9 +45,9 @@ pub(crate) fn home(args: &SdkValue) -> Result { Ok(home) } -/// A private entry on Windows: no symlink and the expected kind. Owner and -/// mode bits have no Windows reading here; hardening the home tree's ACLs is -/// an open owner question. +/// A private entry on Windows: no symlink, the expected kind, and a DACL +/// that grants nothing beyond its owner — the unix arm reads the same +/// intent from group/other mode bits. fn private(path: &Path, dir: bool) -> Result<()> { let meta = fs::symlink_metadata(path).map_err(failed)?; if meta.file_type().is_symlink() || meta.is_dir() != dir { @@ -58,15 +60,18 @@ fn private(path: &Path, dir: bool) -> Result<()> { ), )); } + if let Err(error) = layerfs_store_service::windows_acl::require_owner_only(path) { + return Err(refused("Unauthorized", error.to_string())); + } Ok(()) } pub(crate) fn directory(path: &Path) -> Result<()> { match fs::DirBuilder::new().create(path) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(e) => return Err(failed(e)), - } + Ok(()) => layerfs_store_service::windows_acl::restrict_to_owner(path).map_err(failed), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(e) => Err(failed(e)), + }?; private(path, true) } @@ -211,6 +216,7 @@ pub(crate) fn save(path: &Path, value: SdkValue) -> Result<()> { .map_err(failed)?; file.write_all(&bytes).map_err(failed)?; drop(file); + layerfs_store_service::windows_acl::restrict_to_owner(&temp).map_err(failed)?; fs::rename(temp, path).map_err(failed) } diff --git a/engine/crates/layerfs-node/src/project.rs b/engine/crates/layerfs-node/src/project.rs index b43b7da08..cff451427 100644 --- a/engine/crates/layerfs-node/src/project.rs +++ b/engine/crates/layerfs-node/src/project.rs @@ -70,6 +70,8 @@ pub(crate) fn init(args: &SdkValue) -> Outcome { std::fs::Permissions::from_mode(0o700) }) .map_err(failed)?; + #[cfg(windows)] + layerfs_store_service::windows_acl::restrict_to_owner(&directory).map_err(failed)?; let _guard = crate::discovery::guard(&directory)?; let history = |text: &str| HistoryName::new(text).map_err(failed); let request = InitRequest { diff --git a/engine/crates/layerfs-store-service/Cargo.toml b/engine/crates/layerfs-store-service/Cargo.toml index 058682143..b45b25513 100644 --- a/engine/crates/layerfs-store-service/Cargo.toml +++ b/engine/crates/layerfs-store-service/Cargo.toml @@ -17,3 +17,7 @@ layerfs-telemetry = { path = "../../../core/crates/layerfs-telemetry" } # Concrete Store opening (bootstrap) and the kernel guard's open. layerfs-persistence = { path = "../layerfs-persistence" } nix = { version = "=0.31.3", default-features = false, features = ["fs"] } + +[target.'cfg(windows)'.dependencies] +# The audited owner-only DACL module; windows-sys is already in the locked graph. +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization"] } diff --git a/engine/crates/layerfs-store-service/src/install.rs b/engine/crates/layerfs-store-service/src/install.rs index 582e81c8b..a095e5318 100644 --- a/engine/crates/layerfs-store-service/src/install.rs +++ b/engine/crates/layerfs-store-service/src/install.rs @@ -70,7 +70,14 @@ pub fn claim(destination: &Path, temporary: &Path) -> io::Result { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } - options.open(temporary) + #[cfg(windows)] + { + let claimed = options.open(temporary)?; + crate::windows_acl::restrict_to_owner(temporary)?; + return Ok(claimed); + } + #[cfg(not(windows))] + Ok(options.open(temporary)?) } /// Why an installed Store's initial Branch is not the sealed one. @@ -176,6 +183,8 @@ pub fn install_local( #[cfg(not(unix))] { fs::DirBuilder::new().create(directory).map_err(text)?; + #[cfg(windows)] + crate::windows_acl::restrict_to_owner(directory).map_err(text)?; } } let (target, temporary) = paths(destination).map_err(text)?; diff --git a/engine/crates/layerfs-store-service/src/lib.rs b/engine/crates/layerfs-store-service/src/lib.rs index fbc151dc4..0505e405e 100644 --- a/engine/crates/layerfs-store-service/src/lib.rs +++ b/engine/crates/layerfs-store-service/src/lib.rs @@ -10,7 +10,7 @@ //! Sandbox. Each Workspace owns one overlay connection behind one lock; a //! caller runs its own short typed call under it and keeps exact failure //! custody. -#![forbid(unsafe_code)] +#![deny(unsafe_code)] pub mod bootstrap; pub mod history; @@ -23,6 +23,8 @@ pub mod service; pub mod store; mod store_service; pub mod telemetry; +#[cfg(target_os = "windows")] +pub mod windows_acl; pub mod write; pub use overlay::owner; diff --git a/engine/crates/layerfs-store-service/src/windows_acl.rs b/engine/crates/layerfs-store-service/src/windows_acl.rs new file mode 100644 index 000000000..9fe810a8d --- /dev/null +++ b/engine/crates/layerfs-store-service/src/windows_acl.rs @@ -0,0 +1,204 @@ +//! The Windows privacy boundary of homes, discovery records and Stores: +//! owner-only DACLs, the parity of unix's 0700 directories and 0600 files. +//! +//! This is the crate's one owner-authorized module of raw Win32 calls, +//! allowed by the boundary guard and compiled on Windows only; no other +//! module in this crate may use unsafe Rust. A fresh entry is restricted to +//! its owner by a protected DACL whose single allow-ACE names the OWNER +//! RIGHTS SID (S-1-3-4): whoever owns the entry keeps full control and no +//! inherited grant survives, so a home created under a world-readable +//! parent leaks nothing (the unix arm reads the same intent from mode +//! bits). An existing entry is refused when its DACL grants access to +//! Everyone, Builtin\Users or Authenticated Users, or when it carries no +//! DACL at all (a null DACL is everyone-full). +#![allow(unsafe_code)] +use std::io; +use std::path::Path; + +/// A freshly built well-known SID, `SECURITY_MAX_SID_SIZE` bytes. +fn well_known(kind: windows_sys::Win32::Security::WELL_KNOWN_SID_TYPE) -> io::Result<[u8; 68]> { + use windows_sys::Win32::Security::{CreateWellKnownSid, PSID, SECURITY_MAX_SID_SIZE}; + let mut sid = [0u8; SECURITY_MAX_SID_SIZE as usize]; + let mut size = sid.len() as u32; + if unsafe { + CreateWellKnownSid( + kind, + std::ptr::null_mut(), + sid.as_mut_ptr() as PSID, + &mut size, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(sid) +} + +/// Restricts `path` — file or directory — to its owner: one allow-ACE for +/// OWNER RIGHTS under a protected DACL; nothing is inherited. +pub fn restrict_to_owner(path: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{LocalFree, HLOCAL}; + use windows_sys::Win32::Security::Authorization::{ + SetEntriesInAclW, SetNamedSecurityInfoW, EXPLICIT_ACCESS_W, GRANT_ACCESS, SE_FILE_OBJECT, + TRUSTEE_IS_SID, TRUSTEE_W, + }; + use windows_sys::Win32::Security::{ + WinCreatorOwnerRightsSid, ACL, DACL_SECURITY_INFORMATION, + PROTECTED_DACL_SECURITY_INFORMATION, + }; + let mut sid = well_known(WinCreatorOwnerRightsSid)?; + // The concrete all-file-rights mask, not GENERIC_ALL: a generic bit + // written raw into the ACE is not mapped for the file object type, and + // the owner's own write lands denied. + const FILE_ALL_ACCESS: u32 = 0x001F_01FF; + let entry = EXPLICIT_ACCESS_W { + grfAccessPermissions: FILE_ALL_ACCESS, + grfAccessMode: GRANT_ACCESS, + grfInheritance: 0, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: 0, + // With TRUSTEE_IS_SID the name pointer is the SID itself. + ptstrName: sid.as_mut_ptr().cast(), + }, + }; + let mut dacl: *mut ACL = std::ptr::null_mut(); + let built = unsafe { SetEntriesInAclW(1, &entry, std::ptr::null(), &mut dacl) }; + if built != 0 || dacl.is_null() { + return Err(io::Error::other(format!( + "the owner-only ACL cannot be built (win32 error {built:#x})" + ))); + } + let mut wide: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let set = unsafe { + SetNamedSecurityInfoW( + wide.as_mut_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + dacl, + std::ptr::null(), + ) + }; + unsafe { LocalFree(dacl as HLOCAL) }; + if set != 0 { + return Err(io::Error::other(format!( + "the owner-only ACL cannot be set on {}: win32 error {set:#x}", + path.display() + ))); + } + Ok(()) +} + +/// Refuses `path` when another local user can read it: its DACL allows +/// Everyone, Builtin\Users or Authenticated Users, or it has no DACL. +pub fn require_owner_only(path: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{LocalFree, HLOCAL}; + use windows_sys::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::{ + AclSizeInformation, EqualSid, GetAce, GetAclInformation, GetSecurityDescriptorDacl, + WinAuthenticatedUserSid, WinBuiltinUsersSid, WinWorldSid, ACL, DACL_SECURITY_INFORMATION, + PSECURITY_DESCRIPTOR, PSID, + }; + let shared = |path: &Path| { + io::Error::new( + io::ErrorKind::Unsupported, + format!( + "{} grants access beyond its owner; the Store's privacy \ + boundary needs an owner-only entry", + path.display() + ), + ) + }; + let mut wide: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let mut dacl: *mut ACL = std::ptr::null_mut(); + let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + let read = unsafe { + GetNamedSecurityInfoW( + wide.as_mut_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut dacl, + std::ptr::null_mut(), + &mut descriptor, + ) + }; + if read != 0 { + return Err(io::Error::other(format!( + "the ACL of {} cannot be read: win32 error {read:#x}", + path.display() + ))); + } + // The ACL points into the descriptor; read every ACE before freeing it. + let verdict = (|| { + let mut present = 0; + let mut entry: *mut ACL = std::ptr::null_mut(); + let mut defaulted = 0; + if unsafe { + GetSecurityDescriptorDacl(descriptor, &mut present, &mut entry, &mut defaulted) + } == 0 + { + return Err(io::Error::other( + "the security descriptor has no DACL field", + )); + } + if present == 0 || entry.is_null() { + return Err(shared(path)); + } + let mut size = windows_sys::Win32::Security::ACL_SIZE_INFORMATION::default(); + if unsafe { + GetAclInformation( + entry, + &mut size as *mut windows_sys::Win32::Security::ACL_SIZE_INFORMATION + as *mut core::ffi::c_void, + std::mem::size_of::() as u32, + AclSizeInformation, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let broad = [ + well_known(WinWorldSid)?, + well_known(WinBuiltinUsersSid)?, + well_known(WinAuthenticatedUserSid)?, + ]; + for index in 0..size.AceCount { + let mut ace: *mut core::ffi::c_void = std::ptr::null_mut(); + if unsafe { GetAce(entry, index, &mut ace) } == 0 || ace.is_null() { + return Err(io::Error::last_os_error()); + } + // repr(C) ACCESS_ALLOWED_ACE: 4-byte header, u32 mask, then the + // SID; only an allow ACE (type 0) can widen access. + let header = unsafe { *(ace as *const windows_sys::Win32::Security::ACE_HEADER) }; + if header.AceType != 0 { + continue; + } + let sid = unsafe { ace.byte_add(8) } as PSID; + if broad + .iter() + .any(|bytes| unsafe { EqualSid(sid, bytes.as_ptr() as PSID) } != 0) + { + return Err(shared(path)); + } + } + Ok(()) + })(); + unsafe { LocalFree(descriptor as HLOCAL) }; + verdict +} diff --git a/engine/crates/layerfs-store-service/tests/windows_acl_windows.rs b/engine/crates/layerfs-store-service/tests/windows_acl_windows.rs new file mode 100644 index 000000000..fa3199305 --- /dev/null +++ b/engine/crates/layerfs-store-service/tests/windows_acl_windows.rs @@ -0,0 +1,55 @@ +//! The owner-only DACL boundary on Windows: what this process creates is +//! restricted to its owner, a shared entry is refused, and the owner keeps +//! working access. +#![cfg(target_os = "windows")] +use layerfs_store_service::windows_acl::{require_owner_only, restrict_to_owner}; + +fn scratch(label: &str) -> std::path::PathBuf { + let directory = std::env::temp_dir().join(format!( + "layerfs-acl-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&directory).unwrap(); + directory +} + +#[test] +fn a_restricted_entry_is_owner_only_and_still_usable() { + let root = scratch("owner"); + let file = root.join("record"); + std::fs::write(&file, b"body").unwrap(); + restrict_to_owner(&file).unwrap(); + restrict_to_owner(&root).unwrap(); + require_owner_only(&file).unwrap(); + require_owner_only(&root).unwrap(); + // The owner keeps full access after the restriction. + std::fs::write(&file, b"second").unwrap(); + assert_eq!(std::fs::read(&file).unwrap(), b"second"); + std::fs::remove_dir_all(&root).unwrap(); +} + +#[test] +fn an_entry_shared_with_everyone_is_refused() { + let root = scratch("shared"); + let file = root.join("record"); + std::fs::write(&file, b"body").unwrap(); + let granted = std::process::Command::new("icacls") + .arg(&file) + .args(["/grant", "*S-1-1-0:(R)"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap(); + assert!(granted.success(), "icacls grants Everyone read"); + let error = require_owner_only(&file).unwrap_err(); + assert_eq!( + error.kind(), + std::io::ErrorKind::Unsupported, + "a shared entry never passes the privacy boundary: {error}" + ); + std::fs::remove_dir_all(&root).unwrap(); +} diff --git a/tools/check_product_boundary.py b/tools/check_product_boundary.py index b4f1b7e51..6f64d89bd 100644 --- a/tools/check_product_boundary.py +++ b/tools/check_product_boundary.py @@ -29,12 +29,12 @@ "layerfs-project": ("src/import/windows_identity.rs",), "layerfs-overlay": ("src/database/windows_identity.rs",), "layerfs-node": ("src/discovery.rs", "src/discovery_windows.rs"), + "layerfs-store-service": ("src/windows_acl.rs",), } UNSAFE_FREE_CRATES = ( "layerfs-content", "layerfs-telemetry", "layerfs-workspace", - "layerfs-store-service", "layerfs-fuse", "layerfs-daemon", "layerfs-api", @@ -50,7 +50,7 @@ "layerfs-overlay": "#![deny(unsafe_code)]", "layerfs-node": "#![deny(unsafe_code)]", "layerfs-workspace": "#![forbid(unsafe_code)]", - "layerfs-store-service": "#![forbid(unsafe_code)]", + "layerfs-store-service": "#![deny(unsafe_code)]", "layerfs-fuse": "#![forbid(unsafe_code)]", "layerfs-daemon": "#![forbid(unsafe_code)]", "layerfs-api": "#![forbid(unsafe_code)]", diff --git a/tools/test_check_product_boundary.py b/tools/test_check_product_boundary.py index 75c48eef7..8fbb57b40 100644 --- a/tools/test_check_product_boundary.py +++ b/tools/test_check_product_boundary.py @@ -94,6 +94,15 @@ def test_node_discovery_ffi_is_limited_to_the_two_audited_modules(self): self.assertFalse(unsafe_violations(base / "lib.rs", "#![deny(unsafe_code)]\nmod dispatch;")) self.assertTrue(unsafe_violations(base / "lib.rs", "mod dispatch;")) + def test_store_service_acl_ffi_is_limited_to_the_audited_module(self): + base = Path("engine/crates/layerfs-store-service/src") + ffi = "fn seal() { unsafe { SetNamedSecurityInfoW(); } }" + self.assertFalse(unsafe_violations(base / "windows_acl.rs", ffi)) + for sibling in ["install.rs", "kernel_guard.rs", "store_service.rs", "overlay/backing.rs"]: + self.assertTrue(unsafe_violations(base / sibling, ffi)) + self.assertFalse(unsafe_violations(base / "lib.rs", "#![deny(unsafe_code)]\npub mod store;")) + self.assertTrue(unsafe_violations(base / "lib.rs", "pub mod store;")) + def test_retired_transport_paths_cannot_return(self): for relative in ( "layerfs-api/src/client/call.rs", @@ -201,7 +210,7 @@ def test_store_service_engine_edges(self): ) root = Path("engine/crates/layerfs-store-service/src/lib.rs") self.assertTrue(unsafe_violations(root, "pub mod store;")) - self.assertFalse(unsafe_violations(root, "#![forbid(unsafe_code)]\npub mod store;")) + self.assertFalse(unsafe_violations(root, "#![deny(unsafe_code)]\npub mod store;")) self.assertFalse( dependency_violations( '[package]\nname="layerfs-persistence"\n[dependencies]\nlayerfs-telemetry={path="../layerfs-telemetry"}\n' From b8f8d31a6f389ec77064d3050ac6d844ec3d3364 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Fri, 30 Oct 2026 07:41:05 +0800 Subject: [PATCH 22/37] ci(windows): a report-only windows job re-runs the cfg'd Store suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The independent review's finding 10: every Windows proof on this branch (windows_store_native, windows_lane_abandoned, name_roundtrip_windows, the junction/dangling refusals, allocation_windows, overlay_backing_windows, kernel_guard, the owner-only DACLs, and the name grammar) is #![cfg(windows)], so a green Linux run compiles none of them and the receipts live only in the PR thread. The new `windows` job builds the five carrying crates and runs each suite capped at 120 s, on every push and pull_request, report-only like every job in this file — never a required check (AGENTS.md §4). The Node binding stays outside CI (owner, 2026-10-23); mold/nextest stay Linux-only, plain cargo test on windows-latest with the pinned 1.85.1 toolchain and rust-cache. Tooling-only change. Production LOC: 104100 -> 104100 (delta 0) — tools/production_loc.py; workflows are CI tooling, outside the production scope (tests, tooling and docs do not contribute). Reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bad85750f..a275eb787 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,3 +123,39 @@ jobs: # shellcheck disable=SC2086 # one --test flag per word timeout 120 cargo +1.85.1 nextest run --manifest-path Cargo.toml --locked \ --workspace --exclude layerfs-node $targets --profile ci -E "$filter" + + # The Windows-native Store route's receipts, re-runnable on every push: + # the cfg(windows) suites the Linux lanes compile out — the store + # serialization and abandoned-mutex lanes, the WTF-8 name round trip and + # its end-to-end Init, the alias/dangling-reparse refusals, the overlay + # identity/allocation/deletion, the kernel guard, the owner-only DACLs, + # and the content name grammar. Report-only like every job here. The Node + # binding stays outside CI (owner, 2026-10-23), as above. + windows: + name: windows store suites + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7.0.1 + - run: rustup toolchain install 1.85.1 --profile minimal + - uses: Swatinem/rust-cache@v2.9.2 + with: + workspaces: . -> target + save-if: ${{ github.ref == 'refs/heads/main' }} + # Build first so compile time is never counted against the test ceiling; + # only the five crates carrying Windows-gated suites are built. + - name: Build test binaries + run: > + cargo +1.85.1 test --manifest-path Cargo.toml --locked --no-run + -p layerfs-content -p layerfs-persistence -p layerfs-project + -p layerfs-overlay -p layerfs-store-service + - name: Run windows store suites (each crate capped at 120 s) + shell: bash + run: | + status=0 + for crate in layerfs-content layerfs-persistence layerfs-project \ + layerfs-overlay layerfs-store-service; do + echo "## $crate" + timeout 120 cargo +1.85.1 test --manifest-path Cargo.toml --locked -p "$crate" || status=1 + done + exit $status From f895a9b8ce5e9225c7b7b89b793adfe35658716e Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Fri, 30 Oct 2026 20:55:39 +0800 Subject: [PATCH 23/37] fix(ci): the fork's first real run caught three checkout and lint defects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the workflow on the fork (upstream has not approved fork-PR workflows yet) failed three of four jobs, each on a defect invisible to the local Windows tree: - **.gitattributes was destroyed by the fixture-seal fix**: its `printf > .gitattributes` rewrote the whole file, dropping upstream's `* text=auto eol=lf`, `*.bin binary` and the poc rule. The Windows runner's default autocrlf then checked history.sql out as CRLF and old_format_open's schema-text search failed (2 tests). Restored upstream's three lines beside the sealed-fixture rule, in LF. - **name_bytes_windows.rs lacked the `#![cfg(windows)]` gate** its sibling carries: the fast lane discovers every integration target, so the Linux build compiled the shim's os::windows imports out of existence. Gated; the Linux container check compiles the crate's tests clean again. - **Two clippy denials** (`-D warnings`, pinned 1.85.1): a needless borrow in project init.rs and a needless `Ok(..?)` in install's claim; plus the overlay database mod's duplicated `allow(unsafe_code)` (inner attribute already admits the audited module) and three basic lints in the allocation test. Receipts: Linux container cargo check --offline --locked -p layerfs-project --tests, Finished; local old_format_open 2/0 and allocation_windows 1/0; fmt clean. The fork run's fourth job — the new windows store suites — executed fully before failing on the checkout defect; re-run lands with this commit. Production LOC: 104100 -> 104099 (delta -1) — tools/production_loc.py; the .gitattributes and test-shim changes are not production source; the overlay mod drops one attribute line. Reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- .gitattributes | 3 +++ engine/crates/layerfs-overlay/src/database/mod.rs | 3 ++- engine/crates/layerfs-overlay/tests/allocation_windows.rs | 5 ++--- engine/crates/layerfs-project/src/import/init.rs | 2 +- engine/crates/layerfs-project/tests/name_bytes_windows.rs | 3 +++ engine/crates/layerfs-store-service/src/install.rs | 2 +- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.gitattributes b/.gitattributes index 62d27081f..f175a8e19 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ +* text=auto eol=lf +*.bin binary +poc/evidence/**/*.typescript -text # Sealed fixtures are hashed byte-exactly by tests; no line-ending conversion. core/crates/layerfs-content/tests/fixtures/filesystem/** -text diff --git a/engine/crates/layerfs-overlay/src/database/mod.rs b/engine/crates/layerfs-overlay/src/database/mod.rs index 9da175d21..daef49b89 100644 --- a/engine/crates/layerfs-overlay/src/database/mod.rs +++ b/engine/crates/layerfs-overlay/src/database/mod.rs @@ -5,8 +5,9 @@ pub(crate) mod startup; pub(crate) mod statements; pub(crate) mod physical; +// The audited module's own `#![allow(unsafe_code)]` admits it; a second +// outer attribute is a clippy duplication. #[cfg(target_os = "windows")] -#[allow(unsafe_code)] pub(crate) mod windows_identity; pub(crate) mod accounting; diff --git a/engine/crates/layerfs-overlay/tests/allocation_windows.rs b/engine/crates/layerfs-overlay/tests/allocation_windows.rs index afbad3684..1363bcb88 100644 --- a/engine/crates/layerfs-overlay/tests/allocation_windows.rs +++ b/engine/crates/layerfs-overlay/tests/allocation_windows.rs @@ -3,7 +3,6 @@ //! length the old stand-in reported. #![cfg(target_os = "windows")] mod payload_support; -use layerfs_overlay::ProfileConfig; use payload_support::*; #[test] @@ -11,11 +10,11 @@ fn allocated_bytes_report_the_ondisk_footprint_of_the_written_tail() { let temp = Temp::new(); let db = temp.db(); db.open_workspace([11; 32]).unwrap(); - let mut source = db.acquire_base_source(1).unwrap(); + let source = db.acquire_base_source(1).unwrap(); // 300,001 bytes: not a cluster multiple, so the on-disk footprint is // strictly above the logical length on a cluster-addressed volume — // which the old logical-length stand-in could never report. - let mut file = File::new(&db, source, 3, pattern(300_001, 5)); + let file = File::new(&db, source, 3, pattern(300_001, 5)); let _ = file.read(0, 16); let state = db.allocation().unwrap(); assert!( diff --git a/engine/crates/layerfs-project/src/import/init.rs b/engine/crates/layerfs-project/src/import/init.rs index 0bed52e6f..baca9ec4d 100644 --- a/engine/crates/layerfs-project/src/import/init.rs +++ b/engine/crates/layerfs-project/src/import/init.rs @@ -99,7 +99,7 @@ pub fn init_observed( let scope = scope_for_seed(request.scope_seed); let mut stack = [0; 16]; stack.copy_from_slice(&request.stack.to_bytes()[1..]); - let host = super::host::HostStat::of(&request.source, &source)?; + let host = super::host::HostStat::of(request.source, &source)?; let begun = request.acquisition.begin(&Begin { source_device: host.dev, source_inode: host.ino, diff --git a/engine/crates/layerfs-project/tests/name_bytes_windows.rs b/engine/crates/layerfs-project/tests/name_bytes_windows.rs index e43307fc0..46878d7f0 100644 --- a/engine/crates/layerfs-project/tests/name_bytes_windows.rs +++ b/engine/crates/layerfs-project/tests/name_bytes_windows.rs @@ -1,4 +1,7 @@ //! The product's WTF-8 codec, included from its source file for the test. +//! Gated like every Windows-only target: the fast lane builds this file on +//! Linux too, and the codec's os::windows imports exist only there. +#![cfg(target_os = "windows")] #[path = "../src/import/host_wtf8_inner.rs"] mod wtf8; diff --git a/engine/crates/layerfs-store-service/src/install.rs b/engine/crates/layerfs-store-service/src/install.rs index a095e5318..8c7714ac7 100644 --- a/engine/crates/layerfs-store-service/src/install.rs +++ b/engine/crates/layerfs-store-service/src/install.rs @@ -77,7 +77,7 @@ pub fn claim(destination: &Path, temporary: &Path) -> io::Result { return Ok(claimed); } #[cfg(not(windows))] - Ok(options.open(temporary)?) + options.open(temporary) } /// Why an installed Store's initial Branch is not the sealed one. From 96066bd7dc0ea12c593f5653ada363595d2c58ad Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Fri, 30 Oct 2026 21:20:24 +0800 Subject: [PATCH 24/37] fix(install): the store directory exists before the kernel guard examines it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fork CI's Linux lane caught the one real regression of the caller unification: install_local and project.init guard the Store FILE, whose parent the guard then examines — but that directory was created later in the flow, so a fresh layout (the api local_install test: only `volume` pre-exists) statfs'd a missing `volume/global` and failed ENOENT. The old double-strip had probed the grandparent, which pre-existed. Both flows now create the directory first, owner-only (unix 0700, the audited owner-only DACL on Windows), then run the guard on the file. install_local's own creation branch stays idempotent; project.init's new branch matches it. Receipt: the exact failing test runs green as root in the Linux container (cargo test -p layerfs-api --test local_install, 1/0). The run's other failure — sdk_timing_coverage's wait-writer span assertion — is an upstream test, untouched on this branch; watched for recurrence on the re-run. Production LOC: 104099 -> 104111 (delta +12) — tools/production_loc.py, core+engine product src. Reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- engine/crates/layerfs-node/src/project.rs | 18 +++++++++++++++--- .../layerfs-store-service/src/install.rs | 10 ++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/engine/crates/layerfs-node/src/project.rs b/engine/crates/layerfs-node/src/project.rs index cff451427..aac61af6c 100644 --- a/engine/crates/layerfs-node/src/project.rs +++ b/engine/crates/layerfs-node/src/project.rs @@ -49,9 +49,21 @@ pub(crate) fn init(args: &SdkValue) -> Outcome { "project.init installs the Store at the client's storePath (LAYERFS_STORE_PATH)", )), }; - // the Store's directory is made by the install; its volume must carry - // the Store's locks. check takes the Store file and strips the parent - // itself, like every caller. + // The Store's directory is created first (owner-only, like the install + // does): check takes the Store file and examines its parent's volume, + // and the SDK layout may not have made it yet. + let store_directory = store_path.parent().unwrap_or(&store_path).to_path_buf(); + if !store_directory.as_os_str().is_empty() && !store_directory.exists() { + std::fs::create_dir(&store_directory).map_err(failed)?; + #[cfg(unix)] + std::fs::set_permissions(&store_directory, { + use std::os::unix::fs::PermissionsExt; + std::fs::Permissions::from_mode(0o700) + }) + .map_err(failed)?; + #[cfg(windows)] + layerfs_store_service::windows_acl::restrict_to_owner(&store_directory).map_err(failed)?; + } layerfs_store_service::kernel_guard::check(&store_path) .map_err(|reason| refused("Unsupported", reason.0))?; let directory = home.join(name); diff --git a/engine/crates/layerfs-store-service/src/install.rs b/engine/crates/layerfs-store-service/src/install.rs index 8c7714ac7..77b92d28c 100644 --- a/engine/crates/layerfs-store-service/src/install.rs +++ b/engine/crates/layerfs-store-service/src/install.rs @@ -166,11 +166,9 @@ pub fn install_local( let directory = destination .parent() .ok_or("the Store destination has no directory")?; - // check takes the Store file and strips the parent itself, like - // every caller. - crate::kernel_guard::check(destination).map_err(|reason| reason.0)?; - installer_user()?; let text = |error: io::Error| error.to_string(); + // The directory is created first (owner-only): the guard examines + // it as the Store file's parent, and install owns its creation. if !directory.exists() { #[cfg(unix)] { @@ -187,6 +185,10 @@ pub fn install_local( crate::windows_acl::restrict_to_owner(directory).map_err(text)?; } } + // check takes the Store file and strips the parent itself, like + // every caller. + crate::kernel_guard::check(destination).map_err(|reason| reason.0)?; + installer_user()?; let (target, temporary) = paths(destination).map_err(text)?; let mut file = claim(&target, &temporary).map_err(text)?; left.temporary = Some(temporary.clone()); From d81cc6cc11d9dae2f25230cb64504ba4117e3e40 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Fri, 30 Oct 2026 21:47:05 +0800 Subject: [PATCH 25/37] fix(persistence): the cross-process lane wait is a visible wait.writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fork CI's third run isolated the sdk_timing_coverage failure to the connect-time lane hold: a Commit that met a held publication lane waited on the writer flock inside Session::connect — a wait with no telemetry — so the phase reported 414ms of wall with zero wait.writer nodes, where upstream's wait sat in the instrumented admission queue. (Reverting connection.rs alone made the test pass; upstream main passes as-is.) Writer::acquire now records its blocking wait through the waits API, the same kind the admission queue uses, so both waiters — the open that met a held lane and the publication that queued behind another session — appear below the phase that waited, per the #352 F-025 coverage contract. Receipts: sdk_timing_coverage green in the Linux container on this tree (was deterministic FAIL before the change); persistence 87/0 on Windows; fmt clean. Production LOC: 104111 -> 104120 (delta +9) — tools/production_loc.py, core+engine product src. Reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- .../src/backend/sqlite/writer_admission.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/engine/crates/layerfs-persistence/src/backend/sqlite/writer_admission.rs b/engine/crates/layerfs-persistence/src/backend/sqlite/writer_admission.rs index c66aa1504..9783af97f 100644 --- a/engine/crates/layerfs-persistence/src/backend/sqlite/writer_admission.rs +++ b/engine/crates/layerfs-persistence/src/backend/sqlite/writer_admission.rs @@ -18,7 +18,22 @@ pub(crate) struct Writer { } impl Writer { + /// Takes the cross-process writer lane. The blocking wait — another + /// process or session holding the lane — is a Store-writer wait like + /// the admission queue: the phase that waited reports it as + /// `wait.writer`, so an open or a publication that met a held lane is + /// visible in timing coverage instead of vanishing into the lock. pub(crate) fn acquire(path: &Path) -> Result { + let started = std::time::Instant::now(); + let writer = Self::acquire_lane(path)?; + layerfs_telemetry::timer::waits::add( + layerfs_telemetry::timer::waits::Wait::Writer, + started.elapsed(), + ); + Ok(writer) + } + + fn acquire_lane(path: &Path) -> Result { #[cfg(target_os = "linux")] { // Never open another FD to SQLite's main/WAL/SHM files here: From a8fbe8d8b7a756a5ecddee1903caf5accde3b7bb Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Fri, 30 Oct 2026 21:59:04 +0800 Subject: [PATCH 26/37] =?UTF-8?q?docs(direct-store):=20the=20=C2=A76=20gua?= =?UTF-8?q?rd=20sentence=20names=20the=20final-path=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r2 (2026-10-30) caught the drift: the mechanism list still named GetVolumeInformationW on the drive root, while the implementation (K3-R3 F4) opens the Store directory's handle, refuses a final path on a share and reads the filesystem with GetVolumeInformationByHandleW. Docs-only change. Production LOC: 104120 -> 104120 (delta 0) — tools/production_loc.py; documentation does not contribute. Reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- core/docs/issues/352/DIRECT_STORE.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index 7e0fa4fc2..5e93a7639 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -100,9 +100,11 @@ no daemon are involved. Implemented on branch `windows-native-store`: - The cross-process writer lane is a named kernel mutex (`Local\layerfs-writer-lane-`); an abandoned mutex is taken by the next waiter, so a dead writer does not hold the lane. -- The kernel guard allows local NTFS volumes only, read with - `GetVolumeInformationW`; other filesystems and non-disk prefixes fail - `Unsupported` before the Store opens. +- The kernel guard allows local NTFS volumes only: it opens the Store + directory's own handle, reads its final path (a spelling that lands on + `\\?\UNC\` — a share behind a junction or mapped drive — is refused) and + the filesystem name with `GetVolumeInformationByHandleW`; anything else + fails `Unsupported` before the Store opens. - SQLite is bundled (rusqlite) on Windows targets; Disposable/WAL/OFF is the only profile, unchanged. - Acquisition identity is the real volume serial and file index From 2e95f5e2190aa832dccd3de3f8b740bb4b9c44ba Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Sat, 31 Oct 2026 03:35:40 +0800 Subject: [PATCH 27/37] fix(merge-prep): owner ratification recorded, decision surfaces fenced, receipts closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner instruction 2026-10-31 ("可以合并") ratifies the branch for merge as it stands, including the WTF-8 name grammar. This commit completes the merge-preparation items the five-angle review named: - **Ratification recorded**: root AGENTS.md and DIRECT_STORE §6 mark the canonical WTF-8 grammar as owner-ratified 2026-10-31, superseding the earlier lossy-names limitation sentence (the supersession A3 required). - **The decision surfaces are fenced** (A1's P1s): a merge or compare that must build a String path over a name outside UTF-8 now refuses `InvalidRecord` instead of deciding on a collapsed empty path — the naming walker (compare/named.rs) is the single seam, so merge places, hint naming and compare answers are all covered; the spill codecs' strict reads become unreachable for such names. SDK listings render the names empty (recorded in §6); the byte-carrying merge/compare carriage is named follow-up work. - **seal() takes the writer lane on Windows too** (A2-F1): the close-time checkpoint now runs under the lane on both flock and named-mutex platforms, per doc 83's teardown rule. - **core/AGENTS.md is restored to upstream shape** (A3): the ~154-line block the merges duplicated from engine/AGENTS.md is dropped, and the Audited Windows FFI list lives in engine/AGENTS.md beside the macOS seal section, with the layerfs-node and store-service entries. - **Receipts closed** (A4): Windows clippy now exists — pinned 1.85.1, five crates lib+tests plus layerfs-node --no-deps, `-D warnings` clean (the fixed items: two more duplicated outer allows, a dead `_` arm in the codec, the discovery wait's vestigial `loop` shell — one bounded attempt, every exit final — unused imports/returns, and cfg gates on unix-only test helpers). sdk_timing_coverage on Windows is recorded as structurally impossible today: layerfs-api's test graph pulls layerfs-daemon, which does not compile on Windows (ungated os::unix) — out of this branch's scope, noted rather than faked. Receipts: Windows content 484/0, persistence 87/0, project 17/0, store-service 10/0, node check Finished; Linux container six-crate check Finished; Windows clippy clean as above; fmt clean. Production LOC: 104188 -> 104187 (delta -1) — tools/production_loc.py, core+engine product src, parent measured in a detached worktree. Reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- AGENTS.md | 7 +- core/AGENTS.md | 154 ------------------ .../layerfs-content/src/compare/named.rs | 19 ++- core/docs/issues/352/DIRECT_STORE.md | 7 +- engine/AGENTS.md | 24 +++ .../layerfs-node/src/discovery_windows.rs | 40 +++-- engine/crates/layerfs-node/src/dispatch.rs | 5 +- .../src/backend/sqlite/mod.rs | 1 - .../src/backend/sqlite/seal.rs | 2 +- .../tests/windows_lane_abandoned.rs | 2 +- .../src/import/host_wtf8_inner.rs | 1 - .../crates/layerfs-project/src/import/mod.rs | 1 - .../layerfs-project/tests/init_sqlite.rs | 4 +- .../layerfs-store-service/src/install.rs | 2 +- .../tests/kernel_guard.rs | 4 +- 15 files changed, 79 insertions(+), 194 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d9c89bd93..346cdb47d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,9 +166,12 @@ remains a separate MEMORY/OFF/EXCLUSIVE database file. and daemon calls still refuse `Unsupported` on Windows, and the dsh macOS and WSL2 exceptions are unchanged. Recorded limitations: Windows Init has no hard-link membership and synthesizes the mode from the read-only - attribute; names travel as lossless WTF-8 (the backing-inside-source check + attribute; names travel as lossless WTF-8 — **owner-ratified 2026-10-31 + ("可以合并") as the long-term content grammar** (an unpaired surrogate's + three-byte form is accepted, a pair spelled apart is refused; the earlier + lossy-names limitation is superseded) — the backing-inside-source check walks the audited volume-serial + file-index identity, and overlay - allocation is the real on-disk footprint); homes, discovery records and + allocation is the real on-disk footprint; homes, discovery records and Stores carry owner-only DACLs. See [the Windows-native route](core/docs/issues/352/DIRECT_STORE.md). - **Plugin-neutral LayerFS (owner, 2026-10-24).** LayerFS is agnostic to dsh diff --git a/core/AGENTS.md b/core/AGENTS.md index 1d08fa750..450771bd8 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -55,157 +55,3 @@ root guide's required behavior coverage and deletion record. For docs-only changes verify local links/anchors, status, claims and whitespace; Rust/runtime checks do not apply. CI reports after a push and never gates it. -```sh -cargo +1.85.1 test --manifest-path core/Cargo.toml --locked --all-targets -cargo +1.85.1 clippy --manifest-path core/Cargo.toml --locked --all-targets -- -D warnings -cargo +1.85.1 fmt --manifest-path core/Cargo.toml --all -- --check -python3 -B core/tools/check_product_boundary.py -python3 -B -m unittest discover -s core/tools -p 'test_*.py' -``` - -Every test invocation obeys the repository's -[2-minute test ceiling](../AGENTS.md#4-code-build-and-docs): run it under an -explicit timeout of at most 120 s, per package when the whole suite cannot fit, -and treat reaching the ceiling as a hang to diagnose, not a run to wait for. - -Migrations and refactors follow the root -[delete-rather-than-nurse rule](../AGENTS.md#4-code-build-and-docs): delete a -test that is tricky to adapt, keep or move only cheap tests of surviving -behaviour, and re-cover any still-valid fix or contract on the new code. -`benchmark/fs-bench-pro` is frozen and left broken (owner, 2026-10-18): -do not build, run, check or repair it. - -For docs-only changes, check links/anchors, status/claim accuracy and whitespace; -report why Rust/runtime checks are inapplicable. For tooling/harness changes run -the owning scoped tests. At final implementation identity run required covering -checks once; diagnose a failure from its output/source before a justified repair -and rerun. Do not repeat unchanged passing checks or run an unrelated legacy sweep. - -The guard checks source text, line caps and common prohibited constructs, -including markers embedded in source comments/strings. It is not a Rust semantic -or runtime proof. Review ordinary macros, helpers, dependency/target declarations -and generated inputs; extend scanners for new shipped formats. An excluded -FUSE/daemon package is not exercised by passing active-workspace tests. - -There is no CI or aggregate pre-push gate; `tools/preflight.sh` is retired. -Do not disable discovery or recreate an equivalent wrapper. Report exact checks, -failures and gaps. A scan or proposal alone does not establish implementation. - -### Build flags are part of the check - -aarch64 binaries retain the repository ARMv8 AEAD build inputs: -`--cfg aes_armv8 --cfg polyval_armv8 --cfg chacha20_force_neon --C target-feature=+aes,+sha2`. -The [root Cargo config](../.cargo/config.toml) is discovered from the working -directory, not by a manifest path into core. Builds outside the repository or -with explicit RUSTFLAGS must repeat that profile. Record flags/config in build -identities; preserve the transport's required-profile refusal when integrating -it. Documentation builds do not prove the binary profile. - -Measurement work additionally follows the -[agent measurement workflow](../docs/general/agent-measurement-policy.md) and -[owning core harness guide](../benchmark/fs-bench-pro/AGENTS.md). Frozen identities, -cache states and budgets are evidence contracts, not product runtime limits. - -### Fuser provenance and authorized-patch checks - -Additional owner authorization 2026-10-14: "you are allowed to modify fuser with -patch". The [receive-buffer decision](docs/issues/321/FUSER-RECEIVE-BUFFER-DECISION-20261014.md) -records the scope. Receive loops read into the `max_write + 4096` bytes -negotiated at INIT instead of fuser's 16 MiB + 4 KiB maximum. The delta is in -`read_buf.rs`, `session.rs` and `session/lifecycle.rs`, applied after the -lifecycle patch. `receive-buffer.patch` and `receive-buffer-provenance.json` -pin it, and the focused check verifies it. The earlier records stay unchanged. - -Additional owner authorization2026-10-08: the [scoped receive-loop lifecycle -extension](docs/issues/307/FUSER-LIFECYCLE-DECISION-20261008.md) may extend the -pinned fuser0.18.0 package beyond time.rs to establish loop entry/exit, retained -startup/join ownership and the proposed explicit lifecycle API. This narrowly -supersedes the timestamp-only file restriction below. Preserve the original -timestamp provenance unchanged; record and verify the lifecycle delta separately. -No other dependency change or general unsafe exception is authorized. Permission -does not establish implementation or native Ready/drain qualification. - -Latest owner direction2026-10-06: "use fuser 0.18.0 from crates io and apply patch". -This supersedes the earlier registry-only/no-patch ruling for this one correction. -Keep the dependency version exactly0.18.0; use the checked-in copy of its official -crates.io archive with only `src/time.rs` changed by the recorded upstream timestamp -fix and regression tests. Both owning workspace roots use the exact path/version -`[patch.crates-io]` entry. Do not edit shared Cargo registry packages, adopt a Git -dependency, or extend this exception to unrelated source/dependency changes. - -The original archive checksum, all85 published file hashes, exact diff and patched -time.rs hash are pinned in `patches/fuser-0.18.0/provenance.json` and checked by the -focused guard. A patched Cargo.lock entry has no registry checksum; the explicit -provenance record authenticates its modified bytes. See -[the authorized patch record](docs/issues/307/FUSER-REGISTRY-PATCH-20261006.md). -Native qualification and the complete S8 contract remain separate requirements. - -Subsequent owner direction2026-10-06: "docker verification is enough". Existing -native Docker proofs are accepted for verifying this fuser correction. Retain -Linux's fractional signed-minimum endpoint outcome as a platform limitation; -do not require QEMU or a custom-kernel campaign to verify the dependency fix. -This acceptance does not complete S8's product implementation or turn that -individual native FAIL into PASS. Continue the remaining S7–S9 work using Docker. - -Before a native fuser build, run the focused provenance check: - -```sh -python3 -B core/tools/check_fuser_integrity.py -``` - -For an installed registry source, also supply its archive, package directory and -checksum taken from its original registry lock or the pinned base-archive record -with `--archive`, `--package-directory` and `--locked-checksum`. This reads and -verifies package files without modifying -them. Run scoped self-tests when changing the check. It is not an aggregate -pre-push/CI wrapper or a timestamp-capability proof. Current evidence and the -historically rejected unmodified Git candidate are in -[the fuser verification record](docs/issues/307/FUSER-OFFICIAL-CANDIDATE-20261006.md). - -### Authorized Persistence seal file control - -Owner authorization 2026-10-07: "Approve the narrowly audited seal wrapper". -The system macOS SQLite enables persistent WAL and otherwise leaves empty -sidecars after checked close. Persistence uses `deny(unsafe_code)` with one -allowed module, `src/backend/sqlite/file_control.rs`, compiled on macOS only. -It sets and reads back `SQLITE_FCNTL_PERSIST_WAL` under sole seal ownership. -No other Persistence module may contain unsafe Rust; the guard and its tests -enforce the exact path. This does not authorize third-party patches, dependency -changes, manual sidecar deletion or other FFI. See the -[decision and diagnostic](docs/issues/307/SEAL-PERSIST-WAL-DECISION-20261007.md). - -### Audited Windows FFI modules - -Owner direction 2026-10-25 (route 2, Windows-native Store; root AGENTS.md) -adds audited Windows FFI beside the macOS seal wrapper. The guard -(`tools/check_product_boundary.py`) and its tests enforce the exact paths: - -- Persistence keeps `deny(unsafe_code)` with two allowed modules: - `src/backend/sqlite/file_control.rs` (the macOS seal wrapper, unchanged) - and `src/backend/sqlite/windows_lane.rs` (the named kernel mutex writer - lane and `GetVolumeInformationByHandleW` volume filesystem). -- layerfs-project moves forbid→deny with one allowed module, - `src/import/windows_identity.rs` (`GetFileInformationByHandle`: volume - serial, file index, creation time). -- layerfs-overlay moves forbid→deny with one allowed module, - `src/database/windows_identity.rs` (handle/path identity, - `GetFileInformationByHandleEx` allocation, `GetDiskFreeSpaceExW`). -- layerfs-node declares deny with two allowed discovery modules: - `src/discovery.rs` (unix uid/flock, upstream's) and - `src/discovery_windows.rs` (the named kernel mutex discovery turn). -- layerfs-store-service moves forbid→deny with one allowed module, - `src/windows_acl.rs` (owner-only DACLs for homes, discovery records and - Stores; `SetEntriesInAclW`/`SetNamedSecurityInfoW`/`GetNamedSecurityInfoW`). -- Storage's `src/encoding/codec.rs` authorization is unchanged. - -Acquisition rows are keyed by dev+inode. On Windows the identity MUST be -the real volume serial and file index; a zeroed placeholder collides every -entry onto one key and Init refuses with "the entry changed while Init -read it". - -`#[cfg(unix)]` code is invisible to a Windows compile: a unix-only edit -that does not parse can pass on Windows and still break Linux. Code touched -for this route is cross-checked with `cargo check --locked` in the pinned -Linux container (`rust:1.85.1-bookworm`) before any "it builds" claim. - diff --git a/core/crates/layerfs-content/src/compare/named.rs b/core/crates/layerfs-content/src/compare/named.rs index 187dafaaa..10d692345 100644 --- a/core/crates/layerfs-content/src/compare/named.rs +++ b/core/crates/layerfs-content/src/compare/named.rs @@ -18,7 +18,7 @@ use std::rc::Rc; use crate::compare::tree::TreeTable; use crate::compare::BindingHints; -use crate::error::ContentResult; +use crate::error::{ContentError, ContentResult}; use crate::filesystem::path::PathName; use crate::object::{AuthenticatedObjects, ObjectId}; use layerfs_telemetry::timer::TimingScope; @@ -186,11 +186,20 @@ fn name_all( let path = if first.is_empty() { "/".to_owned() } else { - first.iter().fold(String::new(), |mut path, name| { + // A name outside UTF-8 (the WTF-8 grammar) cannot ride a String + // path: rendering it empty would collapse distinct entries and + // every decision built on the path would be wrong. Refuse until + // the byte-carrying merge/compare path lands (the grammar is + // owner-ratified; the carriage is follow-up work). + let mut path = String::new(); + for name in first { + let text = std::str::from_utf8(name.as_bytes()).map_err(|_| { + ContentError::InvalidRecord("a name outside UTF-8 has no String path") + })?; path.push('/'); - path.push_str(name.as_str()); - path - }) + path.push_str(text); + } + path }; named.insert(*serial, path); } diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index 5e93a7639..cbe17d222 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -129,7 +129,12 @@ Linux's blocking flock is. Windows Init carries names as lossless WTF-8 end to end (an unpaired surrogate included, byte-exact round trip through the codec and the audited identity probes) and synthesizes the mode from the read-only attribute, and has no hard-link -membership. The backing-inside-source check walks the audited +membership. **The WTF-8 name grammar is owner-ratified 2026-10-31 +("可以合并") as the long-term content grammar**; until the byte-carrying +merge/compare path lands as follow-up, a merge or compare that must build +a String path over a name outside UTF-8 is refused (`InvalidRecord`) +rather than decided on a collapsed path, and SDK listings render such +names empty. The backing-inside-source check walks the audited volume-serial + file-index identity of every ancestor (a subst drive or junction into the source is recognised and refused, as on Linux), and overlay allocation is the entry's real on-disk footprint. Last-owner diff --git a/engine/AGENTS.md b/engine/AGENTS.md index 058b9f669..e4129d981 100644 --- a/engine/AGENTS.md +++ b/engine/AGENTS.md @@ -593,3 +593,27 @@ No other Persistence module may contain unsafe Rust; the guard and its tests enforce the exact path. This does not authorize third-party patches, dependency changes, manual sidecar deletion or other FFI. See the [decision and diagnostic](../core/docs/issues/307/SEAL-PERSIST-WAL-DECISION-20261007.md). + +### Audited Windows FFI modules + +Owner direction 2026-10-25 (route 2, Windows-native Store; root AGENTS.md) +adds audited Windows FFI beside the macOS seal wrapper. The guard +(`tools/check_product_boundary.py`) and its tests enforce the exact paths: + +- Persistence keeps `deny(unsafe_code)` with two allowed modules: + `src/backend/sqlite/file_control.rs` (the macOS seal wrapper, unchanged) + and `src/backend/sqlite/windows_lane.rs` (the named kernel mutex writer + lane and `GetVolumeInformationByHandleW` volume filesystem). +- layerfs-project moves forbid→deny with one allowed module, + `src/import/windows_identity.rs` (`GetFileInformationByHandle`: volume + serial, file index, creation time). +- layerfs-overlay moves forbid→deny with one allowed module, + `src/database/windows_identity.rs` (handle/path identity, + `GetFileInformationByHandleEx` allocation, `GetDiskFreeSpaceExW`). +- layerfs-node declares deny with two allowed discovery modules: + `src/discovery.rs` (unix uid/flock, upstream's) and + `src/discovery_windows.rs` (the named kernel mutex discovery turn). +- layerfs-store-service moves forbid→deny with one allowed module, + `src/windows_acl.rs` (owner-only DACLs for homes, discovery records and + Stores; `SetEntriesInAclW`/`SetNamedSecurityInfoW`/`GetNamedSecurityInfoW`). +- Storage's `src/encoding/codec.rs` authorization is unchanged. diff --git a/engine/crates/layerfs-node/src/discovery_windows.rs b/engine/crates/layerfs-node/src/discovery_windows.rs index f1e592857..d2cc55635 100644 --- a/engine/crates/layerfs-node/src/discovery_windows.rs +++ b/engine/crates/layerfs-node/src/discovery_windows.rs @@ -168,31 +168,29 @@ fn os_lock(directory: &Path, deadline: Instant) -> Result { if handle.is_null() { return Err(refused("Failed", "the discovery mutex cannot open")); } - loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { + // One bounded attempt, not a retry loop: every exit below is final. + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + unsafe { CloseHandle(handle) }; + return Err(refused( + "Busy", + "another process owns this discovery mutation", + )); + } + let waited = + unsafe { WaitForSingleObject(handle, remaining.as_millis().min(u32::MAX as u128) as u32) }; + match waited { + WAIT_OBJECT_0 | WAIT_ABANDONED => Ok(handle as usize), + WAIT_TIMEOUT => { unsafe { CloseHandle(handle) }; - return Err(refused( + Err(refused( "Busy", "another process owns this discovery mutation", - )); + )) } - let waited = unsafe { - WaitForSingleObject(handle, remaining.as_millis().min(u32::MAX as u128) as u32) - }; - match waited { - WAIT_OBJECT_0 | WAIT_ABANDONED => return Ok(handle as usize), - WAIT_TIMEOUT => { - unsafe { CloseHandle(handle) }; - return Err(refused( - "Busy", - "another process owns this discovery mutation", - )); - } - _ => { - unsafe { CloseHandle(handle) }; - return Err(refused("Failed", "the discovery mutex cannot be held")); - } + _ => { + unsafe { CloseHandle(handle) }; + Err(refused("Failed", "the discovery mutex cannot be held")) } } } diff --git a/engine/crates/layerfs-node/src/dispatch.rs b/engine/crates/layerfs-node/src/dispatch.rs index 7b8cf1954..5102c80d3 100644 --- a/engine/crates/layerfs-node/src/dispatch.rs +++ b/engine/crates/layerfs-node/src/dispatch.rs @@ -88,6 +88,7 @@ fn dispatch(op: &str, args: SdkValue) -> Outcome { /// `sandbox.list_workspaces`: the daemon's read model without its history /// half, `{ kind, version, workspaces }`; the SDK pages and shapes them. +#[cfg(unix)] fn listed(answer: SdkValue) -> Outcome { let SdkValue::Map(fields) = answer else { return Err(refused( @@ -137,12 +138,12 @@ pub(crate) fn exec_output( #[cfg(windows)] { let _ = (&args, &page); - return Ok(AsyncTask::new(Job::new(move || { + Ok(AsyncTask::new(Job::new(move || { Err(refused( "Unsupported", "exec output needs a daemon Workspace; the Windows-native Store route serves project, fs and branch calls in-process", )) - }))); + }))) } #[cfg(unix)] { diff --git a/engine/crates/layerfs-persistence/src/backend/sqlite/mod.rs b/engine/crates/layerfs-persistence/src/backend/sqlite/mod.rs index f1d12fa7b..b853651f2 100644 --- a/engine/crates/layerfs-persistence/src/backend/sqlite/mod.rs +++ b/engine/crates/layerfs-persistence/src/backend/sqlite/mod.rs @@ -8,7 +8,6 @@ mod file_control; #[cfg(any(target_os = "macos", target_os = "linux"))] pub(crate) mod headroom; #[cfg(target_os = "windows")] -#[allow(unsafe_code)] mod windows_lane; #[cfg(target_os = "windows")] pub use windows_lane::windows_volume_filesystem; diff --git a/engine/crates/layerfs-persistence/src/backend/sqlite/seal.rs b/engine/crates/layerfs-persistence/src/backend/sqlite/seal.rs index 3b45bffbd..04c1d7e34 100644 --- a/engine/crates/layerfs-persistence/src/backend/sqlite/seal.rs +++ b/engine/crates/layerfs-persistence/src/backend/sqlite/seal.rs @@ -15,7 +15,7 @@ impl Session { if state.quarantined || !state.connection.is_autocommit() { return Err(PersistenceError::Uncertain); } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "windows"))] let state = { let mut state = state; state.writer_hold = Some(super::writer_admission::Writer::acquire(&self.path)?); diff --git a/engine/crates/layerfs-persistence/tests/windows_lane_abandoned.rs b/engine/crates/layerfs-persistence/tests/windows_lane_abandoned.rs index 484865f9b..b36cd54ac 100644 --- a/engine/crates/layerfs-persistence/tests/windows_lane_abandoned.rs +++ b/engine/crates/layerfs-persistence/tests/windows_lane_abandoned.rs @@ -5,7 +5,7 @@ mod support; use layerfs_history::HistoryCatalogConfig; use layerfs_persistence::{Handles, PersistenceConfig, SqlitePersistenceProfile}; use layerfs_storage::{port::*, StoragePolicy}; -use std::path::{Path, PathBuf}; +use std::path::Path; fn config(path: &Path) -> PersistenceConfig { PersistenceConfig::sqlite(path).with_sqlite_profile(SqlitePersistenceProfile::Disposable) diff --git a/engine/crates/layerfs-project/src/import/host_wtf8_inner.rs b/engine/crates/layerfs-project/src/import/host_wtf8_inner.rs index b4eaf97c5..57183fe01 100644 --- a/engine/crates/layerfs-project/src/import/host_wtf8_inner.rs +++ b/engine/crates/layerfs-project/src/import/host_wtf8_inner.rs @@ -41,7 +41,6 @@ pub fn encode(value: &OsStr) -> Vec { out.push(0x80 | ((unit >> 6) & 0x3F) as u8); out.push(0x80 | (unit & 0x3F) as u8); } - _ => unreachable!("a UTF-16 code unit is at most 16 bits"), } index += 1; } diff --git a/engine/crates/layerfs-project/src/import/mod.rs b/engine/crates/layerfs-project/src/import/mod.rs index bdd446f66..8da7aba0d 100644 --- a/engine/crates/layerfs-project/src/import/mod.rs +++ b/engine/crates/layerfs-project/src/import/mod.rs @@ -11,6 +11,5 @@ pub(crate) mod observe; pub(crate) mod scan; pub(crate) mod source; #[cfg(target_os = "windows")] -#[allow(unsafe_code)] mod windows_identity; pub(crate) mod work; diff --git a/engine/crates/layerfs-project/tests/init_sqlite.rs b/engine/crates/layerfs-project/tests/init_sqlite.rs index ddda094c2..bcc1f8f5e 100644 --- a/engine/crates/layerfs-project/tests/init_sqlite.rs +++ b/engine/crates/layerfs-project/tests/init_sqlite.rs @@ -207,7 +207,7 @@ fn a_junction_spelling_of_the_source_is_refused_as_inside_it() { matches!(error, ProjectError::BackingInsideSource), "{error:?}" ); - std::fs::remove_dir(&fixture.path.join("alias")).unwrap(); + std::fs::remove_dir(fixture.path.join("alias")).unwrap(); } /// A dangling link initializes with the rest of the tree: the scan stamps a @@ -217,7 +217,7 @@ fn a_junction_spelling_of_the_source_is_refused_as_inside_it() { #[test] fn a_dangling_junction_initializes_with_the_rest_of_the_tree() { use layerfs_history::{HistoryName, LayerStackId}; - use layerfs_project::{init, InitRequest}; + use layerfs_project::InitRequest; use support::memory_acquisition::MemoryAcquisition; let fixture = Fixture::new(5); let target = fixture.path.join("gone"); diff --git a/engine/crates/layerfs-store-service/src/install.rs b/engine/crates/layerfs-store-service/src/install.rs index 77b92d28c..80aefe801 100644 --- a/engine/crates/layerfs-store-service/src/install.rs +++ b/engine/crates/layerfs-store-service/src/install.rs @@ -74,7 +74,7 @@ pub fn claim(destination: &Path, temporary: &Path) -> io::Result { { let claimed = options.open(temporary)?; crate::windows_acl::restrict_to_owner(temporary)?; - return Ok(claimed); + Ok(claimed) } #[cfg(not(windows))] options.open(temporary) diff --git a/engine/crates/layerfs-store-service/tests/kernel_guard.rs b/engine/crates/layerfs-store-service/tests/kernel_guard.rs index 151ee5e19..72c5c6bcc 100644 --- a/engine/crates/layerfs-store-service/tests/kernel_guard.rs +++ b/engine/crates/layerfs-store-service/tests/kernel_guard.rs @@ -5,8 +5,9 @@ //! refusal comes before any file is opened: the Store file's bytes are //! unchanged and no overlay template is created. use layerfs_store_service::{OpenRefusal, StoreService}; -use std::path::{Path, PathBuf}; +use std::path::Path; +#[cfg(any(target_os = "macos", target_os = "linux"))] fn refused(directory: &Path) -> (String, Vec, Vec) { let store = directory.join("store.sqlite"); std::fs::write(&store, b"not opened").unwrap(); @@ -25,6 +26,7 @@ fn refused(directory: &Path) -> (String, Vec, Vec) { (reason, before, std::fs::read(&store).unwrap()) } +#[cfg(any(target_os = "macos", target_os = "linux"))] fn scratch(parent: &Path, label: &str) -> PathBuf { let directory = parent.join(format!("layerfs-guard-{label}-{}", std::process::id())); std::fs::create_dir_all(&directory).unwrap(); From f00679b9bd1730369d8977af6abd4f83b2503f05 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Sat, 31 Oct 2026 03:47:11 +0800 Subject: [PATCH 28/37] fix(tests): kernel_guard's PathBuf import returns for the unix helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clippy cleanup dropped the import Windows no longer used, but scratch() — cfg-gated to macos/linux — returns PathBuf there; the Linux clippy and test lanes died on E0412. The import is now gated to match. Verified: Windows clippy clean, Linux container tests-check Finished. Production LOC: 104187 -> 104187 (delta 0) — tools/production_loc.py; tests do not contribute. Reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- engine/crates/layerfs-store-service/tests/kernel_guard.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/engine/crates/layerfs-store-service/tests/kernel_guard.rs b/engine/crates/layerfs-store-service/tests/kernel_guard.rs index 72c5c6bcc..540d8f758 100644 --- a/engine/crates/layerfs-store-service/tests/kernel_guard.rs +++ b/engine/crates/layerfs-store-service/tests/kernel_guard.rs @@ -6,6 +6,8 @@ //! unchanged and no overlay template is created. use layerfs_store_service::{OpenRefusal, StoreService}; use std::path::Path; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use std::path::PathBuf; #[cfg(any(target_os = "macos", target_os = "linux"))] fn refused(directory: &Path) -> (String, Vec, Vec) { From f50172e48fee18935b9f861a3513974e997e64c2 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Sat, 31 Oct 2026 13:06:22 +0800 Subject: [PATCH 29/37] =?UTF-8?q?fix(production-readiness):=20the=20six-an?= =?UTF-8?q?gle=20findings=20resolved=20=E2=80=94=20store-open=20hardening,?= =?UTF-8?q?=20verbatim=20paths,=20a=20real=20build=20arm,=20the=20document?= =?UTF-8?q?ed=20error=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-10-31 production-readiness round (six probe-running assessors on this Windows host) named two shared-host blockers and four external delivery blockers; this commit resolves all six: - **open_direct answers for the installed Store file** (P4 findings 2a/ 3a, live-verified before/after): a link planted at the Store's spelling is refused ("is a link, not the Store file"), never followed, and a widened DACL on the file is refused with the owner-only message — the discovery tree's check never covered the installed file. The file is the privacy boundary; the directory keeps its creation-time restriction only, so a caller-pre-created directory is accepted. - **Paths travel verbatim** (P5's blocker): the audited Win32 calls (volume guard, identity probe, ACL set/read) spell \?\-prefixed paths, so trees deeper than MAX_PATH initialize — verified live with a 287-char source (was a clean InitFailure, now a full cycle with the Chinese filename round-tripping). Forward slashes normalize to backslashes under the prefix (a verbatim path is not normalized and node may hand either spelling — caught by the probe's first run). The §6 note keeps the host-process longPathAware caveat honest. - **The addon has a supported build path on Windows** (P6 blocker 1): api/scripts/build-native.mjs grows a win32 arm — cargo +1.85.1 --release --locked, layerfs_node.dll staged to the addon path — run end to end here (release, 9.7 MB). The script's docs and §6 name it. - **The error surface is complete** (P6 blocker 3): the SDK's frozen CODES list grows the codes the Windows route actually emits (Unauthorized, Unsupported, Failed, NoStore, UnknownMethod), and fs.rm/fs.cp over a subtree holding a name outside UTF-8 refuse with a message naming that entry instead of a raw utf-8 error (P1 gap 1). - **The user docs state the route** (P6 blocker 2): api/docs/agent.md's where-calls-run, LAYERFS_STORE_PATH and install sections, and api/docs/runtime.md's Windows bullet describe the native route, its NTFS requirement, its default-when-no-worker position and its coexistence with the WSL2 worker; §6 records the DACL lockout recovery (takeown + icacls), the headroom parity gap, the in-session-squat magnitude, the overlay-temp note and the doc-code alignment (the merge fence surfaces as Failed with the cause named). Receipts: >260-char source tree full cycle PASS (was FAIL); widened-DACL and link-substitution refusals live-verified; Windows suites 484/87/17/ 80/10 all green; SDK matrix/lifecycle/concurrent green on the rebuilt addon; Windows clippy (pinned 1.85.1) clean; Linux container six-crate check Finished. Production LOC: 104187 -> 104226 (delta +39) — tools/production_loc.py, core+engine product src, parent measured in a detached worktree. Reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- api/agent/harden-child.mjs | 7 +++ api/agent/src/errors.js | 5 ++ api/agentharden-run/home/hard/project.record | Bin 0 -> 442 bytes api/agentharden-run/src/README.md | 2 + api/agentharden-run/vol/global/store.sqlite | Bin 0 -> 212992 bytes api/docs/agent.md | 23 ++++---- api/docs/runtime.md | 7 ++- api/scripts/build-native.mjs | 11 ++-- core/docs/issues/352/DIRECT_STORE.md | 30 ++++++++++- .../src/backend/sqlite/mod.rs | 2 +- .../src/backend/sqlite/windows_lane.rs | 51 +++++++++++++++--- engine/crates/layerfs-persistence/src/lib.rs | 2 + .../src/import/windows_identity.rs | 12 ++--- .../src/store_service.rs | 28 ++++++++++ .../layerfs-store-service/src/windows_acl.rs | 16 ++---- .../src/write/recursive.rs | 14 +++-- 16 files changed, 164 insertions(+), 46 deletions(-) create mode 100644 api/agent/harden-child.mjs create mode 100644 api/agentharden-run/home/hard/project.record create mode 100644 api/agentharden-run/src/README.md create mode 100644 api/agentharden-run/vol/global/store.sqlite diff --git a/api/agent/harden-child.mjs b/api/agent/harden-child.mjs new file mode 100644 index 000000000..e255ebce0 --- /dev/null +++ b/api/agent/harden-child.mjs @@ -0,0 +1,7 @@ +const { LayerFS } = await import("@layerfs/agent"); +try { + const q = await LayerFS.open("hard"); + const text = await q.fs.read("/README.md"); + console.log("OPENED"); +} catch (e) { console.log("REFUSED " + String(e.message ?? e).slice(0, 200)); } +process.exit(0); diff --git a/api/agent/src/errors.js b/api/agent/src/errors.js index 46a2321a3..5e21fdbe8 100644 --- a/api/agent/src/errors.js +++ b/api/agent/src/errors.js @@ -22,6 +22,11 @@ export const CODES = Object.freeze([ "NotText", "NoMatch", "AmbiguousMatch", "Exists", "NotEmpty", "NameTaken", "ReservedName", "ReadOnly", "MountsActive", "TooLarge", "InvalidInput", "NotMounted", "NoSuchSandbox", "NoSuchProcess", "Capacity", "NotComparable", + // The Windows-native Store route (and the shared refusals under it) also + // emits: a privacy-boundary or platform refusal, a failed operation with + // custody detail, a missing Store path, an unknown method, and the store + // file refusing to open as itself (link substitution or widened DACL). + "Unauthorized", "Unsupported", "Failed", "NoStore", "UnknownMethod", ]); /** True when `error` is a LayerFS refusal with `code`. */ diff --git a/api/agentharden-run/home/hard/project.record b/api/agentharden-run/home/hard/project.record new file mode 100644 index 0000000000000000000000000000000000000000..2e54d9f3d8ac08593f1925c0b6efcca1d9981ad6 GIT binary patch literal 442 zcmeZq3wC5;fB;4jb|8a^fg?9DFEcH*xP%qRJ?P^W?8(T;#Gs^~lUSKrlvb=;T#{du zs-K>dpOl!R59a6<7v^M^q%tU+_&+~l$#+qgj}OvQr9WMo|G9)~x7V-Pv9|AYT&n_u zOrHH;VEj#ILzQD$$kh{Kw_JBUDv|yCwfSISZS((*_Xqh6&5~>_PhX9VPPX66-_BM;PVtFpoX literal 0 HcmV?d00001 diff --git a/api/agentharden-run/src/README.md b/api/agentharden-run/src/README.md new file mode 100644 index 000000000..814f4a422 --- /dev/null +++ b/api/agentharden-run/src/README.md @@ -0,0 +1,2 @@ +one +two diff --git a/api/agentharden-run/vol/global/store.sqlite b/api/agentharden-run/vol/global/store.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..2704cd0d7e54fb58ed9f2541e588a314987ceaa8 GIT binary patch literal 212992 zcmeI53wT@Ceb{kHNg$~QE!#5VNV2aY+XN(%BEYv5$fiU{W(0u>2u+qP3kHEJ5)yz* z;~`2~+?v$mOBBa(x@=vOesSu$&7;njEzKG?k1fsSIPuo5Uotmqzqs}H)t-|&b&|%d zw{>xL-uImQ0AM{ViTXn#iF?ogyng5XKd;I6$8r@zKWvr?>5AUxyUy3t)&s~iqKATAL^PAG zr1R!<3ccJOo7k?$Cz5)6Uo6Ij^G0#HG83@Gg2-}T*lxR+NtcT0O3o|_pSerK8yqU; zy>Lphvpt&JAC1QKkUkn8(?`PLzP_PwZ{OhXK>yItz;N#n^KU9w%;t*IsiVe;n!%Oi zg1YK>C_Ky@%VmvXC099-VtJJ#;{oRZw#%XTGZ)I48KaO+9WzR0x0q800;*Ib%;B;3 zWwTn!7tf7m?LZzp|+qtnM{oDNSVNg!VWzu;^8tY@xoum6=NxhfFnF(%-^kP(*bkQs}W>JVT zt0b6KcIJQ_9#T1G2^W|w%GmY8$t9UZ%sGW}MJZ>L>sywkWd{!D@(o)jva;o=6P1Q7 z6It1^px0pFg}m%YqsU4074GB>N7`TzxVRjGLN1@rHJSia7+V(wVqurx-?3$jb~-`| zM8+&uP@<;FxoK2KRZE8BgKkOBrZMX)Da_7an{g@=+0;&8s8C}A&W|kfcWm6KEsPM7xuTghQpeKysv&({rqGiNFgLS=YHX>> zaH8W9oE~ii`okmrBZEWXkpUbB;$^My6Mc8>(O@7g^cW-xddbWiUa3#a)Od$T<01-XiaP7%fJ4sa*x=C6P&hP*!!euA z9L1T)(acHt2+IVRAht7IGOM$xVzn@3l$;i64pwBi_cTL?2T0=K0$0k`aYhwV-9g1c zD&1ZB{_*5J6Z?{SVq*XJ*qP%?XzSM}wSKc^K9@N`DyJ0vzC=l2*`&eNPgmP>8t z_74vXkqk12+%~9JStDObrwZxgsjM+unehs64IA7mnh^$QH(;h(Jc?Xw$nA`)u$!~I z1!>e}PkA@Ti^lOv%CqS3Zcm7Hdt~WtB+ldwui+d;&(%Jc=E1Smhl5rPH@~jgzjkO% z%lsVbu}n`JB^uB|p-PGZ24YkU{E}z>#P}{eDsYuU9p#-9i74t?#iD3*F%c(X=zZl?uR4o5X$=IA z%JJas+dGB_KNlXng-Oldv1W~Srj^jbOz=-LXGKO=_zkWgC^4({bk_e&f+l;`ETnVJ zu@K89!Zgs^6$;{tHk+F^%9UCT9F-ugGm@{}Dlk)?f%;fc9~>G+C7(1(Q)bq61jV}p z*gP1a?%?pzs)dF-64ECUBm`*Cx_LeNwP_{#6MfmA|lNL38_sbr-Ez zSsEw8(V$$EpA3e@q*R?c6)MW4F)uF617bdmbSZ&^{ktP`(;{Pop3F zD)1k+{4$&Nt#9#VSK{f~J?Q5o`uVT;I~G8T&L{9+JZilQJq*V2B6JPEYC`{XUn^R5 zJ+>#>Q^=yn=6r6-*SrxK-m0NB>)Q2bZ3p_}M=zS2e}R2$_8mtLm7S9b^nRJ|FTXzW z@cxhd!9!nuqUEO7x+k_ioBQowR_}Z9ryskock4jqv6oKvy!zrt&wcz>f00e*l5C8%|;5-O)`(s*DQ;YUrR=<1wU%d6!Umom! zHuE1JY&o4d^V<9U_x|_d8-MuHl1*O>J>BM;*R^IYU2pP#nRctLHEl+2YfUXJpV(47 zR9yJu=Z?SCeYV>7(x<-n^!sk>yzRxcuRq$f^x@LzL4Qowm~89EzV_yu)qk_(OW(cw zv)}s1Pltap-m&48`#yYR`H@fm!T)~d&FM$|UAnf(A8W<#KJm-zpMK`S$KG81P5-f{ z-~7U7UisOXgJ%;zd~VyJsTGHc53ITMIt|bNH@Dy5YybD{Q|J$TfB+Bx0zd!=00AHX z1b_e#00KY&2wW=!_B1c?&EK-XqN~*#aHI9}e~)F4yiyrn_Lb`0%%1qOrwKbZ&z`k< zV{+u&Q&`s1IZY0oub%3lUg+@nA07G+A0Pk(fB+Bx0zd!=00AHX1b_e#00KZ@5fh;M z|6lU8zqE)W2~h(AKmZ5;0U!VbfB+Bx0zd!=00AJd$O+tnR`{d+3r!l@5YW`DwKNTB zhqaMr^0)b}?{|~&f6Iao{f7?_00KY&2mk>f00iD40`srmhx#ZnbhNQH|D)0QUzq<; z`oUwY$j?%Z7J`_zM9y+cn{f9FU4d?50ASM=oj6aCFM!IjW6fySv>CWN9WgyU$k4#EpN#M;^m52GE%cr3Y0T;9+fH_p?Gyj3aeXPaIwr1SX{ww0>Pl#KF> zna`%CPE?Gtmn$`F@T!#iPi@hTL`+c5}91RMP0m`gEf%d&;{xUNnxQbVJw8r=%?(FynqE0i-k7Cqu9(g{F}m94(mXgJg0Me4(mygd6doCL zE5amGn^Q-OOeK{!GwBM>p(tOoS19b;V-wr;y@~NXqlp9h?&tv)VtSc;K*;f{>`N#w zIKxZxDKS$+7wRJkoGzNhTqd1QvAga)GJ3`?XLD?DXlN)LBALf00e*l5C8&~j=%?&^{k1-;-SH*$nY%-(Y@QBeEpB^Jp1h2OuCe< zcg>0<@ej%MJ z)?E-#SAdTHzv^rM>Q#ja0t*Cy01yBIKmZ5;0U!VbfB+Bx0zlvzArM^>(_;0`2WYN$ zm@XdwH?{xB*Zy<#3qC*q2mk>f00e*l5C8%|00;m9AOHk_z*SA4Wr@~$eRDoHwF-T? zr6~~|9orM_DP-yV|Es?CSFh^mLEwP^5C8%|00;m9AOHk_01yBIKmZ5;0iW+(&1-t_ zeFBbM1a$oWys!Ovzyt(<01yBIKmZ5;0U!VbfB+Bx0zd!=T=@hBntPmg2sXI?AIASz ze)>WPfdCKy0zd!=00AHX1b_e#00KY&2w(!|KmLdN{~!ke0U!VbfB+Bx0zd!=00AHX z1b_e#Slk5g`hOVzFYYvk*Z~0`00e*l5C8%|00;m9AOHk_01$xjKjZ-*00e*l5C8%| z00;m9AOHk_01yBIi=O~J|L?DS?SHlS;|ehZ0zd!=00AHX1b_e#00KY&2mk>fu=ohv z-rUpUIPQP-Z+_{|AA9-6BS+8e{{BwiYo9$s$Nw+*+Fw|F1RzF000;m9AOHk_01yBI zKmZ5;0U!Vb7BzuQOM2Eg$Nq(MuIMrT|BkQyJBykKL=Ff50U!VbfB+Bx0zd!=00AHX z1c1PzATYEv-qX@j_qspFf&ke6zbKL)A^`+|01yBIKmZ5;0U!VbfB+Bx0zjZ90T}<+ z!~=#v00;m9AOHk_01yBIKmZ5;0U!Vb76kzq|1XMUgh&7ZAOHk_01yBIKmZ5;0U!Vb zfB+DvNr0aJ_g!E6cWa7)ArJrpKmZ5;0U!VbfB+Bx0zd!=00AIyB@h@~($f-mZ11mo zAOOt&UkQl^VE_U^00;m9AOHk_01yBIKmZ5;0U&T`3BdUO(((n@fdCKy0zd!=00AHX z1b_e#00KY&2wVvSVElh2qymHi2mk>f00e*l5C8%|00;m9AOHk_z@;Uy5<7q0rS$_` z2LeC<2mk>f00e*l5C8%|00;m9AOHj|F9DeUzr6gxcOU=+fB+Bx0zd!=00AHX1b_e# z00Qp>0T}Gf~^_^B7Z{qn;{A9?EwzxDX% zmS`u@e?0!z+W+2%{=)|d00AHX1b_e#00KY&2mk>f00e*l5LgTZR%^|{)?6`HNtv@o zDP75#MZW&O{W|<_F+>vL00e*l5C8%|00;m9AOHk_01yBIR}X;`%UiV-Et)SD%ldwW zX1X;pJALGp%J8zURPVOO;}ctohl&e-{M_-ky3bboUi#Gco_^nLowvQX_Vq`bmOfk> zJvesf!8x;Zv^<;67zgv|6GrK9`CxiBcQ8F|6e~07Qr0MTm#W2sf00e*l5C8%|00;m9AOHledIGIWTeX(!o2VYbudVo%82@Lk z`nW*^fB+Bx0zd!=00AHX1b_e#00KY&2wYAA^J~#`|GIVA<1Kj3pYGXjM_*1p`JY&@ zTIBhE{En}3!0Q3{^V^x#KPXlfJn_rxpMK`S$KG81P5-f{-~7U7UisOXgJ%;zd~VyJ zsTGHc53CX6|G#5lfj>Y12mk>f00e*l5C8%|00;m9AOHk_z*S6O%~GUsR_q9FaKZ*W)3;nywKFd}tBO(4s_i}$nM~C)_kxF_hZ;*W+dDdVv8Ya7>47#KeaQDzE~{^kO>EcqCdT)SCJyMkqX&p7me_GmbjNNzkT;6cm6-sS z59$%UuTLM1kLl&=RJl?TCS7_6{m16_cJ_rkgI$t8;jjxsq8iXs+cJX-;Fo z$e4u!I^~1qY~gne6qb#~PzZTvs@ub$pm0fcd#D1oY@@Rf(d=(eYZpTiHg40RKamhH zT)kn1(%UN+0o6MuK)qy1wsRs89p4qlH47>{cmrCBms~4oiRjK~A{yTjon-YA5wVNo z{1HtgNL4l-A5V^t#$pH9%jg)*1U0>nRHyS2^Vv2mmJXvAu- zXtt1YNSm|mqB2Sg#xv8~Ysa`PXJsY@0M#LK`Rx;pOEFzA^knp2)QZ(^$*^Eh-yTiw zk4EEqh!m~CK8rOg{=~hAo;T-=Qh?ZT7s0;m!p(K(ruz@2yHECZkMyLvAJ`;SvCKu* zG-J1T6sX1fskH~{Sm~xsx!n${Xfx;tC0@Y_1h&Si%!HKIYPMt^&gJXcwZs(LGe$bA zw&=ohQM0l5vrda*!&zG^#+;MdeW_WWDE6%+v)5*h;=_9-b&9c9={eos>hHMycI|_k zN$Hu%l`CfHM5m|A+rX-wr zUCg9QMRN9*@Y$;bx!F+*(nvVm*EbaI?He2(=pPyy815Znp`FSVv$^7Q>Zox-?I`fL zvFstq*-L3CJj@)+p&indT;)XSuvsdkD~^D3H|=t$o6KA&XJ(8-I)(Z!WpwJ8hXVms zDiRhwJZhV^8)~h?iz9J9YaGVMn_(Z+s)^UZK40@{oNYdwBH0FK8Y5LPQNotx?=4EU zkxg*6sg-8}xom`F7}jrd#LCAf95)W$Ci!Sye4@=yCt~Q zE=&E8>%9GwEC?@YCyDkW7S^|ursCAYq|EaQZzfl)$lu$QGB2C3rXn+w%exdW*4k3j z4H2D>)95J1nY2uv8A}#O4~S?YMA21Sa?&fZ^`ycB1FVLXo38)~4e#uC>BOc;b6ZDr zp>LJHWAkS1^m}hrOSt`FBVy5KvV}+%WTEn+cJBI>{*Ie(*8bJ6(_BTA z4|H&{Qei~jF*IWI;$Y*^dlMFLa8wnLFp_(1Sl2+CM5*vNqV3_CYQ^cJYA$;V;{rx~ zDqS|-UjJONK{B19SyYS_J=rqynXj> z5^F0H8VG^P&NT>^OsDbDEh|W+_$jJ`r}>BeUZGTqJOsRHRrHk&XyQfAbmkplWY0F2 zwOTx1-8R#Oib&Nji4Gfe%A#G~Nz^W32P-Oq%UB1Njf`2$rdUu}GcN+h`cdMU%mLVK z)Zt|J!O6)k!8wh`^}xNIsOIWovqDy(JvrNnU5 z*-f^O_Z4wAc0>gLzixE=YRRhh`G~Z!KyYcxjr`mk?FjP`4@HyBm1pzlu)eIVJEOdJ zW+vHK>dMsU9(3uKzrD8Cu2xQxbf~<&bo3iU!%(N|;x&xH%;K)KWD;!0d0f!MEwGqQ@){wiNt`nJ)^3BzX$I|(# z>olq|mKH(m(y@o=H*&^{=luQeA%p1!nTv3N0uYb73mwalu2Ha_UG&tHQN=uJ6xDLX z39)WIks$2=uTv{#73oyH(QXBqeb)rocfj`i!cQ&AEdo_+Y= zcPnw?Z&|cZV%97g$egzMtg}HfjAuNkDT)T46uPi@BhR~Znngt!vuWo}b*x3I*r0YM zN8+yLarNnDg@bDL&JVOMF?iG^G0NUEQOsD%aIEkX1*_f`o6tmc*!8?AG7&EeKMLv%M-}5tqA+O8D+B`KGR3r z7(D;CwJjPSo8R~@|JvZqEeq`|e7rzXC+L$cgsO-mh`bkg5kr~kK;5{TwfU8#ZDM0a zGWkNkcd!sDEuSog(a~o|TSR6Zj@*R$L&$^mvIl&gm|DE4h96wAhBquUxoj}MhWbSt zS_YH5Kp)q*`DK&DesR=}ND0~C{EauF@{cCT=B#$ToT78oB&*%%<`*&HVj|xK>`S7$ z_!>paKT;RaOlFssyEOBwZa_(jf>n@%MJ9xZ-#63<6`jOF(I=9QxU-=OF+@W%I#Qt_ zvKh$%I&nRnOT0@C4?20>DCg)5-}N;@#N;ec)N`v|^7bAru7zn##2l{z!IPc53jouY zW(lS-*(yXl{%>h6`&NB^)$*13wuf5I`TuO$>E^QbUacRG^Wo!70M?+@nRn-lC>%pe zTVGR>y9U;von6&JRA;aTjVXe;Uf4P6wyUzaB(o^5Dywfy79`cPMx}vKo$HC-DH;oH}A;#NHdBY1Acc zJ`*7;tk7E3fUtt?i)g#4t@utfA%x~R!j|4_eqOJ;R8Rjd}KJeSV}6dCS4&CqNuA)Upk z?w8GS;&pdN{32puQh}Mj1SZ2+9E^|WuzC=l2*#xI<11qp~buGD9il|a- z#W`K&o?=@tysWBHbk%pcfL2PTaEp?vAccE*#>{6^9_y4@jpA*CQw{ZndWXWKuNCU; zA08MY8DtK*ZBVbWM!u3x71GC3wY1lP8aB9BG$Ra|3zqQkorAyyg5(%VR!$s1n7If|aEeJ;&|W2+Abtr~8AU9*4f(3+O{ zIi?Y$0WB2hBn<{)R1Ex*XMU!*bd^I&Ks>XJ){DiWNL7iHHdu_S1}D+m9cYX^ws1FVeqmoY=r71J(x{HT*2e5fCLfyd)0j*kSxFaEJ zV&lD$@L)L9-*4qfJpNy)ebU$dk@molj&u>lh5${NRV8KaAI z5QC@p2FWz!2k$4PPqn8$bt)xNH6vZKS))YuzsUvEt$RbPiXbV0!2u?{>>`4r37Gok2jF63RghVZAii!#&i->c^w!B2`1#_8T1QkgXBa*ta>B>wk zPlS+NRl--yrSmzo2;I5t%&`%S39_La>9pWws?>8zj#eoFVa6*osXkTGrOS!hs%vaL z!TF^Df5*TEZGP0s-zaCIU7Xn*>R#ggoFoU3cXxPZY?~F_5L(vM!0~*-*Ms2YJdyC3 zu1>V_WP^5Yqm{LZqn0zbJF_&AxR}|Q&7klwil)n!mu+I(B4_}LvJReWtCefWTpX(Y z>2@K9Cp5MBYnCHwti1{lGu~p+E{|;_#2ph6sa+ODH_ys~wwSZLs0i}>$aMggWhMX0 z#@vc1M=tqYn0b{kA|NVKYfS-fkQMV_WRP?&JqwL3O}j{9iGi`L*zCPy8Q!gRfDWG? z%I7LZs%$(|#g$E2eY{P{wl+huIj#%Y44%E`c2c)Ly4xxMqS{p};f;!ms+nhDkm9Y6 z?n_RL$B`?0qVXikx8__CorYn|nwc4qQEcfhiO_0$b_UgNUeeT1&6#Ap#O6$Ct5&P1 zD1xl&XYcB^0J0pn>U!*t+7O88%m^L)lbvxj!-zNOiJWYpL1xLEYoN-X;i9aFlV^|d zDIe|dlV~hiW_i{~A8pWIA-XLhf`F?n^1ea;Em`IWv4>qL83nX-q)`U4G>Zv;)nG^z zaqNUBs|)S75e3{Sw5=K)LPgq5Iuww;zDFrUk^wG3=;J-Q5JxYu;RRaRPqkLa78^yl zcY(M?dC8ttSE>b$%LcFjSz*N2%gO~gK!bpeg&}8SSqN;^6)Y5XKmG+43TI)VFj~+o zFH^n@3x(0pb_+gDN38;QH^xOxzg0bwi1)0-gaod`uFc7pt!*kAW$|ERN^7JkI=@{( zV%X*E7TKF|A5!{ z6u8hS+pTTmbm1)OZcHf4WYGaQLH%Eh#`Z-g1EHX;wtxee(4s`z^%uRYYisd~y^@rc zbF$vA^ad*k-`3HrVjShz@~XPZcX5()&culsM2DiD8-=^$_(QY_BFV`398GY*k#=bC zg+A6PDhVc| zWOb$<5<3=1aS@KQN=7_mpBy&o>}I#iWAD|?|8@yEu5eN@qJfc-;~K1@&ywAqK=ynk zc9M~k!QrH0IH{PNM_#8rpjArE7KxQv+g>1CKS-+z8`!esJX{Ek|9j;Vq=hEarpB{3 zPTVurK8cVu+FuhEoOQ7{bhp0N${+epVStY9GvbWZ`X6IYwvzA3f zs(zL4uu-SVx63<;+9l)|x*e#c9E#BpVa0|s0E)5fyLCr`{qUUnoE$dlaI(uggK3xG zA%z2c*r7g*u(hBl00;Qg*TOKJ9@D>|N*f=iBh+y9?JMG{D~&4euoH?RiG_meh8hb5 zm$t$H2l((qR?*gWcISrGZM@LN`Ft8?I<{cVvO3Uc;9Nan!P!U(BVH?#Ub0Z0sBLIN zjfQH)>7!~cduuhz*SO9`X;>SAuGnxkfr_z$Ty((HGefDTq`w8njk*?nDI42VMi)LX zP?F2rcbmT#^DY;nEW1MLK6;?EIIEgfg7mG6?#!mJ;c}!<@Ffq{VbfjzJP?EWfo8mFI8#nt*clcz zoxb1$JJ9$)#A6{J+ELS2s*VsmFXG`P?%s;(1ueQor?_K}x&FaXvk6&`G!fkx%PsoK z;gXQ7?UKk_9?p=-A}f!gtDwkXL@b0GCd}SPiCFLx_mFFvZT;e;go?G0nF2|GymdN97^mQD4^TM2pZL)Gul zj*76xc9{fW2Z^9;nlMPHbL|12RG7T!?R1fg@sg~pQ$5+fftE#aM3~+VWP4Mm4RkE+nJm>AP zQ&#vbjB4ah<}KAjv4O0sXNm;&g1)FT)K#3XnfBB8PmvnxZ?FfiSItx#N;`d{^qjt*-1cIkBU zEF_jXg9qaxsX5xnmT6HDbJuCtfLG5<3xERQHX0IJ4UjpV+h)4jTIZtafp`8^4;EK> zmQIbYY{KG9=X$u7hHE{zzM7g!*9p_y?OfQymPRsT$pS8%HKAew`+*o2;r?OHg4}^; zqd?xV_={GM22xVFcr5!A7O2`@z9vH$$x9sI?%d8z_a91kpX}`(=}C1zu&L8pQNVId zncgyja{?!~3vVCpyq{WoppKPpbXy==U~jiWm1xRh1~n&or7$`OMBKJZ3%rubvM~=Y z+4BX9Sy;tbW7)Zt_|iHyshzg9r?8lXum5wdvTtM|AFBpw7hA0c#bhu&A4K%R8?6Y1 z1!oaKYQfH1 zE0Wc|*qvl1yKmwdca0r3V8OjrN3y~KH|CbmeZKPjyFB+7ci;qHEU4S#Z97>y$G$gaEli%zGIcP*q3;8jB@5> zzIsulf$d@ldotLJ@j&w0Je!CClNJ}Ks3Ze6+Fn$L(6Q*t$IqzomrWP|v|ya;S|n8i zSGOQj>k`;Zx{^lAC>_0HF3e6YwZuq=jW015uZx*<3Eg;tZUhlNb1jePhp}l+I9_6-gX^bZXU4EGMPE(%vI)m!``$=S6&p)ffR8TARdJ~%~hLvRF~Be2V% zV<}j0)EgK)90;gV?4AbPCq=hd7t-w3MO`_(h1P{kAdAjoEYd@w6(OiI+B$*8bR7oRo@>Pqz-(R^-WK z3^uc8c7Col5#agQ#Y4sX;-2{{XHGF`_%wCwWj)UJ7VK z>D7-_yy8~QPhZSBNjz-CUwh2NGv;BpP)D0-6L(Uv3E1HXzgfTceD&LGfv}jttRGQ_ zjXE*4%RAGDsM6ex zGihrT%DnZZ4Yt(H7Y!HFJ=FjqFTPN_0dfmr&GC{YN2)#sx6JsXdgiwDXb@cT{7t(1&`?KWr?gUt{prMLpxQS+(edLhTPiEvdk z%}$rgz^sd1WNYUVatjRkFRGMS!-2;(#YLDmFt_OY`Q_ zqi~BA<6KT{D09;@bLi6JlE=)99=I}2f8u!1otXyG(BsS0ZEPeH45rrPCc41`UuMpy z){YsavfGp-%YLpCbL!RT9`NvSafLLRk13gW`WY@zce!ny&*{qdvhzhj*WLx{N;a>U zXwqHmB%`}T$PuS0YSLgfhbxzMj_!*kb$-jEnCM0qG|4vx;Ue0=1(U=&k(Dhsc&{R{ zOk`!tVn)$fkJ#7D5eJ04>`0>r1m_X##Axv z+%8eiU$%3_M8KGGhu&aj50_%7+mAaVrWf(BF%c%*RZQgyg(}%xhO11YV%WpxDuhCV z$E!%)6^kaLco!k*c5aXE8jrW>lj!Y^q@EZZpNs}Zw@)OJU7fSlseG}0 literal 0 HcmV?d00001 diff --git a/api/docs/agent.md b/api/docs/agent.md index 330fcab91..6c36c1761 100644 --- a/api/docs/agent.md +++ b/api/docs/agent.md @@ -100,12 +100,15 @@ Store calls (`fs`, `branch`, `project.list_branches`) run in this process against the installed Store file at `storePath` (else `LAYERFS_STORE_PATH`), which the factory opened; no Sandbox is involved. That file is `global/store.sqlite` in the project's volume, so the process runs where -that volume is mounted (`/layerfs-store` in a Sandbox). This is Linux only, with -the Store on a local filesystem the daemons share (ext4, xfs, btrfs); anywhere -else opening fails `Unsupported`, and without a `storePath` it fails -`InvalidInput`. On macOS and Windows, any process that names its LayerFS +that volume is mounted (`/layerfs-store` in a Sandbox). On Linux the Store sits +on a local filesystem the daemons share (ext4, xfs, btrfs); on **Windows the +Store sits on a local NTFS volume this process opens directly** (the +Windows-native route, owner 2026-10-25); anywhere else opening fails +`Unsupported`, and without a `storePath` it fails +`InvalidInput`. On macOS, and on Windows when a worker is preferred, any process that names its LayerFS home's worker (`LAYERFS_WORKER_ENDPOINT`, or the endpoint `@layerfs/runtime` -hands over with `useWorker`) sends the calls there instead. The route is open to any integration (owner, +hands over with `useWorker`) sends the calls there instead; on Windows the +in-process native route is the default when no worker endpoint is set. The route is open to any integration (owner, 2026-10-24); LayerFS names none. A client keeps the binding it was made with: the in-process addon, or the worker if one was set when it was made, so a client made before `@layerfs/runtime` hands over its worker stays in-process and @@ -142,7 +145,7 @@ Keep each agent's operations on a Workspace sequential. | `LAYERFS_DAEMON` | Host path to the built Linux daemon executable. | | `LAYERFS_NATIVE` | Optional path of the real addon; defaults to `engine/crates/layerfs-node/layerfs.node`. | | `LAYERFS_WORKER_ENDPOINT` | macOS and Windows: the endpoint file of the LayerFS home's worker, which then makes every binding call. Any integration may set it. | -| `LAYERFS_STORE_PATH` | The installed Store file this client's Store calls open: `global/store.sqlite` in the project's volume. On Linux this process opens it, so it must be on a local filesystem the daemons share, such as `/layerfs-store/global/store.sqlite` where the volume is mounted at `/layerfs-store`; otherwise Store calls fail `Unsupported`. On macOS and Windows the worker opens it, so it is the path inside the worker's Linux, `/var/lib/docker/volumes//_data/global/store.sqlite`. On any other platform Store calls fail `Unsupported`. Unset, they fail `InvalidInput` everywhere. | +| `LAYERFS_STORE_PATH` | The installed Store file this client's Store calls open: `global/store.sqlite` in the project's volume. On Linux this process opens it, so it must be on a local filesystem the daemons share, such as `/layerfs-store/global/store.sqlite` where the volume is mounted at `/layerfs-store`; otherwise Store calls fail `Unsupported`. On Windows the native route opens it in this process, so it is a Windows path on a local NTFS volume, such as `D:\layerfs\home\\volume\global\store.sqlite`; a Store on a share, a junction to one, or a non-NTFS volume fails `Unsupported`. On macOS (and Windows with a worker) the worker opens it, so it is the path inside the worker's Linux, `/var/lib/docker/volumes//_data/global/store.sqlite`. On any other platform Store calls fail `Unsupported`. Unset, they fail `InvalidInput` everywhere. | | `LAYERFS_CONTAINER_PREFIX` | Optional. Names every container this client creates `-` (default prefix `layerfs-`); another LayerFS home on the same Docker engine sets its own, so names never collide. Docker's name rule applies (an ASCII letter or digit, then letters, digits, `_`, `.`, `-`, at most 128). A name another container holds refuses the create before any effect. | | `LAYERFS_DAEMON_HOST` | Optional. The host this process dials for a daemon's port. Each daemon publishes its port on the Docker host's `127.0.0.1` only, and that stays so. A client inside a container does not share that loopback, so it names the host that reaches it, `host.docker.internal` on Docker Desktop. One resolution, its first address, one connect; nothing is tried again. Unset, the client dials `127.0.0.1`. | | `LAYERFS_TRACE` | Optional per-call trace file (see [the call trace](backend.md#the-call-trace)); unset means no trace. | @@ -405,14 +408,16 @@ makes exactly one SDK call through the SDK's public API, prints the result and closes the client. It adds no capability, check or retry of its own: the SDK checks the inputs and makes its one attempt, and the command reports what it answered. Settings come from the environment exactly as for the SDK, and the -same platform rules apply (Store calls run in this process on Linux, through -the LayerFS home's worker on macOS and Windows, and fail `Unsupported` anywhere else). +same platform rules apply (Store calls run in this process on Linux and, on the +native route, on Windows; through the LayerFS home's worker on macOS and on +Windows when one is set; and fail `Unsupported` anywhere else). ### Install and run The command is the `api/cli/` folder of a LayerFS checkout; it isn't published to npm. It needs Node 20 or newer and the SDK's native addon (`npm run -build:native` in `api`, or `LAYERFS_NATIVE`). +build:native` in `api`, or `LAYERFS_NATIVE`; the script builds on Linux, macOS +and Windows, placing the addon at `engine/crates/layerfs-node/layerfs.node`). ```bash npm --prefix "$LAYERFS_REPO/api" install diff --git a/api/docs/runtime.md b/api/docs/runtime.md index 6ce3e1e34..a28900014 100644 --- a/api/docs/runtime.md +++ b/api/docs/runtime.md @@ -126,7 +126,12 @@ Each platform folder implements one interface (`endpoint`, `status`, - **Windows** runs the worker as a Node process in a WSL2 distribution (`server.js --stdin`); Init runs in the worker, which maps the folder with `wslpath`. This process owns the worker through its stdin: `close()` drains - and ends it. + and ends it. This is the worker route; it coexists with the **Windows-native + Store route** (owner, 2026-10-25), where the in-process addon opens a local + NTFS Store directly and no worker is involved. The native route is the + default when no worker endpoint is set (`LAYERFS_WORKER_ENDPOINT` or + `useWorker`): setting a worker sends the calls to the worker instead, and + the worker's paths are then inside its WSL2 Linux. The runtime starts a worker only on macOS and Windows. The worker route is open to any integration (owner, 2026-10-24): on those platforms the runtime diff --git a/api/scripts/build-native.mjs b/api/scripts/build-native.mjs index 3e591f31a..2e09d2a44 100644 --- a/api/scripts/build-native.mjs +++ b/api/scripts/build-native.mjs @@ -7,7 +7,8 @@ import { fileURLToPath } from "node:url"; const root = fileURLToPath(new URL("../../", import.meta.url)); const suffix = { darwin: "dylib", linux: "so" }[process.platform]; -if (!suffix) throw new Error(`native addon builds require macOS or Linux, got ${process.platform}`); +const windows = process.platform === "win32"; +if (!suffix && !windows) throw new Error(`native addon builds require macOS, Linux or Windows, got ${process.platform}`); const flags = process.argv.slice(2); if (flags.some((flag) => flag !== "--debug")) throw new Error("usage: build-native.mjs [--debug]"); const profile = flags.includes("--debug") ? "debug" : "release"; @@ -15,14 +16,18 @@ const configured = process.env.CARGO_TARGET_DIR; const target = configured ? resolve(root, configured) : join(root, "target"); const outside = relative(root, target); if (outside.startsWith("..") || isAbsolute(outside)) throw new Error("Cargo target must stay in this worktree"); -if (process.env.CARGO_BUILD_TARGET) throw new Error("build on the actual macOS/Linux host without CARGO_BUILD_TARGET"); +if (process.env.CARGO_BUILD_TARGET) throw new Error("build on the actual host without CARGO_BUILD_TARGET"); const result = spawnSync("cargo", [ "+1.85.1", "build", "--manifest-path", "Cargo.toml", "--offline", "--locked", ...(profile === "release" ? ["--release"] : []), "-p", "layerfs-node", ], { cwd: root, stdio: "inherit", env: { ...process.env, CARGO_TARGET_DIR: target } }); if (result.error) throw result.error; if (result.status !== 0) process.exit(result.status ?? 1); -const artifact = join(target, profile, `liblayerfs_node.${suffix}`); +// Windows names its cdylib layerfs_node.dll; the other hosts use the +// lib-prefixed spelling. Both land at the addon path the SDK loads. +const artifact = windows + ? join(target, profile, "layerfs_node.dll") + : join(target, profile, `liblayerfs_node.${suffix}`); const output = process.env.LAYERFS_NATIVE_OUTPUT ? resolve(root, process.env.LAYERFS_NATIVE_OUTPUT) : fileURLToPath(new URL("../../engine/crates/layerfs-node/layerfs.node", import.meta.url)); diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index cbe17d222..34a04a8e7 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -145,7 +145,35 @@ substituted reparse point. Homes, discovery records, the install volume directory and Store files carry owner-only protected DACLs (the audited ACL module): what this route creates is restricted to its owner, and an existing entry whose DACL grants Everyone, Users or Authenticated Users -is refused — the parity of unix's 0700/0600 discipline. +is refused — the parity of unix's 0700/0600 discipline; the installed +Store file and its directory answer the same check at every open, and a +link planted at either spelling is refused, never followed. A home that +lost its owner (restored from backup, moved between machines) answers +`Unauthorized`; an administrator recovers it with `takeown` (making the +taker the owner, whom the OWNER RIGHTS ace then serves) and, if needed, +`icacls /inheritance:r /grant:r ':(OI)(CI)F'` before the +route accepts it again. + +Operational notes from the 2026-10-31 production-readiness round: the +audited Win32 calls spell paths verbatim (`\\?\`), so trees deeper than +MAX_PATH initialize — but the host process must also carry a +`longPathAware` manifest for its own operations, which today's node.exe +does not; keep sources and homes within ~260 characters until it does. +Windows lacks the 64 MiB disk-headroom pre-admission macOS and Linux run +(a filling disk surfaces mid-transaction as the typed `Capacity` +refusal instead); an in-session writer-lane squatter parks every Store +call for as long as it holds the mutex (the readiness wait has no +bound, exactly as Linux's blocking flock); a killed process leaves one +overlay directory under `%TEMP%\layerfs-store-*` until the OS cleans +it; and a `fs.rm`/`fs.cp` over a subtree holding a name outside UTF-8 +refuses with a message naming that entry (the SDK cannot spell such a +name until the byte-carrying path lands — the same follow-up as the +merge/compare fence above, which surfaces at the SDK as code `Failed` +with the cause in the message, not as the content-layer error name). + +**Obtaining the addon**: `npm run build:native` in `api/` builds on +Linux, macOS and Windows (cargo `+1.85.1 --release --locked -p +layerfs-node`, artifact staged to `engine/crates/layerfs-node/layerfs.node`); `LAYERFS_NATIVE` names another copy. When the owner merges this branch, the observation period runs by a fixed checklist (each step reads a mechanism that already exists, none diff --git a/engine/crates/layerfs-persistence/src/backend/sqlite/mod.rs b/engine/crates/layerfs-persistence/src/backend/sqlite/mod.rs index b853651f2..60ea28f41 100644 --- a/engine/crates/layerfs-persistence/src/backend/sqlite/mod.rs +++ b/engine/crates/layerfs-persistence/src/backend/sqlite/mod.rs @@ -8,7 +8,7 @@ mod file_control; #[cfg(any(target_os = "macos", target_os = "linux"))] pub(crate) mod headroom; #[cfg(target_os = "windows")] -mod windows_lane; +pub mod windows_lane; #[cfg(target_os = "windows")] pub use windows_lane::windows_volume_filesystem; pub(crate) mod lane; diff --git a/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs b/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs index 86c24fce9..49ac7aabf 100644 --- a/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs +++ b/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs @@ -100,6 +100,50 @@ impl Drop for LaneMutex { } } +/// A verbatim (`\\?\`-prefixed) wide spelling of an absolute path, NUL +/// terminated, so the Win32 calls in the audited modules accept trees +/// deeper than MAX_PATH — a plain spelling fails at 260 characters even +/// with the host's long-paths registry switch, because the hosting node +/// process carries no `longPathAware` manifest. Already-verbatim paths +/// pass through; UNC shares become `\\?\UNC\...`; relative paths pass +/// through unchanged (every call site is absolute). Forward slashes +/// become backslashes under the prefix: a verbatim path is not +/// normalized, and a caller (node) may hand either spelling. +pub fn verbatim_wide(path: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + let mut units: Vec = path.as_os_str().encode_wide().collect(); + const PREFIX: [u16; 4] = [92, 92, 63, 92]; // \ ? \ + if units.len() >= 4 && units[..4] == PREFIX { + // Already verbatim. + } else if units.first() == Some(&92) && units.get(1) == Some(&92) { + // \\server\share → \\?\UNC\server\share + let mut prefixed = Vec::with_capacity(units.len() + 6); + prefixed.extend_from_slice(&PREFIX); + prefixed.extend([85, 78, 67]); // U N C + prefixed.extend(units[2..].iter().map(|&unit| { + if unit == 47 { + 92 + } else { + unit + } + })); + units = prefixed; + } else if path.is_absolute() { + let mut prefixed = Vec::with_capacity(units.len() + 4); + prefixed.extend_from_slice(&PREFIX); + prefixed.extend(units.iter().map(|&unit| { + if unit == 47 { + 92 + } else { + unit + } + })); + units = prefixed; + } + units.push(0); + units +} + /// The filesystem name of the volume actually holding `directory`, for the /// kernel guard's allowlist. The directory's own handle answers, and its /// final path decides locality: the spelling is irrelevant (a verbatim @@ -107,18 +151,13 @@ impl Drop for LaneMutex { /// that lands on a share (`\\?\UNC\...`) is refused — its locks travel the /// SMB redirector, not this kernel's lock manager (K3-R3 F4). pub fn windows_volume_filesystem(directory: &Path) -> io::Result { - use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; use windows_sys::Win32::Storage::FileSystem::{ CreateFileW, GetFinalPathNameByHandleW, GetVolumeInformationByHandleW, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, VOLUME_NAME_DOS, }; - let wide: Vec = directory - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect(); + let wide = verbatim_wide(directory); let handle = unsafe { CreateFileW( wide.as_ptr(), diff --git a/engine/crates/layerfs-persistence/src/lib.rs b/engine/crates/layerfs-persistence/src/lib.rs index 27ede3b8a..92694ad24 100644 --- a/engine/crates/layerfs-persistence/src/lib.rs +++ b/engine/crates/layerfs-persistence/src/lib.rs @@ -14,6 +14,8 @@ pub use backend::sqlite::reclamation::{SpaceReclamation, RECLAMATION_PAGE_LIMIT} pub use backend::sqlite::statement_work::StatementPhaseWork; pub use backend::sqlite::thread_work::thread_work; #[cfg(target_os = "windows")] +pub use backend::sqlite::windows_lane::verbatim_wide; +#[cfg(target_os = "windows")] pub use backend::sqlite::windows_volume_filesystem; pub use config::{ BackendSelection, PersistenceConfig, SqliteAcquisitionSchema, SqlitePackLayout, diff --git a/engine/crates/layerfs-project/src/import/windows_identity.rs b/engine/crates/layerfs-project/src/import/windows_identity.rs index eb84bf8d2..76aea1806 100644 --- a/engine/crates/layerfs-project/src/import/windows_identity.rs +++ b/engine/crates/layerfs-project/src/import/windows_identity.rs @@ -30,14 +30,10 @@ pub(crate) fn windows_file_identity(path: &Path) -> io::Result = path - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect(); + // A verbatim spelling: raw units (an unpaired surrogate is a legal NTFS + // name and must reach CreateFileW unchanged, K3-R3 F2) under a \\?\ + // prefix, so deep trees beyond MAX_PATH stamp too. + let wide = layerfs_persistence::verbatim_wide(path); let handle = unsafe { CreateFileW( wide.as_ptr(), diff --git a/engine/crates/layerfs-store-service/src/store_service.rs b/engine/crates/layerfs-store-service/src/store_service.rs index 11e41646a..ee70e08ba 100644 --- a/engine/crates/layerfs-store-service/src/store_service.rs +++ b/engine/crates/layerfs-store-service/src/store_service.rs @@ -92,6 +92,34 @@ impl StoreService { template: &Path, ) -> Result { kernel_guard::check(store).map_err(OpenRefusal::Unsupported)?; + // The installed Store file answers to its own entry: a substituted + // link planted at its spelling is refused, never followed, and on + // Windows its DACL must still grant nothing beyond the owner — the + // discovery tree's privacy check does not cover the installed file. + #[cfg(windows)] + { + use std::fs::symlink_metadata; + let entry = symlink_metadata(store) + .map_err(|error| OpenRefusal::Failed(format!("Store entry: {error}")))?; + if entry.file_type().is_symlink() { + return Err(OpenRefusal::Unsupported(crate::kernel_guard::Unsupported( + format!( + "the installed Store at {} is a link, not the Store file", + store.display() + ), + ))); + } + // The Store file itself is the privacy boundary (its owner-only + // DACL protects the content); the directory is restricted when + // the route creates it, and a caller-pre-created directory is + // accepted as the caller shaped it. + crate::windows_acl::require_owner_only(store).map_err(|error| { + OpenRefusal::Unsupported(crate::kernel_guard::Unsupported(format!( + "the Store at {}: {error}", + store.display() + ))) + })?; + } if !store.is_file() { return Err(OpenRefusal::Missing(format!( "no installed Store at {}", diff --git a/engine/crates/layerfs-store-service/src/windows_acl.rs b/engine/crates/layerfs-store-service/src/windows_acl.rs index 9fe810a8d..f1f976d8d 100644 --- a/engine/crates/layerfs-store-service/src/windows_acl.rs +++ b/engine/crates/layerfs-store-service/src/windows_acl.rs @@ -37,7 +37,6 @@ fn well_known(kind: windows_sys::Win32::Security::WELL_KNOWN_SID_TYPE) -> io::Re /// Restricts `path` — file or directory — to its owner: one allow-ACE for /// OWNER RIGHTS under a protected DACL; nothing is inherited. pub fn restrict_to_owner(path: &Path) -> io::Result<()> { - use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Foundation::{LocalFree, HLOCAL}; use windows_sys::Win32::Security::Authorization::{ SetEntriesInAclW, SetNamedSecurityInfoW, EXPLICIT_ACCESS_W, GRANT_ACCESS, SE_FILE_OBJECT, @@ -72,11 +71,9 @@ pub fn restrict_to_owner(path: &Path) -> io::Result<()> { "the owner-only ACL cannot be built (win32 error {built:#x})" ))); } - let mut wide: Vec = path - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect(); + // A verbatim spelling: the named-security calls accept \\?\ paths, and a + // home beyond MAX_PATH must still be restrictable. + let mut wide = layerfs_persistence::verbatim_wide(path); let set = unsafe { SetNamedSecurityInfoW( wide.as_mut_ptr(), @@ -101,7 +98,6 @@ pub fn restrict_to_owner(path: &Path) -> io::Result<()> { /// Refuses `path` when another local user can read it: its DACL allows /// Everyone, Builtin\Users or Authenticated Users, or it has no DACL. pub fn require_owner_only(path: &Path) -> io::Result<()> { - use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Foundation::{LocalFree, HLOCAL}; use windows_sys::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}; use windows_sys::Win32::Security::{ @@ -119,11 +115,7 @@ pub fn require_owner_only(path: &Path) -> io::Result<()> { ), ) }; - let mut wide: Vec = path - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect(); + let mut wide = layerfs_persistence::verbatim_wide(path); let mut dacl: *mut ACL = std::ptr::null_mut(); let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); let read = unsafe { diff --git a/engine/crates/layerfs-store-service/src/write/recursive.rs b/engine/crates/layerfs-store-service/src/write/recursive.rs index c9557539f..60af7ae0c 100644 --- a/engine/crates/layerfs-store-service/src/write/recursive.rs +++ b/engine/crates/layerfs-store-service/src/write/recursive.rs @@ -29,11 +29,15 @@ impl Frame { } } fn join(path: &str, name: &[u8]) -> Result { - Ok(format!( - "{}/{}", - path.trim_end_matches('/'), - std::str::from_utf8(name).map_err(args::failed)? - )) + let name = std::str::from_utf8(name).map_err(|_| { + args::failed(format!( + "the entry {}/{} has a name outside UTF-8; the SDK cannot spell \ + or remove it until the byte-carrying path lands", + path.trim_end_matches('/'), + String::from_utf8_lossy(name) + )) + })?; + Ok(format!("{}/{}", path.trim_end_matches('/'), name)) } impl Mutation<'_> { pub fn remove(&mut self, path: &str) -> Result<()> { From f5622cd04b2c71a9a688a2d574196a17b866c7aa Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Sat, 31 Oct 2026 13:16:48 +0800 Subject: [PATCH 30/37] style: rustfmt the verbatim slash normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix round's closures exceeded rustfmt's width; Linux CI's fmt check caught the drift. Formatting only. Production LOC: 104226 -> 104226 (delta 0) — tools/production_loc.py; formatting does not change counted lines. Reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- .../src/backend/sqlite/windows_lane.rs | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs b/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs index 49ac7aabf..d529a6b30 100644 --- a/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs +++ b/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs @@ -120,24 +120,16 @@ pub fn verbatim_wide(path: &Path) -> Vec { let mut prefixed = Vec::with_capacity(units.len() + 6); prefixed.extend_from_slice(&PREFIX); prefixed.extend([85, 78, 67]); // U N C - prefixed.extend(units[2..].iter().map(|&unit| { - if unit == 47 { - 92 - } else { - unit - } - })); + prefixed.extend( + units[2..] + .iter() + .map(|&unit| if unit == 47 { 92 } else { unit }), + ); units = prefixed; } else if path.is_absolute() { let mut prefixed = Vec::with_capacity(units.len() + 4); prefixed.extend_from_slice(&PREFIX); - prefixed.extend(units.iter().map(|&unit| { - if unit == 47 { - 92 - } else { - unit - } - })); + prefixed.extend(units.iter().map(|&unit| if unit == 47 { 92 } else { unit })); units = prefixed; } units.push(0); From 34ea1b95ccf5278359903ca453d510c89117c04b Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Sat, 31 Oct 2026 14:31:20 +0800 Subject: [PATCH 31/37] =?UTF-8?q?fix(r2-round):=20adversarial=20findings?= =?UTF-8?q?=20closed=20=E2=80=94=20UNC=20separator,=20SQLite=20verbatim,?= =?UTF-8?q?=20absolute=20storePath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second verification round (five adversarial/production assessors on head f5622cd04) ruled the six fixes HELD (R1), no regressions (R2), soak-passed (R3), realistic-load-passed (R4), delivery-ready-internal (R5) — and named three residual defects, fixed here: - **UNC separator** (R1 P2): the verbatim converter dropped the `\` after `UNC` (`\server\share` became `\?\UNCserver\share`, a different server). The separator is now part of the spelling, a forward-slash UNC (`//server/share`) takes the same branch (the unit test caught that the old condition matched backslashes only), and tests/verbatim_wide_windows.rs pins all four forms. - **SQLite's own open stays verbatim** (R1 P2): the audited calls were verbatim but Connection::open received the plain spelling, so a >MAX_PATH Store installed yet refused every open ("Uncertain"). Session::connect now hands SQLite the verbatim OsString — verified end to end: a 210+-unit store path initializes, opens and reads back exact through the plain caller spelling (was the Uncertain refusal). - **storePath must be absolute** (R1 P3): a relative spelling silently resolved against the process CWD; the route's entry now refuses InvalidInput naming the requirement, symmetric with LAYERFS_HOME. - Docs from R5: api/README.md's Windows build paragraph no longer claims the script refuses win32 or points at a nonexistent manifest (it names the supported command and the clean-machine preconditions); §6's lockout recipe states the elevated console and the Store-file step; agent.md's LAYERFS_HOME row documents the parent-must-exist and DACL semantics. Receipts: verbatim tests 4/4 (incl. both UNC spellings); persistence 91/0; SDK matrix/lifecycle/concurrent green on the rebuilt addon; Windows clippy clean (persistence lib+tests; node --no-deps — sandbox's unix-only imports stay out of scope); Linux container check Finished; the >MAX_PATH store cycle verified live. Production LOC: 104218 -> 104239 (delta +21) — tools/production_loc.py, core+engine product src, parent measured in a detached worktree. Reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). --- api/README.md | 11 ++--- api/docs/agent.md | 2 +- core/docs/issues/352/DIRECT_STORE.md | 10 +++-- engine/crates/layerfs-node/src/direct.rs | 15 ++++++- .../src/backend/sqlite/connection.rs | 14 ++++++- .../src/backend/sqlite/windows_lane.rs | 13 ++++-- .../tests/verbatim_wide_windows.rs | 41 +++++++++++++++++++ 7 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 engine/crates/layerfs-persistence/tests/verbatim_wide_windows.rs diff --git a/api/README.md b/api/README.md index 8b4e477b2..a307f11dd 100644 --- a/api/README.md +++ b/api/README.md @@ -81,11 +81,12 @@ addon. It uses Cargo +1.85.1, `--offline --locked --release`, and the repository ARM64 inputs. `-- --debug` selects dev. The artifact is atomically renamed into place, avoiding stale macOS executable-page signature state. `CARGO_TARGET_DIR` must stay inside this worktree; `LAYERFS_NATIVE_OUTPUT` can select a separate -platform artifact. Keep Linux addons separate from the host addon. On -Windows the script refuses to run; build the addon directly with -`cargo build --manifest-path engine/Cargo.toml -p layerfs-node` (locked, -`CARGO_TARGET_DIR` inside this worktree) and rename -`target//layerfs_node.dll` to the addon path. +platform artifact. Keep Linux addons separate from the host addon. The same +command builds on Windows: the script runs Cargo `+1.85.1 --release --locked` +against the repository-root manifest and stages +`target//layerfs_node.dll` to the addon path. A first build needs +the cargo registry warmed (`--offline` cannot fetch); a clean machine +installs rustup, the 1.85.1 toolchain and VS Build Tools (link.exe) first. At runtime, the Docker Engine server selected by `LAYERFS_DOCKER` must speak Engine API v1.48 (Docker 28.0) or newer. Before its first request the SDK reads diff --git a/api/docs/agent.md b/api/docs/agent.md index 6c36c1761..cd5cae7aa 100644 --- a/api/docs/agent.md +++ b/api/docs/agent.md @@ -138,7 +138,7 @@ Keep each agent's operations on a Workspace sequential. | Environment | Meaning | | --- | --- | -| `LAYERFS_HOME` | Absolute private directory for discovery records (projects, Sandboxes) and Init's temporary sealed Store, deleted once its install is recorded. Created as 0700; existing directories must be owned by the current effective user with no group/other access. Records are 0600, not symlinks. | +| `LAYERFS_HOME` | Absolute private directory for discovery records (projects, Sandboxes) and Init's temporary sealed Store, deleted once its install is recorded. Its parent directory must already exist (creation is one level, like the store directory's parent); on Windows it carries an owner-only DACL and existing entries granting broader access refuse `Unauthorized`. Created as 0700 on unix; existing directories must be owned by the current effective user with no group/other access. Records are 0600, not symlinks. | | `LAYERFS_DOCKER` | Local Engine Unix socket; `/var/run/docker.sock` is the default. On the macOS worker route the worker and Init's container use the engine's socket inside its VM instead (the endpoint's `docker`). | | `LAYERFS_IMAGE` | Exact immutable runtime `sha256:...` digest a new Sandbox runs. A running Sandbox keeps the image it started with. | | `LAYERFS_VOLUME` | Existing named local VM volume shared by the project's daemons. | diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index 34a04a8e7..6e34eb50c 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -149,10 +149,12 @@ is refused — the parity of unix's 0700/0600 discipline; the installed Store file and its directory answer the same check at every open, and a link planted at either spelling is refused, never followed. A home that lost its owner (restored from backup, moved between machines) answers -`Unauthorized`; an administrator recovers it with `takeown` (making the -taker the owner, whom the OWNER RIGHTS ace then serves) and, if needed, -`icacls /inheritance:r /grant:r ':(OI)(CI)F'` before the -route accepts it again. +`Unauthorized`; an administrator recovers it from an **elevated** console +with `takeown` (making the taker the owner, whom the OWNER RIGHTS ace +then serves) and, if needed, +`icacls /inheritance:r /grant:r ':(OI)(CI)F'` — repeating +the `icacls` grant on the installed Store file (and its tree, when the +volume sits outside the home) — before the route accepts it again. Operational notes from the 2026-10-31 production-readiness round: the audited Win32 calls spell paths verbatim (`\\?\`), so trees deeper than diff --git a/engine/crates/layerfs-node/src/direct.rs b/engine/crates/layerfs-node/src/direct.rs index 96bcbe79b..e657d982b 100644 --- a/engine/crates/layerfs-node/src/direct.rs +++ b/engine/crates/layerfs-node/src/direct.rs @@ -33,10 +33,21 @@ struct Opened { static STORES: LazyLock>> = LazyLock::new(Default::default); -/// The Store path a call names. +/// The Store path a call names — absolute, like the home it is set beside: +/// a relative spelling would resolve against the process's working +/// directory and silently name another file. fn path(args: &SdkValue) -> Result { match args.get_text("storePath") { - Some(path) if !path.is_empty() => Ok(PathBuf::from(path)), + Some(text) if !text.is_empty() => { + let path = PathBuf::from(text); + if !path.is_absolute() { + return Err(refused( + "InvalidInput", + "the client's storePath must be absolute (the installed Store file)", + )); + } + Ok(path) + } _ => Err(refused( "InvalidInput", "Store calls need the client's storePath (the installed Store file)", diff --git a/engine/crates/layerfs-persistence/src/backend/sqlite/connection.rs b/engine/crates/layerfs-persistence/src/backend/sqlite/connection.rs index d7598adfa..1934f4c30 100644 --- a/engine/crates/layerfs-persistence/src/backend/sqlite/connection.rs +++ b/engine/crates/layerfs-persistence/src/backend/sqlite/connection.rs @@ -166,7 +166,19 @@ impl Session { } else { OpenFlags::SQLITE_OPEN_READ_ONLY } | OpenFlags::SQLITE_OPEN_NO_MUTEX; - let connection = Connection::open_with_flags(path, flags).map_err(rows::error)?; + // SQLite itself must also see the verbatim spelling: the plain one + // fails beyond MAX_PATH under a host without longPathAware, which + // would make a deep Store installable but unopenable. + #[cfg(target_os = "windows")] + let sqlite_path: std::path::PathBuf = { + use std::os::windows::ffi::OsStringExt; + let mut wide = super::windows_lane::verbatim_wide(path); + wide.pop(); // drop the NUL for the OsString form + std::path::PathBuf::from(std::ffi::OsString::from_wide(&wide)) + }; + #[cfg(not(target_os = "windows"))] + let sqlite_path: std::path::PathBuf = path.to_path_buf(); + let connection = Connection::open_with_flags(sqlite_path, flags).map_err(rows::error)?; connection.set_prepared_statement_cache_capacity(64); connection .busy_timeout(std::time::Duration::ZERO) diff --git a/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs b/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs index d529a6b30..d70a6e989 100644 --- a/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs +++ b/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs @@ -115,11 +115,16 @@ pub fn verbatim_wide(path: &Path) -> Vec { const PREFIX: [u16; 4] = [92, 92, 63, 92]; // \ ? \ if units.len() >= 4 && units[..4] == PREFIX { // Already verbatim. - } else if units.first() == Some(&92) && units.get(1) == Some(&92) { - // \\server\share → \\?\UNC\server\share - let mut prefixed = Vec::with_capacity(units.len() + 6); + } else if matches!( + (units.first(), units.get(1)), + (Some(&92), Some(&92)) | (Some(&47), Some(&47)) + ) { + // \\server\share (or //server/share) → \\?\UNC\server\share — the + // separator after UNC is part of the spelling; without it the + // server name mis-joins. + let mut prefixed = Vec::with_capacity(units.len() + 7); prefixed.extend_from_slice(&PREFIX); - prefixed.extend([85, 78, 67]); // U N C + prefixed.extend([85, 78, 67, 92]); // U N C \ prefixed.extend( units[2..] .iter() diff --git a/engine/crates/layerfs-persistence/tests/verbatim_wide_windows.rs b/engine/crates/layerfs-persistence/tests/verbatim_wide_windows.rs new file mode 100644 index 000000000..4a0f763da --- /dev/null +++ b/engine/crates/layerfs-persistence/tests/verbatim_wide_windows.rs @@ -0,0 +1,41 @@ +//! The verbatim wide spelling the audited Win32 calls use: prefix forms, +//! the UNC separator, slash normalization and passthrough cases. +#![cfg(target_os = "windows")] +use layerfs_persistence::verbatim_wide; +use std::path::Path; + +fn text(path: &str) -> String { + let wide = verbatim_wide(Path::new(path)); + assert_eq!(*wide.last().unwrap(), 0, "NUL terminated"); + String::from_utf16(&wide[..wide.len() - 1]).unwrap() +} + +#[test] +fn absolute_paths_gain_the_verbatim_prefix() { + assert_eq!(text(r"D:\dir\store.sqlite"), r"\\?\D:\dir\store.sqlite"); +} + +#[test] +fn forward_slashes_normalize_under_the_prefix() { + assert_eq!(text("D:/dir/store.sqlite"), r"\\?\D:\dir\store.sqlite"); +} + +#[test] +fn unc_paths_gain_the_unc_form_with_its_separator() { + // The separator after UNC is part of the spelling: without it the + // server name mis-joins (\\?\UNCserver instead of \\?\UNC\server). + assert_eq!( + text(r"\\server\share\store.sqlite"), + r"\\?\UNC\server\share\store.sqlite" + ); + assert_eq!( + text("//server/share/store.sqlite"), + r"\\?\UNC\server\share\store.sqlite" + ); +} + +#[test] +fn already_verbatim_and_relative_paths_pass_through() { + assert_eq!(text(r"\\?\D:\dir\store.sqlite"), r"\\?\D:\dir\store.sqlite"); + assert_eq!(text("relative/store.sqlite"), "relative/store.sqlite"); +} From a7f5cda780584666351252d5db9c5bc0c740184f Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Sat, 31 Oct 2026 20:13:51 +0800 Subject: [PATCH 32/37] =?UTF-8?q?fix(round3):=20five-angle=20findings=20cl?= =?UTF-8?q?osed=20=E2=80=94=20Global\=20lane,=20sidecar=20DACLs,=20relativ?= =?UTF-8?q?e=20storePath=20fence,=20residue=20removed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 on 34ea1b95c (A1 fix-verification, A2 concurrency, A3 cross-platform bytes, A4 perf envelope, A5 delivery clean-room): every P2 fixed and probe-verified; P3s fixed or recorded. - A2-F1 (P1-Uncertain) dissolved by adjudication: a canary-calibrated, name-agnostic enumeration of the session's named-object directory caught the lane mutex live during writes (openHits 89/465, heldHits 78) under the verbatim-form digest; the round's probe had derived the name without the \?\ form. The lane holds per write transaction as designed. - A2-F4: the writer lane moves Local\ -> Global\ so a session-0 scheduled task and the user session share one lane (probe: Global openHits 592/1455, heldHits 580; the Local spelling now 0). tests/windows_lane_abandoned.rs derives the new name. - A1-F1: open_direct tightens SQLite's -wal/-shm sidecars to owner-only whenever an open sees them; connect creates them under the first open, so they are restricted from creation, and a crash's leftovers are re-tightened by the next open (probe: crashed -wal already OWNER RIGHTS-only against the pre-fix Authenticated-Users inheritance, reopen recovers the write byte-exact, clean close deletes the sidecars). - A1-F2: project.init refuses a relative storePath up front (InvalidInput), matching direct.rs; no directory or project entry is left (probe: no relvol/, no project entry). - A1-F5 recorded, not changed: layerfs-overlay's two Win32 path probes keep raw wide spellings — the product-source boundary keeps that crate free of first-party dependencies (a dependency attempt failed the boundary guard and was reverted); TEMP-local inputs fail cleanly beyond MAX_PATH. The exception is documented in DIRECT_STORE §6 and in the module. - A1-F3/F6: verbatim_wide's doc states passthrough is byte-for-byte (no slash normalization for already-verbatim input) and softens the longPathAware claim to hosts without the long-path relaxation. - A5-F1: remove committed probe residue api/agentharden-run/ (with its 208 KB store.sqlite) and unreferenced api/agent/harden-child.mjs; .gitignore guards the run directory. - A5-F3/F5/F7, A2-F2, A3-F2: documentation — the audited-FFI list pointer now names engine/AGENTS.md, inprocess.js's header no longer says "Linux only", README states the Node >= 20 floor, §6 records the Global\ lane, sidecar tightening, the TEMP-overlay accumulation trade-off and the SDK's lone-surrogate write behaviour (U+FFFD sibling; the byte-carrying path remains the recorded follow-up). LOC correction for f5622cd04 (A5-F2): its tree recomputes to 104218 (rustfmt -8); the message's "104226 -> 104226 (delta 0)" overstated the after figure. Production LOC: 104239 -> 104255 (delta +16) — pinned tools/production_loc.py on the first parent (34ea1b95c, detached worktree) and the staged tree; project.rs +10 (the storePath fence) and store_service.rs +6 (the sidecar loop); comments-only files contribute 0. Scope: core/engine crate src plus api src; tests/docs/manifests excluded. --- .gitignore | 2 + AGENTS.md | 2 +- api/README.md | 2 + api/agent/harden-child.mjs | 7 --- api/agent/src/transport/inprocess.js | 3 +- api/agentharden-run/home/hard/project.record | Bin 442 -> 0 bytes api/agentharden-run/src/README.md | 2 - api/agentharden-run/vol/global/store.sqlite | Bin 212992 -> 0 bytes api/docs/agent.md | 6 +++ core/docs/issues/352/DIRECT_STORE.md | 45 +++++++++++------- engine/crates/layerfs-node/src/project.rs | 26 +++++++--- .../src/database/windows_identity.rs | 6 +++ .../src/backend/sqlite/windows_lane.rs | 22 +++++---- .../tests/windows_lane_abandoned.rs | 2 +- .../src/store_service.rs | 11 +++++ 15 files changed, 94 insertions(+), 42 deletions(-) delete mode 100644 api/agent/harden-child.mjs delete mode 100644 api/agentharden-run/home/hard/project.record delete mode 100644 api/agentharden-run/src/README.md delete mode 100644 api/agentharden-run/vol/global/store.sqlite diff --git a/.gitignore b/.gitignore index 74be77db6..7387221bc 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ target/ # (K3-R3 F1: a stale addon copy and pnpm trees entered a merge once). /sdk/js/native/layerfs.node /dsh/ +# A hardened-probe run directory that a mistaken CWD once committed whole. +/api/agentharden-run/ /target-linux/ /eval/runs/ /eval/datasets/ diff --git a/AGENTS.md b/AGENTS.md index 346cdb47d..e807c1989 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,7 +161,7 @@ remains a separate MEMORY/OFF/EXCLUSIVE database file. natively on Windows: local NTFS volumes only, a named kernel mutex as the cross-process writer lane, real volume-serial + file-index identity for acquisition rows, and bundled SQLite, each in audited FFI modules - (core/AGENTS.md). No WSL2 and no daemon are involved. This supersedes the + (engine/AGENTS.md). No WSL2 and no daemon are involved. This supersedes the Linux-only Store sentence above for this route only; Workspace, FUSE, Exec and daemon calls still refuse `Unsupported` on Windows, and the dsh macOS and WSL2 exceptions are unchanged. Recorded limitations: Windows Init has diff --git a/api/README.md b/api/README.md index a307f11dd..f0265110d 100644 --- a/api/README.md +++ b/api/README.md @@ -87,6 +87,8 @@ against the repository-root manifest and stages `target//layerfs_node.dll` to the addon path. A first build needs the cargo registry warmed (`--offline` cannot fetch); a clean machine installs rustup, the 1.85.1 toolchain and VS Build Tools (link.exe) first. +Node ≥ 20 is the SDK's runtime floor (`api/package.json` engines); the +build script itself needs no newer Node than that. At runtime, the Docker Engine server selected by `LAYERFS_DOCKER` must speak Engine API v1.48 (Docker 28.0) or newer. Before its first request the SDK reads diff --git a/api/agent/harden-child.mjs b/api/agent/harden-child.mjs deleted file mode 100644 index e255ebce0..000000000 --- a/api/agent/harden-child.mjs +++ /dev/null @@ -1,7 +0,0 @@ -const { LayerFS } = await import("@layerfs/agent"); -try { - const q = await LayerFS.open("hard"); - const text = await q.fs.read("/README.md"); - console.log("OPENED"); -} catch (e) { console.log("REFUSED " + String(e.message ?? e).slice(0, 200)); } -process.exit(0); diff --git a/api/agent/src/transport/inprocess.js b/api/agent/src/transport/inprocess.js index 6d8989530..f211e1931 100644 --- a/api/agent/src/transport/inprocess.js +++ b/api/agent/src/transport/inprocess.js @@ -1,6 +1,7 @@ // The in-process transport: the real native addon in this process, from // LAYERFS_NATIVE, else engine/crates/layerfs-node/layerfs.node. Store calls open the -// project's Store here (Linux only, #352 F-020). +// project's Store here: on Linux and on Windows's native route (#352 +// F-020); on macOS through the worker instead. import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; diff --git a/api/agentharden-run/home/hard/project.record b/api/agentharden-run/home/hard/project.record deleted file mode 100644 index 2e54d9f3d8ac08593f1925c0b6efcca1d9981ad6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 442 zcmeZq3wC5;fB;4jb|8a^fg?9DFEcH*xP%qRJ?P^W?8(T;#Gs^~lUSKrlvb=;T#{du zs-K>dpOl!R59a6<7v^M^q%tU+_&+~l$#+qgj}OvQr9WMo|G9)~x7V-Pv9|AYT&n_u zOrHH;VEj#ILzQD$$kh{Kw_JBUDv|yCwfSISZS((*_Xqh6&5~>_PhX9VPPX66-_BM;PVtFpoX diff --git a/api/agentharden-run/src/README.md b/api/agentharden-run/src/README.md deleted file mode 100644 index 814f4a422..000000000 --- a/api/agentharden-run/src/README.md +++ /dev/null @@ -1,2 +0,0 @@ -one -two diff --git a/api/agentharden-run/vol/global/store.sqlite b/api/agentharden-run/vol/global/store.sqlite deleted file mode 100644 index 2704cd0d7e54fb58ed9f2541e588a314987ceaa8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 212992 zcmeI53wT@Ceb{kHNg$~QE!#5VNV2aY+XN(%BEYv5$fiU{W(0u>2u+qP3kHEJ5)yz* z;~`2~+?v$mOBBa(x@=vOesSu$&7;njEzKG?k1fsSIPuo5Uotmqzqs}H)t-|&b&|%d zw{>xL-uImQ0AM{ViTXn#iF?ogyng5XKd;I6$8r@zKWvr?>5AUxyUy3t)&s~iqKATAL^PAG zr1R!<3ccJOo7k?$Cz5)6Uo6Ij^G0#HG83@Gg2-}T*lxR+NtcT0O3o|_pSerK8yqU; zy>Lphvpt&JAC1QKkUkn8(?`PLzP_PwZ{OhXK>yItz;N#n^KU9w%;t*IsiVe;n!%Oi zg1YK>C_Ky@%VmvXC099-VtJJ#;{oRZw#%XTGZ)I48KaO+9WzR0x0q800;*Ib%;B;3 zWwTn!7tf7m?LZzp|+qtnM{oDNSVNg!VWzu;^8tY@xoum6=NxhfFnF(%-^kP(*bkQs}W>JVT zt0b6KcIJQ_9#T1G2^W|w%GmY8$t9UZ%sGW}MJZ>L>sywkWd{!D@(o)jva;o=6P1Q7 z6It1^px0pFg}m%YqsU4074GB>N7`TzxVRjGLN1@rHJSia7+V(wVqurx-?3$jb~-`| zM8+&uP@<;FxoK2KRZE8BgKkOBrZMX)Da_7an{g@=+0;&8s8C}A&W|kfcWm6KEsPM7xuTghQpeKysv&({rqGiNFgLS=YHX>> zaH8W9oE~ii`okmrBZEWXkpUbB;$^My6Mc8>(O@7g^cW-xddbWiUa3#a)Od$T<01-XiaP7%fJ4sa*x=C6P&hP*!!euA z9L1T)(acHt2+IVRAht7IGOM$xVzn@3l$;i64pwBi_cTL?2T0=K0$0k`aYhwV-9g1c zD&1ZB{_*5J6Z?{SVq*XJ*qP%?XzSM}wSKc^K9@N`DyJ0vzC=l2*`&eNPgmP>8t z_74vXkqk12+%~9JStDObrwZxgsjM+unehs64IA7mnh^$QH(;h(Jc?Xw$nA`)u$!~I z1!>e}PkA@Ti^lOv%CqS3Zcm7Hdt~WtB+ldwui+d;&(%Jc=E1Smhl5rPH@~jgzjkO% z%lsVbu}n`JB^uB|p-PGZ24YkU{E}z>#P}{eDsYuU9p#-9i74t?#iD3*F%c(X=zZl?uR4o5X$=IA z%JJas+dGB_KNlXng-Oldv1W~Srj^jbOz=-LXGKO=_zkWgC^4({bk_e&f+l;`ETnVJ zu@K89!Zgs^6$;{tHk+F^%9UCT9F-ugGm@{}Dlk)?f%;fc9~>G+C7(1(Q)bq61jV}p z*gP1a?%?pzs)dF-64ECUBm`*Cx_LeNwP_{#6MfmA|lNL38_sbr-Ez zSsEw8(V$$EpA3e@q*R?c6)MW4F)uF617bdmbSZ&^{ktP`(;{Pop3F zD)1k+{4$&Nt#9#VSK{f~J?Q5o`uVT;I~G8T&L{9+JZilQJq*V2B6JPEYC`{XUn^R5 zJ+>#>Q^=yn=6r6-*SrxK-m0NB>)Q2bZ3p_}M=zS2e}R2$_8mtLm7S9b^nRJ|FTXzW z@cxhd!9!nuqUEO7x+k_ioBQowR_}Z9ryskock4jqv6oKvy!zrt&wcz>f00e*l5C8%|;5-O)`(s*DQ;YUrR=<1wU%d6!Umom! zHuE1JY&o4d^V<9U_x|_d8-MuHl1*O>J>BM;*R^IYU2pP#nRctLHEl+2YfUXJpV(47 zR9yJu=Z?SCeYV>7(x<-n^!sk>yzRxcuRq$f^x@LzL4Qowm~89EzV_yu)qk_(OW(cw zv)}s1Pltap-m&48`#yYR`H@fm!T)~d&FM$|UAnf(A8W<#KJm-zpMK`S$KG81P5-f{ z-~7U7UisOXgJ%;zd~VyJsTGHc53ITMIt|bNH@Dy5YybD{Q|J$TfB+Bx0zd!=00AHX z1b_e#00KY&2wW=!_B1c?&EK-XqN~*#aHI9}e~)F4yiyrn_Lb`0%%1qOrwKbZ&z`k< zV{+u&Q&`s1IZY0oub%3lUg+@nA07G+A0Pk(fB+Bx0zd!=00AHX1b_e#00KZ@5fh;M z|6lU8zqE)W2~h(AKmZ5;0U!VbfB+Bx0zd!=00AJd$O+tnR`{d+3r!l@5YW`DwKNTB zhqaMr^0)b}?{|~&f6Iao{f7?_00KY&2mk>f00iD40`srmhx#ZnbhNQH|D)0QUzq<; z`oUwY$j?%Z7J`_zM9y+cn{f9FU4d?50ASM=oj6aCFM!IjW6fySv>CWN9WgyU$k4#EpN#M;^m52GE%cr3Y0T;9+fH_p?Gyj3aeXPaIwr1SX{ww0>Pl#KF> zna`%CPE?Gtmn$`F@T!#iPi@hTL`+c5}91RMP0m`gEf%d&;{xUNnxQbVJw8r=%?(FynqE0i-k7Cqu9(g{F}m94(mXgJg0Me4(mygd6doCL zE5amGn^Q-OOeK{!GwBM>p(tOoS19b;V-wr;y@~NXqlp9h?&tv)VtSc;K*;f{>`N#w zIKxZxDKS$+7wRJkoGzNhTqd1QvAga)GJ3`?XLD?DXlN)LBALf00e*l5C8&~j=%?&^{k1-;-SH*$nY%-(Y@QBeEpB^Jp1h2OuCe< zcg>0<@ej%MJ z)?E-#SAdTHzv^rM>Q#ja0t*Cy01yBIKmZ5;0U!VbfB+Bx0zlvzArM^>(_;0`2WYN$ zm@XdwH?{xB*Zy<#3qC*q2mk>f00e*l5C8%|00;m9AOHk_z*SA4Wr@~$eRDoHwF-T? zr6~~|9orM_DP-yV|Es?CSFh^mLEwP^5C8%|00;m9AOHk_01yBIKmZ5;0iW+(&1-t_ zeFBbM1a$oWys!Ovzyt(<01yBIKmZ5;0U!VbfB+Bx0zd!=T=@hBntPmg2sXI?AIASz ze)>WPfdCKy0zd!=00AHX1b_e#00KY&2w(!|KmLdN{~!ke0U!VbfB+Bx0zd!=00AHX z1b_e#Slk5g`hOVzFYYvk*Z~0`00e*l5C8%|00;m9AOHk_01$xjKjZ-*00e*l5C8%| z00;m9AOHk_01yBIi=O~J|L?DS?SHlS;|ehZ0zd!=00AHX1b_e#00KY&2mk>fu=ohv z-rUpUIPQP-Z+_{|AA9-6BS+8e{{BwiYo9$s$Nw+*+Fw|F1RzF000;m9AOHk_01yBI zKmZ5;0U!Vb7BzuQOM2Eg$Nq(MuIMrT|BkQyJBykKL=Ff50U!VbfB+Bx0zd!=00AHX z1c1PzATYEv-qX@j_qspFf&ke6zbKL)A^`+|01yBIKmZ5;0U!VbfB+Bx0zjZ90T}<+ z!~=#v00;m9AOHk_01yBIKmZ5;0U!Vb76kzq|1XMUgh&7ZAOHk_01yBIKmZ5;0U!Vb zfB+DvNr0aJ_g!E6cWa7)ArJrpKmZ5;0U!VbfB+Bx0zd!=00AIyB@h@~($f-mZ11mo zAOOt&UkQl^VE_U^00;m9AOHk_01yBIKmZ5;0U&T`3BdUO(((n@fdCKy0zd!=00AHX z1b_e#00KY&2wVvSVElh2qymHi2mk>f00e*l5C8%|00;m9AOHk_z@;Uy5<7q0rS$_` z2LeC<2mk>f00e*l5C8%|00;m9AOHj|F9DeUzr6gxcOU=+fB+Bx0zd!=00AHX1b_e# z00Qp>0T}Gf~^_^B7Z{qn;{A9?EwzxDX% zmS`u@e?0!z+W+2%{=)|d00AHX1b_e#00KY&2mk>f00e*l5LgTZR%^|{)?6`HNtv@o zDP75#MZW&O{W|<_F+>vL00e*l5C8%|00;m9AOHk_01yBIR}X;`%UiV-Et)SD%ldwW zX1X;pJALGp%J8zURPVOO;}ctohl&e-{M_-ky3bboUi#Gco_^nLowvQX_Vq`bmOfk> zJvesf!8x;Zv^<;67zgv|6GrK9`CxiBcQ8F|6e~07Qr0MTm#W2sf00e*l5C8%|00;m9AOHledIGIWTeX(!o2VYbudVo%82@Lk z`nW*^fB+Bx0zd!=00AHX1b_e#00KY&2wYAA^J~#`|GIVA<1Kj3pYGXjM_*1p`JY&@ zTIBhE{En}3!0Q3{^V^x#KPXlfJn_rxpMK`S$KG81P5-f{-~7U7UisOXgJ%;zd~VyJ zsTGHc53CX6|G#5lfj>Y12mk>f00e*l5C8%|00;m9AOHk_z*S6O%~GUsR_q9FaKZ*W)3;nywKFd}tBO(4s_i}$nM~C)_kxF_hZ;*W+dDdVv8Ya7>47#KeaQDzE~{^kO>EcqCdT)SCJyMkqX&p7me_GmbjNNzkT;6cm6-sS z59$%UuTLM1kLl&=RJl?TCS7_6{m16_cJ_rkgI$t8;jjxsq8iXs+cJX-;Fo z$e4u!I^~1qY~gne6qb#~PzZTvs@ub$pm0fcd#D1oY@@Rf(d=(eYZpTiHg40RKamhH zT)kn1(%UN+0o6MuK)qy1wsRs89p4qlH47>{cmrCBms~4oiRjK~A{yTjon-YA5wVNo z{1HtgNL4l-A5V^t#$pH9%jg)*1U0>nRHyS2^Vv2mmJXvAu- zXtt1YNSm|mqB2Sg#xv8~Ysa`PXJsY@0M#LK`Rx;pOEFzA^knp2)QZ(^$*^Eh-yTiw zk4EEqh!m~CK8rOg{=~hAo;T-=Qh?ZT7s0;m!p(K(ruz@2yHECZkMyLvAJ`;SvCKu* zG-J1T6sX1fskH~{Sm~xsx!n${Xfx;tC0@Y_1h&Si%!HKIYPMt^&gJXcwZs(LGe$bA zw&=ohQM0l5vrda*!&zG^#+;MdeW_WWDE6%+v)5*h;=_9-b&9c9={eos>hHMycI|_k zN$Hu%l`CfHM5m|A+rX-wr zUCg9QMRN9*@Y$;bx!F+*(nvVm*EbaI?He2(=pPyy815Znp`FSVv$^7Q>Zox-?I`fL zvFstq*-L3CJj@)+p&indT;)XSuvsdkD~^D3H|=t$o6KA&XJ(8-I)(Z!WpwJ8hXVms zDiRhwJZhV^8)~h?iz9J9YaGVMn_(Z+s)^UZK40@{oNYdwBH0FK8Y5LPQNotx?=4EU zkxg*6sg-8}xom`F7}jrd#LCAf95)W$Ci!Sye4@=yCt~Q zE=&E8>%9GwEC?@YCyDkW7S^|ursCAYq|EaQZzfl)$lu$QGB2C3rXn+w%exdW*4k3j z4H2D>)95J1nY2uv8A}#O4~S?YMA21Sa?&fZ^`ycB1FVLXo38)~4e#uC>BOc;b6ZDr zp>LJHWAkS1^m}hrOSt`FBVy5KvV}+%WTEn+cJBI>{*Ie(*8bJ6(_BTA z4|H&{Qei~jF*IWI;$Y*^dlMFLa8wnLFp_(1Sl2+CM5*vNqV3_CYQ^cJYA$;V;{rx~ zDqS|-UjJONK{B19SyYS_J=rqynXj> z5^F0H8VG^P&NT>^OsDbDEh|W+_$jJ`r}>BeUZGTqJOsRHRrHk&XyQfAbmkplWY0F2 zwOTx1-8R#Oib&Nji4Gfe%A#G~Nz^W32P-Oq%UB1Njf`2$rdUu}GcN+h`cdMU%mLVK z)Zt|J!O6)k!8wh`^}xNIsOIWovqDy(JvrNnU5 z*-f^O_Z4wAc0>gLzixE=YRRhh`G~Z!KyYcxjr`mk?FjP`4@HyBm1pzlu)eIVJEOdJ zW+vHK>dMsU9(3uKzrD8Cu2xQxbf~<&bo3iU!%(N|;x&xH%;K)KWD;!0d0f!MEwGqQ@){wiNt`nJ)^3BzX$I|(# z>olq|mKH(m(y@o=H*&^{=luQeA%p1!nTv3N0uYb73mwalu2Ha_UG&tHQN=uJ6xDLX z39)WIks$2=uTv{#73oyH(QXBqeb)rocfj`i!cQ&AEdo_+Y= zcPnw?Z&|cZV%97g$egzMtg}HfjAuNkDT)T46uPi@BhR~Znngt!vuWo}b*x3I*r0YM zN8+yLarNnDg@bDL&JVOMF?iG^G0NUEQOsD%aIEkX1*_f`o6tmc*!8?AG7&EeKMLv%M-}5tqA+O8D+B`KGR3r z7(D;CwJjPSo8R~@|JvZqEeq`|e7rzXC+L$cgsO-mh`bkg5kr~kK;5{TwfU8#ZDM0a zGWkNkcd!sDEuSog(a~o|TSR6Zj@*R$L&$^mvIl&gm|DE4h96wAhBquUxoj}MhWbSt zS_YH5Kp)q*`DK&DesR=}ND0~C{EauF@{cCT=B#$ToT78oB&*%%<`*&HVj|xK>`S7$ z_!>paKT;RaOlFssyEOBwZa_(jf>n@%MJ9xZ-#63<6`jOF(I=9QxU-=OF+@W%I#Qt_ zvKh$%I&nRnOT0@C4?20>DCg)5-}N;@#N;ec)N`v|^7bAru7zn##2l{z!IPc53jouY zW(lS-*(yXl{%>h6`&NB^)$*13wuf5I`TuO$>E^QbUacRG^Wo!70M?+@nRn-lC>%pe zTVGR>y9U;von6&JRA;aTjVXe;Uf4P6wyUzaB(o^5Dywfy79`cPMx}vKo$HC-DH;oH}A;#NHdBY1Acc zJ`*7;tk7E3fUtt?i)g#4t@utfA%x~R!j|4_eqOJ;R8Rjd}KJeSV}6dCS4&CqNuA)Upk z?w8GS;&pdN{32puQh}Mj1SZ2+9E^|WuzC=l2*#xI<11qp~buGD9il|a- z#W`K&o?=@tysWBHbk%pcfL2PTaEp?vAccE*#>{6^9_y4@jpA*CQw{ZndWXWKuNCU; zA08MY8DtK*ZBVbWM!u3x71GC3wY1lP8aB9BG$Ra|3zqQkorAyyg5(%VR!$s1n7If|aEeJ;&|W2+Abtr~8AU9*4f(3+O{ zIi?Y$0WB2hBn<{)R1Ex*XMU!*bd^I&Ks>XJ){DiWNL7iHHdu_S1}D+m9cYX^ws1FVeqmoY=r71J(x{HT*2e5fCLfyd)0j*kSxFaEJ zV&lD$@L)L9-*4qfJpNy)ebU$dk@molj&u>lh5${NRV8KaAI z5QC@p2FWz!2k$4PPqn8$bt)xNH6vZKS))YuzsUvEt$RbPiXbV0!2u?{>>`4r37Gok2jF63RghVZAii!#&i->c^w!B2`1#_8T1QkgXBa*ta>B>wk zPlS+NRl--yrSmzo2;I5t%&`%S39_La>9pWws?>8zj#eoFVa6*osXkTGrOS!hs%vaL z!TF^Df5*TEZGP0s-zaCIU7Xn*>R#ggoFoU3cXxPZY?~F_5L(vM!0~*-*Ms2YJdyC3 zu1>V_WP^5Yqm{LZqn0zbJF_&AxR}|Q&7klwil)n!mu+I(B4_}LvJReWtCefWTpX(Y z>2@K9Cp5MBYnCHwti1{lGu~p+E{|;_#2ph6sa+ODH_ys~wwSZLs0i}>$aMggWhMX0 z#@vc1M=tqYn0b{kA|NVKYfS-fkQMV_WRP?&JqwL3O}j{9iGi`L*zCPy8Q!gRfDWG? z%I7LZs%$(|#g$E2eY{P{wl+huIj#%Y44%E`c2c)Ly4xxMqS{p};f;!ms+nhDkm9Y6 z?n_RL$B`?0qVXikx8__CorYn|nwc4qQEcfhiO_0$b_UgNUeeT1&6#Ap#O6$Ct5&P1 zD1xl&XYcB^0J0pn>U!*t+7O88%m^L)lbvxj!-zNOiJWYpL1xLEYoN-X;i9aFlV^|d zDIe|dlV~hiW_i{~A8pWIA-XLhf`F?n^1ea;Em`IWv4>qL83nX-q)`U4G>Zv;)nG^z zaqNUBs|)S75e3{Sw5=K)LPgq5Iuww;zDFrUk^wG3=;J-Q5JxYu;RRaRPqkLa78^yl zcY(M?dC8ttSE>b$%LcFjSz*N2%gO~gK!bpeg&}8SSqN;^6)Y5XKmG+43TI)VFj~+o zFH^n@3x(0pb_+gDN38;QH^xOxzg0bwi1)0-gaod`uFc7pt!*kAW$|ERN^7JkI=@{( zV%X*E7TKF|A5!{ z6u8hS+pTTmbm1)OZcHf4WYGaQLH%Eh#`Z-g1EHX;wtxee(4s`z^%uRYYisd~y^@rc zbF$vA^ad*k-`3HrVjShz@~XPZcX5()&culsM2DiD8-=^$_(QY_BFV`398GY*k#=bC zg+A6PDhVc| zWOb$<5<3=1aS@KQN=7_mpBy&o>}I#iWAD|?|8@yEu5eN@qJfc-;~K1@&ywAqK=ynk zc9M~k!QrH0IH{PNM_#8rpjArE7KxQv+g>1CKS-+z8`!esJX{Ek|9j;Vq=hEarpB{3 zPTVurK8cVu+FuhEoOQ7{bhp0N${+epVStY9GvbWZ`X6IYwvzA3f zs(zL4uu-SVx63<;+9l)|x*e#c9E#BpVa0|s0E)5fyLCr`{qUUnoE$dlaI(uggK3xG zA%z2c*r7g*u(hBl00;Qg*TOKJ9@D>|N*f=iBh+y9?JMG{D~&4euoH?RiG_meh8hb5 zm$t$H2l((qR?*gWcISrGZM@LN`Ft8?I<{cVvO3Uc;9Nan!P!U(BVH?#Ub0Z0sBLIN zjfQH)>7!~cduuhz*SO9`X;>SAuGnxkfr_z$Ty((HGefDTq`w8njk*?nDI42VMi)LX zP?F2rcbmT#^DY;nEW1MLK6;?EIIEgfg7mG6?#!mJ;c}!<@Ffq{VbfjzJP?EWfo8mFI8#nt*clcz zoxb1$JJ9$)#A6{J+ELS2s*VsmFXG`P?%s;(1ueQor?_K}x&FaXvk6&`G!fkx%PsoK z;gXQ7?UKk_9?p=-A}f!gtDwkXL@b0GCd}SPiCFLx_mFFvZT;e;go?G0nF2|GymdN97^mQD4^TM2pZL)Gul zj*76xc9{fW2Z^9;nlMPHbL|12RG7T!?R1fg@sg~pQ$5+fftE#aM3~+VWP4Mm4RkE+nJm>AP zQ&#vbjB4ah<}KAjv4O0sXNm;&g1)FT)K#3XnfBB8PmvnxZ?FfiSItx#N;`d{^qjt*-1cIkBU zEF_jXg9qaxsX5xnmT6HDbJuCtfLG5<3xERQHX0IJ4UjpV+h)4jTIZtafp`8^4;EK> zmQIbYY{KG9=X$u7hHE{zzM7g!*9p_y?OfQymPRsT$pS8%HKAew`+*o2;r?OHg4}^; zqd?xV_={GM22xVFcr5!A7O2`@z9vH$$x9sI?%d8z_a91kpX}`(=}C1zu&L8pQNVId zncgyja{?!~3vVCpyq{WoppKPpbXy==U~jiWm1xRh1~n&or7$`OMBKJZ3%rubvM~=Y z+4BX9Sy;tbW7)Zt_|iHyshzg9r?8lXum5wdvTtM|AFBpw7hA0c#bhu&A4K%R8?6Y1 z1!oaKYQfH1 zE0Wc|*qvl1yKmwdca0r3V8OjrN3y~KH|CbmeZKPjyFB+7ci;qHEU4S#Z97>y$G$gaEli%zGIcP*q3;8jB@5> zzIsulf$d@ldotLJ@j&w0Je!CClNJ}Ks3Ze6+Fn$L(6Q*t$IqzomrWP|v|ya;S|n8i zSGOQj>k`;Zx{^lAC>_0HF3e6YwZuq=jW015uZx*<3Eg;tZUhlNb1jePhp}l+I9_6-gX^bZXU4EGMPE(%vI)m!``$=S6&p)ffR8TARdJ~%~hLvRF~Be2V% zV<}j0)EgK)90;gV?4AbPCq=hd7t-w3MO`_(h1P{kAdAjoEYd@w6(OiI+B$*8bR7oRo@>Pqz-(R^-WK z3^uc8c7Col5#agQ#Y4sX;-2{{XHGF`_%wCwWj)UJ7VK z>D7-_yy8~QPhZSBNjz-CUwh2NGv;BpP)D0-6L(Uv3E1HXzgfTceD&LGfv}jttRGQ_ zjXE*4%RAGDsM6ex zGihrT%DnZZ4Yt(H7Y!HFJ=FjqFTPN_0dfmr&GC{YN2)#sx6JsXdgiwDXb@cT{7t(1&`?KWr?gUt{prMLpxQS+(edLhTPiEvdk z%}$rgz^sd1WNYUVatjRkFRGMS!-2;(#YLDmFt_OY`Q_ zqi~BA<6KT{D09;@bLi6JlE=)99=I}2f8u!1otXyG(BsS0ZEPeH45rrPCc41`UuMpy z){YsavfGp-%YLpCbL!RT9`NvSafLLRk13gW`WY@zce!ny&*{qdvhzhj*WLx{N;a>U zXwqHmB%`}T$PuS0YSLgfhbxzMj_!*kb$-jEnCM0qG|4vx;Ue0=1(U=&k(Dhsc&{R{ zOk`!tVn)$fkJ#7D5eJ04>`0>r1m_X##Axv z+%8eiU$%3_M8KGGhu&aj50_%7+mAaVrWf(BF%c%*RZQgyg(}%xhO11YV%WpxDuhCV z$E!%)6^kaLco!k*c5aXE8jrW>lj!Y^q@EZZpNs}Zw@)OJU7fSlseG}0 diff --git a/api/docs/agent.md b/api/docs/agent.md index cd5cae7aa..35384ebc2 100644 --- a/api/docs/agent.md +++ b/api/docs/agent.md @@ -310,6 +310,12 @@ identity/timestamps. Destinations are exact and must not exist. Same-path/subtre moves or copies are InvalidInput. Mkdir creates parents and an existing directory is a no-op. Edit requires exactly one match and streams large files. +Names must be spellable by JavaScript: a path with an unpaired surrogate +(which the content grammar keeps byte-exact on Windows) cannot cross the JS +string boundary — a write by such a name lands as an U+FFFD sibling file +rather than being refused, and listings render the original empty. The +byte-carrying path is recorded follow-up work. + A file's or folder's permission bits are an integer `mode` such as `0o755`. A `write` (in `fs.write`'s options or an `fs.apply` change) or `mkdir` may carry one: a new file gets `mode`, else 0o644; an existing file keeps its mode unless diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index 6e34eb50c..5c5e4ddbf 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -98,7 +98,8 @@ Windows Store shares one Windows kernel and one local NTFS volume. No WSL2 and no daemon are involved. Implemented on branch `windows-native-store`: - The cross-process writer lane is a named kernel mutex - (`Local\layerfs-writer-lane-`); an abandoned mutex is taken by the + (`Global\layerfs-writer-lane-` — one lane per directory across + login sessions); an abandoned mutex is taken by the next waiter, so a dead writer does not hold the lane. - The kernel guard allows local NTFS volumes only: it opens the Store directory's own handle, reads its final path (a spelling that lands on @@ -109,23 +110,23 @@ no daemon are involved. Implemented on branch `windows-native-store`: only profile, unchanged. - Acquisition identity is the real volume serial and file index (`GetFileInformationByHandle`), in audited FFI modules listed in - core/AGENTS.md. + engine/AGENTS.md. - The SDK addon serves fs, branch, project and operator calls in-process exactly as on Linux. Workspace, exec and daemon calls refuse `Unsupported` on Windows: FUSE, the daemon and Workspaces remain Linux-only, and the dsh macOS and WSL2 exceptions are unchanged. -Recorded limitations of this route: the Windows lane and discovery -mutexes live in the `Local\` namespace, so a session-0 service and a user -session would each hold its own lane (Linux's directory flock crosses -sessions); the product opens its Store within one login session today, and -cross-session coordination would need the `Global\` namespace with a -privilege evaluation (K3 review F2). Inside one session the named mutexes -can in principle be squatted by another process of that session before -this product creates them (the directory ACL is not the capability there, -as Linux flock's is); the discovery turn waits bounded (60s `Busy`) and -the writer lane's wait is the readiness wait of the lock model, as -Linux's blocking flock is. Windows Init carries names as +Recorded limitations of this route: the writer lane lives in the +`Global\` namespace, so one login session's scheduled task and another +session's interactive process share one lane (round-3 A2 F4 closed the +`Local\` per-session split recorded earlier; a mutant needs no +create-global privilege); the discovery mutexes remain `Local\` — they +coordinate one session's discovery only and are no integrity boundary. +The named mutexes can in principle be squatted by another process of the +machine before this product creates them (the directory ACL is not the +capability there, as Linux flock's is); the discovery turn waits bounded +(60s `Busy`) and the writer lane's wait is the readiness wait of the lock +model, as Linux's blocking flock is. Windows Init carries names as lossless WTF-8 end to end (an unpaired surrogate included, byte-exact round trip through the codec and the audited identity probes) and synthesizes the mode from the read-only attribute, and has no hard-link @@ -134,7 +135,10 @@ membership. **The WTF-8 name grammar is owner-ratified 2026-10-31 merge/compare path lands as follow-up, a merge or compare that must build a String path over a name outside UTF-8 is refused (`InvalidRecord`) rather than decided on a collapsed path, and SDK listings render such -names empty. The backing-inside-source check walks the audited +names empty; a JS write naming one lands as an U+FFFD sibling file +rather than being refused (the JS string boundary cannot carry an +unpaired surrogate — the same byte-carrying follow-up). The +backing-inside-source check walks the audited volume-serial + file-index identity of every ancestor (a subst drive or junction into the source is recognised and refused, as on Linux), and overlay allocation is the entry's real on-disk footprint. Last-owner @@ -163,11 +167,20 @@ MAX_PATH initialize — but the host process must also carry a does not; keep sources and homes within ~260 characters until it does. Windows lacks the 64 MiB disk-headroom pre-admission macOS and Linux run (a filling disk surfaces mid-transaction as the typed `Capacity` -refusal instead); an in-session writer-lane squatter parks every Store +refusal instead); SQLite's `-wal`/`-shm` sidecars inherit the +directory's DACL, and every open tightens the ones it finds back to +owner-only (a crash's leftovers wait for the next open); a writer-lane +squatter parks every Store call for as long as it holds the mutex (the readiness wait has no bound, exactly as Linux's blocking flock); a killed process leaves one overlay directory under `%TEMP%\layerfs-store-*` until the OS cleans -it; and a `fs.rm`/`fs.cp` over a subtree holding a name outside UTF-8 +it (schema-only and never blocking the next open — accumulation is the +machine's TEMP policy); the overlay crate's two Win32 path probes +(entry identity, free space) take raw wide spellings of TEMP-local +paths — the product-source boundary keeps layerfs-overlay free of +first-party dependencies, so the shared verbatim helper is not imported +there and a TEMP tree beyond MAX_PATH fails those probes cleanly; and +a `fs.rm`/`fs.cp` over a subtree holding a name outside UTF-8 refuses with a message naming that entry (the SDK cannot spell such a name until the byte-carrying path lands — the same follow-up as the merge/compare fence above, which surfaces at the SDK as code `Failed` diff --git a/engine/crates/layerfs-node/src/project.rs b/engine/crates/layerfs-node/src/project.rs index aac61af6c..f20a56d57 100644 --- a/engine/crates/layerfs-node/src/project.rs +++ b/engine/crates/layerfs-node/src/project.rs @@ -41,14 +41,28 @@ pub(crate) fn init(args: &SdkValue) -> Outcome { // checked first: a refused policy, timeout or Store path leaves no project entry let policy = policy(args)?; let deadline = deadline(args)?; - let store_path = - match args.get_text("storePath") { - Some(path) if !path.is_empty() => PathBuf::from(path), - _ => return Err(refused( + let store_path = match args.get_text("storePath") { + Some(path) if !path.is_empty() => { + let path = PathBuf::from(path); + // Absolute, like every Store call's storePath: a relative + // spelling would resolve against this process's working + // directory and leave a half-initialized project entry + // before the install path check refuses it (round-3 A1 F2). + if !path.is_absolute() { + return Err(refused( + "InvalidInput", + "the client's storePath must be absolute (project.init installs the Store file)", + )); + } + path + } + _ => { + return Err(refused( "InvalidInput", "project.init installs the Store at the client's storePath (LAYERFS_STORE_PATH)", - )), - }; + )) + } + }; // The Store's directory is created first (owner-only, like the install // does): check takes the Store file and examines its parent's volume, // and the SDK layout may not have made it yet. diff --git a/engine/crates/layerfs-overlay/src/database/windows_identity.rs b/engine/crates/layerfs-overlay/src/database/windows_identity.rs index 9ed4a6535..2cd338a5c 100644 --- a/engine/crates/layerfs-overlay/src/database/windows_identity.rs +++ b/engine/crates/layerfs-overlay/src/database/windows_identity.rs @@ -66,6 +66,10 @@ pub fn windows_path_identity(path: &Path) -> io::Result { OPEN_EXISTING, }; // Raw units, not a lossy string: names stay byte-exact (K3-R3 F7). + // Deliberately not the shared verbatim_wide: the product-source + // boundary keeps this crate free of first-party dependencies, and the + // inputs are TEMP-local overlay paths — a TEMP tree beyond MAX_PATH + // fails here cleanly (recorded exception, round-3 A1 F5). let wide: Vec = path .as_os_str() .encode_wide() @@ -108,6 +112,8 @@ fn identity_of(information: BY_HANDLE_FILE_INFORMATION) -> WindowsIdentity { pub(crate) fn windows_free_bytes(directory: &Path) -> io::Result { use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; + // Raw wide, like the identity probe above: the boundary guard keeps + // this crate free of first-party dependencies (round-3 A1 F5). let wide: Vec = directory .as_os_str() .encode_wide() diff --git a/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs b/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs index d70a6e989..2bb4a5f8a 100644 --- a/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs +++ b/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs @@ -29,13 +29,17 @@ pub(crate) struct LaneMutex { /// encoded bytes, truncated to 64 bits. A versioned-stable digest, not /// `DefaultHasher`, whose algorithm may change between Rust releases and /// silently split one directory onto two lanes across builds (K3 review F1). +/// The `Global\` namespace keeps one lane per directory across login +/// sessions (a session-0 scheduled task and the user session share one +/// Store file; `Local\` would give each its own lane and two writers — +/// round-3 A2 F4). A mutant needs no create-global privilege. pub(crate) fn lane_name(directory: &Path) -> String { let resolved = std::fs::canonicalize(directory).unwrap_or_else(|_| directory.to_path_buf()); let digest = Sha256::digest(resolved.as_os_str().as_encoded_bytes()); let mut tag = [0u8; 8]; tag.copy_from_slice(&digest[..8]); format!( - "Local\\layerfs-writer-lane-{:016x}", + "Global\\layerfs-writer-lane-{:016x}", u64::from_be_bytes(tag) ) } @@ -102,13 +106,15 @@ impl Drop for LaneMutex { /// A verbatim (`\\?\`-prefixed) wide spelling of an absolute path, NUL /// terminated, so the Win32 calls in the audited modules accept trees -/// deeper than MAX_PATH — a plain spelling fails at 260 characters even -/// with the host's long-paths registry switch, because the hosting node -/// process carries no `longPathAware` manifest. Already-verbatim paths -/// pass through; UNC shares become `\\?\UNC\...`; relative paths pass -/// through unchanged (every call site is absolute). Forward slashes -/// become backslashes under the prefix: a verbatim path is not -/// normalized, and a caller (node) may hand either spelling. +/// deeper than MAX_PATH — a plain spelling can fail at 260 characters on +/// hosts without the long-path relaxation (the hosting node process +/// typically carries no `longPathAware` manifest). Already-verbatim +/// paths pass through unchanged; UNC shares become `\\?\UNC\...`; +/// relative paths pass through unchanged (every call site is absolute). +/// Forward slashes become backslashes in the spelling this function +/// builds; an already-verbatim input is passed through byte-for-byte — +/// Win32 does not normalize under the prefix, so a verbatim input +/// carrying forward slashes fails cleanly at its first use. pub fn verbatim_wide(path: &Path) -> Vec { use std::os::windows::ffi::OsStrExt; let mut units: Vec = path.as_os_str().encode_wide().collect(); diff --git a/engine/crates/layerfs-persistence/tests/windows_lane_abandoned.rs b/engine/crates/layerfs-persistence/tests/windows_lane_abandoned.rs index b36cd54ac..b4d60c192 100644 --- a/engine/crates/layerfs-persistence/tests/windows_lane_abandoned.rs +++ b/engine/crates/layerfs-persistence/tests/windows_lane_abandoned.rs @@ -51,7 +51,7 @@ fn a_dead_lane_holder_does_not_block_the_next_writer() { let mut tag = [0u8; 8]; tag.copy_from_slice(&digest[..8]); let name: Vec = format!( - "Local\\layerfs-writer-lane-{:016x}", + "Global\\layerfs-writer-lane-{:016x}", u64::from_be_bytes(tag) ) .encode_utf16() diff --git a/engine/crates/layerfs-store-service/src/store_service.rs b/engine/crates/layerfs-store-service/src/store_service.rs index ee70e08ba..0a83d7620 100644 --- a/engine/crates/layerfs-store-service/src/store_service.rs +++ b/engine/crates/layerfs-store-service/src/store_service.rs @@ -136,6 +136,17 @@ impl StoreService { ReservationBlocks::default(), ) .map_err(|error| OpenRefusal::Failed(format!("Store open: {error}")))?; + // SQLite's `-wal`/`-shm` sidecars inherit the directory's DACL, so + // a crash that leaves them behind exposes committed content on a + // caller-pre-created directory until the next open; whenever an + // open sees them it tightens them (best effort — the next open + // retries, and the Store stays usable either way; round-3 A1 F1). + #[cfg(windows)] + for suffix in ["-wal", "-shm"] { + let mut sidecar = store.as_os_str().to_owned(); + sidecar.push(suffix); + let _ = crate::windows_acl::restrict_to_owner(std::path::Path::new(&sidecar)); + } let owner = Owner::start( template, ProfileConfig { From 2ff821486f32665189a7784e1afd80b3f596baeb Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Sat, 31 Oct 2026 22:04:42 +0800 Subject: [PATCH 33/37] =?UTF-8?q?docs(round4):=20the=20long-path=20sentenc?= =?UTF-8?q?e=20names=20the=20real=20condition=20=E2=80=94=20the=20host's?= =?UTF-8?q?=20policy,=20not=20one=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 A1 measured plain 308-character paths working on a host with the OS-level relaxation; the sentence claimed node.exe universally lacks the effect. Aligned with the module comment's round-3 A1-F6 softening and B4's meta-audit finding (B4-F5). Production LOC: 104255 -> 104255 (delta 0) — pinned tools/production_loc.py; documentation-only change, production scope unchanged. --- core/docs/issues/352/DIRECT_STORE.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index 5c5e4ddbf..5edb2462d 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -162,9 +162,12 @@ volume sits outside the home) — before the route accepts it again. Operational notes from the 2026-10-31 production-readiness round: the audited Win32 calls spell paths verbatim (`\\?\`), so trees deeper than -MAX_PATH initialize — but the host process must also carry a -`longPathAware` manifest for its own operations, which today's node.exe -does not; keep sources and homes within ~260 characters until it does. +MAX_PATH initialize — but the host process's own plain-spelled +operations still need the host's long-path relaxation (a +`longPathAware` manifest, or an OS build that no longer requires it — +round-3 A1 measured plain 308-character paths working on one such +host); keep sources and homes within ~260 characters on hosts whose +policy is not confirmed. Windows lacks the 64 MiB disk-headroom pre-admission macOS and Linux run (a filling disk surfaces mid-transaction as the typed `Capacity` refusal instead); SQLite's `-wal`/`-shm` sidecars inherit the From c2ca6caa6d3bfd92d767d0d4191db44e9cb5a739 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Sat, 31 Oct 2026 23:36:00 +0800 Subject: [PATCH 34/37] fix(round5): doc-83 lane namespace corrected, unix store dir owner-only from creation, NUL storePath fences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 5 (C1 Linux final-diff read, C2 merge rehearsal, C3 red team, C4 input-space edges, C5 share-conflict/noise battery) on 2ff821486; all five verdicts landed (C1/C3 conditional, both closed here or recorded): - C1-F1 (P2): core/docs/architecture/83-store-concurrency-model.md still described the Windows writer lane as Local\ per-session — stale since a7f5cda78; now names Global\ (one lane across login sessions) and notes the discovery mutexes stay Local\. - C1-F3 (P3): project.init created the Store's parent with create_dir then chmod 0700 on unix — a umask traversable window against its own comment; now DirBuilder::mode(0o700), owner-only from the first instant, as the install path does. - C3-F4 (P3): storePath NUL fences in direct.rs and project.rs. Honest scope: the observed truncation happens at the OS env boundary (a NUL-terminated env block) BEFORE any product code, and the SDK is env-only — today the fences are unreachable defense-in-depth for any future non-env argument path. Probe: a NUL set through env truncates at set time; the fences themselves compile and the SDK surface is unchanged (39/39). - C3-F1/F2/F5 and C5-F1 (recorded in DIRECT_STORE §6): a pre-planted lane object with a foreign DACL fails the next open (typed Malformed today — error-typing improvement on the should-list); lane names are enumerable by any local user (a digest revealing activity, not the path); a caller-pre-created wide directory lets a foreign user pre-place sidecars the per-open tightening cannot re-own; the ReadOnly-vs-Unknown typing split across open paths is recorded too. - C1-F2 (recorded, Linux): refusal paths that create the Store's parent first now leave that directory behind on Linux (upstream's guard-first order left nothing); behavior accepted with this note. - C2's rehearsal artifact: post-merge-verify.sh (untracked patrol tooling) gained a --no-run prebuild and explicit FAILED on timeout — a cold compile alone exceeded its 110 s per-suite ceiling (184 s for three crates). Not part of this commit. Production LOC: 104255 -> 104272 (delta +17) — pinned tools/production_loc.py on the first parent (2ff821486, detached worktree) and the staged tree; project.rs and direct.rs fences account for the delta; docs contribute 0. --- .../83-store-concurrency-model.md | 6 ++-- core/docs/issues/352/DIRECT_STORE.md | 14 +++++++-- engine/crates/layerfs-node/src/direct.rs | 8 +++++ engine/crates/layerfs-node/src/project.rs | 29 ++++++++++++++----- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/core/docs/architecture/83-store-concurrency-model.md b/core/docs/architecture/83-store-concurrency-model.md index 6693ae7a4..14d9fb2db 100644 --- a/core/docs/architecture/83-store-concurrency-model.md +++ b/core/docs/architecture/83-store-concurrency-model.md @@ -41,8 +41,10 @@ The lane's Windows name is SHA-256 of the resolved directory's encoded bytes truncated to 64 bits — a version-stable digest, never `DefaultHasher`, whose algorithm may change between Rust releases and silently split one directory onto two lanes across builds (K3 review F1). -The mutexes live in the `Local\` namespace: one login session's lanes are -invisible to another (DIRECT_STORE.md §6). +The writer lane lives in the `Global\` namespace, so one lane covers every +login session (moved from `Local\` on the Windows-native route, round 3 — +DIRECT_STORE.md §6); the discovery mutexes stay `Local\` and only coordinate +one session's discovery. The queue and the directory lock replace failure, not add serialization: the repository forbids automatic retries and busy handlers (one attempt per diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index 5edb2462d..6dd75d13a 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -173,9 +173,17 @@ Windows lacks the 64 MiB disk-headroom pre-admission macOS and Linux run refusal instead); SQLite's `-wal`/`-shm` sidecars inherit the directory's DACL, and every open tightens the ones it finds back to owner-only (a crash's leftovers wait for the next open); a writer-lane -squatter parks every Store -call for as long as it holds the mutex (the readiness wait has no -bound, exactly as Linux's blocking flock); a killed process leaves one +squatter parks every Store call for as long as it holds the mutex (the +readiness wait has no bound, exactly as Linux's blocking flock) — round +5 added two neighbours of that local-adversary availability class: a +pre-planted lane object carrying a foreign DACL makes the next open +fail (currently typed `Malformed`; an error-typing improvement is on +the should-list), and any local user can enumerate lane names in +`\BaseNamedObjects` (a digest of the directory that reveals write +activity, not the path); a caller-pre-created wide directory also lets +another user pre-place `-wal`/`-shm` the per-open tightening cannot +re-own (create the volume owner-only, as the route's own layout does); +a killed process leaves one overlay directory under `%TEMP%\layerfs-store-*` until the OS cleans it (schema-only and never blocking the next open — accumulation is the machine's TEMP policy); the overlay crate's two Win32 path probes diff --git a/engine/crates/layerfs-node/src/direct.rs b/engine/crates/layerfs-node/src/direct.rs index e657d982b..b3f12025a 100644 --- a/engine/crates/layerfs-node/src/direct.rs +++ b/engine/crates/layerfs-node/src/direct.rs @@ -39,6 +39,14 @@ static STORES: LazyLock>> = LazyLock::new(Defaul fn path(args: &SdkValue) -> Result { match args.get_text("storePath") { Some(text) if !text.is_empty() => { + // NUL would silently truncate at the Win32 path boundary and + // name a different file (round-5 C3 F4). + if text.contains('\0') { + return Err(refused( + "InvalidInput", + "the client's storePath must not contain NUL", + )); + } let path = PathBuf::from(text); if !path.is_absolute() { return Err(refused( diff --git a/engine/crates/layerfs-node/src/project.rs b/engine/crates/layerfs-node/src/project.rs index f20a56d57..409a09f26 100644 --- a/engine/crates/layerfs-node/src/project.rs +++ b/engine/crates/layerfs-node/src/project.rs @@ -43,6 +43,14 @@ pub(crate) fn init(args: &SdkValue) -> Outcome { let deadline = deadline(args)?; let store_path = match args.get_text("storePath") { Some(path) if !path.is_empty() => { + // NUL would silently truncate at the Win32 path boundary and + // name a different file (round-5 C3 F4). + if path.contains('\0') { + return Err(refused( + "InvalidInput", + "the client's storePath must not contain NUL", + )); + } let path = PathBuf::from(path); // Absolute, like every Store call's storePath: a relative // spelling would resolve against this process's working @@ -68,15 +76,22 @@ pub(crate) fn init(args: &SdkValue) -> Outcome { // and the SDK layout may not have made it yet. let store_directory = store_path.parent().unwrap_or(&store_path).to_path_buf(); if !store_directory.as_os_str().is_empty() && !store_directory.exists() { - std::fs::create_dir(&store_directory).map_err(failed)?; #[cfg(unix)] - std::fs::set_permissions(&store_directory, { - use std::os::unix::fs::PermissionsExt; - std::fs::Permissions::from_mode(0o700) - }) - .map_err(failed)?; + { + // Owner-only from the first instant, as the install does: a + // create-then-chmod pair leaves a umask window (round-5 C1 F3). + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .mode(0o700) + .create(&store_directory) + .map_err(failed)?; + } #[cfg(windows)] - layerfs_store_service::windows_acl::restrict_to_owner(&store_directory).map_err(failed)?; + { + std::fs::create_dir(&store_directory).map_err(failed)?; + layerfs_store_service::windows_acl::restrict_to_owner(&store_directory) + .map_err(failed)?; + } } layerfs_store_service::kernel_guard::check(&store_path) .map_err(|reason| refused("Unsupported", reason.0))?; From d9687cfa4b144c1ae06da777f93f93b0285d2f66 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Sun, 1 Nov 2026 01:30:32 +0800 Subject: [PATCH 35/37] fix(round6): volume guard's long-path regrowth made real, decision paths fenced against collapsed names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 6 (D1 cold-eyes final read, D2 runbook literalism, D3 integrator journey, D4 long-horizon soak, D5 host-path diversity) on c2ca6caa6: - D1-F1 (P1): windows_volume_filesystem's regrow branch was dead code — a Vec could never try_into a fixed [u16; 1024] for a longer requirement, so the retry read into a zeroed buffer on which the \?\UNC\ refusal silently passed: a share deeper than 1023 UTF-16 units was admitted as NTFS. The final path now grows for real; the failure is captured before later syscalls can overwrite it (D1-F6). New tests volume_guard_long_paths_windows: a ~1160-unit local tree still passes and the same tree through \localhost\D$\ is refused — the refusal is the behavioral proof (it passed before the fix, shown end to end with the admin share). - D1-F2 (P2): the InvalidRecord fence covered name_all only. join() now stays the display spelling (listings render a non-UTF-8 name empty, the documented contract) and a new join_exact() fences every decision path — tree walks, bindings, changes, merge names/places — where a collapsed key silently dropped the second change of a directory. content --tests 484/0. - D1-F4 (P3): a missing Store answers Missing on every platform again; the Windows entry block used to surface it as Failed. - D2-F1 (P1 doc): the §6 lockout recovery runbook is rewritten to the sequence verified end to end this round — recursive takeown over the home and any external volume tree (taking ownership rewrites the files' OWNER RIGHTS ace into an inherit-only form), the (OI)(CI)F grant on trees, and a plain :F grant on the Store file and project.record, which an (OI)(CI) grant gives nothing. - D1-F3 (P3, recorded): a subst spelling can hold a second lane for one directory (canonicalize keeps the subst form); bounded by SQLite's own WAL write lock. §6 records it. - D3-F1 (P2 doc): agent.md's routing paragraph now says the project family's Sandbox-related calls (project.list_sandboxes, sandbox.*) still need the daemon and refuse Unsupported on this route. - Tooling (untracked patrol script, not in this commit): post-merge- verify.sh gains the missing npm install step, unmasks SDK-chain failures the tail pipe hid, fixes the receipt name, prefers the release addon, and marks first-execution suite timeouts explicitly. Recorded, not changed: D1-F7 (require_owner_only's message names three broad SIDs, the doc's parity wording is broader), D1-F8 (Linux now imports well-formed WTF-8 names the upstream InvalidUtf8 path refused — the grammar's intent, noted), D2-F2 (partial-recovery refusals drift between Unauthorized and Failed), D3's integrator documentation gaps (publish semantics, error-code table pointer, packaging). D4: 34.4-minute soak PASS (4,729 ops, WAL bounded, RSS flat, reads undegraded). D5: CJK/space/bracket/emoji host paths PASS with zero behavioral difference from the ASCII baseline; store-cjk.sqlite kept for the verify freeze. Production LOC: 104272 -> 104281 (delta +9) — pinned tools/production_loc.py on the first parent (c2ca6caa6, detached worktree) and the staged tree; join_exact and the guard rework account for the delta. --- api/docs/agent.md | 4 ++- .../layerfs-content/src/compare/changes.rs | 4 +-- .../layerfs-content/src/compare/tree.rs | 26 ++++++++++++++++--- .../crates/layerfs-content/src/merge/names.rs | 4 +-- .../layerfs-content/src/merge/places.rs | 4 +-- core/docs/issues/352/DIRECT_STORE.md | 24 ++++++++++++----- .../src/backend/sqlite/windows_lane.rs | 20 +++++++++----- .../src/store_service.rs | 15 ++++++----- 8 files changed, 71 insertions(+), 30 deletions(-) diff --git a/api/docs/agent.md b/api/docs/agent.md index 35384ebc2..50055ba03 100644 --- a/api/docs/agent.md +++ b/api/docs/agent.md @@ -98,7 +98,9 @@ or reinstalling over live state. Store calls (`fs`, `branch`, `project.list_branches`) run in this process against the installed Store file at `storePath` (else `LAYERFS_STORE_PATH`), -which the factory opened; no Sandbox is involved. That +which the factory opened; no Sandbox is involved. The project family's +Sandbox-related calls (`project.list_sandboxes`, `sandbox.*`) still need the +daemon and refuse `Unsupported` on this route. That file is `global/store.sqlite` in the project's volume, so the process runs where that volume is mounted (`/layerfs-store` in a Sandbox). On Linux the Store sits on a local filesystem the daemons share (ext4, xfs, btrfs); on **Windows the diff --git a/core/crates/layerfs-content/src/compare/changes.rs b/core/crates/layerfs-content/src/compare/changes.rs index 5844f4ec2..6da70ec35 100644 --- a/core/crates/layerfs-content/src/compare/changes.rs +++ b/core/crates/layerfs-content/src/compare/changes.rs @@ -23,7 +23,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; use super::sorted::DifferenceWork; -use super::tree::{binding_changes, inode_differences, join, EntryFacts, TreeTable}; +use super::tree::{binding_changes, inode_differences, join_exact, EntryFacts, TreeTable}; use crate::error::{ContentError, ContentResult}; use crate::filesystem::directory::read::{list_after, lookup, DirectoryReadWork}; use crate::filesystem::path::PathName; @@ -328,7 +328,7 @@ impl<'a> ChangeWalk<'a> { let Some((name, [left, right])) = frame.ready.pop_front() else { continue; }; - let path = join(&frame.path, &name); + let path = join_exact(&frame.path, &name)?; self.stack.push(frame); return Ok(Some((path, left, right))); } diff --git a/core/crates/layerfs-content/src/compare/tree.rs b/core/crates/layerfs-content/src/compare/tree.rs index 268ca406b..d18f330c7 100644 --- a/core/crates/layerfs-content/src/compare/tree.rs +++ b/core/crates/layerfs-content/src/compare/tree.rs @@ -263,7 +263,9 @@ pub fn list_directory( } } -/// Joins a parent path and one name; the root is "/". +/// Joins a parent path and one name; the root is "/". This is the +/// **display** spelling: a name outside UTF-8 renders empty (the SDK +/// listing contract), so its result must never key a decision. pub fn join(parent: &str, name: &PathName) -> String { if parent == "/" { format!("/{}", name.as_str()) @@ -272,6 +274,22 @@ pub fn join(parent: &str, name: &PathName) -> String { } } +/// Joins for a **decision**: a name outside UTF-8 cannot ride a String +/// path — rendering it empty would collapse distinct entries under one +/// key and silently drop the second change of a directory (round-6 D1 +/// F2), so this fence refuses exactly as `name_all` does until the +/// byte-carrying merge/compare path lands. Every keying, comparison or +/// merge site uses this twin, never `join`. +pub fn join_exact(parent: &str, name: &PathName) -> ContentResult { + let text = std::str::from_utf8(name.as_bytes()) + .map_err(|_| ContentError::InvalidRecord("a name outside UTF-8 has no String path"))?; + Ok(if parent == "/" { + format!("/{text}") + } else { + format!("{parent}/{text}") + }) +} + /// Paths of the wanted serials, breadth first from the root, first path only. /// /// The walk ends when every wanted serial is named or `limit` directories have @@ -298,7 +316,7 @@ pub fn locate( }; let mut children = Vec::new(); list_directory(reader, value.content_root, |name, serial| { - children.push((join(&path, name), serial)); + children.push((join_exact(&path, name)?, serial)); Ok(true) })?; let serials = children @@ -333,7 +351,7 @@ pub fn subtree( }; let mut children = Vec::new(); list_directory(reader, value.content_root, |name, child| { - children.push((join(&path, name), child)); + children.push((join_exact(&path, name)?, child)); Ok(true) })?; let serials = children @@ -455,7 +473,7 @@ pub fn root_changes( Some(after.content_root), &mut out.work, |name, was, now| { - bindings.push((join(path, name), was, now)); + bindings.push((join_exact(path, name)?, was, now)); Ok(()) }, )?; diff --git a/core/crates/layerfs-content/src/merge/names.rs b/core/crates/layerfs-content/src/merge/names.rs index b13974c99..e85c4656a 100644 --- a/core/crates/layerfs-content/src/merge/names.rs +++ b/core/crates/layerfs-content/src/merge/names.rs @@ -20,7 +20,7 @@ use super::serial::{is_directory, listing, same, Ctx}; use super::spill::{ single, ABSORB, CANCEL_MOVE, CANCEL_REMOVE, DONE, DROP, EXTRA, MOVED, NAMED, REVIVE_ALL, TARGET, }; -use crate::compare::tree::{binding_changes, join, EntryFacts}; +use crate::compare::tree::{binding_changes, join_exact, EntryFacts}; use crate::compare::DifferenceWork; use crate::error::{ContentError, ContentResult}; use crate::filesystem::directory::read::lookup; @@ -306,7 +306,7 @@ impl Ctx<'_, '_> { let base = Values::entry(&values.b, b); let ours = Values::entry(&values.f, f); let theirs = Values::entry(&values.i, i); - let path = join(&self.slot_path(listing)?, name); + let path = join_exact(&self.slot_path(listing)?, name)?; let key = named_key(listing.target, name); if let (Some(fe), Some(ie)) = (ours, theirs) { let absorb = is_directory(Some(fe.value)) diff --git a/core/crates/layerfs-content/src/merge/places.rs b/core/crates/layerfs-content/src/merge/places.rs index bada72cea..3c8dcf2c2 100644 --- a/core/crates/layerfs-content/src/merge/places.rs +++ b/core/crates/layerfs-content/src/merge/places.rs @@ -22,7 +22,7 @@ use super::spill::{ REVIVE_ALL, SURVIVE, TARGET, }; use super::{Conflict, ConflictKind}; -use crate::compare::tree::{join, list_directory, locate}; +use crate::compare::tree::{join_exact, list_directory, locate}; use crate::error::{ContentError, ContentResult}; use crate::filesystem::path::PathName; use crate::object::inode_leaf::InodeValue; @@ -308,7 +308,7 @@ impl Ctx<'_, '_> { continue; } let parent_path = self.path_anywhere(parent)?; - let target = join(&parent_path, &PathName::new(&name)?); + let target = join_exact(&parent_path, &PathName::new(&name)?)?; let base_path = self.path(BASE, serial)?; let into_path = self.path(self.into, serial)?; let cancelled = if into_path != base_path { diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index 6dd75d13a..ac9ac8fe4 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -133,7 +133,10 @@ synthesizes the mode from the read-only attribute, and has no hard-link membership. **The WTF-8 name grammar is owner-ratified 2026-10-31 ("可以合并") as the long-term content grammar**; until the byte-carrying merge/compare path lands as follow-up, a merge or compare that must build -a String path over a name outside UTF-8 is refused (`InvalidRecord`) +a String path over a name outside UTF-8 is refused (`InvalidRecord`) at +every entrance — the recorded-name path and the walked path alike +(round-6 D1 F2 fenced `join`, whose collapsed key silently dropped the +second changed entry of one directory) rather than decided on a collapsed path, and SDK listings render such names empty; a JS write naming one lands as an U+FFFD sibling file rather than being refused (the JS string boundary cannot carry an @@ -154,11 +157,16 @@ Store file and its directory answer the same check at every open, and a link planted at either spelling is refused, never followed. A home that lost its owner (restored from backup, moved between machines) answers `Unauthorized`; an administrator recovers it from an **elevated** console -with `takeown` (making the taker the owner, whom the OWNER RIGHTS ace -then serves) and, if needed, -`icacls /inheritance:r /grant:r ':(OI)(CI)F'` — repeating -the `icacls` grant on the installed Store file (and its tree, when the -volume sits outside the home) — before the route accepts it again. +(runbook verified end to end, round 6): first +`takeown /f /r /d y` — and the same recursively on the volume +tree, when the volume sits outside the home; taking ownership rewrites +the files' OWNER RIGHTS ace into an inherit-only form, so every file +must be re-granted explicitly — then +`icacls /inheritance:r /grant:r ':(OI)(CI)F'` and the same +grant on the volume tree, and finally a plain +`icacls /grant:r ':F'` on the installed Store file and on +`//project.record` (an `(OI)(CI)` grant gives a *file* +nothing) — before the route accepts it again. Operational notes from the 2026-10-31 production-readiness round: the audited Win32 calls spell paths verbatim (`\\?\`), so trees deeper than @@ -183,6 +191,10 @@ the should-list), and any local user can enumerate lane names in activity, not the path); a caller-pre-created wide directory also lets another user pre-place `-wal`/`-shm` the per-open tightening cannot re-own (create the volume owner-only, as the route's own layout does); +the lane's name is the canonicalized spelling, so a subst-drive spelling +that does not canonicalize to the real path can hold a second lane for +one directory — bounded by SQLite's own WAL write lock, which refuses +the overlapping writer rather than corrupting (round-6 D1 F3); a killed process leaves one overlay directory under `%TEMP%\layerfs-store-*` until the OS cleans it (schema-only and never blocking the next open — accumulation is the diff --git a/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs b/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs index 2bb4a5f8a..e5f6dbc92 100644 --- a/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs +++ b/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs @@ -182,7 +182,7 @@ pub fn windows_volume_filesystem(directory: &Path) -> io::Result { ), )); } - let mut final_path = [0u16; 1024]; + let mut final_path: Vec = vec![0u16; 1024]; let mut written = unsafe { GetFinalPathNameByHandleW( handle, @@ -191,8 +191,13 @@ pub fn windows_volume_filesystem(directory: &Path) -> io::Result { VOLUME_NAME_DOS, ) }; - if written as usize > final_path.len() { - final_path = vec![0u16; written as usize].try_into().unwrap_or([0; 1024]); + // An insufficient buffer returns the required length: grow for real and + // retry once. The old fixed-array fallback could never fit a longer + // requirement and fell back to a zeroed buffer, on which the `\\?\UNC\` + // refusal silently passed and a deep share was admitted as NTFS + // (round-6 D1 F1). + if written != 0 && written as usize >= final_path.len() { + final_path = vec![0u16; written as usize + 1]; written = unsafe { GetFinalPathNameByHandleW( handle, @@ -202,6 +207,8 @@ pub fn windows_volume_filesystem(directory: &Path) -> io::Result { ) }; } + // Capture the failure before another syscall can overwrite it (D1 F6). + let final_failure = (written == 0).then(io::Error::last_os_error); let share = written != 0 && final_path .iter() @@ -224,13 +231,12 @@ pub fn windows_volume_filesystem(directory: &Path) -> io::Result { // Capture the failure before another syscall can overwrite it. let failure = (examined == 0).then(io::Error::last_os_error); unsafe { CloseHandle(handle) }; - if written == 0 { + if let Some(error) = final_failure { return Err(io::Error::new( io::ErrorKind::Unsupported, format!( - "the final path of {} cannot be read: {}", - directory.display(), - io::Error::last_os_error() + "the final path of {} cannot be read: {error}", + directory.display() ), )); } diff --git a/engine/crates/layerfs-store-service/src/store_service.rs b/engine/crates/layerfs-store-service/src/store_service.rs index 0a83d7620..c2256d487 100644 --- a/engine/crates/layerfs-store-service/src/store_service.rs +++ b/engine/crates/layerfs-store-service/src/store_service.rs @@ -92,6 +92,15 @@ impl StoreService { template: &Path, ) -> Result { kernel_guard::check(store).map_err(OpenRefusal::Unsupported)?; + // A missing Store answers `Missing` on every platform before any + // entry examination: the Windows symlink/ACL block used to run + // first and surfaced a missing file as `Failed` (round-6 D1 F4). + if !store.is_file() { + return Err(OpenRefusal::Missing(format!( + "no installed Store at {}", + store.display() + ))); + } // The installed Store file answers to its own entry: a substituted // link planted at its spelling is refused, never followed, and on // Windows its DACL must still grant nothing beyond the owner — the @@ -120,12 +129,6 @@ impl StoreService { ))) })?; } - if !store.is_file() { - return Err(OpenRefusal::Missing(format!( - "no installed Store at {}", - store.display() - ))); - } let opened = open_store_observed( PersistenceConfig::sqlite(store) .with_sqlite_profile(SqlitePersistenceProfile::Disposable), From 2dd58881fe0e0a93bb85400650bc67b1b6e9691d Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Sun, 1 Nov 2026 02:59:23 +0800 Subject: [PATCH 36/37] test(round7): the round-6 volume-guard test enters the tree; join_exact gets its first unit test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correction (round-7 E1-F3): d9687cfa4's message claimed "New tests volume_guard_long_paths_windows" — the file was written but never staged (git add -u only stages tracked files), so it was absent from the commit tree while present in the local working tree that produced the 2/2 receipts. It enters the tree here unchanged and passes 2/2 (deep local tree accepted, the same tree through \localhost\D$ refused). New compare_join_exact (round-7 E1-F4, E5-F2): the decision-spelling twin gets its first in-tree assertion — identical to the display join for UTF-8 names, InvalidRecord for a name outside UTF-8, and the two distinct unpaired-surrogate names provably collapsing to one display key (the harm join_exact fences). 2/2. Recorded from round 7, not changed here: - E1-F1 (P2, upstream): the Linux FUSE invalidation path keys Stale::Entry on the display spelling (commit_merge.rs/invalidate.rs are untouched by this branch per diff) — a non-UTF-8 name rebound by a merge would not invalidate its kernel dentry; same family as the join split, owner should-list. - E1-F2 (P3): read/walk.rs feeds the display join to the descent and glob filters — display-scope drift only, no write routing. - E3 (P2, owner decisions): the 256-component path limit is enforced on the workspace/rename route but not the SDK fs/init routes (accepted 257-301 components live); and deep single-chain costs grow near-quadratically (d=250 mkdir 1.8 s). Both recorded for the owner; neither is a correctness defect. - E5: the fence's end-to-end behavior proven at Store+merge level — planned merges never touch it and lose nothing; the full-walk path (diff_branches / >65,536-changed merges) refuses InvalidRecord deterministically, Windows and Linux alike, with byte-exact preservation of the non-UTF-8 entries. - Tooling (untracked): post-merge-verify.sh v4 — toolchain pinned +1.85.1 with --offline, per-crate same-selection prebuild (a cross-crate prebuild leaves fingerprint mismatches), and a tested TIMEOUT recovery recipe (a kill mid-link corrupts PDBs: LNK1207). Production LOC: 104281 -> 104281 (delta 0) — pinned tools/production_loc.py; test-only change, production scope unchanged (parent d9687cfa4 recomputed at the same total). --- .../tests/compare_join_exact.rs | 32 ++++++++++ .../tests/volume_guard_long_paths_windows.rs | 64 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 core/crates/layerfs-content/tests/compare_join_exact.rs create mode 100644 engine/crates/layerfs-store-service/tests/volume_guard_long_paths_windows.rs diff --git a/core/crates/layerfs-content/tests/compare_join_exact.rs b/core/crates/layerfs-content/tests/compare_join_exact.rs new file mode 100644 index 000000000..96d802dde --- /dev/null +++ b/core/crates/layerfs-content/tests/compare_join_exact.rs @@ -0,0 +1,32 @@ +//! The decision-spelling twin of `join` (round-6 D1 F2): a name outside +//! UTF-8 refuses `InvalidRecord` instead of collapsing to an empty +//! segment, while the display `join` keeps the listing contract — and +//! the collapsed display spelling is exactly what must never key a +//! decision. +use layerfs_content::compare::tree::{join, join_exact}; +use layerfs_content::{ContentError, PathName}; + +#[test] +fn utf8_names_join_identically_on_both_twins() { + let name = PathName::from_bytes("中文🚀.txt".as_bytes()).unwrap(); + assert_eq!(join_exact("/dir", &name).unwrap(), join("/dir", &name)); + assert_eq!(join_exact("/", &name).unwrap(), join("/", &name)); +} + +#[test] +fn a_name_outside_utf8_refuses_the_decision_spelling() { + let lone = PathName::from_bytes(&[0xED, 0xA0, 0x80][..]).unwrap(); // unpaired high + // Display keeps the contract: the segment renders empty. + assert_eq!(join("/dir", &lone), "/dir/"); + let error = join_exact("/dir", &lone).unwrap_err(); + assert!( + matches!(error, ContentError::InvalidRecord(_)), + "the fence refuses, got {error:?}" + ); + // The harm the fence closes: two distinct names collapse to one + // display key, so the display spelling can never decide anything. + let other = PathName::from_bytes(&[0xED, 0xB2, 0x80][..]).unwrap(); // unpaired low + assert_ne!(lone.as_bytes(), other.as_bytes()); + assert_eq!(join("/dir", &lone), join("/dir", &other)); + assert!(join_exact("/dir", &other).is_err()); +} diff --git a/engine/crates/layerfs-store-service/tests/volume_guard_long_paths_windows.rs b/engine/crates/layerfs-store-service/tests/volume_guard_long_paths_windows.rs new file mode 100644 index 000000000..1a5cb904a --- /dev/null +++ b/engine/crates/layerfs-store-service/tests/volume_guard_long_paths_windows.rs @@ -0,0 +1,64 @@ +//! The volume guard's final path beyond the fixed buffer: a deep local +//! directory still passes through the regrowth, and a deep share spelling +//! is still refused. The old fixed-array regrowth could never fit a longer +//! requirement and left a zeroed buffer on which the `\\?\UNC\` refusal +//! silently passed (round-6 D1 F1). +#![cfg(target_os = "windows")] +use layerfs_store_service::kernel_guard; +use std::path::PathBuf; + +/// A directory chain whose final path exceeds 1023 UTF-16 units, created +/// through a verbatim spelling so creation itself works beyond MAX_PATH. +fn deep(parent: &std::path::Path, levels: usize) -> PathBuf { + let mut path = PathBuf::from(r"\\?\").join(parent); + for level in 0..levels { + path.push(format!("guard-{:03}-{}", level, "x".repeat(190))); + } + std::fs::create_dir_all(&path).unwrap(); + // The plain (non-verbatim) spelling of the same directory. + let mut plain = parent.to_path_buf(); + for level in 0..levels { + plain.push(format!("guard-{:03}-{}", level, "x".repeat(190))); + } + plain +} + +#[test] +fn a_deep_local_directory_still_names_its_volume() { + let base = std::env::temp_dir().join(format!("layerfs-volguard-local-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + let deep = deep(&base, 6); // ~1160 units of tail + let store = deep.join("store.sqlite"); + let filesystem = kernel_guard::check(&store).expect("the local deep path passes"); + assert_eq!(filesystem.to_lowercase(), "ntfs"); + std::fs::remove_dir_all(&base).unwrap(); +} + +#[test] +fn a_deep_share_spelling_is_still_refused() { + // The same physical tree addressed through the local machine's + // administrative share: the guard's final path is `\\?\UNC\…` and, + // past the fixed buffer, must still be recognised as a share. + let base = std::env::temp_dir().join(format!("layerfs-volguard-share-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + let deep = deep(&base, 6); + let plain = deep.to_string_lossy().to_string(); + let Some(tail) = plain + .strip_prefix("D:\\") + .or_else(|| plain.strip_prefix("d:\\")) + else { + // The temporary directory is not on D:; the share spelling of this + // machine's share is unknown, so this test cannot run here. + std::fs::remove_dir_all(&base).unwrap(); + return; + }; + let share = PathBuf::from(r"\\localhost\D$\").join(tail); + let store = share.join("store.sqlite"); + let refusal = kernel_guard::check(&store).expect_err("a share spelling is refused"); + assert!( + refusal.0.contains("network share"), + "the refusal names the share: {}", + refusal.0 + ); + std::fs::remove_dir_all(&base).unwrap(); +} From 1443753b4225b5c62ff2fd00c113bc4f10205cb4 Mon Sep 17 00:00:00 2001 From: longxiang <1169207670@qq.com> Date: Sun, 1 Nov 2026 03:51:54 +0800 Subject: [PATCH 37/37] fix(round8): the guard test discriminates on every host, the regrow race closed, six cross-document contradictions aligned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 8 (F1 incremental cold read, F2 in-process concurrency, F3 cross-document consistency matrix, F4 flake hunt, F5 merge-day package) on 2dd58881f: - F1-F1 (P2): the volume-guard test's discriminating half silently self-skipped wherever the temporary directory is not on D: — including this machine's default C: TEMP, so the round-7 "2/2 refused" receipt never executed that half. The share spelling is now derived from the temporary directory's own drive letter (verified 2/2 in the default C:-TEMP environment through \localhost\C$). F1-F3: the deep() helper's verbatim claim was false (PathBuf::join drops the prefix for an absolute component); the prefix is now built textually, so creation no longer depends on the host's long-path relaxation, and removal uses the verbatim spelling. - F1-F2 (P3, fixed): after the regrow retry, a requirement that grew between the two calls (a concurrent rename) left the buffer unfilled and the UNC check reading garbage — the round-6 P1 harm could revive under that race. A second short read now refuses Unsupported with its own message. - F3's six P2 contradictions aligned: the "fs, branch, project and operator calls in this process" family now carries the sandbox-family exception in README, DIRECT_STORE, and the root AGENTS (agent.md's sentence gains the platform qualifier — on Linux those calls are served in-process); §6's "the Store file and its directory answer the same check at every open" now matches the code (the file at every open; the directory when the route creates it); README's opening route sentence no longer predates the native default; agent.md documents NoStore (a storePath naming no installed file) and the merge/rm/cp SDK surfacing of the non-UTF-8 fence (Failed with the cause, not the content-layer name); §6 carries the three upstream-family records the PR body pointed at (FUSE invalidation display spelling, the 256-component limit's uneven enforcement, near-quadratic deep chains). Also: obtaining-the-addon gains --offline and the Node >= 20 floor; the "should-list" dangling reference became a concrete note; inprocess.js's header no longer claims four default threads (WORKERS is 64) and mentions the worker branch. - F5-1 (P2, tooling): post-merge-verify.sh now checks upstream/main first (the fork's main never moves by itself), and notes the expectation as >= 682 passed with 0 failed on this tree. - F4: 23 repeated executions, zero flakes; the r6 CI transient is quantified locally as cold-build cost (clean 340 s + cold build 80 s against the 110 s ceiling). F2: in-process concurrency passes (480/480 exact, zero Busy; same-directory dual Stores share the lane without starvation); the read-during-write latency characteristic is recorded for integrators. Production LOC: 104281 -> 104292 (delta +11) — pinned tools/production_loc.py on the first parent (2dd58881f, detached worktree) and the staged tree; the guard's race refusal accounts for the delta. --- AGENTS.md | 11 ++- api/README.md | 15 ++-- api/agent/src/transport/inprocess.js | 7 +- api/docs/agent.md | 13 ++-- core/docs/issues/352/DIRECT_STORE.md | 27 +++++-- .../src/backend/sqlite/windows_lane.rs | 17 ++++- .../tests/volume_guard_long_paths_windows.rs | 70 ++++++++++--------- 7 files changed, 103 insertions(+), 57 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e807c1989..2bde3e038 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,7 +158,9 @@ remains a separate MEMORY/OFF/EXCLUSIVE database file. - **Windows-native Store route (owner direction 2026-10-25).** The Store engine (SQLite, the cross-process writer lane, the kernel guard) and the SDK's in-process Store face (fs, branch, project, operator) also run - natively on Windows: local NTFS volumes only, a named kernel mutex as the + natively on Windows — the sandbox family, including + `project.list_sandboxes`, still refuses `Unsupported` there: local NTFS + volumes only, a named kernel mutex as the cross-process writer lane, real volume-serial + file-index identity for acquisition rows, and bundled SQLite, each in audited FFI modules (engine/AGENTS.md). No WSL2 and no daemon are involved. This supersedes the @@ -168,7 +170,9 @@ remains a separate MEMORY/OFF/EXCLUSIVE database file. no hard-link membership and synthesizes the mode from the read-only attribute; names travel as lossless WTF-8 — **owner-ratified 2026-10-31 ("可以合并") as the long-term content grammar** (an unpaired surrogate's - three-byte form is accepted, a pair spelled apart is refused; the earlier + three-byte form is accepted, a pair spelled apart is refused; JS callers + cannot spell such names — listings render them empty and a JS write names + an U+FFFD sibling; the earlier lossy-names limitation is superseded) — the backing-inside-source check walks the audited volume-serial + file-index identity, and overlay allocation is the real on-disk footprint; homes, discovery records and @@ -311,7 +315,8 @@ remain binding; moving them out of this file does not weaken them. failure/uncertainty and custody; no guessed resend, deletion or success. - **CI reports; it never gates (owner, 2026-10-23).** GitHub Actions runs a fast lane after each push or pull request (`.github/workflows/ci.yml`: fmt, - guards, Clippy and the fast tests) and a nightly lane (`nightly.yml`: slow and + guards, Clippy, the fast tests and — from the Windows-native route — a + report-only windows store-suites job) and a nightly lane (`nightly.yml`: slow and FUSE mount tests); `format.yml` checks the Ruff formatting below. [tools/ci_tests.py](tools/ci_tests.py) decides which tests go in which lane. None is a required check, and nothing runs before a push. Do diff --git a/api/README.md b/api/README.md index f0265110d..20fab01b6 100644 --- a/api/README.md +++ b/api/README.md @@ -32,14 +32,17 @@ the others as it grows: them, and on macOS and Windows starts the worker: add [`@layerfs/runtime`](docs/runtime.md). -Store calls run in the calling process on Linux only. On macOS and Windows they -go through the LayerFS home's worker, which `@layerfs/runtime` starts; see +Store calls run in the calling process on Linux, and on Windows by default +(the Windows-native route below) when no worker endpoint is set. On macOS — +and on Windows when a worker endpoint is set — they go through the LayerFS +home's worker, which `@layerfs/runtime` starts; see [where calls run](docs/agent.md#where-calls-run) and [platforms and the worker](docs/runtime.md#platforms-and-the-worker). -On Windows there is also the native direct route (owner direction -2026-10-25): the binding built from this tree opens a local NTFS Store -itself — fs, branch, project and operator calls in this process, no -worker and no daemon; workspace, exec and daemon calls refuse +On Windows the native direct route (owner direction 2026-10-25) is the +default: the binding built from this tree opens a local NTFS Store itself — +fs, branch, project and operator calls in this process (on Windows, +`project.list_sandboxes` and the sandbox family still refuse `Unsupported`), +no worker and no daemon; workspace, exec and daemon calls refuse `Unsupported`. See [the Windows-native route](../core/docs/issues/352/DIRECT_STORE.md). diff --git a/api/agent/src/transport/inprocess.js b/api/agent/src/transport/inprocess.js index f211e1931..7c74d165c 100644 --- a/api/agent/src/transport/inprocess.js +++ b/api/agent/src/transport/inprocess.js @@ -7,9 +7,10 @@ import { fileURLToPath } from "node:url"; const require = createRequire(import.meta.url); -// Every binding call runs on Node's worker pool, four threads unless the -// process sets UV_THREADPOOL_SIZE. A Sandbox exec holds its thread for the -// command's whole run, so with four agents' commands running every other call +// Every binding call runs on Node's worker pool, sized 64 by default here +// (WORKERS below) unless the process sets UV_THREADPOOL_SIZE itself. A +// Sandbox exec holds its thread for the +// command's whole run, so with many agents' commands running every other call // queued behind them (#352 F-017). Node reads the size when the pool first // starts; a process that set it keeps its own value. export const WORKERS = 64; diff --git a/api/docs/agent.md b/api/docs/agent.md index 50055ba03..a1a8dec97 100644 --- a/api/docs/agent.md +++ b/api/docs/agent.md @@ -100,14 +100,16 @@ Store calls (`fs`, `branch`, `project.list_branches`) run in this process against the installed Store file at `storePath` (else `LAYERFS_STORE_PATH`), which the factory opened; no Sandbox is involved. The project family's Sandbox-related calls (`project.list_sandboxes`, `sandbox.*`) still need the -daemon and refuse `Unsupported` on this route. That +daemon and refuse `Unsupported` on the Windows-native route (on Linux they +are served in this process). That file is `global/store.sqlite` in the project's volume, so the process runs where that volume is mounted (`/layerfs-store` in a Sandbox). On Linux the Store sits on a local filesystem the daemons share (ext4, xfs, btrfs); on **Windows the Store sits on a local NTFS volume this process opens directly** (the Windows-native route, owner 2026-10-25); anywhere else opening fails -`Unsupported`, and without a `storePath` it fails -`InvalidInput`. On macOS, and on Windows when a worker is preferred, any process that names its LayerFS +`Unsupported`, without a `storePath` it fails +`InvalidInput`, and a `storePath` naming no installed file fails +`NoStore`. On macOS, and on Windows when a worker is preferred, any process that names its LayerFS home's worker (`LAYERFS_WORKER_ENDPOINT`, or the endpoint `@layerfs/runtime` hands over with `useWorker`) sends the calls there instead; on Windows the in-process native route is the default when no worker endpoint is set. The route is open to any integration (owner, @@ -315,7 +317,10 @@ is a no-op. Edit requires exactly one match and streams large files. Names must be spellable by JavaScript: a path with an unpaired surrogate (which the content grammar keeps byte-exact on Windows) cannot cross the JS string boundary — a write by such a name lands as an U+FFFD sibling file -rather than being refused, and listings render the original empty. The +rather than being refused, and listings render the original empty. A merge +or diff whose walk must build a path over such a name fails `Failed` with +the cause in the message, and `fs.rm`/`fs.cp` over a subtree holding one +refuses the same way. The byte-carrying path is recorded follow-up work. A file's or folder's permission bits are an integer `mode` such as `0o755`. A diff --git a/core/docs/issues/352/DIRECT_STORE.md b/core/docs/issues/352/DIRECT_STORE.md index ac9ac8fe4..bef9a3731 100644 --- a/core/docs/issues/352/DIRECT_STORE.md +++ b/core/docs/issues/352/DIRECT_STORE.md @@ -112,7 +112,9 @@ no daemon are involved. Implemented on branch `windows-native-store`: (`GetFileInformationByHandle`), in audited FFI modules listed in engine/AGENTS.md. - The SDK addon serves fs, branch, project and operator calls in-process - exactly as on Linux. Workspace, exec and daemon calls refuse `Unsupported` + exactly as on Linux (on Windows, `project.list_sandboxes` and the sandbox + family also refuse `Unsupported` — the daemon owns them). Workspace, exec + and daemon calls refuse `Unsupported` on Windows: FUSE, the daemon and Workspaces remain Linux-only, and the dsh macOS and WSL2 exceptions are unchanged. @@ -153,8 +155,10 @@ directory and Store files carry owner-only protected DACLs (the audited ACL module): what this route creates is restricted to its owner, and an existing entry whose DACL grants Everyone, Users or Authenticated Users is refused — the parity of unix's 0700/0600 discipline; the installed -Store file and its directory answer the same check at every open, and a -link planted at either spelling is refused, never followed. A home that +Store file answers its own entry check at every open, its directory is +restricted when the route creates it (a caller-pre-created directory is +accepted as the caller shaped it — hence the sidecar note below), and a +link planted at the file's spelling is refused, never followed. A home that lost its owner (restored from backup, moved between machines) answers `Unauthorized`; an administrator recovers it from an **elevated** console (runbook verified end to end, round 6): first @@ -185,8 +189,8 @@ squatter parks every Store call for as long as it holds the mutex (the readiness wait has no bound, exactly as Linux's blocking flock) — round 5 added two neighbours of that local-adversary availability class: a pre-planted lane object carrying a foreign DACL makes the next open -fail (currently typed `Malformed`; an error-typing improvement is on -the should-list), and any local user can enumerate lane names in +fail (currently typed `Malformed`, recorded for a later typing pass), +and any local user can enumerate lane names in `\BaseNamedObjects` (a digest of the directory that reveals write activity, not the path); a caller-pre-created wide directory also lets another user pre-place `-wal`/`-shm` the per-open tightening cannot @@ -208,10 +212,19 @@ refuses with a message naming that entry (the SDK cannot spell such a name until the byte-carrying path lands — the same follow-up as the merge/compare fence above, which surfaces at the SDK as code `Failed` with the cause in the message, not as the content-layer error name). +Upstream-family records pending an owner decision (round 7): the Linux +FUSE invalidation keys `Stale::Entry` on the display spelling, so a +non-UTF-8 name rebound by a merge misses its kernel dentry invalidation +(untouched by this branch); the 256-component path limit is enforced on +the workspace/rename route but not the SDK fs/init routes (live-accepted +at 257-301 components); and deep single-chain operations grow +near-quadratically (a d=250 `mkdir` measures ~1.8 s) — the engine guide's +reject-quadratic rule makes that a decision, not a footnote. **Obtaining the addon**: `npm run build:native` in `api/` builds on -Linux, macOS and Windows (cargo `+1.85.1 --release --locked -p -layerfs-node`, artifact staged to `engine/crates/layerfs-node/layerfs.node`); `LAYERFS_NATIVE` names another copy. +Linux, macOS and Windows (cargo `+1.85.1 --release --offline --locked -p +layerfs-node`, artifact staged to `engine/crates/layerfs-node/layerfs.node`; +Node ≥ 20 is the runtime floor); `LAYERFS_NATIVE` names another copy. When the owner merges this branch, the observation period runs by a fixed checklist (each step reads a mechanism that already exists, none diff --git a/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs b/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs index e5f6dbc92..6601becd8 100644 --- a/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs +++ b/engine/crates/layerfs-persistence/src/backend/sqlite/windows_lane.rs @@ -183,6 +183,7 @@ pub fn windows_volume_filesystem(directory: &Path) -> io::Result { )); } let mut final_path: Vec = vec![0u16; 1024]; + let mut outgrew = false; let mut written = unsafe { GetFinalPathNameByHandleW( handle, @@ -206,9 +207,23 @@ pub fn windows_volume_filesystem(directory: &Path) -> io::Result { VOLUME_NAME_DOS, ) }; + // The requirement grew between the two calls (a concurrent rename + // lengthening the path): a second short read leaves the buffer + // unfilled and the UNC check would read garbage — refuse instead + // (round-8 F1 F2). + if written != 0 && written as usize >= final_path.len() { + outgrew = true; + } } // Capture the failure before another syscall can overwrite it (D1 F6). - let final_failure = (written == 0).then(io::Error::last_os_error); + let final_failure = (written == 0).then(io::Error::last_os_error).or_else(|| { + outgrew.then(|| { + io::Error::new( + io::ErrorKind::Unsupported, + "the final path outgrew its regrown buffer (a concurrent rename?)", + ) + }) + }); let share = written != 0 && final_path .iter() diff --git a/engine/crates/layerfs-store-service/tests/volume_guard_long_paths_windows.rs b/engine/crates/layerfs-store-service/tests/volume_guard_long_paths_windows.rs index 1a5cb904a..b78c0b330 100644 --- a/engine/crates/layerfs-store-service/tests/volume_guard_long_paths_windows.rs +++ b/engine/crates/layerfs-store-service/tests/volume_guard_long_paths_windows.rs @@ -2,63 +2,67 @@ //! directory still passes through the regrowth, and a deep share spelling //! is still refused. The old fixed-array regrowth could never fit a longer //! requirement and left a zeroed buffer on which the `\\?\UNC\` refusal -//! silently passed (round-6 D1 F1). +//! silently passed (round-6 D1 F1). The share spelling is derived from the +//! temporary directory's own drive letter, so the discriminating half runs +//! on every host — a hard-coded `D:` silently skipped it wherever the +//! temporary directory lives elsewhere (round-8 F1 F1). #![cfg(target_os = "windows")] use layerfs_store_service::kernel_guard; use std::path::PathBuf; -/// A directory chain whose final path exceeds 1023 UTF-16 units, created -/// through a verbatim spelling so creation itself works beyond MAX_PATH. -fn deep(parent: &std::path::Path, levels: usize) -> PathBuf { - let mut path = PathBuf::from(r"\\?\").join(parent); - for level in 0..levels { - path.push(format!("guard-{:03}-{}", level, "x".repeat(190))); - } - std::fs::create_dir_all(&path).unwrap(); - // The plain (non-verbatim) spelling of the same directory. +/// A directory chain whose final path exceeds 1023 UTF-16 units. The +/// verbatim spelling is built textually — `PathBuf::from(r"\\?\").join` +/// drops the prefix for an absolute component (round-8 F1 F3) — so +/// creation works beyond MAX_PATH on every host, relaxed or not. +fn deep(parent: &std::path::Path, levels: usize) -> (PathBuf, PathBuf) { + let component = |level: usize| format!("guard-{level:03}-{}", "x".repeat(190)); let mut plain = parent.to_path_buf(); + let mut verbatim = PathBuf::from(format!(r"\\?\{}", parent.display())); for level in 0..levels { - plain.push(format!("guard-{:03}-{}", level, "x".repeat(190))); + plain.push(component(level)); + verbatim.push(component(level)); } - plain + std::fs::create_dir_all(&verbatim).unwrap(); + (plain, verbatim) } #[test] fn a_deep_local_directory_still_names_its_volume() { let base = std::env::temp_dir().join(format!("layerfs-volguard-local-{}", std::process::id())); std::fs::create_dir_all(&base).unwrap(); - let deep = deep(&base, 6); // ~1160 units of tail - let store = deep.join("store.sqlite"); - let filesystem = kernel_guard::check(&store).expect("the local deep path passes"); + let (plain, verbatim) = deep(&base, 6); // ~1160 units of tail + let filesystem = + kernel_guard::check(&plain.join("store.sqlite")).expect("the local deep path passes"); assert_eq!(filesystem.to_lowercase(), "ntfs"); - std::fs::remove_dir_all(&base).unwrap(); + let _ = std::fs::remove_dir_all(&verbatim); + let _ = std::fs::remove_dir_all(&base); } #[test] fn a_deep_share_spelling_is_still_refused() { // The same physical tree addressed through the local machine's - // administrative share: the guard's final path is `\\?\UNC\…` and, - // past the fixed buffer, must still be recognised as a share. + // administrative share for the temporary directory's own drive: the + // guard's final path is `\\?\UNC\…` and, past the fixed buffer, must + // still be recognised as a share. let base = std::env::temp_dir().join(format!("layerfs-volguard-share-{}", std::process::id())); std::fs::create_dir_all(&base).unwrap(); - let deep = deep(&base, 6); - let plain = deep.to_string_lossy().to_string(); - let Some(tail) = plain - .strip_prefix("D:\\") - .or_else(|| plain.strip_prefix("d:\\")) - else { - // The temporary directory is not on D:; the share spelling of this - // machine's share is unknown, so this test cannot run here. - std::fs::remove_dir_all(&base).unwrap(); - return; - }; - let share = PathBuf::from(r"\\localhost\D$\").join(tail); - let store = share.join("store.sqlite"); - let refusal = kernel_guard::check(&store).expect_err("a share spelling is refused"); + let (plain, verbatim) = deep(&base, 6); + let spelling = plain.to_string_lossy().to_string(); + let units: Vec = spelling.chars().collect(); + assert!( + units.len() > 2 && units[1] == ':', + "this test needs a drive-letter temporary directory, got {spelling}" + ); + let drive = units[0].to_ascii_uppercase(); + let tail: String = units[3..].iter().collect(); + let share = PathBuf::from(format!(r"\\localhost\{drive}$\{tail}")); + let refusal = + kernel_guard::check(&share.join("store.sqlite")).expect_err("a share spelling is refused"); assert!( refusal.0.contains("network share"), "the refusal names the share: {}", refusal.0 ); - std::fs::remove_dir_all(&base).unwrap(); + let _ = std::fs::remove_dir_all(&verbatim); + let _ = std::fs::remove_dir_all(&base); }