From c439389dc23130373fae8373b262b0892ab02990 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 19:41:48 +0530 Subject: [PATCH 01/25] ci: run the test suite on Windows --- .github/workflows/windows.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/windows.yml diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..fbae7de --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,33 @@ +name: Windows + +on: + push: + branches: + - dev + - "nxl/windows-support-*" + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Windows tests (NTFS) + runs-on: windows-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Run tests + run: cargo test --workspace --locked From 48317b9bb3806b80581d2780ea48dbb5bfd62906 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 19:55:07 +0530 Subject: [PATCH 02/25] fix: canonicalize paths with dunce std::fs::canonicalize returns \\?\ paths on Windows. Those paths break Git, shells, and comparisons. --- Cargo.lock | 8 ++++++++ Cargo.toml | 1 + crates/cli/Cargo.toml | 1 + crates/cli/src/main.rs | 4 ++-- crates/core/Cargo.toml | 1 + crates/core/src/lib.rs | 6 ++++-- crates/core/src/tests.rs | 35 +++++++++++++++++++++++++++++++++-- 7 files changed, 50 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 695c47f..8fd4f29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,6 +166,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "equivalent" version = "1.0.2" @@ -698,6 +704,7 @@ name = "rift" version = "0.0.13" dependencies = [ "dirs", + "dunce", "git2", "libc", "rand", @@ -716,6 +723,7 @@ name = "rift-cli" version = "0.0.13" dependencies = [ "clap", + "dunce", "rift", "rusqlite", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 0ce3dba..def3ce6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ version = "0.0.13" [workspace.dependencies] clap = { version = "4.5", features = ["derive"] } dirs = "6.0" +dunce = "1" libc = "0.2" rand = "0.9" rusqlite = { version = "0.35", features = ["bundled"] } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 9bcf5fe..24b01b9 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -10,6 +10,7 @@ path = "src/main.rs" [dependencies] clap.workspace = true +dunce.workspace = true rift = { path = "../core" } thiserror.workspace = true diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 5b3372b..4c52deb 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -185,7 +185,7 @@ fn run() -> Result<()> { Ok(()) } Command::Init { at, here } => { - let requested = std::fs::canonicalize(at.unwrap_or(std::env::current_dir()?))?; + let requested = dunce::canonicalize(at.unwrap_or(std::env::current_dir()?))?; let (at, existing, missing_marker) = init_target(&manager, &requested, here)?; let initialized_from_inside = std::env::current_dir()?.starts_with(&at); let mut converting = false; @@ -265,7 +265,7 @@ fn run() -> Result<()> { no_hooks, } => { let at = manager.workspace(at.unwrap_or(std::env::current_dir()?))?; - let cwd = std::fs::canonicalize(std::env::current_dir()?)?; + let cwd = dunce::canonicalize(std::env::current_dir()?)?; if children { let result = manager.remove_all_with_options( &at, diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 9819e79..8f5eb94 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] dirs.workspace = true +dunce.workspace = true libc.workspace = true rand.workspace = true rusqlite.workspace = true diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 2e4b878..1ceb38d 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -237,7 +237,7 @@ impl Manager { return Err(Error::OverlappingWorkspace(destination_parent)); } fs::create_dir_all(&destination_parent)?; - let destination_parent = fs::canonicalize(destination_parent)?; + let destination_parent = existing_directory(&destination_parent)?; let name = match input.name { Some(name) => RiftName::new(name)?, None => name::generated() @@ -665,7 +665,9 @@ fn default_database_path() -> Result { } fn existing_directory(path: &Path) -> Result { - let path = fs::canonicalize(path)?; + // `std::fs::canonicalize` returns `\\?\` paths on Windows. Those break Git, + // shells, and path comparisons, so store the legacy form when it is unambiguous. + let path = dunce::canonicalize(path)?; if !path.is_dir() { return Err(Error::Path(format!("not a directory: {}", path.display()))); } diff --git a/crates/core/src/tests.rs b/crates/core/src/tests.rs index ec46f85..e22e86a 100644 --- a/crates/core/src/tests.rs +++ b/crates/core/src/tests.rs @@ -16,7 +16,7 @@ fn source(temp: &TempDir) -> PathBuf { let source = temp.path().join("app"); fs::create_dir(&source).unwrap(); fs::write(source.join("file.txt"), "hello").unwrap(); - fs::canonicalize(source).unwrap() + dunce::canonicalize(source).unwrap() } fn marker_id(path: &Path) -> RiftId { @@ -110,7 +110,7 @@ fn create_supports_custom_storage_and_rejects_invalid_destinations() { .with_storage(Some(custom.clone())), ) .unwrap(); - assert_eq!(child, fs::canonicalize(&custom).unwrap().join("custom")); + assert_eq!(child, dunce::canonicalize(&custom).unwrap().join("custom")); assert!(matches!( manager.create( Create::new(source.clone()) @@ -1270,6 +1270,37 @@ fn unavailable_cow_does_not_create_a_child() { assert!(manager.list(&source).unwrap().is_empty()); } +#[cfg(windows)] +#[test] +fn windows_registry_paths_round_trip_without_verbatim_prefix() { + let temp = TempDir::new().unwrap(); + let raw = temp.path().join("app"); + fs::create_dir(&raw).unwrap(); + fs::write(raw.join("file.txt"), "hello").unwrap(); + let mut manager = manager(&temp); + manager.init(&raw).unwrap(); + + let workspace = manager.workspace(&raw).unwrap(); + assert_eq!(workspace, dunce::canonicalize(&raw).unwrap()); + assert_no_verbatim_prefix(&workspace); + assert!(manager.list(&workspace).unwrap().is_empty()); + assert!(manager.ancestors(&workspace).unwrap().is_empty()); + + let child = manager + .create(create_input(workspace.clone(), "child")) + .unwrap(); + assert_eq!(manager.list(&raw).unwrap(), vec![child.clone()]); + assert_eq!(manager.ancestors(&child).unwrap(), vec![workspace]); + assert_eq!(manager.workspace(&child).unwrap(), child); + assert_no_verbatim_prefix(&child); +} + +#[cfg(windows)] +fn assert_no_verbatim_prefix(path: &Path) { + let text = path.to_string_lossy(); + assert!(!text.starts_with(r"\\?\"), "{text}"); +} + #[cfg(unix)] fn running_as_root() -> bool { // SAFETY: geteuid has no preconditions and only reads the process identity. From c2291181772843c1ffe21859b6c571cd270fd0f1 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 19:57:08 +0530 Subject: [PATCH 03/25] fix: report a workspace that another program is using Windows rename fails while a handle is open. Retry briefly, then return in_use. --- crates/core/src/lib.rs | 44 ++++++++++++++++++++++++++++++++++-- crates/core/src/rpc.rs | 1 + crates/core/src/tests.rs | 35 ++++++++++++++++++++++++++++ npm/rift-snapshot/index.d.ts | 1 + 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 1ceb38d..82b2161 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -67,6 +67,8 @@ pub enum Error { MissingRift(PathBuf), #[error("workspace path overlaps another managed workspace: {0}")] OverlappingWorkspace(PathBuf), + #[error("another program is using the workspace {0}; close it and retry")] + InUse(PathBuf), #[error("invalid rift config at {path}: {message}")] InvalidConfig { path: PathBuf, message: String }, #[error("{hook} hook failed at {path}: `{command}` {message}")] @@ -522,11 +524,11 @@ impl Manager { )) })?; fs::create_dir_all(trash_parent)?; - if let Err(error) = fs::rename(&target.original_path, &target.trash_path) { + if let Err(error) = rename_into_trash(&target.original_path, &target.trash_path) { for record in moved.iter().rev() { let _ = fs::rename(&record.trash_path, &record.original_path); } - return Err(error.into()); + return Err(error); } moved.push(target); } @@ -658,6 +660,44 @@ impl Manager { } } +fn rename_into_trash(from: &Path, to: &Path) -> Result<()> { + #[cfg(windows)] + { + rename_into_trash_windows(from, to) + } + #[cfg(not(windows))] + { + fs::rename(from, to).map_err(Error::from) + } +} + +#[cfg(windows)] +fn rename_into_trash_windows(from: &Path, to: &Path) -> Result<()> { + if let Err(error) = fs::rename(from, to) { + if !is_windows_in_use(&error) { + return Err(error.into()); + } + } else { + return Ok(()); + } + // A cwd or editor handle often closes within a second. After that, tell the user to close it. + for delay_ms in [0, 10, 25, 50, 100, 200, 400, 200] { + std::thread::sleep(std::time::Duration::from_millis(delay_ms)); + match fs::rename(from, to) { + Ok(()) => return Ok(()), + Err(error) if is_windows_in_use(&error) => continue, + Err(error) => return Err(error.into()), + } + } + Err(Error::InUse(from.to_path_buf())) +} + +#[cfg(windows)] +fn is_windows_in_use(error: &std::io::Error) -> bool { + // ERROR_ACCESS_DENIED is 5. ERROR_SHARING_VIOLATION is 32. + matches!(error.raw_os_error(), Some(5 | 32)) +} + fn default_database_path() -> Result { let base = dirs::data_local_dir() .ok_or_else(|| Error::Path("user data directory is unavailable".into()))?; diff --git a/crates/core/src/rpc.rs b/crates/core/src/rpc.rs index e77bdad..80f62cf 100644 --- a/crates/core/src/rpc.rs +++ b/crates/core/src/rpc.rs @@ -102,6 +102,7 @@ impl From for Failure { Error::NamesExhausted(path) => ("names_exhausted", Some(path.clone())), Error::MissingRift(path) => ("missing_rift", Some(path.clone())), Error::OverlappingWorkspace(path) => ("inside_source", Some(path.clone())), + Error::InUse(path) => ("in_use", Some(path.clone())), Error::InvalidConfig { path, .. } => ("invalid_config", Some(path.clone())), Error::HookFailed { path, .. } => ("hook_failed", Some(path.clone())), }; diff --git a/crates/core/src/tests.rs b/crates/core/src/tests.rs index e22e86a..7ca7bea 100644 --- a/crates/core/src/tests.rs +++ b/crates/core/src/tests.rs @@ -1295,6 +1295,41 @@ fn windows_registry_paths_round_trip_without_verbatim_prefix() { assert_no_verbatim_prefix(&child); } +#[cfg(windows)] +#[test] +fn remove_reports_in_use_while_a_file_is_open() { + use std::fs::OpenOptions; + use std::os::windows::fs::OpenOptionsExt; + + const FILE_SHARE_READ: u32 = 1; + + let temp = TempDir::new().unwrap(); + let source = source(&temp); + let mut manager = manager(&temp); + manager.init(&source).unwrap(); + let child = manager + .create(create_input(source.clone(), "busy")) + .unwrap(); + let held = OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .open(child.join("file.txt")) + .unwrap(); + + let error = manager.remove(&child).unwrap_err(); + + assert!(matches!(error, Error::InUse(ref path) if path == &child)); + assert!(error.to_string().contains("close it and retry")); + assert!(child.exists()); + assert_eq!(manager.list(&source).unwrap(), vec![child.clone()]); + assert_eq!(manager.workspace(&child).unwrap(), child); + + drop(held); + manager.remove(&child).unwrap(); + assert!(!child.exists()); + assert!(manager.list(&source).unwrap().is_empty()); +} + #[cfg(windows)] fn assert_no_verbatim_prefix(path: &Path) { let text = path.to_string_lossy(); diff --git a/npm/rift-snapshot/index.d.ts b/npm/rift-snapshot/index.d.ts index e355b0c..ba4ae6d 100644 --- a/npm/rift-snapshot/index.d.ts +++ b/npm/rift-snapshot/index.d.ts @@ -41,6 +41,7 @@ export type RiftErrorCode = | "names_exhausted" | "missing_rift" | "inside_source" + | "in_use" | "invalid_config" | "hook_failed" | "invalid_request" From e99d9efe705012c83d69ea279df79def805bc8f3 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:06:16 +0530 Subject: [PATCH 04/25] fix(cli): leave a workspace before moving it to trash Windows cannot rename a directory that is the process cwd. The printed shell path still comes from the directory captured before that move. --- crates/cli/src/main.rs | 91 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 4c52deb..90783f3 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -267,6 +267,7 @@ fn run() -> Result<()> { let at = manager.workspace(at.unwrap_or(std::env::current_dir()?))?; let cwd = dunce::canonicalize(std::env::current_dir()?)?; if children { + leave_trashed_directory(&manager, &at, &cwd, true, &[])?; let result = manager.remove_all_with_options( &at, RemoveOptions::default().hook_mode(if no_hooks { @@ -294,6 +295,7 @@ fn run() -> Result<()> { let ancestors = manager.ancestors(&at)?; let unregistering_root = ancestors.is_empty(); require_force_for_root(unregistering_root, force)?; + leave_trashed_directory(&manager, &at, &cwd, false, &ancestors)?; let destination = if cli.shell_cwd && cwd.starts_with(&at) { if unregistering_root { Some(at.clone()) @@ -374,6 +376,49 @@ fn git_root(path: &std::path::Path) -> PathBuf { .to_path_buf() } +fn leave_trashed_directory( + manager: &Manager, + at: &std::path::Path, + cwd: &std::path::Path, + children: bool, + ancestors: &[PathBuf], +) -> Result<()> { + let preserving = children || ancestors.is_empty(); + let descendants = if preserving { + manager.descendants(at)? + } else { + Vec::new() + }; + let Some(stay) = directory_outside_trash(cwd, at, children, ancestors, &descendants) else { + return Ok(()); + }; + std::env::set_current_dir(stay)?; + Ok(()) +} + +fn directory_outside_trash( + cwd: &std::path::Path, + at: &std::path::Path, + children: bool, + ancestors: &[PathBuf], + descendants: &[PathBuf], +) -> Option { + let preserving = children || ancestors.is_empty(); + let moving = if preserving { + descendants.iter().any(|path| cwd.starts_with(path)) + } else { + cwd.starts_with(at) + }; + if !moving { + return None; + } + if preserving { + Some(at.to_path_buf()) + } else { + ancestors.first().cloned() + } +} + fn require_force_for_root(unregistering_root: bool, force: bool) -> Result<()> { if unregistering_root && !force { return Err(CliError::ForceRequired); @@ -431,6 +476,52 @@ mod tests { assert_eq!(init_target(&manager, &nested, true).unwrap().0, nested); } + #[test] + fn remove_stands_in_the_parent_before_trashing_a_child() { + let parent = PathBuf::from("work").join("app"); + let child = parent.join(".rifts").join("app").join("child"); + + assert_eq!( + directory_outside_trash( + &child.join("src"), + &child, + false, + std::slice::from_ref(&parent), + &[], + ), + Some(parent.clone()) + ); + assert_eq!( + directory_outside_trash(&parent, &child, false, std::slice::from_ref(&parent), &[]), + None + ); + } + + #[test] + fn remove_stands_in_the_preserved_workspace_before_trashing_descendants() { + let root = PathBuf::from("work").join("app"); + let child = PathBuf::from("work").join(".rifts").join("app").join("child"); + + assert_eq!( + directory_outside_trash( + &child.join("src"), + &root, + true, + &[], + std::slice::from_ref(&child), + ), + Some(root.clone()) + ); + assert_eq!( + directory_outside_trash(&child, &root, false, &[], std::slice::from_ref(&child)), + Some(root.clone()) + ); + assert_eq!( + directory_outside_trash(&root, &root, false, &[], &[child]), + None + ); + } + #[test] fn root_unregistration_requires_force() { assert!(matches!( From 9575fdaaa9e58a53c3c7deb3336525fb43e7494b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:07:33 +0530 Subject: [PATCH 05/25] fix: run Windows hooks through cmd /d /s /c raw_arg keeps quotes inside the command, and /d skips AutoRun. Hook tests compare the written lines, not the shell's line endings. --- crates/core/src/hook.rs | 8 ++++++-- crates/core/src/tests.rs | 43 +++++++++++++++++++++++++++++----------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/crates/core/src/hook.rs b/crates/core/src/hook.rs index a1f18d6..53afc50 100644 --- a/crates/core/src/hook.rs +++ b/crates/core/src/hook.rs @@ -67,8 +67,12 @@ fn run_step( #[cfg(windows)] fn shell_command(command: &str) -> Command { - let mut shell = Command::new("cmd"); - shell.args(["/C", command]); + use std::os::windows::process::CommandExt; + + let mut shell = Command::new("cmd.exe"); + // Rust's normal quoting turns inner quotes into \", which cmd does not unescape. + // /d skips AutoRun. /s strips one pair of quotes around the command. + shell.raw_arg(format!("/d /s /c \"{command}\"")); shell } diff --git a/crates/core/src/tests.rs b/crates/core/src/tests.rs index 7ca7bea..dc789a3 100644 --- a/crates/core/src/tests.rs +++ b/crates/core/src/tests.rs @@ -33,6 +33,14 @@ fn create_options(copy_mode: CopyMode, hook_mode: HookMode) -> CreateOptions { .hook_mode(hook_mode) } +fn hook_log_lines(path: &Path) -> Vec { + fs::read_to_string(path) + .unwrap() + .lines() + .map(|line| line.trim().to_owned()) + .collect() +} + fn child_path(source: &Path, name: &str) -> PathBuf { source.parent().unwrap().join(".rifts/app").join(name) } @@ -292,14 +300,8 @@ run = "echo post >> lifecycle.log" .create(create_input(source.clone(), "lifecycle")) .unwrap(); - assert_eq!( - fs::read_to_string(source.join("lifecycle.log")).unwrap(), - "pre\n" - ); - assert_eq!( - fs::read_to_string(child.join("lifecycle.log")).unwrap(), - "pre\npost\n" - ); + assert_eq!(hook_log_lines(&source.join("lifecycle.log")), ["pre"]); + assert_eq!(hook_log_lines(&child.join("lifecycle.log")), ["pre", "post"]); } #[test] @@ -506,10 +508,7 @@ run = "echo post >> lifecycle.log" manager.remove(&child).unwrap(); assert!(!child.exists()); - assert_eq!( - fs::read_to_string(trash.join("lifecycle.log")).unwrap(), - "pre\npost\n" - ); + assert_eq!(hook_log_lines(&trash.join("lifecycle.log")), ["pre", "post"]); } #[test] @@ -1330,6 +1329,26 @@ fn remove_reports_in_use_while_a_file_is_open() { assert!(manager.list(&source).unwrap().is_empty()); } +#[cfg(windows)] +#[test] +fn windows_hooks_keep_quotes_in_the_command() { + let temp = TempDir::new().unwrap(); + let source = source(&temp); + fs::write( + source.join(".rift.toml"), + "version = 1\n[[hooks.postcreate]]\nrun = 'echo \"quoted\">quote.log'\n", + ) + .unwrap(); + let mut manager = manager(&temp); + manager.init(&source).unwrap(); + + let child = manager + .create(create_input(source, "quoted-hook")) + .unwrap(); + + assert_eq!(hook_log_lines(&child.join("quote.log")), ["\"quoted\""]); +} + #[cfg(windows)] fn assert_no_verbatim_prefix(path: &Path) { let text = path.to_string_lossy(); From af79d381c18254a1665f64321438d2ec6950d88e Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:08:56 +0530 Subject: [PATCH 06/25] feat(cli): add a PowerShell shell-init wrapper pwsh and powershell both get a rift function that changes directory from the path rift prints, keeps the native exit code, and forces UTF-8 for that call. --- crates/cli/src/main.rs | 94 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 90783f3..c1fc21e 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -34,6 +34,8 @@ enum Shell { Bash, Zsh, Nushell, + #[value(alias = "powershell")] + Pwsh, } impl Shell { @@ -75,6 +77,40 @@ impl Shell { ^{executable} ...$rest }} }} +}}"#, + ) + } + Shell::Pwsh => { + let executable = powershell_quote(executable); + format!( + r#"function global:rift {{ + $exe = {executable} + $command = $null + for ($i = 0; $i -lt $args.Count; $i++) {{ + $arg = [string]$args[$i] + if ($arg -eq '--database') {{ $i++; continue }} + if ($arg -like '-*') {{ continue }} + $command = $arg + break + }} + if ($command -notin 'init','create','remove') {{ + & $exe @args + return + }} + $encoding = [Console]::OutputEncoding + $code = 0 + $cwd = $null + try {{ + [Console]::OutputEncoding = [System.Text.Utf8Encoding]::new() + $cwd = & $exe --shell-cwd @args | Select-Object -Last 1 + $code = $LASTEXITCODE + }} finally {{ + [Console]::OutputEncoding = $encoding + }} + if ($cwd) {{ + Set-Location -LiteralPath $cwd -ErrorAction Stop + }} + $global:LASTEXITCODE = $code }}"#, ) } @@ -435,6 +471,10 @@ fn posix_shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } +fn powershell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + fn nushell_shell_quote(value: &str) -> String { let mut hashes = String::from("#"); while value.contains(&format!("'{}", hashes)) { @@ -610,6 +650,60 @@ mod tests { assert_eq!(Shell::Nushell.init_script("/tmp/rift"), wrapper); } + #[test] + fn shell_init_accepts_pwsh_and_powershell() { + for name in ["pwsh", "powershell"] { + let cli = Cli::try_parse_from(["rift", "shell-init", name]).unwrap(); + assert!(matches!( + cli.command, + Command::ShellInit { shell: Shell::Pwsh } + )); + } + } + + #[test] + fn shell_init_renders_powershell_wrapper() { + let wrapper = r#"function global:rift { + $exe = 'C:\Program Files\rift.exe' + $command = $null + for ($i = 0; $i -lt $args.Count; $i++) { + $arg = [string]$args[$i] + if ($arg -eq '--database') { $i++; continue } + if ($arg -like '-*') { continue } + $command = $arg + break + } + if ($command -notin 'init','create','remove') { + & $exe @args + return + } + $encoding = [Console]::OutputEncoding + $code = 0 + $cwd = $null + try { + [Console]::OutputEncoding = [System.Text.Utf8Encoding]::new() + $cwd = & $exe --shell-cwd @args | Select-Object -Last 1 + $code = $LASTEXITCODE + } finally { + [Console]::OutputEncoding = $encoding + } + if ($cwd) { + Set-Location -LiteralPath $cwd -ErrorAction Stop + } + $global:LASTEXITCODE = $code +}"#; + + assert_eq!( + Shell::Pwsh.init_script(r"C:\Program Files\rift.exe"), + wrapper + ); + assert!( + Shell::Pwsh + .init_script(r"C:\it's\rift.exe") + .contains("$exe = 'C:\\it''s\\rift.exe'") + ); + } + #[test] fn nushell_shell_quote_uses_enough_raw_string_hashes() { assert_eq!(nushell_shell_quote("/tmp/rift"), "r#'/tmp/rift'#"); From 3086b205cf80d45c59935f2ddc080e0d5a97fa79 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:09:40 +0530 Subject: [PATCH 07/25] fix: reject Windows device names and forbidden characters CON, trailing dots, and <>:\"|?* are not valid workspace names on Windows. Those names stay valid on other platforms. --- crates/core/src/name.rs | 81 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/core/src/name.rs b/crates/core/src/name.rs index 8c35012..13f35cc 100644 --- a/crates/core/src/name.rs +++ b/crates/core/src/name.rs @@ -9,7 +9,7 @@ pub(crate) struct RiftName(String); impl RiftName { pub(crate) fn new(name: String) -> Result { let single_segment = Path::new(&name).file_name() == Some(OsStr::new(&name)); - if !single_segment || name.starts_with('.') { + if !single_segment || name.starts_with('.') || is_reserved_windows_name(&name) { return Err(Error::Path(format!("invalid rift name: {name}"))); } Ok(Self(name)) @@ -20,6 +20,48 @@ impl RiftName { } } +#[cfg(not(windows))] +fn is_reserved_windows_name(_name: &str) -> bool { + false +} + +#[cfg(windows)] +fn is_reserved_windows_name(name: &str) -> bool { + if name.ends_with(['.', ' ']) + || name + .chars() + .any(|character| character.is_control() || "<>:\"|?*".contains(character)) + { + return true; + } + let stem = name.split('.').next().unwrap_or(name); + matches!( + stem.to_ascii_uppercase().as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ) +} + const ADJECTIVES: &[&str] = &[ "amber", "bold", "brisk", "calm", "cedar", "clear", "cobalt", "coral", "dawn", "ember", "gentle", "golden", "jade", "lively", "lunar", "mellow", "misty", "noble", "quiet", "rapid", @@ -60,6 +102,43 @@ mod tests { assert!(RiftName::new("child/".into()).is_err()); } + #[cfg(not(windows))] + #[test] + fn device_names_are_valid_off_windows() { + assert_eq!(RiftName::new("CON".into()).unwrap().as_str(), "CON"); + } + + #[cfg(windows)] + #[test] + fn windows_rejects_device_names_trailing_space_and_forbidden_characters() { + for name in [ + "CON", + "con", + "NUL.txt", + "COM1", + "com9.dat", + "LPT1", + "prn", + "AUX.log", + "file.", + "file ", + "ab", + "a:b", + "a\"b", + "a|b", + "a?b", + "a*b", + "a\u{0001}b", + ] { + assert!(RiftName::new(name.into()).is_err(), "{name}"); + } + assert_eq!(RiftName::new("child".into()).unwrap().as_str(), "child"); + assert!(RiftName::new("COM10".into()).is_ok()); + assert!(RiftName::new("console".into()).is_ok()); + assert!(RiftName::new("file.CON".into()).is_ok()); + } + #[test] fn names_cannot_be_hidden() { assert!(RiftName::new(".trash".into()).is_err()); From c9b1f165b91bbf111c236db684ee5e1a2267ee24 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:11:38 +0530 Subject: [PATCH 08/25] test: add Windows CLI checks for paths, remove, and pwsh The checks return early unless RIFT_REQUIRE_REFS_TESTS is set. When it is set, a failed create or a missing pwsh fails the run. --- crates/cli/tests/windows_e2e.rs | 236 ++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 crates/cli/tests/windows_e2e.rs diff --git a/crates/cli/tests/windows_e2e.rs b/crates/cli/tests/windows_e2e.rs new file mode 100644 index 0000000..750ce71 --- /dev/null +++ b/crates/cli/tests/windows_e2e.rs @@ -0,0 +1,236 @@ +#![cfg(windows)] + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use tempfile::{Builder, TempDir}; + +#[test] +fn refs_environment_can_create_a_workspace() { + let Some(fixture) = refs_fixture() else { + return; + }; + let source = prepared_source(&fixture); + fixture.success(&source, ["init", "--here"]); + let create = fixture.run(&source, ["create", "--name", "child"]); + assert!( + create.status.success(), + "RIFT_REQUIRE_REFS_TESTS requires create to succeed on this volume\n{}", + command_text(&create) + ); +} + +#[test] +fn init_and_create_print_paths_without_verbatim_prefix() { + let Some(fixture) = refs_fixture() else { + return; + }; + let source = prepared_source(&fixture); + let init = fixture.success(&source, ["init", "--here"]); + assert!(init.stdout.is_empty()); + assert_no_verbatim_prefix(&init.stderr); + + let create = fixture.success(&source, ["create", "--name", "child"]); + let child = create.single_stdout_path(); + assert_no_verbatim_prefix(&create.stdout); + assert_no_verbatim_prefix(&create.stderr); + assert!(child.join("marker.txt").is_file()); +} + +#[test] +fn remove_from_inside_the_workspace_moves_it_to_trash() { + let Some(fixture) = refs_fixture() else { + return; + }; + let (_source, child) = created_child(&fixture); + let remove = fixture.run(&child, ["remove"]); + assert!( + remove.status.success(), + "remove from inside the workspace failed\n{}", + command_text(&remove) + ); + assert!(!child.exists()); + assert!(trash_contains_marker(child.parent().unwrap())); +} + +#[test] +fn powershell_remove_returns_to_the_parent_workspace() { + let Some(fixture) = refs_fixture() else { + return; + }; + require_pwsh(); + let (source, child) = created_child(&fixture); + let parent = fixture.success(&child, ["ancestors"]).single_stdout_path(); + let init_script = fixture.success(fixture.root(), ["shell-init", "pwsh"]); + let script = format!( + "{init}\nSet-Location -LiteralPath {child}\nrift --database {database} remove\nif ($LASTEXITCODE -ne 0) {{ exit $LASTEXITCODE }}\nWrite-Output (Get-Location).ProviderPath\n", + init = init_script.stdout, + child = powershell_literal(&child), + database = powershell_literal(fixture.database()), + ); + let output = Command::new("pwsh") + .args(["-NoProfile", "-Command", &script]) + .output() + .unwrap(); + assert!( + output.status.success(), + "pwsh remove failed\n{}", + command_text(&output) + ); + let location = String::from_utf8(output.stdout) + .unwrap() + .lines() + .next_back() + .unwrap_or("") + .trim() + .to_owned(); + assert!( + same_directory(&location, &parent.to_string_lossy()), + "expected to end in {}, got {location}", + parent.display() + ); + assert_no_verbatim_prefix(&location); + assert!(!child.exists()); + assert!(source.join(".rift").is_file()); +} + +fn refs_fixture() -> Option { + std::env::var_os("RIFT_REQUIRE_REFS_TESTS") + .is_some() + .then(Fixture::new) +} + +fn require_pwsh() { + let available = Command::new("pwsh") + .args(["-NoProfile", "-Command", "exit 0"]) + .status() + .is_ok_and(|status| status.success()); + assert!(available, "RIFT_REQUIRE_REFS_TESTS requires pwsh on PATH"); +} + +fn prepared_source(fixture: &Fixture) -> PathBuf { + let source = fixture.root().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("marker.txt"), "kept").unwrap(); + source +} + +fn created_child(fixture: &Fixture) -> (PathBuf, PathBuf) { + let source = prepared_source(fixture); + fixture.success(&source, ["init", "--here"]); + let child = fixture + .success(&source, ["create", "--name", "child"]) + .single_stdout_path(); + (source, child) +} + +fn trash_contains_marker(storage: &Path) -> bool { + let trash = storage.join(".trash"); + std::fs::read_dir(&trash).is_ok_and(|entries| { + entries + .filter_map(Result::ok) + .any(|entry| entry.path().join("marker.txt").is_file()) + }) +} + +fn assert_no_verbatim_prefix(text: &str) { + assert!(!text.contains(r"\\?\"), "{text}"); +} + +fn same_directory(left: &str, right: &str) -> bool { + fn normalized(path: &str) -> String { + path.trim() + .trim_start_matches(r"\\?\") + .trim_end_matches(['\\', '/']) + .to_ascii_lowercase() + } + normalized(left) == normalized(right) +} + +fn powershell_literal(path: &Path) -> String { + format!("'{}'", path.display().to_string().replace('\'', "''")) +} + +struct Fixture { + temp: TempDir, + database: PathBuf, + binary: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let temp = Builder::new() + .prefix(".rift-cli-windows-") + .tempdir_in(std::env::current_dir().unwrap()) + .unwrap(); + Self { + database: temp.path().join("registry.sqlite"), + binary: PathBuf::from(env!("CARGO_BIN_EXE_rift")), + temp, + } + } + + fn root(&self) -> &Path { + self.temp.path() + } + + fn database(&self) -> &Path { + &self.database + } + + fn success(&self, cwd: &Path, args: I) -> CommandOutput + where + I: IntoIterator, + S: AsRef, + { + let output = self.run(cwd, args); + assert!( + output.status.success(), + "expected success\n{}", + command_text(&output) + ); + command_output(output) + } + + fn run(&self, cwd: &Path, args: I) -> Output + where + I: IntoIterator, + S: AsRef, + { + Command::new(&self.binary) + .arg("--database") + .arg(&self.database) + .args(args) + .current_dir(cwd) + .output() + .unwrap() + } +} + +struct CommandOutput { + stdout: String, + stderr: String, +} + +impl CommandOutput { + fn single_stdout_path(&self) -> PathBuf { + let paths = self.stdout.lines().map(PathBuf::from).collect::>(); + assert_eq!(paths.len(), 1, "expected one stdout path, got {paths:?}"); + paths.into_iter().next().unwrap() + } +} + +fn command_output(output: Output) -> CommandOutput { + CommandOutput { + stdout: String::from_utf8(output.stdout).unwrap(), + stderr: String::from_utf8(output.stderr).unwrap(), + } +} + +fn command_text(output: &Output) -> String { + format!( + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} From 6b12eae37fba351e196d804441a249813446cda9 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:14:27 +0530 Subject: [PATCH 09/25] fix(opencode): spawn rift without pinning a Windows workspace The RPC child starts in the home directory. Cancel kills the process tree. A bare name resolves to rift.exe, or to the bundled binary next to npm's shim. --- plugins/opencode/src/command.ts | 8 +++-- plugins/opencode/src/strategy.ts | 49 +++++++++++++++++++++++++- plugins/opencode/test/strategy.test.ts | 27 +++++++++++++- 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/plugins/opencode/src/command.ts b/plugins/opencode/src/command.ts index f6f1da3..172cb41 100644 --- a/plugins/opencode/src/command.ts +++ b/plugins/opencode/src/command.ts @@ -1,4 +1,5 @@ -import { spawn } from "node:child_process" +import { spawn, spawnSync } from "node:child_process" +import { homedir } from "node:os" interface Failure { code: string @@ -29,6 +30,8 @@ export function rpc(executable: string, request: object, signal: AbortSignal): P return new Promise((resolve, reject) => { const grouped = process.platform !== "win32" const child = spawn(executable, ["rpc"], { + // A neutral cwd so this process does not pin a workspace and block its removal. + cwd: homedir(), detached: grouped, stdio: ["pipe", "pipe", "pipe"], }) @@ -46,7 +49,8 @@ export function rpc(executable: string, request: object, signal: AbortSignal): P if (!child.pid) return try { if (grouped) process.kill(-child.pid, "SIGTERM") - else child.kill() + // child.kill() leaves hook grandchildren holding the workspace. + else spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true }) } catch {} } const abort = () => { diff --git a/plugins/opencode/src/strategy.ts b/plugins/opencode/src/strategy.ts index 480bf73..45b785c 100644 --- a/plugins/opencode/src/strategy.ts +++ b/plugins/opencode/src/strategy.ts @@ -1,4 +1,6 @@ import { Worktree } from "@opencode/plugin" +import { existsSync } from "node:fs" +import { spawnSync } from "node:child_process" import path from "node:path" import { RpcError, rpc } from "./command.js" @@ -14,15 +16,26 @@ export interface Warning { message: string } +export interface ExecutableLookup { + platform: string + arch: string + findOnPath(fileName: string): string | undefined + exists(filePath: string): boolean +} + interface Runtime { warning?: (input: Warning) => void rpc?: typeof rpc + lookup?: ExecutableLookup } const hints: Partial> = { workspace_not_initialized: "Rift source is not initialized; run `rift init` from the project root first", initialization_required: "This Rift workspace must be initialized first; run `rift init` from its root folder", missing_marker: "This Rift workspace is missing its `.rift` marker; run `rift init` to restore it", + cow_unavailable: + "Copy-on-write cloning is unavailable on this volume. Create a Dev Drive and move the project onto it", + in_use: "Another program is using this workspace. Close it and retry", } function parseOptions(value: Record): Options { @@ -43,11 +56,45 @@ function parseOptions(value: Record): Options { return { executable, copyAll, hooks, database } } +export function resolveExecutable(configured: string, lookup: ExecutableLookup): string { + if (lookup.platform !== "win32" || configured.includes("/") || configured.includes("\\")) return configured + const base = configured.toLowerCase().endsWith(".exe") ? configured.slice(0, -4) : configured + const exe = lookup.findOnPath(`${base}.exe`) + if (exe) return exe + const cmd = lookup.findOnPath(`${base}.cmd`) + if (!cmd) return configured + // npm's .cmd shim cannot be spawned without a shell. The binary sits next to node_modules/.bin. + const bundled = path.win32.resolve( + path.win32.dirname(cmd), + "..", + "rift-snapshot", + "prebuilds", + `windows-${lookup.arch}`, + "rift.exe", + ) + return lookup.exists(bundled) ? bundled : configured +} + +function hostLookup(): ExecutableLookup { + return { + platform: process.platform, + arch: process.arch, + findOnPath(fileName) { + const finder = process.platform === "win32" ? "where.exe" : "which" + const result = spawnSync(finder, [fileName], { encoding: "utf8" }) + if (result.status !== 0) return undefined + return result.stdout.split(/\r?\n/).find((line) => line.trim())?.trim() + }, + exists: existsSync, + } +} + export function makeStrategy(value: Record = {}, runtime: Runtime = {}) { const options = parseOptions(value) + const executable = resolveExecutable(options.executable, runtime.lookup ?? hostLookup()) const call = runtime.rpc ?? rpc const request = (command: object, signal: AbortSignal) => - call(options.executable, { database: options.database, ...command }, signal) + call(executable, { database: options.database, ...command }, signal) const failure = (error: unknown) => { const message = (error instanceof RpcError && hints[error.code]) || (error instanceof Error ? error.message : String(error)) diff --git a/plugins/opencode/test/strategy.test.ts b/plugins/opencode/test/strategy.test.ts index 7a74046..90ba827 100644 --- a/plugins/opencode/test/strategy.test.ts +++ b/plugins/opencode/test/strategy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { RpcError } from "../src/command.js" -import { makeStrategy } from "../src/strategy.js" +import { makeStrategy, resolveExecutable, type ExecutableLookup } from "../src/strategy.js" function fixture() { const calls: object[] = [] @@ -92,9 +92,34 @@ describe("Rift workspace strategy", () => { await expect(create("missing_marker")).rejects.toThrow( "This Rift workspace is missing its `.rift` marker; run `rift init` to restore it", ) + await expect(create("cow_unavailable")).rejects.toThrow( + "Copy-on-write cloning is unavailable on this volume. Create a Dev Drive and move the project onto it", + ) + await expect(create("in_use")).rejects.toThrow("Another program is using this workspace. Close it and retry") await expect(create("hook_failed")).rejects.toThrow("hook_failed at /project") }) + test("resolves a Windows executable without spawning the npm shim", () => { + const bundled = "C:\\proj\\node_modules\\rift-snapshot\\prebuilds\\windows-x64\\rift.exe" + const lookup = (found: Record, present: string[]): ExecutableLookup => ({ + platform: "win32", + arch: "x64", + findOnPath: (fileName) => found[fileName], + exists: (filePath) => present.includes(filePath), + }) + + expect(resolveExecutable("rift", lookup({ "rift.exe": "C:\\Tools\\rift.exe" }, []))).toBe("C:\\Tools\\rift.exe") + expect(resolveExecutable("rift.exe", lookup({ "rift.exe": "C:\\Tools\\rift.exe" }, []))).toBe( + "C:\\Tools\\rift.exe", + ) + expect( + resolveExecutable("rift", lookup({ "rift.cmd": "C:\\proj\\node_modules\\.bin\\rift.cmd" }, [bundled])), + ).toBe(bundled) + expect(resolveExecutable("rift", lookup({ "rift.cmd": "C:\\proj\\node_modules\\.bin\\rift.cmd" }, []))).toBe("rift") + expect(resolveExecutable("C:\\rift\\rift.exe", lookup({}, []))).toBe("C:\\rift\\rift.exe") + expect(resolveExecutable("rift", { ...lookup({}, []), platform: "linux" })).toBe("rift") + }) + test("keeps OpenCode inventory consistent after post-hook failures", async () => { const warnings: Array<{ directory: string; message: string }> = [] const strategy = makeStrategy( From bbeab76d56e9954eaa977188048e33b30900cdfe Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:21:40 +0530 Subject: [PATCH 10/25] fix(opencode): find rift.exe beside a global npm shim A global install puts rift.cmd in the npm prefix and the package in prefix\node_modules\rift-snapshot. A local install still resolves through node_modules\.bin. --- plugins/opencode/src/strategy.ts | 18 ++++++++---------- plugins/opencode/test/strategy.test.ts | 17 ++++++++++++++--- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/plugins/opencode/src/strategy.ts b/plugins/opencode/src/strategy.ts index 45b785c..df1840e 100644 --- a/plugins/opencode/src/strategy.ts +++ b/plugins/opencode/src/strategy.ts @@ -63,16 +63,14 @@ export function resolveExecutable(configured: string, lookup: ExecutableLookup): if (exe) return exe const cmd = lookup.findOnPath(`${base}.cmd`) if (!cmd) return configured - // npm's .cmd shim cannot be spawned without a shell. The binary sits next to node_modules/.bin. - const bundled = path.win32.resolve( - path.win32.dirname(cmd), - "..", - "rift-snapshot", - "prebuilds", - `windows-${lookup.arch}`, - "rift.exe", - ) - return lookup.exists(bundled) ? bundled : configured + // npm's .cmd shim cannot be spawned without a shell. The binary is \node_modules\rift-snapshot globally and \..\rift-snapshot locally. + const shimDir = path.win32.dirname(cmd) + const bundled = (root: string) => + path.win32.resolve(shimDir, root, "rift-snapshot", "prebuilds", `windows-${lookup.arch}`, "rift.exe") + const global = bundled("node_modules") + const local = bundled("..") + if (lookup.exists(global)) return global + return lookup.exists(local) ? local : configured } function hostLookup(): ExecutableLookup { diff --git a/plugins/opencode/test/strategy.test.ts b/plugins/opencode/test/strategy.test.ts index 90ba827..f352e01 100644 --- a/plugins/opencode/test/strategy.test.ts +++ b/plugins/opencode/test/strategy.test.ts @@ -100,7 +100,9 @@ describe("Rift workspace strategy", () => { }) test("resolves a Windows executable without spawning the npm shim", () => { - const bundled = "C:\\proj\\node_modules\\rift-snapshot\\prebuilds\\windows-x64\\rift.exe" + const local = "C:\\proj\\node_modules\\rift-snapshot\\prebuilds\\windows-x64\\rift.exe" + const global = "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\rift-snapshot\\prebuilds\\windows-x64\\rift.exe" + const globalBesideShim = "C:\\Users\\me\\AppData\\Roaming\\rift-snapshot\\prebuilds\\windows-x64\\rift.exe" const lookup = (found: Record, present: string[]): ExecutableLookup => ({ platform: "win32", arch: "x64", @@ -113,8 +115,17 @@ describe("Rift workspace strategy", () => { "C:\\Tools\\rift.exe", ) expect( - resolveExecutable("rift", lookup({ "rift.cmd": "C:\\proj\\node_modules\\.bin\\rift.cmd" }, [bundled])), - ).toBe(bundled) + resolveExecutable("rift", lookup({ "rift.cmd": "C:\\proj\\node_modules\\.bin\\rift.cmd" }, [local])), + ).toBe(local) + expect( + resolveExecutable("rift", lookup({ "rift.cmd": "C:\\Users\\me\\AppData\\Roaming\\npm\\rift.cmd" }, [global])), + ).toBe(global) + expect( + resolveExecutable( + "rift", + lookup({ "rift.cmd": "C:\\Users\\me\\AppData\\Roaming\\npm\\rift.cmd" }, [global, globalBesideShim]), + ), + ).toBe(global) expect(resolveExecutable("rift", lookup({ "rift.cmd": "C:\\proj\\node_modules\\.bin\\rift.cmd" }, []))).toBe("rift") expect(resolveExecutable("C:\\rift\\rift.exe", lookup({}, []))).toBe("C:\\rift\\rift.exe") expect(resolveExecutable("rift", { ...lookup({}, []), platform: "linux" })).toBe("rift") From fcb2b70c90b1164554cb37c76482e6e516b8dfa2 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:22:11 +0530 Subject: [PATCH 11/25] fix(opencode): stop rewriting in_use and cow_unavailable The core messages already name the path and the fix. A Dev Drive hint is wrong on Linux and macOS. --- plugins/opencode/src/strategy.ts | 3 --- plugins/opencode/test/strategy.test.ts | 6 ++---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/plugins/opencode/src/strategy.ts b/plugins/opencode/src/strategy.ts index df1840e..2fe0ba8 100644 --- a/plugins/opencode/src/strategy.ts +++ b/plugins/opencode/src/strategy.ts @@ -33,9 +33,6 @@ const hints: Partial> = { workspace_not_initialized: "Rift source is not initialized; run `rift init` from the project root first", initialization_required: "This Rift workspace must be initialized first; run `rift init` from its root folder", missing_marker: "This Rift workspace is missing its `.rift` marker; run `rift init` to restore it", - cow_unavailable: - "Copy-on-write cloning is unavailable on this volume. Create a Dev Drive and move the project onto it", - in_use: "Another program is using this workspace. Close it and retry", } function parseOptions(value: Record): Options { diff --git a/plugins/opencode/test/strategy.test.ts b/plugins/opencode/test/strategy.test.ts index f352e01..8c1db31 100644 --- a/plugins/opencode/test/strategy.test.ts +++ b/plugins/opencode/test/strategy.test.ts @@ -92,10 +92,8 @@ describe("Rift workspace strategy", () => { await expect(create("missing_marker")).rejects.toThrow( "This Rift workspace is missing its `.rift` marker; run `rift init` to restore it", ) - await expect(create("cow_unavailable")).rejects.toThrow( - "Copy-on-write cloning is unavailable on this volume. Create a Dev Drive and move the project onto it", - ) - await expect(create("in_use")).rejects.toThrow("Another program is using this workspace. Close it and retry") + await expect(create("cow_unavailable")).rejects.toThrow("cow_unavailable at /project") + await expect(create("in_use")).rejects.toThrow("in_use at /project") await expect(create("hook_failed")).rejects.toThrow("hook_failed at /project") }) From 4b826ffc4ebe8e98a8ecf46191b66ecd83cb8ede Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:23:30 +0530 Subject: [PATCH 12/25] fix(cli): leave a sibling descendant before trashing it Removing a created workspace also trashes descendants, and those live in sibling storage. If the process cwd is inside one, move to the parent first. --- crates/cli/src/main.rs | 52 ++++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index c1fc21e..576463f 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -419,12 +419,7 @@ fn leave_trashed_directory( children: bool, ancestors: &[PathBuf], ) -> Result<()> { - let preserving = children || ancestors.is_empty(); - let descendants = if preserving { - manager.descendants(at)? - } else { - Vec::new() - }; + let descendants = manager.descendants(at)?; let Some(stay) = directory_outside_trash(cwd, at, children, ancestors, &descendants) else { return Ok(()); }; @@ -440,12 +435,9 @@ fn directory_outside_trash( descendants: &[PathBuf], ) -> Option { let preserving = children || ancestors.is_empty(); - let moving = if preserving { - descendants.iter().any(|path| cwd.starts_with(path)) - } else { - cwd.starts_with(at) - }; - if !moving { + let inside_at = !preserving && cwd.starts_with(at); + let inside_descendant = descendants.iter().any(|path| cwd.starts_with(path)); + if !inside_at && !inside_descendant { return None; } if preserving { @@ -537,6 +529,42 @@ mod tests { ); } + #[test] + fn remove_stands_in_the_parent_before_trashing_a_sibling_descendant() { + let parent = PathBuf::from("work").join("app"); + let child = PathBuf::from("work") + .join(".rifts") + .join("app") + .join("child"); + let grandchild = PathBuf::from("work") + .join(".rifts") + .join("app") + .join(".rifts") + .join("child") + .join("grandchild"); + + assert_eq!( + directory_outside_trash( + &grandchild.join("src"), + &child, + false, + std::slice::from_ref(&parent), + std::slice::from_ref(&grandchild), + ), + Some(parent.clone()) + ); + assert_eq!( + directory_outside_trash( + &parent, + &child, + false, + std::slice::from_ref(&parent), + std::slice::from_ref(&grandchild), + ), + None + ); + } + #[test] fn remove_stands_in_the_preserved_workspace_before_trashing_descendants() { let root = PathBuf::from("work").join("app"); From 52f6cb2466e5120b66a8005663aec346e14b41f9 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:11:25 +0530 Subject: [PATCH 13/25] feat(windows): probe ReFS block cloning and fail closed elsewhere Windows now uses RefsStrategy. init and create clone a multi-cluster probe file with FSCTL_DUPLICATE_EXTENTS_TO_FILE, check the bytes and write isolation, and delete the probe. On any other volume they fail with CowUnavailable naming the filesystem and pointing at Dev Drive. create also requires the source and the destination parent to share a volume serial number from FILE_ID_INFO. Directories and regular files are cloned. Reparse points and encrypted files are unsupported for now. The NTFS CI job sets RIFT_REQUIRE_UNSUPPORTED_WINDOWS_TESTS. --- .github/workflows/windows.yml | 2 + Cargo.lock | 1 + crates/core/Cargo.toml | 8 + crates/core/src/lib.rs | 2 + crates/core/src/strategy/mod.rs | 17 +- crates/core/src/strategy/refs.rs | 473 ++++++++++++++++++++ crates/core/src/windows_filesystem_tests.rs | 65 +++ 7 files changed, 563 insertions(+), 5 deletions(-) create mode 100644 crates/core/src/strategy/refs.rs create mode 100644 crates/core/src/windows_filesystem_tests.rs diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index fbae7de..4ee6be1 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -19,6 +19,8 @@ jobs: test: name: Windows tests (NTFS) runs-on: windows-latest + env: + RIFT_REQUIRE_UNSUPPORTED_WINDOWS_TESTS: "1" steps: - name: Check out repository uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index 8fd4f29..70071fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -716,6 +716,7 @@ dependencies = [ "toml", "ulid", "walkdir", + "windows-sys", ] [[package]] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 8f5eb94..abbb5e2 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -20,6 +20,14 @@ walkdir.workspace = true [target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies] git2 = { version = "0.20", default-features = false } +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Ioctl", +] } + [dev-dependencies] tempfile.workspace = true diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 82b2161..e3e1207 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -13,6 +13,8 @@ mod strategy; mod linux_filesystem_tests; #[cfg(all(test, target_os = "linux"))] mod test_support; +#[cfg(all(test, windows))] +mod windows_filesystem_tests; use id::RiftId; use name::RiftName; diff --git a/crates/core/src/strategy/mod.rs b/crates/core/src/strategy/mod.rs index 4450583..bfb8604 100644 --- a/crates/core/src/strategy/mod.rs +++ b/crates/core/src/strategy/mod.rs @@ -1,6 +1,8 @@ +#[cfg(any(test, not(any(target_os = "linux", target_os = "macos", windows))))] +use crate::Error; +#[cfg(test)] +use crate::filter::CopyFilter; use crate::{CopyMode, InitProgress, Result}; -#[cfg(any(test, not(any(target_os = "linux", target_os = "macos"))))] -use crate::{Error, filter::CopyFilter}; use std::fs; use std::io; use std::path::Path; @@ -13,6 +15,8 @@ mod btrfs; mod linux; #[cfg(target_os = "linux")] mod reflink; +#[cfg(windows)] +mod refs; pub(crate) trait Strategy { fn copy_directory(&self, from: &Path, to: &Path, mode: CopyMode) -> Result<()>; @@ -52,14 +56,17 @@ pub(crate) fn default_strategy() -> Box { #[cfg(target_os = "macos")] return Box::new(apfs::ApfsStrategy); - #[cfg(not(any(target_os = "linux", target_os = "macos")))] + #[cfg(windows)] + return Box::new(refs::RefsStrategy); + + #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] return Box::new(UnsupportedStrategy); } -#[cfg(not(any(target_os = "linux", target_os = "macos")))] +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] struct UnsupportedStrategy; -#[cfg(not(any(target_os = "linux", target_os = "macos")))] +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] impl Strategy for UnsupportedStrategy { fn copy_directory(&self, _from: &Path, _to: &Path, _mode: CopyMode) -> Result<()> { Err(Error::CowUnavailable( diff --git a/crates/core/src/strategy/refs.rs b/crates/core/src/strategy/refs.rs new file mode 100644 index 0000000..ff84bd2 --- /dev/null +++ b/crates/core/src/strategy/refs.rs @@ -0,0 +1,473 @@ +use super::{Strategy, StrategyInit, create_destination}; +use crate::{CopyMode, Error, InitProgress, Result, filter::CopyFilter}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write}; +use std::os::windows::fs::OpenOptionsExt; +use std::os::windows::io::AsRawHandle; +use std::path::Path; +use std::ptr::{null, null_mut}; +use walkdir::WalkDir; +use windows_sys::Win32::Foundation::{ERROR_BLOCK_TOO_MANY_REFERENCES, GENERIC_READ, MAX_PATH}; +use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_ENCRYPTED, FILE_ATTRIBUTE_REPARSE_POINT, + FILE_ATTRIBUTE_SPARSE_FILE, FILE_BASIC_INFO, FILE_FLAG_BACKUP_SEMANTICS, + FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FILE_INFO_BY_HANDLE_CLASS, FILE_READ_ATTRIBUTES, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_STANDARD_INFO, FileBasicInfo, + FileIdInfo, FileStandardInfo, GetFileInformationByHandleEx, GetVolumeInformationByHandleW, +}; +use windows_sys::Win32::System::IO::DeviceIoControl; +use windows_sys::Win32::System::Ioctl::{ + DUPLICATE_EXTENTS_DATA, FILE_SET_SPARSE_BUFFER, FSCTL_DUPLICATE_EXTENTS_TO_FILE, + FSCTL_GET_INTEGRITY_INFORMATION, FSCTL_GET_INTEGRITY_INFORMATION_BUFFER, + FSCTL_SET_INTEGRITY_INFORMATION, FSCTL_SET_INTEGRITY_INFORMATION_BUFFER, FSCTL_SET_SPARSE, +}; + +pub(super) struct RefsStrategy; + +impl Strategy for RefsStrategy { + fn copy_directory(&self, from: &Path, to: &Path, mode: CopyMode) -> Result<()> { + let destination_parent = same_volume_parent(from, to)?; + verify_block_cloning(destination_parent)?; + create_destination(to)?; + clone_tree(from, to, (mode == CopyMode::Filtered).then_some(CopyFilter)) + } + + fn initialize_directory( + &self, + path: &Path, + _progress: &mut dyn FnMut(InitProgress), + ) -> Result { + verify_block_cloning(path)?; + Ok(StrategyInit::AlreadyNative) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EntryKind { + Directory, + File, + Unsupported, +} + +impl EntryKind { + fn classify(attributes: u32) -> Self { + if attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + Self::Unsupported + } else if attributes & FILE_ATTRIBUTE_DIRECTORY != 0 { + Self::Directory + } else if attributes & FILE_ATTRIBUTE_ENCRYPTED != 0 { + Self::Unsupported + } else { + Self::File + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct FileId { + volume: u64, + file: [u8; 16], +} + +impl FileId { + fn of(file: &File) -> io::Result { + let id: FILE_ID_INFO = information(file)?; + Ok(Self { + volume: id.VolumeSerialNumber, + file: id.FileId.Identifier, + }) + } +} + +struct SourceFile<'a> { + path: &'a Path, + file: File, + basic: FILE_BASIC_INFO, + size: u64, +} + +impl<'a> SourceFile<'a> { + fn new(path: &'a Path, file: File, basic: FILE_BASIC_INFO) -> io::Result { + let standard: FILE_STANDARD_INFO = information(&file)?; + Ok(Self { + path, + file, + basic, + size: standard.EndOfFile as u64, + }) + } +} + +fn same_volume_parent<'a>(from: &Path, to: &'a Path) -> Result<&'a Path> { + let destination_parent = to + .parent() + .ok_or_else(|| Error::Path(format!("destination has no parent: {}", to.display())))?; + if volume(from)? == volume(destination_parent)? { + Ok(destination_parent) + } else { + Err(Error::CowUnavailable(format!( + "Rift needs the source and the destination on the same ReFS volume, but {} and {} are on different volumes", + from.display(), + destination_parent.display() + ))) + } +} + +fn volume(path: &Path) -> io::Result { + Ok(FileId::of(&open(path, FILE_READ_ATTRIBUTES)?)?.volume) +} + +fn clone_tree(from: &Path, to: &Path, filter: Option) -> Result<()> { + for entry in WalkDir::new(from) + .min_depth(1) + .follow_links(false) + .into_iter() + .filter_entry(|entry| { + filter.is_none_or(|filter| { + entry + .path() + .strip_prefix(from) + .map_or(true, |path| !filter.excludes(path)) + }) + }) + { + let entry = entry?; + let source = entry.path(); + let destination = to.join( + source + .strip_prefix(from) + .map_err(|error| Error::Path(error.to_string()))?, + ); + let file = open(source, GENERIC_READ)?; + let basic: FILE_BASIC_INFO = information(&file)?; + match EntryKind::classify(basic.FileAttributes) { + EntryKind::Directory => fs::create_dir(&destination)?, + EntryKind::File => clone_file(&SourceFile::new(source, file, basic)?, &destination)?, + EntryKind::Unsupported => return Err(Error::UnsupportedEntry(source.to_path_buf())), + } + } + Ok(()) +} + +fn clone_file(source: &SourceFile, destination: &Path) -> Result<()> { + let target = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(destination)?; + if source.size == 0 { + return Ok(()); + } + // A sparse destination keeps ReFS from allocating clusters for the length set below, and a + // sparse source can only be cloned into a sparse destination. + set_sparse(&target, true) + .map_err(|error| cow_unavailable("prepare the clone of", source.path, error))?; + let integrity = integrity(&source.file) + .map_err(|error| cow_unavailable("read the integrity settings of", source.path, error))?; + match_integrity(&integrity, &target); + target.set_len(source.size)?; + let cluster_size = u64::from(integrity.ClusterSizeInBytes); + if !cluster_size.is_power_of_two() || cluster_size > CLONE_CHUNK { + return Err(Error::CowUnavailable(format!( + "{} reports an unusable cluster size of {cluster_size} bytes", + source.path.display() + ))); + } + for (offset, length) in clone_regions(source.size, cluster_size) { + duplicate_extents(&source.file, &target, offset, length) + .map_err(|error| cow_unavailable("clone", source.path, error))?; + } + if source.basic.FileAttributes & FILE_ATTRIBUTE_SPARSE_FILE == 0 { + set_sparse(&target, false)?; + } + Ok(()) +} + +// Each cloned region must stay under 4 GiB: +// https://learn.microsoft.com/windows-server/storage/refs/block-cloning +const CLONE_CHUNK: u64 = 1 << 30; + +// ReFS clones whole clusters, so the last region runs past the end of file to the next cluster +// boundary. The destination keeps the exact length set before cloning. +fn clone_regions(size: u64, cluster_size: u64) -> impl Iterator { + let end = size.div_ceil(cluster_size) * cluster_size; + (0..end) + .step_by(CLONE_CHUNK as usize) + .map(move |offset| (offset, CLONE_CHUNK.min(end - offset))) +} + +fn duplicate_extents(source: &File, target: &File, offset: u64, length: u64) -> io::Result<()> { + let region = DUPLICATE_EXTENTS_DATA { + FileHandle: source.as_raw_handle(), + SourceFileOffset: offset as i64, + TargetFileOffset: offset as i64, + ByteCount: length as i64, + }; + control(target, FSCTL_DUPLICATE_EXTENTS_TO_FILE, ®ion, &mut ()).map(drop) +} + +fn set_sparse(file: &File, sparse: bool) -> io::Result<()> { + control( + file, + FSCTL_SET_SPARSE, + &FILE_SET_SPARSE_BUFFER { SetSparse: sparse }, + &mut (), + ) + .map(drop) +} + +fn integrity(file: &File) -> io::Result { + let mut integrity = FSCTL_GET_INTEGRITY_INFORMATION_BUFFER::default(); + control(file, FSCTL_GET_INTEGRITY_INFORMATION, &(), &mut integrity)?; + Ok(integrity) +} + +// Dev Drives refuse to change integrity settings (reflink-copy#27). A mismatch that survives +// this fails the clone itself. +fn match_integrity(source: &FSCTL_GET_INTEGRITY_INFORMATION_BUFFER, target: &File) { + let _ = integrity(target).and_then(|current| { + if (current.ChecksumAlgorithm, current.Flags) == (source.ChecksumAlgorithm, source.Flags) { + return Ok(()); + } + let settings = FSCTL_SET_INTEGRITY_INFORMATION_BUFFER { + ChecksumAlgorithm: source.ChecksumAlgorithm, + Reserved: 0, + Flags: source.Flags, + }; + control(target, FSCTL_SET_INTEGRITY_INFORMATION, &settings, &mut ()).map(drop) + }); +} + +fn cow_unavailable(action: &str, path: &Path, error: io::Error) -> Error { + let reason = if error.raw_os_error() == Some(ERROR_BLOCK_TOO_MANY_REFERENCES as i32) { + "ReFS allows at most 8,175 clones of the same data, and this file's data has reached that limit".to_owned() + } else { + error.to_string() + }; + Error::CowUnavailable(format!("failed to {action} {}: {reason}", path.display())) +} + +fn verify_block_cloning(directory: &Path) -> Result<()> { + let operation_id = ulid::Ulid::new(); + let source = directory.join(format!(".rift-refs-probe-{operation_id}")); + let clone = directory.join(format!(".rift-refs-probe-clone-{operation_id}")); + let result = probe(&source, &clone).map_err(|error| match error { + Error::CowUnavailable(reason) => unsupported_volume(directory, &reason), + error => error, + }); + let cleanup = [&source, &clone] + .into_iter() + .filter(|path| path.exists()) + .try_for_each(fs::remove_file); + result.and(cleanup.map_err(Error::from)) +} + +// Several clusters at either ReFS cluster size (4 KiB or 64 KiB) plus a partial one. A probe that +// fits in one partial cluster proves nothing, because ReFS shares no storage for a file's tail. +const PROBE_SIZE: usize = 3 * 65_536 + 1; + +fn probe(source: &Path, clone: &Path) -> Result<()> { + let contents = (0..PROBE_SIZE) + .map(|index| (index % 251) as u8 + 1) + .collect::>(); + fs::write(source, &contents)?; + let file = open(source, GENERIC_READ)?; + let basic = information(&file)?; + let source_file = SourceFile::new(source, file, basic)?; + clone_file(&source_file, clone)?; + if !same_contents(&source_file.file, &File::open(clone)?)? { + return Err(Error::CowUnavailable( + "the probe clone differs from its source".into(), + )); + } + OpenOptions::new() + .write(true) + .open(clone)? + .write_all(b"rift")?; + if fs::read(source)? != contents { + return Err(Error::CowUnavailable( + "writing to the probe clone changed its source".into(), + )); + } + Ok(()) +} + +fn unsupported_volume(directory: &Path, reason: &str) -> Error { + let filesystem = filesystem_name(directory) + .unwrap_or_else(|error| format!("an unidentified filesystem ({error})")); + Error::CowUnavailable(format!( + "{} is on {filesystem}, where Rift cannot clone files ({reason}). Rift on Windows needs a ReFS volume such as a Dev Drive (https://learn.microsoft.com/windows/dev-drive/); move the project onto one", + directory.display() + )) +} + +fn filesystem_name(path: &Path) -> io::Result { + let directory = open(path, FILE_READ_ATTRIBUTES)?; + let mut name = [0_u16; MAX_PATH as usize + 1]; + // SAFETY: `name` is writable for its full length, and the outputs Rift does not need are + // null with zero length. + let succeeded = unsafe { + GetVolumeInformationByHandleW( + directory.as_raw_handle(), + null_mut(), + 0, + null_mut(), + null_mut(), + null_mut(), + name.as_mut_ptr(), + name.len() as u32, + ) + }; + if succeeded == 0 { + return Err(io::Error::last_os_error()); + } + let length = name + .iter() + .position(|unit| *unit == 0) + .unwrap_or(name.len()); + Ok(String::from_utf16_lossy(&name[..length])) +} + +fn same_contents(mut left: &File, mut right: &File) -> io::Result { + left.seek(SeekFrom::Start(0))?; + right.seek(SeekFrom::Start(0))?; + let mut left = BufReader::with_capacity(1 << 16, left); + let mut right = BufReader::with_capacity(1 << 16, right); + loop { + let length = { + let (ours, theirs) = (left.fill_buf()?, right.fill_buf()?); + let length = ours.len().min(theirs.len()); + if length == 0 { + return Ok(ours.is_empty() && theirs.is_empty()); + } + if ours[..length] != theirs[..length] { + return Ok(false); + } + length + }; + left.consume(length); + right.consume(length); + } +} + +fn open(path: &Path, access: u32) -> io::Result { + OpenOptions::new() + .access_mode(access) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS) + .open(path) +} + +trait Information: Default { + const CLASS: FILE_INFO_BY_HANDLE_CLASS; +} + +impl Information for FILE_BASIC_INFO { + const CLASS: FILE_INFO_BY_HANDLE_CLASS = FileBasicInfo; +} + +impl Information for FILE_STANDARD_INFO { + const CLASS: FILE_INFO_BY_HANDLE_CLASS = FileStandardInfo; +} + +impl Information for FILE_ID_INFO { + const CLASS: FILE_INFO_BY_HANDLE_CLASS = FileIdInfo; +} + +fn information(file: &File) -> io::Result { + let mut value = T::default(); + // SAFETY: `T::CLASS` names the information class whose reply has `T`'s layout, and `value` + // is writable for `size_of::()` bytes. + let succeeded = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + T::CLASS, + (&raw mut value).cast(), + size_of::() as u32, + ) + }; + if succeeded == 0 { + return Err(io::Error::last_os_error()); + } + Ok(value) +} + +/// Output buffers that `DeviceIoControl` may fill with any bytes. +trait Pod {} + +impl Pod for () {} + +impl Pod for [u8] {} + +impl Pod for FSCTL_GET_INTEGRITY_INFORMATION_BUFFER {} + +fn control( + file: &File, + code: u32, + input: &I, + output: &mut O, +) -> io::Result { + let input_size = size_of_val(input); + let output_size = size_of_val(output); + let mut returned = 0; + // SAFETY: `input` and `output` are live for their full sizes, `O` accepts any bytes, and the + // handle was opened without overlapped I/O, so the call finishes before it returns. + let succeeded = unsafe { + DeviceIoControl( + file.as_raw_handle(), + code, + if input_size == 0 { + null() + } else { + (input as *const I).cast() + }, + input_size as u32, + if output_size == 0 { + null_mut() + } else { + (output as *mut O).cast() + }, + output_size as u32, + &mut returned, + null_mut(), + ) + }; + if succeeded == 0 { + return Err(io::Error::last_os_error()); + } + Ok(returned as usize) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entry_kind_follows_file_attributes() { + assert_eq!(EntryKind::classify(0x20), EntryKind::File); + assert_eq!(EntryKind::classify(0x21), EntryKind::File); + assert_eq!(EntryKind::classify(0x10), EntryKind::Directory); + assert_eq!(EntryKind::classify(0x4010), EntryKind::Directory); + assert_eq!(EntryKind::classify(0x4020), EntryKind::Unsupported); + assert_eq!(EntryKind::classify(0x420), EntryKind::Unsupported); + assert_eq!(EntryKind::classify(0x410), EntryKind::Unsupported); + } + + #[test] + fn clone_regions_cover_whole_clusters_in_bounded_chunks() { + assert_eq!(clone_regions(0, 4096).collect::>(), []); + assert_eq!(clone_regions(1, 4096).collect::>(), [(0, 4096)]); + assert_eq!( + clone_regions(65_536, 65_536).collect::>(), + [(0, 65_536)] + ); + assert_eq!( + clone_regions(196_609, 4096).collect::>(), + [(0, 200_704)] + ); + assert_eq!( + clone_regions((1 << 30) + 1, 65_536).collect::>(), + [(0, 1 << 30), (1 << 30, 65_536)] + ); + } +} diff --git a/crates/core/src/windows_filesystem_tests.rs b/crates/core/src/windows_filesystem_tests.rs new file mode 100644 index 0000000..5157702 --- /dev/null +++ b/crates/core/src/windows_filesystem_tests.rs @@ -0,0 +1,65 @@ +use crate::strategy::default_strategy; +use crate::{CopyMode, Error, Manager}; +use std::fs; +use std::path::Path; +use tempfile::{Builder, TempDir}; + +#[test] +fn production_unsupported_windows_volume_rejects_management() { + if std::env::var_os("RIFT_REQUIRE_UNSUPPORTED_WINDOWS_TESTS").is_none() { + return; + } + let temp = current_volume_temp(); + let source = temp.path().join("source"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("file.txt"), "hello").unwrap(); + let mut manager = Manager::open(temp.path().join("registry.sqlite")).unwrap(); + + match manager.init(&source) { + Err(Error::CowUnavailable(message)) => assert_dev_drive_guidance(&message), + result => panic!("init should fail closed on NTFS, got {result:?}"), + } + assert!(!source.join(".rift").exists()); + assert_no_probe_files(&source); + assert!(manager.registry.active_paths().unwrap().is_empty()); + + let storage = temp.path().join("storage"); + fs::create_dir(&storage).unwrap(); + let destination = storage.join("child"); + match default_strategy().copy_directory(&source, &destination, CopyMode::Filtered) { + Err(Error::CowUnavailable(message)) => assert_dev_drive_guidance(&message), + result => panic!("copy should fail closed on NTFS, got {result:?}"), + } + assert!(!destination.exists()); + assert_no_probe_files(&storage); +} + +fn current_volume_temp() -> TempDir { + Builder::new() + .prefix(".rift-manager-test-") + .tempdir_in(std::env::current_dir().unwrap()) + .unwrap() +} + +fn assert_dev_drive_guidance(message: &str) { + assert!( + message.contains(" is on NTFS,") + && message.contains("needs a ReFS volume such as a Dev Drive") + && message.contains("https://learn.microsoft.com/windows/dev-drive/") + && message.contains("move the project onto one"), + "unexpected guidance: {message}" + ); +} + +fn assert_no_probe_files(path: &Path) { + let leftovers = fs::read_dir(path) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .filter(|name| name.to_string_lossy().starts_with(".rift-refs-probe")) + .collect::>(); + assert!( + leftovers.is_empty(), + "probe files left in {}: {leftovers:?}", + path.display() + ); +} From fbdc7748aeac705212be4d84fc23384b1b598498 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:15:27 +0530 Subject: [PATCH 14/25] ci(windows): test the ReFS strategy on ReFS and Dev Drive volumes Adds a ReFS job on windows-2022 (diskpart partition) and a Dev Drive job on windows-2025 (samypr100/setup-dev-drive, 10 GB, trusted). scripts/ci/windows-refs.ps1 checks the volume is ReFS, prints its cluster size and Dev Drive state, and copies the checkout onto it, so tests create their temporary directories there. With RIFT_REQUIRE_REFS_TESTS set, the probe must pass on ReFS and leave nothing behind, and a create must clone a multi-cluster file with shared extents, isolate writes in both directions, and copy empty and nested files. All Windows jobs run with --no-fail-fast and --show-output so every test binary reports and gated tests show what they observed. --- .github/workflows/windows.yml | 56 ++++++++++- crates/core/src/lib.rs | 2 +- crates/core/src/strategy/refs.rs | 29 ++++++ crates/core/src/test_support/mod.rs | 3 + .../core/src/test_support/windows_extents.rs | 59 ++++++++++++ crates/core/src/windows_filesystem_tests.rs | 94 ++++++++++++++++++- scripts/ci/windows-refs.ps1 | 41 ++++++++ 7 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 crates/core/src/test_support/windows_extents.rs create mode 100644 scripts/ci/windows-refs.ps1 diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 4ee6be1..d9e3107 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -32,4 +32,58 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Run tests - run: cargo test --workspace --locked + run: cargo test --workspace --locked --no-fail-fast -- --show-output + + refs: + name: Windows tests (ReFS) + runs-on: windows-2022 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}\target + RIFT_REQUIRE_REFS_TESTS: "1" + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Create a ReFS volume and copy the checkout onto it + run: ./scripts/ci/windows-refs.ps1 -Volume E -CreatePartition + + - name: Run tests on ReFS + working-directory: ${{ env.RIFT_CHECKOUT }} + run: cargo test --package rift --package rift-cli --locked --no-fail-fast -- --show-output + + devdrive: + name: Windows tests (Dev Drive) + runs-on: windows-2025 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}\target + RIFT_REQUIRE_REFS_TESTS: "1" + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Create a Dev Drive + uses: samypr100/setup-dev-drive@v4 + with: + drive-size: 10GB + drive-format: ReFS + native-dev-drive: true + trusted-dev-drive: true + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Copy the checkout onto the Dev Drive + run: ./scripts/ci/windows-refs.ps1 -Volume $env:DEV_DRIVE + + - name: Run tests on the Dev Drive + working-directory: ${{ env.RIFT_CHECKOUT }} + run: cargo test --package rift --package rift-cli --locked --no-fail-fast -- --show-output diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index e3e1207..f4971ee 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -11,7 +11,7 @@ mod strategy; #[cfg(all(test, target_os = "linux"))] mod linux_filesystem_tests; -#[cfg(all(test, target_os = "linux"))] +#[cfg(all(test, any(target_os = "linux", windows)))] mod test_support; #[cfg(all(test, windows))] mod windows_filesystem_tests; diff --git a/crates/core/src/strategy/refs.rs b/crates/core/src/strategy/refs.rs index ff84bd2..36f270f 100644 --- a/crates/core/src/strategy/refs.rs +++ b/crates/core/src/strategy/refs.rs @@ -441,6 +441,35 @@ fn control( #[cfg(test)] mod tests { use super::*; + use tempfile::{Builder, TempDir}; + + fn current_volume_temp() -> TempDir { + Builder::new() + .prefix(".rift-core-test-") + .tempdir_in(std::env::current_dir().unwrap()) + .unwrap() + } + + #[test] + fn refs_integration_environment_is_available() { + if std::env::var_os("RIFT_REQUIRE_REFS_TESTS").is_none() { + return; + } + let temp = current_volume_temp(); + let filesystem = filesystem_name(temp.path()).unwrap(); + assert_eq!(filesystem, "ReFS"); + verify_block_cloning(temp.path()).unwrap(); + assert_eq!( + fs::read_dir(temp.path()).unwrap().count(), + 0, + "the probe left files in {}", + temp.path().display() + ); + println!( + "block cloning probe passed on {filesystem} at {}", + temp.path().display() + ); + } #[test] fn entry_kind_follows_file_attributes() { diff --git a/crates/core/src/test_support/mod.rs b/crates/core/src/test_support/mod.rs index b77db02..9a4cead 100644 --- a/crates/core/src/test_support/mod.rs +++ b/crates/core/src/test_support/mod.rs @@ -1 +1,4 @@ +#[cfg(target_os = "linux")] pub(crate) mod linux_extents; +#[cfg(windows)] +pub(crate) mod windows_extents; diff --git a/crates/core/src/test_support/windows_extents.rs b/crates/core/src/test_support/windows_extents.rs new file mode 100644 index 0000000..d9ba566 --- /dev/null +++ b/crates/core/src/test_support/windows_extents.rs @@ -0,0 +1,59 @@ +use std::fs::File; +use std::io; +use std::os::windows::io::AsRawHandle; +use std::path::Path; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::ERROR_MORE_DATA; +use windows_sys::Win32::System::IO::DeviceIoControl; +use windows_sys::Win32::System::Ioctl::{ + FSCTL_GET_RETRIEVAL_POINTERS_AND_REFCOUNT, STARTING_VCN_INPUT_BUFFER, +}; + +pub(crate) fn assert_shared_extents(source: &Path, clone: &Path) { + for path in [source, clone] { + let counts = reference_counts(path).unwrap(); + assert!( + counts.iter().any(|count| *count > 1), + "{} has no extent shared with another file: reference counts {counts:?}", + path.display() + ); + } +} + +fn reference_counts(path: &Path) -> io::Result> { + let file = File::open(path)?; + let input = STARTING_VCN_INPUT_BUFFER { StartingVcn: 0 }; + let mut output = vec![0_u64; 4096]; + let mut returned = 0; + // SAFETY: `input` and `output` live for the call with the sizes passed, and the handle is + // synchronous, so the call finishes before it returns. + let succeeded = unsafe { + DeviceIoControl( + file.as_raw_handle(), + FSCTL_GET_RETRIEVAL_POINTERS_AND_REFCOUNT, + (&raw const input).cast(), + size_of::() as u32, + output.as_mut_ptr().cast(), + (output.len() * size_of::()) as u32, + &mut returned, + null_mut(), + ) + }; + if succeeded == 0 { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_MORE_DATA as i32) { + return Err(error); + } + } + // RETRIEVAL_POINTERS_AND_REFCOUNT_BUFFER: a u32 extent count, the starting VCN, then + // extents of { NextVcn: i64, Lcn: i64, ReferenceCount: u32 } padded to three words. + let extents = (output[0] & u64::from(u32::MAX)) as usize; + Ok(output[2..] + .as_chunks::<3>() + .0 + .iter() + .take(extents) + .filter(|[_, lcn, _]| *lcn as i64 != -1) + .map(|[_, _, count]| *count as u32) + .collect()) +} diff --git a/crates/core/src/windows_filesystem_tests.rs b/crates/core/src/windows_filesystem_tests.rs index 5157702..7f19bb9 100644 --- a/crates/core/src/windows_filesystem_tests.rs +++ b/crates/core/src/windows_filesystem_tests.rs @@ -1,9 +1,54 @@ use crate::strategy::default_strategy; -use crate::{CopyMode, Error, Manager}; -use std::fs; +use crate::test_support::windows_extents::assert_shared_extents; +use crate::{CopyMode, Create, Error, InitOutcome, Manager}; +use std::fs::{self, OpenOptions}; +use std::io::{Seek, SeekFrom, Write}; use std::path::Path; +use std::time::Instant; use tempfile::{Builder, TempDir}; +const LARGE_FILE_SIZE: usize = (1 << 20) + 123; + +#[test] +fn production_refs_volume_round_trip() { + if !requires_refs_tests() { + return; + } + let temp = current_volume_temp(); + let source = temp.path().join("source"); + fs::create_dir_all(source.join("nested/deeper")).unwrap(); + fs::write(source.join("empty.txt"), "").unwrap(); + fs::write(source.join("nested/deeper/leaf.txt"), "leaf").unwrap(); + let large = pseudo_random(LARGE_FILE_SIZE, 0x5eed); + fs::write(source.join("large.bin"), &large).unwrap(); + let mut manager = Manager::open(temp.path().join("registry.sqlite")).unwrap(); + + assert_eq!(manager.init(&source).unwrap(), InitOutcome::Registered); + assert_no_probe_files(&source); + let started = Instant::now(); + let child = manager + .create(Create::new(source.clone()).named("child")) + .unwrap(); + let elapsed = started.elapsed(); + assert_no_probe_files(child.parent().unwrap()); + + assert_eq!(fs::read(child.join("empty.txt")).unwrap(), b""); + assert_eq!( + fs::read_to_string(child.join("nested/deeper/leaf.txt")).unwrap(), + "leaf" + ); + assert!( + fs::read(child.join("large.bin")).unwrap() == large, + "large.bin differs in the clone" + ); + assert_shared_extents(&source.join("large.bin"), &child.join("large.bin")); + assert_isolated(&source.join("large.bin"), &child.join("large.bin")); + println!( + "refs round trip: created {} in {elapsed:?}", + child.display() + ); +} + #[test] fn production_unsupported_windows_volume_rejects_management() { if std::env::var_os("RIFT_REQUIRE_UNSUPPORTED_WINDOWS_TESTS").is_none() { @@ -16,7 +61,10 @@ fn production_unsupported_windows_volume_rejects_management() { let mut manager = Manager::open(temp.path().join("registry.sqlite")).unwrap(); match manager.init(&source) { - Err(Error::CowUnavailable(message)) => assert_dev_drive_guidance(&message), + Err(Error::CowUnavailable(message)) => { + assert_dev_drive_guidance(&message); + println!("init on NTFS: {message}"); + } result => panic!("init should fail closed on NTFS, got {result:?}"), } assert!(!source.join(".rift").exists()); @@ -34,6 +82,10 @@ fn production_unsupported_windows_volume_rejects_management() { assert_no_probe_files(&storage); } +fn requires_refs_tests() -> bool { + std::env::var_os("RIFT_REQUIRE_REFS_TESTS").is_some() +} + fn current_volume_temp() -> TempDir { Builder::new() .prefix(".rift-manager-test-") @@ -51,6 +103,42 @@ fn assert_dev_drive_guidance(message: &str) { ); } +fn assert_isolated(source: &Path, clone: &Path) { + let original = fs::read(source).unwrap(); + overwrite(source, 4096, b"parent mutation"); + assert!( + fs::read(clone).unwrap() == original, + "writing {} changed its clone", + source.display() + ); + overwrite(clone, 8192, b"child mutation"); + let mut mutated = original; + mutated[4096..4096 + 15].copy_from_slice(b"parent mutation"); + assert!( + fs::read(source).unwrap() == mutated, + "writing {} changed its source", + clone.display() + ); +} + +fn overwrite(path: &Path, offset: u64, bytes: &[u8]) { + let mut file = OpenOptions::new().write(true).open(path).unwrap(); + file.seek(SeekFrom::Start(offset)).unwrap(); + file.write_all(bytes).unwrap(); +} + +fn pseudo_random(length: usize, mut state: u64) -> Vec { + let mut bytes = Vec::with_capacity(length + 8); + while bytes.len() < length { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + bytes.extend_from_slice(&state.to_le_bytes()); + } + bytes.truncate(length); + bytes +} + fn assert_no_probe_files(path: &Path) { let leftovers = fs::read_dir(path) .unwrap() diff --git a/scripts/ci/windows-refs.ps1 b/scripts/ci/windows-refs.ps1 new file mode 100644 index 0000000..5bf382d --- /dev/null +++ b/scripts/ci/windows-refs.ps1 @@ -0,0 +1,41 @@ +param( + [Parameter(Mandatory = $true)] + [string] $Volume, + [switch] $CreatePartition +) + +$ErrorActionPreference = 'Stop' +$letter = $Volume.TrimEnd('\').TrimEnd(':') + +function Fail([string] $Message) { + Write-Output "::error::Windows filesystem fixture failed: $Message" + exit 1 +} + +if ($CreatePartition) { + Write-Output "==> create a ReFS partition at ${letter}:" + $script = Join-Path $env:RUNNER_TEMP 'rift-refs-diskpart.txt' + @( + 'select volume C:' + 'shrink desired=10240' + 'create partition primary' + 'format quick fs=refs label=RiftReFS' + "assign letter=$letter" + ) | Set-Content -Path $script -Encoding ascii + diskpart /s $script + if ($LASTEXITCODE -ne 0) { Fail "diskpart exited with $LASTEXITCODE" } +} + +Write-Output "==> volume ${letter}:" +$info = Get-Volume -DriveLetter $letter +$info | Format-List DriveLetter, FileSystem, FileSystemLabel, AllocationUnitSize, Size, SizeRemaining | Out-String | Write-Output +fsutil fsinfo volumeinfo "${letter}:" +fsutil devdrv query "${letter}:" +if ($info.FileSystem -ne 'ReFS') { Fail "${letter}: is $($info.FileSystem), expected ReFS" } + +Write-Output "==> copy the checkout" +$checkout = "${letter}:\rift" +robocopy $env:GITHUB_WORKSPACE $checkout /E /XD target /NFL /NDL /NJH /NJS /NP +if ($LASTEXITCODE -ge 8) { Fail "robocopy exited with $LASTEXITCODE" } +"RIFT_CHECKOUT=$checkout" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 +exit 0 From f2f33178b4123e3553303cdf4f16cfdf139882a5 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:27:33 +0530 Subject: [PATCH 15/25] feat(windows): copy metadata, links, streams, and hard links Each walked entry is classified once from its attributes and reparse tag. Symlinks are recreated from their target text, with a Developer Mode hint when Windows refuses. Junctions are recreated from their raw reparse data and never descended into. Other reparse points (cloud placeholders, dedup, app execution aliases) and encrypted files are UnsupportedEntry. Files keep their creation, last-access, and last-write times and their settable attributes, applied last so read-only lands after every write. Git for Windows reads creation time as ctime, so a clone keeps the index's stat data valid. Directories get theirs deepest-first after the walk. Named streams are copied by value. Hard links are keyed by the 128-bit FILE_ID_INFO id and recreated against the first clone. The ReFS round trip now covers a Git repository with dirty, staged, untracked, and ignored files, read-only objects, a hidden file, a stream, a hard-linked pair, file and directory symlinks, junctions inside and outside the tree, a Unicode name, and a path past 260 characters. --- crates/core/Cargo.toml | 1 + crates/core/src/strategy/refs.rs | 324 ++++++++++++++++-- crates/core/src/windows_filesystem_tests.rs | 358 +++++++++++++++++++- 3 files changed, 638 insertions(+), 45 deletions(-) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index abbb5e2..487ef1d 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -26,6 +26,7 @@ windows-sys = { version = "0.61", features = [ "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Ioctl", + "Win32_System_SystemServices", ] } [dev-dependencies] diff --git a/crates/core/src/strategy/refs.rs b/crates/core/src/strategy/refs.rs index 36f270f..de95def 100644 --- a/crates/core/src/strategy/refs.rs +++ b/crates/core/src/strategy/refs.rs @@ -1,25 +1,39 @@ use super::{Strategy, StrategyInit, create_destination}; use crate::{CopyMode, Error, InitProgress, Result, filter::CopyFilter}; +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; use std::fs::{self, File, OpenOptions}; use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write}; -use std::os::windows::fs::OpenOptionsExt; +use std::os::windows::ffi::OsStringExt; +use std::os::windows::fs::{OpenOptionsExt, symlink_dir, symlink_file}; use std::os::windows::io::AsRawHandle; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::ptr::{null, null_mut}; use walkdir::WalkDir; -use windows_sys::Win32::Foundation::{ERROR_BLOCK_TOO_MANY_REFERENCES, GENERIC_READ, MAX_PATH}; +use windows_sys::Win32::Foundation::{ + ERROR_BLOCK_TOO_MANY_REFERENCES, ERROR_HANDLE_EOF, ERROR_INSUFFICIENT_BUFFER, ERROR_MORE_DATA, + ERROR_PRIVILEGE_NOT_HELD, GENERIC_READ, GENERIC_WRITE, MAX_PATH, +}; use windows_sys::Win32::Storage::FileSystem::{ - FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_ENCRYPTED, FILE_ATTRIBUTE_REPARSE_POINT, - FILE_ATTRIBUTE_SPARSE_FILE, FILE_BASIC_INFO, FILE_FLAG_BACKUP_SEMANTICS, - FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FILE_INFO_BY_HANDLE_CLASS, FILE_READ_ATTRIBUTES, - FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_STANDARD_INFO, FileBasicInfo, - FileIdInfo, FileStandardInfo, GetFileInformationByHandleEx, GetVolumeInformationByHandleW, + FILE_ATTRIBUTE_ARCHIVE, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_ENCRYPTED, + FILE_ATTRIBUTE_HIDDEN, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_NOT_CONTENT_INDEXED, + FILE_ATTRIBUTE_READONLY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_SPARSE_FILE, + FILE_ATTRIBUTE_SYSTEM, FILE_ATTRIBUTE_TAG_INFO, FILE_ATTRIBUTE_TEMPORARY, FILE_BASIC_INFO, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, + FILE_INFO_BY_HANDLE_CLASS, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, + FILE_SHARE_WRITE, FILE_STANDARD_INFO, FILE_WRITE_ATTRIBUTES, FileAttributeTagInfo, + FileBasicInfo, FileIdInfo, FileStandardInfo, FileStreamInfo, GetFileInformationByHandleEx, + GetVolumeInformationByHandleW, MAXIMUM_REPARSE_DATA_BUFFER_SIZE, SetFileInformationByHandle, }; use windows_sys::Win32::System::IO::DeviceIoControl; use windows_sys::Win32::System::Ioctl::{ DUPLICATE_EXTENTS_DATA, FILE_SET_SPARSE_BUFFER, FSCTL_DUPLICATE_EXTENTS_TO_FILE, FSCTL_GET_INTEGRITY_INFORMATION, FSCTL_GET_INTEGRITY_INFORMATION_BUFFER, - FSCTL_SET_INTEGRITY_INFORMATION, FSCTL_SET_INTEGRITY_INFORMATION_BUFFER, FSCTL_SET_SPARSE, + FSCTL_GET_REPARSE_POINT, FSCTL_SET_INTEGRITY_INFORMATION, + FSCTL_SET_INTEGRITY_INFORMATION_BUFFER, FSCTL_SET_REPARSE_POINT, FSCTL_SET_SPARSE, +}; +use windows_sys::Win32::System::SystemServices::{ + IO_REPARSE_TAG_MOUNT_POINT, IO_REPARSE_TAG_SYMLINK, }; pub(super) struct RefsStrategy; @@ -46,14 +60,30 @@ impl Strategy for RefsStrategy { enum EntryKind { Directory, File, + Symlink { directory: bool }, + Junction, Unsupported, } impl EntryKind { - fn classify(attributes: u32) -> Self { + fn of(file: &File, attributes: u32) -> io::Result { + let reparse_tag = if attributes & FILE_ATTRIBUTE_REPARSE_POINT == 0 { + 0 + } else { + information::(file)?.ReparseTag + }; + Ok(Self::classify(attributes, reparse_tag)) + } + + fn classify(attributes: u32, reparse_tag: u32) -> Self { + let directory = attributes & FILE_ATTRIBUTE_DIRECTORY != 0; if attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { - Self::Unsupported - } else if attributes & FILE_ATTRIBUTE_DIRECTORY != 0 { + match reparse_tag { + IO_REPARSE_TAG_SYMLINK => Self::Symlink { directory }, + IO_REPARSE_TAG_MOUNT_POINT if directory => Self::Junction, + _ => Self::Unsupported, + } + } else if directory { Self::Directory } else if attributes & FILE_ATTRIBUTE_ENCRYPTED != 0 { Self::Unsupported @@ -84,6 +114,7 @@ struct SourceFile<'a> { file: File, basic: FILE_BASIC_INFO, size: u64, + links: u32, } impl<'a> SourceFile<'a> { @@ -94,6 +125,7 @@ impl<'a> SourceFile<'a> { file, basic, size: standard.EndOfFile as u64, + links: standard.NumberOfLinks, }) } } @@ -118,6 +150,9 @@ fn volume(path: &Path) -> io::Result { } fn clone_tree(from: &Path, to: &Path, filter: Option) -> Result<()> { + let root: FILE_BASIC_INFO = information(&open(from, FILE_READ_ATTRIBUTES)?)?; + let mut hard_links = HashMap::new(); + let mut directories = Vec::new(); for entry in WalkDir::new(from) .min_depth(1) .follow_links(false) @@ -140,12 +175,36 @@ fn clone_tree(from: &Path, to: &Path, filter: Option) -> Result<()> ); let file = open(source, GENERIC_READ)?; let basic: FILE_BASIC_INFO = information(&file)?; - match EntryKind::classify(basic.FileAttributes) { - EntryKind::Directory => fs::create_dir(&destination)?, - EntryKind::File => clone_file(&SourceFile::new(source, file, basic)?, &destination)?, + match EntryKind::of(&file, basic.FileAttributes)? { + EntryKind::Directory => { + fs::create_dir(&destination)?; + directories.push((basic, destination)); + } + EntryKind::File => { + let source = SourceFile::new(source, file, basic)?; + if source.links > 1 { + let id = FileId::of(&source.file)?; + if let Some(existing) = hard_links.get(&id) { + fs::hard_link(existing, &destination)?; + } else { + clone_file(&source, &destination)?; + hard_links.insert(id, destination); + } + } else { + clone_file(&source, &destination)?; + } + } + EntryKind::Symlink { directory } => { + copy_symlink(source, &basic, &destination, directory)?; + } + EntryKind::Junction => copy_junction(&file, &basic, &destination)?, EntryKind::Unsupported => return Err(Error::UnsupportedEntry(source.to_path_buf())), } } + for (basic, destination) in directories.into_iter().rev() { + apply_basic(&open(&destination, FILE_WRITE_ATTRIBUTES)?, &basic)?; + } + apply_basic(&open(to, FILE_WRITE_ATTRIBUTES)?, &root)?; Ok(()) } @@ -155,16 +214,22 @@ fn clone_file(source: &SourceFile, destination: &Path) -> Result<()> { .write(true) .create_new(true) .open(destination)?; - if source.size == 0 { - return Ok(()); + if source.size > 0 { + clone_data(source, &target)?; } + copy_streams(source, destination)?; + apply_basic(&target, &source.basic)?; + Ok(()) +} + +fn clone_data(source: &SourceFile, target: &File) -> Result<()> { // A sparse destination keeps ReFS from allocating clusters for the length set below, and a // sparse source can only be cloned into a sparse destination. - set_sparse(&target, true) + set_sparse(target, true) .map_err(|error| cow_unavailable("prepare the clone of", source.path, error))?; let integrity = integrity(&source.file) .map_err(|error| cow_unavailable("read the integrity settings of", source.path, error))?; - match_integrity(&integrity, &target); + match_integrity(&integrity, target); target.set_len(source.size)?; let cluster_size = u64::from(integrity.ClusterSizeInBytes); if !cluster_size.is_power_of_two() || cluster_size > CLONE_CHUNK { @@ -174,11 +239,177 @@ fn clone_file(source: &SourceFile, destination: &Path) -> Result<()> { ))); } for (offset, length) in clone_regions(source.size, cluster_size) { - duplicate_extents(&source.file, &target, offset, length) + duplicate_extents(&source.file, target, offset, length) .map_err(|error| cow_unavailable("clone", source.path, error))?; } if source.basic.FileAttributes & FILE_ATTRIBUTE_SPARSE_FILE == 0 { - set_sparse(&target, false)?; + set_sparse(target, false)?; + } + Ok(()) +} + +// Streams are copied by value. Cloning into an alternate data stream crashed ReFS before a +// hotfix (microsoft/CopyOnWrite#24). +fn copy_streams(source: &SourceFile, destination: &Path) -> Result<()> { + for stream in stream_names(&source.file)? { + let mut reader = File::open(with_stream(source.path, &stream))?; + let mut writer = OpenOptions::new() + .write(true) + .create_new(true) + .open(with_stream(destination, &stream))?; + io::copy(&mut reader, &mut writer)?; + } + Ok(()) +} + +fn with_stream(path: &Path, stream: &OsStr) -> PathBuf { + let mut path = path.as_os_str().to_owned(); + path.push(stream); + path.into() +} + +fn stream_names(file: &File) -> io::Result> { + let mut buffer = vec![0_u64; 512]; + loop { + // SAFETY: `buffer` is writable for its full length in bytes and 8-byte aligned, as + // FILE_STREAM_INFO requires. + let succeeded = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileStreamInfo, + buffer.as_mut_ptr().cast(), + (buffer.len() * size_of::()) as u32, + ) + }; + if succeeded != 0 { + break; + } + let error = io::Error::last_os_error(); + match error.raw_os_error().map(|code| code as u32) { + Some(ERROR_MORE_DATA | ERROR_INSUFFICIENT_BUFFER) => buffer.resize(buffer.len() * 2, 0), + Some(ERROR_HANDLE_EOF) => return Ok(Vec::new()), + _ => return Err(error), + } + } + let bytes = buffer + .iter() + .flat_map(|word| word.to_le_bytes()) + .collect::>(); + let mut names = Vec::new(); + let mut offset = 0; + // FILE_STREAM_INFO: NextEntryOffset and StreamNameLength (in bytes) as u32, two i64 sizes, + // then the UTF-16 name. + while let Some(header) = bytes.get(offset..offset + 24) { + let next = u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize; + let length = u32::from_le_bytes([header[4], header[5], header[6], header[7]]) as usize; + let name = bytes + .get(offset + 24..offset + 24 + length) + .ok_or_else(|| io::Error::other("malformed alternate stream information"))?; + let name = OsString::from_wide( + &name + .as_chunks::<2>() + .0 + .iter() + .map(|unit| u16::from_le_bytes(*unit)) + .collect::>(), + ); + if name != "::$DATA" { + names.push(name); + } + if next == 0 { + break; + } + offset += next; + } + Ok(names) +} + +fn copy_symlink( + source: &Path, + basic: &FILE_BASIC_INFO, + destination: &Path, + directory: bool, +) -> Result<()> { + let target = fs::read_link(source)?; + let created = if directory { + symlink_dir(&target, destination) + } else { + symlink_file(&target, destination) + }; + created.map_err(|error| { + if error.raw_os_error() == Some(ERROR_PRIVILEGE_NOT_HELD as i32) { + Error::Io(io::Error::new( + error.kind(), + format!( + "creating the symbolic link {} needs Windows Developer Mode or administrator rights: {error}", + destination.display() + ), + )) + } else { + error.into() + } + })?; + apply_basic(&open(destination, FILE_WRITE_ATTRIBUTES)?, basic)?; + Ok(()) +} + +fn copy_junction(source: &File, basic: &FILE_BASIC_INFO, destination: &Path) -> Result<()> { + let mut reparse = vec![0_u8; MAXIMUM_REPARSE_DATA_BUFFER_SIZE as usize]; + let length = control(source, FSCTL_GET_REPARSE_POINT, &(), reparse.as_mut_slice())?; + fs::create_dir(destination)?; + let junction = open(destination, GENERIC_WRITE)?; + control( + &junction, + FSCTL_SET_REPARSE_POINT, + &reparse[..length], + &mut (), + )?; + apply_basic(&junction, basic)?; + Ok(()) +} + +const SETTABLE_ATTRIBUTES: u32 = FILE_ATTRIBUTE_READONLY + | FILE_ATTRIBUTE_HIDDEN + | FILE_ATTRIBUTE_SYSTEM + | FILE_ATTRIBUTE_ARCHIVE + | FILE_ATTRIBUTE_NOT_CONTENT_INDEXED + | FILE_ATTRIBUTE_TEMPORARY; + +// Runs last for each entry, so the read-only attribute lands after every write. +fn apply_basic(file: &File, source: &FILE_BASIC_INFO) -> io::Result<()> { + set_basic( + file, + &FILE_BASIC_INFO { + CreationTime: source.CreationTime, + LastAccessTime: source.LastAccessTime, + LastWriteTime: source.LastWriteTime, + ChangeTime: 0, + FileAttributes: settable(source.FileAttributes), + }, + ) +} + +// Zero attributes would leave the current ones unchanged, so clearing them all takes +// FILE_ATTRIBUTE_NORMAL. +fn settable(attributes: u32) -> u32 { + match attributes & SETTABLE_ATTRIBUTES { + 0 => FILE_ATTRIBUTE_NORMAL, + attributes => attributes, + } +} + +fn set_basic(file: &File, basic: &FILE_BASIC_INFO) -> io::Result<()> { + // SAFETY: `basic` is a live FILE_BASIC_INFO, the layout FileBasicInfo expects. + let succeeded = unsafe { + SetFileInformationByHandle( + file.as_raw_handle(), + FileBasicInfo, + (basic as *const FILE_BASIC_INFO).cast(), + size_of::() as u32, + ) + }; + if succeeded == 0 { + return Err(io::Error::last_os_error()); } Ok(()) } @@ -374,6 +605,10 @@ impl Information for FILE_ID_INFO { const CLASS: FILE_INFO_BY_HANDLE_CLASS = FileIdInfo; } +impl Information for FILE_ATTRIBUTE_TAG_INFO { + const CLASS: FILE_INFO_BY_HANDLE_CLASS = FileAttributeTagInfo; +} + fn information(file: &File) -> io::Result { let mut value = T::default(); // SAFETY: `T::CLASS` names the information class whose reply has `T`'s layout, and `value` @@ -472,14 +707,45 @@ mod tests { } #[test] - fn entry_kind_follows_file_attributes() { - assert_eq!(EntryKind::classify(0x20), EntryKind::File); - assert_eq!(EntryKind::classify(0x21), EntryKind::File); - assert_eq!(EntryKind::classify(0x10), EntryKind::Directory); - assert_eq!(EntryKind::classify(0x4010), EntryKind::Directory); - assert_eq!(EntryKind::classify(0x4020), EntryKind::Unsupported); - assert_eq!(EntryKind::classify(0x420), EntryKind::Unsupported); - assert_eq!(EntryKind::classify(0x410), EntryKind::Unsupported); + fn entry_kind_follows_attributes_and_reparse_tags() { + assert_eq!(EntryKind::classify(0x20, 0), EntryKind::File); + assert_eq!(EntryKind::classify(0x21, 0), EntryKind::File); + assert_eq!(EntryKind::classify(0x10, 0), EntryKind::Directory); + assert_eq!(EntryKind::classify(0x4010, 0), EntryKind::Directory); + assert_eq!(EntryKind::classify(0x4020, 0), EntryKind::Unsupported); + assert_eq!( + EntryKind::classify(0x420, 0xa000_000c), + EntryKind::Symlink { directory: false } + ); + assert_eq!( + EntryKind::classify(0x410, 0xa000_000c), + EntryKind::Symlink { directory: true } + ); + assert_eq!(EntryKind::classify(0x410, 0xa000_0003), EntryKind::Junction); + assert_eq!( + EntryKind::classify(0x420, 0xa000_0003), + EntryKind::Unsupported + ); + assert_eq!( + EntryKind::classify(0x420, 0x9000_301a), + EntryKind::Unsupported + ); + assert_eq!( + EntryKind::classify(0x420, 0x8000_001b), + EntryKind::Unsupported + ); + assert_eq!( + EntryKind::classify(0x420, 0x8000_0013), + EntryKind::Unsupported + ); + } + + #[test] + fn settable_attributes_never_ask_to_keep_the_current_ones() { + assert_eq!(settable(0x8221), 0x21); + assert_eq!(settable(0x2006), 0x2006); + assert_eq!(settable(0x200), 0x80); + assert_eq!(settable(0), 0x80); } #[test] diff --git a/crates/core/src/windows_filesystem_tests.rs b/crates/core/src/windows_filesystem_tests.rs index 7f19bb9..89f2e30 100644 --- a/crates/core/src/windows_filesystem_tests.rs +++ b/crates/core/src/windows_filesystem_tests.rs @@ -1,30 +1,49 @@ use crate::strategy::default_strategy; use crate::test_support::windows_extents::assert_shared_extents; use crate::{CopyMode, Create, Error, InitOutcome, Manager}; -use std::fs::{self, OpenOptions}; -use std::io::{Seek, SeekFrom, Write}; -use std::path::Path; +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Seek, SeekFrom, Write}; +use std::os::windows::fs::{FileTypeExt, MetadataExt, OpenOptionsExt, symlink_dir, symlink_file}; +use std::os::windows::io::AsRawHandle; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::ptr::null_mut; use std::time::Instant; use tempfile::{Builder, TempDir}; +use walkdir::WalkDir; +use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FileIdInfo, + GetFileInformationByHandleEx, REPARSE_GUID_DATA_BUFFER, REPARSE_GUID_DATA_BUFFER_0, +}; +use windows_sys::Win32::System::IO::DeviceIoControl; +use windows_sys::Win32::System::Ioctl::FSCTL_SET_REPARSE_POINT; +use windows_sys::core::GUID; const LARGE_FILE_SIZE: usize = (1 << 20) + 123; +// create rewrites the marker and detaches HEAD after copying. +const REWRITTEN_BY_CREATE: [&str; 2] = [".rift", ".git/HEAD"]; + #[test] fn production_refs_volume_round_trip() { if !requires_refs_tests() { return; } let temp = current_volume_temp(); - let source = temp.path().join("source"); - fs::create_dir_all(source.join("nested/deeper")).unwrap(); - fs::write(source.join("empty.txt"), "").unwrap(); - fs::write(source.join("nested/deeper/leaf.txt"), "leaf").unwrap(); - let large = pseudo_random(LARGE_FILE_SIZE, 0x5eed); - fs::write(source.join("large.bin"), &large).unwrap(); + let outside = temp.path().join("outside"); + fs::create_dir(&outside).unwrap(); + fs::write(outside.join("keep.txt"), "outside").unwrap(); + let source = rich_git_workspace(temp.path(), &outside); let mut manager = Manager::open(temp.path().join("registry.sqlite")).unwrap(); assert_eq!(manager.init(&source).unwrap(), InitOutcome::Registered); assert_no_probe_files(&source); + refresh_git_index(&source); + assert_eq!( + git_output(&source, &["diff-files", "--name-only"]), + "dirty.txt\n" + ); let started = Instant::now(); let child = manager .create(Create::new(source.clone()).named("child")) @@ -32,23 +51,72 @@ fn production_refs_volume_round_trip() { let elapsed = started.elapsed(); assert_no_probe_files(child.parent().unwrap()); - assert_eq!(fs::read(child.join("empty.txt")).unwrap(), b""); + assert_same_tree(&source, &child); + assert!(!child.join("node_modules").exists()); assert_eq!( - fs::read_to_string(child.join("nested/deeper/leaf.txt")).unwrap(), - "leaf" + fs::read_to_string(stream(&child.join("streams.txt"), "rift.test")).unwrap(), + "alternate stream" + ); + assert_eq!( + file_id(&child.join("nested/file.txt")), + file_id(&child.join("nested/hard.txt")) + ); + assert_ne!( + file_id(&child.join("nested/file.txt")), + file_id(&source.join("nested/file.txt")) + ); + assert!( + fs::symlink_metadata(child.join("nested/link.txt")) + .unwrap() + .file_type() + .is_symlink_file() ); assert!( - fs::read(child.join("large.bin")).unwrap() == large, - "large.bin differs in the clone" + fs::symlink_metadata(child.join("dirlink")) + .unwrap() + .file_type() + .is_symlink_dir() ); + assert_eq!( + git_output(&child, &["diff-files", "--name-only"]), + "dirty.txt\n" + ); + assert_detached_git_copy(&source, &child); assert_shared_extents(&source.join("large.bin"), &child.join("large.bin")); assert_isolated(&source.join("large.bin"), &child.join("large.bin")); println!( - "refs round trip: created {} in {elapsed:?}", - child.display() + "refs round trip: created {} with {} entries in {elapsed:?}", + child.display(), + WalkDir::new(&child).into_iter().count() ); } +#[test] +fn production_refs_create_rejects_unknown_reparse_points() { + if !requires_refs_tests() { + return; + } + let temp = current_volume_temp(); + let source = temp.path().join("source"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("plain.txt"), "plain").unwrap(); + let placeholder = source.join("placeholder.dat"); + fs::write(&placeholder, "placeholder").unwrap(); + set_third_party_reparse_point(&placeholder); + let mut manager = Manager::open(temp.path().join("registry.sqlite")).unwrap(); + manager.init(&source).unwrap(); + + match manager.create(Create::new(source.clone()).named("child")) { + Err(Error::UnsupportedEntry(path)) => { + assert!(path.ends_with("placeholder.dat"), "{}", path.display()); + } + result => panic!("create should refuse the reparse point, got {result:?}"), + } + assert!(!temp.path().join(".rifts/source/child").exists()); + assert!(manager.list(&source).unwrap().is_empty()); + println!("refs create refused an unknown reparse tag and removed the partial child"); +} + #[test] fn production_unsupported_windows_volume_rejects_management() { if std::env::var_os("RIFT_REQUIRE_UNSUPPORTED_WINDOWS_TESTS").is_none() { @@ -103,6 +171,264 @@ fn assert_dev_drive_guidance(message: &str) { ); } +fn rich_git_workspace(root: &Path, outside: &Path) -> PathBuf { + let source = root.join("source"); + let nested = source.join("nested"); + let deeper = nested.join("deeper"); + fs::create_dir_all(&deeper).unwrap(); + fs::write(deeper.join("leaf.txt"), "leaf").unwrap(); + fs::write(nested.join("file.txt"), "hello").unwrap(); + fs::hard_link(nested.join("file.txt"), nested.join("hard.txt")).unwrap(); + fs::write(source.join("empty.txt"), "").unwrap(); + fs::write( + source.join("large.bin"), + pseudo_random(LARGE_FILE_SIZE, 0x5eed), + ) + .unwrap(); + fs::write(source.join("readonly.txt"), "read only").unwrap(); + let mut permissions = fs::metadata(source.join("readonly.txt")) + .unwrap() + .permissions(); + permissions.set_readonly(true); + fs::set_permissions(source.join("readonly.txt"), permissions).unwrap(); + fs::write(source.join("hidden.txt"), "hidden").unwrap(); + run( + "attrib", + &["+h".as_ref(), source.join("hidden.txt").as_os_str()], + ); + fs::write(source.join("streams.txt"), "main stream").unwrap(); + fs::write( + stream(&source.join("streams.txt"), "rift.test"), + "alternate stream", + ) + .unwrap(); + fs::write(source.join("ünïcødé-文件.txt"), "unicode").unwrap(); + let long = source + .join("long") + .join("a".repeat(120)) + .join("b".repeat(120)); + fs::create_dir_all(&long).unwrap(); + fs::write(long.join("file.txt"), "long path").unwrap(); + fs::create_dir_all(source.join("node_modules/pkg")).unwrap(); + fs::write(source.join("node_modules/pkg/index.js"), "module").unwrap(); + fs::write(source.join(".gitignore"), "*.log\nnode_modules/\n").unwrap(); + fs::write(source.join("dirty.txt"), "committed").unwrap(); + fs::write(source.join("staged.txt"), "committed").unwrap(); + + git(&source, &["init", "-q"]); + git(&source, &["config", "user.email", "test@example.com"]); + git(&source, &["config", "user.name", "Test"]); + git(&source, &["config", "core.autocrlf", "false"]); + git(&source, &["config", "core.longpaths", "true"]); + git(&source, &["add", "."]); + git(&source, &["commit", "-q", "-m", "initial"]); + fs::write(source.join("dirty.txt"), "dirty").unwrap(); + fs::write(source.join("staged.txt"), "staged").unwrap(); + git(&source, &["add", "staged.txt"]); + fs::write(source.join("untracked.txt"), "untracked").unwrap(); + fs::write(source.join("debug.log"), "ignored").unwrap(); + + symlink_file("file.txt", nested.join("link.txt")).unwrap(); + symlink_dir("nested", source.join("dirlink")).unwrap(); + junction(&source.join("junction-inside"), &deeper); + junction(&source.join("junction-outside"), outside); + source +} + +fn assert_same_tree(source: &Path, clone: &Path) { + let (expected, actual) = (tree(source), tree(clone)); + assert_eq!( + actual.keys().collect::>(), + expected.keys().collect::>(), + "the clone has different entries" + ); + for (path, metadata) in &expected { + let copied = &actual[path]; + assert_eq!( + ( + copied.file_attributes(), + copied.creation_time(), + copied.last_write_time() + ), + ( + metadata.file_attributes(), + metadata.creation_time(), + metadata.last_write_time() + ), + "attributes or times differ for {}", + path.display() + ); + if metadata.file_type().is_symlink() { + assert_eq!( + fs::read_link(clone.join(path)).unwrap(), + fs::read_link(source.join(path)).unwrap(), + "link target differs for {}", + path.display() + ); + } else if metadata.is_file() { + assert!( + fs::read(clone.join(path)).unwrap() == fs::read(source.join(path)).unwrap(), + "contents differ for {}", + path.display() + ); + } + } +} + +fn tree(root: &Path) -> BTreeMap { + WalkDir::new(root) + .min_depth(1) + .follow_links(false) + .into_iter() + .filter_entry(|entry| entry.file_name() != "node_modules") + .map(|entry| { + let entry = entry.unwrap(); + ( + entry.path().strip_prefix(root).unwrap().to_path_buf(), + fs::symlink_metadata(entry.path()).unwrap(), + ) + }) + .filter(|(path, _)| { + !REWRITTEN_BY_CREATE + .iter() + .any(|rewritten| path.as_path() == Path::new(rewritten)) + }) + .collect() +} + +fn assert_detached_git_copy(source: &Path, child: &Path) { + let commit = git_output(source, &["rev-parse", "--verify", "HEAD^{commit}"]); + assert!( + !Command::new("git") + .arg("-C") + .arg(child) + .args(["symbolic-ref", "-q", "HEAD"]) + .status() + .unwrap() + .success() + ); + assert_eq!( + fs::read_to_string(child.join(".git/HEAD")).unwrap(), + format!("{}\n", commit.trim()) + ); + assert_eq!( + git_output(child, &["diff", "--cached", "--name-only"]), + "staged.txt\n" + ); +} + +fn file_id(path: &Path) -> (u64, [u8; 16]) { + let file = File::open(path).unwrap(); + let mut id = FILE_ID_INFO::default(); + // SAFETY: `id` is a writable FILE_ID_INFO, the layout FileIdInfo expects. + let succeeded = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileIdInfo, + (&raw mut id).cast(), + size_of::() as u32, + ) + }; + assert_ne!(succeeded, 0, "{}", io::Error::last_os_error()); + (id.VolumeSerialNumber, id.FileId.Identifier) +} + +fn set_third_party_reparse_point(path: &Path) { + let file = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS) + .open(path) + .unwrap(); + let reparse = REPARSE_GUID_DATA_BUFFER { + ReparseTag: 0x1234, + ReparseDataLength: 1, + Reserved: 0, + ReparseGuid: GUID::from_u128(0x5249_4654_0000_4000_8000_0000_0000_0001), + GenericReparseBuffer: REPARSE_GUID_DATA_BUFFER_0 { DataBuffer: [1] }, + }; + let mut returned = 0; + // SAFETY: `reparse` holds the 24-byte header and the one data byte passed, and the handle is + // synchronous, so the call finishes before it returns. + let succeeded = unsafe { + DeviceIoControl( + file.as_raw_handle(), + FSCTL_SET_REPARSE_POINT, + (&raw const reparse).cast(), + 25, + null_mut(), + 0, + &mut returned, + null_mut(), + ) + }; + assert_ne!( + succeeded, + 0, + "setting a third-party reparse point failed: {}", + io::Error::last_os_error() + ); +} + +fn junction(link: &Path, target: &Path) { + run( + "cmd", + &[ + "/c".as_ref(), + "mklink".as_ref(), + "/J".as_ref(), + link.as_os_str(), + target.as_os_str(), + ], + ); +} + +fn stream(path: &Path, name: &str) -> PathBuf { + let mut path = path.as_os_str().to_owned(); + path.push(":"); + path.push(name); + path.into() +} + +// `update-index --refresh` exits non-zero while dirty.txt has unstaged changes. +fn refresh_git_index(path: &Path) { + Command::new("git") + .arg("-C") + .arg(path) + .args(["update-index", "-q", "--refresh"]) + .status() + .unwrap(); +} + +fn git(path: &Path, args: &[&str]) { + git_output(path, args); +} + +fn git_output(path: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +fn run(program: &str, args: &[&std::ffi::OsStr]) { + let output = Command::new(program).args(args).output().unwrap(); + assert!( + output.status.success(), + "{program} {args:?} failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + fn assert_isolated(source: &Path, clone: &Path) { let original = fs::read(source).unwrap(); overwrite(source, 4096, b"parent mutation"); From 4c1aeebb94e1be5475dfaf084b7f190f066584e8 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:30:37 +0530 Subject: [PATCH 16/25] feat(windows): flush sources before cloning them Clones share what is on disk, so writes still in the cache can come back zero-filled (git-lfs#6312, pnpm#7186). Each source file is now flushed through a second, writable handle before it is cloned. Read-only files, such as Git objects, have the attribute cleared for the flush and restored on every path. When no writable handle can be opened, for example because another program holds the file without write sharing, the clone is compared byte for byte with its source and a mismatch fails with CowUnavailable naming the file. New ReFS tests write 64 files of 4 MiB and create immediately, and clone a file held open without write sharing. --- crates/core/src/strategy/refs.rs | 42 +++++++++++ crates/core/src/windows_filesystem_tests.rs | 78 ++++++++++++++++++++- 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/crates/core/src/strategy/refs.rs b/crates/core/src/strategy/refs.rs index de95def..2f69189 100644 --- a/crates/core/src/strategy/refs.rs +++ b/crates/core/src/strategy/refs.rs @@ -209,6 +209,7 @@ fn clone_tree(from: &Path, to: &Path, filter: Option) -> Result<()> } fn clone_file(source: &SourceFile, destination: &Path) -> Result<()> { + let flushed = flush(source.path, source.basic.FileAttributes)?; let target = OpenOptions::new() .read(true) .write(true) @@ -216,12 +217,43 @@ fn clone_file(source: &SourceFile, destination: &Path) -> Result<()> { .open(destination)?; if source.size > 0 { clone_data(source, &target)?; + if !flushed && !same_contents(&source.file, &target)? { + return Err(Error::CowUnavailable(format!( + "the clone of {} differs from it, so another program may be writing to it; close that program and try again", + source.path.display() + ))); + } } copy_streams(source, destination)?; apply_basic(&target, &source.basic)?; Ok(()) } +// Block cloning shares what is on disk, so writes still in the cache came back zero-filled in +// clones (https://github.com/git-lfs/git-lfs/issues/6312, pnpm#7186). Flushing needs a writable +// handle; when none can be opened, the caller compares the clone with its source instead. +fn flush(path: &Path, attributes: u32) -> Result { + if attributes & FILE_ATTRIBUTE_READONLY == 0 { + return Ok(flush_writable(path)?); + } + let file = open(path, FILE_WRITE_ATTRIBUTES)?; + set_attributes(&file, attributes & !FILE_ATTRIBUTE_READONLY)?; + let flushed = flush_writable(path); + set_attributes(&file, attributes)?; + Ok(flushed?) +} + +fn flush_writable(path: &Path) -> io::Result { + match OpenOptions::new() + .write(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + { + Ok(file) => file.sync_all().map(|()| true), + Err(_) => Ok(false), + } +} + fn clone_data(source: &SourceFile, target: &File) -> Result<()> { // A sparse destination keeps ReFS from allocating clusters for the length set below, and a // sparse source can only be cloned into a sparse destination. @@ -389,6 +421,16 @@ fn apply_basic(file: &File, source: &FILE_BASIC_INFO) -> io::Result<()> { ) } +fn set_attributes(file: &File, attributes: u32) -> io::Result<()> { + set_basic( + file, + &FILE_BASIC_INFO { + FileAttributes: settable(attributes), + ..FILE_BASIC_INFO::default() + }, + ) +} + // Zero attributes would leave the current ones unchanged, so clearing them all takes // FILE_ATTRIBUTE_NORMAL. fn settable(attributes: u32) -> u32 { diff --git a/crates/core/src/windows_filesystem_tests.rs b/crates/core/src/windows_filesystem_tests.rs index 89f2e30..a1bbb56 100644 --- a/crates/core/src/windows_filesystem_tests.rs +++ b/crates/core/src/windows_filesystem_tests.rs @@ -13,8 +13,8 @@ use std::time::Instant; use tempfile::{Builder, TempDir}; use walkdir::WalkDir; use windows_sys::Win32::Storage::FileSystem::{ - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FileIdInfo, - GetFileInformationByHandleEx, REPARSE_GUID_DATA_BUFFER, REPARSE_GUID_DATA_BUFFER_0, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FILE_SHARE_READ, + FileIdInfo, GetFileInformationByHandleEx, REPARSE_GUID_DATA_BUFFER, REPARSE_GUID_DATA_BUFFER_0, }; use windows_sys::Win32::System::IO::DeviceIoControl; use windows_sys::Win32::System::Ioctl::FSCTL_SET_REPARSE_POINT; @@ -91,6 +91,80 @@ fn production_refs_volume_round_trip() { ); } +#[test] +fn production_refs_clones_files_written_moments_before() { + if !requires_refs_tests() { + return; + } + let temp = current_volume_temp(); + let source = temp.path().join("source"); + fs::create_dir(&source).unwrap(); + let mut manager = Manager::open(temp.path().join("registry.sqlite")).unwrap(); + manager.init(&source).unwrap(); + let files = (1..=64_u64) + .map(|seed| { + let name = format!("file-{seed:02}.bin"); + let contents = pseudo_random(4 << 20, seed); + fs::write(source.join(&name), &contents).unwrap(); + (name, contents) + }) + .collect::>(); + + let started = Instant::now(); + let child = manager + .create(Create::new(source.clone()).named("child")) + .unwrap(); + let elapsed = started.elapsed(); + + for (name, contents) in &files { + let cloned = fs::read(child.join(name)).unwrap(); + if let Some(offset) = cloned.iter().zip(contents).position(|(a, b)| a != b) { + let end = (offset + 16).min(cloned.len()); + panic!( + "{name} differs from offset {offset}; the clone holds {:?} there", + &cloned[offset..end] + ); + } + assert_eq!(cloned.len(), contents.len(), "{name} has the wrong length"); + } + println!( + "write-then-clone: created 64 freshly written 4 MiB files in {elapsed:?}, {:?} per file", + elapsed / 64 + ); +} + +#[test] +fn production_refs_clones_files_held_open_without_write_sharing() { + if !requires_refs_tests() { + return; + } + let temp = current_volume_temp(); + let source = temp.path().join("source"); + fs::create_dir(&source).unwrap(); + let held = source.join("held.bin"); + let contents = pseudo_random(LARGE_FILE_SIZE, 0x4e1d); + fs::write(&held, &contents).unwrap(); + let mut manager = Manager::open(temp.path().join("registry.sqlite")).unwrap(); + manager.init(&source).unwrap(); + let holder = OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .open(&held) + .unwrap(); + + let child = manager + .create(Create::new(source.clone()).named("child")) + .unwrap(); + drop(holder); + + assert!( + fs::read(child.join("held.bin")).unwrap() == contents, + "held.bin differs in the clone" + ); + assert_shared_extents(&held, &child.join("held.bin")); + println!("refs create cloned a file another handle held without write sharing"); +} + #[test] fn production_refs_create_rejects_unknown_reparse_points() { if !requires_refs_tests() { From 9db4be0e6ae30c7bd3b59c5cdf8d56075e32ceca Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:34:41 +0530 Subject: [PATCH 17/25] feat(windows): remove read-only workspaces std::fs::remove_dir_all cannot delete read-only files, such as Git objects, on volumes without POSIX delete semantics, which includes the plain ReFS volume in CI. RefsStrategy::remove_directory now clears the read-only attribute across the tree on a permission error and retries once. It never descends into reparse points and std never follows them, so junction and symlink targets survive. The ReFS round trip now removes the child and runs gc, and checks that a junction target outside the tree survives. A separate test records what std alone does on each volume next to RefsStrategy's result. --- crates/core/src/strategy/refs.rs | 34 ++++++++++- crates/core/src/windows_filesystem_tests.rs | 62 +++++++++++++++++++-- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/crates/core/src/strategy/refs.rs b/crates/core/src/strategy/refs.rs index 2f69189..239dc81 100644 --- a/crates/core/src/strategy/refs.rs +++ b/crates/core/src/strategy/refs.rs @@ -5,7 +5,7 @@ use std::ffi::{OsStr, OsString}; use std::fs::{self, File, OpenOptions}; use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write}; use std::os::windows::ffi::OsStringExt; -use std::os::windows::fs::{OpenOptionsExt, symlink_dir, symlink_file}; +use std::os::windows::fs::{MetadataExt, OpenOptionsExt, symlink_dir, symlink_file}; use std::os::windows::io::AsRawHandle; use std::path::{Path, PathBuf}; use std::ptr::{null, null_mut}; @@ -54,6 +54,17 @@ impl Strategy for RefsStrategy { verify_block_cloning(path)?; Ok(StrategyInit::AlreadyNative) } + + fn remove_directory(&self, path: &Path) -> Result<()> { + match fs::remove_dir_all(path) { + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => { + clear_read_only(path)?; + fs::remove_dir_all(path)?; + Ok(()) + } + result => Ok(result?), + } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -421,6 +432,27 @@ fn apply_basic(file: &File, source: &FILE_BASIC_INFO) -> io::Result<()> { ) } +// Volumes without POSIX delete semantics, such as plain ReFS on Windows Server 2022, refuse to +// delete read-only files like Git objects. Reparse points lose their own read-only attribute +// but are never descended into. +fn clear_read_only(root: &Path) -> Result<()> { + let mut entries = WalkDir::new(root).follow_links(false).into_iter(); + while let Some(entry) = entries.next() { + let entry = entry?; + let attributes = entry.metadata()?.file_attributes(); + if attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 && entry.file_type().is_dir() { + entries.skip_current_dir(); + } + if attributes & FILE_ATTRIBUTE_READONLY != 0 { + set_attributes( + &open(entry.path(), FILE_WRITE_ATTRIBUTES)?, + attributes & !FILE_ATTRIBUTE_READONLY, + )?; + } + } + Ok(()) +} + fn set_attributes(file: &File, attributes: u32) -> io::Result<()> { set_basic( file, diff --git a/crates/core/src/windows_filesystem_tests.rs b/crates/core/src/windows_filesystem_tests.rs index a1bbb56..5a7d7c2 100644 --- a/crates/core/src/windows_filesystem_tests.rs +++ b/crates/core/src/windows_filesystem_tests.rs @@ -84,6 +84,20 @@ fn production_refs_volume_round_trip() { assert_detached_git_copy(&source, &child); assert_shared_extents(&source.join("large.bin"), &child.join("large.bin")); assert_isolated(&source.join("large.bin"), &child.join("large.bin")); + + manager.remove(&child).unwrap(); + assert!(!child.exists()); + let removed = manager.gc().unwrap(); + assert_eq!(removed.len(), 1, "gc removed {removed:?}"); + assert!(!removed[0].exists(), "gc left {}", removed[0].display()); + assert_eq!( + fs::read_to_string(outside.join("keep.txt")).unwrap(), + "outside" + ); + assert_eq!( + fs::read_to_string(source.join("nested/deeper/leaf.txt")).unwrap(), + "leaf" + ); println!( "refs round trip: created {} with {} entries in {elapsed:?}", child.display(), @@ -165,6 +179,42 @@ fn production_refs_clones_files_held_open_without_write_sharing() { println!("refs create cloned a file another handle held without write sharing"); } +#[test] +fn production_refs_remove_deletes_read_only_trees_without_following_junctions() { + if !requires_refs_tests() { + return; + } + let temp = current_volume_temp(); + let outside = temp.path().join("outside"); + fs::create_dir(&outside).unwrap(); + fs::write(outside.join("keep.txt"), "outside").unwrap(); + let read_only_tree = |name: &str| { + let root = temp.path().join(name); + fs::create_dir_all(root.join("objects/0d")).unwrap(); + fs::write(root.join("objects/0d/8a474f"), "object").unwrap(); + set_readonly(&root.join("objects/0d/8a474f")); + junction(&root.join("outside"), &outside); + root + }; + let control = read_only_tree("control"); + let std_result = fs::remove_dir_all(&control); + let tree = read_only_tree("tree"); + + default_strategy().remove_directory(&tree).unwrap(); + + assert!(!tree.exists()); + assert_eq!( + fs::read_to_string(outside.join("keep.txt")).unwrap(), + "outside" + ); + if control.exists() { + default_strategy().remove_directory(&control).unwrap(); + } + println!( + "std::fs::remove_dir_all on a read-only tree here: {std_result:?}; RefsStrategy removed it" + ); +} + #[test] fn production_refs_create_rejects_unknown_reparse_points() { if !requires_refs_tests() { @@ -260,11 +310,7 @@ fn rich_git_workspace(root: &Path, outside: &Path) -> PathBuf { ) .unwrap(); fs::write(source.join("readonly.txt"), "read only").unwrap(); - let mut permissions = fs::metadata(source.join("readonly.txt")) - .unwrap() - .permissions(); - permissions.set_readonly(true); - fs::set_permissions(source.join("readonly.txt"), permissions).unwrap(); + set_readonly(&source.join("readonly.txt")); fs::write(source.join("hidden.txt"), "hidden").unwrap(); run( "attrib", @@ -444,6 +490,12 @@ fn set_third_party_reparse_point(path: &Path) { ); } +fn set_readonly(path: &Path) { + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_readonly(true); + fs::set_permissions(path, permissions).unwrap(); +} + fn junction(link: &Path, target: &Path) { run( "cmd", From 5ea565a23c43a5807c9092bcbc992fb24fc44ee5 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:34:51 +0530 Subject: [PATCH 18/25] test(windows): cover storage on another volume and time a warm create copy_directory must refuse a destination on another volume with CowUnavailable and create nothing; CI's ReFS checkout and the runner's temporary directory sit on different volumes. The write-then-clone test also times a second create of the same, now flushed, files. --- crates/core/src/windows_filesystem_tests.rs | 36 +++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/core/src/windows_filesystem_tests.rs b/crates/core/src/windows_filesystem_tests.rs index 5a7d7c2..cf6e56b 100644 --- a/crates/core/src/windows_filesystem_tests.rs +++ b/crates/core/src/windows_filesystem_tests.rs @@ -141,9 +141,16 @@ fn production_refs_clones_files_written_moments_before() { } assert_eq!(cloned.len(), contents.len(), "{name} has the wrong length"); } + let started = Instant::now(); + manager + .create(Create::new(source.clone()).named("again")) + .unwrap(); + let again = started.elapsed(); println!( - "write-then-clone: created 64 freshly written 4 MiB files in {elapsed:?}, {:?} per file", - elapsed / 64 + "write-then-clone: created 64 freshly written 4 MiB files in {elapsed:?}, {:?} per file; \ + the same files again in {again:?}, {:?} per file", + elapsed / 64, + again / 64 ); } @@ -215,6 +222,31 @@ fn production_refs_remove_deletes_read_only_trees_without_following_junctions() ); } +#[test] +fn production_refs_copy_rejects_storage_on_another_volume() { + if !requires_refs_tests() { + return; + } + let temp = current_volume_temp(); + let source = temp.path().join("source"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("file.txt"), "hello").unwrap(); + let other_volume = TempDir::new().unwrap(); + let destination = other_volume.path().join("child"); + + match default_strategy().copy_directory(&source, &destination, CopyMode::Filtered) { + Err(Error::CowUnavailable(message)) => { + assert!( + message.contains("on the same ReFS volume"), + "unexpected message: {message}" + ); + println!("copy onto another volume: {message}"); + } + result => panic!("copy onto another volume should fail, got {result:?}"), + } + assert!(!destination.exists()); +} + #[test] fn production_refs_create_rejects_unknown_reparse_points() { if !requires_refs_tests() { From 4d61eb18524c2e5fa5f1284bbd8e707e0a2cc1c4 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 20:39:04 +0530 Subject: [PATCH 19/25] test(windows): cover sparse sources in the ReFS round trip A sparse source must stay sparse in its clone, since ReFS only clones a sparse file into a sparse one. The round trip also counts the child's entries before removing it, so the printed count is real. --- crates/core/src/windows_filesystem_tests.rs | 25 ++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/core/src/windows_filesystem_tests.rs b/crates/core/src/windows_filesystem_tests.rs index cf6e56b..005aa51 100644 --- a/crates/core/src/windows_filesystem_tests.rs +++ b/crates/core/src/windows_filesystem_tests.rs @@ -49,10 +49,19 @@ fn production_refs_volume_round_trip() { .create(Create::new(source.clone()).named("child")) .unwrap(); let elapsed = started.elapsed(); + let entries = WalkDir::new(&child).into_iter().count(); assert_no_probe_files(child.parent().unwrap()); assert_same_tree(&source, &child); assert!(!child.join("node_modules").exists()); + assert_ne!( + fs::symlink_metadata(child.join("sparse.bin")) + .unwrap() + .file_attributes() + & 0x200, + 0, + "the clone of a sparse file should stay sparse" + ); assert_eq!( fs::read_to_string(stream(&child.join("streams.txt"), "rift.test")).unwrap(), "alternate stream" @@ -99,9 +108,8 @@ fn production_refs_volume_round_trip() { "leaf" ); println!( - "refs round trip: created {} with {} entries in {elapsed:?}", - child.display(), - WalkDir::new(&child).into_iter().count() + "refs round trip: created {} with {entries} entries in {elapsed:?}", + child.display() ); } @@ -354,6 +362,17 @@ fn rich_git_workspace(root: &Path, outside: &Path) -> PathBuf { "alternate stream", ) .unwrap(); + let sparse = source.join("sparse.bin"); + fs::write(&sparse, "").unwrap(); + run( + "fsutil", + &["sparse".as_ref(), "setflag".as_ref(), sparse.as_os_str()], + ); + let mut sparse_file = OpenOptions::new().write(true).open(&sparse).unwrap(); + sparse_file.seek(SeekFrom::Start(2 << 20)).unwrap(); + sparse_file.write_all(b"data between holes").unwrap(); + sparse_file.set_len(4 << 20).unwrap(); + drop(sparse_file); fs::write(source.join("ünïcødé-文件.txt"), "unicode").unwrap(); let long = source .join("long") From d8453d741d560587f91020f3faa7acbc412448da Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 21:15:22 +0530 Subject: [PATCH 20/25] refactor(windows): name retry, stream, and probe constants --- crates/core/src/lib.rs | 61 ++++++++++--------- crates/core/src/strategy/refs.rs | 30 +++++---- .../core/src/test_support/windows_extents.rs | 34 ++++++++--- 3 files changed, 73 insertions(+), 52 deletions(-) diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index f4971ee..e8bf303 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -21,6 +21,7 @@ use name::RiftName; use registry::{MovedRecord, PathRecord, Record, Registry, SubtreeScope}; use std::fs; use std::path::{Path, PathBuf}; +use std::time::Duration; use strategy::{Strategy, StrategyInit}; use thiserror::Error; @@ -662,42 +663,44 @@ impl Manager { } } -fn rename_into_trash(from: &Path, to: &Path) -> Result<()> { - #[cfg(windows)] - { - rename_into_trash_windows(from, to) - } - #[cfg(not(windows))] - { - fs::rename(from, to).map_err(Error::from) - } -} +const TRASH_RENAME_BACKOFF: &[Duration] = &[ + Duration::from_millis(10), + Duration::from_millis(20), + Duration::from_millis(40), + Duration::from_millis(80), + Duration::from_millis(160), + Duration::from_millis(320), + Duration::from_millis(370), +]; -#[cfg(windows)] -fn rename_into_trash_windows(from: &Path, to: &Path) -> Result<()> { - if let Err(error) = fs::rename(from, to) { - if !is_windows_in_use(&error) { - return Err(error.into()); - } - } else { - return Ok(()); - } - // A cwd or editor handle often closes within a second. After that, tell the user to close it. - for delay_ms in [0, 10, 25, 50, 100, 200, 400, 200] { - std::thread::sleep(std::time::Duration::from_millis(delay_ms)); +fn rename_into_trash(from: &Path, to: &Path) -> Result<()> { + let mut backoff = TRASH_RENAME_BACKOFF.iter(); + loop { match fs::rename(from, to) { Ok(()) => return Ok(()), - Err(error) if is_windows_in_use(&error) => continue, + Err(error) if is_in_use(&error) => match backoff.next() { + Some(delay) => std::thread::sleep(*delay), + None => return Err(Error::InUse(from.to_path_buf())), + }, Err(error) => return Err(error.into()), } } - Err(Error::InUse(from.to_path_buf())) } -#[cfg(windows)] -fn is_windows_in_use(error: &std::io::Error) -> bool { - // ERROR_ACCESS_DENIED is 5. ERROR_SHARING_VIOLATION is 32. - matches!(error.raw_os_error(), Some(5 | 32)) +fn is_in_use(error: &std::io::Error) -> bool { + #[cfg(windows)] + { + use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION}; + matches!( + error.raw_os_error().map(|code| code as u32), + Some(ERROR_ACCESS_DENIED | ERROR_SHARING_VIOLATION) + ) + } + #[cfg(not(windows))] + { + let _ = error; + false + } } fn default_database_path() -> Result { @@ -707,8 +710,6 @@ fn default_database_path() -> Result { } fn existing_directory(path: &Path) -> Result { - // `std::fs::canonicalize` returns `\\?\` paths on Windows. Those break Git, - // shells, and path comparisons, so store the legacy form when it is unambiguous. let path = dunce::canonicalize(path)?; if !path.is_dir() { return Err(Error::Path(format!("not a directory: {}", path.display()))); diff --git a/crates/core/src/strategy/refs.rs b/crates/core/src/strategy/refs.rs index 239dc81..354736a 100644 --- a/crates/core/src/strategy/refs.rs +++ b/crates/core/src/strategy/refs.rs @@ -21,9 +21,10 @@ use windows_sys::Win32::Storage::FileSystem::{ FILE_ATTRIBUTE_SYSTEM, FILE_ATTRIBUTE_TAG_INFO, FILE_ATTRIBUTE_TEMPORARY, FILE_BASIC_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FILE_INFO_BY_HANDLE_CLASS, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, - FILE_SHARE_WRITE, FILE_STANDARD_INFO, FILE_WRITE_ATTRIBUTES, FileAttributeTagInfo, - FileBasicInfo, FileIdInfo, FileStandardInfo, FileStreamInfo, GetFileInformationByHandleEx, - GetVolumeInformationByHandleW, MAXIMUM_REPARSE_DATA_BUFFER_SIZE, SetFileInformationByHandle, + FILE_SHARE_WRITE, FILE_STANDARD_INFO, FILE_STREAM_INFO, FILE_WRITE_ATTRIBUTES, + FileAttributeTagInfo, FileBasicInfo, FileIdInfo, FileStandardInfo, FileStreamInfo, + GetFileInformationByHandleEx, GetVolumeInformationByHandleW, MAXIMUM_REPARSE_DATA_BUFFER_SIZE, + SetFileInformationByHandle, }; use windows_sys::Win32::System::IO::DeviceIoControl; use windows_sys::Win32::System::Ioctl::{ @@ -340,13 +341,19 @@ fn stream_names(file: &File) -> io::Result> { .collect::>(); let mut names = Vec::new(); let mut offset = 0; - // FILE_STREAM_INFO: NextEntryOffset and StreamNameLength (in bytes) as u32, two i64 sizes, - // then the UTF-16 name. - while let Some(header) = bytes.get(offset..offset + 24) { - let next = u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize; - let length = u32::from_le_bytes([header[4], header[5], header[6], header[7]]) as usize; + const NEXT_ENTRY_OFFSET: usize = std::mem::offset_of!(FILE_STREAM_INFO, NextEntryOffset); + const STREAM_NAME_LENGTH: usize = std::mem::offset_of!(FILE_STREAM_INFO, StreamNameLength); + const STREAM_NAME: usize = std::mem::offset_of!(FILE_STREAM_INFO, StreamName); + let u32_at = |header: &[u8], at: usize| { + let mut unit = [0; 4]; + unit.copy_from_slice(&header[at..at + size_of::()]); + u32::from_le_bytes(unit) as usize + }; + while let Some(header) = bytes.get(offset..offset + STREAM_NAME) { + let next = u32_at(header, NEXT_ENTRY_OFFSET); + let length = u32_at(header, STREAM_NAME_LENGTH); let name = bytes - .get(offset + 24..offset + 24 + length) + .get(offset + STREAM_NAME..offset + STREAM_NAME + length) .ok_or_else(|| io::Error::other("malformed alternate stream information"))?; let name = OsString::from_wide( &name @@ -567,9 +574,8 @@ fn verify_block_cloning(directory: &Path) -> Result<()> { result.and(cleanup.map_err(Error::from)) } -// Several clusters at either ReFS cluster size (4 KiB or 64 KiB) plus a partial one. A probe that -// fits in one partial cluster proves nothing, because ReFS shares no storage for a file's tail. -const PROBE_SIZE: usize = 3 * 65_536 + 1; +const MAX_CLUSTER_SIZE: usize = 64 * 1024; +const PROBE_SIZE: usize = 3 * MAX_CLUSTER_SIZE + 1; fn probe(source: &Path, clone: &Path) -> Result<()> { let contents = (0..PROBE_SIZE) diff --git a/crates/core/src/test_support/windows_extents.rs b/crates/core/src/test_support/windows_extents.rs index d9ba566..fed1d0c 100644 --- a/crates/core/src/test_support/windows_extents.rs +++ b/crates/core/src/test_support/windows_extents.rs @@ -6,7 +6,8 @@ use std::ptr::null_mut; use windows_sys::Win32::Foundation::ERROR_MORE_DATA; use windows_sys::Win32::System::IO::DeviceIoControl; use windows_sys::Win32::System::Ioctl::{ - FSCTL_GET_RETRIEVAL_POINTERS_AND_REFCOUNT, STARTING_VCN_INPUT_BUFFER, + FSCTL_GET_RETRIEVAL_POINTERS_AND_REFCOUNT, RETRIEVAL_POINTERS_AND_REFCOUNT_BUFFER, + RETRIEVAL_POINTERS_AND_REFCOUNT_BUFFER_0, STARTING_VCN_INPUT_BUFFER, }; pub(crate) fn assert_shared_extents(source: &Path, clone: &Path) { @@ -45,15 +46,28 @@ fn reference_counts(path: &Path) -> io::Result> { return Err(error); } } - // RETRIEVAL_POINTERS_AND_REFCOUNT_BUFFER: a u32 extent count, the starting VCN, then - // extents of { NextVcn: i64, Lcn: i64, ReferenceCount: u32 } padded to three words. - let extents = (output[0] & u64::from(u32::MAX)) as usize; - Ok(output[2..] - .as_chunks::<3>() - .0 + // SAFETY: `output` is aligned for the ioctl structs and holds the reply. The slice length + // stays inside that allocation. + let extents = unsafe { + let header = output + .as_ptr() + .cast::(); + let count = (*header).ExtentCount as usize; + let extents_at = std::mem::offset_of!(RETRIEVAL_POINTERS_AND_REFCOUNT_BUFFER, Extents); + let available = (output.len() * size_of::()).saturating_sub(extents_at) + / size_of::(); + std::slice::from_raw_parts( + output + .as_ptr() + .cast::() + .add(extents_at) + .cast::(), + count.min(available), + ) + }; + Ok(extents .iter() - .take(extents) - .filter(|[_, lcn, _]| *lcn as i64 != -1) - .map(|[_, _, count]| *count as u32) + .filter(|extent| extent.Lcn != -1) + .map(|extent| extent.ReferenceCount) .collect()) } From d5219dafb4837e60fbc5aacc242c9788dd6d35ba Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 21:19:40 +0530 Subject: [PATCH 21/25] refactor(windows): make flush outcomes and FFI contracts explicit --- crates/core/src/strategy/refs.rs | 105 ++++++++++++++++++------------- plugins/opencode/src/strategy.ts | 2 +- 2 files changed, 63 insertions(+), 44 deletions(-) diff --git a/crates/core/src/strategy/refs.rs b/crates/core/src/strategy/refs.rs index 354736a..30fca2f 100644 --- a/crates/core/src/strategy/refs.rs +++ b/crates/core/src/strategy/refs.rs @@ -158,11 +158,11 @@ fn same_volume_parent<'a>(from: &Path, to: &'a Path) -> Result<&'a Path> { } fn volume(path: &Path) -> io::Result { - Ok(FileId::of(&open(path, FILE_READ_ATTRIBUTES)?)?.volume) + Ok(FileId::of(&open_without_following(path, FILE_READ_ATTRIBUTES)?)?.volume) } fn clone_tree(from: &Path, to: &Path, filter: Option) -> Result<()> { - let root: FILE_BASIC_INFO = information(&open(from, FILE_READ_ATTRIBUTES)?)?; + let root: FILE_BASIC_INFO = information(&open_without_following(from, FILE_READ_ATTRIBUTES)?)?; let mut hard_links = HashMap::new(); let mut directories = Vec::new(); for entry in WalkDir::new(from) @@ -185,7 +185,7 @@ fn clone_tree(from: &Path, to: &Path, filter: Option) -> Result<()> .strip_prefix(from) .map_err(|error| Error::Path(error.to_string()))?, ); - let file = open(source, GENERIC_READ)?; + let file = open_without_following(source, GENERIC_READ)?; let basic: FILE_BASIC_INFO = information(&file)?; match EntryKind::of(&file, basic.FileAttributes)? { EntryKind::Directory => { @@ -214,12 +214,20 @@ fn clone_tree(from: &Path, to: &Path, filter: Option) -> Result<()> } } for (basic, destination) in directories.into_iter().rev() { - apply_basic(&open(&destination, FILE_WRITE_ATTRIBUTES)?, &basic)?; + apply_final_metadata( + &open_without_following(&destination, FILE_WRITE_ATTRIBUTES)?, + &basic, + )?; } - apply_basic(&open(to, FILE_WRITE_ATTRIBUTES)?, &root)?; + apply_final_metadata(&open_without_following(to, FILE_WRITE_ATTRIBUTES)?, &root)?; Ok(()) } +enum Flush { + Done, + Unavailable, +} + fn clone_file(source: &SourceFile, destination: &Path) -> Result<()> { let flushed = flush(source.path, source.basic.FileAttributes)?; let target = OpenOptions::new() @@ -229,40 +237,45 @@ fn clone_file(source: &SourceFile, destination: &Path) -> Result<()> { .open(destination)?; if source.size > 0 { clone_data(source, &target)?; - if !flushed && !same_contents(&source.file, &target)? { - return Err(Error::CowUnavailable(format!( - "the clone of {} differs from it, so another program may be writing to it; close that program and try again", - source.path.display() - ))); + match flushed { + Flush::Done => {} + Flush::Unavailable => { + if !same_contents(&source.file, &target)? { + return Err(Error::CowUnavailable(format!( + "the clone of {} differs from it, so another program may be writing to it; close that program and try again", + source.path.display() + ))); + } + } } } copy_streams(source, destination)?; - apply_basic(&target, &source.basic)?; + apply_final_metadata(&target, &source.basic)?; Ok(()) } // Block cloning shares what is on disk, so writes still in the cache came back zero-filled in // clones (https://github.com/git-lfs/git-lfs/issues/6312, pnpm#7186). Flushing needs a writable -// handle; when none can be opened, the caller compares the clone with its source instead. -fn flush(path: &Path, attributes: u32) -> Result { +// handle. +fn flush(path: &Path, attributes: u32) -> Result { if attributes & FILE_ATTRIBUTE_READONLY == 0 { return Ok(flush_writable(path)?); } - let file = open(path, FILE_WRITE_ATTRIBUTES)?; + let file = open_without_following(path, FILE_WRITE_ATTRIBUTES)?; set_attributes(&file, attributes & !FILE_ATTRIBUTE_READONLY)?; let flushed = flush_writable(path); set_attributes(&file, attributes)?; Ok(flushed?) } -fn flush_writable(path: &Path) -> io::Result { +fn flush_writable(path: &Path) -> io::Result { match OpenOptions::new() .write(true) .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) .open(path) { - Ok(file) => file.sync_all().map(|()| true), - Err(_) => Ok(false), + Ok(file) => file.sync_all().map(|()| Flush::Done), + Err(_) => Ok(Flush::Unavailable), } } @@ -292,8 +305,7 @@ fn clone_data(source: &SourceFile, target: &File) -> Result<()> { Ok(()) } -// Streams are copied by value. Cloning into an alternate data stream crashed ReFS before a -// hotfix (microsoft/CopyOnWrite#24). +// Cloning into an alternate data stream crashed ReFS before a hotfix (microsoft/CopyOnWrite#24). fn copy_streams(source: &SourceFile, destination: &Path) -> Result<()> { for stream in stream_names(&source.file)? { let mut reader = File::open(with_stream(source.path, &stream))?; @@ -399,7 +411,10 @@ fn copy_symlink( error.into() } })?; - apply_basic(&open(destination, FILE_WRITE_ATTRIBUTES)?, basic)?; + apply_final_metadata( + &open_without_following(destination, FILE_WRITE_ATTRIBUTES)?, + basic, + )?; Ok(()) } @@ -407,14 +422,14 @@ fn copy_junction(source: &File, basic: &FILE_BASIC_INFO, destination: &Path) -> let mut reparse = vec![0_u8; MAXIMUM_REPARSE_DATA_BUFFER_SIZE as usize]; let length = control(source, FSCTL_GET_REPARSE_POINT, &(), reparse.as_mut_slice())?; fs::create_dir(destination)?; - let junction = open(destination, GENERIC_WRITE)?; + let junction = open_without_following(destination, GENERIC_WRITE)?; control( &junction, FSCTL_SET_REPARSE_POINT, &reparse[..length], &mut (), )?; - apply_basic(&junction, basic)?; + apply_final_metadata(&junction, basic)?; Ok(()) } @@ -425,8 +440,7 @@ const SETTABLE_ATTRIBUTES: u32 = FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_NOT_CONTENT_INDEXED | FILE_ATTRIBUTE_TEMPORARY; -// Runs last for each entry, so the read-only attribute lands after every write. -fn apply_basic(file: &File, source: &FILE_BASIC_INFO) -> io::Result<()> { +fn apply_final_metadata(file: &File, source: &FILE_BASIC_INFO) -> io::Result<()> { set_basic( file, &FILE_BASIC_INFO { @@ -440,8 +454,7 @@ fn apply_basic(file: &File, source: &FILE_BASIC_INFO) -> io::Result<()> { } // Volumes without POSIX delete semantics, such as plain ReFS on Windows Server 2022, refuse to -// delete read-only files like Git objects. Reparse points lose their own read-only attribute -// but are never descended into. +// delete read-only files like Git objects. fn clear_read_only(root: &Path) -> Result<()> { let mut entries = WalkDir::new(root).follow_links(false).into_iter(); while let Some(entry) = entries.next() { @@ -452,7 +465,7 @@ fn clear_read_only(root: &Path) -> Result<()> { } if attributes & FILE_ATTRIBUTE_READONLY != 0 { set_attributes( - &open(entry.path(), FILE_WRITE_ATTRIBUTES)?, + &open_without_following(entry.path(), FILE_WRITE_ATTRIBUTES)?, attributes & !FILE_ATTRIBUTE_READONLY, )?; } @@ -582,7 +595,7 @@ fn probe(source: &Path, clone: &Path) -> Result<()> { .map(|index| (index % 251) as u8 + 1) .collect::>(); fs::write(source, &contents)?; - let file = open(source, GENERIC_READ)?; + let file = open_without_following(source, GENERIC_READ)?; let basic = information(&file)?; let source_file = SourceFile::new(source, file, basic)?; clone_file(&source_file, clone)?; @@ -613,7 +626,7 @@ fn unsupported_volume(directory: &Path, reason: &str) -> Error { } fn filesystem_name(path: &Path) -> io::Result { - let directory = open(path, FILE_READ_ATTRIBUTES)?; + let directory = open_without_following(path, FILE_READ_ATTRIBUTES)?; let mut name = [0_u16; MAX_PATH as usize + 1]; // SAFETY: `name` is writable for its full length, and the outputs Rift does not need are // null with zero length. @@ -661,7 +674,7 @@ fn same_contents(mut left: &File, mut right: &File) -> io::Result { } } -fn open(path: &Path, access: u32) -> io::Result { +fn open_without_following(path: &Path, access: u32) -> io::Result { OpenOptions::new() .access_mode(access) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) @@ -669,29 +682,32 @@ fn open(path: &Path, access: u32) -> io::Result { .open(path) } -trait Information: Default { +/// # Safety +/// +/// `CLASS` replies with exactly `Self`'s layout. +unsafe trait Information: Default { const CLASS: FILE_INFO_BY_HANDLE_CLASS; } -impl Information for FILE_BASIC_INFO { +unsafe impl Information for FILE_BASIC_INFO { const CLASS: FILE_INFO_BY_HANDLE_CLASS = FileBasicInfo; } -impl Information for FILE_STANDARD_INFO { +unsafe impl Information for FILE_STANDARD_INFO { const CLASS: FILE_INFO_BY_HANDLE_CLASS = FileStandardInfo; } -impl Information for FILE_ID_INFO { +unsafe impl Information for FILE_ID_INFO { const CLASS: FILE_INFO_BY_HANDLE_CLASS = FileIdInfo; } -impl Information for FILE_ATTRIBUTE_TAG_INFO { +unsafe impl Information for FILE_ATTRIBUTE_TAG_INFO { const CLASS: FILE_INFO_BY_HANDLE_CLASS = FileAttributeTagInfo; } fn information(file: &File) -> io::Result { let mut value = T::default(); - // SAFETY: `T::CLASS` names the information class whose reply has `T`'s layout, and `value` + // SAFETY: `Information` promises `T::CLASS` replies with exactly `T`'s layout, and `value` // is writable for `size_of::()` bytes. let succeeded = unsafe { GetFileInformationByHandleEx( @@ -707,14 +723,16 @@ fn information(file: &File) -> io::Result { Ok(value) } -/// Output buffers that `DeviceIoControl` may fill with any bytes. -trait Pod {} +/// # Safety +/// +/// Any byte pattern the OS writes is a valid value of `Self`. +unsafe trait Pod {} -impl Pod for () {} +unsafe impl Pod for () {} -impl Pod for [u8] {} +unsafe impl Pod for [u8] {} -impl Pod for FSCTL_GET_INTEGRITY_INFORMATION_BUFFER {} +unsafe impl Pod for FSCTL_GET_INTEGRITY_INFORMATION_BUFFER {} fn control( file: &File, @@ -725,8 +743,9 @@ fn control( let input_size = size_of_val(input); let output_size = size_of_val(output); let mut returned = 0; - // SAFETY: `input` and `output` are live for their full sizes, `O` accepts any bytes, and the - // handle was opened without overlapped I/O, so the call finishes before it returns. + // SAFETY: `input` and `output` are live for their full sizes, `Pod` promises any byte pattern + // the OS writes is a valid `O`, and the handle was opened without overlapped I/O, so the call + // finishes before it returns. let succeeded = unsafe { DeviceIoControl( file.as_raw_handle(), diff --git a/plugins/opencode/src/strategy.ts b/plugins/opencode/src/strategy.ts index 2fe0ba8..659cce3 100644 --- a/plugins/opencode/src/strategy.ts +++ b/plugins/opencode/src/strategy.ts @@ -60,7 +60,7 @@ export function resolveExecutable(configured: string, lookup: ExecutableLookup): if (exe) return exe const cmd = lookup.findOnPath(`${base}.cmd`) if (!cmd) return configured - // npm's .cmd shim cannot be spawned without a shell. The binary is \node_modules\rift-snapshot globally and \..\rift-snapshot locally. + // npm's .cmd shim cannot be spawned without a shell. const shimDir = path.win32.dirname(cmd) const bundled = (root: string) => path.win32.resolve(shimDir, root, "rift-snapshot", "prebuilds", `windows-${lookup.arch}`, "rift.exe") From bc97e0d44f13340a70f8c7c1b07e6e43cedff833 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 21:20:27 +0530 Subject: [PATCH 22/25] test(windows): assert git refresh and mark gated e2e runs --- crates/cli/tests/windows_e2e.rs | 10 ++++++++++ crates/core/src/windows_filesystem_tests.rs | 13 +------------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/cli/tests/windows_e2e.rs b/crates/cli/tests/windows_e2e.rs index 750ce71..8b3661c 100644 --- a/crates/cli/tests/windows_e2e.rs +++ b/crates/cli/tests/windows_e2e.rs @@ -18,6 +18,7 @@ fn refs_environment_can_create_a_workspace() { "RIFT_REQUIRE_REFS_TESTS requires create to succeed on this volume\n{}", command_text(&create) ); + println!("windows e2e: create succeeded on {}", source.display()); } #[test] @@ -35,6 +36,10 @@ fn init_and_create_print_paths_without_verbatim_prefix() { assert_no_verbatim_prefix(&create.stdout); assert_no_verbatim_prefix(&create.stderr); assert!(child.join("marker.txt").is_file()); + println!( + "windows e2e: init and create printed {} without a verbatim prefix", + child.display() + ); } #[test] @@ -51,6 +56,10 @@ fn remove_from_inside_the_workspace_moves_it_to_trash() { ); assert!(!child.exists()); assert!(trash_contains_marker(child.parent().unwrap())); + println!( + "windows e2e: remove from inside moved {} to trash", + child.display() + ); } #[test] @@ -92,6 +101,7 @@ fn powershell_remove_returns_to_the_parent_workspace() { assert_no_verbatim_prefix(&location); assert!(!child.exists()); assert!(source.join(".rift").is_file()); + println!("windows e2e: pwsh remove returned to {}", parent.display()); } fn refs_fixture() -> Option { diff --git a/crates/core/src/windows_filesystem_tests.rs b/crates/core/src/windows_filesystem_tests.rs index 005aa51..e31eed0 100644 --- a/crates/core/src/windows_filesystem_tests.rs +++ b/crates/core/src/windows_filesystem_tests.rs @@ -22,7 +22,6 @@ use windows_sys::core::GUID; const LARGE_FILE_SIZE: usize = (1 << 20) + 123; -// create rewrites the marker and detaches HEAD after copying. const REWRITTEN_BY_CREATE: [&str; 2] = [".rift", ".git/HEAD"]; #[test] @@ -39,7 +38,7 @@ fn production_refs_volume_round_trip() { assert_eq!(manager.init(&source).unwrap(), InitOutcome::Registered); assert_no_probe_files(&source); - refresh_git_index(&source); + git(&source, &["update-index", "-q", "--refresh"]); assert_eq!( git_output(&source, &["diff-files", "--name-only"]), "dirty.txt\n" @@ -567,16 +566,6 @@ fn stream(path: &Path, name: &str) -> PathBuf { path.into() } -// `update-index --refresh` exits non-zero while dirty.txt has unstaged changes. -fn refresh_git_index(path: &Path) { - Command::new("git") - .arg("-C") - .arg(path) - .args(["update-index", "-q", "--refresh"]) - .status() - .unwrap(); -} - fn git(path: &Path, args: &[&str]) { git_output(path, args); } From 2d768730aa0d6893a45b12e32097212341c566d7 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 21:21:13 +0530 Subject: [PATCH 23/25] refactor: shorten the Windows reserved-name check --- crates/core/src/name.rs | 32 ++++++-------------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/crates/core/src/name.rs b/crates/core/src/name.rs index 13f35cc..4b4c860 100644 --- a/crates/core/src/name.rs +++ b/crates/core/src/name.rs @@ -34,32 +34,12 @@ fn is_reserved_windows_name(name: &str) -> bool { { return true; } - let stem = name.split('.').next().unwrap_or(name); - matches!( - stem.to_ascii_uppercase().as_str(), - "CON" - | "PRN" - | "AUX" - | "NUL" - | "COM1" - | "COM2" - | "COM3" - | "COM4" - | "COM5" - | "COM6" - | "COM7" - | "COM8" - | "COM9" - | "LPT1" - | "LPT2" - | "LPT3" - | "LPT4" - | "LPT5" - | "LPT6" - | "LPT7" - | "LPT8" - | "LPT9" - ) + let stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase(); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || matches!( + stem.as_bytes(), + [b'C', b'O', b'M', b'1'..=b'9'] | [b'L', b'P', b'T', b'1'..=b'9'] + ) } const ADJECTIVES: &[&str] = &[ From 565751cf3b3d094e6530b05a9a066b6a8aac0481 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 21:24:35 +0530 Subject: [PATCH 24/25] ci: run the Windows jobs in the main CI workflow --- .github/workflows/ci.yml | 72 ++++++++++++++++++++++++++++ .github/workflows/windows.yml | 89 ----------------------------------- 2 files changed, 72 insertions(+), 89 deletions(-) delete mode 100644 .github/workflows/windows.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b345b97..3eaffe9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,3 +104,75 @@ jobs: env: RIFT_REQUIRE_APFS_TESTS: "1" run: cargo test --package rift --locked + + windows-ntfs-test: + name: Windows tests (NTFS) + runs-on: windows-latest + env: + RIFT_REQUIRE_UNSUPPORTED_WINDOWS_TESTS: "1" + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Run tests + run: cargo test --workspace --locked --no-fail-fast -- --show-output + + refs: + name: Windows tests (ReFS) + runs-on: windows-2022 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}\target + RIFT_REQUIRE_REFS_TESTS: "1" + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Create a ReFS volume and copy the checkout onto it + run: ./scripts/ci/windows-refs.ps1 -Volume E -CreatePartition + + - name: Run tests on ReFS + working-directory: ${{ env.RIFT_CHECKOUT }} + run: cargo test --package rift --package rift-cli --locked --no-fail-fast -- --show-output + + devdrive: + name: Windows tests (Dev Drive) + runs-on: windows-2025 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}\target + RIFT_REQUIRE_REFS_TESTS: "1" + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Create a Dev Drive + uses: samypr100/setup-dev-drive@v4 + with: + drive-size: 10GB + drive-format: ReFS + native-dev-drive: true + trusted-dev-drive: true + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Copy the checkout onto the Dev Drive + run: ./scripts/ci/windows-refs.ps1 -Volume $env:DEV_DRIVE + + - name: Run tests on the Dev Drive + working-directory: ${{ env.RIFT_CHECKOUT }} + run: cargo test --package rift --package rift-cli --locked --no-fail-fast -- --show-output diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml deleted file mode 100644 index d9e3107..0000000 --- a/.github/workflows/windows.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Windows - -on: - push: - branches: - - dev - - "nxl/windows-support-*" - pull_request: - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - name: Windows tests (NTFS) - runs-on: windows-latest - env: - RIFT_REQUIRE_UNSUPPORTED_WINDOWS_TESTS: "1" - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - - - name: Run tests - run: cargo test --workspace --locked --no-fail-fast -- --show-output - - refs: - name: Windows tests (ReFS) - runs-on: windows-2022 - env: - CARGO_TARGET_DIR: ${{ github.workspace }}\target - RIFT_REQUIRE_REFS_TESTS: "1" - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - - - name: Create a ReFS volume and copy the checkout onto it - run: ./scripts/ci/windows-refs.ps1 -Volume E -CreatePartition - - - name: Run tests on ReFS - working-directory: ${{ env.RIFT_CHECKOUT }} - run: cargo test --package rift --package rift-cli --locked --no-fail-fast -- --show-output - - devdrive: - name: Windows tests (Dev Drive) - runs-on: windows-2025 - env: - CARGO_TARGET_DIR: ${{ github.workspace }}\target - RIFT_REQUIRE_REFS_TESTS: "1" - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Create a Dev Drive - uses: samypr100/setup-dev-drive@v4 - with: - drive-size: 10GB - drive-format: ReFS - native-dev-drive: true - trusted-dev-drive: true - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - - - name: Copy the checkout onto the Dev Drive - run: ./scripts/ci/windows-refs.ps1 -Volume $env:DEV_DRIVE - - - name: Run tests on the Dev Drive - working-directory: ${{ env.RIFT_CHECKOUT }} - run: cargo test --package rift --package rift-cli --locked --no-fail-fast -- --show-output From d682696ff65601476283fe5d89dc6b2348abfc71 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 9 Oct 2026 21:02:31 +0530 Subject: [PATCH 25/25] docs: document Windows support --- README.md | 20 +++++++++++++++++++- plugins/opencode/README.md | 4 +++- specs.md | 20 ++++++++++++++------ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ba6ca26..b9eccb3 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ eval "$(rift shell-init zsh)" # or bash rift shell-init nushell | save -f (($nu.user-autoload-dirs | first) | path join "rift.nu") ``` +```powershell +Invoke-Expression (& { (rift shell-init pwsh | Out-String) }) +``` + ## Lifecycle Hooks Add `.rift.toml` to a workspace to run commands around creation and removal: @@ -71,6 +75,8 @@ run = "docker compose -p rift-$RIFT_ID down -v" Hooks receive `RIFT_SOURCE`, `RIFT_DESTINATION`, `RIFT_ID`, and `RIFT_PARENT_ID`. Hook output goes to stderr so workspace paths on stdout stay machine-readable. Use `--no-hooks` to skip hooks. +On Windows, hooks run through `cmd.exe`, so write `%RIFT_ID%` instead of `$RIFT_ID`. + ## Agents and OpenCode Each agent can get an isolated copy of your real working state instead of sharing your checkout or starting from a @@ -153,7 +159,7 @@ keeps the source directory, removes its `.rift` marker, and trashes registered d | Linux x64 | btrfs snapshots | `rift init` converts a directory into a subvolume | | Linux x64 | Native per-file reflinks | XFS and other filesystems with working `FICLONE` | | macOS arm64 / x64 | APFS `clonefile` | Requires an APFS volume | -| Windows x64 | None | Package is published; workspace creation is unsupported | +| Windows x64 | ReFS block cloning | Per-file `FSCTL_DUPLICATE_EXTENTS_TO_FILE` on a ReFS volume such as a [Dev Drive](https://learn.microsoft.com/windows/dev-drive/). `rift init` refuses NTFS | Each managed workspace has a `.rift` marker containing its ID. A SQLite registry stores paths, parents, and trash entries. Default storage is adjacent to the source root: @@ -167,6 +173,18 @@ entries. Default storage is adjacent to the source root: Workspaces never overlap, concurrent creates never delete each other's destinations, and removal is a trash operation until `rift gc` runs. +## Windows + +The project must be on a ReFS volume, and `rift create --into` must point to a directory on the same volume. Rift +clones each file separately, so `rift create` takes longer as the number of files grows. + +`rift remove` fails with "another program is using the workspace" while another program has a file in the workspace +open or has its working directory there. Close that program and run `rift remove` again. + +`rift create` recreates symbolic links and junctions. Recreating a symbolic link needs Windows Developer Mode or +administrator rights. `rift create` refuses encrypted files and every other kind of reparse point, such as cloud +placeholder files. + ## JavaScript API The package selects a Bun or Node FFI binding through conditional exports. diff --git a/plugins/opencode/README.md b/plugins/opencode/README.md index a0b3ddb..3b10d1d 100644 --- a/plugins/opencode/README.md +++ b/plugins/opencode/README.md @@ -34,4 +34,6 @@ Move sessions with OpenCode's `opencode.session_move` tool. - Starting refs are not supported; Rift snapshots a working directory, not a commit. - Remove a workspace's child Rifts before removing it. - `rift.remove` refuses to remove the workspace the current session is in. -- Windows can't create workspaces, because Rift needs copy-on-write cloning. +- On Windows, workspaces need a ReFS volume such as a [Dev Drive](https://learn.microsoft.com/windows/dev-drive/). +- On Windows, removing a workspace fails while OpenCode still has terminals, shells, or MCP servers running in it, + because OpenCode does not stop them before it calls the worktree strategy's `remove`. diff --git a/specs.md b/specs.md index 025abed..06f7708 100644 --- a/specs.md +++ b/specs.md @@ -2,7 +2,7 @@ ## Requirement -`rift` must be cross-platform as far as practical. Core semantics should work across macOS, Linux, and Windows. On Linux, managed workspaces use either btrfs subvolumes for instantaneous writable snapshots or native per-file reflinks for copy-on-write tree cloning. +`rift` must be cross-platform as far as practical. Core semantics should work across macOS, Linux, and Windows. On Linux, managed workspaces use either btrfs subvolumes for instantaneous writable snapshots or native per-file reflinks for copy-on-write tree cloning. On Windows, managed workspaces live on a ReFS volume and use per-file block cloning. ## API @@ -16,10 +16,11 @@ init(input: { `init` prepares and registers an original workspace for Rift. -- On Linux, `at` must be on btrfs or a filesystem with native reflink support; on other supported systems, initialization registers the workspace without filesystem conversion. +- On Linux, `at` must be on btrfs or a filesystem with native reflink support. On Windows, `at` must be on a ReFS volume where block cloning works. On macOS, initialization registers the workspace without filesystem conversion. - If `at` is already a btrfs subvolume, register it without replacing it. - If `at` is an ordinary btrfs directory, reflink-import it once into a staged btrfs subvolume and atomically replace the original directory at its existing path. - On other Linux filesystems, verify native reflink support and register `at` without replacing it. +- On Windows, run a block-cloning probe in `at` and register `at` without replacing it. If the probe fails, name the filesystem `at` is on and instruct the user to move the project onto a ReFS volume such as a Dev Drive. - The original directory is retained under an internal temporary path only while it is needed for rollback and is removed before a successful `init` returns. - The core operation initializes exactly `at` and does not search parent directories. - The CLI defaults `at` to the current working directory; by default it selects the nearest existing managed ancestor or nearest Git root, prints the selected path, and then invokes core `init` with that exact path. `--here` opts into selecting exactly the supplied path. @@ -42,6 +43,7 @@ Default behavior: - Source is `from`. - `name` defaults to a random adjective-noun directory name independent of the rift ULID. +- On Windows, `name` must not end in a dot or a space, and it must not contain a control character or any of `<>:"|?*`. Ignoring case, the part of `name` before its first dot must not be `CON`, `PRN`, `AUX`, `NUL`, `COM1` through `COM9`, or `LPT1` through `LPT9`. - `into` defaults to the managed rift directory. - Copy the workspace while excluding known heavyweight regenerable dependency, build, and cache artifacts. - Preserve manifests, lockfiles, dirty files, staged files, untracked files, and ignored files that are not part of the built-in excluded artifact set. @@ -70,9 +72,9 @@ run = "pnpm run cleanup" run = "echo removed" ``` -Hooks run sequentially with inherited stdio and environment plus `RIFT_SOURCE`, `RIFT_DESTINATION`, `RIFT_ID`, and `RIFT_PARENT_ID`. Precreate runs in the source workspace and postcreate runs in the destination. The first failing command stops later hooks. A precreate failure prevents copying; after a postcreate failure, the created workspace remains registered and on disk. +Hooks run sequentially with inherited stdio and environment plus `RIFT_SOURCE`, `RIFT_DESTINATION`, `RIFT_ID`, and `RIFT_PARENT_ID`. Each command runs as `sh -c `, or as `cmd.exe /d /s /c ""` on Windows. Precreate runs in the source workspace and postcreate runs in the destination. The first failing command stops later hooks. A precreate failure prevents copying; after a postcreate failure, the created workspace remains registered and on disk. -On btrfs, `from` must already be a subvolume. If it is an ordinary directory, fail and instruct the user to run `rift init` first. On other reflink-capable Linux filesystems, clone the directory tree with native per-file reflinks. +On btrfs, `from` must already be a subvolume. If it is an ordinary directory, fail and instruct the user to run `rift init` first. On other reflink-capable Linux filesystems, clone the directory tree with native per-file reflinks. On Windows, `from` and the destination directory must be on the same volume. Run the block-cloning probe in the destination directory, then clone the tree file by file. If `from` is already managed by Rift, create copies that exact directory. Do not resolve back to an earlier workspace. Metadata should record the immediate source rift as its parent. @@ -106,6 +108,7 @@ remove(input: { - If `at` identifies a registered source root, preserve its directory, delete its `.rift` marker, move each existing registered descendant into trash, tolerate descendants already absent from disk, and delete its active registry tree. - The CLI requires `-f` or `--force` when `remove` would unregister a registered source root; this confirmation is not part of the core or FFI operation. - The CLI exposes the descendant-preserving mode as `rift remove --children`; the core and FFI input field remains `all`. +- If the CLI's working directory is inside a directory that `remove` will move into trash, the CLI first changes its working directory to the parent workspace, or to the preserved workspace when it unregisters a root or runs with `--children`. Windows cannot rename a directory that is a process's working directory. This step is not part of the core or FFI operation. - If `at` identifies a created rift, move its full descendant subtree into trash. - When `all` is true, preserve `at` and delete every managed descendant. In this mode `at` may be the registered source root. - `hooks` defaults to true. Preremove runs in `at` before filesystem or registry changes. Postremove runs after successful removal, from the trash directory when `at` was moved and from `at` when it was preserved. A preremove failure prevents removal; a postremove failure reports an error without rolling back the completed removal. @@ -113,6 +116,7 @@ remove(input: { - Verify each existing directory's `.rift` marker before deleting it. - Refuse removal if any descendant path is missing, because the registered active tree no longer matches the filesystem. - Move each removed rift from `/` to `/.trash/-` so custom `into` storage remains on the same filesystem. +- On Windows, if a move into trash fails with access denied or a sharing violation, retry it for about one second. If it still fails, fail with `InUse` and the path of the rift that could not be moved. - After successful filesystem moves, delete the active tree records and insert trash records for garbage collection. ### `list` @@ -146,6 +150,7 @@ gc(): AbsolutePath[] - On btrfs, attempt immediate subvolume deletion first. - If standard mount permissions deny deletion of a populated subvolume, delete its contents and remove the now-empty subvolume with ordinary directory removal. - On reflink-backed Linux filesystems, recursively remove the reflinked directory tree. +- On Windows, recursively remove the directory tree. If Windows denies the deletion, clear the read-only attribute throughout the tree without descending into reparse points, then remove the tree again. - Delete each trash registry record after its filesystem directory is successfully removed. - Delete active registry records whose filesystem directories were removed outside Rift only when no existing recorded descendant would be orphaned, and include pruned missing paths in the result. @@ -182,6 +187,7 @@ CREATE TABLE trash ( - The original registered workspace has `parent_id = NULL`. - A created rift has `parent_id` set to the source rift `id`. - `path` is its current location, not its identity. +- On Windows, `path` and every path the API returns omit the `\\?\` prefix unless the path cannot be written without it. - Provenance is a rooted tree. Descendants of any rift can be listed through recursive queries over `parent_id`. - `remove` moves a whole active subtree into trash, so no surviving active record depends on deleted ancestry. @@ -213,9 +219,11 @@ Copying is implemented behind a `Strategy` interface so platform-specific copy-o - The `BtrfsStrategy` performs native per-file reflink imports when `init` converts an existing ordinary workspace into a subvolume and when filtered `create` materializes only included paths. Exact `create` uses writable btrfs snapshots. - The `LinuxReflinkStrategy` production strategy on Linux verifies native reflink support during `init` and uses native per-file reflinks during `create` without spawning an external copy command. XFS uses this path, as do other Linux filesystems when their `FICLONE` support succeeds. - The `ApfsStrategy` production strategy on macOS uses APFS `clonefile` directory cloning for exact copies and per-entry cloning for filtered copies. +- The `RefsStrategy` production strategy on Windows runs a block-cloning probe during `init` and before each `create`. The probe uses the same routine as `create` to clone a file that spans several clusters, then compares the clone with its source, checks that a write to the clone leaves the source unchanged, and deletes both files. +- The `RefsStrategy` clones each file with `FSCTL_DUPLICATE_EXTENTS_TO_FILE`. Before each clone it flushes the source through a writable handle. For a read-only source, it clears the attribute for the flush and then restores it. If it cannot open a writable handle, it compares the clone with its source and fails `create` on any difference. +- The `RefsStrategy` copies alternate data streams by value, keeps hard links within the tree, recreates symbolic links and junctions with their original targets, and copies times and attributes. It refuses encrypted files and every other reparse point, such as cloud placeholder files, with `UnsupportedEntry`. - If no implemented copy-on-write strategy succeeds, `create` fails. - Full byte copying is not implemented as a fallback. -- Future strategies may add Windows copy-on-write support without changing the API. ## Packaging @@ -232,4 +240,4 @@ The npm launcher package temporarily publishes as `rift-snapshot` and bundles pr For CLI ergonomics, the primary workspace path for `rift init`, `rift create`, `rift remove`, `rift list`, and `rift ancestors` defaults to the current working directory when it is omitted. Workspace operations locate their root by searching upward for its `.rift` marker. The CLI applies similar selection before calling exact-path core `init`, unless `rift init --here` is explicitly requested. -The CLI may provide opt-in Bash, Zsh, and Nushell integration through `rift shell-init `. The resulting shell function delegates filesystem and registry operations to the executable, then changes the caller's working directory after `init`, `create`, or removal of the current rift. This shell behavior is not part of the native library or FFI APIs. +The CLI may provide opt-in Bash, Zsh, Nushell, and PowerShell integration through `rift shell-init `, where `pwsh` and `powershell` both select PowerShell. The resulting shell function delegates filesystem and registry operations to the executable, then changes the caller's working directory after `init`, `create`, or removal of the current rift. This shell behavior is not part of the native library or FFI APIs.