From da9fa56f7f951a7006f8c6def73c6d93cf81412b Mon Sep 17 00:00:00 2001 From: Jiacheng Xu <13862180016@163.com> Date: Wed, 22 Jul 2026 02:50:14 +0800 Subject: [PATCH 1/2] fix: preserve VFS cache during navigation refresh --- .github/workflows/release.yml | 90 +-- .github/workflows/rust-rewrite.yml | 88 +-- Cargo.lock | 6 +- Cargo.toml | 2 +- README.md | 10 +- README.zh-CN.md | 10 +- THIRD_PARTY_NOTICES.md | 14 +- crates/mountmate-core/src/rc.rs | 59 +- crates/mountmate-core/src/service.rs | 18 +- distribution/rclone/rclone-v1.74.4-swr.patch | 717 +++++++++++++++++++ distribution/rclone/source.json | 11 + docs/development-roadmap.md | 12 + licenses/RUST-THIRD-PARTY.txt | 6 +- release-notes/v0.6.0-alpha.6.md | 21 + scripts/build-patched-rclone.sh | 47 ++ 15 files changed, 968 insertions(+), 143 deletions(-) create mode 100644 distribution/rclone/rclone-v1.74.4-swr.patch create mode 100644 distribution/rclone/source.json create mode 100644 release-notes/v0.6.0-alpha.6.md create mode 100644 scripts/build-patched-rclone.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34444183..cca596c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,20 @@ jobs: with: persist-credentials: false ref: ${{ inputs.tag || github.ref }} + - name: Check out pinned rclone source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: rclone/rclone + ref: 5bc93a2a7ab0ebd0a11352bc4968eabeffb18027 + path: .rclone-source + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: 1.25.0 + cache-dependency-path: .rclone-source/go.sum + - name: Test patched rclone VFS behavior + shell: bash + run: sh scripts/build-patched-rclone.sh .rclone-source target/rclone-quality/rclone --test - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 with: toolchain: 1.97.0 @@ -87,44 +101,37 @@ jobs: include: - os: windows-latest asset: SSHMountMate-windows-x64 - rclone_platform: windows - rclone_arch: amd64 - rclone_sha256: ef097ef9de37a57feb7d9f9c7afb34148ad3c65be8025f1d8f7f521554a701ea plink_arch: w64 plink_sha256: e5621ffe4879f0ec39ed40f688db9399c2d43054d41ef14472fa335c4693b915 - os: windows-11-arm asset: SSHMountMate-windows-arm64 - rclone_platform: windows - rclone_arch: arm64 - rclone_sha256: 72194ad0aaf210d7a55808801191fecc7e175444dab7be7491b7a63074521f3a plink_arch: wa64 plink_sha256: 998011ef455dabbb66c176913ea897a58e176402d53dc01607a5b36610a7f5fa - os: macos-15-intel asset: SSHMountMate-macos-x64 - rclone_platform: osx - rclone_arch: amd64 - rclone_sha256: 4188aa84043d7a6240912923f47639a9d2da21f3b40a521c065c8d92e66563f6 - os: macos-14 asset: SSHMountMate-macos-arm64 - rclone_platform: osx - rclone_arch: arm64 - rclone_sha256: c2100e2d4a4b3be04c55cd45380cafe7647e1ad772bb055f52f00876ed701167 - os: ubuntu-latest asset: SSHMountMate-linux-x64 - rclone_platform: linux - rclone_arch: amd64 - rclone_sha256: fe435e0c36228e7c2f116a8701f01127bb1f694005fc11d1f27186c8bca4115d - os: ubuntu-24.04-arm asset: SSHMountMate-linux-arm64 - rclone_platform: linux - rclone_arch: arm64 - rclone_sha256: 97685285c9ad6a0cf17d5844115d2a67245af6444db672187074bd9c358de419 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false ref: ${{ inputs.tag || github.ref }} + - name: Check out pinned rclone source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: rclone/rclone + ref: 5bc93a2a7ab0ebd0a11352bc4968eabeffb18027 + path: .rclone-source + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: 1.25.0 + cache-dependency-path: .rclone-source/go.sum - name: Validate release version shell: bash env: @@ -223,52 +230,27 @@ jobs: shell: pwsh run: ./tests/rust_windows_gui_smoke.ps1 -Binary target/release/SSHMountMate.exe - - name: Bundle verified rclone - if: runner.os != 'Windows' + - name: Build pinned patched rclone shell: bash - env: - RCLONE_VERSION: v1.74.4 - RCLONE_PLATFORM: ${{ matrix.rclone_platform }} - RCLONE_ARCH: ${{ matrix.rclone_arch }} - RCLONE_SHA256: ${{ matrix.rclone_sha256 }} run: | set -euo pipefail - archive="rclone-${RCLONE_VERSION}-${RCLONE_PLATFORM}-${RCLONE_ARCH}.zip" - curl --fail --location --silent --show-error \ - "https://downloads.rclone.org/${RCLONE_VERSION}/${archive}" \ - --output "${RUNNER_TEMP}/${archive}" - echo "${RCLONE_SHA256} ${RUNNER_TEMP}/${archive}" | shasum -a 256 --check - mkdir -p "${RUNNER_TEMP}/rclone" target/release/bin - unzip -q "${RUNNER_TEMP}/${archive}" -d "${RUNNER_TEMP}/rclone" - find "${RUNNER_TEMP}/rclone" -type f -name rclone -exec cp {} target/release/bin/rclone \; - chmod 755 target/release/bin/rclone - shasum -a 256 target/release/bin/rclone | awk '{print $1}' > target/release/bin/rclone.sha256 - - - name: Bundle verified rclone and Plink + binary=rclone + if [ "$RUNNER_OS" = Windows ]; then binary=rclone.exe; fi + sh scripts/build-patched-rclone.sh .rclone-source "target/release/bin/${binary}" + if [ "$RUNNER_OS" = macOS ]; then + shasum -a 256 "target/release/bin/${binary}" | awk '{print $1}' > "target/release/bin/${binary}.sha256" + else + sha256sum "target/release/bin/${binary}" | awk '{print $1}' > "target/release/bin/${binary}.sha256" + fi + + - name: Bundle verified Plink if: runner.os == 'Windows' shell: pwsh env: - RCLONE_VERSION: v1.74.4 - RCLONE_PLATFORM: ${{ matrix.rclone_platform }} - RCLONE_ARCH: ${{ matrix.rclone_arch }} - RCLONE_SHA256: ${{ matrix.rclone_sha256 }} PLINK_VERSION: '0.84' PLINK_ARCH: ${{ matrix.plink_arch }} PLINK_SHA256: ${{ matrix.plink_sha256 }} run: | - $archive = "rclone-$env:RCLONE_VERSION-$env:RCLONE_PLATFORM-$env:RCLONE_ARCH.zip" - $archivePath = Join-Path $env:RUNNER_TEMP $archive - Invoke-WebRequest "https://downloads.rclone.org/$env:RCLONE_VERSION/$archive" -OutFile $archivePath - $actual = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant() - if ($actual -ne $env:RCLONE_SHA256) { throw "rclone archive SHA-256 mismatch: $actual" } - $extract = Join-Path $env:RUNNER_TEMP rclone - Expand-Archive -Path $archivePath -DestinationPath $extract -Force - New-Item -ItemType Directory -Force target/release/bin | Out-Null - $binary = Get-ChildItem -Path $extract -Recurse -File -Filter rclone.exe | Select-Object -First 1 - if (-not $binary) { throw "rclone archive did not contain rclone.exe" } - Copy-Item $binary.FullName target/release/bin/rclone.exe - (Get-FileHash -Algorithm SHA256 target/release/bin/rclone.exe).Hash.ToLowerInvariant() | - Set-Content -NoNewline target/release/bin/rclone.exe.sha256 $plinkUrl = "https://the.earth.li/~sgtatham/putty/$env:PLINK_VERSION/$env:PLINK_ARCH/plink.exe" Invoke-WebRequest $plinkUrl -OutFile target/release/bin/plink.exe $plinkActual = (Get-FileHash -Algorithm SHA256 target/release/bin/plink.exe).Hash.ToLowerInvariant() diff --git a/.github/workflows/rust-rewrite.yml b/.github/workflows/rust-rewrite.yml index e0e18aee..ef527bd2 100644 --- a/.github/workflows/rust-rewrite.yml +++ b/.github/workflows/rust-rewrite.yml @@ -16,6 +16,20 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false + - name: Check out pinned rclone source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: rclone/rclone + ref: 5bc93a2a7ab0ebd0a11352bc4968eabeffb18027 + path: .rclone-source + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: 1.25.0 + cache-dependency-path: .rclone-source/go.sum + - name: Test patched rclone VFS behavior + shell: bash + run: sh scripts/build-patched-rclone.sh .rclone-source target/rclone-quality/rclone --test - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 with: toolchain: 1.97.0 @@ -85,43 +99,36 @@ jobs: include: - os: windows-latest artifact: SSHMountMate-rust-windows-x64 - rclone_platform: windows - rclone_arch: amd64 - rclone_sha256: ef097ef9de37a57feb7d9f9c7afb34148ad3c65be8025f1d8f7f521554a701ea plink_arch: w64 plink_sha256: e5621ffe4879f0ec39ed40f688db9399c2d43054d41ef14472fa335c4693b915 - os: windows-11-arm artifact: SSHMountMate-rust-windows-arm64 - rclone_platform: windows - rclone_arch: arm64 - rclone_sha256: 72194ad0aaf210d7a55808801191fecc7e175444dab7be7491b7a63074521f3a plink_arch: wa64 plink_sha256: 998011ef455dabbb66c176913ea897a58e176402d53dc01607a5b36610a7f5fa - os: macos-15-intel artifact: SSHMountMate-rust-macos-x64 - rclone_platform: osx - rclone_arch: amd64 - rclone_sha256: 4188aa84043d7a6240912923f47639a9d2da21f3b40a521c065c8d92e66563f6 - os: macos-14 artifact: SSHMountMate-rust-macos-arm64 - rclone_platform: osx - rclone_arch: arm64 - rclone_sha256: c2100e2d4a4b3be04c55cd45380cafe7647e1ad772bb055f52f00876ed701167 - os: ubuntu-latest artifact: SSHMountMate-rust-linux-x64 - rclone_platform: linux - rclone_arch: amd64 - rclone_sha256: fe435e0c36228e7c2f116a8701f01127bb1f694005fc11d1f27186c8bca4115d - os: ubuntu-24.04-arm artifact: SSHMountMate-rust-linux-arm64 - rclone_platform: linux - rclone_arch: arm64 - rclone_sha256: 97685285c9ad6a0cf17d5844115d2a67245af6444db672187074bd9c358de419 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false + - name: Check out pinned rclone source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: rclone/rclone + ref: 5bc93a2a7ab0ebd0a11352bc4968eabeffb18027 + path: .rclone-source + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: 1.25.0 + cache-dependency-path: .rclone-source/go.sum - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 with: toolchain: 1.97.0 @@ -297,53 +304,28 @@ jobs: timeout-minutes: 3 shell: pwsh run: ./tests/rust_windows_gui_smoke.ps1 -Binary target/release/SSHMountMate.exe - - name: Bundle verified rclone - if: runner.os != 'Windows' + - name: Build pinned patched rclone shell: bash - env: - RCLONE_VERSION: v1.74.4 - RCLONE_PLATFORM: ${{ matrix.rclone_platform }} - RCLONE_ARCH: ${{ matrix.rclone_arch }} - RCLONE_SHA256: ${{ matrix.rclone_sha256 }} run: | set -euo pipefail - archive="rclone-${RCLONE_VERSION}-${RCLONE_PLATFORM}-${RCLONE_ARCH}.zip" - curl --fail --location --silent --show-error \ - "https://downloads.rclone.org/${RCLONE_VERSION}/${archive}" \ - --output "${RUNNER_TEMP}/${archive}" - echo "${RCLONE_SHA256} ${RUNNER_TEMP}/${archive}" | shasum -a 256 --check - mkdir -p "${RUNNER_TEMP}/rclone" target/release/bin - unzip -q "${RUNNER_TEMP}/${archive}" -d "${RUNNER_TEMP}/rclone" - find "${RUNNER_TEMP}/rclone" -type f -name rclone -exec cp {} target/release/bin/rclone \; - test -x target/release/bin/rclone || chmod 755 target/release/bin/rclone - shasum -a 256 target/release/bin/rclone | awk '{print $1}' > target/release/bin/rclone.sha256 + binary=rclone + if [ "$RUNNER_OS" = Windows ]; then binary=rclone.exe; fi + sh scripts/build-patched-rclone.sh .rclone-source "target/release/bin/${binary}" + if [ "$RUNNER_OS" = macOS ]; then + shasum -a 256 "target/release/bin/${binary}" | awk '{print $1}' > "target/release/bin/${binary}.sha256" + else + sha256sum "target/release/bin/${binary}" | awk '{print $1}' > "target/release/bin/${binary}.sha256" + fi cp THIRD_PARTY_NOTICES.md licenses/*.txt target/release/ cp distribution/SSHMountMate.install-layout target/release/ - - name: Bundle verified rclone and Plink + - name: Bundle verified Plink if: runner.os == 'Windows' shell: pwsh env: - RCLONE_VERSION: v1.74.4 - RCLONE_PLATFORM: ${{ matrix.rclone_platform }} - RCLONE_ARCH: ${{ matrix.rclone_arch }} - RCLONE_SHA256: ${{ matrix.rclone_sha256 }} PLINK_VERSION: '0.84' PLINK_ARCH: ${{ matrix.plink_arch }} PLINK_SHA256: ${{ matrix.plink_sha256 }} run: | - $archive = "rclone-$env:RCLONE_VERSION-$env:RCLONE_PLATFORM-$env:RCLONE_ARCH.zip" - $archivePath = Join-Path $env:RUNNER_TEMP $archive - Invoke-WebRequest "https://downloads.rclone.org/$env:RCLONE_VERSION/$archive" -OutFile $archivePath - $actual = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant() - if ($actual -ne $env:RCLONE_SHA256) { throw "rclone archive SHA-256 mismatch: $actual" } - $extract = Join-Path $env:RUNNER_TEMP "rclone" - Expand-Archive -Path $archivePath -DestinationPath $extract -Force - New-Item -ItemType Directory -Force target/release/bin | Out-Null - $binary = Get-ChildItem -Path $extract -Recurse -File -Filter rclone.exe | Select-Object -First 1 - if (-not $binary) { throw "rclone archive did not contain rclone.exe" } - Copy-Item $binary.FullName target/release/bin/rclone.exe - (Get-FileHash -Algorithm SHA256 target/release/bin/rclone.exe).Hash.ToLowerInvariant() | - Set-Content -NoNewline target/release/bin/rclone.exe.sha256 $plinkUrl = "https://the.earth.li/~sgtatham/putty/$env:PLINK_VERSION/$env:PLINK_ARCH/plink.exe" Invoke-WebRequest $plinkUrl -OutFile target/release/bin/plink.exe $plinkActual = (Get-FileHash -Algorithm SHA256 target/release/bin/plink.exe).Hash.ToLowerInvariant() diff --git a/Cargo.lock b/Cargo.lock index 2a694e54..23eb2d0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2982,7 +2982,7 @@ dependencies = [ [[package]] name = "mountmate-core" -version = "0.6.0-alpha.5" +version = "0.6.0-alpha.6" dependencies = [ "base64", "configparser", @@ -3013,7 +3013,7 @@ dependencies = [ [[package]] name = "mountmate-platform" -version = "0.6.0-alpha.5" +version = "0.6.0-alpha.6" dependencies = [ "async-channel 2.5.0", "mountmate-core", @@ -5007,7 +5007,7 @@ dependencies = [ [[package]] name = "ssh-mountmate" -version = "0.6.0-alpha.5" +version = "0.6.0-alpha.6" dependencies = [ "async-channel 2.5.0", "dark-light", diff --git a/Cargo.toml b/Cargo.toml index 1e639736..d9f1bd76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.6.0-alpha.5" +version = "0.6.0-alpha.6" edition = "2024" rust-version = "1.88" license = "MIT" diff --git a/README.md b/README.md index a25b054d..8e289ed9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ It uses rclone for the actual mount operation and provides a small GUI around th shared SSH session for OAuth/2FA-style login. - Store passwords and key passphrases through `rclone obscure`, not as plain text. - Check for rclone and platform mount dependencies. -- Bundle and verify the official rclone binary in release builds. +- Build and verify a pinned, minimally patched rclone in release builds. - Configure global rclone VFS cache options in the GUI. - Show mount status, capacity usage, Lustre project/user/group quotas, logs, and common actions per connection. - Show the real rclone upload queue and remote-transfer progress after local file copies appear complete. @@ -28,7 +28,7 @@ It uses rclone for the actual mount operation and provides a small GUI around th ## Requirements -SSH MountMate release builds bundle the official rclone binary for the target platform and verify it before use. Source builds can use an explicitly configured rclone, a previously managed copy, or a compatible rclone found on `PATH`. +SSH MountMate release builds bundle a pinned rclone v1.74.4 source build with a small audited cache-refresh patch and verify it before use. Source builds can use an explicitly configured rclone, a previously managed copy, or a compatible rclone found on `PATH`. Windows: @@ -52,7 +52,7 @@ macOS: - macFUSE or FUSE-T - OpenSSH Client -Important macOS note: SSH MountMate release builds use the bundled official rclone binary, so users normally do not need Homebrew rclone. If you override rclone or run from source, do not use the Homebrew `rclone` package for mounting. Homebrew's rclone package cannot run `rclone mount` on macOS. Use the official rclone binary instead: +Important macOS note: SSH MountMate release builds use the bundled rclone binary, so users normally do not need Homebrew rclone. If you override rclone or run from source, do not use the Homebrew `rclone` package for mounting. Homebrew's rclone package cannot run `rclone mount` on macOS. Use the official rclone installer instead: ```bash curl https://rclone.org/install.sh | sudo bash @@ -124,7 +124,7 @@ In the Settings window, `Check dependencies` reports rclone, OpenSSH, and the cu ## Bundled And Managed rclone -Release workflows download a pinned official rclone archive for the target platform and architecture, verify its SHA-256 digest, and place rclone beside the Rust application inside the package. At runtime SSH MountMate verifies the bundled digest again and materializes a content-addressed managed copy in the application data directory. Explicitly configured and existing legacy managed copies remain supported for migration; a compatible system rclone is the final source-build fallback. +Release workflows check out an immutable rclone v1.74.4 commit, verify and apply the tracked stale-while-revalidate patch, build it with pinned Go 1.25.0, and place rclone beside the Rust application inside the package. At runtime SSH MountMate verifies the bundled digest again and materializes a content-addressed managed copy in the application data directory. Explicitly configured and existing legacy managed copies remain supported for migration; a compatible system rclone is the final source-build fallback, but passive navigation refresh is skipped when that binary lacks the custom capability. The remote server is assumed to be a Linux server reachable over SSH/SFTP. @@ -140,7 +140,7 @@ Use the latest GitHub Release and download the package for your platform: - `SSHMountMate-linux-arm64.zip` Release builds are produced from the Rust workspace by six native GitHub Actions runners. Windows -and Linux ZIPs contain one executable with the verified official rclone embedded; Windows builds +and Linux ZIPs contain one executable with the verified patched rclone embedded; Windows builds also embed the independently verified official Plink used by interactive sharing. macOS ZIPs contain the native `SSH MountMate.app` bundle with rclone and license notices inside the application. diff --git a/README.zh-CN.md b/README.zh-CN.md index 598d1cf4..f9d79cdd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -18,7 +18,7 @@ SSH MountMate 是一个跨平台桌面程序,用来通过 SSH/SFTP 把 Linux 登录的交互式共享 SSH。 - 密码和密钥短语通过 `rclone obscure` 保存,不明文存储。 - 检查 rclone 和系统挂载依赖。 -- Release 构建内置并校验官方 rclone 二进制。 +- Release 构建会从固定源码构建并校验带最小补丁的 rclone。 - 在 GUI 中配置全局 rclone VFS 缓存选项。 - 在连接卡片中显示挂载状态、容量、日志和常用操作。 - 在本地复制窗口结束后继续显示 rclone 真实上传队列和远端传输进度。 @@ -28,7 +28,7 @@ SSH MountMate 是一个跨平台桌面程序,用来通过 SSH/SFTP 把 Linux ## 运行依赖 -SSH MountMate 的 Release 构建会内置目标平台的官方 rclone 二进制,并在使用前校验。源码构建可以使用显式配置的 rclone、已有托管副本或 `PATH` 中兼容的 rclone。 +SSH MountMate 的 Release 构建会内置固定 rclone v1.74.4 源码加一份经过审计的缓存刷新补丁,并在使用前校验。源码构建可以使用显式配置的 rclone、已有托管副本或 `PATH` 中兼容的 rclone。 Windows: @@ -52,7 +52,7 @@ macOS: - macFUSE 或 FUSE-T - OpenSSH Client -macOS 重要提示:SSH MountMate Release 构建会使用内置的官方 rclone,通常不需要用户安装 Homebrew rclone。如果你手动覆盖 rclone 或从源码运行,不要使用 Homebrew 安装的 `rclone` 做挂载。Homebrew 版 rclone 在 macOS 上不能执行 `rclone mount`,请改用 rclone 官方二进制: +macOS 重要提示:SSH MountMate Release 构建会使用内置 rclone,通常不需要用户安装 Homebrew rclone。如果你手动覆盖 rclone 或从源码运行,不要使用 Homebrew 安装的 `rclone` 做挂载。Homebrew 版 rclone 在 macOS 上不能执行 `rclone mount`,请改用 rclone 官方安装器: ```bash curl https://rclone.org/install.sh | sudo bash @@ -122,7 +122,7 @@ Settings 页面里的 `检查依赖` 会报告 rclone、OpenSSH 和当前平台 ## 内置和托管 rclone -Release 工作流会下载目标平台和架构的固定版本官方 rclone,校验 SHA-256 后把它纳入对应平台的发布产物。运行时 SSH MountMate 会再次校验内置摘要,并在应用数据目录生成按内容摘要命名的托管副本。为迁移兼容,显式配置和已有旧版托管副本仍然可用;源码构建最后还可以使用系统 `PATH` 中兼容的 rclone。 +Release 工作流会检出不可变的 rclone v1.74.4 提交,校验并应用仓库内的 stale-while-revalidate 补丁,再用固定 Go 1.25.0 构建。运行时 SSH MountMate 会再次校验内置摘要,并在应用数据目录生成按内容摘要命名的托管副本。为迁移兼容,显式配置和已有旧版托管副本仍然可用;源码构建最后还可以使用系统 `PATH` 中兼容的 rclone,但缺少定制能力时会跳过被动导航刷新。 远端服务器默认按 Linux SSH/SFTP 服务器处理。 @@ -138,7 +138,7 @@ Release 工作流会下载目标平台和架构的固定版本官方 rclone, - `SSHMountMate-linux-arm64.zip` 这些发布包由六个原生 GitHub Actions runner 从 Rust 工作区构建。Windows 和 Linux ZIP 内 -是一个嵌入并校验官方 rclone 的可执行文件;Windows 还会内嵌单独校验的官方 Plink,用于 +是一个嵌入并校验定制 rclone 的可执行文件;Windows 还会内嵌单独校验的官方 Plink,用于 交互式共享。macOS ZIP 内是原生 `SSH MountMate.app`,rclone 和许可证声明位于应用包内。 内置第三方声明可以在 Settings 页面查看,或执行: diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b7a0e2eb..dba750af 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,7 @@ # Third-Party Notices -SSH MountMate release builds bundle the official rclone binary for the target platform. +SSH MountMate release builds bundle a source-built, minimally modified rclone binary for the +target platform. The complete generated inventory and license text for every Rust crate linked on the six release targets is available in `licenses/RUST-THIRD-PARTY.txt`. CI regenerates it from `Cargo.lock` with @@ -13,13 +14,18 @@ cargo-about and rejects stale output. - License: MIT - License text: `licenses/rclone-COPYING.txt` -The bundled rclone binary is downloaded from the official rclone download host during the SSH MountMate build. Release workflows currently pin rclone v1.74.4: +Release workflows check out rclone v1.74.4 at immutable upstream commit +`5bc93a2a7ab0ebd0a11352bc4968eabeffb18027`, apply the audited patch below, and build it with Go +1.25.0. The bundled binary identifies itself as `v1.74.4-ssh-mountmate.1`: ```text -https://downloads.rclone.org/v1.74.4/rclone-v1.74.4--.zip +distribution/rclone/rclone-v1.74.4-swr.patch +SHA-256: ebdf3b6d3043526a29efd285768a829e2291275d6fbd4c4836861c665f440334 ``` -Platform is `windows`, `osx`, or `linux`; architecture is selected from the build machine, usually `amd64` or `arm64`. +The patch adds only the cache-preserving `vfs/refresh-swr` remote-control operation used for passive +file-manager navigation refresh. The source/build provenance is recorded in +`distribution/rclone/source.json`; rclone remains licensed under the MIT license above. ## rfd diff --git a/crates/mountmate-core/src/rc.rs b/crates/mountmate-core/src/rc.rs index b4be95ca..dee90da0 100644 --- a/crates/mountmate-core/src/rc.rs +++ b/crates/mountmate-core/src/rc.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::net::{IpAddr, SocketAddr}; use std::time::Duration; @@ -84,9 +85,9 @@ impl HttpRcClient { refresh_remote_snapshot(self, remote, relative_dir, recursive) } - /// Invalidate and refresh only the VFS cache entry. This deliberately - /// avoids operations/list and vfs/queue so Explorer navigation can remain - /// fire-and-forget and cannot expose transfer state. + /// Refresh a cached VFS directory without invalidating its old snapshot. + /// This custom endpoint is deliberately not emulated on older rclone + /// builds because their ordinary refresh blocks readers during remote I/O. pub fn refresh_remote_cache(&self, relative_dir: &str) -> Result<(), RcError> { refresh_remote_cache(self, relative_dir) } @@ -199,11 +200,32 @@ pub fn refresh_remote_cache(api: &impl RcApi, relative_dir: &str) -> Result<(), } else { json!({"dir": relative_dir}) }; - api.call("vfs/forget", params.clone())?; - api.call("vfs/refresh", params)?; + let response: CacheRefreshResponse = + decode("vfs/refresh-swr", api.call("vfs/refresh-swr", params)?)?; + let key = relative_dir.trim_matches('/'); + match response.result.get(key).map(String::as_str) { + Some("OK") => {} + Some(message) => { + return Err(RcError::Request { + method: "vfs/refresh-swr".into(), + message: message.into(), + }); + } + None => { + return Err(RcError::InvalidResponse { + method: "vfs/refresh-swr".into(), + message: format!("missing result for directory {key:?}"), + }); + } + } Ok(()) } +#[derive(Debug, Deserialize)] +struct CacheRefreshResponse { + result: HashMap, +} + #[cfg(test)] mod tests { use std::cell::RefCell; @@ -309,8 +331,8 @@ mod tests { } #[test] - fn cache_only_refresh_calls_exactly_forget_and_refresh() { - let api = FakeRc::new([json!({}), json!({}), json!({})]); + fn cache_only_refresh_calls_stale_while_revalidate_endpoint() { + let api = FakeRc::new([json!({"result": {"subdir": "OK"}})]); refresh_remote_cache(&api, "subdir").unwrap(); let calls = api.calls.borrow(); assert_eq!( @@ -318,10 +340,29 @@ mod tests { .iter() .map(|(method, _)| method.as_str()) .collect::>(), - ["vfs/forget", "vfs/refresh"] + ["vfs/refresh-swr"] ); assert_eq!(calls[0].1, json!({"dir": "subdir"})); - assert_eq!(calls[1].1, json!({"dir": "subdir"})); + } + + #[test] + fn cache_only_refresh_surfaces_directory_failure() { + let api = FakeRc::new([json!({"result": {"subdir": "directory not found"}})]); + let error = refresh_remote_cache(&api, "subdir").unwrap_err(); + assert!(matches!( + error, + RcError::Request { method, message } + if method == "vfs/refresh-swr" && message == "directory not found" + )); + } + + #[test] + fn cache_only_root_refresh_uses_empty_result_key() { + let api = FakeRc::new([json!({"result": {"": "OK"}})]); + refresh_remote_cache(&api, "").unwrap(); + let calls = api.calls.borrow(); + assert_eq!(calls[0].0, "vfs/refresh-swr"); + assert_eq!(calls[0].1, json!({})); } #[test] diff --git a/crates/mountmate-core/src/service.rs b/crates/mountmate-core/src/service.rs index ef4c2c47..3aec00d3 100644 --- a/crates/mountmate-core/src/service.rs +++ b/crates/mountmate-core/src/service.rs @@ -254,24 +254,30 @@ impl MountService { Err(ServiceError::PathOutsideMount(local_path.into())) } - /// Refresh only rclone's local VFS cache for an Explorer navigation. The - /// operation intentionally does not list the remote or inspect the upload - /// queue; callers use it from a bounded background worker. + /// Revalidate one cached VFS directory for Explorer navigation while its + /// previous snapshot remains readable. Callers use a bounded background + /// worker and never fall back to an invalidating or blocking refresh. pub fn refresh_job_cache_only(&self, job: &RefreshJob) -> Result<(), ServiceError> { let state: MountState = read_json(&self.paths.state_file(&job.identity.server_id))?; if MountIdentity::from_state(&state) != job.identity { return Err(ServiceError::StaleMount(job.identity.server_id.clone())); } - let client = HttpRcClient::with_credentials( + let identity_client = HttpRcClient::with_credentials( &state.rc_addr, &state.rc_user, &state.rc_pass, Duration::from_secs(3), )?; - if client.process_id()? != job.identity.pid { + if identity_client.process_id()? != job.identity.pid { return Err(ServiceError::StaleMount(job.identity.server_id.clone())); } - client.refresh_remote_cache(&job.relative_dir)?; + HttpRcClient::with_credentials( + &state.rc_addr, + &state.rc_user, + &state.rc_pass, + Duration::from_secs(60), + )? + .refresh_remote_cache(&job.relative_dir)?; Ok(()) } diff --git a/distribution/rclone/rclone-v1.74.4-swr.patch b/distribution/rclone/rclone-v1.74.4-swr.patch new file mode 100644 index 00000000..6b37a072 --- /dev/null +++ b/distribution/rclone/rclone-v1.74.4-swr.patch @@ -0,0 +1,717 @@ +diff --git a/vfs/dir.go b/vfs/dir.go +index 05fefd9..a0af58f 100644 +--- a/vfs/dir.go ++++ b/vfs/dir.go +@@ -1,6 +1,7 @@ + package vfs + + import ( ++ "context" + "encoding/json" + "fmt" + "os" +@@ -28,15 +29,18 @@ type Dir struct { + inode uint64 // read only: inode number + f fs.Fs // read only + cleanupTimer *time.Timer // read only: timer to call cacheCleanup +- +- mu sync.RWMutex // protects the following +- parent *Dir // parent, nil for root +- path string +- entry fs.Directory +- read time.Time // time directory entry last read +- items map[string]Node // directory entries - can be empty but not nil +- virtual map[string]vState // virtual directory entries - may be nil +- sys atomic.Value // user defined info to be attached here ++ refreshMu sync.Mutex // serializes forced and stale-while-revalidate refreshes ++ ++ mu sync.RWMutex // protects the following ++ parent *Dir // parent, nil for root ++ path string ++ entry fs.Directory ++ read time.Time // time directory entry last read ++ items map[string]Node // directory entries - can be empty but not nil ++ virtual map[string]vState // virtual directory entries - may be nil ++ refreshing bool // a stale-while-revalidate listing is in progress ++ generation uint64 // incremented whenever cached contents are invalidated ++ sys atomic.Value // user defined info to be attached here + + modTimeMu sync.Mutex // protects the following + modTime time.Time +@@ -82,12 +86,14 @@ func (d *Dir) cacheCleanup() { + + when := time.Now() + ++ d.refreshMu.Lock() ++ defer d.refreshMu.Unlock() + d.mu.Lock() + _, stale := d._age(when) + d.mu.Unlock() + + if stale { +- d.ForgetAll() ++ d.forgetAllForCleanup() + } + } + +@@ -233,12 +239,39 @@ func (d *Dir) ForgetAll() (hasVirtual bool) { + } + } + ++ d.mu.RUnlock() ++ return d.forgetSelf() ++} ++ ++// forgetAllForCleanup recursively expires cached directories without holding ++// an ancestor directory lock while waiting for a child refresh to finish. ++// d.refreshMu must already be held by the caller. ++func (d *Dir) forgetAllForCleanup() (hasVirtual bool) { ++ d.mu.RLock() ++ children := make([]*Dir, 0) ++ for _, node := range d.items { ++ if dir, ok := node.(*Dir); ok { ++ children = append(children, dir) ++ } ++ } + d.mu.RUnlock() + ++ for _, dir := range children { ++ dir.refreshMu.Lock() ++ dir.forgetAllForCleanup() ++ dir.refreshMu.Unlock() ++ } ++ return d.forgetSelf() ++} ++ ++// forgetSelf clears only this directory's cache. ++func (d *Dir) forgetSelf() (hasVirtual bool) { ++ + // We run this part with Lock so we can modify the Dir + + d.mu.Lock() + defer d.mu.Unlock() ++ d.generation++ + + // Purge any unnecessary virtual entries + d._purgeVirtual() +@@ -275,6 +308,7 @@ func (d *Dir) invalidateDir(absPath string) { + node := d.vfs.root.cachedNode(absPath) + if dir, ok := node.(*Dir); ok { + dir.mu.Lock() ++ dir.generation++ + if !dir.read.IsZero() { + fs.Debugf(dir.path, "invalidating directory cache") + dir.read = time.Time{} +@@ -444,6 +478,7 @@ func name(p string) string { + // note that we add new objects rather than updating old ones + func (d *Dir) addObject(node Node) { + d.mu.Lock() ++ d.generation++ + leaf := node.Name() + d.items[leaf] = node + if d.virtual == nil { +@@ -505,6 +540,7 @@ func (d *Dir) AddVirtual(leaf string, size int64, isDir bool) { + // from a remote directory listing. + func (d *Dir) delObject(leaf string) { + d.mu.Lock() ++ d.generation++ + delete(d.items, leaf) + if d.virtual == nil { + d.virtual = make(map[string]vState) +@@ -530,6 +566,11 @@ func (d *Dir) DelVirtual(leaf string) { + + // read the directory and sets d.items - must be called with the lock held + func (d *Dir) _readDir() error { ++ // A stale-while-revalidate refresh deliberately keeps the previous ++ // snapshot readable until its replacement is ready. ++ if d.refreshing { ++ return nil ++ } + when := time.Now() + if age, stale := d._age(when); stale { + if age != 0 { +@@ -538,12 +579,31 @@ func (d *Dir) _readDir() error { + } else { + return nil + } +- entries, err := list.DirSorted(d.vfs.ctx, d.f, false, d.path) ++ entries, err := d.listDir(d.vfs.ctx, d.f, d.path) ++ if err != nil { ++ return err ++ } ++ ++ err = d._readDirFromEntries(entries, nil, time.Time{}) ++ if err != nil { ++ return err ++ } ++ ++ d.read = time.Now() ++ d.cleanupTimer.Reset(time.Duration(d.vfs.Opt.DirCacheTime * 2)) ++ ++ return nil ++} ++ ++// listDir reads and normalizes a single remote directory. It does not require ++// d.mu, so callers may fetch a replacement snapshot without blocking readers. ++func (d *Dir) listDir(ctx context.Context, f fs.Fs, dirPath string) (entries fs.DirEntries, err error) { ++ entries, err = list.DirSorted(ctx, f, false, dirPath) + if err == fs.ErrorDirNotFound { + // We treat directory not found as empty because we + // create directories on the fly + } else if err != nil { +- return err ++ return nil, err + } + + if d.vfs.Opt.BlockNormDupes { // do this only if requested, as it will have a performance hit +@@ -575,15 +635,7 @@ func (d *Dir) _readDir() error { + entries = filteredEntries + } + +- err = d._readDirFromEntries(entries, nil, time.Time{}) +- if err != nil { +- return err +- } +- +- d.read = time.Now() +- d.cleanupTimer.Reset(time.Duration(d.vfs.Opt.DirCacheTime * 2)) +- +- return nil ++ return entries, nil + } + + // update d.items for each dir in the DirTree below this one and +@@ -788,6 +840,8 @@ func (d *Dir) _readDirFromEntries(entries fs.DirEntries, dirTree dirtree.DirTree + + // readDirTree forces a refresh of the complete directory tree + func (d *Dir) readDirTree() error { ++ d.refreshMu.Lock() ++ defer d.refreshMu.Unlock() + d.mu.RLock() + f, path := d.f, d.path + d.mu.RUnlock() +@@ -812,12 +866,56 @@ func (d *Dir) readDirTree() error { + + // readDir forces a refresh of the directory + func (d *Dir) readDir() error { ++ d.refreshMu.Lock() ++ defer d.refreshMu.Unlock() + d.mu.Lock() + defer d.mu.Unlock() + d.read = time.Time{} + return d._readDir() + } + ++// readDirSWR refreshes a single directory while keeping its old snapshot ++// readable. Concurrent refreshes are coalesced. Listing failures leave the ++// prior cache contents and freshness timestamp unchanged. ++func (d *Dir) readDirSWR(ctx context.Context) error { ++ if !d.refreshMu.TryLock() { ++ return nil ++ } ++ defer d.refreshMu.Unlock() ++ ++ d.mu.Lock() ++ if d.refreshing { ++ d.mu.Unlock() ++ return nil ++ } ++ d.refreshing = true ++ f, dirPath, generation := d.f, d.path, d.generation ++ d.mu.Unlock() ++ ++ defer func() { ++ d.mu.Lock() ++ d.refreshing = false ++ d.mu.Unlock() ++ }() ++ ++ entries, err := d.listDir(ctx, f, dirPath) ++ if err != nil { ++ return err ++ } ++ ++ d.mu.Lock() ++ defer d.mu.Unlock() ++ if d.path != dirPath || d.generation != generation { ++ return fmt.Errorf("directory cache changed during refresh of %q", dirPath) ++ } ++ if err = d._readDirFromEntries(entries, nil, time.Time{}); err != nil { ++ return err ++ } ++ d.read = time.Now() ++ d.cleanupTimer.Reset(time.Duration(d.vfs.Opt.DirCacheTime * 2)) ++ return nil ++} ++ + // jsonErrorf formats the string according to a format specifier and + // returns the resulting string as a JSON blob with key "error" + func jsonErrorf(format string, a ...any) []byte { +diff --git a/vfs/dir_test.go b/vfs/dir_test.go +index 3d7ab8c..81c4b11 100644 +--- a/vfs/dir_test.go ++++ b/vfs/dir_test.go +@@ -3,11 +3,14 @@ package vfs + import ( + "context" + "encoding/json" ++ "errors" + "fmt" + "os" + "runtime" + "slices" + "sort" ++ "sync" ++ "sync/atomic" + "testing" + "time" + "unsafe" +@@ -19,6 +22,39 @@ import ( + "github.com/stretchr/testify/require" + ) + ++type controlledListFs struct { ++ fs.Fs ++ block atomic.Bool ++ fail atomic.Bool ++ started chan struct{} ++ release chan struct{} ++ once sync.Once ++} ++ ++func (f *controlledListFs) List(ctx context.Context, dir string) (fs.DirEntries, error) { ++ if f.block.Load() { ++ f.once.Do(func() { close(f.started) }) ++ select { ++ case <-f.release: ++ case <-ctx.Done(): ++ return nil, ctx.Err() ++ } ++ } ++ if f.fail.Load() { ++ return nil, errors.New("injected list failure") ++ } ++ return f.Fs.List(ctx, dir) ++} ++ ++func nodeNames(nodes Nodes) []string { ++ names := make([]string, 0, len(nodes)) ++ for _, node := range nodes { ++ names = append(names, node.Name()) ++ } ++ sort.Strings(names) ++ return names ++} ++ + func dirCreate(t *testing.T) (r *fstest.Run, vfs *VFS, dir *Dir, item fstest.Item) { + r, vfs = newTestVFS(t) + +@@ -83,6 +119,306 @@ func TestDirMethods(t *testing.T) { + assert.Equal(t, vfs, dir.VFS()) + } + ++func TestDirReadDirSWRKeepsSnapshotReadable(t *testing.T) { ++ r := fstest.NewRun(t) ++ r.WriteObject(context.Background(), "old.txt", "old", t1) ++ f := &controlledListFs{ ++ Fs: r.Fremote, ++ started: make(chan struct{}), ++ release: make(chan struct{}), ++ } ++ vfs := New(context.Background(), f, nil) ++ t.Cleanup(func() { cleanupVFS(t, vfs) }) ++ root, err := vfs.Root() ++ require.NoError(t, err) ++ initial, err := root.ReadDirAll() ++ require.NoError(t, err) ++ assert.Equal(t, []string{"old.txt"}, nodeNames(initial)) ++ ++ r.WriteObject(context.Background(), "new.txt", "new", t2) ++ f.block.Store(true) ++ done := make(chan error, 1) ++ go func() { done <- root.readDirSWR(context.Background()) }() ++ select { ++ case <-f.started: ++ case <-time.After(5 * time.Second): ++ t.Fatal("background listing did not start") ++ } ++ ++ readDone := make(chan Nodes, 1) ++ go func() { ++ nodes, readErr := root.ReadDirAll() ++ if readErr != nil { ++ readDone <- nil ++ return ++ } ++ readDone <- nodes ++ }() ++ select { ++ case nodes := <-readDone: ++ assert.Equal(t, []string{"old.txt"}, nodeNames(nodes)) ++ case <-time.After(500 * time.Millisecond): ++ t.Fatal("cached directory read blocked behind background listing") ++ } ++ ++ close(f.release) ++ require.NoError(t, <-done) ++ updated, err := root.ReadDirAll() ++ require.NoError(t, err) ++ assert.Equal(t, []string{"new.txt", "old.txt"}, nodeNames(updated)) ++} ++ ++func TestDirReadDirSWRFailurePreservesSnapshot(t *testing.T) { ++ r := fstest.NewRun(t) ++ r.WriteObject(context.Background(), "old.txt", "old", t1) ++ f := &controlledListFs{ ++ Fs: r.Fremote, ++ started: make(chan struct{}), ++ release: make(chan struct{}), ++ } ++ vfs := New(context.Background(), f, nil) ++ t.Cleanup(func() { cleanupVFS(t, vfs) }) ++ root, err := vfs.Root() ++ require.NoError(t, err) ++ _, err = root.ReadDirAll() ++ require.NoError(t, err) ++ root.mu.RLock() ++ readBefore := root.read ++ oldNode := root.items["old.txt"] ++ root.mu.RUnlock() ++ ++ f.fail.Store(true) ++ err = root.readDirSWR(context.Background()) ++ require.EqualError(t, err, "injected list failure") ++ root.mu.RLock() ++ assert.Equal(t, readBefore, root.read) ++ assert.Same(t, oldNode, root.items["old.txt"]) ++ root.mu.RUnlock() ++ nodes, err := root.ReadDirAll() ++ require.NoError(t, err) ++ assert.Equal(t, []string{"old.txt"}, nodeNames(nodes)) ++} ++ ++func TestDirReadDirSWRCancellationReleasesRefresh(t *testing.T) { ++ r := fstest.NewRun(t) ++ r.WriteObject(context.Background(), "old.txt", "old", t1) ++ f := &controlledListFs{ ++ Fs: r.Fremote, ++ started: make(chan struct{}), ++ release: make(chan struct{}), ++ } ++ vfs := New(context.Background(), f, nil) ++ t.Cleanup(func() { cleanupVFS(t, vfs) }) ++ root, err := vfs.Root() ++ require.NoError(t, err) ++ _, err = root.ReadDirAll() ++ require.NoError(t, err) ++ ++ ctx, cancel := context.WithCancel(context.Background()) ++ f.block.Store(true) ++ done := make(chan error, 1) ++ go func() { done <- root.readDirSWR(ctx) }() ++ select { ++ case <-f.started: ++ case <-time.After(5 * time.Second): ++ t.Fatal("background listing did not start") ++ } ++ cancel() ++ require.ErrorIs(t, <-done, context.Canceled) ++ ++ f.block.Store(false) ++ require.NoError(t, root.readDirSWR(context.Background())) ++} ++ ++func TestDirReadDirSWRDiscardsInvalidatedResult(t *testing.T) { ++ r := fstest.NewRun(t) ++ r.WriteObject(context.Background(), "old.txt", "old", t1) ++ f := &controlledListFs{ ++ Fs: r.Fremote, ++ started: make(chan struct{}), ++ release: make(chan struct{}), ++ } ++ vfs := New(context.Background(), f, nil) ++ t.Cleanup(func() { cleanupVFS(t, vfs) }) ++ root, err := vfs.Root() ++ require.NoError(t, err) ++ _, err = root.ReadDirAll() ++ require.NoError(t, err) ++ r.WriteObject(context.Background(), "new.txt", "new", t2) ++ ++ f.block.Store(true) ++ done := make(chan error, 1) ++ go func() { done <- root.readDirSWR(context.Background()) }() ++ select { ++ case <-f.started: ++ case <-time.After(5 * time.Second): ++ t.Fatal("background listing did not start") ++ } ++ root.invalidateDir("") ++ close(f.release) ++ require.EqualError(t, <-done, "directory cache changed during refresh of \"\"") ++ ++ root.mu.RLock() ++ assert.True(t, root.read.IsZero()) ++ assert.NotNil(t, root.items["old.txt"]) ++ assert.Nil(t, root.items["new.txt"]) ++ root.mu.RUnlock() ++} ++ ++func TestDirReadDirSWRDiscardsConcurrentVirtualDelete(t *testing.T) { ++ r := fstest.NewRun(t) ++ r.WriteObject(context.Background(), "old.txt", "old", t1) ++ f := &controlledListFs{ ++ Fs: r.Fremote, ++ started: make(chan struct{}), ++ release: make(chan struct{}), ++ } ++ vfs := New(context.Background(), f, nil) ++ t.Cleanup(func() { cleanupVFS(t, vfs) }) ++ root, err := vfs.Root() ++ require.NoError(t, err) ++ _, err = root.ReadDirAll() ++ require.NoError(t, err) ++ r.WriteObject(context.Background(), "new.txt", "new", t2) ++ ++ f.block.Store(true) ++ done := make(chan error, 1) ++ go func() { done <- root.readDirSWR(context.Background()) }() ++ select { ++ case <-f.started: ++ case <-time.After(5 * time.Second): ++ t.Fatal("background listing did not start") ++ } ++ root.DelVirtual("old.txt") ++ close(f.release) ++ require.EqualError(t, <-done, "directory cache changed during refresh of \"\"") ++ ++ root.mu.RLock() ++ assert.NotZero(t, root.read) ++ assert.Nil(t, root.items["old.txt"]) ++ assert.Nil(t, root.items["new.txt"]) ++ root.mu.RUnlock() ++} ++ ++func TestDirReadDirSWRSerializesCacheCleanup(t *testing.T) { ++ r := fstest.NewRun(t) ++ r.WriteObject(context.Background(), "old.txt", "old", t1) ++ f := &controlledListFs{ ++ Fs: r.Fremote, ++ started: make(chan struct{}), ++ release: make(chan struct{}), ++ } ++ vfs := New(context.Background(), f, nil) ++ t.Cleanup(func() { cleanupVFS(t, vfs) }) ++ root, err := vfs.Root() ++ require.NoError(t, err) ++ _, err = root.ReadDirAll() ++ require.NoError(t, err) ++ r.WriteObject(context.Background(), "new.txt", "new", t2) ++ root.mu.Lock() ++ root.read = time.Now().Add(-3 * time.Duration(vfs.Opt.DirCacheTime)) ++ root.mu.Unlock() ++ ++ f.block.Store(true) ++ refreshDone := make(chan error, 1) ++ go func() { refreshDone <- root.readDirSWR(context.Background()) }() ++ select { ++ case <-f.started: ++ case <-time.After(5 * time.Second): ++ t.Fatal("background listing did not start") ++ } ++ cleanupDone := make(chan struct{}) ++ go func() { ++ root.cacheCleanup() ++ close(cleanupDone) ++ }() ++ select { ++ case <-cleanupDone: ++ t.Fatal("cache cleanup did not serialize with background refresh") ++ case <-time.After(200 * time.Millisecond): ++ } ++ nodes, err := root.ReadDirAll() ++ require.NoError(t, err) ++ assert.Equal(t, []string{"old.txt"}, nodeNames(nodes)) ++ ++ close(f.release) ++ require.NoError(t, <-refreshDone) ++ select { ++ case <-cleanupDone: ++ case <-time.After(5 * time.Second): ++ t.Fatal("cache cleanup did not resume after background refresh") ++ } ++ updated, err := root.ReadDirAll() ++ require.NoError(t, err) ++ assert.Equal(t, []string{"new.txt", "old.txt"}, nodeNames(updated)) ++} ++ ++func TestDirReadDirSWRSerializesAncestorCacheCleanup(t *testing.T) { ++ r := fstest.NewRun(t) ++ r.WriteObject(context.Background(), "dir/old.txt", "old", t1) ++ f := &controlledListFs{ ++ Fs: r.Fremote, ++ started: make(chan struct{}), ++ release: make(chan struct{}), ++ } ++ vfs := New(context.Background(), f, nil) ++ t.Cleanup(func() { cleanupVFS(t, vfs) }) ++ _, err := vfs.Stat("dir/old.txt") ++ require.NoError(t, err) ++ root, err := vfs.Root() ++ require.NoError(t, err) ++ dir := root.cachedDir("dir") ++ require.NotNil(t, dir) ++ r.WriteObject(context.Background(), "dir/new.txt", "new", t2) ++ ++ f.block.Store(true) ++ refreshDone := make(chan error, 1) ++ go func() { refreshDone <- dir.readDirSWR(context.Background()) }() ++ select { ++ case <-f.started: ++ case <-time.After(5 * time.Second): ++ t.Fatal("child background listing did not start") ++ } ++ root.refreshMu.Lock() ++ defer root.refreshMu.Unlock() ++ cleanupDone := make(chan struct{}) ++ go func() { ++ root.forgetAllForCleanup() ++ close(cleanupDone) ++ }() ++ select { ++ case <-cleanupDone: ++ t.Fatal("ancestor cleanup did not wait for child background refresh") ++ case <-time.After(200 * time.Millisecond): ++ } ++ parentReadDone := make(chan Nodes, 1) ++ go func() { ++ nodes, readErr := root.ReadDirAll() ++ if readErr != nil { ++ parentReadDone <- nil ++ return ++ } ++ parentReadDone <- nodes ++ }() ++ select { ++ case nodes := <-parentReadDone: ++ assert.Equal(t, []string{"dir"}, nodeNames(nodes)) ++ case <-time.After(200 * time.Millisecond): ++ t.Fatal("ancestor read blocked behind child background refresh") ++ } ++ nodes, err := dir.ReadDirAll() ++ require.NoError(t, err) ++ assert.Equal(t, []string{"old.txt"}, nodeNames(nodes)) ++ ++ close(f.release) ++ require.NoError(t, <-refreshDone) ++ select { ++ case <-cleanupDone: ++ case <-time.After(5 * time.Second): ++ t.Fatal("ancestor cleanup did not resume after child refresh") ++ } ++} ++ + func TestDirForgetAll(t *testing.T) { + _, vfs, dir, file1 := dirCreate(t) + +diff --git a/vfs/rc.go b/vfs/rc.go +index 02aff0c..63a7050 100644 +--- a/vfs/rc.go ++++ b/vfs/rc.go +@@ -75,6 +75,63 @@ If the parameter recursive=true is given the whole directory tree + will get refreshed. This refresh will use --fast-list if enabled. + ` + getVFSHelp, + }) ++ rc.Add(rc.Call{ ++ Path: "vfs/refresh-swr", ++ Fn: rcRefreshSWR, ++ Title: "Refresh a cached directory without blocking readers.", ++ Help: ` ++This refreshes cached directories while continuing to serve their previous ++contents. Remote listing happens without holding the directory cache lock; ++successful results are merged under a short lock and failures leave the old ++snapshot untouched. ++ ++Only directories already present in the VFS cache can be refreshed. If no ++paths are passed, the root directory is refreshed. Otherwise pass directories ++as dir=path, dir2=other/path, and so on. ++` + getVFSHelp, ++ }) ++} ++ ++func rcRefreshSWR(ctx context.Context, in rc.Params) (out rc.Params, err error) { ++ vfs, err := getVFS(in) ++ if err != nil { ++ return nil, err ++ } ++ root, err := vfs.Root() ++ if err != nil { ++ return nil, err ++ } ++ ++ result := map[string]string{} ++ refresh := func(dirPath string) { ++ dirPath = strings.Trim(dirPath, "/") ++ dir := root.cachedDir(dirPath) ++ if dir == nil { ++ result[dirPath] = ENOENT.Error() ++ return ++ } ++ if refreshErr := dir.readDirSWR(ctx); refreshErr != nil { ++ result[dirPath] = refreshErr.Error() ++ } else { ++ result[dirPath] = "OK" ++ } ++ } ++ ++ if len(in) == 0 { ++ refresh("") ++ } else { ++ for key, value := range in { ++ dirPath, ok := value.(string) ++ if !ok { ++ return nil, fmt.Errorf("value must be string %q=%v", key, value) ++ } ++ if !strings.HasPrefix(key, "dir") { ++ return nil, fmt.Errorf("unknown key %q", key) ++ } ++ refresh(dirPath) ++ } ++ } ++ return rc.Params{"result": result}, nil + } + + func rcRefresh(ctx context.Context, in rc.Params) (out rc.Params, err error) { +diff --git a/vfs/rc_test.go b/vfs/rc_test.go +index d2105d0..6eb1d37 100644 +--- a/vfs/rc_test.go ++++ b/vfs/rc_test.go +@@ -89,6 +89,29 @@ func TestRcRefresh(t *testing.T) { + // FIXME needs more tests + } + ++func TestRcRefreshSWR(t *testing.T) { ++ r, vfs, call := rcNewRun(t, "vfs/refresh-swr") ++ _, _ = r, vfs ++ out, err := call.Fn(context.Background(), rc.Params{"fs": fs.ConfigString(r.Fremote)}) ++ require.NoError(t, err) ++ assert.Equal(t, rc.Params{ ++ "result": map[string]string{"": "OK"}, ++ }, out) ++} ++ ++func TestRcRefreshSWRRejectsUncachedPath(t *testing.T) { ++ r, vfs, call := rcNewRun(t, "vfs/refresh-swr") ++ _, _ = r, vfs ++ out, err := call.Fn(context.Background(), rc.Params{ ++ "fs": fs.ConfigString(r.Fremote), ++ "dir": "not-cached", ++ }) ++ require.NoError(t, err) ++ assert.Equal(t, rc.Params{ ++ "result": map[string]string{"not-cached": ENOENT.Error()}, ++ }, out) ++} ++ + func TestRcPollInterval(t *testing.T) { + r, vfs, call := rcNewRun(t, "vfs/poll-interval") + _ = vfs diff --git a/distribution/rclone/source.json b/distribution/rclone/source.json new file mode 100644 index 00000000..3d35ba4b --- /dev/null +++ b/distribution/rclone/source.json @@ -0,0 +1,11 @@ +{ + "project": "rclone", + "repository": "https://github.com/rclone/rclone", + "upstream_tag": "v1.74.4", + "upstream_commit": "5bc93a2a7ab0ebd0a11352bc4968eabeffb18027", + "go_version": "1.25.0", + "build_version": "v1.74.4-ssh-mountmate.1", + "patch": "rclone-v1.74.4-swr.patch", + "patch_sha256": "ebdf3b6d3043526a29efd285768a829e2291275d6fbd4c4836861c665f440334", + "added_rc_capability": "vfs/refresh-swr" +} diff --git a/docs/development-roadmap.md b/docs/development-roadmap.md index bd880150..29c4fe44 100644 --- a/docs/development-roadmap.md +++ b/docs/development-roadmap.md @@ -804,6 +804,18 @@ Cross-platform considerations: - Compiled the complete x64 and ARM64 installer scripts locally with the pinned official Inno Setup 6.4.3 compiler before starting another release workflow. +### 2026-07-22 - v0.6.0-alpha.6 cache-preserving navigation refresh + +- Audited rclone v1.74.4 and confirmed ordinary nonrecursive `vfs/refresh` holds the directory mutex + during remote listing, so merely removing `vfs/forget` still blocks Explorer reads. +- Added the isolated `vfs/refresh-swr` capability: it lists outside the directory lock, serves the + previous snapshot throughout, briefly merges successful results with existing virtual entries, + and preserves the old cache and freshness timestamp on failure. +- Passive navigation uses only the new capability and does not fall back on older external rclone + binaries. Manual refresh retains its explicit forget, refresh, verified listing, and queue checks. +- Release CI builds the immutable upstream rclone commit with a checksum-pinned repository patch and + pinned Go toolchain for all six native targets. + ### 2026-07-15 - Preserved `issue-1-reply.md` and the five user-owned screenshots as untracked files. diff --git a/licenses/RUST-THIRD-PARTY.txt b/licenses/RUST-THIRD-PARTY.txt index 36cc0ed8..953a5c72 100644 --- a/licenses/RUST-THIRD-PARTY.txt +++ b/licenses/RUST-THIRD-PARTY.txt @@ -13329,9 +13329,9 @@ DEALINGS IN THE SOFTWARE. MIT License Used by: -- ssh-mountmate 0.6.0-alpha.5 (https://github.com/Stardust0831/ssh-mountmate) -- mountmate-core 0.6.0-alpha.5 (https://github.com/Stardust0831/ssh-mountmate) -- mountmate-platform 0.6.0-alpha.5 (https://github.com/Stardust0831/ssh-mountmate) +- ssh-mountmate 0.6.0-alpha.6 (https://github.com/Stardust0831/ssh-mountmate) +- mountmate-core 0.6.0-alpha.6 (https://github.com/Stardust0831/ssh-mountmate) +- mountmate-platform 0.6.0-alpha.6 (https://github.com/Stardust0831/ssh-mountmate) - block2 0.5.1 (https://github.com/madsmtm/objc2) - block2 0.6.2 (https://github.com/madsmtm/objc2) - block 0.1.6 (http://github.com/SSheldon/rust-block) diff --git a/release-notes/v0.6.0-alpha.6.md b/release-notes/v0.6.0-alpha.6.md new file mode 100644 index 00000000..cc9ea4e2 --- /dev/null +++ b/release-notes/v0.6.0-alpha.6.md @@ -0,0 +1,21 @@ +# SSH MountMate v0.6.0-alpha.6 + +This prerelease corrects installed-edition background refresh behavior for file-manager navigation. + +## Changes + +- Explorer navigation refresh now keeps the previous directory snapshot readable while rclone lists + the remote directory in the background. +- A successful background listing is merged under a short cache lock. A failed, cancelled, or stale + listing leaves the previous snapshot intact and does not overwrite a newer local invalidation. +- Manual refresh remains an explicit cache forget and verified remote refresh, so it continues to + provide the strongest user-requested freshness behavior. +- Release packages now contain the auditable `rclone v1.74.4-ssh-mountmate.1` source build with the + cache-preserving RC capability. The upstream commit, patch digest, and Go toolchain are pinned. + +## Upgrade Note + +Existing mounts keep their current rclone process across an application update. Unmount and remount +them once after installing this prerelease to activate cache-preserving navigation refresh. Until +then, or when an explicitly configured external rclone lacks the capability, passive refresh is +safely skipped without invalidating the directory cache. diff --git a/scripts/build-patched-rclone.sh b/scripts/build-patched-rclone.sh new file mode 100644 index 00000000..f066e313 --- /dev/null +++ b/scripts/build-patched-rclone.sh @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + echo "usage: $0 RCLONE_SOURCE OUTPUT [--test]" >&2 + exit 2 +fi + +source_dir=$1 +output=$2 +test_mode=${3:-} +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +patch_file="$repo_root/distribution/rclone/rclone-v1.74.4-swr.patch" +expected_commit=5bc93a2a7ab0ebd0a11352bc4968eabeffb18027 +expected_patch=ebdf3b6d3043526a29efd285768a829e2291275d6fbd4c4836861c665f440334 +build_version=v1.74.4-ssh-mountmate.1 + +test "$(git -C "$source_dir" rev-parse HEAD)" = "$expected_commit" +if command -v sha256sum >/dev/null 2>&1; then + actual_patch=$(sha256sum "$patch_file" | awk '{print $1}') +else + actual_patch=$(shasum -a 256 "$patch_file" | awk '{print $1}') +fi +test "$actual_patch" = "$expected_patch" +test "$(go env GOVERSION)" = go1.25.0 + +git -C "$source_dir" apply --check "$patch_file" +git -C "$source_dir" apply "$patch_file" + +if [ "$test_mode" = "--test" ]; then + (cd "$source_dir" && \ + go test ./vfs -run '^(TestDirReadDirSWR|TestRcRefreshSWR)' -count=1) +elif [ -n "$test_mode" ]; then + echo "unknown option: $test_mode" >&2 + exit 2 +fi + +mkdir -p "$(dirname -- "$output")" +output=$(CDPATH= cd -- "$(dirname -- "$output")" && pwd)/$(basename -- "$output") +(cd "$source_dir" && \ + CGO_ENABLED=0 SOURCE_DATE_EPOCH=1783527537 \ + go build -trimpath -buildvcs=false \ + -ldflags="-s -w -buildid= -X github.com/rclone/rclone/fs.Version=$build_version" \ + -o "$output" .) +version_output=$("$output" version) +printf '%s\n' "$version_output" +printf '%s\n' "$version_output" | grep -F "rclone $build_version" >/dev/null From a83183d9b01de0030fb18373f9f5fb6779eea724 Mon Sep 17 00:00:00 2001 From: Jiacheng Xu <13862180016@163.com> Date: Wed, 22 Jul 2026 03:15:18 +0800 Subject: [PATCH 2/2] fix: build patched rclone with mount support --- .gitattributes | 2 ++ distribution/rclone/source.json | 2 ++ scripts/build-patched-rclone.sh | 31 ++++++++++++++++++++++++++----- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.gitattributes b/.gitattributes index 4ae96d84..14a15ec7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ distribution/SSHMountMate.install-layout text eol=lf +distribution/rclone/*.patch text eol=lf +scripts/*.sh text eol=lf diff --git a/distribution/rclone/source.json b/distribution/rclone/source.json index 3d35ba4b..be32e9da 100644 --- a/distribution/rclone/source.json +++ b/distribution/rclone/source.json @@ -5,6 +5,8 @@ "upstream_commit": "5bc93a2a7ab0ebd0a11352bc4968eabeffb18027", "go_version": "1.25.0", "build_version": "v1.74.4-ssh-mountmate.1", + "build_tags": ["cmount"], + "cgo_policy": "enabled on native macOS builds; disabled on Windows and Linux", "patch": "rclone-v1.74.4-swr.patch", "patch_sha256": "ebdf3b6d3043526a29efd285768a829e2291275d6fbd4c4836861c665f440334", "added_rc_capability": "vfs/refresh-swr" diff --git a/scripts/build-patched-rclone.sh b/scripts/build-patched-rclone.sh index f066e313..60fda798 100644 --- a/scripts/build-patched-rclone.sh +++ b/scripts/build-patched-rclone.sh @@ -15,14 +15,25 @@ expected_commit=5bc93a2a7ab0ebd0a11352bc4968eabeffb18027 expected_patch=ebdf3b6d3043526a29efd285768a829e2291275d6fbd4c4836861c665f440334 build_version=v1.74.4-ssh-mountmate.1 -test "$(git -C "$source_dir" rev-parse HEAD)" = "$expected_commit" +actual_commit=$(git -C "$source_dir" rev-parse HEAD | tr -d '\r') +if [ "$actual_commit" != "$expected_commit" ]; then + echo "unexpected rclone commit: expected $expected_commit, got $actual_commit" >&2 + exit 1 +fi if command -v sha256sum >/dev/null 2>&1; then actual_patch=$(sha256sum "$patch_file" | awk '{print $1}') else actual_patch=$(shasum -a 256 "$patch_file" | awk '{print $1}') fi -test "$actual_patch" = "$expected_patch" -test "$(go env GOVERSION)" = go1.25.0 +if [ "$actual_patch" != "$expected_patch" ]; then + echo "rclone patch SHA-256 mismatch: expected $expected_patch, got $actual_patch" >&2 + exit 1 +fi +go_version=$(go env GOVERSION | tr -d '\r') +if [ "$go_version" != go1.25.0 ]; then + echo "unexpected Go version: expected go1.25.0, got $go_version" >&2 + exit 1 +fi git -C "$source_dir" apply --check "$patch_file" git -C "$source_dir" apply "$patch_file" @@ -37,9 +48,19 @@ fi mkdir -p "$(dirname -- "$output")" output=$(CDPATH= cd -- "$(dirname -- "$output")" && pwd)/$(basename -- "$output") +go_os=$(go env GOOS | tr -d '\r') +cgo_enabled=0 +cgo_cflags= +cgo_ldflags= +if [ "$go_os" = darwin ]; then + cgo_enabled=1 + cgo_cflags=-I/usr/local/include + cgo_ldflags=-L/usr/local/lib +fi (cd "$source_dir" && \ - CGO_ENABLED=0 SOURCE_DATE_EPOCH=1783527537 \ - go build -trimpath -buildvcs=false \ + CGO_ENABLED="$cgo_enabled" CGO_CFLAGS="$cgo_cflags" CGO_LDFLAGS="$cgo_ldflags" \ + SOURCE_DATE_EPOCH=1783527537 \ + go build -tags cmount -trimpath -buildvcs=false \ -ldflags="-s -w -buildid= -X github.com/rclone/rclone/fs.Version=$build_version" \ -o "$output" .) version_output=$("$output" version)