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/Cargo.lock b/Cargo.lock index 695c47f..70071fe 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", @@ -709,6 +716,7 @@ dependencies = [ "toml", "ulid", "walkdir", + "windows-sys", ] [[package]] @@ -716,6 +724,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/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/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..576463f 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 }}"#, ) } @@ -185,7 +221,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,8 +301,9 @@ 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 { + leave_trashed_directory(&manager, &at, &cwd, true, &[])?; let result = manager.remove_all_with_options( &at, RemoveOptions::default().hook_mode(if no_hooks { @@ -294,6 +331,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 +412,41 @@ 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 descendants = manager.descendants(at)?; + 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 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 { + 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); @@ -390,6 +463,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)) { @@ -431,6 +508,88 @@ 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_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"); + 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!( @@ -519,6 +678,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'#"); diff --git a/crates/cli/tests/windows_e2e.rs b/crates/cli/tests/windows_e2e.rs new file mode 100644 index 0000000..8b3661c --- /dev/null +++ b/crates/cli/tests/windows_e2e.rs @@ -0,0 +1,246 @@ +#![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) + ); + println!("windows e2e: create succeeded on {}", source.display()); +} + +#[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()); + println!( + "windows e2e: init and create printed {} without a verbatim prefix", + child.display() + ); +} + +#[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())); + println!( + "windows e2e: remove from inside moved {} to trash", + child.display() + ); +} + +#[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()); + println!("windows e2e: pwsh remove returned to {}", parent.display()); +} + +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) + ) +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 9819e79..487ef1d 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 @@ -19,6 +20,15 @@ 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", + "Win32_System_SystemServices", +] } + [dev-dependencies] tempfile.workspace = true 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/lib.rs b/crates/core/src/lib.rs index 2e4b878..e8bf303 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -11,14 +11,17 @@ 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; use id::RiftId; 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; @@ -67,6 +70,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}")] @@ -237,7 +242,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() @@ -522,11 +527,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 +663,46 @@ impl Manager { } } +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), +]; + +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_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()), + } + } +} + +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 { let base = dirs::data_local_dir() .ok_or_else(|| Error::Path("user data directory is unavailable".into()))?; @@ -665,7 +710,7 @@ fn default_database_path() -> Result { } fn existing_directory(path: &Path) -> Result { - let path = fs::canonicalize(path)?; + 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/name.rs b/crates/core/src/name.rs index 8c35012..4b4c860 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,28 @@ 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).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] = &[ "amber", "bold", "brisk", "calm", "cedar", "clear", "cobalt", "coral", "dawn", "ember", "gentle", "golden", "jade", "lively", "lunar", "mellow", "misty", "noble", "quiet", "rapid", @@ -60,6 +82,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()); 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/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..30fca2f --- /dev/null +++ b/crates/core/src/strategy/refs.rs @@ -0,0 +1,867 @@ +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::ffi::OsStringExt; +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}; +use walkdir::WalkDir; +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_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_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::{ + DUPLICATE_EXTENTS_DATA, FILE_SET_SPARSE_BUFFER, FSCTL_DUPLICATE_EXTENTS_TO_FILE, + FSCTL_GET_INTEGRITY_INFORMATION, FSCTL_GET_INTEGRITY_INFORMATION_BUFFER, + 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; + +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) + } + + 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)] +enum EntryKind { + Directory, + File, + Symlink { directory: bool }, + Junction, + Unsupported, +} + +impl EntryKind { + 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 { + 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 + } 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, + links: u32, +} + +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, + links: standard.NumberOfLinks, + }) + } +} + +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_without_following(path, FILE_READ_ATTRIBUTES)?)?.volume) +} + +fn clone_tree(from: &Path, to: &Path, filter: Option) -> Result<()> { + 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) + .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_without_following(source, GENERIC_READ)?; + let basic: FILE_BASIC_INFO = information(&file)?; + 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_final_metadata( + &open_without_following(&destination, FILE_WRITE_ATTRIBUTES)?, + &basic, + )?; + } + 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() + .read(true) + .write(true) + .create_new(true) + .open(destination)?; + if source.size > 0 { + clone_data(source, &target)?; + 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_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. +fn flush(path: &Path, attributes: u32) -> Result { + if attributes & FILE_ATTRIBUTE_READONLY == 0 { + return Ok(flush_writable(path)?); + } + 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 { + match OpenOptions::new() + .write(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + { + Ok(file) => file.sync_all().map(|()| Flush::Done), + Err(_) => Ok(Flush::Unavailable), + } +} + +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) + .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(()) +} + +// 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; + 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 + STREAM_NAME..offset + STREAM_NAME + 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_final_metadata( + &open_without_following(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_without_following(destination, GENERIC_WRITE)?; + control( + &junction, + FSCTL_SET_REPARSE_POINT, + &reparse[..length], + &mut (), + )?; + apply_final_metadata(&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; + +fn apply_final_metadata(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), + }, + ) +} + +// Volumes without POSIX delete semantics, such as plain ReFS on Windows Server 2022, refuse to +// 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() { + 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_without_following(entry.path(), FILE_WRITE_ATTRIBUTES)?, + attributes & !FILE_ATTRIBUTE_READONLY, + )?; + } + } + Ok(()) +} + +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 { + 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(()) +} + +// 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)) +} + +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) + .map(|index| (index % 251) as u8 + 1) + .collect::>(); + fs::write(source, &contents)?; + 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)?; + 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_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. + 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_without_following(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) +} + +/// # Safety +/// +/// `CLASS` replies with exactly `Self`'s layout. +unsafe trait Information: Default { + const CLASS: FILE_INFO_BY_HANDLE_CLASS; +} + +unsafe impl Information for FILE_BASIC_INFO { + const CLASS: FILE_INFO_BY_HANDLE_CLASS = FileBasicInfo; +} + +unsafe impl Information for FILE_STANDARD_INFO { + const CLASS: FILE_INFO_BY_HANDLE_CLASS = FileStandardInfo; +} + +unsafe impl Information for FILE_ID_INFO { + const CLASS: FILE_INFO_BY_HANDLE_CLASS = FileIdInfo; +} + +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: `Information` promises `T::CLASS` replies with exactly `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) +} + +/// # Safety +/// +/// Any byte pattern the OS writes is a valid value of `Self`. +unsafe trait Pod {} + +unsafe impl Pod for () {} + +unsafe impl Pod for [u8] {} + +unsafe 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, `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(), + 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::*; + 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_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] + 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/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..fed1d0c --- /dev/null +++ b/crates/core/src/test_support/windows_extents.rs @@ -0,0 +1,73 @@ +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, 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) { + 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); + } + } + // 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() + .filter(|extent| extent.Lcn != -1) + .map(|extent| extent.ReferenceCount) + .collect()) +} diff --git a/crates/core/src/tests.rs b/crates/core/src/tests.rs index ec46f85..dc789a3 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 { @@ -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) } @@ -110,7 +118,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()) @@ -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] @@ -1270,6 +1269,92 @@ 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)] +#[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)] +#[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(); + assert!(!text.starts_with(r"\\?\"), "{text}"); +} + #[cfg(unix)] fn running_as_root() -> bool { // SAFETY: geteuid has no preconditions and only reads the process identity. diff --git a/crates/core/src/windows_filesystem_tests.rs b/crates/core/src/windows_filesystem_tests.rs new file mode 100644 index 0000000..e31eed0 --- /dev/null +++ b/crates/core/src/windows_filesystem_tests.rs @@ -0,0 +1,645 @@ +use crate::strategy::default_strategy; +use crate::test_support::windows_extents::assert_shared_extents; +use crate::{CopyMode, Create, Error, InitOutcome, Manager}; +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, 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; +use windows_sys::core::GUID; + +const LARGE_FILE_SIZE: usize = (1 << 20) + 123; + +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 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); + git(&source, &["update-index", "-q", "--refresh"]); + 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")) + .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" + ); + 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::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")); + + 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} entries in {elapsed:?}", + child.display() + ); +} + +#[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"); + } + 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; \ + the same files again in {again:?}, {:?} per file", + elapsed / 64, + again / 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_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_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() { + 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() { + 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); + println!("init on NTFS: {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 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-") + .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 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(); + set_readonly(&source.join("readonly.txt")); + 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(); + 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") + .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 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", + &[ + "/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() +} + +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"); + 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() + .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() + ); +} 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" 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/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..659cce3 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,9 +16,17 @@ 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> = { @@ -43,11 +53,43 @@ 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. + 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 { + 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..8c1db31 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,43 @@ 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("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") }) + test("resolves a Windows executable without spawning the npm shim", () => { + 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", + 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" }, [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") + }) + test("keeps OpenCode inventory consistent after post-hook failures", async () => { const warnings: Array<{ directory: string; message: string }> = [] const strategy = makeStrategy( 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 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.