From fc249d7511e440b5fba001835bcc80c5eec081d5 Mon Sep 17 00:00:00 2001 From: Vanyo Vanev Date: Thu, 10 Sep 2026 18:59:54 +0300 Subject: [PATCH 01/31] Add optional rooted filesystem handles and bridge transport --- Cargo.lock | 12 + Cargo.toml | 2 +- crates/filesystem-sdk/Cargo.toml | 12 + crates/filesystem-sdk/LICENSE | 373 ++++++++++++ crates/filesystem-sdk/README.md | 23 + crates/filesystem-sdk/src/lib.rs | 230 +++++++ crates/filesystem-sdk/src/wire.rs | 482 +++++++++++++++ crates/service-contracts/Cargo.toml | 1 + crates/service-contracts/src/lib.rs | 15 + crates/ssh-core/Cargo.toml | 2 + crates/ssh-core/examples/bridge_probe.rs | 74 +++ crates/ssh-core/examples/mount_probe.rs | 172 ++++++ .../ssh-core/examples/native_bridge_probe.rs | 183 ++++++ crates/ssh-core/src/directory.rs | 6 + crates/ssh-core/src/lib.rs | 1 + crates/ssh-core/src/mounted.rs | 563 ++++++++++++++++++ crates/ssh-core/src/volumes.rs | 13 + docs/filesystem-integration-progress.md | 116 ++++ docs/local-drive-bridge.md | 142 +++++ docs/post-goal-backlog.md | 16 + src-tauri/src/mounted_binding.rs | 232 ++++++++ src-tauri/src/workspace_services.rs | 15 + 22 files changed, 2684 insertions(+), 1 deletion(-) create mode 100644 crates/filesystem-sdk/Cargo.toml create mode 100644 crates/filesystem-sdk/LICENSE create mode 100644 crates/filesystem-sdk/README.md create mode 100644 crates/filesystem-sdk/src/lib.rs create mode 100644 crates/filesystem-sdk/src/wire.rs create mode 100644 crates/ssh-core/examples/bridge_probe.rs create mode 100644 crates/ssh-core/examples/mount_probe.rs create mode 100644 crates/ssh-core/examples/native_bridge_probe.rs create mode 100644 crates/ssh-core/src/mounted.rs create mode 100644 docs/filesystem-integration-progress.md create mode 100644 docs/local-drive-bridge.md create mode 100644 src-tauri/src/mounted_binding.rs diff --git a/Cargo.lock b/Cargo.lock index 969f402..aad7eb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4877,12 +4877,23 @@ dependencies = [ "serde_json", "sha1 0.11.0", "sha2 0.10.9", + "shellcanvas-filesystem-sdk", "shellcanvas-services", "tempfile", "tokio", "uuid", ] +[[package]] +name = "shellcanvas-filesystem-sdk" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "shellcanvas-services" version = "0.1.0" @@ -4891,6 +4902,7 @@ dependencies = [ "async-trait", "serde", "serde_json", + "shellcanvas-filesystem-sdk", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5a777fc..4bd55a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/service-contracts", "crates/adapter-sdk", "crates/adapter-runtime", "crates/ssh-core", "src-tauri"] +members = ["crates/filesystem-sdk", "crates/service-contracts", "crates/adapter-sdk", "crates/adapter-runtime", "crates/ssh-core", "src-tauri"] resolver = "2" # Released Tauri still requires Tao 0.35. Pin the upstream Windows keyboard diff --git a/crates/filesystem-sdk/Cargo.toml b/crates/filesystem-sdk/Cargo.toml new file mode 100644 index 0000000..a3732c0 --- /dev/null +++ b/crates/filesystem-sdk/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "shellcanvas-filesystem-sdk" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" +description = "Optional filesystem handles and native bridge protocol for ShellCanvas" + +[dependencies] +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["io-util", "sync", "rt", "time"] } diff --git a/crates/filesystem-sdk/LICENSE b/crates/filesystem-sdk/LICENSE new file mode 100644 index 0000000..a612ad9 --- /dev/null +++ b/crates/filesystem-sdk/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/crates/filesystem-sdk/README.md b/crates/filesystem-sdk/README.md new file mode 100644 index 0000000..f542b78 --- /dev/null +++ b/crates/filesystem-sdk/README.md @@ -0,0 +1,23 @@ +# ShellCanvas filesystem SDK + +Optional rooted filesystem handles for native bridge applications. This crate has +no WinFsp, FUSE, SSH, desktop-window or kernel-driver dependency. It is MPL-2.0. + +Providers implement `MountedFileSystem` only when they support filesystem-style +access. Sequential download/upload contracts remain separate. `MountPath` is a +validated sequence of relative components, not an arbitrary remote pathname. + +`wire::Server` serves one explicitly granted provider/root through inherited +anonymous pipes. `wire::Client` is the blocking counterpart used by native OS +callbacks. Frames are length-prefixed JSON with a 1 MiB bound, protocol version +and monotonic request ID. File I/O uses 32 KiB chunks and directory replies use +pages; those are request bounds, not limits on file or directory totals. + +One server instance owns its file/directory handles. Source reconnection creates +a new grant; old handles must never be attached to it. A timed-out operation +retires the transport and is not replayed. The desktop must supervise the native +process and release its grant on teardown; this protocol is not a sandbox for +native executables. + +The independent Drive Bridge vendors a snapshot of this crate for standalone +builds. Update that snapshot and validate the protocol whenever changing it. diff --git a/crates/filesystem-sdk/src/lib.rs b/crates/filesystem-sdk/src/lib.rs new file mode 100644 index 0000000..c5b7839 --- /dev/null +++ b/crates/filesystem-sdk/src/lib.rs @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Optional filesystem projection for native local mounts. This is not the +//! sequential transfer API. A provider grants one root and owns remote paths. +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::{fmt, sync::Arc}; +pub mod wire; + +pub const MOUNT_IO_CHUNK: usize = 32 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum FsErrorKind { + NotFound, + PermissionDenied, + AlreadyExists, + NotDirectory, + IsDirectory, + NotEmpty, + InvalidInput, + Unsupported, + ReadOnly, + Offline, + TimedOut, + Io, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FsError { + pub kind: FsErrorKind, + pub message: String, +} +impl FsError { + pub fn new(kind: FsErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} +impl fmt::Display for FsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} +impl std::error::Error for FsError {} +pub type FsResult = Result; + +/// Relative components within the granted root. An empty path means that root. +/// OS backends translate their local separators; providers translate these +/// components to their own namespace. No drive prefixes or parent traversal. +#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)] +#[serde(try_from = "Vec", into = "Vec")] +pub struct MountPath(Vec); +impl TryFrom> for MountPath { + type Error = FsError; + fn try_from(names: Vec) -> FsResult { + names + .iter() + .try_fold(Self::root(), |path, name| path.child(name)) + } +} +impl From for Vec { + fn from(path: MountPath) -> Self { + path.0 + } +} +impl MountPath { + pub fn root() -> Self { + Self::default() + } + pub fn components(&self) -> &[String] { + &self.0 + } + pub fn child(&self, name: &str) -> FsResult { + if name.is_empty() || matches!(name, "." | "..") || name.contains(['/', '\\', '\0', ':']) { + return Err(FsError::new( + FsErrorKind::InvalidInput, + "Invalid mounted filename", + )); + } + let mut path = self.0.clone(); + path.push(name.into()); + Ok(Self(path)) + } + pub fn parent(&self) -> Option { + (!self.0.is_empty()).then(|| Self(self.0[..self.0.len() - 1].to_vec())) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum FsKind { + File, + Directory, + Symlink, + Other, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FsMetadata { + pub kind: FsKind, + pub size: u64, + /// Unix seconds; absent timestamps must not be invented by the provider. + pub accessed: Option, + pub modified: Option, + pub permissions: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FsDirectoryEntry { + pub name: String, + pub metadata: FsMetadata, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FsSpace { + pub block_size: u64, + pub blocks: u64, + pub blocks_free: u64, + pub blocks_available: u64, + pub files: u64, + pub files_free: u64, + pub name_max: u64, +} +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub struct FsCapabilities { + pub writable: bool, + pub atomic_replace: bool, + pub durable_flush: bool, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum FsCreate { + OpenExisting, + CreateNew, + OpenOrCreate, +} +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub struct FsOpenOptions { + pub read: bool, + pub write: bool, + pub create: FsCreate, + pub truncate: bool, +} +impl FsOpenOptions { + pub fn validate(self) -> FsResult<()> { + if !self.write && (!self.read || self.truncate || self.create != FsCreate::OpenExisting) { + return Err(FsError::new( + FsErrorKind::InvalidInput, + "Invalid file open options", + )); + } + Ok(()) + } +} +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] +pub struct FsSetMetadata { + pub size: Option, + pub accessed: Option, + pub modified: Option, + pub permissions: Option, +} + +/// Each call consumes at most MOUNT_IO_CHUNK bytes. Writes are acknowledged +/// before returning. Native backends split larger requests. Handles never +/// migrate to a reconnected source. Close/drop release resources, not upload. +#[async_trait] +pub trait MountedFile: Send + Sync { + async fn metadata(&self) -> FsResult; + async fn read_at(&self, offset: u64, length: u32) -> FsResult>; + async fn write_at(&self, offset: u64, bytes: &[u8]) -> FsResult<()>; + async fn set_metadata(&self, metadata: FsSetMetadata) -> FsResult<()>; + /// A provider without durable_flush only guarantees acknowledged writes. + async fn flush(&self) -> FsResult<()>; + async fn close(&self) -> FsResult<()>; +} +#[async_trait] +pub trait MountedDirectory: Send { + /// Incremental server batches; empty means EOF. No total directory cap. + async fn next(&mut self) -> FsResult>; + async fn close(&mut self) -> FsResult<()>; +} +#[async_trait] +pub trait MountedFileSystem: Send + Sync { + /// Cheap lifecycle check, including heartbeats that perform no file I/O. + fn check_available(&self) -> FsResult<()> { + Ok(()) + } + fn capabilities(&self) -> FsCapabilities; + async fn space(&self, _path: &MountPath) -> FsResult { + Err(FsError::new( + FsErrorKind::Unsupported, + "Remote capacity reporting is unavailable", + )) + } + async fn metadata(&self, path: &MountPath) -> FsResult; + async fn open( + &self, + path: &MountPath, + options: FsOpenOptions, + ) -> FsResult>; + async fn open_directory(&self, path: &MountPath) -> FsResult>; + async fn set_metadata(&self, path: &MountPath, metadata: FsSetMetadata) -> FsResult<()>; + async fn mkdir(&self, path: &MountPath) -> FsResult<()>; + async fn remove(&self, path: &MountPath, directory: bool) -> FsResult<()>; + async fn rename(&self, from: &MountPath, to: &MountPath, replace: bool) -> FsResult<()>; +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn mount_paths_cannot_escape_or_inject_native_namespaces() { + for name in [ + "", + ".", + "..", + "../outside", + "a/b", + "a\\b", + "C:", + "file:stream", + "nul\0", + ] { + assert!(MountPath::root().child(name).is_err(), "{name:?}"); + } + let path = MountPath::root() + .child("資料") + .unwrap() + .child("hello world.txt") + .unwrap(); + assert_eq!(path.components(), &["資料", "hello world.txt"]); + assert_eq!(path.parent().unwrap().parent().unwrap(), MountPath::root()); + assert!(MountPath::root().parent().is_none()); + } +} diff --git a/crates/filesystem-sdk/src/wire.rs b/crates/filesystem-sdk/src/wire.rs new file mode 100644 index 0000000..e04eeaf --- /dev/null +++ b/crates/filesystem-sdk/src/wire.rs @@ -0,0 +1,482 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Versioned bridge IPC over inherited anonymous pipes, never a TCP listener. +//! The parent grants exactly one filesystem root. Neither endpoint sends SSH +//! credentials, unscoped remote paths, executable paths or shell commands. +use crate::*; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::{ + collections::{HashMap, VecDeque}, + io::{self, Read, Write}, + sync::{ + atomic::{AtomicU64, Ordering}, + Mutex, + }, + time::Duration, +}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +pub const PROTOCOL: u32 = 1; +pub const MAX_FRAME: usize = 1024 * 1024; +pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Request { + pub version: u32, + pub id: u64, + pub operation: Operation, +} +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "method", content = "params", deny_unknown_fields)] +pub enum Operation { + Capabilities, + Space { + path: MountPath, + }, + Metadata { + path: MountPath, + }, + Open { + path: MountPath, + options: FsOpenOptions, + }, + OpenDirectory { + path: MountPath, + }, + ReadDirectory { + handle: u64, + }, + CloseDirectory { + handle: u64, + }, + FileMetadata { + handle: u64, + }, + Read { + handle: u64, + offset: u64, + length: u32, + }, + Write { + handle: u64, + offset: u64, + bytes: Vec, + }, + SetFileMetadata { + handle: u64, + metadata: FsSetMetadata, + }, + Flush { + handle: u64, + }, + Close { + handle: u64, + }, + SetMetadata { + path: MountPath, + metadata: FsSetMetadata, + }, + Mkdir { + path: MountPath, + }, + Remove { + path: MountPath, + directory: bool, + }, + Rename { + from: MountPath, + to: MountPath, + replace: bool, + }, +} +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Response { + pub version: u32, + pub id: u64, + pub result: FsResult, +} +#[derive(Debug, Serialize, Deserialize)] +pub enum Value { + Unit, + Capabilities(FsCapabilities), + Space(FsSpace), + Metadata(FsMetadata), + Handle(u64), + Data(Vec), + Entries(Vec), +} +fn invalid(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} +fn encode(value: &T) -> io::Result> { + let bytes = serde_json::to_vec(value).map_err(|e| invalid(e.to_string()))?; + if bytes.is_empty() || bytes.len() > MAX_FRAME { + return Err(invalid("Filesystem bridge frame exceeds its bound")); + } + Ok(bytes) +} +fn frame_length(prefix: [u8; 4]) -> io::Result { + let size = u32::from_be_bytes(prefix) as usize; + if size == 0 || size > MAX_FRAME { + Err(invalid("Invalid filesystem bridge frame length")) + } else { + Ok(size) + } +} +pub fn read_frame(reader: &mut impl Read) -> io::Result { + let mut prefix = [0; 4]; + reader.read_exact(&mut prefix)?; + let mut bytes = vec![0; frame_length(prefix)?]; + reader.read_exact(&mut bytes)?; + serde_json::from_slice(&bytes).map_err(|e| invalid(e.to_string())) +} +pub fn write_frame(writer: &mut impl Write, value: &T) -> io::Result<()> { + let bytes = encode(value)?; + writer.write_all(&(bytes.len() as u32).to_be_bytes())?; + writer.write_all(&bytes)?; + writer.flush() +} +pub async fn read_frame_async( + reader: &mut (impl AsyncRead + Unpin), +) -> io::Result { + let mut prefix = [0; 4]; + reader.read_exact(&mut prefix).await?; + let mut bytes = vec![0; frame_length(prefix)?]; + reader.read_exact(&mut bytes).await?; + serde_json::from_slice(&bytes).map_err(|e| invalid(e.to_string())) +} +pub async fn write_frame_async( + writer: &mut (impl AsyncWrite + Unpin), + value: &T, +) -> io::Result<()> { + let bytes = encode(value)?; + writer + .write_all(&(bytes.len() as u32).to_be_bytes()) + .await?; + writer.write_all(&bytes).await?; + writer.flush().await +} + +struct DirectoryState { + directory: Box, + pending: VecDeque, + eof: bool, +} +/// Lives for one helper/root grant. IDs are never reused or shared with another +/// grant, and provider handles are released when the pipe closes. +pub struct Server { + fs: Arc, + next: u64, + files: HashMap>, + directories: HashMap, +} +impl Server { + pub fn new(fs: Arc) -> Self { + Self { + fs, + next: 0, + files: HashMap::new(), + directories: HashMap::new(), + } + } + fn allocate(&mut self) -> FsResult { + self.next = self + .next + .checked_add(1) + .ok_or_else(|| FsError::new(FsErrorKind::Io, "Handle identity exhausted"))?; + Ok(self.next) + } + fn file(&self, handle: u64) -> FsResult<&Arc> { + self.files + .get(&handle) + .ok_or_else(|| FsError::new(FsErrorKind::Offline, "Unknown or closed file handle")) + } + pub async fn dispatch(&mut self, operation: Operation) -> FsResult { + match operation { + Operation::Capabilities => { + self.fs.check_available()?; + return Ok(Value::Capabilities(self.fs.capabilities())); + } + Operation::Space { path } => return Ok(Value::Space(self.fs.space(&path).await?)), + Operation::Metadata { path } => { + return Ok(Value::Metadata(self.fs.metadata(&path).await?)) + } + Operation::Open { path, options } => { + options.validate()?; + let id = self.allocate()?; + let file = self.fs.open(&path, options).await?; + self.files.insert(id, file); + return Ok(Value::Handle(id)); + } + Operation::OpenDirectory { path } => { + let id = self.allocate()?; + let directory = self.fs.open_directory(&path).await?; + self.directories.insert( + id, + DirectoryState { + directory, + pending: VecDeque::new(), + eof: false, + }, + ); + return Ok(Value::Handle(id)); + } + Operation::ReadDirectory { handle } => { + let state = self.directories.get_mut(&handle).ok_or_else(|| { + FsError::new(FsErrorKind::Offline, "Directory handle is closed") + })?; + if state.pending.is_empty() && !state.eof { + let page = state.directory.next().await?; + state.eof = page.is_empty(); + state.pending.extend(page); + } + let take = state.pending.len().min(64); + return Ok(Value::Entries(state.pending.drain(..take).collect())); + } + Operation::CloseDirectory { handle } => { + if let Some(mut state) = self.directories.remove(&handle) { + state.directory.close().await?; + } + } + Operation::FileMetadata { handle } => { + return Ok(Value::Metadata(self.file(handle)?.metadata().await?)) + } + Operation::Read { + handle, + offset, + length, + } => { + if length as usize > MOUNT_IO_CHUNK { + return Err(FsError::new( + FsErrorKind::InvalidInput, + "Read exceeds chunk size", + )); + } + let data = self.file(handle)?.read_at(offset, length).await?; + if data.len() > length as usize { + return Err(FsError::new( + FsErrorKind::Io, + "Provider exceeded requested read length", + )); + } + return Ok(Value::Data(data)); + } + Operation::Write { + handle, + offset, + bytes, + } => { + if bytes.len() > MOUNT_IO_CHUNK { + return Err(FsError::new( + FsErrorKind::InvalidInput, + "Write exceeds chunk size", + )); + } + self.file(handle)?.write_at(offset, &bytes).await?; + } + Operation::SetFileMetadata { handle, metadata } => { + self.file(handle)?.set_metadata(metadata).await? + } + Operation::Flush { handle } => self.file(handle)?.flush().await?, + Operation::Close { handle } => { + if let Some(file) = self.files.remove(&handle) { + file.close().await?; + } + } + Operation::SetMetadata { path, metadata } => { + self.fs.set_metadata(&path, metadata).await? + } + Operation::Mkdir { path } => self.fs.mkdir(&path).await?, + Operation::Remove { path, directory } => self.fs.remove(&path, directory).await?, + Operation::Rename { from, to, replace } => self.fs.rename(&from, &to, replace).await?, + } + Ok(Value::Unit) + } + pub async fn close(&mut self) { + // Bound the whole teardown, not each handle serially. A disconnected + // server with many open files must not hold shutdown for hours. + let files = std::mem::take(&mut self.files); + let directories = std::mem::take(&mut self.directories); + let _ = tokio::time::timeout(REQUEST_TIMEOUT, async move { + let mut closing = tokio::task::JoinSet::new(); + for file in files.into_values() { + if closing.len() == 16 { + let _ = closing.join_next().await; + } + closing.spawn(async move { + let _ = file.close().await; + }); + } + for mut dir in directories.into_values() { + if closing.len() == 16 { + let _ = closing.join_next().await; + } + closing.spawn(async move { + let _ = dir.directory.close().await; + }); + } + while closing.join_next().await.is_some() {} + }) + .await; + } + pub async fn serve( + mut self, + mut reader: impl AsyncRead + Unpin, + mut writer: impl AsyncWrite + Unpin, + ) -> io::Result<()> { + let result = async { + let mut last = 0; + loop { + let request: Request = read_frame_async(&mut reader).await?; + if request.version != PROTOCOL || request.id <= last { + return Err(invalid("Invalid bridge version or request sequence")); + } + last = request.id; + let result = + tokio::time::timeout(REQUEST_TIMEOUT, self.dispatch(request.operation)).await; + let timed_out = result.is_err(); + let result = result.unwrap_or_else(|_| { + Err(FsError::new( + FsErrorKind::TimedOut, + "Filesystem request timed out; attachment is being retired", + )) + }); + tokio::time::timeout( + REQUEST_TIMEOUT, + write_frame_async( + &mut writer, + &Response { + version: PROTOCOL, + id: request.id, + result, + }, + ), + ) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + "Bridge stopped reading filesystem replies", + ) + })??; + if timed_out { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "Filesystem bridge retired after timeout", + )); + } + } + } + .await; + self.close().await; + result + } +} + +/// Blocking native callback client. No async executor or SSH implementation is +/// needed in a driver backend. Serialize complete request/reply exchanges so OS +/// callbacks from different threads cannot consume one another's replies. +pub struct Client { + io: Mutex<(R, W, bool)>, + next: AtomicU64, +} +impl Client { + pub fn new(reader: R, writer: W) -> Self { + Self { + io: Mutex::new((reader, writer, false)), + next: AtomicU64::new(0), + } + } + pub fn call(&self, operation: Operation) -> FsResult { + let mut io = self + .io + .lock() + .map_err(|_| FsError::new(FsErrorKind::Offline, "Bridge transport lock failed"))?; + if io.2 { + return Err(FsError::new( + FsErrorKind::Offline, + "Bridge connection is closed", + )); + } + let id = self.next.fetch_add(1, Ordering::Relaxed) + 1; + let result = (|| -> io::Result { + write_frame( + &mut io.1, + &Request { + version: PROTOCOL, + id, + operation, + }, + )?; + let response: Response = read_frame(&mut io.0)?; + if response.version != PROTOCOL || response.id != id { + return Err(invalid("Mismatched filesystem reply")); + } + Ok(response) + })(); + match result { + Ok(response) => response.result, + Err(e) => { + io.2 = true; + Err(FsError::new(FsErrorKind::Offline, e.to_string())) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn framing_rejects_oversize_and_path_traversal() { + let mut bytes = ((MAX_FRAME + 1) as u32).to_be_bytes().as_slice().to_vec(); + assert!(read_frame::(&mut bytes.as_slice()).is_err()); + let json = br#"{"version":1,"id":1,"operation":{"method":"Metadata","params":{"path":["..","etc"]}}}"#; + bytes = (json.len() as u32).to_be_bytes().to_vec(); + bytes.extend_from_slice(json); + assert!(read_frame::(&mut bytes.as_slice()).is_err()); + } + #[test] + fn framed_requests_preserve_large_offsets() { + let mut bytes = vec![]; + write_frame( + &mut bytes, + &Request { + version: PROTOCOL, + id: 10, + operation: Operation::Read { + handle: 3, + offset: (1u64 << 53) + 1, + length: 32768, + }, + }, + ) + .unwrap(); + let decoded: Request = read_frame(&mut bytes.as_slice()).unwrap(); + assert!( + matches!(decoded.operation, Operation::Read { offset, .. } if offset == (1u64 << 53) + 1) + ); + } + #[test] + fn mismatched_reply_retires_the_transport() { + let mut bytes = vec![]; + write_frame( + &mut bytes, + &Response { + version: PROTOCOL, + id: 99, + result: Ok(Value::Unit), + }, + ) + .unwrap(); + let client = Client::new(bytes.as_slice(), vec![]); + assert_eq!( + client.call(Operation::Capabilities).unwrap_err().kind, + FsErrorKind::Offline + ); + assert_eq!( + client.call(Operation::Capabilities).unwrap_err().kind, + FsErrorKind::Offline + ); + } +} diff --git a/crates/service-contracts/Cargo.toml b/crates/service-contracts/Cargo.toml index 73b18ef..4491e43 100644 --- a/crates/service-contracts/Cargo.toml +++ b/crates/service-contracts/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" license = "MPL-2.0" [dependencies] +shellcanvas-filesystem-sdk = { path = "../filesystem-sdk" } anyhow = "1" async-trait = "0.1" serde = { version = "1", features = ["derive"] } diff --git a/crates/service-contracts/src/lib.rs b/crates/service-contracts/src/lib.rs index 73ee564..c9411d7 100644 --- a/crates/service-contracts/src/lib.rs +++ b/crates/service-contracts/src/lib.rs @@ -14,6 +14,7 @@ pub use device::*; pub mod directory; pub use directory::*; pub mod volumes; +pub use shellcanvas_filesystem_sdk::*; pub use volumes::*; pub mod terminal; pub use terminal::*; @@ -161,6 +162,20 @@ pub struct TextDocument { #[async_trait] pub trait FileSystemProvider: Send + Sync { + /// Optional native local-drive projection, separate from remote disk control. + fn supports_local_mount(&self) -> bool { + false + } + async fn mount_root( + &self, + _path: &str, + _writable: bool, + ) -> FsResult> { + Err(FsError::new( + FsErrorKind::Unsupported, + "Local drive attachment is unavailable on this file provider", + )) + } /// Optional; basic file providers need not implement disk management. async fn volumes(&self) -> Result { Ok(FileVolumes::unavailable()) diff --git a/crates/ssh-core/Cargo.toml b/crates/ssh-core/Cargo.toml index 6f6266c..0528511 100644 --- a/crates/ssh-core/Cargo.toml +++ b/crates/ssh-core/Cargo.toml @@ -21,5 +21,7 @@ uuid = { version = "1", features = ["v4"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "io-util"] } [dev-dependencies] +tokio = { version = "1", features = ["process"] } +shellcanvas-filesystem-sdk = { path = "../filesystem-sdk" } tempfile = "3" rand = "0.10" diff --git a/crates/ssh-core/examples/bridge_probe.rs b/crates/ssh-core/examples/bridge_probe.rs new file mode 100644 index 0000000..71af315 --- /dev/null +++ b/crates/ssh-core/examples/bridge_probe.rs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Live anonymous-pipe acceptance using the independently compiled bridge. +use anyhow::{ensure, Context, Result}; +use shellcanvas_core::*; +use shellcanvas_filesystem_sdk::wire::Server; +use std::{path::PathBuf, process::Stdio, sync::Arc, time::Duration}; +#[tokio::main] +async fn main() -> Result<()> { + let args: Vec<_> = std::env::args().collect(); + ensure!( + args.len() == 6, + "Usage: bridge_probe HOST USER KEY_PATH ADDITIONAL_KNOWN_HOSTS BRIDGE_EXECUTABLE" + ); + ensure!( + std::env::var("SHELLCANVAS_LIVE_MOUNT_PROBE").as_deref() == Ok("1"), + "Disposable remote writes require SHELLCANVAS_LIVE_MOUNT_PROBE=1" + ); + let connection = Arc::new( + Connection::connect_with_trust_store( + &ConnectOptions { + host: args[1].clone(), + username: args[2].clone(), + port: 22, + key_path: args[3].clone(), + password: std::env::var("SHELLCANVAS_PROBE_PASSWORD").ok(), + passphrase: None, + }, + PathBuf::from(&args[4]), + ) + .await?, + ); + let service = Arc::new(connection.text_files().await?); + let root = service + .make_directory( + "/tmp", + &format!("shellcanvas-bridge-{}", uuid::Uuid::new_v4()), + ) + .await?; + let browser = SshFileBrowser::new(Arc::new(SftpBrowser(service.clone())), connection.clone()); + let result: Result<()> = async { + let fs = browser.mount_root(&root, true).await?; + let executable = PathBuf::from(&args[5]).canonicalize()?; + let mut command = tokio::process::Command::new(executable); + command + .arg("--verify-transport") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); + #[cfg(windows)] + command.creation_flags(0x08000000); + let mut child = command.spawn()?; + let reader = child.stdout.take().context("Missing child stdout")?; + let writer = child.stdin.take().context("Missing child stdin")?; + let serving = tokio::spawn(Server::new(fs).serve(reader, writer)); + let status = tokio::time::timeout(Duration::from_secs(90), child.wait()).await??; + let outcome = serving.await?; + ensure!(status.success(), "Bridge process failed: {status}"); + ensure!( + outcome.is_err_and(|e| e.kind() == std::io::ErrorKind::UnexpectedEof), + "Unexpected server termination" + ); + Ok(()) + } + .await; + let entries = browser.list(Some("/tmp")).await?; + if let Some(entry) = entries.entries.iter().find(|e| e.path == root) { + service.remove_entry(&root, &entry.revision).await?; + } + connection.disconnect().await?; + result?; + println!("PASS: separate native bridge -> inherited pipes -> scoped core -> SFTP; disposable root removed"); + Ok(()) +} diff --git a/crates/ssh-core/examples/mount_probe.rs b/crates/ssh-core/examples/mount_probe.rs new file mode 100644 index 0000000..28c3ade --- /dev/null +++ b/crates/ssh-core/examples/mount_probe.rs @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Opt-in SFTP mounted-file contract acceptance. Touches only a fresh UUID tree. +use anyhow::{ensure, Result}; +use shellcanvas_core::*; +use std::{path::PathBuf, sync::Arc}; + +#[tokio::main] +async fn main() -> Result<()> { + let args: Vec<_> = std::env::args().collect(); + ensure!( + args.len() == 5, + "Usage: mount_probe HOST USER KEY_PATH ADDITIONAL_KNOWN_HOSTS" + ); + ensure!( + std::env::var("SHELLCANVAS_LIVE_MOUNT_PROBE").as_deref() == Ok("1"), + "Set SHELLCANVAS_LIVE_MOUNT_PROBE=1 to authorize a disposable remote directory" + ); + let connection = Arc::new( + Connection::connect_with_trust_store( + &ConnectOptions { + host: args[1].clone(), + username: args[2].clone(), + port: 22, + key_path: args[3].clone(), + password: std::env::var("SHELLCANVAS_PROBE_PASSWORD").ok(), + passphrase: None, + }, + PathBuf::from(&args[4]), + ) + .await?, + ); + let service = Arc::new(connection.text_files().await?); + let browser = SshFileBrowser::new(Arc::new(SftpBrowser(service.clone())), connection.clone()); + let root = service + .make_directory( + "/tmp", + &format!("shellcanvas-mount-{}", uuid::Uuid::new_v4()), + ) + .await?; + println!("Created disposable root: {root}"); + let fs = browser.mount_root(&root, true).await?; + let result: Result<()> = async { + let path = MountPath::root().child("資料 ' data.bin")?; + let create = FsOpenOptions { + read: true, + write: true, + create: FsCreate::CreateNew, + truncate: false, + }; + let file = fs.open(&path, create).await?; + ensure!( + fs.open(&path, create).await.is_err(), + "Exclusive create replaced a file" + ); + file.write_at(0, b"begin").await?; + // More than 4 GiB exercises 64-bit offsets without a huge transfer. + let offset = (1_u64 << 32) + 19; + file.write_at(offset, b"end").await?; + file.flush().await?; + ensure!( + file.metadata().await?.size == offset + 3, + "Incorrect large-file size" + ); + ensure!( + file.read_at(offset, 3).await? == b"end", + "Seeked read differs" + ); + ensure!( + file.read_at(0, 5).await? == b"begin", + "Initial bytes differ" + ); + ensure!( + file.read_at(offset + 3, 20).await?.is_empty(), + "EOF differs" + ); + file.set_metadata(FsSetMetadata { + size: Some(5), + ..Default::default() + }) + .await?; + ensure!(file.metadata().await?.size == 5, "Truncate failed"); + let reader = fs + .open( + &path, + FsOpenOptions { + read: true, + write: false, + create: FsCreate::OpenExisting, + truncate: false, + }, + ) + .await?; + ensure!( + reader.write_at(0, b"x").await.is_err(), + "Read handle permitted write" + ); + let temp = MountPath::root().child("save.tmp")?; + let save = fs.open(&temp, create).await?; + save.write_at(0, b"saved").await?; + save.flush().await?; + save.close().await?; + fs.rename(&temp, &path, true).await?; + ensure!( + reader.read_at(0, 5).await? == b"begin", + "Rename retargeted an open handle" + ); + let saved = fs + .open( + &path, + FsOpenOptions { + read: true, + write: false, + create: FsCreate::OpenExisting, + truncate: false, + }, + ) + .await?; + ensure!( + saved.read_at(0, 5).await? == b"saved", + "Atomic save bytes differ" + ); + saved.close().await?; + reader.close().await?; + file.close().await?; + ensure!( + file.read_at(0, 1).await.is_err(), + "Closed handle stayed usable" + ); + let readonly = browser.mount_root(&root, false).await?; + ensure!( + readonly.open(&path, create).await.is_err(), + "Read-only mount accepted writable open" + ); + ensure!( + readonly.remove(&path, false).await.is_err(), + "Read-only mount removed a file" + ); + ensure!( + fs.remove(&MountPath::root(), true).await.is_err(), + "Root was removable" + ); + let dir = MountPath::root().child("subfolder")?; + fs.mkdir(&dir).await?; + let mut listing = fs.open_directory(&MountPath::root()).await?; + let mut names = vec![]; + loop { + let page = listing.next().await?; + if page.is_empty() { + break; + } + names.extend(page.into_iter().map(|e| e.name)); + } + listing.close().await?; + ensure!( + names.len() == 2 && names.contains(&"subfolder".into()), + "Directory enumeration differs" + ); + fs.remove(&dir, true).await?; + fs.remove(&path, false).await?; + Ok(()) + } + .await; + // The checked mutation service removes only the UUID root created above. + let listing = browser.list(Some("/tmp")).await?; + if let Some(entry) = listing.entries.iter().find(|entry| entry.path == root) { + service.remove_entry(&root, &entry.revision).await?; + } + connection.disconnect().await?; + result?; + println!("PASS: >4 GiB offsets, truncate, EOF, concurrent handles, atomic replacement, read-only enforcement, directory enumeration, close and cleanup"); + Ok(()) +} diff --git a/crates/ssh-core/examples/native_bridge_probe.rs b/crates/ssh-core/examples/native_bridge_probe.rs new file mode 100644 index 0000000..0c6c740 --- /dev/null +++ b/crates/ssh-core/examples/native_bridge_probe.rs @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Linux native mount acceptance. SSH carries only the inherited-pipe protocol +//! to a separately built bridge; the selected filesystem remains the core SFTP provider. +use anyhow::{ensure, Context, Result}; +use shellcanvas_core::*; +use shellcanvas_filesystem_sdk::wire::Server; +use std::{path::PathBuf, process::Stdio, sync::Arc, time::Duration}; +fn quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} +fn ssh(args: &[String], remote: &str) -> tokio::process::Command { + let mut command = tokio::process::Command::new("ssh"); + command.args([ + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=yes", + "-i", + &args[3], + "-l", + &args[2], + &args[1], + remote, + ]); + command.kill_on_drop(true); + #[cfg(windows)] + command.creation_flags(0x08000000); + command +} +async fn run(args: &[String], remote: &str) -> Result { + let output = + tokio::time::timeout(Duration::from_secs(300), ssh(args, remote).output()).await??; + ensure!( + output.status.success(), + "Remote test failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} +#[tokio::main] +async fn main() -> Result<()> { + let args: Vec<_> = std::env::args().collect(); + ensure!( + args.len() == 6, + "Usage: native_bridge_probe HOST USER KEY_PATH ADDITIONAL_KNOWN_HOSTS REMOTE_BRIDGE" + ); + ensure!( + std::env::var("SHELLCANVAS_LIVE_MOUNT_PROBE").as_deref() == Ok("1"), + "Set SHELLCANVAS_LIVE_MOUNT_PROBE=1 for disposable native mount testing" + ); + let connection = Arc::new( + Connection::connect_with_trust_store( + &ConnectOptions { + host: args[1].clone(), + username: args[2].clone(), + port: 22, + key_path: args[3].clone(), + password: None, + passphrase: None, + }, + PathBuf::from(&args[4]), + ) + .await?, + ); + let service = Arc::new(connection.text_files().await?); + let root = service + .make_directory( + "/tmp", + &format!("shellcanvas-native-{}", uuid::Uuid::new_v4()), + ) + .await?; + let source = service.make_directory(&root, "source").await?; + let target = service.make_directory(&root, "mount").await?; + let browser = SshFileBrowser::new(Arc::new(SftpBrowser(service.clone())), connection.clone()); + let fs = browser.mount_root(&source, true).await?; + let remote = format!("exec {} --mount {}", quote(&args[5]), quote(&target)); + let mut child = ssh(&args, &remote) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + let reader = child.stdout.take().context("Missing child stdout")?; + let writer = child.stdin.take().context("Missing child stdin")?; + let serving = tokio::spawn(Server::new(fs).serve(reader, writer)); + let result: Result<()> = async { + run( + &args, + &format!( + "for i in $(seq 1 50); do mountpoint -q {} && exit 0; sleep .1; done; exit 1", + quote(&target) + ), + ) + .await?; + let script = service.create_text(&root, "test.py", TEST).await?; + print!( + "{}", + run( + &args, + &format!( + "timeout 240s python3 {} {}", + quote(&script.path), + quote(&target) + ) + ) + .await? + ); + Ok(()) + } + .await; + // All test file descriptors are closed before ordinary unmount. Never use lazy/forced detach. + let detached = run( + &args, + &format!( + "if mountpoint -q {}; then fusermount3 -u {}; fi", + quote(&target), + quote(&target) + ), + ) + .await; + serving.abort(); // Drops both pipe endpoints; heartbeat retires the helper. + let _ = serving.await; + let ended = tokio::time::timeout(Duration::from_secs(10), child.wait()).await; + if ended.is_err() { + let _ = child.kill().await; + } + if let Err(error) = detached { + anyhow::bail!( + "Native test result: {result:?}; detach failed: {error}. Fixture retained at {root}" + ); + } + // The regular Files action intentionally removes only empty directories. + // This harness owns the entire UUID fixture, including the populated source. + let cleanup = "import os,shutil,sys; p=sys.argv[1]; assert p.startswith('/tmp/shellcanvas-native-') and os.path.dirname(p)=='/tmp' and not os.path.islink(p) and not os.path.ismount(p+'/mount'); shutil.rmtree(p)"; + run(&args, &format!("python3 -c {} {}", quote(cleanup), quote(&root))).await?; + connection.disconnect().await?; + result?; + println!("PASS: native Linux filesystem -> FUSE bridge -> core root grant -> real SFTP; detached and disposable tree removed"); + Ok(()) +} +const TEST: &str = r#"import os, sys, errno +from pathlib import Path +p = Path(sys.argv[1]) +f = os.open(p/'seek.bin', os.O_CREAT|os.O_EXCL|os.O_RDWR, 0o600) +try: + os.pwrite(f, b'begin', 0) + os.pwrite(f, b'end', (1<<32)+19) + os.fsync(f) + assert os.fstat(f).st_size == (1<<32)+22 + assert os.pread(f, 3, (1<<32)+19) == b'end' + os.ftruncate(f, 5) + assert os.pread(f, 20, 0) == b'begin' + (p/'save.tmp').write_bytes(b'replacement') + os.replace(p/'save.tmp', p/'seek.bin') + assert os.pread(f, 20, 0) == b'begin' + assert (p/'seek.bin').read_bytes() == b'replacement' +finally: + os.close(f) +(p/'directory').mkdir() +for i in range(70): + (p/'directory'/f'item-{i:03}').write_bytes(bytes([i])) +assert len(list((p/'directory').iterdir())) == 70 +assert len(list((p/'directory').iterdir())) == 70 +fd = os.open(p/'directory'/'item-000', os.O_RDWR) +os.rename(p/'directory', p/'renamed') +try: + os.pwrite(fd, b'changed', 0) + assert (p/'renamed'/'item-000').read_bytes() == b'changed' +finally: + os.close(fd) +try: + os.rmdir(p/'renamed') + raise AssertionError('nonempty directory removed') +except OSError as e: + assert e.errno in (errno.ENOTEMPTY, errno.EIO) +assert os.statvfs(p).f_blocks > 0 +try: + (p/'missing').read_bytes() + raise AssertionError('missing file readable') +except FileNotFoundError: + pass +print('LINUX_NATIVE_MOUNT_PASS: sparse offset, truncate, atomic editor save, old handle identity, paged enumeration, directory rename with open file, capacity and errors', flush=True) +"#; diff --git a/crates/ssh-core/src/directory.rs b/crates/ssh-core/src/directory.rs index 6a3975b..d9f270e 100644 --- a/crates/ssh-core/src/directory.rs +++ b/crates/ssh-core/src/directory.rs @@ -71,6 +71,12 @@ impl SftpBrowser { } #[async_trait] impl FileSystemProvider for SftpBrowser { + fn supports_local_mount(&self) -> bool { + true + } + async fn mount_root(&self, path: &str, writable: bool) -> FsResult> { + Ok(crate::mounted::SftpMount::new(self.0.clone(), path, writable).await?) + } async fn list(&self, path: Option<&str>) -> Result { let mut directory = collect_directory(self.open(path).await?).await?; // Existing materializing callers retain their presentation order. Page diff --git a/crates/ssh-core/src/lib.rs b/crates/ssh-core/src/lib.rs index d856c88..580ce2e 100644 --- a/crates/ssh-core/src/lib.rs +++ b/crates/ssh-core/src/lib.rs @@ -7,6 +7,7 @@ pub use volumes::SshFileBrowser; pub mod file_actions; pub mod host_keys; pub use host_keys::*; +pub mod mounted; pub mod probe; pub mod profiles; pub mod provider; diff --git a/crates/ssh-core/src/mounted.rs b/crates/ssh-core/src/mounted.rs new file mode 100644 index 0000000..38b1154 --- /dev/null +++ b/crates/ssh-core/src/mounted.rs @@ -0,0 +1,563 @@ +// SPDX-License-Identifier: MPL-2.0 +//! SFTP implementation of the optional native mount service. No remote agent. +use crate::{SftpBrowser, SftpTextFiles, OP_TIMEOUT}; +use async_trait::async_trait; +use russh_sftp::{ + client::error::Error as SftpError, + protocol::{FileAttributes, OpenFlags, StatusCode}, +}; +use shellcanvas_services::*; +use std::{future::Future, sync::Arc}; +use tokio::sync::RwLock; + +fn error(e: SftpError) -> FsError { + let kind = match &e { + SftpError::Status(s) => match s.status_code { + StatusCode::NoSuchFile => FsErrorKind::NotFound, + StatusCode::PermissionDenied => FsErrorKind::PermissionDenied, + StatusCode::NoConnection | StatusCode::ConnectionLost => FsErrorKind::Offline, + StatusCode::OpUnsupported => FsErrorKind::Unsupported, + _ => FsErrorKind::Io, + }, + _ => FsErrorKind::Io, + }; + FsError::new(kind, e.to_string()) +} +async fn request(f: impl Future>) -> FsResult { + tokio::time::timeout(OP_TIMEOUT, f) + .await + .map_err(|_| { + FsError::new( + FsErrorKind::TimedOut, + "Remote filesystem request timed out; a write may have completed", + ) + })? + .map_err(error) +} +fn metadata(a: FileAttributes) -> FsMetadata { + FsMetadata { + kind: if a.is_dir() { + FsKind::Directory + } else if a.is_regular() { + FsKind::File + } else if a.is_symlink() { + FsKind::Symlink + } else { + FsKind::Other + }, + size: a.size.unwrap_or(0), + accessed: a.atime.map(u64::from), + modified: a.mtime.map(u64::from), + permissions: a.permissions, + } +} +fn writable(enabled: bool) -> FsResult<()> { + if enabled { + Ok(()) + } else { + Err(FsError::new( + FsErrorKind::ReadOnly, + "This attachment is read-only", + )) + } +} +fn mutable_path(path: &MountPath) -> FsResult<()> { + if path.components().is_empty() { + Err(FsError::new( + FsErrorKind::PermissionDenied, + "The attachment root cannot be removed or replaced", + )) + } else { + Ok(()) + } +} +fn regular(a: &FileAttributes) -> FsResult<()> { + if a.is_regular() { + Ok(()) + } else { + Err(FsError::new( + if a.is_dir() { + FsErrorKind::IsDirectory + } else { + FsErrorKind::Unsupported + }, + "Only regular files can be opened through this attachment", + )) + } +} +fn attributes(update: FsSetMetadata, current: FileAttributes) -> FsResult { + let mut attrs = FileAttributes::empty(); + attrs.size = update.size; + attrs.permissions = update.permissions.map(|p| p & 0o777); + // SFTP v3 changes access and modification time as a pair. Preserve the + // unspecified side, and refuse timestamps the protocol cannot represent. + if update.accessed.is_some() || update.modified.is_some() { + let convert = |value: Option| { + value.and_then(|v| u32::try_from(v).ok()).ok_or_else(|| { + FsError::new( + FsErrorKind::Unsupported, + "Timestamp is unavailable or outside the SFTP v3 range", + ) + }) + }; + attrs.atime = Some(convert(update.accessed.or(current.atime.map(u64::from)))?); + attrs.mtime = Some(convert(update.modified.or(current.mtime.map(u64::from)))?); + } + Ok(attrs) +} + +pub struct SftpMount { + service: Arc, + root: String, + writable: bool, +} +impl SftpMount { + pub async fn new(service: Arc, path: &str, write: bool) -> FsResult> { + let root = SftpBrowser(service.clone()) + .canonicalize(path) + .await + .map_err(|e| FsError::new(FsErrorKind::Io, e.to_string()))?; + if !request(service.raw.lstat(&root)).await?.attrs.is_dir() { + return Err(FsError::new( + FsErrorKind::NotDirectory, + "Select a remote directory", + )); + } + Ok(Arc::new(Self { + service, + root, + writable: write, + })) + } + /// Walk components without following links. SFTP v3 has no openat/nofollow + /// primitive: this rejects observed links, not concurrent server-side path + /// replacement. The authenticated account remains the server security bound. + async fn resolve(&self, path: &MountPath, absent_leaf: bool) -> FsResult { + let mut result = self.root.clone(); + let root_attrs = request(self.service.raw.lstat(&result)).await?.attrs; + if !root_attrs.is_dir() { + return Err(FsError::new( + FsErrorKind::NotDirectory, + "Attachment root changed", + )); + } + for (index, name) in path.components().iter().enumerate() { + result = format!("{}/{name}", result.trim_end_matches('/')); + let last = index + 1 == path.components().len(); + match request(self.service.raw.lstat(&result)).await { + Ok(a) => { + if a.attrs.is_symlink() || !(a.attrs.is_dir() || a.attrs.is_regular()) { + return Err(FsError::new( + FsErrorKind::Unsupported, + "Links and special files are not exposed by this attachment", + )); + } + if !last && !a.attrs.is_dir() { + return Err(FsError::new( + FsErrorKind::NotDirectory, + "A path component is not a directory", + )); + } + } + Err(e) if last && absent_leaf && e.kind == FsErrorKind::NotFound => {} + Err(e) => return Err(e), + } + } + Ok(result) + } +} + +struct RemoteHandle { + service: Arc, + id: RwLock>, +} +impl RemoteHandle { + async fn close(&self) -> FsResult<()> { + let mut id = self.id.write().await; + if let Some(id) = id.take() { + request(self.service.raw.close(id)).await?; + } + Ok(()) + } +} +impl Drop for RemoteHandle { + fn drop(&mut self) { + if let Some(id) = self.id.get_mut().take() { + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + let service = self.service.clone(); + runtime.spawn(async move { + let _ = request(service.raw.close(id)).await; + }); + } + } + } +} +fn closed() -> FsError { + FsError::new(FsErrorKind::Offline, "Remote file handle is closed") +} +struct RemoteFile { + handle: RemoteHandle, + read: bool, + write: bool, +} +#[async_trait] +impl MountedFile for RemoteFile { + async fn metadata(&self) -> FsResult { + let id = self.handle.id.read().await; + Ok(metadata( + request( + self.handle + .service + .raw + .fstat(id.as_ref().ok_or_else(closed)?), + ) + .await? + .attrs, + )) + } + async fn read_at(&self, offset: u64, length: u32) -> FsResult> { + if !self.read { + return Err(FsError::new( + FsErrorKind::PermissionDenied, + "Handle was not opened for reading", + )); + } + if length as usize > MOUNT_IO_CHUNK || offset.checked_add(u64::from(length)).is_none() { + return Err(FsError::new( + FsErrorKind::InvalidInput, + "Invalid read range", + )); + } + if length == 0 { + return Ok(vec![]); + } + let id = self.handle.id.read().await; + let operation = + self.handle + .service + .raw + .read(id.as_ref().ok_or_else(closed)?, offset, length); + match tokio::time::timeout(OP_TIMEOUT, operation).await { + Ok(Ok(data)) if data.data.len() <= length as usize => Ok(data.data.to_vec()), + Ok(Ok(_)) => Err(FsError::new( + FsErrorKind::Io, + "Server exceeded the read length", + )), + Ok(Err(SftpError::Status(s))) if s.status_code == StatusCode::Eof => Ok(vec![]), + Ok(Err(e)) => Err(error(e)), + Err(_) => Err(FsError::new(FsErrorKind::TimedOut, "Remote read timed out")), + } + } + async fn write_at(&self, offset: u64, bytes: &[u8]) -> FsResult<()> { + writable(self.write)?; + if bytes.len() > MOUNT_IO_CHUNK || offset.checked_add(bytes.len() as u64).is_none() { + return Err(FsError::new( + FsErrorKind::InvalidInput, + "Invalid write range", + )); + } + let id = self.handle.id.read().await; + request(self.handle.service.raw.write( + id.as_ref().ok_or_else(closed)?, + offset, + bytes.to_vec(), + )) + .await?; + Ok(()) + } + async fn set_metadata(&self, update: FsSetMetadata) -> FsResult<()> { + writable(self.write)?; + let id = self.handle.id.read().await; + let id = id.as_ref().ok_or_else(closed)?; + let current = request(self.handle.service.raw.fstat(id)).await?.attrs; + request( + self.handle + .service + .raw + .fsetstat(id, attributes(update, current)?), + ) + .await?; + Ok(()) + } + async fn flush(&self) -> FsResult<()> { + let id = self.handle.id.read().await; + let id = id.as_ref().ok_or_else(closed)?; + if self.write && self.handle.service.fsync { + tokio::time::timeout( + OP_TIMEOUT, + self.handle.service.extension("fsync@openssh.com", &[id]), + ) + .await + .map_err(|_| FsError::new(FsErrorKind::TimedOut, "Remote flush timed out"))? + .map_err(|e| FsError::new(FsErrorKind::Io, e.to_string()))?; + } + Ok(()) + } + async fn close(&self) -> FsResult<()> { + self.handle.close().await + } +} + +struct RemoteDirectory { + handle: RemoteHandle, + eof: bool, +} +#[async_trait] +impl MountedDirectory for RemoteDirectory { + async fn next(&mut self) -> FsResult> { + if self.eof { + return Ok(vec![]); + } + loop { + let id = self.handle.id.read().await; + let result = tokio::time::timeout( + OP_TIMEOUT, + self.handle + .service + .raw + .readdir(id.as_ref().ok_or_else(closed)?), + ) + .await; + match result { + Ok(Ok(names)) => { + let entries: Vec<_> = names + .files + .into_iter() + .filter(|f| { + MountPath::root().child(&f.filename).is_ok() + && (f.attrs.is_dir() || f.attrs.is_regular()) + }) + .map(|f| FsDirectoryEntry { + name: f.filename, + metadata: metadata(f.attrs), + }) + .collect(); + if !entries.is_empty() { + return Ok(entries); + } + } + Ok(Err(SftpError::Status(s))) if s.status_code == StatusCode::Eof => { + self.eof = true; + return Ok(vec![]); + } + Ok(Err(e)) => return Err(error(e)), + Err(_) => { + return Err(FsError::new( + FsErrorKind::TimedOut, + "Remote directory read timed out", + )) + } + } + } + } + async fn close(&mut self) -> FsResult<()> { + self.eof = true; + self.handle.close().await + } +} + +#[async_trait] +impl MountedFileSystem for SftpMount { + async fn space(&self, path: &MountPath) -> FsResult { + let path = self.resolve(path, false).await?; + let stats = request(self.service.raw.statvfs(path)).await?; + Ok(FsSpace { + block_size: stats.fragment_size, + blocks: stats.blocks, + blocks_free: stats.blocks_free, + blocks_available: stats.blocks_avail, + files: stats.inodes, + files_free: stats.inodes_free, + name_max: stats.name_max, + }) + } + fn capabilities(&self) -> FsCapabilities { + FsCapabilities { + writable: self.writable, + atomic_replace: self.service.can_save(), + durable_flush: self.service.fsync, + } + } + async fn metadata(&self, path: &MountPath) -> FsResult { + let path = self.resolve(path, false).await?; + Ok(metadata(request(self.service.raw.lstat(path)).await?.attrs)) + } + async fn open( + &self, + path: &MountPath, + options: FsOpenOptions, + ) -> FsResult> { + options.validate()?; + if options.write { + writable(self.writable)?; + } + let path = self + .resolve(path, options.create != FsCreate::OpenExisting) + .await?; + match request(self.service.raw.lstat(&path)).await { + Ok(a) => { + if options.create == FsCreate::CreateNew { + return Err(FsError::new( + FsErrorKind::AlreadyExists, + "File already exists", + )); + } + regular(&a.attrs)?; + } + Err(e) + if e.kind == FsErrorKind::NotFound && options.create != FsCreate::OpenExisting => {} + Err(e) => return Err(e), + } + let mut flags = OpenFlags::empty(); + if options.read { + flags |= OpenFlags::READ; + } + if options.write { + flags |= OpenFlags::WRITE; + } + if options.create != FsCreate::OpenExisting { + flags |= OpenFlags::CREATE; + } + if options.create == FsCreate::CreateNew { + flags |= OpenFlags::EXCLUDE; + } + // Truncate only after validating the returned handle is a regular file. + let service = self.service.clone(); + let file = tokio::spawn(async move { + let id = request(service.raw.open(path, flags, FileAttributes::empty())) + .await? + .handle; + let file = RemoteFile { + handle: RemoteHandle { + service, + id: RwLock::new(Some(id)), + }, + read: options.read, + write: options.write, + }; + if file.metadata().await?.kind != FsKind::File { + return Err(FsError::new( + FsErrorKind::Unsupported, + "Opened object is not a regular file", + )); + } + if options.truncate { + file.set_metadata(FsSetMetadata { + size: Some(0), + ..Default::default() + }) + .await?; + } + Ok::<_, FsError>(Arc::new(file) as Arc) + }) + .await + .map_err(|e| FsError::new(FsErrorKind::Io, e.to_string()))??; + Ok(file) + } + async fn open_directory(&self, path: &MountPath) -> FsResult> { + let path = self.resolve(path, false).await?; + if !request(self.service.raw.lstat(&path)).await?.attrs.is_dir() { + return Err(FsError::new(FsErrorKind::NotDirectory, "Not a directory")); + } + let service = self.service.clone(); + tokio::spawn(async move { + let id = request(service.raw.opendir(path)).await?.handle; + Ok(Box::new(RemoteDirectory { + handle: RemoteHandle { + service, + id: RwLock::new(Some(id)), + }, + eof: false, + }) as Box) + }) + .await + .map_err(|e| FsError::new(FsErrorKind::Io, e.to_string()))? + } + async fn set_metadata(&self, path: &MountPath, update: FsSetMetadata) -> FsResult<()> { + writable(self.writable)?; + let path = self.resolve(path, false).await?; + let current = request(self.service.raw.lstat(&path)).await?.attrs; + if update.size.is_some() { + regular(¤t)?; + } + request(self.service.raw.setstat(path, attributes(update, current)?)).await?; + Ok(()) + } + async fn mkdir(&self, path: &MountPath) -> FsResult<()> { + writable(self.writable)?; + mutable_path(path)?; + let path = self.resolve(path, true).await?; + match request(self.service.raw.lstat(&path)).await { + Ok(_) => { + return Err(FsError::new( + FsErrorKind::AlreadyExists, + "Directory already exists", + )) + } + Err(e) if e.kind == FsErrorKind::NotFound => {} + Err(e) => return Err(e), + } + request(self.service.raw.mkdir(path, FileAttributes::empty())).await?; + Ok(()) + } + async fn remove(&self, path: &MountPath, directory: bool) -> FsResult<()> { + writable(self.writable)?; + mutable_path(path)?; + let path = self.resolve(path, false).await?; + let a = request(self.service.raw.lstat(&path)).await?.attrs; + if directory { + if !a.is_dir() { + return Err(FsError::new(FsErrorKind::NotDirectory, "Not a directory")); + } + request(self.service.raw.rmdir(path)).await?; + } else { + regular(&a)?; + request(self.service.raw.remove(path)).await?; + } + Ok(()) + } + async fn rename(&self, from: &MountPath, to: &MountPath, replace: bool) -> FsResult<()> { + writable(self.writable)?; + mutable_path(from)?; + mutable_path(to)?; + let from = self.resolve(from, false).await?; + let to = self.resolve(to, true).await?; + if from == to { + return Ok(()); + } + if replace { + if !self.service.can_save() { + return Err(FsError::new( + FsErrorKind::Unsupported, + "Server does not support atomic replacement", + )); + } + tokio::time::timeout( + OP_TIMEOUT, + self.service + .extension("posix-rename@openssh.com", &[&from, &to]), + ) + .await + .map_err(|_| { + FsError::new( + FsErrorKind::TimedOut, + "Rename timed out; check the destination before retrying", + ) + })? + .map_err(|e| FsError::new(FsErrorKind::Io, e.to_string()))?; + } else { + match request(self.service.raw.lstat(&to)).await { + Ok(_) => { + return Err(FsError::new( + FsErrorKind::AlreadyExists, + "Destination already exists", + )) + } + Err(e) if e.kind == FsErrorKind::NotFound => {} + Err(e) => return Err(e), + } + request(self.service.raw.rename(from, to)).await?; + } + Ok(()) + } +} diff --git a/crates/ssh-core/src/volumes.rs b/crates/ssh-core/src/volumes.rs index f87cdbb..43f3af9 100644 --- a/crates/ssh-core/src/volumes.rs +++ b/crates/ssh-core/src/volumes.rs @@ -131,6 +131,19 @@ impl SshFileBrowser { } #[async_trait] impl FileSystemProvider for SshFileBrowser { + fn supports_local_mount(&self) -> bool { + true + } + async fn mount_root(&self, path: &str, writable: bool) -> FsResult> { + // Each attachment has its own channel, while authentication and verified + // host identity remain owned by the accepted connection. + let service = self + .connection + .text_files() + .await + .map_err(|e| FsError::new(FsErrorKind::Offline, e.to_string()))?; + Ok(crate::mounted::SftpMount::new(Arc::new(service), path, writable).await?) + } async fn volumes(&self) -> Result { self.inventory().await } diff --git a/docs/filesystem-integration-progress.md b/docs/filesystem-integration-progress.md new file mode 100644 index 0000000..3aa0502 --- /dev/null +++ b/docs/filesystem-integration-progress.md @@ -0,0 +1,116 @@ +# Filesystem integration implementation checkpoint + +Updated 2026-09-10. Goal remains active. This is not a release/completion claim. + +## Accepted scope + +Implement optional core filesystem operations and machinery, with Windows +WinFsp and Linux/macOS FUSE backends where supported. Modern client systems are +the priority. The user's later decision makes the driver-facing bridge a separate +public, free app; the core must not depend on third-party filesystem drivers. +Include the dependencies' licensing/distribution limitations in that app. + +## Current implementation + +- `crates/filesystem-sdk`: small MPL-2.0 optional rooted filesystem contract and + inherited-pipe protocol. Validated relative components, metadata/capacity, + handle-based offset I/O, truncate, flush, atomic replacement, incremental + directories, errors and handle cleanup. No driver or SSH dependency. +- `FileSystemProvider` has optional `supports_local_mount` / `mount_root` defaults. + Existing providers do not need to implement them. Sequential transfer semantics + remain unchanged. +- SFTP implements the contract. An SSH attachment uses a dedicated SFTP channel + on the accepted connection. Read-only grants are enforced at the provider. + No special daemon, script or mount utility is needed on the remote file host. +- Desktop Files bindings forward the optional mount capability. Mounted files and + directories capture that binding; retirement rejects further I/O and reports + uncertain in-flight writes, while close still targets the original handles. + Heartbeats check binding health. Whole-grant handle teardown and blocked reply + writes have bounded deadlines. +- Public repository: https://github.com/techartdev/ShellCanvas-DriveBridge, + initial commit `0cc04ad2e0c31f7e30e39960ea9536e0d0d324f9`. + Follow-up `dac05fa` adds executable help/notices, build/native-test status, + binding health and bounded protocol cleanup. Published on `main`. + Canonical local checkout: `D:\Mine\ShellCanvas-DriveBridge`. + `.local/drive-bridge` is the initial build staging copy, not the canonical repo. +- Bridge has native Windows callbacks and a shared Linux/macOS FUSE backend, + plus an independently buildable vendored SDK. GPL-3.0-only bridge licensing + leaves the main app MPL-2.0. README/THIRD-PARTY describe WinFsp/wrapper terms, + separate macFUSE installation and commercial binary-bundling restrictions. +- Main desktop storage/settings/installation and right-click attachment UI are + **not integrated yet**. No user mapping is active or advertised as ready. + +## Evidence gathered + +- `cargo check -p shellcanvas-core` passes. +- `cargo clippy -p shellcanvas-core -p shellcanvas-filesystem-sdk --all-targets -- -D warnings` passes. +- Four filesystem SDK tests pass: path validation, bounded frames, exact large + offsets and transport retirement on a mismatched reply. +- Opt-in `mount_probe` on the authorized Linux SSH host passes real SFTP tests: + offsets above 4 GiB using a sparse file, truncation, EOF, atomic save replacement, + old/new open-handle identity, close, read-only enforcement, directory listing + and removal of its disposable UUID directory. +- Opt-in `bridge_probe` passes with the independently compiled Windows executable: + native process → inherited pipes → core root grant → real SFTP. Offset I/O, + flush, truncate, rename, closed-handle refusal and disposable cleanup pass. +- Windows bridge builds; Linux `cargo check` and `cargo clippy -- -D warnings` + pass with `x86_64-unknown-linux-gnu`. Neither is a native mounted-drive test. +- GitHub Actions run `34496246842` completed successfully for the initial public + commit on Windows, Ubuntu and macOS 14. This verifies compilation, not native + mounted-drive behavior on Windows/macOS. +- All 32 SSH core unit tests and 16 desktop workspace-binding tests pass, including + retirement/cleanup/error preservation for mounted handles. Desktop library + compilation passes. +- Native Linux mount checks pass through the CI-built FUSE executable, a pipe + relayed over SSH, the Windows core and real SFTP. Verified sparse offsets above + 4 GiB, truncate, replacement saves with old handle identity preserved, a 70-file + directory spanning pages, directory rename with an open file, capacity and + missing/nonempty errors. Ordinary unmount succeeded. This deliberately relayed + test has extra network round trips and is not a production performance baseline. + The first test timed out creating files; the next passed filesystem checks but + exposed a harness cleanup bug (normal Files deletion only accepts empty folders). + Both disposable fixtures were subsequently confirmed unmounted and removed. + `native_bridge_probe` now uses an explicit bounded remote timeout and scoped + recursive cleanup of its own UUID fixture. Final replay passed end to end, + including ordinary unmount and confirmed removal of the entire disposable tree. +- The Linux test host already has `fusermount3`, `fusermount` and `/dev/fuse`. + Its ordinary PATH has no cargo/rustc/gcc. Prefer a CI-built binary for a + disposable FUSE test rather than changing the server's installed toolchain. +- Verified the official WinFsp 2.1.25156 MSI signature (Navimatics). Installation + failed with Windows Installer 1925 / exit 1603: administrator privileges needed. + No successful driver installation has been established. The async UAC question + is pending. This is an OS privilege issue, not an automatic approval rejection. + Installer/log are under `.local/bridge-tools`. Workspace-only libclang 18.1.1 + is available at `.local/bridge-tools/python/clang/native` for Windows builds. + +## Remaining completion gates + +1. Implement reviewed bridge installation/configuration, one-root/one-source + grant management, helper supervision and explicit user access mode. Keep + credentials in the desktop and never accept an executable path from a web app. +2. Add the Files folder/volume context action, attachment dialog and mapping + status/management UI. Verify the modern neutral theme and compact layouts. +3. Pin connection generation and source identity throughout mapping lifetime. + Closing a Files window must not stop a mapping; source replacement/disconnect + and app quit must account for active mappings and busy handles. No silent + forced detach or stale-handle reconnection. +4. Native Windows mount tests after administrator setup: Explorer and actual + local file APIs, ordinary editor/temporary-file replacement saves, directory + rename with open handles, capacity, errors and disconnect behavior. +5. Native Linux FUSE tests using a CI binary and a disposable directory. Validate + directory cursor/rewind and inode retention/forget behavior, create/rename/ + truncate, permissions, flush, detach and helper failure. +6. Verify modern macOS compilation and native runtime as available. fuser's + kernel/libfuse backend is implemented; FSKit operation is not established. + Do not claim an OS version/runtime works solely because Linux compiled. +7. Resolve currently documented limits before calling the release ready: + Windows cleanup-time deletion failures need visible reporting, file attributes + and cross-handle directory rename need native checks, busy detach needs a + control protocol, and memory-mapped workflows need explicit acceptance. +8. Review cancellation/late responses, bounded teardown and protocol errors with + failure fixtures, then run the relevant core/desktop regression checks. Add + reproducible release packages/notices and update bridge docs with exact verified + platforms. Do not represent an unsigned preview binary as a supported release. + +Preserve pre-existing theme changes in this worktree. The normal app profile and +the unrelated assistant repository were not modified by this implementation pass. diff --git a/docs/local-drive-bridge.md b/docs/local-drive-bridge.md new file mode 100644 index 0000000..668c044 --- /dev/null +++ b/docs/local-drive-bridge.md @@ -0,0 +1,142 @@ +# Attach remote folders to this computer + +Status: implementation active, 2026-09-10. See the authoritative +[implementation checkpoint](filesystem-integration-progress.md). This is separate from +[remote drives and mounts](drives-and-mounts.md), which manage storage on the host. + +## Product decision + +The selected design is an **Attach to this computer…** action in Files, backed by +the separate public, free [Drive Bridge app](https://github.com/techartdev/ShellCanvas-DriveBridge). +The core has no WinFsp/FUSE dependency. A user selects a folder or a browsable +volume, chooses a local drive letter/folder and read-only or read/write access, +then opens it from ordinary local applications. Existing Linux and Mac SSH +hosts are sufficient for Windows client testing; a Windows remote host is not +required. Nothing new needs to run on an SFTP-capable remote host. + +The desktop owns root grants, connection lifetime and credentials. The separate +native bridge owns OS filesystem callbacks and driver dependencies. The two +communicate through scoped inherited pipes. Native-code installation and mapping +management must be explicit; an isolated web frame cannot supply arbitrary launch +paths. Keep installation optional so Files and Terminal retain their small +dependency footprint. The bridge repository documents its GPL license and the +dependencies' separate terms. Modern supported client systems are the priority; +legacy Catalina client compatibility is outside this goal. + +## Local platform backends + +| Client OS | Recommended starting point | Setup and scope | +| --- | --- | --- | +| Windows | Native WinFsp API with a Rust bridge | Signed driver/runtime installed once; drive letter or local directory. No WSL or Cygwin required for this native approach. | +| Linux | FUSE/libfuse backend sharing the Rust provider logic | Local directory mount; distribution-specific runtime/helper setup. | +| macOS | Evaluate macFUSE, including its modern FSKit backend | Current macFUSE requires macOS 12+. The FSKit backend targets macOS 26. Catalina needs a separate legacy-runtime decision and testing. | + +Sources: [WinFsp distribution](https://winfsp.dev/rel/), +[native callback API](https://winfsp.dev/doc/WinFsp-API-winfsp.h/), +[Linux FUSE documentation](https://www.kernel.org/doc/html/latest/filesystems/fuse/), +[macFUSE platform requirements](https://macfuse.github.io/). +These are local client requirements: the old Mac can still be a remote SFTP host. + +WinFsp is the proposed Windows dependency, not a commercial drive-manager app. +Its GPLv3 license includes a FLOSS exception and a separate commercial option. +Before distributing a bridge, verify the exact exception and packaging against +ShellCanvas's MPL-2.0 licensing; do not assume a future private bridge is covered. +Review macFUSE redistribution separately when selecting that backend. +See [WinFsp licensing](https://winfsp.dev/com/). + +[SSHFS-Win](https://github.com/winfsp/sshfs-win) demonstrates the desired user +workflow, but wrapping it would introduce separate SSH/session handling and an +SSH-specific foundation. Prefer reusing our accepted file provider and trust +decisions. A local SMB/WebDAV gateway is not the selected approach: it introduces +another server/protocol layer instead of implementing filesystem callbacks. + +## Small, optional core extension + +Flow: local application → OS filesystem framework → native Rust mount service +→ accepted Files provider → remote filesystem. + +The existing `FileTransferService` is designed for checked, sequential whole-file +transfers. `TransferReader.read()` has no offset, and `TransferWriter.finish()` +publishes an absent destination. These contracts should retain their meaning. +They are insufficient for applications that seek within a file or save through +a temporary file followed by replacement. + +Add an optional handle-based filesystem capability, initially implemented by +the SFTP provider. Derive the final method signatures from the Windows prototype, +rather than expanding the mandatory provider or isolated-app API in advance: + +- Read-only subset: metadata, incremental directory enumeration, open/close and + reads at explicit offsets. +- Writable subset: create, writes at offsets, truncate, flush, rename/replace, + removal and directory creation, with explicit supported metadata behavior. +- Optional semantics: links, locks, durability and other OS-specific features. + Never advertise guarantees that the underlying provider cannot deliver. + +Simple devices keep their current interfaces. A browse/download-only provider +does not automatically qualify for mounting. Future FTP/API adapters can opt in +where their semantics support it; do not hide missing random writes behind +unbounded whole-file caching. + +## Lifetime, access and failures + +- Pin each mapping to its Files source, account, verified host identity and root. + Switching the visible workspace must never retarget a drive. Keep old open + handles invalid after a connection generation changes. +- Run filesystem I/O outside the WebView/UI thread. Prefer a supervised native + helper with user-restricted authenticated IPC. Keep credentials in the core; + do not pass passwords or keys on command lines. +- A read/write attachment is an explicit grant for local applications to modify + that remote subtree. The mount cannot ask a UI question for every OS write. + Define path and symlink handling before exposing writes; reject escapes and + unsupported names rather than mapping them ambiguously. Do not claim a server + sandbox merely because the UI selected a folder. +- Enumerate lazily and read in bounded chunks, with bounded workers and caches. + No whole-tree pre-scan or arbitrary total file/depth cap. Define cache expiry + and refresh behavior when another remote client modifies files. +- Start with acknowledged writes and no offline write-back. Surface timeouts, + permission failures and failed writes to the calling app. Report flush according + to the negotiated provider capability; an SFTP acknowledgement alone is not a + promise of stable disk persistence. Do not defer fallible uploads until close, + where the OS callback may be unable to return the error to the application. +- Network loss leaves a visibly offline mapping with bounded I/O failure, not + an Explorer freeze. Reconnection must revalidate identity; never automatically + replay an ambiguously completed mutation. +- Closing a Files window does not detach the drive. Normal detach drains writes + and reports busy handles. Quitting ShellCanvas must handle active mappings + explicitly; silent forced detach and automatic startup mounting are out of scope + for the first release. + +## User flow + +1. Folder/volume context menu: **Attach to this computer…**. +2. Dialog shows host and remote path, local target, access mode and any missing + native component. Installation is a deliberate setup action. +3. A built-in mappings view shows name, source, local path and connection state, + with Open, Retry and Detach actions. Failed setup must leave no phantom drive. +4. Disconnect/quit explains which mappings are affected and allows cancellation + when files remain in use. + +## Delivery gates + +- **BRIDGE-01 — Windows proof.** Select and validate the WinFsp native Rust binding + and licensing; build a read-only SFTP mapping against a disposable directory on + an existing host. Verify Explorer enumeration, large-file seek/read, hashes, + Unicode paths and connection loss. This is an engineering milestone, not the + completed end-user feature. +- **BRIDGE-02 — First usable release.** Implement write/create/truncate/replace, + clear unsupported-operation errors, context-menu setup, mapping management, + bounded caching and safe detach/quit. Test an ordinary editor and Office-style + temporary-file/rename saves, simultaneous handles, remote changes, disk-full and + permission errors, interrupted writes, case-sensitive name collisions and + reconnect identity changes. Compare resulting remote bytes. Do not promise + database, VM-image or distributed locking compatibility without specific tests. +- **BRIDGE-03 — Other client OSs.** Implement Linux and macOS backends after the + shared contract is exercised. Test the exact supported runtime/OS combinations; + decide Catalina support independently from its remote-host support. +- **BRIDGE-04 — Extension access.** Exercise a second Files provider and only then + expose the needed optional native-adapter methods. Consider an app management + API with explicit grants, without granting web apps arbitrary OS mounting. + +The design assessment installed no driver or remote software. Subsequent live +implementation tests use newly created disposable directories; see the checkpoint +for evidence, pending administrator setup and unfinished release gates. diff --git a/docs/post-goal-backlog.md b/docs/post-goal-backlog.md index 57d1e3d..b541728 100644 --- a/docs/post-goal-backlog.md +++ b/docs/post-goal-backlog.md @@ -243,6 +243,22 @@ are contract demonstrations, not production device support. ## Extensions, AI and distribution +- [ ] **BRIDGE-01 — Attach remote folders as local drives: Windows proof.** + Proposed built-in action with an optional WinFsp native component and a + small, optional handle-based Files capability. Verify read-only SFTP access + through Explorer/local apps on an existing Linux or Mac host. A Windows + remote host is not needed. Driver setup and license packaging need validation. + See [design and acceptance gates](local-drive-bridge.md). +- [ ] **BRIDGE-02 — Writable local-drive release.** Offset writes, ordinary editor + saves/replace, errors, bounded caches, disconnect handling, mappings UI and + detach/quit lifecycle. A read-only prototype alone does not complete this. +- [ ] **BRIDGE-03 — Linux/macOS local mounts.** FUSE/platform backends and exact OS + verification; decide Catalina runtime support separately from using it as + a remote SSH host. +- [ ] **BRIDGE-04 — Optional adapter/app integration.** Prove a second file + provider, then expose only the optional contracts needed. Keep native mount + ownership and credentials in the desktop; limited adapters stay unchanged. + - [ ] **EXT-03 — Package distribution.** Publisher authentication, signatures, explicit rollback tooling and marketplace/catalog distribution. Existing install/update/remove, integrity checks and retained generations are complete. diff --git a/src-tauri/src/mounted_binding.rs b/src-tauri/src/mounted_binding.rs new file mode 100644 index 0000000..07ebc8e --- /dev/null +++ b/src-tauri/src/mounted_binding.rs @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: MPL-2.0 +//! A mount captures its Files binding. Retired bindings never resolve a new host. +use super::*; + +impl Binding { + pub(super) fn mount_check(&self) -> FsResult<()> { + self.check() + .map_err(|error| FsError::new(FsErrorKind::Offline, error.to_string())) + } + async fn mount_run( + &self, + write: bool, + operation: impl Future> + Send, + ) -> FsResult { + self.mount_check()?; + let result = operation.await; + self.after(write) + .map_err(|error| FsError::new(FsErrorKind::Offline, error.to_string()))?; + result + } +} + +pub(super) struct FileSystem { + binding: Binding, + inner: Arc, +} +impl FileSystem { + pub(super) fn new(binding: Binding, inner: Arc) -> Self { + Self { binding, inner } + } +} +#[async_trait] +impl MountedFileSystem for FileSystem { + fn check_available(&self) -> FsResult<()> { + self.binding.mount_check()?; + self.inner.check_available() + } + fn capabilities(&self) -> FsCapabilities { + self.inner.capabilities() + } + async fn space(&self, path: &MountPath) -> FsResult { + self.binding.mount_run(false, self.inner.space(path)).await + } + async fn metadata(&self, path: &MountPath) -> FsResult { + self.binding + .mount_run(false, self.inner.metadata(path)) + .await + } + async fn open( + &self, + path: &MountPath, + options: FsOpenOptions, + ) -> FsResult> { + self.binding.mount_check()?; + let inner = self.inner.open(path, options).await?; + if let Err(error) = self + .binding + .after(options.create != FsCreate::OpenExisting || options.truncate) + { + let _ = tokio::time::timeout(Duration::from_secs(3), inner.close()).await; + return Err(FsError::new(FsErrorKind::Offline, error.to_string())); + } + Ok(Arc::new(File { + binding: self.binding.clone(), + inner, + })) + } + async fn open_directory(&self, path: &MountPath) -> FsResult> { + self.binding.mount_check()?; + let mut inner = self.inner.open_directory(path).await?; + if let Err(error) = self.binding.mount_check() { + let _ = tokio::time::timeout(Duration::from_secs(3), inner.close()).await; + return Err(error); + } + Ok(Box::new(Directory { + binding: self.binding.clone(), + inner, + })) + } + async fn set_metadata(&self, path: &MountPath, metadata: FsSetMetadata) -> FsResult<()> { + self.binding + .mount_run(true, self.inner.set_metadata(path, metadata)) + .await + } + async fn mkdir(&self, path: &MountPath) -> FsResult<()> { + self.binding.mount_run(true, self.inner.mkdir(path)).await + } + async fn remove(&self, path: &MountPath, directory: bool) -> FsResult<()> { + self.binding + .mount_run(true, self.inner.remove(path, directory)) + .await + } + async fn rename(&self, from: &MountPath, to: &MountPath, replace: bool) -> FsResult<()> { + self.binding + .mount_run(true, self.inner.rename(from, to, replace)) + .await + } +} +struct File { + binding: Binding, + inner: Arc, +} +#[async_trait] +impl MountedFile for File { + async fn metadata(&self) -> FsResult { + self.binding.mount_run(false, self.inner.metadata()).await + } + async fn read_at(&self, offset: u64, length: u32) -> FsResult> { + self.binding + .mount_run(false, self.inner.read_at(offset, length)) + .await + } + async fn write_at(&self, offset: u64, bytes: &[u8]) -> FsResult<()> { + self.binding + .mount_run(true, self.inner.write_at(offset, bytes)) + .await + } + async fn set_metadata(&self, metadata: FsSetMetadata) -> FsResult<()> { + self.binding + .mount_run(true, self.inner.set_metadata(metadata)) + .await + } + async fn flush(&self) -> FsResult<()> { + self.binding.mount_run(true, self.inner.flush()).await + } + async fn close(&self) -> FsResult<()> { + // Cleanup targets the captured handle even when its binding is retired. + self.inner.close().await + } +} +struct Directory { + binding: Binding, + inner: Box, +} +#[async_trait] +impl MountedDirectory for Directory { + async fn next(&mut self) -> FsResult> { + self.binding.mount_run(false, self.inner.next()).await + } + async fn close(&mut self) -> FsResult<()> { + self.inner.close().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicUsize; + struct Transport; + #[async_trait] + impl ConnectionLifecycle for Transport { + fn is_connected(&self) -> bool { + true + } + async fn disconnect(&self) -> Result<()> { + Ok(()) + } + } + struct Handle { + closes: AtomicUsize, + } + #[async_trait] + impl MountedFile for Handle { + async fn metadata(&self) -> FsResult { + Err(FsError::new(FsErrorKind::PermissionDenied, "fixture")) + } + async fn read_at(&self, _: u64, _: u32) -> FsResult> { + Ok(vec![7]) + } + async fn write_at(&self, _: u64, _: &[u8]) -> FsResult<()> { + Ok(()) + } + async fn set_metadata(&self, _: FsSetMetadata) -> FsResult<()> { + Ok(()) + } + async fn flush(&self) -> FsResult<()> { + Ok(()) + } + async fn close(&self) -> FsResult<()> { + self.closes.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + #[tokio::test] + async fn retired_mount_refuses_io_but_keeps_cleanup_and_typed_errors() { + let binding = Binding { + alive: Arc::new(AtomicBool::new(true)), + source: ConnectionResource::new( + ConnectionIdentity { + instance: 1, + generation: 1, + adapter: "fixture".into(), + }, + Arc::new(Transport), + ), + role: "files", + }; + let inner = Arc::new(Handle { + closes: AtomicUsize::new(0), + }); + let file = File { + binding: binding.clone(), + inner: inner.clone(), + }; + assert_eq!( + file.metadata().await.unwrap_err().kind, + FsErrorKind::PermissionDenied + ); + assert_eq!(file.read_at(0, 1).await.unwrap(), vec![7]); + // Retirement during an acknowledged write must report uncertainty. + let retired = binding.clone(); + let error = binding + .mount_run(true, async move { + retired.alive.store(false, Ordering::Release); + Ok(()) + }) + .await + .unwrap_err(); + assert_eq!(error.kind, FsErrorKind::Offline); + assert!(error.message.contains("uncertain")); + assert_eq!( + file.read_at(0, 1).await.unwrap_err().kind, + FsErrorKind::Offline + ); + assert_eq!( + file.write_at(0, &[8]).await.unwrap_err().kind, + FsErrorKind::Offline + ); + file.close().await.unwrap(); + assert_eq!(inner.closes.load(Ordering::SeqCst), 1); + } +} diff --git a/src-tauri/src/workspace_services.rs b/src-tauri/src/workspace_services.rs index 1999e46..3bce8aa 100644 --- a/src-tauri/src/workspace_services.rs +++ b/src-tauri/src/workspace_services.rs @@ -13,6 +13,9 @@ use std::{ time::Duration, }; +#[path = "mounted_binding.rs"] +mod mounted_binding; + #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct ServiceStatus { @@ -571,6 +574,18 @@ impl Drop for WorkspaceServices { #[async_trait] impl FileSystemProvider for Bound { + fn supports_local_mount(&self) -> bool { + self.binding.check().is_ok() && self.service.supports_local_mount() + } + async fn mount_root(&self, path: &str, writable: bool) -> FsResult> { + self.binding.mount_check()?; + let filesystem = self.service.mount_root(path, writable).await?; + self.binding.mount_check()?; + Ok(Arc::new(mounted_binding::FileSystem::new( + self.binding.clone(), + filesystem, + ))) + } async fn list(&self, path: Option<&str>) -> Result { self.binding.run(false, self.service.list(path)).await } From 19332e32def8dafba0ec6a55bf7b99c81dc05485 Mon Sep 17 00:00:00 2001 From: Vanyo Vanev Date: Thu, 10 Sep 2026 20:07:32 +0300 Subject: [PATCH 02/31] Integrate optional drive bridge installation and attachment lifecycle --- Cargo.lock | 1 + crates/adapter-runtime/src/lib.rs | 1 + crates/adapter-runtime/src/process_tree.rs | 3 +- .../examples/lifecycle_fixture.rs | 60 ++ crates/filesystem-sdk/src/bridge_control.rs | 160 +++++ crates/filesystem-sdk/src/lib.rs | 1 + crates/filesystem-sdk/src/wire.rs | 24 +- .../ssh-core/examples/native_bridge_probe.rs | 49 +- docs/filesystem-integration-progress.md | 88 ++- docs/local-drive-bridge.md | 6 +- src-tauri/Cargo.toml | 1 + src-tauri/src/adapters.rs | 3 + src-tauri/src/drive_bridge_install.rs | 410 ++++++++++++ src-tauri/src/drive_mappings.rs | 630 ++++++++++++++++++ src-tauri/src/lib.rs | 49 +- src-tauri/src/mounted_binding.rs | 56 +- src-tauri/src/workspace_services.rs | 1 + src/App.tsx | 38 +- src/app-services.ts | 6 + src/apps/Files.tsx | 32 + src/components/AttachDriveDialog.tsx | 189 ++++++ src/components/DriveBridgeSettings.css | 155 +++++ src/components/DriveBridgeSettings.tsx | 231 +++++++ src/components/DriveMappings.css | 127 ++++ src/components/DriveMappings.tsx | 121 ++++ src/components/FileVolumes.css | 10 + src/components/FileVolumes.tsx | 30 +- src/components/SettingsDialog.tsx | 7 +- src/sdk.ts | 19 + src/services.test.ts | 2 + src/services.ts | 6 + src/session-services.ts | 22 + 32 files changed, 2493 insertions(+), 45 deletions(-) create mode 100644 crates/filesystem-sdk/examples/lifecycle_fixture.rs create mode 100644 crates/filesystem-sdk/src/bridge_control.rs create mode 100644 src-tauri/src/drive_bridge_install.rs create mode 100644 src-tauri/src/drive_mappings.rs create mode 100644 src/components/AttachDriveDialog.tsx create mode 100644 src/components/DriveBridgeSettings.css create mode 100644 src/components/DriveBridgeSettings.tsx create mode 100644 src/components/DriveMappings.css create mode 100644 src/components/DriveMappings.tsx diff --git a/Cargo.lock b/Cargo.lock index aad7eb5..352d81a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4814,6 +4814,7 @@ dependencies = [ "russh", "serde", "serde_json", + "sha2 0.10.9", "shellcanvas-adapter-runtime", "shellcanvas-core", "shellcanvas-services", diff --git a/crates/adapter-runtime/src/lib.rs b/crates/adapter-runtime/src/lib.rs index 3a7dcb7..3e8bb14 100644 --- a/crates/adapter-runtime/src/lib.rs +++ b/crates/adapter-runtime/src/lib.rs @@ -7,6 +7,7 @@ mod custom; mod diagnostics; mod process; mod process_tree; +pub use process_tree::ProcessTree; mod services; mod standard; mod transfers; diff --git a/crates/adapter-runtime/src/process_tree.rs b/crates/adapter-runtime/src/process_tree.rs index a66b9bd..fe9c22e 100644 --- a/crates/adapter-runtime/src/process_tree.rs +++ b/crates/adapter-runtime/src/process_tree.rs @@ -2,7 +2,8 @@ use std::io; use tokio::process::{Child, Command}; -pub(crate) struct ProcessTree { +/// Ownership of a trusted native helper. This is lifecycle control, not a sandbox. +pub struct ProcessTree { #[cfg(windows)] job: windows::Job, } diff --git a/crates/filesystem-sdk/examples/lifecycle_fixture.rs b/crates/filesystem-sdk/examples/lifecycle_fixture.rs new file mode 100644 index 0000000..3974136 --- /dev/null +++ b/crates/filesystem-sdk/examples/lifecycle_fixture.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Synthetic child-process acceptance fixture. No filesystem or driver access. +use shellcanvas_filesystem_sdk::{ + bridge_control::{BridgeDirective, BridgeEvent}, + wire::{Client, Operation, Value}, +}; +use std::{ + io::{self, Write}, + thread, + time::Duration, +}; +fn main() -> Result<(), Box> { + let mode = std::env::args().nth(1).unwrap_or_default(); + match mode.as_str() { + "stall" => { + thread::sleep(Duration::from_secs(120)); + return Ok(()); + } + "invalid" => { + let payload = br#"{"version":999,"id":1,"operation":{"method":"Capabilities"}}"#; + let mut out = io::stdout().lock(); + out.write_all(&(payload.len() as u32).to_be_bytes())?; + out.write_all(payload)?; + out.flush()?; + thread::sleep(Duration::from_secs(120)); + return Ok(()); + } + "busy" => {} + _ => return Err("Choose busy, invalid or stall".into()), + } + // A child filling stderr must not block its protocol pipe. + eprint!("{}", "fixture diagnostic; ".repeat(8000)); + let client = Client::new(io::stdin(), io::stdout()); + client.call(Operation::Capabilities)?; + client.call(Operation::Report { + event: BridgeEvent::Ready, + })?; + let mut refused = false; + loop { + match client.call(Operation::Poll)? { + Value::Directive(BridgeDirective::Detach) => { + if refused { + client.call(Operation::Report { + event: BridgeEvent::Detached, + })?; + return Ok(()); + } + refused = true; + client.call(Operation::Report { + event: BridgeEvent::DetachFailed { + message: "Fixture file is busy".into(), + }, + })?; + } + Value::Directive(BridgeDirective::Continue) => {} + _ => return Err("Unexpected reply".into()), + } + thread::sleep(Duration::from_millis(20)); + } +} diff --git a/crates/filesystem-sdk/src/bridge_control.rs b/crates/filesystem-sdk/src/bridge_control.rs new file mode 100644 index 0000000..242d263 --- /dev/null +++ b/crates/filesystem-sdk/src/bridge_control.rs @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Driver-neutral lifecycle exchange for a single native attachment process. +use crate::{FsError, FsErrorKind, FsResult}; +use serde::{Deserialize, Serialize}; +use std::sync::Mutex; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum BridgePhase { + Starting, + Attached, + Detaching, + Detached, + Failed, +} +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BridgeSnapshot { + pub phase: BridgePhase, + pub message: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum BridgeDirective { + Continue, + Detach, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum BridgeEvent { + Ready, + Detached, + DetachFailed { message: String }, + Warning { message: String }, +} +struct State { + snapshot: BridgeSnapshot, + pending: bool, +} +pub struct BridgeControl(Mutex); +impl Default for BridgeControl { + fn default() -> Self { + Self(Mutex::new(State { + snapshot: BridgeSnapshot { + phase: BridgePhase::Starting, + message: None, + }, + pending: false, + })) + } +} +impl BridgeControl { + fn state(&self) -> FsResult> { + self.0 + .lock() + .map_err(|_| FsError::new(FsErrorKind::Offline, "Attachment state unavailable")) + } + pub fn snapshot(&self) -> FsResult { + Ok(self.state()?.snapshot.clone()) + } + pub fn request_detach(&self) -> FsResult<()> { + let mut state = self.state()?; + if state.snapshot.phase != BridgePhase::Attached { + return Err(FsError::new( + FsErrorKind::InvalidInput, + "Attachment is not ready for a detach request", + )); + } + state.snapshot.phase = BridgePhase::Detaching; + state.snapshot.message = None; + state.pending = true; + Ok(()) + } + pub fn poll(&self) -> FsResult { + let mut state = self.state()?; + if matches!( + state.snapshot.phase, + BridgePhase::Failed | BridgePhase::Detached + ) { + return Err(FsError::new(FsErrorKind::Offline, "Attachment is retired")); + } + Ok(if std::mem::take(&mut state.pending) { + BridgeDirective::Detach + } else { + BridgeDirective::Continue + }) + } + pub fn report(&self, event: BridgeEvent) -> FsResult<()> { + let mut state = self.state()?; + match event { + BridgeEvent::Ready if state.snapshot.phase == BridgePhase::Starting => { + state.snapshot.phase = BridgePhase::Attached + } + BridgeEvent::Detached + if matches!( + state.snapshot.phase, + BridgePhase::Attached | BridgePhase::Detaching + ) => + { + state.snapshot.phase = BridgePhase::Detached; + state.snapshot.message = None; + state.pending = false; + } + BridgeEvent::DetachFailed { message } + if state.snapshot.phase == BridgePhase::Detaching => + { + state.snapshot.phase = BridgePhase::Attached; + state.snapshot.message = Some(message.chars().take(4096).collect()); + } + BridgeEvent::Warning { message } + if matches!( + state.snapshot.phase, + BridgePhase::Attached | BridgePhase::Detaching + ) => + { + state.snapshot.message = Some(message.chars().take(4096).collect()) + } + _ => { + return Err(FsError::new( + FsErrorKind::InvalidInput, + "Unexpected attachment lifecycle event", + )) + } + } + Ok(()) + } + pub fn fail(&self, message: String) { + if let Ok(mut state) = self.state() { + if state.snapshot.phase != BridgePhase::Detached { + state.snapshot.phase = BridgePhase::Failed; + state.snapshot.message = Some(message.chars().take(4096).collect()); + } + state.pending = false; + } + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn busy_detach_preserves_attachment_and_requires_a_new_explicit_request() { + let control = BridgeControl::default(); + assert!(control.request_detach().is_err()); + control.report(BridgeEvent::Ready).unwrap(); + control.request_detach().unwrap(); + assert!(control.request_detach().is_err()); + assert!(matches!(control.poll().unwrap(), BridgeDirective::Detach)); + assert!(matches!(control.poll().unwrap(), BridgeDirective::Continue)); + control + .report(BridgeEvent::DetachFailed { + message: "Close the editor".into(), + }) + .unwrap(); + assert_eq!(control.snapshot().unwrap().phase, BridgePhase::Attached); + assert!(matches!(control.poll().unwrap(), BridgeDirective::Continue)); + control.request_detach().unwrap(); + control.poll().unwrap(); + control.report(BridgeEvent::Detached).unwrap(); + control.fail("pipe closed normally".into()); + assert_eq!(control.snapshot().unwrap().phase, BridgePhase::Detached); + assert!(control.report(BridgeEvent::Ready).is_err()); + } +} diff --git a/crates/filesystem-sdk/src/lib.rs b/crates/filesystem-sdk/src/lib.rs index c5b7839..5cddec6 100644 --- a/crates/filesystem-sdk/src/lib.rs +++ b/crates/filesystem-sdk/src/lib.rs @@ -4,6 +4,7 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use std::{fmt, sync::Arc}; +pub mod bridge_control; pub mod wire; pub const MOUNT_IO_CHUNK: usize = 32 * 1024; diff --git a/crates/filesystem-sdk/src/wire.rs b/crates/filesystem-sdk/src/wire.rs index e04eeaf..c27c19b 100644 --- a/crates/filesystem-sdk/src/wire.rs +++ b/crates/filesystem-sdk/src/wire.rs @@ -2,6 +2,7 @@ //! Versioned bridge IPC over inherited anonymous pipes, never a TCP listener. //! The parent grants exactly one filesystem root. Neither endpoint sends SSH //! credentials, unscoped remote paths, executable paths or shell commands. +use crate::bridge_control::{BridgeControl, BridgeDirective, BridgeEvent}; use crate::*; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use std::{ @@ -15,7 +16,7 @@ use std::{ }; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -pub const PROTOCOL: u32 = 1; +pub const PROTOCOL: u32 = 2; pub const MAX_FRAME: usize = 1024 * 1024; pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Debug, Serialize, Deserialize)] @@ -28,6 +29,10 @@ pub struct Request { #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "method", content = "params", deny_unknown_fields)] pub enum Operation { + Poll, + Report { + event: BridgeEvent, + }, Capabilities, Space { path: MountPath, @@ -97,6 +102,7 @@ pub struct Response { } #[derive(Debug, Serialize, Deserialize)] pub enum Value { + Directive(BridgeDirective), Unit, Capabilities(FsCapabilities), Space(FsSpace), @@ -165,6 +171,7 @@ struct DirectoryState { /// Lives for one helper/root grant. IDs are never reused or shared with another /// grant, and provider handles are released when the pipe closes. pub struct Server { + control: Arc, fs: Arc, next: u64, files: HashMap>, @@ -172,7 +179,11 @@ pub struct Server { } impl Server { pub fn new(fs: Arc) -> Self { + Self::with_control(fs, Arc::new(BridgeControl::default())) + } + pub fn with_control(fs: Arc, control: Arc) -> Self { Self { + control, fs, next: 0, files: HashMap::new(), @@ -193,6 +204,14 @@ impl Server { } pub async fn dispatch(&mut self, operation: Operation) -> FsResult { match operation { + Operation::Poll => { + self.fs.check_available()?; + return Ok(Value::Directive(self.control.poll()?)); + } + Operation::Report { event } => { + self.control.report(event)?; + return Ok(Value::Unit); + } Operation::Capabilities => { self.fs.check_available()?; return Ok(Value::Capabilities(self.fs.capabilities())); @@ -368,6 +387,9 @@ impl Server { } } .await; + if let Err(error) = &result { + self.control.fail(error.to_string()); + } self.close().await; result } diff --git a/crates/ssh-core/examples/native_bridge_probe.rs b/crates/ssh-core/examples/native_bridge_probe.rs index 0c6c740..a88c578 100644 --- a/crates/ssh-core/examples/native_bridge_probe.rs +++ b/crates/ssh-core/examples/native_bridge_probe.rs @@ -3,8 +3,10 @@ //! to a separately built bridge; the selected filesystem remains the core SFTP provider. use anyhow::{ensure, Context, Result}; use shellcanvas_core::*; +use shellcanvas_filesystem_sdk::bridge_control::{BridgeControl, BridgePhase}; use shellcanvas_filesystem_sdk::wire::Server; use std::{path::PathBuf, process::Stdio, sync::Arc, time::Duration}; +use tokio::io::AsyncBufReadExt; fn quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } @@ -82,7 +84,8 @@ async fn main() -> Result<()> { .spawn()?; let reader = child.stdout.take().context("Missing child stdout")?; let writer = child.stdin.take().context("Missing child stdin")?; - let serving = tokio::spawn(Server::new(fs).serve(reader, writer)); + let control = Arc::new(BridgeControl::default()); + let serving = tokio::spawn(Server::with_control(fs, control.clone()).serve(reader, writer)); let result: Result<()> = async { run( &args, @@ -92,6 +95,34 @@ async fn main() -> Result<()> { ), ) .await?; + tokio::time::timeout(Duration::from_secs(10), async { + while control.snapshot()?.phase != BridgePhase::Attached { + tokio::time::sleep(Duration::from_millis(100)).await; + } + anyhow::Ok(()) + }).await??; + let hold = "import os,sys; f=os.open(sys.argv[1]+'/held',os.O_CREAT|os.O_RDWR,0o600); print('HELD',flush=True); sys.stdin.read(); os.close(f)"; + let mut holder = ssh(&args, &format!("exec timeout 60s python3 -u -c {} {}", quote(hold), quote(&target))) + .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit()).spawn()?; + let mut ready = tokio::io::BufReader::new(holder.stdout.take().unwrap()); + let mut line = String::new(); + tokio::time::timeout(Duration::from_secs(15), ready.read_line(&mut line)).await??; + ensure!(line.trim() == "HELD", "Busy-file fixture did not open"); + control.request_detach()?; + tokio::time::timeout(Duration::from_secs(25), async { + loop { + let snapshot = control.snapshot()?; + if snapshot.phase == BridgePhase::Attached && snapshot.message.is_some() { break; } + ensure!(snapshot.phase != BridgePhase::Detached && snapshot.phase != BridgePhase::Failed, "Busy drive was detached or failed"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + anyhow::Ok(()) + }).await??; + run(&args, &format!("mountpoint -q {}", quote(&target))).await?; + drop(holder.stdin.take()); + let status = tokio::time::timeout(Duration::from_secs(10), holder.wait()).await??; + ensure!(status.success(), "Busy-file fixture did not close cleanly"); + println!("BUSY_DETACH_PASS: held file prevented unmount; attachment stayed available"); let script = service.create_text(&root, "test.py", TEST).await?; print!( "{}", @@ -105,6 +136,16 @@ async fn main() -> Result<()> { ) .await? ); + control.request_detach()?; + tokio::time::timeout(Duration::from_secs(25), async { + loop { + let snapshot = control.snapshot()?; + if snapshot.phase == BridgePhase::Detached { break; } + ensure!(snapshot.phase == BridgePhase::Detaching, "Graceful detach failed: {:?}", snapshot.message); + tokio::time::sleep(Duration::from_millis(100)).await; + } + anyhow::Ok(()) + }).await??; Ok(()) } .await; @@ -132,7 +173,11 @@ async fn main() -> Result<()> { // The regular Files action intentionally removes only empty directories. // This harness owns the entire UUID fixture, including the populated source. let cleanup = "import os,shutil,sys; p=sys.argv[1]; assert p.startswith('/tmp/shellcanvas-native-') and os.path.dirname(p)=='/tmp' and not os.path.islink(p) and not os.path.ismount(p+'/mount'); shutil.rmtree(p)"; - run(&args, &format!("python3 -c {} {}", quote(cleanup), quote(&root))).await?; + run( + &args, + &format!("python3 -c {} {}", quote(cleanup), quote(&root)), + ) + .await?; connection.disconnect().await?; result?; println!("PASS: native Linux filesystem -> FUSE bridge -> core root grant -> real SFTP; detached and disposable tree removed"); diff --git a/docs/filesystem-integration-progress.md b/docs/filesystem-integration-progress.md index 3aa0502..c4fb5f2 100644 --- a/docs/filesystem-integration-progress.md +++ b/docs/filesystem-integration-progress.md @@ -37,15 +37,70 @@ Include the dependencies' licensing/distribution limitations in that app. plus an independently buildable vendored SDK. GPL-3.0-only bridge licensing leaves the main app MPL-2.0. README/THIRD-PARTY describe WinFsp/wrapper terms, separate macFUSE installation and commercial binary-bundling restrictions. -- Main desktop storage/settings/installation and right-click attachment UI are - **not integrated yet**. No user mapping is active or advertised as ready. +- Main desktop has reviewed optional bridge installation in Settings → Files. + The native chooser stages exact bytes; approval is window-owned, single-use and + expires. Installed versions are immutable content-addressed files under the + app data directory, with atomic selection and hash verification before launch. + The UI describes native-code trust and driver/license terms. Installation tests + use isolated temporary profiles; no bridge was installed into the user's profile. + Three installer tests pass, including changed-review rejection preserving the + previous installation. Frontend production build, desktop clippy and 640px + preview overflow checks pass. Native chooser/approval still needs end-to-end use. + Native chooser/approval and an actual desktop-launched Windows mapping remain + unverified; the implementation is a development preview. +- Files now offers **Attach to this computer…** for folders/current directories, + and an Attach action beside mounted volume locations. The dialog checks optional + provider support and installation, defaults to read-only, offers a Windows drive + letter or a native empty-folder chooser on Unix, and shows attachment status. + Settings → Files lists mappings and their host/path, warnings and detach actions. + Compact 400px light and normal-width dark dialog layouts were checked with a + synthetic UI fixture; these checks did not create an OS mount. +- The desktop mapping manager reserves the exact session/source before opening + a rooted grant, retains a connection lease, launches only a hash-verified installed + helper and supervises it over inherited pipes. It bounds startup, stderr memory, + failed-process cleanup and handle teardown. Closing Files or switching visible + hosts does not stop or retarget a mapping. Disconnect/source replacement and + remote-volume unmount refuse while affected mappings run; quit opens Settings + with an explanation. Failed disconnect no longer marks a retained workspace + disconnected in the UI. Busy detach never kills or retries the helper. + Unconfirmed process cleanup conservatively retains the grant/connection guard + and shows a warning; automatic recovery from that state is not yet implemented. + The core also enforces read-only grants/handles independently of provider write + behavior. Installed bytes are checked again immediately before helper launch. +- Protocol v2 adds ready/status/warning events and an explicit detach request. + A failed busy detach returns to Attached and requires a new request; it never + automatically retries. Windows holds an open-context gate across detach; Linux + and macOS use ordinary system unmount without force/lazy flags. Windows cleanup + failures reach the parent as structured warning events displayed by the mapping + manager. Lifecycle and Windows gate unit tests pass. +- Public bridge PR https://github.com/techartdev/ShellCanvas-DriveBridge/pull/1 + contains the detach changes. Native Linux acceptance passed against `32d0c79`: + held file prevented unmount, attachment remained usable, releasing it allowed + an explicit clean detach, and the full file-operation/cleanup test passed. + Latest review-branch commit `6edb2a9` adds structured cleanup warnings and + retired-channel refinements. All Windows/Ubuntu/macOS 14 checks pass on that + commit (run `34502539007`); the PR is ready but requires repository review. + Main `dac05fa` + remains protocol v1 until this PR merges; use a v2 bridge with the updated core. ## Evidence gathered - `cargo check -p shellcanvas-core` passes. - `cargo clippy -p shellcanvas-core -p shellcanvas-filesystem-sdk --all-targets -- -D warnings` passes. -- Four filesystem SDK tests pass: path validation, bounded frames, exact large - offsets and transport retirement on a mismatched reply. +- Five filesystem SDK tests pass: path validation, bounded frames, exact large + offsets, transport retirement on a mismatched reply and explicit busy detach. +- Four desktop mapping tests pass, including connection lease ownership and an + opt-in native child-process fixture. On Windows the fixture verifies that a + stalled startup and invalid protocol are terminated/reaped, 160 KB of stderr + does not block the protocol, busy detach retains the helper, and a second explicit + detach completes. It uses no driver and performs no local/remote file operations. + Reproduce with `cargo build -p shellcanvas-filesystem-sdk --example lifecycle_fixture`, + set `SHELLCANVAS_BRIDGE_FIXTURE` to that absolute executable, then run + `cargo test -p shellcanvas --lib drive_mappings::tests -- --include-ignored`. +- Thirty frontend service/session/app-boundary tests pass, including source pins + on the new attach/availability commands. Desktop clippy and frontend type-check + pass. The production frontend build passes (the existing large-chunk advisory + remains); this does not verify a native mounted drive. - Opt-in `mount_probe` on the authorized Linux SSH host passes real SFTP tests: offsets above 4 GiB using a sparse file, truncation, EOF, atomic save replacement, old/new open-handle identity, close, read-only enforcement, directory listing @@ -85,15 +140,15 @@ Include the dependencies' licensing/distribution limitations in that app. ## Remaining completion gates -1. Implement reviewed bridge installation/configuration, one-root/one-source - grant management, helper supervision and explicit user access mode. Keep - credentials in the desktop and never accept an executable path from a web app. -2. Add the Files folder/volume context action, attachment dialog and mapping - status/management UI. Verify the modern neutral theme and compact layouts. -3. Pin connection generation and source identity throughout mapping lifetime. - Closing a Files window must not stop a mapping; source replacement/disconnect - and app quit must account for active mappings and busy handles. No silent - forced detach or stale-handle reconnection. +1. Native end-to-end installation verification and an actual desktop-created + mapping, including chooser cancel, missing driver and changed executable. + The manager/UI/source leases are implemented; native user-flow verification + remains, including disconnect/source replacement/quit with busy local files. +2. Add mapping Open-local-location convenience and an explicit recovery workflow + for unconfirmed cleanup. Do not silently clear ownership or claim detach. +3. Broaden failure acceptance to real network loss, permission/disk-full errors, + late SFTP open responses and cancellation while preparing the root. Verify the + native chooser/source-retirement race without touching the normal user profile. 4. Native Windows mount tests after administrator setup: Explorer and actual local file APIs, ordinary editor/temporary-file replacement saves, directory rename with open handles, capacity, errors and disconnect behavior. @@ -104,9 +159,10 @@ Include the dependencies' licensing/distribution limitations in that app. kernel/libfuse backend is implemented; FSKit operation is not established. Do not claim an OS version/runtime works solely because Linux compiled. 7. Resolve currently documented limits before calling the release ready: - Windows cleanup-time deletion failures need visible reporting, file attributes - and cross-handle directory rename need native checks, busy detach needs a - control protocol, and memory-mapped workflows need explicit acceptance. + Windows cleanup-time deletion warnings and busy-detach control are implemented + but need native WinFsp acceptance. File attributes, volume-wide flush and + cross-handle directory rename need work/checks. Memory-mapped workflows need + explicit acceptance. 8. Review cancellation/late responses, bounded teardown and protocol errors with failure fixtures, then run the relevant core/desktop regression checks. Add reproducible release packages/notices and update bridge docs with exact verified diff --git a/docs/local-drive-bridge.md b/docs/local-drive-bridge.md index 668c044..f785783 100644 --- a/docs/local-drive-bridge.md +++ b/docs/local-drive-bridge.md @@ -111,8 +111,10 @@ unbounded whole-file caching. 1. Folder/volume context menu: **Attach to this computer…**. 2. Dialog shows host and remote path, local target, access mode and any missing native component. Installation is a deliberate setup action. -3. A built-in mappings view shows name, source, local path and connection state, - with Open, Retry and Detach actions. Failed setup must leave no phantom drive. +3. Settings → Files shows host, remote/local path, access and attachment state, + with explicit Detach and completed-result Dismiss actions. Open-local-location + and recovery convenience actions remain backlog. Failed setup must leave no + phantom drive; unconfirmed cleanup remains visible. 4. Disconnect/quit explains which mappings are affected and allows cancellation when files remain in use. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 86c343d..87c0284 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,6 +18,7 @@ tauri-plugin-dialog = "2" anyhow = "1" async-trait = "0.1" serde_json = "1" +sha2 = "0.10" reqwest = { version = "0.13.4", default-features = false, features = ["native-tls", "system-proxy"] } keyring = { version = "3.6.3", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] } tempfile = "3" diff --git a/src-tauri/src/adapters.rs b/src-tauri/src/adapters.rs index fea1b67..17230e9 100644 --- a/src-tauri/src/adapters.rs +++ b/src-tauri/src/adapters.rs @@ -175,6 +175,9 @@ pub async fn replace_adapter_source( .ok_or("Workspace is closed")?; let replaces_files = workspace.is_source_for(&expected, &crate::workspace_services::ServiceRole::Files); + state + .mappings + .ensure_releasable(session_id, Some(&expected))?; let retired = workspace.replace_source(&expected, candidate)?; if replaces_files { transfers.close_session(session_id); diff --git a/src-tauri/src/drive_bridge_install.rs b/src-tauri/src/drive_bridge_install.rs new file mode 100644 index 0000000..cd6a5d9 --- /dev/null +++ b/src-tauri/src/drive_bridge_install.rs @@ -0,0 +1,410 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Reviewed installation of an optional native bridge. Review never executes it. +//! The launch path comes from a verified profile installation, never an app frame. +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashMap, + fs, + io::{Read, Write}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use tempfile::TempDir; + +const MAX_BINARY: u64 = 128 * 1024 * 1024; +const REVIEW_LIFETIME: Duration = Duration::from_secs(15 * 60); +#[cfg(windows)] +const BINARY: &str = "shellcanvas-drive-bridge.exe"; +#[cfg(not(windows))] +const BINARY: &str = "shellcanvas-drive-bridge"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Installation { + pub version: u32, + pub name: String, + pub sha256: String, + pub size: u64, +} +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Review { + pub id: String, + pub source: String, + pub installation: Installation, +} +struct Candidate { + owner: String, + created: Instant, + directory: TempDir, + installation: Installation, +} +#[derive(Clone, Default)] +pub struct Reviews(Arc>>); + +fn hash(path: &Path) -> Result<(String, u64), String> { + let file = fs::File::open(path).map_err(|e| e.to_string())?; + if !file.metadata().map_err(|e| e.to_string())?.is_file() { + return Err("Choose a regular executable file.".into()); + } + let mut reader = file.take(MAX_BINARY + 1); + let mut digest = Sha256::new(); + let mut size = 0u64; + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = reader.read(&mut buffer).map_err(|e| e.to_string())?; + if read == 0 { + break; + } + size += read as u64; + if size > MAX_BINARY { + return Err("Bridge executable exceeds 128 MiB.".into()); + } + digest.update(&buffer[..read]); + } + if size == 0 { + return Err("Bridge executable is empty.".into()); + } + Ok((format!("{:x}", digest.finalize()), size)) +} +fn anchor(storage: &Path) -> Result { + let root = storage.join("drive-bridge"); + fs::create_dir_all(&root).map_err(|e| e.to_string())?; + root.canonicalize().map_err(|e| e.to_string()) +} +fn child_directory(root: &Path, child: &str) -> Result { + let path = root.join(child); + fs::create_dir_all(&path).map_err(|e| e.to_string())?; + let resolved = path.canonicalize().map_err(|e| e.to_string())?; + if resolved.parent() != Some(root) { + return Err("Bridge storage directory was redirected.".into()); + } + Ok(resolved) +} +fn validate(info: &Installation) -> Result<(), String> { + if info.version != 1 + || info.sha256.len() != 64 + || !info + .sha256 + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + || info.size == 0 + || info.size > MAX_BINARY + || info.name.is_empty() + || info.name.len() > 512 + { + return Err("Invalid bridge installation record.".into()); + } + Ok(()) +} +pub(crate) fn verify(path: &Path, info: &Installation) -> Result<(), String> { + if fs::symlink_metadata(path) + .map_err(|e| e.to_string())? + .file_type() + .is_symlink() + { + return Err("Bridge executable was replaced by a symbolic link.".into()); + } + let (digest, size) = hash(path)?; + if digest != info.sha256 || size != info.size { + return Err( + "Bridge executable changed after review. Review it again before running it.".into(), + ); + } + Ok(()) +} +impl Reviews { + pub fn review(&self, storage: &Path, source: &Path, owner: &str) -> Result { + let source = source.canonicalize().map_err(|e| e.to_string())?; + if !fs::metadata(&source).map_err(|e| e.to_string())?.is_file() { + return Err("Choose a regular executable file.".into()); + } + let name = source + .file_name() + .and_then(|s| s.to_str()) + .ok_or("Executable filename is not valid text")? + .to_string(); + let root = anchor(storage)?; + let staging = child_directory(&root, "reviews")?; + let mut pending = self.0.lock().map_err(|_| "Bridge reviews unavailable")?; + pending.retain(|_, candidate| candidate.created.elapsed() < REVIEW_LIFETIME); + if pending.len() >= 8 { + return Err("Close an existing bridge review before opening another.".into()); + } + let directory = tempfile::tempdir_in(staging).map_err(|e| e.to_string())?; + let staged = directory.path().join(BINARY); + let mut original = fs::File::open(&source) + .map_err(|e| e.to_string())? + .take(MAX_BINARY + 1); + let mut copy = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&staged) + .map_err(|e| e.to_string())?; + let size = std::io::copy(&mut original, &mut copy).map_err(|e| e.to_string())?; + if size > MAX_BINARY { + return Err("Bridge executable exceeds 128 MiB.".into()); + } + copy.sync_all().map_err(|e| e.to_string())?; + drop(copy); + let (sha256, size) = hash(&staged)?; + let installation = Installation { + version: 1, + name, + sha256, + size, + }; + validate(&installation)?; + let id = uuid::Uuid::new_v4().to_string(); + pending.insert( + id.clone(), + Candidate { + owner: owner.into(), + created: Instant::now(), + directory, + installation: installation.clone(), + }, + ); + Ok(Review { + id, + source: source.to_string_lossy().into_owned(), + installation, + }) + } + pub fn cancel(&self, id: &str, owner: &str) -> Result<(), String> { + let mut pending = self.0.lock().map_err(|_| "Bridge reviews unavailable")?; + if pending.get(id).is_some_and(|item| item.owner != owner) { + return Err("This review belongs to another window.".into()); + } + pending.remove(id); + Ok(()) + } + pub fn install(&self, storage: &Path, id: &str, owner: &str) -> Result { + let candidate = { + let mut pending = self.0.lock().map_err(|_| "Bridge reviews unavailable")?; + let item = pending + .get(id) + .ok_or("Bridge review expired. Choose the executable again.")?; + if item.owner != owner { + return Err("This review belongs to another window.".into()); + } + pending.remove(id).unwrap() + }; + if candidate.created.elapsed() >= REVIEW_LIFETIME { + return Err("Bridge review expired. Choose the executable again.".into()); + } + let staged = candidate.directory.path().join(BINARY); + verify(&staged, &candidate.installation)?; + let root = anchor(storage)?; + let versions = child_directory(&root, "versions")?; + let directory = child_directory(&versions, &candidate.installation.sha256)?; + let target = directory.join(BINARY); + if !target.try_exists().map_err(|e| e.to_string())? { + // Atomic publication; never replace the bytes of an active installation. + let mut temporary = + tempfile::NamedTempFile::new_in(&directory).map_err(|e| e.to_string())?; + std::io::copy( + &mut fs::File::open(&staged).map_err(|e| e.to_string())?, + &mut temporary, + ) + .map_err(|e| e.to_string())?; + temporary.as_file().sync_all().map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + temporary + .as_file() + .set_permissions(fs::Permissions::from_mode(0o700)) + .map_err(|e| e.to_string())?; + } + temporary + .persist_noclobber(&target) + .map_err(|e| e.to_string())?; + } + verify(&target, &candidate.installation)?; + let mut record = tempfile::NamedTempFile::new_in(&root).map_err(|e| e.to_string())?; + record + .write_all(&serde_json::to_vec(&candidate.installation).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + record.as_file().sync_all().map_err(|e| e.to_string())?; + record + .persist(root.join("installation.json")) + .map_err(|e| e.to_string())?; + Ok(candidate.installation) + } +} +pub fn installed(storage: &Path) -> Result, String> { + let root = storage.join("drive-bridge"); + let record = root.join("installation.json"); + let file = match fs::File::open(record) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.to_string()), + }; + let info: Installation = serde_json::from_reader(file.take(4097)).map_err(|e| e.to_string())?; + validate(&info)?; + let root = root.canonicalize().map_err(|e| e.to_string())?; + let versions = root + .join("versions") + .canonicalize() + .map_err(|e| e.to_string())?; + let directory = versions + .join(&info.sha256) + .canonicalize() + .map_err(|e| e.to_string())?; + if versions.parent() != Some(root.as_path()) || directory.parent() != Some(versions.as_path()) { + return Err("Bridge installation was redirected.".into()); + } + let executable = directory.join(BINARY); + verify(&executable, &info)?; + Ok(Some((info, executable))) +} + +#[tauri::command] +pub async fn drive_bridge_installation( + app: tauri::AppHandle, +) -> Result, String> { + let storage = crate::profile_store::storage_dir(&app)?; + tauri::async_runtime::spawn_blocking(move || { + installed(&storage).map(|value| value.map(|(info, _)| info)) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn review_drive_bridge( + window: tauri::WebviewWindow, + state: tauri::State<'_, Reviews>, +) -> Result, String> { + use tauri::Manager; + use tauri_plugin_dialog::DialogExt; + let app = window.app_handle().clone(); + let owner = window.label().to_string(); + let storage = crate::profile_store::storage_dir(&app)?; + let reviews = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + let picker = app + .dialog() + .file() + .set_title("Choose ShellCanvas Drive Bridge"); + #[cfg(windows)] + let picker = picker.add_filter("Drive Bridge executable", &["exe"]); + let Some(selected) = picker.blocking_pick_file() else { + return Ok(None); + }; + let path = selected + .into_path() + .map_err(|_| "Choose a local executable")?; + reviews.review(&storage, &path, &owner).map(Some) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn cancel_drive_bridge_review( + id: String, + window: tauri::WebviewWindow, + state: tauri::State<'_, Reviews>, +) -> Result<(), String> { + let reviews = state.inner().clone(); + let owner = window.label().to_string(); + tauri::async_runtime::spawn_blocking(move || reviews.cancel(&id, &owner)) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn install_drive_bridge( + id: String, + window: tauri::WebviewWindow, + state: tauri::State<'_, Reviews>, +) -> Result { + use tauri::Manager; + let storage = crate::profile_store::storage_dir(window.app_handle())?; + let owner = window.label().to_string(); + let reviews = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || reviews.install(&storage, &id, &owner)) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn review_pins_bytes_is_window_owned_and_install_survives_restart() { + let profile = tempfile::tempdir().unwrap(); + let download = tempfile::tempdir().unwrap(); + let source = download.path().join(BINARY); + fs::write(&source, b"reviewed binary fixture; never executed").unwrap(); + let reviews = Reviews::default(); + let review = reviews.review(profile.path(), &source, "main").unwrap(); + assert!(reviews + .install(profile.path(), &review.id, "foreign") + .is_err()); + fs::write(&source, b"changed download").unwrap(); + let accepted = reviews.install(profile.path(), &review.id, "main").unwrap(); + assert!(reviews.install(profile.path(), &review.id, "main").is_err()); + drop(reviews); + let (saved, executable) = installed(profile.path()).unwrap().unwrap(); + assert_eq!(saved.sha256, accepted.sha256); + assert_eq!( + fs::read(&executable).unwrap(), + b"reviewed binary fixture; never executed" + ); + fs::write(executable, b"tampered installed bytes").unwrap(); + assert!(installed(profile.path()) + .unwrap_err() + .contains("changed after review")); + } + #[test] + fn cancelled_review_cannot_install_and_invalid_record_cannot_supply_a_path() { + let profile = tempfile::tempdir().unwrap(); + assert!(installed(profile.path()).unwrap().is_none()); + let source = profile.path().join("candidate"); + fs::write(&source, b"fixture").unwrap(); + let reviews = Reviews::default(); + let review = reviews.review(profile.path(), &source, "main").unwrap(); + reviews.cancel(&review.id, "main").unwrap(); + assert!(reviews.install(profile.path(), &review.id, "main").is_err()); + let malicious = Installation { + version: 1, + name: "fixture".into(), + sha256: "../elsewhere".into(), + size: 1, + }; + fs::write( + profile.path().join("drive-bridge/installation.json"), + serde_json::to_vec(&malicious).unwrap(), + ) + .unwrap(); + assert!(installed(profile.path()).is_err()); + } + #[test] + fn changed_review_does_not_replace_the_existing_installation() { + let profile = tempfile::tempdir().unwrap(); + let source = profile.path().join("candidate"); + fs::write(&source, b"original approved fixture").unwrap(); + let reviews = Reviews::default(); + let first = reviews.review(profile.path(), &source, "main").unwrap(); + let installed_first = reviews.install(profile.path(), &first.id, "main").unwrap(); + fs::write(&source, b"new candidate fixture").unwrap(); + let next = reviews.review(profile.path(), &source, "main").unwrap(); + let staged = reviews + .0 + .lock() + .unwrap() + .get(&next.id) + .unwrap() + .directory + .path() + .join(BINARY); + fs::write(staged, b"tampered review").unwrap(); + assert!(reviews.install(profile.path(), &next.id, "main").is_err()); + assert_eq!( + installed(profile.path()).unwrap().unwrap().0.sha256, + installed_first.sha256 + ); + } +} diff --git a/src-tauri/src/drive_mappings.rs b/src-tauri/src/drive_mappings.rs new file mode 100644 index 0000000..25dca30 --- /dev/null +++ b/src-tauri/src/drive_mappings.rs @@ -0,0 +1,630 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Desktop-owned root grants. Drivers and filesystem callbacks live in the bridge. +use crate::{connection_resource::ConnectionLease, DesktopState}; +use serde::Serialize; +use shellcanvas_services::{ + bridge_control::{BridgeControl, BridgePhase, BridgeSnapshot}, + wire::Server, + ConnectionIdentity, FileSystemProvider, +}; +use std::{ + collections::BTreeMap, + path::PathBuf, + process::Stdio, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; +use tauri::{Manager, State}; +use tokio::{io::AsyncReadExt, process::Command}; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MappingInfo { + pub id: String, + pub session_id: u64, + pub source: ConnectionIdentity, + pub remote_path: String, + pub host_label: String, + pub local_path: String, + pub writable: bool, + pub status: BridgeSnapshot, + pub running: bool, + pub cleanup_warning: Option, +} +struct Mapping { + info: MappingInfo, + control: Arc, + _lease: Option, +} +#[derive(Clone, Default)] +pub struct Mappings(Arc>>); +impl Mappings { + fn lock(&self) -> Result>, String> { + self.0 + .lock() + .map_err(|_| "Attachment state unavailable".into()) + } + pub fn list(&self) -> Result, String> { + self.lock()? + .values() + .map(|m| { + let mut info = m.info.clone(); + info.status = m.control.snapshot().map_err(|e| e.to_string())?; + Ok(info) + }) + .collect() + } + pub fn ensure_releasable( + &self, + session: u64, + source: Option<&ConnectionIdentity>, + ) -> Result<(), String> { + if self.lock()?.values().any(|m| { + m.info.running + && m.info.session_id == session + && source.is_none_or(|s| s == &m.info.source) + }) { + return Err("Detach this host's local drives in Settings → Files before disconnecting or replacing its file connection.".into()); + } + Ok(()) + } + pub fn has_running(&self) -> bool { + self.lock() + .map(|items| items.values().any(|m| m.info.running)) + .unwrap_or(true) + } + fn reserve( + &self, + info: MappingInfo, + control: Arc, + lease: Option, + ) -> Result<(), String> { + let mut items = self.lock()?; + if items + .values() + .any(|m| m.info.running && m.info.local_path == info.local_path) + { + return Err("That local location already has an attachment.".into()); + } + items.insert( + info.id.clone(), + Mapping { + info, + control, + _lease: lease, + }, + ); + Ok(()) + } + fn finished(&self, id: &str) { + if let Ok(mut items) = self.lock() { + if let Some(item) = items.get_mut(id) { + item.info.running = false; + item._lease = None; + } + } + } + fn detach(&self, id: &str) -> Result<(), String> { + let items = self.lock()?; + let mapping = items.get(id).ok_or("Attachment no longer exists")?; + mapping.control.request_detach().map_err(|e| e.to_string()) + } +} + +#[tauri::command] +pub fn drive_mappings(state: State<'_, DesktopState>) -> Result, String> { + state.mappings.list() +} +#[tauri::command] +pub fn detach_drive(id: String, state: State<'_, DesktopState>) -> Result<(), String> { + state.mappings.detach(&id) +} +#[tauri::command] +pub fn dismiss_drive(id: String, state: State<'_, DesktopState>) -> Result<(), String> { + let mut items = state.mappings.lock()?; + if items.get(&id).is_some_and(|item| item.info.running) { + return Err("Detach the drive before dismissing it.".into()); + } + items.remove(&id); + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Availability { + pub supported: bool, + pub installed: bool, + pub windows: bool, +} +#[tauri::command] +pub async fn drive_mapping_available( + session_id: u64, + binding: ConnectionIdentity, + app: tauri::AppHandle, + state: State<'_, DesktopState>, +) -> Result { + let files = crate::filesystem(&state, session_id, Some(&binding)).await?; + let supported = files.supports_local_mount(); + let storage = crate::profile_store::storage_dir(&app)?; + let installed = tauri::async_runtime::spawn_blocking(move || { + crate::drive_bridge_install::installed(&storage) + }) + .await + .map_err(|e| e.to_string())?? + .is_some(); + Ok(Availability { + supported, + installed, + windows: cfg!(windows), + }) +} + +fn windows_target(value: &str) -> Result { + let bytes = value.as_bytes(); + if bytes.len() != 2 || !bytes[0].is_ascii_uppercase() || bytes[1] != b':' || bytes[0] < b'D' { + return Err("Choose a drive letter from D: to Z:.".into()); + } + Ok(PathBuf::from(value)) +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] // Tauri injects the window/state beside the scoped user request. +pub async fn attach_drive( + session_id: u64, + binding: ConnectionIdentity, + path: String, + writable: bool, + drive: Option, + host_label: Option, + window: tauri::WebviewWindow, + state: State<'_, DesktopState>, +) -> Result, String> { + let app = window.app_handle().clone(); + let storage = crate::profile_store::storage_dir(&app)?; + let installation = tauri::async_runtime::spawn_blocking(move || { + crate::drive_bridge_install::installed(&storage) + }) + .await + .map_err(|e| e.to_string())?? + .ok_or("Install Drive Bridge in Settings → Files first.")?; + let target = if cfg!(windows) { + windows_target(drive.as_deref().ok_or("Choose a drive letter")?)? + } else { + use tauri_plugin_dialog::DialogExt; + let selected = tauri::async_runtime::spawn_blocking(move || { + app.dialog() + .file() + .set_title("Choose an empty local folder for the attachment") + .blocking_pick_folder() + }) + .await + .map_err(|e| e.to_string())?; + let Some(selected) = selected else { + return Ok(None); + }; + let selected = selected.into_path().map_err(|_| "Choose a local folder")?; + tauri::async_runtime::spawn_blocking(move || -> Result { + let target = selected.canonicalize().map_err(|e| e.to_string())?; + if target + .read_dir() + .map_err(|e| e.to_string())? + .next() + .is_some() + { + return Err( + "Choose an empty folder so existing local files are not hidden.".into(), + ); + } + Ok(target) + }) + .await + .map_err(|e| e.to_string())?? + }; + // Reserve while holding the registry lock: disconnect/replacement use the same + // order and cannot retire this source between validation and grant ownership. + let _transition = state.mount_transition.lock().await; + let registry = state.registry.lock().await; + let session = registry + .sessions + .get(&session_id) + .ok_or("Workspace is closed")?; + session.check_source(&crate::ServiceRole::Files, Some(&binding))?; + let files = session + .files + .clone() + .ok_or("This host has no file access")?; + if !files.supports_local_mount() { + return Err("This file provider does not support local attachments.".into()); + } + let connection = session + .service_connection(&crate::ServiceRole::Files) + .ok_or("File connection unavailable")?; + let lease = connection.lease()?; + let id = uuid::Uuid::new_v4().to_string(); + let control = Arc::new(BridgeControl::default()); + let info = MappingInfo { + id: id.clone(), + session_id, + source: binding, + remote_path: path.clone(), + host_label: host_label + .filter(|label| !label.trim().is_empty()) + .unwrap_or_else(|| format!("Workspace {session_id}")) + .chars() + .take(256) + .collect(), + local_path: target.to_string_lossy().into_owned(), + writable, + status: control.snapshot().map_err(|e| e.to_string())?, + running: true, + cleanup_warning: None, + }; + state.mappings.reserve(info, control.clone(), Some(lease))?; + let mappings = state.mappings.clone(); + let task_id = id.clone(); + // A canceled UI invocation does not drop a live mapping or its connection lease. + tauri::async_runtime::spawn(async move { + let safe = Arc::new(AtomicBool::new(true)); + let result = run( + installation, + target, + files, + path, + writable, + control.clone(), + safe.clone(), + ) + .await; + if let Err(error) = result { + control.fail(error.clone()); + if !safe.load(Ordering::Acquire) { + if let Ok(mut items) = mappings.lock() { + if let Some(item) = items.get_mut(&task_id) { + item.info.cleanup_warning = Some(error); + } + } + } + } + if safe.load(Ordering::Acquire) { + mappings.finished(&task_id); + } + }); + Ok(Some(id)) +} + +async fn run( + installation: (crate::drive_bridge_install::Installation, PathBuf), + target: PathBuf, + files: Arc, + path: String, + writable: bool, + control: Arc, + safe: Arc, +) -> Result<(), String> { + let root = tokio::time::timeout(Duration::from_secs(30), files.mount_root(&path, writable)) + .await + .map_err(|_| "Opening the attachment root timed out")? + .map_err(|e| e.to_string())?; + if writable && !root.capabilities().writable { + return Err("This file provider permits only read-only attachments. Choose Read only and try again.".into()); + } + let executable = tauri::async_runtime::spawn_blocking(move || { + crate::drive_bridge_install::verify(&installation.1, &installation.0)?; + Ok::<_, String>(installation.1) + }) + .await + .map_err(|e| e.to_string())??; + let mut command = Command::new(executable); + command.arg("--mount").arg(target); + supervise(command, root, control, safe, Duration::from_secs(30)).await +} + +async fn supervise( + mut command: Command, + root: Arc, + control: Arc, + safe: Arc, + startup_timeout: Duration, +) -> Result<(), String> { + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let (mut child, tree) = shellcanvas_adapter_runtime::ProcessTree::spawn(&mut command) + .await + .map_err(|e| e.to_string())?; + safe.store(false, Ordering::Release); + let reader = child + .stdout + .take() + .ok_or("Bridge output pipe unavailable")?; + let writer = child.stdin.take().ok_or("Bridge input pipe unavailable")?; + let mut stderr = child + .stderr + .take() + .ok_or("Bridge diagnostic pipe unavailable")?; + let diagnostics = Arc::new(Mutex::new(Vec::new())); + let tail = diagnostics.clone(); + let drain = tokio::spawn(async move { + let mut chunk = [0u8; 1024]; + while let Ok(count) = stderr.read(&mut chunk).await { + if count == 0 { + break; + } + if let Ok(mut bytes) = tail.lock() { + bytes.extend_from_slice(&chunk[..count]); + let excess = bytes.len().saturating_sub(8192); + bytes.drain(..excess); + } + } + }); + let mut server = + tokio::spawn(Server::with_control(root, control.clone()).serve(reader, writer)); + let start = tokio::time::Instant::now(); + let mut interval = tokio::time::interval(Duration::from_millis(250)); + let mut server_done = false; + let result = loop { + tokio::select! { + status = child.wait() => { + break match status { + Ok(status) if status.success() && control.snapshot().is_ok_and(|s| s.phase == BridgePhase::Detached) => Ok(()), + Ok(status) => Err(format!("Drive Bridge exited ({status}). {}", diagnostic_tail(&diagnostics))), + Err(e) => Err(e.to_string()), + }; + } + result = &mut server => { + server_done = true; + if control.snapshot().is_ok_and(|s| s.phase == BridgePhase::Detached) { break Ok(()); } + break Err(format!("Attachment transport ended: {result:?}. {}", diagnostic_tail(&diagnostics))); + } + _ = interval.tick() => { + if start.elapsed() > startup_timeout && control.snapshot().is_ok_and(|s| s.phase == BridgePhase::Starting) { + break Err(format!("Drive Bridge did not attach within 30 seconds. {}", diagnostic_tail(&diagnostics))); + } + } + } + }; + // User detach only arrives here after the native backend confirms unmount. + // A crashed/stalled startup or broken transport is failure, never a busy retry. + let cleanup = tokio::time::timeout(Duration::from_secs(10), tree.cleanup(&mut child)).await; + if !server_done + && tokio::time::timeout(Duration::from_secs(35), &mut server) + .await + .is_err() + { + server.abort(); + } + drain.abort(); + match cleanup { + Ok(Ok(())) => { safe.store(true, Ordering::Release); result }, + _ => Err("Attachment process cleanup is unconfirmed. Check the local mount before using that location again.".into()), + } +} +fn diagnostic_tail(bytes: &Mutex>) -> String { + bytes + .lock() + .map(|b| String::from_utf8_lossy(&b).into_owned()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use shellcanvas_services::*; + + struct EmptyRoot; + fn unsupported() -> FsResult { + Err(FsError::new( + FsErrorKind::Unsupported, + "Fixture has no files", + )) + } + #[async_trait::async_trait] + impl MountedFileSystem for EmptyRoot { + fn capabilities(&self) -> FsCapabilities { + FsCapabilities { + writable: false, + atomic_replace: false, + durable_flush: false, + } + } + async fn metadata(&self, _: &MountPath) -> FsResult { + unsupported() + } + async fn open(&self, _: &MountPath, _: FsOpenOptions) -> FsResult> { + unsupported() + } + async fn open_directory(&self, _: &MountPath) -> FsResult> { + unsupported() + } + async fn set_metadata(&self, _: &MountPath, _: FsSetMetadata) -> FsResult<()> { + unsupported() + } + async fn mkdir(&self, _: &MountPath) -> FsResult<()> { + unsupported() + } + async fn remove(&self, _: &MountPath, _: bool) -> FsResult<()> { + unsupported() + } + async fn rename(&self, _: &MountPath, _: &MountPath, _: bool) -> FsResult<()> { + unsupported() + } + } + #[tokio::test] + #[ignore = "Build lifecycle_fixture, set SHELLCANVAS_BRIDGE_FIXTURE to its absolute executable, then run explicitly. No driver or remote I/O."] + async fn native_helper_supervision_bounds_failures_and_preserves_busy_detach() { + let fixture = PathBuf::from( + std::env::var_os("SHELLCANVAS_BRIDGE_FIXTURE").expect("Fixture executable is required"), + ); + assert!(fixture.is_absolute() && fixture.is_file()); + for mode in ["stall", "invalid"] { + let control = Arc::new(BridgeControl::default()); + let safe = Arc::new(AtomicBool::new(true)); + let mut command = Command::new(&fixture); + command.arg(mode); + let result = tokio::time::timeout( + Duration::from_secs(12), + supervise( + command, + Arc::new(EmptyRoot), + control, + safe.clone(), + Duration::from_millis(500), + ), + ) + .await + .unwrap(); + assert!(result.is_err(), "{mode}"); + assert!( + safe.load(Ordering::Acquire), + "{mode} process was not reaped" + ); + } + let control = Arc::new(BridgeControl::default()); + let safe = Arc::new(AtomicBool::new(true)); + let mut command = Command::new(fixture); + command.arg("busy"); + let task = tokio::spawn(supervise( + command, + Arc::new(EmptyRoot), + control.clone(), + safe.clone(), + Duration::from_secs(5), + )); + async fn phase(control: &BridgeControl, expected: BridgePhase) { + tokio::time::timeout(Duration::from_secs(5), async { + while control.snapshot().unwrap().phase != expected { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + } + phase(&control, BridgePhase::Attached).await; + assert!(!safe.load(Ordering::Acquire)); + control.request_detach().unwrap(); + phase(&control, BridgePhase::Attached).await; + assert_eq!( + control.snapshot().unwrap().message.as_deref(), + Some("Fixture file is busy") + ); + assert!(!task.is_finished(), "Busy detach killed the helper"); + control.request_detach().unwrap(); + tokio::time::timeout(Duration::from_secs(10), task) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(safe.load(Ordering::Acquire)); + assert_eq!(control.snapshot().unwrap().phase, BridgePhase::Detached); + } + + #[tokio::test] + async fn mapping_lease_survives_workspace_owner_until_native_cleanup() { + struct Connected(AtomicBool); + #[async_trait::async_trait] + impl ConnectionLifecycle for Connected { + fn is_connected(&self) -> bool { + self.0.load(Ordering::Acquire) + } + async fn disconnect(&self) -> anyhow::Result<()> { + self.0.store(false, Ordering::Release); + Ok(()) + } + } + let source = ConnectionIdentity { + instance: 1, + generation: 1, + adapter: "fixture".into(), + }; + let resource = crate::connection_resource::ConnectionResource::new( + source.clone(), + Arc::new(Connected(AtomicBool::new(true))), + ); + let workspace = resource.lease().unwrap(); + let mappings = Mappings::default(); + let control = Arc::new(BridgeControl::default()); + mappings + .reserve( + MappingInfo { + id: "lease".into(), + session_id: 1, + source, + remote_path: "/".into(), + host_label: "Fixture".into(), + local_path: "Z:".into(), + writable: false, + status: control.snapshot().unwrap(), + running: true, + cleanup_warning: None, + }, + control, + Some(resource.lease().unwrap()), + ) + .unwrap(); + drop(workspace); + assert!(resource.is_connected()); + mappings.finished("lease"); + tokio::time::timeout(Duration::from_secs(1), async { + while resource.is_connected() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + } + #[test] + fn reservations_protect_only_their_source_and_outlive_detach_until_reaped() { + let mappings = Mappings::default(); + let control = Arc::new(BridgeControl::default()); + let source = ConnectionIdentity { + instance: 1, + generation: 2, + adapter: "fixture".into(), + }; + let info = MappingInfo { + id: "a".into(), + session_id: 7, + source: source.clone(), + remote_path: "/data".into(), + host_label: "Fixture host".into(), + local_path: "Z:".into(), + writable: false, + status: control.snapshot().unwrap(), + running: true, + cleanup_warning: None, + }; + mappings + .reserve(info.clone(), control.clone(), None) + .unwrap(); + assert!(mappings.ensure_releasable(7, None).is_err()); + assert!(mappings.ensure_releasable(8, None).is_ok()); + let other = ConnectionIdentity { + generation: 3, + ..source.clone() + }; + assert!(mappings.ensure_releasable(7, Some(&other)).is_ok()); + assert!(mappings.reserve(info, control.clone(), None).is_err()); + control + .report(shellcanvas_services::bridge_control::BridgeEvent::Ready) + .unwrap(); + mappings.detach("a").unwrap(); + control + .report(shellcanvas_services::bridge_control::BridgeEvent::Detached) + .unwrap(); + assert!(mappings.ensure_releasable(7, Some(&source)).is_err()); + mappings.finished("a"); + assert!(mappings.ensure_releasable(7, None).is_ok()); + } + #[test] + fn drive_targets_do_not_accept_paths_or_shell_arguments() { + assert_eq!(windows_target("Z:").unwrap(), PathBuf::from("Z:")); + for invalid in ["C:", "z:", "Z:\\folder", "../other", "Z: --option"] { + assert!(windows_target(invalid).is_err()); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04edd94..4e67fb2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,8 @@ mod connection_resource; mod custom_binding; mod custom_services; mod directories; +mod drive_bridge_install; +mod drive_mappings; mod extension_frames; #[cfg(debug_assertions)] mod extension_probe; @@ -45,6 +47,8 @@ use workspace_services::ServiceRole; use workspace_services::WorkspaceServices as ActiveSession; #[derive(Default)] struct DesktopState { + mappings: drive_mappings::Mappings, + mount_transition: Mutex<()>, directories: directories::DirectoryReaders, registry: Arc>>, next_id: AtomicU64, @@ -270,7 +274,11 @@ async fn decide_host_key( } #[tauri::command] async fn disconnect(session_id: u64, state: State<'_, DesktopState>) -> Result<(), String> { - let removed = state.registry.lock().await.remove(session_id); + let removed = { + let mut registry = state.registry.lock().await; + state.mappings.ensure_releasable(session_id, None)?; + registry.remove(session_id) + }; state.transfers.lock().await.close_session(session_id); state.directories.close_session(session_id); if let Some(old) = removed { @@ -403,6 +411,12 @@ async fn set_volume_mounted( mounted: bool, state: State<'_, DesktopState>, ) -> Result<(), String> { + let _transition = state.mount_transition.lock().await; + if !mounted { + state.mappings.ensure_releasable(session_id, binding.as_ref()).map_err(|_| { + "Detach this host's local drives in Settings → Files before unmounting a remote volume.".to_string() + })?; + } filesystem(&state, session_id, binding.as_ref()) .await? .set_volume_mounted(&id, &revision, mounted) @@ -711,6 +725,16 @@ async fn close_terminal( pub fn run() { tauri::Builder::default() .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + if window + .try_state::() + .is_some_and(|s| s.mappings.has_running()) + { + use tauri::Emitter; + api.prevent_close(); + let _ = window.emit("drive-mappings-close-blocked", ()); + } + } if matches!(event, tauri::WindowEvent::Destroyed) { if let Some(state) = window.try_state::() { state.directories.close_owner(window.label()); @@ -730,6 +754,7 @@ pub fn run() { Ok(()) }) .manage(DesktopState::default()) + .manage(drive_bridge_install::Reviews::default()) .manage(adapters::AdapterJobs::default()) .manage(adapter_diagnostics::AdapterDiagnostics::default()) .manage(custom_services::CustomRequests::default()) @@ -759,6 +784,15 @@ pub fn run() { return handler(invoke); } let handler: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![ + drive_bridge_install::drive_bridge_installation, + drive_bridge_install::review_drive_bridge, + drive_bridge_install::cancel_drive_bridge_review, + drive_bridge_install::install_drive_bridge, + drive_mappings::drive_mappings, + drive_mappings::drive_mapping_available, + drive_mappings::attach_drive, + drive_mappings::detach_drive, + drive_mappings::dismiss_drive, repository_install::read_repository_file, repository_install::prepare_repository_read, repository_install::cancel_repository_read, @@ -838,6 +872,15 @@ pub fn run() { ]; handler(invoke) }) - .run(tauri::generate_context!()) - .expect("Unable to start ShellCanvas"); + .build(tauri::generate_context!()) + .expect("Unable to start ShellCanvas") + .run(|app, event| { + if let tauri::RunEvent::ExitRequested { api, .. } = event { + if app.state::().mappings.has_running() { + use tauri::Emitter; + api.prevent_exit(); + let _ = app.emit("drive-mappings-close-blocked", ()); + } + } + }); } diff --git a/src-tauri/src/mounted_binding.rs b/src-tauri/src/mounted_binding.rs index 07ebc8e..d4fb20c 100644 --- a/src-tauri/src/mounted_binding.rs +++ b/src-tauri/src/mounted_binding.rs @@ -23,10 +23,26 @@ impl Binding { pub(super) struct FileSystem { binding: Binding, inner: Arc, + writable: bool, } impl FileSystem { - pub(super) fn new(binding: Binding, inner: Arc) -> Self { - Self { binding, inner } + pub(super) fn new(binding: Binding, inner: Arc, writable: bool) -> Self { + let writable = writable && inner.capabilities().writable; + Self { + binding, + inner, + writable, + } + } +} +fn write_allowed(writable: bool) -> FsResult<()> { + if writable { + Ok(()) + } else { + Err(FsError::new( + FsErrorKind::ReadOnly, + "This attachment or handle is read-only", + )) } } #[async_trait] @@ -36,7 +52,10 @@ impl MountedFileSystem for FileSystem { self.inner.check_available() } fn capabilities(&self) -> FsCapabilities { - self.inner.capabilities() + FsCapabilities { + writable: self.writable, + ..self.inner.capabilities() + } } async fn space(&self, path: &MountPath) -> FsResult { self.binding.mount_run(false, self.inner.space(path)).await @@ -52,6 +71,10 @@ impl MountedFileSystem for FileSystem { options: FsOpenOptions, ) -> FsResult> { self.binding.mount_check()?; + options.validate()?; + if options.write || options.truncate || options.create != FsCreate::OpenExisting { + write_allowed(self.writable)?; + } let inner = self.inner.open(path, options).await?; if let Err(error) = self .binding @@ -63,6 +86,7 @@ impl MountedFileSystem for FileSystem { Ok(Arc::new(File { binding: self.binding.clone(), inner, + writable: self.writable && options.write, })) } async fn open_directory(&self, path: &MountPath) -> FsResult> { @@ -78,19 +102,23 @@ impl MountedFileSystem for FileSystem { })) } async fn set_metadata(&self, path: &MountPath, metadata: FsSetMetadata) -> FsResult<()> { + write_allowed(self.writable)?; self.binding .mount_run(true, self.inner.set_metadata(path, metadata)) .await } async fn mkdir(&self, path: &MountPath) -> FsResult<()> { + write_allowed(self.writable)?; self.binding.mount_run(true, self.inner.mkdir(path)).await } async fn remove(&self, path: &MountPath, directory: bool) -> FsResult<()> { + write_allowed(self.writable)?; self.binding .mount_run(true, self.inner.remove(path, directory)) .await } async fn rename(&self, from: &MountPath, to: &MountPath, replace: bool) -> FsResult<()> { + write_allowed(self.writable)?; self.binding .mount_run(true, self.inner.rename(from, to, replace)) .await @@ -99,6 +127,7 @@ impl MountedFileSystem for FileSystem { struct File { binding: Binding, inner: Arc, + writable: bool, } #[async_trait] impl MountedFile for File { @@ -111,11 +140,13 @@ impl MountedFile for File { .await } async fn write_at(&self, offset: u64, bytes: &[u8]) -> FsResult<()> { + write_allowed(self.writable)?; self.binding .mount_run(true, self.inner.write_at(offset, bytes)) .await } async fn set_metadata(&self, metadata: FsSetMetadata) -> FsResult<()> { + write_allowed(self.writable)?; self.binding .mount_run(true, self.inner.set_metadata(metadata)) .await @@ -201,7 +232,26 @@ mod tests { let file = File { binding: binding.clone(), inner: inner.clone(), + writable: true, }; + let read_only = File { + binding: binding.clone(), + inner: inner.clone(), + writable: false, + }; + assert_eq!( + read_only.write_at(0, &[8]).await.unwrap_err().kind, + FsErrorKind::ReadOnly + ); + assert_eq!( + read_only + .set_metadata(FsSetMetadata::default()) + .await + .unwrap_err() + .kind, + FsErrorKind::ReadOnly + ); + assert_eq!(read_only.read_at(0, 1).await.unwrap(), vec![7]); assert_eq!( file.metadata().await.unwrap_err().kind, FsErrorKind::PermissionDenied diff --git a/src-tauri/src/workspace_services.rs b/src-tauri/src/workspace_services.rs index 3bce8aa..b788933 100644 --- a/src-tauri/src/workspace_services.rs +++ b/src-tauri/src/workspace_services.rs @@ -584,6 +584,7 @@ impl FileSystemProvider for Bound { Ok(Arc::new(mounted_binding::FileSystem::new( self.binding.clone(), filesystem, + writable, ))) } async fn list(&self, path: Option<&str>) -> Result { diff --git a/src/App.tsx b/src/App.tsx index 1080dee..524342c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -257,6 +257,33 @@ export default function App({ const [connectOpen, setConnectOpen] = useState(false); const [adapterConnectOpen, setAdapterConnectOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsInitialSection, setSettingsInitialSection] = useState< + "desktop" | "files" + >("desktop"); + useEffect(() => { + if (!native) return; + let disposed = false; + let stop: (() => void) | undefined; + void import("@tauri-apps/api/event") + .then(async ({ listen }) => { + const unlisten = await listen("drive-mappings-close-blocked", () => { + closeAllowed.current = false; + setCloseApp(false); + setToast( + "Detach local drives in Settings → Files before quitting ShellCanvas.", + ); + setSettingsInitialSection("files"); + setSettingsOpen(true); + }); + if (disposed) unlisten(); + else stop = unlisten; + }) + .catch((e) => setToast(String(e))); + return () => { + disposed = true; + stop?.(); + }; + }, []); const [editingProfile, setEditingProfile] = useState(); const [launcherOpen, setLauncherOpen] = useState(false); const [error, setError] = useState(""); @@ -639,14 +666,11 @@ export default function App({ if (Object.values(desktop.instances).some((instance) => instance.busy)) return; if (connected) { - // Release remote access immediately; local windows and drafts stay mounted. - update({ type: "lost", sessionId: id }); try { await releaseSession(id); + update({ type: "lost", sessionId: id }); } catch (error) { - setToast( - `Connection cleanup failed: ${error}. Your workspace is preserved.`, - ); + setToast(`Could not disconnect: ${error}`); } return; } @@ -674,10 +698,9 @@ export default function App({ Object.values(current.desktop.instances).some((instance) => instance.busy) ) return; - update({ type: "remove", sessionId: id }); - if (current.connected === false) return; try { await releaseSession(id); + update({ type: "remove", sessionId: id }); } catch (e) { setToast(String(e)); } @@ -1090,6 +1113,7 @@ export default function App({ )} {settingsOpen && ( setSettingsOpen(false)} profiles={profiles} session={session} diff --git a/src/app-services.ts b/src/app-services.ts index 3691efc..5e0d813 100644 --- a/src/app-services.ts +++ b/src/app-services.ts @@ -52,6 +52,12 @@ export function scopeAppServices( return { ...ticket }; } const services: SessionServices = { + driveMappingAvailable: base.driveMappingAvailable + ? guard("files.read", base.driveMappingAvailable.bind(base)) + : undefined, + attachDrive: base.attachDrive + ? guard("files.read", base.attachDrive.bind(base)) + : undefined, custom: base.custom ? { list: (signal) => base.custom!.list(signal), diff --git a/src/apps/Files.tsx b/src/apps/Files.tsx index 0991360..bbae5f7 100644 --- a/src/apps/Files.tsx +++ b/src/apps/Files.tsx @@ -45,6 +45,7 @@ import { usePreferences } from "../preferences"; import { visibleFiles } from "../file-view"; import { FileActionDialog } from "../components/FileActionDialog"; import { FileVolumes } from "../components/FileVolumes"; +import { AttachDriveDialog } from "../components/AttachDriveDialog"; import { MoveFileDialog } from "../components/MoveFileDialog"; import { watchFileChanges, watchFileLocations } from "../file-events"; import { relocateNavigation, trackedNavigation } from "../file-navigation"; @@ -76,6 +77,18 @@ export function Files({ workspaceLabel, }: AppContext) { const [showVolumes, setShowVolumes] = useState(false); + const [attachTarget, setAttachTarget] = useState<{ + path: string; + host: string; + services: typeof services; + } | null>(null); + function reviewAttachment(path: string) { + setAttachTarget({ + path, + host: workspaceLabel || session?.info.hostname || "This host", + services, + }); + } const sourceKey = fileSourceKey(session); const previousSource = useRef(sourceKey); const { @@ -1024,6 +1037,16 @@ export function Files({ disabled: !canUpload, run: () => void upload(), }, + ...(!entry || entry.kind === "directory" + ? [ + { + id: "attach-drive", + label: "Attach to this computer…", + disabled: !connected || loading || busy || !services.attachDrive, + run: () => reviewAttachment(entry?.path ?? directory.path), + }, + ] + : []), { id: "upload-folder", label: "Upload folder…", @@ -1482,6 +1505,7 @@ export function Files({ connected={connected} host={workspaceLabel || session?.info.hostname || "This host"} setBusy={setBusy} + attach={services.attachDrive ? reviewAttachment : undefined} back={() => setShowVolumes(false)} navigate={(path) => { setShowVolumes(false); @@ -1493,6 +1517,14 @@ export function Files({ className="file-main" style={showVolumes ? { display: "none" } : undefined} > + {attachTarget && ( + setAttachTarget(null)} + /> + )}
+ {!mapping && ( + + )} +
+ , + document.body, + ); +} diff --git a/src/components/DriveBridgeSettings.css b/src/components/DriveBridgeSettings.css new file mode 100644 index 0000000..45a4a13 --- /dev/null +++ b/src/components/DriveBridgeSettings.css @@ -0,0 +1,155 @@ +/* SPDX-License-Identifier: MPL-2.0 */ +.drive-bridge-settings { + margin-top: 2rem; + padding: 1.4rem; + border: 1px solid var(--sc-border); + border-radius: 1rem; + background: var(--sc-inset); +} +.drive-bridge-settings header { + display: flex; + align-items: center; + gap: 0.9rem; +} +.drive-bridge-settings header > div { + flex: 1; + min-width: 0; +} +.drive-bridge-settings h3, +.drive-bridge-settings h4, +.drive-bridge-settings header p { + margin: 0; +} +.drive-bridge-settings p { + color: var(--sc-muted); + font-size: 0.9rem; + line-height: 1.65; +} +.drive-bridge-icon { + display: flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + flex: 0 0 3rem; + border-radius: 0.85rem; + background: var(--sc-raised); + color: var(--sc-accent); +} +.drive-bridge-badge { + padding: 0.25rem 0.6rem; + border: 1px solid var(--sc-border); + border-radius: 1rem; + color: var(--sc-muted); + font-size: 0.75rem; +} +.drive-bridge-installation { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 0; +} +.drive-bridge-installation > div { + min-width: 0; +} +.drive-bridge-installation p { + margin: 0.3rem 0 0; + overflow-wrap: anywhere; +} +.drive-bridge-settings button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.65rem 0.9rem; + border: 1px solid var(--sc-border); + border-radius: 0.6rem; + background: var(--sc-raised); + color: var(--sc-text); + font: inherit; + cursor: pointer; +} +.drive-bridge-settings .drive-bridge-primary { + background: var(--sc-accent); + color: var(--sc-onAccent); +} +.drive-bridge-settings button:focus-visible, +.drive-bridge-settings summary:focus-visible { + outline: 2px solid var(--sc-accent); + outline-offset: 3px; +} +.drive-bridge-settings details { + border-top: 1px solid var(--sc-border); + padding-top: 1rem; + margin-top: 0.5rem; +} +.drive-bridge-settings summary { + cursor: pointer; + font-size: 0.9rem; +} +.drive-bridge-notices ul { + padding-left: 1.2rem; + color: var(--sc-muted); + font-size: 0.85rem; + line-height: 1.7; +} +.drive-bridge-notices li + li { + margin-top: 0.5rem; +} +.drive-bridge-notices a { + color: var(--sc-accent); +} +.drive-bridge-review { + padding: 1rem; + border: 1px solid var(--sc-border); + border-radius: 0.75rem; + background: var(--sc-surface); +} +.drive-bridge-review h4 { + display: flex; + gap: 0.6rem; + align-items: center; +} +.drive-bridge-review dl { + display: grid; + grid-template-columns: 5rem minmax(0, 1fr); + gap: 0.7rem; + font-size: 0.85rem; +} +.drive-bridge-review dt { + color: var(--sc-muted); +} +.drive-bridge-review dd { + margin: 0; + overflow-wrap: anywhere; +} +.drive-bridge-review .drive-bridge-note { + font-size: 0.8rem; +} +.drive-bridge-actions { + display: flex; + justify-content: flex-end; + gap: 0.7rem; + flex-wrap: wrap; +} +.drive-bridge-settings .drive-bridge-error { + color: var(--sc-danger); + overflow-wrap: anywhere; +} +@media (max-width: 680px) { + .drive-bridge-installation { + align-items: stretch; + flex-direction: column; + } + .drive-bridge-settings { + padding: 1rem; + } + .drive-bridge-review dl { + grid-template-columns: minmax(0, 1fr); + gap: 0.3rem; + } + .drive-bridge-review dd { + margin-bottom: 0.5rem; + } +} diff --git a/src/components/DriveBridgeSettings.tsx b/src/components/DriveBridgeSettings.tsx new file mode 100644 index 0000000..d85c5cb --- /dev/null +++ b/src/components/DriveBridgeSettings.tsx @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: MPL-2.0 +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { useEffect, useRef, useState } from "react"; +import { HardDrive, LoaderCircle, ShieldCheck } from "lucide-react"; +import "./DriveBridgeSettings.css"; +import { DriveMappings } from "./DriveMappings"; + +type Installation = { + version: number; + name: string; + sha256: string; + size: number; +}; +type Review = { id: string; source: string; installation: Installation }; +export function DriveBridgeSettings() { + const [installed, setInstalled] = useState(null); + const [installationStatus, setInstallationStatus] = useState( + "Checking installation…", + ); + const [review, setReview] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const alive = useRef(true); + const pending = useRef(null); + const working = useRef(false); + const native = isTauri(); + useEffect(() => { + alive.current = true; + if (native) + void invoke("drive_bridge_installation") + .then((value) => { + if (alive.current) { + setInstalled(value); + setInstallationStatus( + value ? "Bridge installed" : "No bridge installed", + ); + } + }) + .catch((e) => { + if (alive.current) { + setError(String(e)); + setInstallationStatus("Couldn’t verify installation"); + } + }); + return () => { + alive.current = false; + if (pending.current) + void invoke("cancel_drive_bridge_review", { + id: pending.current, + }).catch(() => {}); + }; + }, [native]); + async function choose() { + if (working.current) return; + working.current = true; + setBusy(true); + setError(""); + try { + const candidate = await invoke("review_drive_bridge"); + if (!alive.current) { + if (candidate) + await invoke("cancel_drive_bridge_review", { id: candidate.id }); + return; + } + pending.current = candidate?.id ?? null; + setReview(candidate); + } catch (e) { + if (alive.current) setError(String(e)); + } finally { + working.current = false; + if (alive.current) setBusy(false); + } + } + async function finish(approve: boolean) { + if (!review || working.current) return; + working.current = true; + setBusy(true); + setError(""); + const id = review.id; + try { + if (approve) { + const saved = await invoke("install_drive_bridge", { + id, + }); + if (alive.current) { + setInstalled(saved); + setInstallationStatus("Bridge installed"); + } + } else await invoke("cancel_drive_bridge_review", { id }); + } catch (e) { + if (alive.current) setError(String(e)); + } finally { + pending.current = null; + working.current = false; + if (alive.current) { + setReview(null); + setBusy(false); + } + } + } + return ( +
+
+ + + +
+

Drive Bridge

+

Use remote folders from your local apps.

+
+ Optional +
+

+ Install the separate, free Drive Bridge app to connect ShellCanvas file + access to your computer’s filesystem. Driver setup is separate. +

+ {error && ( +

+ {error} +

+ )} + {review ? ( +
+

+ Review native app installation +

+

+ This executable will run with your local account’s permissions when + you attach a folder. Install only a build you trust. +

+
+
File
+
{review.source}
+
Size
+
{(review.installation.size / 1048576).toFixed(1)} MB
+
SHA-256
+
+ {review.installation.sha256} +
+
+

+ The hash identifies the reviewed bytes. It is not a publisher + signature. +

+
+ + +
+
+ ) : ( +
+
+ + {native ? installationStatus : "Available in the desktop app"} + +

+ {installed + ? installed.name + : native + ? "Choose a Drive Bridge build for this computer." + : "Install and attach drives in the desktop app."} +

+
+ +
+ )} + +
+ Drivers and licensing +
+

+ Drive Bridge is GPL-3.0-only free software. Commercial use is + permitted under its license; redistribution has source and notice + obligations. ShellCanvas’s core remains MPL-2.0. +

+
    +
  • + Windows: WinFsp runtime. Installing its driver + requires administrator approval. The native runtime and Rust + bindings have separate licensing terms. +
  • +
  • + Linux: FUSE and your distribution’s mount helper. +
  • +
  • + macOS: macFUSE, installed separately. Its license + restricts commercial bundling and automated installation in that + context without permission. +
  • +
+

+ The bridge includes notices through --licenses. Current + source, build instructions and verification status are at{" "} + + ShellCanvas Drive Bridge + + . +

+
+
+
+ ); +} diff --git a/src/components/DriveMappings.css b/src/components/DriveMappings.css new file mode 100644 index 0000000..064eded --- /dev/null +++ b/src/components/DriveMappings.css @@ -0,0 +1,127 @@ +/* SPDX-License-Identifier: MPL-2.0 */ +.drive-mappings { + margin-top: 1.3rem; +} +.drive-mappings h4 { + margin: 0 0 0.8rem; +} +.drive-mapping { + padding: 1rem; + border: 1px solid var(--sc-border); + border-radius: 0.75rem; + background: var(--sc-surface); +} +.drive-mapping + .drive-mapping { + margin-top: 0.7rem; +} +.drive-mapping-heading, +.drive-mapping-status { + display: flex; + align-items: center; + gap: 0.65rem; + flex-wrap: wrap; +} +.drive-mapping-heading > svg { + color: var(--sc-accent); +} +.drive-mapping-heading strong { + flex: 1; + overflow-wrap: anywhere; +} +.drive-mapping-heading span, +.drive-mapping small { + color: var(--sc-muted); + font-size: 0.75rem; +} +.drive-mapping-path { + overflow-wrap: anywhere; + margin: 0.6rem 0 0.3rem; +} +.drive-mapping-status { + justify-content: space-between; + margin-top: 0.85rem; + font-size: 0.85rem; +} +.drive-mapping-status > span { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} +.drive-mapping-notice { + white-space: pre-wrap; + overflow-wrap: anywhere; + padding-top: 0.65rem; + border-top: 1px solid var(--sc-border); +} +.attach-drive-dialog { + width: min(500px, calc(100vw - 32px)); +} +.attach-drive-dialog .file-action-target { + display: flex; + flex-direction: column; + gap: 0.4rem; + overflow-wrap: anywhere; +} +.attach-drive-options { + display: grid; + grid-template-columns: 1fr 1.4fr; + gap: 1rem; + margin-top: 1.3rem; +} +.attach-drive-options label { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.85rem; +} +.attach-drive-options select { + width: 100%; + padding: 0.7rem; + border: 1px solid var(--sc-border); + border-radius: 0.5rem; + background: var(--sc-inset); + color: var(--sc-text); + font: inherit; +} +.attach-drive-options p { + grid-column: 1 / -1; + margin: 0; + font-size: 0.85rem; + line-height: 1.6; + color: var(--sc-muted); +} +.attach-drive-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1.5rem; +} +.attach-drive-dialog button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.65rem 1rem; + border: 1px solid var(--sc-border); + border-radius: 0.55rem; + background: var(--sc-raised); + color: var(--sc-text); + cursor: pointer; +} +.attach-drive-dialog button.primary { + background: var(--sc-accent); + color: var(--sc-onAccent); +} +.attach-drive-dialog :is(button, select):focus-visible { + outline: 2px solid var(--sc-accent); + outline-offset: 3px; +} +.attach-drive-dialog button:disabled { + opacity: 0.5; + cursor: default; +} +@media (max-width: 420px) { + .attach-drive-options { + grid-template-columns: 1fr; + } +} diff --git a/src/components/DriveMappings.tsx b/src/components/DriveMappings.tsx new file mode 100644 index 0000000..f123334 --- /dev/null +++ b/src/components/DriveMappings.tsx @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MPL-2.0 +import { useEffect, useState } from "react"; +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { HardDrive, LoaderCircle, Unplug, X } from "lucide-react"; +import "./DriveMappings.css"; + +export interface DriveMapping { + hostLabel: string; + id: string; + sessionId: number; + remotePath: string; + localPath: string; + writable: boolean; + source: { instance: number; generation: number; adapter: string }; + status: { + phase: "Starting" | "Attached" | "Detaching" | "Detached" | "Failed"; + message?: string | null; + }; + running: boolean; + cleanupWarning?: string | null; +} +export function DriveMappings({ only }: { only?: string }) { + const [items, setItems] = useState([]); + const [error, setError] = useState(""); + const [working, setWorking] = useState(null); + useEffect(() => { + if (!isTauri()) return; + let alive = true; + let timer: ReturnType; + async function refresh() { + try { + const values = await invoke("drive_mappings"); + if (alive) setItems(values); + } catch (e) { + if (alive) setError(String(e)); + } + if (alive) timer = setTimeout(() => void refresh(), 1000); + } + void refresh(); + return () => { + alive = false; + clearTimeout(timer); + }; + }, []); + async function action(item: DriveMapping) { + if (working) return; + const detach = item.running; + setWorking(item.id); + setError(""); + try { + await invoke(detach ? "detach_drive" : "dismiss_drive", { + id: item.id, + }); + if (!detach) + setItems((values) => values.filter((value) => value.id !== item.id)); + } catch (e) { + setError(String(e)); + } finally { + setWorking(null); + } + } + return ( +
+ {!only &&

Local attachments

} + {error && ( +

+ {error} +

+ )} + {!items.length && !only && ( +

+ No attached folders. In Files, right-click a folder and choose “Attach + to this computer…”. +

+ )} + {items + .filter((item) => !only || item.id === only) + .map((item) => { + const pending = + item.status.phase === "Starting" || + item.status.phase === "Detaching"; + return ( +
+
+ + {item.localPath} + {item.writable ? "Read & write" : "Read only"} +
+

{item.remotePath}

+ {item.hostLabel} +
+ + {pending && } + {item.status.phase} + {item.status.phase === "Detached" && item.running + ? " · finishing cleanup" + : ""} + + +
+ {(item.cleanupWarning || item.status.message) && ( +

+ {item.cleanupWarning || item.status.message} +

+ )} +
+ ); + })} +
+ ); +} diff --git a/src/components/FileVolumes.css b/src/components/FileVolumes.css index bbf385e..0aeaa41 100644 --- a/src/components/FileVolumes.css +++ b/src/components/FileVolumes.css @@ -1,4 +1,14 @@ /* SPDX-License-Identifier: MPL-2.0 */ +.file-volume-location { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} +.file-volume-location > button:first-child { + flex: 1; + min-width: 0; +} .file-volumes { flex: 1; min-width: 0; diff --git a/src/components/FileVolumes.tsx b/src/components/FileVolumes.tsx index 5033d5b..4054d61 100644 --- a/src/components/FileVolumes.tsx +++ b/src/components/FileVolumes.tsx @@ -22,6 +22,7 @@ export function FileVolumes({ navigate, back, setBusy, + attach, }: { services: SessionServices; connected: boolean; @@ -29,6 +30,7 @@ export function FileVolumes({ navigate(path: string): void; back(): void; setBusy(busy: boolean): void; + attach?(path: string): void; }) { const [inventory, setInventory] = useState(null); const [error, setError] = useState(""); @@ -146,15 +148,25 @@ export function FileVolumes({ {volume.locations.length ? (
{volume.locations.map((location) => ( - +
+ + {attach && ( + + )} +
))}
) : ( diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 5ca1bed..3e22707 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { usePreferences, type Preferences } from "../preferences"; import { Appearance } from "../themes/Appearance"; +import { DriveBridgeSettings } from "./DriveBridgeSettings"; import type { HostProfile, Session } from "../sdk"; import "./SettingsDialog.css"; @@ -80,6 +81,7 @@ export function SettingsDialog({ connected, manageHost, hostDetails, + initialSection = "desktop", }: { close(): void; profiles: HostProfile[]; @@ -87,9 +89,11 @@ export function SettingsDialog({ connected: boolean; manageHost(profile?: HostProfile): void; hostDetails(): void; + initialSection?: Section; }) { const { values, set, error, blocked, reset } = usePreferences(); - const [section, setSection] = useState
("desktop"); + const [section, setSection] = useState
(initialSection); + useEffect(() => setSection(initialSection), [initialSection]); const [resetOpen, setResetOpen] = useState(false); const dialog = useRef(null); const panel = useRef(null); @@ -344,6 +348,7 @@ export function SettingsDialog({ These preferences apply across Files windows. Each window keeps its own folder and selection.

+ )} {section === "hosts" && ( diff --git a/src/sdk.ts b/src/sdk.ts index 9dbc7d1..a109701 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -228,7 +228,20 @@ export interface ClipboardFileState { sequence: number; intent?: "copy" | "move"; } +export interface DriveMappingAvailability { + supported: boolean; + installed: boolean; + windows: boolean; +} export interface HostServices { + driveMappingAvailable?(sessionId: number): Promise; + attachDrive?( + sessionId: number, + path: string, + writable: boolean, + drive?: string, + hostLabel?: string, + ): Promise; volumes?(sessionId: number): Promise; setVolumeMounted?( sessionId: number, @@ -373,6 +386,12 @@ export interface HostServices { } /** Apps receive a fixed session handle, never connection administration. */ export interface SessionServices { + driveMappingAvailable?(): Promise; + attachDrive?( + path: string, + writable: boolean, + drive?: string, + ): Promise; volumes?(): Promise; setVolumeMounted?( id: string, diff --git a/src/services.test.ts b/src/services.test.ts index 017262a..c13cb09 100644 --- a/src/services.test.ts +++ b/src/services.test.ts @@ -76,6 +76,8 @@ it("captures each source once and attaches it to all native service requests", a const bound = nativeServices.bindSources!(session); files.generation = 2; const requests = [ + () => bound.driveMappingAvailable!(700), + () => bound.attachDrive!(700, "opaque", false, "Z:"), () => bound.volumes!(700), () => bound.setVolumeMounted!(700, "volume", "revision", true), () => bound.list(700), diff --git a/src/services.ts b/src/services.ts index 6c5a8ad..94e0774 100644 --- a/src/services.ts +++ b/src/services.ts @@ -19,6 +19,8 @@ type SourcePins = { custom: Readonly>; }; const commandRoles: Record = { + drive_mapping_available: "files", + attach_drive: "files", file_volumes: "files", set_volume_mounted: "files", paste_system_files: "files", @@ -67,6 +69,10 @@ function createNativeServices(pins?: SourcePins): HostServices { return nativeInvoke(...parameters); }; return { + driveMappingAvailable: (sessionId) => + invoke("drive_mapping_available", { sessionId }), + attachDrive: (sessionId, path, writable, drive, hostLabel) => + invoke("attach_drive", { sessionId, path, writable, drive, hostLabel }), volumes: (sessionId) => invoke("file_volumes", { sessionId }), setVolumeMounted: (sessionId, id, revision, mounted) => invoke("set_volume_mounted", { sessionId, id, revision, mounted }), diff --git a/src/session-services.ts b/src/session-services.ts index c9529b7..3be017e 100644 --- a/src/session-services.ts +++ b/src/session-services.ts @@ -122,6 +122,28 @@ export function bindSession( notifyFileChanges(session!.id, relocate ? "relocation" : "content"); } const services: SessionServices = { + driveMappingAvailable: backend.driveMappingAvailable + ? async () => { + const expected = generation; + const result = await backend.driveMappingAvailable!( + check("files.read"), + ); + check("files.read", expected); + return result; + } + : undefined, + // A successful attachment belongs to the desktop, not this window. Return + // its id even if the visible workspace changed while the chooser was open. + attachDrive: backend.attachDrive + ? async (path, writable, drive) => + backend.attachDrive!( + check("files.read"), + path, + writable, + drive, + session?.info.hostname, + ) + : undefined, volumes: backend.volumes ? async () => { const expected = generation; From fcab81bdbe6f9a03978ed2c26149ae8074ce90a7 Mon Sep 17 00:00:00 2001 From: Vanyo Vanev Date: Thu, 10 Sep 2026 20:19:01 +0300 Subject: [PATCH 03/31] Record bridge open-handle fixes and remaining native checks --- docs/filesystem-integration-progress.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/filesystem-integration-progress.md b/docs/filesystem-integration-progress.md index c4fb5f2..273617d 100644 --- a/docs/filesystem-integration-progress.md +++ b/docs/filesystem-integration-progress.md @@ -33,6 +33,13 @@ Include the dependencies' licensing/distribution limitations in that app. binding health and bounded protocol cleanup. Published on `main`. Canonical local checkout: `D:\Mine\ShellCanvas-DriveBridge`. `.local/drive-bridge` is the initial build staging copy, not the canonical repo. +- Public review branch `feat/graceful-detach` now includes `078e624`: + Windows volume flush visits all writable descriptors and reports the first + failure after attempting the others. Confirmed renames update related open + paths; failed renames preserve them. Dropping an open context closes remote + handles, including failure after acquisition. Two bookkeeping regressions pass; + Windows and Linux clippy pass. Live WinFsp acceptance remains required. + These changes are in PR #1, not public main; protocol v2 still awaits merge. - Bridge has native Windows callbacks and a shared Linux/macOS FUSE backend, plus an independently buildable vendored SDK. GPL-3.0-only bridge licensing leaves the main app MPL-2.0. README/THIRD-PARTY describe WinFsp/wrapper terms, @@ -160,9 +167,9 @@ Include the dependencies' licensing/distribution limitations in that app. Do not claim an OS version/runtime works solely because Linux compiled. 7. Resolve currently documented limits before calling the release ready: Windows cleanup-time deletion warnings and busy-detach control are implemented - but need native WinFsp acceptance. File attributes, volume-wide flush and - cross-handle directory rename need work/checks. Memory-mapped workflows need - explicit acceptance. + but need native WinFsp acceptance. Volume-wide flush and cross-handle rename + are implemented with bookkeeping regression tests; native checks and file + attribute work remain. Memory-mapped workflows need explicit acceptance. 8. Review cancellation/late responses, bounded teardown and protocol errors with failure fixtures, then run the relevant core/desktop regression checks. Add reproducible release packages/notices and update bridge docs with exact verified From d70778f9947ad9493b242b56db1582ae5ecd83a9 Mon Sep 17 00:00:00 2001 From: Vanyo Vanev Date: Thu, 10 Sep 2026 20:37:13 +0300 Subject: [PATCH 04/31] Retain drive ownership through explicit cleanup recovery --- Cargo.lock | 1 + docs/filesystem-integration-progress.md | 15 +- docs/local-drive-bridge.md | 9 ++ src-tauri/Cargo.toml | 5 +- src-tauri/src/drive_mappings.rs | 199 +++++++++++++++++++++++- src-tauri/src/drive_recovery.rs | 79 ++++++++++ src-tauri/src/lib.rs | 4 + src-tauri/src/local_mounts.rs | 136 ++++++++++++++++ src/components/DriveMappings.css | 11 ++ src/components/DriveMappings.tsx | 67 ++++++-- 10 files changed, 501 insertions(+), 25 deletions(-) create mode 100644 src-tauri/src/drive_recovery.rs create mode 100644 src-tauri/src/local_mounts.rs diff --git a/Cargo.lock b/Cargo.lock index 352d81a..fc584ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4808,6 +4808,7 @@ dependencies = [ "anyhow", "async-trait", "keyring", + "libc", "rand 0.10.2", "reqwest", "rusqlite", diff --git a/docs/filesystem-integration-progress.md b/docs/filesystem-integration-progress.md index 273617d..4aaf354 100644 --- a/docs/filesystem-integration-progress.md +++ b/docs/filesystem-integration-progress.md @@ -40,6 +40,8 @@ Include the dependencies' licensing/distribution limitations in that app. handles, including failure after acquisition. Two bookkeeping regressions pass; Windows and Linux clippy pass. Live WinFsp acceptance remains required. These changes are in PR #1, not public main; protocol v2 still awaits merge. + CI run `34507333272` for `078e624` passed builds and tests on Windows, + Ubuntu and macOS 14. This is build evidence, not native Windows/macOS mount evidence. - Bridge has native Windows callbacks and a shared Linux/macOS FUSE backend, plus an independently buildable vendored SDK. GPL-3.0-only bridge licensing leaves the main app MPL-2.0. README/THIRD-PARTY describe WinFsp/wrapper terms, @@ -151,8 +153,17 @@ Include the dependencies' licensing/distribution limitations in that app. mapping, including chooser cancel, missing driver and changed executable. The manager/UI/source leases are implemented; native user-flow verification remains, including disconnect/source replacement/quit with busy local files. -2. Add mapping Open-local-location convenience and an explicit recovery workflow - for unconfirmed cleanup. Do not silently clear ownership or claim detach. +2. Open folder and explicit Retry cleanup controls are implemented. Retry retains + the original helper/job ownership and connection reservation. After process + shutdown, native OS mount-table checks must confirm the location is unmounted; + if not, the UI asks for system unmount followed by another explicit check. No + force/unmount command is added to the core. The native helper fixture proves + a stopped process does not release a still-occupied location. Native Windows + mount-table and Linux parser tests pass; Linux/macOS mount-table code compiles + separately for those targets. Open/retry/dismiss UI checks pass at normal dark + and 400px light layouts with synthetic data. Desktop clippy and production + build pass. Full desktop mapping recovery remains part of gate 1, including + the platform launcher and native Unix mount-table runtime checks. 3. Broaden failure acceptance to real network loss, permission/disk-full errors, late SFTP open responses and cancellation while preparing the root. Verify the native chooser/source-retirement race without touching the normal user profile. diff --git a/docs/local-drive-bridge.md b/docs/local-drive-bridge.md index f785783..1d39e1b 100644 --- a/docs/local-drive-bridge.md +++ b/docs/local-drive-bridge.md @@ -23,6 +23,15 @@ dependency footprint. The bridge repository documents its GPL license and the dependencies' separate terms. Modern supported client systems are the priority; legacy Catalina client compatibility is outside this goal. +Settings → Files lists active attachments. **Open folder** opens the registered +local location in the system file manager; it is available only while attached. +**Detach** requests ordinary native unmount and keeps a busy mapping available. +If shutdown cannot be confirmed, **Retry cleanup** retains the original helper +ownership and checks it again. A stopped process alone is insufficient: the local +OS mount table must also show that the location is unmounted. If a stale mount +remains, remove it using system disk tools, then retry the check. ShellCanvas keeps +the connection reserved until confirmation and does not force-unmount the path. + ## Local platform backends | Client OS | Recommended starting point | Setup and scope | diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 87c0284..1370656 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -37,4 +37,7 @@ rand = "0.10" [target.'cfg(windows)'.dependencies] windows-core = "0.61.2" -windows = { version = "0.61.3", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_Ole", "Win32_System_Memory", "Win32_System_DataExchange", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } +windows = { version = "0.61.3", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_Ole", "Win32_System_Memory", "Win32_System_DataExchange", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } + +[target.'cfg(target_os = "macos")'.dependencies] +libc = "0.2" diff --git a/src-tauri/src/drive_mappings.rs b/src-tauri/src/drive_mappings.rs index 25dca30..f73c660 100644 --- a/src-tauri/src/drive_mappings.rs +++ b/src-tauri/src/drive_mappings.rs @@ -33,11 +33,13 @@ pub struct MappingInfo { pub status: BridgeSnapshot, pub running: bool, pub cleanup_warning: Option, + pub can_retry_cleanup: bool, } struct Mapping { info: MappingInfo, control: Arc, _lease: Option, + recovery: Arc, } #[derive(Clone, Default)] pub struct Mappings(Arc>>); @@ -53,6 +55,9 @@ impl Mappings { .map(|m| { let mut info = m.info.clone(); info.status = m.control.snapshot().map_err(|e| e.to_string())?; + let (warning, can_retry) = m.recovery.snapshot()?; + info.cleanup_warning = warning.or(info.cleanup_warning); + info.can_retry_cleanup = can_retry; Ok(info) }) .collect() @@ -95,6 +100,7 @@ impl Mappings { info, control, _lease: lease, + recovery: Arc::new(crate::drive_recovery::Recovery::default()), }, ); Ok(()) @@ -112,6 +118,30 @@ impl Mappings { let mapping = items.get(id).ok_or("Attachment no longer exists")?; mapping.control.request_detach().map_err(|e| e.to_string()) } + fn recovery(&self, id: &str) -> Result, String> { + Ok(self + .lock()? + .get(id) + .ok_or("Attachment no longer exists")? + .recovery + .clone()) + } + fn open_target(&self, id: &str) -> Result { + let items = self.lock()?; + let mapping = items.get(id).ok_or("Attachment no longer exists")?; + if !mapping.info.running + || mapping.control.snapshot().map_err(|e| e.to_string())?.phase != BridgePhase::Attached + { + return Err("The attachment is not ready to open.".into()); + } + #[cfg(windows)] + { + windows_target(&mapping.info.local_path)?; + Ok(PathBuf::from(format!("{}\\", mapping.info.local_path))) + } + #[cfg(not(windows))] + Ok(PathBuf::from(&mapping.info.local_path)) + } } #[tauri::command] @@ -132,6 +162,58 @@ pub fn dismiss_drive(id: String, state: State<'_, DesktopState>) -> Result<(), S Ok(()) } +#[tauri::command] +pub fn retry_drive_cleanup(id: String, state: State<'_, DesktopState>) -> Result<(), String> { + state.mappings.recovery(&id)?.request() +} + +#[tauri::command] +pub async fn open_drive_location(id: String, state: State<'_, DesktopState>) -> Result<(), String> { + // The caller supplies an attachment ID, never an arbitrary path or program. + let target = state.mappings.open_target(&id)?; + tauri::async_runtime::spawn_blocking(move || open_local_folder(&target)) + .await + .map_err(|e| e.to_string())? +} +fn open_local_folder(target: &std::path::Path) -> Result<(), String> { + #[cfg(windows)] + { + use windows::{ + core::{w, HSTRING}, + Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL}, + }; + let path = HSTRING::from(target.as_os_str()); + let result = + unsafe { ShellExecuteW(None, w!("explore"), &path, None, None, SW_SHOWNORMAL) }; + if result.0 as isize <= 32 { + return Err(format!( + "Windows could not open the attachment (code {}).", + result.0 as isize + )); + } + Ok(()) + } + #[cfg(not(windows))] + { + #[cfg(target_os = "macos")] + let mut command = std::process::Command::new("/usr/bin/open"); + #[cfg(not(target_os = "macos"))] + let mut command = std::process::Command::new("/usr/bin/xdg-open"); + let mut child = command + .arg(target) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| e.to_string())?; + // Reap the desktop launcher without blocking IPC on the file manager. + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) + } +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct Availability { @@ -226,6 +308,10 @@ pub async fn attach_drive( // Reserve while holding the registry lock: disconnect/replacement use the same // order and cannot retire this source between validation and grant ownership. let _transition = state.mount_transition.lock().await; + // Read the local OS table before reserving, without probing filesystem contents. + if crate::local_mounts::occupied(&target)? { + return Err("That local location is already mounted. Choose another location.".into()); + } let registry = state.registry.lock().await; let session = registry .sessions @@ -261,8 +347,10 @@ pub async fn attach_drive( status: control.snapshot().map_err(|e| e.to_string())?, running: true, cleanup_warning: None, + can_retry_cleanup: false, }; state.mappings.reserve(info, control.clone(), Some(lease))?; + let recovery = state.mappings.recovery(&id)?; let mappings = state.mappings.clone(); let task_id = id.clone(); // A canceled UI invocation does not drop a live mapping or its connection lease. @@ -276,6 +364,7 @@ pub async fn attach_drive( writable, control.clone(), safe.clone(), + recovery, ) .await; if let Err(error) = result { @@ -295,6 +384,7 @@ pub async fn attach_drive( Ok(Some(id)) } +#[allow(clippy::too_many_arguments)] // One task owns the root, process and recovery state. async fn run( installation: (crate::drive_bridge_install::Installation, PathBuf), target: PathBuf, @@ -303,6 +393,7 @@ async fn run( writable: bool, control: Arc, safe: Arc, + recovery: Arc, ) -> Result<(), String> { let root = tokio::time::timeout(Duration::from_secs(30), files.mount_root(&path, writable)) .await @@ -318,8 +409,17 @@ async fn run( .await .map_err(|e| e.to_string())??; let mut command = Command::new(executable); - command.arg("--mount").arg(target); - supervise(command, root, control, safe, Duration::from_secs(30)).await + command.arg("--mount").arg(&target); + supervise( + command, + root, + control, + safe, + Duration::from_secs(30), + recovery, + Some(target), + ) + .await } async fn supervise( @@ -328,6 +428,8 @@ async fn supervise( control: Arc, safe: Arc, startup_timeout: Duration, + recovery: Arc, + target: Option, ) -> Result<(), String> { command .stdin(Stdio::piped()) @@ -390,7 +492,9 @@ async fn supervise( }; // User detach only arrives here after the native backend confirms unmount. // A crashed/stalled startup or broken transport is failure, never a busy retry. - let cleanup = tokio::time::timeout(Duration::from_secs(10), tree.cleanup(&mut child)).await; + // Start cleanup immediately to break blocked reads/writes in the pipe server. + let first_cleanup = + tokio::time::timeout(Duration::from_secs(10), tree.cleanup(&mut child)).await; if !server_done && tokio::time::timeout(Duration::from_secs(35), &mut server) .await @@ -399,10 +503,39 @@ async fn supervise( server.abort(); } drain.abort(); - match cleanup { - Ok(Ok(())) => { safe.store(true, Ordering::Release); result }, - _ => Err("Attachment process cleanup is unconfirmed. Check the local mount before using that location again.".into()), + let mut process_stopped = matches!(first_cleanup, Ok(Ok(()))); + loop { + let cleanup = if !process_stopped { + Err("The attachment helper has not confirmed shutdown. Close local applications using this drive, then retry cleanup.".into()) + } else { + check_mount_removed(target.as_deref()) + }; + match cleanup { + Ok(()) => break, + Err(message) => { + control.fail(message.clone()); + recovery.wait_for_retry(message).await; + if !process_stopped { + process_stopped = matches!( + tokio::time::timeout(Duration::from_secs(10), tree.cleanup(&mut child)) + .await, + Ok(Ok(())) + ); + } + } + } } + recovery.finished(); + safe.store(true, Ordering::Release); + result +} +fn check_mount_removed(target: Option<&std::path::Path>) -> Result<(), String> { + if let Some(target) = target { + if crate::local_mounts::occupied(target)? { + return Err(format!("The helper has stopped, but {} is still mounted. Unmount it using your system's disk tools, then retry cleanup. ShellCanvas is keeping the connection reserved.", target.display())); + } + } + Ok(()) } fn diagnostic_tail(bytes: &Mutex>) -> String { bytes @@ -474,6 +607,8 @@ mod tests { control, safe.clone(), Duration::from_millis(500), + Arc::new(crate::drive_recovery::Recovery::default()), + None, ), ) .await @@ -484,6 +619,47 @@ mod tests { "{mode} process was not reaped" ); } + // Read-only occupancy fixture: the system volume must never be treated + // as removed merely because a helper exited. No mount/unmount is performed. + let recovery = Arc::new(crate::drive_recovery::Recovery::default()); + let safe = Arc::new(AtomicBool::new(true)); + let control = Arc::new(BridgeControl::default()); + #[cfg(windows)] + let occupied = PathBuf::from(std::env::var_os("SystemDrive").unwrap()); + #[cfg(not(windows))] + let occupied = PathBuf::from("/"); + let mut command = Command::new(&fixture); + command.arg("invalid"); + let retained = tokio::spawn(supervise( + command, + Arc::new(EmptyRoot), + control.clone(), + safe.clone(), + Duration::from_secs(5), + recovery.clone(), + Some(occupied), + )); + for _ in 0..2 { + tokio::time::timeout(Duration::from_secs(5), async { + while !recovery.snapshot().unwrap().1 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!(recovery + .snapshot() + .unwrap() + .0 + .unwrap() + .contains("still mounted")); + assert_eq!(control.snapshot().unwrap().phase, BridgePhase::Failed); + assert!(!safe.load(Ordering::Acquire)); + assert!(!retained.is_finished()); + recovery.request().unwrap(); + } + retained.abort(); + assert!(retained.await.unwrap_err().is_cancelled()); let control = Arc::new(BridgeControl::default()); let safe = Arc::new(AtomicBool::new(true)); let mut command = Command::new(fixture); @@ -494,6 +670,8 @@ mod tests { control.clone(), safe.clone(), Duration::from_secs(5), + Arc::new(crate::drive_recovery::Recovery::default()), + None, )); async fn phase(control: &BridgeControl, expected: BridgePhase) { tokio::time::timeout(Duration::from_secs(5), async { @@ -561,6 +739,7 @@ mod tests { status: control.snapshot().unwrap(), running: true, cleanup_warning: None, + can_retry_cleanup: false, }, control, Some(resource.lease().unwrap()), @@ -597,10 +776,13 @@ mod tests { status: control.snapshot().unwrap(), running: true, cleanup_warning: None, + can_retry_cleanup: false, }; mappings .reserve(info.clone(), control.clone(), None) .unwrap(); + assert!(mappings.open_target("a").is_err()); + assert!(mappings.recovery("missing").is_err()); assert!(mappings.ensure_releasable(7, None).is_err()); assert!(mappings.ensure_releasable(8, None).is_ok()); let other = ConnectionIdentity { @@ -612,7 +794,12 @@ mod tests { control .report(shellcanvas_services::bridge_control::BridgeEvent::Ready) .unwrap(); + assert!(mappings.open_target("a").is_ok()); + #[cfg(windows)] + assert_eq!(mappings.open_target("a").unwrap(), PathBuf::from("Z:\\")); + assert!(mappings.recovery("a").unwrap().request().is_err()); mappings.detach("a").unwrap(); + assert!(mappings.open_target("a").is_err()); control .report(shellcanvas_services::bridge_control::BridgeEvent::Detached) .unwrap(); diff --git a/src-tauri/src/drive_recovery.rs b/src-tauri/src/drive_recovery.rs new file mode 100644 index 0000000..b884be7 --- /dev/null +++ b/src-tauri/src/drive_recovery.rs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Retain the original helper ownership until an explicit cleanup retry succeeds. +use std::sync::Mutex; +use tokio::sync::Notify; + +#[derive(Default)] +pub(crate) struct Recovery { + state: Mutex<(Option, bool)>, + retry: Notify, +} +impl Recovery { + pub fn snapshot(&self) -> Result<(Option, bool), String> { + self.state + .lock() + .map(|s| s.clone()) + .map_err(|_| "Cleanup state unavailable".into()) + } + pub fn request(&self) -> Result<(), String> { + let mut state = self.state.lock().map_err(|_| "Cleanup state unavailable")?; + if !state.1 { + return Err("This attachment is not waiting for a cleanup retry.".into()); + } + state.1 = false; + self.retry.notify_one(); + Ok(()) + } + pub async fn wait_for_retry(&self, message: String) { + *self.state.lock().unwrap_or_else(|e| e.into_inner()) = (Some(message), true); + // Notify retains a permit if a user retries just before this await. + // No automatic retry and no dropped process ownership while waiting. + self.retry.notified().await; + } + pub fn finished(&self) { + *self.state.lock().unwrap_or_else(|e| e.into_inner()) = (None, false); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + #[tokio::test] + async fn cleanup_waits_for_explicit_retries_and_clears_only_after_success() { + let recovery = Arc::new(Recovery::default()); + assert!(recovery.request().is_err()); + let attempts = Arc::new(AtomicUsize::new(0)); + let worker = recovery.clone(); + let count = attempts.clone(); + let task = tokio::spawn(async move { + while count.fetch_add(1, Ordering::SeqCst) < 2 { + worker.wait_for_retry("Mount still exists".into()).await; + } + worker.finished(); + }); + for expected in 1..=2 { + tokio::time::timeout(std::time::Duration::from_secs(1), async { + while !recovery.snapshot().unwrap().1 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(attempts.load(Ordering::SeqCst), expected); + assert!(!task.is_finished()); + assert_eq!( + recovery.snapshot().unwrap().0.as_deref(), + Some("Mount still exists") + ); + recovery.request().unwrap(); + assert!(recovery.request().is_err()); + } + task.await.unwrap(); + assert_eq!(attempts.load(Ordering::SeqCst), 3); + assert_eq!(recovery.snapshot().unwrap(), (None, false)); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4e67fb2..6f1270b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,6 +22,8 @@ mod custom_services; mod directories; mod drive_bridge_install; mod drive_mappings; +mod drive_recovery; +mod local_mounts; mod extension_frames; #[cfg(debug_assertions)] mod extension_probe; @@ -793,6 +795,8 @@ pub fn run() { drive_mappings::attach_drive, drive_mappings::detach_drive, drive_mappings::dismiss_drive, + drive_mappings::retry_drive_cleanup, + drive_mappings::open_drive_location, repository_install::read_repository_file, repository_install::prepare_repository_read, repository_install::cancel_repository_read, diff --git a/src-tauri/src/local_mounts.rs b/src-tauri/src/local_mounts.rs new file mode 100644 index 0000000..8f4ca95 --- /dev/null +++ b/src-tauri/src/local_mounts.rs @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Read OS mount tables without touching a potentially disconnected filesystem. +//! No driver dependency and no unmount/force operation lives here. +use std::path::Path; + +#[cfg(windows)] +pub(crate) fn occupied(target: &Path) -> Result { + let value = target.to_str().ok_or("Invalid local drive")?.as_bytes(); + if value.len() != 2 || !value[0].is_ascii_uppercase() || value[1] != b':' { + return Err("Expected a local drive letter".into()); + } + let mask = unsafe { windows::Win32::Storage::FileSystem::GetLogicalDrives() }; + if mask == 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + Ok(mask & (1 << (value[0] - b'A')) != 0) +} + +#[cfg(target_os = "linux")] +pub(crate) fn occupied(target: &Path) -> Result { + use std::os::unix::ffi::OsStrExt; + let mounts = std::fs::read("/proc/self/mountinfo").map_err(|e| e.to_string())?; + linux_occupied(&mounts, target.as_os_str().as_bytes()) +} + +#[cfg(any(target_os = "linux", test))] +fn linux_occupied(table: &[u8], target: &[u8]) -> Result { + let mut has_entries = false; + for line in table.split(|b| *b == b'\n').filter(|l| !l.is_empty()) { + has_entries = true; + let fields: Vec<_> = line.split(|b| *b == b' ').collect(); + if fields.len() < 10 || !fields[6..].contains(&b"-".as_slice()) { + return Err("Unable to interpret the local mount table".into()); + } + let mut decoded = Vec::new(); + let mut input = fields[4]; + while !input.is_empty() { + if input[0] == b'\\' { + let code = input.get(1..4).ok_or("Invalid mount table escape")?; + let byte = match code { + b"040" => b' ', + b"011" => b'\t', + b"012" => b'\n', + b"134" => b'\\', + _ => return Err("Invalid mount table escape".into()), + }; + decoded.push(byte); + input = &input[4..]; + } else { + decoded.push(input[0]); + input = &input[1..]; + } + } + if decoded == target { + return Ok(true); + } + } + if !has_entries { + return Err("Local mount table is empty".into()); + } + Ok(false) +} + +#[cfg(target_os = "macos")] +pub(crate) fn occupied(target: &Path) -> Result { + use std::{ + ffi::CStr, + mem::{size_of, MaybeUninit}, + os::unix::ffi::OsStrExt, + }; + // getfsstat owns no global buffer (unlike getmntinfo); NOWAIT avoids querying + // a disconnected FUSE mount. Retry a growing table, never assume truncation is absence. + let mut capacity = 16usize; + for _ in 0..5 { + let mut entries = Vec::>::with_capacity(capacity); + let bytes = capacity + .checked_mul(size_of::()) + .ok_or("Mount table overflow")?; + let bytes = i32::try_from(bytes).map_err(|_| "Mount table too large")?; + let count = + unsafe { libc::getfsstat(entries.as_mut_ptr().cast(), bytes, libc::MNT_NOWAIT) }; + if count < 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + let count = count as usize; + if count >= capacity { + capacity = capacity.checked_mul(2).ok_or("Mount table overflow")?; + continue; + } + if count == 0 { + return Err("Local mount table is empty".into()); + } + // The syscall initialized exactly count entries in our allocated buffer. + unsafe { + entries.set_len(count); + } + for entry in entries { + let entry = unsafe { entry.assume_init() }; + let name = unsafe { CStr::from_ptr(entry.f_mntonname.as_ptr()) }; + if name.to_bytes() == target.as_os_str().as_bytes() { + return Ok(true); + } + } + return Ok(false); + } + Err("Local mount table changed while checking cleanup; retry.".into()) +} + +#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] +pub(crate) fn occupied(_: &Path) -> Result { + Err("Local mount verification is unavailable on this system".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn native_system_volume_is_present_without_filesystem_io() { + #[cfg(windows)] + let target = std::path::PathBuf::from(std::env::var_os("SystemDrive").unwrap()); + #[cfg(not(windows))] + let target = std::path::PathBuf::from("/"); + assert_eq!(occupied(&target), Ok(true)); + } + #[test] + fn linux_mount_table_checks_exact_paths_and_decodes_names() { + let table = b"20 1 0:1 / / rw - ext4 /dev/root rw\n21 20 0:2 / /tmp/my\\040mount\\134folder rw - fuse.shellcanvas bridge rw\n"; + assert_eq!(linux_occupied(table, b"/tmp/my mount\\folder"), Ok(true)); + assert_eq!(linux_occupied(table, b"/tmp/my mount"), Ok(false)); + assert_eq!(linux_occupied(table, b"/tmp/missing"), Ok(false)); + assert!(linux_occupied(b"", b"/tmp").is_err()); + assert!(linux_occupied(b"\n", b"/tmp").is_err()); + assert!(linux_occupied(b"invalid", b"/tmp").is_err()); + assert!(linux_occupied(b"21 20 0:2 / /tmp/\\999 rw - fuse x rw", b"/tmp").is_err()); + } +} diff --git a/src/components/DriveMappings.css b/src/components/DriveMappings.css index 064eded..06135e3 100644 --- a/src/components/DriveMappings.css +++ b/src/components/DriveMappings.css @@ -53,6 +53,17 @@ padding-top: 0.65rem; border-top: 1px solid var(--sc-border); } +.drive-mapping-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.drive-mapping-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; +} .attach-drive-dialog { width: min(500px, calc(100vw - 32px)); } diff --git a/src/components/DriveMappings.tsx b/src/components/DriveMappings.tsx index f123334..62e387f 100644 --- a/src/components/DriveMappings.tsx +++ b/src/components/DriveMappings.tsx @@ -1,7 +1,14 @@ // SPDX-License-Identifier: MPL-2.0 import { useEffect, useState } from "react"; import { invoke, isTauri } from "@tauri-apps/api/core"; -import { HardDrive, LoaderCircle, Unplug, X } from "lucide-react"; +import { + FolderOpen, + HardDrive, + LoaderCircle, + RotateCw, + Unplug, + X, +} from "lucide-react"; import "./DriveMappings.css"; export interface DriveMapping { @@ -18,6 +25,7 @@ export interface DriveMapping { }; running: boolean; cleanupWarning?: string | null; + canRetryCleanup?: boolean; } export function DriveMappings({ only }: { only?: string }) { const [items, setItems] = useState([]); @@ -42,17 +50,18 @@ export function DriveMappings({ only }: { only?: string }) { clearTimeout(timer); }; }, []); - async function action(item: DriveMapping) { + async function action(item: DriveMapping, command: string) { if (working) return; - const detach = item.running; setWorking(item.id); setError(""); try { - await invoke(detach ? "detach_drive" : "dismiss_drive", { + await invoke(command, { id: item.id, }); - if (!detach) + if (command === "dismiss_drive") setItems((values) => values.filter((value) => value.id !== item.id)); + else if (command !== "open_drive_location") + setItems(await invoke("drive_mappings")); } catch (e) { setError(String(e)); } finally { @@ -96,17 +105,43 @@ export function DriveMappings({ only }: { only?: string }) { ? " · finishing cleanup" : ""} - +
+ {item.running && item.status.phase === "Attached" && ( + + )} + {item.canRetryCleanup ? ( + + ) : ( + + )} +
{(item.cleanupWarning || item.status.message) && (

From 30df44ab49e7c88c2f2fba52eba85db853f24e57 Mon Sep 17 00:00:00 2001 From: Vanyo Vanev Date: Thu, 10 Sep 2026 20:47:29 +0300 Subject: [PATCH 05/31] Verify Linux mapped files through the native bridge and SFTP --- .../ssh-core/examples/native_bridge_probe.rs | 31 +++++++++++++++++-- docs/filesystem-integration-progress.md | 14 ++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/ssh-core/examples/native_bridge_probe.rs b/crates/ssh-core/examples/native_bridge_probe.rs index a88c578..3801213 100644 --- a/crates/ssh-core/examples/native_bridge_probe.rs +++ b/crates/ssh-core/examples/native_bridge_probe.rs @@ -129,9 +129,10 @@ async fn main() -> Result<()> { run( &args, &format!( - "timeout 240s python3 {} {}", + "timeout 240s python3 {} {} {}", quote(&script.path), - quote(&target) + quote(&target), + quote(&source) ) ) .await? @@ -183,7 +184,7 @@ async fn main() -> Result<()> { println!("PASS: native Linux filesystem -> FUSE bridge -> core root grant -> real SFTP; detached and disposable tree removed"); Ok(()) } -const TEST: &str = r#"import os, sys, errno +const TEST: &str = r#"import os, sys, errno, mmap from pathlib import Path p = Path(sys.argv[1]) f = os.open(p/'seek.bin', os.O_CREAT|os.O_EXCL|os.O_RDWR, 0o600) @@ -224,5 +225,29 @@ try: raise AssertionError('missing file readable') except FileNotFoundError: pass +payload = bytes(range(256)) * 49 +(p/'mapped.bin').write_bytes(payload) +fd = os.open(p/'mapped.bin', os.O_RDWR) +mapped = mmap.mmap(fd, len(payload), access=mmap.ACCESS_WRITE) +os.close(fd) # The mapping must retain the open object after its descriptor closes. +try: + assert mapped[:] == payload + mapped[4093:4103] = b'cross-page' + mapped.flush() +finally: + mapped.close() +expected = payload[:4093] + b'cross-page' + payload[4103:] +assert (Path(sys.argv[2])/'mapped.bin').read_bytes() == expected, 'mapped write did not reach SFTP source' +fd = os.open(p/'mapped.bin', os.O_RDONLY) +try: + with mmap.mmap(fd, len(expected), access=mmap.ACCESS_READ) as readonly: + assert readonly[:] == expected + with mmap.mmap(fd, len(expected), access=mmap.ACCESS_COPY) as private: + private[:7] = b'private' + private.flush() +finally: + os.close(fd) +assert (Path(sys.argv[2])/'mapped.bin').read_bytes() == expected, 'private mapping modified source' +print('LINUX_MMAP_PASS: shared cross-page writes flushed to source, descriptor-close lifetime, read-only and private mappings', flush=True) print('LINUX_NATIVE_MOUNT_PASS: sparse offset, truncate, atomic editor save, old handle identity, paged enumeration, directory rename with open file, capacity and errors', flush=True) "#; diff --git a/docs/filesystem-integration-progress.md b/docs/filesystem-integration-progress.md index 4aaf354..c2b0ed4 100644 --- a/docs/filesystem-integration-progress.md +++ b/docs/filesystem-integration-progress.md @@ -42,6 +42,16 @@ Include the dependencies' licensing/distribution limitations in that app. These changes are in PR #1, not public main; protocol v2 still awaits merge. CI run `34507333272` for `078e624` passed builds and tests on Windows, Ubuntu and macOS 14. This is build evidence, not native Windows/macOS mount evidence. +- Bridge `da6cf5a` negotiates Linux `FUSE_DIRECT_IO_ALLOW_MMAP` only when the + kernel advertises it, preserving ordinary direct I/O without enabling generic + writeback caching. CI run `34509574335` passed all three platforms. Its Linux + binary (SHA-256 `dac610e7588bf33d53e69f4896cfec8b3a6395cab892e5b42a500a8280415a6d`) + passed native Linux 6.8 tests through the core SFTP root: shared cross-page + mapped writes flushed to the independently inspected source, mapped lifetime + after descriptor close, read-only maps and private copy-on-write isolation. + The full prior native file-operation/busy-detach suite also passed, followed + by ordinary unmount and removal of the disposable UUID tree. The harness is + `crates/ssh-core/examples/native_bridge_probe.rs`. - Bridge has native Windows callbacks and a shared Linux/macOS FUSE backend, plus an independently buildable vendored SDK. GPL-3.0-only bridge licensing leaves the main app MPL-2.0. README/THIRD-PARTY describe WinFsp/wrapper terms, @@ -180,7 +190,9 @@ Include the dependencies' licensing/distribution limitations in that app. Windows cleanup-time deletion warnings and busy-detach control are implemented but need native WinFsp acceptance. Volume-wide flush and cross-handle rename are implemented with bookkeeping regression tests; native checks and file - attribute work remain. Memory-mapped workflows need explicit acceptance. + attribute work remain. Linux shared/private/read-only memory-mapping acceptance + now passes; Windows/macOS mapped-file tests and concurrent remote-edit behavior + remain unverified. This is not database or VM-image compatibility evidence. 8. Review cancellation/late responses, bounded teardown and protocol errors with failure fixtures, then run the relevant core/desktop regression checks. Add reproducible release packages/notices and update bridge docs with exact verified From 5a9cfd9168451ef66bff6ec5521db37e64ad7388 Mon Sep 17 00:00:00 2001 From: Vanyo Vanev Date: Thu, 10 Sep 2026 21:06:01 +0300 Subject: [PATCH 06/31] Record native Windows bridge acceptance and remaining integration checks --- docs/filesystem-integration-progress.md | 34 +++++++++++++++++++------ docs/local-drive-bridge.md | 8 +++--- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/docs/filesystem-integration-progress.md b/docs/filesystem-integration-progress.md index c2b0ed4..4738307 100644 --- a/docs/filesystem-integration-progress.md +++ b/docs/filesystem-integration-progress.md @@ -38,7 +38,8 @@ Include the dependencies' licensing/distribution limitations in that app. failure after attempting the others. Confirmed renames update related open paths; failed renames preserve them. Dropping an open context closes remote handles, including failure after acquisition. Two bookkeeping regressions pass; - Windows and Linux clippy pass. Live WinFsp acceptance remains required. + Windows and Linux clippy pass. Native coverage is described below; these + specific failure/volume-flush/cross-handle cases still need native acceptance. These changes are in PR #1, not public main; protocol v2 still awaits merge. CI run `34507333272` for `078e624` passed builds and tests on Windows, Ubuntu and macOS 14. This is build evidence, not native Windows/macOS mount evidence. @@ -56,6 +57,18 @@ Include the dependencies' licensing/distribution limitations in that app. plus an independently buildable vendored SDK. GPL-3.0-only bridge licensing leaves the main app MPL-2.0. README/THIRD-PARTY describe WinFsp/wrapper terms, separate macFUSE installation and commercial binary-bundling restrictions. +- Bridge `30c4125` passes native WinFsp 2.1.25156 acceptance in CI run + `34511425152`, Windows job `102986233383`. The opt-in `tests/native_windows.rs` + launches the real executable and mounts an unused drive letter over a disposable + local provider. Windows file APIs verify offsets above 4 GiB, flush/truncate, + temporary-file replacement saves, exact directory enumeration across pages, + rename/deletion, missing/denied/nonempty errors and provider capacity. Shared + cross-page mapped writes reach the independently inspected source; mappings + survive descriptor close; read-only and private copy-on-write maps pass. + A held file prevents detach; an explicit retry after close removes the drive. + All four native markers pass, with one test passing in 27.93 seconds. Windows, + Ubuntu and macOS 14 build/test jobs pass. This is real WinFsp acceptance with + a local provider, not desktop UI or an end-to-end SFTP mapping test. - Main desktop has reviewed optional bridge installation in Settings → Files. The native chooser stages exact bytes; approval is window-owned, single-use and expires. Installed versions are immutable content-addressed files under the @@ -96,7 +109,7 @@ Include the dependencies' licensing/distribution limitations in that app. contains the detach changes. Native Linux acceptance passed against `32d0c79`: held file prevented unmount, attachment remained usable, releasing it allowed an explicit clean detach, and the full file-operation/cleanup test passed. - Latest review-branch commit `6edb2a9` adds structured cleanup warnings and + Review-branch commit `6edb2a9` adds structured cleanup warnings and retired-channel refinements. All Windows/Ubuntu/macOS 14 checks pass on that commit (run `34502539007`); the PR is ready but requires repository review. Main `dac05fa` @@ -152,7 +165,9 @@ Include the dependencies' licensing/distribution limitations in that app. disposable FUSE test rather than changing the server's installed toolchain. - Verified the official WinFsp 2.1.25156 MSI signature (Navimatics). Installation failed with Windows Installer 1925 / exit 1603: administrator privileges needed. - No successful driver installation has been established. The async UAC question + No successful driver installation on this PC has been established. CI now + installs the official runtime on its disposable Windows runner, checking the + pinned MSI SHA-256 and Authenticode signer before native tests. The local UAC question is pending. This is an OS privilege issue, not an automatic approval rejection. Installer/log are under `.local/bridge-tools`. Workspace-only libclang 18.1.1 is available at `.local/bridge-tools/python/clang/native` for Windows builds. @@ -177,9 +192,10 @@ Include the dependencies' licensing/distribution limitations in that app. 3. Broaden failure acceptance to real network loss, permission/disk-full errors, late SFTP open responses and cancellation while preparing the root. Verify the native chooser/source-retirement race without touching the normal user profile. -4. Native Windows mount tests after administrator setup: Explorer and actual - local file APIs, ordinary editor/temporary-file replacement saves, directory - rename with open handles, capacity, errors and disconnect behavior. +4. Native Windows file API, replacement-save, capacity, basic error and busy-detach + checks now pass in disposable WinFsp CI. Remaining: desktop-created SFTP mapping, + Explorer/ordinary editor acceptance, directory rename with open handles, + disconnect behavior and failure recovery. Local driver setup is still pending. 5. Native Linux FUSE tests using a CI binary and a disposable directory. Validate directory cursor/rewind and inode retention/forget behavior, create/rename/ truncate, permissions, flush, detach and helper failure. @@ -188,10 +204,12 @@ Include the dependencies' licensing/distribution limitations in that app. Do not claim an OS version/runtime works solely because Linux compiled. 7. Resolve currently documented limits before calling the release ready: Windows cleanup-time deletion warnings and busy-detach control are implemented - but need native WinFsp acceptance. Volume-wide flush and cross-handle rename + with busy detach now passing native WinFsp acceptance. Cleanup-time failure + warnings still need native failure injection. Volume-wide flush and cross-handle rename are implemented with bookkeeping regression tests; native checks and file attribute work remain. Linux shared/private/read-only memory-mapping acceptance - now passes; Windows/macOS mapped-file tests and concurrent remote-edit behavior + passes, as does Windows shared/private/read-only mapping against a disposable + local provider. macOS mapped-file tests and concurrent remote-edit behavior remain unverified. This is not database or VM-image compatibility evidence. 8. Review cancellation/late responses, bounded teardown and protocol errors with failure fixtures, then run the relevant core/desktop regression checks. Add diff --git a/docs/local-drive-bridge.md b/docs/local-drive-bridge.md index 1d39e1b..36b1369 100644 --- a/docs/local-drive-bridge.md +++ b/docs/local-drive-bridge.md @@ -121,8 +121,8 @@ unbounded whole-file caching. 2. Dialog shows host and remote path, local target, access mode and any missing native component. Installation is a deliberate setup action. 3. Settings → Files shows host, remote/local path, access and attachment state, - with explicit Detach and completed-result Dismiss actions. Open-local-location - and recovery convenience actions remain backlog. Failed setup must leave no + with explicit Detach, Open folder, Retry cleanup and completed-result Dismiss + actions. Failed setup must leave no phantom drive; unconfirmed cleanup remains visible. 4. Disconnect/quit explains which mappings are affected and allows cancellation when files remain in use. @@ -150,4 +150,6 @@ unbounded whole-file caching. The design assessment installed no driver or remote software. Subsequent live implementation tests use newly created disposable directories; see the checkpoint -for evidence, pending administrator setup and unfinished release gates. +for evidence and unfinished release gates. Native Windows API tests now pass with +WinFsp on disposable CI runners; driver setup on the developer PC and desktop UI +acceptance remain pending. From 0a23e6860a93bfadb024fb17ca8e90bc4224250c Mon Sep 17 00:00:00 2001 From: Vanyo Vanev Date: Thu, 10 Sep 2026 21:15:18 +0300 Subject: [PATCH 07/31] Retire SFTP channels after unconfirmed mount handle acquisition --- crates/ssh-core/Cargo.toml | 2 +- crates/ssh-core/src/mounted.rs | 88 +++++++++++++++++++++++-- docs/filesystem-integration-progress.md | 24 ++++++- 3 files changed, 106 insertions(+), 8 deletions(-) diff --git a/crates/ssh-core/Cargo.toml b/crates/ssh-core/Cargo.toml index 0528511..532d17e 100644 --- a/crates/ssh-core/Cargo.toml +++ b/crates/ssh-core/Cargo.toml @@ -21,7 +21,7 @@ uuid = { version = "1", features = ["v4"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "io-util"] } [dev-dependencies] -tokio = { version = "1", features = ["process"] } +tokio = { version = "1", features = ["process", "test-util"] } shellcanvas-filesystem-sdk = { path = "../filesystem-sdk" } tempfile = "3" rand = "0.10" diff --git a/crates/ssh-core/src/mounted.rs b/crates/ssh-core/src/mounted.rs index 38b1154..5f3604d 100644 --- a/crates/ssh-core/src/mounted.rs +++ b/crates/ssh-core/src/mounted.rs @@ -19,6 +19,7 @@ fn error(e: SftpError) -> FsError { StatusCode::OpUnsupported => FsErrorKind::Unsupported, _ => FsErrorKind::Io, }, + SftpError::Timeout => FsErrorKind::TimedOut, _ => FsErrorKind::Io, }; FsError::new(kind, e.to_string()) @@ -34,6 +35,24 @@ async fn request(f: impl Future>) -> FsResult>, +) -> FsResult { + match request(operation).await { + Ok(handle) => Ok(handle.handle), + Err(error) => { + if error.kind == FsErrorKind::TimedOut { + // The remote server may have allocated a handle whose reply was + // lost or late. No handle ID means we cannot send CLOSE. Retire + // this mount's dedicated SFTP channel so the server releases it. + // Never retry OPEN: CREATE could already have changed the source. + let _ = service.raw.close_session(); + } + Err(error) + } + } +} fn metadata(a: FileAttributes) -> FsMetadata { FsMetadata { kind: if a.is_dir() { @@ -424,9 +443,11 @@ impl MountedFileSystem for SftpMount { // Truncate only after validating the returned handle is a regular file. let service = self.service.clone(); let file = tokio::spawn(async move { - let id = request(service.raw.open(path, flags, FileAttributes::empty())) - .await? - .handle; + let id = acquire_handle( + &service, + service.raw.open(path, flags, FileAttributes::empty()), + ) + .await?; let file = RemoteFile { handle: RemoteHandle { service, @@ -461,7 +482,7 @@ impl MountedFileSystem for SftpMount { } let service = self.service.clone(); tokio::spawn(async move { - let id = request(service.raw.opendir(path)).await?.handle; + let id = acquire_handle(&service, service.raw.opendir(path)).await?; Ok(Box::new(RemoteDirectory { handle: RemoteHandle { service, @@ -561,3 +582,62 @@ impl MountedFileSystem for SftpMount { Ok(()) } } + +#[cfg(test)] +mod acquisition_tests { + use super::*; + use russh_sftp::client::RawSftpSession; + use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream}; + + async fn packet(stream: &mut DuplexStream) -> Vec { + let length = stream.read_u32().await.unwrap(); + assert!(length < 4096); + let mut bytes = vec![0; length as usize]; + stream.read_exact(&mut bytes).await.unwrap(); + bytes + } + + #[tokio::test(start_paused = true)] + async fn unconfirmed_file_and_directory_opens_close_the_dedicated_channel() { + // Exercise the actual SFTP request path with both the library's own + // deadline and our outer deadline. Keep the service alive throughout: + // cleanup must be caused by failed acquisition, not its final Drop. + for (directory, library_deadline) in [(false, 120), (true, 120), (false, 1)] { + let (client, mut remote) = tokio::io::duplex(4096); + let peer = tokio::spawn(async move { + assert_eq!(packet(&mut remote).await[0], 1); // SSH_FXP_INIT + remote + .write_all(&[0, 0, 0, 5, 2, 0, 0, 0, 3]) + .await + .unwrap(); + let open = packet(&mut remote).await; + assert_eq!(open[0], if directory { 11 } else { 3 }); + // Simulate an allocated remote handle with its reply delayed. + // Session EOF is the only way to release an ID the client lacks. + let mut byte = [0]; + let n = remote.read(&mut byte).await.unwrap(); + assert_eq!(n, 0, "Timed-out OPEN did not close its SFTP channel"); + }); + let raw = RawSftpSession::new(client); + raw.set_timeout(library_deadline); + let service = SftpTextFiles::new(raw).await.unwrap(); + let result = if directory { + acquire_handle(&service, service.raw.opendir("/fixture")).await + } else { + acquire_handle( + &service, + service + .raw + .open("/fixture", OpenFlags::READ, FileAttributes::empty()), + ) + .await + }; + assert_eq!(result.unwrap_err().kind, FsErrorKind::TimedOut); + tokio::time::timeout(std::time::Duration::from_secs(1), peer) + .await + .expect("Unclaimed remote handle channel stayed open") + .unwrap(); + drop(service); + } + } +} diff --git a/docs/filesystem-integration-progress.md b/docs/filesystem-integration-progress.md index 4738307..340f1b0 100644 --- a/docs/filesystem-integration-progress.md +++ b/docs/filesystem-integration-progress.md @@ -69,6 +69,22 @@ Include the dependencies' licensing/distribution limitations in that app. All four native markers pass, with one test passing in 27.93 seconds. Windows, Ubuntu and macOS 14 build/test jobs pass. This is real WinFsp acceptance with a local provider, not desktop UI or an end-to-end SFTP mapping test. +- Follow-up bridge `36464c5`, CI run `34512537189`, passes native Windows failure + injection as well (28.21 seconds). Write-through writes surface the expected + Windows status for I/O failure, offline, timeout and read-only rejection; + independently inspected source bytes remain unchanged. Failed durable flush + reports an error. Unrelated I/O remains usable after these operation failures. + Failed close and cleanup-time deletion deliver structured warnings to the parent; + failed deletion preserves the source. This injects provider errors, not a real + network outage or exhausted remote disk. Prior native checks continue to pass. +- SFTP handle acquisition now closes the mount's dedicated channel if OPEN or + OPENDIR times out before the handle ID arrives. This releases potentially + allocated but unclaimed server handles without replaying CREATE. Native library + timeouts are classified as TimedOut. A paused-clock test exercises real SFTP + framing over an in-memory connection: file/directory acquisition and the library + deadline each produce channel EOF while the service is still alive. Actual + network-loss and caller-cancellation acceptance remain separate checks. All 33 + SSH core unit tests and core all-target clippy pass after this change. - Main desktop has reviewed optional bridge installation in Settings → Files. The native chooser stages exact bytes; approval is window-owned, single-use and expires. Installed versions are immutable content-addressed files under the @@ -189,8 +205,10 @@ Include the dependencies' licensing/distribution limitations in that app. and 400px light layouts with synthetic data. Desktop clippy and production build pass. Full desktop mapping recovery remains part of gate 1, including the platform launcher and native Unix mount-table runtime checks. -3. Broaden failure acceptance to real network loss, permission/disk-full errors, - late SFTP open responses and cancellation while preparing the root. Verify the +3. Broaden failure acceptance to real network loss and remote permission/disk-full + errors. Native provider error/cleanup warning injection now passes. Unconfirmed + SFTP open timeout cleanup is fixed and protocol-fixture tested; verify late + responses/caller cancellation while preparing the root. Verify the native chooser/source-retirement race without touching the normal user profile. 4. Native Windows file API, replacement-save, capacity, basic error and busy-detach checks now pass in disposable WinFsp CI. Remaining: desktop-created SFTP mapping, @@ -205,7 +223,7 @@ Include the dependencies' licensing/distribution limitations in that app. 7. Resolve currently documented limits before calling the release ready: Windows cleanup-time deletion warnings and busy-detach control are implemented with busy detach now passing native WinFsp acceptance. Cleanup-time failure - warnings still need native failure injection. Volume-wide flush and cross-handle rename + warnings now pass native failure injection. Volume-wide flush and cross-handle rename are implemented with bookkeeping regression tests; native checks and file attribute work remain. Linux shared/private/read-only memory-mapping acceptance passes, as does Windows shared/private/read-only mapping against a disposable From 99314bee815d1d4f8f6219860548293a0ee1ed91 Mon Sep 17 00:00:00 2001 From: Vanyo Vanev Date: Thu, 10 Sep 2026 21:22:22 +0300 Subject: [PATCH 08/31] Record native Windows connection-loss verification --- docs/filesystem-integration-progress.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/filesystem-integration-progress.md b/docs/filesystem-integration-progress.md index 340f1b0..d9c3fc5 100644 --- a/docs/filesystem-integration-progress.md +++ b/docs/filesystem-integration-progress.md @@ -77,6 +77,14 @@ Include the dependencies' licensing/distribution limitations in that app. Failed close and cleanup-time deletion deliver structured warnings to the parent; failed deletion preserves the source. This injects provider errors, not a real network outage or exhausted remote disk. Prior native checks continue to pass. +- Bridge `1459801`, CI run `34513391440`, passes actual Windows pipe-loss acceptance + (31.50 seconds for the full test). With a write-through file still open, both + parent pipe endpoints are dropped. The next write fails within ten seconds, + independently inspected confirmed source bytes remain unchanged, the helper + exits unsuccessfully and the drive letter disappears. Unexpected lifecycle + replies/connection loss now return failure exits on both Windows and FUSE; + ordinary explicit detach remains successful. All three platform CI jobs pass. + This tests abrupt bridge IPC loss, not an actual SSH network outage. - SFTP handle acquisition now closes the mount's dedicated channel if OPEN or OPENDIR times out before the handle ID arrives. This releases potentially allocated but unclaimed server handles without replaying CREATE. Native library @@ -118,7 +126,10 @@ Include the dependencies' licensing/distribution limitations in that app. - Protocol v2 adds ready/status/warning events and an explicit detach request. A failed busy detach returns to Attached and requires a new request; it never automatically retries. Windows holds an open-context gate across detach; Linux - and macOS use ordinary system unmount without force/lazy flags. Windows cleanup + and macOS explicit Detach uses ordinary system unmount without force/lazy flags. + Abnormal FUSE session/process cleanup also invokes the library's own teardown; + `fuser` 0.18's fallback can use lazy/forced unmount. Its native failure path remains + a verification gate, distinct from the normal busy-detach evidence. Windows cleanup failures reach the parent as structured warning events displayed by the mapping manager. Lifecycle and Windows gate unit tests pass. - Public bridge PR https://github.com/techartdev/ShellCanvas-DriveBridge/pull/1 @@ -216,7 +227,9 @@ Include the dependencies' licensing/distribution limitations in that app. disconnect behavior and failure recovery. Local driver setup is still pending. 5. Native Linux FUSE tests using a CI binary and a disposable directory. Validate directory cursor/rewind and inode retention/forget behavior, create/rename/ - truncate, permissions, flush, detach and helper failure. + truncate, permissions, flush, detach and helper failure. Specifically exercise + pipe loss with an open file: inspect the resulting mount table and the library's + abnormal cleanup behavior; do not infer it from explicit busy-detach tests. 6. Verify modern macOS compilation and native runtime as available. fuser's kernel/libfuse backend is implemented; FSKit operation is not established. Do not claim an OS version/runtime works solely because Linux compiled. From 8ae44d59a79441688c2121e1d59cd824171e831a Mon Sep 17 00:00:00 2001 From: Vanyo Vanev Date: Thu, 10 Sep 2026 21:29:51 +0300 Subject: [PATCH 09/31] Verify native Linux transport loss and disconnected mount cleanup --- .../ssh-core/examples/native_bridge_probe.rs | 54 +++++++++++++++++-- docs/filesystem-integration-progress.md | 20 ++++++- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/crates/ssh-core/examples/native_bridge_probe.rs b/crates/ssh-core/examples/native_bridge_probe.rs index 3801213..0ef2ed5 100644 --- a/crates/ssh-core/examples/native_bridge_probe.rs +++ b/crates/ssh-core/examples/native_bridge_probe.rs @@ -6,7 +6,7 @@ use shellcanvas_core::*; use shellcanvas_filesystem_sdk::bridge_control::{BridgeControl, BridgePhase}; use shellcanvas_filesystem_sdk::wire::Server; use std::{path::PathBuf, process::Stdio, sync::Arc, time::Duration}; -use tokio::io::AsyncBufReadExt; +use tokio::io::{AsyncBufReadExt, AsyncReadExt}; fn quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } @@ -51,6 +51,7 @@ async fn main() -> Result<()> { std::env::var("SHELLCANVAS_LIVE_MOUNT_PROBE").as_deref() == Ok("1"), "Set SHELLCANVAS_LIVE_MOUNT_PROBE=1 for disposable native mount testing" ); + let transport_loss = std::env::var("SHELLCANVAS_PROBE_TRANSPORT_LOSS").as_deref() == Ok("1"); let connection = Arc::new( Connection::connect_with_trust_store( &ConnectOptions { @@ -74,6 +75,7 @@ async fn main() -> Result<()> { .await?; let source = service.make_directory(&root, "source").await?; let target = service.make_directory(&root, "mount").await?; + println!("NATIVE_FIXTURE: {root}"); let browser = SshFileBrowser::new(Arc::new(SftpBrowser(service.clone())), connection.clone()); let fs = browser.mount_root(&source, true).await?; let remote = format!("exec {} --mount {}", quote(&args[5]), quote(&target)); @@ -101,6 +103,47 @@ async fn main() -> Result<()> { } anyhow::Ok(()) }).await??; + if transport_loss { + let script = r#"import os,sys,errno +f=os.open(sys.argv[1]+'/loss.bin',os.O_CREAT|os.O_EXCL|os.O_RDWR,0o600) +os.pwrite(f,b'confirmed',0) +os.fsync(f) +print('HELD',flush=True) +sys.stdin.read() +try: + os.pwrite(f,b'unconfirmed',0) + raise AssertionError('write succeeded after loss of provider') +except OSError as e: + assert e.errno in (errno.EIO,errno.ENOTCONN,errno.ENODEV), e + print('LINUX_LOST_WRITE_PASS: errno='+str(e.errno),flush=True) +finally: + try: + os.close(f) + except OSError as e: + assert e.errno in (errno.EIO,errno.ENOTCONN,errno.ENODEV), e + print('LINUX_LOST_CLOSE_ERROR: errno='+str(e.errno),flush=True) +"#; + let mut holder = ssh(&args, &format!("exec timeout 45s python3 -u -c {} {}", quote(script), quote(&target))) + .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit()).spawn()?; + let mut output = tokio::io::BufReader::new(holder.stdout.take().unwrap()); + let mut line = String::new(); + tokio::time::timeout(Duration::from_secs(15), output.read_line(&mut line)).await??; + ensure!(line.trim() == "HELD", "Loss fixture did not open its file"); + // Drop the bridge's real transport endpoints, while the separate + // SSH connection driving the local application remains available. + serving.abort(); + while !serving.is_finished() { tokio::task::yield_now().await; } + drop(holder.stdin.take()); + line.clear(); + tokio::time::timeout(Duration::from_secs(15), output.read_to_string(&mut line)).await??; + print!("{line}"); + ensure!(tokio::time::timeout(Duration::from_secs(5), holder.wait()).await??.success(), "Lost-write fixture failed"); + let status = tokio::time::timeout(Duration::from_secs(15), child.wait()).await??; + ensure!(!status.success(), "Transport loss reported a successful exit"); + let verify = "import sys; from pathlib import Path; assert Path(sys.argv[1]+'/loss.bin').read_bytes()==b'confirmed'; mounted=any(l.split()[4]==sys.argv[2] for l in open('/proc/self/mountinfo')); print('LINUX_LOSS_MOUNT_STATE: '+('retained' if mounted else 'removed')); print('LINUX_TRANSPORT_LOSS_PASS: failed write, preserved source, failure exit')"; + print!("{}",run(&args, &format!("python3 -c {} {} {}",quote(verify),quote(&source),quote(&target))).await?); + return Ok(()); + } let hold = "import os,sys; f=os.open(sys.argv[1]+'/held',os.O_CREAT|os.O_RDWR,0o600); print('HELD',flush=True); sys.stdin.read(); os.close(f)"; let mut holder = ssh(&args, &format!("exec timeout 60s python3 -u -c {} {}", quote(hold), quote(&target))) .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit()).spawn()?; @@ -150,11 +193,14 @@ async fn main() -> Result<()> { Ok(()) } .await; - // All test file descriptors are closed before ordinary unmount. Never use lazy/forced detach. + // Query mountinfo even for a disconnected FUSE mount whose stat() fails. + // Recovery is an ordinary unmount after the fixture's descriptors close. + let present = "import sys; sys.exit(0 if any(l.split()[4]==sys.argv[1] for l in open('/proc/self/mountinfo')) else 1)"; let detached = run( &args, &format!( - "if mountpoint -q {}; then fusermount3 -u {}; fi", + "if python3 -c {} {}; then fusermount3 -u -- {}; fi", + quote(present), quote(&target), quote(&target) ), @@ -173,7 +219,7 @@ async fn main() -> Result<()> { } // The regular Files action intentionally removes only empty directories. // This harness owns the entire UUID fixture, including the populated source. - let cleanup = "import os,shutil,sys; p=sys.argv[1]; assert p.startswith('/tmp/shellcanvas-native-') and os.path.dirname(p)=='/tmp' and not os.path.islink(p) and not os.path.ismount(p+'/mount'); shutil.rmtree(p)"; + let cleanup = "import os,shutil,sys; p=sys.argv[1]; assert p.startswith('/tmp/shellcanvas-native-') and os.path.dirname(p)=='/tmp' and not os.path.islink(p) and not any(l.split()[4]==p+'/mount' for l in open('/proc/self/mountinfo')); shutil.rmtree(p)"; run( &args, &format!("python3 -c {} {}", quote(cleanup), quote(&root)), diff --git a/docs/filesystem-integration-progress.md b/docs/filesystem-integration-progress.md index d9c3fc5..ec0dcc2 100644 --- a/docs/filesystem-integration-progress.md +++ b/docs/filesystem-integration-progress.md @@ -85,6 +85,21 @@ Include the dependencies' licensing/distribution limitations in that app. replies/connection loss now return failure exits on both Windows and FUSE; ordinary explicit detach remains successful. All three platform CI jobs pass. This tests abrupt bridge IPC loss, not an actual SSH network outage. +- The same `1459801` Linux artifact (SHA-256 + `4c759947a074c87ff29c10467afdf02961b4fb5e5938875c9f350ea970925882`) + passes native Linux 6.8 bridge-transport-loss acceptance over the core SFTP + provider. A local file is held open after a confirmed write/fsync, then both + bridge pipes are closed. The next write and final close report ENOTCONN (107), + source bytes inspected through an independent SSH command remain `confirmed`, + the bridge exits unsuccessfully and `/proc/self/mountinfo` shows the mount + removed. The fixture and staged binary were separately confirmed removed. + Reproduce the existing `native_bridge_probe` with both + `SHELLCANVAS_LIVE_MOUNT_PROBE=1` and `SHELLCANVAS_PROBE_TRANSPORT_LOSS=1`. + Its cleanup now consults mountinfo even when a disconnected mount rejects stat. + The first attempt exposed a test expectation issue: close also returned the + connection error; after explicitly validating that result the full replay passed. + Example compilation/clippy pass. This verifies the Linux 6.8/root runtime used; + non-root helper fallback, modern macOS and actual SSH outages remain separate. - SFTP handle acquisition now closes the mount's dedicated channel if OPEN or OPENDIR times out before the handle ID arrives. This releases potentially allocated but unclaimed server handles without replaying CREATE. Native library @@ -228,8 +243,9 @@ Include the dependencies' licensing/distribution limitations in that app. 5. Native Linux FUSE tests using a CI binary and a disposable directory. Validate directory cursor/rewind and inode retention/forget behavior, create/rename/ truncate, permissions, flush, detach and helper failure. Specifically exercise - pipe loss with an open file: inspect the resulting mount table and the library's - abnormal cleanup behavior; do not infer it from explicit busy-detach tests. + pipe loss with an open file now passes on Linux 6.8 as root, including source + preservation and confirmed mount disappearance. Validate non-root helper + fallback and other supported runtimes separately; no cross-platform inference. 6. Verify modern macOS compilation and native runtime as available. fuser's kernel/libfuse backend is implemented; FSKit operation is not established. Do not claim an OS version/runtime works solely because Linux compiled. From e9c036b40ca10928eba74a32a869de0baae1712b Mon Sep 17 00:00:00 2001 From: Vanyo Vanev Date: Thu, 10 Sep 2026 21:35:47 +0300 Subject: [PATCH 10/31] Retire mounted filesystems when their original SSH connection closes --- .../ssh-core/examples/native_bridge_probe.rs | 23 +++++++++++++--- crates/ssh-core/src/mounted.rs | 27 +++++++++++++++++++ crates/ssh-core/src/volumes.rs | 8 +++++- docs/filesystem-integration-progress.md | 19 +++++++++++-- 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/crates/ssh-core/examples/native_bridge_probe.rs b/crates/ssh-core/examples/native_bridge_probe.rs index 0ef2ed5..0b31031 100644 --- a/crates/ssh-core/examples/native_bridge_probe.rs +++ b/crates/ssh-core/examples/native_bridge_probe.rs @@ -52,6 +52,11 @@ async fn main() -> Result<()> { "Set SHELLCANVAS_LIVE_MOUNT_PROBE=1 for disposable native mount testing" ); let transport_loss = std::env::var("SHELLCANVAS_PROBE_TRANSPORT_LOSS").as_deref() == Ok("1"); + let ssh_loss = std::env::var("SHELLCANVAS_PROBE_SSH_LOSS").as_deref() == Ok("1"); + ensure!( + !(transport_loss && ssh_loss), + "Select one connection-loss mode" + ); let connection = Arc::new( Connection::connect_with_trust_store( &ConnectOptions { @@ -103,7 +108,7 @@ async fn main() -> Result<()> { } anyhow::Ok(()) }).await??; - if transport_loss { + if transport_loss || ssh_loss { let script = r#"import os,sys,errno f=os.open(sys.argv[1]+'/loss.bin',os.O_CREAT|os.O_EXCL|os.O_RDWR,0o600) os.pwrite(f,b'confirmed',0) @@ -131,8 +136,16 @@ finally: ensure!(line.trim() == "HELD", "Loss fixture did not open its file"); // Drop the bridge's real transport endpoints, while the separate // SSH connection driving the local application remains available. - serving.abort(); - while !serving.is_finished() { tokio::task::yield_now().await; } + if ssh_loss { + connection.disconnect().await?; + tokio::time::timeout(Duration::from_secs(5), async { + while connection.is_connected() { tokio::task::yield_now().await; } + }).await?; + println!("SSH_SOURCE_CLOSED: bridge pipes remain connected"); + } else { + serving.abort(); + while !serving.is_finished() { tokio::task::yield_now().await; } + } drop(holder.stdin.take()); line.clear(); tokio::time::timeout(Duration::from_secs(15), output.read_to_string(&mut line)).await??; @@ -225,7 +238,9 @@ finally: &format!("python3 -c {} {}", quote(cleanup), quote(&root)), ) .await?; - connection.disconnect().await?; + if connection.is_connected() { + connection.disconnect().await?; + } result?; println!("PASS: native Linux filesystem -> FUSE bridge -> core root grant -> real SFTP; detached and disposable tree removed"); Ok(()) diff --git a/crates/ssh-core/src/mounted.rs b/crates/ssh-core/src/mounted.rs index 5f3604d..7563b76 100644 --- a/crates/ssh-core/src/mounted.rs +++ b/crates/ssh-core/src/mounted.rs @@ -129,9 +129,26 @@ pub struct SftpMount { service: Arc, root: String, writable: bool, + connection: Option>, } impl SftpMount { pub async fn new(service: Arc, path: &str, write: bool) -> FsResult> { + Self::prepare(service, path, write, None).await + } + pub(crate) async fn connected( + service: Arc, + path: &str, + write: bool, + connection: Arc, + ) -> FsResult> { + Self::prepare(service, path, write, Some(connection)).await + } + async fn prepare( + service: Arc, + path: &str, + write: bool, + connection: Option>, + ) -> FsResult> { let root = SftpBrowser(service.clone()) .canonicalize(path) .await @@ -146,6 +163,7 @@ impl SftpMount { service, root, writable: write, + connection, })) } /// Walk components without following links. SFTP v3 has no openat/nofollow @@ -377,6 +395,15 @@ impl MountedDirectory for RemoteDirectory { #[async_trait] impl MountedFileSystem for SftpMount { + fn check_available(&self) -> FsResult<()> { + if self.connection.as_ref().is_some_and(|c| !c.is_connected()) { + return Err(FsError::new( + FsErrorKind::Offline, + "The attachment's SSH connection has closed", + )); + } + Ok(()) + } async fn space(&self, path: &MountPath) -> FsResult { let path = self.resolve(path, false).await?; let stats = request(self.service.raw.statvfs(path)).await?; diff --git a/crates/ssh-core/src/volumes.rs b/crates/ssh-core/src/volumes.rs index 43f3af9..7becb79 100644 --- a/crates/ssh-core/src/volumes.rs +++ b/crates/ssh-core/src/volumes.rs @@ -142,7 +142,13 @@ impl FileSystemProvider for SshFileBrowser { .text_files() .await .map_err(|e| FsError::new(FsErrorKind::Offline, e.to_string()))?; - Ok(crate::mounted::SftpMount::new(Arc::new(service), path, writable).await?) + Ok(crate::mounted::SftpMount::connected( + Arc::new(service), + path, + writable, + self.connection.clone(), + ) + .await?) } async fn volumes(&self) -> Result { self.inventory().await diff --git a/docs/filesystem-integration-progress.md b/docs/filesystem-integration-progress.md index ec0dcc2..282ee91 100644 --- a/docs/filesystem-integration-progress.md +++ b/docs/filesystem-integration-progress.md @@ -100,6 +100,19 @@ Include the dependencies' licensing/distribution limitations in that app. connection error; after explicitly validating that result the full replay passed. Example compilation/clippy pass. This verifies the Linux 6.8/root runtime used; non-root helper fallback, modern macOS and actual SSH outages remain separate. +- Native Linux controlled SSH-disconnect acceptance exposed and fixed a heartbeat + gap: failed file I/O did not itself make the underlying SFTP mount's cheap health + check fail. Production SSH mounts now retain the exact original connection's + lifecycle and report Offline when it closes, without resolving any new source. + `SHELLCANVAS_PROBE_SSH_LOSS=1` closes only the fixture's SSH source connection; + the bridge pipes and independent test-control SSH sessions remain available. + Before the fix, the helper missed its exit deadline. After the fix, live Linux + FUSE write/close return EIO, source bytes remain `confirmed`, the heartbeat reports + the closed SSH connection, the helper exits unsuccessfully and mountinfo shows + removal. Both the failing and passing disposable fixtures and the staged binary + were independently confirmed removed. All 33 core tests and all-target clippy + pass. This is a controlled real SSH disconnect, not a black-holed network or a + silent SFTP-only channel close; those failure detection paths remain to verify. - SFTP handle acquisition now closes the mount's dedicated channel if OPEN or OPENDIR times out before the handle ID arrives. This releases potentially allocated but unclaimed server handles without replaying CREATE. Native library @@ -231,8 +244,10 @@ Include the dependencies' licensing/distribution limitations in that app. and 400px light layouts with synthetic data. Desktop clippy and production build pass. Full desktop mapping recovery remains part of gate 1, including the platform launcher and native Unix mount-table runtime checks. -3. Broaden failure acceptance to real network loss and remote permission/disk-full - errors. Native provider error/cleanup warning injection now passes. Unconfirmed +3. Broaden failure acceptance to stalled/black-holed network and SFTP-only channel + loss, and remote permission/disk-full errors. Controlled SSH disconnect now + passes over a native Linux mount, after fixing its connection-bound heartbeat. + Native provider error/cleanup warning injection now passes. Unconfirmed SFTP open timeout cleanup is fixed and protocol-fixture tested; verify late responses/caller cancellation while preparing the root. Verify the native chooser/source-retirement race without touching the normal user profile. From b2069b3850c9a6c7c71b341ec957de761f81c062 Mon Sep 17 00:00:00 2001 From: Vanyo Vanev Date: Thu, 10 Sep 2026 21:41:44 +0300 Subject: [PATCH 11/31] Detect idle SFTP channel closure in mount heartbeats --- crates/ssh-core/src/connection.rs | 5 +- crates/ssh-core/src/lib.rs | 1 + crates/ssh-core/src/mounted.rs | 78 +++++++++++++++++++++++++ crates/ssh-core/src/sftp_transport.rs | 73 +++++++++++++++++++++++ crates/ssh-core/src/text.rs | 15 +++++ docs/filesystem-integration-progress.md | 17 +++++- 6 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 crates/ssh-core/src/sftp_transport.rs diff --git a/crates/ssh-core/src/connection.rs b/crates/ssh-core/src/connection.rs index 37d14f8..69a0895 100644 --- a/crates/ssh-core/src/connection.rs +++ b/crates/ssh-core/src/connection.rs @@ -234,10 +234,7 @@ impl Connection { let mut channel = self.handle.channel_open_session().await?; channel.request_subsystem(true, "sftp").await?; wait_for_acceptance(&mut channel, "Text file subsystem").await?; - crate::SftpTextFiles::new(russh_sftp::client::RawSftpSession::new( - channel.into_stream(), - )) - .await + crate::SftpTextFiles::from_stream(channel.into_stream()).await }) .await .context("Text file negotiation timed out")? diff --git a/crates/ssh-core/src/lib.rs b/crates/ssh-core/src/lib.rs index 580ce2e..091cb6c 100644 --- a/crates/ssh-core/src/lib.rs +++ b/crates/ssh-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod probe; pub mod profiles; pub mod provider; pub mod settings; +mod sftp_transport; pub mod terminal; pub mod text; pub mod transfers; diff --git a/crates/ssh-core/src/mounted.rs b/crates/ssh-core/src/mounted.rs index 7563b76..92816b3 100644 --- a/crates/ssh-core/src/mounted.rs +++ b/crates/ssh-core/src/mounted.rs @@ -47,6 +47,9 @@ async fn acquire_handle( // lost or late. No handle ID means we cannot send CLOSE. Retire // this mount's dedicated SFTP channel so the server releases it. // Never retry OPEN: CREATE could already have changed the source. + service + .channel_closed + .store(true, std::sync::atomic::Ordering::Release); let _ = service.raw.close_session(); } Err(error) @@ -396,6 +399,16 @@ impl MountedDirectory for RemoteDirectory { #[async_trait] impl MountedFileSystem for SftpMount { fn check_available(&self) -> FsResult<()> { + if self + .service + .channel_closed + .load(std::sync::atomic::Ordering::Acquire) + { + return Err(FsError::new( + FsErrorKind::Offline, + "The attachment's SFTP channel has closed", + )); + } if self.connection.as_ref().is_some_and(|c| !c.is_connected()) { return Err(FsError::new( FsErrorKind::Offline, @@ -624,6 +637,65 @@ mod acquisition_tests { bytes } + async fn live_channel() -> ( + Arc, + tokio::sync::oneshot::Sender<()>, + tokio::task::JoinHandle<()>, + ) { + let (client, mut remote) = tokio::io::duplex(4096); + let (close, closing) = tokio::sync::oneshot::channel(); + let peer = tokio::spawn(async move { + assert_eq!(packet(&mut remote).await[0], 1); + remote + .write_all(&[0, 0, 0, 5, 2, 0, 0, 0, 3]) + .await + .unwrap(); + let _ = closing.await; + // Remote EOF without an outstanding filesystem request. + drop(remote); + }); + let service = Arc::new(SftpTextFiles::from_stream(client).await.unwrap()); + (service, close, peer) + } + + #[tokio::test] + async fn idle_channel_eof_retires_only_its_mount_heartbeat() { + use shellcanvas_filesystem_sdk::wire::{Operation, Server}; + let (service_a, close_a, peer_a) = live_channel().await; + let (service_b, close_b, peer_b) = live_channel().await; + let server = |service| { + Server::new(Arc::new(SftpMount { + service, + root: "/fixture".into(), + writable: false, + connection: None, + })) + }; + let mut a = server(service_a); + let mut b = server(service_b); + assert!(a.dispatch(Operation::Poll).await.is_ok()); + assert!(b.dispatch(Operation::Poll).await.is_ok()); + close_a.send(()).unwrap(); + peer_a.await.unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if let Err(error) = a.dispatch(Operation::Poll).await { + assert_eq!(error.kind, FsErrorKind::Offline); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } + }) + .await + .expect("Idle SFTP EOF was invisible to the heartbeat"); + assert!( + b.dispatch(Operation::Poll).await.is_ok(), + "Another channel was retired" + ); + close_b.send(()).unwrap(); + peer_b.await.unwrap(); + } + #[tokio::test(start_paused = true)] async fn unconfirmed_file_and_directory_opens_close_the_dedicated_channel() { // Exercise the actual SFTP request path with both the library's own @@ -660,6 +732,12 @@ mod acquisition_tests { .await }; assert_eq!(result.unwrap_err().kind, FsErrorKind::TimedOut); + assert!( + service + .channel_closed + .load(std::sync::atomic::Ordering::Acquire), + "Timed-out handle acquisition left the mount heartbeat healthy" + ); tokio::time::timeout(std::time::Duration::from_secs(1), peer) .await .expect("Unclaimed remote handle channel stayed open") diff --git a/crates/ssh-core/src/sftp_transport.rs b/crates/ssh-core/src/sftp_transport.rs new file mode 100644 index 0000000..e929416 --- /dev/null +++ b/crates/ssh-core/src/sftp_transport.rs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Observe the lifetime of one SFTP stream without sending probe traffic. +use std::{ + io, + pin::Pin, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + task::{Context, Poll}, +}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +pub(crate) struct Observed { + inner: S, + closed: Arc, +} +impl Observed { + pub(crate) fn new(inner: S, closed: Arc) -> Self { + Self { inner, closed } + } +} +impl Drop for Observed { + fn drop(&mut self) { + self.closed.store(true, Ordering::Release); + } +} +impl AsyncRead for Observed { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let before = buf.filled().len(); + let had_space = buf.remaining() != 0; + let result = Pin::new(&mut self.inner).poll_read(cx, buf); + if matches!(&result, Poll::Ready(Err(_))) + || (matches!(&result, Poll::Ready(Ok(()))) && had_space && buf.filled().len() == before) + { + self.closed.store(true, Ordering::Release); + } + result + } +} +impl AsyncWrite for Observed { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + let result = Pin::new(&mut self.inner).poll_write(cx, bytes); + if matches!(&result, Poll::Ready(Err(_))) + || (!bytes.is_empty() && matches!(&result, Poll::Ready(Ok(0)))) + { + self.closed.store(true, Ordering::Release); + } + result + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let result = Pin::new(&mut self.inner).poll_flush(cx); + if matches!(&result, Poll::Ready(Err(_))) { + self.closed.store(true, Ordering::Release); + } + result + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let result = Pin::new(&mut self.inner).poll_shutdown(cx); + if result.is_ready() { + self.closed.store(true, Ordering::Release); + } + result + } +} diff --git a/crates/ssh-core/src/text.rs b/crates/ssh-core/src/text.rs index e94adc7..44f7199 100644 --- a/crates/ssh-core/src/text.rs +++ b/crates/ssh-core/src/text.rs @@ -7,6 +7,7 @@ use russh_sftp::{ protocol::{FileAttributes, OpenFlags, Packet, StatusCode}, }; use sha2::{Digest, Sha256}; +use std::sync::{atomic::AtomicBool, Arc}; use tokio::sync::Mutex; pub const TEXT_LIMIT: usize = 256 * 1024; @@ -49,8 +50,21 @@ pub struct SftpTextFiles { atomic_replace: bool, pub(crate) fsync: bool, pub(crate) save_lock: Mutex<()>, + pub(crate) channel_closed: Arc, } impl SftpTextFiles { + pub(crate) async fn from_stream(stream: S) -> Result + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, + { + let closed = Arc::new(AtomicBool::new(false)); + let raw = RawSftpSession::new(crate::sftp_transport::Observed::new(stream, closed.clone())); + let mut service = Self::new(raw).await?; + service.channel_closed = closed; + Ok(service) + } + /// Wrap an externally managed raw session. Its owner supplies connection + /// lifetime; desktop connections use `from_stream` to observe idle EOF too. pub async fn new(raw: RawSftpSession) -> Result { let version = raw.init().await?; Ok(Self { @@ -64,6 +78,7 @@ impl SftpTextFiles { .is_some_and(|v| v == "1"), raw, save_lock: Mutex::new(()), + channel_closed: Arc::new(AtomicBool::new(false)), }) } pub fn can_save(&self) -> bool { diff --git a/docs/filesystem-integration-progress.md b/docs/filesystem-integration-progress.md index 282ee91..a540412 100644 --- a/docs/filesystem-integration-progress.md +++ b/docs/filesystem-integration-progress.md @@ -113,6 +113,18 @@ Include the dependencies' licensing/distribution limitations in that app. were independently confirmed removed. All 33 core tests and all-target clippy pass. This is a controlled real SSH disconnect, not a black-holed network or a silent SFTP-only channel close; those failure detection paths remain to verify. +- Desktop SFTP channels now observe their own transport EOF, I/O errors, shutdown + and drop, without additional remote probes. Mount heartbeat checks report Offline + after channel retirement even if the SSH connection remains open. Unconfirmed + OPEN/OPENDIR timeout also marks that same channel retired before closing it. + A real SFTP framing fixture closes one idle channel while a second remains open: + the first bridge Poll reports Offline and the second continues successfully. + The timeout fixture also verifies that heartbeat health is retired. All 34 core + unit tests and all-target clippy pass. Live `mount_probe` still passes offsets + above 4 GiB, truncate/EOF, concurrent handles, atomic saves, read-only grants, + directory enumeration and disposable cleanup. This is channel-close protocol + evidence plus a live healthy-path regression; native channel-only loss and + stalled-network detection remain separate checks. - SFTP handle acquisition now closes the mount's dedicated channel if OPEN or OPENDIR times out before the handle ID arrives. This releases potentially allocated but unclaimed server handles without replaying CREATE. Native library @@ -244,8 +256,9 @@ Include the dependencies' licensing/distribution limitations in that app. and 400px light layouts with synthetic data. Desktop clippy and production build pass. Full desktop mapping recovery remains part of gate 1, including the platform launcher and native Unix mount-table runtime checks. -3. Broaden failure acceptance to stalled/black-holed network and SFTP-only channel - loss, and remote permission/disk-full errors. Controlled SSH disconnect now +3. Broaden failure acceptance to stalled/black-holed network and native SFTP-only + channel loss, and remote permission/disk-full errors. Idle channel EOF now has + protocol-level coverage with a second unaffected channel. Controlled SSH disconnect now passes over a native Linux mount, after fixing its connection-bound heartbeat. Native provider error/cleanup warning injection now passes. Unconfirmed SFTP open timeout cleanup is fixed and protocol-fixture tested; verify late From 16ce9e49663c2bc8c190399670d02fb5cc4fc1ef Mon Sep 17 00:00:00 2001 From: Vanyo Vanev Date: Thu, 10 Sep 2026 21:54:52 +0300 Subject: [PATCH 12/31] Offer available drive letters for local attachments --- docs/filesystem-integration-progress.md | 9 +++++++ src-tauri/src/drive_mappings.rs | 19 +++++++++++++++ src-tauri/src/local_mounts.rs | 13 +++++++++++ src/components/AttachDriveDialog.tsx | 31 +++++++++++++++++++------ src/sdk.ts | 1 + 5 files changed, 66 insertions(+), 7 deletions(-) diff --git a/docs/filesystem-integration-progress.md b/docs/filesystem-integration-progress.md index a540412..99686e6 100644 --- a/docs/filesystem-integration-progress.md +++ b/docs/filesystem-integration-progress.md @@ -12,6 +12,15 @@ Include the dependencies' licensing/distribution limitations in that app. ## Current implementation +- Windows attachment setup now offers only unused D: through Z: drive letters, + also excluding active ShellCanvas reservations. The backend still checks the + chosen location at attachment time. Synthetic browser checks confirm the first + available choice and the disabled/explained no-free-letter state. TypeScript + and desktop clippy pass. Bridge `49c481a` explains missing/damaged WinFsp with + official setup guidance. A real helper launch on this PC without WinFsp passes: + after the protocol capability handshake it exits promptly with that message, + without requesting filesystem operations. No driver was installed. This does + not replace the full desktop installation and mapped-drive checks below. - `crates/filesystem-sdk`: small MPL-2.0 optional rooted filesystem contract and inherited-pipe protocol. Validated relative components, metadata/capacity, handle-based offset I/O, truncate, flush, atomic replacement, incremental diff --git a/src-tauri/src/drive_mappings.rs b/src-tauri/src/drive_mappings.rs index f73c660..3e34a08 100644 --- a/src-tauri/src/drive_mappings.rs +++ b/src-tauri/src/drive_mappings.rs @@ -220,6 +220,8 @@ pub struct Availability { pub supported: bool, pub installed: bool, pub windows: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub available_drives: Option>, } #[tauri::command] pub async fn drive_mapping_available( @@ -237,10 +239,27 @@ pub async fn drive_mapping_available( .await .map_err(|e| e.to_string())?? .is_some(); + #[cfg(windows)] + let available_drives = { + let reserved = state.mappings.list()?; + Some( + crate::local_mounts::available_drive_letters()? + .into_iter() + .filter(|letter| { + !reserved + .iter() + .any(|m| m.running && m.local_path.eq_ignore_ascii_case(letter)) + }) + .collect(), + ) + }; + #[cfg(not(windows))] + let available_drives = None; Ok(Availability { supported, installed, windows: cfg!(windows), + available_drives, }) } diff --git a/src-tauri/src/local_mounts.rs b/src-tauri/src/local_mounts.rs index 8f4ca95..dccc27e 100644 --- a/src-tauri/src/local_mounts.rs +++ b/src-tauri/src/local_mounts.rs @@ -3,6 +3,19 @@ //! No driver dependency and no unmount/force operation lives here. use std::path::Path; +#[cfg(windows)] +pub(crate) fn available_drive_letters() -> Result, String> { + let mask = unsafe { windows::Win32::Storage::FileSystem::GetLogicalDrives() }; + if mask == 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + Ok((b'D'..=b'Z') + .rev() + .filter(|letter| mask & (1 << (letter - b'A')) == 0) + .map(|letter| format!("{}:", char::from(letter))) + .collect()) +} + #[cfg(windows)] pub(crate) fn occupied(target: &Path) -> Result { let value = target.to_str().ok_or("Invalid local drive")?.as_bytes(); diff --git a/src/components/AttachDriveDialog.tsx b/src/components/AttachDriveDialog.tsx index a668c7e..ba28d9e 100644 --- a/src/components/AttachDriveDialog.tsx +++ b/src/components/AttachDriveDialog.tsx @@ -28,6 +28,10 @@ export function AttachDriveDialog({ const [working, setWorking] = useState(false); const [mapping, setMapping] = useState(null); const [error, setError] = useState(""); + const availableDrives = + availability?.availableDrives ?? + Array.from({ length: 23 }, (_, i) => `${String.fromCharCode(90 - i)}:`); + const noDrive = availability?.windows && availableDrives.length === 0; useEffect(() => { alive.current = true; const previous = document.activeElement as HTMLElement | null; @@ -37,7 +41,12 @@ export function AttachDriveDialog({ Promise.reject("Local attachments are available in the desktop app.") ) .then((value) => { - if (alive.current) setAvailability(value); + if (alive.current) { + setAvailability(value); + if (value.windows && value.availableDrives) { + setDrive(value.availableDrives[0] ?? ""); + } + } }) .catch((e) => { if (alive.current) setError(String(e)); @@ -52,6 +61,7 @@ export function AttachDriveDialog({ working || !availability?.supported || !availability.installed || + noDrive || !services.attachDrive ) return; @@ -126,13 +136,11 @@ export function AttachDriveDialog({ Local drive @@ -140,6 +148,12 @@ export function AttachDriveDialog({ ) : (

You’ll choose an empty local folder in the next step.

)} + {noDrive && ( +

+ All drive letters from D: to Z: are in use. Detach a drive, + then reopen this dialog. +

+ )}