From 5e083034f76500f50e77f8cf7aa20a0149113b28 Mon Sep 17 00:00:00 2001 From: alexbowe Date: Wed, 6 May 2026 13:59:38 -0700 Subject: [PATCH 1/7] feat: add Windows WinFsp mount support --- .github/workflows/public-test.yml | 48 ++ .github/workflows/release.yml | 75 ++- README.md | 38 +- THIRD-PARTY-NOTICES.md | 16 + scripts/ci/test-steam-depotfs-windows.ps1 | 122 +++++ src/SteamDepotFs/Mounting.cs | 166 ++++++ src/SteamDepotFs/Program.cs | 14 +- src/SteamDepotFs/SteamDepotFs.csproj | 20 +- src/SteamDepotFs/WinFspMountSupport.cs | 636 ++++++++++++++++++++++ tests/SteamDepotFs.Tests/MountingTests.cs | 33 ++ 10 files changed, 1131 insertions(+), 37 deletions(-) create mode 100644 scripts/ci/test-steam-depotfs-windows.ps1 create mode 100644 src/SteamDepotFs/Mounting.cs create mode 100644 src/SteamDepotFs/WinFspMountSupport.cs create mode 100644 tests/SteamDepotFs.Tests/MountingTests.cs diff --git a/.github/workflows/public-test.yml b/.github/workflows/public-test.yml index 1303a2a..62a85af 100644 --- a/.github/workflows/public-test.yml +++ b/.github/workflows/public-test.yml @@ -37,6 +37,54 @@ jobs: CACHE_MIN_FREE_BYTES: 2G REQUIRE_FUSE: 1 + windows-public-depot: + if: ${{ github.event_name == 'workflow_dispatch' }} + runs-on: windows-2022 + + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Install WinFsp + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $installer = Join-Path $env:RUNNER_TEMP "winfsp-2.1.25156.msi" + $log = Join-Path $env:RUNNER_TEMP "winfsp-install.log" + Invoke-WebRequest ` + -Uri "https://github.com/winfsp/winfsp/releases/download/v2.1/winfsp-2.1.25156.msi" ` + -OutFile $installer + + $hash = (Get-FileHash $installer -Algorithm SHA256).Hash + if ($hash -ne "073A70E00F77423E34BED98B86E600DEF93393BA5822204FAC57A29324DB9F7A") { + throw "Unexpected WinFsp installer SHA256: $hash" + } + + $process = Start-Process ` + -FilePath msiexec.exe ` + -ArgumentList "/i `"$installer`" /qn /norestart /l*v `"$log`"" ` + -Wait ` + -PassThru + + if ($process.ExitCode -notin 0, 3010) { + Get-Content $log -ErrorAction SilentlyContinue + throw "WinFsp installer failed with exit code $($process.ExitCode)." + } + + $fsptool = "${env:ProgramFiles(x86)}\WinFsp\bin\fsptool.exe" + if (Test-Path $fsptool) { + & $fsptool ver + } + + - name: Test Windows public depot mount + shell: pwsh + run: scripts/ci/test-steam-depotfs-windows.ps1 + authenticated-depot: if: ${{ github.event_name != 'pull_request' }} runs-on: ubuntu-24.04 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c29db1f..15beea6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,32 +42,58 @@ jobs: mkdir -p dist scripts/ci/compute-release-version.sh - - name: Publish Linux x64 build + - name: Publish release builds if: ${{ steps.version.outputs.released == 'true' }} shell: bash run: | version="${{ steps.version.outputs.version }}" - publish_dir="dist/publish/linux-x64" - archive_root="SteamDepotFS-$version-linux-x64" - - dotnet publish src/SteamDepotFs/SteamDepotFs.csproj \ - -c Release \ - -r linux-x64 \ - --self-contained true \ - -p:UseAppHost=true \ - -p:Version="$version" \ - -p:AssemblyVersion="$version.0" \ - -p:FileVersion="$version.0" \ - -o "$publish_dir" - - mkdir -p "dist/$archive_root" - cp -R "$publish_dir"/. "dist/$archive_root/" - cp README.md "dist/$archive_root/" - cp CHANGELOG.md "dist/$archive_root/" - cp LICENSE NOTICE THIRD-PARTY-NOTICES.md "dist/$archive_root/" - printf '%s\n' "$version" > "dist/$archive_root/VERSION" - tar -C dist -czf "dist/$archive_root.tar.gz" "$archive_root" - cp "dist/$archive_root.tar.gz" "dist/SteamDepotFS-linux-x64.tar.gz" + rids=( + linux-x64 + linux-arm64 + win-x64 + win-arm64 + osx-x64 + osx-arm64 + ) + + : > dist/release-assets.txt + for rid in "${rids[@]}"; do + publish_dir="dist/publish/$rid" + archive_root="SteamDepotFS-$version-$rid" + + dotnet publish src/SteamDepotFs/SteamDepotFs.csproj \ + -c Release \ + -r "$rid" \ + --self-contained true \ + -p:UseAppHost=true \ + -p:Version="$version" \ + -p:AssemblyVersion="$version.0" \ + -p:FileVersion="$version.0" \ + -o "$publish_dir" + + mkdir -p "dist/$archive_root" + cp -R "$publish_dir"/. "dist/$archive_root/" + cp README.md "dist/$archive_root/" + cp CHANGELOG.md "dist/$archive_root/" + cp LICENSE NOTICE THIRD-PARTY-NOTICES.md "dist/$archive_root/" + printf '%s\n' "$version" > "dist/$archive_root/VERSION" + + if [[ "$rid" == win-* ]]; then + (cd dist && zip -qr "$archive_root.zip" "$archive_root") + cp "dist/$archive_root.zip" "dist/SteamDepotFS-$rid.zip" + { + echo "dist/$archive_root.zip" + echo "dist/SteamDepotFS-$rid.zip" + } >> dist/release-assets.txt + else + tar -C dist -czf "dist/$archive_root.tar.gz" "$archive_root" + cp "dist/$archive_root.tar.gz" "dist/SteamDepotFS-$rid.tar.gz" + { + echo "dist/$archive_root.tar.gz" + echo "dist/SteamDepotFS-$rid.tar.gz" + } >> dist/release-assets.txt + fi + done - name: Commit changelog if: ${{ steps.version.outputs.released == 'true' }} @@ -101,10 +127,9 @@ jobs: GH_TOKEN: ${{ github.token }} run: | tag="${{ steps.version.outputs.tag }}" - version="${{ steps.version.outputs.version }}" + mapfile -t assets < dist/release-assets.txt gh release create "$tag" \ - "dist/SteamDepotFS-$version-linux-x64.tar.gz" \ - "dist/SteamDepotFS-linux-x64.tar.gz" \ + "${assets[@]}" \ --repo "$GITHUB_REPOSITORY" \ --title "$tag" \ --notes-file dist/RELEASE_NOTES.md diff --git a/README.md b/README.md index 89a1a4b..356a9f2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It was created for GitHub workflows that operate on Steam game files but have li ## Installation -For CI or other projects, download the latest Linux x64 release archive. The release build is self-contained and does not require the .NET SDK: +For CI or other projects, download the release archive for the target platform. Release builds are self-contained and do not require the .NET SDK: ```bash curl -L -o SteamDepotFS-linux-x64.tar.gz \ @@ -14,6 +14,21 @@ curl -L -o SteamDepotFS-linux-x64.tar.gz \ tar -xzf SteamDepotFS-linux-x64.tar.gz ``` +Release assets use these stable names: + +| Platform | Asset | +| --- | --- | +| Windows x64 | `SteamDepotFS-win-x64.zip` | +| Windows arm64 | `SteamDepotFS-win-arm64.zip` | +| Linux x64 | `SteamDepotFS-linux-x64.tar.gz` | +| Linux arm64 | `SteamDepotFS-linux-arm64.tar.gz` | +| macOS Apple Silicon | `SteamDepotFS-osx-arm64.tar.gz` | +| macOS Intel | `SteamDepotFS-osx-x64.tar.gz` | + +For mounted filesystem access on Windows, install WinFsp 2.1 or later: + +https://winfsp.dev/rel/ + For mounted filesystem access on Linux, install FUSE: ```bash @@ -22,7 +37,9 @@ sudo apt-get install -y fuse3 libfuse2 sudo modprobe fuse || true ``` -The `smoke`, `list`, and `read` commands do not require FUSE. Only `mount` requires FUSE. +The `smoke`, `list`, `inspect`, and `read` commands do not require WinFsp or FUSE. Only `mount` requires an OS filesystem driver. + +macOS release binaries currently support `smoke`, `list`, `inspect`, and `read`. macOS `mount` is not implemented yet; use Windows with WinFsp or Linux with FUSE for mounted filesystem access. To build from source instead, install the .NET 8 SDK and run: @@ -67,12 +84,23 @@ dotnet run --project src/SteamDepotFs/SteamDepotFs.csproj -c Release -- mount \ --mount-point /tmp/steam-depotfs ``` -Unmount: +On Windows, use an unused drive letter or an empty directory mount point: + +```powershell +SteamDepotFs.exe mount ` + --app ` + --depot ` + --mount-point X: +``` + +Unmount on Linux: ```bash fusermount3 -u /tmp/steam-depotfs ``` +Unmount on Windows by stopping the SteamDepotFS process, for example with `Ctrl+C` in the terminal running `mount`. + Anonymous login is used unless credentials are provided. Credentials can be passed as arguments or environment variables: | Argument | Environment variable | @@ -120,7 +148,7 @@ The default public smoke target is Spacewar: - depot `481` - branch `public` -That depot is small and works anonymously, so it is useful for validating Steam login, manifest resolution, CDN downloads, cache reuse, and FUSE mounting before testing private depots. +That depot is small and works anonymously, so it is useful for validating Steam login, manifest resolution, CDN downloads, cache reuse, and mounting before testing private depots. Run the public test script: @@ -155,7 +183,7 @@ Releases are created directly from `main` after the `Depot tests` workflow succe - `fix:`, `perf:`, `refactor:`, and `revert:` create a patch release - docs-only or CI-only changes do not create a release -Each release publishes a Linux x64 archive with both a versioned asset name and the stable `SteamDepotFS-linux-x64.tar.gz` asset name for `releases/latest` downloads. CI also generates release notes for the GitHub release body, updates the tracked `CHANGELOG.md`, and includes that changelog inside the archive. +Each release publishes Windows, Linux, and macOS archives with both versioned asset names and stable `SteamDepotFS-` asset names for `releases/latest` downloads. CI also generates release notes for the GitHub release body, updates the tracked `CHANGELOG.md`, and includes that changelog inside each archive. ## License diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index d318133..ef6cfdb 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -18,11 +18,16 @@ their own licenses. | Package | Version | License | Authors | Project | | --- | --- | --- | --- | --- | | SteamKit2 | 3.4.0 | LGPL-2.1-only | SteamKit2 | https://github.com/SteamRE/SteamKit | +| winfsp.net | 2.1.25156 | GPL-3.0 with WinFsp Free/Libre and Open Source Software exception | Bill Zissimopoulos | https://github.com/winfsp/winfsp | | Mono.Fuse.NETStandard | 1.1.0 | MIT | Jonathan Pryor, Alexey Kolpakov | https://github.com/alhimik45/Mono.Fuse.NETStandard | | Mono.Posix.NETStandard | 1.0.0 | Package license URL: https://go.microsoft.com/fwlink/?linkid=869050 | Microsoft | https://go.microsoft.com/fwlink/?linkid=869051 | +| Microsoft.Win32.Registry | 5.0.0 | MIT | Microsoft | https://github.com/dotnet/runtime | | protobuf-net | 3.2.56 | Apache-2.0 | Marc Gravell | https://github.com/protobuf-net/protobuf-net | | protobuf-net.Core | 3.2.56 | Apache-2.0 | Marc Gravell | https://github.com/protobuf-net/protobuf-net | +| System.IO.FileSystem.AccessControl | 5.0.0 | MIT | Microsoft | https://github.com/dotnet/runtime | | System.IO.Hashing | 10.0.1 | MIT | Microsoft | https://dot.net/ | +| System.Security.AccessControl | 5.0.0 | MIT | Microsoft | https://github.com/dotnet/runtime | +| System.Security.Principal.Windows | 5.0.0 | MIT | Microsoft | https://github.com/dotnet/runtime | | ZstdSharp.Port | 0.8.7 | MIT | Oleg Stepanischev | https://github.com/oleg-st/ZstdSharp | ## LGPL Notice For SteamKit2 @@ -40,3 +45,14 @@ SteamDepotFS does not modify SteamKit2. If you receive a binary distribution that includes SteamKit2, you may replace or relink the SteamKit2 library with a modified compatible version under the terms of the LGPL-2.1-only license. SteamDepotFS source and build instructions are available in this repository. + +## WinFsp Notice + +Windows binary distributions may include the WinFsp .NET binding. The WinFsp +source repository is available at: + +https://github.com/winfsp/winfsp + +WinFsp is distributed under GPL-3.0 with a special exception for Free/Libre +and Open Source Software. SteamDepotFS does not bundle the WinFsp kernel +driver; users install the WinFsp runtime separately. diff --git a/scripts/ci/test-steam-depotfs-windows.ps1 b/scripts/ci/test-steam-depotfs-windows.ps1 new file mode 100644 index 0000000..499e425 --- /dev/null +++ b/scripts/ci/test-steam-depotfs-windows.ps1 @@ -0,0 +1,122 @@ +param( + [string] $Configuration = "Release", + [string] $RuntimeIdentifier = "win-x64", + [string] $MountPoint = "X:", + [string] $PublishDir = "", + [string] $CacheDir = "", + [int] $TimeoutSeconds = 90 +) + +$ErrorActionPreference = "Stop" + +function Resolve-WorkRoot { + if (-not [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + return $env:RUNNER_TEMP + } + + return [System.IO.Path]::GetTempPath() +} + +function Join-MountPath { + param( + [string] $Root, + [string] $RelativePath + ) + + if ($Root -match "^[A-Za-z]:\\?$") { + return "$($Root.TrimEnd('\'))\$RelativePath" + } + + return Join-Path $Root $RelativePath +} + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "../..") +$project = Join-Path $repoRoot "src/SteamDepotFs/SteamDepotFs.csproj" +$workRoot = Resolve-WorkRoot +if ([string]::IsNullOrWhiteSpace($PublishDir)) { + $PublishDir = Join-Path $workRoot "steam-depotfs-windows-publish" +} + +if ([string]::IsNullOrWhiteSpace($CacheDir)) { + $CacheDir = Join-Path $workRoot "steam-depotfs-windows-cache" +} + +Remove-Item $PublishDir -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Path $PublishDir, $CacheDir -Force | Out-Null + +dotnet publish $project ` + -c $Configuration ` + -r $RuntimeIdentifier ` + --self-contained true ` + -p:UseAppHost=true ` + -o $PublishDir + +$exe = Join-Path $PublishDir "SteamDepotFs.exe" +if (-not (Test-Path $exe)) { + throw "SteamDepotFS executable was not published to $exe." +} + +$commonArgs = @( + "--app", "480", + "--depot", "481", + "--cache-dir", $CacheDir, + "--cache-max-bytes", "1G", + "--cache-low-watermark", "512M", + "--cache-min-free-bytes", "256M", + "--timeout", "180" +) + +& $exe smoke @commonArgs +if ($LASTEXITCODE -ne 0) { + throw "SteamDepotFS smoke failed with exit code $LASTEXITCODE." +} + +$mountStdout = Join-Path $workRoot "steam-depotfs-windows-mount.out.log" +$mountStderr = Join-Path $workRoot "steam-depotfs-windows-mount.err.log" +Remove-Item $mountStdout, $mountStderr -Force -ErrorAction SilentlyContinue + +$mountArgs = @("mount", "--mount-point", $MountPoint) + $commonArgs +$mountProcess = Start-Process ` + -FilePath $exe ` + -ArgumentList $mountArgs ` + -PassThru ` + -NoNewWindow ` + -RedirectStandardOutput $mountStdout ` + -RedirectStandardError $mountStderr + +try { + $targetPath = Join-MountPath $MountPoint "installscript.vdf" + $deadline = [DateTimeOffset]::UtcNow.AddSeconds($TimeoutSeconds) + while ([DateTimeOffset]::UtcNow -lt $deadline) { + if (Test-Path $targetPath) { + Get-Item $targetPath | Format-List FullName, Length + Get-FileHash $targetPath -Algorithm SHA256 | Format-List Path, Hash + Write-Host "WinFsp mount test passed." + return + } + + if ($mountProcess.HasExited) { + Write-Error "SteamDepotFS mount process exited early with code $($mountProcess.ExitCode)." + } + + Start-Sleep -Seconds 1 + } + + throw "Timed out waiting for $targetPath." +} +finally { + if (-not $mountProcess.HasExited) { + Stop-Process -Id $mountProcess.Id -Force -ErrorAction SilentlyContinue + Wait-Process -Id $mountProcess.Id -Timeout 10 -ErrorAction SilentlyContinue + } + + Write-Host "--- mount stdout ---" + if (Test-Path $mountStdout) { + Get-Content $mountStdout -ErrorAction SilentlyContinue + } + + Write-Host "--- mount stderr ---" + if (Test-Path $mountStderr) { + Get-Content $mountStderr -ErrorAction SilentlyContinue + } +} diff --git a/src/SteamDepotFs/Mounting.cs b/src/SteamDepotFs/Mounting.cs new file mode 100644 index 0000000..80bf2da --- /dev/null +++ b/src/SteamDepotFs/Mounting.cs @@ -0,0 +1,166 @@ +internal interface IDepotMountHost : IDisposable +{ + string MountPoint { get; } + void Start(); +} + +internal enum DepotMountBackend +{ + LinuxFuse, + WinFsp +} + +internal sealed record MountPreflight( + DepotMountBackend? Backend, + string MountPoint, + IReadOnlyList Errors) +{ + public bool Succeeded => Backend is not null && Errors.Count == 0; + + public static MountPreflight Success(DepotMountBackend backend, string mountPoint) + => new(backend, mountPoint, []); + + public static MountPreflight Failure(string mountPoint, params string[] errors) + => new(null, mountPoint, errors); + + public void ThrowIfFailed() + { + if (Succeeded) + { + return; + } + + throw new InvalidOperationException(string.Join(Environment.NewLine, Errors)); + } +} + +internal static class DepotMountFactory +{ + public static MountPreflight Check(string mountPoint) + { + if (string.IsNullOrWhiteSpace(mountPoint)) + { + return MountPreflight.Failure(mountPoint, "mount requires --mount-point ."); + } + + if (OperatingSystem.IsWindows()) + { + return WinFspMountSupport.Check(mountPoint); + } + + if (OperatingSystem.IsLinux()) + { + return CheckLinuxFuse(mountPoint); + } + + if (OperatingSystem.IsMacOS()) + { + return MountPreflight.Failure( + mountPoint, + "macOS mount support is not implemented in this build.", + "Use smoke, list, or read on macOS, or mount on Windows with WinFsp or Linux with FUSE."); + } + + return MountPreflight.Failure( + mountPoint, + $"Mount is not supported on this OS: {Environment.OSVersion.Platform}."); + } + + public static IDepotMountHost Create(MountPreflight preflight, DepotReader reader) + { + preflight.ThrowIfFailed(); + return preflight.Backend switch + { + DepotMountBackend.LinuxFuse => CreateLinuxFuse(preflight.MountPoint, reader), + DepotMountBackend.WinFsp => WinFspMountSupport.Create(preflight.MountPoint, reader), + _ => throw new PlatformNotSupportedException("No mount backend is available.") + }; + } + + internal static bool IsWindowsDriveMountPoint(string mountPoint) + { + if (mountPoint.Length is not 2 and not 3) + { + return false; + } + + if (!char.IsAsciiLetter(mountPoint[0]) || mountPoint[1] != ':') + { + return false; + } + + return mountPoint.Length == 2 || mountPoint[2] is '\\' or '/'; + } + + private static MountPreflight CheckLinuxFuse(string mountPoint) + { +#if FUSE_MOUNT + var fullMountPoint = Path.GetFullPath(mountPoint); + if (!Directory.Exists(fullMountPoint)) + { + return MountPreflight.Failure( + fullMountPoint, + $"Linux FUSE mount point does not exist: {fullMountPoint}", + "Create it first, for example: mkdir -p "); + } + + if (!File.Exists("/dev/fuse")) + { + return MountPreflight.Failure( + fullMountPoint, + "Linux FUSE device is not available at /dev/fuse.", + "Install and load FUSE, for example: sudo apt-get install -y fuse3 libfuse2 && sudo modprobe fuse"); + } + + return MountPreflight.Success(DepotMountBackend.LinuxFuse, fullMountPoint); +#else + return MountPreflight.Failure( + mountPoint, + "This build does not include Linux FUSE mount support.", + "Use a Linux build of SteamDepotFS or rebuild with -p:EnableFuse=true."); +#endif + } + + private static IDepotMountHost CreateLinuxFuse(string mountPoint, DepotReader reader) + { +#if FUSE_MOUNT + return new FuseDepotMountHost(mountPoint, reader); +#else + throw new PlatformNotSupportedException("This build does not include Linux FUSE mount support."); +#endif + } + +#if FUSE_MOUNT + private sealed class FuseDepotMountHost : IDepotMountHost + { + private readonly DepotFuseFileSystem _fileSystem; + + public FuseDepotMountHost(string mountPoint, DepotReader reader) + { + _fileSystem = new DepotFuseFileSystem(mountPoint, reader); + } + + public string MountPoint => _fileSystem.MountPoint; + + public void Start() + => _fileSystem.Start(); + + public void Dispose() + => _fileSystem.Dispose(); + } +#endif +} + +#if !WINDOWS_MOUNT +internal static class WinFspMountSupport +{ + public static MountPreflight Check(string mountPoint) + => MountPreflight.Failure( + mountPoint, + "This build does not include Windows WinFsp mount support.", + "Use a Windows release archive or rebuild with -p:EnableWinFsp=true."); + + public static IDepotMountHost Create(string mountPoint, DepotReader reader) + => throw new PlatformNotSupportedException("This build does not include Windows WinFsp mount support."); +} +#endif diff --git a/src/SteamDepotFs/Program.cs b/src/SteamDepotFs/Program.cs index 14a3e30..e369384 100644 --- a/src/SteamDepotFs/Program.cs +++ b/src/SteamDepotFs/Program.cs @@ -1,8 +1,10 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; +#if FUSE_MOUNT using Mono.Fuse.NETStandard; using Mono.Unix.Native; +#endif using SteamKit2; using SteamKit2.CDN; @@ -210,13 +212,11 @@ private static async Task RunMountAsync(DepotOptions options, ParsedArgs pa throw new ArgumentException("mount requires --mount-point or a positional mount point."); } - if (!OperatingSystem.IsLinux()) - { - throw new PlatformNotSupportedException("The FUSE mount command must run on Linux or WSL. Use smoke/list/read here to test the Steam path."); - } + var mountPreflight = DepotMountFactory.Check(mountPoint); + mountPreflight.ThrowIfFailed(); await using var depot = await DepotReader.OpenAsync(options, cancellationToken); - using var fs = new DepotFuseFileSystem(Path.GetFullPath(mountPoint), depot); + using var fs = DepotMountFactory.Create(mountPreflight, depot); Console.Error.WriteLine($"mounting app={options.AppId} depot={options.DepotId} manifest={depot.Manifest.ManifestGID} at {fs.MountPoint}"); fs.Start(); @@ -240,7 +240,7 @@ private static void Usage() list [--limit N] List paths from the manifest. inspect --path PATH Print manifest metadata and chunk layout for a depot path. read --path PATH [--out FILE] Read a depot path, optionally with --offset and --length. - mount --mount-point DIR Mount the depot read-only through Linux FUSE. + mount --mount-point PATH Mount the depot read-only through the OS filesystem driver. Common options: --app ID Steam app id. Default: 480. @@ -1424,6 +1424,7 @@ public DirectoryNode GetOrAddDirectory(string name, string path) } } +#if FUSE_MOUNT internal sealed class DepotFuseFileSystem : FileSystem { private static readonly FilePermissions DirectoryMode = @@ -1660,6 +1661,7 @@ private static ulong StableInode(string path) return BitConverter.ToUInt64(hash, 0) & 0x7fffffffffffffff; } } +#endif internal sealed class ParsedArgs { diff --git a/src/SteamDepotFs/SteamDepotFs.csproj b/src/SteamDepotFs/SteamDepotFs.csproj index c11e5c2..f905741 100644 --- a/src/SteamDepotFs/SteamDepotFs.csproj +++ b/src/SteamDepotFs/SteamDepotFs.csproj @@ -14,11 +14,29 @@ https://github.com/raidertool/SteamDepotFS + + true + true + false + false + false + true + $(DefineConstants);WINDOWS_MOUNT + $(DefineConstants);FUSE_MOUNT + + - + + + + + + + + <_Parameter1>SteamDepotFs.Tests diff --git a/src/SteamDepotFs/WinFspMountSupport.cs b/src/SteamDepotFs/WinFspMountSupport.cs new file mode 100644 index 0000000..10d1c5f --- /dev/null +++ b/src/SteamDepotFs/WinFspMountSupport.cs @@ -0,0 +1,636 @@ +#if WINDOWS_MOUNT +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using Fsp; +using SteamKit2; +using FileInfo = Fsp.Interop.FileInfo; +using VolumeInfo = Fsp.Interop.VolumeInfo; + +internal static class WinFspMountSupport +{ + public static MountPreflight Check(string mountPoint) + { + var normalizedMountPoint = NormalizeMountPoint(mountPoint); + var mountPointError = ValidateMountPoint(normalizedMountPoint); + if (mountPointError is not null) + { + return MountPreflight.Failure(normalizedMountPoint, mountPointError); + } + + try + { + using var host = new FileSystemHost(new PreflightFileSystem()); + var status = host.Preflight(normalizedMountPoint); + return status == FileSystemBase.STATUS_SUCCESS + ? MountPreflight.Success(DepotMountBackend.WinFsp, normalizedMountPoint) + : MountPreflight.Failure( + normalizedMountPoint, + $"WinFsp preflight failed for {normalizedMountPoint}: NTSTATUS 0x{(uint)status:x8}", + "Install WinFsp 2.1 or later from https://winfsp.dev/rel/ and rerun the mount command."); + } + catch (Exception ex) when (IsWinFspLoadFailure(ex)) + { + return MountPreflight.Failure( + normalizedMountPoint, + "WinFsp is not installed or its runtime DLLs are unavailable.", + "Install WinFsp 2.1 or later from https://winfsp.dev/rel/ and rerun the mount command."); + } + } + + public static IDepotMountHost Create(string mountPoint, DepotReader reader) + => new WinFspDepotMountHost(mountPoint, reader); + + private static string NormalizeMountPoint(string mountPoint) + { + mountPoint = mountPoint.Trim(); + if (DepotMountFactory.IsWindowsDriveMountPoint(mountPoint)) + { + return char.ToUpperInvariant(mountPoint[0]) + ":"; + } + + return Path.GetFullPath(mountPoint); + } + + private static string? ValidateMountPoint(string mountPoint) + { + if (DepotMountFactory.IsWindowsDriveMountPoint(mountPoint)) + { + var driveRoot = mountPoint + Path.DirectorySeparatorChar; + if (DriveInfo.GetDrives().Any(d => d.Name.Equals(driveRoot, StringComparison.OrdinalIgnoreCase))) + { + return $"Windows mount drive is already in use: {mountPoint}"; + } + + return null; + } + + if (File.Exists(mountPoint)) + { + return $"Windows mount point is a file, not a directory: {mountPoint}"; + } + + if (Directory.Exists(mountPoint)) + { + return null; + } + + var parent = Path.GetDirectoryName(mountPoint); + return !string.IsNullOrWhiteSpace(parent) && Directory.Exists(parent) + ? null + : $"Windows mount point parent does not exist: {mountPoint}"; + } + + private static bool IsWinFspLoadFailure(Exception ex) + => ex is DllNotFoundException or BadImageFormatException || + ex is TypeInitializationException { InnerException: not null } && + IsWinFspLoadFailure(ex.InnerException); + + private sealed class PreflightFileSystem : FileSystemBase + { + } + + private sealed class WinFspDepotMountHost : IDepotMountHost + { + private readonly FileSystemHost _host; + + public WinFspDepotMountHost(string mountPoint, DepotReader reader) + { + MountPoint = mountPoint; + _host = new FileSystemHost(new WinFspDepotFileSystem(reader)); + } + + public string MountPoint { get; } + + public void Start() + { + var status = _host.MountEx(MountPoint, ThreadCount: 0, SecurityDescriptor: null!, Synchronized: false, DebugLog: 0); + if (status != FileSystemBase.STATUS_SUCCESS) + { + throw new InvalidOperationException($"WinFsp mount failed for {MountPoint}: NTSTATUS 0x{(uint)status:x8}"); + } + + Thread.Sleep(Timeout.Infinite); + } + + public void Dispose() + => _host.Dispose(); + } + + private sealed class WinFspDepotFileSystem : FileSystemBase + { + private const uint AllocationUnit = 4096; + private static readonly byte[] SecurityDescriptor = CreateSecurityDescriptor(); + private readonly DepotReader _reader; + private readonly ulong _createdAt; + private readonly ulong _mountedAt; + + public WinFspDepotFileSystem(DepotReader reader) + { + _reader = reader; + _createdAt = ToFileTime(reader.Manifest.CreationTime); + _mountedAt = ToFileTime(DateTime.UtcNow); + } + + public override int Init(object host) + { + if (host is FileSystemHost fileSystemHost) + { + fileSystemHost.SectorSize = 512; + fileSystemHost.SectorsPerAllocationUnit = 8; + fileSystemHost.MaxComponentLength = 255; + fileSystemHost.CaseSensitiveSearch = true; + fileSystemHost.CasePreservedNames = true; + fileSystemHost.UnicodeOnDisk = true; + fileSystemHost.PersistentAcls = false; + fileSystemHost.ReparsePoints = false; + fileSystemHost.NamedStreams = false; + fileSystemHost.FileInfoTimeout = 60_000; + fileSystemHost.DirInfoTimeout = 60_000; + fileSystemHost.VolumeInfoTimeout = 60_000; + fileSystemHost.SecurityTimeout = 60_000; + fileSystemHost.PostCleanupWhenModifiedOnly = true; + fileSystemHost.FileSystemName = "SteamDepotFS"; + } + + return STATUS_SUCCESS; + } + + public override int ExceptionHandler(Exception ex) + { + Console.Error.WriteLine($"WinFsp operation failed: {ex.Message}"); + return STATUS_UNSUCCESSFUL; + } + + public override int GetVolumeInfo(out VolumeInfo volumeInfo) + { + const ulong blockSize = AllocationUnit; + volumeInfo = new VolumeInfo + { + TotalSize = RoundUp(_reader.Manifest.TotalUncompressedSize, blockSize), + FreeSize = 0 + }; + volumeInfo.SetVolumeLabel("SteamDepotFS"); + return STATUS_SUCCESS; + } + + public override int GetSecurityByName(string fileName, out uint fileAttributes, ref byte[] securityDescriptor) + { + fileAttributes = 0; + if (!TryGetNode(fileName, out var node)) + { + return STATUS_OBJECT_NAME_NOT_FOUND; + } + + fileAttributes = FileAttributesFor(node); + CopySecurityDescriptor(ref securityDescriptor); + return STATUS_SUCCESS; + } + + public override int Open( + string fileName, + uint createOptions, + uint grantedAccess, + out object fileNode, + out object fileDesc, + out FileInfo fileInfo, + out string normalizedName) + { + fileNode = null!; + fileDesc = null!; + fileInfo = default; + normalizedName = fileName; + + if (!TryGetNode(fileName, out var node)) + { + return STATUS_OBJECT_NAME_NOT_FOUND; + } + + if (node.IsDirectory && (createOptions & FILE_NON_DIRECTORY_FILE) != 0) + { + return STATUS_FILE_IS_A_DIRECTORY; + } + + if (!node.IsDirectory && (createOptions & FILE_DIRECTORY_FILE) != 0) + { + return STATUS_NOT_A_DIRECTORY; + } + + fileNode = node; + fileInfo = FileInfoFor(node); + return STATUS_SUCCESS; + } + + public override void Close(object fileNode, object fileDesc) + { + } + + public override int Read( + object fileNode, + object fileDesc, + IntPtr buffer, + ulong offset, + uint length, + out uint bytesTransferred) + { + bytesTransferred = 0; + if (fileNode is not WinFspNode { File: { } file }) + { + return STATUS_FILE_IS_A_DIRECTORY; + } + + if (length > int.MaxValue) + { + return STATUS_INVALID_PARAMETER; + } + + var managedBuffer = new byte[(int)length]; + int read; + if (file.Flags.HasFlag(EDepotFileFlag.Symlink) && file.LinkTarget is not null) + { + read = ReadLinkTarget(file.LinkTarget, (long)offset, managedBuffer); + } + else + { + read = _reader.ReadAsync(file, checked((long)offset), managedBuffer, CancellationToken.None) + .GetAwaiter() + .GetResult(); + } + + if (read > 0) + { + Marshal.Copy(managedBuffer, 0, buffer, read); + } + + bytesTransferred = (uint)read; + return STATUS_SUCCESS; + } + + public override int GetFileInfo(object fileNode, object fileDesc, out FileInfo fileInfo) + { + if (fileNode is not WinFspNode node) + { + fileInfo = default; + return STATUS_OBJECT_NAME_NOT_FOUND; + } + + fileInfo = FileInfoFor(node); + return STATUS_SUCCESS; + } + + public override int Flush(object fileNode, object fileDesc, out FileInfo fileInfo) + { + fileInfo = fileNode is WinFspNode node ? FileInfoFor(node) : default; + return STATUS_SUCCESS; + } + + public override int ReadDirectory( + object fileNode, + object fileDesc, + string pattern, + string marker, + IntPtr buffer, + uint length, + out uint bytesTransferred) + => SeekableReadDirectory(fileNode, fileDesc, pattern, marker, buffer, length, out bytesTransferred); + + public override bool ReadDirectoryEntry( + object fileNode, + object fileDesc, + string pattern, + string marker, + ref object context, + out string fileName, + out FileInfo fileInfo) + { + fileName = string.Empty; + fileInfo = default; + if (fileNode is not WinFspNode { Directory: { } directory }) + { + return false; + } + + if (context is not DirectoryEnumeration enumeration) + { + enumeration = new DirectoryEnumeration(DirectoryEntries(directory, marker)); + context = enumeration; + } + + if (!enumeration.TryMoveNext(out var entry)) + { + return false; + } + + fileName = entry.Name; + fileInfo = entry.FileInfo; + return true; + } + + public override int GetDirInfoByName( + object fileNode, + object fileDesc, + string fileName, + out string normalizedName, + out FileInfo fileInfo) + { + normalizedName = fileName; + fileInfo = default; + if (fileNode is not WinFspNode { Directory: { } directory }) + { + return STATUS_NOT_A_DIRECTORY; + } + + var childPath = CombinePath(directory.Path, fileName); + if (!TryGetNode(childPath, out var child)) + { + return STATUS_OBJECT_NAME_NOT_FOUND; + } + + normalizedName = child.Name; + fileInfo = FileInfoFor(child); + return STATUS_SUCCESS; + } + + public override int GetSecurity(object fileNode, object fileDesc, ref byte[] securityDescriptor) + { + CopySecurityDescriptor(ref securityDescriptor); + return STATUS_SUCCESS; + } + + public override int Create( + string fileName, + uint createOptions, + uint grantedAccess, + uint fileAttributes, + byte[] securityDescriptor, + ulong allocationSize, + out object fileNode, + out object fileDesc, + out FileInfo fileInfo, + out string normalizedName) + { + fileNode = null!; + fileDesc = null!; + fileInfo = default; + normalizedName = fileName; + return STATUS_MEDIA_WRITE_PROTECTED; + } + + public override int Write( + object fileNode, + object fileDesc, + IntPtr buffer, + ulong offset, + uint length, + bool writeToEndOfFile, + bool constrainedIo, + out uint bytesTransferred, + out FileInfo fileInfo) + { + bytesTransferred = 0; + fileInfo = fileNode is WinFspNode node ? FileInfoFor(node) : default; + return STATUS_MEDIA_WRITE_PROTECTED; + } + + public override int Overwrite( + object fileNode, + object fileDesc, + uint fileAttributes, + bool replaceFileAttributes, + ulong allocationSize, + out FileInfo fileInfo) + { + fileInfo = fileNode is WinFspNode node ? FileInfoFor(node) : default; + return STATUS_MEDIA_WRITE_PROTECTED; + } + + public override int SetBasicInfo( + object fileNode, + object fileDesc, + uint fileAttributes, + ulong creationTime, + ulong lastAccessTime, + ulong lastWriteTime, + ulong changeTime, + out FileInfo fileInfo) + { + fileInfo = fileNode is WinFspNode node ? FileInfoFor(node) : default; + return STATUS_MEDIA_WRITE_PROTECTED; + } + + public override int SetFileSize( + object fileNode, + object fileDesc, + ulong newSize, + bool setAllocationSize, + out FileInfo fileInfo) + { + fileInfo = fileNode is WinFspNode node ? FileInfoFor(node) : default; + return STATUS_MEDIA_WRITE_PROTECTED; + } + + public override int CanDelete(object fileNode, object fileDesc, string fileName) + => STATUS_MEDIA_WRITE_PROTECTED; + + public override int Rename( + object fileNode, + object fileDesc, + string fileName, + string newFileName, + bool replaceIfExists) + => STATUS_MEDIA_WRITE_PROTECTED; + + public override int SetSecurity( + object fileNode, + object fileDesc, + AccessControlSections sections, + byte[] securityDescriptor) + => STATUS_MEDIA_WRITE_PROTECTED; + + private bool TryGetNode(string path, out WinFspNode node) + { + var normalized = FileIndex.Normalize(path); + if (_reader.Index.TryGetDirectory(normalized, out var directory)) + { + node = new WinFspNode(directory.Name, normalized, directory, null); + return true; + } + + if (_reader.Index.TryGetFile(normalized, out var file)) + { + node = new WinFspNode(Path.GetFileName(normalized), normalized, null, file); + return true; + } + + node = default; + return false; + } + + private IEnumerable DirectoryEntries(DirectoryNode directory, string marker) + { + var entries = new List + { + new(".", FileInfoFor(new WinFspNode(".", directory.Path, directory, null))), + new("..", FileInfoFor(new WinFspNode("..", directory.Path, directory, null))) + }; + + entries.AddRange(directory.Directories.Values + .OrderBy(static child => child.Name, StringComparer.Ordinal) + .Select(child => new DirectoryEntryInfo( + child.Name, + FileInfoFor(new WinFspNode(child.Name, child.Path, child, null))))); + + entries.AddRange(directory.Files + .OrderBy(static child => child.Key, StringComparer.Ordinal) + .Select(child => new DirectoryEntryInfo( + child.Key, + FileInfoFor(new WinFspNode(child.Key, child.Value.FileName, null, child.Value))))); + + return string.IsNullOrEmpty(marker) + ? entries + : entries.Where(entry => string.Compare(entry.Name, marker, StringComparison.Ordinal) > 0); + } + + private FileInfo FileInfoFor(WinFspNode node) + { + if (node.Directory is not null) + { + return new FileInfo + { + FileAttributes = FileAttributesFor(node), + AllocationSize = 0, + FileSize = 0, + CreationTime = _createdAt, + LastAccessTime = _mountedAt, + LastWriteTime = _createdAt, + ChangeTime = _mountedAt, + IndexNumber = StableIndexNumber(node.Path), + HardLinks = 1 + }; + } + + var file = node.File!; + var size = FileSize(file); + return new FileInfo + { + FileAttributes = FileAttributesFor(node), + AllocationSize = RoundUp((ulong)size, AllocationUnit), + FileSize = (ulong)size, + CreationTime = _createdAt, + LastAccessTime = _mountedAt, + LastWriteTime = _createdAt, + ChangeTime = _mountedAt, + IndexNumber = StableIndexNumber(file.FileName), + HardLinks = 1 + }; + } + + private static uint FileAttributesFor(WinFspNode node) + { + var attributes = System.IO.FileAttributes.ReadOnly; + attributes |= node.Directory is not null + ? System.IO.FileAttributes.Directory + : System.IO.FileAttributes.Normal; + return (uint)attributes; + } + + private static long FileSize(DepotManifest.FileData file) + => file.Flags.HasFlag(EDepotFileFlag.Symlink) && file.LinkTarget is not null + ? System.Text.Encoding.UTF8.GetByteCount(file.LinkTarget) + : (long)file.TotalSize; + + private static int ReadLinkTarget(string linkTarget, long offset, byte[] destination) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(linkTarget); + if (offset < 0 || offset >= bytes.Length || destination.Length == 0) + { + return 0; + } + + var length = Math.Min(destination.Length, bytes.Length - (int)offset); + Buffer.BlockCopy(bytes, (int)offset, destination, 0, length); + return length; + } + + private static string CombinePath(string parent, string child) + => parent.Length == 0 ? child : parent + "/" + child; + + private static ulong RoundUp(ulong value, ulong unit) + => value == 0 ? 0 : ((value + unit - 1) / unit) * unit; + + private static ulong StableIndexNumber(string path) + { + var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(path)); + return BitConverter.ToUInt64(hash, 0) & 0x7fffffffffffffff; + } + + private static ulong ToFileTime(DateTime value) + { + var utc = value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc); + var minimum = DateTime.FromFileTimeUtc(0); + return utc <= minimum ? 0 : (ulong)utc.ToFileTimeUtc(); + } + + private static byte[] CreateSecurityDescriptor() + { +#pragma warning disable CA1416 + var descriptor = new RawSecurityDescriptor("O:SYG:SYD:P(A;;0x1200a9;;;WD)"); + var bytes = new byte[descriptor.BinaryLength]; + descriptor.GetBinaryForm(bytes, 0); + return bytes; +#pragma warning restore CA1416 + } + + private static void CopySecurityDescriptor(ref byte[] securityDescriptor) + { + if (securityDescriptor is null) + { + return; + } + + if (securityDescriptor.Length < SecurityDescriptor.Length) + { + securityDescriptor = SecurityDescriptor.ToArray(); + return; + } + + Array.Copy(SecurityDescriptor, securityDescriptor, SecurityDescriptor.Length); + } + + private readonly record struct WinFspNode( + string Name, + string Path, + DirectoryNode? Directory, + DepotManifest.FileData? File) + { + public bool IsDirectory => Directory is not null; + } + + private sealed record DirectoryEntryInfo(string Name, FileInfo FileInfo); + + private sealed class DirectoryEnumeration + { + private readonly IReadOnlyList _entries; + private int _index = -1; + + public DirectoryEnumeration(IEnumerable entries) + { + _entries = entries.ToArray(); + } + + public bool TryMoveNext(out DirectoryEntryInfo entry) + { + _index++; + if (_index >= _entries.Count) + { + entry = default!; + return false; + } + + entry = _entries[_index]; + return true; + } + } + } +} +#endif diff --git a/tests/SteamDepotFs.Tests/MountingTests.cs b/tests/SteamDepotFs.Tests/MountingTests.cs new file mode 100644 index 0000000..84d07c4 --- /dev/null +++ b/tests/SteamDepotFs.Tests/MountingTests.cs @@ -0,0 +1,33 @@ +using Xunit; + +namespace SteamDepotFs.Tests; + +public sealed class MountingTests +{ + [Theory] + [InlineData("X:")] + [InlineData("x:")] + [InlineData("Z:\\")] + [InlineData("Z:/")] + public void IsWindowsDriveMountPoint_AcceptsDriveRoots(string mountPoint) + => Assert.True(DepotMountFactory.IsWindowsDriveMountPoint(mountPoint)); + + [Theory] + [InlineData("")] + [InlineData("X")] + [InlineData("XX:")] + [InlineData("1:")] + [InlineData("/tmp/steam-depotfs")] + [InlineData(@"C:\mount\steam-depotfs")] + public void IsWindowsDriveMountPoint_RejectsNonDriveRoots(string mountPoint) + => Assert.False(DepotMountFactory.IsWindowsDriveMountPoint(mountPoint)); + + [Fact] + public void Check_RejectsEmptyMountPointBeforePlatformSpecificChecks() + { + var result = DepotMountFactory.Check(""); + + Assert.False(result.Succeeded); + Assert.Contains("mount requires --mount-point", result.Errors[0]); + } +} From 4f7f134f7ceb7967022f1e4e04076e2ff38b61c5 Mon Sep 17 00:00:00 2001 From: alexbowe Date: Wed, 6 May 2026 14:46:08 -0700 Subject: [PATCH 2/7] feat: add native macOS macFUSE mount support --- .github/workflows/public-test.yml | 21 + README.md | 16 +- scripts/ci/install-macfuse-macos.sh | 67 ++ scripts/ci/test-steam-depotfs-macos.sh | 111 +++ src/SteamDepotFs/MacFuseMountSupport.cs | 984 ++++++++++++++++++++++ src/SteamDepotFs/Mounting.cs | 63 +- src/SteamDepotFs/Program.cs | 5 + src/SteamDepotFs/SteamDepotFs.csproj | 6 + tests/SteamDepotFs.Tests/MountingTests.cs | 43 + 9 files changed, 1304 insertions(+), 12 deletions(-) create mode 100755 scripts/ci/install-macfuse-macos.sh create mode 100755 scripts/ci/test-steam-depotfs-macos.sh create mode 100644 src/SteamDepotFs/MacFuseMountSupport.cs diff --git a/.github/workflows/public-test.yml b/.github/workflows/public-test.yml index 62a85af..aea2998 100644 --- a/.github/workflows/public-test.yml +++ b/.github/workflows/public-test.yml @@ -85,6 +85,27 @@ jobs: shell: pwsh run: scripts/ci/test-steam-depotfs-windows.ps1 + macos-public-depot: + if: ${{ github.event_name == 'workflow_dispatch' }} + runs-on: macos-15 + + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Install macFUSE + run: scripts/ci/install-macfuse-macos.sh + + - name: Test macOS public depot mount + run: scripts/ci/test-steam-depotfs-macos.sh + env: + MOUNT_DIR: /Volumes/SteamDepotFS-Test + authenticated-depot: if: ${{ github.event_name != 'pull_request' }} runs-on: ubuntu-24.04 diff --git a/README.md b/README.md index 356a9f2..58e0ee1 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,13 @@ sudo apt-get install -y fuse3 libfuse2 sudo modprobe fuse || true ``` -The `smoke`, `list`, `inspect`, and `read` commands do not require WinFsp or FUSE. Only `mount` requires an OS filesystem driver. +For mounted filesystem access on macOS, install macFUSE: + +https://macfuse.github.io/ -macOS release binaries currently support `smoke`, `list`, `inspect`, and `read`. macOS `mount` is not implemented yet; use Windows with WinFsp or Linux with FUSE for mounted filesystem access. +On macOS 15.4 or later with macFUSE 5 or later, mount under `/Volumes/` to use macFUSE's FSKit backend. Other macOS mount points use the kernel backend and may require approving the macFUSE system extension. + +The `smoke`, `list`, `inspect`, and `read` commands do not require WinFsp or FUSE. Only `mount` requires an OS filesystem driver. To build from source instead, install the .NET 8 SDK and run: @@ -99,6 +103,12 @@ Unmount on Linux: fusermount3 -u /tmp/steam-depotfs ``` +Unmount on macOS: + +```bash +diskutil unmount /tmp/steam-depotfs +``` + Unmount on Windows by stopping the SteamDepotFS process, for example with `Ctrl+C` in the terminal running `mount`. Anonymous login is used unless credentials are provided. Credentials can be passed as arguments or environment variables: @@ -165,7 +175,7 @@ scripts/bench/read-matrix.sh The script defaults to `--read-ahead-chunks` values `0 1 2`, `--max-chunk-concurrency` values `4 8 16`, and cold cache runs. Set `WARM_CACHE=1`, `ITERATIONS`, `OFFSET`, `LENGTH`, `READ_AHEAD_VALUES`, or `CONCURRENCY_VALUES` to tune the run. -The repository includes `.github/workflows/public-test.yml`, which runs the same public test on pushes and pull requests. The workflow builds the project, reads `installscript.vdf` from the Spacewar depot, mounts the depot through FUSE, and verifies the file is visible through the mounted filesystem. +The repository includes `.github/workflows/public-test.yml`, which runs the same public test on pushes and pull requests. The workflow builds the project, reads `installscript.vdf` from the Spacewar depot, mounts the depot through FUSE, and verifies the file is visible through the mounted filesystem. Manual runs also test WinFsp on Windows and macFUSE on macOS hosted runners. The workflow also includes an authenticated smoke test for pushes and manual runs. It logs in with configured Steam credentials, resolves the configured depot, and reads a small file from that depot. Configure either: diff --git a/scripts/ci/install-macfuse-macos.sh b/scripts/ci/install-macfuse-macos.sh new file mode 100755 index 0000000..00c82b8 --- /dev/null +++ b/scripts/ci/install-macfuse-macos.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "macFUSE install script only runs on macOS." >&2 + exit 1 +fi + +MACFUSE_VERSION="${MACFUSE_VERSION:-5.2.0}" +MACFUSE_SHA256="${MACFUSE_SHA256:-09a4b4c23c1930af45335fc119696797da41562dec1630602d2db637f4804f27}" +WORK_ROOT="${RUNNER_TEMP:-/tmp}" +DMG_FILE="$WORK_ROOT/macfuse-$MACFUSE_VERSION.dmg" +MOUNT_ROOT="$WORK_ROOT/macfuse-dmg" +VOLUME_PATH="" + +cleanup() { + if [[ -n "$VOLUME_PATH" ]]; then + hdiutil detach "$VOLUME_PATH" -force >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +curl -fsSL \ + -o "$DMG_FILE" \ + "https://github.com/macfuse/macfuse/releases/download/macfuse-$MACFUSE_VERSION/macfuse-$MACFUSE_VERSION.dmg" + +echo "$MACFUSE_SHA256 $DMG_FILE" | shasum -a 256 -c - + +rm -rf "$MOUNT_ROOT" +mkdir -p "$MOUNT_ROOT" +hdiutil attach -nobrowse -readonly -mountroot "$MOUNT_ROOT" "$DMG_FILE" >/dev/null + +for candidate in "$MOUNT_ROOT"/*; do + if [[ -d "$candidate" ]]; then + VOLUME_PATH="$candidate" + break + fi +done + +if [[ -z "$VOLUME_PATH" ]]; then + echo "Unable to find mounted macFUSE volume under $MOUNT_ROOT." >&2 + exit 1 +fi + +PKG_FILE="$VOLUME_PATH/Install macFUSE.pkg" +if [[ ! -f "$PKG_FILE" ]]; then + PKG_FILE="$(find "$VOLUME_PATH" -name '*.pkg' -print -quit)" +fi + +if [[ -z "$PKG_FILE" ]]; then + echo "Unable to find macFUSE pkg in $VOLUME_PATH." >&2 + exit 1 +fi + +sudo installer -pkg "$PKG_FILE" -target / + +MACFUSE_CLI="/Library/Filesystems/macfuse.fs/Contents/Resources/macfuse.app/Contents/MacOS/macfuse" +if [[ -x "$MACFUSE_CLI" ]]; then + sudo "$MACFUSE_CLI" install --components file-system-extensions --force +fi + +if [[ ! -e /usr/local/lib/libfuse.dylib && ! -e /opt/homebrew/lib/libfuse.dylib ]]; then + echo "macFUSE installed, but libfuse.dylib was not found in /usr/local/lib or /opt/homebrew/lib." >&2 + exit 1 +fi + +echo "macFUSE $MACFUSE_VERSION installed." diff --git a/scripts/ci/test-steam-depotfs-macos.sh b/scripts/ci/test-steam-depotfs-macos.sh new file mode 100755 index 0000000..54cb541 --- /dev/null +++ b/scripts/ci/test-steam-depotfs-macos.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "macOS mount test only runs on macOS." >&2 + exit 1 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PROJECT="$ROOT/src/SteamDepotFs/SteamDepotFs.csproj" +WORK_ROOT="${RUNNER_TEMP:-/tmp}" +CACHE_DIR="${CACHE_DIR:-$WORK_ROOT/steam-depotfs-macos-cache}" +PUBLISH_DIR="${PUBLISH_DIR:-$WORK_ROOT/steam-depotfs-macos-publish}" +MOUNT_DIR="${MOUNT_DIR:-$WORK_ROOT/steam-depotfs-mount}" +TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-90}" + +case "$(uname -m)" in + arm64) RUNTIME_IDENTIFIER="${RUNTIME_IDENTIFIER:-osx-arm64}" ;; + x86_64) RUNTIME_IDENTIFIER="${RUNTIME_IDENTIFIER:-osx-x64}" ;; + *) + echo "Unsupported macOS architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +if [[ ! -d /Library/Filesystems/macfuse.fs ]]; then + echo "macFUSE is not installed." >&2 + exit 1 +fi + +if [[ ! -e /usr/local/lib/libfuse.dylib && ! -e /opt/homebrew/lib/libfuse.dylib ]]; then + echo "macFUSE libfuse.dylib is not available." >&2 + exit 1 +fi + +rm -rf "$PUBLISH_DIR" +mkdir -p "$PUBLISH_DIR" "$CACHE_DIR" + +dotnet publish "$PROJECT" \ + -c Release \ + -r "$RUNTIME_IDENTIFIER" \ + --self-contained true \ + -p:UseAppHost=true \ + -o "$PUBLISH_DIR" + +EXE="$PUBLISH_DIR/SteamDepotFs" +if [[ ! -x "$EXE" ]]; then + echo "SteamDepotFS executable was not published to $EXE." >&2 + exit 1 +fi + +COMMON_ARGS=( + --app 480 + --depot 481 + --cache-dir "$CACHE_DIR" + --cache-max-bytes 1G + --cache-low-watermark 512M + --cache-min-free-bytes 256M + --timeout 180 +) + +"$EXE" smoke "${COMMON_ARGS[@]}" + +MOUNT_STDOUT="$WORK_ROOT/steam-depotfs-macos-mount.out.log" +MOUNT_STDERR="$WORK_ROOT/steam-depotfs-macos-mount.err.log" +rm -f "$MOUNT_STDOUT" "$MOUNT_STDERR" + +if [[ "$MOUNT_DIR" == /Volumes/* ]]; then + sudo mkdir -p "$MOUNT_DIR" + sudo chown "$(id -u):$(id -g)" "$MOUNT_DIR" +else + mkdir -p "$MOUNT_DIR" +fi + +"$EXE" mount \ + --mount-point "$MOUNT_DIR" \ + "${COMMON_ARGS[@]}" >"$MOUNT_STDOUT" 2>"$MOUNT_STDERR" & +MOUNT_PID=$! + +cleanup() { + diskutil unmount force "$MOUNT_DIR" >/dev/null 2>&1 || umount "$MOUNT_DIR" >/dev/null 2>&1 || true + wait "$MOUNT_PID" >/dev/null 2>&1 || true + if [[ "$MOUNT_DIR" == /Volumes/SteamDepotFS-* ]]; then + sudo rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +TARGET_FILE="$MOUNT_DIR/installscript.vdf" +for _ in $(seq 1 "$TIMEOUT_SECONDS"); do + if [[ -f "$TARGET_FILE" ]]; then + ls -l "$TARGET_FILE" + shasum -a 256 "$TARGET_FILE" + echo "macFUSE mount test passed." + exit 0 + fi + + if ! kill -0 "$MOUNT_PID" >/dev/null 2>&1; then + echo "SteamDepotFS mount process exited early." >&2 + break + fi + + sleep 1 +done + +echo "--- mount stdout ---" >&2 +sed -n '1,160p' "$MOUNT_STDOUT" >&2 || true +echo "--- mount stderr ---" >&2 +sed -n '1,160p' "$MOUNT_STDERR" >&2 || true + +exit 1 diff --git a/src/SteamDepotFs/MacFuseMountSupport.cs b/src/SteamDepotFs/MacFuseMountSupport.cs new file mode 100644 index 0000000..e32a67f --- /dev/null +++ b/src/SteamDepotFs/MacFuseMountSupport.cs @@ -0,0 +1,984 @@ +#if MACFUSE_MOUNT +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using SteamKit2; + +internal static unsafe class MacFuseMountSupport +{ + private const string FuseLibraryName = "fuse"; + private static int _resolverConfigured; + + public static MountPreflight Check(string mountPoint) + { + MacFuseNative.ConfigureResolver(); + if (!MacFuseRuntime.IsInstalled) + { + return MountPreflight.Failure( + mountPoint, + "macFUSE is not installed.", + "Install macFUSE from https://macfuse.github.io/ and rerun the mount command."); + } + + if (!MacFuseNative.TryLoad(out var loadError)) + { + return MountPreflight.Failure( + mountPoint, + "macFUSE libfuse is not available to SteamDepotFS.", + loadError, + "Install or reinstall macFUSE from https://macfuse.github.io/ and rerun the mount command."); + } + + return MountPreflight.Success(DepotMountBackend.MacFuse, mountPoint); + } + + public static IDepotMountHost Create(string mountPoint, DepotReader reader) + { + MacFuseNative.ConfigureResolver(); + return new MacFuseDepotMountHost(mountPoint, reader); + } + + private static class MacFuseNative + { + private static readonly string[] MacFuseLibraryCandidates = + [ + "/usr/local/lib/libfuse.dylib", + "/usr/local/lib/libfuse.2.dylib", + "/opt/homebrew/lib/libfuse.dylib", + "/opt/homebrew/lib/libfuse.2.dylib" + ]; + + public static void ConfigureResolver() + { + if (Interlocked.Exchange(ref _resolverConfigured, 1) != 0) + { + return; + } + + NativeLibrary.SetDllImportResolver(typeof(MacFuseMountSupport).Assembly, Resolve); + } + + public static bool TryLoad([NotNullWhen(false)] out string? error) + { + foreach (var candidate in MacFuseLibraryCandidates) + { + if (!NativeLibrary.TryLoad(candidate, out var handle)) + { + continue; + } + + NativeLibrary.Free(handle); + error = null; + return true; + } + + error = "Unable to load libfuse.dylib from /usr/local/lib or /opt/homebrew/lib."; + return false; + } + + private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + if (libraryName != FuseLibraryName) + { + return IntPtr.Zero; + } + + foreach (var candidate in MacFuseLibraryCandidates) + { + if (NativeLibrary.TryLoad(candidate, out var handle)) + { + return handle; + } + } + + return IntPtr.Zero; + } + } + + [DllImport(FuseLibraryName, EntryPoint = "fuse_main_real", CallingConvention = CallingConvention.Cdecl)] + private static extern int FuseMainReal( + int argc, + IntPtr argv, + MacFuseOperations* operations, + UIntPtr operationSize, + IntPtr userData); + + [StructLayout(LayoutKind.Sequential, Size = 464)] + private struct MacFuseOperations + { + public IntPtr GetAttr; + public IntPtr ReadLink; + public IntPtr GetDir; + public IntPtr MkNod; + public IntPtr MkDir; + public IntPtr Unlink; + public IntPtr RmDir; + public IntPtr SymLink; + public IntPtr Rename; + public IntPtr Link; + public IntPtr Chmod; + public IntPtr Chown; + public IntPtr Truncate; + public IntPtr Utime; + public IntPtr Open; + public IntPtr Read; + public IntPtr Write; + public IntPtr StatFs; + public IntPtr Flush; + public IntPtr Release; + public IntPtr Fsync; + public IntPtr SetXAttr; + public IntPtr GetXAttr; + public IntPtr ListXAttr; + public IntPtr RemoveXAttr; + public IntPtr OpenDir; + public IntPtr ReadDir; + public IntPtr ReleaseDir; + public IntPtr FsyncDir; + public IntPtr Init; + public IntPtr Destroy; + public IntPtr Access; + public IntPtr Create; + public IntPtr FTruncate; + public IntPtr FGetAttr; + public IntPtr Lock; + public IntPtr UTimens; + public IntPtr Bmap; + private readonly uint _flags; + private readonly uint _paddingAfterFlags; + public IntPtr Ioctl; + public IntPtr Poll; + public IntPtr WriteBuf; + public IntPtr ReadBuf; + public IntPtr Flock; + public IntPtr FAllocate; + public IntPtr Reserved00; + public IntPtr Reserved01; + public IntPtr RenameX; + public IntPtr StatFsX; + public IntPtr SetVolName; + public IntPtr Exchange; + public IntPtr GetXTimes; + public IntPtr SetBackupTime; + public IntPtr SetChangeTime; + public IntPtr SetCreateTime; + public IntPtr ChFlags; + public IntPtr SetAttrX; + public IntPtr FSetAttrX; + } + + [StructLayout(LayoutKind.Sequential, Size = 144)] + private struct MacStat + { + public int Device; + public ushort Mode; + public ushort LinkCount; + public ulong Inode; + public uint UserId; + public uint GroupId; + public int RawDevice; + private readonly int _padding0; + public MacTimespec AccessTime; + public MacTimespec ModifyTime; + public MacTimespec ChangeTime; + public MacTimespec BirthTime; + public long Size; + public long Blocks; + public int BlockSize; + public uint Flags; + public uint Generation; + private readonly int _spare0; + private readonly long _spare1; + private readonly long _spare2; + } + + [StructLayout(LayoutKind.Sequential)] + private readonly struct MacTimespec(long seconds) + { + public readonly long Seconds = seconds; + public readonly long Nanoseconds = 0; + } + + [StructLayout(LayoutKind.Sequential, Size = 64)] + private struct MacStatVfs + { + public ulong BlockSize; + public ulong FragmentSize; + public uint Blocks; + public uint FreeBlocks; + public uint AvailableBlocks; + public uint FileCount; + public uint FreeFiles; + public uint AvailableFiles; + public ulong FileSystemId; + public ulong Flags; + public ulong NameMax; + } + + private sealed class MacFuseDepotMountHost : IDepotMountHost + { + private readonly MacFuseFileSystem _fileSystem; + + public MacFuseDepotMountHost(string mountPoint, DepotReader reader) + { + MountPoint = mountPoint; + _fileSystem = new MacFuseFileSystem(mountPoint, reader); + } + + public string MountPoint { get; } + + public void Start() + => _fileSystem.Start(); + + public void Dispose() + => _fileSystem.Dispose(); + } + + private sealed class MacFuseFileSystem : IDisposable + { + private const int ErrnoNoEntry = 2; + private const int ErrnoIo = 5; + private const int ErrnoIsDirectory = 21; + private const int ErrnoInvalidArgument = 22; + private const int ErrnoReadOnlyFileSystem = 30; + + private const int OpenAccessMode = 0x0003; + private const int OpenReadOnly = 0; + private const int AccessWrite = 0x02; + + private const ushort DirectoryMode = + 0x4000 | + 0x0100 | 0x0040 | + 0x0020 | 0x0008 | + 0x0004 | 0x0001; + + private const ushort FileMode = + 0x8000 | + 0x0100 | + 0x0020 | + 0x0004; + + private const ushort ExecutableFileMode = + FileMode | + 0x0040 | + 0x0008 | + 0x0001; + + private const ushort SymlinkMode = + 0xa000 | + 0x0100 | + 0x0020 | + 0x0004; + + private static readonly object MountLock = new(); + private static MacFuseFileSystem? Current; + + private readonly DepotReader _reader; + private readonly long _now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + private bool _disposed; + + public MacFuseFileSystem(string mountPoint, DepotReader reader) + { + MountPoint = mountPoint; + _reader = reader; + } + + public string MountPoint { get; } + + public void Start() + { + var operations = CreateOperations(); + var args = BuildArguments(); + var argv = AllocateArgv(args); + try + { + lock (MountLock) + { + if (Current is not null) + { + throw new InvalidOperationException("Only one macFUSE mount can run in a SteamDepotFS process."); + } + + Current = this; + } + + var result = FuseMainReal( + args.Count, + argv, + &operations, + (UIntPtr)Marshal.SizeOf(), + IntPtr.Zero); + + if (result != 0) + { + throw new InvalidOperationException($"macFUSE mount failed for {MountPoint}: exit code {result}"); + } + } + finally + { + lock (MountLock) + { + if (ReferenceEquals(Current, this)) + { + Current = null; + } + } + + FreeArgv(argv, args.Count); + } + } + + public void Dispose() + { + _disposed = true; + } + + private List BuildArguments() + { + var args = new List + { + "SteamDepotFS", + "-f", + "-oro", + "-ofsname=SteamDepotFS", + "-ovolname=SteamDepotFS", + "-osubtype=steam-depotfs" + }; + + if (MacFuseRuntime.ShouldUseFSKitBackend(MountPoint)) + { + args.Add("-obackend=fskit"); + } + + if (Environment.GetEnvironmentVariable("STEAM_DEPOTFS_FUSE_DEBUG") == "1") + { + args.Add("-d"); + } + + args.Add(MountPoint); + return args; + } + + private static MacFuseOperations CreateOperations() + => new() + { + GetAttr = (IntPtr)(delegate* unmanaged[Cdecl])&GetAttr, + ReadLink = (IntPtr)(delegate* unmanaged[Cdecl])&ReadLink, + Open = (IntPtr)(delegate* unmanaged[Cdecl])&Open, + Read = (IntPtr)(delegate* unmanaged[Cdecl])&Read, + StatFs = (IntPtr)(delegate* unmanaged[Cdecl])&StatFs, + OpenDir = (IntPtr)(delegate* unmanaged[Cdecl])&OpenDir, + ReadDir = (IntPtr)(delegate* unmanaged[Cdecl])&ReadDir, + ReleaseDir = (IntPtr)(delegate* unmanaged[Cdecl])&ReleaseDir, + Access = (IntPtr)(delegate* unmanaged[Cdecl])&Access, + Write = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyWrite, + MkNod = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyCreateSpecialFile, + MkDir = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyCreateDirectory, + Unlink = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyPath, + RmDir = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyPath, + SymLink = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyTwoPaths, + Rename = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyTwoPaths, + Link = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyTwoPaths, + Chmod = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyChangeMode, + Chown = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyChangeOwner, + Truncate = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyTruncate, + Create = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyCreate, + FTruncate = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyFTruncate, + SetXAttr = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlySetXAttr, + RemoveXAttr = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyRemoveXAttr, + SetVolName = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlySetVolName, + RenameX = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyRenameX, + ChFlags = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyChFlags + }; + + private static IntPtr AllocateArgv(IReadOnlyList args) + { + var argv = Marshal.AllocHGlobal((args.Count + 1) * IntPtr.Size); + for (var i = 0; i < args.Count; i++) + { + Marshal.WriteIntPtr(argv, i * IntPtr.Size, StringToHGlobalUtf8(args[i])); + } + + Marshal.WriteIntPtr(argv, args.Count * IntPtr.Size, IntPtr.Zero); + return argv; + } + + private static void FreeArgv(IntPtr argv, int count) + { + if (argv == IntPtr.Zero) + { + return; + } + + for (var i = 0; i < count; i++) + { + var arg = Marshal.ReadIntPtr(argv, i * IntPtr.Size); + if (arg != IntPtr.Zero) + { + Marshal.FreeHGlobal(arg); + } + } + + Marshal.FreeHGlobal(argv); + } + + private static IntPtr StringToHGlobalUtf8(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + var ptr = Marshal.AllocHGlobal(bytes.Length + 1); + Marshal.Copy(bytes, 0, ptr, bytes.Length); + Marshal.WriteByte(ptr, bytes.Length, 0); + return ptr; + } + + private static MacFuseFileSystem? TryGetCurrent() + { + lock (MountLock) + { + return Current is { _disposed: false } current ? current : null; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int GetAttr(byte* path, MacStat* stat) + { + try + { + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + return current.GetAttr(PathFromNative(path), stat); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE getattr failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadLink(byte* path, byte* buffer, UIntPtr size) + { + try + { + if (size.ToUInt64() > int.MaxValue) + { + return -ErrnoInvalidArgument; + } + + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + return current.ReadLink(PathFromNative(path), buffer, (int)size); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE readlink failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int Open(byte* path, IntPtr fileInfo) + { + try + { + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + return current.Open(PathFromNative(path), fileInfo); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE open failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int Read(byte* path, byte* buffer, UIntPtr size, long offset, IntPtr fileInfo) + { + try + { + if (size.ToUInt64() > int.MaxValue) + { + return -ErrnoInvalidArgument; + } + + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + return current.Read(PathFromNative(path), buffer, (int)size, offset); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE read failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int StatFs(byte* path, MacStatVfs* stat) + { + try + { + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + return current.StatFs(stat); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE statfs failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int OpenDir(byte* path, IntPtr fileInfo) + { + try + { + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + return current.OpenDir(PathFromNative(path)); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE opendir failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadDir(byte* path, IntPtr buffer, IntPtr filler, long offset, IntPtr fileInfo) + { + try + { + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + return current.ReadDir(PathFromNative(path), buffer, filler); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE readdir failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReleaseDir(byte* path, IntPtr fileInfo) + => 0; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int Access(byte* path, int mode) + { + try + { + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + return current.Access(PathFromNative(path), mode); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE access failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyPath(byte* path) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyTwoPaths(byte* oldPath, byte* newPath) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyWrite(byte* path, byte* buffer, UIntPtr size, long offset, IntPtr fileInfo) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyCreateSpecialFile(byte* path, ushort mode, int device) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyCreateDirectory(byte* path, ushort mode) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyChangeMode(byte* path, ushort mode) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyChangeOwner(byte* path, uint owner, uint group) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyTruncate(byte* path, long length) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyCreate(byte* path, ushort mode, IntPtr fileInfo) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyFTruncate(byte* path, long length, IntPtr fileInfo) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlySetXAttr(byte* path, byte* name, byte* value, UIntPtr size, int flags, uint position) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyRemoveXAttr(byte* path, byte* name) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlySetVolName(byte* name) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyRenameX(byte* oldPath, byte* newPath, uint flags) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyChFlags(byte* path, uint flags) + => -ErrnoReadOnlyFileSystem; + + private int GetAttr(string path, MacStat* stat) + { + if (_reader.Index.TryGetDirectory(path, out var directory)) + { + *stat = StatForDirectory(directory); + return 0; + } + + if (_reader.Index.TryGetFile(path, out var file)) + { + *stat = StatForFile(file); + return 0; + } + + return -ErrnoNoEntry; + } + + private int ReadLink(string path, byte* buffer, int size) + { + if (!_reader.Index.TryGetFile(path, out var file)) + { + return -ErrnoNoEntry; + } + + if (!file.Flags.HasFlag(EDepotFileFlag.Symlink) || string.IsNullOrEmpty(file.LinkTarget)) + { + return -ErrnoInvalidArgument; + } + + var target = Encoding.UTF8.GetBytes(file.LinkTarget); + if (size <= 0) + { + return -ErrnoInvalidArgument; + } + + var length = Math.Min(target.Length, size - 1); + Marshal.Copy(target, 0, (IntPtr)buffer, length); + buffer[length] = 0; + return 0; + } + + private int Open(string path, IntPtr fileInfo) + { + if (!_reader.Index.TryGetFile(path, out _)) + { + return _reader.Index.TryGetDirectory(path, out _) ? -ErrnoIsDirectory : -ErrnoNoEntry; + } + + if (fileInfo != IntPtr.Zero) + { + var flags = Marshal.ReadInt32(fileInfo); + if ((flags & OpenAccessMode) != OpenReadOnly) + { + return -ErrnoReadOnlyFileSystem; + } + } + + return 0; + } + + private int Read(string path, byte* buffer, int size, long offset) + { + if (!_reader.Index.TryGetFile(path, out var file)) + { + return -ErrnoNoEntry; + } + + if (size < 0) + { + return -ErrnoInvalidArgument; + } + + var managedBuffer = new byte[size]; + int read; + if (file.Flags.HasFlag(EDepotFileFlag.Symlink) && file.LinkTarget is not null) + { + read = ReadLinkTarget(file.LinkTarget, offset, managedBuffer); + } + else + { + read = _reader.ReadAsync(file, offset, managedBuffer, CancellationToken.None) + .GetAwaiter() + .GetResult(); + } + + if (read > 0) + { + Marshal.Copy(managedBuffer, 0, (IntPtr)buffer, read); + } + + return read; + } + + private int StatFs(MacStatVfs* stat) + { + const ulong blockSize = 4096; + var blocks = Math.Max(1, (_reader.Manifest.TotalUncompressedSize + blockSize - 1) / blockSize); + *stat = new MacStatVfs + { + BlockSize = blockSize, + FragmentSize = blockSize, + Blocks = ClampToUInt32(blocks), + FreeBlocks = 0, + AvailableBlocks = 0, + FileCount = ClampToUInt32((ulong)_reader.Index.AllFiles.Count + 1), + FreeFiles = 0, + AvailableFiles = 0, + FileSystemId = 0, + Flags = 1, + NameMax = 255 + }; + return 0; + } + + private int OpenDir(string path) + => _reader.Index.TryGetDirectory(path, out _) ? 0 : -ErrnoNoEntry; + + private int ReadDir(string path, IntPtr buffer, IntPtr filler) + { + if (!_reader.Index.TryGetDirectory(path, out var node)) + { + return -ErrnoNoEntry; + } + + if (!FillDirectoryEntry(buffer, filler, ".", StatForDirectory(node)) || + !FillDirectoryEntry(buffer, filler, "..", StatForDirectory(node))) + { + return 0; + } + + foreach (var directory in node.Directories.Values.OrderBy(static d => d.Name, StringComparer.Ordinal)) + { + if (!FillDirectoryEntry(buffer, filler, directory.Name, StatForDirectory(directory))) + { + return 0; + } + } + + foreach (var file in node.Files.OrderBy(static f => f.Key, StringComparer.Ordinal)) + { + if (!FillDirectoryEntry(buffer, filler, file.Key, StatForFile(file.Value))) + { + return 0; + } + } + + return 0; + } + + private int Access(string path, int mode) + { + if (!_reader.Index.Exists(path)) + { + return -ErrnoNoEntry; + } + + return (mode & AccessWrite) != 0 ? -ErrnoReadOnlyFileSystem : 0; + } + + private static bool FillDirectoryEntry(IntPtr buffer, IntPtr filler, string name, MacStat stat) + { + var fillerFunction = (delegate* unmanaged[Cdecl])filler; + var nameBytes = Encoding.UTF8.GetBytes(name); + if (nameBytes.Length > 255) + { + return false; + } + + var terminatedName = new byte[nameBytes.Length + 1]; + Buffer.BlockCopy(nameBytes, 0, terminatedName, 0, nameBytes.Length); + + fixed (byte* namePtr = terminatedName) + { + return fillerFunction(buffer, namePtr, &stat, 0) == 0; + } + } + + private MacStat StatForDirectory(DirectoryNode directory) + => new() + { + Mode = DirectoryMode, + LinkCount = 2, + Inode = StableInode(directory.Path), + AccessTime = new MacTimespec(_now), + ModifyTime = new MacTimespec(_now), + ChangeTime = new MacTimespec(_now), + BirthTime = new MacTimespec(_now), + Size = 0, + Blocks = 0, + BlockSize = 4096 + }; + + private MacStat StatForFile(DepotManifest.FileData file) + { + var mode = file.Flags.HasFlag(EDepotFileFlag.Symlink) + ? SymlinkMode + : file.Flags.HasFlag(EDepotFileFlag.Executable) + ? ExecutableFileMode + : FileMode; + + var size = file.Flags.HasFlag(EDepotFileFlag.Symlink) && file.LinkTarget is not null + ? Encoding.UTF8.GetByteCount(file.LinkTarget) + : checked((long)file.TotalSize); + + var manifestTime = new DateTimeOffset(_reader.Manifest.CreationTime.ToUniversalTime()).ToUnixTimeSeconds(); + return new MacStat + { + Mode = mode, + LinkCount = 1, + Inode = StableInode(file.FileName), + AccessTime = new MacTimespec(_now), + ModifyTime = new MacTimespec(manifestTime), + ChangeTime = new MacTimespec(_now), + BirthTime = new MacTimespec(manifestTime), + Size = size, + Blocks = (size + 511) / 512, + BlockSize = 4096 + }; + } + + private static int ReadLinkTarget(string linkTarget, long offset, byte[] buffer) + { + var data = Encoding.UTF8.GetBytes(linkTarget); + if (offset >= data.Length) + { + return 0; + } + + var available = data.Length - checked((int)offset); + var toCopy = Math.Min(buffer.Length, available); + Buffer.BlockCopy(data, checked((int)offset), buffer, 0, toCopy); + return toCopy; + } + + private static string PathFromNative(byte* path) + => Marshal.PtrToStringUTF8((IntPtr)path) ?? "/"; + + private static ulong StableInode(string path) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(path)); + return BitConverter.ToUInt64(hash, 0) & 0x7fffffffffffffff; + } + + private static uint ClampToUInt32(ulong value) + => value > uint.MaxValue ? uint.MaxValue : (uint)value; + } +} + +internal static class MacFuseRuntime +{ + private const string BundlePath = "/Library/Filesystems/macfuse.fs"; + private const string InfoPlistPath = BundlePath + "/Contents/Info.plist"; + private const string MountHelperPath = BundlePath + "/Contents/Resources/mount_macfuse"; + + public static bool IsInstalled => Directory.Exists(BundlePath) && File.Exists(MountHelperPath); + + public static bool ShouldUseFSKitBackend(string mountPoint) + => OperatingSystem.IsMacOSVersionAtLeast(15, 4) && + VersionAtLeast(5, 0) && + IsFSKitMountPoint(mountPoint); + + internal static bool IsFSKitMountPoint(string mountPoint) + => IsUnderVolumes(mountPoint); + + private static bool VersionAtLeast(int major, int minor) + { + var version = ReadVersion(); + return version is not null && version >= new Version(major, minor); + } + + private static Version? ReadVersion() + { + if (!File.Exists(InfoPlistPath)) + { + return null; + } + + try + { + var document = XDocument.Load(InfoPlistPath); + var elements = document.Descendants().ToList(); + for (var i = 0; i < elements.Count - 1; i++) + { + if (elements[i].Name.LocalName == "key" && + elements[i].Value == "CFBundleShortVersionString" && + Version.TryParse(elements[i + 1].Value, out var version)) + { + return version; + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Xml.XmlException) + { + } + + return null; + } + + private static bool IsUnderVolumes(string mountPoint) + { + var fullPath = Path.GetFullPath(mountPoint).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return fullPath.StartsWith("/Volumes/", StringComparison.Ordinal); + } +} +#endif diff --git a/src/SteamDepotFs/Mounting.cs b/src/SteamDepotFs/Mounting.cs index 80bf2da..cd2de45 100644 --- a/src/SteamDepotFs/Mounting.cs +++ b/src/SteamDepotFs/Mounting.cs @@ -1,3 +1,7 @@ +#if FUSE_MOUNT +using Mono.Fuse.NETStandard; +#endif + internal interface IDepotMountHost : IDisposable { string MountPoint { get; } @@ -7,6 +11,7 @@ internal interface IDepotMountHost : IDisposable internal enum DepotMountBackend { LinuxFuse, + MacFuse, WinFsp } @@ -55,10 +60,7 @@ public static MountPreflight Check(string mountPoint) if (OperatingSystem.IsMacOS()) { - return MountPreflight.Failure( - mountPoint, - "macOS mount support is not implemented in this build.", - "Use smoke, list, or read on macOS, or mount on Windows with WinFsp or Linux with FUSE."); + return CheckMacFuse(mountPoint); } return MountPreflight.Failure( @@ -72,6 +74,7 @@ public static IDepotMountHost Create(MountPreflight preflight, DepotReader reade return preflight.Backend switch { DepotMountBackend.LinuxFuse => CreateLinuxFuse(preflight.MountPoint, reader), + DepotMountBackend.MacFuse => CreateMacFuse(preflight.MountPoint, reader), DepotMountBackend.WinFsp => WinFspMountSupport.Create(preflight.MountPoint, reader), _ => throw new PlatformNotSupportedException("No mount backend is available.") }; @@ -96,12 +99,9 @@ private static MountPreflight CheckLinuxFuse(string mountPoint) { #if FUSE_MOUNT var fullMountPoint = Path.GetFullPath(mountPoint); - if (!Directory.Exists(fullMountPoint)) + if (!TryValidateDirectoryMountPoint("Linux FUSE", fullMountPoint, out var error)) { - return MountPreflight.Failure( - fullMountPoint, - $"Linux FUSE mount point does not exist: {fullMountPoint}", - "Create it first, for example: mkdir -p "); + return MountPreflight.Failure(fullMountPoint, error); } if (!File.Exists("/dev/fuse")) @@ -130,6 +130,51 @@ private static IDepotMountHost CreateLinuxFuse(string mountPoint, DepotReader re #endif } + private static MountPreflight CheckMacFuse(string mountPoint) + { +#if MACFUSE_MOUNT + var fullMountPoint = Path.GetFullPath(mountPoint); + if (!TryValidateDirectoryMountPoint("macOS FUSE", fullMountPoint, out var error)) + { + return MountPreflight.Failure(fullMountPoint, error); + } + + return MacFuseMountSupport.Check(fullMountPoint); +#else + return MountPreflight.Failure( + mountPoint, + "This build does not include macOS FUSE mount support.", + "Use a macOS release archive or rebuild with -p:EnableMacFuse=true."); +#endif + } + + private static IDepotMountHost CreateMacFuse(string mountPoint, DepotReader reader) + { +#if MACFUSE_MOUNT + return MacFuseMountSupport.Create(mountPoint, reader); +#else + throw new PlatformNotSupportedException("This build does not include macOS FUSE mount support."); +#endif + } + + private static bool TryValidateDirectoryMountPoint(string backendName, string mountPoint, out string error) + { + if (File.Exists(mountPoint)) + { + error = $"{backendName} mount point is a file, not a directory: {mountPoint}"; + return false; + } + + if (!Directory.Exists(mountPoint)) + { + error = $"{backendName} mount point does not exist: {mountPoint}"; + return false; + } + + error = string.Empty; + return true; + } + #if FUSE_MOUNT private sealed class FuseDepotMountHost : IDepotMountHost { diff --git a/src/SteamDepotFs/Program.cs b/src/SteamDepotFs/Program.cs index e369384..ba14afb 100644 --- a/src/SteamDepotFs/Program.cs +++ b/src/SteamDepotFs/Program.cs @@ -1466,6 +1466,11 @@ public DepotFuseFileSystem(string mountPoint, DepotReader reader) MaxReadSize = 1024 * 1024; AttributeTimeout = 60; PathTimeout = 60; + + if (Environment.GetEnvironmentVariable("STEAM_DEPOTFS_FUSE_DEBUG") == "1") + { + EnableFuseDebugOutput = true; + } } protected override Errno OnGetPathStatus(string path, out Stat stat) diff --git a/src/SteamDepotFs/SteamDepotFs.csproj b/src/SteamDepotFs/SteamDepotFs.csproj index f905741..0191cc1 100644 --- a/src/SteamDepotFs/SteamDepotFs.csproj +++ b/src/SteamDepotFs/SteamDepotFs.csproj @@ -20,9 +20,15 @@ false false false + false true + true + true + false $(DefineConstants);WINDOWS_MOUNT $(DefineConstants);FUSE_MOUNT + $(DefineConstants);MACFUSE_MOUNT + true diff --git a/tests/SteamDepotFs.Tests/MountingTests.cs b/tests/SteamDepotFs.Tests/MountingTests.cs index 84d07c4..a25cac3 100644 --- a/tests/SteamDepotFs.Tests/MountingTests.cs +++ b/tests/SteamDepotFs.Tests/MountingTests.cs @@ -1,4 +1,5 @@ using Xunit; +using System.Reflection; namespace SteamDepotFs.Tests; @@ -30,4 +31,46 @@ public void Check_RejectsEmptyMountPointBeforePlatformSpecificChecks() Assert.False(result.Succeeded); Assert.Contains("mount requires --mount-point", result.Errors[0]); } + + [Theory] + [InlineData("/Volumes/SteamDepotFS-Test")] + [InlineData("/Volumes/SteamDepotFS-Test/Nested")] + public void IsFSKitMountPoint_AcceptsVolumesPaths(string mountPoint) + { + var result = TryInvokeIsFSKitMountPoint(mountPoint); + if (result is null) + { + return; + } + + Assert.True(result.Value); + } + + [Theory] + [InlineData("/tmp/SteamDepotFS-Test")] + [InlineData("/VolumesSidecar/SteamDepotFS-Test")] + public void IsFSKitMountPoint_RejectsNonVolumesPaths(string mountPoint) + { + var result = TryInvokeIsFSKitMountPoint(mountPoint); + if (result is null) + { + return; + } + + Assert.False(result.Value); + } + + private static bool? TryInvokeIsFSKitMountPoint(string mountPoint) + { + var type = typeof(DepotMountFactory).Assembly.GetType("MacFuseRuntime"); + if (type is null) + { + return null; + } + + var method = type.GetMethod("IsFSKitMountPoint", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + + return (bool)method.Invoke(null, [mountPoint])!; + } } From 33996d1e5d202d59ebd56f7dae173c34f2efdfd1 Mon Sep 17 00:00:00 2001 From: alexbowe Date: Wed, 6 May 2026 14:53:09 -0700 Subject: [PATCH 3/7] ci: bound macOS mount smoke teardown --- .github/workflows/public-test.yml | 1 + scripts/ci/test-steam-depotfs-macos.sh | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/public-test.yml b/.github/workflows/public-test.yml index aea2998..d810f45 100644 --- a/.github/workflows/public-test.yml +++ b/.github/workflows/public-test.yml @@ -102,6 +102,7 @@ jobs: run: scripts/ci/install-macfuse-macos.sh - name: Test macOS public depot mount + timeout-minutes: 10 run: scripts/ci/test-steam-depotfs-macos.sh env: MOUNT_DIR: /Volumes/SteamDepotFS-Test diff --git a/scripts/ci/test-steam-depotfs-macos.sh b/scripts/ci/test-steam-depotfs-macos.sh index 54cb541..a4eaac8 100755 --- a/scripts/ci/test-steam-depotfs-macos.sh +++ b/scripts/ci/test-steam-depotfs-macos.sh @@ -79,6 +79,16 @@ MOUNT_PID=$! cleanup() { diskutil unmount force "$MOUNT_DIR" >/dev/null 2>&1 || umount "$MOUNT_DIR" >/dev/null 2>&1 || true + + if kill -0 "$MOUNT_PID" >/dev/null 2>&1; then + kill "$MOUNT_PID" >/dev/null 2>&1 || true + sleep 1 + fi + + if kill -0 "$MOUNT_PID" >/dev/null 2>&1; then + kill -9 "$MOUNT_PID" >/dev/null 2>&1 || true + fi + wait "$MOUNT_PID" >/dev/null 2>&1 || true if [[ "$MOUNT_DIR" == /Volumes/SteamDepotFS-* ]]; then sudo rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true From 168ce8147d4a015bcc144c675c29ce0dcc7550f3 Mon Sep 17 00:00:00 2001 From: alexbowe Date: Wed, 6 May 2026 15:00:42 -0700 Subject: [PATCH 4/7] fix: improve macFUSE FSKit mount validation --- .github/workflows/public-test.yml | 1 + scripts/ci/install-macfuse-macos.sh | 2 +- scripts/ci/test-steam-depotfs-macos.sh | 6 ++++ src/SteamDepotFs/MacFuseMountSupport.cs | 42 ++++++++++++++++++++----- 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/.github/workflows/public-test.yml b/.github/workflows/public-test.yml index d810f45..e69be9f 100644 --- a/.github/workflows/public-test.yml +++ b/.github/workflows/public-test.yml @@ -106,6 +106,7 @@ jobs: run: scripts/ci/test-steam-depotfs-macos.sh env: MOUNT_DIR: /Volumes/SteamDepotFS-Test + STEAM_DEPOTFS_FUSE_DEBUG: 1 authenticated-depot: if: ${{ github.event_name != 'pull_request' }} diff --git a/scripts/ci/install-macfuse-macos.sh b/scripts/ci/install-macfuse-macos.sh index 00c82b8..d0391c8 100755 --- a/scripts/ci/install-macfuse-macos.sh +++ b/scripts/ci/install-macfuse-macos.sh @@ -56,7 +56,7 @@ sudo installer -pkg "$PKG_FILE" -target / MACFUSE_CLI="/Library/Filesystems/macfuse.fs/Contents/Resources/macfuse.app/Contents/MacOS/macfuse" if [[ -x "$MACFUSE_CLI" ]]; then - sudo "$MACFUSE_CLI" install --components file-system-extensions --force + sudo "$MACFUSE_CLI" install --force fi if [[ ! -e /usr/local/lib/libfuse.dylib && ! -e /opt/homebrew/lib/libfuse.dylib ]]; then diff --git a/scripts/ci/test-steam-depotfs-macos.sh b/scripts/ci/test-steam-depotfs-macos.sh index a4eaac8..648e25c 100755 --- a/scripts/ci/test-steam-depotfs-macos.sh +++ b/scripts/ci/test-steam-depotfs-macos.sh @@ -117,5 +117,11 @@ echo "--- mount stdout ---" >&2 sed -n '1,160p' "$MOUNT_STDOUT" >&2 || true echo "--- mount stderr ---" >&2 sed -n '1,160p' "$MOUNT_STDERR" >&2 || true +echo "--- mount table ---" >&2 +mount >&2 || true +echo "--- mount point listing ---" >&2 +ls -la "$MOUNT_DIR" >&2 || true +echo "--- diskutil info ---" >&2 +diskutil info "$MOUNT_DIR" >&2 || true exit 1 diff --git a/src/SteamDepotFs/MacFuseMountSupport.cs b/src/SteamDepotFs/MacFuseMountSupport.cs index e32a67f..c1c6122 100644 --- a/src/SteamDepotFs/MacFuseMountSupport.cs +++ b/src/SteamDepotFs/MacFuseMountSupport.cs @@ -279,12 +279,14 @@ private sealed class MacFuseFileSystem : IDisposable private readonly DepotReader _reader; private readonly long _now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + private readonly bool _allowWriteModeOpens; private bool _disposed; public MacFuseFileSystem(string mountPoint, DepotReader reader) { MountPoint = mountPoint; _reader = reader; + _allowWriteModeOpens = MacFuseRuntime.ShouldUseFSKitBackend(mountPoint); } public string MountPoint { get; } @@ -306,6 +308,7 @@ public void Start() Current = this; } + LogDebug("macFUSE arguments: " + string.Join(' ', args)); var result = FuseMainReal( args.Count, argv, @@ -454,7 +457,9 @@ private static int GetAttr(byte* path, MacStat* stat) return -ErrnoIo; } - return current.GetAttr(PathFromNative(path), stat); + var managedPath = PathFromNative(path); + LogDebug($"macFUSE getattr {managedPath}"); + return current.GetAttr(managedPath, stat); } catch (Exception ex) { @@ -479,7 +484,9 @@ private static int ReadLink(byte* path, byte* buffer, UIntPtr size) return -ErrnoIo; } - return current.ReadLink(PathFromNative(path), buffer, (int)size); + var managedPath = PathFromNative(path); + LogDebug($"macFUSE readlink {managedPath}"); + return current.ReadLink(managedPath, buffer, (int)size); } catch (Exception ex) { @@ -499,7 +506,9 @@ private static int Open(byte* path, IntPtr fileInfo) return -ErrnoIo; } - return current.Open(PathFromNative(path), fileInfo); + var managedPath = PathFromNative(path); + LogDebug($"macFUSE open {managedPath}"); + return current.Open(managedPath, fileInfo); } catch (Exception ex) { @@ -524,7 +533,9 @@ private static int Read(byte* path, byte* buffer, UIntPtr size, long offset, Int return -ErrnoIo; } - return current.Read(PathFromNative(path), buffer, (int)size, offset); + var managedPath = PathFromNative(path); + LogDebug($"macFUSE read {managedPath} size={size} offset={offset}"); + return current.Read(managedPath, buffer, (int)size, offset); } catch (Exception ex) { @@ -544,6 +555,7 @@ private static int StatFs(byte* path, MacStatVfs* stat) return -ErrnoIo; } + LogDebug("macFUSE statfs"); return current.StatFs(stat); } catch (Exception ex) @@ -564,7 +576,9 @@ private static int OpenDir(byte* path, IntPtr fileInfo) return -ErrnoIo; } - return current.OpenDir(PathFromNative(path)); + var managedPath = PathFromNative(path); + LogDebug($"macFUSE opendir {managedPath}"); + return current.OpenDir(managedPath); } catch (Exception ex) { @@ -584,7 +598,9 @@ private static int ReadDir(byte* path, IntPtr buffer, IntPtr filler, long offset return -ErrnoIo; } - return current.ReadDir(PathFromNative(path), buffer, filler); + var managedPath = PathFromNative(path); + LogDebug($"macFUSE readdir {managedPath}"); + return current.ReadDir(managedPath, buffer, filler); } catch (Exception ex) { @@ -608,7 +624,9 @@ private static int Access(byte* path, int mode) return -ErrnoIo; } - return current.Access(PathFromNative(path), mode); + var managedPath = PathFromNative(path); + LogDebug($"macFUSE access {managedPath} mode={mode}"); + return current.Access(managedPath, mode); } catch (Exception ex) { @@ -725,7 +743,7 @@ private int Open(string path, IntPtr fileInfo) return _reader.Index.TryGetDirectory(path, out _) ? -ErrnoIsDirectory : -ErrnoNoEntry; } - if (fileInfo != IntPtr.Zero) + if (!_allowWriteModeOpens && fileInfo != IntPtr.Zero) { var flags = Marshal.ReadInt32(fileInfo); if ((flags & OpenAccessMode) != OpenReadOnly) @@ -914,6 +932,14 @@ private static int ReadLinkTarget(string linkTarget, long offset, byte[] buffer) private static string PathFromNative(byte* path) => Marshal.PtrToStringUTF8((IntPtr)path) ?? "/"; + private static void LogDebug(string message) + { + if (Environment.GetEnvironmentVariable("STEAM_DEPOTFS_FUSE_DEBUG") == "1") + { + Console.Error.WriteLine(message); + } + } + private static ulong StableInode(string path) { var hash = SHA256.HashData(Encoding.UTF8.GetBytes(path)); From 2b81de66b10e7505cb7b9a694127c59bb87ae149 Mon Sep 17 00:00:00 2001 From: alexbowe Date: Wed, 6 May 2026 15:05:03 -0700 Subject: [PATCH 5/7] fix: let macFUSE FSKit create volume mountpoints --- scripts/ci/test-steam-depotfs-macos.sh | 6 ++++-- src/SteamDepotFs/Mounting.cs | 21 ++++++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/scripts/ci/test-steam-depotfs-macos.sh b/scripts/ci/test-steam-depotfs-macos.sh index 648e25c..8ce4a5b 100755 --- a/scripts/ci/test-steam-depotfs-macos.sh +++ b/scripts/ci/test-steam-depotfs-macos.sh @@ -66,8 +66,10 @@ MOUNT_STDERR="$WORK_ROOT/steam-depotfs-macos-mount.err.log" rm -f "$MOUNT_STDOUT" "$MOUNT_STDERR" if [[ "$MOUNT_DIR" == /Volumes/* ]]; then - sudo mkdir -p "$MOUNT_DIR" - sudo chown "$(id -u):$(id -g)" "$MOUNT_DIR" + diskutil unmount force "$MOUNT_DIR" >/dev/null 2>&1 || true + if [[ -d "$MOUNT_DIR" ]]; then + sudo rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true + fi else mkdir -p "$MOUNT_DIR" fi diff --git a/src/SteamDepotFs/Mounting.cs b/src/SteamDepotFs/Mounting.cs index cd2de45..c75b966 100644 --- a/src/SteamDepotFs/Mounting.cs +++ b/src/SteamDepotFs/Mounting.cs @@ -134,12 +134,27 @@ private static MountPreflight CheckMacFuse(string mountPoint) { #if MACFUSE_MOUNT var fullMountPoint = Path.GetFullPath(mountPoint); - if (!TryValidateDirectoryMountPoint("macOS FUSE", fullMountPoint, out var error)) + if (File.Exists(fullMountPoint)) { - return MountPreflight.Failure(fullMountPoint, error); + return MountPreflight.Failure( + fullMountPoint, + $"macOS FUSE mount point is a file, not a directory: {fullMountPoint}"); + } + + var macFuse = MacFuseMountSupport.Check(fullMountPoint); + if (!macFuse.Succeeded) + { + return macFuse; + } + + if (!Directory.Exists(fullMountPoint) && !MacFuseRuntime.ShouldUseFSKitBackend(fullMountPoint)) + { + return MountPreflight.Failure( + fullMountPoint, + $"macOS FUSE mount point does not exist: {fullMountPoint}"); } - return MacFuseMountSupport.Check(fullMountPoint); + return macFuse; #else return MountPreflight.Failure( mountPoint, From db6edc8d76b6cf1876cf82aa57c3dd49bd10b351 Mon Sep 17 00:00:00 2001 From: alexbowe Date: Wed, 6 May 2026 15:09:56 -0700 Subject: [PATCH 6/7] ci: split hosted and self-hosted macOS mount checks --- .github/workflows/public-test.yml | 29 ++++++++++++++++++++++++-- README.md | 2 +- scripts/ci/test-steam-depotfs-macos.sh | 8 ++++++- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.github/workflows/public-test.yml b/.github/workflows/public-test.yml index e69be9f..0fc0080 100644 --- a/.github/workflows/public-test.yml +++ b/.github/workflows/public-test.yml @@ -6,6 +6,12 @@ on: - main pull_request: workflow_dispatch: + inputs: + run_macos_self_hosted_mount: + description: Run the macFUSE mount test on a self-hosted macOS runner. + required: false + default: false + type: boolean jobs: public-depot: @@ -101,12 +107,31 @@ jobs: - name: Install macFUSE run: scripts/ci/install-macfuse-macos.sh - - name: Test macOS public depot mount + - name: Test macOS public depot smoke + timeout-minutes: 10 + run: scripts/ci/test-steam-depotfs-macos.sh + env: + MOUNT_DIR: /Volumes/SteamDepotFS-Test + RUN_MOUNT: 0 + + macos-self-hosted-public-depot: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_macos_self_hosted_mount }} + runs-on: [self-hosted, macOS] + + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Test self-hosted macOS public depot mount timeout-minutes: 10 run: scripts/ci/test-steam-depotfs-macos.sh env: MOUNT_DIR: /Volumes/SteamDepotFS-Test - STEAM_DEPOTFS_FUSE_DEBUG: 1 authenticated-depot: if: ${{ github.event_name != 'pull_request' }} diff --git a/README.md b/README.md index 58e0ee1..f3f2c17 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ scripts/bench/read-matrix.sh The script defaults to `--read-ahead-chunks` values `0 1 2`, `--max-chunk-concurrency` values `4 8 16`, and cold cache runs. Set `WARM_CACHE=1`, `ITERATIONS`, `OFFSET`, `LENGTH`, `READ_AHEAD_VALUES`, or `CONCURRENCY_VALUES` to tune the run. -The repository includes `.github/workflows/public-test.yml`, which runs the same public test on pushes and pull requests. The workflow builds the project, reads `installscript.vdf` from the Spacewar depot, mounts the depot through FUSE, and verifies the file is visible through the mounted filesystem. Manual runs also test WinFsp on Windows and macFUSE on macOS hosted runners. +The repository includes `.github/workflows/public-test.yml`, which runs the same public test on pushes and pull requests. The workflow builds the project, reads `installscript.vdf` from the Spacewar depot, mounts the depot through FUSE on Linux, and verifies the file is visible through the mounted filesystem. Manual runs also test WinFsp on Windows. GitHub-hosted macOS runners publish the native macOS binary, install macFUSE, and run the Steam smoke/read check; macFUSE mount validation is available through the `run_macos_self_hosted_mount` manual workflow input on a self-hosted macOS runner with macFUSE configured. The workflow also includes an authenticated smoke test for pushes and manual runs. It logs in with configured Steam credentials, resolves the configured depot, and reads a small file from that depot. Configure either: diff --git a/scripts/ci/test-steam-depotfs-macos.sh b/scripts/ci/test-steam-depotfs-macos.sh index 8ce4a5b..12c1eb7 100755 --- a/scripts/ci/test-steam-depotfs-macos.sh +++ b/scripts/ci/test-steam-depotfs-macos.sh @@ -2,7 +2,7 @@ set -euo pipefail if [[ "$(uname -s)" != "Darwin" ]]; then - echo "macOS mount test only runs on macOS." >&2 + echo "macOS test only runs on macOS." >&2 exit 1 fi @@ -13,6 +13,7 @@ CACHE_DIR="${CACHE_DIR:-$WORK_ROOT/steam-depotfs-macos-cache}" PUBLISH_DIR="${PUBLISH_DIR:-$WORK_ROOT/steam-depotfs-macos-publish}" MOUNT_DIR="${MOUNT_DIR:-$WORK_ROOT/steam-depotfs-mount}" TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-90}" +RUN_MOUNT="${RUN_MOUNT:-1}" case "$(uname -m)" in arm64) RUNTIME_IDENTIFIER="${RUNTIME_IDENTIFIER:-osx-arm64}" ;; @@ -61,6 +62,11 @@ COMMON_ARGS=( "$EXE" smoke "${COMMON_ARGS[@]}" +if [[ "$RUN_MOUNT" != "1" ]]; then + echo "Skipping macFUSE mount test." + exit 0 +fi + MOUNT_STDOUT="$WORK_ROOT/steam-depotfs-macos-mount.out.log" MOUNT_STDERR="$WORK_ROOT/steam-depotfs-macos-mount.err.log" rm -f "$MOUNT_STDOUT" "$MOUNT_STDERR" From 04d59411cd58f4b128f315ab69ecddc172aa96bf Mon Sep 17 00:00:00 2001 From: alexbowe Date: Wed, 6 May 2026 23:36:55 -0700 Subject: [PATCH 7/7] refactor: clean up cross-platform mount code --- src/SteamDepotFs/DepotFileSystemMetadata.cs | 79 ++ src/SteamDepotFs/LinuxFuseFileSystem.cs | 229 +++++ src/SteamDepotFs/MacFuseFileSystem.cs | 685 ++++++++++++++ src/SteamDepotFs/MacFuseMountSupport.cs | 960 +------------------- src/SteamDepotFs/MacFuseNative.cs | 188 ++++ src/SteamDepotFs/MacFuseRuntime.cs | 60 ++ src/SteamDepotFs/Program.cs | 248 ----- src/SteamDepotFs/WinFspMountSupport.cs | 72 +- 8 files changed, 1254 insertions(+), 1267 deletions(-) create mode 100644 src/SteamDepotFs/DepotFileSystemMetadata.cs create mode 100644 src/SteamDepotFs/LinuxFuseFileSystem.cs create mode 100644 src/SteamDepotFs/MacFuseFileSystem.cs create mode 100644 src/SteamDepotFs/MacFuseNative.cs create mode 100644 src/SteamDepotFs/MacFuseRuntime.cs diff --git a/src/SteamDepotFs/DepotFileSystemMetadata.cs b/src/SteamDepotFs/DepotFileSystemMetadata.cs new file mode 100644 index 0000000..cfee722 --- /dev/null +++ b/src/SteamDepotFs/DepotFileSystemMetadata.cs @@ -0,0 +1,79 @@ +using System.Security.Cryptography; +using System.Text; +using SteamKit2; + +internal static class DepotFileSystemMetadata +{ + public const ulong BlockSize = 4096; + public const ulong DiskBlockSize = 512; + public const int MaxNameBytes = 255; + + public static long FileSize(DepotManifest.FileData file) + => file.Flags.HasFlag(EDepotFileFlag.Symlink) && file.LinkTarget is not null + ? LinkTargetBytes(file.LinkTarget).Length + : checked((long)file.TotalSize); + + public static long DiskBlocks(long size) + => (size + (long)DiskBlockSize - 1) / (long)DiskBlockSize; + + public static ulong RoundUp(ulong value, ulong unit) + => value == 0 ? 0 : ((value + unit - 1) / unit) * unit; + + public static ulong StableId(string path) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(path)); + return BitConverter.ToUInt64(hash, 0) & 0x7fffffffffffffff; + } + + public static byte[] LinkTargetBytes(string linkTarget) + => Encoding.UTF8.GetBytes(linkTarget); + + public static int ReadLinkTarget(string linkTarget, long offset, byte[] destination) + { + var bytes = LinkTargetBytes(linkTarget); + if (offset < 0 || offset >= bytes.Length || destination.Length == 0) + { + return 0; + } + + var start = checked((int)offset); + var length = Math.Min(destination.Length, bytes.Length - start); + Buffer.BlockCopy(bytes, start, destination, 0, length); + return length; + } + + public static IEnumerable EnumerateDirectory(DirectoryNode directory) + { + yield return new DepotDirectoryEntry(".", directory.Path, directory, null); + yield return new DepotDirectoryEntry("..", directory.Path, directory, null); + + foreach (var child in directory.Directories.Values.OrderBy(static child => child.Name, StringComparer.Ordinal)) + { + yield return new DepotDirectoryEntry(child.Name, child.Path, child, null); + } + + foreach (var child in directory.Files.OrderBy(static child => child.Key, StringComparer.Ordinal)) + { + yield return new DepotDirectoryEntry(child.Key, child.Value.FileName, null, child.Value); + } + } + + public static string CombinePath(string parent, string child) + => parent.Length == 0 ? child : parent + "/" + child; + + public static long ToUnixTimeSeconds(DateTime value) + => new DateTimeOffset(value.ToUniversalTime()).ToUnixTimeSeconds(); + + public static ulong ToFileTime(DateTime value) + { + var utc = value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc); + var minimum = DateTime.FromFileTimeUtc(0); + return utc <= minimum ? 0 : (ulong)utc.ToFileTimeUtc(); + } +} + +internal readonly record struct DepotDirectoryEntry( + string Name, + string Path, + DirectoryNode? Directory, + DepotManifest.FileData? File); diff --git a/src/SteamDepotFs/LinuxFuseFileSystem.cs b/src/SteamDepotFs/LinuxFuseFileSystem.cs new file mode 100644 index 0000000..6012346 --- /dev/null +++ b/src/SteamDepotFs/LinuxFuseFileSystem.cs @@ -0,0 +1,229 @@ +#if FUSE_MOUNT +using Mono.Fuse.NETStandard; +using Mono.Unix.Native; +using SteamKit2; + +internal sealed class DepotFuseFileSystem : FileSystem +{ + private static readonly FilePermissions DirectoryMode = + FilePermissions.S_IFDIR | + FilePermissions.S_IRUSR | FilePermissions.S_IXUSR | + FilePermissions.S_IRGRP | FilePermissions.S_IXGRP | + FilePermissions.S_IROTH | FilePermissions.S_IXOTH; + + private static readonly FilePermissions FileMode = + FilePermissions.S_IFREG | + FilePermissions.S_IRUSR | + FilePermissions.S_IRGRP | + FilePermissions.S_IROTH; + + private static readonly FilePermissions ExecutableFileMode = + FileMode | + FilePermissions.S_IXUSR | + FilePermissions.S_IXGRP | + FilePermissions.S_IXOTH; + + private static readonly FilePermissions SymlinkMode = + FilePermissions.S_IFLNK | + FilePermissions.S_IRUSR | + FilePermissions.S_IRGRP | + FilePermissions.S_IROTH; + + private readonly DepotReader _reader; + private readonly long _mountedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + public DepotFuseFileSystem(string mountPoint, DepotReader reader) + : base(mountPoint) + { + _reader = reader; + Name = "steam-depotfs"; + MultiThreaded = true; + EnableKernelCache = true; + EnableDirectIO = false; + EnableLargeReadRequests = true; + MaxReadSize = 1024 * 1024; + AttributeTimeout = 60; + PathTimeout = 60; + + if (Environment.GetEnvironmentVariable("STEAM_DEPOTFS_FUSE_DEBUG") == "1") + { + EnableFuseDebugOutput = true; + } + } + + protected override Errno OnGetPathStatus(string path, out Stat stat) + { + if (_reader.Index.TryGetDirectory(path, out var directory)) + { + stat = StatForDirectory(directory); + return 0; + } + + if (_reader.Index.TryGetFile(path, out var file)) + { + stat = StatForFile(file); + return 0; + } + + stat = default; + return Errno.ENOENT; + } + + protected override Errno OnReadSymbolicLink(string link, out string target) + { + target = string.Empty; + if (!_reader.Index.TryGetFile(link, out var file)) + { + return Errno.ENOENT; + } + + if (!file.Flags.HasFlag(EDepotFileFlag.Symlink) || string.IsNullOrEmpty(file.LinkTarget)) + { + return Errno.EINVAL; + } + + target = file.LinkTarget; + return 0; + } + + protected override Errno OnOpenHandle(string file, OpenedPathInfo info) + { + if (!_reader.Index.TryGetFile(file, out _)) + { + return _reader.Index.TryGetDirectory(file, out _) ? Errno.EISDIR : Errno.ENOENT; + } + + if ((((int)info.OpenAccess) & 3) != (int)OpenFlags.O_RDONLY) + { + return Errno.EROFS; + } + + info.KeepCache = true; + info.DirectIO = false; + return 0; + } + + protected override Errno OnReadHandle(string file, OpenedPathInfo info, byte[] buf, long offset, out int bytesWritten) + { + bytesWritten = 0; + try + { + if (!_reader.Index.TryGetFile(file, out var depotFile)) + { + return Errno.ENOENT; + } + + bytesWritten = _reader.ReadAsync(depotFile, offset, buf, CancellationToken.None).GetAwaiter().GetResult(); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"read failed for {file}: {ex.Message}"); + return Errno.EIO; + } + } + + protected override Errno OnOpenDirectory(string directory, OpenedPathInfo info) + => _reader.Index.TryGetDirectory(directory, out _) ? 0 : Errno.ENOENT; + + protected override Errno OnReleaseDirectory(string directory, OpenedPathInfo info) + => 0; + + protected override Errno OnReadDirectory(string directory, OpenedPathInfo info, out IEnumerable paths) + { + paths = []; + if (!_reader.Index.TryGetDirectory(directory, out var node)) + { + return Errno.ENOENT; + } + + paths = DepotFileSystemMetadata.EnumerateDirectory(node) + .Select(entry => Entry(entry.Name, entry.Directory is not null + ? StatForDirectory(entry.Directory) + : StatForFile(entry.File!))) + .ToArray(); + return 0; + } + + protected override Errno OnAccessPath(string path, AccessModes mode) + { + if (!_reader.Index.Exists(path)) + { + return Errno.ENOENT; + } + + return mode.HasFlag(AccessModes.W_OK) ? Errno.EROFS : 0; + } + + protected override Errno OnGetFileSystemStatus(string path, out Statvfs buf) + { + var blockSize = DepotFileSystemMetadata.BlockSize; + var blocks = Math.Max(1, DepotFileSystemMetadata.RoundUp(_reader.Manifest.TotalUncompressedSize, blockSize) / blockSize); + buf = default; + buf.f_bsize = blockSize; + buf.f_frsize = blockSize; + buf.f_blocks = blocks; + buf.f_bfree = 0; + buf.f_bavail = 0; + buf.f_files = (ulong)_reader.Index.AllFiles.Count + 1; + buf.f_ffree = 0; + buf.f_favail = 0; + buf.f_namemax = DepotFileSystemMetadata.MaxNameBytes; + return 0; + } + + protected override Errno OnCreateHandle(string file, OpenedPathInfo info, FilePermissions mode) => Errno.EROFS; + protected override Errno OnWriteHandle(string file, OpenedPathInfo info, byte[] buf, long offset, out int bytesRead) + { + bytesRead = 0; + return Errno.EROFS; + } + + protected override Errno OnCreateDirectory(string directory, FilePermissions mode) => Errno.EROFS; + protected override Errno OnRemoveFile(string file) => Errno.EROFS; + protected override Errno OnRemoveDirectory(string directory) => Errno.EROFS; + protected override Errno OnRenamePath(string oldpath, string newpath) => Errno.EROFS; + protected override Errno OnChangePathPermissions(string path, FilePermissions mode) => Errno.EROFS; + protected override Errno OnChangePathOwner(string path, long owner, long group) => Errno.EROFS; + protected override Errno OnTruncateFile(string file, long length) => Errno.EROFS; + + private static DirectoryEntry Entry(string name, Stat stat) => new(name) { Stat = stat }; + + private Stat StatForDirectory(DirectoryNode directory) + => new() + { + st_ino = DepotFileSystemMetadata.StableId(directory.Path), + st_mode = DirectoryMode, + st_nlink = 2, + st_size = 0, + st_blksize = (long)DepotFileSystemMetadata.BlockSize, + st_blocks = 0, + st_atime = _mountedAt, + st_mtime = _mountedAt, + st_ctime = _mountedAt + }; + + private Stat StatForFile(DepotManifest.FileData file) + { + var mode = file.Flags.HasFlag(EDepotFileFlag.Symlink) + ? SymlinkMode + : file.Flags.HasFlag(EDepotFileFlag.Executable) + ? ExecutableFileMode + : FileMode; + + var size = DepotFileSystemMetadata.FileSize(file); + return new Stat + { + st_ino = DepotFileSystemMetadata.StableId(file.FileName), + st_mode = mode, + st_nlink = 1, + st_size = size, + st_blksize = (long)DepotFileSystemMetadata.BlockSize, + st_blocks = DepotFileSystemMetadata.DiskBlocks(size), + st_atime = _mountedAt, + st_mtime = DepotFileSystemMetadata.ToUnixTimeSeconds(_reader.Manifest.CreationTime), + st_ctime = _mountedAt + }; + } +} +#endif diff --git a/src/SteamDepotFs/MacFuseFileSystem.cs b/src/SteamDepotFs/MacFuseFileSystem.cs new file mode 100644 index 0000000..4ef40a1 --- /dev/null +++ b/src/SteamDepotFs/MacFuseFileSystem.cs @@ -0,0 +1,685 @@ +#if MACFUSE_MOUNT +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using SteamKit2; + +internal static unsafe partial class MacFuseMountSupport +{ + private sealed class MacFuseFileSystem : IDisposable + { + private const int ErrnoNoEntry = 2; + private const int ErrnoIo = 5; + private const int ErrnoIsDirectory = 21; + private const int ErrnoInvalidArgument = 22; + private const int ErrnoReadOnlyFileSystem = 30; + + private const int OpenAccessMode = 0x0003; + private const int OpenReadOnly = 0; + private const int AccessWrite = 0x02; + + private const ushort DirectoryMode = + 0x4000 | + 0x0100 | 0x0040 | + 0x0020 | 0x0008 | + 0x0004 | 0x0001; + + private const ushort FileMode = + 0x8000 | + 0x0100 | + 0x0020 | + 0x0004; + + private const ushort ExecutableFileMode = + FileMode | + 0x0040 | + 0x0008 | + 0x0001; + + private const ushort SymlinkMode = + 0xa000 | + 0x0100 | + 0x0020 | + 0x0004; + + private static readonly object MountLock = new(); + private static MacFuseFileSystem? Current; + + private readonly DepotReader _reader; + private readonly long _now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + private readonly bool _allowWriteModeOpens; + private bool _disposed; + + public MacFuseFileSystem(string mountPoint, DepotReader reader) + { + MountPoint = mountPoint; + _reader = reader; + _allowWriteModeOpens = MacFuseRuntime.ShouldUseFSKitBackend(mountPoint); + } + + public string MountPoint { get; } + + public void Start() + { + var operations = CreateOperations(); + var args = BuildArguments(); + var argv = AllocateArgv(args); + try + { + lock (MountLock) + { + if (Current is not null) + { + throw new InvalidOperationException("Only one macFUSE mount can run in a SteamDepotFS process."); + } + + Current = this; + } + + LogDebug("macFUSE arguments: " + string.Join(' ', args)); + var result = FuseMainReal( + args.Count, + argv, + &operations, + (UIntPtr)Marshal.SizeOf(), + IntPtr.Zero); + + if (result != 0) + { + throw new InvalidOperationException($"macFUSE mount failed for {MountPoint}: exit code {result}"); + } + } + finally + { + lock (MountLock) + { + if (ReferenceEquals(Current, this)) + { + Current = null; + } + } + + FreeArgv(argv, args.Count); + } + } + + public void Dispose() + { + _disposed = true; + } + + private List BuildArguments() + { + var args = new List + { + "SteamDepotFS", + "-f", + "-oro", + "-ofsname=SteamDepotFS", + "-ovolname=SteamDepotFS", + "-osubtype=steam-depotfs" + }; + + if (MacFuseRuntime.ShouldUseFSKitBackend(MountPoint)) + { + args.Add("-obackend=fskit"); + } + + if (Environment.GetEnvironmentVariable("STEAM_DEPOTFS_FUSE_DEBUG") == "1") + { + args.Add("-d"); + } + + args.Add(MountPoint); + return args; + } + + private static MacFuseOperations CreateOperations() + => new() + { + GetAttr = (IntPtr)(delegate* unmanaged[Cdecl])&GetAttr, + ReadLink = (IntPtr)(delegate* unmanaged[Cdecl])&ReadLink, + Open = (IntPtr)(delegate* unmanaged[Cdecl])&Open, + Read = (IntPtr)(delegate* unmanaged[Cdecl])&Read, + StatFs = (IntPtr)(delegate* unmanaged[Cdecl])&StatFs, + OpenDir = (IntPtr)(delegate* unmanaged[Cdecl])&OpenDir, + ReadDir = (IntPtr)(delegate* unmanaged[Cdecl])&ReadDir, + ReleaseDir = (IntPtr)(delegate* unmanaged[Cdecl])&ReleaseDir, + Access = (IntPtr)(delegate* unmanaged[Cdecl])&Access, + Write = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyWrite, + MkNod = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyCreateSpecialFile, + MkDir = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyCreateDirectory, + Unlink = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyPath, + RmDir = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyPath, + SymLink = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyTwoPaths, + Rename = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyTwoPaths, + Link = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyTwoPaths, + Chmod = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyChangeMode, + Chown = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyChangeOwner, + Truncate = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyTruncate, + Create = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyCreate, + FTruncate = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyFTruncate, + SetXAttr = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlySetXAttr, + RemoveXAttr = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyRemoveXAttr, + SetVolName = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlySetVolName, + RenameX = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyRenameX, + ChFlags = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyChFlags + }; + + private static IntPtr AllocateArgv(IReadOnlyList args) + { + var argv = Marshal.AllocHGlobal((args.Count + 1) * IntPtr.Size); + for (var i = 0; i < args.Count; i++) + { + Marshal.WriteIntPtr(argv, i * IntPtr.Size, StringToHGlobalUtf8(args[i])); + } + + Marshal.WriteIntPtr(argv, args.Count * IntPtr.Size, IntPtr.Zero); + return argv; + } + + private static void FreeArgv(IntPtr argv, int count) + { + if (argv == IntPtr.Zero) + { + return; + } + + for (var i = 0; i < count; i++) + { + var arg = Marshal.ReadIntPtr(argv, i * IntPtr.Size); + if (arg != IntPtr.Zero) + { + Marshal.FreeHGlobal(arg); + } + } + + Marshal.FreeHGlobal(argv); + } + + private static IntPtr StringToHGlobalUtf8(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + var ptr = Marshal.AllocHGlobal(bytes.Length + 1); + Marshal.Copy(bytes, 0, ptr, bytes.Length); + Marshal.WriteByte(ptr, bytes.Length, 0); + return ptr; + } + + private static MacFuseFileSystem? TryGetCurrent() + { + lock (MountLock) + { + return Current is { _disposed: false } current ? current : null; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int GetAttr(byte* path, MacStat* stat) + { + try + { + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + var managedPath = PathFromNative(path); + LogDebug($"macFUSE getattr {managedPath}"); + return current.GetAttr(managedPath, stat); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE getattr failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadLink(byte* path, byte* buffer, UIntPtr size) + { + try + { + if (size.ToUInt64() > int.MaxValue) + { + return -ErrnoInvalidArgument; + } + + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + var managedPath = PathFromNative(path); + LogDebug($"macFUSE readlink {managedPath}"); + return current.ReadLink(managedPath, buffer, (int)size); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE readlink failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int Open(byte* path, IntPtr fileInfo) + { + try + { + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + var managedPath = PathFromNative(path); + LogDebug($"macFUSE open {managedPath}"); + return current.Open(managedPath, fileInfo); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE open failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int Read(byte* path, byte* buffer, UIntPtr size, long offset, IntPtr fileInfo) + { + try + { + if (size.ToUInt64() > int.MaxValue) + { + return -ErrnoInvalidArgument; + } + + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + var managedPath = PathFromNative(path); + LogDebug($"macFUSE read {managedPath} size={size} offset={offset}"); + return current.Read(managedPath, buffer, (int)size, offset); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE read failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int StatFs(byte* path, MacStatVfs* stat) + { + try + { + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + LogDebug("macFUSE statfs"); + return current.StatFs(stat); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE statfs failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int OpenDir(byte* path, IntPtr fileInfo) + { + try + { + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + var managedPath = PathFromNative(path); + LogDebug($"macFUSE opendir {managedPath}"); + return current.OpenDir(managedPath); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE opendir failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadDir(byte* path, IntPtr buffer, IntPtr filler, long offset, IntPtr fileInfo) + { + try + { + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + var managedPath = PathFromNative(path); + LogDebug($"macFUSE readdir {managedPath}"); + return current.ReadDir(managedPath, buffer, filler); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE readdir failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReleaseDir(byte* path, IntPtr fileInfo) + => 0; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int Access(byte* path, int mode) + { + try + { + var current = TryGetCurrent(); + if (current is null) + { + return -ErrnoIo; + } + + var managedPath = PathFromNative(path); + LogDebug($"macFUSE access {managedPath} mode={mode}"); + return current.Access(managedPath, mode); + } + catch (Exception ex) + { + Console.Error.WriteLine($"macFUSE access failed: {ex.Message}"); + return -ErrnoIo; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyPath(byte* path) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyTwoPaths(byte* oldPath, byte* newPath) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyWrite(byte* path, byte* buffer, UIntPtr size, long offset, IntPtr fileInfo) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyCreateSpecialFile(byte* path, ushort mode, int device) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyCreateDirectory(byte* path, ushort mode) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyChangeMode(byte* path, ushort mode) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyChangeOwner(byte* path, uint owner, uint group) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyTruncate(byte* path, long length) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyCreate(byte* path, ushort mode, IntPtr fileInfo) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyFTruncate(byte* path, long length, IntPtr fileInfo) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlySetXAttr(byte* path, byte* name, byte* value, UIntPtr size, int flags, uint position) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyRemoveXAttr(byte* path, byte* name) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlySetVolName(byte* name) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyRenameX(byte* oldPath, byte* newPath, uint flags) + => -ErrnoReadOnlyFileSystem; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ReadOnlyChFlags(byte* path, uint flags) + => -ErrnoReadOnlyFileSystem; + + private int GetAttr(string path, MacStat* stat) + { + if (_reader.Index.TryGetDirectory(path, out var directory)) + { + *stat = StatForDirectory(directory); + return 0; + } + + if (_reader.Index.TryGetFile(path, out var file)) + { + *stat = StatForFile(file); + return 0; + } + + return -ErrnoNoEntry; + } + + private int ReadLink(string path, byte* buffer, int size) + { + if (!_reader.Index.TryGetFile(path, out var file)) + { + return -ErrnoNoEntry; + } + + if (!file.Flags.HasFlag(EDepotFileFlag.Symlink) || string.IsNullOrEmpty(file.LinkTarget)) + { + return -ErrnoInvalidArgument; + } + + var target = DepotFileSystemMetadata.LinkTargetBytes(file.LinkTarget); + if (size <= 0) + { + return -ErrnoInvalidArgument; + } + + var length = Math.Min(target.Length, size - 1); + Marshal.Copy(target, 0, (IntPtr)buffer, length); + buffer[length] = 0; + return 0; + } + + private int Open(string path, IntPtr fileInfo) + { + if (!_reader.Index.TryGetFile(path, out _)) + { + return _reader.Index.TryGetDirectory(path, out _) ? -ErrnoIsDirectory : -ErrnoNoEntry; + } + + if (!_allowWriteModeOpens && fileInfo != IntPtr.Zero) + { + var flags = Marshal.ReadInt32(fileInfo); + if ((flags & OpenAccessMode) != OpenReadOnly) + { + return -ErrnoReadOnlyFileSystem; + } + } + + return 0; + } + + private int Read(string path, byte* buffer, int size, long offset) + { + if (!_reader.Index.TryGetFile(path, out var file)) + { + return -ErrnoNoEntry; + } + + if (size < 0 || offset < 0) + { + return -ErrnoInvalidArgument; + } + + var managedBuffer = new byte[size]; + int read; + if (file.Flags.HasFlag(EDepotFileFlag.Symlink) && file.LinkTarget is not null) + { + read = DepotFileSystemMetadata.ReadLinkTarget(file.LinkTarget, offset, managedBuffer); + } + else + { + read = _reader.ReadAsync(file, offset, managedBuffer, CancellationToken.None) + .GetAwaiter() + .GetResult(); + } + + if (read > 0) + { + Marshal.Copy(managedBuffer, 0, (IntPtr)buffer, read); + } + + return read; + } + + private int StatFs(MacStatVfs* stat) + { + var blockSize = DepotFileSystemMetadata.BlockSize; + var blocks = Math.Max(1, DepotFileSystemMetadata.RoundUp(_reader.Manifest.TotalUncompressedSize, blockSize) / blockSize); + *stat = new MacStatVfs + { + BlockSize = blockSize, + FragmentSize = blockSize, + Blocks = ClampToUInt32(blocks), + FreeBlocks = 0, + AvailableBlocks = 0, + FileCount = ClampToUInt32((ulong)_reader.Index.AllFiles.Count + 1), + FreeFiles = 0, + AvailableFiles = 0, + FileSystemId = 0, + Flags = 1, + NameMax = DepotFileSystemMetadata.MaxNameBytes + }; + return 0; + } + + private int OpenDir(string path) + => _reader.Index.TryGetDirectory(path, out _) ? 0 : -ErrnoNoEntry; + + private int ReadDir(string path, IntPtr buffer, IntPtr filler) + { + if (!_reader.Index.TryGetDirectory(path, out var node)) + { + return -ErrnoNoEntry; + } + + foreach (var entry in DepotFileSystemMetadata.EnumerateDirectory(node)) + { + var stat = entry.Directory is not null ? StatForDirectory(entry.Directory) : StatForFile(entry.File!); + if (!FillDirectoryEntry(buffer, filler, entry.Name, stat)) + { + return 0; + } + } + + return 0; + } + + private int Access(string path, int mode) + { + if (!_reader.Index.Exists(path)) + { + return -ErrnoNoEntry; + } + + return (mode & AccessWrite) != 0 ? -ErrnoReadOnlyFileSystem : 0; + } + + private static bool FillDirectoryEntry(IntPtr buffer, IntPtr filler, string name, MacStat stat) + { + var fillerFunction = (delegate* unmanaged[Cdecl])filler; + var nameBytes = Encoding.UTF8.GetBytes(name); + if (nameBytes.Length > DepotFileSystemMetadata.MaxNameBytes) + { + return false; + } + + var terminatedName = new byte[nameBytes.Length + 1]; + Buffer.BlockCopy(nameBytes, 0, terminatedName, 0, nameBytes.Length); + + fixed (byte* namePtr = terminatedName) + { + return fillerFunction(buffer, namePtr, &stat, 0) == 0; + } + } + + private MacStat StatForDirectory(DirectoryNode directory) + => new() + { + Mode = DirectoryMode, + LinkCount = 2, + Inode = DepotFileSystemMetadata.StableId(directory.Path), + AccessTime = new MacTimespec(_now), + ModifyTime = new MacTimespec(_now), + ChangeTime = new MacTimespec(_now), + BirthTime = new MacTimespec(_now), + Size = 0, + Blocks = 0, + BlockSize = (int)DepotFileSystemMetadata.BlockSize + }; + + private MacStat StatForFile(DepotManifest.FileData file) + { + var mode = file.Flags.HasFlag(EDepotFileFlag.Symlink) + ? SymlinkMode + : file.Flags.HasFlag(EDepotFileFlag.Executable) + ? ExecutableFileMode + : FileMode; + + var size = DepotFileSystemMetadata.FileSize(file); + var manifestTime = DepotFileSystemMetadata.ToUnixTimeSeconds(_reader.Manifest.CreationTime); + return new MacStat + { + Mode = mode, + LinkCount = 1, + Inode = DepotFileSystemMetadata.StableId(file.FileName), + AccessTime = new MacTimespec(_now), + ModifyTime = new MacTimespec(manifestTime), + ChangeTime = new MacTimespec(_now), + BirthTime = new MacTimespec(manifestTime), + Size = size, + Blocks = DepotFileSystemMetadata.DiskBlocks(size), + BlockSize = (int)DepotFileSystemMetadata.BlockSize + }; + } + + private static string PathFromNative(byte* path) + => Marshal.PtrToStringUTF8((IntPtr)path) ?? "/"; + + private static void LogDebug(string message) + { + if (Environment.GetEnvironmentVariable("STEAM_DEPOTFS_FUSE_DEBUG") == "1") + { + Console.Error.WriteLine(message); + } + } + + private static uint ClampToUInt32(ulong value) + => value > uint.MaxValue ? uint.MaxValue : (uint)value; + } +} +#endif diff --git a/src/SteamDepotFs/MacFuseMountSupport.cs b/src/SteamDepotFs/MacFuseMountSupport.cs index c1c6122..b39b848 100644 --- a/src/SteamDepotFs/MacFuseMountSupport.cs +++ b/src/SteamDepotFs/MacFuseMountSupport.cs @@ -1,18 +1,6 @@ #if MACFUSE_MOUNT -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Security.Cryptography; -using System.Text; -using System.Xml.Linq; -using SteamKit2; - -internal static unsafe class MacFuseMountSupport +internal static unsafe partial class MacFuseMountSupport { - private const string FuseLibraryName = "fuse"; - private static int _resolverConfigured; - public static MountPreflight Check(string mountPoint) { MacFuseNative.ConfigureResolver(); @@ -42,183 +30,6 @@ public static IDepotMountHost Create(string mountPoint, DepotReader reader) return new MacFuseDepotMountHost(mountPoint, reader); } - private static class MacFuseNative - { - private static readonly string[] MacFuseLibraryCandidates = - [ - "/usr/local/lib/libfuse.dylib", - "/usr/local/lib/libfuse.2.dylib", - "/opt/homebrew/lib/libfuse.dylib", - "/opt/homebrew/lib/libfuse.2.dylib" - ]; - - public static void ConfigureResolver() - { - if (Interlocked.Exchange(ref _resolverConfigured, 1) != 0) - { - return; - } - - NativeLibrary.SetDllImportResolver(typeof(MacFuseMountSupport).Assembly, Resolve); - } - - public static bool TryLoad([NotNullWhen(false)] out string? error) - { - foreach (var candidate in MacFuseLibraryCandidates) - { - if (!NativeLibrary.TryLoad(candidate, out var handle)) - { - continue; - } - - NativeLibrary.Free(handle); - error = null; - return true; - } - - error = "Unable to load libfuse.dylib from /usr/local/lib or /opt/homebrew/lib."; - return false; - } - - private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) - { - if (libraryName != FuseLibraryName) - { - return IntPtr.Zero; - } - - foreach (var candidate in MacFuseLibraryCandidates) - { - if (NativeLibrary.TryLoad(candidate, out var handle)) - { - return handle; - } - } - - return IntPtr.Zero; - } - } - - [DllImport(FuseLibraryName, EntryPoint = "fuse_main_real", CallingConvention = CallingConvention.Cdecl)] - private static extern int FuseMainReal( - int argc, - IntPtr argv, - MacFuseOperations* operations, - UIntPtr operationSize, - IntPtr userData); - - [StructLayout(LayoutKind.Sequential, Size = 464)] - private struct MacFuseOperations - { - public IntPtr GetAttr; - public IntPtr ReadLink; - public IntPtr GetDir; - public IntPtr MkNod; - public IntPtr MkDir; - public IntPtr Unlink; - public IntPtr RmDir; - public IntPtr SymLink; - public IntPtr Rename; - public IntPtr Link; - public IntPtr Chmod; - public IntPtr Chown; - public IntPtr Truncate; - public IntPtr Utime; - public IntPtr Open; - public IntPtr Read; - public IntPtr Write; - public IntPtr StatFs; - public IntPtr Flush; - public IntPtr Release; - public IntPtr Fsync; - public IntPtr SetXAttr; - public IntPtr GetXAttr; - public IntPtr ListXAttr; - public IntPtr RemoveXAttr; - public IntPtr OpenDir; - public IntPtr ReadDir; - public IntPtr ReleaseDir; - public IntPtr FsyncDir; - public IntPtr Init; - public IntPtr Destroy; - public IntPtr Access; - public IntPtr Create; - public IntPtr FTruncate; - public IntPtr FGetAttr; - public IntPtr Lock; - public IntPtr UTimens; - public IntPtr Bmap; - private readonly uint _flags; - private readonly uint _paddingAfterFlags; - public IntPtr Ioctl; - public IntPtr Poll; - public IntPtr WriteBuf; - public IntPtr ReadBuf; - public IntPtr Flock; - public IntPtr FAllocate; - public IntPtr Reserved00; - public IntPtr Reserved01; - public IntPtr RenameX; - public IntPtr StatFsX; - public IntPtr SetVolName; - public IntPtr Exchange; - public IntPtr GetXTimes; - public IntPtr SetBackupTime; - public IntPtr SetChangeTime; - public IntPtr SetCreateTime; - public IntPtr ChFlags; - public IntPtr SetAttrX; - public IntPtr FSetAttrX; - } - - [StructLayout(LayoutKind.Sequential, Size = 144)] - private struct MacStat - { - public int Device; - public ushort Mode; - public ushort LinkCount; - public ulong Inode; - public uint UserId; - public uint GroupId; - public int RawDevice; - private readonly int _padding0; - public MacTimespec AccessTime; - public MacTimespec ModifyTime; - public MacTimespec ChangeTime; - public MacTimespec BirthTime; - public long Size; - public long Blocks; - public int BlockSize; - public uint Flags; - public uint Generation; - private readonly int _spare0; - private readonly long _spare1; - private readonly long _spare2; - } - - [StructLayout(LayoutKind.Sequential)] - private readonly struct MacTimespec(long seconds) - { - public readonly long Seconds = seconds; - public readonly long Nanoseconds = 0; - } - - [StructLayout(LayoutKind.Sequential, Size = 64)] - private struct MacStatVfs - { - public ulong BlockSize; - public ulong FragmentSize; - public uint Blocks; - public uint FreeBlocks; - public uint AvailableBlocks; - public uint FileCount; - public uint FreeFiles; - public uint AvailableFiles; - public ulong FileSystemId; - public ulong Flags; - public ulong NameMax; - } - private sealed class MacFuseDepotMountHost : IDepotMountHost { private readonly MacFuseFileSystem _fileSystem; @@ -237,774 +48,5 @@ public void Start() public void Dispose() => _fileSystem.Dispose(); } - - private sealed class MacFuseFileSystem : IDisposable - { - private const int ErrnoNoEntry = 2; - private const int ErrnoIo = 5; - private const int ErrnoIsDirectory = 21; - private const int ErrnoInvalidArgument = 22; - private const int ErrnoReadOnlyFileSystem = 30; - - private const int OpenAccessMode = 0x0003; - private const int OpenReadOnly = 0; - private const int AccessWrite = 0x02; - - private const ushort DirectoryMode = - 0x4000 | - 0x0100 | 0x0040 | - 0x0020 | 0x0008 | - 0x0004 | 0x0001; - - private const ushort FileMode = - 0x8000 | - 0x0100 | - 0x0020 | - 0x0004; - - private const ushort ExecutableFileMode = - FileMode | - 0x0040 | - 0x0008 | - 0x0001; - - private const ushort SymlinkMode = - 0xa000 | - 0x0100 | - 0x0020 | - 0x0004; - - private static readonly object MountLock = new(); - private static MacFuseFileSystem? Current; - - private readonly DepotReader _reader; - private readonly long _now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - private readonly bool _allowWriteModeOpens; - private bool _disposed; - - public MacFuseFileSystem(string mountPoint, DepotReader reader) - { - MountPoint = mountPoint; - _reader = reader; - _allowWriteModeOpens = MacFuseRuntime.ShouldUseFSKitBackend(mountPoint); - } - - public string MountPoint { get; } - - public void Start() - { - var operations = CreateOperations(); - var args = BuildArguments(); - var argv = AllocateArgv(args); - try - { - lock (MountLock) - { - if (Current is not null) - { - throw new InvalidOperationException("Only one macFUSE mount can run in a SteamDepotFS process."); - } - - Current = this; - } - - LogDebug("macFUSE arguments: " + string.Join(' ', args)); - var result = FuseMainReal( - args.Count, - argv, - &operations, - (UIntPtr)Marshal.SizeOf(), - IntPtr.Zero); - - if (result != 0) - { - throw new InvalidOperationException($"macFUSE mount failed for {MountPoint}: exit code {result}"); - } - } - finally - { - lock (MountLock) - { - if (ReferenceEquals(Current, this)) - { - Current = null; - } - } - - FreeArgv(argv, args.Count); - } - } - - public void Dispose() - { - _disposed = true; - } - - private List BuildArguments() - { - var args = new List - { - "SteamDepotFS", - "-f", - "-oro", - "-ofsname=SteamDepotFS", - "-ovolname=SteamDepotFS", - "-osubtype=steam-depotfs" - }; - - if (MacFuseRuntime.ShouldUseFSKitBackend(MountPoint)) - { - args.Add("-obackend=fskit"); - } - - if (Environment.GetEnvironmentVariable("STEAM_DEPOTFS_FUSE_DEBUG") == "1") - { - args.Add("-d"); - } - - args.Add(MountPoint); - return args; - } - - private static MacFuseOperations CreateOperations() - => new() - { - GetAttr = (IntPtr)(delegate* unmanaged[Cdecl])&GetAttr, - ReadLink = (IntPtr)(delegate* unmanaged[Cdecl])&ReadLink, - Open = (IntPtr)(delegate* unmanaged[Cdecl])&Open, - Read = (IntPtr)(delegate* unmanaged[Cdecl])&Read, - StatFs = (IntPtr)(delegate* unmanaged[Cdecl])&StatFs, - OpenDir = (IntPtr)(delegate* unmanaged[Cdecl])&OpenDir, - ReadDir = (IntPtr)(delegate* unmanaged[Cdecl])&ReadDir, - ReleaseDir = (IntPtr)(delegate* unmanaged[Cdecl])&ReleaseDir, - Access = (IntPtr)(delegate* unmanaged[Cdecl])&Access, - Write = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyWrite, - MkNod = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyCreateSpecialFile, - MkDir = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyCreateDirectory, - Unlink = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyPath, - RmDir = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyPath, - SymLink = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyTwoPaths, - Rename = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyTwoPaths, - Link = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyTwoPaths, - Chmod = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyChangeMode, - Chown = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyChangeOwner, - Truncate = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyTruncate, - Create = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyCreate, - FTruncate = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyFTruncate, - SetXAttr = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlySetXAttr, - RemoveXAttr = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyRemoveXAttr, - SetVolName = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlySetVolName, - RenameX = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyRenameX, - ChFlags = (IntPtr)(delegate* unmanaged[Cdecl])&ReadOnlyChFlags - }; - - private static IntPtr AllocateArgv(IReadOnlyList args) - { - var argv = Marshal.AllocHGlobal((args.Count + 1) * IntPtr.Size); - for (var i = 0; i < args.Count; i++) - { - Marshal.WriteIntPtr(argv, i * IntPtr.Size, StringToHGlobalUtf8(args[i])); - } - - Marshal.WriteIntPtr(argv, args.Count * IntPtr.Size, IntPtr.Zero); - return argv; - } - - private static void FreeArgv(IntPtr argv, int count) - { - if (argv == IntPtr.Zero) - { - return; - } - - for (var i = 0; i < count; i++) - { - var arg = Marshal.ReadIntPtr(argv, i * IntPtr.Size); - if (arg != IntPtr.Zero) - { - Marshal.FreeHGlobal(arg); - } - } - - Marshal.FreeHGlobal(argv); - } - - private static IntPtr StringToHGlobalUtf8(string value) - { - var bytes = Encoding.UTF8.GetBytes(value); - var ptr = Marshal.AllocHGlobal(bytes.Length + 1); - Marshal.Copy(bytes, 0, ptr, bytes.Length); - Marshal.WriteByte(ptr, bytes.Length, 0); - return ptr; - } - - private static MacFuseFileSystem? TryGetCurrent() - { - lock (MountLock) - { - return Current is { _disposed: false } current ? current : null; - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int GetAttr(byte* path, MacStat* stat) - { - try - { - var current = TryGetCurrent(); - if (current is null) - { - return -ErrnoIo; - } - - var managedPath = PathFromNative(path); - LogDebug($"macFUSE getattr {managedPath}"); - return current.GetAttr(managedPath, stat); - } - catch (Exception ex) - { - Console.Error.WriteLine($"macFUSE getattr failed: {ex.Message}"); - return -ErrnoIo; - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadLink(byte* path, byte* buffer, UIntPtr size) - { - try - { - if (size.ToUInt64() > int.MaxValue) - { - return -ErrnoInvalidArgument; - } - - var current = TryGetCurrent(); - if (current is null) - { - return -ErrnoIo; - } - - var managedPath = PathFromNative(path); - LogDebug($"macFUSE readlink {managedPath}"); - return current.ReadLink(managedPath, buffer, (int)size); - } - catch (Exception ex) - { - Console.Error.WriteLine($"macFUSE readlink failed: {ex.Message}"); - return -ErrnoIo; - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int Open(byte* path, IntPtr fileInfo) - { - try - { - var current = TryGetCurrent(); - if (current is null) - { - return -ErrnoIo; - } - - var managedPath = PathFromNative(path); - LogDebug($"macFUSE open {managedPath}"); - return current.Open(managedPath, fileInfo); - } - catch (Exception ex) - { - Console.Error.WriteLine($"macFUSE open failed: {ex.Message}"); - return -ErrnoIo; - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int Read(byte* path, byte* buffer, UIntPtr size, long offset, IntPtr fileInfo) - { - try - { - if (size.ToUInt64() > int.MaxValue) - { - return -ErrnoInvalidArgument; - } - - var current = TryGetCurrent(); - if (current is null) - { - return -ErrnoIo; - } - - var managedPath = PathFromNative(path); - LogDebug($"macFUSE read {managedPath} size={size} offset={offset}"); - return current.Read(managedPath, buffer, (int)size, offset); - } - catch (Exception ex) - { - Console.Error.WriteLine($"macFUSE read failed: {ex.Message}"); - return -ErrnoIo; - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int StatFs(byte* path, MacStatVfs* stat) - { - try - { - var current = TryGetCurrent(); - if (current is null) - { - return -ErrnoIo; - } - - LogDebug("macFUSE statfs"); - return current.StatFs(stat); - } - catch (Exception ex) - { - Console.Error.WriteLine($"macFUSE statfs failed: {ex.Message}"); - return -ErrnoIo; - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int OpenDir(byte* path, IntPtr fileInfo) - { - try - { - var current = TryGetCurrent(); - if (current is null) - { - return -ErrnoIo; - } - - var managedPath = PathFromNative(path); - LogDebug($"macFUSE opendir {managedPath}"); - return current.OpenDir(managedPath); - } - catch (Exception ex) - { - Console.Error.WriteLine($"macFUSE opendir failed: {ex.Message}"); - return -ErrnoIo; - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadDir(byte* path, IntPtr buffer, IntPtr filler, long offset, IntPtr fileInfo) - { - try - { - var current = TryGetCurrent(); - if (current is null) - { - return -ErrnoIo; - } - - var managedPath = PathFromNative(path); - LogDebug($"macFUSE readdir {managedPath}"); - return current.ReadDir(managedPath, buffer, filler); - } - catch (Exception ex) - { - Console.Error.WriteLine($"macFUSE readdir failed: {ex.Message}"); - return -ErrnoIo; - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReleaseDir(byte* path, IntPtr fileInfo) - => 0; - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int Access(byte* path, int mode) - { - try - { - var current = TryGetCurrent(); - if (current is null) - { - return -ErrnoIo; - } - - var managedPath = PathFromNative(path); - LogDebug($"macFUSE access {managedPath} mode={mode}"); - return current.Access(managedPath, mode); - } - catch (Exception ex) - { - Console.Error.WriteLine($"macFUSE access failed: {ex.Message}"); - return -ErrnoIo; - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadOnlyPath(byte* path) - => -ErrnoReadOnlyFileSystem; - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadOnlyTwoPaths(byte* oldPath, byte* newPath) - => -ErrnoReadOnlyFileSystem; - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadOnlyWrite(byte* path, byte* buffer, UIntPtr size, long offset, IntPtr fileInfo) - => -ErrnoReadOnlyFileSystem; - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadOnlyCreateSpecialFile(byte* path, ushort mode, int device) - => -ErrnoReadOnlyFileSystem; - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadOnlyCreateDirectory(byte* path, ushort mode) - => -ErrnoReadOnlyFileSystem; - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadOnlyChangeMode(byte* path, ushort mode) - => -ErrnoReadOnlyFileSystem; - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadOnlyChangeOwner(byte* path, uint owner, uint group) - => -ErrnoReadOnlyFileSystem; - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadOnlyTruncate(byte* path, long length) - => -ErrnoReadOnlyFileSystem; - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadOnlyCreate(byte* path, ushort mode, IntPtr fileInfo) - => -ErrnoReadOnlyFileSystem; - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadOnlyFTruncate(byte* path, long length, IntPtr fileInfo) - => -ErrnoReadOnlyFileSystem; - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadOnlySetXAttr(byte* path, byte* name, byte* value, UIntPtr size, int flags, uint position) - => -ErrnoReadOnlyFileSystem; - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadOnlyRemoveXAttr(byte* path, byte* name) - => -ErrnoReadOnlyFileSystem; - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadOnlySetVolName(byte* name) - => -ErrnoReadOnlyFileSystem; - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadOnlyRenameX(byte* oldPath, byte* newPath, uint flags) - => -ErrnoReadOnlyFileSystem; - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ReadOnlyChFlags(byte* path, uint flags) - => -ErrnoReadOnlyFileSystem; - - private int GetAttr(string path, MacStat* stat) - { - if (_reader.Index.TryGetDirectory(path, out var directory)) - { - *stat = StatForDirectory(directory); - return 0; - } - - if (_reader.Index.TryGetFile(path, out var file)) - { - *stat = StatForFile(file); - return 0; - } - - return -ErrnoNoEntry; - } - - private int ReadLink(string path, byte* buffer, int size) - { - if (!_reader.Index.TryGetFile(path, out var file)) - { - return -ErrnoNoEntry; - } - - if (!file.Flags.HasFlag(EDepotFileFlag.Symlink) || string.IsNullOrEmpty(file.LinkTarget)) - { - return -ErrnoInvalidArgument; - } - - var target = Encoding.UTF8.GetBytes(file.LinkTarget); - if (size <= 0) - { - return -ErrnoInvalidArgument; - } - - var length = Math.Min(target.Length, size - 1); - Marshal.Copy(target, 0, (IntPtr)buffer, length); - buffer[length] = 0; - return 0; - } - - private int Open(string path, IntPtr fileInfo) - { - if (!_reader.Index.TryGetFile(path, out _)) - { - return _reader.Index.TryGetDirectory(path, out _) ? -ErrnoIsDirectory : -ErrnoNoEntry; - } - - if (!_allowWriteModeOpens && fileInfo != IntPtr.Zero) - { - var flags = Marshal.ReadInt32(fileInfo); - if ((flags & OpenAccessMode) != OpenReadOnly) - { - return -ErrnoReadOnlyFileSystem; - } - } - - return 0; - } - - private int Read(string path, byte* buffer, int size, long offset) - { - if (!_reader.Index.TryGetFile(path, out var file)) - { - return -ErrnoNoEntry; - } - - if (size < 0) - { - return -ErrnoInvalidArgument; - } - - var managedBuffer = new byte[size]; - int read; - if (file.Flags.HasFlag(EDepotFileFlag.Symlink) && file.LinkTarget is not null) - { - read = ReadLinkTarget(file.LinkTarget, offset, managedBuffer); - } - else - { - read = _reader.ReadAsync(file, offset, managedBuffer, CancellationToken.None) - .GetAwaiter() - .GetResult(); - } - - if (read > 0) - { - Marshal.Copy(managedBuffer, 0, (IntPtr)buffer, read); - } - - return read; - } - - private int StatFs(MacStatVfs* stat) - { - const ulong blockSize = 4096; - var blocks = Math.Max(1, (_reader.Manifest.TotalUncompressedSize + blockSize - 1) / blockSize); - *stat = new MacStatVfs - { - BlockSize = blockSize, - FragmentSize = blockSize, - Blocks = ClampToUInt32(blocks), - FreeBlocks = 0, - AvailableBlocks = 0, - FileCount = ClampToUInt32((ulong)_reader.Index.AllFiles.Count + 1), - FreeFiles = 0, - AvailableFiles = 0, - FileSystemId = 0, - Flags = 1, - NameMax = 255 - }; - return 0; - } - - private int OpenDir(string path) - => _reader.Index.TryGetDirectory(path, out _) ? 0 : -ErrnoNoEntry; - - private int ReadDir(string path, IntPtr buffer, IntPtr filler) - { - if (!_reader.Index.TryGetDirectory(path, out var node)) - { - return -ErrnoNoEntry; - } - - if (!FillDirectoryEntry(buffer, filler, ".", StatForDirectory(node)) || - !FillDirectoryEntry(buffer, filler, "..", StatForDirectory(node))) - { - return 0; - } - - foreach (var directory in node.Directories.Values.OrderBy(static d => d.Name, StringComparer.Ordinal)) - { - if (!FillDirectoryEntry(buffer, filler, directory.Name, StatForDirectory(directory))) - { - return 0; - } - } - - foreach (var file in node.Files.OrderBy(static f => f.Key, StringComparer.Ordinal)) - { - if (!FillDirectoryEntry(buffer, filler, file.Key, StatForFile(file.Value))) - { - return 0; - } - } - - return 0; - } - - private int Access(string path, int mode) - { - if (!_reader.Index.Exists(path)) - { - return -ErrnoNoEntry; - } - - return (mode & AccessWrite) != 0 ? -ErrnoReadOnlyFileSystem : 0; - } - - private static bool FillDirectoryEntry(IntPtr buffer, IntPtr filler, string name, MacStat stat) - { - var fillerFunction = (delegate* unmanaged[Cdecl])filler; - var nameBytes = Encoding.UTF8.GetBytes(name); - if (nameBytes.Length > 255) - { - return false; - } - - var terminatedName = new byte[nameBytes.Length + 1]; - Buffer.BlockCopy(nameBytes, 0, terminatedName, 0, nameBytes.Length); - - fixed (byte* namePtr = terminatedName) - { - return fillerFunction(buffer, namePtr, &stat, 0) == 0; - } - } - - private MacStat StatForDirectory(DirectoryNode directory) - => new() - { - Mode = DirectoryMode, - LinkCount = 2, - Inode = StableInode(directory.Path), - AccessTime = new MacTimespec(_now), - ModifyTime = new MacTimespec(_now), - ChangeTime = new MacTimespec(_now), - BirthTime = new MacTimespec(_now), - Size = 0, - Blocks = 0, - BlockSize = 4096 - }; - - private MacStat StatForFile(DepotManifest.FileData file) - { - var mode = file.Flags.HasFlag(EDepotFileFlag.Symlink) - ? SymlinkMode - : file.Flags.HasFlag(EDepotFileFlag.Executable) - ? ExecutableFileMode - : FileMode; - - var size = file.Flags.HasFlag(EDepotFileFlag.Symlink) && file.LinkTarget is not null - ? Encoding.UTF8.GetByteCount(file.LinkTarget) - : checked((long)file.TotalSize); - - var manifestTime = new DateTimeOffset(_reader.Manifest.CreationTime.ToUniversalTime()).ToUnixTimeSeconds(); - return new MacStat - { - Mode = mode, - LinkCount = 1, - Inode = StableInode(file.FileName), - AccessTime = new MacTimespec(_now), - ModifyTime = new MacTimespec(manifestTime), - ChangeTime = new MacTimespec(_now), - BirthTime = new MacTimespec(manifestTime), - Size = size, - Blocks = (size + 511) / 512, - BlockSize = 4096 - }; - } - - private static int ReadLinkTarget(string linkTarget, long offset, byte[] buffer) - { - var data = Encoding.UTF8.GetBytes(linkTarget); - if (offset >= data.Length) - { - return 0; - } - - var available = data.Length - checked((int)offset); - var toCopy = Math.Min(buffer.Length, available); - Buffer.BlockCopy(data, checked((int)offset), buffer, 0, toCopy); - return toCopy; - } - - private static string PathFromNative(byte* path) - => Marshal.PtrToStringUTF8((IntPtr)path) ?? "/"; - - private static void LogDebug(string message) - { - if (Environment.GetEnvironmentVariable("STEAM_DEPOTFS_FUSE_DEBUG") == "1") - { - Console.Error.WriteLine(message); - } - } - - private static ulong StableInode(string path) - { - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(path)); - return BitConverter.ToUInt64(hash, 0) & 0x7fffffffffffffff; - } - - private static uint ClampToUInt32(ulong value) - => value > uint.MaxValue ? uint.MaxValue : (uint)value; - } -} - -internal static class MacFuseRuntime -{ - private const string BundlePath = "/Library/Filesystems/macfuse.fs"; - private const string InfoPlistPath = BundlePath + "/Contents/Info.plist"; - private const string MountHelperPath = BundlePath + "/Contents/Resources/mount_macfuse"; - - public static bool IsInstalled => Directory.Exists(BundlePath) && File.Exists(MountHelperPath); - - public static bool ShouldUseFSKitBackend(string mountPoint) - => OperatingSystem.IsMacOSVersionAtLeast(15, 4) && - VersionAtLeast(5, 0) && - IsFSKitMountPoint(mountPoint); - - internal static bool IsFSKitMountPoint(string mountPoint) - => IsUnderVolumes(mountPoint); - - private static bool VersionAtLeast(int major, int minor) - { - var version = ReadVersion(); - return version is not null && version >= new Version(major, minor); - } - - private static Version? ReadVersion() - { - if (!File.Exists(InfoPlistPath)) - { - return null; - } - - try - { - var document = XDocument.Load(InfoPlistPath); - var elements = document.Descendants().ToList(); - for (var i = 0; i < elements.Count - 1; i++) - { - if (elements[i].Name.LocalName == "key" && - elements[i].Value == "CFBundleShortVersionString" && - Version.TryParse(elements[i + 1].Value, out var version)) - { - return version; - } - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Xml.XmlException) - { - } - - return null; - } - - private static bool IsUnderVolumes(string mountPoint) - { - var fullPath = Path.GetFullPath(mountPoint).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return fullPath.StartsWith("/Volumes/", StringComparison.Ordinal); - } } #endif diff --git a/src/SteamDepotFs/MacFuseNative.cs b/src/SteamDepotFs/MacFuseNative.cs new file mode 100644 index 0000000..ebe466a --- /dev/null +++ b/src/SteamDepotFs/MacFuseNative.cs @@ -0,0 +1,188 @@ +#if MACFUSE_MOUNT +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.InteropServices; + +internal static unsafe partial class MacFuseMountSupport +{ + private const string FuseLibraryName = "fuse"; + private static int _resolverConfigured; + + [DllImport(FuseLibraryName, EntryPoint = "fuse_main_real", CallingConvention = CallingConvention.Cdecl)] + private static extern int FuseMainReal( + int argc, + IntPtr argv, + MacFuseOperations* operations, + UIntPtr operationSize, + IntPtr userData); + + private static class MacFuseNative + { + private static readonly string[] MacFuseLibraryCandidates = + [ + "/usr/local/lib/libfuse.dylib", + "/usr/local/lib/libfuse.2.dylib", + "/opt/homebrew/lib/libfuse.dylib", + "/opt/homebrew/lib/libfuse.2.dylib" + ]; + + public static void ConfigureResolver() + { + if (Interlocked.Exchange(ref _resolverConfigured, 1) != 0) + { + return; + } + + NativeLibrary.SetDllImportResolver(typeof(MacFuseMountSupport).Assembly, Resolve); + } + + public static bool TryLoad([NotNullWhen(false)] out string? error) + { + foreach (var candidate in MacFuseLibraryCandidates) + { + if (!NativeLibrary.TryLoad(candidate, out var handle)) + { + continue; + } + + NativeLibrary.Free(handle); + error = null; + return true; + } + + error = "Unable to load libfuse.dylib from /usr/local/lib or /opt/homebrew/lib."; + return false; + } + + private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + if (libraryName != FuseLibraryName) + { + return IntPtr.Zero; + } + + foreach (var candidate in MacFuseLibraryCandidates) + { + if (NativeLibrary.TryLoad(candidate, out var handle)) + { + return handle; + } + } + + return IntPtr.Zero; + } + } + + [StructLayout(LayoutKind.Sequential, Size = 464)] + private struct MacFuseOperations + { + public IntPtr GetAttr; + public IntPtr ReadLink; + public IntPtr GetDir; + public IntPtr MkNod; + public IntPtr MkDir; + public IntPtr Unlink; + public IntPtr RmDir; + public IntPtr SymLink; + public IntPtr Rename; + public IntPtr Link; + public IntPtr Chmod; + public IntPtr Chown; + public IntPtr Truncate; + public IntPtr Utime; + public IntPtr Open; + public IntPtr Read; + public IntPtr Write; + public IntPtr StatFs; + public IntPtr Flush; + public IntPtr Release; + public IntPtr Fsync; + public IntPtr SetXAttr; + public IntPtr GetXAttr; + public IntPtr ListXAttr; + public IntPtr RemoveXAttr; + public IntPtr OpenDir; + public IntPtr ReadDir; + public IntPtr ReleaseDir; + public IntPtr FsyncDir; + public IntPtr Init; + public IntPtr Destroy; + public IntPtr Access; + public IntPtr Create; + public IntPtr FTruncate; + public IntPtr FGetAttr; + public IntPtr Lock; + public IntPtr UTimens; + public IntPtr Bmap; + private readonly uint _flags; + private readonly uint _paddingAfterFlags; + public IntPtr Ioctl; + public IntPtr Poll; + public IntPtr WriteBuf; + public IntPtr ReadBuf; + public IntPtr Flock; + public IntPtr FAllocate; + public IntPtr Reserved00; + public IntPtr Reserved01; + public IntPtr RenameX; + public IntPtr StatFsX; + public IntPtr SetVolName; + public IntPtr Exchange; + public IntPtr GetXTimes; + public IntPtr SetBackupTime; + public IntPtr SetChangeTime; + public IntPtr SetCreateTime; + public IntPtr ChFlags; + public IntPtr SetAttrX; + public IntPtr FSetAttrX; + } + + [StructLayout(LayoutKind.Sequential, Size = 144)] + private struct MacStat + { + public int Device; + public ushort Mode; + public ushort LinkCount; + public ulong Inode; + public uint UserId; + public uint GroupId; + public int RawDevice; + private readonly int _padding0; + public MacTimespec AccessTime; + public MacTimespec ModifyTime; + public MacTimespec ChangeTime; + public MacTimespec BirthTime; + public long Size; + public long Blocks; + public int BlockSize; + public uint Flags; + public uint Generation; + private readonly int _spare0; + private readonly long _spare1; + private readonly long _spare2; + } + + [StructLayout(LayoutKind.Sequential)] + private readonly struct MacTimespec(long seconds) + { + public readonly long Seconds = seconds; + public readonly long Nanoseconds = 0; + } + + [StructLayout(LayoutKind.Sequential, Size = 64)] + private struct MacStatVfs + { + public ulong BlockSize; + public ulong FragmentSize; + public uint Blocks; + public uint FreeBlocks; + public uint AvailableBlocks; + public uint FileCount; + public uint FreeFiles; + public uint AvailableFiles; + public ulong FileSystemId; + public ulong Flags; + public ulong NameMax; + } +} +#endif diff --git a/src/SteamDepotFs/MacFuseRuntime.cs b/src/SteamDepotFs/MacFuseRuntime.cs new file mode 100644 index 0000000..a4e686f --- /dev/null +++ b/src/SteamDepotFs/MacFuseRuntime.cs @@ -0,0 +1,60 @@ +#if MACFUSE_MOUNT +using System.Xml.Linq; + +internal static class MacFuseRuntime +{ + private const string BundlePath = "/Library/Filesystems/macfuse.fs"; + private const string InfoPlistPath = BundlePath + "/Contents/Info.plist"; + private const string MountHelperPath = BundlePath + "/Contents/Resources/mount_macfuse"; + + public static bool IsInstalled => Directory.Exists(BundlePath) && File.Exists(MountHelperPath); + + public static bool ShouldUseFSKitBackend(string mountPoint) + => OperatingSystem.IsMacOSVersionAtLeast(15, 4) && + VersionAtLeast(5, 0) && + IsFSKitMountPoint(mountPoint); + + internal static bool IsFSKitMountPoint(string mountPoint) + => IsUnderVolumes(mountPoint); + + private static bool VersionAtLeast(int major, int minor) + { + var version = ReadVersion(); + return version is not null && version >= new Version(major, minor); + } + + private static Version? ReadVersion() + { + if (!File.Exists(InfoPlistPath)) + { + return null; + } + + try + { + var document = XDocument.Load(InfoPlistPath); + var elements = document.Descendants().ToList(); + for (var i = 0; i < elements.Count - 1; i++) + { + if (elements[i].Name.LocalName == "key" && + elements[i].Value == "CFBundleShortVersionString" && + Version.TryParse(elements[i + 1].Value, out var version)) + { + return version; + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Xml.XmlException) + { + } + + return null; + } + + private static bool IsUnderVolumes(string mountPoint) + { + var fullPath = Path.GetFullPath(mountPoint).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return fullPath.StartsWith("/Volumes/", StringComparison.Ordinal); + } +} +#endif diff --git a/src/SteamDepotFs/Program.cs b/src/SteamDepotFs/Program.cs index ba14afb..4abe1cf 100644 --- a/src/SteamDepotFs/Program.cs +++ b/src/SteamDepotFs/Program.cs @@ -1,10 +1,6 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; -#if FUSE_MOUNT -using Mono.Fuse.NETStandard; -using Mono.Unix.Native; -#endif using SteamKit2; using SteamKit2.CDN; @@ -1424,250 +1420,6 @@ public DirectoryNode GetOrAddDirectory(string name, string path) } } -#if FUSE_MOUNT -internal sealed class DepotFuseFileSystem : FileSystem -{ - private static readonly FilePermissions DirectoryMode = - FilePermissions.S_IFDIR | - FilePermissions.S_IRUSR | FilePermissions.S_IXUSR | - FilePermissions.S_IRGRP | FilePermissions.S_IXGRP | - FilePermissions.S_IROTH | FilePermissions.S_IXOTH; - - private static readonly FilePermissions FileMode = - FilePermissions.S_IFREG | - FilePermissions.S_IRUSR | - FilePermissions.S_IRGRP | - FilePermissions.S_IROTH; - - private static readonly FilePermissions ExecutableFileMode = - FileMode | - FilePermissions.S_IXUSR | - FilePermissions.S_IXGRP | - FilePermissions.S_IXOTH; - - private static readonly FilePermissions SymlinkMode = - FilePermissions.S_IFLNK | - FilePermissions.S_IRUSR | - FilePermissions.S_IRGRP | - FilePermissions.S_IROTH; - - private readonly DepotReader _reader; - private readonly long _now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - - public DepotFuseFileSystem(string mountPoint, DepotReader reader) - : base(mountPoint) - { - _reader = reader; - Name = "steam-depotfs"; - MultiThreaded = true; - EnableKernelCache = true; - EnableDirectIO = false; - EnableLargeReadRequests = true; - MaxReadSize = 1024 * 1024; - AttributeTimeout = 60; - PathTimeout = 60; - - if (Environment.GetEnvironmentVariable("STEAM_DEPOTFS_FUSE_DEBUG") == "1") - { - EnableFuseDebugOutput = true; - } - } - - protected override Errno OnGetPathStatus(string path, out Stat stat) - { - if (_reader.Index.TryGetDirectory(path, out var directory)) - { - stat = StatForDirectory(directory); - return 0; - } - - if (_reader.Index.TryGetFile(path, out var file)) - { - stat = StatForFile(file); - return 0; - } - - stat = default; - return Errno.ENOENT; - } - - protected override Errno OnReadSymbolicLink(string link, out string target) - { - target = string.Empty; - if (!_reader.Index.TryGetFile(link, out var file)) - { - return Errno.ENOENT; - } - - if (!file.Flags.HasFlag(EDepotFileFlag.Symlink) || string.IsNullOrEmpty(file.LinkTarget)) - { - return Errno.EINVAL; - } - - target = file.LinkTarget; - return 0; - } - - protected override Errno OnOpenHandle(string file, OpenedPathInfo info) - { - if (!_reader.Index.TryGetFile(file, out _)) - { - return _reader.Index.TryGetDirectory(file, out _) ? Errno.EISDIR : Errno.ENOENT; - } - - if ((((int)info.OpenAccess) & 3) != (int)OpenFlags.O_RDONLY) - { - return Errno.EROFS; - } - - info.KeepCache = true; - info.DirectIO = false; - return 0; - } - - protected override Errno OnReadHandle(string file, OpenedPathInfo info, byte[] buf, long offset, out int bytesWritten) - { - bytesWritten = 0; - try - { - if (!_reader.Index.TryGetFile(file, out var depotFile)) - { - return Errno.ENOENT; - } - - bytesWritten = _reader.ReadAsync(depotFile, offset, buf, CancellationToken.None).GetAwaiter().GetResult(); - return 0; - } - catch (Exception ex) - { - Console.Error.WriteLine($"read failed for {file}: {ex.Message}"); - return Errno.EIO; - } - } - - protected override Errno OnOpenDirectory(string directory, OpenedPathInfo info) - => _reader.Index.TryGetDirectory(directory, out _) ? 0 : Errno.ENOENT; - - protected override Errno OnReleaseDirectory(string directory, OpenedPathInfo info) - => 0; - - protected override Errno OnReadDirectory(string directory, OpenedPathInfo info, out IEnumerable paths) - { - paths = []; - if (!_reader.Index.TryGetDirectory(directory, out var node)) - { - return Errno.ENOENT; - } - - var entries = new List - { - Entry(".", StatForDirectory(node)), - Entry("..", StatForDirectory(node)) - }; - - entries.AddRange(node.Directories.Values - .OrderBy(static d => d.Name, StringComparer.Ordinal) - .Select(d => Entry(d.Name, StatForDirectory(d)))); - - entries.AddRange(node.Files - .OrderBy(static f => f.Key, StringComparer.Ordinal) - .Select(f => Entry(f.Key, StatForFile(f.Value)))); - - paths = entries; - return 0; - } - - protected override Errno OnAccessPath(string path, AccessModes mode) - { - if (!_reader.Index.Exists(path)) - { - return Errno.ENOENT; - } - - return mode.HasFlag(AccessModes.W_OK) ? Errno.EROFS : 0; - } - - protected override Errno OnGetFileSystemStatus(string path, out Statvfs buf) - { - buf = default; - const ulong blockSize = 4096; - var blocks = Math.Max(1, (_reader.Manifest.TotalUncompressedSize + blockSize - 1) / blockSize); - buf.f_bsize = blockSize; - buf.f_frsize = blockSize; - buf.f_blocks = blocks; - buf.f_bfree = 0; - buf.f_bavail = 0; - buf.f_files = (ulong)_reader.Index.AllFiles.Count + 1; - buf.f_ffree = 0; - buf.f_favail = 0; - buf.f_namemax = 255; - return 0; - } - - protected override Errno OnCreateHandle(string file, OpenedPathInfo info, FilePermissions mode) => Errno.EROFS; - protected override Errno OnWriteHandle(string file, OpenedPathInfo info, byte[] buf, long offset, out int bytesRead) - { - bytesRead = 0; - return Errno.EROFS; - } - protected override Errno OnCreateDirectory(string directory, FilePermissions mode) => Errno.EROFS; - protected override Errno OnRemoveFile(string file) => Errno.EROFS; - protected override Errno OnRemoveDirectory(string directory) => Errno.EROFS; - protected override Errno OnRenamePath(string oldpath, string newpath) => Errno.EROFS; - protected override Errno OnChangePathPermissions(string path, FilePermissions mode) => Errno.EROFS; - protected override Errno OnChangePathOwner(string path, long owner, long group) => Errno.EROFS; - protected override Errno OnTruncateFile(string file, long length) => Errno.EROFS; - - private static DirectoryEntry Entry(string name, Stat stat) => new(name) { Stat = stat }; - - private Stat StatForDirectory(DirectoryNode directory) - => new() - { - st_ino = StableInode(directory.Path), - st_mode = DirectoryMode, - st_nlink = 2, - st_size = 0, - st_blksize = 4096, - st_blocks = 0, - st_atime = _now, - st_mtime = _now, - st_ctime = _now - }; - - private Stat StatForFile(DepotManifest.FileData file) - { - var mode = file.Flags.HasFlag(EDepotFileFlag.Symlink) - ? SymlinkMode - : file.Flags.HasFlag(EDepotFileFlag.Executable) - ? ExecutableFileMode - : FileMode; - - var size = file.Flags.HasFlag(EDepotFileFlag.Symlink) && file.LinkTarget is not null - ? file.LinkTarget.Length - : (long)file.TotalSize; - - return new Stat - { - st_ino = StableInode(file.FileName), - st_mode = mode, - st_nlink = 1, - st_size = size, - st_blksize = 4096, - st_blocks = (size + 511) / 512, - st_atime = _now, - st_mtime = new DateTimeOffset(_reader.Manifest.CreationTime.ToUniversalTime()).ToUnixTimeSeconds(), - st_ctime = _now - }; - } - - private static ulong StableInode(string path) - { - var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(path)); - return BitConverter.ToUInt64(hash, 0) & 0x7fffffffffffffff; - } -} -#endif - internal sealed class ParsedArgs { private readonly Dictionary _options; diff --git a/src/SteamDepotFs/WinFspMountSupport.cs b/src/SteamDepotFs/WinFspMountSupport.cs index 10d1c5f..713690d 100644 --- a/src/SteamDepotFs/WinFspMountSupport.cs +++ b/src/SteamDepotFs/WinFspMountSupport.cs @@ -1,7 +1,6 @@ #if WINDOWS_MOUNT using System.Runtime.InteropServices; using System.Security.AccessControl; -using System.Security.Cryptography; using Fsp; using SteamKit2; using FileInfo = Fsp.Interop.FileInfo; @@ -167,7 +166,7 @@ public override int GetVolumeInfo(out VolumeInfo volumeInfo) const ulong blockSize = AllocationUnit; volumeInfo = new VolumeInfo { - TotalSize = RoundUp(_reader.Manifest.TotalUncompressedSize, blockSize), + TotalSize = DepotFileSystemMetadata.RoundUp(_reader.Manifest.TotalUncompressedSize, blockSize), FreeSize = 0 }; volumeInfo.SetVolumeLabel("SteamDepotFS"); @@ -248,7 +247,7 @@ public override int Read( int read; if (file.Flags.HasFlag(EDepotFileFlag.Symlink) && file.LinkTarget is not null) { - read = ReadLinkTarget(file.LinkTarget, (long)offset, managedBuffer); + read = DepotFileSystemMetadata.ReadLinkTarget(file.LinkTarget, checked((long)offset), managedBuffer); } else { @@ -340,7 +339,7 @@ public override int GetDirInfoByName( return STATUS_NOT_A_DIRECTORY; } - var childPath = CombinePath(directory.Path, fileName); + var childPath = DepotFileSystemMetadata.CombinePath(directory.Path, fileName); if (!TryGetNode(childPath, out var child)) { return STATUS_OBJECT_NAME_NOT_FOUND; @@ -468,23 +467,10 @@ private bool TryGetNode(string path, out WinFspNode node) private IEnumerable DirectoryEntries(DirectoryNode directory, string marker) { - var entries = new List - { - new(".", FileInfoFor(new WinFspNode(".", directory.Path, directory, null))), - new("..", FileInfoFor(new WinFspNode("..", directory.Path, directory, null))) - }; - - entries.AddRange(directory.Directories.Values - .OrderBy(static child => child.Name, StringComparer.Ordinal) - .Select(child => new DirectoryEntryInfo( - child.Name, - FileInfoFor(new WinFspNode(child.Name, child.Path, child, null))))); - - entries.AddRange(directory.Files - .OrderBy(static child => child.Key, StringComparer.Ordinal) - .Select(child => new DirectoryEntryInfo( - child.Key, - FileInfoFor(new WinFspNode(child.Key, child.Value.FileName, null, child.Value))))); + var entries = DepotFileSystemMetadata.EnumerateDirectory(directory) + .Select(entry => new DirectoryEntryInfo( + entry.Name, + FileInfoFor(new WinFspNode(entry.Name, entry.Path, entry.Directory, entry.File)))); return string.IsNullOrEmpty(marker) ? entries @@ -504,23 +490,23 @@ private FileInfo FileInfoFor(WinFspNode node) LastAccessTime = _mountedAt, LastWriteTime = _createdAt, ChangeTime = _mountedAt, - IndexNumber = StableIndexNumber(node.Path), + IndexNumber = DepotFileSystemMetadata.StableId(node.Path), HardLinks = 1 }; } var file = node.File!; - var size = FileSize(file); + var size = DepotFileSystemMetadata.FileSize(file); return new FileInfo { FileAttributes = FileAttributesFor(node), - AllocationSize = RoundUp((ulong)size, AllocationUnit), + AllocationSize = DepotFileSystemMetadata.RoundUp((ulong)size, AllocationUnit), FileSize = (ulong)size, CreationTime = _createdAt, LastAccessTime = _mountedAt, LastWriteTime = _createdAt, ChangeTime = _mountedAt, - IndexNumber = StableIndexNumber(file.FileName), + IndexNumber = DepotFileSystemMetadata.StableId(file.FileName), HardLinks = 1 }; } @@ -534,42 +520,8 @@ private static uint FileAttributesFor(WinFspNode node) return (uint)attributes; } - private static long FileSize(DepotManifest.FileData file) - => file.Flags.HasFlag(EDepotFileFlag.Symlink) && file.LinkTarget is not null - ? System.Text.Encoding.UTF8.GetByteCount(file.LinkTarget) - : (long)file.TotalSize; - - private static int ReadLinkTarget(string linkTarget, long offset, byte[] destination) - { - var bytes = System.Text.Encoding.UTF8.GetBytes(linkTarget); - if (offset < 0 || offset >= bytes.Length || destination.Length == 0) - { - return 0; - } - - var length = Math.Min(destination.Length, bytes.Length - (int)offset); - Buffer.BlockCopy(bytes, (int)offset, destination, 0, length); - return length; - } - - private static string CombinePath(string parent, string child) - => parent.Length == 0 ? child : parent + "/" + child; - - private static ulong RoundUp(ulong value, ulong unit) - => value == 0 ? 0 : ((value + unit - 1) / unit) * unit; - - private static ulong StableIndexNumber(string path) - { - var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(path)); - return BitConverter.ToUInt64(hash, 0) & 0x7fffffffffffffff; - } - private static ulong ToFileTime(DateTime value) - { - var utc = value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc); - var minimum = DateTime.FromFileTimeUtc(0); - return utc <= minimum ? 0 : (ulong)utc.ToFileTimeUtc(); - } + => DepotFileSystemMetadata.ToFileTime(value); private static byte[] CreateSecurityDescriptor() {