diff --git a/.github/workflows/e2e-rattler-vfs-tests.yml b/.github/workflows/e2e-rattler-vfs-tests.yml new file mode 100644 index 0000000000..39416147cf --- /dev/null +++ b/.github/workflows/e2e-rattler-vfs-tests.yml @@ -0,0 +1,113 @@ +on: + push: + branches: [main] + pull_request: + paths: + - crates/rattler_vfs/** + - scripts/e2e/rattler-vfs-mount.nu + - test-data/rattler-vfs/** + - .github/workflows/e2e-rattler-vfs-tests.yml + +name: E2E rattler-vfs Mount Tests + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + RUST_LOG: info + RUST_BACKTRACE: 1 + CARGO_TERM_COLOR: always + +jobs: + e2e-mount: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-14, windows-latest] + + env: + SCCACHE_GHA_ENABLED: "true" + + steps: + - name: Checkout source code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + submodules: recursive + + - uses: prefix-dev/setup-pixi@82d477f15f3a381dbcc8adc1206ce643fe110fb7 # v0.9.3 + with: + environments: rattler-vfs + + - name: Install FUSE (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y fuse3 + + - name: Install NFS client (Linux) + if: runner.os == 'Linux' + run: sudo apt-get install -y nfs-common + + - name: Enable ProjFS (Windows) + if: runner.os == 'Windows' + shell: powershell + run: Enable-WindowsOptionalFeature -Online -FeatureName Client-ProjFS -NoRestart + + # `rattler mount` resolves its package cache via dirs::cache_dir() + # (RATTLER_CACHE_DIR is unset here): ~/.cache on Linux, + # ~/Library/Caches on macOS, ~/AppData/Local on Windows. Only the + # path for the current OS exists; actions/cache ignores the others. + - name: Cache conda packages + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cache/rattler/cache/pkgs + ~/Library/Caches/rattler/cache/pkgs + ~/AppData/Local/rattler/cache/pkgs + key: rattler-vfs-pkgs-${{ runner.os }}-${{ hashFiles('test-data/rattler-vfs/pixi.lock') }} + restore-keys: | + rattler-vfs-pkgs-${{ runner.os }}- + + # Read-only tests (includes symlink, network failure, and shutdown tests) + - name: FUSE read-only + if: runner.os == 'Linux' + timeout-minutes: 15 + env: {TRANSPORT: fuse, OVERLAY: "false"} + run: pixi run -e rattler-vfs e2e-rattler-vfs + + - name: NFS read-only + if: runner.os != 'Windows' + timeout-minutes: 15 + env: {TRANSPORT: nfs, OVERLAY: "false"} + run: pixi run -e rattler-vfs e2e-rattler-vfs + + # Overlay tests (longer timeout: includes persistence, mismatch, shutdown, and stale mount tests) + - name: FUSE overlay + if: runner.os == 'Linux' + timeout-minutes: 15 + env: {TRANSPORT: fuse, OVERLAY: "true"} + run: pixi run -e rattler-vfs e2e-rattler-vfs + + - name: NFS overlay + if: runner.os != 'Windows' + timeout-minutes: 15 + env: {TRANSPORT: nfs, OVERLAY: "true"} + run: pixi run -e rattler-vfs e2e-rattler-vfs + + - name: ProjFS overlay + if: runner.os == 'Windows' + timeout-minutes: 15 + env: {TRANSPORT: projfs, OVERLAY: "true"} + run: pixi run -e rattler-vfs e2e-rattler-vfs + + - name: Cleanup mounts + if: always() + shell: bash + run: | + for mnt in "${RUNNER_TEMP:-/tmp}"/rattler-vfs-*; do + sudo umount -f "$mnt" 2>/dev/null || true + fusermount3 -u "$mnt" 2>/dev/null || true + done + pkill -9 -f 'rattler mount' 2>/dev/null || true + taskkill /F /IM rattler.exe 2>/dev/null || true diff --git a/Cargo.lock b/Cargo.lock index 7eb17ee60d..f54b7b7535 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,7 +130,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -141,7 +141,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1403,7 +1403,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1956,7 +1956,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2169,7 +2169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2368,6 +2368,26 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "fuser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80a5eca878900c2e39e9e52fd797954b7fc39eeefc8558257114bfea6a698fcf" +dependencies = [ + "bitflags 2.13.0", + "libc", + "log", + "memchr", + "nix", + "num_enum", + "page_size", + "parking_lot", + "pkg-config", + "ref-cast", + "smallvec", + "zerocopy", +] + [[package]] name = "futures" version = "0.3.32" @@ -3429,7 +3449,7 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1886916523694cd6ea3d175f03a1e5010699a2a4cc13696d83d7bea1d80638" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3641,6 +3661,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miette" version = "7.6.0" @@ -3762,6 +3791,38 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2970fbbc8c785e8246234a7bd004ed66cd1ed1a35ec73669a92545e419b836" +[[package]] +name = "nfs3_macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d8fb377e6efeb91911a8ed962a69615535e167e675025a2be8fa109751426a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "nfs3_server" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fa1515f86cd0b985e3ca7b8004d39a07e70298911cf39f09e403684c32bac8a" +dependencies = [ + "anyhow", + "nfs3_types", + "tokio", + "tracing", +] + +[[package]] +name = "nfs3_types" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5d5ab1a9fdfeab2f03b36b6300dea4ea3806e6a057cb479628b41d65e356b94" +dependencies = [ + "nfs3_macros", +] + [[package]] name = "nix" version = "0.30.1" @@ -3772,6 +3833,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", + "memoffset", ] [[package]] @@ -3817,7 +3879,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3926,6 +3988,28 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "oauth2" version = "5.0.0" @@ -5003,6 +5087,7 @@ dependencies = [ "rattler_cache", "rattler_conda_types", "rattler_index", + "rattler_lock", "rattler_menuinst", "rattler_networking", "rattler_package_streaming", @@ -5010,6 +5095,7 @@ dependencies = [ "rattler_shell", "rattler_solve", "rattler_upload", + "rattler_vfs", "rattler_virtual_packages", "regex", "reqwest", @@ -5083,6 +5169,7 @@ dependencies = [ "itertools 0.14.0", "jiff", "lazy-regex", + "memchr", "memmap2", "nom 8.0.0", "nom-language", @@ -5612,6 +5699,36 @@ dependencies = [ "url", ] +[[package]] +name = "rattler_vfs" +version = "0.1.0" +dependencies = [ + "anyhow", + "astral-reqwest-middleware", + "fs4", + "fuser", + "hex", + "libc", + "memchr", + "memmap2", + "nfs3_server", + "nfs3_types", + "rattler", + "rattler_cache", + "rattler_conda_types", + "rattler_lock", + "rattler_networking", + "reflink-copy", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "windows 0.62.2", +] + [[package]] name = "rattler_virtual_packages" version = "3.0.2" @@ -6037,7 +6154,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6095,7 +6212,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6860,7 +6977,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7093,7 +7210,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7112,7 +7229,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7966,7 +8083,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 081d29bc5d..6d2ff3538e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,7 @@ fs-err = { version = "3.1.0" } fslock = "0.2.1" futures = "0.3.31" futures-util = "0.3.31" +fuser = { version = "0.17.0", default-features = false } # lots of other crates are still stuck on older version which breaks `deserialize` # pinning to 0.14.7 because 0.14.8 introduces deprecation notices. generic-array = "=0.14.7" @@ -111,6 +112,7 @@ libc = { version = "0.2" } libloading = "0.9.0" libz-sys = { version = "1.1.22", default-features = false } md-5 = "0.11.0" +mem = "0.5.0" memchr = "2.7.4" memmap2 = "0.9.5" miette = "7.6.0" @@ -231,6 +233,7 @@ rattler_sandbox = { path = "crates/rattler_sandbox", version = "=0.2.23", defaul rattler_shell = { path = "crates/rattler_shell", version = "=0.27.8", default-features = false } rattler_solve = { path = "crates/rattler_solve", version = "=7.2.0", default-features = false } rattler_upload = { path = "crates/rattler_upload", version = "=0.8.2", default-features = false } +rattler_vfs = { path = "crates/rattler_vfs", version = "=0.1.0", default-features = false } rattler_virtual_packages = { path = "crates/rattler_virtual_packages", version = "=3.0.2", default-features = false } # This is also a rattler crate, but we only pin it to minor version diff --git a/crates/rattler-bin/Cargo.toml b/crates/rattler-bin/Cargo.toml index 6d58d86f55..70fe954e61 100644 --- a/crates/rattler-bin/Cargo.toml +++ b/crates/rattler-bin/Cargo.toml @@ -16,13 +16,14 @@ name = "rattler" path = "src/main.rs" [features] -default = ["rustls", "s3", "gcs", "oauth"] +default = ["rustls", "s3", "gcs", "oauth", "mount"] native-tls = [ "reqwest/native-tls", "rattler/native-tls", "rattler_repodata_gateway/native-tls", "rattler_networking/native-tls", "rattler_cache/native-tls", + "rattler_vfs?/native-tls", ] rustls = [ "reqwest/rustls", @@ -30,10 +31,14 @@ rustls = [ "rattler_repodata_gateway/rustls", "rattler_networking/rustls", "rattler_cache/rustls", + "rattler_vfs?/rustls", ] s3 = ["rattler_networking/s3", "rattler_upload/s3"] gcs = ["rattler_networking/gcs"] oauth = ["rattler/oauth"] +# `rattler mount` subcommand. Enabled by default; opt out with +# --no-default-features. +mount = ["dep:rattler_vfs", "dep:rattler_lock"] [dependencies] anyhow = { workspace = true } @@ -64,6 +69,8 @@ rattler_solve = { workspace = true, default-features = false, features = [ ] } rattler_virtual_packages = { workspace = true, default-features = false } rattler_cache = { workspace = true, default-features = false } +rattler_vfs = { workspace = true, optional = true, features = ["nfs"] } +rattler_lock = { workspace = true, optional = true } rattler_menuinst = { workspace = true, default-features = false } rattler_package_streaming = { workspace = true, default-features = false, features = [ "reqwest", diff --git a/crates/rattler-bin/src/commands/mod.rs b/crates/rattler-bin/src/commands/mod.rs index 191884743c..d2df77adf9 100644 --- a/crates/rattler-bin/src/commands/mod.rs +++ b/crates/rattler-bin/src/commands/mod.rs @@ -10,6 +10,8 @@ pub mod inspect; pub mod link; pub mod list; pub mod menu; +#[cfg(feature = "mount")] +pub mod mount; pub mod prefix; pub mod progress; pub mod run; diff --git a/crates/rattler-bin/src/commands/mount.rs b/crates/rattler-bin/src/commands/mount.rs new file mode 100644 index 0000000000..4179755087 --- /dev/null +++ b/crates/rattler-bin/src/commands/mount.rs @@ -0,0 +1,136 @@ +//! `rattler mount` — mount a lockfile as a virtual conda environment. +//! +//! Wraps the [`rattler_vfs`] library API. The library is the supported +//! integration point for downstream consumers (pixi); this subcommand exists +//! as a developer convenience for mounting a lockfile from the shell and as +//! the canonical CLI surface for the rattler binary. + +use std::path::PathBuf; + +use clap::Parser; +use miette::{IntoDiagnostic, Result}; +use rattler_cache::{default_cache_dir, package_cache::PackageCache}; +use rattler_conda_types::Platform; +use rattler_lock::{DEFAULT_ENVIRONMENT_NAME, LockFile}; +use rattler_vfs::{MountConfig, Transport, build_and_mount, compute_env_hash}; + +/// Mount a lockfile as a virtual conda environment. +#[derive(Debug, Parser)] +pub struct Opt { + /// Lock file to mount (e.g. `pixi.lock`). + pub lock_file: PathBuf, + + /// Where the virtual environment should appear. + pub mount_point: PathBuf, + + /// Environment name in the lock file. + #[clap(long, default_value = DEFAULT_ENVIRONMENT_NAME)] + pub environment: String, + + /// Persistent writable overlay directory. If omitted, the mount is read-only. + /// Required for `ProjFS` (which is always writable). + #[clap(long)] + pub overlay: Option, + + /// Transport backend. Defaults to the platform's preferred transport. + #[clap(long, value_enum, default_value_t = TransportArg::Auto)] + pub transport: TransportArg, + + /// Allow other users to access the mount (FUSE only; requires + /// `user_allow_other` in `/etc/fuse.conf`). + #[clap(long)] + pub allow_other: bool, +} + +/// Transport selector exposed on the CLI. Mirrors [`rattler_vfs::Transport`] +/// but with snake-case clap-friendly variants. +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum TransportArg { + /// Platform default: FUSE on Linux, NFS on macOS, `ProjFS` on Windows. + Auto, + /// FUSE via libfuse3 (Linux) or macFUSE (macOS). + Fuse, + /// `NFSv3` userspace server on localhost. + Nfs, + /// Windows Projected File System. + Projfs, +} + +impl From for Transport { + fn from(t: TransportArg) -> Self { + match t { + TransportArg::Auto => Transport::Auto, + TransportArg::Fuse => Transport::Fuse, + TransportArg::Nfs => Transport::Nfs, + TransportArg::Projfs => Transport::ProjFs, + } + } +} + +/// Convert any `anyhow::Error` (used by `rattler_vfs`) into a miette diagnostic. +fn anyhow_to_miette(e: anyhow::Error) -> miette::Report { + miette::miette!("{e}") +} + +pub async fn mount(opt: Opt) -> Result<()> { + let lockfile = LockFile::from_path(&opt.lock_file).into_diagnostic()?; + let platform = Platform::current(); + let env_hash = + compute_env_hash(&lockfile, &opt.environment, platform).map_err(anyhow_to_miette)?; + + let cache_dir = default_cache_dir().map_err(anyhow_to_miette)?.join("pkgs"); + let package_cache = PackageCache::new(cache_dir); + + // Absolutize rather than canonicalize: the mount point need not exist yet. + // Create it if missing, but refuse a path that already names a file. + let mount_point = std::path::absolute(&opt.mount_point).into_diagnostic()?; + if mount_point.is_file() { + miette::bail!( + "mount point {} is an existing file; expected a directory (or a path to create)", + mount_point.display() + ); + } + std::fs::create_dir_all(&mount_point).into_diagnostic()?; + let transport: Transport = opt.transport.into(); + + let config = match opt.overlay { + Some(overlay) => { + MountConfig::new_writable(mount_point.clone(), Some(overlay), transport, env_hash) + } + None => MountConfig::new_read_only(mount_point.clone(), transport, env_hash), + }; + let config = config.with_allow_other(opt.allow_other); + + let handle = build_and_mount( + &lockfile, + &opt.environment, + platform, + &package_cache, + &config, + ) + .await + .map_err(anyhow_to_miette)?; + + eprintln!( + "mounted {} at {}", + opt.lock_file.display(), + mount_point.display() + ); + + // Wait for ctrl-c (or sigterm on Unix) then explicitly unmount so any + // unmount failures surface as exit-1 instead of being swallowed by Drop. + #[cfg(unix)] + { + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .into_diagnostic()?; + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = sigterm.recv() => {}, + } + } + #[cfg(not(unix))] + tokio::signal::ctrl_c().await.into_diagnostic()?; + + handle.unmount().await.map_err(anyhow_to_miette)?; + Ok(()) +} diff --git a/crates/rattler-bin/src/main.rs b/crates/rattler-bin/src/main.rs index 141db8d627..aa97983ee1 100644 --- a/crates/rattler-bin/src/main.rs +++ b/crates/rattler-bin/src/main.rs @@ -62,6 +62,8 @@ enum Command { Link(commands::link::Opt), InjectIntoPrefix(commands::prefix::InjectOpt), RemoveFromPrefix(commands::prefix::RemoveFromPrefixOpt), + #[cfg(feature = "mount")] + Mount(commands::mount::Opt), Upload(Box), List(commands::list::Opt), Exec(commands::exec::Opt), @@ -132,6 +134,8 @@ async fn async_main() -> miette::Result<()> { Command::Link(opts) => commands::link::link(opts).await, Command::InjectIntoPrefix(opts) => commands::prefix::inject(opts).await, Command::RemoveFromPrefix(opts) => commands::prefix::remove_from_prefix(opts).await, + #[cfg(feature = "mount")] + Command::Mount(opts) => commands::mount::mount(opts).await, Command::Upload(opts) => { if offline { return Err(miette::miette!( diff --git a/crates/rattler/src/install/link.rs b/crates/rattler/src/install/link.rs index fc8f8540cc..346b3f4e6d 100644 --- a/crates/rattler/src/install/link.rs +++ b/crates/rattler/src/install/link.rs @@ -4,7 +4,7 @@ use fs_err as fs; use memmap2::Mmap; use once_cell::sync::Lazy; use rattler_conda_types::Platform; -use rattler_conda_types::package::{FileMode, PathType, PathsEntry, PrefixPlaceholder}; +use rattler_conda_types::package::{FileMode, Offsets, PathType, PathsEntry, PrefixPlaceholder}; use rattler_digest::Sha256; use rattler_digest::{HashingWriter, Sha256Hash}; use reflink_copy::reflink; @@ -176,6 +176,8 @@ pub fn link_file( let link_method = if let Some(PrefixPlaceholder { file_mode, placeholder, + offsets, + shebang_length, }) = path_json_entry.prefix_placeholder.as_ref() { // Memory map the source file. This provides us with easy access to a continuous stream of @@ -214,16 +216,66 @@ pub fn link_file( Cow::Borrowed(target_prefix) }; - // Replace the prefix placeholder in the file with the new placeholder - copy_and_replace_placeholders( - source.as_ref(), - &mut destination_writer, - placeholder, - &target_prefix, - &target_platform, - *file_mode, - ) - .map_err(|err| LinkFileError::IoError(String::from("replacing placeholders"), err))?; + // depending on the availability of the offsets + match offsets { + Some(offsets) => { + // The offsets/`shebang_length` come from the (untrusted) `paths.json`. If they are + // inconsistent with the file contents we do not fail the install: we fall back to + // the search-based path with a warning. The offset function guarantees it wrote + // nothing before reporting an inconsistency, so the fallback reuses the same + // (empty) destination. + match copy_and_replace_placeholders_with_offsets( + source.as_ref(), + &mut destination_writer, + placeholder, + &target_prefix, + &target_platform, + *file_mode, + offsets, + *shebang_length, + ) { + Ok(()) => {} + Err(OffsetReplaceError::InconsistentMetadata(reason)) => { + tracing::warn!( + "prefix replacement offsets for '{}' are inconsistent ({reason}); \ + falling back to search-based replacement", + path_json_entry.relative_path.display() + ); + copy_and_replace_placeholders( + source.as_ref(), + &mut destination_writer, + placeholder, + &target_prefix, + &target_platform, + *file_mode, + ) + .map_err(|err| { + LinkFileError::IoError(String::from("replacing placeholders"), err) + })?; + } + Err(OffsetReplaceError::Io(err)) => { + return Err(LinkFileError::IoError( + String::from("replacing placeholders"), + err, + )); + } + } + } + None => { + // Replace the prefix placeholder in the file with the new placeholder + copy_and_replace_placeholders( + source.as_ref(), + &mut destination_writer, + placeholder, + &target_prefix, + &target_platform, + *file_mode, + ) + .map_err(|err| { + LinkFileError::IoError(String::from("replacing placeholders"), err) + })?; + } + } let (mut file, current_hash) = destination_writer.finalize(); @@ -237,11 +289,13 @@ pub fn link_file( let metadata = fs::symlink_metadata(&source_path) .map_err(LinkFileError::FailedToReadSourceFileMetadata)?; + + let executable = has_executable_permissions(&metadata.permissions()); + // (re)sign the binary if the file is executable or is a Mach-O binary (e.g., dylib) // This is required for all macOS platforms because prefix replacement modifies the binary // content, which invalidates existing signatures. We need to preserve entitlements. - if (has_executable_permissions(&metadata.permissions()) - || file_type == Some(FileType::MachO)) + if (executable || file_type == Some(FileType::MachO)) && target_platform.is_osx() && *file_mode == FileMode::Binary { @@ -650,6 +704,99 @@ pub fn copy_and_replace_placeholders( Ok(()) } +/// Error returned by the offset-based prefix replacement functions +/// ([`copy_and_replace_placeholders_with_offsets`] and the specialized +/// text/binary variants it dispatches to). +/// +/// The `offsets` and `shebang_length` recorded in `paths.json` come from the +/// package producer and are not trusted. When they are inconsistent with the +/// file contents or with each other (see the CEP "Prefix placeholder offsets +/// in `paths.json`"), the install MUST NOT fail: the caller falls back to the +/// search-based replacement path. Genuine IO errors that occur while writing +/// the patched file are surfaced separately so they are not mistaken for +/// producer non-conformance. +/// +/// The offset functions guarantee that they write nothing to the destination +/// before returning [`OffsetReplaceError::InconsistentMetadata`]; this is what +/// lets the caller reuse the same (still empty) destination for the fallback. +#[derive(Debug, thiserror::Error)] +pub enum OffsetReplaceError { + /// The recorded `offsets`/`shebang_length` are inconsistent with the file + /// contents or with each other. Callers SHOULD fall back to search-based + /// replacement rather than failing the install. + #[error("inconsistent prefix replacement metadata: {0}")] + InconsistentMetadata(String), + + /// A genuine IO error occurred while writing the patched file. + #[error(transparent)] + Io(#[from] std::io::Error), +} + +impl OffsetReplaceError { + fn inconsistent(msg: impl Into) -> Self { + OffsetReplaceError::InconsistentMetadata(msg.into()) + } +} + +/// Given the contents of a file copy it to the `destination` and in the process replace the +/// `prefix_placeholder` text with the `target_prefix` text, using the offsets recorded in +/// `paths.json` instead of searching the file contents. +/// +/// This switches to more specialized functions that handle the replacement of either +/// textual and binary placeholders, the [`FileMode`] enum switches between the two functions. +/// See both [`copy_and_replace_cstring_placeholder_offsets`] and +/// [`copy_and_replace_textual_placeholder_offsets`]. +/// +/// `shebang_length` bounds the leading shebang region for text files and is ignored for binary +/// files. Returns [`OffsetReplaceError::InconsistentMetadata`] (having written nothing) when the +/// metadata does not match the file, so the caller can fall back to search-based replacement. +#[allow(clippy::too_many_arguments)] +pub fn copy_and_replace_placeholders_with_offsets( + source_bytes: &[u8], + mut destination: impl Write, + prefix_placeholder: &str, + target_prefix: &str, + target_platform: &Platform, + file_mode: FileMode, + offsets: &Offsets, + shebang_length: Option, +) -> Result<(), OffsetReplaceError> { + match (file_mode, offsets) { + (FileMode::Text, Offsets::Text(offsets)) => { + copy_and_replace_textual_placeholder_offsets( + source_bytes, + destination, + prefix_placeholder, + target_prefix, + target_platform, + offsets, + shebang_length, + )?; + } + (FileMode::Binary, Offsets::Binary(groups)) => { + // conda does not replace the prefix in the binary files on windows + // DLLs are loaded quite differently anyways (there is no rpath, for example). + if target_platform.is_windows() { + destination.write_all(source_bytes)?; + } else { + copy_and_replace_cstring_placeholder_offsets( + source_bytes, + destination, + prefix_placeholder, + target_prefix, + groups, + )?; + } + } + _ => { + return Err(OffsetReplaceError::inconsistent( + "offsets shape does not match file mode", + )); + } + } + Ok(()) +} + static SHEBANG_REGEX: Lazy = Lazy::new(|| { // ^(#! pretty much the whole match string // (?:[ ]*) allow spaces between #! and beginning of @@ -745,6 +892,64 @@ fn replace_shebang<'a>( } } +/// Transform the shebang region (the first `shebang_length` bytes of a text file) exactly as the +/// installer does when writing the patched file, returning the region's contribution to the output. +/// +/// On targets with shebang handling ([`Platform::is_unix`]) the region minus its trailing newline +/// is rewritten by `replace_shebang` (which may collapse an over-long line to the +/// `#!/usr/bin/env ` form) and the trailing newline, if present, is appended unchanged. On +/// other targets the region receives plain placeholder replacement (searching at most +/// `shebang_length` bytes). +/// +/// This is the single source of truth for how the shebang region is transformed, shared by the +/// install-time replacement here and the mount-time ranged reads in `rattler_vfs`, so the two stay +/// byte-identical. +pub fn replace_shebang_region( + region: &[u8], + prefix_placeholder: &str, + target_prefix: &str, + target_platform: &Platform, +) -> Vec { + if region.is_empty() { + return Vec::new(); + } + + if target_platform.is_unix() { + // Feed the region minus its trailing newline to the shebang rules; the newline byte, when + // present, is appended unchanged. + let has_newline = region[region.len() - 1] == b'\n'; + let line_end = if has_newline { + region.len() - 1 + } else { + region.len() + }; + let first_line = String::from_utf8_lossy(®ion[..line_end]); + let new_shebang = replace_shebang( + first_line, + (prefix_placeholder, target_prefix), + target_platform, + ); + let mut out = new_shebang.into_owned().into_bytes(); + if has_newline { + out.extend_from_slice(®ion[line_end..]); + } + out + } else { + // Non-rewriting target (e.g. Windows for a noarch package): plain placeholder replacement. + let old_prefix = prefix_placeholder.as_bytes(); + let new_prefix = target_prefix.as_bytes(); + let mut out = Vec::with_capacity(region.len()); + let mut last = 0; + for index in memchr::memmem::find_iter(region, old_prefix) { + out.extend_from_slice(®ion[last..index]); + out.extend_from_slice(new_prefix); + last = index + old_prefix.len(); + } + out.extend_from_slice(®ion[last..]); + out + } +} + /// Given the contents of a file copy it to the `destination` and in the process replace the /// `prefix_placeholder` text with the `target_prefix` text. /// @@ -766,9 +971,16 @@ pub fn copy_and_replace_textual_placeholder( // check if we have a shebang. We need to handle it differently because it has a maximum length // that can be exceeded in very long target prefix's. if target_platform.is_unix() && source_bytes.starts_with(b"#!") { - // extract first line - let (first, rest) = - source_bytes.split_at(source_bytes.iter().position(|&c| c == b'\n').unwrap_or(0)); + // Extract the first line. When the file has no newline the whole file is + // the shebang line; using the file length (rather than `0`) keeps the + // `#!` prefix in `first_line` so `replace_shebang`'s `starts_with("#!")` + // assertion holds instead of panicking. + let (first, rest) = source_bytes.split_at( + source_bytes + .iter() + .position(|&c| c == b'\n') + .unwrap_or(source_bytes.len()), + ); let first_line = String::from_utf8_lossy(first); let new_shebang = replace_shebang( first_line, @@ -796,6 +1008,154 @@ pub fn copy_and_replace_textual_placeholder( Ok(()) } +/// Writes `source[start..end]` to `destination`, returning an [`std::io::ErrorKind::InvalidData`] +/// error instead of panicking when the range is invalid (out of bounds or out of order). +/// +/// The offsets driving the prefix replacement come from a package's `paths.json`, which is not +/// trusted input. A malformed or malicious entry (an offset past the end of the file, or offsets +/// that are not sorted/overlapping) must surface as a recoverable error rather than crash the +/// process (which for e.g. a FUSE/NFS mount would take down the whole mount). +fn write_replacement_range( + destination: &mut W, + source: &[u8], + start: usize, + end: usize, +) -> Result<(), std::io::Error> { + let slice = source.get(start..end).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "invalid prefix replacement offsets: range {start}..{end} is out of bounds or out \ + of order for content of length {}", + source.len() + ), + ) + })?; + destination.write_all(slice) +} + +/// Given the contents of a file copy it to the `destination` and in the process replace the +/// `prefix_placeholder` text with the `target_prefix` text using the offsets from the `paths.json`. +/// +/// This is a text based version where the complete string is replaced. This works fine for text +/// files but will not work correctly for binary files where the length of the string is often +/// important. See [`copy_and_replace_cstring_placeholder_offsets`] when you are dealing with binary +/// content. +/// +/// `offsets` are absolute byte positions in `source_bytes` and, per the CEP, **exclude** any +/// occurrence inside the shebang region. Each listed offset is spliced uniformly. The shebang +/// region — the first `shebang_length` bytes, present exactly when the file starts with `#!` — is +/// handled separately: on targets with shebang handling ([`Platform::is_unix`]) the region minus +/// its trailing newline is rewritten by `replace_shebang` and the newline byte copied through +/// verbatim; on other targets the region gets plain placeholder replacement. +/// +/// The recorded metadata is validated before anything is written, so a mismatch surfaces as +/// [`OffsetReplaceError::InconsistentMetadata`] with an untouched destination the caller can hand +/// to search-based replacement. +pub fn copy_and_replace_textual_placeholder_offsets( + source_bytes: &[u8], + mut destination: impl Write, + prefix_placeholder: &str, + target_prefix: &str, + target_platform: &Platform, + offsets: &[usize], + shebang_length: Option, +) -> Result<(), OffsetReplaceError> { + let old_prefix = prefix_placeholder.as_bytes(); + let new_prefix = target_prefix.as_bytes(); + + // Determine the shebang region from the recorded `shebang_length` rather than re-deriving it + // from the file contents. Per the CEP `shebang_length` is present exactly when the file starts + // with `#!`, and its value is the offset of the first newline plus one (or the file size when + // there is no newline). The first `shebang_length` bytes form the shebang region. + let starts_with_shebang = source_bytes.starts_with(b"#!"); + let region_end = if starts_with_shebang { + let len = shebang_length.ok_or_else(|| { + OffsetReplaceError::inconsistent("file starts with #! but shebang_length is absent") + })?; + // Validate the recorded value against the actual contents. + let expected = source_bytes + .iter() + .position(|&c| c == b'\n') + .map_or(source_bytes.len(), |i| i + 1); + if len != expected { + return Err(OffsetReplaceError::inconsistent(format!( + "shebang_length {len} does not match the first newline position + 1 ({expected})" + ))); + } + len + } else { + if shebang_length.is_some() { + return Err(OffsetReplaceError::inconsistent( + "shebang_length present but the file does not start with #!", + )); + } + 0 + }; + + // Validate the offsets before writing anything so that, on inconsistent metadata, the caller + // can fall back to search-based replacement using the still-empty destination. Offsets must be + // in range, sorted in strictly increasing non-overlapping order, at or after the shebang + // region, and the placeholder bytes must actually be present at each one. + let mut prev_end = region_end; + for &offset in offsets { + if offset < region_end { + return Err(OffsetReplaceError::inconsistent(format!( + "offset {offset} lies inside the shebang region (< {region_end})" + ))); + } + if offset < prev_end { + return Err(OffsetReplaceError::inconsistent( + "offsets are not sorted in strictly increasing, non-overlapping order", + )); + } + let end = offset + .checked_add(old_prefix.len()) + .filter(|&end| end <= source_bytes.len()) + .ok_or_else(|| { + OffsetReplaceError::inconsistent(format!( + "offset {offset} is out of range for content of length {}", + source_bytes.len() + )) + })?; + if &source_bytes[offset..end] != old_prefix { + return Err(OffsetReplaceError::inconsistent(format!( + "placeholder bytes are not present at recorded offset {offset}" + ))); + } + prev_end = end; + } + + // --- The metadata is consistent; write the patched file. --- + + // Handle the shebang region via the shared helper, so that install-time and + // mount-time (rattler_vfs) replacement stay byte-identical. + if region_end > 0 { + let region_out = replace_shebang_region( + &source_bytes[..region_end], + prefix_placeholder, + target_prefix, + target_platform, + ); + destination.write_all(®ion_out)?; + } + + // Splice the recorded body offsets. + let mut last_match = region_end; + for &offset in offsets { + write_replacement_range(&mut destination, source_bytes, last_match, offset)?; + destination.write_all(new_prefix)?; + last_match = offset + old_prefix.len(); + } + + // Write any remaining bytes after the final replacement. + if last_match < source_bytes.len() { + destination.write_all(&source_bytes[last_match..])?; + } + + Ok(()) +} + /// Given the contents of a file, copies it to the `destination` and in the process replace any /// binary c-style string that contains the text `prefix_placeholder` with a binary compatible /// c-string where the `prefix_placeholder` text is replaced with the `target_prefix` text. @@ -871,6 +1231,133 @@ pub fn copy_and_replace_cstring_placeholder( } } +/// Given the contents & offsets of a file, copies it to the `destination` and in the process replace +/// any binary c-style string that contains the text `prefix_placeholder` with a binary compatible +/// c-string where the `prefix_placeholder` text is replaced with the `target_prefix` text. +/// +/// The length of the input will match the output. +/// +/// Offsets are grouped by c-string: each inner slice lists the prefix start +/// positions followed by the position of the NUL terminator, or the file size +/// when the final c-string is unterminated at end-of-file (the padding then +/// runs to EOF, still preserving the length). For example, `[[5, 39], [22, 30, +/// 39]]` means one c-string with the prefix at offset 5 (NUL at 39), and +/// another with prefixes at 22 and 30 (NUL at 39). +/// +/// The metadata is validated before anything is written, so a mismatch surfaces as +/// [`OffsetReplaceError::InconsistentMetadata`] with an untouched destination the caller can hand +/// to search-based replacement. +pub fn copy_and_replace_cstring_placeholder_offsets( + source_bytes: &[u8], + mut destination: impl Write, + prefix_placeholder: &str, + target_prefix: &str, + groups: &[Vec], +) -> Result<(), OffsetReplaceError> { + let old_prefix = prefix_placeholder.as_bytes(); + let new_prefix = target_prefix.as_bytes(); + + if new_prefix.len() > old_prefix.len() { + return Err(OffsetReplaceError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "target prefix cannot be longer than the placeholder prefix", + ))); + } + + // The binary form must list at least one c-string. + if groups.is_empty() { + return Err(OffsetReplaceError::inconsistent( + "binary offsets outer list is empty", + )); + } + + // Validate every group before writing so that, on inconsistent metadata, the caller can fall + // back to search-based replacement using the still-empty destination. Within each group the + // prefix offsets must be in range (before the terminator), sorted in strictly increasing + // non-overlapping order — also across groups — and the placeholder bytes must be present. + let mut prev_end = 0usize; + for group in groups { + // Each group lists the prefix offsets followed by the NUL terminator position. + let Some((&nul_pos, prefix_offsets)) = group.split_last() else { + return Err(OffsetReplaceError::inconsistent( + "binary offset group is empty", + )); + }; + if prefix_offsets.is_empty() { + return Err(OffsetReplaceError::inconsistent( + "binary offset group has no prefix offsets", + )); + } + if nul_pos > source_bytes.len() { + return Err(OffsetReplaceError::inconsistent(format!( + "NUL offset {nul_pos} is out of range for content of length {}", + source_bytes.len() + ))); + } + for &offset in prefix_offsets { + if offset < prev_end { + return Err(OffsetReplaceError::inconsistent( + "binary offsets are not sorted / c-string ranges overlap", + )); + } + let end = offset + .checked_add(old_prefix.len()) + .filter(|&end| end <= nul_pos) + .ok_or_else(|| { + OffsetReplaceError::inconsistent(format!( + "offset {offset} does not fit before its NUL terminator {nul_pos}" + )) + })?; + if &source_bytes[offset..end] != old_prefix { + return Err(OffsetReplaceError::inconsistent(format!( + "placeholder bytes are not present at recorded offset {offset}" + ))); + } + prev_end = end; + } + prev_end = nul_pos; + } + + // --- The metadata is consistent; write the patched file. --- + let length_change = old_prefix.len() - new_prefix.len(); + let mut last_pos = 0; + + for group in groups { + // Validated above: non-empty group, terminator in range, offsets ordered and in range. + let (&nul_pos, prefix_offsets) = + group.split_last().expect("group validated to be non-empty"); + + for &offset in prefix_offsets { + // Write bytes between last position and this prefix + write_replacement_range(&mut destination, source_bytes, last_pos, offset)?; + // Write the new prefix + destination.write_all(new_prefix)?; + // Advance past old prefix in source + last_pos = offset + old_prefix.len(); + } + + // Write remaining bytes from last prefix end to the NUL position (or EOF for an + // unterminated final c-string, where `nul_pos == source_bytes.len()`). + write_replacement_range(&mut destination, source_bytes, last_pos, nul_pos)?; + + // Pad with zeros to preserve total length. For an unterminated final c-string this padding + // runs to the end of the file. + let padding = prefix_offsets.len() * length_change; + if padding > 0 { + destination.write_all(&vec![0; padding])?; + } + + last_pos = nul_pos; + } + + // Write any remaining bytes after the last c-string + if last_pos < source_bytes.len() { + destination.write_all(&source_bytes[last_pos..])?; + } + + Ok(()) +} + fn symlink(source_path: &Path, destination_path: &Path) -> std::io::Result<()> { #[cfg(windows)] return fs_err::os::windows::fs::symlink_file(source_path, destination_path); @@ -966,6 +1453,8 @@ mod test { prefix_placeholder: Some(PrefixPlaceholder { file_mode: FileMode::Text, placeholder: "/old/placeholder/path".to_string(), + offsets: None, + shebang_length: None, }), sha256: None, size_in_bytes: None, @@ -1460,4 +1949,568 @@ mod test { ); assert_eq!(fs::read(&dest_path).unwrap(), b"hello world"); } + + #[rstest] + #[case("Hello, cruel world!", [7].to_vec(), "cruel", "fabulous", "Hello, fabulous world!")] + #[case( + "prefix_placeholder", + [0].to_vec(), + "prefix_placeholder", + "target_prefix", + "target_prefix" + )] + pub fn test_copy_and_replace_textual_placeholder_with_offsets( + #[case] input: &str, + #[case] offsets: Vec, + #[case] prefix_placeholder: &str, + #[case] target_prefix: &str, + #[case] expected_output: &str, + ) { + let mut output = Cursor::new(Vec::new()); + super::copy_and_replace_textual_placeholder_offsets( + input.as_bytes(), + &mut output, + prefix_placeholder, + target_prefix, + &Platform::Linux64, + &offsets, + None, + ) + .unwrap(); + assert_eq!( + &String::from_utf8_lossy(&output.into_inner()), + expected_output + ); + } + + /// Records only the body occurrences in `offsets`, filtering out the ones inside the shebang + /// region — exactly what a CEP-conformant producer emits. + fn conformant_text_offsets(input: &[u8], placeholder: &str) -> (Vec, Option) { + let shebang_length = input.starts_with(b"#!").then(|| { + input + .iter() + .position(|&c| c == b'\n') + .map_or(input.len(), |i| i + 1) + }); + let region_end = shebang_length.unwrap_or(0); + let offsets = memchr::memmem::find_iter(input, placeholder.as_bytes()) + .filter(|&o| o >= region_end) + .collect(); + (offsets, shebang_length) + } + + /// CEP test vector 1: a Unix target with a short target prefix. The occurrence inside the + /// shebang line (excluded from `offsets`) is rewritten by the shebang rules and, being short + /// enough, the patched line is kept; the body occurrence is spliced at its recorded offset. + #[test] + fn test_textual_offsets_shebang_kept_short_prefix() { + let prefix_placeholder = "/this/is/placeholder"; + let target_prefix = "/opt/conda"; + let input = + format!("#!{prefix_placeholder}/python\nimport sys # see {prefix_placeholder}/lib\n") + .into_bytes(); + + let (offsets, shebang_length) = conformant_text_offsets(&input, prefix_placeholder); + assert_eq!(offsets.len(), 1, "only the body occurrence is recorded"); + assert_eq!(shebang_length, Some(30)); + + let mut output = Cursor::new(Vec::new()); + super::copy_and_replace_textual_placeholder_offsets( + &input, + &mut output, + prefix_placeholder, + target_prefix, + &Platform::Linux64, + &offsets, + shebang_length, + ) + .unwrap(); + + let expected = format!("#!{target_prefix}/python\nimport sys # see {target_prefix}/lib\n"); + assert_eq!(String::from_utf8_lossy(&output.into_inner()), expected); + } + + /// CEP test vector 3: a non-rewriting target (Windows, e.g. a `noarch` package) with an + /// occurrence inside the shebang region. There is no shebang machinery, so the region MUST get + /// plain placeholder replacement even though its occurrence is not in `offsets`. + #[test] + fn test_textual_offsets_shebang_windows_plain_region() { + let prefix_placeholder = "/this/is/placeholder"; + let target_prefix = "/opt/conda"; + let input = + format!("#!{prefix_placeholder}/python\nimport sys # see {prefix_placeholder}/lib\n") + .into_bytes(); + + let (offsets, shebang_length) = conformant_text_offsets(&input, prefix_placeholder); + + let mut output = Cursor::new(Vec::new()); + super::copy_and_replace_textual_placeholder_offsets( + &input, + &mut output, + prefix_placeholder, + target_prefix, + &Platform::Win64, + &offsets, + shebang_length, + ) + .unwrap(); + + // The shebang region's occurrence is replaced by the plain in-region path, not left behind. + let expected = format!("#!{target_prefix}/python\nimport sys # see {target_prefix}/lib\n"); + assert_eq!(String::from_utf8_lossy(&output.into_inner()), expected); + } + + /// CEP test vector 4: a shebang file with no trailing newline, where `shebang_length` equals + /// the file size. The whole file is the shebang line and there is no newline to copy through. + #[test] + fn test_textual_offsets_shebang_no_trailing_newline() { + let prefix_placeholder = "/this/is/placeholder"; + let target_prefix = "/opt/conda"; + let input = format!("#!{prefix_placeholder}/python").into_bytes(); + + let (offsets, shebang_length) = conformant_text_offsets(&input, prefix_placeholder); + assert!(offsets.is_empty(), "the only occurrence is in the shebang"); + assert_eq!(shebang_length, Some(input.len())); + + let mut output = Cursor::new(Vec::new()); + super::copy_and_replace_textual_placeholder_offsets( + &input, + &mut output, + prefix_placeholder, + target_prefix, + &Platform::Linux64, + &offsets, + shebang_length, + ) + .unwrap(); + + assert_eq!( + String::from_utf8_lossy(&output.into_inner()), + format!("#!{target_prefix}/python") + ); + } + + /// CEP test vector 5: a file whose only occurrence is in the shebang line, so `offsets` is the + /// empty list. The shebang is short enough to keep. + #[test] + fn test_textual_offsets_only_shebang_occurrence_empty_offsets() { + let prefix_placeholder = "/this/is/placeholder"; + let target_prefix = "/opt/conda"; + let input = format!("#!{prefix_placeholder}/python\nimport sys\n").into_bytes(); + + let (offsets, shebang_length) = conformant_text_offsets(&input, prefix_placeholder); + assert!(offsets.is_empty()); + + let mut output = Cursor::new(Vec::new()); + super::copy_and_replace_textual_placeholder_offsets( + &input, + &mut output, + prefix_placeholder, + target_prefix, + &Platform::Linux64, + &offsets, + shebang_length, + ) + .unwrap(); + + assert_eq!( + String::from_utf8_lossy(&output.into_inner()), + format!("#!{target_prefix}/python\nimport sys\n") + ); + } + + /// CEP test vector 6: multiple occurrences within one shebang line. All of them are in the + /// region (so `offsets` is empty) and the shebang rules replace them all. + #[test] + fn test_textual_offsets_multiple_occurrences_in_shebang() { + let prefix_placeholder = "/this/is/placeholder"; + let target_prefix = "/opt/conda"; + let input = + format!("#!{prefix_placeholder}/python -S {prefix_placeholder}/site\nprint(1)\n") + .into_bytes(); + + let (offsets, shebang_length) = conformant_text_offsets(&input, prefix_placeholder); + assert!( + offsets.is_empty(), + "both occurrences are inside the shebang line" + ); + + let mut output = Cursor::new(Vec::new()); + super::copy_and_replace_textual_placeholder_offsets( + &input, + &mut output, + prefix_placeholder, + target_prefix, + &Platform::Linux64, + &offsets, + shebang_length, + ) + .unwrap(); + + assert_eq!( + String::from_utf8_lossy(&output.into_inner()), + format!("#!{target_prefix}/python -S {target_prefix}/site\nprint(1)\n") + ); + } + + /// CEP test vector 7: a shebang line longer than the kernel limit that contains no occurrence + /// of the placeholder. `shebang_length` is still present (the file starts with `#!`) and the + /// over-long line collapses to the `#!/usr/bin/env ` form regardless. + #[test] + fn test_textual_offsets_overlong_shebang_no_occurrence() { + let prefix_placeholder = "/this/is/placeholder"; + let target_prefix = "/opt/conda"; + let long_shebang = "#!/this/is/loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong/executable -o test -x"; + assert!(long_shebang.len() > 127); + let input = format!("{long_shebang}\nprint(1)\n").into_bytes(); + + let (offsets, shebang_length) = conformant_text_offsets(&input, prefix_placeholder); + assert!(offsets.is_empty(), "the placeholder does not occur at all"); + assert_eq!(shebang_length, Some(long_shebang.len() + 1)); + + let mut output = Cursor::new(Vec::new()); + super::copy_and_replace_textual_placeholder_offsets( + &input, + &mut output, + prefix_placeholder, + target_prefix, + &Platform::Linux64, + &offsets, + shebang_length, + ) + .unwrap(); + + assert_eq!( + String::from_utf8_lossy(&output.into_inner()), + "#!/usr/bin/env executable -o test -x\nprint(1)\n" + ); + } + + /// A CEP-conformant producer never lists a shebang-region occurrence in `offsets`. If a + /// non-conformant producer does, the offset function reports inconsistent metadata (writing + /// nothing) so the installer falls back to search-based replacement — which produces the same + /// bytes. + #[test] + fn test_textual_offsets_shebang_occurrence_in_offsets_is_inconsistent() { + let prefix_placeholder = "/this/is/placeholder"; + let target_prefix = "/opt/conda"; + let input = + format!("#!{prefix_placeholder}/python\nimport sys # see {prefix_placeholder}/lib\n") + .into_bytes(); + let shebang_length = input.iter().position(|&c| c == b'\n').unwrap() + 1; + + // Non-conformant: lists BOTH occurrences, including the in-region one at offset 2. + let non_conformant: Vec = + memchr::memmem::find_iter(&input, prefix_placeholder.as_bytes()).collect(); + assert_eq!(non_conformant.len(), 2); + + let mut output = Cursor::new(Vec::new()); + let result = super::copy_and_replace_textual_placeholder_offsets( + &input, + &mut output, + prefix_placeholder, + target_prefix, + &Platform::Linux64, + &non_conformant, + Some(shebang_length), + ); + assert!( + matches!( + result, + Err(super::OffsetReplaceError::InconsistentMetadata(_)) + ), + "in-region offset must be rejected: {result:?}" + ); + assert!( + output.into_inner().is_empty(), + "nothing is written, so the fallback starts from a clean destination" + ); + + // The search-based fallback produces the correct bytes. + let mut fallback = Cursor::new(Vec::new()); + super::copy_and_replace_textual_placeholder( + &input, + &mut fallback, + prefix_placeholder, + target_prefix, + &Platform::Linux64, + ) + .unwrap(); + let expected = format!("#!{target_prefix}/python\nimport sys # see {target_prefix}/lib\n"); + assert_eq!(String::from_utf8_lossy(&fallback.into_inner()), expected); + } + + /// A file that starts with `#!` but carries no `shebang_length` is producer non-conformance and + /// must be reported as inconsistent metadata rather than mishandled. + #[test] + fn test_textual_offsets_shebang_length_absent_is_inconsistent() { + let mut output = Cursor::new(Vec::new()); + let result = super::copy_and_replace_textual_placeholder_offsets( + b"#!/this/is/placeholder/python\n", + &mut output, + "/this/is/placeholder", + "/opt/conda", + &Platform::Linux64, + &[], + None, + ); + assert!( + matches!( + result, + Err(super::OffsetReplaceError::InconsistentMetadata(_)) + ), + "{result:?}" + ); + assert!(output.into_inner().is_empty()); + } + + /// A `shebang_length` that disagrees with the first-newline position is inconsistent. + #[test] + fn test_textual_offsets_shebang_length_mismatch_is_inconsistent() { + let mut output = Cursor::new(Vec::new()); + let result = super::copy_and_replace_textual_placeholder_offsets( + b"#!/this/is/placeholder/python\nbody\n", + &mut output, + "/this/is/placeholder", + "/opt/conda", + &Platform::Linux64, + &[], + Some(20), // the correct value is 30 + ); + assert!( + matches!( + result, + Err(super::OffsetReplaceError::InconsistentMetadata(_)) + ), + "{result:?}" + ); + assert!(output.into_inner().is_empty()); + } + + /// Regression for the search-based path: a `#!` file with no trailing newline must not panic. + /// Previously the missing newline yielded an empty "first line", tripping an assertion inside + /// `replace_shebang`. + #[test] + fn test_scan_path_shebang_without_newline_does_not_panic() { + let mut output = Cursor::new(Vec::new()); + super::copy_and_replace_textual_placeholder( + b"#!/this/is/placeholder/python", + &mut output, + "/this/is/placeholder", + "/opt/conda", + &Platform::Linux64, + ) + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&output.into_inner()), + "#!/opt/conda/python" + ); + } + + /// CEP test vector 8: a binary file whose final C string is unterminated at end-of-file. The + /// group's last value is the file size and the length-preserving padding runs to EOF. + #[test] + fn test_binary_offsets_unterminated_final_cstring() { + let input = b"AAAA/placeholder"; + let groups = vec![vec![4, input.len()]]; + + let mut output = Cursor::new(Vec::new()); + super::copy_and_replace_cstring_placeholder_offsets( + input, + &mut output, + "/placeholder", + "/opt", + &groups, + ) + .unwrap(); + + let out = output.into_inner(); + assert_eq!(out, b"AAAA/opt\0\0\0\0\0\0\0\0"); + assert_eq!(out.len(), input.len(), "length must be preserved"); + } + + /// Offsets come from the (untrusted) `paths.json`. Malformed offsets must return a recoverable + /// error rather than panic and take down the caller (e.g. a FUSE/NFS read thread). + #[rstest] + // Offset past the end of the file. + #[case(vec![1000])] + // Offsets out of order (second starts before the first prefix ends). + #[case(vec![7, 0])] + fn test_textual_offsets_invalid_returns_error(#[case] offsets: Vec) { + let mut output = Cursor::new(Vec::new()); + let result = super::copy_and_replace_textual_placeholder_offsets( + b"Hello, cruel world!", + &mut output, + "cruel", + "fabulous", + &Platform::Linux64, + &offsets, + None, + ); + assert!( + matches!( + result, + Err(super::OffsetReplaceError::InconsistentMetadata(_)) + ), + "malformed offsets should surface as inconsistent metadata, not a panic: {result:?}" + ); + // Nothing must be written when the metadata is rejected, so the caller can reuse the + // destination for search-based replacement. + assert!(output.into_inner().is_empty()); + } + + /// Malformed binary offset groups must also return an error rather than panic (empty group, + /// out-of-range NUL position, ...). + #[rstest] + // Empty group would underflow `group.len() - 1`. + #[case(vec![vec![]])] + // Prefix offset and NUL position beyond the end of the file. + #[case(vec![vec![1000, 2000]])] + fn test_binary_offsets_invalid_returns_error(#[case] groups: Vec>) { + let mut output = Cursor::new(Vec::new()); + let result = super::copy_and_replace_cstring_placeholder_offsets( + b"12345Hello, fabulous world!\x006789", + &mut output, + "fabulous", + "cruel", + &groups, + ); + assert!( + matches!( + result, + Err(super::OffsetReplaceError::InconsistentMetadata(_)) + ), + "malformed offsets should surface as inconsistent metadata, not a panic: {result:?}" + ); + // Nothing must be written when the metadata is rejected. + assert!(output.into_inner().is_empty()); + } + + #[rstest] + // The NUL terminator sits at offset 27 (the `\x00` byte), not 28. The last value of the group + // is the NUL position, per the CEP. + #[case( + b"12345Hello, fabulous world!\x006789", + vec![vec![12, 27]], + "fabulous", + "cruel", + b"12345Hello, cruel world!\x00\x00\x00\x006789" + )] + pub fn test_copy_and_replace_binary_placeholder_offsets( + #[case] input: &[u8], + #[case] groups: Vec>, + #[case] prefix_placeholder: &str, + #[case] target_prefix: &str, + #[case] expected_output: &[u8], + ) { + assert_eq!( + expected_output.len(), + input.len(), + "input and expected output must have the same length" + ); + let mut output = Cursor::new(Vec::new()); + super::copy_and_replace_cstring_placeholder_offsets( + input, + &mut output, + prefix_placeholder, + target_prefix, + &groups, + ) + .unwrap(); + assert_eq!(&output.into_inner(), expected_output); + } + + #[rstest] + #[case(b"short\x00", vec![vec![0, 5]], "short", "verylong")] + #[case(b"short1234\x00", vec![vec![0, 9]], "short", "verylong")] + pub fn test_shorter_binary_placeholder_offsets( + #[case] input: &[u8], + #[case] groups: Vec>, + #[case] prefix_placeholder: &str, + #[case] target_prefix: &str, + ) { + assert!(target_prefix.len() > prefix_placeholder.len()); + + let mut output = Cursor::new(Vec::new()); + let result = super::copy_and_replace_cstring_placeholder_offsets( + input, + &mut output, + prefix_placeholder, + target_prefix, + &groups, + ); + assert!(result.is_err()); + } + + #[rstest] + #[case( + b"beginrandomdataPATH=/placeholder/etc/share:/placeholder/bin/:\x00somemoretext", + b"beginrandomdataPATH=/target/etc/share:/target/bin/:\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00somemoretext", + vec![vec![20, 43, 61]] + )] + #[case( + b"beginrandomdataPATH=/placeholder/etc/share:/placeholder/bin/another/placeholder/:\x00somemoretext", + b"beginrandomdataPATH=/target/etc/share:/target/bin/another/target/:\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00somemoretext", + vec![vec![20, 43, 67, 81]], + )] + fn replace_binary_path_var_offsets( + #[case] input: &[u8], + #[case] result: &[u8], + #[case] groups: Vec>, + ) { + let mut output = Cursor::new(Vec::new()); + super::copy_and_replace_cstring_placeholder_offsets( + input, + &mut output, + "/placeholder", + "/target", + &groups, + ) + .unwrap(); + let out = &output.into_inner(); + assert_eq!(out, result); + assert_eq!(out.len(), input.len()); + } + + /// CEP test vectors 2 and 5: the placeholder occurs only inside the shebang line, so a + /// conformant producer records `offsets: []`. With a target prefix well over the 127-byte + /// Linux limit the patched shebang collapses to the `#!/usr/bin/env ` form. + #[test] + fn test_replace_long_prefix_in_text_file_offsets() { + let test_data_dir = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../test-data"); + let test_file = test_data_dir.join("shebang_test.txt"); + let prefix_placeholder = "/this/is/placeholder"; + let mut target_prefix = "/super/long/".to_string(); + for _ in 0..15 { + target_prefix.push_str("verylongstring/"); + } + let input = fs::read(test_file).unwrap(); + + // The only occurrence is inside the shebang region, so `offsets` is empty. + // Derive `shebang_length` (first-newline index + 1) from the file rather than + // hardcoding it, so the test is robust to a CRLF checkout on Windows — where the + // extra carriage return shifts the newline and thus the region length. + let offsets: Vec = Vec::new(); + let shebang_length = input + .iter() + .position(|&c| c == b'\n') + .map_or(input.len(), |i| i + 1); + + let mut output = Cursor::new(Vec::new()); + super::copy_and_replace_textual_placeholder_offsets( + &input, + &mut output, + prefix_placeholder, + &target_prefix, + &Platform::Linux64, + &offsets, + Some(shebang_length), + ) + .unwrap(); + + let output = output.into_inner(); + let replaced = String::from_utf8_lossy(&output); + insta::assert_snapshot!(replaced); + } } diff --git a/crates/rattler/src/install/snapshots/rattler__install__link__test__replace_long_prefix_in_text_file_offsets.snap b/crates/rattler/src/install/snapshots/rattler__install__link__test__replace_long_prefix_in_text_file_offsets.snap new file mode 100644 index 0000000000..042dbb8a0e --- /dev/null +++ b/crates/rattler/src/install/snapshots/rattler__install__link__test__replace_long_prefix_in_text_file_offsets.snap @@ -0,0 +1,14 @@ +--- +source: crates/rattler/src/install/link.rs +assertion_line: 1427 +expression: replaced +--- +#!/usr/bin/env executable -m 123123 + +# just a comment + +import sys +import re + +main(...) + diff --git a/crates/rattler_conda_types/Cargo.toml b/crates/rattler_conda_types/Cargo.toml index 9bf29ef28c..2221714ef7 100644 --- a/crates/rattler_conda_types/Cargo.toml +++ b/crates/rattler_conda_types/Cargo.toml @@ -27,6 +27,7 @@ itertools = { workspace = true } lazy-regex = { workspace = true } nom = { workspace = true } nom-language = { workspace = true } +memchr = { workspace = true } purl = { workspace = true, features = ["serde"] } rattler_digest = { workspace = true, default-features = false, features = [ "serde", diff --git a/crates/rattler_conda_types/src/package/mod.rs b/crates/rattler_conda_types/src/package/mod.rs index a8029a5a95..31ee1c46ef 100644 --- a/crates/rattler_conda_types/src/package/mod.rs +++ b/crates/rattler_conda_types/src/package/mod.rs @@ -29,7 +29,7 @@ pub use { no_link::NoLink, no_softlink::NoSoftlink, package_metadata::PackageMetadata, - paths::{FileMode, PathType, PathsEntry, PathsJson, PrefixPlaceholder}, + paths::{FileMode, Offsets, PathType, PathsEntry, PathsJson, PrefixPlaceholder}, run_exports::RunExportsJson, }; diff --git a/crates/rattler_conda_types/src/package/paths.rs b/crates/rattler_conda_types/src/package/paths.rs index 6c8af3e0a3..3583fdab00 100644 --- a/crates/rattler_conda_types/src/package/paths.rs +++ b/crates/rattler_conda_types/src/package/paths.rs @@ -121,6 +121,8 @@ impl PathsJson { prefix_placeholder: prefix.map(|entry| PrefixPlaceholder { file_mode: entry.file_mode, placeholder: (*entry.prefix).to_owned(), + offsets: None, + shebang_length: None, }), no_link: no_link.contains(&path), sha256: None, @@ -187,6 +189,47 @@ pub struct PrefixPlaceholder { /// was build. #[serde(rename = "prefix_placeholder")] pub placeholder: String, + + /// Byte offsets, from the beginning of the file, at which the placeholder + /// occurs in the file contents as stored in the package (before any + /// replacement). + /// + /// For text-mode files this is a flat list of byte positions: + /// `[offset1, offset2, ...]` + /// + /// For binary-mode files this is grouped by c-string. Each inner + /// array lists the prefix offsets followed by the position of the + /// NUL terminator, or the file size when the final c-string is + /// unterminated at end-of-file: + /// `[[prefix1, nul], [prefix2, prefix3, nul], ...]` + /// + /// Occurrences inside the shebang region (the first + /// [`Self::shebang_length`] bytes) are **excluded** — that region is + /// transformed by the installer's shebang rules rather than by plain + /// splicing at these offsets — so every value is greater than or equal to + /// `shebang_length` when it is present. + /// + /// `None` for older packages or packages whose publisher did not + /// populate the field — callers must scan the file themselves in + /// that case. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offsets: Option, + + /// The length in bytes of the file's shebang region: the first line + /// including its terminating newline, or the whole file size when the + /// file contains no newline. + /// + /// Present if and only if [`Self::offsets`] is present, `file_mode` is + /// [`FileMode::Text`], and the file starts with the bytes `#!` — + /// regardless of whether the first line itself contains the placeholder, + /// because installers collapse an over-long shebang even when it does not. + /// + /// Lets consumers compute the exact post-replacement file size and locate + /// the region an installer transforms under its shebang rules without + /// rendering the whole file. `None` for binary-mode placeholders, text + /// files that do not start with a shebang, or older packages. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shebang_length: Option, } /// A single entry in the `paths.json` file. @@ -217,17 +260,44 @@ pub struct PathsEntry { pub prefix_placeholder: Option, /// A hex representation of the SHA256 hash of the contents of the file. - /// This entry is only present in version 1 of the paths.json file. + /// This entry is present in version 1 and up of the paths.json file. #[serde_as(as = "Option>")] #[serde(default, skip_serializing_if = "Option::is_none")] pub sha256: Option, /// The size of the file in bytes - /// This entry is only present in version 1 of the paths.json file. + /// This entry is present in version 1 and up of the paths.json file. #[serde(default, skip_serializing_if = "Option::is_none")] pub size_in_bytes: Option, } +/// Byte offsets where the prefix placeholder appears in a file. +/// +/// The format depends on the file mode: +/// - **Text**: a flat list of byte positions (`[10, 45, 100]`). +/// - **Binary**: grouped by c-string — each inner array lists the prefix +/// offsets followed by the position of the NUL terminator, or the file size +/// when the final c-string is unterminated at end-of-file +/// (`[[5, 39], [22, 30, 39]]`). +/// +/// Occurrences inside the shebang region (the first +/// [`PrefixPlaceholder::shebang_length`] bytes) are excluded; the installer +/// transforms that region under its own shebang rules. +/// +/// The shape is determined normatively by `file_mode`, not inferred from the +/// JSON structure: an empty text list and an empty binary list are +/// indistinguishable. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)] +#[serde(untagged)] +pub enum Offsets { + /// Text-mode offsets: flat list of byte positions where the placeholder + /// appears. + Text(Vec), + /// Binary-mode offsets: grouped by c-string. Each inner array contains + /// the prefix start positions followed by the NUL terminator position. + Binary(Vec>), +} + /// The file mode of the entry #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)] #[serde(rename_all = "lowercase")] @@ -262,9 +332,9 @@ fn is_no_link_default(value: &bool) -> bool { #[cfg(test)] mod test { - use crate::package::PackageFile; + use crate::package::{PackageFile, PrefixPlaceholder}; - use super::{PathsEntry, PathsJson}; + use super::{FileMode, Offsets, PathBuf, PathType, PathsEntry, PathsJson}; #[test] pub fn roundtrip_paths_json() { @@ -348,4 +418,196 @@ mod test { paths_version: 1 }); } + + #[test] + pub fn test_deserialize_paths_json_with_offsets() { + let package_dir = tempfile::tempdir().unwrap(); + let info_dir = package_dir.path().join("info"); + std::fs::create_dir_all(&info_dir).unwrap(); + + // Create a mock paths.json with offset fields + let paths_json = r#"{ + "paths": [ + { + "_path": "bin/example", + "no_link": false, + "path_type": "hardlink", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size_in_bytes": 1024, + "file_mode": "binary", + "prefix_placeholder": "/opt/conda", + "offsets": [[100, 500], [200, 300, 800]] + }, + { + "_path": "lib/library.so", + "no_link": false, + "path_type": "hardlink", + "sha256": "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", + "size_in_bytes": 2048 + }, + { + "_path": "share/doc/readme.txt", + "no_link": false, + "path_type": "hardlink", + "sha256": "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3", + "size_in_bytes": 256, + "file_mode": "text", + "prefix_placeholder": "/home/builder/conda", + "offsets": [10, 45] + }, + { + "_path": "bin/symlink-example", + "no_link": false, + "path_type": "softlink" + } + ], + "paths_version": 1 + }"#; + + // Write the mock paths.json + std::fs::write(info_dir.join("paths.json"), paths_json).unwrap(); + + // Test loading it + let paths_json = + PathsJson::from_package_directory_with_deprecated_fallback(package_dir.path()).unwrap(); + + assert_eq!(paths_json.paths_version, 1); + assert_eq!(paths_json.paths.len(), 4); + + // First entry: binary with offsets + assert_eq!( + paths_json.paths[0].relative_path, + PathBuf::from("bin/example") + ); + assert_eq!(paths_json.paths[0].size_in_bytes, Some(1024)); + let prefix = paths_json.paths[0].prefix_placeholder.as_ref().unwrap(); + assert_eq!(prefix.file_mode, FileMode::Binary); + assert_eq!( + prefix.offsets, + Some(Offsets::Binary(vec![vec![100, 500], vec![200, 300, 800]])) + ); + + // Second entry: no prefix placeholder + assert!(paths_json.paths[1].prefix_placeholder.is_none()); + + // Third entry: text with offsets + let text_prefix = paths_json.paths[2].prefix_placeholder.as_ref().unwrap(); + assert_eq!(text_prefix.file_mode, FileMode::Text); + assert_eq!(text_prefix.offsets, Some(Offsets::Text(vec![10, 45]))); + + // Fourth entry: symlink, no offsets + assert_eq!(paths_json.paths[3].path_type, PathType::SoftLink); + assert!(paths_json.paths[3].prefix_placeholder.is_none()); + + insta::assert_yaml_snapshot!(paths_json); + } + + #[test] + pub fn test_optional_fields_handling() { + let package_dir = tempfile::tempdir().unwrap(); + let info_dir = package_dir.path().join("info"); + std::fs::create_dir_all(&info_dir).unwrap(); + + // Test that the fields are truly optional + let minimal = r#"{ + "paths": [ + { + "_path": "file.txt", + "path_type": "hardlink" + } + ], + "paths_version": 1 + }"#; + + std::fs::write(info_dir.join("paths.json"), minimal).unwrap(); + + let paths_json = PathsJson::from_package_directory(package_dir.path()).unwrap(); + + assert_eq!(paths_json.paths_version, 1); + assert_eq!(paths_json.paths[0].sha256, None); + assert_eq!(paths_json.paths[0].size_in_bytes, None); + assert!(paths_json.paths[0].prefix_placeholder.is_none()); + } + + #[test] + pub fn test_serialization_roundtrip() { + // Create a PathsJson with offset fields programmatically + let original = PathsJson { + paths: vec![ + PathsEntry { + relative_path: PathBuf::from("bin/tool"), + no_link: false, + path_type: PathType::HardLink, + prefix_placeholder: Some(PrefixPlaceholder { + file_mode: FileMode::Binary, + placeholder: "/opt/conda".to_string(), + offsets: Some(Offsets::Binary(vec![vec![50, 200], vec![150, 200]])), + shebang_length: None, + }), + sha256: None, + size_in_bytes: Some(4096), + }, + PathsEntry { + relative_path: PathBuf::from("lib/module.py"), + no_link: false, + path_type: PathType::HardLink, + prefix_placeholder: None, + sha256: None, + size_in_bytes: Some(512), + }, + ], + paths_version: 1, + }; + + // Serialize to JSON + let json = serde_json::to_string_pretty(&original).unwrap(); + + // Deserialize back + let deserialized: PathsJson = serde_json::from_str(&json).unwrap(); + + // Verify roundtrip + assert_eq!(original, deserialized); + assert_eq!(deserialized.paths_version, 1); + assert_eq!( + deserialized.paths[0] + .prefix_placeholder + .as_ref() + .unwrap() + .offsets, + Some(Offsets::Binary(vec![vec![50, 200], vec![150, 200]])) + ); + } + + #[test] + pub fn test_fallback_from_v1_to_deprecated() { + let package_dir = tempfile::tempdir().unwrap(); + let info_dir = package_dir.path().join("info"); + std::fs::create_dir_all(&info_dir).unwrap(); + + // Don't create paths.json, but create deprecated files + let files_content = "bin/old-tool\nlib/old-lib.so\n"; + std::fs::write(info_dir.join("files"), files_content).unwrap(); + + // Create actual files so path_type detection works + let bin_dir = package_dir.path().join("bin"); + let lib_dir = package_dir.path().join("lib"); + std::fs::create_dir_all(&bin_dir).unwrap(); + std::fs::create_dir_all(&lib_dir).unwrap(); + std::fs::write(bin_dir.join("old-tool"), "#!/bin/sh\necho test").unwrap(); + std::fs::write(lib_dir.join("old-lib.so"), "binary data").unwrap(); + + let paths_json = + PathsJson::from_package_directory_with_deprecated_fallback(package_dir.path()).unwrap(); + + // Should fall back and create v1 + assert_eq!(paths_json.paths_version, 1); + assert_eq!(paths_json.paths.len(), 2); + + // Deprecated format shouldn't have offsets + assert!(paths_json.paths.iter().all(|p| { + p.prefix_placeholder + .as_ref() + .is_none_or(|pp| pp.offsets.is_none()) + })); + } } diff --git a/crates/rattler_conda_types/src/package/snapshots/rattler_conda_types__package__paths__test__deserialize_paths_json_with_offsets.snap b/crates/rattler_conda_types/src/package/snapshots/rattler_conda_types__package__paths__test__deserialize_paths_json_with_offsets.snap new file mode 100644 index 0000000000..86331f30f0 --- /dev/null +++ b/crates/rattler_conda_types/src/package/snapshots/rattler_conda_types__package__paths__test__deserialize_paths_json_with_offsets.snap @@ -0,0 +1,34 @@ +--- +source: crates/rattler_conda_types/src/package/paths.rs +assertion_line: 482 +expression: paths_json +--- +paths: + - _path: bin/example + path_type: hardlink + file_mode: binary + prefix_placeholder: /opt/conda + offsets: + - - 100 + - 500 + - - 200 + - 300 + - 800 + sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + size_in_bytes: 1024 + - _path: bin/symlink-example + path_type: softlink + - _path: lib/library.so + path_type: hardlink + sha256: d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592 + size_in_bytes: 2048 + - _path: share/doc/readme.txt + path_type: hardlink + file_mode: text + prefix_placeholder: /home/builder/conda + offsets: + - 10 + - 45 + sha256: a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3 + size_in_bytes: 256 +paths_version: 1 diff --git a/crates/rattler_vfs/Cargo.toml b/crates/rattler_vfs/Cargo.toml new file mode 100644 index 0000000000..fc1712cbd6 --- /dev/null +++ b/crates/rattler_vfs/Cargo.toml @@ -0,0 +1,67 @@ +[package] +name = "rattler_vfs" +version = "0.1.0" +description = "Serve conda environments as a virtual filesystem (FUSE, NFS, or ProjFS) directly from the package cache" +categories.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true +readme.workspace = true + +[dependencies] +anyhow = { workspace = true } +fs4 = { workspace = true } +hex = { workspace = true } +libc = { workspace = true } +thiserror = { workspace = true } +memchr = { workspace = true } +memmap2 = { workspace = true } +rattler = { workspace = true, default-features = false } +reflink-copy = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } +rattler_cache = { workspace = true, default-features = false } +rattler_conda_types = { workspace = true } +rattler_lock = { workspace = true } +rattler_networking = { workspace = true, default-features = false } +reqwest-middleware = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-std", "io-util", "signal"] } +tracing = { workspace = true } + +# NFS transport (optional) +nfs3_server = { version = "0.10", optional = true } +nfs3_types = { version = "0.5", optional = true } + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.62", features = ["Win32_Storage_ProjectedFileSystem", "Win32_Foundation", "Win32_System_LibraryLoader"] } + +# FUSE always-on for Linux. Uses fuser's pure-rust mount path, which does a +# direct mount(2) when privileged (CAP_SYS_ADMIN) and otherwise exec's the +# host's setuid /usr/bin/fusermount3 for unprivileged mounts. +[target.'cfg(target_os = "linux")'.dependencies] +fuser = { workspace = true } + +# macOS-only dependencies: +# - fuser is opt-in (requires macFUSE installed system-wide) +[target.'cfg(target_os = "macos")'.dependencies] +fuser = { workspace = true, optional = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[features] +default = ["nfs"] +nfs = ["dep:nfs3_server", "dep:nfs3_types"] +fuse = ["dep:fuser"] # only meaningful on macOS +native-tls = [ + "rattler/native-tls", + "rattler_cache/native-tls", + "rattler_networking/native-tls", +] +rustls = [ + "rattler/rustls", + "rattler_cache/rustls", + "rattler_networking/rustls", +] diff --git a/crates/rattler_vfs/build.rs b/crates/rattler_vfs/build.rs new file mode 100644 index 0000000000..4d9f8d85fd --- /dev/null +++ b/crates/rattler_vfs/build.rs @@ -0,0 +1,12 @@ +fn main() { + // Delay-load ProjectedFSLib.dll so the binary can start even when the + // Windows Projected File System optional feature is not installed. Without + // this, the DLL is in the normal import table and the Windows loader kills + // the process before main() when the DLL is absent. With delay-load the + // DLL is only resolved on the first ProjFS API call, giving us a chance to + // check availability and return a clear error. + if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "windows" { + println!("cargo::rustc-link-arg=/DELAYLOAD:projectedfslib.dll"); + println!("cargo::rustc-link-lib=delayimp"); + } +} diff --git a/crates/rattler_vfs/src/codesign.rs b/crates/rattler_vfs/src/codesign.rs new file mode 100644 index 0000000000..b282771e8f --- /dev/null +++ b/crates/rattler_vfs/src/codesign.rs @@ -0,0 +1,723 @@ +//! In-memory ad-hoc Mach-O code re-signing. +//! +//! After binary prefix replacement, the page hashes in the embedded `CodeDirectory` +//! are stale. This module recomputes them in-place without invoking `/usr/bin/codesign`. + +use sha2::{Digest, Sha256}; +use std::fmt; + +// Mach-O magic values +const MACHO_MAGIC_64: u32 = 0xfeedfacf; +const MACHO_CIGAM_64: u32 = 0xcffaedfe; +const MACHO_MAGIC_32: u32 = 0xfeedface; +const MACHO_CIGAM_32: u32 = 0xcefaedfe; +const FAT_MAGIC: u32 = 0xcafebabe; + +const LC_CODE_SIGNATURE: u32 = 0x1d; +const SUPERBLOB_MAGIC: u32 = 0xfade0cc0; +const CODE_DIRECTORY_MAGIC: u32 = 0xfade0c02; +const HASH_TYPE_SHA256: u8 = 2; + +#[derive(Debug)] +pub enum CodesignError { + TooSmall, + NoCodeSignature, + OutOfBounds, + InvalidSuperBlob, + NoCodeDirectory, + UnsupportedHashType(u8), +} + +impl fmt::Display for CodesignError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TooSmall => write!(f, "binary too small"), + Self::NoCodeSignature => write!(f, "LC_CODE_SIGNATURE not found"), + Self::OutOfBounds => write!(f, "code signature out of bounds"), + Self::InvalidSuperBlob => write!(f, "invalid SuperBlob magic"), + Self::NoCodeDirectory => write!(f, "CodeDirectory not found in SuperBlob"), + Self::UnsupportedHashType(t) => { + write!(f, "unsupported hash type {t} (expected SHA-256)") + } + } + } +} + +fn read_u32_be(data: &[u8], offset: usize) -> u32 { + u32::from_be_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]) +} + +fn read_u32(data: &[u8], offset: usize, big_endian: bool) -> u32 { + if big_endian { + read_u32_be(data, offset) + } else { + u32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]) + } +} + +/// Determine endianness and header size from Mach-O magic. +/// Returns `(big_endian, header_size)` or `None` if not a Mach-O. +/// +/// When the first 4 bytes are read as big-endian: +/// - MAGIC values mean the binary IS big-endian (bytes match the constant directly) +/// - CIGAM values mean the binary is little-endian (bytes are swapped) +fn macho_info(magic: u32) -> Option<(bool, usize)> { + match magic { + MACHO_MAGIC_64 => Some((true, 32)), + MACHO_CIGAM_64 => Some((false, 32)), + MACHO_MAGIC_32 => Some((true, 28)), + MACHO_CIGAM_32 => Some((false, 28)), + _ => None, + } +} + +/// Walk Mach-O load commands, return `(dataoff, datasize)` from `LC_CODE_SIGNATURE`. +fn find_code_signature(macho: &[u8]) -> Result<(u32, u32), CodesignError> { + if macho.len() < 28 { + return Err(CodesignError::TooSmall); + } + + let magic = read_u32_be(macho, 0); + let (big_endian, header_size) = macho_info(magic).ok_or(CodesignError::TooSmall)?; + + let ncmds = read_u32(macho, 16, big_endian) as usize; + let mut offset = header_size; + + for _ in 0..ncmds { + if offset + 8 > macho.len() { + return Err(CodesignError::OutOfBounds); + } + let cmd = read_u32(macho, offset, big_endian); + let cmdsize = read_u32(macho, offset + 4, big_endian) as usize; + if cmdsize < 8 || offset + cmdsize > macho.len() { + return Err(CodesignError::OutOfBounds); + } + + if cmd == LC_CODE_SIGNATURE { + let dataoff = read_u32(macho, offset + 8, big_endian); + let datasize = read_u32(macho, offset + 12, big_endian); + return Ok((dataoff, datasize)); + } + offset += cmdsize; + } + + Err(CodesignError::NoCodeSignature) +} + +/// Parse a `SuperBlob`, return the offset of the `CodeDirectory` blob within it. +fn find_code_directory(superblob: &[u8]) -> Result { + if superblob.len() < 12 { + return Err(CodesignError::InvalidSuperBlob); + } + + let magic = read_u32_be(superblob, 0); + if magic != SUPERBLOB_MAGIC { + return Err(CodesignError::InvalidSuperBlob); + } + + let count = read_u32_be(superblob, 8) as usize; + + for i in 0..count { + let index_offset = 12 + i * 8; + if index_offset + 8 > superblob.len() { + return Err(CodesignError::OutOfBounds); + } + let blob_offset = read_u32_be(superblob, index_offset + 4) as usize; + if blob_offset + 4 > superblob.len() { + return Err(CodesignError::OutOfBounds); + } + let blob_magic = read_u32_be(superblob, blob_offset); + if blob_magic == CODE_DIRECTORY_MAGIC { + return Ok(blob_offset); + } + } + + Err(CodesignError::NoCodeDirectory) +} + +/// Re-sign a single Mach-O binary (thin, not fat) in-place. +fn resign_macho_slice(data: &mut [u8]) -> Result<(), CodesignError> { + let (dataoff, datasize) = find_code_signature(data)?; + let sig_start = dataoff as usize; + let sig_end = sig_start + datasize as usize; + if sig_end > data.len() { + return Err(CodesignError::OutOfBounds); + } + + let cd_offset_in_sb = find_code_directory(&data[sig_start..sig_end])?; + let cd_abs = sig_start + cd_offset_in_sb; + + // Parse CodeDirectory fields (big-endian) + if cd_abs + 44 > data.len() { + return Err(CodesignError::OutOfBounds); + } + let hash_offset = read_u32_be(data, cd_abs + 16) as usize; + let n_code_slots = read_u32_be(data, cd_abs + 28) as usize; + let code_limit = read_u32_be(data, cd_abs + 32) as usize; + let hash_size = data[cd_abs + 36] as usize; + let hash_type = data[cd_abs + 37]; + let page_size_log2 = data[cd_abs + 39]; + + if hash_type != HASH_TYPE_SHA256 { + return Err(CodesignError::UnsupportedHashType(hash_type)); + } + if hash_size != 32 { + return Err(CodesignError::UnsupportedHashType(hash_type)); + } + if page_size_log2 >= 64 { + return Err(CodesignError::OutOfBounds); + } + if code_limit > data.len() { + return Err(CodesignError::OutOfBounds); + } + let page_size = 1usize << page_size_log2; + + // Verify bounds for the hash table (check for overflow) + let hashes_start = cd_abs + .checked_add(hash_offset) + .ok_or(CodesignError::OutOfBounds)?; + let hashes_end = n_code_slots + .checked_mul(hash_size) + .and_then(|n| hashes_start.checked_add(n)) + .ok_or(CodesignError::OutOfBounds)?; + if hashes_end > data.len() { + return Err(CodesignError::OutOfBounds); + } + + // Recompute page hashes + for i in 0..n_code_slots { + let page_start = i * page_size; + let page_end = std::cmp::min(page_start + page_size, code_limit); + let hash = Sha256::digest(&data[page_start..page_end]); + let dest = hashes_start + i * hash_size; + data[dest..dest + hash_size].copy_from_slice(&hash); + } + + Ok(()) +} + +/// Re-sign a Mach-O binary in-place after prefix replacement. +/// +/// Handles both thin and fat (universal) binaries. For non-Mach-O data, returns `Ok(())`. +pub fn adhoc_resign(data: &mut [u8]) -> Result<(), CodesignError> { + if data.len() < 4 { + return Ok(()); + } + + let magic = read_u32_be(data, 0); + + if magic == FAT_MAGIC { + // Fat binary: re-sign each architecture slice + if data.len() < 8 { + return Err(CodesignError::TooSmall); + } + let nfat_arch = read_u32_be(data, 4) as usize; + + for i in 0..nfat_arch { + let entry_offset = 8 + i * 20; + if entry_offset + 20 > data.len() { + return Err(CodesignError::OutOfBounds); + } + let slice_offset = read_u32_be(data, entry_offset + 8) as usize; + let slice_size = read_u32_be(data, entry_offset + 12) as usize; + if slice_offset + slice_size > data.len() { + return Err(CodesignError::OutOfBounds); + } + resign_macho_slice(&mut data[slice_offset..slice_offset + slice_size])?; + } + Ok(()) + } else if macho_info(magic).is_some() { + resign_macho_slice(data) + } else { + // Not a Mach-O binary, nothing to do + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a minimal Mach-O 64-bit binary with a valid code signature. + /// + /// `content_pages` are 4096-byte pages of content (the last may be shorter). + /// The header + load command occupy the first 48 bytes of the first page. + fn build_test_macho(content_pages: &[&[u8]], identifier: &str, big_endian: bool) -> Vec { + let page_size: usize = 4096; + + // Build content: header page + extra content pages + let mut content = vec![0u8; page_size]; // first page (header lives here) + + // Write mach_header_64 + // MAGIC on disk = big-endian binary, CIGAM on disk = little-endian binary + let (magic, write_u32): (u32, fn(u32) -> [u8; 4]) = if big_endian { + (MACHO_MAGIC_64, u32::to_be_bytes) + } else { + (MACHO_CIGAM_64, u32::to_le_bytes) + }; + content[0..4].copy_from_slice(&magic.to_be_bytes()); + content[4..8].copy_from_slice(&write_u32(0x0100000c)); // cputype arm64 + content[12..16].copy_from_slice(&write_u32(2)); // MH_EXECUTE + content[16..20].copy_from_slice(&write_u32(1)); // ncmds = 1 + content[20..24].copy_from_slice(&write_u32(16)); // sizeofcmds + + // Append content pages + for page in content_pages { + let mut padded = vec![0u8; page_size]; + let copy_len = page.len().min(page_size); + padded[..copy_len].copy_from_slice(&page[..copy_len]); + content.extend_from_slice(&padded); + } + + let code_limit = content.len(); + let n_code_slots = code_limit.div_ceil(page_size); + + // Build CodeDirectory + let ident_bytes = identifier.as_bytes(); + let ident_offset: u32 = 44; // right after the fixed fields + let hash_offset: u32 = ident_offset + ident_bytes.len() as u32 + 1; // +1 for null + let cd_length = hash_offset as usize + n_code_slots * 32; // 32 = SHA-256 hash size + + let mut cd = vec![0u8; cd_length]; + // magic (big-endian) + cd[0..4].copy_from_slice(&CODE_DIRECTORY_MAGIC.to_be_bytes()); + // length + cd[4..8].copy_from_slice(&(cd_length as u32).to_be_bytes()); + // version + cd[8..12].copy_from_slice(&0x20400u32.to_be_bytes()); + // flags = adhoc + cd[12..16].copy_from_slice(&0x2u32.to_be_bytes()); + // hashOffset + cd[16..20].copy_from_slice(&hash_offset.to_be_bytes()); + // identOffset + cd[20..24].copy_from_slice(&ident_offset.to_be_bytes()); + // nSpecialSlots = 0 + cd[24..28].copy_from_slice(&0u32.to_be_bytes()); + // nCodeSlots + cd[28..32].copy_from_slice(&(n_code_slots as u32).to_be_bytes()); + // codeLimit + cd[32..36].copy_from_slice(&(code_limit as u32).to_be_bytes()); + // hashSize + cd[36] = 32; + // hashType = SHA-256 + cd[37] = HASH_TYPE_SHA256; + // platform + cd[38] = 0; + // pageSize = log2(4096) = 12 + cd[39] = 12; + + // identifier string (null-terminated) + let ident_start = ident_offset as usize; + cd[ident_start..ident_start + ident_bytes.len()].copy_from_slice(ident_bytes); + cd[ident_start + ident_bytes.len()] = 0; + + // Compute page hashes + for i in 0..n_code_slots { + let page_start = i * page_size; + let page_end = std::cmp::min(page_start + page_size, code_limit); + let hash = Sha256::digest(&content[page_start..page_end]); + let dest = hash_offset as usize + i * 32; + cd[dest..dest + 32].copy_from_slice(&hash); + } + + // Build SuperBlob: 1 blob (the CodeDirectory) + let sb_length = 12 + 8 + cd.len(); // header + 1 index entry + cd + let mut superblob = Vec::with_capacity(sb_length); + superblob.extend_from_slice(&SUPERBLOB_MAGIC.to_be_bytes()); + superblob.extend_from_slice(&(sb_length as u32).to_be_bytes()); + superblob.extend_from_slice(&1u32.to_be_bytes()); // count = 1 + // BlobIndex: type=0 (CodeDirectory), offset=20 + superblob.extend_from_slice(&0u32.to_be_bytes()); + superblob.extend_from_slice(&20u32.to_be_bytes()); // offset from superblob start + superblob.extend_from_slice(&cd); + + // Write LC_CODE_SIGNATURE load command at offset 32 + let dataoff = code_limit as u32; + let datasize = superblob.len() as u32; + let write_u32_fn: fn(u32) -> [u8; 4] = write_u32; + content[32..36].copy_from_slice(&write_u32_fn(LC_CODE_SIGNATURE)); + content[36..40].copy_from_slice(&write_u32_fn(16)); // cmdsize + content[40..44].copy_from_slice(&write_u32_fn(dataoff)); + content[44..48].copy_from_slice(&write_u32_fn(datasize)); + + // Append the signature + content.extend_from_slice(&superblob); + + // Fix page 0 hash (it now includes the load command we just wrote) + let hash = Sha256::digest(&content[0..page_size]); + let sig_start = code_limit; + let cd_start = sig_start + 20; // SuperBlob header(12) + BlobIndex(8) + let hashes_start = cd_start + hash_offset as usize; + content[hashes_start..hashes_start + 32].copy_from_slice(&hash); + + content + } + + fn build_test_macho_32bit(content_pages: &[&[u8]], identifier: &str) -> Vec { + let page_size: usize = 4096; + let header_size: usize = 28; // 32-bit header is 28 bytes + + let mut content = vec![0u8; page_size]; + + // mach_header (32-bit, little-endian) — CIGAM on disk = little-endian + content[0..4].copy_from_slice(&MACHO_CIGAM_32.to_be_bytes()); + content[4..8].copy_from_slice(&12u32.to_le_bytes()); // cputype ARM + content[12..16].copy_from_slice(&2u32.to_le_bytes()); // MH_EXECUTE + content[16..20].copy_from_slice(&1u32.to_le_bytes()); // ncmds = 1 + content[20..24].copy_from_slice(&16u32.to_le_bytes()); // sizeofcmds + + for page in content_pages { + let mut padded = vec![0u8; page_size]; + let copy_len = page.len().min(page_size); + padded[..copy_len].copy_from_slice(&page[..copy_len]); + content.extend_from_slice(&padded); + } + + let code_limit = content.len(); + let n_code_slots = code_limit.div_ceil(page_size); + + // Build CodeDirectory (same as 64-bit, always big-endian) + let ident_bytes = identifier.as_bytes(); + let ident_offset: u32 = 44; + let hash_offset: u32 = ident_offset + ident_bytes.len() as u32 + 1; + let cd_length = hash_offset as usize + n_code_slots * 32; + + let mut cd = vec![0u8; cd_length]; + cd[0..4].copy_from_slice(&CODE_DIRECTORY_MAGIC.to_be_bytes()); + cd[4..8].copy_from_slice(&(cd_length as u32).to_be_bytes()); + cd[8..12].copy_from_slice(&0x20400u32.to_be_bytes()); + cd[12..16].copy_from_slice(&0x2u32.to_be_bytes()); + cd[16..20].copy_from_slice(&hash_offset.to_be_bytes()); + cd[20..24].copy_from_slice(&ident_offset.to_be_bytes()); + cd[24..28].copy_from_slice(&0u32.to_be_bytes()); + cd[28..32].copy_from_slice(&(n_code_slots as u32).to_be_bytes()); + cd[32..36].copy_from_slice(&(code_limit as u32).to_be_bytes()); + cd[36] = 32; + cd[37] = HASH_TYPE_SHA256; + cd[39] = 12; + let ident_start = ident_offset as usize; + cd[ident_start..ident_start + ident_bytes.len()].copy_from_slice(ident_bytes); + + for i in 0..n_code_slots { + let page_start = i * page_size; + let page_end = std::cmp::min(page_start + page_size, code_limit); + let hash = Sha256::digest(&content[page_start..page_end]); + let dest = hash_offset as usize + i * 32; + cd[dest..dest + 32].copy_from_slice(&hash); + } + + let sb_length = 12 + 8 + cd.len(); + let mut superblob = Vec::with_capacity(sb_length); + superblob.extend_from_slice(&SUPERBLOB_MAGIC.to_be_bytes()); + superblob.extend_from_slice(&(sb_length as u32).to_be_bytes()); + superblob.extend_from_slice(&1u32.to_be_bytes()); + superblob.extend_from_slice(&0u32.to_be_bytes()); + superblob.extend_from_slice(&20u32.to_be_bytes()); + superblob.extend_from_slice(&cd); + + // LC_CODE_SIGNATURE at offset 28 (after 32-bit header) + let dataoff = code_limit as u32; + let datasize = superblob.len() as u32; + content[header_size..header_size + 4].copy_from_slice(&LC_CODE_SIGNATURE.to_le_bytes()); + content[header_size + 4..header_size + 8].copy_from_slice(&16u32.to_le_bytes()); + content[header_size + 8..header_size + 12].copy_from_slice(&dataoff.to_le_bytes()); + content[header_size + 12..header_size + 16].copy_from_slice(&datasize.to_le_bytes()); + + content.extend_from_slice(&superblob); + + // Fix page 0 hash + let hash = Sha256::digest(&content[0..page_size]); + let sig_start = code_limit; + let cd_start = sig_start + 20; + let hashes_start = cd_start + hash_offset as usize; + content[hashes_start..hashes_start + 32].copy_from_slice(&hash); + + content + } + + /// Verify that all page hashes in the `CodeDirectory` match the actual content. + fn verify_page_hashes(data: &[u8]) -> bool { + let magic = read_u32_be(data, 0); + let slices: Vec<(usize, usize)> = if magic == FAT_MAGIC { + let nfat = read_u32_be(data, 4) as usize; + (0..nfat) + .map(|i| { + let off = 8 + i * 20; + ( + read_u32_be(data, off + 8) as usize, + read_u32_be(data, off + 12) as usize, + ) + }) + .collect() + } else { + vec![(0, data.len())] + }; + + for (slice_off, slice_size) in slices { + let s = &data[slice_off..slice_off + slice_size]; + let (dataoff, _) = find_code_signature(s).unwrap(); + let sig_start = dataoff as usize; + let cd_off = find_code_directory(&s[sig_start..]).unwrap(); + let cd_abs = sig_start + cd_off; + + let hash_offset = read_u32_be(s, cd_abs + 16) as usize; + let n_code_slots = read_u32_be(s, cd_abs + 28) as usize; + let code_limit = read_u32_be(s, cd_abs + 32) as usize; + let page_size = 1usize << s[cd_abs + 39]; + + for i in 0..n_code_slots { + let page_start = i * page_size; + let page_end = std::cmp::min(page_start + page_size, code_limit); + let expected = Sha256::digest(&s[page_start..page_end]); + let stored_start = cd_abs + hash_offset + i * 32; + if s[stored_start..stored_start + 32] != *expected { + return false; + } + } + } + true + } + + // --- Parsing tests --- + + #[test] + fn test_find_code_signature() { + let data = build_test_macho(&[b"hello"], "test", false); + let (dataoff, datasize) = find_code_signature(&data).unwrap(); + assert!(dataoff > 0); + assert!(datasize > 0); + assert_eq!(dataoff as usize + datasize as usize, data.len()); + } + + #[test] + fn test_find_code_signature_missing() { + // Binary with 0 load commands + let mut data = vec![0u8; 64]; + data[0..4].copy_from_slice(&MACHO_MAGIC_64.to_be_bytes()); + // ncmds = 0 + assert!(matches!( + find_code_signature(&data), + Err(CodesignError::NoCodeSignature) + )); + } + + #[test] + fn test_find_code_signature_too_small() { + assert!(matches!( + find_code_signature(&[0; 4]), + Err(CodesignError::TooSmall) + )); + } + + #[test] + fn test_find_code_directory() { + let data = build_test_macho(&[b"hello"], "test", false); + let (dataoff, datasize) = find_code_signature(&data).unwrap(); + let sig = &data[dataoff as usize..(dataoff + datasize) as usize]; + let cd_offset = find_code_directory(sig).unwrap(); + let cd_magic = read_u32_be(sig, cd_offset); + assert_eq!(cd_magic, CODE_DIRECTORY_MAGIC); + } + + #[test] + fn test_find_code_directory_missing() { + // SuperBlob with 0 blobs + let mut sb = vec![0u8; 12]; + sb[0..4].copy_from_slice(&SUPERBLOB_MAGIC.to_be_bytes()); + sb[4..8].copy_from_slice(&12u32.to_be_bytes()); + sb[8..12].copy_from_slice(&0u32.to_be_bytes()); + assert!(matches!( + find_code_directory(&sb), + Err(CodesignError::NoCodeDirectory) + )); + } + + // --- Re-signing tests --- + + #[test] + fn test_resign_updates_page_hashes() { + let mut data = build_test_macho(&[b"original content"], "test", false); + assert!(verify_page_hashes(&data)); + + // Modify content in the second page (first extra content page) + data[4096] = 0xff; + data[4097] = 0xfe; + assert!(!verify_page_hashes(&data)); + + adhoc_resign(&mut data).unwrap(); + assert!(verify_page_hashes(&data)); + } + + #[test] + fn test_resign_preserves_size() { + let mut data = build_test_macho(&[b"content"], "test", false); + let original_size = data.len(); + data[4096] = 0xff; + adhoc_resign(&mut data).unwrap(); + assert_eq!(data.len(), original_size); + } + + #[test] + fn test_resign_noop_for_non_macho() { + let mut data = vec![0u8; 100]; + data[0..4].copy_from_slice(b"ELF!"); + assert!(adhoc_resign(&mut data).is_ok()); + } + + #[test] + fn test_resign_noop_for_empty() { + let mut data = vec![]; + assert!(adhoc_resign(&mut data).is_ok()); + } + + #[test] + fn test_resign_multiple_modified_pages() { + let page_a = vec![0xaau8; 4096]; + let page_b = vec![0xbbu8; 4096]; + let page_c = vec![0xccu8; 4096]; + let mut data = build_test_macho( + &[page_a.as_slice(), page_b.as_slice(), page_c.as_slice()], + "multi", + false, + ); + assert!(verify_page_hashes(&data)); + + // Modify pages 1 and 3 (content pages at offsets 4096 and 12288) + data[4096] = 0x00; + data[12288] = 0x00; + assert!(!verify_page_hashes(&data)); + + adhoc_resign(&mut data).unwrap(); + assert!(verify_page_hashes(&data)); + } + + #[test] + fn test_resign_partial_last_page() { + // Content page with only 100 bytes (less than 4096) + let short_page = vec![0x42u8; 100]; + let mut data = build_test_macho(&[short_page.as_slice()], "partial", false); + assert!(verify_page_hashes(&data)); + + data[4096] = 0xff; + adhoc_resign(&mut data).unwrap(); + assert!(verify_page_hashes(&data)); + } + + #[test] + fn test_resign_fat_binary() { + // Build two thin binaries + let thin_a = build_test_macho(&[b"arch-a"], "fat-test", false); + let thin_b = build_test_macho(&[b"arch-b"], "fat-test", false); + + // Align slices to 4096 boundaries + let slice_a_offset = 4096usize; // after fat header + let slice_a_size = thin_a.len(); + let slice_b_offset = (slice_a_offset + slice_a_size + 4095) & !4095; // align to 4096 + let slice_b_size = thin_b.len(); + let total_size = slice_b_offset + slice_b_size; + + let mut fat = vec![0u8; total_size]; + // Fat header (big-endian) + fat[0..4].copy_from_slice(&FAT_MAGIC.to_be_bytes()); + fat[4..8].copy_from_slice(&2u32.to_be_bytes()); // 2 architectures + + // fat_arch[0] + fat[8..12].copy_from_slice(&0x01000007u32.to_be_bytes()); // x86_64 + fat[16..20].copy_from_slice(&(slice_a_offset as u32).to_be_bytes()); + fat[20..24].copy_from_slice(&(slice_a_size as u32).to_be_bytes()); + fat[24..28].copy_from_slice(&12u32.to_be_bytes()); // align = 2^12 + + // fat_arch[1] + fat[28..32].copy_from_slice(&0x0100000cu32.to_be_bytes()); // arm64 + fat[36..40].copy_from_slice(&(slice_b_offset as u32).to_be_bytes()); + fat[40..44].copy_from_slice(&(slice_b_size as u32).to_be_bytes()); + fat[44..48].copy_from_slice(&12u32.to_be_bytes()); + + // Copy slices + fat[slice_a_offset..slice_a_offset + slice_a_size].copy_from_slice(&thin_a); + fat[slice_b_offset..slice_b_offset + slice_b_size].copy_from_slice(&thin_b); + + assert!(verify_page_hashes(&fat)); + + // Modify content in both slices + fat[slice_a_offset + 4096] = 0xff; + fat[slice_b_offset + 4096] = 0xfe; + assert!(!verify_page_hashes(&fat)); + + adhoc_resign(&mut fat).unwrap(); + assert!(verify_page_hashes(&fat)); + } + + #[test] + fn test_resign_32bit_macho() { + let mut data = build_test_macho_32bit(&[b"32bit content"], "test32"); + assert!(verify_page_hashes(&data)); + + data[4096] = 0xff; + adhoc_resign(&mut data).unwrap(); + assert!(verify_page_hashes(&data)); + } + + #[test] + fn test_resign_big_endian_macho() { + let mut data = build_test_macho(&[b"be content"], "test-be", true); + assert!(verify_page_hashes(&data)); + + data[4096] = 0xff; + adhoc_resign(&mut data).unwrap(); + assert!(verify_page_hashes(&data)); + } + + // --- Malformed binary edge cases --- + + #[test] + fn test_resign_invalid_page_size_log2() { + let mut data = build_test_macho(&[b"test"], "test", false); + // Find CodeDirectory and corrupt page_size_log2 + let (dataoff, _) = find_code_signature(&data).unwrap(); + let cd_off = find_code_directory(&data[dataoff as usize..]).unwrap(); + let cd_abs = dataoff as usize + cd_off; + data[cd_abs + 39] = 64; // page_size_log2 = 64 would overflow 1usize << 64 + assert!(matches!( + resign_macho_slice(&mut data), + Err(CodesignError::OutOfBounds) + )); + } + + #[test] + fn test_resign_code_limit_exceeds_data() { + let mut data = build_test_macho(&[b"test"], "test", false); + let (dataoff, _) = find_code_signature(&data).unwrap(); + let cd_off = find_code_directory(&data[dataoff as usize..]).unwrap(); + let cd_abs = dataoff as usize + cd_off; + // Set code_limit to way beyond data length + let huge_limit = (data.len() as u32 + 99999).to_be_bytes(); + data[cd_abs + 32..cd_abs + 36].copy_from_slice(&huge_limit); + assert!(matches!( + resign_macho_slice(&mut data), + Err(CodesignError::OutOfBounds) + )); + } + + #[test] + fn test_resign_zero_code_slots() { + let mut data = build_test_macho(&[b"test"], "test", false); + let (dataoff, _) = find_code_signature(&data).unwrap(); + let cd_off = find_code_directory(&data[dataoff as usize..]).unwrap(); + let cd_abs = dataoff as usize + cd_off; + // Set n_code_slots to 0 + data[cd_abs + 28..cd_abs + 32].copy_from_slice(&0u32.to_be_bytes()); + // Should succeed (nothing to resign) + assert!(resign_macho_slice(&mut data).is_ok()); + } +} diff --git a/crates/rattler_vfs/src/fuse_adapter.rs b/crates/rattler_vfs/src/fuse_adapter.rs new file mode 100644 index 0000000000..52da17beff --- /dev/null +++ b/crates/rattler_vfs/src/fuse_adapter.rs @@ -0,0 +1,497 @@ +//! FUSE transport adapter. +//! +//! Thin layer that maps `fuser::Filesystem` callbacks to `VfsOps` trait methods. +//! Handles FUSE-specific concerns: capability negotiation, passthrough, reply types. + +use fuser::{ + Errno, FileHandle, Filesystem, FopenFlags, INodeNo, InitFlags, KernelConfig, LockOwner, + OpenFlags, ReplyAttr, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen, ReplyXattr, + Request, +}; +use libc::EIO; +use memmap2::Mmap; +use std::{ + collections::HashMap, + ffi::OsStr, + fs::File, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + time::Duration, +}; + +use crate::vfs_ops::{ContentSource, FileAttr, FileKind, VfsOps}; + +// --------------------------------------------------------------------------- +// Conversions between crate-local types and fuser types +// --------------------------------------------------------------------------- + +impl From for fuser::FileType { + fn from(kind: FileKind) -> fuser::FileType { + match kind { + FileKind::RegularFile => fuser::FileType::RegularFile, + FileKind::Directory => fuser::FileType::Directory, + FileKind::Symlink => fuser::FileType::Symlink, + } + } +} + +impl From for fuser::FileAttr { + fn from(attr: FileAttr) -> fuser::FileAttr { + fuser::FileAttr { + ino: INodeNo(attr.ino), + size: attr.size, + blocks: attr.blocks, + atime: attr.atime, + mtime: attr.mtime, + ctime: attr.ctime, + crtime: attr.atime, + kind: attr.kind.into(), + perm: attr.perm, + nlink: attr.nlink, + uid: attr.uid, + gid: attr.gid, + rdev: 0, + flags: 0, + blksize: 512, + } + } +} + +/// Content of an open file managed by the adapter. +enum OpenFileContent { + /// Materialized content from the VFS (e.g. after prefix replacement). + Materialized(Vec), + /// Raw mmap of the cache file (no prefix replacement needed). + Mapped(Mmap), + /// Kernel handles reads directly via the backing fd (Linux 6.9+). + /// The `BackingId` must stay alive until `release()`. + Passthrough { _backing_id: Arc }, + /// VFS-managed handle (overlay writable files). Reads/writes/release + /// must be delegated back to the VFS using this key. + VfsManaged(u64), + /// Inode-based reads for transformed/virtual content. Each `read()` + /// call delegates to `VfsOps::read(ino, offset, size)`. + InodeRead(u64), +} + +impl OpenFileContent { + fn as_bytes(&self) -> Option<&[u8]> { + match self { + Self::Materialized(buf) => Some(buf), + Self::Mapped(mmap) => Some(mmap.as_ref()), + Self::Passthrough { .. } | Self::VfsManaged(_) | Self::InodeRead(_) => None, + } + } +} + +/// FUSE transport adapter, generic over any `VfsOps` implementation. +pub struct FuseAdapter { + vfs: T, + open_files: Mutex>, + next_fh: AtomicU64, + passthrough_enabled: AtomicBool, +} + +impl FuseAdapter { + pub fn new(vfs: T) -> Self { + FuseAdapter { + vfs, + open_files: Mutex::new(HashMap::new()), + next_fh: AtomicU64::new(1), + passthrough_enabled: AtomicBool::new(false), + } + } + + fn store_content(&self, content: OpenFileContent) -> u64 { + let fh = self.next_fh.fetch_add(1, Ordering::Relaxed); + self.open_files.lock().unwrap().insert(fh, content); + fh + } +} + +impl Filesystem for FuseAdapter { + fn init(&mut self, _req: &Request, config: &mut KernelConfig) -> std::io::Result<()> { + if config.add_capabilities(InitFlags::FUSE_PASSTHROUGH).is_ok() { + self.passthrough_enabled.store(true, Ordering::Relaxed); + tracing::info!("FUSE passthrough enabled"); + } + Ok(()) + } + + fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) { + match self.vfs.lookup(parent.0, name) { + Ok(attr) => { + let fattr: fuser::FileAttr = attr.into(); + reply.entry(&Duration::MAX, &fattr, fuser::Generation(0)); + } + Err(e) => reply.error(Errno::from_i32(e)), + } + } + + fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option, reply: ReplyAttr) { + match self.vfs.getattr(ino.0) { + Ok(attr) => { + let fattr: fuser::FileAttr = attr.into(); + reply.attr(&Duration::MAX, &fattr); + } + Err(e) => reply.error(Errno::from_i32(e)), + } + } + + fn readlink(&self, _req: &Request, ino: INodeNo, reply: ReplyData) { + match self.vfs.readlink(ino.0) { + Ok(target) => reply.data(target.as_os_str().as_encoded_bytes()), + Err(e) => reply.error(Errno::from_i32(e)), + } + } + + fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) { + let write = matches!( + flags.acc_mode(), + fuser::OpenAccMode::O_WRONLY | fuser::OpenAccMode::O_RDWR + ); + + if write { + // Write path: delegate to VFS open_write + match self.vfs.open_write(ino.0) { + Ok(vfs_fh) => { + let fh = self.store_content(OpenFileContent::VfsManaged(vfs_fh)); + reply.opened(FileHandle(fh), FopenFlags::empty()); + } + Err(e) => reply.error(Errno::from_i32(e)), + } + return; + } + + // Read path: use content_source to decide strategy + match self.vfs.content_source(ino.0) { + Ok(ContentSource::Direct(path)) => { + if self.passthrough_enabled.load(Ordering::Relaxed) { + let file = match File::open(&path) { + Ok(f) => f, + Err(e) => { + tracing::warn!("passthrough open failed for {}: {}", path.display(), e); + reply.error(Errno::from_i32(EIO)); + return; + } + }; + match reply.open_backing(&file) { + Ok(id) => { + let backing_id = Arc::new(id); + let fh = self.next_fh.fetch_add(1, Ordering::Relaxed); + reply.opened_passthrough( + FileHandle(fh), + FopenFlags::FOPEN_KEEP_CACHE, + &backing_id, + ); + self.open_files.lock().unwrap().insert( + fh, + OpenFileContent::Passthrough { + _backing_id: backing_id, + }, + ); + } + Err(e) => { + // Passthrough negotiated but not usable at runtime; + // disable and fall back to mmap for this and future opens. + tracing::warn!( + "FUSE passthrough unavailable at runtime ({}), falling back to mmap", + e + ); + self.passthrough_enabled.store(false, Ordering::Relaxed); + let mmap = match unsafe { Mmap::map(&file) } { + Ok(m) => m, + Err(e) => { + tracing::warn!("failed to mmap {}: {}", path.display(), e); + reply.error(Errno::from_i32(EIO)); + return; + } + }; + let fh = self.store_content(OpenFileContent::Mapped(mmap)); + reply.opened(FileHandle(fh), FopenFlags::FOPEN_KEEP_CACHE); + } + }; + } else { + // Passthrough not available — mmap and serve normally + let file = match File::open(&path) { + Ok(f) => f, + Err(e) => { + tracing::warn!("failed to open {}: {}", path.display(), e); + reply.error(Errno::from_i32(EIO)); + return; + } + }; + let mmap = match unsafe { Mmap::map(&file) } { + Ok(m) => m, + Err(e) => { + tracing::warn!("failed to mmap {}: {}", path.display(), e); + reply.error(Errno::from_i32(EIO)); + return; + } + }; + let fh = self.store_content(OpenFileContent::Mapped(mmap)); + reply.opened(FileHandle(fh), FopenFlags::FOPEN_KEEP_CACHE); + } + } + Ok(ContentSource::Transformed | ContentSource::Virtual) => { + // Inode-based reads — store the inode so read() can call VFS + let fh = self.store_content(OpenFileContent::InodeRead(ino.0)); + reply.opened(FileHandle(fh), FopenFlags::FOPEN_KEEP_CACHE); + } + Err(e) => { + // Directories/symlinks — open with empty content + let _ = e; + let fh = self.store_content(OpenFileContent::Materialized(vec![])); + reply.opened(FileHandle(fh), FopenFlags::FOPEN_KEEP_CACHE); + } + } + } + + fn release( + &self, + _req: &Request, + _ino: INodeNo, + fh: FileHandle, + _flags: OpenFlags, + _lock_owner: Option, + _flush: bool, + reply: ReplyEmpty, + ) { + if fh != FileHandle(0) { + let removed = self.open_files.lock().unwrap().remove(&fh.0); + // Propagate release to VFS for overlay-managed handles + if let Some(OpenFileContent::VfsManaged(vfs_fh)) = removed { + self.vfs.release_write(vfs_fh); + } + } + reply.ok(); + } + + fn read( + &self, + _req: &Request, + _ino: INodeNo, + fh: FileHandle, + offset: u64, + size: u32, + _flags: OpenFlags, + _lock: Option, + reply: ReplyData, + ) { + let lock = self.open_files.lock().unwrap(); + let Some(content) = lock.get(&fh.0) else { + reply.error(Errno::from_i32(EIO)); + return; + }; + + match content { + OpenFileContent::VfsManaged(vfs_fh) => { + let vfs_fh = *vfs_fh; + drop(lock); // release adapter lock before calling into VFS + match self.vfs.read_handle(vfs_fh, offset, size) { + Ok(data) => reply.data(&data), + Err(e) => reply.error(Errno::from_i32(e)), + } + } + OpenFileContent::InodeRead(ino) => { + let ino = *ino; + drop(lock); // release adapter lock before calling into VFS + match self.vfs.read(ino, offset, size) { + Ok(data) => reply.data(&data), + Err(e) => reply.error(Errno::from_i32(e)), + } + } + _ => match content.as_bytes() { + Some(data) => { + let start = (offset as usize).min(data.len()); + let end = (start + size as usize).min(data.len()); + reply.data(&data[start..end]); + } + None => { + // Passthrough — kernel handles reads, shouldn't reach here + reply.error(Errno::from_i32(EIO)); + } + }, + } + } + + fn getxattr( + &self, + _req: &Request, + _ino: INodeNo, + _name: &OsStr, + _size: u32, + reply: ReplyXattr, + ) { + reply.error(Errno::NO_XATTR); + } + + fn listxattr(&self, _req: &Request, _ino: INodeNo, size: u32, reply: ReplyXattr) { + if size == 0 { + reply.size(0); + } else { + reply.data(&[]); + } + } + + fn readdir( + &self, + _req: &Request, + ino: INodeNo, + _fh: FileHandle, + offset: u64, + mut reply: ReplyDirectory, + ) { + match self.vfs.readdir(ino.0, offset) { + Ok(entries) => { + for (i, entry) in entries.into_iter().enumerate() { + let ftype: fuser::FileType = entry.kind.into(); + if reply.add( + INodeNo(entry.ino), + offset + i as u64 + 1, + ftype, + &entry.name, + ) { + break; + } + } + reply.ok(); + } + Err(e) => reply.error(Errno::from_i32(e)), + } + } + + // Write operations — delegate to VfsOps (EROFS for read-only, overlay handles them) + + fn create( + &self, + _req: &Request, + parent: INodeNo, + name: &OsStr, + mode: u32, + _umask: u32, + _flags: i32, + reply: fuser::ReplyCreate, + ) { + match self.vfs.create(parent.0, name, mode) { + Ok((attr, vfs_fh)) => { + let fattr: fuser::FileAttr = attr.into(); + let fh = self.store_content(OpenFileContent::VfsManaged(vfs_fh)); + reply.created( + &Duration::MAX, + &fattr, + fuser::Generation(0), + FileHandle(fh), + FopenFlags::empty(), + ); + } + Err(e) => reply.error(Errno::from_i32(e)), + } + } + + fn write( + &self, + _req: &Request, + _ino: INodeNo, + fh: FileHandle, + offset: u64, + data: &[u8], + _write_flags: fuser::WriteFlags, + _flags: OpenFlags, + _lock_owner: Option, + reply: fuser::ReplyWrite, + ) { + let vfs_fh = if let Some(OpenFileContent::VfsManaged(vfs_fh)) = + self.open_files.lock().unwrap().get(&fh.0) + { + *vfs_fh + } else { + reply.error(Errno::from_i32(EIO)); + return; + }; + match self.vfs.write(vfs_fh, offset, data) { + Ok(written) => reply.written(written), + Err(e) => reply.error(Errno::from_i32(e)), + } + } + + fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { + match self.vfs.unlink(parent.0, name) { + Ok(()) => reply.ok(), + Err(e) => reply.error(Errno::from_i32(e)), + } + } + + fn mkdir( + &self, + _req: &Request, + parent: INodeNo, + name: &OsStr, + mode: u32, + _umask: u32, + reply: ReplyEntry, + ) { + match self.vfs.mkdir(parent.0, name, mode) { + Ok(attr) => { + let fattr: fuser::FileAttr = attr.into(); + reply.entry(&Duration::MAX, &fattr, fuser::Generation(0)); + } + Err(e) => reply.error(Errno::from_i32(e)), + } + } + + fn rmdir(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { + match self.vfs.rmdir(parent.0, name) { + Ok(()) => reply.ok(), + Err(e) => reply.error(Errno::from_i32(e)), + } + } + + fn rename( + &self, + _req: &Request, + parent: INodeNo, + name: &OsStr, + newparent: INodeNo, + newname: &OsStr, + flags: fuser::RenameFlags, + reply: ReplyEmpty, + ) { + match self + .vfs + .rename(parent.0, name, newparent.0, newname, flags.bits()) + { + Ok(()) => reply.ok(), + Err(e) => reply.error(Errno::from_i32(e)), + } + } + + fn setattr( + &self, + _req: &Request, + ino: INodeNo, + mode: Option, + _uid: Option, + _gid: Option, + size: Option, + _atime: Option, + _mtime: Option, + _ctime: Option, + _fh: Option, + _crtime: Option, + _chgtime: Option, + _bkuptime: Option, + _flags: Option, + reply: ReplyAttr, + ) { + match self.vfs.setattr(ino.0, size, mode) { + Ok(attr) => { + let fattr: fuser::FileAttr = attr.into(); + reply.attr(&Duration::MAX, &fattr); + } + Err(e) => reply.error(Errno::from_i32(e)), + } + } +} diff --git a/crates/rattler_vfs/src/lib.rs b/crates/rattler_vfs/src/lib.rs new file mode 100644 index 0000000000..a7e2a54e25 --- /dev/null +++ b/crates/rattler_vfs/src/lib.rs @@ -0,0 +1,1724 @@ +//! Virtual conda environment mounts. +//! +//! `rattler_vfs` presents a conda environment as a virtual filesystem, serving +//! files directly from the package cache with on-the-fly prefix replacement. +//! No files are copied to disk for read-only use; a persistent copy-on-write +//! overlay enables writes (e.g. `pip install`) without modifying the cache. +//! +//! # Quick start +//! +//! ```rust,no_run +//! use rattler_vfs::{MountConfig, Transport, build_and_mount, compute_env_hash}; +//! use rattler_cache::{default_cache_dir, package_cache::PackageCache}; +//! use rattler_conda_types::Platform; +//! use rattler_lock::LockFile; +//! # async fn example() -> anyhow::Result<()> { +//! let lockfile = LockFile::from_path("pixi.lock".as_ref())?; +//! let platform = Platform::current(); +//! let env_hash = compute_env_hash(&lockfile, "default", platform)?; +//! let cache = PackageCache::new(default_cache_dir()?.join("pkgs")); +//! +//! let config = MountConfig::new_read_only( +//! "/path/to/env".into(), +//! Transport::Auto, +//! env_hash, +//! ); +//! let handle = build_and_mount(&lockfile, "default", platform, &cache, &config).await?; +//! // Environment is live at /path/to/env. +//! // Dropping `handle` unmounts; call `handle.unmount().await` for explicit error handling. +//! # Ok(()) +//! # } +//! ``` +//! +//! # Platform support +//! +//! | Platform | Default backend | Available | +//! |----------|-----------------|-----------| +//! | Linux | FUSE | FUSE, NFS | +//! | macOS | NFS | NFS, FUSE (requires [macFUSE]) | +//! | Windows | [ProjFS] | `ProjFS` | +//! +//! [`Transport::Auto`] selects the default for the current platform. +//! +//! **Why NFS on macOS?** FUSE on macOS requires [macFUSE], a third-party +//! kernel extension that needs System Integrity Protection (SIP) to be +//! reduced on Apple Silicon. Additionally, FUSE mounts lose all kernel vnode +//! code-signature caches on unmount, causing a significant Gatekeeper +//! re-verification penalty on every remount. The NFS transport uses macOS's +//! built-in NFS client, avoiding both issues. +//! +//! **Why FUSE on Linux?** FUSE has lower overhead than NFS on Linux (no TCP +//! stack, no marshalling) and supports kernel-level page caching and passthrough +//! I/O. The NFS backend is available as a fallback. +//! +//! **Why `ProjFS` on Windows?** [ProjFS] is built into Windows 10+ and mounts to +//! any directory without elevation. Its demand-driven callback model +//! ("materialize files when accessed") maps naturally onto virtual environments. +//! NFS is not supported as a transport on Windows due to client limitations +//! (portmapper requirements, drive-letter-only mounts, `NFSv2` fallback). +//! +//! **macOS alternatives under investigation:** [FSKit] is Apple's modern +//! successor to kernel extensions for filesystems, but current known +//! implementations target block-style storage rather than projected/virtual +//! filesystems. +//! +//! [macFUSE]: https://osxfuse.github.io/ +//! [ProjFS]: https://learn.microsoft.com/en-us/windows/win32/projfs/projected-file-system +//! [FSKit]: https://developer.apple.com/documentation/fskit + +#[cfg(target_os = "macos")] +pub mod codesign; +#[cfg(any(target_os = "linux", feature = "fuse"))] +pub mod fuse_adapter; +pub(crate) mod metadata_tree; +#[cfg(feature = "nfs")] +pub mod nfs_adapter; +pub mod overlay; +pub mod overlay_fs; +pub mod prefix_replacement; +#[cfg(target_os = "windows")] +pub mod projfs_adapter; +pub mod vfs_ops; +pub mod virtual_fs; + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::Arc, +}; + +use metadata_tree::MetadataNode; +use rattler::install::PythonInfo; +use rattler::install::python_entry_point_template; +use rattler_cache::package_cache::PackageCache; +use rattler_conda_types::Platform; +use rattler_conda_types::package::{EntryPoint, LinkJson, NoArchLinks, PackageFile, PathsJson}; +use rattler_lock::LockFile; +use rattler_networking::LazyClient; +use virtual_fs::VirtualFS; + +// --------------------------------------------------------------------------- +// Structured errors for downstream consumers (pixi) +// --------------------------------------------------------------------------- + +/// Errors from `rattler_vfs` that downstream consumers can match on. +/// +/// Most `rattler_vfs` functions return `anyhow::Result` for convenience, with +/// these variants as the underlying cause when a structured match is needed. +/// Use `anyhow::Error::downcast_ref::()` to extract them. +#[derive(Debug, thiserror::Error)] +pub enum MountError { + /// The requested environment was not found in the lock file. + #[error("environment '{name}' not found in lock file")] + EnvironmentNotFound { name: String }, + + /// No packages for the requested platform in the environment. + #[error("no packages for platform {platform} in environment '{environment}'")] + PlatformNotFound { + platform: Platform, + environment: String, + }, + + /// The `ProjFS` optional Windows feature is not enabled. + #[error( + "Windows Projected File System (ProjFS) is not available.\n\ + Enable it with (requires Administrator):\n\n \ + Enable-WindowsOptionalFeature -Online -FeatureName Client-ProjFS -NoRestart\n" + )] + ProjFsDllMissing, + + /// `ProjFS` does not support read-only mode. + #[error( + "ProjFS does not support read-only mode: it lacks a pre-creation \ + notification, so new files can always be created. Use Mode::Writable \ + instead (overlay_dir is ignored on ProjFS)." + )] + ProjFsReadOnlyUnsupported, + + /// Linux NFS mount requires passwordless sudo. + #[error( + "Linux NFS mount requires passwordless sudo (the NFS client needs \ + CAP_SYS_ADMIN). Configure passwordless sudo for `mount -t nfs` or \ + use Transport::Fuse instead." + )] + SudoRequired, + + /// The overlay's environment hash doesn't match the current lock file. + #[error( + "the overlay was created for a different environment (expected hash \ + '{expected}', found '{found}'). The overlay may contain files you \ + want to keep.\n\ + To reset the overlay for the new environment, remove it and remount:\n \ + rm -rf " + )] + OverlayEnvHashMismatch { expected: String, found: String }, + + /// The overlay directory was created by a different transport. + #[error( + "overlay was created with transport '{found}' but the current mount \ + requested '{expected}'. Remove the overlay manually or switch back \ + to the original transport." + )] + OverlayTransportMismatch { expected: String, found: String }, + + /// The requested transport is not available on this platform. + #[error("transport {transport:?} not available (missing feature or unsupported platform)")] + TransportNotAvailable { transport: Transport }, +} + +/// Build a virtual directory tree from a package's `PathsJson`. +/// +/// Each call extends `env_paths` and `directory_indices` with the entries +/// from one package. The `cache_path` should point to the extracted package +/// directory in the cache. +/// +/// For noarch Python packages, pass `python_info` to rewrite paths: +/// `site-packages/` → `lib/pythonX.Y/site-packages/` and +/// `python-scripts/` → `bin/`. +pub(crate) fn path_parse( + paths_json: &PathsJson, + cache_path: &Path, + python_info: Option<&PythonInfo>, + env_paths: &mut Vec, + directory_indices: &mut HashMap, +) { + let cachepath: Arc = cache_path.into(); + + for path in &paths_json.paths { + // For noarch Python packages, rewrite site-packages/ and python-scripts/ paths + let (virtual_path, cache_prefix_override) = match python_info { + Some(info) => { + let rewritten = info.get_python_noarch_target_path(&path.relative_path); + if rewritten.as_ref() == path.relative_path { + (path.relative_path.clone(), None) + } else { + let original_parent = path + .relative_path + .parent() + .map_or_else(|| PathBuf::from("."), |p| PathBuf::from(".").join(p)); + (rewritten.into_owned(), Some(original_parent)) + } + } + None => (path.relative_path.clone(), None), + }; + + let parent_directory = virtual_path.parent().unwrap_or(Path::new(".")); + let mut parent_index = 0; + + for component in parent_directory.components() { + let current_path = env_paths[parent_index] + .as_directory() + .expect("parent is always a directory") + .prefix_path + .join(component); + + if let Some(&index) = directory_indices.get(¤t_path) { + parent_index = index; + } else { + let new_dir = MetadataNode::new_directory(current_path.clone(), parent_index); + let child_index = env_paths.len(); + + env_paths.push(new_dir); + env_paths[parent_index] + .as_directory_mut() + .expect("parent is a directory") + .children + .push(child_index); + + directory_indices.insert(current_path, child_index); + parent_index = child_index; + } + } + + let file_name = virtual_path.file_name().expect("files always have names"); + + let file_index = env_paths.len(); + let mut file_entry = MetadataNode::new_file( + file_name.into(), + parent_index, + cachepath.clone(), + path.path_type, + path.prefix_placeholder.clone(), + ); + if let Some(ref override_path) = cache_prefix_override { + file_entry.as_file_mut().unwrap().cache_prefix_path = Some(override_path.clone()); + } + env_paths.push(file_entry); + + env_paths[parent_index] + .as_directory_mut() + .expect("parent is a directory") + .children + .push(file_index); + } +} + +/// Ensure a directory exists in the metadata tree, creating it if necessary. +/// Returns the index of the directory. +fn ensure_directory( + dir_path: PathBuf, + parent_index: usize, + env_paths: &mut Vec, + directory_indices: &mut HashMap, +) -> usize { + if let Some(&index) = directory_indices.get(&dir_path) { + return index; + } + let new_dir = MetadataNode::new_directory(dir_path.clone(), parent_index); + let child_index = env_paths.len(); + env_paths.push(new_dir); + env_paths[parent_index] + .as_directory_mut() + .expect("parent is a directory") + .children + .push(child_index); + directory_indices.insert(dir_path, child_index); + child_index +} + +/// Generate noarch python entry point scripts and add them as virtual files +/// in the metadata tree. +pub(crate) fn add_entry_points( + entry_points: &[EntryPoint], + target_prefix: &str, + python_info: &PythonInfo, + env_paths: &mut Vec, + directory_indices: &mut HashMap, +) { + let bin_dir = PathBuf::from("./bin"); + let bin_index = ensure_directory(bin_dir, 0, env_paths, directory_indices); + + for ep in entry_points { + let content = python_entry_point_template(target_prefix, false, ep, python_info); + let file_index = env_paths.len(); + env_paths.push(MetadataNode::new_virtual_file( + ep.command.as_str().into(), + bin_index, + content.into_bytes(), + )); + env_paths[bin_index] + .as_directory_mut() + .expect("bin is a directory") + .children + .push(file_index); + } +} + +/// Initialise the root directory and index for a new virtual filesystem tree. +pub(crate) fn new_empty_tree() -> (Vec, HashMap) { + let env_paths = vec![MetadataNode::new_directory(PathBuf::from("."), 0)]; + let mut directory_indices = HashMap::new(); + directory_indices.insert(PathBuf::from("."), 0); + (env_paths, directory_indices) +} + +// --------------------------------------------------------------------------- +// Library API: mount orchestration +// --------------------------------------------------------------------------- + +/// Opaque metadata tree produced by [`build_metadata_tree`]. +/// +/// Pass to [`mount`] or [`build_and_mount`]; the internal representation is +/// not stable and is intentionally not exposed. The newtype wrapper means +/// downstream consumers cannot construct one directly — guaranteeing every +/// mount went through `build_metadata_tree`'s validation. +pub struct MetadataTree(pub(crate) Vec); + +/// Transport backend for the virtual filesystem. +/// +/// See the [crate-level docs](crate#platform-support) for why each platform +/// has a different default. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Transport { + /// Auto-detect the best backend for the current platform: FUSE on Linux, + /// NFS on macOS, `ProjFS` on Windows. + Auto, + /// `NFSv3` userspace server on localhost. Works on all platforms without + /// kernel extensions. On Windows, constrained to port 2049 and drive letters. + /// + /// **Linux note:** `mount -t nfs` requires `CAP_SYS_ADMIN`, which is not + /// granted in unprivileged user namespaces. The adapter probes for + /// passwordless `sudo` before attempting the mount and fails fast if it's + /// not available. On Linux, prefer [`Transport::Fuse`] unless you + /// specifically need NFS parity with macOS — [`Transport::Auto`] already + /// picks FUSE. + Nfs, + /// FUSE via libfuse3 (Linux) or macFUSE (macOS, requires `fuse` feature). + /// Not available on Windows. + Fuse, + /// Windows Projected File System. Demand-driven: files are materialized on + /// first access. Only available on Windows 10 version 1809+. + ProjFs, +} + +impl Transport { + /// Short name for state file tracking. + pub fn name(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Nfs => "nfs", + Self::Fuse => "fuse", + Self::ProjFs => "projfs", + } + } + + /// Resolve `Auto` to the platform-appropriate transport. + pub fn resolve(self) -> Self { + match self { + Self::Auto => { + if cfg!(target_os = "windows") { + Self::ProjFs + } else if cfg!(target_os = "macos") { + Self::Nfs + } else { + Self::Fuse + } + } + other => other, + } + } + + /// Whether this transport is available on the current platform and build. + /// + /// Use this at config-parse time to reject invalid combinations early + /// instead of waiting for [`mount`] to fail at runtime. + pub fn is_available(self) -> bool { + match self.resolve() { + Self::Auto => unreachable!("resolve() never returns Auto"), + Self::Fuse => cfg!(any(target_os = "linux", feature = "fuse")), + Self::Nfs => cfg!(feature = "nfs"), + Self::ProjFs => cfg!(target_os = "windows"), + } + } +} + +/// Whether the mount is read-only or writable, and where the writable +/// overlay lives. +/// +/// `ProjFS` does not support read-only mode (it lacks a pre-creation +/// notification, so new files can always be created via the virtualization +/// root) — passing [`Mode::ReadOnly`] to a `ProjFS` mount returns a clear error. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum Mode { + /// Read-only mount. Writes return `EROFS`. Not supported on `ProjFS` — + /// use [`Mode::ReadOnlyIfSupported`] for cross-platform configs. + ReadOnly, + + /// Read-only if the transport supports it, otherwise writable. + /// + /// On FUSE/NFS this behaves identically to [`Mode::ReadOnly`]. + /// On `ProjFS` (which cannot enforce read-only) this silently falls + /// through to writable mode and logs a warning. Use this in pixi + /// configs where `mount-read-only = true` should work cross-platform. + ReadOnlyIfSupported, + + /// Writable mount. Writes go to a persistent copy-on-write overlay. + /// + /// For FUSE/NFS, `overlay_dir` is a separate persistent directory pinned + /// to a specific environment via [`MountConfig::env_hash`]. + /// + /// For `ProjFS`, `overlay_dir` is ignored — `ProjFS` writes hydrated + /// content directly to the virtualization root (the mount point) and + /// tracks deletions via tombstones. Pass `overlay_dir: None` for `ProjFS`. + Writable { + /// Persistent overlay directory for FUSE/NFS. `None` is valid only + /// for `ProjFS`, which uses the mount point itself. + overlay_dir: Option, + }, +} + +/// Configuration for mounting a virtual environment. +/// +/// Marked `#[non_exhaustive]` so new fields can be added without a `SemVer` +/// break. Construct via [`MountConfig::new_read_only`] or +/// [`MountConfig::new_writable`], optionally chaining +/// [`with_allow_other`](MountConfig::with_allow_other). +#[non_exhaustive] +pub struct MountConfig { + /// Directory where the virtual environment will appear. + pub mount_point: PathBuf, + + /// Read-only or writable, and where the overlay lives. + pub mode: Mode, + + /// Transport backend. Use [`Transport::Auto`] to let the platform decide. + pub transport: Transport, + + /// Identity hash of the resolved environment, used to detect when the + /// environment has changed and the overlay needs to be reset. Compute + /// with [`compute_env_hash`]. + pub env_hash: String, + + /// Allow other users to access the mount. Only applies to FUSE; requires + /// `user_allow_other` in `/etc/fuse.conf`. Most use cases don't need this. + pub allow_other: bool, +} + +impl MountConfig { + /// Read-only mount. Writes return `EROFS`. + pub fn new_read_only(mount_point: PathBuf, transport: Transport, env_hash: String) -> Self { + Self { + mount_point, + mode: Mode::ReadOnly, + transport, + env_hash, + allow_other: false, + } + } + + /// Read-only if the transport supports it, otherwise writable. + /// + /// On FUSE/NFS this behaves like [`Self::new_read_only`]. On `ProjFS` — + /// which cannot enforce read-only, since it has no pre-creation + /// notification — it falls through to a writable mount (logging a warning) + /// instead of erroring with [`MountError::ProjFsReadOnlyUnsupported`]. Use + /// this for cross-platform configs where `mount-read-only = true` should + /// still work on Windows. + pub fn new_read_only_if_supported( + mount_point: PathBuf, + transport: Transport, + env_hash: String, + ) -> Self { + Self { + mount_point, + mode: Mode::ReadOnlyIfSupported, + transport, + env_hash, + allow_other: false, + } + } + + /// Writable mount with a persistent COW overlay. + /// + /// `overlay_dir` is required for FUSE/NFS and must be a separate directory + /// from `mount_point`. Pass `None` for `ProjFS` — `ProjFS` uses the mount + /// point itself as the virtualization root. + pub fn new_writable( + mount_point: PathBuf, + overlay_dir: Option, + transport: Transport, + env_hash: String, + ) -> Self { + Self { + mount_point, + mode: Mode::Writable { overlay_dir }, + transport, + env_hash, + allow_other: false, + } + } + + /// Allow other users to access the mount (FUSE only). + pub fn with_allow_other(mut self, allow_other: bool) -> Self { + self.allow_other = allow_other; + self + } +} + +/// Handle to a running mount. +/// +/// The mount stays live for as long as this handle exists. Dropping it +/// triggers a best-effort unmount and stops the background server (NFS) or +/// session (FUSE). Use [`MountHandle::unmount`] for explicit, error-returning +/// unmount; prefer it over relying on Drop when error handling matters. +/// +/// Marked `#[non_exhaustive]` so new transport variants can be added without +/// a `SemVer` break. Downstream `match` arms must include `_ =>` to be exhaustive. +#[non_exhaustive] +pub enum MountHandle { + #[cfg(feature = "nfs")] + Nfs(nfs_adapter::NfsMountHandle), + #[cfg(any(target_os = "linux", feature = "fuse"))] + Fuse(fuser::BackgroundSession), + #[cfg(target_os = "windows")] + ProjFs(projfs_adapter::ProjFsHandle), +} + +impl MountHandle { + /// Whether the mount's backing server is still running. + /// + /// Currently only meaningful for the NFS transport, where the userspace + /// server task can exit unexpectedly (panic, I/O error, or unexpected + /// clean return) leaving the kernel mount stale. FUSE and `ProjFS` mounts + /// are managed by the kernel and always report healthy from userspace. + /// + /// Pixi's `MountGuard` can poll this to detect a dead server before + /// handing out a reference to the environment. + #[allow(unreachable_patterns)] + pub fn is_healthy(&self) -> bool { + match self { + #[cfg(feature = "nfs")] + Self::Nfs(h) => h.is_healthy(), + _ => true, + } + } + + /// Explicitly unmount the filesystem and shut down the backing server. + /// + /// This is async so the NFS unmount path can use `tokio::process::Command` + /// instead of blocking the runtime. FUSE and `ProjFS` unmount synchronously + /// (kernel-managed, no subprocess) so the async boundary is free for them. + /// + /// Prefer this over relying on Drop when error handling matters (e.g. + /// sidecar shutdown, CI cleanup, signal-handling paths). Drop stays as a + /// best-effort fallback that logs failures but cannot return them. + #[allow(unreachable_patterns)] + pub async fn unmount(self) -> anyhow::Result<()> { + match self { + #[cfg(feature = "nfs")] + Self::Nfs(h) => h.unmount().await, + #[cfg(any(target_os = "linux", feature = "fuse"))] + Self::Fuse(session) => { + // fuser does not expose a Result-returning unmount; dropping + // the BackgroundSession is the documented shutdown path. + drop(session); + Ok(()) + } + #[cfg(target_os = "windows")] + Self::ProjFs(h) => h.unmount(), + _ => Ok(()), + } + } +} + +/// Build the in-memory metadata tree from a parsed lock file. +/// +/// Fetches packages from `package_cache` as needed, reads `PathsJson` for each, +/// and constructs the virtual directory tree with noarch Python path rewriting +/// and entry point generation. +/// +/// Caller responsibilities: +/// - Parse the lock file once via [`LockFile::from_path`]. +/// - Pick a [`Platform`] (usually [`Platform::current()`]). +/// - Construct a [`PackageCache`] (commonly via +/// [`rattler_cache::default_cache_dir()`]). Decoupling the cache from this +/// function lets pixi share its own cache and lets tests use a temp dir. +pub async fn build_metadata_tree( + lockfile: &LockFile, + environment_name: &str, + platform: Platform, + package_cache: &PackageCache, + mount_point: &Path, +) -> anyhow::Result { + let environment = + lockfile + .environment(environment_name) + .ok_or(MountError::EnvironmentNotFound { + name: environment_name.to_string(), + })?; + let package_refs: Vec<_> = lockfile + .platform(platform.as_str()) + .and_then(|p| environment.packages(p)) + .ok_or(MountError::PlatformNotFound { + platform, + environment: environment_name.to_string(), + })? + .collect(); + + let python_info = package_refs + .iter() + .filter_map(|p| p.as_binary_conda()) + .find(|p| p.package_record.name.as_normalized() == "python") + .map(|p| PythonInfo::from_python_record(&p.package_record, platform)) + .transpose() + .map_err(|e| anyhow::anyhow!("failed to get python info: {e}"))?; + + let (mut env_paths, mut directory_indices) = new_empty_tree(); + let mount_str = mount_point.to_string_lossy().to_string(); + + // Build a single lazily-initialized HTTP client for the whole package loop. + // `LazyClient::default()` forces construction eagerly, which on macOS walks + // the keychain via `rustls_native_certs` and takes several seconds. Using + // `LazyClient::new` defers that work until the first cache miss, so + // warm-cache mounts skip it entirely. + let client = LazyClient::new(reqwest_middleware::ClientWithMiddleware::default); + + // Validate all packages up front so we can parallelize fetching. + let mut conda_packages: Vec<_> = package_refs + .iter() + .filter_map(|p| p.as_binary_conda()) + .collect(); + + // Sort largest first so long downloads start early (mirrors rattler's + // installer pattern at installer/mod.rs:600). + conda_packages.sort_by(|a, b| { + b.package_record + .size + .unwrap_or(0) + .cmp(&a.package_record.size.unwrap_or(0)) + }); + + // Fetch + parse packages in parallel. The tree mutation (path_parse) + // stays serial because env_paths/directory_indices are shared mutable + // state — the downloads and JSON parses are the expensive parts. + let concurrency = Arc::new(tokio::sync::Semaphore::new(16)); + let mut join_set = tokio::task::JoinSet::new(); + + for package_data in &conda_packages { + let cache = package_cache.clone(); + let client = client.clone(); + let record = package_data.package_record.clone(); + let location = package_data.location.clone(); + let is_noarch_python = package_data.package_record.noarch.is_python(); + let sem = concurrency.clone(); + + join_set.spawn(async move { + let _permit = sem + .acquire() + .await + .map_err(|e| anyhow::anyhow!("concurrency semaphore closed: {e}"))?; + + let url = location + .as_url() + .ok_or_else(|| anyhow::anyhow!("package has no URL"))? + .clone(); + let cache_metadata = cache + .get_or_fetch_from_url_with_retry( + &record, + url, + client, + rattler_networking::retry_policies::default_retry_policy(), + None, + // rattler_vfs limits concurrency via its own semaphore + // (acquired above), so don't apply the cache's limiter too. + None, + ) + .await?; + + // Parse paths.json inside the spawned task to avoid blocking the + // main runtime thread. + let path = cache_metadata.path().to_path_buf(); + let paths_json = PathsJson::from_package_directory_with_deprecated_fallback(&path)?; + + // For noarch python packages, also load link.json for entry points. + let entry_points: Vec = if is_noarch_python { + LinkJson::from_package_directory(&path) + .ok() + .and_then(|lj| match lj.noarch { + NoArchLinks::Python(ep) => Some(ep.entry_points), + NoArchLinks::Generic => None, + }) + .unwrap_or_default() + } else { + Vec::new() + }; + + Ok::<_, anyhow::Error>((path, paths_json, is_noarch_python, entry_points)) + }); + } + + // Collect results and build the tree serially. + while let Some(result) = join_set.join_next().await { + let (cache_path, paths_json, is_noarch_python, entry_points) = + result.map_err(|e| anyhow::anyhow!("fetch task failed: {e}"))??; + + let noarch_python_info = if is_noarch_python { + python_info.as_ref() + } else { + None + }; + path_parse( + &paths_json, + &cache_path, + noarch_python_info, + &mut env_paths, + &mut directory_indices, + ); + + if let Some(ref python_info) = python_info + && !entry_points.is_empty() + { + add_entry_points( + &entry_points, + &mount_str, + python_info, + &mut env_paths, + &mut directory_indices, + ); + } + + tracing::debug!("parsed {} metadata entries", env_paths.len()); + } + + Ok(MetadataTree(env_paths)) +} + +/// Mount a pre-built metadata tree. Returns a handle that unmounts on drop. +pub async fn mount(metadata: MetadataTree, config: &MountConfig) -> anyhow::Result { + let transport = config.transport.resolve(); + + // ProjFS-specific pre-flight checks: DLL availability, mode validity, + // overlay state. Done before VFS construction so we don't waste offset + // computation if ProjFS isn't installed or the user passed Mode::ReadOnly. + #[cfg(target_os = "windows")] + if matches!(transport, Transport::ProjFs) { + // Verify that the ProjFS optional feature is enabled before calling + // any ProjFS API. The `windows` crate delay-loads the DLL, so a + // missing feature won't crash the process, but the first API call + // would return a confusing "not found" HRESULT. Give users a clear + // message instead. + { + use std::os::windows::ffi::OsStrExt; + let dll: Vec = std::ffi::OsStr::new("projectedfslib.dll") + .encode_wide() + .chain(Some(0)) + .collect(); + let handle = unsafe { + windows::Win32::System::LibraryLoader::LoadLibraryW(windows::core::PCWSTR( + dll.as_ptr(), + )) + }; + if handle.is_err() { + return Err(MountError::ProjFsDllMissing.into()); + } + } + + // ProjFS is always writable — it writes hydrated content directly + // to the virtualization root and tracks deletions via tombstones. + // There is no read-only mode: ProjFS lacks a pre-creation + // notification, so new files can always be created. + if matches!(config.mode, Mode::ReadOnly) { + return Err(MountError::ProjFsReadOnlyUnsupported.into()); + } + if matches!(config.mode, Mode::ReadOnlyIfSupported) { + tracing::warn!("ProjFS does not support read-only mode; falling through to writable"); + } + + // Validate overlay state (env hash) to reject stale mounts. + { + use crate::overlay::{OverlayError, OverlayState}; + match OverlayState::load( + config.mount_point.clone(), + config.env_hash.clone(), + "projfs".to_string(), + ) { + Ok(_) => {} // hash matches or fresh overlay + Err(OverlayError::EnvHashMismatch { + expected, found, .. + }) => { + return Err(MountError::OverlayEnvHashMismatch { expected, found }.into()); + } + Err(OverlayError::TransportMismatch { + expected, found, .. + }) => { + return Err(MountError::OverlayTransportMismatch { expected, found }.into()); + } + Err(e) => anyhow::bail!("overlay state check failed: {e}"), + } + } + } + + // Construct the VirtualFS once. Each transport branch consumes it. + // VFS construction does eager prefix-offset computation, so we want + // exactly one call per mount. + let vfs = VirtualFS::new(metadata.0, &config.mount_point); + + match transport { + #[cfg(feature = "nfs")] + Transport::Nfs => Ok(MountHandle::Nfs(mount_nfs(vfs, config).await?)), + #[cfg(any(target_os = "linux", feature = "fuse"))] + Transport::Fuse => Ok(MountHandle::Fuse(mount_fuse(vfs, config)?)), + #[cfg(target_os = "windows")] + Transport::ProjFs => { + let adapter = projfs_adapter::ProjFsAdapter::new(vfs); + let handle = adapter.start(&config.mount_point)?; + Ok(MountHandle::ProjFs(handle)) + } + #[allow(unreachable_patterns)] + _ => Err(MountError::TransportNotAvailable { transport })?, + } +} + +/// Build the metadata tree from a lock file and mount it. +/// +/// This is the main entry point for library consumers. It looks up the +/// environment + platform in `lockfile`, fetches each package via +/// `package_cache`, constructs the virtual directory tree, and mounts it. +/// Returns a [`MountHandle`] that unmounts on drop (or call +/// [`MountHandle::unmount`] for explicit error handling). +pub async fn build_and_mount( + lockfile: &LockFile, + environment_name: &str, + platform: Platform, + package_cache: &PackageCache, + config: &MountConfig, +) -> anyhow::Result { + let metadata = build_metadata_tree( + lockfile, + environment_name, + platform, + package_cache, + &config.mount_point, + ) + .await?; + mount(metadata, config).await +} + +/// Schema version for [`compute_env_hash`]. Bump when the canonical form +/// changes so overlays are intentionally invalidated rather than silently +/// drifting. The golden test `test_env_hash_stability` will fail when this +/// is bumped, reminding the author to update the expected hash. +pub const ENV_HASH_SCHEMA_VERSION: u32 = 2; + +/// Compute an environment identity hash scoped to a single `(env, platform)`. +/// +/// Hashes only the resolved package list for `environment_name` on `platform`, +/// **not** the entire lock-file bytes. Two consequences: +/// +/// 1. Editing environment B does not invalidate overlays for environment A. +/// The pixi sidecar can keep one overlay per `(lockfile, env, platform)` +/// tuple without thrashing on unrelated changes. +/// 2. Reformatting the lock file or reordering its packages does not change +/// the hash, because each package is built into an explicit canonical +/// string (not serde-derived) and the strings are sorted before hashing. +/// +/// The canonical form uses `\0`-separated fields per package to prevent +/// concatenation collisions. Only fields that identify the package content +/// are included (name, version, build/subdir for conda, url for pypi, and +/// the content sha256 when available). +pub fn compute_env_hash( + lockfile: &LockFile, + environment_name: &str, + platform: Platform, +) -> anyhow::Result { + use sha2::{Digest, Sha256}; + + let environment = + lockfile + .environment(environment_name) + .ok_or(MountError::EnvironmentNotFound { + name: environment_name.to_string(), + })?; + let packages = lockfile + .platform(platform.as_str()) + .and_then(|p| environment.packages(p)) + .ok_or(MountError::PlatformNotFound { + platform, + environment: environment_name.to_string(), + })?; + + let mut hasher = Sha256::new(); + hasher.update(ENV_HASH_SCHEMA_VERSION.to_le_bytes()); + + // Build an explicit canonical string per package using only the fields + // that identify its content. Sorted before hashing so reordering inside + // the lockfile does not change the output. + let mut package_keys: Vec = packages + .map(|pkg| match pkg { + rattler_lock::LockedPackage::Conda(c) => { + let record = c.record(); + let sha_hex = record + .and_then(|r| r.sha256.as_ref()) + .map(hex::encode) + .unwrap_or_default(); + if sha_hex.is_empty() { + tracing::warn!( + "package {} has no sha256; env hash may collide across rebuilds", + c.name().as_normalized(), + ); + } + format!( + "conda\0{}\0{}\0{}\0{}\0{}", + c.name().as_normalized(), + record.map(|r| r.version.to_string()).unwrap_or_default(), + record.map(|r| r.build.as_str()).unwrap_or_default(), + record.map(|r| r.subdir.as_str()).unwrap_or_default(), + sha_hex, + ) + } + rattler_lock::LockedPackage::Pypi(p) => { + let sha_hex = p + .as_wheel() + .and_then(|w| w.hash.as_ref()) + .and_then(|h| h.sha256()) + .map(hex::encode) + .unwrap_or_default(); + format!( + "pypi\0{}\0{}\0{}\0{}", + p.name(), + p.version_string(), + p.location(), + sha_hex, + ) + } + }) + .collect(); + package_keys.sort(); + + for key in &package_keys { + hasher.update(key.as_bytes()); + hasher.update(b"\n"); + } + Ok(format!("sha256:{}", hex::encode(hasher.finalize()))) +} + +// --------------------------------------------------------------------------- +// Internal: transport-specific mount helpers +// --------------------------------------------------------------------------- + +/// Mount via FUSE, with optional writable overlay. +/// +/// The overlay is retried once if the env hash mismatches (environment updated). +#[cfg(any(target_os = "linux", feature = "fuse"))] +fn mount_fuse(vfs: VirtualFS, config: &MountConfig) -> anyhow::Result { + use fuse_adapter::FuseAdapter; + use fuser::{Config as FuserConfig, MountOption, SessionACL}; + + let mut fuser_config = FuserConfig::default(); + fuser_config.mount_options = vec![ + MountOption::FSName("conda-packages".to_string()), + MountOption::CUSTOM("noatime".to_string()), + ]; + if matches!(config.mode, Mode::ReadOnly | Mode::ReadOnlyIfSupported) { + fuser_config.mount_options.push(MountOption::RO); + } + if config.allow_other { + fuser_config.acl = SessionACL::All; + } + + match &config.mode { + Mode::Writable { + overlay_dir: Some(overlay_dir), + } => { + let overlay = create_overlay(vfs, overlay_dir, &config.env_hash, "fuse")?; + let adapter = FuseAdapter::new(overlay); + Ok(fuser::spawn_mount2( + adapter, + &config.mount_point, + &fuser_config, + )?) + } + Mode::Writable { overlay_dir: None } => { + anyhow::bail!( + "FUSE writable mode requires an overlay directory. Use \ + MountConfig::new_writable(.., Some(overlay_dir), ..) or \ + MountConfig::new_read_only(..) for a read-only mount." + ); + } + Mode::ReadOnly | Mode::ReadOnlyIfSupported => { + let adapter = FuseAdapter::new(vfs); + Ok(fuser::spawn_mount2( + adapter, + &config.mount_point, + &fuser_config, + )?) + } + } +} + +/// Mount via NFS, with optional writable overlay. +/// +/// The overlay is retried once if the env hash mismatches (environment updated). +#[cfg(feature = "nfs")] +async fn mount_nfs( + vfs: VirtualFS, + config: &MountConfig, +) -> anyhow::Result { + use nfs_adapter::NfsAdapter; + + let read_only = matches!(config.mode, Mode::ReadOnly | Mode::ReadOnlyIfSupported); + + let bind_port = 0u16; + + let server_handle = match &config.mode { + Mode::Writable { + overlay_dir: Some(overlay_dir), + } => { + let overlay = create_overlay(vfs, overlay_dir, &config.env_hash, "nfs")?; + NfsAdapter::new(overlay).serve(bind_port).await? + } + Mode::Writable { overlay_dir: None } => { + anyhow::bail!( + "NFS writable mode requires an overlay directory. Use \ + MountConfig::new_writable(.., Some(overlay_dir), ..) or \ + MountConfig::new_read_only(..) for a read-only mount." + ); + } + Mode::ReadOnly | Mode::ReadOnlyIfSupported => NfsAdapter::new(vfs).serve(bind_port).await?, + }; + + let port = server_handle.port(); + + // `soft` with a bounded timeout so a dead userspace NFS server (e.g. the + // sidecar crashed) surfaces EIO to clients instead of wedging them in + // uninterruptible D-state on every access, which a hard mount would. The + // server is always local, so the usual soft-mount data-loss caveat is moot: + // if it dies, the mount is gone regardless. timeo is in deciseconds. + let mut opts = format!( + "noacl,nolock,soft,timeo=100,retrans=3,vers=3,tcp,port={port},mountport={port},rsize=1048576" + ); + if read_only { + opts.push_str(",ro"); + } else { + opts.push_str(",wsize=1048576"); + } + + #[cfg(target_os = "macos")] + { + let mnt = config.mount_point.display().to_string(); + let status = tokio::process::Command::new("mount_nfs") + .args(["-o", &opts, "localhost:/", &mnt]) + .status() + .await?; + if !status.success() { + server_handle.abort(); + anyhow::bail!("NFS mount failed with exit status {status}"); + } + } + + #[cfg(target_os = "linux")] + { + let mnt = config.mount_point.display().to_string(); + // Probe passwordless sudo first — `mount -t nfs` needs CAP_SYS_ADMIN + // which isn't available in unprivileged user namespaces, so there's no + // userspace fallback we can reach for. Fail loudly instead of letting + // sudo prompt interactively (terrible UX in `pixi run`). + // + // TODO(bind-mount): investigate `unshare -Urm` + `mount --bind` as a + // rootless alternative transport on Linux. That would work in rootless + // containers where neither FUSE nor sudo is available. + let probe = tokio::process::Command::new("sudo") + .args(["-n", "true"]) + .status() + .await; + match probe { + Ok(s) if s.success() => {} + _ => { + server_handle.abort(); + return Err(MountError::SudoRequired.into()); + } + } + + let status = tokio::process::Command::new("sudo") + .args(["mount", "-t", "nfs", "-o", &opts, "localhost:/", &mnt]) + .status() + .await?; + if !status.success() { + server_handle.abort(); + anyhow::bail!("NFS mount failed with exit status {status}"); + } + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + server_handle.abort(); + anyhow::bail!("NFS mount is not supported on this platform. Use ProjFS on Windows."); + } + + // Only reachable on the NFS-capable targets; on other platforms the block + // above diverges, so gate the success tail to avoid unreachable-code errors. + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + tracing::info!("mounted via NFS on {}", config.mount_point.display()); + + Ok(nfs_adapter::NfsMountHandle { + mount_point: config.mount_point.clone(), + server_handle, + unmounted: false, + }) + } +} + +/// Create an overlay, wiping and retrying transparently on state-version +/// mismatch (internal schema change). Returns a structured error on env-hash +/// mismatch so the caller can decide whether to wipe — the overlay may contain +/// user work. Refuses (does not wipe) on transport mismatch. +/// +/// Acquires the directory lock once and carries it through the wipe-and-retry +/// path so no other process can sneak in between the wipe and the reload. +#[cfg(any(feature = "nfs", target_os = "linux", feature = "fuse"))] +fn create_overlay( + vfs: VirtualFS, + overlay_dir: &Path, + env_hash: &str, + transport: &str, +) -> anyhow::Result> { + use crate::overlay::{OverlayError, OverlayState}; + + // Acquire the lock once, before the first load attempt. The lock handle + // is passed through both the initial load and the retry so the wipe + // step is protected. + let lock = OverlayState::acquire_lock(overlay_dir) + .map_err(|e| anyhow::anyhow!("failed to acquire overlay lock: {e}"))?; + + let state = match OverlayState::load_with_lock( + overlay_dir.to_path_buf(), + env_hash.to_string(), + transport.to_string(), + lock, + ) { + Ok(state) => state, + Err(OverlayError::EnvHashMismatch { + expected, found, .. + }) => { + return Err(MountError::OverlayEnvHashMismatch { expected, found }.into()); + } + Err(OverlayError::VersionMismatch { lock, .. }) => { + tracing::info!("overlay state version changed; wiping and recreating"); + // Lock is still held — safe to wipe without a race. + if overlay_dir.exists() { + std::fs::remove_dir_all(overlay_dir)?; + } + OverlayState::load_with_lock( + overlay_dir.to_path_buf(), + env_hash.to_string(), + transport.to_string(), + lock, + ) + .map_err(|e| anyhow::anyhow!("failed to recreate overlay state: {e}"))? + } + Err(OverlayError::TransportMismatch { + expected, found, .. + }) => { + return Err(MountError::OverlayTransportMismatch { expected, found }.into()); + } + Err(e) => anyhow::bail!("failed to load overlay state: {e}"), + }; + + overlay_fs::OverlayFS::wrap(vfs, state) + .map_err(|e| anyhow::anyhow!("failed to wrap VFS with overlay: {e}")) +} + +/// Force unmount a mount point. +/// +/// Best-effort cleanup for stale mounts (e.g. after a crash). The +/// `transport` hint selects the right teardown method: +/// +/// | Platform | Transport | Method | +/// |----------|-----------|--------| +/// | Linux | FUSE | `fusermount3 -uz` | +/// | Linux | NFS | `sudo umount -f` (requires passwordless sudo) | +/// | macOS | FUSE / NFS | `umount -f` | +/// | Windows | `ProjFS` | Not yet supported — returns an error | +/// +/// Pass [`Transport::Auto`] to use the platform default. +/// +/// **NFS on Linux note:** `umount -f` requires `CAP_SYS_ADMIN`. If +/// passwordless sudo is not available, this will fail. Consider switching +/// to [`Transport::Fuse`] where possible. +/// +/// **`ProjFS` note:** stale `ProjFS` mounts are structurally different — the +/// virtualization context died with the owning process, but hydrated files +/// remain. Recovery currently requires wiping the directory and remounting. +/// A future version may support re-attaching to an existing virtualization +/// root. +pub fn force_unmount(mount_point: &Path, transport: Transport) -> anyhow::Result<()> { + let transport = transport.resolve(); + let mnt = mount_point.display().to_string(); + + match transport { + #[cfg(any(target_os = "linux", feature = "fuse"))] + Transport::Fuse => { + #[cfg(target_os = "linux")] + { + let status = std::process::Command::new("fusermount3") + .args(["-uz", &mnt]) + .status()?; + if !status.success() { + anyhow::bail!("fusermount3 -uz {mnt} failed (exit {status})"); + } + } + #[cfg(target_os = "macos")] + { + let status = std::process::Command::new("umount") + .args(["-f", &mnt]) + .status()?; + if !status.success() { + anyhow::bail!("umount -f {mnt} failed (exit {status})"); + } + } + } + #[cfg(feature = "nfs")] + Transport::Nfs => { + #[cfg(target_os = "macos")] + { + let status = std::process::Command::new("umount") + .args(["-f", &mnt]) + .status()?; + if !status.success() { + anyhow::bail!("umount -f {mnt} failed (exit {status})"); + } + } + #[cfg(target_os = "linux")] + { + let status = std::process::Command::new("sudo") + .args(["umount", "-f", &mnt]) + .status()?; + if !status.success() { + anyhow::bail!( + "sudo umount -f {mnt} failed (exit {status}). \ + NFS force-unmount on Linux requires passwordless sudo." + ); + } + } + } + #[cfg(target_os = "windows")] + Transport::ProjFs => { + anyhow::bail!( + "ProjFS stale-mount recovery is not yet supported. \ + The virtualization context died with the owning process; \ + hydrated files remain at {mnt}. Remove the directory \ + manually and remount, or wait for re-attach support." + ); + } + _ => { + anyhow::bail!( + "force_unmount: transport {transport:?} is not available on this platform" + ); + } + } + + #[allow(unreachable_code)] + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rattler_conda_types::package::{PathType, PathsEntry, PathsJson}; + use std::path::PathBuf; + + fn make_paths_json(paths: Vec<&str>) -> PathsJson { + PathsJson { + paths: paths + .into_iter() + .map(|p| PathsEntry { + relative_path: PathBuf::from(p), + path_type: PathType::HardLink, + prefix_placeholder: None, + no_link: false, + sha256: None, + size_in_bytes: None, + }) + .collect(), + paths_version: 1, + } + } + + #[test] + fn test_single_file_at_root() { + let paths_json = make_paths_json(vec!["foo.txt"]); + let (mut env_paths, mut dir_indices) = new_empty_tree(); + path_parse( + &paths_json, + Path::new("/cache/pkg"), + None, + &mut env_paths, + &mut dir_indices, + ); + + assert_eq!(env_paths.len(), 2); // root + foo.txt + let root = env_paths[0].as_directory().unwrap(); + assert_eq!(root.children.len(), 1); + let file = env_paths[root.children[0]].as_file().unwrap(); + assert_eq!(file.file_name, "foo.txt"); + assert_eq!(&*file.cache_base_path, Path::new("/cache/pkg")); + } + + #[test] + fn test_nested_directories() { + let paths_json = make_paths_json(vec!["a/b/c.txt"]); + let (mut env_paths, mut dir_indices) = new_empty_tree(); + path_parse( + &paths_json, + Path::new("/cache/pkg"), + None, + &mut env_paths, + &mut dir_indices, + ); + + // root, dir "a", dir "a/b", file "c.txt" + assert_eq!(env_paths.len(), 4); + + let root = env_paths[0].as_directory().unwrap(); + assert_eq!(root.children.len(), 1); + + let dir_a = env_paths[root.children[0]].as_directory().unwrap(); + assert_eq!(dir_a.prefix_path, PathBuf::from("./a")); + assert_eq!(dir_a.children.len(), 1); + + let dir_b = env_paths[dir_a.children[0]].as_directory().unwrap(); + assert_eq!(dir_b.prefix_path, PathBuf::from("./a/b")); + assert_eq!(dir_b.children.len(), 1); + + let file = env_paths[dir_b.children[0]].as_file().unwrap(); + assert_eq!(file.file_name, "c.txt"); + } + + #[test] + fn test_directory_dedup() { + let paths_json = make_paths_json(vec!["lib/foo", "lib/bar"]); + let (mut env_paths, mut dir_indices) = new_empty_tree(); + path_parse( + &paths_json, + Path::new("/cache/pkg"), + None, + &mut env_paths, + &mut dir_indices, + ); + + // root, dir "lib", file "foo", file "bar" + assert_eq!(env_paths.len(), 4); + + let root = env_paths[0].as_directory().unwrap(); + assert_eq!(root.children.len(), 1); // single lib dir + + let lib_dir = env_paths[root.children[0]].as_directory().unwrap(); + assert_eq!(lib_dir.children.len(), 2); // foo and bar + } + + #[test] + fn test_multiple_packages() { + let pkg1 = make_paths_json(vec!["lib/foo.so"]); + let pkg2 = make_paths_json(vec!["lib/bar.so"]); + + let (mut env_paths, mut dir_indices) = new_empty_tree(); + path_parse( + &pkg1, + Path::new("/cache/pkg1"), + None, + &mut env_paths, + &mut dir_indices, + ); + path_parse( + &pkg2, + Path::new("/cache/pkg2"), + None, + &mut env_paths, + &mut dir_indices, + ); + + // root, dir "lib", file "foo.so", file "bar.so" + assert_eq!(env_paths.len(), 4); + + let lib_dir = env_paths[1].as_directory().unwrap(); + assert_eq!(lib_dir.children.len(), 2); + + let foo = env_paths[lib_dir.children[0]].as_file().unwrap(); + assert_eq!(&*foo.cache_base_path, Path::new("/cache/pkg1")); + + let bar = env_paths[lib_dir.children[1]].as_file().unwrap(); + assert_eq!(&*bar.cache_base_path, Path::new("/cache/pkg2")); + } + + #[test] + fn test_empty_paths_json() { + let paths_json = make_paths_json(vec![]); + let (mut env_paths, mut dir_indices) = new_empty_tree(); + path_parse( + &paths_json, + Path::new("/cache/pkg"), + None, + &mut env_paths, + &mut dir_indices, + ); + + assert_eq!(env_paths.len(), 1); // root only + let root = env_paths[0].as_directory().unwrap(); + assert_eq!(root.children.len(), 0); + } + + fn make_python_info() -> PythonInfo { + use rattler_conda_types::Version; + use std::str::FromStr; + PythonInfo::from_version( + &Version::from_str("3.11.0").unwrap(), + None, + rattler_conda_types::Platform::Linux64, + ) + .unwrap() + } + + fn make_entry_points() -> Vec { + use std::str::FromStr; + vec![ + EntryPoint::from_str("ipython = IPython:start_ipython").unwrap(), + EntryPoint::from_str("ipython3 = IPython:start_ipython").unwrap(), + ] + } + + #[test] + fn test_entry_points_creates_bin_dir() { + let (mut env_paths, mut dir_indices) = new_empty_tree(); + let python_info = make_python_info(); + add_entry_points( + &make_entry_points(), + "/prefix", + &python_info, + &mut env_paths, + &mut dir_indices, + ); + + // root + bin dir + 2 files + assert!(dir_indices.contains_key(&PathBuf::from("./bin"))); + let root = env_paths[0].as_directory().unwrap(); + assert_eq!(root.children.len(), 1); // bin dir + } + + #[test] + fn test_entry_points_adds_files() { + let (mut env_paths, mut dir_indices) = new_empty_tree(); + let python_info = make_python_info(); + add_entry_points( + &make_entry_points(), + "/prefix", + &python_info, + &mut env_paths, + &mut dir_indices, + ); + + let bin_idx = dir_indices[&PathBuf::from("./bin")]; + let bin_dir = env_paths[bin_idx].as_directory().unwrap(); + assert_eq!(bin_dir.children.len(), 2); + + let names: Vec<_> = bin_dir + .children + .iter() + .map(|&i| env_paths[i].file_name().to_str().unwrap().to_string()) + .collect(); + assert!(names.contains(&"ipython".to_string())); + assert!(names.contains(&"ipython3".to_string())); + } + + #[test] + fn test_entry_points_virtual_content() { + let (mut env_paths, mut dir_indices) = new_empty_tree(); + let python_info = make_python_info(); + add_entry_points( + &make_entry_points(), + "/prefix", + &python_info, + &mut env_paths, + &mut dir_indices, + ); + + let bin_idx = dir_indices[&PathBuf::from("./bin")]; + let bin_dir = env_paths[bin_idx].as_directory().unwrap(); + let file = env_paths[bin_dir.children[0]].as_file().unwrap(); + + let content = file + .virtual_content + .as_ref() + .expect("should have virtual content"); + let text = std::str::from_utf8(content).unwrap(); + assert!( + text.contains("#!/prefix/bin/python3.11"), + "shebang missing: {text}" + ); + assert!( + text.contains("from IPython import"), + "import missing: {text}" + ); + assert!( + text.contains("start_ipython()"), + "function call missing: {text}" + ); + } + + #[test] + fn test_entry_points_dedup_bin_dir() { + // Create a tree that already has bin/ from another package + let pkg = make_paths_json(vec!["bin/existing"]); + let (mut env_paths, mut dir_indices) = new_empty_tree(); + path_parse( + &pkg, + Path::new("/cache/pkg"), + None, + &mut env_paths, + &mut dir_indices, + ); + + let bin_idx = dir_indices[&PathBuf::from("./bin")]; + let before_children = env_paths[bin_idx].as_directory().unwrap().children.len(); + assert_eq!(before_children, 1); // just "existing" + + let python_info = make_python_info(); + add_entry_points( + &make_entry_points(), + "/prefix", + &python_info, + &mut env_paths, + &mut dir_indices, + ); + + // bin dir should now have 3 children (existing + ipython + ipython3), not a new bin dir + let bin_dir = env_paths[bin_idx].as_directory().unwrap(); + assert_eq!(bin_dir.children.len(), 3); + + // Root should still only have 1 child (the single bin dir) + let root = env_paths[0].as_directory().unwrap(); + assert_eq!(root.children.len(), 1); + } + + // --- noarch Python path rewriting tests --- + + #[test] + fn test_noarch_python_rewrites_site_packages() { + let paths_json = make_paths_json(vec![ + "site-packages/foo/__init__.py", + "site-packages/foo/bar.py", + ]); + let python_info = make_python_info(); // python 3.11 + let (mut env_paths, mut dir_indices) = new_empty_tree(); + path_parse( + &paths_json, + Path::new("/cache/pkg"), + Some(&python_info), + &mut env_paths, + &mut dir_indices, + ); + + // Should have: lib/python3.11/site-packages/foo/ directory structure + assert!(dir_indices.contains_key(&PathBuf::from("./lib"))); + assert!(dir_indices.contains_key(&PathBuf::from("./lib/python3.11"))); + assert!(dir_indices.contains_key(&PathBuf::from("./lib/python3.11/site-packages"))); + assert!(dir_indices.contains_key(&PathBuf::from("./lib/python3.11/site-packages/foo"))); + // Should NOT have bare site-packages at root + assert!(!dir_indices.contains_key(&PathBuf::from("./site-packages"))); + } + + #[test] + fn test_noarch_python_rewrites_python_scripts() { + let paths_json = make_paths_json(vec!["python-scripts/mycmd"]); + let python_info = make_python_info(); + let (mut env_paths, mut dir_indices) = new_empty_tree(); + path_parse( + &paths_json, + Path::new("/cache/pkg"), + Some(&python_info), + &mut env_paths, + &mut dir_indices, + ); + + // Should appear under bin/ + assert!(dir_indices.contains_key(&PathBuf::from("./bin"))); + let bin_idx = dir_indices[&PathBuf::from("./bin")]; + let bin_dir = env_paths[bin_idx].as_directory().unwrap(); + assert_eq!(bin_dir.children.len(), 1); + let file = env_paths[bin_dir.children[0]].as_file().unwrap(); + assert_eq!(file.file_name, "mycmd"); + } + + #[test] + fn test_noarch_python_preserves_cache_path() { + let paths_json = make_paths_json(vec!["site-packages/foo/bar.py"]); + let python_info = make_python_info(); + let (mut env_paths, mut dir_indices) = new_empty_tree(); + path_parse( + &paths_json, + Path::new("/cache/noarch-pkg"), + Some(&python_info), + &mut env_paths, + &mut dir_indices, + ); + + // Find bar.py + let foo_idx = dir_indices[&PathBuf::from("./lib/python3.11/site-packages/foo")]; + let foo_dir = env_paths[foo_idx].as_directory().unwrap(); + let file = env_paths[foo_dir.children[0]].as_file().unwrap(); + + // cache_base_path points to the package cache + assert_eq!(&*file.cache_base_path, Path::new("/cache/noarch-pkg")); + // cache_prefix_path overrides to original on-disk location + assert_eq!( + file.cache_prefix_path.as_deref(), + Some(Path::new("./site-packages/foo")) + ); + } + + #[test] + fn test_noarch_non_rewritten_paths_unchanged() { + // Files not under site-packages/ or python-scripts/ should be unchanged + let paths_json = make_paths_json(vec!["share/data/file.txt"]); + let python_info = make_python_info(); + let (mut env_paths, mut dir_indices) = new_empty_tree(); + path_parse( + &paths_json, + Path::new("/cache/pkg"), + Some(&python_info), + &mut env_paths, + &mut dir_indices, + ); + + assert!(dir_indices.contains_key(&PathBuf::from("./share"))); + assert!(dir_indices.contains_key(&PathBuf::from("./share/data"))); + let data_idx = dir_indices[&PathBuf::from("./share/data")]; + let file = env_paths[env_paths[data_idx].as_directory().unwrap().children[0]] + .as_file() + .unwrap(); + assert_eq!(file.file_name, "file.txt"); + // No cache_prefix_path override needed + assert!(file.cache_prefix_path.is_none()); + } + + #[test] + fn test_non_noarch_no_rewrite() { + // Without python_info, site-packages/ stays as-is + let paths_json = make_paths_json(vec!["site-packages/foo/bar.py"]); + let (mut env_paths, mut dir_indices) = new_empty_tree(); + path_parse( + &paths_json, + Path::new("/cache/pkg"), + None, + &mut env_paths, + &mut dir_indices, + ); + + assert!(dir_indices.contains_key(&PathBuf::from("./site-packages"))); + assert!(!dir_indices.contains_key(&PathBuf::from("./lib"))); + } + + /// Minimal v6 lockfile with 2 conda packages (non-alphabetical order) + /// used exclusively for the env hash golden test. Unlike the full + /// test-data/rattler-vfs/pixi.lock, this fixture never changes when + /// upstream dependencies are bumped. + const GOLDEN_LOCKFILE: &str = "\ +version: 6 +environments: + default: + channels: + - url: https://prefix.dev/conda-forge/ + packages: + linux-64: + - conda: https://prefix.dev/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda +packages: +- conda: https://prefix.dev/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c + md5: ad659d0a2b3e47e38d829aa8cad2d610 + license: LicenseRef-Public-Domain + size: 119135 + timestamp: 1767016325805 +- conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + sha256: e1a9e3b1c8fe62dc3932a616c284b5d8cbe3124bbfbedcf4ce5c828cb166ee19 + md5: 9614359868482abba1bd15ce465e3c42 + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 13387 + timestamp: 1760831448842 +"; + + #[test] + fn test_env_hash_stability() { + // Golden test: if this fails, either the canonical form drifted + // accidentally (fix the drift) or ENV_HASH_SCHEMA_VERSION was bumped + // intentionally (update the expected hash below). + let lockfile = rattler_lock::LockFile::from_reader(GOLDEN_LOCKFILE.as_bytes(), None) + .expect("golden lockfile should parse"); + + let hash = + compute_env_hash(&lockfile, "default", Platform::Linux64).expect("hash should succeed"); + + // To update: run `cargo test -p rattler_vfs test_env_hash_stability` + // and copy the "got" value here. + assert_eq!( + hash, "sha256:e2822c5c31a5cc682a0eb82ef1eb867eec1cde2373bf159a37c7261a23011ecd", + "env hash drifted. If intentional (e.g. ENV_HASH_SCHEMA_VERSION bumped), \ + update this golden value. If accidental, investigate what changed in the \ + canonical form." + ); + } +} diff --git a/crates/rattler_vfs/src/metadata_tree.rs b/crates/rattler_vfs/src/metadata_tree.rs new file mode 100644 index 0000000000..bacabce868 --- /dev/null +++ b/crates/rattler_vfs/src/metadata_tree.rs @@ -0,0 +1,162 @@ +//! In-memory metadata tree shared by all transports. +//! +//! Each [`MetadataNode`] is either a [`DirectoryNode`] (children indices into +//! the same `Vec`) or a [`FileNode`] (a leaf with cache path, +//! prefix-replacement metadata, and optional materialized content). The tree +//! is built once by `build_metadata_tree` and consumed by `VirtualFS::new`, +//! after which it backs FUSE, NFS, and `ProjFS` reads alike. +//! +//! Despite the historical `fuse_directory.rs` filename, this is **not** +//! FUSE-specific. + +use std::{ + ffi::{OsStr, OsString}, + path::{Path, PathBuf}, + sync::Arc, +}; + +use rattler_conda_types::package::{PathType, PrefixPlaceholder}; + +#[derive(Debug)] +pub struct DirectoryNode { + pub prefix_path: PathBuf, + pub parent: usize, + pub children: Vec, +} + +impl DirectoryNode { + fn new(prefix_path: PathBuf, parent: usize) -> Self { + DirectoryNode { + prefix_path, + parent, + children: vec![], + } + } +} + +#[derive(Debug)] +pub struct FileNode { + pub file_name: OsString, + pub parent: usize, + pub cache_base_path: Arc, + pub path_type: PathType, + pub prefix_placeholder: Option, + /// Pre-materialized content for virtual files (e.g. generated entry point scripts). + /// When set, the FUSE layer serves this content directly instead of reading from disk. + pub virtual_content: Option>, + /// Pre-computed file size after prefix replacement (for text-mode files). + /// When set, `_getattr()` uses this instead of the on-disk file size. + pub computed_size: Option, + /// Override for the cache directory path used by `_getpath()`. + /// When set (e.g. for noarch Python files where the virtual path differs from + /// the on-disk cache path), `_getpath()` uses this instead of `parent.prefix_path`. + pub cache_prefix_path: Option, +} + +impl FileNode { + fn new( + file_name: OsString, + parent: usize, + cache_base_path: Arc, + path_type: PathType, + prefix_placeholder: Option, + ) -> Self { + FileNode { + file_name, + parent, + cache_base_path, + path_type, + prefix_placeholder, + virtual_content: None, + computed_size: None, + cache_prefix_path: None, + } + } + + fn new_virtual(file_name: OsString, parent: usize, content: Vec) -> Self { + FileNode { + file_name, + parent, + cache_base_path: Arc::from(Path::new("")), + path_type: PathType::HardLink, + prefix_placeholder: None, + virtual_content: Some(content), + computed_size: None, + cache_prefix_path: None, + } + } +} + +#[derive(Debug)] +pub enum MetadataNode { + Directory(DirectoryNode), + File(FileNode), +} + +impl MetadataNode { + pub fn file_name(&self) -> &OsStr { + match self { + Self::Directory(directory) => directory + .prefix_path + .file_name() + .unwrap_or(std::ffi::OsStr::new(".")), + Self::File(file) => &file.file_name, + } + } + + pub fn new_directory(prefix_path: PathBuf, parent: usize) -> Self { + MetadataNode::Directory(DirectoryNode::new(prefix_path, parent)) + } + + pub fn new_file( + file_name: OsString, + parent: usize, + cache_base_path: Arc, + path_type: PathType, + prefix_placeholder: Option, + ) -> Self { + MetadataNode::File(FileNode::new( + file_name, + parent, + cache_base_path, + path_type, + prefix_placeholder, + )) + } + + pub fn new_virtual_file(file_name: OsString, parent: usize, content: Vec) -> Self { + MetadataNode::File(FileNode::new_virtual(file_name, parent, content)) + } + + pub fn as_directory(&self) -> Option<&DirectoryNode> { + if let Self::Directory(directory) = self { + Some(directory) + } else { + None + } + } + + pub fn as_directory_mut(&mut self) -> Option<&mut DirectoryNode> { + if let Self::Directory(directory) = self { + Some(directory) + } else { + None + } + } + + pub fn as_file(&self) -> Option<&FileNode> { + if let Self::File(file) = self { + Some(file) + } else { + None + } + } + + pub fn as_file_mut(&mut self) -> Option<&mut FileNode> { + if let Self::File(file) = self { + Some(file) + } else { + None + } + } +} diff --git a/crates/rattler_vfs/src/nfs_adapter.rs b/crates/rattler_vfs/src/nfs_adapter.rs new file mode 100644 index 0000000000..7661bb19cf --- /dev/null +++ b/crates/rattler_vfs/src/nfs_adapter.rs @@ -0,0 +1,691 @@ +//! NFS transport adapter. +//! +//! Thin layer that maps NFS3 callbacks to `VfsOps` trait methods. +//! Implements `NfsReadFileSystem` for read-only mounts and `NfsFileSystem` +//! for writable overlay mounts. Handles NFS-specific concerns: type conversion, +//! async wrapping of sync VFS ops, and lazy write handle management. + +use std::collections::{HashMap, VecDeque}; +#[cfg(unix)] +use std::ffi::OsStr; +use std::ffi::OsString; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use nfs3_server::tcp::{NFSTcp, NFSTcpListener}; +use nfs3_server::vfs::{ + DirEntryPlus, FileHandleU64, NextResult, NfsFileSystem, NfsReadFileSystem, ReadDirPlusIterator, +}; +use nfs3_types::nfs3::{ + Nfs3Option, createverf3, fattr3, filename3, ftype3, nfspath3, nfsstat3, nfstime3, sattr3, + specdata3, +}; +use nfs3_types::xdr_codec::Opaque; + +use crate::vfs_ops::{FileAttr, FileKind, VfsOps}; + +/// LRU cache of open write handles, keyed by inode. +/// +/// NFS is stateless but `VfsOps` needs `open_write` / `write` / `release_write`, +/// so we lazily open a handle on first write and reuse it for subsequent +/// writes to the same inode. When the cache exceeds capacity we evict the +/// **least recently used** entry — not an arbitrary one — so a sustained +/// write to a single file does not get its handle thrown out by an unrelated +/// touch. +struct WriteHandleCache { + /// inode → file handle + map: HashMap, + /// Insertion / touch order. The front is the least recently used. + order: VecDeque, +} + +impl WriteHandleCache { + fn new() -> Self { + Self { + map: HashMap::new(), + order: VecDeque::new(), + } + } + + /// Get the handle for `ino`, marking it as most recently used. + fn get(&mut self, ino: u64) -> Option { + let fh = *self.map.get(&ino)?; + // Move ino to the back (most recently used). O(N) for N <= 64 is fine; + // swap to a real LRU crate if MAX_WRITE_HANDLES grows. + if let Some(pos) = self.order.iter().position(|&i| i == ino) { + self.order.remove(pos); + } + self.order.push_back(ino); + Some(fh) + } + + fn insert(&mut self, ino: u64, fh: u64) { + self.map.insert(ino, fh); + self.order.push_back(ino); + } + + /// Evict and return the least recently used `(ino, fh)` pair. + fn pop_lru(&mut self) -> Option<(u64, u64)> { + let ino = self.order.pop_front()?; + let fh = self.map.remove(&ino)?; + Some((ino, fh)) + } + + fn len(&self) -> usize { + self.map.len() + } +} + +/// NFS transport adapter, generic over any `VfsOps` implementation. +pub struct NfsAdapter { + vfs: Arc, + /// Lazy write handles: see [`WriteHandleCache`]. + write_handles: Mutex, +} + +impl NfsAdapter { + pub fn new(vfs: T) -> Self { + Self { + vfs: Arc::new(vfs), + write_handles: Mutex::new(WriteHandleCache::new()), + } + } + + /// Start the NFS server, binding to `127.0.0.1:{port}`. + /// + /// Always binds in writable mode — write operations on read-only VFS + /// implementations return EROFS naturally via `VfsOps` defaults, + /// matching how the FUSE adapter handles this. + /// Pass `port = 0` to let the OS pick a free port. + pub async fn serve(self, port: u16) -> std::io::Result { + let addr = format!("127.0.0.1:{port}"); + let listener = NFSTcpListener::bind(&addr, self).await?; + let actual_port = listener.get_listen_port(); + // Wrap the server task so unexpected exits (errors or clean returns) + // are logged instead of silently swallowed. Panics are caught by + // tokio at the task boundary and logged at WARN level by default. + let handle = tokio::spawn(async move { + match listener.handle_forever().await { + Ok(()) => { + tracing::error!( + "NFS server task exited unexpectedly (clean return). \ + The kernel mount is now stale — file accesses will hang." + ); + } + Err(e) => { + tracing::error!( + "NFS server task failed: {e}. \ + The kernel mount is now stale — file accesses will hang." + ); + } + } + }); + Ok(NfsServerHandle { + port: actual_port, + server_task: handle, + }) + } + + /// Maximum number of concurrent write handles to keep open. + const MAX_WRITE_HANDLES: usize = 64; + + /// Get or create a write handle for the given inode. + /// Handles are cached to avoid reopening on consecutive writes to the same file. + /// When the cache exceeds `MAX_WRITE_HANDLES`, the least recently used + /// handle is evicted (see [`WriteHandleCache::pop_lru`]). + fn get_write_handle(&self, ino: u64) -> Result { + let mut cache = self.write_handles.lock().unwrap(); + if let Some(fh) = cache.get(ino) { + return Ok(fh); + } + while cache.len() >= Self::MAX_WRITE_HANDLES { + if let Some((_, old_fh)) = cache.pop_lru() { + self.vfs.release_write(old_fh); + } else { + break; + } + } + let fh = self.vfs.open_write(ino).map_err(errno_to_nfsstat)?; + cache.insert(ino, fh); + Ok(fh) + } +} + +/// Handle to a running NFS server task. +pub struct NfsServerHandle { + port: u16, + pub(crate) server_task: tokio::task::JoinHandle<()>, +} + +impl NfsServerHandle { + /// The TCP port the server is listening on. + pub fn port(&self) -> u16 { + self.port + } + + /// Abort the server task. + pub fn abort(&self) { + self.server_task.abort(); + } + + /// Whether the NFS server task is still running. + /// + /// Returns `false` if the server exited (error, panic, or unexpected + /// clean return). A dead server means the kernel mount is stale and file + /// accesses will hang until the mount is force-unmounted. + pub fn is_healthy(&self) -> bool { + !self.server_task.is_finished() + } +} + +/// Handle to a mounted NFS filesystem. Unmounts and stops the server on drop. +pub struct NfsMountHandle { + // Only read by the macOS/Linux `umount` paths; unused on targets without + // NFS-mount support (e.g. Windows). + #[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))] + pub(crate) mount_point: std::path::PathBuf, + pub(crate) server_handle: NfsServerHandle, + /// Whether `do_unmount` has already run successfully. Set by + /// [`NfsMountHandle::unmount`] so [`Drop`] doesn't repeat the work. + pub(crate) unmounted: bool, +} + +impl NfsMountHandle { + /// The TCP port the NFS server is listening on. + pub fn port(&self) -> u16 { + self.server_handle.port() + } + + /// Whether the NFS server task is still running. + /// + /// Returns `false` if the server exited unexpectedly. See + /// [`NfsServerHandle::is_healthy`] for details. + pub fn is_healthy(&self) -> bool { + self.server_handle.is_healthy() + } + + /// Explicitly unmount the NFS mount and stop the server task. + /// + /// This is async and uses `tokio::process::Command` so it does not block + /// the runtime. Prefer this over Drop when you need to surface unmount + /// failures (e.g. sidecar shutdown). Drop keeps a sync fallback that + /// uses `std::process::Command` (blocking but bounded) and logs failures + /// instead of returning them. + pub async fn unmount(mut self) -> anyhow::Result<()> { + if self.unmounted { + return Ok(()); + } + #[cfg(target_os = "macos")] + { + let mnt = self.mount_point.display().to_string(); + let status = tokio::process::Command::new("umount") + .args(["-f", &mnt]) + .status() + .await?; + if !status.success() { + self.server_handle.abort(); + self.unmounted = true; + anyhow::bail!("umount -f {mnt} failed (exit {status})"); + } + } + #[cfg(target_os = "linux")] + { + let mnt = self.mount_point.display().to_string(); + let status = tokio::process::Command::new("sudo") + .args(["umount", "-f", &mnt]) + .status() + .await?; + if !status.success() { + self.server_handle.abort(); + self.unmounted = true; + anyhow::bail!("sudo umount -f {mnt} failed (exit {status})"); + } + } + // NFS mount is not supported on Windows — use ProjFS instead. + self.server_handle.abort(); + self.unmounted = true; + Ok(()) + } + + /// Sync best-effort unmount for the Drop impl. Uses blocking + /// `std::process::Command` — acceptable because Drop is typically + /// called at shutdown, not on a hot path. + fn do_unmount_sync(&mut self) { + if self.unmounted { + return; + } + #[cfg(target_os = "macos")] + { + let mnt = self.mount_point.display().to_string(); + let _ = std::process::Command::new("umount") + .args(["-f", &mnt]) + .status(); + } + #[cfg(target_os = "linux")] + { + let mnt = self.mount_point.display().to_string(); + let _ = std::process::Command::new("sudo") + .args(["umount", "-f", &mnt]) + .status(); + } + self.server_handle.abort(); + self.unmounted = true; + } +} + +impl Drop for NfsMountHandle { + fn drop(&mut self) { + self.do_unmount_sync(); + } +} + +// --------------------------------------------------------------------------- +// Conversion helpers +// --------------------------------------------------------------------------- + +fn file_attr_to_fattr3(attr: &FileAttr) -> fattr3 { + fattr3 { + type_: match attr.kind { + FileKind::RegularFile => ftype3::NF3REG, + FileKind::Directory => ftype3::NF3DIR, + FileKind::Symlink => ftype3::NF3LNK, + }, + mode: u32::from(attr.perm), + nlink: attr.nlink, + uid: attr.uid, + gid: attr.gid, + size: attr.size, + used: attr.blocks * 512, + rdev: specdata3::default(), + fsid: 0, + fileid: attr.ino, + atime: system_time_to_nfstime(attr.atime), + mtime: system_time_to_nfstime(attr.mtime), + ctime: system_time_to_nfstime(attr.ctime), + } +} + +fn system_time_to_nfstime(t: SystemTime) -> nfstime3 { + match t.duration_since(SystemTime::UNIX_EPOCH) { + Ok(d) => nfstime3 { + seconds: d.as_secs() as u32, + nseconds: d.subsec_nanos(), + }, + Err(_) => nfstime3 { + seconds: 0, + nseconds: 0, + }, + } +} + +/// Convert NFS wire-format filename bytes to an OS string. +/// On Unix, filenames are arbitrary byte sequences. On Windows, they must be +/// valid UTF-8 (which conda paths always are — they're sourced from JSON). +fn os_str_from_nfs_bytes(bytes: &[u8]) -> Result { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + Ok(OsStr::from_bytes(bytes).to_owned()) + } + #[cfg(not(unix))] + { + match std::str::from_utf8(bytes) { + Ok(s) => Ok(OsString::from(s)), + Err(_) => Err(nfsstat3::NFS3ERR_INVAL), + } + } +} + +fn errno_to_nfsstat(errno: i32) -> nfsstat3 { + match errno { + libc::ENOENT => nfsstat3::NFS3ERR_NOENT, + libc::EACCES => nfsstat3::NFS3ERR_ACCES, + libc::ENOTDIR => nfsstat3::NFS3ERR_NOTDIR, + libc::EISDIR => nfsstat3::NFS3ERR_ISDIR, + libc::EROFS => nfsstat3::NFS3ERR_ROFS, + libc::EEXIST => nfsstat3::NFS3ERR_EXIST, + libc::ENOTEMPTY => nfsstat3::NFS3ERR_NOTEMPTY, + libc::ENOSPC => nfsstat3::NFS3ERR_NOSPC, + _ => nfsstat3::NFS3ERR_IO, + } +} + +// --------------------------------------------------------------------------- +// ReadDirPlus iterator backed by a pre-fetched Vec +// --------------------------------------------------------------------------- + +struct VfsDirPlusIter { + entries: Vec>, + pos: usize, +} + +impl ReadDirPlusIterator for VfsDirPlusIter { + async fn next(&mut self) -> NextResult> { + if self.pos >= self.entries.len() { + return NextResult::Eof; + } + let entry = self.entries[self.pos].clone(); + self.pos += 1; + NextResult::Ok(entry) + } +} + +// --------------------------------------------------------------------------- +// NfsReadFileSystem implementation +// --------------------------------------------------------------------------- + +impl NfsReadFileSystem for NfsAdapter { + type Handle = FileHandleU64; + + fn root_dir(&self) -> FileHandleU64 { + FileHandleU64::new(1) + } + + async fn lookup( + &self, + dirid: &FileHandleU64, + filename: &filename3<'_>, + ) -> Result { + let vfs = self.vfs.clone(); + let parent_ino: u64 = dirid.as_u64(); + let name_bytes = filename.0.as_ref().to_vec(); + tokio::task::spawn_blocking(move || { + let name = &os_str_from_nfs_bytes(&name_bytes)?; + let attr = vfs.lookup(parent_ino, name).map_err(errno_to_nfsstat)?; + Ok(FileHandleU64::new(attr.ino)) + }) + .await + .unwrap_or_else(|e| { + tracing::error!("NFS handler panicked: {e}"); + Err(nfsstat3::NFS3ERR_IO) + }) + } + + async fn getattr(&self, id: &FileHandleU64) -> Result { + let vfs = self.vfs.clone(); + let ino = id.as_u64(); + tokio::task::spawn_blocking(move || { + let attr = vfs.getattr(ino).map_err(errno_to_nfsstat)?; + Ok(file_attr_to_fattr3(&attr)) + }) + .await + .unwrap_or_else(|e| { + tracing::error!("NFS handler panicked: {e}"); + Err(nfsstat3::NFS3ERR_IO) + }) + } + + async fn read( + &self, + id: &FileHandleU64, + offset: u64, + count: u32, + ) -> Result<(Vec, bool), nfsstat3> { + let vfs = self.vfs.clone(); + let ino = id.as_u64(); + tokio::task::spawn_blocking(move || { + let data = vfs.read(ino, offset, count).map_err(errno_to_nfsstat)?; + let eof = (data.len() as u32) < count; + Ok((data, eof)) + }) + .await + .unwrap_or_else(|e| { + tracing::error!("NFS handler panicked: {e}"); + Err(nfsstat3::NFS3ERR_IO) + }) + } + + async fn readdirplus( + &self, + dirid: &FileHandleU64, + cookie: u64, + ) -> Result, nfsstat3> { + let vfs = self.vfs.clone(); + let ino = dirid.as_u64(); + tokio::task::spawn_blocking(move || { + let dir_entries = vfs.readdir(ino, cookie).map_err(errno_to_nfsstat)?; + let mut entries = Vec::with_capacity(dir_entries.len()); + for (i, de) in dir_entries.into_iter().enumerate() { + let attr = vfs.getattr(de.ino).ok().map(|a| file_attr_to_fattr3(&a)); + entries.push(DirEntryPlus { + fileid: de.ino, + name: filename3(Opaque::owned(de.name.as_encoded_bytes().to_vec())), + cookie: cookie + i as u64 + 1, + name_attributes: attr, + name_handle: Some(FileHandleU64::new(de.ino)), + }); + } + Ok(VfsDirPlusIter { entries, pos: 0 }) + }) + .await + .unwrap_or_else(|e| { + tracing::error!("NFS handler panicked: {e}"); + Err(nfsstat3::NFS3ERR_IO) + }) + } + + async fn readlink(&self, id: &FileHandleU64) -> Result, nfsstat3> { + let vfs = self.vfs.clone(); + let ino = id.as_u64(); + tokio::task::spawn_blocking(move || { + let target = vfs.readlink(ino).map_err(errno_to_nfsstat)?; + let bytes = target.as_os_str().as_encoded_bytes().to_vec(); + Ok(nfspath3(Opaque::owned(bytes))) + }) + .await + .unwrap_or_else(|e| { + tracing::error!("NFS handler panicked: {e}"); + Err(nfsstat3::NFS3ERR_IO) + }) + } +} + +// --------------------------------------------------------------------------- +// NfsFileSystem (writable) implementation +// --------------------------------------------------------------------------- + +impl NfsFileSystem for NfsAdapter { + async fn setattr(&self, id: &FileHandleU64, setattr: sattr3) -> Result { + let vfs = self.vfs.clone(); + let ino = id.as_u64(); + tokio::task::spawn_blocking(move || { + let size = match setattr.size { + Nfs3Option::Some(s) => Some(s), + Nfs3Option::None => None, + }; + let mode = match setattr.mode { + Nfs3Option::Some(m) => Some(m), + Nfs3Option::None => None, + }; + let attr = vfs.setattr(ino, size, mode).map_err(errno_to_nfsstat)?; + Ok(file_attr_to_fattr3(&attr)) + }) + .await + .unwrap_or_else(|e| { + tracing::error!("NFS handler panicked: {e}"); + Err(nfsstat3::NFS3ERR_IO) + }) + } + + async fn write( + &self, + id: &FileHandleU64, + offset: u64, + data: &[u8], + ) -> Result { + let ino = id.as_u64(); + let data = data.to_vec(); + + // Retry once on EIO: a concurrent write to a different file can evict + // (and close) our cached write handle between acquiring it and using it, + // since the LRU cache is bounded. Eviction also drops the cache entry, + // so re-acquiring the handle reopens a fresh one. + for attempt in 0..2 { + let fh = self.get_write_handle(ino)?; + let vfs = self.vfs.clone(); + let data = data.clone(); + let write_result = tokio::task::spawn_blocking(move || vfs.write(fh, offset, &data)) + .await + .unwrap_or_else(|e| { + tracing::error!("NFS write handler panicked: {e}"); + Err(libc::EIO) + }); + + match write_result { + Ok(_) => { + let vfs = self.vfs.clone(); + return tokio::task::spawn_blocking(move || vfs.getattr(ino)) + .await + .unwrap_or_else(|e| { + tracing::error!("NFS handler panicked: {e}"); + Err(libc::EIO) + }) + .map(|attr| file_attr_to_fattr3(&attr)) + .map_err(errno_to_nfsstat); + } + // Evicted handle on the first try: fall through to reopen+retry. + Err(libc::EIO) if attempt == 0 => {} + Err(e) => return Err(errno_to_nfsstat(e)), + } + } + Err(nfsstat3::NFS3ERR_IO) + } + + async fn create( + &self, + dirid: &FileHandleU64, + filename: &filename3<'_>, + attr: sattr3, + ) -> Result<(FileHandleU64, fattr3), nfsstat3> { + let vfs = self.vfs.clone(); + let parent_ino = dirid.as_u64(); + let name_bytes = filename.0.as_ref().to_vec(); + let mode = match attr.mode { + Nfs3Option::Some(m) => m, + Nfs3Option::None => 0o644, + }; + tokio::task::spawn_blocking(move || { + let name = &os_str_from_nfs_bytes(&name_bytes)?; + let (file_attr, fh) = vfs + .create(parent_ino, name, mode) + .map_err(errno_to_nfsstat)?; + vfs.release_write(fh); + Ok(( + FileHandleU64::new(file_attr.ino), + file_attr_to_fattr3(&file_attr), + )) + }) + .await + .unwrap_or_else(|e| { + tracing::error!("NFS handler panicked: {e}"); + Err(nfsstat3::NFS3ERR_IO) + }) + } + + async fn create_exclusive( + &self, + dirid: &FileHandleU64, + filename: &filename3<'_>, + _createverf: createverf3, + ) -> Result { + // Use regular create — exclusive semantics aren't critical for our use case + let vfs = self.vfs.clone(); + let parent_ino = dirid.as_u64(); + let name_bytes = filename.0.as_ref().to_vec(); + tokio::task::spawn_blocking(move || { + let name = &os_str_from_nfs_bytes(&name_bytes)?; + let (file_attr, fh) = vfs.create(parent_ino, name, 0o644).map_err(|errno| { + tracing::warn!( + "create_exclusive failed: parent={parent_ino} name={name:?} errno={errno}" + ); + errno_to_nfsstat(errno) + })?; + vfs.release_write(fh); + Ok(FileHandleU64::new(file_attr.ino)) + }) + .await + .unwrap_or_else(|e| { + tracing::error!("create_exclusive spawn_blocking panicked: {e}"); + Err(nfsstat3::NFS3ERR_IO) + }) + } + + async fn mkdir( + &self, + dirid: &FileHandleU64, + dirname: &filename3<'_>, + ) -> Result<(FileHandleU64, fattr3), nfsstat3> { + let vfs = self.vfs.clone(); + let parent_ino = dirid.as_u64(); + let name_bytes = dirname.0.as_ref().to_vec(); + tokio::task::spawn_blocking(move || { + let name = &os_str_from_nfs_bytes(&name_bytes)?; + let dir_attr = vfs + .mkdir(parent_ino, name, 0o755) + .map_err(errno_to_nfsstat)?; + Ok(( + FileHandleU64::new(dir_attr.ino), + file_attr_to_fattr3(&dir_attr), + )) + }) + .await + .unwrap_or_else(|e| { + tracing::error!("NFS handler panicked: {e}"); + Err(nfsstat3::NFS3ERR_IO) + }) + } + + async fn remove( + &self, + dirid: &FileHandleU64, + filename: &filename3<'_>, + ) -> Result<(), nfsstat3> { + let vfs = self.vfs.clone(); + let parent_ino = dirid.as_u64(); + let name_bytes = filename.0.as_ref().to_vec(); + tokio::task::spawn_blocking(move || { + let name = &os_str_from_nfs_bytes(&name_bytes)?; + vfs.unlink(parent_ino, name).map_err(errno_to_nfsstat) + }) + .await + .unwrap_or_else(|e| { + tracing::error!("NFS handler panicked: {e}"); + Err(nfsstat3::NFS3ERR_IO) + }) + } + + async fn rename<'a>( + &self, + from_dirid: &FileHandleU64, + from_filename: &filename3<'a>, + to_dirid: &FileHandleU64, + to_filename: &filename3<'a>, + ) -> Result<(), nfsstat3> { + let vfs = self.vfs.clone(); + let from_parent = from_dirid.as_u64(); + let to_parent = to_dirid.as_u64(); + let from_name = from_filename.0.as_ref().to_vec(); + let to_name = to_filename.0.as_ref().to_vec(); + tokio::task::spawn_blocking(move || { + let from = os_str_from_nfs_bytes(&from_name)?; + let to = os_str_from_nfs_bytes(&to_name)?; + vfs.rename(from_parent, &from, to_parent, &to, 0) + .map_err(errno_to_nfsstat) + }) + .await + .unwrap_or_else(|e| { + tracing::error!("NFS handler panicked: {e}"); + Err(nfsstat3::NFS3ERR_IO) + }) + } + + async fn symlink<'a>( + &self, + _dirid: &FileHandleU64, + _linkname: &filename3<'a>, + _symlink: &nfspath3<'a>, + _attr: &sattr3, + ) -> Result<(FileHandleU64, fattr3), nfsstat3> { + Err(nfsstat3::NFS3ERR_NOTSUPP) + } +} diff --git a/crates/rattler_vfs/src/overlay.rs b/crates/rattler_vfs/src/overlay.rs new file mode 100644 index 0000000000..9823593bba --- /dev/null +++ b/crates/rattler_vfs/src/overlay.rs @@ -0,0 +1,441 @@ +//! Persistent overlay state management. +//! +//! Tracks whiteouts (deleted files) and environment identity in a JSON state +//! file. The state file is written atomically (write-tmp → fsync → rename) on +//! every mutation for crash safety. + +use fs4::fs_std::FileExt; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashSet, + fs, + io::Write, + path::{Path, PathBuf}, +}; + +pub(crate) const STATE_FILENAME: &str = ".rattler_vfs_state.json"; +pub(crate) const STATE_TMP_FILENAME: &str = ".rattler_vfs_state.tmp"; +pub(crate) const STATE_LOCK_FILENAME: &str = ".rattler_vfs_state.lock"; + +#[derive(Debug)] +pub enum OverlayError { + Io(std::io::Error), + Json(serde_json::Error), + /// Env hash mismatch. The `lock` field carries the directory lock. + /// `create_overlay` returns this as a structured `MountError` so the + /// caller can decide whether to wipe — the overlay may contain user work. + EnvHashMismatch { + expected: String, + found: String, + lock: fs::File, + }, + /// Transport mismatch. The `lock` field carries the directory lock. + TransportMismatch { + expected: String, + found: String, + lock: fs::File, + }, + /// State file version mismatch. The `lock` field carries the directory lock. + VersionMismatch { + expected: u32, + found: u32, + lock: fs::File, + }, +} + +impl std::fmt::Display for OverlayError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "overlay I/O error: {e}"), + Self::Json(e) => write!(f, "overlay state file error: {e}"), + Self::EnvHashMismatch { + expected, found, .. + } => { + write!( + f, + "overlay environment mismatch: expected {expected}, found {found}" + ) + } + Self::TransportMismatch { + expected, found, .. + } => { + write!( + f, + "overlay transport mismatch: expected {expected}, found {found}" + ) + } + Self::VersionMismatch { + expected, found, .. + } => { + write!( + f, + "overlay state version mismatch: expected {expected}, found {found}" + ) + } + } + } +} + +impl From for OverlayError { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} + +impl From for OverlayError { + fn from(e: serde_json::Error) -> Self { + Self::Json(e) + } +} + +/// Current state file format version. Bump when the format changes +/// incompatibly. On load, if the version doesn't match, the overlay is +/// considered stale and should be wiped. +const STATE_VERSION: u32 = 1; + +#[derive(Serialize, Deserialize)] +struct StateFile { + /// Format version — reject overlays from incompatible versions. + #[serde(default)] + version: u32, + env_hash: String, + /// Transport that created this overlay (e.g. "fuse", "nfs"). + /// Used to detect incompatible overlay/transport combinations. + #[serde(default)] + transport: String, + whiteouts: Vec, + #[serde(default)] + opaque_dirs: Vec, +} + +/// Persistent overlay state: whiteouts, environment identity, and transport type. +/// +/// Holds an exclusive file lock on `.rattler_vfs_state.lock` for its entire +/// lifetime, preventing concurrent access to the same overlay directory. +/// The lock is released automatically on drop (or on process crash, since +/// advisory file locks are released by the OS). +pub struct OverlayState { + dir: PathBuf, + pub(crate) whiteouts: HashSet, + pub(crate) opaque_dirs: HashSet, + state_path: PathBuf, + env_hash: String, + transport: String, + /// Exclusive file lock held for the lifetime of this state. Dropping + /// the `File` releases the lock. + _lock: fs::File, +} + +impl std::fmt::Debug for OverlayState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OverlayState") + .field("dir", &self.dir) + .field("env_hash", &self.env_hash) + .field("transport", &self.transport) + .field("whiteouts", &self.whiteouts.len()) + .field("opaque_dirs", &self.opaque_dirs.len()) + .finish() + } +} + +impl OverlayState { + /// Acquire the overlay directory lock without loading state. + /// + /// Returns the lock file handle. Use with [`Self::load_with_lock`] when the + /// caller needs to hold the lock across a wipe-and-retry cycle. + pub fn acquire_lock(dir: &Path) -> Result { + fs::create_dir_all(dir)?; + let lock_path = dir.join(STATE_LOCK_FILENAME); + let lock = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path)?; + if lock.try_lock_exclusive().is_err() { + tracing::warn!( + "overlay state lock at {} is held by another process; waiting up to 15s", + lock_path.display(), + ); + // Bounded wait rather than blocking forever: a stuck holder would + // otherwise surface only as an opaque readiness timeout upstream. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + loop { + std::thread::sleep(std::time::Duration::from_millis(200)); + if lock.try_lock_exclusive().is_ok() { + break; + } + if std::time::Instant::now() >= deadline { + return Err(OverlayError::Io(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + format!( + "overlay state lock at {} is still held after 15s; is another \ + mount of this environment already running?", + lock_path.display() + ), + ))); + } + } + } + Ok(lock) + } + + /// Load an existing overlay or create a new one. + /// + /// Acquires the directory lock internally. If you need to hold the lock + /// across a wipe-and-retry cycle, use [`Self::acquire_lock`] + + /// [`Self::load_with_lock`] instead. + pub fn load(dir: PathBuf, env_hash: String, transport: String) -> Result { + let lock = Self::acquire_lock(&dir)?; + Self::load_with_lock(dir, env_hash, transport, lock) + } + + /// Load an existing overlay using a pre-acquired lock. + /// + /// The `lock` must have been obtained via [`Self::acquire_lock`] on the same + /// directory. This variant exists so `create_overlay` can hold the lock + /// across the wipe-and-retry path without a race window. + pub fn load_with_lock( + dir: PathBuf, + env_hash: String, + transport: String, + lock: fs::File, + ) -> Result { + fs::create_dir_all(&dir)?; + let state_path = dir.join(STATE_FILENAME); + + let (whiteouts, opaque_dirs) = if state_path.exists() { + let content = fs::read_to_string(&state_path)?; + let state: StateFile = serde_json::from_str(&content)?; + if state.version != STATE_VERSION { + return Err(OverlayError::VersionMismatch { + expected: STATE_VERSION, + found: state.version, + lock, + }); + } + if state.env_hash != env_hash { + return Err(OverlayError::EnvHashMismatch { + expected: env_hash, + found: state.env_hash, + lock, + }); + } + if !state.transport.is_empty() && state.transport != transport { + return Err(OverlayError::TransportMismatch { + expected: transport, + found: state.transport, + lock, + }); + } + ( + state.whiteouts.into_iter().collect(), + state.opaque_dirs.into_iter().collect(), + ) + } else { + (HashSet::new(), HashSet::new()) + }; + + let overlay = OverlayState { + dir, + whiteouts, + opaque_dirs, + state_path, + env_hash, + transport, + _lock: lock, + }; + // Write initial state file if it didn't exist + overlay.flush()?; + Ok(overlay) + } + + /// Check if a virtual path has been whiteout'd (deleted). + pub fn is_whiteout(&self, path: &Path) -> bool { + self.whiteouts.contains(path) + } + + /// Mark a virtual path as deleted. Flushes state to disk. + pub fn add_whiteout(&mut self, path: PathBuf) -> Result<(), OverlayError> { + self.whiteouts.insert(path); + self.flush() + } + + /// Remove a whiteout (e.g. when recreating a deleted file). Flushes state to disk. + pub fn remove_whiteout(&mut self, path: &Path) -> Result<(), OverlayError> { + if self.whiteouts.remove(path) { + self.flush()?; + } + Ok(()) + } + + /// Get the path in the upper layer for a given virtual path. + pub fn upper_path(&self, virtual_path: &Path) -> PathBuf { + self.dir.join(virtual_path) + } + + /// Check if a file exists in the upper layer. + pub fn has_upper(&self, virtual_path: &Path) -> bool { + self.upper_path(virtual_path).exists() + } + + /// Check if a directory is opaque (lower layer hidden entirely). + pub fn is_opaque(&self, path: &Path) -> bool { + self.opaque_dirs.contains(path) + } + + /// Mark a directory as opaque. Flushes state to disk. + pub fn add_opaque_dir(&mut self, path: PathBuf) -> Result<(), OverlayError> { + self.opaque_dirs.insert(path); + self.flush() + } + + /// Remove opaque marker from a directory. Flushes state to disk. + pub fn remove_opaque_dir(&mut self, path: &Path) -> Result<(), OverlayError> { + self.opaque_dirs.remove(path); + self.flush() + } + + /// The overlay directory path. + pub fn dir(&self) -> &Path { + &self.dir + } + + /// Atomically write state to disk (write-tmp → fsync → rename). + pub(crate) fn flush(&self) -> Result<(), OverlayError> { + let state = StateFile { + version: STATE_VERSION, + env_hash: self.env_hash.clone(), + transport: self.transport.clone(), + whiteouts: self.whiteouts.iter().cloned().collect(), + opaque_dirs: self.opaque_dirs.iter().cloned().collect(), + }; + let json = serde_json::to_string_pretty(&state)?; + + let tmp_path = self.state_path.with_extension("tmp"); + let mut file = fs::File::create(&tmp_path)?; + file.write_all(json.as_bytes())?; + file.sync_all()?; + fs::rename(&tmp_path, &self.state_path)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_load_creates_new_state() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("overlay"); + let state = OverlayState::load(dir.clone(), "hash123".into(), "test".into()).unwrap(); + + assert!(state.whiteouts.is_empty()); + assert!(dir.join(STATE_FILENAME).exists()); + + let content = fs::read_to_string(dir.join(STATE_FILENAME)).unwrap(); + let parsed: StateFile = serde_json::from_str(&content).unwrap(); + assert_eq!(parsed.env_hash, "hash123"); + assert!(parsed.whiteouts.is_empty()); + } + + #[test] + fn test_load_verifies_hash() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("overlay"); + + // Create with one hash + OverlayState::load(dir.clone(), "hash_a".into(), "test".into()).unwrap(); + + // Try to load with different hash + let err = OverlayState::load(dir, "hash_b".into(), "test".into()).unwrap_err(); + assert!(matches!(err, OverlayError::EnvHashMismatch { .. })); + } + + #[test] + fn test_load_accepts_matching_hash() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("overlay"); + + OverlayState::load(dir.clone(), "hash_a".into(), "test".into()).unwrap(); + let state = OverlayState::load(dir, "hash_a".into(), "test".into()).unwrap(); + assert!(state.whiteouts.is_empty()); + } + + #[test] + fn test_whiteout_add_remove() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("overlay"); + let mut state = OverlayState::load(dir, "hash".into(), "test".into()).unwrap(); + + let path = PathBuf::from("lib/foo.py"); + assert!(!state.is_whiteout(&path)); + + state.add_whiteout(path.clone()).unwrap(); + assert!(state.is_whiteout(&path)); + + state.remove_whiteout(&path).unwrap(); + assert!(!state.is_whiteout(&path)); + } + + #[test] + fn test_whiteout_persists_across_reload() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("overlay"); + + // Add a whiteout + { + let mut state = OverlayState::load(dir.clone(), "hash".into(), "test".into()).unwrap(); + state.add_whiteout(PathBuf::from("lib/deleted.py")).unwrap(); + } + + // Reload and verify + let state = OverlayState::load(dir, "hash".into(), "test".into()).unwrap(); + assert!(state.is_whiteout(Path::new("lib/deleted.py"))); + } + + #[test] + fn test_upper_path() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("overlay"); + let state = OverlayState::load(dir.clone(), "hash".into(), "test".into()).unwrap(); + + assert_eq!( + state.upper_path(Path::new("lib/foo.py")), + dir.join("lib/foo.py") + ); + } + + #[test] + fn test_has_upper() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("overlay"); + let state = OverlayState::load(dir.clone(), "hash".into(), "test".into()).unwrap(); + + assert!(!state.has_upper(Path::new("lib/foo.py"))); + + // Create the file in upper + fs::create_dir_all(dir.join("lib")).unwrap(); + fs::write(dir.join("lib/foo.py"), b"content").unwrap(); + + assert!(state.has_upper(Path::new("lib/foo.py"))); + } + + #[test] + fn test_flush_produces_valid_json() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("overlay"); + let mut state = OverlayState::load(dir.clone(), "hash".into(), "test".into()).unwrap(); + + state.add_whiteout(PathBuf::from("a/b.py")).unwrap(); + state.add_whiteout(PathBuf::from("c/d.py")).unwrap(); + + // Read and verify the state file is valid JSON + let content = fs::read_to_string(dir.join(STATE_FILENAME)).unwrap(); + let parsed: StateFile = serde_json::from_str(&content).unwrap(); + assert_eq!(parsed.env_hash, "hash"); + assert_eq!(parsed.whiteouts.len(), 2); + } +} diff --git a/crates/rattler_vfs/src/overlay_fs.rs b/crates/rattler_vfs/src/overlay_fs.rs new file mode 100644 index 0000000000..2aaf252e2b --- /dev/null +++ b/crates/rattler_vfs/src/overlay_fs.rs @@ -0,0 +1,2340 @@ +//! Writable overlay filesystem. +//! +//! Wraps a read-only `VfsOps` (the lower layer) with a persistent upper +//! directory for copy-on-write semantics. Implements the same `VfsOps` trait +//! so it can be used interchangeably with the read-only VFS. + +use libc::{EIO, ENOENT}; +use std::{ + collections::HashMap, + ffi::{OsStr, OsString}, + fs::{self, File}, + io::{Read, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, + sync::{ + Mutex, + atomic::{AtomicU64, Ordering}, + }, +}; + +mod inode; + +use crate::overlay::{OverlayState, STATE_FILENAME, STATE_LOCK_FILENAME, STATE_TMP_FILENAME}; +use crate::vfs_ops::{ContentSource, DirEntry, FileAttr, FileKind, VfsOps, set_file_permissions}; +use inode::{ResolvedIno, UPPER_INODE_BASE, UpperInodeMap}; + +/// A writable overlay filesystem that wraps a read-only lower layer. +pub struct OverlayFS { + lower: T, + state: Mutex, + /// Immutable overlay directory path — avoids locking state just to build upper paths. + overlay_dir: PathBuf, + upper_inodes: Mutex, + lower_ino_cache: Mutex>, + open_files: Mutex>, + next_fh: AtomicU64, + /// Lower inodes promoted to upper layer via rename or `open_write` COW. + /// Maps `lower_ino` → `upper_ino` so the kernel's cached handle still works. + promoted: Mutex>, +} + +impl OverlayFS { + /// Wrap a lower VFS with a pre-loaded overlay state. + /// + /// The state must have been loaded via [`OverlayState::load`] with + /// matching `env_hash` and `transport`; this constructor does not + /// revalidate. Splitting validation from VFS consumption lets the caller + /// detect a stale overlay (e.g. version mismatch), wipe it, and retry + /// without losing the lower VFS — see `create_overlay` in `lib.rs`. + pub fn wrap(lower: T, state: OverlayState) -> Result { + let overlay_dir = state.dir().to_path_buf(); + + let mut lower_ino_cache = HashMap::new(); + lower_ino_cache.insert(PathBuf::new(), 1u64); + + let overlay = OverlayFS { + lower, + state: Mutex::new(state), + overlay_dir: overlay_dir.clone(), + upper_inodes: Mutex::new(UpperInodeMap::new()), + lower_ino_cache: Mutex::new(lower_ino_cache), + open_files: Mutex::new(HashMap::new()), + next_fh: AtomicU64::new(1), + promoted: Mutex::new(HashMap::new()), + }; + + overlay.scan_upper(&overlay_dir)?; + Ok(overlay) + } + + /// Convenience: load state and wrap in one call. + /// + /// **Consumes `lower` on validation failure.** Production code that needs + /// transparent recovery from a stale overlay should call + /// [`OverlayState::load`] first and then [`OverlayFS::wrap`] separately — + /// see `create_overlay` in `lib.rs`. This helper exists for tests and + /// simple callers that don't need recovery. + pub fn new( + lower: T, + overlay_dir: PathBuf, + env_hash: String, + transport: String, + ) -> Result { + let state = OverlayState::load(overlay_dir, env_hash, transport) + .map_err(|e| format!("failed to load overlay state: {e}"))?; + Self::wrap(lower, state) + } + + fn scan_upper(&self, base: &Path) -> Result<(), String> { + let entries = match fs::read_dir(base) { + Ok(e) => e, + Err(_) => return Ok(()), + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = path.file_name().unwrap_or_default(); + + // Skip overlay internal files + if name == STATE_FILENAME || name == STATE_TMP_FILENAME || name == STATE_LOCK_FILENAME { + continue; + } + + let relative = path + .strip_prefix(&self.overlay_dir) + .map_err(|e| format!("path error: {e}"))? + .to_path_buf(); + self.assign_upper_ino(relative); + + if path.is_dir() { + self.scan_upper(&path)?; + } + } + Ok(()) + } + + fn assign_upper_ino(&self, virtual_path: PathBuf) -> u64 { + self.upper_inodes + .lock() + .unwrap() + .get_or_assign(virtual_path) + } + + /// Get the on-disk upper layer path for a virtual path (no lock needed). + fn upper_path(&self, virtual_path: &Path) -> PathBuf { + self.overlay_dir.join(virtual_path) + } + + fn resolve_path(&self, parent: u64, name: &OsStr) -> Result { + let parent_path = match self.resolve_ino(parent)? { + ResolvedIno::Upper(p) | ResolvedIno::Lower(_, p) => p, + }; + Ok(parent_path.join(name)) + } + + fn resolve_ino(&self, ino: u64) -> Result { + // Check if this lower inode was promoted to upper via rename or COW + let effective = if ino < UPPER_INODE_BASE { + self.promoted.lock().unwrap().get(&ino).copied() + } else { + None + }; + let effective_ino = effective.unwrap_or(ino); + + if effective_ino >= UPPER_INODE_BASE { + let map = self.upper_inodes.lock().unwrap(); + let path = map.path_for(effective_ino).cloned().ok_or_else(|| { + tracing::warn!( + "overlay resolve_ino: upper ino={} not found in inode map", + effective_ino + ); + ENOENT + })?; + Ok(ResolvedIno::Upper(path)) + } else { + let path = self.lower.ino_to_path(effective_ino)?; + Ok(ResolvedIno::Lower(effective_ino, path)) + } + } + + fn lower_ino_for_path(&self, path: &Path) -> Option { + if path.as_os_str().is_empty() { + return Some(1); + } + + // Check cache for the full path first + { + let cache = self.lower_ino_cache.lock().unwrap(); + if let Some(&ino) = cache.get(path) { + return Some(ino); + } + } + + // Walk from root, batch cache updates + let mut current_ino = 1u64; + let mut current_path = PathBuf::new(); + let mut new_entries = Vec::new(); + + for component in path.components() { + let name = component.as_os_str(); + + // Check cache for intermediate prefix + current_path.push(name); + { + let cache = self.lower_ino_cache.lock().unwrap(); + if let Some(&cached_ino) = cache.get(¤t_path) { + current_ino = cached_ino; + continue; + } + } + + match self.lower.lookup(current_ino, name) { + Ok(attr) => { + current_ino = attr.ino; + new_entries.push((current_path.clone(), current_ino)); + } + Err(_) => return None, + } + } + + // Batch insert all new cache entries + if !new_entries.is_empty() { + let mut cache = self.lower_ino_cache.lock().unwrap(); + for (path, ino) in new_entries { + cache.insert(path, ino); + } + } + + Some(current_ino) + } + + fn make_upper_attr(&self, path: &Path, ino: u64) -> Result { + let full_path = self.upper_path(path); + let metadata = fs::symlink_metadata(&full_path).map_err(|_e| ENOENT)?; + Ok(FileAttr::from_metadata(&metadata, ino)) + } + + fn ensure_upper_parent(&self, virtual_path: &Path) -> Result<(), i32> { + if let Some(parent) = virtual_path.parent() { + fs::create_dir_all(self.upper_path(parent)).map_err(|e| { + tracing::warn!( + "failed to create upper parent for {:?}: {}", + virtual_path, + e + ); + EIO + })?; + } + Ok(()) + } + + fn copy_to_upper(&self, virtual_path: &Path, lower_ino: u64) -> Result { + self.ensure_upper_parent(virtual_path)?; + let upper_path = self.upper_path(virtual_path); + + if upper_path.exists() { + return Ok(upper_path); + } + + // Check if this is a directory — directories need recursive COW + if let Ok(attr) = self.lower.getattr(lower_ino) + && attr.kind == FileKind::Directory + { + return self.copy_dir_to_upper(virtual_path, lower_ino); + } + + if let Ok(ContentSource::Direct(source)) = self.lower.content_source(lower_ino) { + reflink_copy::reflink_or_copy(&source, &upper_path).map_err(|e| { + tracing::warn!("COW copy failed {:?} -> {:?}: {}", source, upper_path, e); + EIO + })?; + } else { + // Transformed or Virtual — read all content via the VFS + let data = self.lower.read(lower_ino, 0, u32::MAX)?; + fs::write(&upper_path, &data).map_err(|e| { + tracing::warn!("COW write failed {:?}: {}", upper_path, e); + EIO + })?; + } + Ok(upper_path) + } + + /// Recursively copy a lower-layer directory and all its visible children + /// to the upper layer. Used when renaming a directory from lower to upper. + fn copy_dir_to_upper(&self, virtual_path: &Path, lower_ino: u64) -> Result { + let upper_path = self.upper_path(virtual_path); + fs::create_dir_all(&upper_path).map_err(|e| { + tracing::warn!("COW mkdir failed {:?}: {}", upper_path, e); + EIO + })?; + + let state = self.state.lock().unwrap(); + let entries = self.lower.readdir(lower_ino, 0).unwrap_or_default(); + // Collect entries to avoid holding state lock during recursive COW + let children: Vec<_> = entries + .into_iter() + .filter(|e| { + e.name != "." && e.name != ".." && !state.is_whiteout(&virtual_path.join(&e.name)) + }) + .collect(); + drop(state); + + for child in children { + let child_path = virtual_path.join(&child.name); + self.copy_to_upper(&child_path, child.ino)?; + } + + Ok(upper_path) + } +} + +impl VfsOps for OverlayFS { + fn lookup(&self, parent: u64, name: &OsStr) -> Result { + let parent_path = match self.resolve_ino(parent)? { + ResolvedIno::Upper(p) | ResolvedIno::Lower(_, p) => p, + }; + let virtual_path = parent_path.join(name); + + // Single state lock: check whiteout and opaque in one acquisition + { + let state = self.state.lock().unwrap(); + if state.is_whiteout(&virtual_path) { + return Err(ENOENT); + } + if state.is_opaque(&parent_path) { + return Err(ENOENT); + } + } + + // Check upper layer — use symlink_metadata directly (avoids TOCTOU of exists + stat) + let upper = self.upper_path(&virtual_path); + if let Ok(metadata) = fs::symlink_metadata(&upper) { + let ino = self.assign_upper_ino(virtual_path.clone()); + return Ok(FileAttr::from_metadata(&metadata, ino)); + } + + // Fall through to lower + if let Some(lower_parent_ino) = self.lower_ino_for_path(&parent_path) { + let result = self.lower.lookup(lower_parent_ino, name); + if let Ok(ref attr) = result { + self.lower_ino_cache + .lock() + .unwrap() + .insert(virtual_path, attr.ino); + } + result + } else { + Err(ENOENT) + } + } + + fn getattr(&self, ino: u64) -> Result { + match self.resolve_ino(ino)? { + ResolvedIno::Upper(path) => { + // Try upper first, fall through to lower if it's just a structural dir + match self.make_upper_attr(&path, ino) { + Ok(attr) => Ok(attr), + Err(_) => { + if let Some(lower_ino) = self.lower_ino_for_path(&path) { + self.lower.getattr(lower_ino) + } else { + Err(ENOENT) + } + } + } + } + ResolvedIno::Lower(lower_ino, _) => self.lower.getattr(lower_ino), + } + } + + fn readlink(&self, ino: u64) -> Result { + match self.resolve_ino(ino)? { + ResolvedIno::Upper(path) => { + let full = self.upper_path(&path); + fs::read_link(&full).map_err(|_e| EIO) + } + ResolvedIno::Lower(lower_ino, _) => self.lower.readlink(lower_ino), + } + } + + fn read(&self, ino: u64, offset: u64, size: u32) -> Result, i32> { + match self.resolve_ino(ino)? { + ResolvedIno::Upper(path) => { + let full = self.upper_path(&path); + if full.exists() && !full.is_dir() { + let mut file = File::open(&full).map_err(|_e| EIO)?; + file.seek(SeekFrom::Start(offset)).map_err(|_e| EIO)?; + let mut buf = vec![0u8; size as usize]; + let n = file.read(&mut buf).map_err(|_e| EIO)?; + buf.truncate(n); + return Ok(buf); + } + // Fall through to lower + if let Some(lower_ino) = self.lower_ino_for_path(&path) { + self.lower.read(lower_ino, offset, size) + } else { + Err(ENOENT) + } + } + ResolvedIno::Lower(lower_ino, _) => self.lower.read(lower_ino, offset, size), + } + } + + fn content_source(&self, ino: u64) -> Result { + match self.resolve_ino(ino)? { + ResolvedIno::Upper(path) => { + let full = self.upper_path(&path); + if full.exists() && !full.is_dir() { + Ok(ContentSource::Direct(full)) + } else if let Some(lower_ino) = self.lower_ino_for_path(&path) { + self.lower.content_source(lower_ino) + } else { + Err(ENOENT) + } + } + ResolvedIno::Lower(lower_ino, _) => self.lower.content_source(lower_ino), + } + } + + fn open_write(&self, ino: u64) -> Result { + let (virtual_path, needs_cow_from) = match self.resolve_ino(ino)? { + ResolvedIno::Upper(path) => { + let p = self.upper_path(&path); + if p.exists() { + (path, None) + } else { + let li = self.lower_ino_for_path(&path).ok_or(ENOENT)?; + (path, Some(li)) + } + } + ResolvedIno::Lower(li, path) => (path, Some(li)), + }; + + if let Some(li) = needs_cow_from { + self.copy_to_upper(&virtual_path, li)?; + // Promote: the kernel may call getattr/read with the original lower + // inode after this COW, so redirect it to the upper copy. + if ino < UPPER_INODE_BASE { + let upper_ino = self.assign_upper_ino(virtual_path.clone()); + self.promoted.lock().unwrap().insert(ino, upper_ino); + } + } + + let upper_path = self.upper_path(&virtual_path); + let file = File::options() + .read(true) + .write(true) + .open(&upper_path) + .map_err(|e| { + tracing::warn!("overlay open write failed {:?}: {}", upper_path, e); + EIO + })?; + let fh = self.next_fh.fetch_add(1, Ordering::Relaxed); + self.open_files.lock().unwrap().insert(fh, file); + Ok(fh) + } + + fn read_handle(&self, fh: u64, offset: u64, size: u32) -> Result, i32> { + let mut files = self.open_files.lock().unwrap(); + let file = files.get_mut(&fh).ok_or(EIO)?; + file.seek(SeekFrom::Start(offset)).map_err(|_e| EIO)?; + let mut buf = vec![0u8; size as usize]; + let n = file.read(&mut buf).map_err(|_e| EIO)?; + buf.truncate(n); + Ok(buf) + } + + fn release_write(&self, fh: u64) { + self.open_files.lock().unwrap().remove(&fh); + } + + fn readdir(&self, ino: u64, offset: u64) -> Result, i32> { + let (dir_path, lower_dir_ino) = match self.resolve_ino(ino)? { + ResolvedIno::Upper(p) => { + let li = self.lower_ino_for_path(&p); + (p, li) + } + ResolvedIno::Lower(li, p) => (p, Some(li)), + }; + let state = self.state.lock().unwrap(); + + // Collect entries by name — upper overrides lower + let mut entries_by_name: HashMap = HashMap::new(); + + // Start with lower layer entries — unless this directory is opaque + let lower_ino = if state.is_opaque(&dir_path) { + None + } else { + lower_dir_ino + }; + if let Some(lower_ino) = lower_ino + && let Ok(lower_entries) = self.lower.readdir(lower_ino, 0) + { + for entry in lower_entries { + // Skip . and .. — we'll add them ourselves + if entry.name == "." || entry.name == ".." { + continue; + } + let child_path = dir_path.join(&entry.name); + if !state.is_whiteout(&child_path) { + entries_by_name.insert(entry.name.clone(), entry); + } + } + } + + // Overlay upper layer entries + let upper_dir = self.upper_path(&dir_path); + drop(state); + + if let Ok(read_dir) = fs::read_dir(&upper_dir) { + for entry in read_dir.flatten() { + let name = entry.file_name(); + if name == STATE_FILENAME + || name == STATE_LOCK_FILENAME + || name.to_str().is_some_and(|s| s.ends_with(".tmp")) + { + continue; + } + let child_path = dir_path.join(&name); + let child_ino = self.assign_upper_ino(child_path); + let kind = if entry.path().is_dir() { + FileKind::Directory + } else { + FileKind::RegularFile + }; + entries_by_name.insert( + name.clone(), + DirEntry { + ino: child_ino, + kind, + name, + }, + ); + } + } + + // Build final list with . and .. at the front + let mut result = Vec::new(); + if offset == 0 { + result.push(DirEntry { + ino: 1, // parent — simplified + kind: FileKind::Directory, + name: OsString::from(".."), + }); + } + if offset <= 1 { + result.push(DirEntry { + ino, + kind: FileKind::Directory, + name: OsString::from("."), + }); + } + + // Sort entries by name for deterministic ordering across readdir calls. + // HashMap iteration order is non-deterministic — without sorting, offset-based + // pagination would skip different entries on each call. + let mut sorted_entries: Vec<_> = entries_by_name.into_values().collect(); + sorted_entries.sort_by(|a, b| a.name.cmp(&b.name)); + + let skip = offset.saturating_sub(2) as usize; + result.extend(sorted_entries.into_iter().skip(skip)); + Ok(result) + } + + fn create(&self, parent: u64, name: &OsStr, mode: u32) -> Result<(FileAttr, u64), i32> { + let virtual_path = self.resolve_path(parent, name).inspect_err(|&e| { + tracing::warn!( + "overlay create: resolve_path failed for parent={} name={:?}: errno={}", + parent, + name, + e + ); + })?; + self.ensure_upper_parent(&virtual_path).inspect_err(|&e| { + tracing::warn!( + "overlay create: ensure_upper_parent failed for {:?}: errno={}", + virtual_path, + e + ); + })?; + + let upper_path = self.upper_path(&virtual_path); + let file = File::options() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(&upper_path) + .map_err(|e| { + tracing::warn!("create failed {:?}: {}", upper_path, e); + EIO + })?; + set_file_permissions(&upper_path, mode).ok(); + + self.state + .lock() + .unwrap() + .remove_whiteout(&virtual_path) + .map_err(|e| { + tracing::warn!( + "overlay create: remove_whiteout/flush failed for {:?}: {}", + virtual_path, + e + ); + EIO + })?; + + let ino = self.assign_upper_ino(virtual_path.clone()); + let attr = self.make_upper_attr(&virtual_path, ino)?; + + let fh = self.next_fh.fetch_add(1, Ordering::Relaxed); + self.open_files.lock().unwrap().insert(fh, file); + + Ok((attr, fh)) + } + + fn write(&self, fh: u64, offset: u64, data: &[u8]) -> Result { + let mut files = self.open_files.lock().unwrap(); + let file = files.get_mut(&fh).ok_or_else(|| { + tracing::warn!("overlay write: fh={} not found in open_files", fh); + EIO + })?; + file.seek(SeekFrom::Start(offset)).map_err(|e| { + tracing::warn!( + "overlay write: seek failed fh={} offset={}: {}", + fh, + offset, + e + ); + EIO + })?; + file.write_all(data).map_err(|e| { + tracing::warn!("overlay write: write failed fh={}: {}", fh, e); + EIO + })?; + Ok(data.len() as u32) + } + + fn unlink(&self, parent: u64, name: &OsStr) -> Result<(), i32> { + let virtual_path = self.resolve_path(parent, name)?; + let upper_path = self.upper_path(&virtual_path); + + // If this is a lower-layer directory with visible children, reject. + // The NFS server routes both REMOVE and RMDIR through unlink, so we + // must enforce ENOTEMPTY here as well as in rmdir. + if let Some(lower_ino) = self.lower_ino_for_path(&virtual_path) + && let Ok(attr) = self.lower.getattr(lower_ino) + && attr.kind == FileKind::Directory + { + let state = self.state.lock().unwrap(); + if let Ok(lower_entries) = self.lower.readdir(lower_ino, 0) { + let has_visible = lower_entries.iter().any(|e| { + if e.name == "." || e.name == ".." { + return false; + } + !state.is_whiteout(&virtual_path.join(&e.name)) + }); + if has_visible { + return Err(libc::ENOTEMPTY); + } + } + } + + // Remove from upper if present (ignore NotFound). + // The NFS server routes both REMOVE and RMDIR through this function, + // so handle both files and directories. Try remove_file first; on + // failure (EPERM on macOS, EISDIR on Linux) fall back to remove_dir. + if let Err(e) = fs::remove_file(&upper_path) + && e.kind() != std::io::ErrorKind::NotFound + && let Err(e2) = fs::remove_dir(&upper_path) + && e2.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + "unlink/rmdir failed {:?}: file={}, dir={}", + upper_path, + e, + e2 + ); + return Err(e2.raw_os_error().unwrap_or(EIO)); + } + + // Only whiteout if the file exists in the lower layer — no point tracking + // deletions of upper-only files (e.g. .pyc temp files) + if self.lower_ino_for_path(&virtual_path).is_some() { + self.state + .lock() + .unwrap() + .add_whiteout(virtual_path) + .map_err(|_e| EIO)?; + } + + Ok(()) + } + + fn mkdir(&self, parent: u64, name: &OsStr, mode: u32) -> Result { + let virtual_path = self.resolve_path(parent, name)?; + let upper_path = self.upper_path(&virtual_path); + + fs::create_dir_all(&upper_path).map_err(|e| { + tracing::warn!("mkdir failed {:?}: {}", upper_path, e); + EIO + })?; + set_file_permissions(&upper_path, mode).ok(); + + // Only mark opaque if previously whiteout'd (rmdir + mkdir pattern) + let mut state = self.state.lock().unwrap(); + let was_whiteoutd = state.is_whiteout(&virtual_path); + state.remove_whiteout(&virtual_path).map_err(|_e| EIO)?; + if was_whiteoutd && self.lower_ino_for_path(&virtual_path).is_some() { + state + .add_opaque_dir(virtual_path.clone()) + .map_err(|_e| EIO)?; + } + drop(state); + + let ino = self.assign_upper_ino(virtual_path.clone()); + self.make_upper_attr(&virtual_path, ino) + } + + fn rmdir(&self, parent: u64, name: &OsStr) -> Result<(), i32> { + let virtual_path = self.resolve_path(parent, name)?; + let upper_path = self.upper_path(&virtual_path); + + // Check for visible children before removing. A lower-layer directory + // may still contain files not covered by whiteouts — removing it would + // hide them all (the whiteout on the directory itself blocks lookups for + // every child). + if let Some(lower_ino) = self.lower_ino_for_path(&virtual_path) { + let state = self.state.lock().unwrap(); + if let Ok(lower_entries) = self.lower.readdir(lower_ino, 0) { + let has_visible = lower_entries.iter().any(|e| { + if e.name == "." || e.name == ".." { + return false; + } + !state.is_whiteout(&virtual_path.join(&e.name)) + }); + if has_visible { + return Err(libc::ENOTEMPTY); + } + } + } + + if let Err(e) = fs::remove_dir(&upper_path) + && e.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!("rmdir failed {:?}: {}", upper_path, e); + return Err(EIO); + } + + if self.lower_ino_for_path(&virtual_path).is_some() { + self.state + .lock() + .unwrap() + .add_whiteout(virtual_path) + .map_err(|_e| EIO)?; + } + + Ok(()) + } + + fn rename( + &self, + parent: u64, + name: &OsStr, + newparent: u64, + newname: &OsStr, + flags: u32, + ) -> Result<(), i32> { + // Handle RENAME_NOREPLACE: fail if destination exists + #[cfg(target_os = "linux")] + if flags & libc::RENAME_NOREPLACE != 0 { + let dst_check = self.resolve_path(newparent, newname)?; + if self.upper_path(&dst_check).exists() { + return Err(libc::EEXIST); + } + let state = self.state.lock().unwrap(); + if !state.is_whiteout(&dst_check) && self.lower_ino_for_path(&dst_check).is_some() { + return Err(libc::EEXIST); + } + } + // Suppress unused warning on non-Linux + #[cfg(not(target_os = "linux"))] + let _ = flags; + + let src_path = self.resolve_path(parent, name)?; + let dst_path = self.resolve_path(newparent, newname)?; + let upper_src = self.upper_path(&src_path); + let upper_dst = self.upper_path(&dst_path); + + self.ensure_upper_parent(&dst_path)?; + + let upper_src_existed = upper_src.exists(); + if upper_src_existed { + // If this is a directory that also has lower-layer content, COW + // any lower children not already present in upper before renaming. + // Without this, the lower children are lost (whiteout hides them, + // and the destination only gets the upper content). + if let Some(lower_ino) = self.lower_ino_for_path(&src_path) + && let Ok(attr) = self.lower.getattr(lower_ino) + && attr.kind == FileKind::Directory + { + let state = self.state.lock().unwrap(); + let entries = self.lower.readdir(lower_ino, 0).unwrap_or_default(); + let children: Vec<_> = entries + .into_iter() + .filter(|e| { + e.name != "." + && e.name != ".." + && !state.is_whiteout(&src_path.join(&e.name)) + }) + .collect(); + drop(state); + for child in children { + let child_path = src_path.join(&child.name); + // copy_to_upper is idempotent — skips if already in upper + self.copy_to_upper(&child_path, child.ino)?; + } + } + fs::rename(&upper_src, &upper_dst).map_err(|e| { + tracing::warn!("rename failed {:?} -> {:?}: {}", upper_src, upper_dst, e); + EIO + })?; + } else { + // Source is in lower — COW then move + let parent_path = match self.resolve_ino(parent)? { + ResolvedIno::Upper(p) | ResolvedIno::Lower(_, p) => p, + }; + let lower_parent_ino = self.lower_ino_for_path(&parent_path).ok_or(ENOENT)?; + let attr = self.lower.lookup(lower_parent_ino, name)?; + self.copy_to_upper(&src_path, attr.ino)?; + fs::rename(&upper_src, &upper_dst).map_err(|e| { + tracing::warn!( + "rename after COW failed {:?} -> {:?}: {}", + upper_src, + upper_dst, + e + ); + EIO + })?; + + // Promote: the kernel holds the lower inode for the source file. + // After COW + rename, that inode must resolve to the upper destination. + let upper_ino = self.assign_upper_ino(dst_path.clone()); + self.promoted.lock().unwrap().insert(attr.ino, upper_ino); + } + + // Only track whiteouts for paths that exist in the lower layer. + // Single lock acquisition for check + modify to avoid TOCTOU. + let src_in_lower = self.lower_ino_for_path(&src_path).is_some(); + let mut state = self.state.lock().unwrap(); + let dst_whiteoutd = state.is_whiteout(&dst_path); + let mut dirty = false; + if src_in_lower { + state.whiteouts.insert(src_path.clone()); + dirty = true; + } + if dst_whiteoutd { + state.whiteouts.remove(&dst_path); + dirty = true; + } + if dirty { + state.flush().map_err(|_e| EIO)?; + } + drop(state); + + // For upper→upper renames, remap the existing inode to the new path. + // The kernel keeps the source inode alive and expects it to resolve + // to the new location. + // For lower→upper (COW), the inode was already assigned above. + if upper_src_existed { + self.upper_inodes + .lock() + .unwrap() + .rename_path(&src_path, dst_path); + } + Ok(()) + } + + fn setattr(&self, ino: u64, size: Option, mode: Option) -> Result { + // Only COW for meaningful mutations (size/mode), not timestamp updates. + // Combined with noatime mount option, this avoids copying large binaries + // just because something read them. + if size.is_none() && mode.is_none() { + return self.getattr(ino); + } + + let (virtual_path, upper_ino) = match self.resolve_ino(ino)? { + ResolvedIno::Upper(path) => (path, ino), + ResolvedIno::Lower(lower_ino, path) => { + self.copy_to_upper(&path, lower_ino)?; + let ui = self.assign_upper_ino(path.clone()); + self.promoted.lock().unwrap().insert(lower_ino, ui); + (path, ui) + } + }; + + let upper_path = self.upper_path(&virtual_path); + + if let Some(size) = size { + let file = File::options() + .write(true) + .open(&upper_path) + .map_err(|_e| EIO)?; + file.set_len(size).map_err(|_e| EIO)?; + } + if let Some(mode) = mode { + set_file_permissions(&upper_path, mode).map_err(|_e| EIO)?; + } + + self.make_upper_attr(&virtual_path, upper_ino) + } + + fn ino_to_path(&self, ino: u64) -> Result { + match self.resolve_ino(ino)? { + ResolvedIno::Upper(p) | ResolvedIno::Lower(_, p) => Ok(p), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::UNIX_EPOCH; + use tempfile::TempDir; + + // A minimal VfsOps implementation for testing the overlay + struct MockLowerFS; + + impl VfsOps for MockLowerFS { + fn lookup(&self, _parent: u64, _name: &OsStr) -> Result { + Err(ENOENT) + } + fn getattr(&self, _ino: u64) -> Result { + Err(ENOENT) + } + fn readlink(&self, _ino: u64) -> Result { + Err(ENOENT) + } + fn read(&self, _ino: u64, _offset: u64, _size: u32) -> Result, i32> { + Err(EIO) + } + fn content_source(&self, _ino: u64) -> Result { + Err(ENOENT) + } + fn readdir(&self, _ino: u64, _offset: u64) -> Result, i32> { + Ok(vec![]) + } + fn ino_to_path(&self, ino: u64) -> Result { + if ino == 1 { + Ok(PathBuf::new()) // root + } else { + Err(ENOENT) + } + } + } + + #[test] + fn test_overlay_create_and_read() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new(MockLowerFS, overlay_dir, "hash".into(), "test".into()).unwrap(); + + // Create needs a parent inode. Use UPPER_INODE_BASE as a root-like parent. + // First, create a directory for the parent + let dir_attr = ofs.mkdir(1, OsStr::new("testdir"), 0o755).unwrap(); + + // Create a file in that directory + let (file_attr, fh) = ofs + .create(dir_attr.ino, OsStr::new("test.txt"), 0o644) + .unwrap(); + assert!(file_attr.ino >= UPPER_INODE_BASE); + + // Write content + ofs.write(fh, 0, b"hello overlay").unwrap(); + + // Read it back + let data = ofs.read_handle(fh, 0, 1024).unwrap(); + assert_eq!(data, b"hello overlay"); + } + + #[test] + fn test_overlay_unlink() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerFS, + overlay_dir.clone(), + "hash".into(), + "test".into(), + ) + .unwrap(); + + // Create a dir and file + let dir_attr = ofs.mkdir(1, OsStr::new("dir"), 0o755).unwrap(); + ofs.create(dir_attr.ino, OsStr::new("file.txt"), 0o644) + .unwrap(); + + // Verify file exists + let state = ofs.state.lock().unwrap(); + assert!(state.has_upper(Path::new("dir/file.txt"))); + drop(state); + + // Delete it + ofs.unlink(dir_attr.ino, OsStr::new("file.txt")).unwrap(); + + // File should be gone from upper. No whiteout needed since it was upper-only. + let state = ofs.state.lock().unwrap(); + assert!(!state.has_upper(Path::new("dir/file.txt"))); + assert!(!state.is_whiteout(Path::new("dir/file.txt"))); + } + + #[test] + fn test_overlay_create_after_unlink() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new(MockLowerFS, overlay_dir, "hash".into(), "test".into()).unwrap(); + + let dir_attr = ofs.mkdir(1, OsStr::new("dir"), 0o755).unwrap(); + + // Create, delete, recreate + ofs.create(dir_attr.ino, OsStr::new("file.txt"), 0o644) + .unwrap(); + ofs.unlink(dir_attr.ino, OsStr::new("file.txt")).unwrap(); + let (_, fh) = ofs + .create(dir_attr.ino, OsStr::new("file.txt"), 0o644) + .unwrap(); + + // Whiteout should be cleared + let state = ofs.state.lock().unwrap(); + assert!(!state.is_whiteout(Path::new("dir/file.txt"))); + assert!(state.has_upper(Path::new("dir/file.txt"))); + drop(state); + + // Write and read + ofs.write(fh, 0, b"recreated").unwrap(); + let data = ofs.read_handle(fh, 0, 1024).unwrap(); + assert_eq!(data, b"recreated"); + } + + #[test] + fn test_overlay_mkdir_rmdir() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new(MockLowerFS, overlay_dir, "hash".into(), "test".into()).unwrap(); + + let dir_attr = ofs.mkdir(1, OsStr::new("mydir"), 0o755).unwrap(); + assert!(dir_attr.ino >= UPPER_INODE_BASE); + + let state = ofs.state.lock().unwrap(); + assert!(state.has_upper(Path::new("mydir"))); + drop(state); + + ofs.rmdir(1, OsStr::new("mydir")).unwrap(); + + // No whiteout needed since this dir was upper-only (MockLowerFS has no files) + let state = ofs.state.lock().unwrap(); + assert!(!state.is_whiteout(Path::new("mydir"))); + } + + #[test] + fn test_overlay_getattr_upper() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new(MockLowerFS, overlay_dir, "hash".into(), "test".into()).unwrap(); + + let dir_attr = ofs.mkdir(1, OsStr::new("dir"), 0o755).unwrap(); + let (file_attr, fh) = ofs + .create(dir_attr.ino, OsStr::new("test.txt"), 0o644) + .unwrap(); + ofs.write(fh, 0, b"content").unwrap(); + + // getattr should reflect the written size + // Need to flush/close first for size to be accurate + ofs.release_write(fh); + + let attr = ofs.getattr(file_attr.ino).unwrap(); + assert_eq!(attr.size, 7); // "content".len() + assert_eq!(attr.kind, FileKind::RegularFile); + } + + #[test] + fn test_overlay_persistence() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + + // Create files in first session + { + let ofs = OverlayFS::new( + MockLowerFS, + overlay_dir.clone(), + "hash".into(), + "test".into(), + ) + .unwrap(); + let dir_attr = ofs.mkdir(1, OsStr::new("dir"), 0o755).unwrap(); + let (_, fh) = ofs + .create(dir_attr.ino, OsStr::new("persist.txt"), 0o644) + .unwrap(); + ofs.write(fh, 0, b"persistent").unwrap(); + ofs.release_write(fh); + } + + // Reload and verify + { + let ofs = + OverlayFS::new(MockLowerFS, overlay_dir, "hash".into(), "test".into()).unwrap(); + let state = ofs.state.lock().unwrap(); + assert!(state.has_upper(Path::new("dir/persist.txt"))); + } + } + + // --- Merged directory tests using a richer mock --- + + /// A mock lower FS with a known file tree for testing merged lookups. + /// Tree: root(1) → lib(2) → python(3) → foo.py(4), bar.py(5) + struct MockLowerWithFiles; + + impl VfsOps for MockLowerWithFiles { + fn lookup(&self, parent: u64, name: &OsStr) -> Result { + let make_attr = |ino: u64, kind: FileKind, size: u64| FileAttr { + ino, + size, + blocks: 1, + atime: UNIX_EPOCH, + mtime: UNIX_EPOCH, + ctime: UNIX_EPOCH, + kind, + perm: 0o644, + nlink: 1, + uid: 0, + gid: 0, + }; + // Tree: root(1) → lib(2) → python(3) → {foo.py(4), bar.py(5)} + // → bin(6) → pytest(7) [virtual entry point] + match (parent, name.to_str().unwrap()) { + (1, "lib") => Ok(make_attr(2, FileKind::Directory, 0)), + (1, "bin") => Ok(make_attr(6, FileKind::Directory, 0)), + (2, "python") => Ok(make_attr(3, FileKind::Directory, 0)), + (3, "foo.py") => Ok(make_attr(4, FileKind::RegularFile, 11)), + (3, "bar.py") => Ok(make_attr(5, FileKind::RegularFile, 11)), + (6, "pytest") => Ok(make_attr(7, FileKind::RegularFile, 22)), + _ => Err(ENOENT), + } + } + fn getattr(&self, ino: u64) -> Result { + let (kind, size) = match ino { + 1 | 2 | 3 | 6 => (FileKind::Directory, 0), + 4 | 5 => (FileKind::RegularFile, 11), // "foo content" / "bar content" + 7 => (FileKind::RegularFile, 22), // "#!/bin/python\nimport..." (virtual) + _ => return Err(ENOENT), + }; + Ok(FileAttr { + ino, + size, + blocks: 1, + atime: UNIX_EPOCH, + mtime: UNIX_EPOCH, + ctime: UNIX_EPOCH, + kind, + perm: 0o644, + nlink: 1, + uid: 0, + gid: 0, + }) + } + fn readlink(&self, _ino: u64) -> Result { + Err(ENOENT) + } + fn read(&self, ino: u64, offset: u64, size: u32) -> Result, i32> { + let content = match ino { + 4 => b"foo content".to_vec(), + 5 => b"bar content".to_vec(), + 7 => b"#!/bin/python\nimport x".to_vec(), // virtual entry point + _ => return Err(EIO), + }; + let start = offset as usize; + let end = (start + size as usize).min(content.len()); + if start >= content.len() { + return Ok(vec![]); + } + Ok(content[start..end].to_vec()) + } + fn content_source(&self, ino: u64) -> Result { + match ino { + 4 | 5 | 7 => Ok(ContentSource::Virtual), // entry point script + _ => Err(ENOENT), + } + } + fn readdir(&self, ino: u64, _offset: u64) -> Result, i32> { + match ino { + 1 => Ok(vec![ + DirEntry { + ino: 2, + kind: FileKind::Directory, + name: "lib".into(), + }, + DirEntry { + ino: 6, + kind: FileKind::Directory, + name: "bin".into(), + }, + ]), + 2 => Ok(vec![DirEntry { + ino: 3, + kind: FileKind::Directory, + name: "python".into(), + }]), + 3 => Ok(vec![ + DirEntry { + ino: 4, + kind: FileKind::RegularFile, + name: "foo.py".into(), + }, + DirEntry { + ino: 5, + kind: FileKind::RegularFile, + name: "bar.py".into(), + }, + ]), + 6 => Ok(vec![DirEntry { + ino: 7, + kind: FileKind::RegularFile, + name: "pytest".into(), + }]), + _ => Err(ENOENT), + } + } + fn ino_to_path(&self, ino: u64) -> Result { + match ino { + 1 => Ok(PathBuf::new()), + 2 => Ok(PathBuf::from("lib")), + 3 => Ok(PathBuf::from("lib/python")), + 4 => Ok(PathBuf::from("lib/python/foo.py")), + 5 => Ok(PathBuf::from("lib/python/bar.py")), + 6 => Ok(PathBuf::from("bin")), + 7 => Ok(PathBuf::from("bin/pytest")), + _ => Err(ENOENT), + } + } + } + + #[test] + fn test_overlay_lookup_falls_through_to_lower() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + // lookup lib → lower inode 2 + let lib_attr = ofs.lookup(1, OsStr::new("lib")).unwrap(); + assert_eq!(lib_attr.ino, 2); + + // lookup python inside lib → lower inode 3 + let python_attr = ofs.lookup(lib_attr.ino, OsStr::new("python")).unwrap(); + assert_eq!(python_attr.ino, 3); + + // lookup foo.py inside python → lower inode 4 + let foo_attr = ofs.lookup(python_attr.ino, OsStr::new("foo.py")).unwrap(); + assert_eq!(foo_attr.ino, 4); + } + + #[test] + fn test_overlay_lookup_through_upper_parent_falls_to_lower() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir.clone(), + "hash".into(), + "test".into(), + ) + .unwrap(); + + // Create a file in the upper layer under lib/python/ + // This creates lib/ and lib/python/ as structural dirs in the upper + fs::create_dir_all(overlay_dir.join("lib/python")).unwrap(); + fs::write(overlay_dir.join("lib/python/new.py"), b"new").unwrap(); + + // Assign upper inodes (simulating what readdir would do) + ofs.assign_upper_ino(PathBuf::from("lib")); + ofs.assign_upper_ino(PathBuf::from("lib/python")); + ofs.assign_upper_ino(PathBuf::from("lib/python/new.py")); + + // Get the upper inode for lib/python + let python_upper_ino = ofs + .upper_inodes + .lock() + .unwrap() + .ino_for_path(Path::new("lib/python")) + .unwrap(); + + // Looking up foo.py via the UPPER inode for lib/python should still + // find it in the LOWER layer + let foo_attr = ofs.lookup(python_upper_ino, OsStr::new("foo.py")).unwrap(); + assert_eq!(foo_attr.ino, 4); // lower inode + } + + #[test] + fn test_overlay_readdir_merges_upper_and_lower() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir.clone(), + "hash".into(), + "test".into(), + ) + .unwrap(); + + // Add a new file in the upper layer under lib/python/ + fs::create_dir_all(overlay_dir.join("lib/python")).unwrap(); + fs::write(overlay_dir.join("lib/python/new.py"), b"new").unwrap(); + + // readdir on lower inode 3 (lib/python) should merge both layers + let entries = ofs.readdir(3, 0).unwrap(); + let names: Vec = entries + .iter() + .filter(|e| e.name != "." && e.name != "..") + .map(|e| e.name.to_str().unwrap().to_string()) + .collect(); + + assert!( + names.contains(&"foo.py".to_string()), + "missing lower file foo.py" + ); + assert!( + names.contains(&"bar.py".to_string()), + "missing lower file bar.py" + ); + assert!( + names.contains(&"new.py".to_string()), + "missing upper file new.py" + ); + } + + #[test] + fn test_overlay_opaque_dir_hides_lower_contents() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + // lib/python exists in lower with foo.py and bar.py + let lib_attr = ofs.lookup(1, OsStr::new("lib")).unwrap(); + let _python_attr = ofs.lookup(lib_attr.ino, OsStr::new("python")).unwrap(); + + // Whiteout all children first so rmdir succeeds (POSIX semantics) + ofs.unlink(3, OsStr::new("foo.py")).unwrap(); + ofs.unlink(3, OsStr::new("bar.py")).unwrap(); + + // Remove and recreate lib/python — should become opaque + ofs.rmdir(2, OsStr::new("python")).unwrap(); + let new_python = ofs.mkdir(2, OsStr::new("python"), 0o755).unwrap(); + + // Lower files should be hidden — lookup foo.py should fail + assert!(ofs.lookup(new_python.ino, OsStr::new("foo.py")).is_err()); + + // readdir should only show . and .. (no lower files) + let entries = ofs.readdir(new_python.ino, 0).unwrap(); + let names: Vec = entries + .iter() + .filter(|e| e.name != "." && e.name != "..") + .map(|e| e.name.to_str().unwrap().to_string()) + .collect(); + assert!(names.is_empty(), "expected empty dir, got: {names:?}"); + + // Creating a new file in the opaque dir should work + let (_, fh) = ofs + .create(new_python.ino, OsStr::new("new.py"), 0o644) + .unwrap(); + ofs.write(fh, 0, b"fresh").unwrap(); + + let entries = ofs.readdir(new_python.ino, 0).unwrap(); + let names: Vec = entries + .iter() + .filter(|e| e.name != "." && e.name != "..") + .map(|e| e.name.to_str().unwrap().to_string()) + .collect(); + assert_eq!(names, vec!["new.py"]); + } + + #[test] + fn test_overlay_readdir_filters_whiteouts() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + // Delete foo.py (lower file) via whiteout + ofs.unlink(3, OsStr::new("foo.py")).unwrap(); + + // readdir should no longer include foo.py + let entries = ofs.readdir(3, 0).unwrap(); + let names: Vec = entries + .iter() + .filter(|e| e.name != "." && e.name != "..") + .map(|e| e.name.to_str().unwrap().to_string()) + .collect(); + + assert!( + !names.contains(&"foo.py".to_string()), + "foo.py should be hidden by whiteout" + ); + assert!( + names.contains(&"bar.py".to_string()), + "bar.py should still be visible" + ); + } + + // --- Rename and inode promotion tests --- + + #[test] + fn test_rename_upper_to_upper() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new(MockLowerFS, overlay_dir, "hash".into(), "test".into()).unwrap(); + + let dir_attr = ofs.mkdir(1, OsStr::new("d"), 0o755).unwrap(); + let (file_attr, fh) = ofs + .create(dir_attr.ino, OsStr::new("a.txt"), 0o644) + .unwrap(); + ofs.write(fh, 0, b"hello").unwrap(); + ofs.release_write(fh); + + let original_ino = file_attr.ino; + + // Rename a.txt → b.txt + ofs.rename( + dir_attr.ino, + OsStr::new("a.txt"), + dir_attr.ino, + OsStr::new("b.txt"), + 0, + ) + .unwrap(); + + // Kernel uses the original inode — getattr should still work + let attr = ofs.getattr(original_ino).unwrap(); + assert_eq!(attr.size, 5); + + // read via original inode should return content + let data = ofs.read(original_ino, 0, 1024).unwrap(); + assert_eq!(data, b"hello"); + + // lookup new name should succeed + assert!(ofs.lookup(dir_attr.ino, OsStr::new("b.txt")).is_ok()); + + // lookup old name should fail + assert_eq!( + ofs.lookup(dir_attr.ino, OsStr::new("a.txt")).unwrap_err(), + ENOENT + ); + } + + #[test] + fn test_rename_lower_to_upper() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + // Get lower inode for foo.py (ino=4) via lookup chain + let lib = ofs.lookup(1, OsStr::new("lib")).unwrap(); + let python = ofs.lookup(lib.ino, OsStr::new("python")).unwrap(); + let foo = ofs.lookup(python.ino, OsStr::new("foo.py")).unwrap(); + assert_eq!(foo.ino, 4); // lower inode + + // Rename foo.py → moved.py + ofs.rename( + python.ino, + OsStr::new("foo.py"), + python.ino, + OsStr::new("moved.py"), + 0, + ) + .unwrap(); + + // getattr with the LOWER inode (4) should still work via promoted map + let attr = ofs.getattr(4).unwrap(); + assert_eq!(attr.kind, FileKind::RegularFile); + + // read with lower inode should return the COW'd content + let data = ofs.read(4, 0, 1024).unwrap(); + assert_eq!(data, b"foo content"); + + // lookup new name should succeed + assert!(ofs.lookup(python.ino, OsStr::new("moved.py")).is_ok()); + + // lookup old name should fail (whiteout) + assert_eq!( + ofs.lookup(python.ino, OsStr::new("foo.py")).unwrap_err(), + ENOENT + ); + + // Whiteout should be persisted + let state = ofs.state.lock().unwrap(); + assert!(state.is_whiteout(Path::new("lib/python/foo.py"))); + } + + #[test] + fn test_rename_noreplace_existing() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new(MockLowerFS, overlay_dir, "hash".into(), "test".into()).unwrap(); + + let dir_attr = ofs.mkdir(1, OsStr::new("d"), 0o755).unwrap(); + ofs.create(dir_attr.ino, OsStr::new("a.txt"), 0o644) + .unwrap(); + ofs.create(dir_attr.ino, OsStr::new("b.txt"), 0o644) + .unwrap(); + + // RENAME_NOREPLACE should fail when destination exists + #[cfg(target_os = "linux")] + { + let err = ofs + .rename( + dir_attr.ino, + OsStr::new("a.txt"), + dir_attr.ino, + OsStr::new("b.txt"), + libc::RENAME_NOREPLACE, + ) + .unwrap_err(); + assert_eq!(err, libc::EEXIST); + } + } + + #[test] + fn test_rename_noreplace_nonexistent() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new(MockLowerFS, overlay_dir, "hash".into(), "test".into()).unwrap(); + + let dir_attr = ofs.mkdir(1, OsStr::new("d"), 0o755).unwrap(); + ofs.create(dir_attr.ino, OsStr::new("a.txt"), 0o644) + .unwrap(); + + // RENAME_NOREPLACE should succeed when destination doesn't exist + #[cfg(target_os = "linux")] + ofs.rename( + dir_attr.ino, + OsStr::new("a.txt"), + dir_attr.ino, + OsStr::new("new.txt"), + libc::RENAME_NOREPLACE, + ) + .unwrap(); + } + + #[test] + fn test_rename_noreplace_lower_existing() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + // Create an upper file + let lib = ofs.lookup(1, OsStr::new("lib")).unwrap(); + let python = ofs.lookup(lib.ino, OsStr::new("python")).unwrap(); + ofs.create(python.ino, OsStr::new("new.py"), 0o644).unwrap(); + + // RENAME_NOREPLACE to a lower-layer file should fail + #[cfg(target_os = "linux")] + { + let err = ofs + .rename( + python.ino, + OsStr::new("new.py"), + python.ino, + OsStr::new("foo.py"), // exists in lower + libc::RENAME_NOREPLACE, + ) + .unwrap_err(); + assert_eq!(err, libc::EEXIST); + } + } + + #[test] + fn test_rename_chain() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + // Get lower inode for foo.py + let lib = ofs.lookup(1, OsStr::new("lib")).unwrap(); + let python = ofs.lookup(lib.ino, OsStr::new("python")).unwrap(); + let foo = ofs.lookup(python.ino, OsStr::new("foo.py")).unwrap(); + let original_ino = foo.ino; // lower inode 4 + + // Chain: foo.py → temp.py → final.py + ofs.rename( + python.ino, + OsStr::new("foo.py"), + python.ino, + OsStr::new("temp.py"), + 0, + ) + .unwrap(); + ofs.rename( + python.ino, + OsStr::new("temp.py"), + python.ino, + OsStr::new("final.py"), + 0, + ) + .unwrap(); + + // Original lower inode should resolve through promoted → upper → current path + let attr = ofs.getattr(original_ino).unwrap(); + assert_eq!(attr.kind, FileKind::RegularFile); + + let data = ofs.read(original_ino, 0, 1024).unwrap(); + assert_eq!(data, b"foo content"); + + // Only final.py should be findable + assert!(ofs.lookup(python.ino, OsStr::new("final.py")).is_ok()); + assert_eq!( + ofs.lookup(python.ino, OsStr::new("temp.py")).unwrap_err(), + ENOENT + ); + assert_eq!( + ofs.lookup(python.ino, OsStr::new("foo.py")).unwrap_err(), + ENOENT + ); + } + + #[test] + fn test_rename_overwrite_upper() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new(MockLowerFS, overlay_dir, "hash".into(), "test".into()).unwrap(); + + let dir_attr = ofs.mkdir(1, OsStr::new("d"), 0o755).unwrap(); + + // Create source and destination files + let (_, fh_a) = ofs + .create(dir_attr.ino, OsStr::new("a.txt"), 0o644) + .unwrap(); + ofs.write(fh_a, 0, b"source").unwrap(); + ofs.release_write(fh_a); + + let (dst_attr, fh_b) = ofs + .create(dir_attr.ino, OsStr::new("b.txt"), 0o644) + .unwrap(); + ofs.write(fh_b, 0, b"destination").unwrap(); + ofs.release_write(fh_b); + + let src_ino = ofs.lookup(dir_attr.ino, OsStr::new("a.txt")).unwrap().ino; + let _dst_ino = dst_attr.ino; + + // Rename a.txt → b.txt (overwrite) + ofs.rename( + dir_attr.ino, + OsStr::new("a.txt"), + dir_attr.ino, + OsStr::new("b.txt"), + 0, + ) + .unwrap(); + + // Source inode should now have the source content at the new path + let data = ofs.read(src_ino, 0, 1024).unwrap(); + assert_eq!(data, b"source"); + + // lookup b.txt should return source inode + let b_attr = ofs.lookup(dir_attr.ino, OsStr::new("b.txt")).unwrap(); + assert_eq!(b_attr.ino, src_ino); + } + + #[test] + fn test_open_write_promotes_lower_inode() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + // Get lower inode for foo.py + let lib = ofs.lookup(1, OsStr::new("lib")).unwrap(); + let python = ofs.lookup(lib.ino, OsStr::new("python")).unwrap(); + let foo = ofs.lookup(python.ino, OsStr::new("foo.py")).unwrap(); + assert_eq!(foo.ino, 4); // lower inode + + // open_write triggers COW + let fh = ofs.open_write(4).unwrap(); + ofs.write(fh, 0, b"modified content").unwrap(); + ofs.release_write(fh); + + // getattr with lower inode should reflect the upper copy + let attr = ofs.getattr(4).unwrap(); + assert_eq!(attr.size, 16); // "modified content".len() + + // read with lower inode should return modified content + let data = ofs.read(4, 0, 1024).unwrap(); + assert_eq!(data, b"modified content"); + } + + #[test] + fn test_unlink_virtual_lower_file() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + // Lookup bin directory and the virtual entry point + let bin = ofs.lookup(1, OsStr::new("bin")).unwrap(); + let pytest = ofs.lookup(bin.ino, OsStr::new("pytest")).unwrap(); + assert_eq!(pytest.ino, 7); // lower inode + + // Read the virtual file content + let data = ofs.read(pytest.ino, 0, 1024).unwrap(); + assert_eq!(data, b"#!/bin/python\nimport x"); + + // Unlink the virtual file (should create a whiteout) + ofs.unlink(bin.ino, OsStr::new("pytest")).unwrap(); + + // Lookup should now fail + assert_eq!( + ofs.lookup(bin.ino, OsStr::new("pytest")).unwrap_err(), + ENOENT + ); + + // Whiteout should be recorded + let state = ofs.state.lock().unwrap(); + assert!(state.is_whiteout(Path::new("bin/pytest"))); + } + + #[test] + fn test_rename_virtual_lower_file() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + // Lookup bin directory and the virtual entry point + let bin = ofs.lookup(1, OsStr::new("bin")).unwrap(); + let pytest = ofs.lookup(bin.ino, OsStr::new("pytest")).unwrap(); + + // Rename the virtual file within the same directory + ofs.rename( + bin.ino, + OsStr::new("pytest"), + bin.ino, + OsStr::new("pytest.bak"), + 0, + ) + .unwrap(); + + // Original name should be gone (whiteout) + assert_eq!( + ofs.lookup(bin.ino, OsStr::new("pytest")).unwrap_err(), + ENOENT + ); + + // New name should work — getattr with original lower inode via promoted map + let attr = ofs.getattr(pytest.ino).unwrap(); + assert_eq!(attr.kind, FileKind::RegularFile); + + // Read via the promoted inode should return the virtual content + let data = ofs.read(pytest.ino, 0, 1024).unwrap(); + assert_eq!(data, b"#!/bin/python\nimport x"); + + // Lookup new name should succeed + assert!(ofs.lookup(bin.ino, OsStr::new("pytest.bak")).is_ok()); + } + + #[test] + fn test_rename_lower_directory_preserves_children() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir.clone(), + "hash".into(), + "test".into(), + ) + .unwrap(); + + // MockLowerWithFiles: lib(2) → python(3) → {foo.py(4), bar.py(5)} + let lib = ofs.lookup(1, OsStr::new("lib")).unwrap(); + let _python = ofs.lookup(lib.ino, OsStr::new("python")).unwrap(); + + // Rename lib/python → lib/~python (simulates pip's stash rename) + ofs.rename( + lib.ino, + OsStr::new("python"), + lib.ino, + OsStr::new("~python"), + 0, + ) + .unwrap(); + + // The renamed directory should be a real directory, not a 0-byte file + let upper_renamed = overlay_dir.join("lib/~python"); + assert!( + upper_renamed.is_dir(), + "renamed dir should be a directory on disk" + ); + + // Children should be accessible under the new name + let renamed = ofs.lookup(lib.ino, OsStr::new("~python")).unwrap(); + let foo = ofs.lookup(renamed.ino, OsStr::new("foo.py")).unwrap(); + let data = ofs.read(foo.ino, 0, 1024).unwrap(); + assert_eq!(data, b"foo content"); + + let bar = ofs.lookup(renamed.ino, OsStr::new("bar.py")).unwrap(); + let data = ofs.read(bar.ino, 0, 1024).unwrap(); + assert_eq!(data, b"bar content"); + + // Old path should be gone + assert_eq!( + ofs.lookup(lib.ino, OsStr::new("python")).unwrap_err(), + ENOENT + ); + } + + #[test] + fn test_rmdir_lower_dir_with_remaining_children_fails() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + // MockLowerWithFiles has: bin(6) → pytest(7) + // Verify pytest is visible + let bin = ofs.lookup(1, OsStr::new("bin")).unwrap(); + assert_eq!(bin.ino, 6); + let pytest = ofs.lookup(bin.ino, OsStr::new("pytest")).unwrap(); + assert_eq!(pytest.ino, 7); + + // rmdir on a lower-layer directory that still has visible children + // should fail with ENOTEMPTY — just like POSIX requires. + let err = ofs.rmdir(1, OsStr::new("bin")).unwrap_err(); + assert_eq!(err, libc::ENOTEMPTY); + + // bin/pytest should still be accessible + let bin = ofs.lookup(1, OsStr::new("bin")).unwrap(); + let pytest = ofs.lookup(bin.ino, OsStr::new("pytest")).unwrap(); + assert_eq!(pytest.ino, 7); + } + + #[test] + fn test_unlink_lower_dir_with_remaining_children_fails() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + // MockLowerWithFiles has: bin(6) → pytest(7) + // The NFS server routes RMDIR through unlink, so unlink must also + // reject removing a directory with visible lower-layer children. + let err = ofs.unlink(1, OsStr::new("bin")).unwrap_err(); + assert_eq!(err, libc::ENOTEMPTY); + + // bin/pytest should still be accessible + let bin = ofs.lookup(1, OsStr::new("bin")).unwrap(); + let pytest = ofs.lookup(bin.ino, OsStr::new("pytest")).unwrap(); + assert_eq!(pytest.ino, 7); + } + + #[test] + fn test_rmdir_lower_dir_after_all_children_whiteoutd() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + // MockLowerWithFiles has: bin(6) → pytest(7) + // Whiteout the only child first + ofs.unlink(6, OsStr::new("pytest")).unwrap(); + + // Now rmdir should succeed — all children are whiteoutd + ofs.rmdir(1, OsStr::new("bin")).unwrap(); + + // bin should now be invisible + assert_eq!(ofs.lookup(1, OsStr::new("bin")).unwrap_err(), ENOENT); + } + + /// Renaming a directory that has content in BOTH layers should preserve + /// all children at the destination — not just the upper-layer ones. + /// Real-world: Python creates __pycache__/ in upper, then pip renames + /// the whole package directory. + #[test] + fn test_rename_dir_with_both_upper_and_lower_content() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir.clone(), + "hash".into(), + "test".into(), + ) + .unwrap(); + + // MockLowerWithFiles: lib(2) → python(3) → {foo.py(4), bar.py(5)} + let lib = ofs.lookup(1, OsStr::new("lib")).unwrap(); + let python = ofs.lookup(lib.ino, OsStr::new("python")).unwrap(); + + // Simulate Python creating a __pycache__/ dir + .pyc in upper + let pycache = ofs + .mkdir(python.ino, OsStr::new("__pycache__"), 0o755) + .unwrap(); + let (_, fh) = ofs + .create(pycache.ino, OsStr::new("foo.cpython-312.pyc"), 0o644) + .unwrap(); + ofs.write(fh, 0, b"compiled").unwrap(); + ofs.release_write(fh); + + // Now upper has lib/python/__pycache__/foo.cpython-312.pyc + // Lower has lib/python/foo.py and lib/python/bar.py + // Rename lib/python → lib/~python (pip's stash pattern) + ofs.rename( + lib.ino, + OsStr::new("python"), + lib.ino, + OsStr::new("~python"), + 0, + ) + .unwrap(); + + // The renamed directory should have ALL content — both layers + let renamed = ofs.lookup(lib.ino, OsStr::new("~python")).unwrap(); + + // Lower-layer files must be accessible + let foo = ofs.lookup(renamed.ino, OsStr::new("foo.py")).unwrap(); + let data = ofs.read(foo.ino, 0, 1024).unwrap(); + assert_eq!(data, b"foo content"); + + let bar = ofs.lookup(renamed.ino, OsStr::new("bar.py")).unwrap(); + let data = ofs.read(bar.ino, 0, 1024).unwrap(); + assert_eq!(data, b"bar content"); + + // Upper-layer content should also be there + let pc = ofs.lookup(renamed.ino, OsStr::new("__pycache__")).unwrap(); + assert!( + ofs.lookup(pc.ino, OsStr::new("foo.cpython-312.pyc")) + .is_ok() + ); + + // Old path should be gone + assert_eq!( + ofs.lookup(lib.ino, OsStr::new("python")).unwrap_err(), + ENOENT + ); + } + + /// Renaming a file onto a path that exists in the lower layer should + /// prevent the lower file from bleeding through after the upper file + /// is later deleted. + #[test] + fn test_rename_overwrite_lower_then_unlink() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + let lib = ofs.lookup(1, OsStr::new("lib")).unwrap(); + let python = ofs.lookup(lib.ino, OsStr::new("python")).unwrap(); + + // Create a new upper file + let (_, fh) = ofs.create(python.ino, OsStr::new("new.py"), 0o644).unwrap(); + ofs.write(fh, 0, b"new content").unwrap(); + ofs.release_write(fh); + + // Rename new.py → foo.py, overwriting the lower-layer foo.py + ofs.rename( + python.ino, + OsStr::new("new.py"), + python.ino, + OsStr::new("foo.py"), + 0, + ) + .unwrap(); + + // foo.py should have the new content + let foo = ofs.lookup(python.ino, OsStr::new("foo.py")).unwrap(); + let data = ofs.read(foo.ino, 0, 1024).unwrap(); + assert_eq!(data, b"new content"); + + // Now unlink foo.py — the lower-layer original should NOT bleed through + ofs.unlink(python.ino, OsStr::new("foo.py")).unwrap(); + assert_eq!( + ofs.lookup(python.ino, OsStr::new("foo.py")).unwrap_err(), + ENOENT + ); + } + + /// setattr on a lower-layer file should COW it to upper before modifying. + #[test] + fn test_setattr_cow_lower_file() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir.clone(), + "hash".into(), + "test".into(), + ) + .unwrap(); + + let lib = ofs.lookup(1, OsStr::new("lib")).unwrap(); + let python = ofs.lookup(lib.ino, OsStr::new("python")).unwrap(); + let foo = ofs.lookup(python.ino, OsStr::new("foo.py")).unwrap(); + assert_eq!(foo.ino, 4); // lower inode + + // Truncate the file — should COW to upper + let attr = ofs.setattr(foo.ino, Some(5), None).unwrap(); + assert_eq!(attr.size, 5); + + // The COW'd file should exist in upper + assert!(overlay_dir.join("lib/python/foo.py").exists()); + + // Read should return truncated content + let data = ofs.read(foo.ino, 0, 1024).unwrap(); + assert_eq!(data, b"foo c"); + } + + /// setattr with no meaningful changes should not trigger COW. + #[test] + fn test_setattr_noop_no_cow() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir.clone(), + "hash".into(), + "test".into(), + ) + .unwrap(); + + let lib = ofs.lookup(1, OsStr::new("lib")).unwrap(); + let python = ofs.lookup(lib.ino, OsStr::new("python")).unwrap(); + let _foo = ofs.lookup(python.ino, OsStr::new("foo.py")).unwrap(); + + // setattr with no size/mode should be a no-op + ofs.setattr(4, None, None).unwrap(); + + // File should NOT have been COW'd to upper + assert!(!overlay_dir.join("lib/python/foo.py").exists()); + } + + /// Rename a file across different parent directories. + #[test] + fn test_rename_across_directories() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + let lib = ofs.lookup(1, OsStr::new("lib")).unwrap(); + let python = ofs.lookup(lib.ino, OsStr::new("python")).unwrap(); + let bin = ofs.lookup(1, OsStr::new("bin")).unwrap(); + + // Move foo.py from lib/python/ to bin/ + ofs.rename( + python.ino, + OsStr::new("foo.py"), + bin.ino, + OsStr::new("foo.py"), + 0, + ) + .unwrap(); + + // Should be gone from python/ + assert_eq!( + ofs.lookup(python.ino, OsStr::new("foo.py")).unwrap_err(), + ENOENT + ); + + // Should exist in bin/ + let foo = ofs.lookup(bin.ino, OsStr::new("foo.py")).unwrap(); + let data = ofs.read(foo.ino, 0, 1024).unwrap(); + assert_eq!(data, b"foo content"); + } + + /// readdir should reflect mutations (create, unlink, rename). + #[test] + fn test_readdir_reflects_mutations() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + let lib = ofs.lookup(1, OsStr::new("lib")).unwrap(); + let python = ofs.lookup(lib.ino, OsStr::new("python")).unwrap(); + + let get_names = |ino: u64| -> Vec { + ofs.readdir(ino, 0) + .unwrap() + .iter() + .filter(|e| e.name != "." && e.name != "..") + .map(|e| e.name.to_str().unwrap().to_string()) + .collect() + }; + + // Initial state: foo.py, bar.py + let names = get_names(python.ino); + assert!(names.contains(&"foo.py".to_string())); + assert!(names.contains(&"bar.py".to_string())); + assert_eq!(names.len(), 2); + + // After unlink foo.py + ofs.unlink(python.ino, OsStr::new("foo.py")).unwrap(); + let names = get_names(python.ino); + assert!(!names.contains(&"foo.py".to_string())); + assert!(names.contains(&"bar.py".to_string())); + + // After create new.py + let (_, fh) = ofs.create(python.ino, OsStr::new("new.py"), 0o644).unwrap(); + ofs.release_write(fh); + let names = get_names(python.ino); + assert!(names.contains(&"new.py".to_string())); + assert!(names.contains(&"bar.py".to_string())); + assert!(!names.contains(&"foo.py".to_string())); + + // After rename bar.py → moved.py + ofs.rename( + python.ino, + OsStr::new("bar.py"), + python.ino, + OsStr::new("moved.py"), + 0, + ) + .unwrap(); + let names = get_names(python.ino); + assert!(names.contains(&"new.py".to_string())); + assert!(names.contains(&"moved.py".to_string())); + assert!(!names.contains(&"bar.py".to_string())); + assert!(!names.contains(&"foo.py".to_string())); + } + + #[test] + fn test_uninstall_reinstall_uninstall_cycle() { + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + let ofs = OverlayFS::new( + MockLowerWithFiles, + overlay_dir, + "hash".into(), + "test".into(), + ) + .unwrap(); + + // MockLowerWithFiles: lib(2) → python(3) → {foo.py(4), bar.py(5)} + let lib = ofs.lookup(1, OsStr::new("lib")).unwrap(); + let python = ofs.lookup(lib.ino, OsStr::new("python")).unwrap(); + + // -- Step 1: "pip uninstall" the old version -- + // Verify originals are visible + let foo = ofs.lookup(python.ino, OsStr::new("foo.py")).unwrap(); + assert_eq!(ofs.read(foo.ino, 0, 1024).unwrap(), b"foo content"); + + // Remove them (creates whiteouts) + ofs.unlink(python.ino, OsStr::new("foo.py")).unwrap(); + ofs.unlink(python.ino, OsStr::new("bar.py")).unwrap(); + + assert_eq!( + ofs.lookup(python.ino, OsStr::new("foo.py")).unwrap_err(), + ENOENT + ); + assert_eq!( + ofs.lookup(python.ino, OsStr::new("bar.py")).unwrap_err(), + ENOENT + ); + + // -- Step 2: "pip install" a new version at the same paths -- + let (new_foo_attr, fh) = ofs.create(python.ino, OsStr::new("foo.py"), 0o644).unwrap(); + ofs.write(fh, 0, b"NEW foo content v2").unwrap(); + ofs.release_write(fh); + + let (_, fh) = ofs.create(python.ino, OsStr::new("bar.py"), 0o644).unwrap(); + ofs.write(fh, 0, b"NEW bar content v2").unwrap(); + ofs.release_write(fh); + + // -- Step 3: Verify the new version is what we see -- + let new_foo = ofs.lookup(python.ino, OsStr::new("foo.py")).unwrap(); + assert_eq!(new_foo.ino, new_foo_attr.ino); // upper inode, not lower + let data = ofs.read(new_foo.ino, 0, 1024).unwrap(); + assert_eq!(data, b"NEW foo content v2"); + + // readdir should show the new files, not the old ones + let entries = ofs.readdir(python.ino, 0).unwrap(); + let names: Vec<_> = entries + .iter() + .filter(|e| e.name != "." && e.name != "..") + .map(|e| e.name.to_str().unwrap().to_string()) + .collect(); + assert!(names.contains(&"foo.py".to_string())); + assert!(names.contains(&"bar.py".to_string())); + + // -- Step 4: "pip uninstall" the new version -- + ofs.unlink(python.ino, OsStr::new("foo.py")).unwrap(); + ofs.unlink(python.ino, OsStr::new("bar.py")).unwrap(); + + // -- Step 5: Verify lower-layer originals do NOT bleed through -- + assert_eq!( + ofs.lookup(python.ino, OsStr::new("foo.py")).unwrap_err(), + ENOENT + ); + assert_eq!( + ofs.lookup(python.ino, OsStr::new("bar.py")).unwrap_err(), + ENOENT + ); + + // readdir should be empty (whiteouts still active) + let entries = ofs.readdir(python.ino, 0).unwrap(); + let names: Vec<_> = entries + .iter() + .filter(|e| e.name != "." && e.name != "..") + .collect(); + assert!(names.is_empty(), "lower layer bled through: {names:?}"); + + // Whiteouts should be persisted + let state = ofs.state.lock().unwrap(); + assert!(state.is_whiteout(Path::new("lib/python/foo.py"))); + assert!(state.is_whiteout(Path::new("lib/python/bar.py"))); + } + + // --- B1/B2 regression tests: state validation is decoupled from VFS consumption --- + + #[test] + fn test_load_then_wrap_recovers_from_env_hash_mismatch() { + // Verifies the wipe-and-reload mechanism works at the OverlayState + // level. create_overlay uses this for VersionMismatch (internal schema + // changes). EnvHashMismatch returns a structured error to the caller + // instead of auto-wiping, since the overlay may contain user work. + use crate::overlay::{OverlayError, OverlayState}; + + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + + // Mount with hash A, then drop. + { + let _ofs = OverlayFS::new( + MockLowerFS, + overlay_dir.clone(), + "hash_a".into(), + "test".into(), + ) + .unwrap(); + } + + // Try to load with hash B — should fail with EnvHashMismatch. + let err = + OverlayState::load(overlay_dir.clone(), "hash_b".into(), "test".into()).unwrap_err(); + assert!(matches!(err, OverlayError::EnvHashMismatch { .. })); + + // Wipe and reload — this is the create_overlay recovery path. + std::fs::remove_dir_all(&overlay_dir).unwrap(); + let state = + OverlayState::load(overlay_dir.clone(), "hash_b".into(), "test".into()).unwrap(); + + // wrap() consumes a fresh lower VFS — no double-construction needed. + let ofs = OverlayFS::wrap(MockLowerFS, state).unwrap(); + assert!(!ofs.state.lock().unwrap().is_whiteout(Path::new("any"))); + } + + #[test] + fn test_load_refuses_transport_mismatch() { + // B2: TransportMismatch is a distinct typed error. The create_overlay + // flow refuses to wipe on this variant — only EnvHashMismatch and + // VersionMismatch trigger the wipe-and-retry recovery path. + use crate::overlay::{OverlayError, OverlayState}; + + let tmp = TempDir::new().unwrap(); + let overlay_dir = tmp.path().join("upper"); + + // Mount with transport "fuse". + { + let _ofs = OverlayFS::new( + MockLowerFS, + overlay_dir.clone(), + "hash".into(), + "fuse".into(), + ) + .unwrap(); + } + + // Try to load as "nfs" — should refuse with TransportMismatch. + let err = OverlayState::load(overlay_dir.clone(), "hash".into(), "nfs".into()).unwrap_err(); + assert!(matches!(err, OverlayError::TransportMismatch { .. })); + + // The overlay directory must still exist — no silent wipe. + assert!(overlay_dir.exists()); + assert!(overlay_dir.join(".rattler_vfs_state.json").exists()); + } +} diff --git a/crates/rattler_vfs/src/overlay_fs/inode.rs b/crates/rattler_vfs/src/overlay_fs/inode.rs new file mode 100644 index 0000000000..96808362d4 --- /dev/null +++ b/crates/rattler_vfs/src/overlay_fs/inode.rs @@ -0,0 +1,82 @@ +//! Inode bookkeeping for the writable overlay. +//! +//! The lower layer (read-only VFS) owns inodes `1..UPPER_INODE_BASE`. Upper +//! layer (overlay) entries get inodes `UPPER_INODE_BASE..u64::MAX` assigned +//! lazily by [`UpperInodeMap`]. [`ResolvedIno`] wraps the result of "given +//! this kernel-visible inode, which layer is it actually in right now?", +//! since rename and copy-on-write can promote a lower inode into the upper +//! layer dynamically. +//! +//! Extracted from `overlay_fs.rs` to keep the inode allocator and the COW / +//! `VfsOps` machinery in separate modules. Pure data + bookkeeping; no I/O. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +/// Inodes at or above this value belong to the upper (overlay) layer. +/// Anything below is owned by the lower read-only VFS. +pub(crate) const UPPER_INODE_BASE: u64 = u64::MAX / 2; + +/// Bidirectional inode ↔ path mapping for upper-layer entries. +pub(crate) struct UpperInodeMap { + path_to_ino: HashMap, + ino_to_path: HashMap, + next_ino: AtomicU64, +} + +impl UpperInodeMap { + pub(crate) fn new() -> Self { + Self { + path_to_ino: HashMap::new(), + ino_to_path: HashMap::new(), + next_ino: AtomicU64::new(UPPER_INODE_BASE), + } + } + + pub(crate) fn get_or_assign(&mut self, virtual_path: PathBuf) -> u64 { + if let Some(&ino) = self.path_to_ino.get(&virtual_path) { + return ino; + } + let ino = self.next_ino.fetch_add(1, Ordering::Relaxed); + self.ino_to_path.insert(ino, virtual_path.clone()); + self.path_to_ino.insert(virtual_path, ino); + ino + } + + pub(crate) fn path_for(&self, ino: u64) -> Option<&PathBuf> { + self.ino_to_path.get(&ino) + } + + /// Reverse lookup. Used by tests; production code asks the upper map + /// via `path_for` after promotion. + #[cfg(test)] + pub(crate) fn ino_for_path(&self, virtual_path: &Path) -> Option { + self.path_to_ino.get(virtual_path).copied() + } + + /// Remap an existing inode from `old_path` to `new_path`. + /// The kernel expects the source inode to remain valid after rename, + /// just pointing at the new path. + pub(crate) fn rename_path(&mut self, old_path: &Path, new_path: PathBuf) { + // Clean up any existing inode at the destination (overwrite case) + if let Some(old_dst_ino) = self.path_to_ino.remove(&new_path) { + self.ino_to_path.remove(&old_dst_ino); + } + // Remap source inode to destination path + if let Some(ino) = self.path_to_ino.remove(old_path) { + self.ino_to_path.insert(ino, new_path.clone()); + self.path_to_ino.insert(new_path, ino); + } + } +} + +/// Result of resolving a kernel-visible inode to its current backing layer. +pub(crate) enum ResolvedIno { + /// File is in the upper (overlay) layer at this virtual path. + Upper(PathBuf), + /// File is in the lower (read-only) layer with this inode and virtual path. + Lower(u64, PathBuf), +} diff --git a/crates/rattler_vfs/src/prefix_replacement.rs b/crates/rattler_vfs/src/prefix_replacement.rs new file mode 100644 index 0000000000..0743818168 --- /dev/null +++ b/crates/rattler_vfs/src/prefix_replacement.rs @@ -0,0 +1,809 @@ +//! Ranged prefix replacement for FUSE/NFS reads. +//! +//! Serves a byte range `[start, end)` from a source file with prefix +//! placeholder replacements applied on the fly, without materializing the +//! entire transformed file in memory. + +use memchr::memmem; +use rattler::install::link::replace_shebang_region; +use rattler_conda_types::Platform; + +/// A precomputed, CEP-conformant text replacement plan for a single file. +/// +/// Mirrors how `rattler::install::link` patches a text file: the shebang region +/// (the first line of a file starting with `#!`) is transformed by the +/// installer's shebang rules, and every remaining placeholder occurrence is +/// recorded as a body offset. Keeping this in one place guarantees mount-time +/// reads stay byte-identical to an install. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextPlan { + /// Absolute source offsets of placeholder occurrences after the shebang + /// region (each spliced as a plain replacement). + pub body_offsets: Vec, + /// Length of the shebang region in the source, i.e. the boundary after + /// which `body_offsets` apply. `0` when the file has no shebang. + pub region_end: usize, + /// The already-transformed shebang region bytes (empty when no shebang). + pub transformed_region: Vec, +} + +/// Build the CEP-conformant text replacement plan for `source`. +/// +/// If the file starts with `#!`, the shebang region (up to and including the +/// first newline, or the whole file when there is none) is transformed via the +/// shared [`replace_shebang_region`] helper — the same code the installer uses, +/// so an over-long line collapses to `#!/usr/bin/env ` on Unix targets +/// exactly as it would on install. Occurrences inside the region are excluded +/// from `body_offsets`, matching the CEP. +pub fn plan_text_replacement( + source: &[u8], + placeholder: &str, + target: &str, + platform: &Platform, +) -> TextPlan { + let region_end = if source.starts_with(b"#!") { + source + .iter() + .position(|&c| c == b'\n') + .map_or(source.len(), |i| i + 1) + } else { + 0 + }; + + let transformed_region = if region_end > 0 { + replace_shebang_region(&source[..region_end], placeholder, target, platform) + } else { + Vec::new() + }; + + let body_offsets = memmem::find_iter(source, placeholder.as_bytes()) + .filter(|&o| o >= region_end) + .collect(); + + TextPlan { + body_offsets, + region_end, + transformed_region, + } +} + +impl TextPlan { + /// Build a plan from the metadata recorded in `paths.json` instead of + /// scanning the file: `body_offsets` come straight from the recorded + /// offsets, and `region` must hold the file's shebang region (its first + /// `shebang_length` bytes) or be empty when no shebang is recorded. Only + /// that region is ever read from disk — recorded offsets exist precisely + /// so consumers don't have to scan file contents. + /// + /// The offsets are trusted as-is: per the CEP they are the producer's + /// contract, and the ranged reads are total functions, so a + /// non-conformant producer yields wrong bytes for its own package rather + /// than a panic. The one sanity check is that a non-empty region starts + /// with `#!` — the shared shebang transform requires it — and `None` is + /// returned otherwise so the caller can fall back to + /// [`plan_text_replacement`]. + pub fn from_recorded( + region: &[u8], + body_offsets: Vec, + placeholder: &str, + target: &str, + platform: &Platform, + ) -> Option { + let transformed_region = if region.is_empty() { + Vec::new() + } else if region.starts_with(b"#!") { + replace_shebang_region(region, placeholder, target, platform) + } else { + return None; + }; + Some(TextPlan { + body_offsets, + region_end: region.len(), + transformed_region, + }) + } +} + +/// Read a range from a text file with prefix replacements applied. +/// +/// The transformed output is the already-transformed shebang region (see +/// [`plan_text_replacement`]) followed by the body — `source[region_end..]` +/// with `old_prefix` → `new_prefix` spliced at each `body_offsets` position. +/// Because replacement can change length, output positions shift relative to +/// source positions. +/// +/// Returns the bytes in the output range `[start, end)`. +#[allow(clippy::too_many_arguments)] +pub fn text_ranged_read( + source: &[u8], + old_prefix: &[u8], + new_prefix: &[u8], + body_offsets: &[usize], + region_end: usize, + transformed_region: &[u8], + start: usize, + end: usize, +) -> Vec { + let delta = new_prefix.len() as isize - old_prefix.len() as isize; + let body_len = source.len().saturating_sub(region_end); + let transformed_len = (transformed_region.len() as isize + + body_len as isize + + delta * body_offsets.len() as isize) + .max(0) as usize; + + let actual_end = end.min(transformed_len); + let actual_start = start.min(transformed_len); + if actual_start >= actual_end { + return vec![]; + } + + let capacity = actual_end - actual_start; + let mut buffer = Vec::with_capacity(capacity); + let mut out_pos = 0usize; + + // 1. The already-transformed shebang region. + emit_bytes( + transformed_region, + &mut out_pos, + actual_start, + actual_end, + &mut buffer, + ); + + // 2. The body: walk `source[region_end..]`, splicing at each body offset. + let mut src_pos = region_end; + let mut offset_idx = 0usize; + while src_pos < source.len() && buffer.len() < capacity { + if offset_idx < body_offsets.len() && src_pos == body_offsets[offset_idx] { + // At a replacement site: emit new_prefix bytes + emit_bytes( + new_prefix, + &mut out_pos, + actual_start, + actual_end, + &mut buffer, + ); + // Skip the old prefix in the source + src_pos += old_prefix.len(); + offset_idx += 1; + } else { + // Regular byte: copy through + if out_pos >= actual_start && out_pos < actual_end { + buffer.push(source[src_pos]); + } + out_pos += 1; + src_pos += 1; + } + } + + buffer +} + +/// Read a range from a binary file with prefix replacements applied. +/// +/// Binary-mode replacement swaps `old_prefix` → `new_prefix` and pads with +/// null bytes to maintain the same total length. Offsets are grouped by +/// c-string: each inner slice lists prefix start positions followed by the +/// NUL terminator position. +/// +/// Output length always equals source length. +/// +/// Returns the bytes in the output range `[start, end)`. +pub fn binary_ranged_read( + source: &[u8], + old_prefix: &[u8], + new_prefix: &[u8], + groups: &[Vec], + start: usize, + end: usize, +) -> Vec { + assert!( + new_prefix.len() <= old_prefix.len(), + "new prefix cannot be longer than old prefix in binary mode" + ); + + let actual_end = end.min(source.len()); + let actual_start = start.min(source.len()); + if actual_start >= actual_end { + return vec![]; + } + + let length_change = old_prefix.len() - new_prefix.len(); + let capacity = actual_end - actual_start; + let mut buffer = Vec::with_capacity(capacity); + + // Build a virtual stream of the fully-replaced file, emitting only + // the bytes that fall within [actual_start, actual_end). + let mut out_pos: usize = 0; // current position in the output stream + let mut src_pos: usize = 0; // current position in the source + + for group in groups { + let (prefix_offsets, nul_slice) = group.split_at(group.len() - 1); + let nul_pos = nul_slice[0]; + + for &offset in prefix_offsets { + // Emit source bytes from src_pos to this prefix + emit_range( + source, + src_pos, + offset, + &mut out_pos, + actual_start, + actual_end, + &mut buffer, + ); + src_pos = offset; + + // Emit new prefix + emit_bytes( + new_prefix, + &mut out_pos, + actual_start, + actual_end, + &mut buffer, + ); + src_pos += old_prefix.len(); + } + + // Emit source bytes from last prefix end to NUL position + emit_range( + source, + src_pos, + nul_pos, + &mut out_pos, + actual_start, + actual_end, + &mut buffer, + ); + src_pos = nul_pos; + + // Emit padding zeros + let padding = prefix_offsets.len() * length_change; + emit_zeros(padding, &mut out_pos, actual_start, actual_end, &mut buffer); + + if buffer.len() >= capacity { + break; + } + } + + // Emit remaining source bytes after the last group + if src_pos < source.len() && buffer.len() < capacity { + emit_range( + source, + src_pos, + source.len(), + &mut out_pos, + actual_start, + actual_end, + &mut buffer, + ); + } + + buffer +} + +/// Emit bytes from `source[src_start..src_end]` into buffer, but only those +/// that fall within the output window `[win_start, win_end)`. +#[inline] +fn emit_range( + source: &[u8], + src_start: usize, + src_end: usize, + out_pos: &mut usize, + win_start: usize, + win_end: usize, + buffer: &mut Vec, +) { + for &b in &source[src_start..src_end] { + if *out_pos >= win_start && *out_pos < win_end { + buffer.push(b); + } + *out_pos += 1; + if *out_pos >= win_end { + return; + } + } +} + +/// Emit a slice of bytes into buffer within the output window. +#[inline] +fn emit_bytes( + data: &[u8], + out_pos: &mut usize, + win_start: usize, + win_end: usize, + buffer: &mut Vec, +) { + for &b in data { + if *out_pos >= win_start && *out_pos < win_end { + buffer.push(b); + } + *out_pos += 1; + if *out_pos >= win_end { + return; + } + } +} + +/// Emit `count` zero bytes into buffer within the output window. +#[inline] +fn emit_zeros( + count: usize, + out_pos: &mut usize, + win_start: usize, + win_end: usize, + buffer: &mut Vec, +) { + for _ in 0..count { + if *out_pos >= win_start && *out_pos < win_end { + buffer.push(0); + } + *out_pos += 1; + if *out_pos >= win_end { + return; + } + } +} + +/// Compute text-mode replacement offsets by scanning the source for the placeholder. +/// Used when paths.json doesn't provide offsets (legacy v1 format). +pub fn collect_offsets(source: &[u8], placeholder: &[u8]) -> Vec { + memmem::find_iter(source, placeholder).collect() +} + +/// Compute binary-mode replacement offsets grouped by c-string. +/// Each inner Vec lists the prefix start positions followed by the NUL +/// terminator position: `[prefix1, prefix2, nul_pos]`. +/// Used when paths.json doesn't provide offsets (legacy v1 format). +pub fn collect_binary_offsets(source: &[u8], placeholder: &[u8]) -> Vec> { + let flat: Vec = memmem::find_iter(source, placeholder).collect(); + let mut groups: Vec> = Vec::new(); + let mut current_group: Vec = Vec::new(); + + for offset in flat { + // Check if there's a NUL between the previous offset's end and this one + if let Some(&prev) = current_group.last() { + let search_start = prev + placeholder.len(); + if let Some(nul_pos) = memchr::memchr(b'\0', &source[search_start..offset]) { + // NUL found — close previous group + current_group.push(search_start + nul_pos); + groups.push(std::mem::take(&mut current_group)); + } + } + current_group.push(offset); + } + + // Close the last group — find the NUL after the last prefix + if !current_group.is_empty() { + let last_end = current_group.last().unwrap() + placeholder.len(); + let nul_pos = + memchr::memchr(b'\0', &source[last_end..]).map_or(source.len(), |p| last_end + p); + current_group.push(nul_pos); + groups.push(current_group); + } + + groups +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Text mode tests ────────────────────────────────────────────── + + fn text_test( + placeholder: &[u8], + prefix: &[u8], + source: &[u8], + expected: &[u8], + start: usize, + end: usize, + ) { + // These cases have no shebang, so every occurrence is a body offset and + // the transformed region is empty. + let offsets = collect_offsets(source, placeholder); + let result = text_ranged_read(source, placeholder, prefix, &offsets, 0, b"", start, end); + assert_eq!( + result, expected, + "text replacement [{start}..{end}] of {source:?}: expected {expected:?}, got {result:?}" + ); + } + + fn binary_test( + placeholder: &[u8], + prefix: &[u8], + source: &[u8], + expected: &[u8], + start: usize, + end: usize, + ) { + let groups = collect_binary_offsets(source, placeholder); + let result = binary_ranged_read(source, placeholder, prefix, &groups, start, end); + assert_eq!( + result, expected, + "binary replacement [{start}..{end}] of {source:?}: expected {expected:?}, got {result:?}" + ); + } + + // Full-file text replacements + + #[test] + fn text_full_file() { + text_test( + b"ABCD", + b"XY", + b"01ABCD23456ABCD7890", + b"01XY23456XY7890", + 0, + 19, + ); + } + + #[test] + fn text_only_placeholder() { + text_test(b"ABCD", b"XY", b"ABCD", b"XY", 0, 4); + } + + #[test] + fn text_consecutive_placeholders() { + text_test(b"ABCD", b"XY", b"ABCDABCD", b"XYXY", 0, 8); + } + + #[test] + fn text_no_placeholders() { + text_test(b"ABCD", b"XY", b"0123456789", b"0123456789", 0, 10); + } + + #[test] + fn text_same_length() { + text_test( + b"ABCD", + b"WXYZ", + b"01ABCD6789012ABCD7890", + b"01WXYZ6789012WXYZ7890", + 0, + 21, + ); + } + + #[test] + fn text_empty_file() { + text_test(b"ABCD", b"XY", b"", b"", 0, 0); + } + + #[test] + fn text_many_placeholders() { + let mut source = Vec::new(); + let mut expected = Vec::new(); + for i in 0..10u8 { + source.extend_from_slice(&[i + b'0', i + b'0']); + source.extend_from_slice(b"ABCD"); + expected.extend_from_slice(&[i + b'0', i + b'0']); + expected.extend_from_slice(b"XY"); + } + text_test(b"ABCD", b"XY", &source, &expected, 0, source.len()); + } + + // Partial-range text replacements + + #[test] + fn text_partial_range() { + text_test( + b"ABCD", + b"XY", + b"ABCD0ABCD5ABCD0ABCD5ABCD", + b"0XY5XY0", + 2, + 9, + ); + } + + #[test] + fn text_start_after_prefix() { + // Output: "XY01234XY56789" (14 chars) — start at index 5 = "34XY56789" + text_test(b"ABCD", b"XY", b"ABCD01234ABCD56789", b"34XY56789", 5, 18); + } + + #[test] + fn text_start_between_placeholders() { + // Output: "XY0123XY5678XY" — start at index 5 = "3XY5678XY" + text_test(b"ABCD", b"XY", b"ABCD0123ABCD5678ABCD", b"3XY5678XY", 5, 20); + } + + #[test] + fn text_start_at_placeholder() { + // Output: "01234XY6789XY" — start at index 5 = "XY6789XY" + text_test(b"ABCD", b"XY", b"01234ABCD6789ABCD", b"XY6789XY", 5, 17); + } + + #[test] + fn text_longer_placeholder() { + text_test( + b"ABCDEFGH", + b"XYZ", + b"01ABCDEFGH234ABCDEFGH567", + b"01XYZ234XYZ567", + 0, + 24, + ); + } + + // ── Binary mode tests ──────────────────────────────────────────── + + #[test] + fn binary_full_file() { + binary_test( + b"ABCD", + b"XY", + b"01ABCD23\x00456ABCD78\x0090", + b"01XY23\x00\x00\x00456XY78\x00\x00\x0090", + 0, + 21, + ); + } + + #[test] + fn binary_only_placeholder() { + binary_test(b"ABCD", b"XY", b"ABCD", b"XY\x00\x00", 0, 4); + } + + #[test] + fn binary_no_placeholders() { + binary_test(b"ABCD", b"XY", b"0123456789", b"0123456789", 0, 10); + } + + #[test] + fn binary_same_length() { + binary_test( + b"ABCD", + b"WXYZ", + b"01ABCD6789012ABCD7890", + b"01WXYZ6789012WXYZ7890", + 0, + 21, + ); + } + + #[test] + fn binary_consecutive_placeholders() { + binary_test(b"ABCD", b"XY", b"ABCDABCD", b"XYXY\x00\x00\x00\x00", 0, 8); + } + + #[test] + fn binary_multiple_placeholders() { + let source = b"\x00\x00ABCDZ\x00\x00\x00ABCDEFABCDEF\x00\x00\x00ABCDMNOPQRSABCDMNOPQRSABCDMNOPQRS\x00\x00"; + let expected = b"\x00\x00XYZ\x00\x00\x00\x00\x00XYEFXYEF\x00\x00\x00\x00\x00\x00\x00XYMNOPQRSXYMNOPQRSXYMNOPQRS\x00\x00\x00\x00\x00\x00\x00\x00"; + binary_test(b"ABCD", b"XY", source, expected, 0, source.len()); + } + + #[test] + fn binary_empty_file() { + binary_test(b"ABCD", b"XY", b"", b"", 0, 0); + } + + #[test] + fn binary_many_placeholders() { + let mut source = Vec::new(); + let mut expected = Vec::new(); + for i in 0..10u8 { + source.extend_from_slice(&[i + b'0', i + b'0']); + source.extend_from_slice(b"ABCD\x00"); + expected.extend_from_slice(&[i + b'0', i + b'0']); + expected.extend_from_slice(b"XY\x00\x00\x00"); + } + binary_test(b"ABCD", b"XY", &source, &expected, 0, source.len()); + } + + // Partial-range binary replacements + + #[test] + fn binary_partial_range() { + binary_test( + b"ABCD", + b"XY", + b"ABCD\x000ABCD\x005ABCD\x000ABCD\x005ABCD\x00", + b"0XY\x00\x00", + 5, + 10, + ); + } + + #[test] + fn binary_start_after_prefix() { + binary_test( + b"ABCD", + b"XY", + b"ABCD01234ABCD\x0056789", + b"34XY\x00\x00\x00\x00\x0056789", + 5, + 19, + ); + } + + #[test] + fn binary_start_between_placeholders() { + binary_test( + b"ABCD", + b"XY", + b"ABCD012\x003ABCD5678ABCD", + b"012\x00\x00\x003XY5678XY\x00\x00\x00\x00", + 2, + 21, + ); + } + + #[test] + fn binary_start_at_placeholder() { + binary_test( + b"ABCD", + b"XY", + b"01234ABCD\x006789ABCD\x00", + b"XY\x00\x00\x006789XY\x00\x00\x00", + 5, + 19, + ); + } + + #[test] + fn binary_longer_placeholder() { + binary_test( + b"ABCDEFGH", + b"XYZ", + b"01ABCDEFGH234ABCDEFGH567", + b"01XYZ234XYZ567\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + 0, + 24, + ); + } + + // ── Offset collection ──────────────────────────────────────────── + + #[test] + fn collect_offsets_basic() { + assert_eq!(collect_offsets(b"01ABCD56ABCD", b"ABCD"), vec![2, 8]); + } + + #[test] + fn collect_offsets_none() { + assert_eq!(collect_offsets(b"0123456789", b"ABCD"), Vec::::new()); + } + + #[test] + fn collect_offsets_consecutive() { + assert_eq!(collect_offsets(b"ABCDABCD", b"ABCD"), vec![0, 4]); + } + + #[test] + fn collect_binary_offsets_single() { + // One prefix in one c-string + assert_eq!( + collect_binary_offsets(b"hello/PFX/bin\x00tail", b"/PFX"), + vec![vec![5, 13]] + ); + } + + #[test] + fn collect_binary_offsets_multi_in_one_cstring() { + // Two prefixes sharing one c-string (PATH-style) + assert_eq!( + collect_binary_offsets(b"PATH=/PFX/a:/PFX/b\x00tail", b"/PFX"), + vec![vec![5, 12, 18]] + ); + } + + #[test] + fn collect_binary_offsets_separate_cstrings() { + // Two prefixes in separate c-strings + assert_eq!( + collect_binary_offsets(b"/PFX/a\x00/PFX/b\x00", b"/PFX"), + vec![vec![0, 6], vec![7, 13]] + ); + } + + // ── Shebang-aware text plans ───────────────────────────────────── + + use rattler_conda_types::Platform; + + #[test] + fn plan_no_shebang() { + let plan = plan_text_replacement(b"hello /PFX world", "/PFX", "/new", &Platform::Linux64); + assert_eq!(plan.region_end, 0); + assert!(plan.transformed_region.is_empty()); + assert_eq!(plan.body_offsets, vec![6]); + } + + #[test] + fn plan_shebang_excludes_region_occurrence() { + // "#!/PFX/python\n" is 14 bytes; the region occurrence at offset 2 is + // excluded, the body occurrence is kept, and the region is rewritten. + let src = b"#!/PFX/python\nimport x # /PFX/lib\n"; + let plan = plan_text_replacement(src, "/PFX", "/new", &Platform::Linux64); + assert_eq!(plan.region_end, 14); + assert_eq!(plan.body_offsets, vec![26]); + assert_eq!(plan.transformed_region, b"#!/new/python\n"); + } + + #[test] + fn shebang_full_read_matches() { + let src = b"#!/PFX/python\nimport x # /PFX/lib\n"; + let plan = plan_text_replacement(src, "/PFX", "/new", &Platform::Linux64); + let out = text_ranged_read( + src, + b"/PFX", + b"/new", + &plan.body_offsets, + plan.region_end, + &plan.transformed_region, + 0, + 1000, + ); + assert_eq!(out, b"#!/new/python\nimport x # /new/lib\n"); + } + + #[test] + fn shebang_ranged_reads_cross_region_boundary() { + let src = b"#!/PFX/python\nimport x # /PFX/lib\n"; + let full: &[u8] = b"#!/new/python\nimport x # /new/lib\n"; + let plan = plan_text_replacement(src, "/PFX", "/new", &Platform::Linux64); + for (s, e) in [(0usize, 5), (10, 20), (13, 15), (0, full.len()), (30, 100)] { + let out = text_ranged_read( + src, + b"/PFX", + b"/new", + &plan.body_offsets, + plan.region_end, + &plan.transformed_region, + s, + e, + ); + let exp = &full[s.min(full.len())..e.min(full.len())]; + assert_eq!(out, exp, "range [{s}, {e})"); + } + } + + #[test] + fn plan_shebang_no_trailing_newline() { + // Whole file is the shebang line; region covers everything, no body. + let src = b"#!/PFX/python"; + let plan = plan_text_replacement(src, "/PFX", "/new", &Platform::Linux64); + assert_eq!(plan.region_end, src.len()); + assert!(plan.body_offsets.is_empty()); + assert_eq!(plan.transformed_region, b"#!/new/python"); + } + + // ── Plans built from recorded paths.json metadata ──────────────── + + #[test] + fn from_recorded_matches_scan() { + let src = b"#!/PFX/python\nimport x # /PFX/lib\n"; + let scanned = plan_text_replacement(src, "/PFX", "/new", &Platform::Linux64); + let recorded = TextPlan::from_recorded( + &src[..scanned.region_end], + scanned.body_offsets.clone(), + "/PFX", + "/new", + &Platform::Linux64, + ) + .unwrap(); + assert_eq!(recorded, scanned); + } + + #[test] + fn from_recorded_no_shebang() { + let plan = + TextPlan::from_recorded(b"", vec![6], "/PFX", "/new", &Platform::Linux64).unwrap(); + assert_eq!(plan.region_end, 0); + assert!(plan.transformed_region.is_empty()); + assert_eq!(plan.body_offsets, vec![6]); + } + + #[test] + fn from_recorded_rejects_non_shebang_region() { + // Recorded shebang_length but the file doesn't start with `#!`: + // the caller must fall back to scanning. + assert!( + TextPlan::from_recorded(b"not a shebang\n", vec![], "/PFX", "/new", &Platform::Linux64) + .is_none() + ); + } +} diff --git a/crates/rattler_vfs/src/projfs_adapter.rs b/crates/rattler_vfs/src/projfs_adapter.rs new file mode 100644 index 0000000000..105a62a155 --- /dev/null +++ b/crates/rattler_vfs/src/projfs_adapter.rs @@ -0,0 +1,629 @@ +//! ProjFS (Windows Projected File System) transport adapter. +//! +//! Maps ProjFS callbacks to `VfsOps` trait methods. ProjFS is demand-driven: +//! files start as placeholders (metadata only) and are hydrated (content +//! materialized) on first read. Once hydrated, reads go directly to disk. +//! +//! This adapter is Windows-only and requires Windows 10 version 1809+. + +// The ProjFS callbacks are `unsafe extern "system" fn`s whose bodies are almost +// entirely raw-pointer FFI against the Win32 API. Under Rust 2024's +// `unsafe_op_in_unsafe_fn` every deref/call would otherwise need its own +// `unsafe {}` wrapper; the whole module is inherently unsafe FFI, so allow it +// module-wide rather than peppering the callbacks with blocks. +#![allow(unsafe_op_in_unsafe_fn)] + +use std::collections::HashMap; +use std::ffi::OsString; +use std::os::windows::ffi::OsStringExt; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use windows::Win32::Foundation::ERROR_FILE_NOT_FOUND; +use windows::Win32::Storage::ProjectedFileSystem::*; +use windows::core::{GUID, HRESULT, PCWSTR}; + +use crate::vfs_ops::{FileAttr, FileKind, VfsOps}; + +/// Cached state for an in-progress directory enumeration. +/// +/// ProjFS calls `start_dir_enum_cb` once, then `get_dir_enum_cb` repeatedly +/// to stream entries until the buffer is full; `end_dir_enum_cb` releases +/// the state. The snapshot + cursor approach keeps the enumeration stable +/// under concurrent reads. +struct EnumState { + entries: Vec<(String, FileAttr)>, + index: usize, +} + +/// Wrap a ProjFS callback body in `catch_unwind`. +/// +/// Any Rust panic unwinding across the `unsafe extern "system"` FFI boundary +/// into Windows is undefined behaviour. We catch panics, log them, and return +/// `ERROR_INTERNAL_ERROR` so ProjFS enters a consistent failed state instead +/// of a UB state. This preserves the "fail the operation, keep the mount +/// alive" contract that the rest of the adapter relies on. +fn projfs_cb(name: &'static str, f: F) -> HRESULT +where + F: FnOnce() -> HRESULT, +{ + // AssertUnwindSafe: ProjFS callbacks don't share mutable state via + // captured references — they access it through the adapter pointer and + // per-enumeration map, both of which are internally synchronized. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + match result { + Ok(hr) => hr, + Err(panic) => { + let msg = panic + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| panic.downcast_ref::().cloned()) + .unwrap_or_else(|| "".to_string()); + tracing::error!("ProjFS callback `{}` panicked: {}", name, msg); + HRESULT::from(windows::Win32::Foundation::ERROR_INTERNAL_ERROR) + } + } +} + +/// ProjFS transport adapter, generic over any `VfsOps` implementation. +/// +/// ProjFS is always writable: it writes hydrated content directly to the +/// virtualization root and tracks deletions via tombstones. Read-only mode +/// cannot be enforced because ProjFS has no pre-creation notification — +/// new files can always be created regardless of notification callbacks. +pub struct ProjFsAdapter { + vfs: Arc, + /// Cache of path → inode mappings for fast lookup. + path_cache: Mutex>, + /// Per-adapter directory enumeration state, keyed by the ProjFS-assigned + /// enumeration GUID. Previously a process-global `LazyLock>`, + /// which tied state to the process (two side-by-side mounts shared the + /// map and a failed Drop leaked globally). Moving it onto the adapter + /// ties lifetime to the mount — clean up is automatic. + enum_sessions: Mutex>, + /// Absolute path to the virtualization root directory. + root: PathBuf, + /// Kept alive for the duration of the virtualization instance. + /// ProjFS may reference this after PrjStartVirtualizing returns. + _notification_root: Vec, +} + +impl ProjFsAdapter { + pub fn new(vfs: T) -> Self { + let mut path_cache = HashMap::new(); + // Root inode is always 1 + path_cache.insert(PathBuf::new(), 1); + // Empty wide string for NotificationRoot (means "root of virtualization instance") + let notification_root: Vec = vec![0u16]; + Self { + vfs: Arc::new(vfs), + path_cache: Mutex::new(path_cache), + enum_sessions: Mutex::new(HashMap::new()), + root: PathBuf::new(), // set in start() + _notification_root: notification_root, + } + } + + /// Resolve a path to an inode, walking the VFS tree component by component. + /// Caches intermediate results for performance. + fn resolve_path(&self, path: &Path) -> Result { + // Check cache first + { + let cache = self.path_cache.lock().unwrap(); + if let Some(&ino) = cache.get(path) { + return Ok(ino); + } + } + + // Walk from root + let mut current_ino = 1u64; + let mut current_path = PathBuf::new(); + let mut new_entries = Vec::new(); + + for component in path.components() { + let name = component.as_os_str(); + current_path.push(name); + + // Check cache for intermediate path + { + let cache = self.path_cache.lock().unwrap(); + if let Some(&cached_ino) = cache.get(¤t_path) { + current_ino = cached_ino; + continue; + } + } + + let attr = self.vfs.lookup(current_ino, name)?; + current_ino = attr.ino; + new_entries.push((current_path.clone(), current_ino)); + } + + // Batch insert cache entries + if !new_entries.is_empty() { + let mut cache = self.path_cache.lock().unwrap(); + for (path, ino) in new_entries { + cache.insert(path, ino); + } + } + + Ok(current_ino) + } + + /// Resolve a path's parent inode and filename. + fn resolve_parent_and_name<'a>( + &self, + path: &'a Path, + ) -> Result<(u64, &'a std::ffi::OsStr), i32> { + let parent = path.parent().unwrap_or(Path::new("")); + let name = path.file_name().ok_or(libc::ENOENT)?; + let parent_ino = self.resolve_path(parent)?; + Ok((parent_ino, name)) + } + + /// Start the ProjFS virtualization instance. + /// + /// The `root` directory must already exist and will be marked as the + /// virtualization root. Returns a handle that stops virtualization on drop. + pub fn start(mut self, root: &Path) -> anyhow::Result { + use std::os::windows::ffi::OsStrExt; + + self.root = root.to_path_buf(); + + // Mark the directory as a virtualization root + let root_wide: Vec = root.as_os_str().encode_wide().chain(Some(0)).collect(); + + // Mark the directory as a virtualization root. Ignore ERROR_ALREADY_EXISTS + // which means it was already marked (e.g. from a previous mount session). + unsafe { + let result = PrjMarkDirectoryAsPlaceholder( + PCWSTR(root_wide.as_ptr()), + PCWSTR::null(), + None, + &PRJ_PLACEHOLDER_ID, + ); + if let Err(ref e) = result { + if e.code() != HRESULT::from(windows::Win32::Foundation::ERROR_ALREADY_EXISTS) { + result?; + } + } + } + + let adapter = Arc::new(self); + + // Set up callbacks + let callbacks = PRJ_CALLBACKS { + StartDirectoryEnumerationCallback: Some(start_dir_enum_cb::), + EndDirectoryEnumerationCallback: Some(end_dir_enum_cb::), + GetDirectoryEnumerationCallback: Some(get_dir_enum_cb::), + GetPlaceholderInfoCallback: Some(get_placeholder_info_cb::), + GetFileDataCallback: Some(get_file_data_cb::), + // QueryFileNameCallback lets ProjFS ask whether a path exists in + // our backing store. EdenFS registers this; ProjFS uses it to + // decide whether a placeholder is "projected" vs user-created. + QueryFileNameCallback: Some(query_file_name_cb::), + // Notification callback for rename support and state tracking. + NotificationCallback: Some(notification_cb::), + ..Default::default() + }; + + let context = Arc::into_raw(adapter.clone()) as *const std::ffi::c_void; + + // Match EdenFS: register for rename and post-mutation events so + // ProjFS can track placeholder state transitions accurately. + let notification_bits = PRJ_NOTIFY_PRE_RENAME + | PRJ_NOTIFY_FILE_RENAMED + | PRJ_NOTIFY_NEW_FILE_CREATED + | PRJ_NOTIFY_FILE_HANDLE_CLOSED_FILE_MODIFIED + | PRJ_NOTIFY_FILE_HANDLE_CLOSED_FILE_DELETED; + let mut notification_mapping = PRJ_NOTIFICATION_MAPPING { + NotificationBitMask: notification_bits, + NotificationRoot: PCWSTR(adapter._notification_root.as_ptr()), + }; + let options = PRJ_STARTVIRTUALIZING_OPTIONS { + NotificationMappings: &mut notification_mapping as *mut _, + NotificationMappingsCount: 1, + // Use enough pool threads so that GetFileDataCallback can be + // dispatched while a notification callback (PRE_RENAME) is blocked + // waiting for force_hydrate to finish reading placeholder files. + // With the default (0 → system-chosen, often 1), the single pool + // thread runs the notification callback and no thread is left to + // service the hydration reads, causing a deadlock. + PoolThreadCount: 4, + ConcurrentThreadCount: 4, + ..Default::default() + }; + + let virt_context = unsafe { + PrjStartVirtualizing( + PCWSTR(root_wide.as_ptr()), + &callbacks, + Some(context), + Some(&options as *const _), + )? + }; + + Ok(ProjFsHandle { + context: virt_context, + _adapter: adapter, + stopped: false, + }) + } +} + +/// Handle to a running ProjFS virtualization instance. Stops on drop. +pub struct ProjFsHandle { + context: PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT, + _adapter: Arc, + /// Whether `PrjStopVirtualizing` has already been called. Set by + /// `unmount()`; checked by `Drop` to avoid double-stopping. + stopped: bool, +} + +impl ProjFsHandle { + /// Explicitly stop ProjFS virtualization. + /// + /// Prefer this over Drop when error handling matters. `PrjStopVirtualizing` + /// itself returns no error, so this method always returns `Ok(())`; it + /// exists for parity with `MountHandle::unmount` and to give callers a + /// single explicit teardown point. + pub fn unmount(mut self) -> anyhow::Result<()> { + if !self.stopped { + unsafe { + PrjStopVirtualizing(self.context); + } + self.stopped = true; + } + Ok(()) + } +} + +impl Drop for ProjFsHandle { + fn drop(&mut self) { + if !self.stopped { + unsafe { + PrjStopVirtualizing(self.context); + } + } + } +} + +// Placeholder GUID for our virtualization root +const PRJ_PLACEHOLDER_ID: GUID = GUID::from_u128(0x72617474_6c65_7266_735f_706c61636568); + +// --------------------------------------------------------------------------- +// ProjFS callback implementations +// --------------------------------------------------------------------------- + +/// Convert a PCWSTR to a PathBuf +fn pcwstr_to_path(s: PCWSTR) -> PathBuf { + unsafe { + let len = (0..).take_while(|&i| *s.0.add(i) != 0).count(); + let slice = std::slice::from_raw_parts(s.0, len); + PathBuf::from(OsString::from_wide(slice)) + } +} + +/// Get the adapter from the callback data's instance context +unsafe fn get_adapter(data: *const PRJ_CALLBACK_DATA) -> &'static ProjFsAdapter { + let context = (*data).InstanceContext as *const ProjFsAdapter; + &*context +} + +/// Query whether a path exists in the backing store. +/// +/// ProjFS calls this to determine if a path is projected (exists in the +/// provider) or local-only. +unsafe extern "system" fn query_file_name_cb(data: *const PRJ_CALLBACK_DATA) -> HRESULT { + projfs_cb("query_file_name", || { + let adapter = get_adapter::(data); + let path = pcwstr_to_path((*data).FilePathName); + match adapter.resolve_path(&path) { + Ok(_) => HRESULT(0), + Err(_) => HRESULT::from(ERROR_FILE_NOT_FOUND), + } + }) +} + +/// Convert FileAttr to PRJ_FILE_BASIC_INFO +fn attr_to_basic_info(attr: &FileAttr) -> PRJ_FILE_BASIC_INFO { + fn system_time_to_filetime(t: std::time::SystemTime) -> i64 { + // Windows FILETIME: 100ns intervals since 1601-01-01 + // Unix epoch: 1970-01-01 = 11644473600 seconds after 1601-01-01 + const EPOCH_DIFF: i64 = 11_644_473_600; + match t.duration_since(std::time::UNIX_EPOCH) { + Ok(d) => (d.as_secs() as i64 + EPOCH_DIFF) * 10_000_000 + d.subsec_nanos() as i64 / 100, + Err(_) => 0, + } + } + + let ft = system_time_to_filetime(attr.mtime); + PRJ_FILE_BASIC_INFO { + IsDirectory: attr.kind == FileKind::Directory, + FileSize: attr.size as i64, + CreationTime: ft, + LastAccessTime: ft, + LastWriteTime: ft, + ChangeTime: ft, + FileAttributes: if attr.kind == FileKind::Directory { + windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_DIRECTORY.0 + } else { + windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL.0 + }, + } +} + +unsafe extern "system" fn start_dir_enum_cb( + data: *const PRJ_CALLBACK_DATA, + enumeration_id: *const GUID, +) -> HRESULT { + projfs_cb("start_dir_enum", || { + let adapter = get_adapter::(data); + let path = pcwstr_to_path((*data).FilePathName); + + let ino = match adapter.resolve_path(&path) { + Ok(ino) => ino, + Err(_) => return HRESULT::from(ERROR_FILE_NOT_FOUND), + }; + + let dir_entries = match adapter.vfs.readdir(ino, 0) { + Ok(entries) => entries, + Err(_) => return HRESULT::from(ERROR_FILE_NOT_FOUND), + }; + + // Resolve attrs for each entry + let mut entries = Vec::new(); + for de in dir_entries { + if de.name == "." || de.name == ".." { + continue; + } + if let Ok(attr) = adapter.vfs.getattr(de.ino) { + let name = de.name.to_string_lossy().to_string(); + entries.push((name, attr)); + } + } + + // Sort by name (ProjFS requires sorted enumeration results) + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + adapter + .enum_sessions + .lock() + .unwrap() + .insert(*enumeration_id, EnumState { entries, index: 0 }); + HRESULT(0) + }) +} + +unsafe extern "system" fn end_dir_enum_cb( + data: *const PRJ_CALLBACK_DATA, + enumeration_id: *const GUID, +) -> HRESULT { + projfs_cb("end_dir_enum", || { + let adapter = get_adapter::(data); + adapter + .enum_sessions + .lock() + .unwrap() + .remove(&*enumeration_id); + HRESULT(0) + }) +} + +unsafe extern "system" fn get_dir_enum_cb( + data: *const PRJ_CALLBACK_DATA, + enumeration_id: *const GUID, + search_expression: PCWSTR, + dir_entry_buffer_handle: PRJ_DIR_ENTRY_BUFFER_HANDLE, +) -> HRESULT { + projfs_cb("get_dir_enum", || { + use std::os::windows::ffi::OsStrExt; + + let adapter = get_adapter::(data); + let mut sessions = adapter.enum_sessions.lock().unwrap(); + let state = match sessions.get_mut(&*enumeration_id) { + Some(s) => s, + None => return HRESULT::from(ERROR_FILE_NOT_FOUND), + }; + + // If this is a restart, reset the index + if ((*data).Flags.0 & PRJ_CB_DATA_FLAG_ENUM_RESTART_SCAN.0) != 0 { + state.index = 0; + } + + while state.index < state.entries.len() { + let (ref name, ref attr) = state.entries[state.index]; + + // Apply search expression filter if provided + if !search_expression.is_null() { + let name_wide: Vec = std::ffi::OsStr::new(name) + .encode_wide() + .chain(Some(0)) + .collect(); + if PrjFileNameMatch(PCWSTR(name_wide.as_ptr()), search_expression) == false { + state.index += 1; + continue; + } + } + + let basic_info = attr_to_basic_info(attr); + + let name_wide: Vec = std::ffi::OsStr::new(name) + .encode_wide() + .chain(Some(0)) + .collect(); + let result = PrjFillDirEntryBuffer( + PCWSTR(name_wide.as_ptr()), + Some(&basic_info), + dir_entry_buffer_handle, + ); + + if result.is_err() { + // Buffer full — ProjFS will call us again + break; + } + + state.index += 1; + } + + HRESULT(0) + }) +} + +unsafe extern "system" fn get_placeholder_info_cb( + data: *const PRJ_CALLBACK_DATA, +) -> HRESULT { + projfs_cb("get_placeholder_info", || { + let adapter = get_adapter::(data); + let path = pcwstr_to_path((*data).FilePathName); + + let (parent_ino, name) = match adapter.resolve_parent_and_name(&path) { + Ok(v) => v, + Err(_) => return HRESULT::from(ERROR_FILE_NOT_FOUND), + }; + + let attr = match adapter.vfs.lookup(parent_ino, name) { + Ok(a) => a, + Err(_) => return HRESULT::from(ERROR_FILE_NOT_FOUND), + }; + + let basic_info = attr_to_basic_info(&attr); + let placeholder_info = PRJ_PLACEHOLDER_INFO { + FileBasicInfo: basic_info, + ..Default::default() + }; + + let result = PrjWritePlaceholderInfo( + (*data).NamespaceVirtualizationContext, + (*data).FilePathName, + &placeholder_info, + std::mem::size_of::() as u32, + ); + + match result { + Ok(()) => HRESULT(0), + Err(e) => e.code(), + } + }) +} + +unsafe extern "system" fn get_file_data_cb( + data: *const PRJ_CALLBACK_DATA, + byte_offset: u64, + length: u32, +) -> HRESULT { + projfs_cb("get_file_data", || { + let adapter = get_adapter::(data); + let path = pcwstr_to_path((*data).FilePathName); + + let ino = match adapter.resolve_path(&path) { + Ok(ino) => ino, + Err(_) => return HRESULT::from(ERROR_FILE_NOT_FOUND), + }; + + // Read the content from VFS + let content = match adapter.vfs.read(ino, byte_offset, length) { + Ok(data) => data, + Err(_) => return HRESULT::from(ERROR_FILE_NOT_FOUND), + }; + + // Allocate a ProjFS-aligned write buffer and copy content into it + let buffer = + PrjAllocateAlignedBuffer((*data).NamespaceVirtualizationContext, content.len()); + if buffer.is_null() { + return HRESULT::from(windows::Win32::Foundation::ERROR_OUTOFMEMORY); + } + + std::ptr::copy_nonoverlapping(content.as_ptr(), buffer as *mut u8, content.len()); + + let result = PrjWriteFileData( + (*data).NamespaceVirtualizationContext, + &(*data).DataStreamId, + buffer, + byte_offset, + content.len() as u32, + ); + + PrjFreeAlignedBuffer(buffer); + + match result { + Ok(()) => HRESULT(0), + Err(e) => e.code(), + } + }) +} + +/// Recursively read all files under `dir` to force ProjFS to hydrate +/// placeholders to on-disk content, and create+delete a temp file in +/// each directory to mark it as "dirty" (modified). After this, ProjFS +/// allows the directory to be renamed. +fn force_hydrate(dir: &Path) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) => { + tracing::warn!("force_hydrate: read_dir({dir:?}) failed: {e}"); + return; + } + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + force_hydrate(&path); + } else { + // Reading triggers ProjFS GetFileDataCallback on a pool thread, + // which writes content to disk (placeholder → hydrated). + if let Err(e) = std::fs::read(&path) { + tracing::warn!("force_hydrate: read({path:?}) failed: {e}"); + } + } + } + // Create and delete a temp file to mark the directory as "dirty" + // (modified). ProjFS may reject renames of clean placeholder + // directories even after their children are hydrated. + let marker = dir.join(".rattler_vfs_hydrate_marker"); + if std::fs::write(&marker, b"").is_ok() { + let _ = std::fs::remove_file(&marker); + } +} + +/// Notification callback for rename support. +/// +/// Force-hydrates placeholder directories before rename so ProjFS allows +/// the operation on the now-full items. Hydration runs on a separate +/// thread to avoid re-entrancy deadlocks with ProjFS callbacks. +unsafe extern "system" fn notification_cb( + data: *const PRJ_CALLBACK_DATA, + is_directory: bool, + notification: PRJ_NOTIFICATION, + _dest_filename: PCWSTR, + _parameters: *mut PRJ_NOTIFICATION_PARAMETERS, +) -> HRESULT { + projfs_cb("notification", || { + let n = notification.0; + + // Force-hydrate directory content before rename so ProjFS allows the + // operation on the now-full items. Hydration runs on a separate thread + // to avoid re-entrancy deadlocks: reading files from within a ProjFS + // callback would dispatch GetFileDataCallback on the same pool, + // potentially deadlocking. PoolThreadCount is set to 4 in start() to + // ensure enough threads. + if n == PRJ_NOTIFICATION_PRE_RENAME.0 { + let adapter = get_adapter::(data); + let rel_path = pcwstr_to_path((*data).FilePathName); + let full_path = adapter.root.join(&rel_path); + if is_directory { + tracing::info!("PRE_RENAME dir: {full_path:?}, spawning hydration thread"); + let handle = std::thread::spawn(move || { + force_hydrate(&full_path); + }); + if let Err(e) = handle.join() { + tracing::error!("hydration thread panicked: {e:?}"); + } + tracing::info!("PRE_RENAME hydration complete, allowing rename"); + } else { + tracing::debug!("PRE_RENAME file: {full_path:?}"); + } + } + + HRESULT(0) + }) +} diff --git a/crates/rattler_vfs/src/vfs_ops.rs b/crates/rattler_vfs/src/vfs_ops.rs new file mode 100644 index 0000000000..9c6e972a86 --- /dev/null +++ b/crates/rattler_vfs/src/vfs_ops.rs @@ -0,0 +1,210 @@ +//! Transport-agnostic virtual filesystem operations. +//! +//! The `VfsOps` trait defines operations that both `VirtualFS` (read-only) +//! and `OverlayFS` (writable) implement. Transport adapters (FUSE, NFS, etc.) +//! are generic over this trait. + +use libc::{ENOENT, EROFS}; +use std::{ + ffi::{OsStr, OsString}, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +/// File type — transport-agnostic equivalent of `fuser::FileType`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileKind { + RegularFile, + Directory, + Symlink, +} + +/// File attributes — transport-agnostic equivalent of `fuser::FileAttr`. +#[derive(Debug, Clone)] +pub struct FileAttr { + pub ino: u64, + pub size: u64, + pub blocks: u64, + pub atime: SystemTime, + pub mtime: SystemTime, + pub ctime: SystemTime, + pub kind: FileKind, + pub perm: u16, + pub nlink: u32, + pub uid: u32, + pub gid: u32, +} + +impl FileAttr { + /// Build a `FileAttr` from OS filesystem metadata, abstracting away + /// platform differences (blocks, ctime, permissions, uid/gid). + pub fn from_metadata(metadata: &std::fs::Metadata, ino: u64) -> Self { + #[cfg(unix)] + let (blocks, ctime, perm, uid, gid) = { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + use std::time::Duration; + ( + metadata.blocks(), + UNIX_EPOCH + Duration::new(metadata.ctime() as u64, metadata.ctime_nsec() as u32), + (metadata.permissions().mode() & 0o777) as u16, + metadata.uid(), + metadata.gid(), + ) + }; + #[cfg(not(unix))] + let (blocks, ctime, perm, uid, gid) = { + ( + (metadata.len() + 511) / 512, + metadata.modified().unwrap_or(UNIX_EPOCH), + if metadata.is_dir() { + 0o755u16 + } else { + 0o644u16 + }, + 0u32, + 0u32, + ) + }; + + let kind = if metadata.is_dir() { + FileKind::Directory + } else if metadata.is_symlink() { + FileKind::Symlink + } else { + FileKind::RegularFile + }; + + FileAttr { + ino, + size: metadata.len(), + blocks, + atime: metadata.accessed().unwrap_or(UNIX_EPOCH), + mtime: metadata.modified().unwrap_or(UNIX_EPOCH), + ctime, + kind, + perm, + nlink: 1, + uid, + gid, + } + } +} + +/// Get the current user's UID and GID. Returns `(0, 0)` on non-Unix platforms. +pub fn current_uid_gid() -> (u32, u32) { + #[cfg(unix)] + { + unsafe { (libc::getuid(), libc::getgid()) } + } + #[cfg(not(unix))] + { + (0, 0) + } +} + +/// Set Unix file permissions cross-platform. On Windows, maps the write bits +/// to the read-only flag. +pub fn set_file_permissions(path: &Path, mode: u32) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + } + #[cfg(not(unix))] + { + let _ = mode; + let readonly = mode & 0o222 == 0; + let mut perms = std::fs::metadata(path)?.permissions(); + perms.set_readonly(readonly); + std::fs::set_permissions(path, perms) + } +} + +/// Hint about how a file's content should be served. +#[derive(Debug)] +pub enum ContentSource { + /// Unmodified file at this path — adapter can use passthrough, mmap, or + /// hardlink depending on the transport. + Direct(PathBuf), + /// Content requires transformation (prefix replacement, codesign). + /// The adapter must use `VfsOps::read()` to get bytes. + Transformed, + /// Small virtual file (e.g. generated entry point scripts). + /// The adapter must use `VfsOps::read()` to get bytes. + Virtual, +} + +/// A directory entry returned by `readdir`. +#[derive(Debug, PartialEq)] +pub struct DirEntry { + pub ino: u64, + pub kind: FileKind, + pub name: OsString, +} + +/// Transport-agnostic filesystem operations. +/// +/// Read operations are required. Write operations default to `EROFS` (read-only +/// filesystem), allowing read-only implementations to skip them. +pub trait VfsOps: Send + Sync + 'static { + fn lookup(&self, parent: u64, name: &OsStr) -> Result; + fn getattr(&self, ino: u64) -> Result; + fn readlink(&self, ino: u64) -> Result; + + /// Read bytes from a file at the given offset. The VFS handles prefix + /// replacement, codesign, and passthrough transparently. + fn read(&self, ino: u64, offset: u64, size: u32) -> Result, i32>; + + /// Hint about how the adapter should serve this file's content. + fn content_source(&self, ino: u64) -> Result; + + fn readdir(&self, ino: u64, offset: u64) -> Result, i32>; + + /// Resolve an inode to its virtual path (relative to root). + /// Used by the overlay to map lower inodes to paths for whiteout checks. + fn ino_to_path(&self, ino: u64) -> Result { + let _ = ino; + Err(ENOENT) + } + + // Write operations — default to EROFS for read-only implementations. + + /// Open a file for writing. Returns a write handle. + fn open_write(&self, _ino: u64) -> Result { + Err(EROFS) + } + /// Read from a write handle (for files currently open for writing). + fn read_handle(&self, _fh: u64, _offset: u64, _size: u32) -> Result, i32> { + Err(EROFS) + } + fn write(&self, _fh: u64, _offset: u64, _data: &[u8]) -> Result { + Err(EROFS) + } + fn release_write(&self, _fh: u64) {} + + fn create(&self, _parent: u64, _name: &OsStr, _mode: u32) -> Result<(FileAttr, u64), i32> { + Err(EROFS) + } + fn unlink(&self, _parent: u64, _name: &OsStr) -> Result<(), i32> { + Err(EROFS) + } + fn mkdir(&self, _parent: u64, _name: &OsStr, _mode: u32) -> Result { + Err(EROFS) + } + fn rmdir(&self, _parent: u64, _name: &OsStr) -> Result<(), i32> { + Err(EROFS) + } + fn rename( + &self, + _parent: u64, + _name: &OsStr, + _newparent: u64, + _newname: &OsStr, + _flags: u32, + ) -> Result<(), i32> { + Err(EROFS) + } + fn setattr(&self, _ino: u64, _size: Option, _mode: Option) -> Result { + Err(EROFS) + } +} diff --git a/crates/rattler_vfs/src/virtual_fs.rs b/crates/rattler_vfs/src/virtual_fs.rs new file mode 100644 index 0000000000..edf46d70e4 --- /dev/null +++ b/crates/rattler_vfs/src/virtual_fs.rs @@ -0,0 +1,1277 @@ +use libc::{EIO, ENOENT, ENOTDIR}; +use memmap2::Mmap; +#[cfg(target_os = "macos")] +use rattler::install::link::copy_and_replace_placeholders_with_offsets; +use rattler_conda_types::Platform; +use rattler_conda_types::package::{FileMode, Offsets, PathType}; +use std::{ + collections::{HashMap, VecDeque}, + ffi::{OsStr, OsString}, + fs::{self, File}, + io::{Read, Seek, SeekFrom}, + path::{Path, PathBuf}, + sync::Mutex, + time::UNIX_EPOCH, +}; + +use crate::vfs_ops::current_uid_gid; + +use crate::metadata_tree::{FileNode, MetadataNode}; +use crate::vfs_ops::{ContentSource, DirEntry, FileAttr, FileKind, VfsOps}; + +/// Compare a directory entry's name against a lookup name. Case-sensitive on +/// Unix; case-insensitive on Windows, where NTFS/ProjFS resolve paths +/// case-insensitively (a lookup for `Lib\\Foo` must find the stored `lib/foo`). +/// Conda paths are ASCII, so ASCII case folding is sufficient. +fn names_match(entry_name: &OsStr, lookup_name: &OsStr) -> bool { + #[cfg(windows)] + { + entry_name + .to_string_lossy() + .eq_ignore_ascii_case(&lookup_name.to_string_lossy()) + } + #[cfg(not(windows))] + { + entry_name == lookup_name + } +} + +/// Bounded FIFO cache of fully materialized + ad-hoc re-signed binaries (macOS). +/// +/// Each entry is a whole binary, so an unbounded map would pin the entire +/// re-signed binary set in memory for the sidecar's lifetime. This caps the +/// entry count and evicts oldest-first; an evicted binary is simply recomputed +/// on the next read. +struct CodesignCache { + map: HashMap>, + order: VecDeque, + max_entries: usize, +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +impl CodesignCache { + fn new(max_entries: usize) -> Self { + Self { + map: HashMap::new(), + order: VecDeque::new(), + max_entries, + } + } + + fn get(&self, ino: &u64) -> Option<&Vec> { + self.map.get(ino) + } + + fn insert(&mut self, ino: u64, data: Vec) { + // Only track insertion order for genuinely new inodes so re-materializing + // the same binary doesn't create a duplicate order entry. + if self.map.insert(ino, data).is_none() { + self.order.push_back(ino); + while self.map.len() > self.max_entries { + match self.order.pop_front() { + Some(old) => { + self.map.remove(&old); + } + None => break, + } + } + } + } +} + +/// Read the first `n` bytes of a file (fewer when the file is shorter). +/// +/// Used to load just the shebang region during plan construction; `n` comes +/// from the recorded `shebang_length`, so it is bounded by `take` rather than +/// pre-allocated in case the metadata is nonsense. +fn read_leading_bytes(path: &Path, n: usize) -> std::io::Result> { + let mut buf = Vec::new(); + File::open(path)?.take(n as u64).read_to_end(&mut buf)?; + Ok(buf) +} + +/// Pre-computed prefix-replacement plan for a file, keyed by inode. +enum ReplacementPlan { + /// Text file: shebang-aware plan — the shebang region is transformed once + /// (exactly as the installer does) and the remaining occurrences are body + /// offsets spliced on read. + Text(crate::prefix_replacement::TextPlan), + /// Binary file: c-string groups, each listing prefix offsets followed by + /// the NUL terminator position. + Binary(Vec>), +} + +pub struct VirtualFS { + metadata: Vec, + mount_point: PathBuf, + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + platform: Platform, + uid: u32, + gid: u32, + /// Pre-computed replacement plans for files with prefix placeholders. + /// Keyed by inode. Populated eagerly at construction from paths.json + /// offsets or by scanning the source file. + offset_cache: HashMap, + /// Cache for fully materialized + codesigned binary content (macOS only). + /// Keyed by inode. Only populated for binary-mode prefix files that need + /// ad-hoc re-signing, since codesign requires the full file. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + codesign_cache: Mutex, +} + +impl VirtualFS { + pub fn new(metadata: Vec, mount_point: &Path) -> Self { + Self::with_platform(metadata, mount_point, Platform::current()) + } + + pub(crate) fn with_platform( + mut metadata: Vec, + mount_point: &Path, + platform: Platform, + ) -> Self { + let target_prefix = mount_point.to_string_lossy(); + let mut offset_cache = HashMap::new(); + + // Eagerly compute replacement offsets and text-mode file sizes. + for i in 0..metadata.len() { + let Some(file) = metadata[i].as_file() else { + continue; + }; + let Some(placeholder) = &file.prefix_placeholder else { + continue; + }; + + let ino = (i + 1) as u64; + let old_prefix = placeholder.placeholder.as_bytes(); + + // Resolve the on-disk cache path, preferring cache_prefix_path + // (set for noarch Python files where virtual path differs from cache path). + let cache_path = { + let p = (*file.cache_base_path).to_path_buf(); + let prefix = match &file.cache_prefix_path { + Some(cp) => cp.as_path(), + None => &metadata[file.parent].as_directory().unwrap().prefix_path, + }; + p.join(prefix).join(&file.file_name) + }; + + // Build the replacement plan. Both modes prefer the offsets + // recorded in paths.json — that metadata exists precisely so + // consumers don't have to scan file contents, and it is trusted + // as-is (the ranged reads are total, so a non-conformant producer + // yields wrong bytes for its own package, never a panic). Scanning + // remains as the fallback for pre-CEP packages. + let plan = match placeholder.file_mode { + FileMode::Text => { + // With recorded offsets, construction reads at most the + // shebang region (`shebang_length` bytes) — the one part + // of the transformation a bare offset list can't express. + let recorded_plan = if let Some(Offsets::Text(body_offsets)) = + &placeholder.offsets + { + let region = match placeholder.shebang_length { + Some(len) if len > 0 => match read_leading_bytes(&cache_path, len) { + Ok(region) => region, + Err(e) => { + tracing::warn!( + "failed to read {} for offset computation: {}", + cache_path.display(), + e + ); + continue; + } + }, + _ => Vec::new(), + }; + let plan = crate::prefix_replacement::TextPlan::from_recorded( + ®ion, + body_offsets.clone(), + &placeholder.placeholder, + &target_prefix, + &platform, + ); + if plan.is_none() { + tracing::warn!( + "{}: recorded shebang_length does not match the file \ + contents; falling back to scanning", + cache_path.display() + ); + } + plan + } else { + None + }; + + let (text_plan, source_len) = match recorded_plan { + Some(plan) => match fs::symlink_metadata(&cache_path) { + Ok(m) => (plan, m.len() as usize), + Err(e) => { + tracing::warn!( + "failed to stat {} for offset computation: {}", + cache_path.display(), + e + ); + continue; + } + }, + None => match fs::read(&cache_path) { + Ok(source) => { + let plan = crate::prefix_replacement::plan_text_replacement( + &source, + &placeholder.placeholder, + &target_prefix, + &platform, + ); + (plan, source.len()) + } + Err(e) => { + tracing::warn!( + "failed to read {} for offset computation: {}", + cache_path.display(), + e + ); + continue; + } + }, + }; + + // Post-replacement size: the transformed shebang region plus + // the unchanged body length plus the per-occurrence delta. + let delta = + target_prefix.len() as isize - placeholder.placeholder.len() as isize; + let body_len = source_len.saturating_sub(text_plan.region_end); + let new_size = (text_plan.transformed_region.len() as isize + + body_len as isize + + delta * text_plan.body_offsets.len() as isize) + .max(0) as u64; + metadata[i].as_file_mut().unwrap().computed_size = Some(new_size); + + ReplacementPlan::Text(text_plan) + } + FileMode::Binary => { + let groups = if let Some(Offsets::Binary(g)) = &placeholder.offsets { + g.clone() + } else { + match fs::read(&cache_path) { + Ok(source) => crate::prefix_replacement::collect_binary_offsets( + &source, old_prefix, + ), + Err(e) => { + tracing::warn!( + "failed to read {} for offset computation: {}", + cache_path.display(), + e + ); + continue; + } + } + }; + ReplacementPlan::Binary(groups) + } + }; + + offset_cache.insert(ino, plan); + } + + VirtualFS { + metadata, + mount_point: mount_point.to_path_buf(), + platform, + uid: current_uid_gid().0, + gid: current_uid_gid().1, + offset_cache, + codesign_cache: Mutex::new(CodesignCache::new(16)), + } + } + + /// Validate an inode number and return the 0-based metadata index. + fn validate_ino(&self, ino: u64) -> Result { + if ino == 0 || ino > self.metadata.len() as u64 { + return Err(ENOENT); + } + Ok((ino - 1) as usize) + } + + /// Build a `FileAttr` with common defaults (uid/gid cached). + fn make_attr(&self, ino: u64, size: u64, kind: FileKind, perm: u16) -> FileAttr { + FileAttr { + ino, + size, + blocks: 0, + atime: UNIX_EPOCH, + mtime: UNIX_EPOCH, + ctime: UNIX_EPOCH, + kind, + perm, + nlink: 1, + uid: self.uid, + gid: self.gid, + } + } + + fn _getpath(&self, file: &FileNode) -> PathBuf { + let mut path = (*file.cache_base_path).to_path_buf(); + let prefix = match &file.cache_prefix_path { + Some(p) => p.as_path(), + None => { + &self.metadata[file.parent] + .as_directory() + .unwrap() + .prefix_path + } + }; + path = path.join(prefix); + path.join(&file.file_name) + } + + fn _getattr(&self, child: &MetadataNode, child_index: &usize) -> FileAttr { + let ino = (child_index + 1) as u64; + match child { + MetadataNode::Directory(_) => self.make_attr(ino, 0, FileKind::Directory, 0o755), + MetadataNode::File(file) => { + if let Some(ref content) = file.virtual_content { + return self.make_attr(ino, content.len() as u64, FileKind::RegularFile, 0o775); + } + + let path = self._getpath(file); + match fs::symlink_metadata(&path) { + Ok(metadata) => { + let mut attr = FileAttr::from_metadata(&metadata, ino); + // Override size if prefix replacement changes the file length + if let Some(computed) = file.computed_size { + attr.size = computed; + } + attr + } + Err(e) => { + tracing::warn!("failed to stat {}: {}", path.display(), e); + self.make_attr(ino, 0, FileKind::RegularFile, 0o644) + } + } + } + } + } + + // -- Testable inner methods -- + + pub(crate) fn do_lookup(&self, parent_ino: u64, name: &OsStr) -> Result { + let parent_index = self.validate_ino(parent_ino)?; + + let Some(parent_directory) = self.metadata[parent_index].as_directory() else { + return Err(ENOTDIR); + }; + + for child_index in parent_directory.children.iter() { + let child = &self.metadata[*child_index]; + if names_match(child.file_name(), name) { + return Ok(self._getattr(child, child_index)); + } + } + + Err(ENOENT) + } + + pub(crate) fn do_getattr(&self, ino: u64) -> Result { + let index = self.validate_ino(ino)?; + let entry = &self.metadata[index]; + Ok(self._getattr(entry, &index)) + } + + pub(crate) fn do_readlink(&self, ino: u64) -> Result { + let index = self.validate_ino(ino)?; + let Some(current_file) = self.metadata[index].as_file() else { + return Err(ENOENT); + }; + let path = self._getpath(current_file); + fs::read_link(&path).map_err(|e| { + tracing::warn!("readlink failed for {}: {}", path.display(), e); + EIO + }) + } + + pub(crate) fn do_content_source(&self, ino: u64) -> Result { + let index = self.validate_ino(ino)?; + + let Some(current_file) = self.metadata[index].as_file() else { + return Err(ENOENT); // directories don't have readable content + }; + + if current_file.path_type == PathType::SoftLink { + return Err(ENOENT); // symlinks don't have readable content + } + + if current_file.virtual_content.is_some() { + return Ok(ContentSource::Virtual); + } + + if current_file.prefix_placeholder.is_some() { + return Ok(ContentSource::Transformed); + } + + let path = self._getpath(current_file); + Ok(ContentSource::Direct(path)) + } + + pub(crate) fn do_read(&self, ino: u64, offset: u64, size: u32) -> Result, i32> { + let index = self.validate_ino(ino)?; + + let Some(current_file) = self.metadata[index].as_file() else { + return Ok(vec![]); // directories + }; + + if current_file.path_type == PathType::SoftLink { + return Ok(vec![]); // symlinks + } + + // Virtual files (e.g. entry points) are served directly from memory + if let Some(ref content) = current_file.virtual_content { + let start = (offset as usize).min(content.len()); + let end = (start + size as usize).min(content.len()); + return Ok(content[start..end].to_vec()); + } + + let path = self._getpath(current_file); + + // Files without prefix replacement: read directly from disk + if current_file.prefix_placeholder.is_none() { + let mut file = File::open(&path).map_err(|e| { + tracing::warn!("failed to open {}: {}", path.display(), e); + EIO + })?; + file.seek(SeekFrom::Start(offset)).map_err(|e| { + tracing::warn!("failed to seek {}: {}", path.display(), e); + EIO + })?; + let mut buf = vec![0u8; size as usize]; + let n = file.read(&mut buf).map_err(|e| { + tracing::warn!("failed to read {}: {}", path.display(), e); + EIO + })?; + buf.truncate(n); + return Ok(buf); + } + + // Has prefix placeholder — use ranged replacement + let placeholder = current_file.prefix_placeholder.as_ref().unwrap(); + + let file = File::open(&path).map_err(|e| { + tracing::warn!("failed to open {}: {}", path.display(), e); + EIO + })?; + + let mmap = unsafe { Mmap::map(&file) }.map_err(|e| { + tracing::warn!("failed to memory map {}: {}", path.display(), e); + EIO + })?; + + let old_prefix = placeholder.placeholder.as_bytes(); + let new_prefix_str = self.mount_point.to_string_lossy(); + let new_prefix = new_prefix_str.as_bytes(); + + let start = offset as usize; + let end = start + size as usize; + + let Some(plan) = self.offset_cache.get(&ino) else { + // No plan — serve source bytes directly + let s = start.min(mmap.len()); + let e = (s + size as usize).min(mmap.len()); + return Ok(mmap[s..e].to_vec()); + }; + + match plan { + ReplacementPlan::Binary(groups) => { + // macOS binaries need codesign after prefix replacement. + // Codesign rehashes every page so it can't be done as a ranged + // operation. Materialize + resign once, cache for subsequent reads. + // The codesign module is compiled only on macOS — other targets + // fall through to `binary_ranged_read` directly. + #[cfg(target_os = "macos")] + if self.platform.is_osx() { + // Fast path: serve from cache + if let Some(cached) = self.codesign_cache.lock().unwrap().get(&ino) { + let s = start.min(cached.len()); + let e = (s + size as usize).min(cached.len()); + return Ok(cached[s..e].to_vec()); + } + + // Slow path: materialize, resign, cache + let target_prefix = self.mount_point.to_string_lossy(); + let mut output = Vec::with_capacity(mmap.len()); + let binary_offsets = Offsets::Binary(groups.clone()); + + let result = copy_and_replace_placeholders_with_offsets( + &mmap, + &mut output, + &placeholder.placeholder, + &target_prefix, + &self.platform, + placeholder.file_mode, + &binary_offsets, + placeholder.shebang_length, + ); + + if let Err(e) = result { + tracing::warn!( + "prefix replacement failed for {} ({}); serving raw bytes", + path.display(), + e + ); + let s = start.min(mmap.len()); + let e = (s + size as usize).min(mmap.len()); + return Ok(mmap[s..e].to_vec()); + } + + if let Err(e) = crate::codesign::adhoc_resign(&mut output) { + tracing::warn!("ad-hoc re-signing failed for {}: {}", path.display(), e); + } + + let s = start.min(output.len()); + let e = (s + size as usize).min(output.len()); + let result = output[s..e].to_vec(); + self.codesign_cache.lock().unwrap().insert(ino, output); + return Ok(result); + } + + Ok(crate::prefix_replacement::binary_ranged_read( + &mmap, old_prefix, new_prefix, groups, start, end, + )) + } + ReplacementPlan::Text(text_plan) => Ok(crate::prefix_replacement::text_ranged_read( + &mmap, + old_prefix, + new_prefix, + &text_plan.body_offsets, + text_plan.region_end, + &text_plan.transformed_region, + start, + end, + )), + } + } + + pub(crate) fn do_readdir(&self, ino: u64, offset: u64) -> Result, i32> { + let index = self.validate_ino(ino)?; + + let Some(current_directory) = self.metadata[index].as_directory() else { + return Err(ENOTDIR); + }; + + let mut entries = Vec::new(); + + if offset == 0 { + entries.push(DirEntry { + ino: (current_directory.parent + 1) as u64, + kind: FileKind::Directory, + name: OsString::from(".."), + }); + } + if offset <= 1 { + entries.push(DirEntry { + ino, + kind: FileKind::Directory, + name: OsString::from("."), + }); + } + + for child_index in current_directory + .children + .iter() + .skip(offset.saturating_sub(2) as usize) + { + let child = &self.metadata[*child_index]; + let kind = match child { + MetadataNode::Directory(_) => FileKind::Directory, + MetadataNode::File(f) => { + if f.path_type == PathType::SoftLink { + FileKind::Symlink + } else { + FileKind::RegularFile + } + } + }; + entries.push(DirEntry { + ino: (child_index + 1) as u64, + kind, + name: child.file_name().to_owned(), + }); + } + + Ok(entries) + } +} + +impl VfsOps for VirtualFS { + fn lookup(&self, parent: u64, name: &OsStr) -> Result { + self.do_lookup(parent, name) + } + fn getattr(&self, ino: u64) -> Result { + self.do_getattr(ino) + } + fn readlink(&self, ino: u64) -> Result { + self.do_readlink(ino) + } + fn read(&self, ino: u64, offset: u64, size: u32) -> Result, i32> { + self.do_read(ino, offset, size) + } + fn content_source(&self, ino: u64) -> Result { + self.do_content_source(ino) + } + fn readdir(&self, ino: u64, offset: u64) -> Result, i32> { + self.do_readdir(ino, offset) + } + + fn ino_to_path(&self, ino: u64) -> Result { + let index = self.validate_ino(ino)?; + let entry = &self.metadata[index]; + + // Root directory → empty path + if index == 0 { + return Ok(PathBuf::new()); + } + + match entry { + MetadataNode::Directory(dir) => { + // prefix_path is like "./lib/python3.14" — strip the "./" prefix + let p = dir + .prefix_path + .strip_prefix("./") + .unwrap_or(&dir.prefix_path); + Ok(p.to_path_buf()) + } + MetadataNode::File(file) => { + let parent = &self.metadata[file.parent]; + let parent_dir = parent.as_directory().ok_or(ENOENT)?; + let parent_path = parent_dir + .prefix_path + .strip_prefix("./") + .unwrap_or(&parent_dir.prefix_path); + Ok(parent_path.join(&file.file_name)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{new_empty_tree, path_parse}; + + #[test] + fn test_codesign_cache_bounds_and_evicts_oldest() { + let mut cache = CodesignCache::new(2); + cache.insert(1, vec![1]); + cache.insert(2, vec![2]); + assert!(cache.get(&1).is_some()); + assert!(cache.get(&2).is_some()); + + // Inserting a third entry evicts the oldest (inode 1). + cache.insert(3, vec![3]); + assert!(cache.get(&1).is_none()); + assert!(cache.get(&2).is_some()); + assert!(cache.get(&3).is_some()); + + // Re-inserting an existing inode updates in place without growing the + // cache or evicting a live entry. + cache.insert(2, vec![22]); + assert_eq!(cache.get(&2), Some(&vec![22])); + assert!(cache.get(&3).is_some()); + } + use rattler_conda_types::package::{ + FileMode, PathType, PathsEntry, PathsJson, PrefixPlaceholder, + }; + #[cfg(unix)] + use std::os::unix::fs::symlink; + #[cfg(windows)] + use std::os::windows::fs::symlink_file as symlink; + use tempfile::TempDir; + + /// Build a test fixture: + /// ```text + /// tmpdir/ + /// ├── lib/ + /// │ ├── libfoo.so "hello world" + /// │ ├── libfoo.so.1 → libfoo.so + /// │ └── libbar.so → gone.so (dangling) + /// ├── etc/ + /// │ └── config.txt "/old/prefix/path/to/thing" + /// └── bin/ + /// └── run.sh "#!/old/prefix/bin/python\nprint('hi')" + /// ``` + fn create_fixture() -> (TempDir, VirtualFS) { + let tmpdir = TempDir::new().unwrap(); + let cache_path = tmpdir.path(); + + // Create directories + fs::create_dir_all(cache_path.join("lib")).unwrap(); + fs::create_dir_all(cache_path.join("etc")).unwrap(); + fs::create_dir_all(cache_path.join("bin")).unwrap(); + + // Create files + fs::write(cache_path.join("lib/libfoo.so"), b"hello world").unwrap(); + symlink("libfoo.so", cache_path.join("lib/libfoo.so.1")).unwrap(); + symlink("gone.so", cache_path.join("lib/libbar.so")).unwrap(); + fs::write( + cache_path.join("etc/config.txt"), + b"/old/prefix/path/to/thing", + ) + .unwrap(); + fs::write( + cache_path.join("bin/run.sh"), + b"#!/old/prefix/bin/python\nprint('hi')", + ) + .unwrap(); + + // Build PathsJson + let paths_json = PathsJson { + paths: vec![ + PathsEntry { + relative_path: PathBuf::from("lib/libfoo.so"), + path_type: PathType::HardLink, + prefix_placeholder: None, + no_link: false, + sha256: None, + size_in_bytes: None, + }, + PathsEntry { + relative_path: PathBuf::from("lib/libfoo.so.1"), + path_type: PathType::SoftLink, + prefix_placeholder: None, + no_link: false, + sha256: None, + size_in_bytes: None, + }, + PathsEntry { + relative_path: PathBuf::from("lib/libbar.so"), + path_type: PathType::SoftLink, + prefix_placeholder: None, + no_link: false, + sha256: None, + size_in_bytes: None, + }, + PathsEntry { + relative_path: PathBuf::from("etc/config.txt"), + path_type: PathType::HardLink, + prefix_placeholder: Some(PrefixPlaceholder { + file_mode: FileMode::Text, + placeholder: "/old/prefix".to_string(), + offsets: None, + shebang_length: None, + }), + no_link: false, + sha256: None, + size_in_bytes: None, + }, + PathsEntry { + relative_path: PathBuf::from("bin/run.sh"), + path_type: PathType::HardLink, + prefix_placeholder: Some(PrefixPlaceholder { + file_mode: FileMode::Text, + placeholder: "/old/prefix".to_string(), + offsets: None, + shebang_length: None, + }), + no_link: false, + sha256: None, + size_in_bytes: None, + }, + ], + paths_version: 1, + }; + + let (mut env_paths, mut dir_indices) = new_empty_tree(); + path_parse( + &paths_json, + cache_path, + None, + &mut env_paths, + &mut dir_indices, + ); + + let mount_point = PathBuf::from("/new/prefix"); + let vfs = VirtualFS::with_platform(env_paths, &mount_point, Platform::Linux64); + + (tmpdir, vfs) + } + + // --- lookup tests --- + + #[test] + fn test_lookup_directory() { + let (_tmp, vfs) = create_fixture(); + let attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + assert_eq!(attr.kind, FileKind::Directory); + } + + #[test] + fn test_lookup_file() { + let (_tmp, vfs) = create_fixture(); + // First find lib directory + let lib_attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + let lib_ino = lib_attr.ino; + // Then find file in lib + let attr = vfs.do_lookup(lib_ino, OsStr::new("libfoo.so")).unwrap(); + assert_eq!(attr.kind, FileKind::RegularFile); + assert!(attr.size > 0); + } + + #[test] + fn test_lookup_not_found() { + let (_tmp, vfs) = create_fixture(); + assert_eq!( + vfs.do_lookup(1, OsStr::new("nonexistent")).unwrap_err(), + ENOENT + ); + } + + #[test] + fn test_lookup_not_directory() { + let (_tmp, vfs) = create_fixture(); + let lib_attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + let file_attr = vfs + .do_lookup(lib_attr.ino, OsStr::new("libfoo.so")) + .unwrap(); + // Try to lookup child of a file + assert_eq!( + vfs.do_lookup(file_attr.ino, OsStr::new("child")) + .unwrap_err(), + ENOTDIR + ); + } + + // --- getattr tests --- + + #[test] + fn test_getattr_root() { + let (_tmp, vfs) = create_fixture(); + let attr = vfs.do_getattr(1).unwrap(); + assert_eq!(attr.kind, FileKind::Directory); + } + + #[test] + fn test_getattr_regular_file() { + let (_tmp, vfs) = create_fixture(); + let lib_attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + let file_attr = vfs + .do_lookup(lib_attr.ino, OsStr::new("libfoo.so")) + .unwrap(); + let attr = vfs.do_getattr(file_attr.ino).unwrap(); + assert_eq!(attr.kind, FileKind::RegularFile); + assert_eq!(attr.size, 11); // "hello world" + } + + #[test] + fn test_getattr_symlink() { + let (_tmp, vfs) = create_fixture(); + let lib_attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + let sym_attr = vfs + .do_lookup(lib_attr.ino, OsStr::new("libfoo.so.1")) + .unwrap(); + assert_eq!(sym_attr.kind, FileKind::Symlink); + } + + #[test] + fn test_getattr_dangling_symlink() { + let (_tmp, vfs) = create_fixture(); + let lib_attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + let sym_attr = vfs + .do_lookup(lib_attr.ino, OsStr::new("libbar.so")) + .unwrap(); + // dangling symlink still reports as symlink via symlink_metadata + assert_eq!(sym_attr.kind, FileKind::Symlink); + } + + #[test] + fn test_getattr_invalid_ino() { + let (_tmp, vfs) = create_fixture(); + assert_eq!(vfs.do_getattr(9999).unwrap_err(), ENOENT); + } + + // --- readlink tests --- + + #[test] + fn test_readlink_valid() { + let (_tmp, vfs) = create_fixture(); + let lib_attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + let sym_attr = vfs + .do_lookup(lib_attr.ino, OsStr::new("libfoo.so.1")) + .unwrap(); + let target = vfs.do_readlink(sym_attr.ino).unwrap(); + assert_eq!(target, PathBuf::from("libfoo.so")); + } + + #[test] + fn test_readlink_dangling() { + let (_tmp, vfs) = create_fixture(); + let lib_attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + let sym_attr = vfs + .do_lookup(lib_attr.ino, OsStr::new("libbar.so")) + .unwrap(); + let target = vfs.do_readlink(sym_attr.ino).unwrap(); + assert_eq!(target, PathBuf::from("gone.so")); + } + + #[test] + fn test_readlink_regular_file() { + let (_tmp, vfs) = create_fixture(); + let lib_attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + let file_attr = vfs + .do_lookup(lib_attr.ino, OsStr::new("libfoo.so")) + .unwrap(); + // read_link on a regular file should fail + assert_eq!(vfs.do_readlink(file_attr.ino), Err(EIO)); + } + + #[test] + fn test_readlink_directory() { + let (_tmp, vfs) = create_fixture(); + // readlink on a directory should fail (not a file) + assert_eq!(vfs.do_readlink(1), Err(ENOENT)); + } + + // --- read tests --- + + #[test] + fn test_read_regular_file() { + let (_tmp, vfs) = create_fixture(); + let lib_attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + let file_attr = vfs + .do_lookup(lib_attr.ino, OsStr::new("libfoo.so")) + .unwrap(); + let data = vfs.do_read(file_attr.ino, 0, 1024).unwrap(); + assert_eq!(data, b"hello world"); + } + + #[test] + fn test_read_with_offset() { + let (_tmp, vfs) = create_fixture(); + let lib_attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + let file_attr = vfs + .do_lookup(lib_attr.ino, OsStr::new("libfoo.so")) + .unwrap(); + let data = vfs.do_read(file_attr.ino, 6, 5).unwrap(); + assert_eq!(data, b"world"); + } + + #[test] + fn test_read_with_prefix_replacement() { + let (_tmp, vfs) = create_fixture(); + let etc_attr = vfs.do_lookup(1, OsStr::new("etc")).unwrap(); + let config_attr = vfs + .do_lookup(etc_attr.ino, OsStr::new("config.txt")) + .unwrap(); + let data = vfs.do_read(config_attr.ino, 0, 4096).unwrap(); + let content = String::from_utf8(data).unwrap(); + assert!( + content.contains("/new/prefix"), + "expected /new/prefix in: {content}" + ); + assert!( + !content.contains("/old/prefix"), + "unexpected /old/prefix in: {content}" + ); + } + + #[test] + fn test_getattr_text_prefix_reports_correct_size() { + let (_tmp, vfs) = create_fixture(); + let etc_attr = vfs.do_lookup(1, OsStr::new("etc")).unwrap(); + let config_attr = vfs + .do_lookup(etc_attr.ino, OsStr::new("config.txt")) + .unwrap(); + + // getattr size should match actual content length, not on-disk size + let data = vfs.do_read(config_attr.ino, 0, u32::MAX).unwrap(); + assert_eq!( + config_attr.size, + data.len() as u64, + "getattr size ({}) != read content length ({})", + config_attr.size, + data.len() + ); + } + + #[test] + fn test_getattr_shebang_prefix_reports_correct_size() { + let (_tmp, vfs) = create_fixture(); + let bin_attr = vfs.do_lookup(1, OsStr::new("bin")).unwrap(); + let run_attr = vfs.do_lookup(bin_attr.ino, OsStr::new("run.sh")).unwrap(); + + // Shebang file: getattr size should match actual content length + let data = vfs.do_read(run_attr.ino, 0, u32::MAX).unwrap(); + assert_eq!( + run_attr.size, + data.len() as u64, + "getattr size ({}) != read content length ({})", + run_attr.size, + data.len() + ); + } + + #[test] + fn test_read_directory_returns_empty() { + let (_tmp, vfs) = create_fixture(); + let data = vfs.do_read(1, 0, 1024).unwrap(); // root directory + assert!(data.is_empty()); + } + + #[test] + fn test_read_symlink_returns_empty() { + let (_tmp, vfs) = create_fixture(); + let lib_attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + let sym_attr = vfs + .do_lookup(lib_attr.ino, OsStr::new("libfoo.so.1")) + .unwrap(); + let data = vfs.do_read(sym_attr.ino, 0, 1024).unwrap(); + assert!(data.is_empty()); + } + + #[test] + fn test_read_dangling_symlink_returns_empty() { + let (_tmp, vfs) = create_fixture(); + let lib_attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + let sym_attr = vfs + .do_lookup(lib_attr.ino, OsStr::new("libbar.so")) + .unwrap(); + let data = vfs.do_read(sym_attr.ino, 0, 1024).unwrap(); + assert!(data.is_empty()); + } + + // --- readdir tests --- + + #[test] + fn test_readdir_root() { + let (_tmp, vfs) = create_fixture(); + let entries = vfs.do_readdir(1, 0).unwrap(); + let names: Vec<_> = entries.iter().map(|e| e.name.to_str().unwrap()).collect(); + assert!(names.contains(&"..")); + assert!(names.contains(&".")); + assert!(names.contains(&"lib")); + assert!(names.contains(&"etc")); + assert!(names.contains(&"bin")); + } + + #[test] + fn test_readdir_subdirectory() { + let (_tmp, vfs) = create_fixture(); + let lib_attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + let entries = vfs.do_readdir(lib_attr.ino, 0).unwrap(); + let names: Vec<_> = entries.iter().map(|e| e.name.to_str().unwrap()).collect(); + assert!(names.contains(&"libfoo.so")); + assert!(names.contains(&"libfoo.so.1")); + assert!(names.contains(&"libbar.so")); + } + + #[test] + fn test_readdir_reports_symlink_kind() { + let (_tmp, vfs) = create_fixture(); + let lib_attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + let entries = vfs.do_readdir(lib_attr.ino, 0).unwrap(); + + let find = |name: &str| { + entries + .iter() + .find(|e| e.name == name) + .unwrap_or_else(|| panic!("entry {name} not found")) + }; + assert_eq!(find("libfoo.so").kind, FileKind::RegularFile); + assert_eq!(find("libfoo.so.1").kind, FileKind::Symlink); + assert_eq!(find("libbar.so").kind, FileKind::Symlink); + } + + #[test] + fn test_readdir_with_offset() { + let (_tmp, vfs) = create_fixture(); + let all = vfs.do_readdir(1, 0).unwrap(); + let skipped = vfs.do_readdir(1, 3).unwrap(); + assert!(skipped.len() < all.len()); + } + + #[test] + fn test_readdir_not_directory() { + let (_tmp, vfs) = create_fixture(); + let lib_attr = vfs.do_lookup(1, OsStr::new("lib")).unwrap(); + let file_attr = vfs + .do_lookup(lib_attr.ino, OsStr::new("libfoo.so")) + .unwrap(); + assert_eq!(vfs.do_readdir(file_attr.ino, 0), Err(ENOTDIR)); + } + + // --- virtual file tests --- + + fn create_fixture_with_virtual_files() -> (TempDir, VirtualFS) { + use rattler::install::PythonInfo; + use rattler_conda_types::Version; + use std::str::FromStr; + + let tmpdir = TempDir::new().unwrap(); + let cache_path = tmpdir.path(); + + fs::create_dir_all(cache_path.join("bin")).unwrap(); + fs::write(cache_path.join("bin/real_tool"), b"real content").unwrap(); + + let paths_json = PathsJson { + paths: vec![PathsEntry { + relative_path: PathBuf::from("bin/real_tool"), + path_type: PathType::HardLink, + prefix_placeholder: None, + no_link: false, + sha256: None, + size_in_bytes: None, + }], + paths_version: 1, + }; + + let (mut env_paths, mut dir_indices) = new_empty_tree(); + path_parse( + &paths_json, + cache_path, + None, + &mut env_paths, + &mut dir_indices, + ); + + // Add a virtual entry point + let python_info = PythonInfo::from_version( + &Version::from_str("3.11.0").unwrap(), + None, + Platform::Linux64, + ) + .unwrap(); + let ep = rattler_conda_types::package::EntryPoint::from_str("mytool = mymod:main").unwrap(); + crate::add_entry_points( + &[ep], + "/new/prefix", + &python_info, + &mut env_paths, + &mut dir_indices, + ); + + let vfs = VirtualFS::with_platform(env_paths, Path::new("/new/prefix"), Platform::Linux64); + (tmpdir, vfs) + } + + #[test] + fn test_lookup_entry_point() { + let (_tmp, vfs) = create_fixture_with_virtual_files(); + let bin_attr = vfs.do_lookup(1, OsStr::new("bin")).unwrap(); + let ep_attr = vfs.do_lookup(bin_attr.ino, OsStr::new("mytool")).unwrap(); + assert_eq!(ep_attr.kind, FileKind::RegularFile); + assert!(ep_attr.size > 0); + } + + #[test] + fn test_getattr_virtual_file() { + let (_tmp, vfs) = create_fixture_with_virtual_files(); + let bin_attr = vfs.do_lookup(1, OsStr::new("bin")).unwrap(); + let ep_attr = vfs.do_lookup(bin_attr.ino, OsStr::new("mytool")).unwrap(); + assert_eq!(ep_attr.kind, FileKind::RegularFile); + assert_eq!(ep_attr.perm, 0o775); // executable + assert!(ep_attr.size > 0); + } + + /// Noarch Python packages store scripts under `python-scripts/` on disk + /// but expose them as `bin/` in the virtual tree. When paths.json has no + /// precomputed offsets, the VFS must resolve the *cache* path (via + /// `cache_prefix_path`) instead of the virtual parent directory path. + /// Regression test for prefix-replacement warnings on noarch entry-point + /// scripts (e.g. "failed to read …/bin/script for offset computation"). + #[test] + fn test_noarch_prefix_replacement_uses_cache_path() { + use rattler::install::PythonInfo; + use rattler_conda_types::Version; + use std::str::FromStr; + + let tmpdir = TempDir::new().unwrap(); + let cache_path = tmpdir.path(); + + // On disk the file lives under python-scripts/ (the raw package layout) + fs::create_dir_all(cache_path.join("python-scripts")).unwrap(); + let script_content = b"#!/old/prefix/bin/python\nprint('hello')"; + fs::write(cache_path.join("python-scripts/myscript"), script_content).unwrap(); + + // Build PathsJson with a prefix placeholder but NO precomputed offsets + let paths_json = PathsJson { + paths: vec![PathsEntry { + relative_path: PathBuf::from("python-scripts/myscript"), + path_type: PathType::HardLink, + prefix_placeholder: Some(PrefixPlaceholder { + file_mode: FileMode::Text, + placeholder: "/old/prefix".to_string(), + offsets: None, + shebang_length: None, + }), + no_link: false, + sha256: None, + size_in_bytes: None, + }], + paths_version: 1, + }; + + let python_info = PythonInfo::from_version( + &Version::from_str("3.11.0").unwrap(), + None, + Platform::Linux64, + ) + .unwrap(); + + let (mut env_paths, mut dir_indices) = new_empty_tree(); + path_parse( + &paths_json, + cache_path, + Some(&python_info), + &mut env_paths, + &mut dir_indices, + ); + + let mount_point = PathBuf::from("/new/prefix"); + let vfs = VirtualFS::with_platform(env_paths, &mount_point, Platform::Linux64); + + // The file should appear under bin/ in the virtual tree + let bin_attr = vfs.do_lookup(1, OsStr::new("bin")).unwrap(); + let script_attr = vfs.do_lookup(bin_attr.ino, OsStr::new("myscript")).unwrap(); + + // Read should perform prefix replacement successfully + let data = vfs.do_read(script_attr.ino, 0, u32::MAX).unwrap(); + let content = String::from_utf8(data.clone()).unwrap(); + assert!( + content.contains("/new/prefix"), + "expected /new/prefix in: {content}" + ); + assert!( + !content.contains("/old/prefix"), + "unexpected /old/prefix in: {content}" + ); + + // getattr size should match actual read content length + assert_eq!( + script_attr.size, + data.len() as u64, + "getattr size ({}) != read content length ({})", + script_attr.size, + data.len() + ); + } + + #[test] + fn test_read_virtual_file() { + let (_tmp, vfs) = create_fixture_with_virtual_files(); + let bin_attr = vfs.do_lookup(1, OsStr::new("bin")).unwrap(); + let ep_attr = vfs.do_lookup(bin_attr.ino, OsStr::new("mytool")).unwrap(); + let data = vfs.do_read(ep_attr.ino, 0, u32::MAX).unwrap(); + assert!(!data.is_empty()); + + let content = String::from_utf8(data).unwrap(); + assert!( + content.contains("#!/new/prefix/bin/python3.11"), + "shebang missing: {content}" + ); + assert!( + content.contains("from mymod import"), + "import missing: {content}" + ); + assert!( + content.contains("main()"), + "function call missing: {content}" + ); + } +} diff --git a/crates/rattler_vfs/tests/install_vs_mount_parity.rs b/crates/rattler_vfs/tests/install_vs_mount_parity.rs new file mode 100644 index 0000000000..f8e68033c5 --- /dev/null +++ b/crates/rattler_vfs/tests/install_vs_mount_parity.rs @@ -0,0 +1,410 @@ +//! Install-vs-mount byte parity tests. +//! +//! These tests verify that `rattler::install::link`'s prefix-replacement +//! routines (which write transformed bytes to a destination Writer) and +//! `rattler_vfs::prefix_replacement`'s ranged-read routines (which return +//! transformed bytes from a source slice) produce **byte-identical** output +//! for the same input. +//! +//! This catches drift between the install-time and mount-time prefix +//! replacement code paths — the same package on disk vs. mounted should be +//! indistinguishable. Shebang scripts are covered explicitly, since the +//! installer rewrites the first line (and may collapse an over-long one to +//! `#!/usr/bin/env `) while the body is spliced at offsets. + +use std::io::Cursor; + +use rattler_conda_types::{Platform, package::FileMode}; +use rattler_vfs::prefix_replacement::{ + binary_ranged_read, collect_binary_offsets, plan_text_replacement, text_ranged_read, +}; + +/// Run install-time prefix replacement and return the resulting bytes. +fn install_replace( + source: &[u8], + placeholder: &str, + target_prefix: &str, + file_mode: FileMode, + platform: Platform, +) -> Vec { + let mut output = Cursor::new(Vec::::new()); + rattler::install::link::copy_and_replace_placeholders( + source, + &mut output, + placeholder, + target_prefix, + &platform, + file_mode, + ) + .expect("install-time replacement should succeed"); + output.into_inner() +} + +/// Run mount-time ranged-read replacement over the full output range, mirroring +/// what the VFS serves for a whole-file read. +fn mount_replace_full( + source: &[u8], + placeholder: &str, + target: &str, + file_mode: FileMode, + platform: Platform, +) -> Vec { + let placeholder_bytes = placeholder.as_bytes(); + let target_bytes = target.as_bytes(); + match file_mode { + FileMode::Text => { + let plan = plan_text_replacement(source, placeholder, target, &platform); + let huge = source.len() + target.len() * (plan.body_offsets.len() + 1) + 1024; + text_ranged_read( + source, + placeholder_bytes, + target_bytes, + &plan.body_offsets, + plan.region_end, + &plan.transformed_region, + 0, + huge, + ) + } + FileMode::Binary => { + let groups = collect_binary_offsets(source, placeholder_bytes); + binary_ranged_read( + source, + placeholder_bytes, + target_bytes, + &groups, + 0, + source.len(), + ) + } + } +} + +/// Assert install-time and mount-time full replacement agree byte-for-byte. +fn assert_full_parity( + source: &[u8], + placeholder: &str, + target: &str, + file_mode: FileMode, + platform: Platform, +) { + let install = install_replace(source, placeholder, target, file_mode, platform); + let mount = mount_replace_full(source, placeholder, target, file_mode, platform); + assert_eq!( + install, mount, + "install vs mount diverged (mode={file_mode:?}, platform={platform}) for {source:?}" + ); +} + +/// Assert that each windowed mount read matches the corresponding slice of the +/// full install output. +fn assert_ranged_parity( + source: &[u8], + placeholder: &str, + target: &str, + file_mode: FileMode, + platform: Platform, + ranges: &[(usize, usize)], +) { + let install = install_replace(source, placeholder, target, file_mode, platform); + for &(start, end) in ranges { + let mount_slice = match file_mode { + FileMode::Text => { + let plan = plan_text_replacement(source, placeholder, target, &platform); + text_ranged_read( + source, + placeholder.as_bytes(), + target.as_bytes(), + &plan.body_offsets, + plan.region_end, + &plan.transformed_region, + start, + end, + ) + } + FileMode::Binary => { + let groups = collect_binary_offsets(source, placeholder.as_bytes()); + binary_ranged_read( + source, + placeholder.as_bytes(), + target.as_bytes(), + &groups, + start, + end, + ) + } + }; + let expected = &install[start.min(install.len())..end.min(install.len())]; + assert_eq!( + mount_slice, expected, + "ranged read [{start}, {end}) diverged (mode={file_mode:?}, platform={platform})" + ); + } +} + +// --------------------------------------------------------------------------- +// Text mode parity (no shebang) +// --------------------------------------------------------------------------- + +#[test] +fn text_mode_simple_replacement_matches_install() { + let placeholder = "/old/conda/prefix"; + let target = "/new/longer/conda/prefix"; + let source = format!("hello {placeholder} world\n"); + assert_full_parity( + source.as_bytes(), + placeholder, + target, + FileMode::Text, + Platform::Linux64, + ); +} + +#[test] +fn text_mode_multiple_replacements_match_install() { + // Three placeholders separated by literal text. + assert_full_parity( + b"a/p b/p c/p d", + "/p", + "/QQQQ", + FileMode::Text, + Platform::Linux64, + ); +} + +#[test] +fn text_mode_no_replacement_match_install() { + assert_full_parity( + b"completely unrelated content with no placeholder\n", + "/old/conda/prefix", + "/new/conda/prefix", + FileMode::Text, + Platform::Linux64, + ); +} + +#[test] +fn text_mode_shorter_target_matches_install() { + let placeholder = "/long/old/prefix/path"; + let source = format!("{placeholder}/bin/python\n"); + assert_full_parity( + source.as_bytes(), + placeholder, + "/short", + FileMode::Text, + Platform::Linux64, + ); +} + +// --------------------------------------------------------------------------- +// Shebang parity — the installer rewrites the first line; the mount path must +// reproduce those exact bytes. +// --------------------------------------------------------------------------- + +#[test] +fn shebang_kept_short_prefix_matches_install() { + let placeholder = "/opt/old/prefix"; + let target = "/opt/new"; + let source = format!("#!{placeholder}/bin/python\nimport os # {placeholder}/lib\n"); + let bytes = source.into_bytes(); + assert_full_parity( + &bytes, + placeholder, + target, + FileMode::Text, + Platform::Linux64, + ); + assert_ranged_parity( + &bytes, + placeholder, + target, + FileMode::Text, + Platform::Linux64, + &[(0, 3), (2, 25), (10, 40), (0, 4096), (35, 4096)], + ); +} + +#[test] +fn shebang_collapses_long_prefix_matches_install() { + // A target well over the 127-byte Linux limit forces the first line to + // collapse to `#!/usr/bin/env `. + let placeholder = "/opt/old"; + let mut target = String::from("/opt"); + for _ in 0..20 { + target.push_str("/verylongsegment"); + } + assert!(target.len() > 127); + let source = format!("#!{placeholder}/bin/perl\nprint 1;\n"); + assert_full_parity( + source.as_bytes(), + placeholder, + &target, + FileMode::Text, + Platform::Linux64, + ); +} + +#[test] +fn shebang_no_trailing_newline_matches_install() { + let placeholder = "/opt/old/prefix"; + let source = format!("#!{placeholder}/bin/python"); + assert_full_parity( + source.as_bytes(), + placeholder, + "/opt/new", + FileMode::Text, + Platform::Linux64, + ); +} + +#[test] +fn shebang_multiple_occurrences_in_line_matches_install() { + let placeholder = "/opt/old"; + let source = format!("#!{placeholder}/bin/python -S {placeholder}/site\nx = 1\n"); + assert_full_parity( + source.as_bytes(), + placeholder, + "/opt/new", + FileMode::Text, + Platform::Linux64, + ); +} + +#[test] +fn shebang_only_occurrence_in_line_matches_install() { + let placeholder = "/opt/old/prefix"; + let source = format!("#!{placeholder}/bin/python\nimport os\n"); + assert_full_parity( + source.as_bytes(), + placeholder, + "/opt/new", + FileMode::Text, + Platform::Linux64, + ); +} + +#[test] +fn shebang_non_rewriting_target_matches_install() { + // On a non-Unix target (e.g. a noarch package mounted on Windows) there is + // no shebang machinery: the region gets plain placeholder replacement, like + // the body. + let placeholder = "/opt/old"; + let target = "/opt/new"; + let source = format!("#!{placeholder}/bin/python\nimport os # {placeholder}/lib\n"); + let bytes = source.into_bytes(); + assert_full_parity(&bytes, placeholder, target, FileMode::Text, Platform::Win64); + assert_ranged_parity( + &bytes, + placeholder, + target, + FileMode::Text, + Platform::Win64, + &[(0, 5), (2, 30), (0, 4096)], + ); +} + +// --------------------------------------------------------------------------- +// Binary mode parity (c-string with null terminator and padding) +// --------------------------------------------------------------------------- + +/// Build a binary blob containing a c-string with a placeholder, terminated +/// by a null byte. +fn build_cstring(placeholder: &str, suffix: &str) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(placeholder.as_bytes()); + buf.extend_from_slice(suffix.as_bytes()); + buf.push(0u8); + // Some trailing context so the test catches replacement past the null. + buf.extend_from_slice(b"\x01\x02\x03tail\x00"); + buf +} + +#[test] +fn binary_mode_cstring_with_padding_matches_install() { + let source = build_cstring("/long/old/prefix", "/lib/foo.so"); + assert_full_parity( + &source, + "/long/old/prefix", + "/short", + FileMode::Binary, + Platform::Linux64, + ); +} + +#[test] +fn binary_mode_no_replacement_matches_install() { + assert_full_parity( + b"\x7fELF unrelated binary contents\x00\x01\x02\x00", + "/long/old/prefix", + "/short", + FileMode::Binary, + Platform::Linux64, + ); +} + +#[test] +fn binary_mode_multiple_cstrings_match_install() { + let placeholder = "/long/old/prefix"; + let mut source = Vec::new(); + source.extend_from_slice(placeholder.as_bytes()); + source.extend_from_slice(b"/a\x00"); + source.extend_from_slice(placeholder.as_bytes()); + source.extend_from_slice(b"/b\x00"); + source.extend_from_slice(b"unrelated\x00"); + assert_full_parity( + &source, + placeholder, + "/p", + FileMode::Binary, + Platform::Linux64, + ); +} + +// --------------------------------------------------------------------------- +// Ranged-read parity: mount-time read of an arbitrary slice should equal the +// corresponding slice of the install-time output. +// --------------------------------------------------------------------------- + +#[test] +fn ranged_read_text_matches_install_slice() { + let placeholder = "/old/conda/prefix"; + let target = "/new/longer/conda/prefix"; + let source = format!("hello {placeholder} middle {placeholder} tail\n"); + let install_len = install_replace( + source.as_bytes(), + placeholder, + target, + FileMode::Text, + Platform::Linux64, + ) + .len(); + assert_ranged_parity( + source.as_bytes(), + placeholder, + target, + FileMode::Text, + Platform::Linux64, + &[(0, 5), (3, 20), (7, install_len), (0, 1)], + ); +} + +#[test] +fn ranged_read_binary_matches_install_slice() { + let source = build_cstring("/long/old/prefix", "/bin/foo"); + let install_len = install_replace( + &source, + "/long/old/prefix", + "/p", + FileMode::Binary, + Platform::Linux64, + ) + .len(); + assert_ranged_parity( + &source, + "/long/old/prefix", + "/p", + FileMode::Binary, + Platform::Linux64, + &[(0, 4), (2, 16), (0, install_len), (10, 20)], + ); +} diff --git a/pixi.lock b/pixi.lock index 1f402bed24..19f39acdcd 100644 --- a/pixi.lock +++ b/pixi.lock @@ -599,6 +599,48 @@ environments: - conda: https://prefix.dev/conda-forge/win-64/vcomp14-14.44.35208-h818238b_32.conda - conda_source: rattler[9e891123] @ crates/rattler-bin - conda_source: rattler_index[9e891123] @ crates/rattler_index + rattler-vfs: + channels: + - url: https://prefix.dev/conda-forge/ + packages: + linux-64: + - conda: https://prefix.dev/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://prefix.dev/conda-forge/linux-64/coreutils-9.5-hd590300_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + - conda: https://prefix.dev/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda + - conda: https://prefix.dev/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + - conda: https://prefix.dev/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + - conda: https://prefix.dev/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://prefix.dev/conda-forge/linux-64/nushell-0.106.1-h7663d75_1.conda + - conda: https://prefix.dev/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda_source: rattler[667739d7] @ crates/rattler-bin + osx-64: + - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/coreutils-9.5-h10d778d_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_2.conda + - conda: https://prefix.dev/conda-forge/osx-64/nushell-0.106.1-haf43392_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/openssl-3.6.3-hc881268_0.conda + - conda_source: rattler[bd866021] @ crates/rattler-bin + osx-arm64: + - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/coreutils-9.5-h93a5062_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/nushell-0.106.1-h267bf11_1.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + - conda_source: rattler[ce9063e9] @ crates/rattler-bin + win-64: + - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.6.17-h4c7d964_0.conda + - conda: https://prefix.dev/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda + - conda: https://prefix.dev/conda-forge/win-64/nushell-0.106.1-h7281763_1.conda + - conda: https://prefix.dev/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda + - conda: https://prefix.dev/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda + - conda: https://prefix.dev/conda-forge/win-64/vc-14.5-h1b7c187_39.conda + - conda: https://prefix.dev/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda + - conda: https://prefix.dev/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda + - conda_source: rattler[4f6e0f0e] @ crates/rattler-bin s3: channels: - url: https://prefix.dev/conda-forge/ @@ -1251,6 +1293,16 @@ packages: license_family: GPL size: 33231 timestamp: 1759965946160 +- conda: https://prefix.dev/conda-forge/linux-64/coreutils-9.5-hd590300_0.conda + sha256: 7cd3b0f55aa55bb27b045c30f32b3f6b874ecc006f3abcb274c71a3bcbacb358 + md5: 126d457e0e7a535278e808a7d8960015 + depends: + - libgcc-ng >=12 + license: GPL-3.0-or-later + license_family: GPL + run_exports: {} + size: 3014238 + timestamp: 1711655132451 - conda: https://prefix.dev/conda-forge/linux-64/cxx-compiler-1.11.0-hfcd1e18_0.conda sha256: 3fcc97ae3e89c150401a50a4de58794ffc67b1ed0e1851468fcc376980201e25 md5: 5da8c935dca9186673987f79cef0b2a5 @@ -1331,6 +1383,23 @@ packages: license_family: GPL size: 69987984 timestamp: 1759965829687 +- conda: https://prefix.dev/conda-forge/linux-64/gcc_impl_linux-64-15.2.0-ha6850e4_19.conda + sha256: 2c0b2870210cfc7e8561676844f4bd3884b345c19100e1da84cbfb4994d8f104 + md5: 1c2eddf7501ef92050d3fbb11df61ee3 + depends: + - binutils_impl_linux-64 >=2.45 + - libgcc >=15.2.0 + - libgcc-devel_linux-64 15.2.0 hcc6f6b0_119 + - libgomp >=15.2.0 + - libsanitizer 15.2.0 h90f66d4_19 + - libstdcxx >=15.2.0 + - libstdcxx-devel_linux-64 15.2.0 hd446a21_119 + - sysroot_linux-64 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 81043749 + timestamp: 1778860073982 - conda: https://prefix.dev/conda-forge/linux-64/gcc_impl_linux-64-15.2.0-he420e7e_18.conda sha256: a088cfd3ae6fa83815faa8703bc9d21cc915f17bd1b51aac9c16ddf678da21e4 md5: cf56b6d74f580b91fd527e10d9a2e324 @@ -1373,6 +1442,20 @@ packages: - libgcc >=15 size: 29081 timestamp: 1777144726741 +- conda: https://prefix.dev/conda-forge/linux-64/gcc_linux-64-15.2.0-h7be306e_27.conda + sha256: b24b13d467898a9b9a17a868a2686412a98f8935dc7cc51547dd90645d4e8436 + md5: 28bc49875f9c38e2401696b3e48d0798 + depends: + - gcc_impl_linux-64 15.2.0.* + - binutils_linux-64 + - sysroot_linux-64 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - libgcc >=15 + size: 29330 + timestamp: 1781279944230 - conda: https://prefix.dev/conda-forge/linux-64/gfortran-14.3.0-he448592_7.conda sha256: 7ab008dd3dc5e6ca0de2614771019a1d35480d51df6216c96b1cf6a5e660ee40 md5: 94394acdc56dcb4d55dddf0393134966 @@ -1454,6 +1537,20 @@ packages: license_family: MIT size: 12129203 timestamp: 1720853576813 +- conda: https://prefix.dev/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda + sha256: fbf86c4a59c2ed05bbffb2ba25c7ed94f6185ec30ecb691615d42342baa1a16a + md5: c80d8a3b84358cb967fa81e7075fbc8a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - icu >=78.3,<79.0a0 + size: 12723451 + timestamp: 1773822285671 - conda: https://prefix.dev/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda sha256: 0960d06048a7185d3542d850986d807c6e37ca2e644342dd0c72feefcf26c2a4 md5: b38117a3c920364aff79f870c984b4a3 @@ -1498,6 +1595,24 @@ packages: - krb5 >=1.22.2,<1.23.0a0 size: 1386730 timestamp: 1769769569681 +- conda: https://prefix.dev/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.conda + sha256: 9b07046870772f28740e3f6149f09ff222843733087a33c5540b169c6289652d + md5: 54157a1c8c0bb70f62dd0b17fba7e7f2 + depends: + - __glibc >=2.17,<3.0.a0 + - keyutils >=1.6.3,<2.0a0 + - libedit >=3.1.20250104,<3.2.0a0 + - libedit >=3.1.20250104,<4.0a0 + - libgcc >=14 + - libstdcxx >=14 + - openssl >=3.5.7,<4.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - krb5 >=1.22.2,<1.23.0a0 + size: 1388990 + timestamp: 1781859420533 - conda: https://prefix.dev/conda-forge/linux-64/ld_impl_linux-64-2.44-h1aa0949_5.conda sha256: dab1fbf65abb05d3f2ee49dff90d60eeb2e02039fcb561343c7cea5dea523585 md5: 511ed8935448c1875776b60ad3daf3a1 @@ -1575,6 +1690,26 @@ packages: - libcurl >=8.20.0,<9.0a0 size: 468706 timestamp: 1777461492876 +- conda: https://prefix.dev/conda-forge/linux-64/libcurl-8.21.0-hae6b9f4_2.conda + sha256: ba8354084b34fce56d5697982fce4a384c148e972e15c0ab891187617fe7ec29 + md5: f9c59d277a16ec8f272b2d5dd2ec3335 + depends: + - __glibc >=2.17,<3.0.a0 + - krb5 >=1.22.2,<1.23.0a0 + - libgcc >=14 + - libnghttp2 >=1.68.1,<2.0a0 + - libpsl >=0.22.0,<0.23.0a0 + - libssh2 >=1.11.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.7,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: curl + license_family: MIT + run_exports: + weak: + - libcurl >=8.21.0,<9.0a0 + size: 480327 + timestamp: 1782911787190 - conda: https://prefix.dev/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda sha256: d789471216e7aba3c184cd054ed61ce3f6dac6f87a50ec69291b9297f8c18724 md5: c277e0a4d549b03ac1e9d6cbbe3d017b @@ -1627,6 +1762,19 @@ packages: run_exports: {} size: 76624 timestamp: 1774719175983 +- conda: https://prefix.dev/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + sha256: 16feffd9ddbbe5b718515d38ee376c685ba95491cd901244e24671d20b952a77 + md5: b24d3c612f71e7aa74158d92106318b2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + run_exports: {} + size: 77856 + timestamp: 1781203599810 - conda: https://prefix.dev/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda sha256: 25cbdfa65580cfab1b8d15ee90b4c9f1e0d72128f1661449c9a999d341377d54 md5: 35f29eec58405aaf55e01cb470d8c26a @@ -1664,6 +1812,20 @@ packages: run_exports: {} size: 1041788 timestamp: 1771378212382 +- conda: https://prefix.dev/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + sha256: 8e0a3b5e41272e5678499b5dfc4cddb673f9e935de01eb0767ce857001229f46 + md5: 57736f29cc2b0ec0b6c2952d3f101b6a + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==15.2.0=*_19 + - libgomp 15.2.0 he0feb66_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 1041084 + timestamp: 1778269013026 - conda: https://prefix.dev/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_18.conda sha256: e318a711400f536c81123e753d4c797a821021fb38970cebfb3f454126016893 md5: d5e96b1ed75ca01906b3d2469b4ce493 @@ -1676,6 +1838,18 @@ packages: - libgcc size: 27526 timestamp: 1771378224552 +- conda: https://prefix.dev/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda + sha256: 9dcf54adfaa5e861123c2da4f2f0451a685464ea7e5a41ad91cf67b31d658d98 + md5: 331ee9b72b9dff570d56b1302c5ab37d + depends: + - libgcc 15.2.0 he0feb66_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - libgcc + size: 27694 + timestamp: 1778269016987 - conda: https://prefix.dev/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda sha256: 2045066dd8e6e58aaf5ae2b722fb6dfdbb57c862b5f34ac7bfb58c40ef39b6ad md5: 280ea6eee9e2ddefde25ff799c4f0363 @@ -1718,6 +1892,18 @@ packages: - _openmp_mutex >=4.5 size: 603262 timestamp: 1771378117851 +- conda: https://prefix.dev/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + sha256: 5abe4ab9d93f6c9757d654f1969ae2267d4505315c1f2f8fe705fd60af084f1b + md5: faac990cb7aedc7f3a2224f2c9b0c26c + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 603817 + timestamp: 1778268942614 - conda: https://prefix.dev/conda-forge/linux-64/libhwloc-2.12.1-default_h7f8ec31_1002.conda sha256: f7fbc792dbcd04bf27219c765c10c239937b34c6c1a1f77a5827724753e02da1 md5: c01021ae525a76fe62720c7346212d74 @@ -1835,6 +2021,21 @@ packages: - libnghttp2 >=1.68.1,<2.0a0 size: 663344 timestamp: 1773854035739 +- conda: https://prefix.dev/conda-forge/linux-64/libpsl-0.22.0-hd9031aa_0.conda + sha256: cd11890a2c1f14d0342bfcbd81f97152d61dc71c27c7085efc13705a8f64f7c9 + md5: e2834a423b3967e7b3b1e901c4a5f42f + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libpsl >=0.22.0,<0.23.0a0 + size: 83067 + timestamp: 1782394772411 - conda: https://prefix.dev/conda-forge/linux-64/libsanitizer-14.3.0-hd08acf3_7.conda sha256: 73eb65f58ed086cf73fb9af3be4a9b288f630e9c2e1caacc75aff5f265d2dda2 md5: 716f4c96e07207d74e635c915b8b3f8b @@ -1860,6 +2061,20 @@ packages: - libsanitizer 15.2.0 size: 8095113 timestamp: 1771378289674 +- conda: https://prefix.dev/conda-forge/linux-64/libsanitizer-15.2.0-h90f66d4_19.conda + sha256: 7a58892a52739ce4c0f7109de9e91b4353104748eb04fc6441d88e8af444ba99 + md5: 67eef12ce33f7ff99900c212d7076fc2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=15.2.0 + - libstdcxx >=15.2.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + weak: + - libsanitizer 15.2.0 + size: 7930689 + timestamp: 1778269054623 - conda: https://prefix.dev/conda-forge/linux-64/libsqlite-3.51.0-hee844dc_0.conda sha256: 4c992dcd0e34b68f843e75406f7f303b1b97c248d18f3c7c330bdc0bc26ae0b3 md5: 729a572a3ebb8c43933b30edcc628ceb @@ -1911,6 +2126,19 @@ packages: run_exports: {} size: 5852330 timestamp: 1771378262446 +- conda: https://prefix.dev/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + sha256: dff1058c76ec6b8759e41cefa2508162d00e4a5e6721aa68ec3fd10094e702dc + md5: 5794b3bdc38177caf969dabd3af08549 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 15.2.0 he0feb66_19 + constrains: + - libstdcxx-ng ==15.2.0=*_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 5852044 + timestamp: 1778269036376 - conda: https://prefix.dev/conda-forge/linux-64/libstdcxx-ng-15.2.0-h4852527_7.conda sha256: 024fd46ac3ea8032a5ec3ea7b91c4c235701a8bf0e6520fe5e6539992a6bd05f md5: f627678cf829bd70bccf141a19c3ad3e @@ -1943,6 +2171,19 @@ packages: - libuv >=1.51.0,<2.0a0 size: 895108 timestamp: 1753948278280 +- conda: https://prefix.dev/conda-forge/linux-64/libuv-1.52.1-h280c20c_0.conda + sha256: e28e4519223f78b3163599ca89c3f2d80bfb53e907e7fc74e806e60d1efa578b + md5: 4e33d49bf4fc853855a3b00643aa5484 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libuv >=1.52.1,<2.0a0 + size: 419935 + timestamp: 1779396012261 - conda: https://prefix.dev/conda-forge/linux-64/libxml2-16-2.15.1-ha9997c6_0.conda sha256: 71436e72a286ef8b57d6f4287626ff91991eb03c7bdbe835280521791efd1434 md5: e7733bc6785ec009e47a224a71917e84 @@ -2119,6 +2360,7 @@ packages: - __glibc >=2.17 license: MIT license_family: MIT + run_exports: {} size: 10460237 timestamp: 1759934195127 - conda: https://prefix.dev/conda-forge/linux-64/nushell-0.110.0-h25c72ac_2.conda @@ -2161,6 +2403,20 @@ packages: - openssl >=3.6.2,<4.0a0 size: 3167099 timestamp: 1775587756857 +- conda: https://prefix.dev/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + sha256: d48f5c22b9897c01e4dff3680f1f57ceb02711ab9c62f74339b080419dfad34b + md5: 79dd2074b5cd5c5c6b2930514a11e22d + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3159683 + timestamp: 1781069855778 - conda: https://prefix.dev/conda-forge/linux-64/pkg-config-0.29.2-h4bc722e_1009.conda sha256: c9601efb1af5391317e04eca77c6fe4d716bf1ca1ad8da2a05d15cb7c28d7d4e md5: 1bee70681f504ea424fb07cdb090c001 @@ -2327,6 +2583,21 @@ packages: - __glibc >=2.17 size: 11493 timestamp: 1777897031483 +- conda: https://prefix.dev/conda-forge/linux-64/rust_linux-64-1.95.0-hb4ea61c_2.conda + sha256: f0e7e6b6dbec4ff4ef3c9c080a82f52af08c4983b568af0433f217be1045a6fe + md5: 371cfdd3ecb3fd19343bf7f21101da48 + depends: + - gcc_linux-64 + - rust 1.95.0.* + - rust-std-x86_64-unknown-linux-gnu 1.95.0.* + - sysroot_linux-64 >=2.17 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong_constrains: + - __glibc >=2.17 + size: 11761 + timestamp: 1780066916977 - conda: https://prefix.dev/conda-forge/linux-64/s2n-1.6.0-h8399546_1.conda sha256: f5b294ce9b40d15a4bc31b315364459c0d702dd3e8751fe8735c88ac6a9ddc67 md5: 8dbc626b1b11e7feb40a14498567b954 @@ -2479,6 +2750,24 @@ packages: run_exports: {} size: 131039 timestamp: 1776865545798 +- conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.6.17-h4c7d964_0.conda + sha256: 7f458e4a82514d7bebbfef23d92817794a16aaf1c748a15f04870d4fb49aeab2 + md5: b9696b2cf00dfeec138c70cee38ed192 + depends: + - __win + license: ISC + run_exports: {} + size: 129352 + timestamp: 1781709016515 +- conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + sha256: f8e3c730fa14ee3f170493779f06522c4acf89169f43db4f039727709b6419cf + md5: a9965dd99f683c5f444428f896635716 + depends: + - __unix + license: ISC + run_exports: {} + size: 128866 + timestamp: 1781708962055 - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda sha256: ab29d57dc70786c1269633ba3dff20288b81664d3ff8d21af995742e2bb03287 md5: 962b9857ee8e7018c22f2776ffa0b2d7 @@ -2498,6 +2787,16 @@ packages: run_exports: {} size: 10801573 timestamp: 1776837550762 +- conda: https://prefix.dev/conda-forge/noarch/compiler-rt22_osx-64-22.1.8-hcf80936_1.conda + sha256: 649d6ceeba53844d0764818ba60868645756561f68dd9f892a055f00c530e954 + md5: bcf37da1bdd75d1a3c313e3c06561357 + constrains: + - compiler-rt >=9.0.1 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 10844896 + timestamp: 1781742158909 - conda: https://prefix.dev/conda-forge/noarch/compiler-rt22_osx-arm64-22.1.4-h7e67a1e_0.conda sha256: 063f451ae646868f72e65b80a1db0b9076a0852d1f4f6fb90f228ef2aa2923d9 md5: 24fe8a66a15acb324986cabdb8e1d6f5 @@ -2508,6 +2807,16 @@ packages: run_exports: {} size: 10910882 timestamp: 1776838303277 +- conda: https://prefix.dev/conda-forge/noarch/compiler-rt22_osx-arm64-22.1.8-h7e67a1e_1.conda + sha256: 0d3cb7b6386265b8a308b1a0a83a0e718e9d78b0e61f7d7a7bb80b47760e35da + md5: 3e936f877a0eb8381d30f14810a39acd + constrains: + - compiler-rt >=9.0.1 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 11005161 + timestamp: 1781741132641 - conda: https://prefix.dev/conda-forge/noarch/compiler-rt_osx-64-19.1.7-h138dee1_1.conda sha256: e6effe89523fc6143819f7a68372b28bf0c176af5b050fe6cf75b62e9f6c6157 md5: 32deecb68e11352deaa3235b709ddab2 @@ -2532,6 +2841,18 @@ packages: run_exports: {} size: 16574 timestamp: 1776837668381 +- conda: https://prefix.dev/conda-forge/noarch/compiler-rt_osx-64-22.1.8-h694c41f_1.conda + sha256: bcccc19cb9c64f9d734e05fcb73a63a1031264395f239d1f29116112cace4ae4 + md5: bda33fa1a1bef4edd443ef3a77f7b435 + depends: + - compiler-rt22_osx-64 22.1.8 hcf80936_1 + constrains: + - clang 22.1.8 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 16711 + timestamp: 1781742230028 - conda: https://prefix.dev/conda-forge/noarch/compiler-rt_osx-arm64-19.1.7-he32a8d3_1.conda sha256: 8c32a3db8adf18ed58197e8895ce4f24a83ed63c817512b9a26724753b116f2a md5: 8d99c82e0f5fed6cc36fcf66a11e03f0 @@ -2556,6 +2877,18 @@ packages: run_exports: {} size: 16667 timestamp: 1776838471158 +- conda: https://prefix.dev/conda-forge/noarch/compiler-rt_osx-arm64-22.1.8-hce30654_1.conda + sha256: e8eccc163a8ca1fcd051826d566934d3785413b5a57d658902eb775026af3856 + md5: d50cec26659fa7e7c285803c77abab9b + depends: + - compiler-rt22_osx-arm64 22.1.8 h7e67a1e_1 + constrains: + - clang 22.1.8 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 16698 + timestamp: 1781741176960 - conda: https://prefix.dev/conda-forge/noarch/distro-1.8.0-pyhd8ed1ab_0.conda sha256: 0d01c4da6d4f0a935599210f82ac0630fa9aeb4fc37cbbc78043a932a39ec4f3 md5: 67999c5465064480fa8016d00ac768f6 @@ -2612,6 +2945,16 @@ packages: run_exports: {} size: 3085932 timestamp: 1771378098166 +- conda: https://prefix.dev/conda-forge/noarch/libgcc-devel_linux-64-15.2.0-hcc6f6b0_119.conda + sha256: 38a557eba305468ac1f90ac85e50d8defd76141cb0b8a43b2fc1aca71dd5d5f2 + md5: 683fcb168e1df9a21fa80d5aa2d9330b + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 3095909 + timestamp: 1778268932148 - conda: https://prefix.dev/conda-forge/noarch/libstdcxx-devel_linux-64-14.3.0-h85bb3a7_107.conda sha256: 54ba5632d93faebbec3899d9df84c6e71c4574d70a2f3babfc5aac4247874038 md5: eaf0f047b048c4d86a4b8c60c0e95f38 @@ -2631,6 +2974,16 @@ packages: run_exports: {} size: 20669511 timestamp: 1771378139786 +- conda: https://prefix.dev/conda-forge/noarch/libstdcxx-devel_linux-64-15.2.0-hd446a21_119.conda + sha256: a2385f3611d5cd25378f9cf2367183320731709c067ddd08d43330d3170f15b8 + md5: bcfe7eae40158c3e355d2f9d3ed41230 + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 20765069 + timestamp: 1778268963689 - conda: https://prefix.dev/conda-forge/noarch/macosx_deployment_target_osx-64-13.0-h2830193_7.conda sha256: 5c099551cdbb70bd478d01b415eba5290808be63aba49546a0a07544ff36457f md5: 52c41829621dbc440345b3f360df1f00 @@ -3243,6 +3596,21 @@ packages: run_exports: {} size: 818667 timestamp: 1776945226955 +- conda: https://prefix.dev/conda-forge/osx-64/clang-22-22.1.8-default_h9a620b7_3.conda + sha256: d84f7495d2c90344be9e2785feee1d385f878423de36dab63decb3c2b4b3d7f9 + md5: 3d45651c7a20f1df3518217a2baa37a1 + depends: + - compiler-rt22 ==22.1.8 + - libclang-cpp22.1 ==22.1.8 default_h5a1b869_3 + - libcxx >=22.1.8 + - __osx >=11.0 + - libllvm22 >=22.1.8,<22.2.0a0 + - libclang-cpp22.1 >=22.1.8,<22.2.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 928258 + timestamp: 1782358919535 - conda: https://prefix.dev/conda-forge/osx-64/clang_impl_osx-64-19.1.7-hc73cdc9_25.conda sha256: 88edc0b34affbfffcec5ffea59b432ee3dd114124fd4d5f992db6935421f4a64 md5: 76954503be09430fb7f4683a61ffb7b0 @@ -3270,6 +3638,25 @@ packages: run_exports: {} size: 27895 timestamp: 1776945498764 +- conda: https://prefix.dev/conda-forge/osx-64/clang_impl_osx-64-22.1.8-default_h0f45732_3.conda + sha256: 91b0f0450cdacbc19f804c7b437a5e1b5004da0b14bacd6cd0c44cba0280a9e8 + md5: 6787c4d8305af9e1c8760aef8261dc77 + depends: + - clang-22 ==22.1.8 default_h9a620b7_3 + - compiler-rt_osx-64 + - compiler-rt 22.1.8.* + - cctools_impl_osx-64 + - ld64_osx-64 * llvm22_1_* + - __osx >=11.0 + - libxml2 + - libxml2-16 >=2.14.6 + - zstd >=1.5.7,<1.6.0a0 + - libzlib >=1.3.2,<2.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 32344 + timestamp: 1782358919535 - conda: https://prefix.dev/conda-forge/osx-64/clang_osx-64-19.1.7-h7e5c614_25.conda sha256: 6435fdeae76f72109bc9c7b41596104075a2a8a9df3ee533bd98bb06548bbc83 md5: a526ba9df7e7d5448d57b33941614dae @@ -3291,6 +3678,18 @@ packages: run_exports: {} size: 20971 timestamp: 1776998812584 +- conda: https://prefix.dev/conda-forge/osx-64/clang_osx-64-22.1.8-h97b245c_32.conda + sha256: d2f21cd1a357ba0ca9e7cfd9a06a0536e0301102f619291cc899053b7a00a1f3 + md5: d8d4a1078b5ec2eb91586aff47455487 + depends: + - cctools_osx-64 + - clang_impl_osx-64 22.1.8.* + - sdkroot_env_osx-64 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 20350 + timestamp: 1781815925866 - conda: https://prefix.dev/conda-forge/osx-64/clangxx-19.1.7-default_h1c12a56_5.conda sha256: 6553c7b6a898bd00c218656d3438dc3a70f2bb79f795ce461792c55304558af2 md5: 6b6f3133d60b448c0f12885f53d5ed09 @@ -3367,6 +3766,19 @@ packages: run_exports: {} size: 16460 timestamp: 1776837669831 +- conda: https://prefix.dev/conda-forge/osx-64/compiler-rt-22.1.8-h694c41f_1.conda + sha256: f77601aeab9695afad2bde5ec895a73b78aec25ca1c6f2ecd3489ec92d5deba2 + md5: a4c0351afa6998160c14520565f4d581 + depends: + - compiler-rt22 22.1.8 h1637cdf_1 + - libcompiler-rt 22.1.8 h1637cdf_1 + constrains: + - clang 22.1.8 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 16551 + timestamp: 1781742232204 - conda: https://prefix.dev/conda-forge/osx-64/compiler-rt22-22.1.4-h1637cdf_0.conda sha256: a6d98bef8c92940df9940c4c620e5f9a0e47486c97e97a87f9d18fd41f5e28ba md5: 1a85ba2ed4edaa0e3fd54e0beb36460d @@ -3378,6 +3790,25 @@ packages: run_exports: {} size: 99155 timestamp: 1776837666232 +- conda: https://prefix.dev/conda-forge/osx-64/compiler-rt22-22.1.8-h1637cdf_1.conda + sha256: 178a47b0a6c89992898d01346efebde5beacca84bfba759c569fdb8f7ab0b18a + md5: 0c9c141740c8869a524f9730743d2297 + depends: + - __osx >=11.0 + - compiler-rt22_osx-64 22.1.8.* + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 99043 + timestamp: 1781742227635 +- conda: https://prefix.dev/conda-forge/osx-64/coreutils-9.5-h10d778d_0.conda + sha256: 7a29ae82cf1c455b4956c8311ae97832460c3585f0d8789fd82161dd2a20d1fd + md5: 8332c7ae324c9fc4b22cc3d84a0582e8 + license: GPL-3.0-or-later + license_family: GPL + run_exports: {} + size: 1374585 + timestamp: 1711655512907 - conda: https://prefix.dev/conda-forge/osx-64/cxx-compiler-1.11.0-h307afc9_0.conda sha256: d6976f8d8b51486072abfe1e76a733688380dcbd1a8e993a43d59b80f7288478 md5: 463bb03bb27f9edc167fb3be224efe96 @@ -3523,6 +3954,20 @@ packages: - libclang-cpp22.1 >=22.1.4,<22.2.0a0 size: 15007676 timestamp: 1776944952122 +- conda: https://prefix.dev/conda-forge/osx-64/libclang-cpp22.1-22.1.8-default_h5a1b869_3.conda + sha256: f61fdbaf250135d9fe8981de0dbfbe8d4e983efcb94c6dc0d1b8b5fc8c21317d + md5: 300864557417d3d65d917181fb959c50 + depends: + - libcxx >=22.1.8 + - __osx >=11.0 + - libllvm22 >=22.1.8,<22.2.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: + weak: + - libclang-cpp22.1 >=22.1.8,<22.2.0a0 + size: 17143866 + timestamp: 1782358919535 - conda: https://prefix.dev/conda-forge/osx-64/libcompiler-rt-22.1.4-h1637cdf_0.conda sha256: decd9684b3658dab99667491785413a587952fd8efb291392ac9aab6745868f0 md5: 1c9992375eea400d47b72deeee70f4a6 @@ -3537,6 +3982,20 @@ packages: - libcompiler-rt >=22.1.4 size: 1373637 timestamp: 1776837637097 +- conda: https://prefix.dev/conda-forge/osx-64/libcompiler-rt-22.1.8-h1637cdf_1.conda + sha256: fcb3d5a2ea4c0d820a724abe93310c18192272acf8a55f7c3d532c3dd9b42f3c + md5: 161eb7c919a59f4ac77185fdaec8cdfd + depends: + - __osx >=11.0 + constrains: + - compiler-rt >=9.0.1 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: + weak: + - libcompiler-rt >=22.1.8 + size: 1373236 + timestamp: 1781742207934 - conda: https://prefix.dev/conda-forge/osx-64/libcurl-8.17.0-h7dd4100_0.conda sha256: a58ca5a28c1cb481f65800781cee9411bd68e8bda43a69817aaeb635d25f7d75 md5: b3985ef7ca4cd2db59756bae2963283a @@ -3571,6 +4030,16 @@ packages: run_exports: {} size: 567125 timestamp: 1776815441323 +- conda: https://prefix.dev/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda + sha256: 57ee997f1f800cf38abc743c0f0a9ddfe6a101c697c35510452ce6f4ddf96361 + md5: 0f600157f28fc7bc9549ecafdfa5bc12 + depends: + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 566717 + timestamp: 1781672189697 - conda: https://prefix.dev/conda-forge/osx-64/libcxx-devel-19.1.7-h7c275be_1.conda sha256: d1ee08b0614d8f9bca84aa91b4015c5efa96162fd865590a126544243699dfc6 md5: 0f3f15e69e98ce9b3307c1d8309d1659 @@ -3675,6 +4144,23 @@ packages: - libllvm22 >=22.1.4,<22.2.0a0 size: 31844852 timestamp: 1776826206742 +- conda: https://prefix.dev/conda-forge/osx-64/libllvm22-22.1.8-hab754da_1.conda + sha256: c2bd652c5a6c4f0e6029786c4e59c54c09018049664006e94d674db4561238ea + md5: 8de4654ab7428c890ef09cee05e11b42 + depends: + - __osx >=11.0 + - libcxx >=19 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: + weak: + - libllvm22 >=22.1.8,<22.2.0a0 + size: 31843736 + timestamp: 1781793214214 - conda: https://prefix.dev/conda-forge/osx-64/liblzma-5.8.1-hd471939_2.conda sha256: 7e22fd1bdb8bf4c2be93de2d4e718db5c548aa082af47a7430eb23192de6bb36 md5: 8468beea04b9065b9807fc8b9cdc5894 @@ -3916,6 +4402,20 @@ packages: run_exports: {} size: 18979793 timestamp: 1776826466814 +- conda: https://prefix.dev/conda-forge/osx-64/llvm-tools-22-22.1.8-hc181bea_1.conda + sha256: 78a4f92ab6172e07cee2769b0548d4d44cbaf528ff3192850380c42793ed158e + md5: 4d3b065f628dd16844b248415e7ca82f + depends: + - __osx >=11.0 + - libcxx >=19 + - libllvm22 22.1.8 hab754da_1 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 18979108 + timestamp: 1781793490824 - conda: https://prefix.dev/conda-forge/osx-64/llvm-tools-22.1.4-h1637cdf_0.conda sha256: c7348a99758d6b9cb2e15aa7b3d4fbfae1a4f9e8217570a75f87d038ab575b89 md5: 95c7c0415da24827c936461bb8410642 @@ -3933,6 +4433,23 @@ packages: run_exports: {} size: 51799 timestamp: 1776826600676 +- conda: https://prefix.dev/conda-forge/osx-64/llvm-tools-22.1.8-h1637cdf_1.conda + sha256: 3c5560b89b4ea98f8ef6ed5ce1375ad2845023ff1ff08617667925c0acde9f1b + md5: 9db34ed898f11f43b16715cafc2d5e6d + depends: + - __osx >=11.0 + - libllvm22 22.1.8 hab754da_1 + - llvm-tools-22 22.1.8 hc181bea_1 + constrains: + - llvm 22.1.8 + - llvmdev 22.1.8 + - clang 22.1.8 + - clang-tools 22.1.8 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 51178 + timestamp: 1781793626474 - conda: https://prefix.dev/conda-forge/osx-64/make-4.4.1-h00291cd_2.conda sha256: 5a5ab3ee828309185e0a76ca80f5da85f31d8480d923abb508ca00fe194d1b5a md5: 59b4ad97bbb36ef5315500d5bde4bcfc @@ -3991,6 +4508,7 @@ packages: - __osx >=10.13 license: MIT license_family: MIT + run_exports: {} size: 10924291 timestamp: 1759934264932 - conda: https://prefix.dev/conda-forge/osx-64/nushell-0.110.0-h03e26d9_2.conda @@ -4031,6 +4549,19 @@ packages: - openssl >=3.6.2,<4.0a0 size: 2776564 timestamp: 1775589970694 +- conda: https://prefix.dev/conda-forge/osx-64/openssl-3.6.3-hc881268_0.conda + sha256: 819d4368d6b5b298fa40d4bc836c1250842489002cacf3fb918a13ee2033b7c6 + md5: 46be42ab403712fd349d007d763bf767 + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 2775300 + timestamp: 1781071391999 - conda: https://prefix.dev/conda-forge/osx-64/pcre2-10.47-h13923f0_0.conda sha256: 8d64a9d36073346542e5ea042ef8207a45a0069a2e65ce3323ee3146db78134c md5: 08f970fb2b75f5be27678e077ebedd46 @@ -4179,6 +4710,22 @@ packages: - __osx >=10.13 size: 11473 timestamp: 1777897327134 +- conda: https://prefix.dev/conda-forge/osx-64/rust_osx-64-1.95.0-hce10650_2.conda + sha256: c9d96cfdf8271da6da1c9e4f5102781faeadb50171d7a868cae756ea71c57d28 + md5: 09b2698f7489199eb2fc42f98777fdcd + depends: + - clang_osx-64 + - ld64_osx-64 + - macosx_deployment_target_osx-64 >=11.0 + - rust 1.95.0.* + - rust-std-x86_64-apple-darwin 1.95.0.* + license: BSD-3-Clause + license_family: BSD + run_exports: + strong_constrains: + - __osx >=11.0 + size: 11785 + timestamp: 1780067434899 - conda: https://prefix.dev/conda-forge/osx-64/shellcheck-0.10.0-h7dd6a17_0.conda sha256: 383901632d791e01f6799a8882c202c1fcf5ec2914f869b2bdd8ae6e139c20b7 md5: 6870813f912971e13d56360d2db55bde @@ -4648,6 +5195,21 @@ packages: run_exports: {} size: 821681 timestamp: 1776926764338 +- conda: https://prefix.dev/conda-forge/osx-arm64/clang-22-22.1.8-default_h3bfd001_3.conda + sha256: 0089918b32daaa4c1ae0762309f5d5cad6549624a0d1d32de7405ebfc616914a + md5: 8d37f4369517317f62385cc0937b7c55 + depends: + - compiler-rt22 ==22.1.8 + - libclang-cpp22.1 ==22.1.8 default_hdca5a3d_3 + - libcxx >=22.1.8 + - __osx >=11.0 + - libllvm22 >=22.1.8,<22.2.0a0 + - libclang-cpp22.1 >=22.1.8,<22.2.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 927702 + timestamp: 1782358827352 - conda: https://prefix.dev/conda-forge/osx-arm64/clang_impl_osx-arm64-19.1.7-h76e6a08_25.conda sha256: ee3c0976bde0ac19f84d29213ea3d9c3fd9c56d56c33ee471a8680cf69307ce1 md5: a4e2f211f7c3cf582a6cb447bee2cad9 @@ -4675,6 +5237,25 @@ packages: run_exports: {} size: 27845 timestamp: 1776927011611 +- conda: https://prefix.dev/conda-forge/osx-arm64/clang_impl_osx-arm64-22.1.8-default_hd222103_3.conda + sha256: 72dae57849bf56157dc371fbcb53a56496830e3d611703602973b96a4578a98b + md5: 78fc1abfa6876f1844935ae3af3bae9d + depends: + - clang-22 ==22.1.8 default_h3bfd001_3 + - compiler-rt_osx-arm64 + - compiler-rt 22.1.8.* + - cctools_impl_osx-arm64 + - ld64_osx-arm64 * llvm22_1_* + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 32235 + timestamp: 1782358827352 - conda: https://prefix.dev/conda-forge/osx-arm64/clang_osx-arm64-19.1.7-h07b0088_25.conda sha256: 92a45a972af5eba3b7fca66880c3d5bdf73d0c69a3e21d1b3999fb9b5be1b323 md5: 1b53cb5305ae53b5aeba20e58c625d96 @@ -4696,6 +5277,18 @@ packages: run_exports: {} size: 21087 timestamp: 1776998964888 +- conda: https://prefix.dev/conda-forge/osx-arm64/clang_osx-arm64-22.1.8-hc95f77d_32.conda + sha256: fbdd3dfad3f7d385b41241371707bb38df99971640e62d50bdc653384acb291b + md5: a2ebbe3033036769fab6b6c044e43acb + depends: + - cctools_osx-arm64 + - clang_impl_osx-arm64 22.1.8.* + - sdkroot_env_osx-arm64 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 20448 + timestamp: 1781815714221 - conda: https://prefix.dev/conda-forge/osx-arm64/clangxx-19.1.7-default_h36137df_5.conda sha256: f8f94321aee9ad83cb1cdf90660885fccb482c34c82ba84c2c167d452376834f md5: c11a3a5a0cdb74d8ce58c6eac8d1f662 @@ -4772,6 +5365,19 @@ packages: run_exports: {} size: 16494 timestamp: 1776838473207 +- conda: https://prefix.dev/conda-forge/osx-arm64/compiler-rt-22.1.8-hce30654_1.conda + sha256: eb0c8aee28824932e465960e313ce41486bb17b440d8195526733044006ee4ae + md5: 7c0a8e9c95ac40105b4c548d992ca537 + depends: + - compiler-rt22 22.1.8 hd34ed20_1 + - libcompiler-rt 22.1.8 hd34ed20_1 + constrains: + - clang 22.1.8 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 16555 + timestamp: 1781741177869 - conda: https://prefix.dev/conda-forge/osx-arm64/compiler-rt22-22.1.4-hd34ed20_0.conda sha256: f6097d3968ca1d29a27355407040fdf81a31600201cca32be0091166c7d518be md5: e257f7ace4ad961f08cf699da266a74d @@ -4783,6 +5389,25 @@ packages: run_exports: {} size: 99681 timestamp: 1776838467203 +- conda: https://prefix.dev/conda-forge/osx-arm64/compiler-rt22-22.1.8-hd34ed20_1.conda + sha256: b7da9c52cbccfa8c2449a9e404225696f60bce30f5270fd6d7be1c5cfb5dc478 + md5: 97912eef554a369ab285baefdf843a86 + depends: + - __osx >=11.0 + - compiler-rt22_osx-arm64 22.1.8.* + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 99905 + timestamp: 1781741175808 +- conda: https://prefix.dev/conda-forge/osx-arm64/coreutils-9.5-h93a5062_0.conda + sha256: 70e50f2f1458e7c172c9b1c149a4289e8eecc9b53e06ab9de0b3572cdbd30a61 + md5: 75840e25e62a109b1d25043c63a4f36c + license: GPL-3.0-or-later + license_family: GPL + run_exports: {} + size: 1478098 + timestamp: 1711655465375 - conda: https://prefix.dev/conda-forge/osx-arm64/cxx-compiler-1.11.0-h88570a1_0.conda sha256: 99800d97a3a2ee9920dfc697b6d4c64e46dc7296c78b1b6c746ff1c24dea5e6c md5: 043afed05ca5a0f2c18252ae4378bdee @@ -4936,6 +5561,20 @@ packages: - libclang-cpp22.1 >=22.1.4,<22.2.0a0 size: 14185091 timestamp: 1776926580464 +- conda: https://prefix.dev/conda-forge/osx-arm64/libclang-cpp22.1-22.1.8-default_hdca5a3d_3.conda + sha256: dcc960219fae62d99281e3767b6b3162b15aa53331ac0233c6d72ed99e5a1fbe + md5: 996a036eabb7a8594626fdc1cf758519 + depends: + - libcxx >=22.1.8 + - __osx >=11.0 + - libllvm22 >=22.1.8,<22.2.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: + weak: + - libclang-cpp22.1 >=22.1.8,<22.2.0a0 + size: 15930788 + timestamp: 1782358827352 - conda: https://prefix.dev/conda-forge/osx-arm64/libcompiler-rt-22.1.4-hd34ed20_0.conda sha256: cc75861e27dc7c6385d9cd9f581d62c507ef23a98b26be0a89fb8ccf54a9a6ea md5: bc957fe2d1c1b0b69c70353599cde50e @@ -4950,6 +5589,20 @@ packages: - libcompiler-rt >=22.1.4 size: 1376608 timestamp: 1776838427260 +- conda: https://prefix.dev/conda-forge/osx-arm64/libcompiler-rt-22.1.8-hd34ed20_1.conda + sha256: e19bef885123e78e93169f2814ff1c41650ee554b0e2b2b79f7398e09803df94 + md5: 45e4352d41a29e442f79f7708d12b655 + depends: + - __osx >=11.0 + constrains: + - compiler-rt >=9.0.1 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: + weak: + - libcompiler-rt >=22.1.8 + size: 1376251 + timestamp: 1781741160097 - conda: https://prefix.dev/conda-forge/osx-arm64/libcurl-8.17.0-hdece5d2_0.conda sha256: 2980c5de44ac3ca2ecbd4a00756da1648ea2945d9e4a2ad9f216c7787df57f10 md5: 791003efe92c17ed5949b309c61a5ab1 @@ -4984,6 +5637,16 @@ packages: run_exports: {} size: 569927 timestamp: 1776816293111 +- conda: https://prefix.dev/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda + sha256: a2e7abab5add9750fab064c024394de48e49f97631c605ad5db5c8ac3fc769ef + md5: 89f76a2a21a3ec3ec983b5eb237c4113 + depends: + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 569349 + timestamp: 1781670209146 - conda: https://prefix.dev/conda-forge/osx-arm64/libcxx-devel-19.1.7-h6dc3340_1.conda sha256: 6dd08a65b8ef162b058dc931aba3bdb6274ba5f05b6c86fbd0c23f2eafc7cc47 md5: 1399af81db60d441e7c6577307d5cf82 @@ -5097,6 +5760,23 @@ packages: - libllvm22 >=22.1.4,<22.2.0a0 size: 30048795 timestamp: 1776828423000 +- conda: https://prefix.dev/conda-forge/osx-arm64/libllvm22-22.1.8-h89af1be_1.conda + sha256: 9c0ece5160e3d8749f43f8dd86777be4cfdb51490fb32f4d8269f8fd9866ce55 + md5: 5726aa6dda93e10bd614e434e9c9b8fc + depends: + - __osx >=11.0 + - libcxx >=19 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: + weak: + - libllvm22 >=22.1.8,<22.2.0a0 + size: 30057877 + timestamp: 1781783269086 - conda: https://prefix.dev/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda sha256: 0cb92a9e026e7bd4842f410a5c5c665c89b2eb97794ffddba519a626b8ce7285 md5: d6df911d4564d77c4374b02552cb17d1 @@ -5367,6 +6047,20 @@ packages: run_exports: {} size: 17825171 timestamp: 1776828746788 +- conda: https://prefix.dev/conda-forge/osx-arm64/llvm-tools-22-22.1.8-hb545844_1.conda + sha256: a5ac8bd2fd67c6e71c991e3172c0ec8430de4ec91506e93c0f1afb56a88ed8e6 + md5: 67a51d348fcfc95393fd0f7d92e119cf + depends: + - __osx >=11.0 + - libcxx >=19 + - libllvm22 22.1.8 h89af1be_1 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 17832371 + timestamp: 1781783379957 - conda: https://prefix.dev/conda-forge/osx-arm64/llvm-tools-22.1.4-hd34ed20_0.conda sha256: e23532a849f181d62c8d3a5a9872c301aa66c731be0dbda64c7f29af58d6dd2b md5: 0a61bb0d9187887cab1315bb62c6cab8 @@ -5384,6 +6078,23 @@ packages: run_exports: {} size: 51640 timestamp: 1776828919113 +- conda: https://prefix.dev/conda-forge/osx-arm64/llvm-tools-22.1.8-hd34ed20_1.conda + sha256: f5933c1fee348ece7129a5e07af2c8051984ed3a3d2d5f95e4c5144e23f29f55 + md5: 3ed593360f7932817da40db4f96f803f + depends: + - __osx >=11.0 + - libllvm22 22.1.8 h89af1be_1 + - llvm-tools-22 22.1.8 hb545844_1 + constrains: + - llvmdev 22.1.8 + - llvm 22.1.8 + - clang 22.1.8 + - clang-tools 22.1.8 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 52210 + timestamp: 1781783441469 - conda: https://prefix.dev/conda-forge/osx-arm64/make-4.4.1-hc9fafa5_2.conda sha256: 90ca65e788406d9029ae23ad4bd944a8b5353ad5f59bd6ce326f980cde46f37e md5: 9f44ef1fea0a25d6a3491c58f3af8460 @@ -5438,6 +6149,7 @@ packages: - __osx >=11.0 license: MIT license_family: MIT + run_exports: {} size: 10482392 timestamp: 1759934241172 - conda: https://prefix.dev/conda-forge/osx-arm64/nushell-0.110.0-h979b848_0.conda @@ -5477,6 +6189,19 @@ packages: - openssl >=3.6.2,<4.0a0 size: 3106008 timestamp: 1775587972483 +- conda: https://prefix.dev/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + sha256: b3e3ca895c336d4eb91c5d2f244a312bdb59a0de8cfa0cc4c179225ab2f6bbfb + md5: 8187a86242741725bfa74785fe812979 + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3102584 + timestamp: 1781069820667 - conda: https://prefix.dev/conda-forge/osx-arm64/pcre2-10.46-h7125dd6_0.conda sha256: 5bf2eeaa57aab6e8e95bea6bd6bb2a739f52eb10572d8ed259d25864d3528240 md5: 0e6e82c3cc3835f4692022e9b9cd5df8 @@ -5626,6 +6351,22 @@ packages: - __osx >=11.0 size: 11510 timestamp: 1777897493351 +- conda: https://prefix.dev/conda-forge/osx-arm64/rust_osx-arm64-1.95.0-h9bac32b_2.conda + sha256: e139d859f9cbdfabecc1cd18d2ad0888e7ceba9909c5bee05f61a3332c177fd8 + md5: 9e4df6abb71480373e94aae8e531faaf + depends: + - clang_osx-arm64 + - ld64_osx-arm64 + - macosx_deployment_target_osx-arm64 >=11.0 + - rust 1.95.0.* + - rust-std-aarch64-apple-darwin 1.95.0.* + license: BSD-3-Clause + license_family: BSD + run_exports: + strong_constrains: + - __osx >=11.0 + size: 11811 + timestamp: 1780067379835 - conda: https://prefix.dev/conda-forge/osx-arm64/shellcheck-0.10.0-hecfb573_0.conda sha256: d175f46af454d3f2ba97f0a4be8a4fdf962aaec996db54dfcf8044d38da3769c md5: 6b2856ca39fa39c438dcd46140cd894e @@ -6241,6 +6982,22 @@ packages: license_family: Other size: 55476 timestamp: 1727963768015 +- conda: https://prefix.dev/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda + sha256: 88609816e0cc7452bac637aaf65783e5edf4fee8a9f8e22bdc3a75882c536061 + md5: dbabbd6234dea34040e631f87676292f + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 58347 + timestamp: 1774072851498 - conda: https://prefix.dev/conda-forge/win-64/make-4.4.1-h0e40799_2.conda sha256: a810cdca3d5fa50d562cda23c0c1195b45ff5f9b0c41e0d4c8c2dd3c043ff4f2 md5: 77ff648ad9fec660f261aa8ab0949f62 @@ -6280,6 +7037,7 @@ packages: - openssl >=3.5.4,<4.0a0 license: MIT license_family: MIT + run_exports: {} size: 10014075 timestamp: 1759934231561 - conda: https://prefix.dev/conda-forge/win-64/openssl-3.6.0-h725018a_0.conda @@ -6294,6 +7052,21 @@ packages: license_family: Apache size: 9440812 timestamp: 1762841722179 +- conda: https://prefix.dev/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda + sha256: cb6e7ba0d010ee0d3249ce9886de3d7613d26d9965d4c95666fa66b9c4c31001 + md5: e99f95734a326c0fd4d02bbd995150d4 + depends: + - ca-certificates + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 9414790 + timestamp: 1781071745579 - conda: https://prefix.dev/conda-forge/win-64/pcre2-10.46-h3402e2f_0.conda sha256: 29c2ed44a8534d27faad96bdce16efe29c2788f556f4c5409d4ae8ae074681ec md5: 889053e920d15353c2665fa6310d7a7a @@ -6428,6 +7201,18 @@ packages: run_exports: {} size: 11252 timestamp: 1777897157443 +- conda: https://prefix.dev/conda-forge/win-64/rust_win-64-1.95.0-h533a698_2.conda + sha256: 8e8c0cdde4e371338fe7cc408a2b71a44135ba4d3a5f911180de231f32c333a4 + md5: 38d16cbf8a44d04459fbcfb662024da8 + depends: + - rust 1.95.0.* + - rust-std-x86_64-pc-windows-msvc 1.95.0.* + - vs_win-64 >=2019 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 11561 + timestamp: 1780067165294 - conda: https://prefix.dev/conda-forge/win-64/shellcheck-0.10.0-h57928b3_0.conda sha256: a7a08960774abdf394791867fa5ec26752eaaf4beda70f7daefbb7076054ee9b md5: c79f416ceb03e3add6e16381ecfdadd9 @@ -6490,6 +7275,18 @@ packages: run_exports: {} size: 19356 timestamp: 1767320221521 +- conda: https://prefix.dev/conda-forge/win-64/vc-14.5-h1b7c187_39.conda + sha256: 17693b60cb54f80c60275f003f3bfc1b128af56dbfd65c4fae37c64eeb755ce1 + md5: 2eacea63f545b97342da520df6854276 + depends: + - vc14_runtime >=14.51.36231 + track_features: + - vc14 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 20362 + timestamp: 1781320968457 - conda: https://prefix.dev/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_32.conda sha256: e3a3656b70d1202e0d042811ceb743bd0d9f7e00e2acdf824d231b044ef6c0fd md5: 378d5dcec45eaea8d303da6f00447ac0 @@ -6515,6 +7312,19 @@ packages: run_exports: {} size: 683233 timestamp: 1767320219644 +- conda: https://prefix.dev/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda + sha256: 8153ed849c92e891eacac0f2f8d7ecb79f9b5fd7f7917fbb896f252a60a40390 + md5: 06a5bf5a1ca16cce0df6eaa91fc42bc2 + depends: + - ucrt >=10.0.20348.0 + - vcomp14 14.51.36231 h1b9f54f_39 + constrains: + - vs2015_runtime 14.51.36231.* *_39 + license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime + license_family: Proprietary + run_exports: {} + size: 737434 + timestamp: 1781320964561 - conda: https://prefix.dev/conda-forge/win-64/vcomp14-14.44.35208-h818238b_32.conda sha256: f3790c88fbbdc55874f41de81a4237b1b91eab75e05d0e58661518ff04d2a8a1 md5: 58f67b437acbf2764317ba273d731f1d @@ -6540,6 +7350,20 @@ packages: - vcomp14 >=14.44.35208 size: 115235 timestamp: 1767320173250 +- conda: https://prefix.dev/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda + sha256: 07fb14713c4bc62e2533a2e23a363abfb0e65650681fba0ae4c840e2219350f3 + md5: 8b53a83fda40ec679e4d63fa32fae989 + depends: + - ucrt >=10.0.20348.0 + constrains: + - vs2015_runtime 14.51.36231.* *_39 + license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime + license_family: Proprietary + run_exports: + strong: + - vcomp14 >=14.51.36231 + size: 120684 + timestamp: 1781320948530 - conda: https://prefix.dev/conda-forge/win-64/vs2015_runtime-14.44.35208-h38c0c73_32.conda sha256: 65cea43f4de99bc81d589e746c538908b2e95aead9042fecfbc56a4d14684a87 md5: dfc1e5bbf1ecb0024a78e4e8bd45239d @@ -6580,6 +7404,24 @@ packages: - ucrt >=10.0.20348.0 size: 23060 timestamp: 1767320175868 +- conda: https://prefix.dev/conda-forge/win-64/vs2022_win-64-19.44.35207-ha74f236_39.conda + sha256: 434b4f517b7119675930d17749bf123558271f3f316b217f7ac759e6d7121e9d + md5: 59f1d09ae752b761542975d7b6ad1b89 + depends: + - vswhere + constrains: + - vs_win-64 2022.14 + track_features: + - vc14 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + size: 24190 + timestamp: 1781320983107 - conda: https://prefix.dev/conda-forge/win-64/vs_win-64-2022.14-ha74f236_34.conda sha256: b07ba17e1f407eba3e8c714530cce9b111439e3ffecd123e58555308a7304a60 md5: b8f1c3f5c2347270f0fa06385ab16c5c @@ -6596,6 +7438,22 @@ packages: - ucrt >=10.0.20348.0 size: 19606 timestamp: 1767320221083 +- conda: https://prefix.dev/conda-forge/win-64/vs_win-64-2022.14-ha74f236_39.conda + sha256: 6bc683635d1e48419ef7fb5ad02e9c52c2a1096e0c462af1ce57cebd10d20e40 + md5: bdf5712b7c14b84f66f6d038ce2625c5 + depends: + - vs2022_win-64 19.44.35207.* + track_features: + - vc14 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + size: 20632 + timestamp: 1781321005208 - conda: https://prefix.dev/conda-forge/win-64/zstd-1.5.7-hbeecb71_2.conda sha256: bc64864377d809b904e877a98d0584f43836c9f2ef27d3d2a1421fa6eae7ca04 md5: 21f56217d6125fb30c3c3f10c786d751 @@ -6653,6 +7511,86 @@ packages: - conda: https://prefix.dev/conda-forge/osx-arm64/sigtool-codesign-0.1.3-h98dc951_0.conda - conda: https://prefix.dev/conda-forge/osx-arm64/tapi-1600.0.11.8-h997e182_2.conda - conda: https://prefix.dev/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda +- conda_source: rattler[4f6e0f0e] @ crates/rattler-bin + variants: + c_compiler: vs2022 + rust_compiler_version: 1.95.0 + target_platform: win-64 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + license: BSD-3-Clause + build_packages: + - conda: https://prefix.dev/conda-forge/noarch/rust-std-x86_64-pc-windows-msvc-1.95.0-h17fc481_1.conda + - conda: https://prefix.dev/conda-forge/noarch/vswhere-3.1.7-h40126e0_1.conda + - conda: https://prefix.dev/conda-forge/win-64/rust-1.95.0-hf8d6059_1.conda + - conda: https://prefix.dev/conda-forge/win-64/rust_win-64-1.95.0-h533a698_2.conda + - conda: https://prefix.dev/conda-forge/win-64/vs2022_win-64-19.44.35207-ha74f236_39.conda + - conda: https://prefix.dev/conda-forge/win-64/vs_win-64-2022.14-ha74f236_39.conda + host_packages: + - conda: https://prefix.dev/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda + - conda: https://prefix.dev/conda-forge/win-64/vc-14.5-h1b7c187_39.conda + - conda: https://prefix.dev/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda + - conda: https://prefix.dev/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda +- conda_source: rattler[667739d7] @ crates/rattler-bin + variants: + c_stdlib: sysroot + c_stdlib_version: '2.28' + rust_compiler_version: 1.95.0 + target_platform: linux-64 + depends: + - libgcc >=15 + - __glibc >=2.28,<3.0.a0 + constrains: + - __glibc >=2.17 + license: BSD-3-Clause + build_packages: + - conda: https://prefix.dev/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://prefix.dev/conda-forge/linux-64/binutils_impl_linux-64-2.45.1-default_hfdba357_102.conda + - conda: https://prefix.dev/conda-forge/linux-64/binutils_linux-64-2.45.1-default_h4852527_102.conda + - conda: https://prefix.dev/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + - conda: https://prefix.dev/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/cmake-3.31.8-hc85cc9f_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/gcc_impl_linux-64-15.2.0-ha6850e4_19.conda + - conda: https://prefix.dev/conda-forge/linux-64/gcc_linux-64-15.2.0-h7be306e_27.conda + - conda: https://prefix.dev/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.conda + - conda: https://prefix.dev/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + - conda: https://prefix.dev/conda-forge/linux-64/libcurl-8.21.0-hae6b9f4_2.conda + - conda: https://prefix.dev/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/libev-4.33-hd590300_2.conda + - conda: https://prefix.dev/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://prefix.dev/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + - conda: https://prefix.dev/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda + - conda: https://prefix.dev/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + - conda: https://prefix.dev/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/libpsl-0.22.0-hd9031aa_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/libsanitizer-15.2.0-h90f66d4_19.conda + - conda: https://prefix.dev/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + - conda: https://prefix.dev/conda-forge/linux-64/libuv-1.52.1-h280c20c_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://prefix.dev/conda-forge/linux-64/make-4.4.1-hb9d3cd8_2.conda + - conda: https://prefix.dev/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/rhash-1.4.6-hb9d3cd8_1.conda + - conda: https://prefix.dev/conda-forge/linux-64/rust-1.95.0-h53717f1_1.conda + - conda: https://prefix.dev/conda-forge/linux-64/rust_linux-64-1.95.0-hb4ea61c_2.conda + - conda: https://prefix.dev/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://prefix.dev/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + - conda: https://prefix.dev/conda-forge/noarch/libgcc-devel_linux-64-15.2.0-hcc6f6b0_119.conda + - conda: https://prefix.dev/conda-forge/noarch/libstdcxx-devel_linux-64-15.2.0-hd446a21_119.conda + - conda: https://prefix.dev/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.95.0-h2c6d0dc_1.conda + - conda: https://prefix.dev/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda + - conda: https://prefix.dev/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + host_packages: + - conda: https://prefix.dev/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://prefix.dev/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + - conda: https://prefix.dev/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda - conda_source: rattler[9e891123] @ crates/rattler-bin variants: c_compiler: vs2022 @@ -6719,6 +7657,51 @@ packages: - conda: https://prefix.dev/conda-forge/osx-64/sigtool-codesign-0.1.3-hc0f2934_0.conda - conda: https://prefix.dev/conda-forge/osx-64/tapi-1600.0.11.8-h8d8e812_2.conda - conda: https://prefix.dev/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda +- conda_source: rattler[bd866021] @ crates/rattler-bin + variants: + c_stdlib: macosx_deployment_target + c_stdlib_version: '13.0' + rust_compiler_version: 1.95.0 + target_platform: osx-64 + depends: + - __osx >=13.0 + constrains: + - __osx >=11.0 + license: BSD-3-Clause + build_packages: + - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://prefix.dev/conda-forge/noarch/compiler-rt22_osx-64-22.1.8-hcf80936_1.conda + - conda: https://prefix.dev/conda-forge/noarch/compiler-rt_osx-64-22.1.8-h694c41f_1.conda + - conda: https://prefix.dev/conda-forge/noarch/macosx_deployment_target_osx-64-13.0-h2830193_7.conda + - conda: https://prefix.dev/conda-forge/noarch/rust-std-x86_64-apple-darwin-1.95.0-h38e4360_1.conda + - conda: https://prefix.dev/conda-forge/noarch/sdkroot_env_osx-64-26.0-h62b880e_7.conda + - conda: https://prefix.dev/conda-forge/osx-64/cctools_impl_osx-64-1030.6.3-llvm22_1_h8fe25a2_4.conda + - conda: https://prefix.dev/conda-forge/osx-64/cctools_osx-64-1030.6.3-llvm22_1_h0a1bb1c_4.conda + - conda: https://prefix.dev/conda-forge/osx-64/clang-22-22.1.8-default_h9a620b7_3.conda + - conda: https://prefix.dev/conda-forge/osx-64/clang_impl_osx-64-22.1.8-default_h0f45732_3.conda + - conda: https://prefix.dev/conda-forge/osx-64/clang_osx-64-22.1.8-h97b245c_32.conda + - conda: https://prefix.dev/conda-forge/osx-64/compiler-rt-22.1.8-h694c41f_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/compiler-rt22-22.1.8-h1637cdf_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/ld64_osx-64-956.6-llvm22_1_h163eae7_4.conda + - conda: https://prefix.dev/conda-forge/osx-64/libclang-cpp22.1-22.1.8-default_h5a1b869_3.conda + - conda: https://prefix.dev/conda-forge/osx-64/libcompiler-rt-22.1.8-h1637cdf_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/libiconv-1.18-h57a12c2_2.conda + - conda: https://prefix.dev/conda-forge/osx-64/libllvm22-22.1.8-hab754da_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/libsigtool-0.1.3-hc0f2934_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/libxml2-16-2.15.3-h0d7f165_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/libxml2-2.15.3-h0712280_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_2.conda + - conda: https://prefix.dev/conda-forge/osx-64/llvm-tools-22-22.1.8-hc181bea_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/llvm-tools-22.1.8-h1637cdf_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/openssl-3.6.3-hc881268_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/rust-1.95.0-h5655b98_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/rust_osx-64-1.95.0-hce10650_2.conda + - conda: https://prefix.dev/conda-forge/osx-64/sigtool-codesign-0.1.3-hc0f2934_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/tapi-1600.0.11.8-h8d8e812_2.conda + - conda: https://prefix.dev/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - conda_source: rattler[c1282f86] @ crates/rattler-bin variants: c_stdlib: sysroot @@ -6775,6 +7758,51 @@ packages: - conda: https://prefix.dev/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda - conda: https://prefix.dev/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda - conda: https://prefix.dev/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda +- conda_source: rattler[ce9063e9] @ crates/rattler-bin + variants: + c_stdlib: macosx_deployment_target + c_stdlib_version: '13.0' + rust_compiler_version: 1.95.0 + target_platform: osx-arm64 + depends: + - __osx >=13.0 + constrains: + - __osx >=11.0 + license: BSD-3-Clause + build_packages: + - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://prefix.dev/conda-forge/noarch/compiler-rt22_osx-arm64-22.1.8-h7e67a1e_1.conda + - conda: https://prefix.dev/conda-forge/noarch/compiler-rt_osx-arm64-22.1.8-hce30654_1.conda + - conda: https://prefix.dev/conda-forge/noarch/macosx_deployment_target_osx-arm64-13.0-h95484f9_7.conda + - conda: https://prefix.dev/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.95.0-hf6ec828_1.conda + - conda: https://prefix.dev/conda-forge/noarch/sdkroot_env_osx-arm64-26.0-ha3f98da_7.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/cctools_impl_osx-arm64-1030.6.3-llvm22_1_hb5e89dc_4.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/cctools_osx-arm64-1030.6.3-llvm22_1_hbe26303_4.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/clang-22-22.1.8-default_h3bfd001_3.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/clang_impl_osx-arm64-22.1.8-default_hd222103_3.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/clang_osx-arm64-22.1.8-hc95f77d_32.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/compiler-rt-22.1.8-hce30654_1.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/compiler-rt22-22.1.8-hd34ed20_1.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/ld64_osx-arm64-956.6-llvm22_1_h692d5aa_4.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libclang-cpp22.1-22.1.8-default_hdca5a3d_3.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libcompiler-rt-22.1.8-hd34ed20_1.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libllvm22-22.1.8-h89af1be_1.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libsigtool-0.1.3-h98dc951_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libxml2-16-2.15.3-h6967ea9_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libxml2-2.15.3-heed7d32_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/llvm-tools-22-22.1.8-hb545844_1.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/llvm-tools-22.1.8-hd34ed20_1.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/rust-1.95.0-h4ff7c5d_1.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/rust_osx-arm64-1.95.0-h9bac32b_2.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/sigtool-codesign-0.1.3-h98dc951_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/tapi-1600.0.11.8-h997e182_2.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - conda_source: rattler_index[0c8c0e74] @ crates/rattler_index variants: c_stdlib: macosx_deployment_target diff --git a/pixi.toml b/pixi.toml index 80ae1b1696..c5ac520332 100644 --- a/pixi.toml +++ b/pixi.toml @@ -114,3 +114,23 @@ e2e-s3-aws = "nu scripts/e2e/s3-aws.nu" [environments.s3] features = ["s3"] no-default-feature = true + +#------------------------------ +# rattler-vfs E2E mount test +#------------------------------ +[feature.rattler-vfs.dependencies] +rattler = { path = "crates/rattler-bin" } +nushell = ">=0.106.1,<0.107" + +# GNU `timeout` (from coreutils) is used by the e2e script to bound stuck +# NFS syscalls. macOS GHA runners don't ship it. Windows uses ProjFS in the +# tests, which doesn't need it; coreutils isn't available on win-64 either. +[feature.rattler-vfs.target.unix.dependencies] +coreutils = ">=9.5,<10" + +[feature.rattler-vfs.tasks] +e2e-rattler-vfs = "nu scripts/e2e/rattler-vfs-mount.nu" + +[environments.rattler-vfs] +features = ["rattler-vfs"] +no-default-feature = true diff --git a/py-rattler/Cargo.lock b/py-rattler/Cargo.lock index 079d5d8d1e..91c6b678ee 100644 --- a/py-rattler/Cargo.lock +++ b/py-rattler/Cargo.lock @@ -4137,6 +4137,7 @@ dependencies = [ "itertools 0.14.0", "jiff", "lazy-regex", + "memchr", "memmap2", "nom", "nom-language", diff --git a/py-rattler/rattler/package/paths_json.py b/py-rattler/rattler/package/paths_json.py index e6fa7ad0c0..7fa415e45b 100644 --- a/py-rattler/rattler/package/paths_json.py +++ b/py-rattler/rattler/package/paths_json.py @@ -608,6 +608,47 @@ def placeholder(self) -> str: """ return self._inner.placeholder + @property + def offsets(self) -> Optional[list[int] | list[list[int]]]: + """ + The byte offsets, from the beginning of the file, at which the placeholder occurs. + + Returns `None` when the field is absent, a `list[int]` for text-mode files, or a + `list[list[int]]` for binary-mode files (grouped by c-string). Occurrences inside the + shebang region (see `shebang_length`) are excluded. + + Examples + -------- + ```python + >>> paths_json = PathsJson.from_path( + ... "../test-data/conda-22.9.0-py38haa244fe_2-paths.json" + ... ) + >>> entry = paths_json.paths[-1] + >>> entry.prefix_placeholder.offsets + >>> + ``` + """ + return self._inner.offsets + + @property + def shebang_length(self) -> Optional[int]: + """ + The length in bytes of the file's shebang region (the first line including its trailing + newline), or `None` when the file has no recorded shebang region. + + Examples + -------- + ```python + >>> paths_json = PathsJson.from_path( + ... "../test-data/conda-22.9.0-py38haa244fe_2-paths.json" + ... ) + >>> entry = paths_json.paths[-1] + >>> entry.prefix_placeholder.shebang_length + >>> + ``` + """ + return self._inner.shebang_length + @classmethod def _from_py_prefix_placeholder(cls, py_prefix_placeholder: PyPrefixPlaceholder) -> PrefixPlaceholder: prefix_placeholder = cls.__new__(cls) diff --git a/py-rattler/src/paths_json.rs b/py-rattler/src/paths_json.rs index 9c6f992b61..2236b21ba3 100644 --- a/py-rattler/src/paths_json.rs +++ b/py-rattler/src/paths_json.rs @@ -1,12 +1,12 @@ use std::path::PathBuf; use pyo3::{ - Bound, Py, PyAny, PyErr, PyResult, Python, exceptions::PyValueError, pyclass, pymethods, - types::PyBytes, + Bound, IntoPyObject, Py, PyAny, PyErr, PyResult, Python, exceptions::PyValueError, pyclass, + pymethods, types::PyBytes, }; use pyo3_async_runtimes::tokio::future_into_py; use rattler_conda_types::package::{ - FileMode, PackageFile, PathType, PathsEntry, PathsJson, PrefixPlaceholder, + FileMode, Offsets, PackageFile, PathType, PathsEntry, PathsJson, PrefixPlaceholder, }; use rattler_package_streaming::seek::read_package_file; use url::Url; @@ -395,6 +395,8 @@ impl PyPrefixPlaceholder { inner: PrefixPlaceholder { file_mode: file_mode.into(), placeholder: placeholder.to_string(), + offsets: None, + shebang_length: None, }, }) } @@ -424,6 +426,27 @@ impl PyPrefixPlaceholder { pub fn set_placeholder(&mut self, placeholder: String) { self.inner.placeholder = placeholder; } + + /// The byte offsets, from the beginning of the file, at which the placeholder occurs. + /// + /// Returns `None` when the field is absent, a `list[int]` for text-mode files, or a + /// `list[list[int]]` for binary-mode files (grouped by c-string). Occurrences inside the + /// shebang region (see `shebang_length`) are excluded. + #[getter] + pub fn offsets<'py>(&self, py: Python<'py>) -> PyResult>> { + Ok(match &self.inner.offsets { + None => None, + Some(Offsets::Text(positions)) => Some(positions.clone().into_pyobject(py)?.into_any()), + Some(Offsets::Binary(groups)) => Some(groups.clone().into_pyobject(py)?.into_any()), + }) + } + + /// The length in bytes of the file's shebang region (first line including its newline), or + /// `None` when the file has no recorded shebang region. + #[getter] + pub fn shebang_length(&self) -> Option { + self.inner.shebang_length + } } /// The file mode of the entry diff --git a/scripts/e2e/rattler-vfs-mount.nu b/scripts/e2e/rattler-vfs-mount.nu new file mode 100644 index 0000000000..0f17bdf661 --- /dev/null +++ b/scripts/e2e/rattler-vfs-mount.nu @@ -0,0 +1,683 @@ +#!/usr/bin/env nu + +# E2E mount test for rattler_vfs +# +# Usage (Linux): +# TRANSPORT=fuse OVERLAY=false pixi run -e rattler-vfs e2e-rattler-vfs # FUSE read-only +# TRANSPORT=fuse OVERLAY=true pixi run -e rattler-vfs e2e-rattler-vfs # FUSE writable +# TRANSPORT=nfs OVERLAY=false pixi run -e rattler-vfs e2e-rattler-vfs # NFS read-only +# TRANSPORT=nfs OVERLAY=true pixi run -e rattler-vfs e2e-rattler-vfs # NFS writable +# Usage (macOS): +# TRANSPORT=nfs OVERLAY=false pixi run -e rattler-vfs e2e-rattler-vfs # NFS read-only +# TRANSPORT=nfs OVERLAY=true pixi run -e rattler-vfs e2e-rattler-vfs # NFS writable +# Usage (Windows): +# TRANSPORT=projfs pixi run -e rattler-vfs e2e-rattler-vfs # ProjFS (always writable) +# +# Env vars: +# TRANSPORT — "fuse", "nfs", or "projfs" +# OVERLAY — "true" for writable overlay, anything else for read-only +# (ignored for ProjFS, which is always writable) + +let transport = ($env.TRANSPORT? | default "nfs") +# ProjFS is always writable — it writes directly to the virtualization root. +let use_overlay = if $transport == "projfs" { true } else { ($env.OVERLAY? | default "false") == "true" } + +let tmp = ($env.RUNNER_TEMP? | default "/tmp") +let overlay_suffix = if $use_overlay { "-rw" } else { "-ro" } +let mount_point = $"($tmp)/rattler-vfs-($transport)($overlay_suffix)" +let overlay_dir = $"($tmp)/rattler-vfs-overlay-($transport)($overlay_suffix)" +let log_file = $"($tmp)/rattler-vfs-($transport)($overlay_suffix).log" +let fixture_lock = "test-data/rattler-vfs/pixi.lock" + +# Python location differs by platform +let python_path = if $nu.os-info.name == "windows" { + $"($mount_point)/python.exe" +} else { + $"($mount_point)/bin/python3" +} + +# Timing results +mut results: list> = [] + +def run [desc: string, cmd: closure] { + let start = (date now) + print $"== ($desc)" + try { do $cmd } catch { } + let code = ($env.LAST_EXIT_CODE? | default 0) + let elapsed = ((date now) - $start) + if $code != 0 { + print $"FAIL: ($desc) \(exit=($code), ($elapsed))" + { desc: $desc, ok: false, elapsed: $elapsed } + } else { + print $"PASS: ($desc) \(($elapsed))" + { desc: $desc, ok: true, elapsed: $elapsed } + } +} + +# Helper: run a critical test — bail immediately on failure +def run_critical [desc: string, cmd: closure] { + let result = (run $desc $cmd) + if not $result.ok { + print $"\nFATAL: critical test '($desc)' failed — aborting" + exit 1 + } + $result +} + +# Helper: expect a command to FAIL (for read-only tests) +def expect_fail [desc: string, cmd: closure] { + let start = (date now) + print $"== ($desc)" + let failed = try { do $cmd; false } catch { true } + let elapsed = ((date now) - $start) + if $failed { + print $"PASS: ($desc) \(($elapsed))" + { desc: $desc, ok: true, elapsed: $elapsed } + } else { + print $"FAIL: ($desc) — operation should have been rejected" + { desc: $desc, ok: false, elapsed: $elapsed } + } +} + +# --------------------------------------------------------------------------- +# Helper: start `rattler mount` and wait for mount +# --------------------------------------------------------------------------- +def start_and_wait [lock: string, mount: string, transport: string, overlay_args: list, log: string, python: string] { + print $"== Starting rattler mount... \(transport=($transport))" + # Wrap the external invocation in try/catch so that when the process is + # killed (e.g. SIGKILL in the stale mount test), nushell does not surface + # `terminated by signal` as a fatal error from the spawned job. + let fs_job = job spawn { + try { + ^rattler mount $lock $mount --transport $transport ...$overlay_args out+err> $log + } catch { } + } + + print $"== Waiting for mount... \(checking ($python))" + # On unix, use external `test -e` wrapped in `timeout` (provided by the + # rattler-vfs-test pixi env's coreutils) instead of nushell's `path exists`: + # the latter calls `fs::metadata`, which blocks uninterruptibly on a stale + # NFS mount left behind by a prior test's incomplete umount. On Windows + # there's no analogous hang (ProjFS), so plain `path exists` is fine. + let is_windows = ($nu.os-info.name == "windows") + if not (seq 0 59 | any {|_| + let exists = if $is_windows { + ($python | path exists) + } else { + try { ^timeout 1 test -e $python; true } catch { false } + } + if $exists { + true + } else { + # Check if the process died — no point waiting 120s for a dead process + let alive = (job list | where id == $fs_job | length) > 0 + if not $alive { + print "== rattler mount exited before mount was ready:" + try { open $log | lines | each { |l| print $" ($l)" } } catch { } + error make {msg: "rattler mount exited before mount was ready (see log above)"} + } + sleep 2sec + false + } + }) { + print "== Mount failed to become ready after 120 seconds. Log tail:" + try { open $log | lines | last 20 | each { |l| print $l } } catch { } + error make {msg: "Mount failed to become ready within 120 seconds"} + } + print "== Mount is ready" + $fs_job +} + +# Helper: stop `rattler mount` and clean up mount +def stop_and_cleanup [fs_job: int, transport: string, mount: string] { + # Kill the process first — triggers unmount via MountHandle::drop + try { job kill $fs_job } catch { } + sleep 1sec # allow time for drop/unmount + + # Safety-net unmount in case the process didn't clean up + if $transport == "fuse" { + try { ^fusermount3 -u $mount } catch { + try { ^umount $mount } catch { } + } + } else if $transport == "nfs" { + # macOS: user-level umount works first; if the mount is still + # attached (umount returned 0 but cleanup is async, or umount + # failed silently), retry with diskutil's force-detach which + # guarantees the mount is gone before the next remount tries the + # same path. + # Linux: needs root, AND the userspace helper `umount.nfs` refuses + # to detach mounts whose loopback server has died. Bypass it with + # `-i` (skip helper) and `-l` (lazy detach) so the kernel always + # succeeds. Otherwise stale mounts accumulate across tests and the + # loopback NFS subsystem eventually wedges. + if $nu.os-info.name == "linux" { + try { ^timeout 10 sudo umount -i -f -l $mount } catch { } + } else { + try { ^timeout 10 umount -f $mount } catch { + try { ^timeout 10 sudo umount -f $mount } catch { } + } + # Verify and force-detach if still mounted (macOS). + let still_mounted = (try { + let out = (^timeout 5 mount | complete) + ($out.stdout | str contains $mount) + } catch { false }) + if $still_mounted { + try { ^timeout 10 diskutil unmount force $mount } catch { } + } + } + } + # ProjFS: no explicit unmount needed — PrjStopVirtualizing is called on drop. + # Allow extra time for ProjFS to release file handles after stop. + if $transport == "projfs" { + sleep 2sec + } +} + +# --------------------------------------------------------------------------- +# Setup +# --------------------------------------------------------------------------- +print $"== Configuration: transport=($transport) overlay=($use_overlay)" +mkdir $mount_point +if $use_overlay and $transport != "projfs" { + mkdir $overlay_dir +} + +let overlay_args = if $transport == "projfs" { + ["--overlay", $mount_point] # ProjFS: always writable, overlay == mount point +} else if $use_overlay { + ["--overlay", $overlay_dir] +} else { + [] +} + +# --------------------------------------------------------------------------- +# Start mount +# --------------------------------------------------------------------------- +let fs_job = (start_and_wait $fixture_lock $mount_point $transport $overlay_args $log_file $python_path) + +# --------------------------------------------------------------------------- +# Read-only tests (always run) +# --------------------------------------------------------------------------- + +# On Windows, conda environments need Library/bin (MKL, OpenBLAS, etc.) on +# PATH for DLL loading. Mimic the activation entries that rattler_shell's +# prefix_path_entries() adds. +if $nu.os-info.name == "windows" { + $env.PATH = ([ + $mount_point, + ($mount_point | path join "Library" "mingw-w64" "bin"), + ($mount_point | path join "Library" "usr" "bin"), + ($mount_point | path join "Library" "bin"), + ($mount_point | path join "Scripts"), + ($mount_point | path join "bin"), + ] | append $env.PATH) +} + +# Directory traversal (skip on Windows — find not available) +if $nu.os-info.name != "windows" { + $results = ($results | append (run_critical "find all files" { + let count = (^find $mount_point -type f | lines | length) + print $" Found ($count) files" + if $count < 10 { + error make {msg: $"Expected at least 10 files, found ($count)"} + } + })) +} + +# Python import +$results = ($results | append (run_critical "import numpy" { + ^$python_path -c "import numpy; print(f'numpy {numpy.__version__}')" +})) + +# Read-only enforcement (only when not using overlay). +# Skip on ProjFS: there is no pre-creation notification, and NTFS ACLs block +# ProjFS placeholder creation too, so new file/dir creation can't be blocked. +if not $use_overlay and $transport != "projfs" { + $results = ($results | append (expect_fail "write file rejected on read-only mount" { + "test" | save $"($mount_point)/should_not_exist.txt" + })) + + $results = ($results | append (expect_fail "mkdir rejected on read-only mount" { + mkdir $"($mount_point)/should_not_exist_dir" + })) +} + +# Symlink resolution (skip on Windows — symlinks handled differently) +if $nu.os-info.name != "windows" { + $results = ($results | append (run "symlink resolution" { + # Find a symlink in the mount and verify its target exists + let symlinks = (^find $mount_point -type l -maxdepth 3 | lines | first 5) + if ($symlinks | length) == 0 { + error make {msg: "Expected at least one symlink in the mount"} + } + for link in $symlinks { + let target = (^readlink $link) + print $" ($link) -> ($target)" + # Resolve relative to the link's parent directory + let parent = ($link | path dirname) + let resolved = if ($target | str starts-with "/") { + $target + } else { + $"($parent)/($target)" + } + # The target should exist (either as a file or another symlink) + if not ($resolved | path exists) { + error make {msg: $"Symlink target does not exist: ($link) -> ($target) (resolved: ($resolved))"} + } + } + print $" Verified ($symlinks | length) symlinks" + })) +} + +# --------------------------------------------------------------------------- +# Overlay tests (only when writable) +# --------------------------------------------------------------------------- +if $use_overlay { + $results = ($results | append (run "write and read file" { + "hello from rattler-vfs" | save $"($mount_point)/test_write.txt" + let content = (open $"($mount_point)/test_write.txt") + if $content != "hello from rattler-vfs" { + error make {msg: $"Content mismatch: ($content)"} + } + rm $"($mount_point)/test_write.txt" + })) + + # Verify overlay stores data in the right place (FUSE/NFS only — ProjFS writes in-place) + if $transport != "projfs" { + $results = ($results | append (run "overlay data placement" { + "placement test" | save $"($mount_point)/overlay_check.txt" + if not ($"($overlay_dir)/overlay_check.txt" | path exists) { + error make {msg: "Write did not appear in overlay directory"} + } + rm $"($mount_point)/overlay_check.txt" + })) + } + + $results = ($results | append (run "mkdir and rename" { + mkdir $"($mount_point)/test_dir" + "test" | save $"($mount_point)/test_dir/a.txt" + mv $"($mount_point)/test_dir/a.txt" $"($mount_point)/test_dir/b.txt" + let content = (open $"($mount_point)/test_dir/b.txt") + if $content != "test" { + error make {msg: $"Content mismatch after rename: ($content)"} + } + })) + + # pip install scipy + $results = ($results | append (run "pip install scipy" { + ^$python_path -m pip install --prefix $mount_point --no-build-isolation scipy + })) + + $results = ($results | append (run "import scipy" { + ^$python_path -c "import scipy; print(f'scipy {scipy.__version__}')" + })) + + # Write a persistence marker before the destructive uninstall tests + "persist_marker" | save $"($mount_point)/persist_test.txt" + + # pip uninstall scipy (pip-installed package) + $results = ($results | append (run "pip uninstall scipy" { + ^$python_path -m pip uninstall -y scipy + })) + + $results = ($results | append (expect_fail "import scipy after uninstall" { + ^$python_path -c "import scipy" + })) + + # pip uninstall pytest (conda-installed package — exercises overlay whiteouts + # for lower-layer file deletion). + # Skip on ProjFS: the base layer (conda packages) is immutable and defined + # by the lock file. Uninstalling a base-layer package should be done by + # modifying the lock file and remounting, not via pip. ProjFS placeholder + # directories also cannot be renamed (ERROR_NOT_SUPPORTED due to reparse + # tags), which breaks pip's uninstall stash step. Pip can still + # install/uninstall in the overlay (as tested by the scipy tests above). + if $transport != "projfs" { + $results = ($results | append (run "pip uninstall pytest" { + ^$python_path -m pip uninstall -y pytest + })) + + $results = ($results | append (expect_fail "import pytest after uninstall" { + ^$python_path -c "import pytest" + })) + } + + # Large file COW materialization + $results = ($results | append (run "large file COW materialization" { + let large_file = $"($mount_point)/large_test.bin" + # Write a 10 MB file through the overlay. + # Use a Python raw string for the path so Windows backslashes + # (e.g. `D:\a\...` → `\a` = BEL) are not interpreted as escapes. + ^$python_path -c $" +import os +data = b'A' * (10 * 1024 * 1024) +with open\(r'($large_file)', 'wb') as f: + f.write\(data) +" + # Verify the file size + let size = (ls $large_file | get size | first) + print $" Wrote ($size) file" + if $size < 10MB { + error make {msg: $"Expected >= 10 MB, got ($size)"} + } + # Read back and verify content + ^$python_path -c $" +with open\(r'($large_file)', 'rb') as f: + data = f.read\() +assert len\(data) == 10 * 1024 * 1024, f'Size mismatch: {len\(data)}' +assert data == b'A' * len\(data), 'Content mismatch' +" + # Verify it landed in the overlay directory (FUSE/NFS only) + if $transport != "projfs" { + if not ($"($overlay_dir)/large_test.bin" | path exists) { + error make {msg: "Large file did not appear in overlay directory"} + } + } + rm $large_file + })) + + # --- Persistence: unmount and remount --- + print "\n== Persistence test: unmount and remount" + + stop_and_cleanup $fs_job $transport $mount_point + + # Remount with the same overlay (use a separate log file to preserve earlier logs) + let log_file2 = $"($tmp)/rattler-vfs-($transport)($overlay_suffix)-remount.log" + let fs_job2 = (start_and_wait $fixture_lock $mount_point $transport $overlay_args $log_file2 $python_path) + + $results = ($results | append (run "persisted file survives remount" { + let content = (open $"($mount_point)/persist_test.txt") + if $content != "persist_marker" { + error make {msg: $"Persisted file content mismatch: ($content)"} + } + })) + + $results = ($results | append (run "renamed file survives remount" { + let content = (open $"($mount_point)/test_dir/b.txt") + if $content != "test" { + error make {msg: $"Renamed file content mismatch: ($content)"} + } + })) + + stop_and_cleanup $fs_job2 $transport $mount_point + + # --- Benign lock-file edit preserves overlay --- + # + # compute_env_hash hashes the resolved package list, not raw lock-file + # bytes, so a trailing YAML comment must NOT change the env hash. + # Regression test for the per-package hash behavior. + print "\n== Benign lock-file edit test" + + let edited_lock = $"($tmp)/rattler-vfs-edited.lock" + open $fixture_lock | $"($in)\n# trailing comment" | save -f $edited_lock + + let log_file3 = $"($tmp)/rattler-vfs-($transport)($overlay_suffix)-edited.log" + let fs_job3 = (start_and_wait $edited_lock $mount_point $transport $overlay_args $log_file3 $python_path) + + $results = ($results | append (run "overlay survives benign lock-file edit" { + let content = (open $"($mount_point)/persist_test.txt") + if $content != "persist_marker" { + error make {msg: $"Persisted file lost after lock-file edit: ($content)"} + } + })) + + stop_and_cleanup $fs_job3 $transport $mount_point + + # --- Env-hash mismatch rejects mount --- + # + # After a genuine package change, the overlay's stored env hash won't match. + # The mount should refuse to start (not silently wipe the overlay). + print "\n== Env-hash mismatch test" + + let modified_lock = $"($tmp)/rattler-vfs-modified.lock" + # Change the first sha256 to produce a different env hash + open $fixture_lock | str replace --regex 'sha256: [0-9a-f]{64}' 'sha256: 0000000000000000000000000000000000000000000000000000000000000000' | save -f $modified_lock + + $results = ($results | append (expect_fail "env-hash mismatch rejects stale overlay" { + let log_mismatch = $"($tmp)/rattler-vfs-($transport)($overlay_suffix)-mismatch.log" + # Bounded with `timeout` on unix (provided by coreutils in the pixi + # env): rattler should refuse and exit, but if it hangs (e.g. blocking + # on cache lock acquisition), `expect_fail` never returns and the + # whole step times out at 15 min. Windows: no `timeout` binary, and + # ProjFS doesn't have NFS-style hangs anyway. + if $nu.os-info.name == "windows" { + ^rattler mount $modified_lock $mount_point --transport $transport ...$overlay_args out+err> $log_mismatch + } else { + ^timeout 60 rattler mount $modified_lock $mount_point --transport $transport ...$overlay_args out+err> $log_mismatch + } + })) + + # --- Transport mismatch rejects mount --- + # + # Overlay records which transport created it. Mounting with a different + # transport must fail rather than silently corrupting state. + # Only testable on Linux where both FUSE and NFS are available. + let other_transport = if $transport == "fuse" { "nfs" } else if $transport == "nfs" and $nu.os-info.name == "linux" { "fuse" } else { "" } + if $other_transport != "" { + print "\n== Transport mismatch test" + + $results = ($results | append (expect_fail "transport mismatch rejects mount" { + let log_transport = $"($tmp)/rattler-vfs-($transport)($overlay_suffix)-transport.log" + ^timeout 60 rattler mount $fixture_lock $mount_point --transport $other_transport ...$overlay_args out+err> $log_transport + })) + } +} + +# --------------------------------------------------------------------------- +# Negative tests (no mount needed) +# --------------------------------------------------------------------------- + +# Network failure: mount with an unreachable package URL. +# Only run on non-Windows (lock file packages differ per platform). +if $nu.os-info.name != "windows" { + print "\n== Network failure test" + + let bad_lock = $"($tmp)/rattler-vfs-bad-url.lock" + open $fixture_lock | str replace --all "https://prefix.dev" "http://127.0.0.1:1" | save -f $bad_lock + + let bad_mount = $"($tmp)/rattler-vfs-bad-url-mount" + mkdir $bad_mount + let bad_log = $"($tmp)/rattler-vfs-bad-url.log" + + # The package cache is content-addressed (keyed by name/version/build + + # sha256, not URL), so the packages downloaded by the earlier tests would + # satisfy this mount without touching the network — and the mount would + # *succeed* and block forever waiting for a signal. Point the cache at an + # empty directory so the unreachable URL is actually exercised. + let bad_cache = $"($tmp)/rattler-vfs-bad-url-cache" + mkdir $bad_cache + + # Wrap with `timeout` so a hang in retry/backoff fails the test instead of + # the whole step (15 min). `timeout` exits 124 on timeout, which still + # satisfies `expect_fail` (any non-zero exit counts). + $results = ($results | append (expect_fail "mount fails with unreachable package URL" { + with-env {RATTLER_CACHE_DIR: $bad_cache} { + ^timeout 60 rattler mount $bad_lock $bad_mount --transport $transport out+err> $bad_log + } + })) + + try { ^timeout 5 rm -rf $bad_mount $bad_cache } catch { } +} + +# --------------------------------------------------------------------------- +# Shutdown tests +# --------------------------------------------------------------------------- + +# Graceful shutdown with a busy mount (open file handle during kill). +# Skip on Windows — job/signal semantics differ. +if $nu.os-info.name != "windows" { + print "\n== Graceful shutdown test" + + let shutdown_mount = $"($tmp)/rattler-vfs-shutdown" + let shutdown_log = $"($tmp)/rattler-vfs-shutdown.log" + mkdir $shutdown_mount + + let shutdown_python = $"($shutdown_mount)/bin/python3" + let fs_job_shutdown = (start_and_wait $fixture_lock $shutdown_mount $transport [] $shutdown_log $shutdown_python) + + # Start a background process that holds a file handle open + let reader_job = job spawn { + ^$shutdown_python -c "import time; f = open(__file__, 'rb'); time.sleep(300)" + } + sleep 1sec # let the reader open the file + + # Kill the mount process (sends SIGTERM → triggers graceful shutdown) + try { job kill $fs_job_shutdown } catch { } + + # Wait up to 10 seconds for process to exit + let exited = (seq 0 9 | any {|_| + let alive = (job list | where id == $fs_job_shutdown | length) > 0 + if not $alive { true } else { sleep 1sec; false } + }) + + try { job kill $reader_job } catch { } + + $results = ($results | append (if $exited { + print "PASS: graceful shutdown with busy mount (mount exited)" + { desc: "graceful shutdown with busy mount", ok: true, elapsed: 0sec } + } else { + print "FAIL: graceful shutdown with busy mount — process did not exit within 10s" + { desc: "graceful shutdown with busy mount", ok: false, elapsed: 10sec } + })) + + stop_and_cleanup $fs_job_shutdown $transport $shutdown_mount + # Wrap with `timeout`: if `stop_and_cleanup`'s umount didn't actually + # detach (e.g. NFS stuck), `rm -rf` would `readdir` into the still-mounted + # path and block forever (a blocked syscall isn't catchable by `try`). + try { ^timeout 5 rm -rf $shutdown_mount } catch { } +} + +# NFS stale mount: SIGKILL the process (no cleanup), verify force-unmount works. +# NFS-only — FUSE auto-unmounts when the FUSE fd is closed. +# +# Linux is excluded: GHA Linux runners' NFS client wedges after several +# mount/umount cycles in this script (umount.nfs returns EPERM under sudo +# even though `umount -i` should bypass it), and a subsequent mount on a +# fresh path then hangs uninterruptibly. The same script runs cleanly on +# Linux outside GHA (locally and on self-hosted runners with privileged +# NFS support) — this is a runner-image limitation, not a code issue. +# +# macOS NFS already exercises this code path successfully — that's the +# platform whose unmount-on-stale behavior we actually ship to users. +# Moving Linux NFS coverage to a self-hosted or container runner with +# proper privilege isolation is tracked as out-of-scope follow-up work. +if $transport == "nfs" and $nu.os-info.name != "linux" { + print "\n== NFS stale mount test" + + let stale_mount = $"($tmp)/rattler-vfs-stale" + let stale_log = $"($tmp)/rattler-vfs-stale.log" + mkdir $stale_mount + + let stale_python = $"($stale_mount)/bin/python3" + let fs_job_stale = (start_and_wait $fixture_lock $stale_mount $transport [] $stale_log $stale_python) + + # SIGKILL — bypasses graceful shutdown, NFS server dies without unmounting. + # Use `pgrep -f` instead of `ps -l | where command =~ ...`: pgrep is + # available on macOS and Linux runners and walks /proc directly without + # nushell's sysinfo wrapper (which surfaces error values for the `command` + # column on some runners). Fall back to 0 → kill no-op if not found, so + # the test fails cleanly instead of hanging. + let pid_str = (try { ^pgrep -f "rattler.*stale" | lines | first } catch { "" }) + if ($pid_str | is-empty) { + print "WARN: could not find rattler stale process via pgrep -f" + } else { + try { ^kill -9 ($pid_str | into int) } catch { } + } + sleep 2sec + + # Mount should be stale — reads should fail. Wrap with `timeout` so a + # hanging NFS read (Linux soft-mount default is hard, and reads block + # indefinitely on a dead server) fails the test fast instead of hitting + # the step's 15-min wall-clock. + let stale_read_failed = try { + ^timeout 5 cat $"($stale_mount)/bin/python3" out+err> /dev/null + false + } catch { true } + + # Force unmount should clean up. Linux: `-i` bypasses umount.nfs (which + # refuses on a stale mount), `-l` lazy-detaches. macOS: `-f` is enough. + # `timeout` guards against macOS umount blocking on a dead NFS server. + let force_unmount_ok = try { + if $nu.os-info.name == "linux" { + ^timeout 10 sudo umount -i -f -l $stale_mount + } else { + ^timeout 10 umount -f $stale_mount + } + true + } catch { false } + + # Make sure the spawned job is reaped — nushell will block on script exit + # waiting for live jobs, which would mask the test failure as a wall-clock + # timeout. + try { job kill $fs_job_stale } catch { } + + # The contract this test asserts is rattler's: when the mount process + # dies, reads on the mount must fail (i.e. the mount is observably stale). + # `force_unmount_ok` is logged as a side-channel but doesn't gate the test + # — that's a property of the kernel NFS client + admin privileges (CI + # runners have varying behavior here, e.g. Linux's umount.nfs returning + # `Operation not permitted` even under sudo with `-l`). + $results = ($results | append (if $stale_read_failed { + print $"PASS: NFS stale mount detected \(force_unmount=($force_unmount_ok))" + { desc: "NFS stale mount detected", ok: true, elapsed: 0sec } + } else { + print $"FAIL: NFS stale mount — read_failed=($stale_read_failed) force_unmount=($force_unmount_ok)" + { desc: "NFS stale mount detected", ok: false, elapsed: 0sec } + })) + + # If the mount is still attached (force_unmount_ok=false), skip the rm + # — `rm -rf` would `readdir` into the stale NFS mount and block forever + # (a blocked syscall isn't catchable by `try`). Best-effort otherwise. + if $force_unmount_ok { + try { ^timeout 5 rm -rf $stale_mount } catch { } + } else { + print $"NOTE: leaving stale mount at ($stale_mount) for the runner-level cleanup" + } +} + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +let all_ok = ($results | all { |r| $r.ok }) + +print "\n== Results:" +for r in $results { + let status = if $r.ok { "PASS" } else { "FAIL" } + print $" ($status) ($r.desc) \(($r.elapsed))" +} + +# Write to GitHub Actions job summary if available +if ($env.GITHUB_STEP_SUMMARY? | is-not-empty) { + let header = $"## rattler-vfs mount test \(($transport), overlay=($use_overlay))\n\n| Test | Status | Time |\n|------|--------|------|\n" + let rows = ($results | each { |r| + let status = if $r.ok { "PASS" } else { "FAIL" } + $"| ($r.desc) | ($status) | ($r.elapsed) |" + } | str join "\n") + $"($header)($rows)\n" | save --append $env.GITHUB_STEP_SUMMARY +} + +# --------------------------------------------------------------------------- +# Final cleanup +# --------------------------------------------------------------------------- +print "\n== Cleaning up..." + +# For non-overlay tests or if overlay tests already cleaned up +if not $use_overlay { + stop_and_cleanup $fs_job $transport $mount_point +} + +# Clean up directories. Wrap rm with `timeout` for the same reason as the +# stale-mount test: a still-attached/stale NFS mount makes `readdir` block +# forever, which `try` cannot interrupt. +if $use_overlay and $transport != "projfs" { + try { ^timeout 5 rm -rf $overlay_dir } catch { } +} +# ProjFS may leave behind tombstones/placeholders that can't be removed +# immediately after stopping virtualization — ignore cleanup errors. +try { ^timeout 5 rm -rf $mount_point } catch { } + +if not $all_ok { + print "\n== rattler mount log tail:" + try { open $log_file | lines | last 30 | each { |l| print $l } } catch { } + exit 1 +} diff --git a/test-data/rattler-vfs/pixi.lock b/test-data/rattler-vfs/pixi.lock new file mode 100644 index 0000000000..6cf3d87778 --- /dev/null +++ b/test-data/rattler-vfs/pixi.lock @@ -0,0 +1,1228 @@ +version: 6 +environments: + default: + channels: + - url: https://prefix.dev/conda-forge/ + options: + pypi-prerelease-mode: if-necessary-or-explicit + packages: + linux-64: + - conda: https://prefix.dev/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://prefix.dev/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda + - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda + - conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + - conda: https://prefix.dev/conda-forge/linux-64/libblas-3.11.0-6_h4a7cf45_openblas.conda + - conda: https://prefix.dev/conda-forge/linux-64/libcblas-3.11.0-6_h0358290_openblas.conda + - conda: https://prefix.dev/conda-forge/linux-64/libexpat-2.7.4-hecca717_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda + - conda: https://prefix.dev/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_18.conda + - conda: https://prefix.dev/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_18.conda + - conda: https://prefix.dev/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_18.conda + - conda: https://prefix.dev/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda + - conda: https://prefix.dev/conda-forge/linux-64/liblapack-3.11.0-6_h47877c9_openblas.conda + - conda: https://prefix.dev/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda + - conda: https://prefix.dev/conda-forge/linux-64/libopenblas-0.3.32-pthreads_h94d23a6_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/libsqlite-3.52.0-hf4e2dac_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_18.conda + - conda: https://prefix.dev/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + - conda: https://prefix.dev/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://prefix.dev/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda + - conda: https://prefix.dev/conda-forge/linux-64/numpy-2.4.3-py312h33ff503_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda + - conda: https://prefix.dev/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/noarch/pip-26.0.1-pyh8b19718_0.conda + - conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://prefix.dev/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda + - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.12-8_cp312.conda + - conda: https://prefix.dev/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://prefix.dev/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + - conda: https://prefix.dev/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://prefix.dev/conda-forge/noarch/wheel-0.46.3-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + osx-arm64: + - conda: https://prefix.dev/conda-forge/osx-arm64/_openmp_mutex-4.5-7_kmp_llvm.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda + - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda + - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda + - conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libblas-3.11.0-6_h51639a9_openblas.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libcblas-3.11.0-6_hb0561ab_openblas.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libcxx-22.1.2-h55c6f16_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libexpat-2.7.4-hf6b4638_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libgcc-15.2.0-hcbb3090_18.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libgfortran-15.2.0-h07b0088_18.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libgfortran5-15.2.0-hdae7583_18.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/liblapack-3.11.0-6_hd9741b5_openblas.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/liblzma-5.8.2-h8088a28_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libopenblas-0.3.32-openmp_he657e61_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libsqlite-3.52.0-h1ae2325_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/llvm-openmp-22.1.1-hc7d1edf_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/numpy-2.4.3-py312h84a4f5f_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/openssl-3.6.1-hd24854e_1.conda + - conda: https://prefix.dev/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/noarch/pip-26.0.1-pyh8b19718_0.conda + - conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://prefix.dev/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/python-3.12.13-h8561d8f_0_cpython.conda + - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.12-8_cp312.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://prefix.dev/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda + - conda: https://prefix.dev/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://prefix.dev/conda-forge/noarch/wheel-0.46.3-pyhd8ed1ab_0.conda + win-64: + - conda: https://prefix.dev/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda + - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.2.25-h4c7d964_0.conda + - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/win-64/libblas-3.11.0-6_hf2e6a31_mkl.conda + - conda: https://prefix.dev/conda-forge/win-64/libcblas-3.11.0-6_h2a3cdd5_mkl.conda + - conda: https://prefix.dev/conda-forge/win-64/libexpat-2.7.4-hac47afa_0.conda + - conda: https://prefix.dev/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda + - conda: https://prefix.dev/conda-forge/win-64/libhwloc-2.12.2-default_h4379cf1_1000.conda + - conda: https://prefix.dev/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda + - conda: https://prefix.dev/conda-forge/win-64/liblapack-3.11.0-6_hf9ab0e9_mkl.conda + - conda: https://prefix.dev/conda-forge/win-64/liblzma-5.8.2-hfd05255_0.conda + - conda: https://prefix.dev/conda-forge/win-64/libsqlite-3.52.0-hf5d6505_0.conda + - conda: https://prefix.dev/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda + - conda: https://prefix.dev/conda-forge/win-64/libxml2-16-2.15.2-h692994f_0.conda + - conda: https://prefix.dev/conda-forge/win-64/libxml2-2.15.2-h5d26750_0.conda + - conda: https://prefix.dev/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda + - conda: https://prefix.dev/conda-forge/win-64/llvm-openmp-22.1.1-h4fa8253_0.conda + - conda: https://prefix.dev/conda-forge/win-64/mkl-2025.3.1-hac47afa_11.conda + - conda: https://prefix.dev/conda-forge/win-64/numpy-2.4.3-py312ha3f287d_0.conda + - conda: https://prefix.dev/conda-forge/win-64/openssl-3.6.1-hf411b9b_1.conda + - conda: https://prefix.dev/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/noarch/pip-26.0.1-pyh8b19718_0.conda + - conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://prefix.dev/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/win-64/python-3.12.13-h0159041_0_cpython.conda + - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.12-8_cp312.conda + - conda: https://prefix.dev/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + - conda: https://prefix.dev/conda-forge/win-64/tbb-2022.3.0-h3155e25_2.conda + - conda: https://prefix.dev/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda + - conda: https://prefix.dev/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://prefix.dev/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda + - conda: https://prefix.dev/conda-forge/win-64/vc-14.3-h41ae7f8_34.conda + - conda: https://prefix.dev/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_34.conda + - conda: https://prefix.dev/conda-forge/win-64/vcomp14-14.44.35208-h818238b_34.conda + - conda: https://prefix.dev/conda-forge/noarch/wheel-0.46.3-pyhd8ed1ab_0.conda +packages: +- conda: https://prefix.dev/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 + md5: a9f577daf3de00bca7c3c76c0ecbd1de + depends: + - __glibc >=2.17,<3.0.a0 + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + size: 28948 + timestamp: 1770939786096 +- conda: https://prefix.dev/conda-forge/osx-arm64/_openmp_mutex-4.5-7_kmp_llvm.conda + build_number: 7 + sha256: 7acaa2e0782cad032bdaf756b536874346ac1375745fb250e9bdd6a48a7ab3cd + md5: a44032f282e7d2acdeb1c240308052dd + depends: + - llvm-openmp >=9.0.1 + license: BSD-3-Clause + license_family: BSD + size: 8325 + timestamp: 1764092507920 +- conda: https://prefix.dev/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + sha256: 0b75d45f0bba3e95dc693336fa51f40ea28c980131fec438afb7ce6118ed05f6 + md5: d2ffd7602c02f2b316fd921d39876885 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + size: 260182 + timestamp: 1771350215188 +- conda: https://prefix.dev/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda + sha256: 540fe54be35fac0c17feefbdc3e29725cce05d7367ffedfaaa1bdda234b019df + md5: 620b85a3f45526a8bc4d23fd78fc22f0 + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + license_family: BSD + size: 124834 + timestamp: 1771350416561 +- conda: https://prefix.dev/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda + sha256: 76dfb71df5e8d1c4eded2dbb5ba15bb8fb2e2b0fe42d94145d5eed4c75c35902 + md5: 4cb8e6b48f67de0b018719cdf1136306 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: bzip2-1.0.6 + license_family: BSD + size: 56115 + timestamp: 1771350256444 +- conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.2.25-h4c7d964_0.conda + sha256: 37950019c59b99585cee5d30dbc2cc9696ed4e11f5742606a4db1621ed8f94d6 + md5: f001e6e220355b7f87403a4d0e5bf1ca + depends: + - __win + license: ISC + size: 147734 + timestamp: 1772006322223 +- conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda + sha256: 67cc7101b36421c5913a1687ef1b99f85b5d6868da3abbf6ec1a4181e79782fc + md5: 4492fd26db29495f0ba23f146cd5638d + depends: + - __unix + license: ISC + size: 147413 + timestamp: 1772006283803 +- conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + sha256: ab29d57dc70786c1269633ba3dff20288b81664d3ff8d21af995742e2bb03287 + md5: 962b9857ee8e7018c22f2776ffa0b2d7 + depends: + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + size: 27011 + timestamp: 1733218222191 +- conda: https://prefix.dev/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + sha256: ee6cf346d017d954255bbcbdb424cddea4d14e4ed7e9813e429db1d795d01144 + md5: 8e662bd460bda79b1ea39194e3c4c9ab + depends: + - python >=3.10 + - typing_extensions >=4.6.0 + license: MIT and PSF-2.0 + size: 21333 + timestamp: 1763918099466 +- conda: https://prefix.dev/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda + sha256: fbf86c4a59c2ed05bbffb2ba25c7ed94f6185ec30ecb691615d42342baa1a16a + md5: c80d8a3b84358cb967fa81e7075fbc8a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + size: 12723451 + timestamp: 1773822285671 +- conda: https://prefix.dev/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda + sha256: 3a7907a17e9937d3a46dfd41cffaf815abad59a569440d1e25177c15fd0684e5 + md5: f1182c91c0de31a7abd40cedf6a5ebef + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 12361647 + timestamp: 1773822915649 +- conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + sha256: e1a9e3b1c8fe62dc3932a616c284b5d8cbe3124bbfbedcf4ce5c828cb166ee19 + md5: 9614359868482abba1bd15ce465e3c42 + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 13387 + timestamp: 1760831448842 +- conda: https://prefix.dev/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + sha256: 3d584956604909ff5df353767f3a2a2f60e07d070b328d109f30ac40cd62df6c + md5: 18335a698559cdbcd86150a48bf54ba6 + depends: + - __glibc >=2.17,<3.0.a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-64 2.45.1 + license: GPL-3.0-only + license_family: GPL + size: 728002 + timestamp: 1774197446916 +- conda: https://prefix.dev/conda-forge/linux-64/libblas-3.11.0-6_h4a7cf45_openblas.conda + build_number: 6 + sha256: 7bfe936dbb5db04820cf300a9cc1f5ee8d5302fc896c2d66e30f1ee2f20fbfd6 + md5: 6d6d225559bfa6e2f3c90ee9c03d4e2e + depends: + - libopenblas >=0.3.32,<0.3.33.0a0 + - libopenblas >=0.3.32,<1.0a0 + constrains: + - blas 2.306 openblas + - liblapack 3.11.0 6*_openblas + - liblapacke 3.11.0 6*_openblas + - libcblas 3.11.0 6*_openblas + - mkl <2026 + license: BSD-3-Clause + size: 18621 + timestamp: 1774503034895 +- conda: https://prefix.dev/conda-forge/osx-arm64/libblas-3.11.0-6_h51639a9_openblas.conda + build_number: 6 + sha256: 979227fc03628925037ab2dfda008eb7b5592644d9c2c21dd285cefe8c42553d + md5: e551103471911260488a02155cef9c94 + depends: + - libopenblas >=0.3.32,<0.3.33.0a0 + - libopenblas >=0.3.32,<1.0a0 + constrains: + - liblapacke 3.11.0 6*_openblas + - liblapack 3.11.0 6*_openblas + - blas 2.306 openblas + - libcblas 3.11.0 6*_openblas + - mkl <2026 + license: BSD-3-Clause + size: 18859 + timestamp: 1774504387211 +- conda: https://prefix.dev/conda-forge/win-64/libblas-3.11.0-6_hf2e6a31_mkl.conda + build_number: 6 + sha256: 10c8054f007adca8c780cd8bb9335fa5d990f0494b825158d3157983a25b1ea2 + md5: 95543eec964b4a4a7ca3c4c9be481aa1 + depends: + - mkl >=2025.3.1,<2026.0a0 + constrains: + - blas 2.306 mkl + - liblapacke 3.11.0 6*_mkl + - liblapack 3.11.0 6*_mkl + - libcblas 3.11.0 6*_mkl + license: BSD-3-Clause + size: 68082 + timestamp: 1774503684284 +- conda: https://prefix.dev/conda-forge/linux-64/libcblas-3.11.0-6_h0358290_openblas.conda + build_number: 6 + sha256: 57edafa7796f6fa3ebbd5367692dd4c7f552be42109c2dd1a7c89b55089bf374 + md5: 36ae340a916635b97ac8a0655ace2a35 + depends: + - libblas 3.11.0 6_h4a7cf45_openblas + constrains: + - blas 2.306 openblas + - liblapack 3.11.0 6*_openblas + - liblapacke 3.11.0 6*_openblas + license: BSD-3-Clause + size: 18622 + timestamp: 1774503050205 +- conda: https://prefix.dev/conda-forge/osx-arm64/libcblas-3.11.0-6_hb0561ab_openblas.conda + build_number: 6 + sha256: 2e6b3e9b1ab672133b70fc6730e42290e952793f132cb5e72eee22835463eba0 + md5: 805c6d31c5621fd75e53dfcf21fb243a + depends: + - libblas 3.11.0 6_h51639a9_openblas + constrains: + - liblapacke 3.11.0 6*_openblas + - blas 2.306 openblas + - liblapack 3.11.0 6*_openblas + license: BSD-3-Clause + size: 18863 + timestamp: 1774504433388 +- conda: https://prefix.dev/conda-forge/win-64/libcblas-3.11.0-6_h2a3cdd5_mkl.conda + build_number: 6 + sha256: 02b2a2225f4899c6aaa1dc723e06b3f7a4903d2129988f91fc1527409b07b0a5 + md5: 9e4bf521c07f4d423cba9296b7927e3c + depends: + - libblas 3.11.0 6_hf2e6a31_mkl + constrains: + - blas 2.306 mkl + - liblapacke 3.11.0 6*_mkl + - liblapack 3.11.0 6*_mkl + license: BSD-3-Clause + size: 68221 + timestamp: 1774503722413 +- conda: https://prefix.dev/conda-forge/osx-arm64/libcxx-22.1.2-h55c6f16_0.conda + sha256: d1402087c8792461bfc081629e8aa97e6e577a31ae0b84e6b9cc144a18f48067 + md5: 4280e0a7fd613b271e022e60dea0138c + depends: + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + size: 568094 + timestamp: 1774439202359 +- conda: https://prefix.dev/conda-forge/linux-64/libexpat-2.7.4-hecca717_0.conda + sha256: d78f1d3bea8c031d2f032b760f36676d87929b18146351c4464c66b0869df3f5 + md5: e7f7ce06ec24cfcfb9e36d28cf82ba57 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - expat 2.7.4.* + license: MIT + license_family: MIT + size: 76798 + timestamp: 1771259418166 +- conda: https://prefix.dev/conda-forge/osx-arm64/libexpat-2.7.4-hf6b4638_0.conda + sha256: 03887d8080d6a8fe02d75b80929271b39697ecca7628f0657d7afaea87761edf + md5: a92e310ae8dfc206ff449f362fc4217f + depends: + - __osx >=11.0 + constrains: + - expat 2.7.4.* + license: MIT + license_family: MIT + size: 68199 + timestamp: 1771260020767 +- conda: https://prefix.dev/conda-forge/win-64/libexpat-2.7.4-hac47afa_0.conda + sha256: b31f6fb629c4e17885aaf2082fb30384156d16b48b264e454de4a06a313b533d + md5: 1c1ced969021592407f16ada4573586d + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - expat 2.7.4.* + license: MIT + license_family: MIT + size: 70323 + timestamp: 1771259521393 +- conda: https://prefix.dev/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 + md5: a360c33a5abe61c07959e449fa1453eb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + size: 58592 + timestamp: 1769456073053 +- conda: https://prefix.dev/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + sha256: 6686a26466a527585e6a75cc2a242bf4a3d97d6d6c86424a441677917f28bec7 + md5: 43c04d9cb46ef176bb2a4c77e324d599 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 40979 + timestamp: 1769456747661 +- conda: https://prefix.dev/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda + sha256: 59d01f2dfa8b77491b5888a5ab88ff4e1574c9359f7e229da254cdfe27ddc190 + md5: 720b39f5ec0610457b725eb3f396219a + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 45831 + timestamp: 1769456418774 +- conda: https://prefix.dev/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda + sha256: faf7d2017b4d718951e3a59d081eb09759152f93038479b768e3d612688f83f5 + md5: 0aa00f03f9e39fb9876085dee11a85d4 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==15.2.0=*_18 + - libgomp 15.2.0 he0feb66_18 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 1041788 + timestamp: 1771378212382 +- conda: https://prefix.dev/conda-forge/osx-arm64/libgcc-15.2.0-hcbb3090_18.conda + sha256: 1d9c4f35586adb71bcd23e31b68b7f3e4c4ab89914c26bed5f2859290be5560e + md5: 92df6107310b1fff92c4cc84f0de247b + depends: + - _openmp_mutex + constrains: + - libgcc-ng ==15.2.0=*_18 + - libgomp 15.2.0 18 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 401974 + timestamp: 1771378877463 +- conda: https://prefix.dev/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_18.conda + sha256: e318a711400f536c81123e753d4c797a821021fb38970cebfb3f454126016893 + md5: d5e96b1ed75ca01906b3d2469b4ce493 + depends: + - libgcc 15.2.0 he0feb66_18 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 27526 + timestamp: 1771378224552 +- conda: https://prefix.dev/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_18.conda + sha256: d2c9fad338fd85e4487424865da8e74006ab2e2475bd788f624d7a39b2a72aee + md5: 9063115da5bc35fdc3e1002e69b9ef6e + depends: + - libgfortran5 15.2.0 h68bc16d_18 + constrains: + - libgfortran-ng ==15.2.0=*_18 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 27523 + timestamp: 1771378269450 +- conda: https://prefix.dev/conda-forge/osx-arm64/libgfortran-15.2.0-h07b0088_18.conda + sha256: 63f89087c3f0c8621c5c89ecceec1e56e5e1c84f65fc9c5feca33a07c570a836 + md5: 26981599908ed2205366e8fc91b37fc6 + depends: + - libgfortran5 15.2.0 hdae7583_18 + constrains: + - libgfortran-ng ==15.2.0=*_18 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 138973 + timestamp: 1771379054939 +- conda: https://prefix.dev/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_18.conda + sha256: 539b57cf50ec85509a94ba9949b7e30717839e4d694bc94f30d41c9d34de2d12 + md5: 646855f357199a12f02a87382d429b75 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=15.2.0 + constrains: + - libgfortran 15.2.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 2482475 + timestamp: 1771378241063 +- conda: https://prefix.dev/conda-forge/osx-arm64/libgfortran5-15.2.0-hdae7583_18.conda + sha256: 91033978ba25e6a60fb86843cf7e1f7dc8ad513f9689f991c9ddabfaf0361e7e + md5: c4a6f7989cffb0544bfd9207b6789971 + depends: + - libgcc >=15.2.0 + constrains: + - libgfortran 15.2.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 598634 + timestamp: 1771378886363 +- conda: https://prefix.dev/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda + sha256: 21337ab58e5e0649d869ab168d4e609b033509de22521de1bfed0c031bfc5110 + md5: 239c5e9546c38a1e884d69effcf4c882 + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 603262 + timestamp: 1771378117851 +- conda: https://prefix.dev/conda-forge/win-64/libhwloc-2.12.2-default_h4379cf1_1000.conda + sha256: 8cdf11333a81085468d9aa536ebb155abd74adc293576f6013fc0c85a7a90da3 + md5: 3b576f6860f838f950c570f4433b086e + depends: + - libwinpthread >=12.0.0.r4.gg4f2fc60ca + - libxml2 + - libxml2-16 >=2.14.6 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause + license_family: BSD + size: 2411241 + timestamp: 1765104337762 +- conda: https://prefix.dev/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda + sha256: 0dcdb1a5f01863ac4e8ba006a8b0dc1a02d2221ec3319b5915a1863254d7efa7 + md5: 64571d1dd6cdcfa25d0664a5950fdaa2 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: LGPL-2.1-only + size: 696926 + timestamp: 1754909290005 +- conda: https://prefix.dev/conda-forge/linux-64/liblapack-3.11.0-6_h47877c9_openblas.conda + build_number: 6 + sha256: 371f517eb7010b21c6cc882c7606daccebb943307cb9a3bf2c70456a5c024f7d + md5: 881d801569b201c2e753f03c84b85e15 + depends: + - libblas 3.11.0 6_h4a7cf45_openblas + constrains: + - blas 2.306 openblas + - liblapacke 3.11.0 6*_openblas + - libcblas 3.11.0 6*_openblas + license: BSD-3-Clause + size: 18624 + timestamp: 1774503065378 +- conda: https://prefix.dev/conda-forge/osx-arm64/liblapack-3.11.0-6_hd9741b5_openblas.conda + build_number: 6 + sha256: 21606b7346810559e259807497b86f438950cf19e71838e44ebaf4bd2b35b549 + md5: ee33d2d05a7c5ea1f67653b37eb74db1 + depends: + - libblas 3.11.0 6_h51639a9_openblas + constrains: + - liblapacke 3.11.0 6*_openblas + - libcblas 3.11.0 6*_openblas + - blas 2.306 openblas + license: BSD-3-Clause + size: 18863 + timestamp: 1774504467905 +- conda: https://prefix.dev/conda-forge/win-64/liblapack-3.11.0-6_hf9ab0e9_mkl.conda + build_number: 6 + sha256: 2e6ac39e456ba13ec8f02fc0787b8a22c89780e24bd5556eaf642177463ffb36 + md5: 7e9cdaf6f302142bc363bbab3b5e7074 + depends: + - libblas 3.11.0 6_hf2e6a31_mkl + constrains: + - blas 2.306 mkl + - liblapacke 3.11.0 6*_mkl + - libcblas 3.11.0 6*_mkl + license: BSD-3-Clause + size: 80571 + timestamp: 1774503757128 +- conda: https://prefix.dev/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda + sha256: 755c55ebab181d678c12e49cced893598f2bab22d582fbbf4d8b83c18be207eb + md5: c7c83eecbb72d88b940c249af56c8b17 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - xz 5.8.2.* + license: 0BSD + size: 113207 + timestamp: 1768752626120 +- conda: https://prefix.dev/conda-forge/osx-arm64/liblzma-5.8.2-h8088a28_0.conda + sha256: 7bfc7ffb2d6a9629357a70d4eadeadb6f88fa26ebc28f606b1c1e5e5ed99dc7e + md5: 009f0d956d7bfb00de86901d16e486c7 + depends: + - __osx >=11.0 + constrains: + - xz 5.8.2.* + license: 0BSD + size: 92242 + timestamp: 1768752982486 +- conda: https://prefix.dev/conda-forge/win-64/liblzma-5.8.2-hfd05255_0.conda + sha256: f25bf293f550c8ed2e0c7145eb404324611cfccff37660869d97abf526eb957c + md5: ba0bfd4c3cf73f299ffe46ff0eaeb8e3 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - xz 5.8.2.* + license: 0BSD + size: 106169 + timestamp: 1768752763559 +- conda: https://prefix.dev/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda + sha256: 927fe72b054277cde6cb82597d0fcf6baf127dcbce2e0a9d8925a68f1265eef5 + md5: d864d34357c3b65a4b731f78c0801dc4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: LGPL-2.1-only + license_family: GPL + size: 33731 + timestamp: 1750274110928 +- conda: https://prefix.dev/conda-forge/linux-64/libopenblas-0.3.32-pthreads_h94d23a6_0.conda + sha256: 6dc30b28f32737a1c52dada10c8f3a41bc9e021854215efca04a7f00487d09d9 + md5: 89d61bc91d3f39fda0ca10fcd3c68594 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libgfortran + - libgfortran5 >=14.3.0 + constrains: + - openblas >=0.3.32,<0.3.33.0a0 + license: BSD-3-Clause + size: 5928890 + timestamp: 1774471724897 +- conda: https://prefix.dev/conda-forge/osx-arm64/libopenblas-0.3.32-openmp_he657e61_0.conda + sha256: 713e453bde3531c22a660577e59bf91ef578dcdfd5edb1253a399fa23514949a + md5: 3a1111a4b6626abebe8b978bb5a323bf + depends: + - __osx >=11.0 + - libgfortran + - libgfortran5 >=14.3.0 + - llvm-openmp >=19.1.7 + constrains: + - openblas >=0.3.32,<0.3.33.0a0 + license: BSD-3-Clause + size: 4308797 + timestamp: 1774472508546 +- conda: https://prefix.dev/conda-forge/linux-64/libsqlite-3.52.0-hf4e2dac_0.conda + sha256: d716847b7deca293d2e49ed1c8ab9e4b9e04b9d780aea49a97c26925b28a7993 + md5: fd893f6a3002a635b5e50ceb9dd2c0f4 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.2,<79.0a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: blessing + size: 951405 + timestamp: 1772818874251 +- conda: https://prefix.dev/conda-forge/osx-arm64/libsqlite-3.52.0-h1ae2325_0.conda + sha256: beb0fd5594d6d7c7cd42c992b6bb4d66cbb39d6c94a8234f15956da99a04306c + md5: f6233a3fddc35a2ec9f617f79d6f3d71 + depends: + - __osx >=11.0 + - icu >=78.2,<79.0a0 + - libzlib >=1.3.1,<2.0a0 + license: blessing + size: 918420 + timestamp: 1772819478684 +- conda: https://prefix.dev/conda-forge/win-64/libsqlite-3.52.0-hf5d6505_0.conda + sha256: 5fccf1e4e4062f8b9a554abf4f9735a98e70f82e2865d0bfdb47b9de94887583 + md5: 8830689d537fda55f990620680934bb1 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: blessing + size: 1297302 + timestamp: 1772818899033 +- conda: https://prefix.dev/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_18.conda + sha256: 78668020064fdaa27e9ab65cd2997e2c837b564ab26ce3bf0e58a2ce1a525c6e + md5: 1b08cd684f34175e4514474793d44bcb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 15.2.0 he0feb66_18 + constrains: + - libstdcxx-ng ==15.2.0=*_18 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 5852330 + timestamp: 1771378262446 +- conda: https://prefix.dev/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda + sha256: 1a7539cfa7df00714e8943e18de0b06cceef6778e420a5ee3a2a145773758aee + md5: db409b7c1720428638e7c0d509d3e1b5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + size: 40311 + timestamp: 1766271528534 +- conda: https://prefix.dev/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda + sha256: 0fccf2d17026255b6e10ace1f191d0a2a18f2d65088fd02430be17c701f8ffe0 + md5: 8a86073cf3b343b87d03f41790d8b4e5 + depends: + - ucrt + constrains: + - pthreads-win32 <0.0a0 + - msys2-conda-epoch <0.0a0 + license: MIT AND BSD-3-Clause-Clear + size: 36621 + timestamp: 1759768399557 +- conda: https://prefix.dev/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + sha256: 6ae68e0b86423ef188196fff6207ed0c8195dd84273cb5623b85aa08033a410c + md5: 5aa797f8787fe7a17d1b0821485b5adc + depends: + - libgcc-ng >=12 + license: LGPL-2.1-or-later + size: 100393 + timestamp: 1702724383534 +- conda: https://prefix.dev/conda-forge/win-64/libxml2-2.15.2-h5d26750_0.conda + sha256: f905eb7046987c336122121759e7f09144729f6898f48cd06df2a945b86998d8 + md5: 1007e1bfe181a2aee214779ee7f13d30 + depends: + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.2,<6.0a0 + - libxml2-16 2.15.2 h692994f_0 + - libzlib >=1.3.1,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - icu <0.0a0 + license: MIT + license_family: MIT + size: 43681 + timestamp: 1772704748950 +- conda: https://prefix.dev/conda-forge/win-64/libxml2-16-2.15.2-h692994f_0.conda + sha256: b8c71b3b609c7cfe17f3f2a47c75394d7b30acfb8b34ad7a049ea8757b4d33df + md5: e365238134188e42ed36ee996159d482 + depends: + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.2,<6.0a0 + - libzlib >=1.3.1,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - libxml2 2.15.2 + - icu <0.0a0 + license: MIT + license_family: MIT + size: 520078 + timestamp: 1772704728534 +- conda: https://prefix.dev/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + sha256: 55044c403570f0dc26e6364de4dc5368e5f3fc7ff103e867c487e2b5ab2bcda9 + md5: d87ff7921124eccd67248aa483c23fec + depends: + - __glibc >=2.17,<3.0.a0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + size: 63629 + timestamp: 1774072609062 +- conda: https://prefix.dev/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + sha256: 361415a698514b19a852f5d1123c5da746d4642139904156ddfca7c922d23a05 + md5: bc5a5721b6439f2f62a84f2548136082 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + size: 47759 + timestamp: 1774072956767 +- conda: https://prefix.dev/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda + sha256: 88609816e0cc7452bac637aaf65783e5edf4fee8a9f8e22bdc3a75882c536061 + md5: dbabbd6234dea34040e631f87676292f + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + size: 58347 + timestamp: 1774072851498 +- conda: https://prefix.dev/conda-forge/osx-arm64/llvm-openmp-22.1.1-hc7d1edf_0.conda + sha256: c6f67e928f47603aca7e4b83632d8f3e82bd698051c7c0b34fcce3796eb9b63c + md5: 5a44f53783d87427790fc8692542f1bb + depends: + - __osx >=11.0 + constrains: + - intel-openmp <0.0a0 + - openmp 22.1.1|22.1.1.* + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + size: 285912 + timestamp: 1774349644882 +- conda: https://prefix.dev/conda-forge/win-64/llvm-openmp-22.1.1-h4fa8253_0.conda + sha256: 64c7fe6490583f3c49c36c2f413e681072102db8abea13a3e1832f44eaf55518 + md5: d9f479404fe316e575f4a4575f3df406 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - openmp 22.1.1|22.1.1.* + - intel-openmp <0.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + size: 347138 + timestamp: 1774349485844 +- conda: https://prefix.dev/conda-forge/win-64/mkl-2025.3.1-hac47afa_11.conda + sha256: f2c2b2a3c2e7d08d78c10bef7c135a4262c80d1d48c85fb5902ca30d61d645f4 + md5: 3fd3009cef89c36e9898a6feeb0f5530 + depends: + - llvm-openmp >=22.1.1 + - tbb >=2022.3.0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: LicenseRef-IntelSimplifiedSoftwareOct2022 + license_family: Proprietary + size: 99997309 + timestamp: 1774449747739 +- conda: https://prefix.dev/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda + sha256: 3fde293232fa3fca98635e1167de6b7c7fda83caf24b9d6c91ec9eefb4f4d586 + md5: 47e340acb35de30501a76c7c799c41d7 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: X11 AND BSD-3-Clause + size: 891641 + timestamp: 1738195959188 +- conda: https://prefix.dev/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda + sha256: 2827ada40e8d9ca69a153a45f7fd14f32b2ead7045d3bbb5d10964898fe65733 + md5: 068d497125e4bf8a66bf707254fff5ae + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + size: 797030 + timestamp: 1738196177597 +- conda: https://prefix.dev/conda-forge/linux-64/numpy-2.4.3-py312h33ff503_0.conda + sha256: 1aab7ba963affa572956b1bd8d239df52a9c7bc799c560f98bc658ab70224e10 + md5: 5930ee8a175a242b4f001b1e9e72024f + depends: + - python + - libgcc >=14 + - libstdcxx >=14 + - __glibc >=2.17,<3.0.a0 + - liblapack >=3.9.0,<4.0a0 + - libblas >=3.9.0,<4.0a0 + - python_abi 3.12.* *_cp312 + - libcblas >=3.9.0,<4.0a0 + constrains: + - numpy-base <0a0 + license: BSD-3-Clause + license_family: BSD + size: 8757569 + timestamp: 1773839284329 +- conda: https://prefix.dev/conda-forge/osx-arm64/numpy-2.4.3-py312h84a4f5f_0.conda + sha256: 8116c570ca5b423b46d968be799eae7494b30fe7d65e4080fc891f35a01ea0d4 + md5: 0a8a2049321d82aeaae02f07045d970e + depends: + - python + - python 3.12.* *_cpython + - libcxx >=19 + - __osx >=11.0 + - python_abi 3.12.* *_cp312 + - liblapack >=3.9.0,<4.0a0 + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + constrains: + - numpy-base <0a0 + license: BSD-3-Clause + license_family: BSD + size: 6840415 + timestamp: 1773839165988 +- conda: https://prefix.dev/conda-forge/win-64/numpy-2.4.3-py312ha3f287d_0.conda + sha256: f0b92b9f58406ce21c7d0f037e58cb62380daffb9232c7cb31ab5edc217527e6 + md5: 6169671e14dc7c36eebfd9870446f11c + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - libcblas >=3.9.0,<4.0a0 + - liblapack >=3.9.0,<4.0a0 + - libblas >=3.9.0,<4.0a0 + - python_abi 3.12.* *_cp312 + constrains: + - numpy-base <0a0 + license: BSD-3-Clause + license_family: BSD + size: 7166412 + timestamp: 1773839142889 +- conda: https://prefix.dev/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda + sha256: 44c877f8af015332a5d12f5ff0fb20ca32f896526a7d0cdb30c769df1144fb5c + md5: f61eb8cd60ff9057122a3d338b99c00f + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + size: 3164551 + timestamp: 1769555830639 +- conda: https://prefix.dev/conda-forge/osx-arm64/openssl-3.6.1-hd24854e_1.conda + sha256: 361f5c5e60052abc12bdd1b50d7a1a43e6a6653aab99a2263bf2288d709dcf67 + md5: f4f6ad63f98f64191c3e77c5f5f29d76 + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + size: 3104268 + timestamp: 1769556384749 +- conda: https://prefix.dev/conda-forge/win-64/openssl-3.6.1-hf411b9b_1.conda + sha256: 53a5ad2e5553b8157a91bb8aa375f78c5958f77cb80e9d2ce59471ea8e5c0bd6 + md5: eb585509b815415bc964b2c7e11c7eb3 + depends: + - ca-certificates + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: Apache + size: 9343023 + timestamp: 1769557547888 +- conda: https://prefix.dev/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda + sha256: c1fc0f953048f743385d31c468b4a678b3ad20caffdeaa94bed85ba63049fd58 + md5: b76541e68fea4d511b1ac46a28dcd2c6 + depends: + - python >=3.8 + - python + license: Apache-2.0 + license_family: APACHE + size: 72010 + timestamp: 1769093650580 +- conda: https://prefix.dev/conda-forge/noarch/pip-26.0.1-pyh8b19718_0.conda + sha256: 8e1497814a9997654ed7990a79c054ea5a42545679407acbc6f7e809c73c9120 + md5: 67bdec43082fd8a9cffb9484420b39a2 + depends: + - python >=3.10,<3.13.0a0 + - setuptools + - wheel + license: MIT + license_family: MIT + size: 1181790 + timestamp: 1770270305795 +- conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + sha256: e14aafa63efa0528ca99ba568eaf506eb55a0371d12e6250aaaa61718d2eb62e + md5: d7585b6550ad04c8c5e21097ada2888e + depends: + - python >=3.9 + - python + license: MIT + license_family: MIT + size: 25877 + timestamp: 1764896838868 +- conda: https://prefix.dev/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + sha256: 5577623b9f6685ece2697c6eb7511b4c9ac5fb607c9babc2646c811b428fd46a + md5: 6b6ece66ebcae2d5f326c77ef2c5a066 + depends: + - python >=3.9 + license: BSD-2-Clause + license_family: BSD + size: 889287 + timestamp: 1750615908735 +- conda: https://prefix.dev/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda + sha256: 9e749fb465a8bedf0184d8b8996992a38de351f7c64e967031944978de03a520 + md5: 2b694bad8a50dc2f712f5368de866480 + depends: + - pygments >=2.7.2 + - python >=3.10 + - iniconfig >=1.0.1 + - packaging >=22 + - pluggy >=1.5,<2 + - tomli >=1 + - colorama >=0.4 + - exceptiongroup >=1 + - python + constrains: + - pytest-faulthandler >=2 + license: MIT + license_family: MIT + size: 299581 + timestamp: 1765062031645 +- conda: https://prefix.dev/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda + sha256: a44655c1c3e1d43ed8704890a91e12afd68130414ea2c0872e154e5633a13d7e + md5: 7eccb41177e15cc672e1babe9056018e + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.7.4,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.2,<6.0a0 + - libnsl >=2.0.1,<2.1.0a0 + - libsqlite >=3.51.2,<4.0a0 + - libuuid >=2.41.3,<3.0a0 + - libxcrypt >=4.4.36 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.5,<4.0a0 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + constrains: + - python_abi 3.12.* *_cp312 + license: Python-2.0 + size: 31608571 + timestamp: 1772730708989 +- conda: https://prefix.dev/conda-forge/osx-arm64/python-3.12.13-h8561d8f_0_cpython.conda + sha256: e658e647a4a15981573d6018928dec2c448b10c77c557c29872043ff23c0eb6a + md5: 8e7608172fa4d1b90de9a745c2fd2b81 + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.7.4,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.2,<6.0a0 + - libsqlite >=3.51.2,<4.0a0 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.5,<4.0a0 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + constrains: + - python_abi 3.12.* *_cp312 + license: Python-2.0 + size: 12127424 + timestamp: 1772730755512 +- conda: https://prefix.dev/conda-forge/win-64/python-3.12.13-h0159041_0_cpython.conda + sha256: a02b446d8b7b167b61733a3de3be5de1342250403e72a63b18dac89e99e6180e + md5: 2956dff38eb9f8332ad4caeba941cfe7 + depends: + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.7.4,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.2,<6.0a0 + - libsqlite >=3.51.2,<4.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.5,<4.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - python_abi 3.12.* *_cp312 + license: Python-2.0 + size: 15840187 + timestamp: 1772728877265 +- conda: https://prefix.dev/conda-forge/noarch/python_abi-3.12-8_cp312.conda + build_number: 8 + sha256: 80677180dd3c22deb7426ca89d6203f1c7f1f256f2d5a94dc210f6e758229809 + md5: c3efd25ac4d74b1584d2f7a57195ddf1 + constrains: + - python 3.12.* *_cpython + license: BSD-3-Clause + license_family: BSD + size: 6958 + timestamp: 1752805918820 +- conda: https://prefix.dev/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 + md5: d7d95fc8287ea7bf33e0e7116d2b95ec + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + size: 345073 + timestamp: 1765813471974 +- conda: https://prefix.dev/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + sha256: a77010528efb4b548ac2a4484eaf7e1c3907f2aec86123ed9c5212ae44502477 + md5: f8381319127120ce51e081dce4865cf4 + depends: + - __osx >=11.0 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + size: 313930 + timestamp: 1765813902568 +- conda: https://prefix.dev/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + sha256: 82088a6e4daa33329a30bc26dc19a98c7c1d3f05c0f73ce9845d4eab4924e9e1 + md5: 8e194e7b992f99a5015edbd4ebd38efd + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 639697 + timestamp: 1773074868565 +- conda: https://prefix.dev/conda-forge/win-64/tbb-2022.3.0-h3155e25_2.conda + sha256: abd9a489f059fba85c8ffa1abdaa4d515d6de6a3325238b8e81203b913cf65a9 + md5: 0f9817ffbe25f9e69ceba5ea70c52606 + depends: + - libhwloc >=2.12.2,<2.12.3.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: APACHE + size: 155869 + timestamp: 1767886839029 +- conda: https://prefix.dev/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + sha256: cafeec44494f842ffeca27e9c8b0c27ed714f93ac77ddadc6aaf726b5554ebac + md5: cffd3bdd58090148f4cfcd831f4b26ab + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + size: 3301196 + timestamp: 1769460227866 +- conda: https://prefix.dev/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda + sha256: 799cab4b6cde62f91f750149995d149bc9db525ec12595e8a1d91b9317f038b3 + md5: a9d86bc62f39b94c4661716624eb21b0 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: TCL + license_family: BSD + size: 3127137 + timestamp: 1769460817696 +- conda: https://prefix.dev/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda + sha256: 0e79810fae28f3b69fe7391b0d43f5474d6bd91d451d5f2bde02f55ae481d5e3 + md5: 0481bfd9814bf525bd4b3ee4b51494c4 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: TCL + license_family: BSD + size: 3526350 + timestamp: 1769460339384 +- conda: https://prefix.dev/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + sha256: 91cafdb64268e43e0e10d30bd1bef5af392e69f00edd34dfaf909f69ab2da6bd + md5: b5325cf06a000c5b14970462ff5e4d58 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 21561 + timestamp: 1774492402955 +- conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + sha256: 032271135bca55aeb156cee361c81350c6f3fb203f57d024d7e5a1fc9ef18731 + md5: 0caa1af407ecff61170c9437a808404d + depends: + - python >=3.10 + - python + license: PSF-2.0 + license_family: PSF + size: 51692 + timestamp: 1756220668932 +- conda: https://prefix.dev/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c + md5: ad659d0a2b3e47e38d829aa8cad2d610 + license: LicenseRef-Public-Domain + size: 119135 + timestamp: 1767016325805 +- conda: https://prefix.dev/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda + sha256: 3005729dce6f3d3f5ec91dfc49fc75a0095f9cd23bab49efb899657297ac91a5 + md5: 71b24316859acd00bdb8b38f5e2ce328 + constrains: + - vc14_runtime >=14.29.30037 + - vs2015_runtime >=14.29.30037 + license: LicenseRef-MicrosoftWindowsSDK10 + size: 694692 + timestamp: 1756385147981 +- conda: https://prefix.dev/conda-forge/win-64/vc-14.3-h41ae7f8_34.conda + sha256: 9dc40c2610a6e6727d635c62cced5ef30b7b30123f5ef67d6139e23d21744b3a + md5: 1e610f2416b6acdd231c5f573d754a0f + depends: + - vc14_runtime >=14.44.35208 + track_features: + - vc14 + license: BSD-3-Clause + license_family: BSD + size: 19356 + timestamp: 1767320221521 +- conda: https://prefix.dev/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_34.conda + sha256: 02732f953292cce179de9b633e74928037fa3741eb5ef91c3f8bae4f761d32a5 + md5: 37eb311485d2d8b2c419449582046a42 + depends: + - ucrt >=10.0.20348.0 + - vcomp14 14.44.35208 h818238b_34 + constrains: + - vs2015_runtime 14.44.35208.* *_34 + license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime + license_family: Proprietary + size: 683233 + timestamp: 1767320219644 +- conda: https://prefix.dev/conda-forge/win-64/vcomp14-14.44.35208-h818238b_34.conda + sha256: 878d5d10318b119bd98ed3ed874bd467acbe21996e1d81597a1dbf8030ea0ce6 + md5: 242d9f25d2ae60c76b38a5e42858e51d + depends: + - ucrt >=10.0.20348.0 + constrains: + - vs2015_runtime 14.44.35208.* *_34 + license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime + license_family: Proprietary + size: 115235 + timestamp: 1767320173250 +- conda: https://prefix.dev/conda-forge/noarch/wheel-0.46.3-pyhd8ed1ab_0.conda + sha256: d6cf2f0ebd5e09120c28ecba450556ce553752652d91795442f0e70f837126ae + md5: bdbd7385b4a67025ac2dba4ef8cb6a8f + depends: + - packaging >=24.0 + - python >=3.10 + license: MIT + license_family: MIT + size: 31858 + timestamp: 1769139207397 +- conda: https://prefix.dev/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 + md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 601375 + timestamp: 1764777111296 diff --git a/test-data/rattler-vfs/pixi.toml b/test-data/rattler-vfs/pixi.toml new file mode 100644 index 0000000000..7d293f3b6d --- /dev/null +++ b/test-data/rattler-vfs/pixi.toml @@ -0,0 +1,21 @@ +# Fixture environment for the rattler_vfs end-to-end mount test. +# +# This is NOT the environment the test *runs* in (that lives in the +# `rattler-vfs` feature/environment of the repository-root `pixi.toml`). This +# manifest defines the environment that the test *mounts*: its committed +# `pixi.lock` is passed to `rattler mount` by `scripts/e2e/rattler-vfs-mount.nu` +# (see the `fixture_lock` variable), which then asserts the mounted files +# behave correctly. +# +# After changing dependencies here, regenerate the lock with `pixi lock` in +# this directory and commit the updated `pixi.lock`. +[workspace] +name = "rattler-vfs-e2e" +channels = ["https://prefix.dev/conda-forge"] +platforms = ["linux-64", "osx-arm64", "win-64"] + +[dependencies] +python = ">=3.11,<3.13" +numpy = ">=1.26,<3" +pip = ">=24,<27" +pytest = ">=8" diff --git a/typos.toml b/typos.toml index 0fd1f25226..24afa2c798 100644 --- a/typos.toml +++ b/typos.toml @@ -19,3 +19,5 @@ strat = "strat" haa = "haa" intoto = "intoto" certifi = "certifi" +ACCES = "ACCES" # NFS3ERR_ACCES / libc::EACCES +NFS3ERR_ACCES = "NFS3ERR_ACCES"