diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cbfa65..28fdefc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -120,7 +120,6 @@ jobs: rclone_arch: arm64 rclone_sha256: 97685285c9ad6a0cf17d5844115d2a67245af6444db672187074bd9c358de419 runs-on: ${{ matrix.os }} - steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -453,6 +452,47 @@ jobs: Remove-Item $shipping -Recurse -Force -ErrorAction SilentlyContinue Expand-Archive "release/${{ matrix.asset }}.zip" $shipping + - name: Install pinned Inno Setup compiler + if: runner.os == 'Windows' && contains(inputs.tag || github.ref_name, '-') + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $expected = '6.4.3' + $iscc = Get-Command iscc.exe -ErrorAction SilentlyContinue + if (-not $iscc) { + if (-not (Get-Command choco.exe -ErrorAction SilentlyContinue)) { + throw 'Neither ISCC.exe nor Chocolatey is available for the pinned Inno Setup toolchain' + } + choco install innosetup --version $expected --yes --no-progress + $iscc = Get-Command iscc.exe -ErrorAction SilentlyContinue + if (-not $iscc) { + $iscc = Get-ChildItem "$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe" -ErrorAction SilentlyContinue | + Select-Object -First 1 + } + } + if (-not $iscc) { throw 'ISCC.exe was not found after installing pinned Inno Setup' } + $version = (Get-Item $iscc.Source).VersionInfo.FileVersion + if (-not $version.StartsWith($expected)) { + throw "Unexpected Inno Setup compiler version '$version'; expected $expected" + } + "ISCC_PATH=$($iscc.Source)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Build unsigned per-user installer for prerelease + if: runner.os == 'Windows' && contains(inputs.tag || github.ref_name, '-') + shell: pwsh + env: + RELEASE_VERSION: ${{ inputs.tag || github.ref_name }} + run: | + $version = $env:RELEASE_VERSION.TrimStart('v') + $arch = if ('${{ matrix.asset }}' -like '*-arm64') { 'arm64' } else { 'x64' } + & ./distribution/windows-installer/build-installer.ps1 ` + -InputExe "target/onefile/SSHMountMate.exe" ` + -OutputDir "release" ` + -AppVersion $version ` + -Arch $arch ` + -IsccPath ($env:ISCC_PATH ?? 'iscc.exe') + if ($LASTEXITCODE -ne 0) { throw 'per-user installer build failed' } + - name: Exercise packaged update and rollback if: runner.os == 'Linux' shell: bash @@ -515,10 +555,17 @@ jobs: path: release/${{ matrix.asset }}.zip if-no-files-found: error + - name: Upload unsigned prerelease installer + if: runner.os == 'Windows' && contains(inputs.tag || github.ref_name, '-') + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ matrix.asset }}-setup + path: release/${{ matrix.asset }}-setup.exe + if-no-files-found: error + release-set: needs: build runs-on: ubuntu-latest - steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -530,6 +577,8 @@ jobs: merge-multiple: true - name: Validate release set shell: bash + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} run: | set -euo pipefail expected=( @@ -544,7 +593,17 @@ jobs: test -s "release-assets/${asset}" done test "$(find release-assets -maxdepth 1 -name 'SSHMountMate-*.zip' | wc -l)" -eq 6 - (cd release-assets && sha256sum SSHMountMate-*.zip > SHA256SUMS.txt) + version="${RELEASE_TAG#v}" + setup_count="$(find release-assets -maxdepth 1 -type f -name 'SSHMountMate-windows-*-setup.exe' | wc -l)" + case "${version}" in + *-*) + test "${setup_count}" -eq 2 + test -s release-assets/SSHMountMate-windows-x64-setup.exe + test -s release-assets/SSHMountMate-windows-arm64-setup.exe + ;; + *) test "${setup_count}" -eq 0 ;; + esac + (cd release-assets && sha256sum SSHMountMate-* > SHA256SUMS.txt) (cd release-assets && sha256sum --check SHA256SUMS.txt) - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 diff --git a/.github/workflows/rust-rewrite.yml b/.github/workflows/rust-rewrite.yml index b11a06e..e0e18ae 100644 --- a/.github/workflows/rust-rewrite.yml +++ b/.github/workflows/rust-rewrite.yml @@ -41,6 +41,9 @@ jobs: - run: cargo fmt --all --check - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - run: cargo test --workspace --all-features + - name: Validate Windows installer contract + shell: bash + run: bash tests/windows_installer_static.sh - name: Verify live stable and prerelease update channels env: GITHUB_TOKEN: ${{ github.token }} diff --git a/Cargo.lock b/Cargo.lock index ee29724..8d543a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2982,7 +2982,7 @@ dependencies = [ [[package]] name = "mountmate-core" -version = "0.4.4-alpha.4" +version = "0.6.0-alpha.1" dependencies = [ "base64", "configparser", @@ -3013,8 +3013,9 @@ dependencies = [ [[package]] name = "mountmate-platform" -version = "0.4.4-alpha.4" +version = "0.6.0-alpha.1" dependencies = [ + "async-channel 2.5.0", "mountmate-core", "notify-rust", "objc2 0.6.4", @@ -3023,6 +3024,7 @@ dependencies = [ "plist", "tempfile", "thiserror 2.0.18", + "url", "windows 0.61.3", "windows-registry", "windows-sys 0.61.2", @@ -5005,7 +5007,7 @@ dependencies = [ [[package]] name = "ssh-mountmate" -version = "0.4.4-alpha.4" +version = "0.6.0-alpha.1" dependencies = [ "async-channel 2.5.0", "dark-light", diff --git a/Cargo.toml b/Cargo.toml index 1add9d8..c34b271 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.4.4-alpha.4" +version = "0.6.0-alpha.1" edition = "2024" rust-version = "1.88" license = "MIT" diff --git a/README.md b/README.md index ed0e574..a25b054 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ It uses rclone for the actual mount operation and provides a small GUI around th - Check for rclone and platform mount dependencies. - Bundle and verify the official rclone binary in release builds. - Configure global rclone VFS cache options in the GUI. -- Show mount status, capacity usage, logs, and common actions per connection. +- 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. - Verify remote directory contents on refresh and expose refresh/transfer actions from connection-card context menus. - Mount or unmount all saved connections from the main window. @@ -334,6 +334,12 @@ Refresh clears the VFS directory cache, actively reloads the requested directory Right-click a connection card for Open, Refresh, Transfers, and Log actions. Settings can register Refresh and Transfers commands in Windows Explorer, macOS Finder Quick Actions, and Nautilus, Nemo, or KDE file managers on Linux. The commands point back to the same SSH MountMate executable; no helper program is installed. A short-lived file-manager process forwards its request to the running app over authenticated loopback IPC and exits. +The Windows prerelease also provides unsigned per-user x64 and ARM64 setup packages. The installed edition lives under `%LOCALAPPDATA%\Programs\SSH MountMate`, does not require elevation, and can observe Explorer navigation into mounted directories. It schedules cache-only VFS refreshes in the background, with bounded concurrency and deduplication, so opening a directory is never blocked. Portable ZIP builds remain available and do not enable passive Explorer observation. Because the setup executables are unsigned, verify `SHA256SUMS.txt` before running them. + +## Capacity And Lustre Quotas + +Mounted cards show used and total capacity. On Lustre paths, the Quota action opens project, current-user, and primary-group block and inode quota details, including soft/hard limits and grace state. The probe uses one authenticated SSH invocation and keeps successful scopes visible when another scope is unavailable. Missing `lfs`, non-Lustre filesystems, missing project IDs, and permission errors are reported without affecting the mount. Interactive/shared SSH connections reuse their verified connector instead of opening a second authentication prompt. + The Rust application keeps a native system-tray icon on Windows, a menu-bar item on macOS, and an AppIndicator on supported Linux desktops. Closing the main window hides it without stopping mounts or transfer monitoring. The tray menu can restore the main window, open Transfers, mount or unmount all connections, and explicitly exit the interface. Exit asks for confirmation when uploads are active or cloud state is unknown; rclone mount processes remain independent of the GUI. ## Capacity Display diff --git a/README.zh-CN.md b/README.zh-CN.md index 5b25347..598d1cf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -318,7 +318,9 @@ SSH MountMate 会尽量启用 rclone 的 host key 校验。 ## 容量显示 -对已挂载连接,SSH MountMate 会在连接卡片上显示已用容量和总容量。对于 Lustre 路径,程序会优先用 `lfs project -d` 读取远端目录的 project ID,再用 `lfs quota -p` 读取 project quota。如果路径不在 Lustre 上、远端没有 `lfs`,或该 project 没有非零 hard block limit,则回退使用 `rclone about`;当 SSH 配置支持非交互登录时,还会继续尝试远端 `df -Pk`。 +对已挂载连接,SSH MountMate 会在连接卡片上显示已用容量和总容量。对于 Lustre 路径,卡片上的“配额”按钮会展示 project、当前用户和主组的空间及 inode 配额,包括软限制、硬限制和宽限期状态。程序通过一次已认证的 SSH 调用查询全部范围;其中一个范围不可用时,其他成功结果仍会保留。远端缺少 `lfs`、路径不在 Lustre 上、project ID 缺失或权限不足时会明确显示原因,但不会影响挂载。交互式共享 SSH 会复用已验证的连接,不会另开认证提示。 + +Windows 预发布版还提供未签名的 x64 和 ARM64 每用户安装包。安装版位于 `%LOCALAPPDATA%\Programs\SSH MountMate`,不需要管理员权限,并可感知资源管理器进入已挂载目录的行为。缓存刷新在后台按有限并发和去重规则异步执行,不会阻塞目录打开。便携 ZIP 继续保留,并且不会启用这种被动感知。安装包尚未签名,运行前请核对 `SHA256SUMS.txt`。 ## 设置 diff --git a/crates/mountmate-app/src/cli.rs b/crates/mountmate-app/src/cli.rs index 5c53223..9ec5d19 100644 --- a/crates/mountmate-app/src/cli.rs +++ b/crates/mountmate-app/src/cli.rs @@ -23,6 +23,11 @@ pub(crate) enum LaunchAction { UnregisterFileManagerMenu, RegisterLoginStartup, UnregisterLoginStartup, + InstallerCheckVersion { + requested: String, + recorded: String, + }, + InstallerUninstallPreflight, Help, Version, Licenses, @@ -56,6 +61,7 @@ pub(crate) fn parse(arguments: impl IntoIterator) -> Result) -> Result Some(LaunchAction::RegisterLoginStartup), "--unregister-login-startup" => Some(LaunchAction::UnregisterLoginStartup), + "--installer-check-version" => { + let requested = next_value(&arguments, &mut index, argument)?; + Some(LaunchAction::InstallerCheckVersion { + requested, + recorded: String::new(), + }) + } + "--installer-recorded-version" => { + set_once( + &mut installer_recorded_version, + next_value(&arguments, &mut index, argument)?, + argument, + )?; + None + } + "--installer-uninstall-preflight" => Some(LaunchAction::InstallerUninstallPreflight), "--show-main" => Some(LaunchAction::Gui { command: AppCommand::ShowMain, update_health: None, @@ -158,6 +180,19 @@ pub(crate) fn parse(arguments: impl IntoIterator) -> Result { + if recorded.is_empty() { + *recorded = installer_recorded_version.ok_or_else(|| { + "--installer-check-version requires a recorded version".to_owned() + })?; + } + if update_helper_token.is_some() + || update_health_marker.is_some() + || update_health_token.is_some() + { + return Err("internal update arguments require their matching command".into()); + } + } LaunchAction::RunUpdateHelper(authorization) => { authorization.token = update_helper_token .ok_or_else(|| "--run-update-helper requires --update-helper-token".to_owned())?; @@ -183,7 +218,8 @@ pub(crate) fn parse(arguments: impl IntoIterator) -> Result { - if update_helper_token.is_some() + if installer_recorded_version.is_some() + || update_helper_token.is_some() || update_health_marker.is_some() || update_health_token.is_some() { @@ -331,6 +367,23 @@ mod tests { parse(args(&["--register-login-startup"])).unwrap(), LaunchAction::RegisterLoginStartup ); + assert_eq!( + parse(args(&[ + "--installer-check-version", + "0.6.0-alpha.1", + "--installer-recorded-version", + "0.6.0-alpha.1", + ])) + .unwrap(), + LaunchAction::InstallerCheckVersion { + requested: "0.6.0-alpha.1".into(), + recorded: "0.6.0-alpha.1".into(), + } + ); + assert_eq!( + parse(args(&["--installer-uninstall-preflight"])).unwrap(), + LaunchAction::InstallerUninstallPreflight + ); } #[test] @@ -378,6 +431,16 @@ mod tests { assert!(parse(args(&["--mount-all", "--show-main"])).is_err()); assert!(parse(args(&["--relative-dir", "folder"])).is_err()); assert!(parse(args(&["--unknown"])).is_err()); + assert!(parse(args(&["--installer-check-version", "0.6.0-alpha.1"])).is_err()); + assert!( + parse(args(&[ + "--installer-check-version", + "0.6.0-alpha.1", + "0.6.0-alpha.1", + ])) + .is_err() + ); + assert!(parse(args(&["--installer-recorded-version", "0.6.0-alpha.1"])).is_err()); } #[test] @@ -390,6 +453,7 @@ mod tests { assert!(help().contains("\n -V, --version")); assert!(!help().contains("update-helper")); assert!(!help().contains("update-health")); + assert!(!help().contains("installer-check-version")); } #[test] diff --git a/crates/mountmate-app/src/i18n.rs b/crates/mountmate-app/src/i18n.rs index 566b73e..6063168 100644 --- a/crates/mountmate-app/src/i18n.rs +++ b/crates/mountmate-app/src/i18n.rs @@ -411,6 +411,11 @@ pub(crate) enum TextKey { MountAll, MountConnectionForTransfers, Mountpoint, + NavigationRefresh, + NavigationRefreshActive, + NavigationRefreshDisabled, + NavigationRefreshHelp, + NavigationRefreshUnavailable, Name, NewConnection, NoMountedConnections, @@ -544,6 +549,15 @@ fn english(key: TextKey) -> &'static str { "Mount a connection to inspect its cloud transfer state" } TextKey::Mountpoint => "Mountpoint (Auto by default)", + TextKey::NavigationRefresh => "Refresh Explorer folders automatically", + TextKey::NavigationRefreshActive => "Active (installed Windows edition; best effort)", + TextKey::NavigationRefreshDisabled => "Disabled", + TextKey::NavigationRefreshHelp => { + "Installed Windows edition only. Navigation is observed out of process; refreshes run in the background and never show a modal error." + } + TextKey::NavigationRefreshUnavailable => { + "Unavailable (installed Windows edition required; use Refresh now for an explicit refresh)" + } TextKey::Name => "Name", TextKey::NewConnection => "New connection", TextKey::NoMountedConnections => "No mounted connections", @@ -676,6 +690,15 @@ fn chinese(key: TextKey) -> &'static str { TextKey::MountAll => "全部挂载", TextKey::MountConnectionForTransfers => "挂载连接后可查看其云端传输状态", TextKey::Mountpoint => "挂载点(默认自动选择)", + TextKey::NavigationRefresh => "自动刷新资源管理器文件夹", + TextKey::NavigationRefreshActive => "已启用(仅 Windows 安装版,尽力而为)", + TextKey::NavigationRefreshDisabled => "已禁用", + TextKey::NavigationRefreshHelp => { + "仅适用于 Windows 安装版。导航观察在进程外进行;刷新在后台运行,不会弹出模态错误。" + } + TextKey::NavigationRefreshUnavailable => { + "不可用(需要 Windows 安装版;可使用“立即刷新”执行明确刷新)" + } TextKey::Name => "名称", TextKey::NewConnection => "新建连接", TextKey::NoMountedConnections => "没有已挂载的连接", @@ -777,6 +800,19 @@ mod tests { ); } + #[test] + fn navigation_refresh_settings_have_bilingual_labels() { + for key in [ + TextKey::NavigationRefresh, + TextKey::NavigationRefreshHelp, + TextKey::NavigationRefreshActive, + TextKey::NavigationRefreshUnavailable, + ] { + assert!(!Locale::English.text(key).is_empty()); + assert!(!Locale::Chinese.text(key).is_empty()); + } + } + #[test] fn choice_keeps_typed_value_and_localized_label() { let choice = Locale::Chinese.choice(AuthMethod::Key, "私钥"); diff --git a/crates/mountmate-app/src/main.rs b/crates/mountmate-app/src/main.rs index ee19647..97b430e 100644 --- a/crates/mountmate-app/src/main.rs +++ b/crates/mountmate-app/src/main.rs @@ -23,7 +23,10 @@ use mountmate_core::app_command::{ AppCommand, AppCommandError, AppCommandServer, InstanceLock, running_instance, same_instance_build, send_command_retry, }; -use mountmate_core::capacity::CapacityInfo; +use mountmate_core::capacity::{ + CapacityInfo, CapacitySnapshot, LustreQuotaMetric, LustreQuotaScopeStatus, LustreQuotaSeverity, + LustreQuotaStatus, +}; use mountmate_core::connection::{ ConnectionDraft, ConnectionSource, DraftError, ImportAction, ImportStatus, PreservedSecretState, SecretAction, SshImportPlan, @@ -35,6 +38,7 @@ use mountmate_core::credential::{ replace_verified, rollback_change, }; use mountmate_core::dependency::{DependencyStatus, check_dependencies}; +use mountmate_core::installed::enforce_no_downgrade; use mountmate_core::interactive_ssh::{ InteractiveSshError, InteractiveSshLoginCommand, InteractiveSshSession, }; @@ -42,6 +46,9 @@ use mountmate_core::model::{ MAX_CONNECTION_TAGS, MAX_TAG_CHARS, MAX_VFS_UPLOAD_TRANSFERS, MIN_VFS_UPLOAD_TRANSFERS, }; use mountmate_core::mountpoint::{HOME_MOUNTPOINT_VALUE, preflight_custom_mountpoint}; +use mountmate_core::navigation_refresh::{ + MountIdentity, NavigationEvent, RefreshJob, RefreshScheduler, validated_relative_dir, +}; use mountmate_core::paths::AppPaths; use mountmate_core::plink_binary::resolve_plink; use mountmate_core::process::MountStatus; @@ -65,7 +72,10 @@ use mountmate_core::{ }; #[cfg(windows)] use mountmate_platform::NativeWindowHandle; -use mountmate_platform::{GlobalProgressState, Platform, PlatformIntegration}; +use mountmate_platform::{ + GlobalProgressState, NavigationObserver, Platform, PlatformIntegration, + notify_shell_updated_dir, start_navigation_observer, +}; use mountmate_platform::{ Notification as NativeNotification, NotificationLevel as NativeNotificationLevel, }; @@ -125,6 +135,18 @@ fn run() -> Result<(), String> { } return Ok(()); } + LaunchAction::InstallerCheckVersion { + requested, + recorded, + } => { + enforce_no_downgrade(Some(VERSION), &requested).map_err(|error| error.to_string())?; + enforce_no_downgrade(Some(&recorded), &requested).map_err(|error| error.to_string())?; + return Ok(()); + } + LaunchAction::InstallerUninstallPreflight => { + run_installer_uninstall_preflight()?; + return Ok(()); + } LaunchAction::RclonePath => { let paths = AppPaths::discover(); let resolved = resolve_rclone(&paths, &application_root(), None) @@ -311,6 +333,69 @@ fn run() -> Result<(), String> { } } +fn run_installer_uninstall_preflight() -> Result<(), String> { + let paths = AppPaths::discover(); + storage::load_servers(&paths).map_err(|error| error.to_string())?; + let service = MountService::new(paths.clone(), application_root()); + let mut active = false; + let entries = match fs::read_dir(&paths.state_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Platform + .uninstall_preflight(false) + .map_err(|error| error.to_string()); + } + Err(error) => return Err(error.to_string()), + }; + for entry in entries { + let entry = entry.map_err(|error| { + format!("Could not enumerate SSH MountMate uninstall state: {error}") + })?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + + // app-command.json is IPC state, not a mount record. It is intentionally + // ignored here; every mount-state JSON file must be readable and valid. + if path == paths.app_command_state() { + continue; + } + + let state: MountState = read_json(&path).map_err(|error| { + format!( + "Could not verify SSH MountMate uninstall state in {}: {error}", + path.display() + ) + })?; + let status = service.status(&state.server_id).map_err(|error| { + format!( + "Could not verify SSH MountMate mount state for {}: {error}", + state.server_id + ) + })?; + if matches!(status, MountStatus::Mounted | MountStatus::Starting) { + active = true; + continue; + } + + let snapshot = service + .transfer_snapshot(&state.server_id) + .map_err(|error| { + format!( + "Could not verify SSH MountMate transfer state for {}: {error}", + state.server_id + ) + })?; + if snapshot.queued > 0 || snapshot.uploading > 0 { + active = true; + } + } + Platform + .uninstall_preflight(active) + .map_err(|error| error.to_string()) +} + #[derive(Clone)] struct Bootstrap { paths: AppPaths, @@ -347,6 +432,10 @@ fn tray_stream(subscription: &TraySubscription) -> async_channel::Receiver async_channel::Receiver { + observer.events() +} + fn run_headless(paths: &AppPaths, command: AppCommand) -> Result<(), String> { let settings = storage::load_settings(paths).map_err(|error| error.to_string())?; let servers = storage::load_servers(paths).map_err(|error| error.to_string())?; @@ -558,9 +647,17 @@ struct App { dependency_status: Option, dependency_checking: bool, capacities: HashMap, + lustre_quotas: HashMap, + quota_observed_at: HashMap, + quota_next_probe: HashMap, + quota_dialog: Option, capacity_errors: HashSet, capacity_refreshing: bool, capacity_refresh_pending: bool, + navigation_observer: Option, + navigation_installed: bool, + navigation_status: Option, + navigation_scheduler: RefreshScheduler, } #[derive(Debug, Clone, Copy, Default)] @@ -935,6 +1032,7 @@ struct SettingsDraft { connection_preferences_expanded: bool, auto_show_transfers: bool, auto_check_updates: bool, + navigation_refresh_enabled: bool, language: Language, appearance_mode: AppearanceMode, accent_color: AccentColor, @@ -981,6 +1079,7 @@ impl SettingsDraft { connection_preferences_expanded: false, auto_show_transfers: settings.auto_show_transfers, auto_check_updates: settings.auto_check_updates, + navigation_refresh_enabled: settings.navigation_refresh_enabled, language: Language::from_value(&settings.language), appearance_mode: settings.appearance_mode, accent_color: settings.accent_color, @@ -1035,6 +1134,7 @@ impl SettingsDraft { settings.startup_all = self.startup_all; settings.auto_show_transfers = self.auto_show_transfers; settings.auto_check_updates = self.auto_check_updates; + settings.navigation_refresh_enabled = self.navigation_refresh_enabled; settings.language = self.language.value().into(); settings.appearance_mode = self.appearance_mode; settings.accent_color = self.accent_color; @@ -1047,6 +1147,11 @@ impl SettingsDraft { enum Message { AppCommand(AppCommand), TrayAction(TrayAction), + ExplorerNavigation(NavigationEvent), + NavigationRefreshFinished { + job: RefreshJob, + result: Result<(), String>, + }, TrayTick, InteractiveTick, InteractiveReadinessChecked { @@ -1192,6 +1297,7 @@ enum Message { AutoTransfersChanged(bool), AutoTransfersDecision(rfd::MessageDialogResult), AutoUpdatesChanged(bool), + NavigationRefreshChanged(bool), CheckForUpdates, UpdateChecked { manual: bool, @@ -1203,7 +1309,10 @@ enum Message { CheckDependencies, DependenciesChecked(Result), CapacityTick, - CapacitiesLoaded(Vec<(String, Result, String>)>), + CapacitiesLoaded(Vec<(String, bool, Result, String>)>), + OpenQuotaDetails(String), + CloseQuotaDetails, + RefreshQuotaDetails(String), LanguageChanged(Language), AppearanceModeChanged(AppearanceMode), AccentColorChanged(AccentColor), @@ -1311,6 +1420,12 @@ impl App { window::close_requests().map(Message::CloseRequested), window::close_events().map(Message::WindowClosed), ]; + if let Some(observer) = &self.navigation_observer { + subscriptions.push( + Subscription::run_with(observer.clone(), navigation_stream) + .map(Message::ExplorerNavigation), + ); + } subscriptions.extend(self.interactive_terminals.values().map(|session| { session .terminal @@ -1320,6 +1435,92 @@ impl App { Subscription::batch(subscriptions) } + fn current_mount_identities(&self) -> HashMap { + self.servers + .iter() + .filter(|server| self.mount_statuses.get(&server.id) == Some(&MountStatus::Mounted)) + .filter_map(|server| { + read_json::(&self.paths.state_file(&server.id)) + .ok() + .map(|state| (server.id.clone(), MountIdentity::from_state(&state))) + }) + .collect() + } + + fn handle_explorer_navigation(&mut self, event: NavigationEvent) -> Task { + let now = Instant::now(); + let current = self.current_mount_identities(); + self.navigation_scheduler.cancel_stale(¤t); + for server in &self.servers { + let Some(identity) = current.get(&server.id).cloned() else { + continue; + }; + let Ok(state) = read_json::(&self.paths.state_file(&server.id)) else { + continue; + }; + let Some(relative_dir) = + validated_relative_dir(&event.target, &state.mountpoint, cfg!(windows)) + else { + continue; + }; + self.navigation_scheduler + .enqueue(event.clone(), relative_dir, identity, now); + break; + } + self.start_ready_navigation_refreshes() + } + + fn start_ready_navigation_refreshes(&mut self) -> Task { + let mut tasks = Vec::new(); + while let Some(job) = self.navigation_scheduler.take_ready() { + let service = self.service.clone(); + let refresh_job = job.clone(); + let message_job = job.clone(); + tasks.push(Task::perform( + async move { + tokio::task::spawn_blocking(move || { + service + .refresh_job_cache_only(&refresh_job) + .map_err(|error| error.to_string()) + }) + .await + .unwrap_or_else(|error| Err(error.to_string())) + }, + move |result| Message::NavigationRefreshFinished { + job: message_job, + result, + }, + )); + } + Task::batch(tasks) + } + + fn reconfigure_navigation_observer(&mut self) { + self.navigation_observer = None; + let locale = self.locale(); + if !self.navigation_installed { + self.navigation_status = + Some(locale.text(TextKey::NavigationRefreshUnavailable).into()); + return; + } + if !self.settings.navigation_refresh_enabled { + self.navigation_status = Some(locale.text(TextKey::NavigationRefreshDisabled).into()); + return; + } + match start_navigation_observer() { + Ok(observer) => { + self.navigation_observer = Some(observer); + self.navigation_status = Some(locale.text(TextKey::NavigationRefreshActive).into()); + } + Err(error) => { + self.navigation_status = Some(format!( + "{}: {error}", + locale.text(TextKey::NavigationRefreshUnavailable) + )); + } + } + } + fn new(bootstrap: Bootstrap) -> (Self, Task) { let Bootstrap { paths, @@ -1344,6 +1545,44 @@ impl App { let mut settings = recovered_settings.settings; let locale = Locale::from_preference(Language::from_value(&settings.language), system_locale); + let navigation_installed = std::env::current_exe() + .ok() + .and_then(|executable| { + Platform + .installed_edition_identity(&executable) + .ok() + .flatten() + }) + .is_some(); + let (navigation_observer, navigation_status) = if !navigation_installed { + ( + None, + Some( + locale + .text(TextKey::NavigationRefreshUnavailable) + .to_owned(), + ), + ) + } else if !settings.navigation_refresh_enabled { + ( + None, + Some(locale.text(TextKey::NavigationRefreshDisabled).to_owned()), + ) + } else { + match start_navigation_observer() { + Ok(observer) => ( + Some(observer), + Some(locale.text(TextKey::NavigationRefreshActive).to_owned()), + ), + Err(error) => ( + None, + Some(format!( + "{}: {error}", + locale.text(TextKey::NavigationRefreshUnavailable) + )), + ), + } + }; let (mut servers, server_status, servers_loaded) = match storage::load_servers(&paths) { Ok(servers) => ( servers, @@ -1509,9 +1748,17 @@ impl App { dependency_status: None, dependency_checking: false, capacities: HashMap::new(), + lustre_quotas: HashMap::new(), + quota_observed_at: HashMap::new(), + quota_next_probe: HashMap::new(), + quota_dialog: None, capacity_errors: HashSet::new(), capacity_refreshing: false, capacity_refresh_pending: false, + navigation_observer, + navigation_installed, + navigation_status, + navigation_scheduler: RefreshScheduler::new(), }; let mut tasks = vec![ open_window.map(Message::MainWindowOpened), @@ -1537,6 +1784,23 @@ impl App { return self.handle_app_command(command); } Message::TrayAction(action) => return self.handle_tray_action(action), + Message::ExplorerNavigation(event) => return self.handle_explorer_navigation(event), + Message::NavigationRefreshFinished { job, result } => { + let current = self.current_mount_identities(); + let current_job = self.navigation_scheduler.is_current(&job, ¤t); + self.navigation_scheduler.finish(job.token); + if current_job { + if let Err(error) = result { + diagnostic_trace(&format!( + "passive Explorer refresh failed for {}: {error}", + job.target.display() + )); + } else { + notify_shell_updated_dir(&job.target); + } + } + return self.start_ready_navigation_refreshes(); + } Message::InteractiveTick => return self.poll_interactive_terminals(), Message::InteractiveReadinessChecked { id, @@ -1597,6 +1861,17 @@ impl App { Message::EndInteractiveSession => return self.end_interactive_session(), Message::RetryTerminal => return self.retry_interactive_terminal(), Message::TrayTick => { + if let Some(failure) = self + .navigation_observer + .as_ref() + .and_then(NavigationObserver::failure) + { + self.navigation_observer = None; + self.navigation_status = Some(format!( + "{}: {failure}", + locale.text(TextKey::NavigationRefreshUnavailable) + )); + } if self.tray.is_some() { TrayController::desktop_iteration(); self.sync_tray(); @@ -1736,6 +2011,12 @@ impl App { .collect(); for id in &unmounted { self.capacities.remove(id); + self.lustre_quotas.remove(id); + self.quota_observed_at.remove(id); + self.quota_next_probe.remove(id); + if self.quota_dialog.as_deref() == Some(id) { + self.quota_dialog = None; + } self.capacity_errors.remove(id); } tasks.push(self.capacity_task()); @@ -1747,16 +2028,44 @@ impl App { } Message::TransferTick => return self.transfer_task(), Message::CapacityTick => return self.capacity_task(), + Message::OpenQuotaDetails(id) => { + if self.mount_statuses.get(&id) == Some(&MountStatus::Mounted) { + self.quota_dialog = Some(id); + } + } + Message::CloseQuotaDetails => self.quota_dialog = None, + Message::RefreshQuotaDetails(id) => { + self.quota_next_probe.remove(&id); + return self.capacity_task(); + } Message::CapacitiesLoaded(results) => { self.capacity_refreshing = false; - for (id, result) in results { + let now = Instant::now(); + for (id, probed_quota, result) in results { match result { - Ok(Some(capacity)) => { - self.capacities.insert(id.clone(), capacity); + Ok(Some(snapshot)) => { + if let Some(capacity) = snapshot.capacity { + self.capacities.insert(id.clone(), capacity); + } else { + self.capacities.remove(&id); + } + if probed_quota { + let delay = match &snapshot.lustre { + LustreQuotaStatus::Available(_) => Duration::from_secs(30), + LustreQuotaStatus::NotLustre { .. } => Duration::from_secs(600), + LustreQuotaStatus::Unavailable { .. } => { + Duration::from_secs(120) + } + }; + self.quota_observed_at.insert(id.clone(), now); + self.quota_next_probe.insert(id.clone(), now + delay); + } + self.lustre_quotas.insert(id.clone(), snapshot.lustre); self.capacity_errors.remove(&id); } Ok(None) => { self.capacities.remove(&id); + self.lustre_quotas.remove(&id); self.capacity_errors.insert(id); } Err(_) => { @@ -3149,6 +3458,13 @@ impl App { draft.auto_check_updates = value; } } + Message::NavigationRefreshChanged(value) => { + if self.navigation_installed + && let Some(draft) = &mut self.settings_draft + { + draft.navigation_refresh_enabled = value; + } + } Message::CheckForUpdates => { if !self.update_checking && !self.update_downloading { self.update_checking = true; @@ -3290,6 +3606,7 @@ impl App { Ok(outcome) => { self.settings = outcome.settings; self.servers = outcome.servers; + self.reconfigure_navigation_observer(); self.settings_draft = None; self.screen = Screen::Connections; self.status = outcome @@ -4180,13 +4497,20 @@ impl App { let Some(id) = draft.editing_id.clone() else { return Task::none(); }; - let tags = match validated_connection_tags(&draft.tags, self.locale()) { - Ok(tags) => tags, - Err(error) => { - self.status = error; - return Task::none(); - } - }; + let existing_tags = self + .servers + .iter() + .find(|server| server.id == id) + .map(|server| server.tags.as_slice()); + let tags = + match validated_connection_tags_for_existing(&draft.tags, existing_tags, self.locale()) + { + Ok(tags) => tags, + Err(error) => { + self.status = error; + return Task::none(); + } + }; let auto_mount_at_login = draft.auto_mount_at_login && draft.connection_method != ConnectionMethod::Interactive; self.editor_saving = true; @@ -4696,6 +5020,7 @@ impl App { self.capacity_refresh_pending = true; return Task::none(); } + let now = Instant::now(); let servers: Vec<_> = self .servers .iter() @@ -4703,14 +5028,21 @@ impl App { self.mount_statuses.get(&server.id) == Some(&MountStatus::Mounted) && !self.busy.contains(&server.id) }) - .cloned() + .map(|server| { + let probe_quota = self + .quota_next_probe + .get(&server.id) + .is_none_or(|next| now >= *next); + let previous_quota = self.lustre_quotas.get(&server.id).cloned(); + (server.clone(), probe_quota, previous_quota) + }) .collect(); if servers.is_empty() { self.capacity_refreshing = false; self.capacity_refresh_pending = false; return Task::none(); } - for server in &servers { + for (server, _, _) in &servers { if !self.capacities.contains_key(&server.id) { self.capacity_errors.remove(&server.id); } @@ -4719,7 +5051,7 @@ impl App { let service = self.service.clone(); let failed_ids = servers .iter() - .map(|server| server.id.clone()) + .map(|(server, _, _)| server.id.clone()) .collect::>(); Task::perform( async move { @@ -4727,21 +5059,38 @@ impl App { std::thread::scope(|scope| { let tasks: Vec<_> = servers .into_iter() - .map(|server| { + .map(|(server, probe_quota, previous_quota)| { let service = service.clone(); let id = server.id.clone(); let task_id = id.clone(); let task = scope.spawn(move || { - service.capacity(&server).map_err(|error| error.to_string()) + if probe_quota { + service + .capacity_snapshot(&server) + .map_err(|error| error.to_string()) + } else { + service.capacity(&server).map(|capacity| { + capacity.map(|capacity| CapacitySnapshot { + capacity: Some(capacity), + lustre: previous_quota.unwrap_or( + LustreQuotaStatus::Unavailable { + reason: mountmate_core::capacity::LustreStatusReason::Other( + "quota has not been probed".into(), + ), + }, + ), + }) + }).map_err(|error| error.to_string()) + } }); - (task_id, task) + (task_id, probe_quota, task) }) .collect(); tasks .into_iter() - .map(|(id, task)| match task.join() { - Ok(result) => (id, result), - Err(_) => (id, Err("capacity worker panicked".into())), + .map(|(id, probed_quota, task)| match task.join() { + Ok(result) => (id, probed_quota, result), + Err(_) => (id, probed_quota, Err("capacity worker panicked".into())), }) .collect() }) @@ -4750,7 +5099,7 @@ impl App { .unwrap_or_else(|error| { failed_ids .into_iter() - .map(|id| (id, Err(error.to_string()))) + .map(|id| (id, false, Err(error.to_string()))) .collect() }) }, @@ -5848,7 +6197,7 @@ impl App { } } - container( + let base: Element<'_, Message> = container( column![ toolbar, mode_actions, @@ -5862,6 +6211,28 @@ impl App { .padding(18) .width(Fill) .height(Fill) + .into(); + let Some(id) = self.quota_dialog.as_deref() else { + return base; + }; + let Some(server) = self.servers.iter().find(|server| server.id == id) else { + return base; + }; + let dialog = quota_details_dialog( + server, + self.lustre_quotas.get(id), + self.quota_observed_at.get(id).map(Instant::elapsed), + self.capacity_refreshing, + locale, + ); + stack![ + base, + container(dialog) + .width(Fill) + .height(Fill) + .center_x(Fill) + .center_y(Fill), + ] .into() } @@ -6987,6 +7358,16 @@ impl App { toggler(draft.auto_check_updates) .label(locale.text(TextKey::CheckUpdatesAutomatically)) .on_toggle(Message::AutoUpdatesChanged), + toggler(draft.navigation_refresh_enabled) + .label(locale.text(TextKey::NavigationRefresh)) + .on_toggle(Message::NavigationRefreshChanged), + text(locale.text(TextKey::NavigationRefreshHelp)).size(13), + text( + self.navigation_status + .as_deref() + .unwrap_or_else(|| locale.text(TextKey::NavigationRefreshUnavailable)), + ) + .size(13), labeled_control( locale.text(TextKey::Language), pick_list( @@ -7609,6 +7990,194 @@ fn capacity_progress_state( } } +fn quota_details_dialog( + server: &ServerConfig, + status: Option<&LustreQuotaStatus>, + observed_age: Option, + refreshing: bool, + locale: Locale, +) -> Element<'static, Message> { + let id = server.id.clone(); + let observed = observed_age.map(|age| match locale { + Locale::English => format!("Updated {}s ago", age.as_secs()), + Locale::Chinese => format!("{} 秒前更新", age.as_secs()), + }); + let mut details = column![].spacing(14); + match status { + Some(LustreQuotaStatus::Available(report)) => { + let project = report.project_id.map_or_else( + || match locale { + Locale::English => "Project (ID unavailable)".into(), + Locale::Chinese => "项目(ID 不可用)".into(), + }, + |project_id| match locale { + Locale::English => format!("Project {project_id}"), + Locale::Chinese => format!("项目 {project_id}"), + }, + ); + let user = match (&report.user_name, report.uid) { + (Some(name), Some(uid)) => format!("{} ({uid})", name), + (Some(name), None) => name.clone(), + (None, Some(uid)) => uid.to_string(), + (None, None) => match locale { + Locale::English => "Current user".into(), + Locale::Chinese => "当前用户".into(), + }, + }; + let group = match (&report.group_name, report.gid) { + (Some(name), Some(gid)) => format!("{} ({gid})", name), + (Some(name), None) => name.clone(), + (None, Some(gid)) => gid.to_string(), + (None, None) => match locale { + Locale::English => "Primary group".into(), + Locale::Chinese => "主组".into(), + }, + }; + details = details + .push( + text(format!( + "{}: {}", + match locale { + Locale::English => "Path", + Locale::Chinese => "路径", + }, + report.resolved_path + )) + .size(12), + ) + .push(quota_scope_view(project, &report.project, locale)) + .push(quota_scope_view(user, &report.current_user, locale)) + .push(quota_scope_view(group, &report.primary_group, locale)); + } + Some(LustreQuotaStatus::NotLustre { reason }) => { + details = details.push(text(match locale { + Locale::English => format!("This path is not on Lustre: {reason}"), + Locale::Chinese => format!("该路径不在 Lustre 文件系统上:{reason}"), + })); + } + Some(LustreQuotaStatus::Unavailable { reason }) => { + details = details.push(text(match locale { + Locale::English => format!("Lustre quota is unavailable: {reason}"), + Locale::Chinese => format!("无法获取 Lustre 配额:{reason}"), + })); + } + None => { + details = details.push(text(match locale { + Locale::English => "Lustre quota has not been checked yet.", + Locale::Chinese => "尚未检查 Lustre 配额。", + })); + } + } + if let Some(observed) = observed { + details = details.push(text(observed).size(12)); + } + let title = match locale { + Locale::English => format!("Quota - {}", server.display_name()), + Locale::Chinese => format!("配额 - {}", server.display_name()), + }; + let refresh = button(match locale { + Locale::English => "Refresh", + Locale::Chinese => "刷新", + }) + .on_press_maybe((!refreshing).then_some(Message::RefreshQuotaDetails(id))); + container( + column![ + row![ + text(title).size(22), + Space::new().width(Fill), + button("x").on_press(Message::CloseQuotaDetails), + ] + .align_y(Center), + scrollable(details).height(Length::Fixed(460.0)), + refresh, + ] + .spacing(14), + ) + .padding(20) + .width(Fill) + .max_width(720.0) + .style(container::rounded_box) + .into() +} + +fn quota_scope_view( + title: String, + status: &LustreQuotaScopeStatus, + locale: Locale, +) -> Element<'static, Message> { + let mut content = column![text(title).size(17)].spacing(5); + match status { + LustreQuotaScopeStatus::Available(scope) => { + content = content + .push(text(quota_metric_summary(&scope.blocks, true, locale)).size(13)) + .push(text(quota_metric_summary(&scope.inodes, false, locale)).size(13)); + } + LustreQuotaScopeStatus::Unavailable { reason } => { + content = content.push( + text(match locale { + Locale::English => format!("Unavailable: {reason}"), + Locale::Chinese => format!("不可用:{reason}"), + }) + .size(13), + ); + } + } + content.into() +} + +fn quota_metric_summary(metric: &LustreQuotaMetric, bytes: bool, locale: Locale) -> String { + let format_value = |value: u64| { + if bytes { + format_bytes(value.saturating_mul(1024)) + } else { + value.to_string() + } + }; + let format_limit = |limit: Option| { + limit.map_or_else( + || match locale { + Locale::English => "unlimited".into(), + Locale::Chinese => "无限制".into(), + }, + format_value, + ) + }; + let metric_name = match (bytes, locale) { + (true, Locale::English) => "Storage", + (true, Locale::Chinese) => "空间", + (false, Locale::English) => "Files", + (false, Locale::Chinese) => "文件数", + }; + let severity = match (metric.severity, locale) { + (LustreQuotaSeverity::Normal, Locale::English) => "normal", + (LustreQuotaSeverity::Normal, Locale::Chinese) => "正常", + (LustreQuotaSeverity::Grace, Locale::English) => "grace active", + (LustreQuotaSeverity::Grace, Locale::Chinese) => "宽限期中", + (LustreQuotaSeverity::SoftExceeded, Locale::English) => "soft limit exceeded", + (LustreQuotaSeverity::SoftExceeded, Locale::Chinese) => "已超过软限制", + (LustreQuotaSeverity::HardExceeded, Locale::English) => "hard limit reached", + (LustreQuotaSeverity::HardExceeded, Locale::Chinese) => "已达到硬限制", + (LustreQuotaSeverity::Unknown, Locale::English) => "unknown", + (LustreQuotaSeverity::Unknown, Locale::Chinese) => "未知", + }; + match locale { + Locale::English => format!( + "{metric_name}: used {}, soft {}, hard {}, grace {}, {severity}", + format_value(metric.used), + format_limit(metric.soft), + format_limit(metric.hard), + metric.grace.raw, + ), + Locale::Chinese => format!( + "{metric_name}:已用 {},软限制 {},硬限制 {},宽限期 {},{severity}", + format_value(metric.used), + format_limit(metric.soft), + format_limit(metric.hard), + metric.grace.raw, + ), + } +} + fn connection_card<'a>( server: &'a ServerConfig, state: ConnectionCardState<'a>, @@ -7661,6 +8230,14 @@ fn connection_card<'a>( if status == MountStatus::Mounted && !busy { open = open.on_press(Message::Open(id.clone())); } + let quota_label = match locale { + Locale::English => "Quota", + Locale::Chinese => "配额", + }; + let mut quota = button(quota_label); + if status == MountStatus::Mounted && !busy { + quota = quota.on_press(Message::OpenQuotaDetails(id.clone())); + } let mut title = row![text(server.display_name()).size(22)] .spacing(8) .align_y(Center); @@ -7880,7 +8457,7 @@ fn connection_card<'a>( .into() }; container( - row![details, operation, open, actions] + row![details, operation, open, quota, actions] .spacing(8) .align_y(Center), ) @@ -8344,19 +8921,54 @@ fn connection_preference_updates( } fn validated_connection_tags(tags: &[String], locale: Locale) -> Result, String> { + validated_connection_tags_for_existing(tags, None, locale) +} + +fn validated_connection_tags_for_existing( + tags: &[String], + existing: Option<&[String]>, + locale: Locale, +) -> Result, String> { let mut normalized = Vec::new(); for tag in tags { - let tag = normalized_tag_name(tag, locale)?; + let tag = match normalized_tag_name(tag, locale) { + Ok(tag) => tag, + Err(error) => { + let tag = tag.trim(); + let is_existing_overlong = tag.chars().count() > MAX_TAG_CHARS + && !tag.chars().any(char::is_control) + && !tag.contains(',') + && !tag.contains(',') + && existing.is_some_and(|existing| existing.iter().any(|item| item == tag)); + if !is_existing_overlong { + return Err(error); + } + tag.to_owned() + } + }; if !normalized.iter().any(|candidate| candidate == &tag) { normalized.push(tag); } } - if normalized.len() > MAX_CONNECTION_TAGS { + let preserves_existing = existing.is_some_and(|existing| { + mountmate_core::model::tag_update_only_preserves_existing(&normalized, existing) + }); + if normalized.len() > MAX_CONNECTION_TAGS && !preserves_existing { return Err(match locale { Locale::English => format!("A connection may have at most {MAX_CONNECTION_TAGS} tags"), Locale::Chinese => format!("一个连接最多只能有 {MAX_CONNECTION_TAGS} 个标签"), }); } + if normalized + .iter() + .any(|tag| tag.chars().count() > MAX_TAG_CHARS) + && !preserves_existing + { + return Err(match locale { + Locale::English => format!("A tag must be at most {MAX_TAG_CHARS} characters"), + Locale::Chinese => format!("标签最多只能有 {MAX_TAG_CHARS} 个字符"), + }); + } Ok(normalized) } @@ -10204,6 +10816,34 @@ fn open_path(path: &Path, locale: Locale) -> Result<(), String> { mod localization_tests { use super::*; + #[test] + fn quota_metric_summary_formats_limits_in_both_languages() { + let metric = LustreQuotaMetric { + used: 1024, + soft: Some(2048), + hard: None, + grace: mountmate_core::capacity::LustreGrace { + raw: "1d".into(), + state: mountmate_core::capacity::LustreGraceState::Active, + }, + severity: LustreQuotaSeverity::Grace, + marked: false, + soft_marked: false, + hard_marked: false, + }; + let english = quota_metric_summary(&metric, true, Locale::English); + assert!(english.contains("used 1.0 MB")); + assert!(english.contains("soft 2.0 MB")); + assert!(english.contains("hard unlimited")); + assert!(english.contains("grace active")); + + let chinese = quota_metric_summary(&metric, false, Locale::Chinese); + assert!(chinese.contains("已用 1024")); + assert!(chinese.contains("软限制 2048")); + assert!(chinese.contains("硬限制 无限制")); + assert!(chinese.contains("宽限期中")); + } + #[test] fn mount_error_summary_is_bounded_to_two_lines_and_compact_content() { let cause = "first detail\nsecond detail\nthird detail that should stay in the log"; @@ -11385,6 +12025,25 @@ mod localization_tests { ) .is_err() ); + + let legacy_long = "界".repeat(MAX_TAG_CHARS + 1); + assert_eq!( + validated_connection_tags_for_existing( + std::slice::from_ref(&legacy_long), + Some(std::slice::from_ref(&legacy_long)), + Locale::English, + ) + .unwrap(), + vec![legacy_long.clone()] + ); + assert!( + validated_connection_tags_for_existing( + &[legacy_long, "new-tag".into()], + Some(std::slice::from_ref(&"界".repeat(MAX_TAG_CHARS + 1))), + Locale::English, + ) + .is_err() + ); } #[test] diff --git a/crates/mountmate-core/src/capacity.rs b/crates/mountmate-core/src/capacity.rs index e582d2a..64e9acc 100644 --- a/crates/mountmate-core/src/capacity.rs +++ b/crates/mountmate-core/src/capacity.rs @@ -10,7 +10,7 @@ use serde::Deserialize; use thiserror::Error; use wait_timeout::ChildExt; -use crate::{AuthMethod, MountState, ServerConfig}; +use crate::{AuthMethod, ConnectionMethod, MountState, ServerConfig}; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x0800_0000; @@ -42,6 +42,90 @@ fi printf '%s\n' "$quota_out" "#; +// Keep the complete Lustre probe in one remote shell invocation. The markers +// are deliberately line-oriented so arbitrary lfs warnings can remain in the +// response without making the parser depend on locale-specific wording. +const LUSTRE_QUOTA_SCRIPT: &str = r#"set -u +export LC_ALL=C +export LANG=C + +target=${1:-.} +if [ -z "$target" ]; then target=.; fi +case "$target" in + '~') target=$HOME ;; + '~/'*) target=$HOME/${target#\~/} ;; +esac + +if ! command -v lfs >/dev/null 2>&1; then + printf '@@MMQ|STATUS|UNAVAILABLE|lfs-missing\n' + exit 0 +fi + +if [ -d "$target" ]; then + resolved=$(cd "$target" 2>/dev/null && pwd -P) || { + printf '@@MMQ|STATUS|UNAVAILABLE|path-unresolved\n' + exit 0 + } +else + resolved=$(readlink -f -- "$target" 2>/dev/null || printf '%s' "$target") +fi +if [ -z "$resolved" ]; then + printf '@@MMQ|STATUS|UNAVAILABLE|path-unresolved\n' + exit 0 +fi + +df_out=$(df -P -T "$resolved" 2>/dev/null || true) +df_line=$(printf '%s\n' "$df_out" | awk 'NR == 2 {print $2 "\t" $NF; exit}') +fstype=${df_line%% *} +mountpoint=${df_line#* } +if [ -z "$fstype" ] || [ -z "$mountpoint" ]; then + printf '@@MMQ|STATUS|UNAVAILABLE|filesystem-unresolved\n' + exit 0 +fi +if [ "$fstype" != "lustre" ]; then + printf '@@MMQ|STATUS|NOT_LUSTRE|%s\n' "$fstype" + exit 0 +fi + +project_out=$(lfs project -d "$resolved" 2>&1 || true) +project_id=$(printf '%s\n' "$project_out" | awk '$1 ~ /^[0-9]+$/ {print $1; exit}') + +uid=$(id -u 2>/dev/null || true) +gid=$(id -g 2>/dev/null || true) +user_name=$(id -un 2>/dev/null || true) +group_name=$(id -gn 2>/dev/null || true) +printf '@@MMQ|STATUS|LUSTRE\n' +printf '@@MMQ|PATH|%s|%s\n' "$resolved" "$mountpoint" +printf '@@MMQ|PROJECT|%s\n' "$project_id" +printf '@@MMQ|IDENTITY|%s|%s|%s|%s\n' "$uid" "$gid" "$user_name" "$group_name" + +run_scope() { + scope=$1 + shift + printf '@@MMQ|BEGIN|%s\n' "$scope" + quota_output=$(lfs quota "$@" "$mountpoint" 2>&1) + quota_status=$? + printf '%s\n' "$quota_output" + printf '@@MMQ|END|%s|%s\n' "$scope" "$quota_status" +} + +if [ -n "$project_id" ]; then + run_scope project -p "$project_id" +else + printf '@@MMQ|BEGIN|project\nproject ID is unavailable\n@@MMQ|END|project|65\n' +fi +if [ -n "$uid" ]; then + run_scope user -u "$uid" +else + printf '@@MMQ|BEGIN|user\n@@MMQ|END|user|64\n' +fi +if [ -n "$gid" ]; then + run_scope group -g "$gid" +else + printf '@@MMQ|BEGIN|group\n@@MMQ|END|group|64\n' +fi +"#; + const FILESYSTEM_CAPACITY_SCRIPT: &str = r#"set -eu target=${1:-.} if [ -z "$target" ]; then target=.; fi @@ -77,6 +161,141 @@ pub struct CapacityInfo { pub source: CapacitySource, } +/// The reason a Lustre probe did not provide quota details. The payload is +/// intentionally textual because remote tools return installation-specific +/// diagnostics; callers can display it without losing useful context. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LustreStatusReason { + AuthUnavailable, + NotLustre(String), + LfsMissing, + PathUnresolved, + FilesystemUnresolved, + ProjectIdMissing, + IdentityUnavailable, + QuotaUnavailable(String), + InvalidOutput(String), + Other(String), +} + +impl std::fmt::Display for LustreStatusReason { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AuthUnavailable => formatter.write_str("authentication unavailable"), + Self::NotLustre(filesystem) => { + write!(formatter, "remote filesystem is not Lustre ({filesystem})") + } + Self::LfsMissing => formatter.write_str("lfs is unavailable"), + Self::PathUnresolved => formatter.write_str("remote path could not be resolved"), + Self::FilesystemUnresolved => formatter.write_str("filesystem could not be resolved"), + Self::ProjectIdMissing => formatter.write_str("Lustre project ID is unavailable"), + Self::IdentityUnavailable => { + formatter.write_str("remote user or group identity is unavailable") + } + Self::QuotaUnavailable(message) + | Self::InvalidOutput(message) + | Self::Other(message) => formatter.write_str(message), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LustreGraceState { + None, + Active, + Expired, + Unlimited, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LustreGrace { + /// The exact grace token emitted by `lfs quota` (for example `1d` or `-`). + pub raw: String, + pub state: LustreGraceState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LustreQuotaSeverity { + Normal, + SoftExceeded, + HardExceeded, + Grace, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LustreQuotaMetric { + /// Used blocks are KiB; used inodes are counts. Values are never clamped. + pub used: u64, + pub soft: Option, + pub hard: Option, + pub grace: LustreGrace, + pub severity: LustreQuotaSeverity, + /// Whether the source row marked the used value with a trailing `*`. + pub marked: bool, + pub soft_marked: bool, + pub hard_marked: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LustreQuotaScopeDetails { + pub blocks: LustreQuotaMetric, + pub inodes: LustreQuotaMetric, +} + +impl LustreQuotaScopeDetails { + pub fn block(&self) -> &LustreQuotaMetric { + &self.blocks + } + + pub fn inode(&self) -> &LustreQuotaMetric { + &self.inodes + } + + pub fn block_capacity(&self) -> Option { + let hard = self.blocks.hard?; + capacity_from_usage( + hard.saturating_mul(1024), + self.blocks.used.saturating_mul(1024), + CapacitySource::LustreProjectQuota, + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LustreQuotaScopeStatus { + Available(LustreQuotaScopeDetails), + Unavailable { reason: LustreStatusReason }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LustreQuotaDetails { + pub resolved_path: String, + pub mountpoint: String, + pub project_id: Option, + pub uid: Option, + pub gid: Option, + pub user_name: Option, + pub group_name: Option, + pub project: LustreQuotaScopeStatus, + pub current_user: LustreQuotaScopeStatus, + pub primary_group: LustreQuotaScopeStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LustreQuotaStatus { + NotLustre { reason: LustreStatusReason }, + Unavailable { reason: LustreStatusReason }, + Available(Box), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapacitySnapshot { + pub capacity: Option, + pub lustre: LustreQuotaStatus, +} + #[derive(Debug, Error)] pub enum CapacityError { #[error("capacity I/O failed: {0}")] @@ -100,9 +319,18 @@ pub fn mounted_capacity( server: &ServerConfig, state: &MountState, rclone_config: &Path, +) -> Result, CapacityError> { + mounted_capacity_with_connector(server, state, rclone_config, None) +} + +pub fn mounted_capacity_with_connector( + server: &ServerConfig, + state: &MountState, + rclone_config: &Path, + connector: Option<&[String]>, ) -> Result, CapacityError> { if server.source == "sai_cluster" - && let Some(capacity) = lustre_project_capacity(server)? + && let Some(capacity) = lustre_project_capacity_with_connector(server, connector)? { return Ok(Some(capacity)); } @@ -110,7 +338,7 @@ pub fn mounted_capacity( return Ok(Some(capacity)); } if server.source != "sai_cluster" - && let Some(capacity) = lustre_project_capacity(server)? + && let Some(capacity) = lustre_project_capacity_with_connector(server, connector)? { return Ok(Some(capacity)); } @@ -118,7 +346,7 @@ pub fn mounted_capacity( if let Ok(Some(capacity)) = &rclone_result { return Ok(Some(*capacity)); } - let remote_result = remote_filesystem_capacity(server); + let remote_result = remote_filesystem_capacity_with_connector(server, connector); if let Ok(Some(capacity)) = &remote_result { return Ok(Some(*capacity)); } @@ -128,6 +356,130 @@ pub fn mounted_capacity( } } +/// Return display capacity and the full Lustre probe result in one snapshot. +/// The existing fallback order is retained: SAI profiles prefer Lustre before +/// local statistics, while other profiles prefer local statistics first. +pub fn capacity_snapshot( + server: &ServerConfig, + state: &MountState, + rclone_config: &Path, +) -> Result { + capacity_snapshot_with_connector(server, state, rclone_config, None) +} + +pub fn capacity_snapshot_with_connector( + server: &ServerConfig, + state: &MountState, + rclone_config: &Path, + connector: Option<&[String]>, +) -> Result { + let lustre = match lustre_quota_status_with_connector(server, connector) { + Ok(status) => status, + Err(error) => LustreQuotaStatus::Unavailable { + reason: LustreStatusReason::QuotaUnavailable(error.to_string()), + }, + }; + let lustre_capacity = lustre_project_snapshot_capacity(&lustre); + + if server.source == "sai_cluster" + && let Some(capacity) = lustre_capacity + { + return Ok(CapacitySnapshot { + capacity: Some(capacity), + lustre, + }); + } + let local = local_mount_capacity(&state.mountpoint); + if let Some(capacity) = local { + return Ok(CapacitySnapshot { + capacity: Some(capacity), + lustre, + }); + } + if server.source != "sai_cluster" + && let Some(capacity) = lustre_capacity + { + return Ok(CapacitySnapshot { + capacity: Some(capacity), + lustre, + }); + } + + let rclone_result = rclone_about_capacity(&state.rclone, rclone_config, &state.remote); + if let Ok(Some(capacity)) = &rclone_result { + return Ok(CapacitySnapshot { + capacity: Some(*capacity), + lustre, + }); + } + let remote_result = remote_filesystem_capacity_with_connector(server, connector); + if let Ok(Some(capacity)) = &remote_result { + return Ok(CapacitySnapshot { + capacity: Some(*capacity), + lustre, + }); + } + match (rclone_result, remote_result) { + (Err(error), _) | (_, Err(error)) => Err(error), + _ => Ok(CapacitySnapshot { + capacity: None, + lustre, + }), + } +} + +/// Compatibility spelling for callers that use a mounted-capacity prefix. +pub fn mounted_capacity_snapshot( + server: &ServerConfig, + state: &MountState, + rclone_config: &Path, +) -> Result { + capacity_snapshot(server, state, rclone_config) +} + +/// Probe Lustre quota details without applying display-capacity fallbacks. +pub fn lustre_quota_status(server: &ServerConfig) -> Result { + lustre_quota_status_with_connector(server, None) +} + +pub fn lustre_quota_status_with_connector( + server: &ServerConfig, + connector: Option<&[String]>, +) -> Result { + // The interactive/shared transport owns authentication and must never be + // bypassed by starting a second SSH process that could prompt. + if server.connection_method == ConnectionMethod::Interactive && connector.is_none() { + return Ok(LustreQuotaStatus::Unavailable { + reason: LustreStatusReason::AuthUnavailable, + }); + } + if server.auth == AuthMethod::Password + && server.source != "ssh_config" + && !server.ssh_config_managed + { + return Ok(LustreQuotaStatus::Unavailable { + reason: LustreStatusReason::AuthUnavailable, + }); + } + let Some(output) = ssh_capacity_output_with_connector(server, LUSTRE_QUOTA_SCRIPT, connector)? + else { + return Ok(LustreQuotaStatus::Unavailable { + reason: LustreStatusReason::Other("non-interactive SSH is unavailable".into()), + }); + }; + Ok(parse_lustre_quota_snapshot(&output)) +} + +fn lustre_project_snapshot_capacity(status: &LustreQuotaStatus) -> Option { + let LustreQuotaStatus::Available(details) = status else { + return None; + }; + let LustreQuotaScopeStatus::Available(project) = &details.project else { + return None; + }; + project.block_capacity() +} + pub fn local_mount_capacity(mountpoint: &Path) -> Option { let total = fs2::total_space(mountpoint).ok()?; let available = fs2::available_space(mountpoint).ok()?; @@ -178,77 +530,99 @@ fn capacity_from_about(about: RcloneAbout) -> Option { ) } -fn lustre_project_capacity(server: &ServerConfig) -> Result, CapacityError> { - let Some(output) = ssh_capacity_output(server, LUSTRE_CAPACITY_SCRIPT)? else { +fn lustre_project_capacity_with_connector( + server: &ServerConfig, + connector: Option<&[String]>, +) -> Result, CapacityError> { + let Some(output) = + ssh_capacity_output_with_connector(server, LUSTRE_CAPACITY_SCRIPT, connector)? + else { return Ok(None); }; Ok(parse_lustre_quota(&output)) } -fn remote_filesystem_capacity( +fn remote_filesystem_capacity_with_connector( server: &ServerConfig, + connector: Option<&[String]>, ) -> Result, CapacityError> { - let Some(output) = ssh_capacity_output(server, FILESYSTEM_CAPACITY_SCRIPT)? else { + let Some(output) = + ssh_capacity_output_with_connector(server, FILESYSTEM_CAPACITY_SCRIPT, connector)? + else { return Ok(None); }; Ok(parse_filesystem_capacity(&output)) } -fn ssh_capacity_output( +fn ssh_capacity_output_with_connector( server: &ServerConfig, script: &str, + connector: Option<&[String]>, ) -> Result, CapacityError> { + if server.connection_method == ConnectionMethod::Interactive && connector.is_none() { + return Ok(None); + } if server.auth == AuthMethod::Password && server.source != "ssh_config" && !server.ssh_config_managed { return Ok(None); } - let Some(ssh) = - crate::rclone_binary::find_system_executable(if cfg!(windows) { "ssh.exe" } else { "ssh" }) - else { - return Ok(None); - }; - let mut arguments = vec![ - "-o".to_owned(), - "BatchMode=yes".to_owned(), - "-o".to_owned(), - "ConnectTimeout=8".to_owned(), - ]; - if (server.source == "ssh_config" || server.ssh_config_managed) - && !server.host_alias.trim().is_empty() - { - let config = if !server.managed_ssh_config_path.trim().is_empty() { - &server.managed_ssh_config_path - } else { - &server.ssh_config_path + let (program, mut arguments) = if let Some(connector) = connector { + let Some((program, arguments)) = connector.split_first() else { + return Ok(None); }; - if !config.trim().is_empty() { - arguments.extend(["-F".into(), config.clone()]); - } - arguments.push(server.host_alias.clone()); + (std::path::PathBuf::from(program), arguments.to_vec()) } else { - if !server.user.trim().is_empty() { - arguments.extend(["-l".into(), server.user.clone()]); - } - arguments.extend(["-p".into(), server.port.clone()]); - if !server.key_file.trim().is_empty() { - arguments.extend([ - "-i".into(), - server.key_file.clone(), - "-o".into(), - "IdentitiesOnly=yes".into(), - ]); + let Some(ssh) = crate::rclone_binary::find_system_executable(if cfg!(windows) { + "ssh.exe" + } else { + "ssh" + }) else { + return Ok(None); + }; + let mut arguments = vec![ + "-o".to_owned(), + "BatchMode=yes".to_owned(), + "-o".to_owned(), + "ConnectTimeout=8".to_owned(), + ]; + if (server.source == "ssh_config" || server.ssh_config_managed) + && !server.host_alias.trim().is_empty() + { + let config = if !server.managed_ssh_config_path.trim().is_empty() { + &server.managed_ssh_config_path + } else { + &server.ssh_config_path + }; + if !config.trim().is_empty() { + arguments.extend(["-F".into(), config.clone()]); + } + arguments.push(server.host_alias.clone()); + } else { + if !server.user.trim().is_empty() { + arguments.extend(["-l".into(), server.user.clone()]); + } + arguments.extend(["-p".into(), server.port.clone()]); + if !server.key_file.trim().is_empty() { + arguments.extend([ + "-i".into(), + server.key_file.clone(), + "-o".into(), + "IdentitiesOnly=yes".into(), + ]); + } + arguments.push(server.host.clone()); } - arguments.push(server.host.clone()); - } + (ssh, arguments) + }; arguments.extend([ "sh".into(), "-s".into(), "--".into(), quote_remote_shell_argument(&remote_path_for_capacity(server)), ]); - let mut command = Command::new(ssh); + let mut command = Command::new(program); command .args(arguments) .stdin(Stdio::piped()) @@ -296,6 +670,9 @@ fn quote_remote_shell_argument(value: &str) -> String { } pub fn parse_lustre_quota(output: &str) -> Option { + if let Some(details) = parse_lustre_scope_row(output) { + return details.block_capacity(); + } for line in output.lines() { let line = line.trim(); if line.is_empty() @@ -324,6 +701,347 @@ pub fn parse_lustre_quota(output: &str) -> Option { None } +/// Parse a complete framed Lustre probe response. Raw quota command output +/// is parsed independently for each scope, so one failed scope does not erase +/// details obtained for the other scopes. +pub fn parse_lustre_quota_snapshot(output: &str) -> LustreQuotaStatus { + let mut status: Option<&str> = None; + let mut status_reason = String::new(); + let mut resolved_path = String::new(); + let mut mountpoint = String::new(); + let mut project_id = None; + let mut uid = None; + let mut gid = None; + let mut user_name = None; + let mut group_name = None; + let mut scope = None::<&str>; + let mut scope_lines = [String::new(), String::new(), String::new()]; + let mut scope_exit = [None::, None::, None::]; + + for line in output.lines() { + let line = line.trim_end_matches('\r'); + if let Some(value) = line.strip_prefix("@@MMQ|STATUS|") { + let mut fields = value.splitn(2, '|'); + status = fields.next(); + status_reason = fields.next().unwrap_or_default().trim().to_owned(); + continue; + } + if let Some(value) = line.strip_prefix("@@MMQ|PATH|") { + let mut fields = value.splitn(2, '|'); + resolved_path = fields.next().unwrap_or_default().to_owned(); + mountpoint = fields.next().unwrap_or_default().to_owned(); + continue; + } + if let Some(value) = line.strip_prefix("@@MMQ|PROJECT|") { + project_id = value.trim().parse::().ok(); + continue; + } + if let Some(value) = line.strip_prefix("@@MMQ|IDENTITY|") { + let mut fields = value.splitn(4, '|'); + uid = fields.next().and_then(|value| value.parse::().ok()); + gid = fields.next().and_then(|value| value.parse::().ok()); + user_name = nonempty_string(fields.next().unwrap_or_default()); + group_name = nonempty_string(fields.next().unwrap_or_default()); + continue; + } + if let Some(value) = line.strip_prefix("@@MMQ|BEGIN|") { + scope = scope_index(value.trim()).map(|index| match index { + 0 => "project", + 1 => "user", + _ => "group", + }); + continue; + } + if let Some(value) = line.strip_prefix("@@MMQ|END|") { + let mut fields = value.split('|'); + let ended_scope = fields.next().unwrap_or_default(); + let code = fields.next().and_then(|value| value.parse::().ok()); + if let Some(index) = scope_index(ended_scope) { + scope_exit[index] = code; + } + scope = None; + continue; + } + if let Some(current) = scope.and_then(scope_index) { + scope_lines[current].push_str(line); + scope_lines[current].push('\n'); + } + } + + match status { + Some("NOT_LUSTRE") => LustreQuotaStatus::NotLustre { + reason: LustreStatusReason::NotLustre(if status_reason.is_empty() { + "remote filesystem is not Lustre".into() + } else { + status_reason + }), + }, + Some("UNAVAILABLE") => LustreQuotaStatus::Unavailable { + reason: unavailable_reason(&status_reason), + }, + Some("LUSTRE") => { + if resolved_path.is_empty() || mountpoint.is_empty() { + return LustreQuotaStatus::Unavailable { + reason: LustreStatusReason::InvalidOutput("missing path frame".into()), + }; + } + let project = if project_id.is_some() { + scope_status(&scope_lines[0], scope_exit[0]) + } else { + LustreQuotaScopeStatus::Unavailable { + reason: LustreStatusReason::ProjectIdMissing, + } + }; + let current_user = scope_status(&scope_lines[1], scope_exit[1]); + let primary_group = scope_status(&scope_lines[2], scope_exit[2]); + LustreQuotaStatus::Available(Box::new(LustreQuotaDetails { + resolved_path, + mountpoint, + project_id, + uid, + gid, + user_name, + group_name, + project, + current_user, + primary_group, + })) + } + _ => LustreQuotaStatus::Unavailable { + reason: LustreStatusReason::InvalidOutput("missing Lustre status frame".into()), + }, + } +} + +/// Parse one unframed `lfs quota` response. This is useful for diagnostics and +/// keeps the parser independently testable from the remote shell framing. +pub fn parse_lustre_quota_scope(output: &str) -> Option { + parse_lustre_scope_row(output) +} + +fn scope_status(output: &str, exit_code: Option) -> LustreQuotaScopeStatus { + if exit_code != Some(0) { + let message = output + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("lfs quota command failed") + .to_owned(); + return LustreQuotaScopeStatus::Unavailable { + reason: LustreStatusReason::QuotaUnavailable(message), + }; + } + parse_lustre_scope_row(output).map_or_else( + || LustreQuotaScopeStatus::Unavailable { + reason: LustreStatusReason::InvalidOutput("quota row was not recognized".into()), + }, + LustreQuotaScopeStatus::Available, + ) +} + +fn scope_index(scope: &str) -> Option { + match scope.trim().to_ascii_lowercase().as_str() { + "project" => Some(0), + "user" | "current-user" | "current_user" => Some(1), + "group" | "primary-group" | "primary_group" => Some(2), + _ => None, + } +} + +fn nonempty_string(value: &str) -> Option { + (!value.trim().is_empty()).then(|| value.trim().to_owned()) +} + +fn unavailable_reason(value: &str) -> LustreStatusReason { + match value.trim().to_ascii_lowercase().as_str() { + "lfs-missing" => LustreStatusReason::LfsMissing, + "path-unresolved" => LustreStatusReason::PathUnresolved, + "filesystem-unresolved" => LustreStatusReason::FilesystemUnresolved, + "project-id-missing" => LustreStatusReason::ProjectIdMissing, + "identity-unavailable" => LustreStatusReason::IdentityUnavailable, + "" => LustreStatusReason::Other("Lustre probe unavailable".into()), + _ => LustreStatusReason::Other(value.trim().to_owned()), + } +} + +fn parse_lustre_scope_row(output: &str) -> Option { + let mut pending = Vec::::new(); + for line in output.lines() { + let fields: Vec = line.split_whitespace().map(str::to_owned).collect(); + if fields.is_empty() { + continue; + } + if let Some(row) = parse_lustre_row_fields(&fields) { + return Some(row); + } + // lfs can wrap a filesystem row after the filesystem column. Carry a + // path-like line into the next line, but never carry warning/header + // text into numeric data. + if pending.is_empty() && fields.len() <= 2 && fields[0].starts_with('/') { + pending.extend(fields.iter().cloned()); + continue; + } + if !pending.is_empty() { + let mut combined = pending.clone(); + combined.extend(fields.iter().map(|field| (*field).to_owned())); + if let Some(row) = parse_lustre_row_fields(&combined) { + return Some(row); + } + pending.clear(); + } + } + None +} + +fn parse_lustre_row_fields(fields: &[String]) -> Option { + // Normal rows have a filesystem field followed by eight quota fields; + // pathless wrapped rows have just the eight quota fields. + for start in 0..fields.len() { + if start + 8 < fields.len() + && let (Some((block_used, block_marked)), Some((inode_used, inode_marked))) = ( + parse_used_token(&fields[start + 1]), + parse_used_token(&fields[start + 5]), + ) + { + let Some((block_soft, block_soft_marked)) = parse_limit_token(&fields[start + 2]) + else { + continue; + }; + let Some((block_hard, block_hard_marked)) = parse_limit_token(&fields[start + 3]) + else { + continue; + }; + let Some((inode_soft, inode_soft_marked)) = parse_limit_token(&fields[start + 6]) + else { + continue; + }; + let Some((inode_hard, inode_hard_marked)) = parse_limit_token(&fields[start + 7]) + else { + continue; + }; + return Some(LustreQuotaScopeDetails { + blocks: quota_metric( + block_used, + block_soft, + block_hard, + &fields[start + 4], + block_marked, + block_soft_marked, + block_hard_marked, + ), + inodes: quota_metric( + inode_used, + inode_soft, + inode_hard, + &fields[start + 8], + inode_marked, + inode_soft_marked, + inode_hard_marked, + ), + }); + } + } + if fields.len() >= 8 + && let (Some((block_used, block_marked)), Some((inode_used, inode_marked))) = + (parse_used_token(&fields[0]), parse_used_token(&fields[4])) + { + let (block_soft, block_soft_marked) = parse_limit_token(&fields[1])?; + let (block_hard, block_hard_marked) = parse_limit_token(&fields[2])?; + let (inode_soft, inode_soft_marked) = parse_limit_token(&fields[5])?; + let (inode_hard, inode_hard_marked) = parse_limit_token(&fields[6])?; + return Some(LustreQuotaScopeDetails { + blocks: quota_metric( + block_used, + block_soft, + block_hard, + &fields[3], + block_marked, + block_soft_marked, + block_hard_marked, + ), + inodes: quota_metric( + inode_used, + inode_soft, + inode_hard, + &fields[7], + inode_marked, + inode_soft_marked, + inode_hard_marked, + ), + }); + } + None +} + +fn parse_used_token(value: &str) -> Option<(u64, bool)> { + let (number, marked) = value + .strip_suffix('*') + .map_or((value, false), |value| (value, true)); + Some((number.parse().ok()?, marked)) +} + +fn parse_limit_token(value: &str) -> Option<(Option, bool)> { + if value == "-" || value == "--" { + return Some((None, false)); + } + let (number, marked) = value + .strip_suffix('*') + .map_or((value, false), |value| (value, true)); + let parsed = number.parse::().ok()?; + Some((if parsed == 0 { None } else { Some(parsed) }, marked)) +} + +fn quota_metric( + used: u64, + soft: Option, + hard: Option, + grace: &str, + used_marked: bool, + soft_marked: bool, + hard_marked: bool, +) -> LustreQuotaMetric { + let grace = parse_grace(grace, soft.is_none() && hard.is_none()); + let severity = if hard.is_some_and(|hard| used >= hard) || hard_marked { + LustreQuotaSeverity::HardExceeded + } else if soft.is_some_and(|soft| used > soft) || soft_marked || (used_marked && soft.is_some()) + { + LustreQuotaSeverity::SoftExceeded + } else if grace.state == LustreGraceState::Active { + LustreQuotaSeverity::Grace + } else { + LustreQuotaSeverity::Normal + }; + LustreQuotaMetric { + used, + soft, + hard, + grace, + severity, + marked: used_marked || soft_marked || hard_marked, + soft_marked, + hard_marked, + } +} + +fn parse_grace(value: &str, unlimited: bool) -> LustreGrace { + let raw = value.to_owned(); + let normalized = value.trim().to_ascii_lowercase(); + let state = if unlimited { + LustreGraceState::Unlimited + } else if normalized.is_empty() || matches!(normalized.as_str(), "-" | "none" | "no") { + LustreGraceState::None + } else if normalized.contains("expired") + || matches!(normalized.as_str(), "0" | "0s" | "00:00" | "00:00:00") + { + LustreGraceState::Expired + } else if normalized.chars().any(|ch| ch.is_ascii_digit()) { + LustreGraceState::Active + } else { + LustreGraceState::Unknown + }; + LustreGrace { raw, state } +} + pub fn parse_filesystem_capacity(output: &str) -> Option { for line in output.lines() { let fields: Vec<_> = line.split_whitespace().collect(); @@ -375,6 +1093,21 @@ mod tests { assert_eq!(capacity.total, 1000 * 1024); assert_eq!(capacity.percent, 100); assert_eq!(capacity.source, CapacitySource::LustreProjectQuota); + + let at_limit = parse_lustre_quota_scope("/lustre 1000 0 1000 - 1 0 0 -").unwrap(); + assert_eq!(at_limit.blocks.severity, LustreQuotaSeverity::HardExceeded); + } + + #[test] + fn interactive_capacity_never_starts_unverified_ssh() { + let server = ServerConfig { + connection_method: ConnectionMethod::Interactive, + ..ServerConfig::default() + }; + assert_eq!( + ssh_capacity_output_with_connector(&server, "exit 99", None).unwrap(), + None + ); } #[test] @@ -424,4 +1157,163 @@ mod tests { "'~/folder with '\\''quotes'\\'''" ); } + + #[test] + fn framed_lustre_snapshot_preserves_all_scopes_and_units() { + let status = parse_lustre_quota_snapshot( + "@@MMQ|STATUS|LUSTRE +@@MMQ|PATH|/data/project|/data +@@MMQ|PROJECT|42 +@@MMQ|IDENTITY|1000|100|alice|users +@@MMQ|BEGIN|project +warning from lfs +Filesystem kbytes quota limit grace files quota limit grace +/data 1200 500 1000 1d 3 4 5 - +@@MMQ|END|project|0 +@@MMQ|BEGIN|user +/data 12 0 0 - 7 0 0 - +@@MMQ|END|user|0 +@@MMQ|BEGIN|group +/data 9 8 16 - 9 10 20 00:00:00 +@@MMQ|END|group|0 +", + ); + let LustreQuotaStatus::Available(details) = status else { + panic!("expected available Lustre status"); + }; + assert_eq!(details.project_id, Some(42)); + assert_eq!(details.uid, Some(1000)); + assert_eq!(details.gid, Some(100)); + assert_eq!(details.user_name.as_deref(), Some("alice")); + assert_eq!(details.group_name.as_deref(), Some("users")); + let LustreQuotaScopeStatus::Available(project) = details.project else { + panic!("project quota missing"); + }; + assert_eq!(project.blocks.used, 1200); + assert_eq!(project.blocks.soft, Some(500)); + assert_eq!(project.blocks.hard, Some(1000)); + assert_eq!(project.blocks.grace.raw, "1d"); + assert_eq!(project.blocks.grace.state, LustreGraceState::Active); + assert_eq!(project.blocks.severity, LustreQuotaSeverity::HardExceeded); + assert_eq!(project.inodes.used, 3); + assert_eq!(project.inodes.soft, Some(4)); + assert_eq!(project.inodes.hard, Some(5)); + let LustreQuotaScopeStatus::Available(user) = details.current_user else { + panic!("user quota missing"); + }; + assert_eq!(user.blocks.hard, None); + assert_eq!(user.blocks.grace.state, LustreGraceState::Unlimited); + assert_eq!(user.inodes.used, 7); + let LustreQuotaScopeStatus::Available(group) = details.primary_group else { + panic!("group quota missing"); + }; + assert_eq!(group.blocks.severity, LustreQuotaSeverity::SoftExceeded); + assert_eq!(group.inodes.grace.state, LustreGraceState::Expired); + } + + #[test] + fn quota_parser_handles_trailing_markers_wrapped_rows_and_overflow() { + let details = parse_lustre_quota_scope( + "lfs warning: ignored +/lustre +1200* 900 1000 - 3* 4 5 - +", + ) + .unwrap(); + assert_eq!(details.blocks.used, 1200); + assert!(details.blocks.marked); + assert_eq!(details.blocks.severity, LustreQuotaSeverity::HardExceeded); + assert!(parse_lustre_quota_scope("/lustre 184467440737095516160 0 0 - 1 0 0 -").is_none()); + } + + #[test] + fn framed_lustre_statuses_and_partial_scope_errors_are_distinct() { + assert!(matches!( + parse_lustre_quota_snapshot("@@MMQ|STATUS|NOT_LUSTRE|nfs\n"), + LustreQuotaStatus::NotLustre { .. } + )); + assert!(matches!( + parse_lustre_quota_snapshot("@@MMQ|STATUS|UNAVAILABLE|lfs-missing\n"), + LustreQuotaStatus::Unavailable { + reason: LustreStatusReason::LfsMissing + } + )); + let status = parse_lustre_quota_snapshot( + "@@MMQ|STATUS|LUSTRE +@@MMQ|PATH|/data|/data +@@MMQ|PROJECT|1 +@@MMQ|IDENTITY|100|100|u|g +@@MMQ|BEGIN|project +/data 1 2 3 - 1 2 3 - +@@MMQ|END|project|0 +@@MMQ|BEGIN|user +permission denied +@@MMQ|END|user|1 +@@MMQ|BEGIN|group +/data 1 0 0 - 1 0 0 - +@@MMQ|END|group|0 +", + ); + let LustreQuotaStatus::Available(details) = status else { + panic!("expected available status"); + }; + assert!(matches!( + details.current_user, + LustreQuotaScopeStatus::Unavailable { + reason: LustreStatusReason::QuotaUnavailable(_) + } + )); + assert!(matches!( + details.project, + LustreQuotaScopeStatus::Available(_) + )); + assert!(matches!( + details.primary_group, + LustreQuotaScopeStatus::Available(_) + )); + assert!(matches!( + parse_lustre_quota_snapshot("garbage output\n"), + LustreQuotaStatus::Unavailable { + reason: LustreStatusReason::InvalidOutput(_) + } + )); + } + + #[test] + fn missing_project_id_does_not_hide_user_or_group_quota() { + let status = parse_lustre_quota_snapshot( + "@@MMQ|STATUS|LUSTRE +@@MMQ|PATH|/data/home/alice|/data +@@MMQ|PROJECT| +@@MMQ|IDENTITY|1000|100|alice|users +@@MMQ|BEGIN|project +project ID is unavailable +@@MMQ|END|project|65 +@@MMQ|BEGIN|user +/data 10 20 30 - 4 5 6 - +@@MMQ|END|user|0 +@@MMQ|BEGIN|group +/data 7 8 9 - 1 2 3 - +@@MMQ|END|group|0 +", + ); + let LustreQuotaStatus::Available(details) = status else { + panic!("expected available Lustre status"); + }; + assert_eq!(details.project_id, None); + assert!(matches!( + details.project, + LustreQuotaScopeStatus::Unavailable { + reason: LustreStatusReason::ProjectIdMissing + } + )); + assert!(matches!( + details.current_user, + LustreQuotaScopeStatus::Available(_) + )); + assert!(matches!( + details.primary_group, + LustreQuotaScopeStatus::Available(_) + )); + } } diff --git a/crates/mountmate-core/src/connection.rs b/crates/mountmate-core/src/connection.rs index 18b2009..b9f1955 100644 --- a/crates/mountmate-core/src/connection.rs +++ b/crates/mountmate-core/src/connection.rs @@ -5,6 +5,7 @@ use thiserror::Error; use crate::model::{ MAX_CONNECTION_TAGS, MAX_TAG_CHARS, normalize_port, normalize_tags, sanitize_id, + tag_update_only_preserves_existing, }; use crate::mountpoint::HOME_MOUNTPOINT_VALUE; use crate::{AuthMethod, ConnectionMethod, ServerConfig}; @@ -306,7 +307,13 @@ impl ConnectionDraft { pub fn validate(&self, servers: &[ServerConfig]) -> Result { let requirements = self.requirements(); let name = required_display_name(&self.name)?; - let tags = validate_tags(&self.tags, &self.folder)?; + let tags = validate_tags( + &self.tags, + &self.folder, + self.existing + .as_ref() + .map(|server| (server.tags.as_slice(), server.folder.as_str())), + )?; let folder = tags.first().cloned().unwrap_or_default(); let host = required_scalar(&self.host, "IP/Host")?; let user = required_scalar(&self.user, "User")?; @@ -575,7 +582,11 @@ fn required_display_name(value: &str) -> Result { Ok(value.into()) } -fn validate_tags(tags: &[String], legacy_folder: &str) -> Result, DraftError> { +fn validate_tags( + tags: &[String], + legacy_folder: &str, + existing: Option<(&[String], &str)>, +) -> Result, DraftError> { if tags.iter().any(|tag| tag.chars().any(char::is_control)) || legacy_folder.chars().any(char::is_control) { @@ -583,12 +594,21 @@ fn validate_tags(tags: &[String], legacy_folder: &str) -> Result, Dr } let mut normalized = tags.to_vec(); normalize_tags(&mut normalized, legacy_folder); - if normalized.len() > MAX_CONNECTION_TAGS { + let existing_normalized = existing.map(|(tags, folder)| { + let mut tags = tags.to_vec(); + normalize_tags(&mut tags, folder); + tags + }); + let preserves_existing = existing_normalized + .as_ref() + .is_some_and(|existing| tag_update_only_preserves_existing(&normalized, existing)); + if normalized.len() > MAX_CONNECTION_TAGS && !preserves_existing { return Err(DraftError::TooManyTags(MAX_CONNECTION_TAGS)); } if normalized .iter() .any(|tag| tag.chars().count() > MAX_TAG_CHARS) + && !preserves_existing { return Err(DraftError::TagTooLong(MAX_TAG_CHARS)); } @@ -1232,16 +1252,48 @@ mod tests { .map(|index| format!("tag-{index}")) .collect::>(); assert_eq!( - validate_tags(&too_many, ""), + validate_tags(&too_many, "", None), Err(DraftError::TooManyTags(crate::model::MAX_CONNECTION_TAGS)) ); let too_long = vec!["界".repeat(crate::model::MAX_TAG_CHARS + 1)]; assert_eq!( - validate_tags(&too_long, ""), + validate_tags(&too_long, "", None), Err(DraftError::TagTooLong(crate::model::MAX_TAG_CHARS)) ); } + #[test] + fn legacy_tag_limits_allow_unrelated_edits_and_progressive_cleanup() { + let mut existing = password_server(); + existing.tags = (0..=crate::model::MAX_CONNECTION_TAGS) + .map(|index| format!("tag-{index}")) + .collect(); + existing.folder = existing.tags[0].clone(); + + let unchanged = ConnectionDraft::from_server(&existing); + assert!(unchanged.validate(std::slice::from_ref(&existing)).is_ok()); + + let mut cleanup = ConnectionDraft::from_server(&existing); + cleanup.tags.pop(); + assert!(cleanup.validate(std::slice::from_ref(&existing)).is_ok()); + + let mut addition = ConnectionDraft::from_server(&existing); + addition.tags.push("new-tag".into()); + assert_eq!( + addition.validate(std::slice::from_ref(&existing)), + Err(DraftError::TooManyTags(crate::model::MAX_CONNECTION_TAGS)) + ); + + let mut overlong = password_server(); + overlong.tags = vec!["界".repeat(crate::model::MAX_TAG_CHARS + 1)]; + overlong.folder = overlong.tags[0].clone(); + assert!( + ConnectionDraft::from_server(&overlong) + .validate(std::slice::from_ref(&overlong)) + .is_ok() + ); + } + #[test] fn refreshing_an_ssh_import_preserves_user_folder() { let mut draft = ConnectionDraft { diff --git a/crates/mountmate-core/src/installed.rs b/crates/mountmate-core/src/installed.rs new file mode 100644 index 0000000..f08e7de --- /dev/null +++ b/crates/mountmate-core/src/installed.rs @@ -0,0 +1,244 @@ +//! Identity and install-policy primitives for the Windows installed edition. +//! +//! Registry access intentionally lives in `mountmate-platform`. This module +//! validates the registry record against the canonical fixed path and owns the +//! version/uninstall policy so it can be tested on every host platform. + +use std::path::{Path, PathBuf}; + +use semver::Version; +use thiserror::Error; + +pub const INSTALLED_MARKER_SCHEMA_VERSION: u32 = 1; +pub const WINDOWS_INSTALL_RECORD_KEY: &str = r"Software\Stardust\SSH MountMate\Install"; +pub const WINDOWS_INSTALL_DIRECTORY: &str = r"Programs\SSH MountMate"; +pub const WINDOWS_EXECUTABLE_NAME: &str = "SSHMountMate.exe"; +pub const WINDOWS_AUMID: &str = "Stardust.SSHMountMate"; + +/// Values written by the per-user installer under the HKCU install record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstalledInstallRecord { + pub schema_version: u32, + pub version: String, + pub install_root: PathBuf, + pub executable_path: PathBuf, + pub aumid: String, + pub architecture: String, +} + +/// A registry record that has been checked against both the current executable +/// and the canonical `%LOCALAPPDATA%` install location. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstalledEditionIdentity { + pub version: Version, + pub install_root: PathBuf, + pub executable_path: PathBuf, + pub aumid: String, + pub architecture: String, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum InstalledIdentityError { + #[error("installed identity marker schema {0} is unsupported")] + UnsupportedSchema(u32), + #[error("installed identity marker has an invalid version: {0}")] + InvalidVersion(String), + #[error("installed identity marker has an invalid AUMID")] + InvalidAumid, + #[error("installed identity marker does not use the canonical install root")] + InstallRootMismatch, + #[error("installed identity marker does not use the canonical executable path")] + ExecutablePathMismatch, + #[error("current executable is not the canonical installed executable")] + CurrentExecutableMismatch, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum InstallPolicyError { + #[error( + "installed version {existing} is newer than requested version {requested}; refusing implicit downgrade" + )] + DowngradeBlocked { + existing: Version, + requested: Version, + }, + #[error("invalid installed version: {0}")] + InvalidExistingVersion(String), + #[error("invalid requested version: {0}")] + InvalidRequestedVersion(String), +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum UninstallPreflightError { + #[error("SSH MountMate has active mounts; unmount them before uninstalling")] + ActiveMounts, +} + +/// Build the only supported installed-edition paths for a Windows user. +pub fn canonical_windows_paths(local_app_data: &Path) -> (PathBuf, PathBuf) { + let root = local_app_data.join(WINDOWS_INSTALL_DIRECTORY); + (root.clone(), root.join(WINDOWS_EXECUTABLE_NAME)) +} + +/// Validate an HKCU marker and the process path together. A path that merely +/// happens to be under `%LOCALAPPDATA%` is not considered installed without a +/// matching marker. +pub fn validate_installed_identity( + record: &InstalledInstallRecord, + current_executable: &Path, + local_app_data: &Path, +) -> Result { + if record.schema_version != INSTALLED_MARKER_SCHEMA_VERSION { + return Err(InstalledIdentityError::UnsupportedSchema( + record.schema_version, + )); + } + let version = Version::parse(&record.version) + .map_err(|_| InstalledIdentityError::InvalidVersion(record.version.clone()))?; + if version.to_string() != record.version { + return Err(InstalledIdentityError::InvalidVersion( + record.version.clone(), + )); + } + if record.aumid != WINDOWS_AUMID { + return Err(InstalledIdentityError::InvalidAumid); + } + + let (canonical_root, canonical_executable) = canonical_windows_paths(local_app_data); + if !same_windows_path(&record.install_root, &canonical_root) { + return Err(InstalledIdentityError::InstallRootMismatch); + } + if !same_windows_path(&record.executable_path, &canonical_executable) { + return Err(InstalledIdentityError::ExecutablePathMismatch); + } + if !same_windows_path(current_executable, &canonical_executable) { + return Err(InstalledIdentityError::CurrentExecutableMismatch); + } + + Ok(InstalledEditionIdentity { + version, + install_root: canonical_root, + executable_path: canonical_executable, + aumid: record.aumid.clone(), + architecture: record.architecture.clone(), + }) +} + +/// Return an error when installing an older version over an installed edition. +/// Equal versions are accepted so repair/reinstall remains possible. +pub fn enforce_no_downgrade( + existing_version: Option<&str>, + requested_version: &str, +) -> Result<(), InstallPolicyError> { + let requested = Version::parse(requested_version) + .map_err(|_| InstallPolicyError::InvalidRequestedVersion(requested_version.into()))?; + if requested.to_string() != requested_version { + return Err(InstallPolicyError::InvalidRequestedVersion( + requested_version.into(), + )); + } + let Some(existing_version) = existing_version else { + return Ok(()); + }; + let existing = Version::parse(existing_version) + .map_err(|_| InstallPolicyError::InvalidExistingVersion(existing_version.into()))?; + if existing.to_string() != existing_version { + return Err(InstallPolicyError::InvalidExistingVersion( + existing_version.into(), + )); + } + if existing > requested { + return Err(InstallPolicyError::DowngradeBlocked { + existing, + requested, + }); + } + Ok(()) +} + +/// Alias suitable for installer/platform callers. +pub fn check_install_version( + existing_version: Option<&str>, + requested_version: &str, +) -> Result<(), InstallPolicyError> { + enforce_no_downgrade(existing_version, requested_version) +} + +/// Common uninstall hook used by the app's future installer preflight CLI. +pub fn enforce_uninstall_preflight(active_mounts: bool) -> Result<(), UninstallPreflightError> { + if active_mounts { + Err(UninstallPreflightError::ActiveMounts) + } else { + Ok(()) + } +} + +fn same_windows_path(left: &Path, right: &Path) -> bool { + windows_path_key(left) == windows_path_key(right) +} + +fn windows_path_key(path: &Path) -> String { + let mut key = path.to_string_lossy().replace('/', "\\"); + while key.ends_with('\\') && key.len() > 3 { + key.pop(); + } + key.to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(local_app_data: &Path, version: &str) -> InstalledInstallRecord { + let (root, executable) = canonical_windows_paths(local_app_data); + InstalledInstallRecord { + schema_version: INSTALLED_MARKER_SCHEMA_VERSION, + version: version.into(), + install_root: root, + executable_path: executable, + aumid: WINDOWS_AUMID.into(), + architecture: "x64".into(), + } + } + + #[test] + fn installed_identity_requires_marker_and_canonical_path() { + let local = Path::new(r"C:\Users\alice\AppData\Local"); + let marker = record(local, "0.6.0-alpha.1"); + let current = + Path::new(r"c:/users/alice/appdata/local/programs/ssh mountmate/SSHMountMate.exe"); + assert!(validate_installed_identity(&marker, current, local).is_ok()); + assert_eq!( + validate_installed_identity(&marker, Path::new(r"C:\tmp\SSHMountMate.exe"), local), + Err(InstalledIdentityError::CurrentExecutableMismatch) + ); + } + + #[test] + fn marker_path_mismatch_is_rejected_even_when_process_path_is_canonical() { + let local = Path::new(r"C:\Users\alice\AppData\Local"); + let mut marker = record(local, "0.6.0-alpha.1"); + marker.executable_path = PathBuf::from(r"C:\Users\alice\Desktop\SSHMountMate.exe"); + let canonical = canonical_windows_paths(local).1; + assert_eq!( + validate_installed_identity(&marker, &canonical, local), + Err(InstalledIdentityError::ExecutablePathMismatch) + ); + } + + #[test] + fn higher_installed_version_blocks_downgrade_but_equal_reinstall_is_allowed() { + assert!(enforce_no_downgrade(Some("0.6.0-alpha.2"), "0.6.0-alpha.1").is_err()); + assert!(enforce_no_downgrade(Some("0.6.0-alpha.1"), "0.6.0-alpha.1").is_ok()); + assert!(enforce_no_downgrade(Some("0.5.0"), "0.6.0-alpha.1").is_ok()); + } + + #[test] + fn uninstall_preflight_blocks_active_mounts() { + assert_eq!( + enforce_uninstall_preflight(true), + Err(UninstallPreflightError::ActiveMounts) + ); + assert!(enforce_uninstall_preflight(false).is_ok()); + } +} diff --git a/crates/mountmate-core/src/lib.rs b/crates/mountmate-core/src/lib.rs index 548fa01..c40d3a5 100644 --- a/crates/mountmate-core/src/lib.rs +++ b/crates/mountmate-core/src/lib.rs @@ -3,9 +3,11 @@ pub mod capacity; pub mod connection; pub mod credential; pub mod dependency; +pub mod installed; pub mod interactive_ssh; pub mod model; pub mod mountpoint; +pub mod navigation_refresh; pub mod paths; pub mod plink_binary; pub mod process; diff --git a/crates/mountmate-core/src/model.rs b/crates/mountmate-core/src/model.rs index 7c9f1f2..43bc796 100644 --- a/crates/mountmate-core/src/model.rs +++ b/crates/mountmate-core/src/model.rs @@ -4,13 +4,17 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; use uuid::Uuid; -pub const SETTINGS_SCHEMA_VERSION: u32 = 14; +pub const SETTINGS_SCHEMA_VERSION: u32 = 15; pub const DEFAULT_VFS_UPLOAD_TRANSFERS: u16 = 4; pub const MIN_VFS_UPLOAD_TRANSFERS: u16 = 1; pub const MAX_VFS_UPLOAD_TRANSFERS: u16 = 32; pub const MAX_CONNECTION_TAGS: usize = 8; pub const MAX_TAG_CHARS: usize = 24; +pub fn tag_update_only_preserves_existing(candidate: &[String], existing: &[String]) -> bool { + candidate.iter().all(|tag| existing.contains(tag)) +} + fn default_port() -> String { "22".into() } @@ -425,6 +429,10 @@ pub struct Settings { pub auto_show_transfers: bool, #[serde(default = "default_true")] pub auto_check_updates: bool, + /// Best-effort Explorer navigation refresh. The app applies this only + /// after validating the Windows installed-edition identity. + #[serde(default = "default_true")] + pub navigation_refresh_enabled: bool, #[serde(default = "default_language")] pub language: String, #[serde(default = "default_appearance_mode")] @@ -461,6 +469,7 @@ impl Default for Settings { startup_all: false, auto_show_transfers: true, auto_check_updates: true, + navigation_refresh_enabled: true, language: default_language(), appearance_mode: default_appearance_mode(), accent_color: default_accent_color(), @@ -724,6 +733,23 @@ mod tests { assert_eq!(migrated.font_scale, FontScale::Standard); } + #[test] + fn legacy_settings_enable_navigation_refresh_by_default() { + let legacy: Settings = serde_json::from_str(r#"{"settings_schema_version":14}"#).unwrap(); + assert!(legacy.navigation_refresh_enabled); + assert!(legacy.migrate().navigation_refresh_enabled); + + let disabled = Settings { + navigation_refresh_enabled: false, + ..Settings::default() + }; + assert!( + !serde_json::from_value::(serde_json::to_value(disabled).unwrap()) + .unwrap() + .navigation_refresh_enabled + ); + } + #[test] fn font_scale_variants_round_trip_through_settings_json() { for font_scale in FontScale::ALL { diff --git a/crates/mountmate-core/src/navigation_refresh.rs b/crates/mountmate-core/src/navigation_refresh.rs new file mode 100644 index 0000000..d9c7b33 --- /dev/null +++ b/crates/mountmate-core/src/navigation_refresh.rs @@ -0,0 +1,449 @@ +//! Safety and scheduling primitives for passive Explorer cache refresh. +//! +//! This module is deliberately independent from the Windows observer. It +//! makes the path and queue policy testable on every platform and gives the +//! tray app a small, nonblocking work coordinator. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use thiserror::Error; + +use crate::model::MountState; +use crate::rclone::{normalize_explorer_refresh_path, normalize_refresh_relative_path}; + +pub const REFRESH_DEDUPE_WINDOW: Duration = Duration::from_secs(5); +pub const MAX_PENDING_REFRESHES: usize = 32; +pub const MAX_RUNNING_REFRESHES: usize = 2; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationEvent { + pub window_id: u64, + pub target: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MountIdentity { + pub server_id: String, + pub pid: u32, + pub process_started_at: Option, +} + +impl MountIdentity { + pub fn from_state(state: &MountState) -> Self { + Self { + server_id: state.server_id.clone(), + pid: state.pid, + process_started_at: state.process_started_at, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RefreshJob { + pub token: u64, + pub window_id: u64, + pub target: PathBuf, + pub relative_dir: String, + pub identity: MountIdentity, + pub key: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnqueueResult { + Queued, + Deduplicated, + DroppedOldest, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum NavigationPathError { + #[error("path contains a NUL or control character")] + ControlCharacter, + #[error("path traversal is not allowed")] + Traversal, + #[error("device, alternate data stream, or shell namespace path is not allowed")] + DeviceOrNamespace, + #[error("path is outside the mounted directory")] + OutsideMount, +} + +/// Resolve an Explorer path to the VFS directory that should be refreshed. +/// A regular file is mapped to its parent when metadata can identify it. +pub fn validated_relative_dir( + requested: &Path, + mountpoint: &Path, + windows: bool, +) -> Option { + validated_relative_dir_result(requested, mountpoint, windows).ok() +} + +pub fn validated_relative_dir_result( + requested: &Path, + mountpoint: &Path, + windows: bool, +) -> Result { + let raw_requested = requested.to_string_lossy(); + let raw_mountpoint = mountpoint.to_string_lossy(); + validate_raw_path(&raw_requested, windows)?; + validate_raw_path(&raw_mountpoint, windows)?; + + let requested = normalize_explorer_refresh_path(&raw_requested, windows); + let mountpoint = normalize_explorer_refresh_path(&raw_mountpoint, windows); + let target = Path::new(&requested); + let mount = Path::new(&mountpoint); + let target = if std::fs::metadata(target).is_ok_and(|metadata| metadata.is_file()) { + target.parent().unwrap_or(target) + } else { + target + }; + let requested = target.to_string_lossy(); + let mountpoint = mount.to_string_lossy(); + let requested_normalized = lexical_path(&requested, windows); + let mountpoint_normalized = lexical_path(&mountpoint, windows); + let equal = if windows { + requested_normalized.eq_ignore_ascii_case(&mountpoint_normalized) + } else { + requested_normalized == mountpoint_normalized + }; + if equal { + return Ok(String::new()); + } + let prefix = format!("{mountpoint_normalized}/"); + let relative = if windows { + requested_normalized.get(prefix.len()..).filter(|_| { + requested_normalized + .get(..prefix.len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(&prefix)) + }) + } else { + requested_normalized.strip_prefix(&prefix) + } + .ok_or(NavigationPathError::OutsideMount)?; + if relative.is_empty() { + return Ok(String::new()); + } + if relative.split('/').any(|component| component == "..") { + return Err(NavigationPathError::Traversal); + } + if relative.split('/').any(|component| component.contains(':')) { + return Err(NavigationPathError::DeviceOrNamespace); + } + Ok(normalize_refresh_relative_path(relative)) +} + +fn validate_raw_path(value: &str, windows: bool) -> Result<(), NavigationPathError> { + if value.chars().any(|ch| ch == '\0' || ch.is_control()) { + return Err(NavigationPathError::ControlCharacter); + } + let normalized = value.replace('\\', "/"); + let lower = normalized.to_ascii_lowercase(); + if lower.starts_with("shell:") + || lower.starts_with("::{") + || lower.starts_with("::") + || lower.starts_with("//./") + || lower.starts_with("//?/") + || lower.starts_with("/device/") + || (windows && lower.starts_with("//")) + { + return Err(NavigationPathError::DeviceOrNamespace); + } + if normalized.split('/').any(|component| component == "..") { + return Err(NavigationPathError::Traversal); + } + if windows && normalized.split('/').any(is_reserved_windows_component) { + return Err(NavigationPathError::DeviceOrNamespace); + } + Ok(()) +} + +fn is_reserved_windows_component(component: &str) -> bool { + let component = component.trim_end_matches([' ', '.']); + let stem = component.split('.').next().unwrap_or_default(); + let upper = stem.to_ascii_uppercase(); + matches!( + upper.as_str(), + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" + ) || upper + .strip_prefix("COM") + .or_else(|| upper.strip_prefix("LPT")) + .is_some_and(|number| matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")) +} + +fn lexical_path(value: &str, windows: bool) -> String { + let mut normalized = value.replace('\\', "/"); + while normalized.ends_with('/') && normalized.len() > 1 { + normalized.pop(); + } + if windows { + normalized.make_ascii_lowercase(); + } + normalized +} + +#[derive(Debug, Default)] +pub struct RefreshScheduler { + pending: VecDeque, + running: HashMap, + running_mounts: HashSet, + last_enqueued: HashMap, + next_token: u64, +} + +impl RefreshScheduler { + pub fn new() -> Self { + Self { + next_token: 1, + ..Self::default() + } + } + + pub fn enqueue( + &mut self, + event: NavigationEvent, + relative_dir: String, + identity: MountIdentity, + now: Instant, + ) -> EnqueueResult { + self.last_enqueued + .retain(|_, last| now.saturating_duration_since(*last) < REFRESH_DEDUPE_WINDOW); + let key = canonical_key(&event.target); + if self + .last_enqueued + .get(&key) + .is_some_and(|last| now.saturating_duration_since(*last) < REFRESH_DEDUPE_WINDOW) + || self.pending.iter().any(|job| job.key == key) + || self.running.values().any(|job| job.key == key) + { + return EnqueueResult::Deduplicated; + } + let job = RefreshJob { + token: self.next_token, + window_id: event.window_id, + target: event.target, + relative_dir, + identity, + key: key.clone(), + }; + self.next_token = self.next_token.wrapping_add(1).max(1); + let dropped = if self.pending.len() >= MAX_PENDING_REFRESHES { + if let Some(dropped) = self.pending.pop_front() { + self.last_enqueued.remove(&dropped.key); + } + true + } else { + false + }; + self.last_enqueued.insert(key, now); + self.pending.push_back(job); + if dropped { + EnqueueResult::DroppedOldest + } else { + EnqueueResult::Queued + } + } + + pub fn take_ready(&mut self) -> Option { + if self.running.len() >= MAX_RUNNING_REFRESHES { + return None; + } + let index = self + .pending + .iter() + .position(|job| !self.running_mounts.contains(&job.identity.server_id))?; + let job = self.pending.remove(index)?; + self.running_mounts.insert(job.identity.server_id.clone()); + self.running.insert(job.token, job.clone()); + Some(job) + } + + pub fn finish(&mut self, token: u64) -> Option { + let job = self.running.remove(&token)?; + self.running_mounts.remove(&job.identity.server_id); + Some(job) + } + + pub fn cancel_stale(&mut self, current: &HashMap) { + self.pending.retain(|job| { + current + .get(&job.identity.server_id) + .is_some_and(|identity| identity == &job.identity) + }); + } + + pub fn is_current(&self, job: &RefreshJob, current: &HashMap) -> bool { + current + .get(&job.identity.server_id) + .is_some_and(|identity| identity == &job.identity) + } + + pub fn pending_len(&self) -> usize { + self.pending.len() + } + + pub fn running_len(&self) -> usize { + self.running.len() + } +} + +fn canonical_key(path: &Path) -> String { + path.to_string_lossy() + .replace('\\', "/") + .to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn identity(id: &str, pid: u32) -> MountIdentity { + MountIdentity { + server_id: id.into(), + pid, + process_started_at: Some(1), + } + } + + fn event(path: &str) -> NavigationEvent { + NavigationEvent { + window_id: 1, + target: PathBuf::from(path), + } + } + + #[test] + fn scheduler_deduplicates_paths_for_five_seconds() { + let now = Instant::now(); + let mut scheduler = RefreshScheduler::new(); + assert_eq!( + scheduler.enqueue(event("/mnt/a"), "".into(), identity("a", 1), now), + EnqueueResult::Queued + ); + assert_eq!( + scheduler.enqueue( + event("/mnt/a"), + "".into(), + identity("a", 1), + now + Duration::from_secs(4) + ), + EnqueueResult::Deduplicated + ); + assert_eq!( + scheduler.enqueue( + event("/mnt/a"), + "".into(), + identity("a", 1), + now + REFRESH_DEDUPE_WINDOW + ), + EnqueueResult::Deduplicated + ); + let job = scheduler.take_ready().unwrap(); + scheduler.finish(job.token); + assert_eq!( + scheduler.enqueue( + event("/mnt/a"), + "".into(), + identity("a", 1), + now + REFRESH_DEDUPE_WINDOW + ), + EnqueueResult::Queued + ); + } + + #[test] + fn scheduler_bounds_pending_and_limits_global_and_mount_concurrency() { + let now = Instant::now(); + let mut scheduler = RefreshScheduler::new(); + for index in 0..(MAX_PENDING_REFRESHES + 4) { + let path = format!("/mnt/{index}"); + scheduler.enqueue( + event(&path), + "".into(), + identity(&format!("m{index}"), index as u32), + now, + ); + } + assert_eq!(scheduler.pending_len(), MAX_PENDING_REFRESHES); + assert!(scheduler.take_ready().is_some()); + assert!(scheduler.take_ready().is_some()); + assert!(scheduler.take_ready().is_none()); + } + + #[test] + fn scheduler_allows_one_running_job_per_mount() { + let now = Instant::now(); + let mut scheduler = RefreshScheduler::new(); + scheduler.enqueue(event("/mnt/a/one"), "one".into(), identity("a", 1), now); + scheduler.enqueue(event("/mnt/a/two"), "two".into(), identity("a", 1), now); + let first = scheduler.take_ready().unwrap(); + assert!(scheduler.take_ready().is_none()); + scheduler.finish(first.token); + assert!(scheduler.take_ready().is_some()); + } + + #[test] + fn path_validation_rejects_traversal_ads_devices_and_sibling_collisions() { + let mount = Path::new("Y:\\Mount"); + assert_eq!( + validated_relative_dir_result(Path::new("Y:\\Mount\\folder"), mount, true).unwrap(), + "folder" + ); + assert!(matches!( + validated_relative_dir_result(Path::new("Y:\\Mount\\..\\other"), mount, true), + Err(NavigationPathError::Traversal) + )); + assert!(matches!( + validated_relative_dir_result(Path::new("Y:\\Mount\\file:stream"), mount, true), + Err(NavigationPathError::DeviceOrNamespace) + )); + assert!(matches!( + validated_relative_dir_result(Path::new("Y:\\Mount2"), mount, true), + Err(NavigationPathError::OutsideMount) + )); + for path in [ + r"\\server\share\folder", + r"\\?\Y:\Mount\folder", + r"Y:\Mount\NUL", + r"Y:\Mount\con.txt", + r"Y:\Mount\COM1 ", + r"Y:\Mount\lpt9.log", + ] { + assert!(matches!( + validated_relative_dir_result(Path::new(path), mount, true), + Err(NavigationPathError::DeviceOrNamespace) + )); + } + } + + #[test] + fn stale_mount_identity_drops_pending_requests() { + let now = Instant::now(); + let mut scheduler = RefreshScheduler::new(); + scheduler.enqueue(event("/mnt/a"), "".into(), identity("a", 1), now); + let mut current = HashMap::new(); + current.insert("a".into(), identity("a", 2)); + scheduler.cancel_stale(¤t); + assert_eq!(scheduler.pending_len(), 0); + } + + #[test] + fn scheduler_prunes_expired_dedupe_history() { + let now = Instant::now(); + let mut scheduler = RefreshScheduler::new(); + scheduler.enqueue(event("/mnt/old"), "".into(), identity("old", 1), now); + let job = scheduler.take_ready().unwrap(); + scheduler.finish(job.token); + assert_eq!(scheduler.last_enqueued.len(), 1); + + scheduler.enqueue( + event("/mnt/new"), + "".into(), + identity("new", 2), + now + REFRESH_DEDUPE_WINDOW, + ); + assert_eq!(scheduler.last_enqueued.len(), 1); + assert!(scheduler.last_enqueued.contains_key("/mnt/new")); + } +} diff --git a/crates/mountmate-core/src/rc.rs b/crates/mountmate-core/src/rc.rs index 20a94c3..b4be95c 100644 --- a/crates/mountmate-core/src/rc.rs +++ b/crates/mountmate-core/src/rc.rs @@ -83,6 +83,13 @@ impl HttpRcClient { ) -> Result { 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. + pub fn refresh_remote_cache(&self, relative_dir: &str) -> Result<(), RcError> { + refresh_remote_cache(self, relative_dir) + } } impl RcApi for HttpRcClient { @@ -186,6 +193,17 @@ pub fn refresh_remote_snapshot( }) } +pub fn refresh_remote_cache(api: &impl RcApi, relative_dir: &str) -> Result<(), RcError> { + let params = if relative_dir.is_empty() { + json!({}) + } else { + json!({"dir": relative_dir}) + }; + api.call("vfs/forget", params.clone())?; + api.call("vfs/refresh", params)?; + Ok(()) +} + #[cfg(test)] mod tests { use std::cell::RefCell; @@ -290,6 +308,22 @@ mod tests { ); } + #[test] + fn cache_only_refresh_calls_exactly_forget_and_refresh() { + let api = FakeRc::new([json!({}), json!({}), json!({})]); + refresh_remote_cache(&api, "subdir").unwrap(); + let calls = api.calls.borrow(); + assert_eq!( + calls + .iter() + .map(|(method, _)| method.as_str()) + .collect::>(), + ["vfs/forget", "vfs/refresh"] + ); + assert_eq!(calls[0].1, json!({"dir": "subdir"})); + assert_eq!(calls[1].1, json!({"dir": "subdir"})); + } + #[test] fn root_refresh_never_sends_the_legacy_quote_remote() { let api = FakeRc::new([ diff --git a/crates/mountmate-core/src/service.rs b/crates/mountmate-core/src/service.rs index 1379698..ef4c2c4 100644 --- a/crates/mountmate-core/src/service.rs +++ b/crates/mountmate-core/src/service.rs @@ -9,11 +9,15 @@ use std::os::windows::process::CommandExt; use thiserror::Error; -use crate::capacity::{CapacityError, CapacityInfo, mounted_capacity}; +use crate::capacity::{ + CapacityError, CapacityInfo, CapacitySnapshot, capacity_snapshot_with_connector, + mounted_capacity_with_connector, +}; use crate::connection::{SshImportPlan, plan_ssh_imports}; use crate::credential::{CredentialError, SystemCredentialStore, hydrate_server_from_system}; use crate::interactive_ssh::{InteractiveSshError, InteractiveSshSession}; use crate::mountpoint::{HOME_MOUNTPOINT_VALUE, MountpointAllocator, SystemMountpointProbe}; +use crate::navigation_refresh::{MountIdentity, RefreshJob}; use crate::paths::AppPaths; use crate::process::MountStatus; use crate::rc::{HttpRcClient, RcError, RefreshResult}; @@ -61,6 +65,8 @@ pub enum ServiceError { Obscure(String), #[error("the selected path is not inside an active SSH MountMate mount: {0}")] PathOutsideMount(String), + #[error("the mount changed before its queued refresh could run: {0}")] + StaleMount(String), } #[derive(Debug, Clone)] @@ -158,8 +164,40 @@ impl MountService { let external_ssh = self.interactive_ssh_arguments(server)?; let prepared_server = self.prepare_server_credentials(server)?; self.ensure_remote(&prepared_server, external_ssh.as_deref())?; - let result = mounted_capacity(&prepared_server, &state, &self.paths.rclone_config()) - .map_err(ServiceError::from); + let result = mounted_capacity_with_connector( + &prepared_server, + &state, + &self.paths.rclone_config(), + external_ssh.as_deref(), + ) + .map_err(ServiceError::from); + self.finish_secret_use(server, &result)?; + result + } + + /// Return the existing display capacity together with Lustre project, + /// current-user, and primary-group quota details. Interactive/shared SSH + /// sessions are intentionally not bypassed by spawning a second SSH + /// process; local mount statistics remain available in that case. + pub fn capacity_snapshot( + &self, + server: &ServerConfig, + ) -> Result, ServiceError> { + if self.status(&server.id)? != MountStatus::Mounted { + return Ok(None); + } + let state: MountState = read_json(&self.paths.state_file(&server.id))?; + let external_ssh = self.interactive_ssh_arguments(server)?; + let prepared_server = self.prepare_server_credentials(server)?; + self.ensure_remote(&prepared_server, external_ssh.as_deref())?; + let result = capacity_snapshot_with_connector( + &prepared_server, + &state, + &self.paths.rclone_config(), + external_ssh.as_deref(), + ) + .map(Some) + .map_err(ServiceError::from); self.finish_secret_use(server, &result)?; result } @@ -216,6 +254,27 @@ 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. + 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( + &state.rc_addr, + &state.rc_user, + &state.rc_pass, + Duration::from_secs(3), + )?; + if client.process_id()? != job.identity.pid { + return Err(ServiceError::StaleMount(job.identity.server_id.clone())); + } + client.refresh_remote_cache(&job.relative_dir)?; + Ok(()) + } + pub fn obscure_secret(&self, secret: &str) -> Result { if secret.is_empty() { return Err(ServiceError::Obscure("secret is empty".into())); @@ -696,6 +755,56 @@ mod tests { assert_eq!(relative_refresh_dir("Z:\\Folder", "Y:", true), None); } + #[cfg(unix)] + #[test] + fn cache_refresh_rejects_a_replaced_mount_before_rc_side_effects() { + let temp = tempdir().unwrap(); + let paths = AppPaths { + config_dir: temp.path().join("config"), + cache_dir: temp.path().join("cache"), + state_dir: temp.path().join("state"), + data_dir: temp.path().join("data"), + }; + fs::create_dir_all(&paths.state_dir).unwrap(); + let state = MountState { + pid: 22, + server_id: "alpha".into(), + remote: "alpha:".into(), + mountpoint: temp.path().join("mount"), + log: temp.path().join("mount.log"), + rc_addr: "127.0.0.1:1".into(), + rc_user: "user".into(), + rc_pass: "pass".into(), + phase: crate::MountPhase::Mounted, + process_started_at: Some(2), + rclone: PathBuf::new(), + mount_backend: crate::MountBackend::Fuse, + }; + fs::write( + paths.state_file("alpha"), + serde_json::to_vec(&state).unwrap(), + ) + .unwrap(); + let service = MountService::new(paths, temp.path().join("app")); + let job = RefreshJob { + token: 1, + window_id: 1, + target: temp.path().join("mount/folder"), + relative_dir: "folder".into(), + identity: MountIdentity { + server_id: "alpha".into(), + pid: 11, + process_started_at: Some(1), + }, + key: "mount/folder".into(), + }; + + assert!(matches!( + service.refresh_job_cache_only(&job), + Err(ServiceError::StaleMount(id)) if id == "alpha" + )); + } + #[cfg(unix)] #[test] fn missing_interactive_session_does_not_start_a_login_process() { diff --git a/crates/mountmate-core/src/storage.rs b/crates/mountmate-core/src/storage.rs index f0a3f37..f6a509e 100644 --- a/crates/mountmate-core/src/storage.rs +++ b/crates/mountmate-core/src/storage.rs @@ -201,6 +201,10 @@ pub fn update_server_preferences_batch( } } for update in updates { + let server = servers + .iter_mut() + .find(|server| server.id == update.id) + .expect("validated connection ID"); if update .tags .as_ref() @@ -214,7 +218,11 @@ pub fn update_server_preferences_batch( if let Some(tags) = &update.tags { let mut normalized_tags = tags.clone(); crate::model::normalize_tags(&mut normalized_tags, ""); - if normalized_tags.len() > crate::model::MAX_CONNECTION_TAGS { + let mut existing_tags = server.tags.clone(); + crate::model::normalize_tags(&mut existing_tags, &server.folder); + let preserves_existing = + crate::model::tag_update_only_preserves_existing(&normalized_tags, &existing_tags); + if normalized_tags.len() > crate::model::MAX_CONNECTION_TAGS && !preserves_existing { return Err(StorageError::InvalidPreferenceUpdate(format!( "connection {} may have at most {} tags", update.id, @@ -224,6 +232,7 @@ pub fn update_server_preferences_batch( if normalized_tags .iter() .any(|tag| tag.chars().count() > crate::model::MAX_TAG_CHARS) + && !preserves_existing { return Err(StorageError::InvalidPreferenceUpdate(format!( "tags for connection {} must be at most {} Unicode characters each", @@ -232,10 +241,6 @@ pub fn update_server_preferences_batch( ))); } } - let server = servers - .iter_mut() - .find(|server| server.id == update.id) - .expect("validated connection ID"); if let Some(mut tags) = update.tags.clone() { crate::model::normalize_tags(&mut tags, ""); server.tags = tags; @@ -1125,6 +1130,56 @@ mod tests { ); } + #[test] + fn batch_preferences_allow_progressive_cleanup_of_legacy_tag_limits() { + let temp = tempdir().unwrap(); + let paths = AppPaths { + config_dir: temp.path().join("config"), + cache_dir: temp.path().join("cache"), + state_dir: temp.path().join("state"), + data_dir: temp.path().join("data"), + }; + let legacy_tags = (0..=crate::model::MAX_CONNECTION_TAGS) + .map(|index| format!("tag-{index}")) + .collect::>(); + save_servers( + &paths, + &[ServerConfig { + id: "alpha".into(), + folder: legacy_tags[0].clone(), + tags: legacy_tags.clone(), + ..ServerConfig::default() + }], + ) + .unwrap(); + + let reduced = legacy_tags[..legacy_tags.len() - 1].to_vec(); + let updated = update_server_preferences_batch( + &paths, + &[ServerPreferenceUpdate { + id: "alpha".into(), + tags: Some(reduced.clone()), + auto_mount_at_login: None, + }], + ) + .unwrap(); + assert_eq!(updated[0].tags, reduced); + + let mut invalid_addition = updated[0].tags.clone(); + invalid_addition.push("new-tag".into()); + assert!( + update_server_preferences_batch( + &paths, + &[ServerPreferenceUpdate { + id: "alpha".into(), + tags: Some(invalid_addition), + auto_mount_at_login: None, + }] + ) + .is_err() + ); + } + #[test] fn batch_remove_requires_existing_unique_ids_and_preserves_order() { let temp = tempdir().unwrap(); diff --git a/crates/mountmate-platform/Cargo.toml b/crates/mountmate-platform/Cargo.toml index e78415f..3868fc4 100644 --- a/crates/mountmate-platform/Cargo.toml +++ b/crates/mountmate-platform/Cargo.toml @@ -8,8 +8,10 @@ repository.workspace = true [dependencies] mountmate-core = { path = "../mountmate-core" } +async-channel.workspace = true notify-rust.workspace = true thiserror.workspace = true +url.workspace = true [target.'cfg(target_os = "macos")'.dependencies] objc2.workspace = true @@ -31,6 +33,8 @@ plist.workspace = true windows = { workspace = true, features = [ "Win32_Foundation", "Win32_System_Com", + "Win32_System_Ole", + "Win32_System_Variant", "Win32_System_WinRT", "Win32_UI_Shell", ] } @@ -42,6 +46,7 @@ windows-sys = { workspace = true, features = [ "Win32_Storage_FileSystem", "Win32_System_Registry", "Win32_System_Threading", + "Win32_UI_Shell", ] } [dev-dependencies] diff --git a/crates/mountmate-platform/src/lib.rs b/crates/mountmate-platform/src/lib.rs index f589219..f6f08d3 100644 --- a/crates/mountmate-platform/src/lib.rs +++ b/crates/mountmate-platform/src/lib.rs @@ -1,8 +1,16 @@ use std::path::Path; +#[cfg(windows)] +use mountmate_core::installed::{ + InstallPolicyError, InstalledInstallRecord, enforce_no_downgrade, validate_installed_identity, +}; +use mountmate_core::installed::{InstalledEditionIdentity, enforce_uninstall_preflight}; use mountmate_core::ssh::SshPermissionControl; use thiserror::Error; +pub mod navigation; +pub use navigation::{NavigationObserver, notify_shell_updated_dir, start_navigation_observer}; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GlobalProgressState { Hidden, @@ -48,6 +56,29 @@ pub trait PlatformIntegration: Send + Sync { fn register_file_manager_menu(&self, executable: &Path) -> Result<(), PlatformError>; fn unregister_file_manager_menu(&self) -> Result<(), PlatformError>; fn set_login_startup(&self, executable: &Path, enabled: bool) -> Result<(), PlatformError>; + /// Return the installed identity only when HKCU's marker and the canonical + /// fixed executable path both validate. Portable copies return `None`. + fn installed_edition_identity( + &self, + current_executable: &Path, + ) -> Result, PlatformError> { + let _ = current_executable; + Err(PlatformError::Unsupported("installed-edition identity")) + } + /// Enforce the no-implicit-downgrade installer policy against the HKCU + /// marker. Missing markers are accepted for first-time installation. + fn enforce_installed_version(&self, requested_version: &str) -> Result<(), PlatformError> { + let _ = requested_version; + Err(PlatformError::Unsupported( + "installed-edition version policy", + )) + } + /// Hook for the app's future uninstall preflight command. Inno Setup uses + /// a non-zero result to block removal while mounts are active. + fn uninstall_preflight(&self, active_mounts: bool) -> Result<(), PlatformError> { + enforce_uninstall_preflight(active_mounts) + .map_err(|error| PlatformError::Failed(error.to_string())) + } } pub struct Platform; @@ -214,6 +245,212 @@ impl PlatformIntegration for Platform { fn set_login_startup(&self, executable: &Path, enabled: bool) -> Result<(), PlatformError> { set_login_startup(executable, enabled) } + + fn installed_edition_identity( + &self, + current_executable: &Path, + ) -> Result, PlatformError> { + installed_edition_identity(current_executable) + } + + fn enforce_installed_version(&self, requested_version: &str) -> Result<(), PlatformError> { + enforce_installed_version(requested_version) + } + + fn uninstall_preflight(&self, active_mounts: bool) -> Result<(), PlatformError> { + enforce_uninstall_preflight(active_mounts) + .map_err(|error| PlatformError::Failed(error.to_string())) + } +} + +fn installed_edition_identity( + current_executable: &Path, +) -> Result, PlatformError> { + #[cfg(windows)] + { + let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") else { + return Ok(None); + }; + let Some(record) = read_windows_install_record()? else { + return Ok(None); + }; + return validate_installed_identity( + &record, + current_executable, + Path::new(&local_app_data), + ) + .map(Some) + .map_err(|error| PlatformError::Failed(error.to_string())); + } + #[cfg(not(windows))] + { + let _ = current_executable; + Ok(None) + } +} + +fn enforce_installed_version(requested_version: &str) -> Result<(), PlatformError> { + #[cfg(windows)] + { + let existing = read_windows_install_record()? + .map(|record| record.version) + .filter(|version| !version.is_empty()); + return enforce_no_downgrade(existing.as_deref(), requested_version).map_err(|error| { + match error { + InstallPolicyError::DowngradeBlocked { .. } + | InstallPolicyError::InvalidExistingVersion(_) + | InstallPolicyError::InvalidRequestedVersion(_) => { + PlatformError::Failed(error.to_string()) + } + } + }); + } + #[cfg(not(windows))] + { + let _ = requested_version; + Ok(()) + } +} + +#[cfg(windows)] +fn read_windows_install_record() -> Result, PlatformError> { + use std::mem::size_of; + use std::os::windows::ffi::OsStrExt; + use std::ptr::null_mut; + + use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_CURRENT_USER, KEY_READ, REG_DWORD, REG_EXPAND_SZ, REG_SZ, RegCloseKey, + RegOpenKeyExW, RegQueryValueExW, + }; + + struct OwnedKey(HKEY); + impl Drop for OwnedKey { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { RegCloseKey(self.0) }; + } + } + } + fn wide(value: &str) -> Vec { + std::ffi::OsStr::new(value) + .encode_wide() + .chain(Some(0)) + .collect() + } + fn query_string(key: HKEY, name: &str) -> Result, PlatformError> { + let name = wide(name); + let mut kind = 0; + let mut bytes = 0; + let result = unsafe { + RegQueryValueExW( + key, + name.as_ptr(), + null_mut(), + &mut kind, + null_mut(), + &mut bytes, + ) + }; + if result == 2 { + return Ok(None); + } + if result != 0 { + return Err(PlatformError::Failed( + std::io::Error::from_raw_os_error(result as i32).to_string(), + )); + } + if kind != REG_SZ && kind != REG_EXPAND_SZ { + return Err(PlatformError::Failed(format!( + "install marker value {name:?} is not a string" + ))); + } + let mut buffer = vec![0u16; (bytes as usize).div_ceil(size_of::())]; + let mut capacity = bytes; + let result = unsafe { + RegQueryValueExW( + key, + name.as_ptr(), + null_mut(), + &mut kind, + buffer.as_mut_ptr().cast(), + &mut capacity, + ) + }; + if result != 0 { + return Err(PlatformError::Failed( + std::io::Error::from_raw_os_error(result as i32).to_string(), + )); + } + if buffer.last() == Some(&0) { + buffer.pop(); + } + Ok(Some(String::from_utf16_lossy(&buffer))) + } + fn query_dword(key: HKEY, name: &str) -> Result, PlatformError> { + let name = wide(name); + let mut kind = 0; + let mut bytes = size_of::() as u32; + let mut value = 0u32; + let result = unsafe { + RegQueryValueExW( + key, + name.as_ptr(), + null_mut(), + &mut kind, + (&mut value as *mut u32).cast(), + &mut bytes, + ) + }; + if result == 2 { + return Ok(None); + } + if result != 0 { + return Err(PlatformError::Failed( + std::io::Error::from_raw_os_error(result as i32).to_string(), + )); + } + if kind != REG_DWORD || bytes != size_of::() as u32 { + return Err(PlatformError::Failed(format!( + "install marker value {name:?} is not a DWORD" + ))); + } + Ok(Some(value)) + } + + let path = wide(mountmate_core::installed::WINDOWS_INSTALL_RECORD_KEY); + let mut key = null_mut(); + let result = unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, path.as_ptr(), 0, KEY_READ, &mut key) }; + if result == 2 { + return Ok(None); + } + if result != 0 { + return Err(PlatformError::Failed( + std::io::Error::from_raw_os_error(result as i32).to_string(), + )); + } + let key = OwnedKey(key); + let Some(schema_version) = query_dword(key.0, "SchemaVersion")? else { + return Ok(None); + }; + let Some(version) = query_string(key.0, "Version")? else { + return Ok(None); + }; + let Some(install_root) = query_string(key.0, "InstallRoot")? else { + return Ok(None); + }; + let Some(executable_path) = query_string(key.0, "ExecutablePath")? else { + return Ok(None); + }; + let aumid = query_string(key.0, "Aumid")?.unwrap_or_default(); + let architecture = query_string(key.0, "Architecture")?.unwrap_or_default(); + Ok(Some(InstalledInstallRecord { + schema_version, + version, + install_root: install_root.into(), + executable_path: executable_path.into(), + aumid, + architecture, + })) } #[cfg(windows)] diff --git a/crates/mountmate-platform/src/navigation.rs b/crates/mountmate-platform/src/navigation.rs new file mode 100644 index 0000000..ab944c9 --- /dev/null +++ b/crates/mountmate-platform/src/navigation.rs @@ -0,0 +1,240 @@ +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex}; + +#[cfg(windows)] +use std::path::PathBuf; +#[cfg(windows)] +use std::sync::atomic::{AtomicU64, Ordering}; + +use mountmate_core::navigation_refresh::NavigationEvent; + +/// A cloneable receiver backed by a dedicated observer thread. The app +/// consumes it through an iced subscription and never blocks its UI thread. +#[derive(Clone)] +pub struct NavigationObserver { + subscription_id: u64, + receiver: async_channel::Receiver, + failure: Arc>>, +} + +impl NavigationObserver { + pub async fn recv(&self) -> Result { + self.receiver.recv().await + } + + pub fn events(&self) -> async_channel::Receiver { + self.receiver.clone() + } + + pub fn failure(&self) -> Option { + self.failure.lock().ok().and_then(|failure| failure.clone()) + } +} + +impl Hash for NavigationObserver { + fn hash(&self, state: &mut H) { + self.subscription_id.hash(state); + } +} + +pub fn start_navigation_observer() -> Result { + #[cfg(windows)] + { + static NEXT_SUBSCRIPTION_ID: AtomicU64 = AtomicU64::new(1); + let (sender, receiver) = async_channel::bounded(64); + let failure = Arc::new(Mutex::new(None)); + let thread_failure = failure.clone(); + std::thread::Builder::new() + .name("ssh-mountmate-explorer-observer".into()) + .spawn(move || poll_explorer_windows(sender, thread_failure)) + .map_err(|error| error.to_string())?; + return Ok(NavigationObserver { + subscription_id: NEXT_SUBSCRIPTION_ID.fetch_add(1, Ordering::Relaxed), + receiver, + failure, + }); + } + #[cfg(not(windows))] + { + Err( + "Explorer navigation observation is available in the Windows installed edition only" + .into(), + ) + } +} + +#[cfg(windows)] +fn poll_explorer_windows( + sender: async_channel::Sender, + failure: Arc>>, +) { + use std::collections::HashMap; + use std::time::Duration; + + use windows::Win32::System::Com::{ + CLSCTX_LOCAL_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, + }; + use windows::Win32::UI::Shell::{IShellWindows, IWebBrowserApp, ShellWindows}; + use windows::core::Interface; + + // Explorer's automation objects are apartment-bound. Keep all COM work + // on this thread and reconcile paths at a low frequency; no DLL injection + // or UI/address-bar scraping is involved. + let init = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; + if init.is_err() { + set_failure(&failure, "COM apartment initialization failed"); + return; + } + let _apartment = ComApartment; + let shell: IShellWindows = + match unsafe { CoCreateInstance(&ShellWindows, None, CLSCTX_LOCAL_SERVER) } { + Ok(shell) => shell, + Err(_) => { + set_failure(&failure, "Explorer automation is unavailable"); + return; + } + }; + let mut previous = HashMap::::new(); + loop { + if sender.is_closed() { + break; + } + let count = unsafe { shell.Count() }.unwrap_or(0); + let mut observed = HashMap::new(); + for index in 0..count { + let variant = i32_variant(index); + let Ok(dispatch) = (unsafe { shell.Item(&variant) }) else { + continue; + }; + let Ok(browser) = dispatch.cast::() else { + continue; + }; + let Ok(hwnd) = (unsafe { browser.HWND() }) else { + continue; + }; + let Ok(url) = (unsafe { browser.LocationURL() }) else { + continue; + }; + let path = match file_url_to_path(&url.to_string()) { + Some(path) => path, + None => continue, + }; + if path.as_os_str().is_empty() { + continue; + } + let path_key = path.to_string_lossy().into_owned(); + let id = hwnd.0 as u64; + observed.insert(id, path_key.clone()); + if previous.get(&id) != Some(&path_key) + && sender + .send_blocking(NavigationEvent { + window_id: id, + target: path, + }) + .is_err() + { + return; + } + } + previous = observed; + std::thread::sleep(Duration::from_millis(750)); + } +} + +#[cfg(windows)] +struct ComApartment; + +#[cfg(windows)] +impl Drop for ComApartment { + fn drop(&mut self) { + unsafe { windows::Win32::System::Com::CoUninitialize() }; + } +} + +#[cfg(windows)] +fn i32_variant(value: i32) -> windows::Win32::System::Variant::VARIANT { + use std::mem::ManuallyDrop; + use windows::Win32::System::Variant::{VARIANT, VARIANT_0, VARIANT_0_0, VARIANT_0_0_0, VT_I4}; + + VARIANT { + Anonymous: VARIANT_0 { + Anonymous: ManuallyDrop::new(VARIANT_0_0 { + vt: VT_I4, + wReserved1: 0, + wReserved2: 0, + wReserved3: 0, + Anonymous: VARIANT_0_0_0 { lVal: value }, + }), + }, + } +} + +#[cfg(windows)] +fn file_url_to_path(value: &str) -> Option { + let url = url::Url::parse(value).ok()?; + if url.scheme() != "file" { + return None; + } + url.to_file_path().ok() +} + +#[cfg(windows)] +fn set_failure(failure: &Arc>>, message: &str) { + if let Ok(mut slot) = failure.lock() { + *slot = Some(message.to_owned()); + } +} + +#[cfg(windows)] +pub fn notify_shell_updated_dir(path: &std::path::Path) { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::UI::Shell::{SHCNE_UPDATEDIR, SHCNF_PATHW, SHChangeNotify}; + + let wide = path + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect::>(); + unsafe { + SHChangeNotify( + SHCNE_UPDATEDIR as i32, + SHCNF_PATHW, + wide.as_ptr().cast(), + std::ptr::null(), + ); + } +} + +#[cfg(not(windows))] +pub fn notify_shell_updated_dir(_path: &std::path::Path) {} + +#[cfg(test)] +mod tests { + use std::collections::hash_map::DefaultHasher; + + use super::*; + + fn observer_hash(observer: &NavigationObserver) -> u64 { + let mut hasher = DefaultHasher::new(); + observer.hash(&mut hasher); + hasher.finish() + } + + #[test] + fn replacement_observers_have_distinct_subscription_identities() { + let (_, first_receiver) = async_channel::bounded(1); + let (_, second_receiver) = async_channel::bounded(1); + let first = NavigationObserver { + subscription_id: 1, + receiver: first_receiver, + failure: Arc::new(Mutex::new(None)), + }; + let second = NavigationObserver { + subscription_id: 2, + receiver: second_receiver, + failure: Arc::new(Mutex::new(None)), + }; + + assert_ne!(observer_hash(&first), observer_hash(&second)); + } +} diff --git a/distribution/windows-installer/SSHMountMate.iss b/distribution/windows-installer/SSHMountMate.iss new file mode 100644 index 0000000..1b65ecd --- /dev/null +++ b/distribution/windows-installer/SSHMountMate.iss @@ -0,0 +1,172 @@ +; Per-user, fixed-path installer for the Windows installed edition. +; Build with: iscc /DARCH=x64|arm64 /DAPP_VERSION=... /DINPUT_EXE=... /DOUTPUT_DIR=... + +#ifndef APP_VERSION + #error APP_VERSION must be supplied by the release workflow +#endif +#ifndef INPUT_EXE + #error INPUT_EXE must be supplied by the release workflow +#endif +#ifndef OUTPUT_DIR + #define OUTPUT_DIR "output" +#endif +#ifndef ARCH + #define ARCH "x64" +#endif + +#if ARCH == "arm64" + #define ARCH_ALLOWED "arm64" + #define ARCH_MODE "arm64" +#else + #define ARCH_ALLOWED "x64compatible" + #define ARCH_MODE "x64compatible" +#endif + +#define AppName "SSH MountMate" +#define AppExeName "SSHMountMate.exe" +#define InstallRoot "{localappdata}\Programs\SSH MountMate" +#define InstallRecord "Software\Stardust\SSH MountMate\Install" +#define Aumid "Stardust.SSHMountMate" + +[Setup] +AppId={{5CCBBD52-BF64-4E48-9B41-6F3BF3C562A7} +AppName={#AppName} +AppVersion={#APP_VERSION} +AppPublisher=Stardust0831 +DefaultDirName={#InstallRoot} +DisableDirPage=yes +DisableProgramGroupPage=yes +ArchitecturesAllowed={#ARCH_ALLOWED} +ArchitecturesInstallIn64BitMode={#ARCH_MODE} +PrivilegesRequired=lowest +UsePreviousAppDir=no +Uninstallable=yes +UninstallDisplayIcon={app}\{#AppExeName} +OutputDir={#OUTPUT_DIR} +OutputBaseFilename=SSHMountMate-windows-{#ARCH}-setup +Compression=lzma2/ultra64 +SolidCompression=yes +WizardStyle=modern +CloseApplications=yes +RestartApplications=no + +[Files] +Source: "{#INPUT_EXE}"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{userprograms}\SSH MountMate"; Filename: "{app}\{#AppExeName}"; WorkingDir: "{app}"; AppUserModelID: "{#Aumid}" + +; The installer, rather than the application, owns these fixed-path Explorer +; registrations. They are removed with the per-user uninstall entry. +[Registry] +Root: HKCU; Subkey: "{#InstallRecord}"; ValueType: none; Flags: uninsdeletekeyifempty +Root: HKCU; Subkey: "{#InstallRecord}"; ValueName: "SchemaVersion"; ValueType: dword; ValueData: "1" +Root: HKCU; Subkey: "{#InstallRecord}"; ValueName: "Version"; ValueType: string; ValueData: "{#APP_VERSION}" +Root: HKCU; Subkey: "{#InstallRecord}"; ValueName: "InstallRoot"; ValueType: string; ValueData: "{#InstallRoot}" +Root: HKCU; Subkey: "{#InstallRecord}"; ValueName: "ExecutablePath"; ValueType: string; ValueData: "{#InstallRoot}\{#AppExeName}" +Root: HKCU; Subkey: "{#InstallRecord}"; ValueName: "Aumid"; ValueType: string; ValueData: "{#Aumid}" +Root: HKCU; Subkey: "{#InstallRecord}"; ValueName: "Architecture"; ValueType: string; ValueData: "{#ARCH}" +Root: HKCU; Subkey: "Software\Classes\AppUserModelId\{#Aumid}"; ValueName: "DisplayName"; ValueType: string; ValueData: "{#AppName}"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\AppUserModelId\{#Aumid}"; ValueName: "IconUri"; ValueType: string; ValueData: "{#InstallRoot}\{#AppExeName}" +Root: HKCU; Subkey: "Software\Classes\Directory\Background\shell\SSHMountMate.Refresh"; ValueName: ""; ValueType: string; ValueData: "Refresh with {#AppName}"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\Directory\Background\shell\SSHMountMate.Refresh"; ValueName: "Icon"; ValueType: string; ValueData: "{#InstallRoot}\{#AppExeName}" +Root: HKCU; Subkey: "Software\Classes\Directory\Background\shell\SSHMountMate.Refresh\command"; ValueName: ""; ValueType: string; ValueData: """{#InstallRoot}\{#AppExeName}"" --refresh-path ""%V\.""" +Root: HKCU; Subkey: "Software\Classes\Directory\shell\SSHMountMate.Refresh"; ValueName: ""; ValueType: string; ValueData: "Refresh with {#AppName}"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\Directory\shell\SSHMountMate.Refresh"; ValueName: "Icon"; ValueType: string; ValueData: "{#InstallRoot}\{#AppExeName}" +Root: HKCU; Subkey: "Software\Classes\Directory\shell\SSHMountMate.Refresh\command"; ValueName: ""; ValueType: string; ValueData: """{#InstallRoot}\{#AppExeName}"" --refresh-path ""%1\.""" +Root: HKCU; Subkey: "Software\Classes\Drive\shell\SSHMountMate.Refresh"; ValueName: ""; ValueType: string; ValueData: "Refresh with {#AppName}"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\Drive\shell\SSHMountMate.Refresh"; ValueName: "Icon"; ValueType: string; ValueData: "{#InstallRoot}\{#AppExeName}" +Root: HKCU; Subkey: "Software\Classes\Drive\shell\SSHMountMate.Refresh\command"; ValueName: ""; ValueType: string; ValueData: """{#InstallRoot}\{#AppExeName}"" --refresh-path ""%1\.""" +Root: HKCU; Subkey: "Software\Classes\Directory\Background\shell\SSHMountMate.Transfers"; ValueName: ""; ValueType: string; ValueData: "Open {#AppName} transfers"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\Directory\Background\shell\SSHMountMate.Transfers\command"; ValueName: ""; ValueType: string; ValueData: """{#InstallRoot}\{#AppExeName}"" --show-transfers" +Root: HKCU; Subkey: "Software\Classes\Directory\shell\SSHMountMate.Transfers"; ValueName: ""; ValueType: string; ValueData: "Open {#AppName} transfers"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\Directory\shell\SSHMountMate.Transfers\command"; ValueName: ""; ValueType: string; ValueData: """{#InstallRoot}\{#AppExeName}"" --show-transfers" +Root: HKCU; Subkey: "Software\Classes\Drive\shell\SSHMountMate.Transfers"; ValueName: ""; ValueType: string; ValueData: "Open {#AppName} transfers"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\Drive\shell\SSHMountMate.Transfers\command"; ValueName: ""; ValueType: string; ValueData: """{#InstallRoot}\{#AppExeName}"" --show-transfers" + +[UninstallDelete] +; Never delete settings, cache, credentials, or application data outside {app}. +Type: filesandordirs; Name: "{app}" + +[Code] +function IsSafeInstallerVersion(const Value: String): Boolean; +var + Index: Integer; +begin + Result := Value <> ''; + for Index := 1 to Length(Value) do begin + if not (Value[Index] in ['0'..'9', 'a'..'z', 'A'..'Z', '.', '+', '-']) then begin + Result := False; + exit; + end; + end; +end; + +function InitializeSetup(): Boolean; +var + ExistingExecutable: String; + ExistingVersion: String; + ExitCode: Integer; +begin + Result := True; + if not RegKeyExists(HKCU, '{#InstallRecord}') then + exit; + + if not RegQueryStringValue(HKCU, '{#InstallRecord}', 'ExecutablePath', ExistingExecutable) then begin + MsgBox('The existing SSH MountMate install record is incomplete. Repair or uninstall the existing installation before installing.', mbError, MB_OK); + Result := False; + exit; + end; + if CompareText(ExistingExecutable, ExpandConstant('{#InstallRoot}\{#AppExeName}')) <> 0 then begin + MsgBox('The existing SSH MountMate install record points outside the fixed install directory. Repair or uninstall the existing installation before installing.', mbError, MB_OK); + Result := False; + exit; + end; + if not RegQueryStringValue(HKCU, '{#InstallRecord}', 'Version', ExistingVersion) then begin + MsgBox('The existing SSH MountMate install record has no readable version. Repair or uninstall the existing installation before installing.', mbError, MB_OK); + Result := False; + exit; + end; + if not IsSafeInstallerVersion(ExistingVersion) then begin + MsgBox('The existing SSH MountMate install record has an invalid version. Repair or uninstall the existing installation before installing.', mbError, MB_OK); + Result := False; + exit; + end; + if not FileExists(ExistingExecutable) then begin + MsgBox('The existing SSH MountMate executable is missing. Repair or uninstall the existing installation before installing.', mbError, MB_OK); + Result := False; + exit; + end; + if not Exec(ExistingExecutable, '--installer-check-version "{#APP_VERSION}" --installer-recorded-version "' + ExistingVersion + '"', '', SW_HIDE, + ewWaitUntilTerminated, ExitCode) then begin + MsgBox('Could not validate the installed SSH MountMate version. Repair or uninstall the existing installation before installing.', mbError, MB_OK); + Result := False; + exit; + end; + if ExitCode <> 0 then begin + MsgBox('A newer SSH MountMate version is already installed. Uninstall it or use a newer installer.', mbError, MB_OK); + Result := False; + end; +end; + +function InitializeUninstall(): Boolean; +var + ExitCode: Integer; +begin + Result := True; + if not FileExists(ExpandConstant('{app}\{#AppExeName}')) then begin + MsgBox('The SSH MountMate executable is missing, so active mounts and uploads cannot be verified. Restore the installed executable before uninstalling.', mbError, MB_OK); + Result := False; + exit; + end; + ; The app owns the active-mount check. A non-zero result blocks uninstall. + if not Exec(ExpandConstant('{app}\{#AppExeName}'), '--installer-uninstall-preflight', '', SW_HIDE, ewWaitUntilTerminated, ExitCode) then begin + MsgBox('Could not run the SSH MountMate uninstall preflight; uninstall was blocked.', mbError, MB_OK); + Result := False; + exit; + end; + if ExitCode <> 0 then begin + MsgBox('SSH MountMate reports active mounts or uploads, or could not verify their state. Unmount all connections and wait for uploads before uninstalling.', mbError, MB_OK); + Result := False; + end; +end; diff --git a/distribution/windows-installer/build-installer.ps1 b/distribution/windows-installer/build-installer.ps1 new file mode 100644 index 0000000..9f99a48 --- /dev/null +++ b/distribution/windows-installer/build-installer.ps1 @@ -0,0 +1,35 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$InputExe, + [Parameter(Mandatory = $true)][string]$OutputDir, + [Parameter(Mandatory = $true)][string]$AppVersion, + [ValidateSet('x64', 'arm64')][string]$Arch = 'x64', + [string]$IsccPath = 'iscc.exe' +) + +$ErrorActionPreference = 'Stop' +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$iss = Join-Path $scriptDir 'SSHMountMate.iss' +if (-not (Test-Path -LiteralPath $InputExe -PathType Leaf)) { + throw "onefile executable was not found: $InputExe" +} +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +$resolvedIscc = Get-Command $IsccPath -ErrorAction SilentlyContinue +if (-not $resolvedIscc) { + throw "Inno Setup ISCC.exe was not found. Install the pinned Inno Setup toolchain before packaging." +} +$versionText = (& $resolvedIscc.Source /? 2>&1 | Out-String) +if ($versionText -notmatch 'Inno Setup 6\.') { + throw "unsupported Inno Setup compiler; expected Inno Setup 6.x" +} + +& $resolvedIscc.Source "/DARCH=$Arch" "/DAPP_VERSION=$AppVersion" "/DINPUT_EXE=$((Resolve-Path $InputExe).Path)" "/DOUTPUT_DIR=$((Resolve-Path $OutputDir).Path)" $iss +if ($LASTEXITCODE -ne 0) { + throw "Inno Setup compilation failed with exit code $LASTEXITCODE" +} +$output = Join-Path (Resolve-Path $OutputDir).Path "SSHMountMate-windows-$Arch-setup.exe" +if (-not (Test-Path -LiteralPath $output -PathType Leaf)) { + throw "Inno Setup did not produce expected output: $output" +} +Write-Output $output diff --git a/docs/development-roadmap.md b/docs/development-roadmap.md index 6b7c65b..312e2af 100644 --- a/docs/development-roadmap.md +++ b/docs/development-roadmap.md @@ -763,6 +763,17 @@ Cross-platform considerations: - macOS NFS, system credential protection, reusable interactive SSH, installers, and server changes are not part of `v0.4.0`. +### 2026-07-21 - v0.6.0-alpha.1 implementation + +- Added one-shot Lustre quota probing for project, current user, and primary group block/inode limits, + with partial-scope results, grace/severity parsing, and interactive SSH connector reuse. +- Added per-user Windows x64/ARM64 Inno Setup packages for prereleases while retaining all portable + ZIP artifacts. Stable releases intentionally exclude the unsigned setup executables. +- Added installed-edition-only Explorer navigation observation. Cache-only VFS refreshes run + asynchronously with path validation, deduplication, bounded queueing, and stale-mount protection. +- Release acceptance requires the six portable ZIPs, two prerelease setup executables, + `SHA256SUMS.txt`, and a signed update manifest that continues to name only canonical ZIP assets. + ### 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 6888494..257cc6f 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.4.4-alpha.4 (https://github.com/Stardust0831/ssh-mountmate) -- mountmate-core 0.4.4-alpha.4 (https://github.com/Stardust0831/ssh-mountmate) -- mountmate-platform 0.4.4-alpha.4 (https://github.com/Stardust0831/ssh-mountmate) +- ssh-mountmate 0.6.0-alpha.1 (https://github.com/Stardust0831/ssh-mountmate) +- mountmate-core 0.6.0-alpha.1 (https://github.com/Stardust0831/ssh-mountmate) +- mountmate-platform 0.6.0-alpha.1 (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.4.4-alpha.5.md b/release-notes/v0.4.4-alpha.5.md new file mode 100644 index 0000000..31bf288 --- /dev/null +++ b/release-notes/v0.4.4-alpha.5.md @@ -0,0 +1,16 @@ +# SSH MountMate v0.4.4-alpha.5 + +This prerelease corrects the search and sort row layout. + +- The sort picker now fills its allocated responsive column instead of sizing only to its current + label, removing the unexplained empty area on the far right. +- The saved-order label uses the same fill width while reordering, so entering and leaving reorder + mode no longer shifts the row layout. + +--- + +本预发布版修正了搜索与排序这一行的布局。 + +- 排序下拉框现在会填满分配给它的响应式区域,不再只按当前文字宽度显示,因此移除了最右侧意义不明 + 的空白。 +- 调整顺序模式下的“保存顺序”显示使用相同宽度,进入或退出排序模式时不会再造成这一行的布局跳动。 diff --git a/release-notes/v0.5.0.md b/release-notes/v0.5.0.md new file mode 100644 index 0000000..49c57d1 --- /dev/null +++ b/release-notes/v0.5.0.md @@ -0,0 +1,39 @@ +# SSH MountMate v0.5.0 + +This release completes the connection organization and batch-management redesign refined through +the v0.4.4 prereleases. + +- **Manage tags** is now the single place to assign existing or new tags to one or more selected + connections, remove a tag from one connection, or delete a tag globally. Tags are visually + compact and can be used to filter connections below Search. +- **Batch actions** focuses on operational work: select all visible connections or a tag group, + mount, unmount, configure login auto-mount per eligible connection, or delete connections after + confirmation. Interactive SSH connections remain excluded from unattended login mounting. +- Saved-order editing is available as a compact action inside the sort menu. Changes remain a draft + until explicitly saved, and connections can move freely regardless of their tags. +- New tag assignments are limited to eight tags per connection and 24 Unicode characters per tag. + Existing configurations above those limits remain editable and can be cleaned up progressively; + the application does not silently discard legacy values. +- The search and sort row now uses its responsive width consistently, removing the unused area and + avoiding layout shifts when entering saved-order mode. +- Portable self-updates can proceed when a stale transaction backup exists alongside a valid + current installation. The old backup is preserved under a unique recovery name; missing, + uninspectable, symlinked, or unexpected installation targets still fail safely instead of being + restored automatically. + +--- + +本版本完成了在 v0.4.4 系列预发布版中持续打磨的连接整理与批量管理改版。 + +- “标签管理”现在统一负责标签操作:可以为一个或多个选中的连接添加已有标签或新标签、从单个连接 + 移除标签,或全局删除标签。标签的视觉样式更加紧凑,并可在搜索框下方快速筛选连接。 +- “批量操作”专注于实际操作:可以全选当前可见连接或按标签组选中,批量挂载、卸载、逐连接设置登录 + 自启,或在二次确认后删除连接。需要实时会话的交互式 SSH 连接不会参与无人值守的登录挂载。 +- 自定义保存顺序收进排序菜单中的紧凑操作项。调整内容在明确保存前只作为草稿,并允许连接跨标签 + 自由移动。 +- 新增标签限制为每个连接最多 8 个、每个标签最多 24 个 Unicode 字符。已有配置即使超过限制仍可 + 编辑并逐步清理,程序不会静默丢弃旧值。 +- 搜索与排序一行现在会一致地使用响应式宽度,移除了无意义的空白,并避免进入保存顺序模式时发生 + 布局跳动。 +- 当有效的当前安装旁存在遗留事务备份时,便携版自更新可以继续,并会将旧备份保存在唯一的恢复路径。 + 如果当前安装缺失、不可检查、是符号链接或类型异常,更新仍会安全失败,不会自动恢复未经确认的内容。 diff --git a/release-notes/v0.6.0-alpha.1.md b/release-notes/v0.6.0-alpha.1.md new file mode 100644 index 0000000..68b95a5 --- /dev/null +++ b/release-notes/v0.6.0-alpha.1.md @@ -0,0 +1,16 @@ +# SSH MountMate v0.6.0-alpha.1 + +This prerelease adds Lustre quota details and an optional Windows installed edition while retaining +the existing portable packages. + +- View Lustre project, current-user, and primary-group storage and inode quotas, including soft and + hard limits, grace state, and partial-scope errors. +- Install per-user on Windows x64 or ARM64 under `%LOCALAPPDATA%\Programs\SSH MountMate` without + administrator privileges, or continue using the portable ZIP. +- Let the Windows installed edition notice Explorer navigation into a mounted directory and refresh + rclone's directory cache asynchronously without blocking navigation. +- Keep stable releases installer-free until the Windows setup executables can be signed. The setup + executables in this prerelease are unsigned; verify them with `SHA256SUMS.txt`. + +The signed update manifest continues to contain only the six canonical portable ZIP assets. The two +Windows setup executables are separate prerelease downloads. diff --git a/tests/windows_installer_static.sh b/tests/windows_installer_static.sh new file mode 100644 index 0000000..c0f9164 --- /dev/null +++ b/tests/windows_installer_static.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +iss="${root}/distribution/windows-installer/SSHMountMate.iss" +builder="${root}/distribution/windows-installer/build-installer.ps1" + +test -s "${iss}" +test -s "${builder}" +grep -F 'PrivilegesRequired=lowest' "${iss}" +grep -F 'DefaultDirName={#InstallRoot}' "${iss}" +grep -F 'WINDOWS_INSTALL_RECORD' "${root}/crates/mountmate-core/src/installed.rs" >/dev/null +grep -F 'installer-uninstall-preflight' "${iss}" +grep -F 'installer-recorded-version' "${iss}" +grep -F 'active mounts or uploads' "${iss}" +grep -F 'Repair or uninstall' "${iss}" +grep -F 'points outside the fixed install directory' "${iss}" +grep -F 'cannot be verified. Restore the installed executable' "${iss}" +grep -F 'InitializeSetup' "${iss}" +grep -F 'Downgrade' "${root}/crates/mountmate-core/src/installed.rs" +grep -F 'SHA256SUMS.txt' "${root}/.github/workflows/release.yml" + +echo "Windows installer static checks passed"