diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..11d76e9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + verify: + runs-on: windows-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run public:check + - run: npm run verify + + rust-sdks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Test publishable Rust SDKs + run: cargo test -p shellcanvas-filesystem-sdk -p shellcanvas-adapter-sdk --locked + - name: Lint publishable Rust SDKs + run: cargo clippy -p shellcanvas-filesystem-sdk -p shellcanvas-adapter-sdk --all-targets --locked -- -D warnings diff --git a/.github/workflows/publish-sdks.yml b/.github/workflows/publish-sdks.yml new file mode 100644 index 0000000..85fa975 --- /dev/null +++ b/.github/workflows/publish-sdks.yml @@ -0,0 +1,45 @@ +name: Publish SDKs + +on: + workflow_dispatch: + inputs: + version: + description: Existing vX.Y.Z tag to publish + required: true + type: string + +permissions: + contents: read + id-token: write + +jobs: + npm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ inputs.version }} + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + - run: npm ci + - run: npm run release:check -- "${{ inputs.version }}" + - run: npm test --workspace @shellcanvas/app-sdk + - run: npm publish --workspace @shellcanvas/app-sdk --access public + + crates: + runs-on: ubuntu-latest + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ inputs.version }} + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + - run: npm run release:check -- "${{ inputs.version }}" + - run: cargo test -p shellcanvas-filesystem-sdk -p shellcanvas-adapter-sdk --locked + - run: cargo publish -p shellcanvas-filesystem-sdk --locked + - run: cargo publish -p shellcanvas-adapter-sdk --locked diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml new file mode 100644 index 0000000..67a4c31 --- /dev/null +++ b/.github/workflows/release-windows.yml @@ -0,0 +1,42 @@ +name: Release Windows installers + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + installers: + runs-on: windows-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run release:check -- "${{ github.ref_name }}" + - run: npm run public:check + - run: npm run verify + - run: npm run release:windows + - name: Generate SHA-256 checksums + shell: pwsh + run: | + $files = Get-ChildItem target/release/bundle/nsis/*-setup.exe,target/release/bundle/msi/*.msi + $lines = $files | ForEach-Object { "{0} {1}" -f (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant(), $_.Name } + $lines | Set-Content target/release/bundle/SHA256SUMS.txt -Encoding ascii + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + shell: pwsh + run: | + gh release create "${{ github.ref_name }}" ` + target/release/bundle/nsis/*-setup.exe ` + target/release/bundle/msi/*.msi ` + target/release/bundle/SHA256SUMS.txt ` + --repo "${{ github.repository }}" ` + --title "ShellCanvas ${{ github.ref_name }}" ` + --generate-notes ` + --verify-tag diff --git a/BACKLOG.md b/BACKLOG.md index 56ebf8a..839821c 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -105,15 +105,15 @@ Scope is flexibility now, not implementing every protocol now. Pick the first re - [ ] **SHIP-03 — Tablet feasibility.** Tauri Android/iOS spikes covering SSH lifecycle, key import, terminal IME, external keyboard and touch window management. Gate: actual device tests; browser responsiveness is insufficient. Phone refinement follows tablet proof. - [ ] **SHIP-04 — Web gateway design.** Authenticated gateway, per-user host access, credential policy and private-network routing. Gate: threat model and deployment prototype before offering hosted access. Commercial terms remain open. - [x] **COMM-00 — Product name.** User selected ShellCanvas; app title, package/crate names, application identity and documentation updated. Workspace folder stays in its existing location. -- [ ] **COMM-01 — Community launch.** Prepare contribution templates, supported-system matrix, SDK examples and release notes. Domain/trademark checks and public release remain separate from the private repository. No paid-tier commitment yet. +- [ ] **COMM-01 — Community launch.** Maintain contribution templates, the supported-system matrix, SDK examples and release notes. Domain/trademark checks and paid-tier planning remain separate work. ## Validation record -- Permission slice: the actual SFTP adapter running as the existing nobody account on evtinsait denied private reads/downloads and forbidden create/save/rename/delete/upload, preserving original files without temporary leftovers. The same services then completed allowed editing and binary transfers. Exact disposable-file/directory cleanup and disconnect passed. Browser folder/editor refusal retained input/drafts and a later retry succeeded; all-target Clippy passed. See [permission validation](docs/permission-validation.md). +- Permission slice: the actual SFTP adapter running as an unprivileged account on an authorized Linux test host denied private reads/downloads and forbidden create/save/rename/delete/upload, preserving original files without temporary leftovers. The same services then completed allowed editing and binary transfers. Exact disposable-file/directory cleanup and disconnect passed. Browser folder/editor refusal retained input/drafts and a later retry succeeded; all-target Clippy passed. See [permission validation](docs/permission-validation.md). -- Terminal service slice: 32 Rust tests and Clippy passed, including an all-targets check of the live probe. Neutral runtime fixtures verify blocked-write cancellation, concurrent output, fixed-size consoles, byte preservation and failures. The live two-console evtinsait probe through the SSH service and native pump/registry passed independent state, different dimensions, one-console close, surviving-console input, stale/cross-session refusal and disconnect. No remote files were changed. +- Terminal service slice: 32 Rust tests and Clippy passed, including an all-targets check of the live probe. Neutral runtime fixtures verify blocked-write cancellation, concurrent output, fixed-size consoles, byte preservation and failures. A live two-console probe on an authorized Linux test host passed independent state, different dimensions, one-console close, surviving-console input, stale/cross-session refusal and disconnect. No remote files were changed. -- Provider-selection completion: 29 Rust tests and Clippy passed. Cached probes share concurrent success/failure results, enforce entry/command/output bounds and do not survive an inspection attempt. Ordered failure fallback, unknown-system identity/capability preservation and deadline tests passed. The read-only evtinsait regression passed known-host authentication, Linux inspection, SFTP locations/preview, PTY input/resize and disconnect. +- Provider-selection completion: 29 Rust tests and Clippy passed. Cached probes share concurrent success/failure results, enforce entry/command/output bounds and do not survive an inspection attempt. Ordered failure fallback, unknown-system identity/capability preservation and deadline tests passed. The read-only authorized-host regression passed known-host authentication, Linux inspection, SFTP locations/preview, PTY input/resize and disconnect. - Windows clipboard/quit walkthrough: Unicode and multiline text copied and pasted between independent editor drafts. Canceling native app quit preserved both; confirmed discard closed the process. Dusk persisted across a full restart, then the original Fjord setting was restored. No remote writes were made. Editor titlebars and dock menus now share stable instance numbering, including document titles; browser labels were checked with two editor windows. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..30a4fcd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +ShellCanvas follows semantic versioning for published SDK packages. Desktop releases use the same version where practical. The project is pre-1.0, so minor releases may include breaking changes with migration notes. + +## 0.1.0 + +Initial public preview: + +- Tauri desktop with SSH/SFTP workspaces, Files, Terminal, Editor and Host details. +- Saved hosts, host-key review, reconnect and multi-workspace behavior. +- Runtime app packages, themes and provisional public app APIs. +- Rust adapter and filesystem SDKs for independently packaged connections. +- Windows NSIS and MSI installer build path. + +See the repository documentation and release notes for current platform validation and known limitations. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5f9db7f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,9 @@ +# Code of conduct + +ShellCanvas contributors and maintainers are expected to keep project spaces respectful, constructive and safe. + +Be considerate, discuss ideas and code rather than people, accept correction, and make room for different levels of experience. Harassment, threats, discrimination, sexualized attention, deliberate disruption and publishing another person's private information are unacceptable. + +Report conduct concerns privately to the repository maintainers through GitHub. Maintainers may edit or remove contributions, comments and other participation that violates these expectations, and may temporarily or permanently restrict participation. Reports will be handled as privately as practical. + +This policy applies in project repositories, issue trackers, review discussions and public spaces where someone represents ShellCanvas. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 464a0e0..7a5fdcb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ShellCanvas is at the prototype stage. Small, focused contributions and compatibility reports will be most useful while the interfaces settle. -Use [the roadmap](ROADMAP.md) for priorities and [the backlog](BACKLOG.md) for task IDs and completion gates. Keep the backlog current with implementation and validation; do not mark an entire platform supported from a fixture or browser preview alone. +Use [the documentation map](docs/README.md), [roadmap](ROADMAP.md) and [backlog](BACKLOG.md) for priorities and completion gates. Keep the backlog current with implementation and validation; do not mark an entire platform supported from a fixture or browser preview alone. Security reports follow [the private reporting policy](SECURITY.md), not the public issue tracker. ## Development diff --git a/Cargo.lock b/Cargo.lock index 969f402..fc584ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4808,12 +4808,14 @@ dependencies = [ "anyhow", "async-trait", "keyring", + "libc", "rand 0.10.2", "reqwest", "rusqlite", "russh", "serde", "serde_json", + "sha2 0.10.9", "shellcanvas-adapter-runtime", "shellcanvas-core", "shellcanvas-services", @@ -4877,12 +4879,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 +4904,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/README.md b/README.md index 9f12720..7ac638e 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ A desktop canvas for remote devices. Built with **Tauri 2, Rust, and TypeScript/ This is an early working prototype, not a complete file manager or a hardened public release. +[User documentation](https://shellcanvas.com/docs/) · [Engineering documentation](docs/README.md) · [Security policy](SECURITY.md) · [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md) + +## Downloads + +Windows NSIS and MSI installers are attached to versioned [GitHub Releases](https://github.com/techartdev/ShellCanvas/releases). The public preview is unsigned, so Windows may show an unknown-publisher warning. Check the release's `SHA256SUMS.txt` before installing. Other native platforms remain development targets with the validation limits described below. + The architecture is evolving toward a connection-neutral desktop: SSH is the first/default adapter. [Installed native adapters](docs/adapter-packages.md) can provide files, consoles, optional text/file changes and remote settings through independently assigned connections in one workspace, alongside **SSH (built in)**. These assignments can be stored in [saved workspace profiles](docs/workspace-profiles.md), with credentials omitted and revision-checked updates. See [the composition design](docs/connections.md). Production Serial, Telnet, FTP and device API adapters are not implemented yet. ## Run @@ -97,7 +103,7 @@ Canvas variants, local wallpapers, interface scaling and toolbar/dock sizing. Theme authors can start from [Canvas Study](examples/themes/canvas-study) without an SDK or build step. -Start with [the roadmap](ROADMAP.md), [development backlog](BACKLOG.md), and [kernel/API roadmap](docs/kernel-roadmap.md). Build independent apps with the [standalone app SDK and starter](docs/app-sdk.md). See [the architecture](docs/architecture.md), [bundled app guide](docs/apps.md), [runtime app guide](docs/runtime-apps.md), [remote support matrix](docs/providers.md), [WispCrew AI integration assessment](docs/ai-integration.md), and [contribution guide](CONTRIBUTING.md). The public SDK remains provisional and is distributed as a local tarball; it has not been published to npm. Commercial packaging is intentionally undecided. +Start with [the documentation map](docs/README.md), [roadmap](ROADMAP.md), [development backlog](BACKLOG.md), and [kernel/API roadmap](docs/kernel-roadmap.md). Build independent apps with the [standalone app SDK and starter](docs/app-sdk.md). See [the architecture](docs/architecture.md), [bundled app guide](docs/apps.md), [runtime app guide](docs/runtime-apps.md), [remote support matrix](docs/providers.md), [WispCrew AI integration assessment](docs/ai-integration.md), and [contribution guide](CONTRIBUTING.md). The public SDK remains provisional while the interfaces settle. Commercial packaging is intentionally undecided. Device integrations can use the [standalone Rust adapter SDK and CLI](docs/adapter-sdk.md). The repository's [AI development skills](docs/ai-development-skills.md) guide diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b1a849d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security policy + +ShellCanvas handles remote credentials, host identity, files, terminals and third-party extension packages. Please report suspected vulnerabilities privately instead of opening a public issue. + +Use GitHub's **Report a vulnerability** form in the repository Security tab. Include the affected version, platform, prerequisites, impact and a minimal reproduction. Remove passwords, private keys, tokens, private host names and user file contents from reports. + +The maintainers will acknowledge a report, investigate it and coordinate a fix and disclosure when the issue is confirmed. There is no paid bug-bounty program unless a separate program says otherwise. + +## Supported versions + +ShellCanvas is an early prototype. Security fixes are applied to the latest release and the current default branch. Older builds are not supported. + +## Important boundaries + +- The terminal has the permissions of the authenticated remote account. +- Host keys are checked before authentication; changed and revoked keys remain blocked. +- Installed native adapters execute with the local user's operating-system permissions and require explicit trust. +- Installed runtime-app isolation currently has targeted Windows/WebView2 validation. Other platforms and hostile-code containment are not claimed. +- Browser fixtures and development previews use synthetic data and do not establish native production security. + +These boundaries describe the current design; they do not replace coordinated vulnerability reporting. 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/adapter-sdk/Cargo.toml b/crates/adapter-sdk/Cargo.toml index ab28a2f..9123d06 100644 --- a/crates/adapter-sdk/Cargo.toml +++ b/crates/adapter-sdk/Cargo.toml @@ -5,6 +5,12 @@ edition = "2021" license = "MPL-2.0" description = "Versioned process protocol and concurrent server for ShellCanvas connection adapters" readme = "README.md" +repository = "https://github.com/techartdev/ShellCanvas" +homepage = "https://shellcanvas.com/docs/adapter-sdk/quickstart.html" +documentation = "https://docs.rs/shellcanvas-adapter-sdk" +keywords = ["shellcanvas", "adapter", "remote", "device", "protocol"] +categories = ["api-bindings", "asynchronous"] +rust-version = "1.93" [dependencies] anyhow = "1" diff --git a/crates/adapter-sdk/src/package.rs b/crates/adapter-sdk/src/package.rs index 0953704..b54649e 100644 --- a/crates/adapter-sdk/src/package.rs +++ b/crates/adapter-sdk/src/package.rs @@ -4,6 +4,8 @@ use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashSet; +const MAX_PACKAGE_FILES: usize = 4096; +const MAX_PACKAGE_BYTES: u64 = 512 * 1024 * 1024; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct PackageFile { @@ -99,6 +101,9 @@ impl Manifest { if self.arguments.iter().any(|arg| arg.contains('\0')) { bail!("Invalid adapter launch argument"); } + if self.files.is_empty() || self.files.len() > MAX_PACKAGE_FILES { + bail!("Adapter packages must contain 1 to 4096 files"); + } let mut paths = HashSet::new(); let mut size = 0u64; for entry in &self.files { @@ -115,6 +120,9 @@ impl Manifest { size = size .checked_add(entry.size) .context("Adapter package size overflow")?; + if size > MAX_PACKAGE_BYTES { + bail!("Adapter package exceeds the 512 MiB payload budget"); + } } if !self .files @@ -172,6 +180,7 @@ impl Manifest { Ok(Value::Object(output)) } } + impl ConfigField { fn accepts(&self, value: &Value) -> bool { match self.kind { @@ -181,3 +190,44 @@ impl ConfigField { } } } + +#[cfg(test)] +mod package_limits { + use super::*; + + fn manifest(files: Vec) -> Manifest { + Manifest { + schema_version: 1, + id: "org.example.adapter".into(), + name: "Example".into(), + version: "1.0.0".into(), + description: String::new(), + platform: "windows-x86_64".into(), + entrypoint: "adapter.exe".into(), + arguments: vec![], + files, + configuration: vec![], + } + } + + fn file(size: u64) -> PackageFile { + PackageFile { + path: "adapter.exe".into(), + size, + sha256: "a".repeat(64), + executable: true, + } + } + + #[test] + fn bounds_review_payload_before_staging() { + assert!(manifest(vec![]).validate().is_err()); + assert!(manifest(vec![file(MAX_PACKAGE_BYTES)]).validate().is_ok()); + assert!(manifest(vec![file(MAX_PACKAGE_BYTES + 1)]) + .validate() + .is_err()); + assert!(manifest((0..=MAX_PACKAGE_FILES).map(|_| file(0)).collect()) + .validate() + .is_err()); + } +} diff --git a/crates/filesystem-sdk/Cargo.toml b/crates/filesystem-sdk/Cargo.toml new file mode 100644 index 0000000..648de76 --- /dev/null +++ b/crates/filesystem-sdk/Cargo.toml @@ -0,0 +1,19 @@ +[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" +readme = "README.md" +repository = "https://github.com/techartdev/ShellCanvas" +homepage = "https://shellcanvas.com/docs/adapter-sdk/filesystem.html" +documentation = "https://docs.rs/shellcanvas-filesystem-sdk" +keywords = ["shellcanvas", "filesystem", "remote", "mount", "sdk"] +categories = ["api-bindings", "filesystem"] +rust-version = "1.93" + +[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/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 new file mode 100644 index 0000000..a327486 --- /dev/null +++ b/crates/filesystem-sdk/src/lib.rs @@ -0,0 +1,233 @@ +// 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 bridge_control; +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<()>; + /// Non-size metadata changes may use a read handle within a writable root; + /// the remote account still needs permission. Size changes require write access. + 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..c27c19b --- /dev/null +++ b/crates/filesystem-sdk/src/wire.rs @@ -0,0 +1,504 @@ +// 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::bridge_control::{BridgeControl, BridgeDirective, BridgeEvent}; +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 = 2; +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 { + Poll, + Report { + event: BridgeEvent, + }, + 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 { + Directive(BridgeDirective), + 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 { + control: Arc, + fs: Arc, + next: u64, + files: HashMap>, + directories: HashMap, +} +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(), + 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::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())); + } + 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; + if let Err(error) = &result { + self.control.fail(error.to_string()); + } + 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..532d17e 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", "test-util"] } +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..00a849f --- /dev/null +++ b/crates/ssh-core/examples/mount_probe.rs @@ -0,0 +1,233 @@ +// 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, + }; + // Windows applies initial read-only attributes after exclusive OPEN, + // while the creating handle still has permission to write its content. + let attributes_path = MountPath::root().child("creation-attributes.txt")?; + let attributes_file = fs.open(&attributes_path, create).await?; + attributes_file.set_metadata(FsSetMetadata { + permissions: Some(0o444), ..Default::default() + }).await?; + attributes_file.write_at(0, b"initial readonly contents").await?; + attributes_file.flush().await?; + ensure!(attributes_file.read_at(0, 32).await? == b"initial readonly contents", + "Read-only creation lost the original handle's write access"); + ensure!(attributes_file.metadata().await?.permissions.unwrap_or(0) & 0o777 == 0o444, + "Initial readonly permissions were not persisted"); + attributes_file.set_metadata(FsSetMetadata { + size: Some(0), permissions: Some(0o444), ..Default::default() + }).await?; + attributes_file.write_at(0, b"replacement").await?; + attributes_file.flush().await?; + ensure!(attributes_file.read_at(0, 32).await? == b"replacement", + "Combined truncate/readonly overwrite did not preserve the handle"); + attributes_file.close().await?; + fs.remove(&attributes_path, false).await?; + println!("SFTP_CREATION_ATTRIBUTES_PASS: initial read-only and combined truncate/permissions preserve the original writable handle"); + 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" + ); + reader.set_metadata(FsSetMetadata { + permissions: Some(0o444), + ..Default::default() + }).await?; + ensure!(reader.metadata().await?.permissions.unwrap_or(0) & 0o777 == 0o444, + "Read handle could not change permissions within writable root"); + reader.set_metadata(FsSetMetadata { + permissions: Some(0o644), + ..Default::default() + }).await?; + ensure!(reader.metadata().await?.permissions.unwrap_or(0) & 0o777 == 0o644, + "Read handle could not restore owner write permission"); + ensure!(reader.set_metadata(FsSetMetadata { size: Some(0), ..Default::default() }).await.is_err(), + "Read handle permitted truncation"); + ensure!(reader.metadata().await?.size == 5, "Rejected truncation changed file size"); + let previous_access = reader.metadata().await?.accessed; + reader.set_metadata(FsSetMetadata { modified: Some(1_600_000_123), ..Default::default() }).await?; + let times = reader.metadata().await?; + ensure!(times.modified == Some(1_600_000_123) && times.accessed == previous_access, + "Single-field modification-time update changed access time"); + reader.set_metadata(FsSetMetadata { accessed: Some(1_600_000_000), ..Default::default() }).await?; + let times = reader.metadata().await?; + ensure!(times.accessed == Some(1_600_000_000) && times.modified == Some(1_600_000_123), + "Single-field access-time update changed modification time"); + ensure!(reader.set_metadata(FsSetMetadata { + modified: Some(u64::from(u32::MAX) + 1), permissions: Some(0o444), ..Default::default() + }).await.is_err(), "SFTP v3 accepted an out-of-range timestamp"); + let unchanged = reader.metadata().await?; + ensure!(unchanged.modified == times.modified && unchanged.accessed == times.accessed + && unchanged.permissions == times.permissions, "Rejected timestamp request partially changed metadata"); + println!("SFTP_TIMES_PASS: single-field updates preserve the other timestamp; out-of-range mixed update has no partial effects"); + 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?; + let readonly_file = readonly.open(&path, FsOpenOptions { + read: true, write: false, create: FsCreate::OpenExisting, truncate: false, + }).await?; + ensure!(readonly_file.set_metadata(FsSetMetadata { + permissions: Some(0o777), ..Default::default() + }).await.is_err(), "Read-only mount accepted metadata changes through a handle"); + readonly_file.close().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..a3d2148 --- /dev/null +++ b/crates/ssh-core/examples/native_bridge_probe.rs @@ -0,0 +1,464 @@ +// 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::bridge_control::{BridgeControl, BridgePhase}; +use shellcanvas_filesystem_sdk::wire::Server; +use std::{ + path::PathBuf, + process::Stdio, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::Duration, +}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt}; + +// Fault injection for this probe's dedicated SFTP channel only. SSH keepalives, +// other channels and the separate native-test control connection stay healthy. +struct StalledSftp { + stream: S, + stalled: Arc, +} +impl tokio::io::AsyncRead for StalledSftp { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + if self.stalled.load(Ordering::Acquire) { + return std::task::Poll::Pending; + } + std::pin::Pin::new(&mut self.stream).poll_read(cx, buf) + } +} +impl tokio::io::AsyncWrite for StalledSftp { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + if self.stalled.load(Ordering::Acquire) { + return std::task::Poll::Ready(Ok(buf.len())); + } + std::pin::Pin::new(&mut self.stream).poll_write(cx, buf) + } + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.stream).poll_flush(cx) + } + fn poll_shutdown( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.stream).poll_shutdown(cx) + } +} +fn quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} +fn local_command(command: &str, unprivileged: bool) -> String { + if unprivileged { + format!( + "exec /usr/sbin/runuser -u nobody -- sh -c {}", + quote(command) + ) + } else { + command.to_owned() + } +} +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 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"); + let sftp_stall = std::env::var("SHELLCANVAS_PROBE_SFTP_STALL").as_deref() == Ok("1"); + let stalled = Arc::new(AtomicBool::new(false)); + let unprivileged = std::env::var("SHELLCANVAS_PROBE_UNPRIVILEGED").as_deref() == Ok("1"); + if unprivileged { + let uid = run(&args, "/usr/sbin/runuser -u nobody -- id -u").await?; + ensure!( + uid.trim().parse::()? != 0, + "Fixture account must be unprivileged" + ); + println!("UNPRIVILEGED_LOCAL_UID: {}", uid.trim()); + } + ensure!( + [transport_loss, ssh_loss, sftp_stall] + .into_iter() + .filter(|mode| *mode) + .count() + <= 1, + "Select one connection-loss mode" + ); + 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?; + println!("NATIVE_FIXTURE: {root}"); + if unprivileged { + run( + &args, + &format!( + "chmod 711 -- {} && chown nobody -- {} && chmod 700 -- {}", + quote(&root), + quote(&target), + quote(&target) + ), + ) + .await?; + } + let browser = SshFileBrowser::new(Arc::new(SftpBrowser(service.clone())), connection.clone()); + let fs: Arc = if sftp_stall { + let channel = connection.handle.channel_open_session().await?; + channel.request_subsystem(true, "sftp").await?; + let raw = russh_sftp::client::RawSftpSession::new(StalledSftp { + stream: channel.into_stream(), + stalled: stalled.clone(), + }); + let isolated = Arc::new(SftpTextFiles::new(raw).await?); + mounted::SftpMount::new(isolated, &source, true).await? + } else { + browser.mount_root(&source, true).await? + }; + let remote = local_command( + &format!("exec {} --mount {}", quote(&args[5]), quote(&target)), + unprivileged, + ); + 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 control = Arc::new(BridgeControl::default()); + let serving = tokio::spawn(Server::with_control(fs, control.clone()).serve(reader, writer)); + let result: Result<()> = async { + run( + &args, + &format!( + "for i in $(seq 1 50); do grep -F -- {} /proc/self/mountinfo >/dev/null && exit 0; sleep .1; done; exit 1", + quote(&target) + ), + ) + .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??; + if transport_loss || ssh_loss || sftp_stall { + let script = r#"import os,sys,errno,time +f=os.open(sys.argv[1]+'/loss.bin',os.O_CREAT|os.O_EXCL|os.O_RDWR|os.O_SYNC,0o600) +os.pwrite(f,b'confirmed',0) +os.fsync(f) +print('HELD',flush=True) +sys.stdin.read() +started=time.monotonic() +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,errno.ETIMEDOUT), e + assert time.monotonic()-started < 25, 'lost write exceeded the deadline' + 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,errno.ETIMEDOUT), e + print('LINUX_LOST_CLOSE_ERROR: errno='+str(e.errno),flush=True) +"#; + let mut holder = ssh(&args, &local_command(&format!("exec timeout 70s python3 -u -c {} {}", quote(script), quote(&target)), unprivileged)) + .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. + if sftp_stall { + stalled.store(true, Ordering::Release); + println!("SFTP_STALLED: bridge pipes and other SSH channels remain connected"); + } else 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(if sftp_stall { 45 } else { 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?); + if sftp_stall { + ensure!(connection.is_connected(), "SFTP retirement disconnected the shared SSH connection"); + browser.list(Some(&source)).await?; + println!("SFTP_STALL_ISOLATION_PASS: unrelated SFTP channel remains usable after mounted-channel timeout"); + } + 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, &local_command(&format!("exec timeout 60s python3 -u -c {} {}", quote(hold), quote(&target)), unprivileged)) + .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, &local_command(&format!("mountpoint -q {}", quote(&target)), unprivileged)).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?; + if unprivileged { + run(&args, &format!("chmod 644 -- {}", quote(&script.path))).await?; + } + print!( + "{}", + run( + &args, + &local_command(&format!( + "timeout 240s python3 {} {} {}", + quote(&script.path), + quote(&target), + quote(if unprivileged { "-" } else { &source }) + ), unprivileged) + ) + .await? + ); + if unprivileged { + // Inspect backing bytes as the source owner, without making the + // remote files directly readable by the local mount account. + let verify = "import sys; from pathlib import Path; p=Path(sys.argv[1]); payload=bytes(range(256))*49; expected=payload[:4093]+b'cross-page'+payload[4103:]; assert (p/'mapped.bin').read_bytes()==expected; assert (p/'seek.bin').read_bytes()==b'replacement'; assert (p/'reused.bin').read_bytes()==b'new-object'; assert (p/'renamed'/'item-000').read_bytes()==b'changed'; print('UNPRIVILEGED_SOURCE_PASS: independent source bytes verified by source owner')"; + print!("{}",run(&args,&format!("python3 -c {} {}",quote(verify),quote(&source))).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; + // 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 python3 -c {} {}; then fusermount3 -u -- {}; fi", + quote(present), + 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 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)), + ) + .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(()) +} +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) +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/'reused.bin').write_bytes(b'old-object') +old = os.open(p/'reused.bin', os.O_RDWR) +try: + old_inode = os.fstat(old).st_ino + os.unlink(p/'reused.bin') + (p/'reused.bin').write_bytes(b'new-object') + assert os.stat(p/'reused.bin').st_ino != old_inode, 'recreated path reused a live inode' + assert os.pread(old, 30, 0) == b'old-object', 'old descriptor migrated to replacement' + os.pwrite(old, b'OLD', 0) + os.fsync(old) + assert os.pread(old, 30, 0) == b'OLD-object' + assert (p/'reused.bin').read_bytes() == b'new-object', 'old descriptor overwrote replacement' +finally: + os.close(old) +assert (p/'reused.bin').read_bytes() == b'new-object', 'old close retired replacement path' +print('LINUX_UNLINKED_HANDLE_PASS: recreated path has distinct inode; old descriptor reads/writes its original object and closes without affecting replacement', flush=True) +(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 +directory_fd = os.open(p/'directory', os.O_RDONLY | os.O_DIRECTORY) +expected_names = {f'item-{i:03}' for i in range(70)} +for _ in range(2): + os.lseek(directory_fd, 0, os.SEEK_SET) + with os.scandir(directory_fd) as entries: + assert {entry.name for entry in entries} == expected_names, 'rewind lost directory entries' +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' + os.lseek(directory_fd, 0, os.SEEK_SET) + with os.scandir(directory_fd) as entries: + assert {entry.name for entry in entries} == expected_names, 'open directory rewind failed after rename' +finally: + os.close(fd) + os.close(directory_fd) +print('LINUX_DIRECTORY_REWIND_PASS: same open directory descriptor enumerates exact pages after rewinding and renaming', flush=True) +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 +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:] +if sys.argv[2] != '-': + 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) +if sys.argv[2] != '-': + 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/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/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..091cb6c 100644 --- a/crates/ssh-core/src/lib.rs +++ b/crates/ssh-core/src/lib.rs @@ -7,10 +7,12 @@ 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; 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 new file mode 100644 index 0000000..80184e2 --- /dev/null +++ b/crates/ssh-core/src/mounted.rs @@ -0,0 +1,1026 @@ +// 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, + }, + SftpError::Timeout => FsErrorKind::TimedOut, + _ => 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 retire_channel(service: &SftpTextFiles) { + service + .channel_closed + .store(true, std::sync::atomic::Ordering::Release); + let _ = service.raw.close_session(); +} +async fn acquire_handle( + service: &SftpTextFiles, + operation: impl Future>, +) -> 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. + retire_channel(service); + } + 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, + 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 + .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, + connection, + })) + } + /// 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>, +} + +// Once an ID leaves RemoteHandle it must either receive a successful CLOSE or +// remain owned by this guard. Cancellation, a rejected CLOSE, and a missing +// runtime all retire this mount's dedicated channel to release unclaimed IDs. +struct ClosingHandle { + service: Arc, + confirmed: bool, +} +impl ClosingHandle { + fn new(service: Arc) -> Self { + Self { + service, + confirmed: false, + } + } + async fn close(mut self, id: String) -> FsResult<()> { + request(self.service.raw.close(id)).await?; + self.confirmed = true; + Ok(()) + } +} +impl Drop for ClosingHandle { + fn drop(&mut self) { + if !self.confirmed { + retire_channel(&self.service); + } + } +} +impl RemoteHandle { + async fn close(&self) -> FsResult<()> { + let mut id = self.id.write().await; + if let Some(id) = id.take() { + ClosingHandle::new(self.service.clone()).close(id).await?; + } + Ok(()) + } +} +impl Drop for RemoteHandle { + fn drop(&mut self) { + if let Some(id) = self.id.get_mut().take() { + let closing = ClosingHandle::new(self.service.clone()); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let _ = closing.close(id).await; + }); + } + } + } +} +fn closed() -> FsError { + FsError::new(FsErrorKind::Offline, "Remote file handle is closed") +} +struct RemoteFile { + handle: RemoteHandle, + read: bool, + write: bool, + metadata_writable: 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.metadata_writable)?; + if update.size.is_some() { + 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 { + 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, + "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?; + 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 metadata_writable = self.writable; + let file = tokio::spawn(async move { + let id = acquire_handle( + &service, + service.raw.open(path, flags, FileAttributes::empty()), + ) + .await?; + let file = RemoteFile { + handle: RemoteHandle { + service, + id: RwLock::new(Some(id)), + }, + read: options.read, + write: options.write, + metadata_writable, + }; + 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 = acquire_handle(&service, service.raw.opendir(path)).await?; + 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(()) + } +} + +#[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 + } + + async fn reply(stream: &mut DuplexStream, kind: u8, id: &[u8], body: &[u8]) { + stream.write_u32((5 + body.len()) as u32).await.unwrap(); + stream.write_u8(kind).await.unwrap(); + stream.write_all(id).await.unwrap(); + stream.write_all(body).await.unwrap(); + } + + #[tokio::test(start_paused = true)] + async fn cancelled_open_closes_a_late_file_or_directory_handle() { + for directory in [false, true] { + let (client, mut remote) = tokio::io::duplex(4096); + let (opened, opening) = tokio::sync::oneshot::channel(); + let (release, released) = 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 open = loop { + let request = packet(&mut remote).await; + if request[0] != 7 { + break request; + } // SSH_FXP_LSTAT + let path = std::str::from_utf8(&request[9..]).unwrap(); + let mode: u32 = if path.ends_with("/file") { + 0o100644 + } else { + 0o40755 + }; + let mut attrs = 4u32.to_be_bytes().to_vec(); // PERMISSIONS + attrs.extend_from_slice(&mode.to_be_bytes()); + reply(&mut remote, 105, &request[1..5], &attrs).await; + }; + assert_eq!(open[0], if directory { 11 } else { 3 }); + opened.send(()).unwrap(); + released.await.unwrap(); // Caller has cancelled before HANDLE arrives. + reply( + &mut remote, + 102, + &open[1..5], + &[0, 0, 0, 4, b'l', b'a', b't', b'e'], + ) + .await; + if !directory { + let stat = packet(&mut remote).await; + assert_eq!(stat[0], 8); // SSH_FXP_FSTAT + let mut attrs = 4u32.to_be_bytes().to_vec(); + attrs.extend_from_slice(&0o100644u32.to_be_bytes()); + reply(&mut remote, 105, &stat[1..5], &attrs).await; + } + let close = packet(&mut remote).await; + assert_eq!(close[0], 4, "Cancelled OPEN did not close the late handle"); + assert_eq!(&close[9..], b"late"); + reply(&mut remote, 101, &close[1..5], &[0; 12]).await; + }); + let service = Arc::new(SftpTextFiles::from_stream(client).await.unwrap()); + let mount = Arc::new(SftpMount { + service, + root: "/fixture".into(), + writable: false, + connection: None, + }); + let opening_task = tokio::spawn(async move { + if directory { + let _handle = mount.open_directory(&MountPath::root()).await.unwrap(); + } else { + let _handle = mount + .open( + &MountPath::root().child("file").unwrap(), + FsOpenOptions { + read: true, + write: false, + create: FsCreate::OpenExisting, + truncate: false, + }, + ) + .await + .unwrap(); + } + }); + opening.await.unwrap(); + opening_task.abort(); + assert!(opening_task.await.unwrap_err().is_cancelled()); + release.send(()).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(1), peer) + .await + .unwrap() + .unwrap(); + } + } + + #[tokio::test] + async fn cancelled_root_preparation_closes_only_its_dedicated_sftp_channel() { + let (other, close_other, other_peer) = live_channel().await; + for after_realpath in [false, true] { + let (client, mut remote) = tokio::io::duplex(4096); + let (entered, waiting) = 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 realpath = packet(&mut remote).await; + assert_eq!(realpath[0], 16); // SSH_FXP_REALPATH + if after_realpath { + let mut name = 1u32.to_be_bytes().to_vec(); + name.extend_from_slice(&8u32.to_be_bytes()); + name.extend_from_slice(b"/fixture"); + name.extend_from_slice(&[0; 8]); // empty longname and attrs + reply(&mut remote, 104, &realpath[1..5], &name).await; + assert_eq!(packet(&mut remote).await[0], 7); // SSH_FXP_LSTAT + } + entered.send(()).unwrap(); + let mut byte = [0]; + assert_eq!( + remote.read(&mut byte).await.unwrap(), + 0, + "Canceled preparation did not close its SFTP channel" + ); + }); + let service = Arc::new(SftpTextFiles::from_stream(client).await.unwrap()); + let preparation = + tokio::spawn(async move { SftpMount::new(service, "/fixture", false).await }); + tokio::time::timeout(std::time::Duration::from_secs(2), waiting) + .await + .unwrap() + .unwrap(); + preparation.abort(); + assert!(preparation.await.err().unwrap().is_cancelled()); + tokio::time::timeout(std::time::Duration::from_secs(2), peer) + .await + .unwrap() + .unwrap(); + assert!( + !other + .channel_closed + .load(std::sync::atomic::Ordering::Acquire), + "Cancellation retired a different SFTP channel" + ); + } + close_other.send(()).unwrap(); + other_peer.await.unwrap(); + } + + 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 + // 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); + 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") + .unwrap(); + drop(service); + } + } + + #[tokio::test(start_paused = true)] + async fn cancelled_or_timed_out_close_retires_its_channel_without_losing_the_handle() { + for cancel in [true, false] { + let (client, mut remote) = tokio::io::duplex(4096); + let (received, receiving) = 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 close = packet(&mut remote).await; + assert_eq!(close[0], 4); // SSH_FXP_CLOSE + received.send(()).unwrap(); + // The server has not acknowledged or released the handle. + let mut byte = [0]; + assert_eq!( + remote.read(&mut byte).await.unwrap(), + 0, + "Unconfirmed CLOSE left the dedicated channel open" + ); + }); + let service = Arc::new(SftpTextFiles::from_stream(client).await.unwrap()); + let handle = Arc::new(RemoteHandle { + service: service.clone(), + id: RwLock::new(Some("fixture-handle".into())), + }); + let closing = { + let handle = handle.clone(); + tokio::spawn(async move { handle.close().await }) + }; + receiving.await.unwrap(); + if cancel { + closing.abort(); + assert!(closing.await.unwrap_err().is_cancelled()); + } else { + assert_eq!( + closing.await.unwrap().unwrap_err().kind, + FsErrorKind::TimedOut + ); + } + assert!( + service + .channel_closed + .load(std::sync::atomic::Ordering::Acquire), + "Unconfirmed CLOSE left a healthy mount with an untracked remote handle" + ); + tokio::time::timeout(std::time::Duration::from_secs(1), peer) + .await + .unwrap() + .unwrap(); + assert!(handle.id.read().await.is_none()); + } + } + + #[tokio::test(start_paused = true)] + async fn close_acknowledgement_controls_channel_retirement() { + for status in [0u32, 4u32] { + let (client, mut remote) = tokio::io::duplex(4096); + 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 close = packet(&mut remote).await; + assert_eq!(close[0], 4); + let mut body = status.to_be_bytes().to_vec(); + body.extend_from_slice(&[0; 8]); + reply(&mut remote, 101, &close[1..5], &body).await; + let mut byte = [0]; + assert_eq!(remote.read(&mut byte).await.unwrap(), 0); + }); + let service = Arc::new(SftpTextFiles::from_stream(client).await.unwrap()); + let handle = RemoteHandle { + service: service.clone(), + id: RwLock::new(Some("fixture".into())), + }; + let result = handle.close().await; + assert_eq!(result.is_ok(), status == 0); + assert_eq!( + service + .channel_closed + .load(std::sync::atomic::Ordering::Acquire), + status != 0 + ); + // Acknowledged CLOSE keeps the channel usable until its owner closes it. + retire_channel(&service); + tokio::time::timeout(std::time::Duration::from_secs(1), peer) + .await + .unwrap() + .unwrap(); + } + } +} diff --git a/crates/ssh-core/src/provider.rs b/crates/ssh-core/src/provider.rs index 0961ec4..b6fec33 100644 --- a/crates/ssh-core/src/provider.rs +++ b/crates/ssh-core/src/provider.rs @@ -400,7 +400,9 @@ mod detection_tests { let bytes = base64::engine::general_purpose::STANDARD.decode(encoded)?; let script = String::from_utf16( &bytes - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|b| u16::from_le_bytes([b[0], b[1]])) .collect::>(), )?; 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/crates/ssh-core/src/volumes.rs b/crates/ssh-core/src/volumes.rs index f87cdbb..7becb79 100644 --- a/crates/ssh-core/src/volumes.rs +++ b/crates/ssh-core/src/volumes.rs @@ -131,6 +131,25 @@ 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::connected( + Arc::new(service), + path, + writable, + self.connection.clone(), + ) + .await?) + } async fn volumes(&self) -> Result { self.inventory().await } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..4e539d1 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,25 @@ +# Documentation map + +The public user documentation lives at [shellcanvas.com/docs](https://shellcanvas.com/docs/). The files in this directory are engineering references, contracts, verification records and implementation history for contributors. + +## Start here + +- [Architecture](architecture.md) and [connection composition](connections.md) +- [Local verification and release checklist](verification.md) +- [Contributing](../CONTRIBUTING.md), [security policy](../SECURITY.md), [roadmap](../ROADMAP.md) and [changelog](../CHANGELOG.md) + +## User-facing implementation references + +- Connections and trust: [host profiles](host-profiles.md), [SSH host identity](ssh-host-trust.md), [recovery](connection-recovery.md) and [workspace profiles](workspace-profiles.md) +- Desktop work: [Files](file-actions.md), [transfers](transfers.md), [Editor](text-editor.md), [Terminal](terminal-services.md), [settings](settings.md) and [themes](themes.md) +- Extensions: [runtime apps](runtime-apps.md), [repository installation](repository-apps.md), [app SDK](app-sdk.md), [adapter SDK](adapter-sdk.md) and [custom services](custom-services.md) + +## Contracts and API design + +- [System API](system-api.md), [app services](app-services.md), [service availability](service-availability.md), [client platform compatibility](client-platform-compatibility.md) and [window management](window-management.md) +- [Filesystem contract](filesystem-contract.md), [workspace bindings](workspace-bindings.md), [adapter process protocol](adapter-process.md) and [providers](providers.md) +- Runtime APIs: [files](app-files.md), [transfers](app-transfers.md), [console](app-console.md), [storage](app-storage.md), [events](app-events.md), [clipboard](app-clipboard.md), [network](app-network.md), [windows](app-window.md) and [host settings](app-host-settings.md) + +## Verification records and history + +Files with `validation`, `completion`, `progress`, `history`, `acceptance`, `goal`, `roadmap`, `backlog`, `workflows` or platform names in their titles are retained engineering evidence. They identify exactly what was tested at a checkpoint; they are not promises that every platform, host or future feature is supported. New evidence should state its date, environment, scope and limitations without including credentials, private host identifiers or user data. diff --git a/docs/adapter-packages.md b/docs/adapter-packages.md index fa5e2de..6d6ca2f 100644 --- a/docs/adapter-packages.md +++ b/docs/adapter-packages.md @@ -35,7 +35,7 @@ Choose `.local/practice-adapter-package/adapter.json` in the installer. The outp Installable manifests use schema version 1 and contain `id`, `name`, semantic `version`, `description`, exact `platform` (for example `windows-x86_64`), `entrypoint`, optional `arguments`, declared `files`, and optional `configuration`. Each file specifies a relative `path`, byte `size`, lowercase SHA-256 `sha256`, and optional `executable` flag. The entrypoint must be declared executable. Configuration fields contain `id`, `label`, `kind` (`text`, `password`, `number`, or `boolean`), optional `required`, and an optional typed `default`. Password defaults are forbidden. Unknown configuration keys and invalid types are rejected before launch. -The installer rejects links, escaping paths, case aliases and reserved Windows names. Metadata has a 1 MiB parsing bound. Asset copying and hashing use 64 KiB chunks; there is no arbitrary total payload or file-tree entry limit. Review captures a staged snapshot, so changing the original folder cannot change the package being approved. Review requests are window-owned, cancelable, single-use and expire after ten minutes. +The installer rejects links, escaping paths, case aliases and reserved Windows names. Metadata has a 1 MiB parsing bound. A package may contain at most 4096 declared files and 512 MiB of payload. Asset copying and hashing use 64 KiB chunks. Review captures a staged snapshot, so changing the original folder cannot change the package being approved. Review requests are window-owned, cancelable, single-use and expire after ten minutes. The catalog lives under Tauri's local application-data directory in `adapters/`. Atomic catalog writes and cross-process locks enforce revision-checked install/update/enable/remove decisions. Each package generation has its own directory. Acquisition verifies its declared assets again and holds an OS file lease through initialization, process execution and confirmed cleanup. Updating, disabling or removing a package does not replace a running connection's assets. Updates preserve disabled state. Collection removes only unreferenced, unleased generations. Versioned review staging now holds an OS lease from creation through copying and review. Catalog listing, installation and removal attempt recovery of abandoned staging; active reviews in other app processes are preserved. Installation releases the staging lease under the catalog lock before renaming on Windows. Cleanup checks canonical containment and refuses links/reparse points. Unknown, legacy and future-format staging directories are preserved because they do not establish this lease contract; no age or PID guess is used. Cleanup failures leave data for a later attempt and do not hide installed adapters. diff --git a/docs/app-network.md b/docs/app-network.md index ed1087b..b23838a 100644 --- a/docs/app-network.md +++ b/docs/app-network.md @@ -49,8 +49,10 @@ try { The application owns protocol parsing, error handling and retry decisions. Slots contain 1–64 ASCII letters, digits, underscores or hyphens. Profiles belong -to the app identity, shared by its windows/versions, not a host session. The -desktop supplies that identity; a caller cannot name another app. Changing an +to an unpredictable installation principal, shared by its windows and updates, +not a host session. A removal retires that principal, so a later package reusing +the same app ID cannot inherit its saved connection. The desktop supplies the +principal; a caller cannot name another app. Changing an endpoint requires a newly entered key rather than reusing the former endpoint's key. The native host adds the bearer header. HTTP redirects are not followed. diff --git a/docs/app-storage.md b/docs/app-storage.md index b871c80..3300925 100644 --- a/docs/app-storage.md +++ b/docs/app-storage.md @@ -2,7 +2,7 @@ Runtime apps can declare `system.storage` to use `client.storage` and `client.settings`. Installation reviews label this permission **This app’s local data and settings**. The host binds both services to the installed app identity; requests cannot select a different app, host, database or directory. Data and settings are separate namespaces. -Values live in the desktop's local IndexedDB database, separate from host profiles, credentials and installed packages. They survive connection changes and app updates. All windows and versions with the same app ID share their committed values. Removing a package preserves its data; a future reinstall of that identity can access it after storage permission is approved. Package identity is not publisher authentication, so review updates/reinstalls as code that can access that app's existing data. +Values live in the desktop's local IndexedDB database, separate from host profiles, credentials and installed packages. They survive connection changes and app updates. All windows and versions in one installation lineage share their committed values. Removing a package retires that lineage: its local records may remain until storage cleanup, but a later package that reuses the same app ID receives a new principal and cannot access them. Package identity is not publisher authentication, so review updates as code that can access that installation's existing data. ## API @@ -28,7 +28,7 @@ do { } while (after); ``` -Both namespaces expose `get`, `put`, `remove` and `list`. `get` returns `{ value, revision }` or `null`. Values are JSON, including JSON `null`; missing data is distinguished by the outer result. An `AbortSignal` is the optional final argument to each method. +Both namespaces expose `get`, `put`, `remove` and `list`. `get` returns `{ value, revision }` or `null`. Values are JSON, including JSON `null`; missing data is distinguished by the outer result. Each namespace is limited to 256 keys and 16 Mi UTF-16 units of serialized values. An `AbortSignal` is the optional final argument to each method. `put` and `remove` require a revision. `null` means the key must be absent; a string must match the last read revision. The host checks that precondition and performs the change in one read/write transaction, including when separate desktop database connections write concurrently. A mismatch returns `RpcError` with code `busy`. Read the new value and ask the user to reconcile meaningful changes; do not automatically repeat a stale write with a fresh revision. A deleted/recreated value receives a new revision, so an old writer cannot overwrite it. @@ -46,7 +46,7 @@ The implementation uses [IndexedDB's transaction and key-order model](https://ww ## Evidence and development -`npm test -- src/extensions/app-storage.test.ts` checks host-owned identity binding, namespace separation, argument validation, permissions and channel-close cancellation. The desktop integration fixture exercises the public client through its opaque frame, plus real IndexedDB connections for concurrent writes, stale deletion, canceled writes and Unicode key pagination. It also remembers a note in one app version, restores it in another and checks that package removal leaves it available before cleaning up the fixture data. These are synthetic local records, with no real remote writes. +`npm test -- src/extensions/app-storage.test.ts` checks host-owned identity binding, namespace separation, cumulative quota decisions, argument validation, permissions and channel-close cancellation. The desktop integration fixture exercises the public client through its opaque frame, plus real IndexedDB connections for concurrent writes, stale deletion, canceled writes and Unicode key pagination. It also remembers a note in one app version and restores it in an update from the same installation lineage before cleaning up the fixture data. These are synthetic local records, with no real remote writes. Build Field Notes with `node scripts/pack-app.mjs examples/dialog-app` and install its package through the desktop's Apps manager. Its **Remember locally** and **Restore local note** actions use only the public client. The older dialog-only workbench does not supply storage and disables those two actions. Desktop preview and native WebViews have separate origins; test fixtures also use separate database names. diff --git a/docs/assistant-goal.md b/docs/assistant-goal.md index 7ed1e28..668dff4 100644 --- a/docs/assistant-goal.md +++ b/docs/assistant-goal.md @@ -1,98 +1,62 @@ -# Standalone assistant and repository installation - -Completed Windows product proof, 2026-09-10, following the closed base API -milestone. The independently installed assistant was verified with real model -calls and host operations, as well as deterministic protocol and broker tests. - -## Decisions - -- The user selected an OpenAI-compatible endpoint, API key and model. -- Live tests used the official OpenAI Responses endpoint with the user's selected - `gpt-5.6-terra`. The user entered the API key in the desktop connection dialog. - Responses was added after Chat Completions rejected reasoning with tool use. - Both protocols remain supported; the model remains user-configurable. -- The assistant may have a public GitHub repository (explicitly approved on - 2026-09-10). The user purchased shellcanvas.com and pointed it at evtinsait. - A documentation website and making the core app repository public are later - steps; keep the future website repository private. -- Assistant UI, history and agent/tool-loop behavior belong in an independent app - project consuming the packaged SDK, with no imports from desktop internals. -- The desktop supplies reviewed repository installation and necessary generic - permission/credential/network primitives. App code does not get unrestricted - native IPC. Model secrets are not manifest fields or conversation storage. -- A root repository descriptor points to a prebuilt self-contained package and - its SHA-256. Fetch repository files directly; no app server, npm install scripts - or GitHub REST/GraphQL dependency is required for installation. Source builds - remain a developer operation. Review the exact fetched package before executing. -- Preserve the modern desktop appearance. Missing file/console/device services - disable the corresponding tools, not the whole assistant. Attachments and - clipboard are real user workflows, not inert buttons. - -## Completion checklist - -- [x] Repository descriptor/schema and authoring command; malformed manifests, - unsafe paths, mismatched identities/digests and interrupted downloads rejected. -- [x] Apps UI accepts a GitHub repository/ref, shows source/version/permissions, - installs the reviewed bytes, and supports reviewed updates with retained windows. -- [x] Independently built assistant repository/package installed through that UI - without rebuilding the desktop for each app change. -- [x] Polished messaging interface: streaming, cancel, errors/retry, model setup, - persistent conversation history, draft/close behavior and host identity. -- [x] Actual OpenAI-compatible model round trip and tool loop, including iteration - limits, cancellation, partial failures and structured tool results. -- [x] Useful host tools for discovery/files and command-capable devices, with - concrete mutation review, original binding ownership and honest partial support. -- [x] Attachments from file selection and clipboard, image/text support, previews, - removal, understandable limits and deliberate context submission to the model. -- [x] Endpoint/key handling, denied permissions, unavailable capabilities, host - switch/reconnect, failed tools and secrets/history separation verified. -- [x] App development, repository packaging and assistant operating documentation - and AI skills tested against a fresh independent checkout. -- [x] Native installed-window visual/workflow checks, SDK regression checks and - final requirement-by-requirement audit, with real-model evidence distinguished - from deterministic fixtures. Restore the normal desktop build after probes. - -Do not mark the goal complete while any explicit requirement above lacks evidence. -The existing Mac installed-app isolation gate remains separate; select and state -the supported client platform for the first installed assistant proof. - -## Evidence (2026-09-10) - -- Public independent app: `techartdev/ShellCanvas-Assistant`, version 0.1.2, - source `44c61c1`. Fresh clone: `npm ci`, all 11 tests, build and repository - packaging passed with a clean resulting worktree. No desktop-source imports. -- Native Windows Apps UI installed 0.1.0, reviewed 0.1.1 and 0.1.2 updates, and - retained an existing 0.1.0 window until closed. Changed permissions affected - newly opened windows. Verified denied network before opening the credential - dialog and explicit session-only history without storage grants; restored all - approved grants afterward. -- Real Terra Responses rounds: workspace discovery in local mode, text/image - attachments (fixture word and color correctly identified), remote file - selection, discovery/list/read/reviewed edit and reviewed console exchange. - Independent SSH readback verified edited bytes and absence of declined/canceled - files. Only generated temporary host fixtures were modified. -- Native clipboard text/image previews and persisted draft attachments passed. - Conversation copy/reload passed. Compact 700 x 620 and normal desktop layouts - were inspected. Stop during approval left no pending review and the next - message succeeded. Manual disconnect is busy-guarded; after Stop, reconnect - preserved chat and required accepting the new binding. Mid-approval binding - changes and stale revisions are deterministic tool tests, not a simulated - claim of live network failure. -- Core frontend: 271 tests / 48 files. SDK packaging/schema: four tests. - `npm run verify:sdk` consumed a packed SDK in fresh outside projects. - `cargo test --workspace --lib` and the production frontend build passed. - Native HTTP tests use generated fixture credentials, not the user's key. -- Strict all-target Clippy and Rust formatting passed. The final embedded-assets - Windows debug build succeeded. After restart, version 0.1.2, the selected Terra - model, the saved endpoint/key-presence metadata and an earlier image conversation - were restored. Reopened the normal desktop without the temporary CDP flag. -- Generated remote fixtures were removed after independent byte/absence checks. - -## Where this milestone stops - -The first installed-app target is Windows. Other desktop/mobile isolation, -signed installers, provider-specific OAuth, automatic context compaction, -unattended background work, multi-host agent runs and WispCrew runtime reuse are -follow-ups, not hidden prerequisites. The assistant uses optional services and -does not add device-specific execution shortcuts. See the post-goal backlog for -future work; the original base API milestone remains closed. +# Canvas Assistant integration + +Canvas Assistant is an independently built ShellCanvas app. It exercises the +public app SDK, repository installer, permission broker, model connection +service, local history, attachments, and reviewed remote-host tools without +importing desktop internals. + +## Architecture + +- The assistant UI, conversation history, and tool loop live in the separate + [ShellCanvas-Assistant](https://github.com/techartdev/ShellCanvas-Assistant) + project. +- Users configure an OpenAI-compatible endpoint, credential, and model through + the desktop connection dialog. The native connection service keeps credentials + outside package fields and conversation storage. +- The app supports OpenAI Responses and compatible Chat Completions transports. +- A repository descriptor identifies a prebuilt package and its SHA-256 digest. + Installation downloads and reviews those bytes directly; it does not clone the + repository, install dependencies, or execute build scripts. +- File and console tools depend on explicit grants and the active workspace + binding. Mutations require a concrete review before dispatch. +- Missing file, console, or device services disable the affected tools while the + rest of the assistant remains available. + +## Verified scope + +The Windows integration proof covered: + +- repository installation and reviewed updates across package versions; +- retained running windows and grant snapshots during an update; +- streaming responses, cancellation, retry, iteration limits, and partial tool + failures; +- local conversation history, drafts, text and image attachments, clipboard + previews, and deliberate context submission; +- workspace discovery, remote file selection, bounded reads, reviewed writes, + and reviewed console input; +- denied permissions, unavailable services, reconnect and binding changes, stale + revisions, and separation between credentials and conversation history; +- compact and normal window layouts plus dirty and busy close guards. + +The source package has its own tests and build. ShellCanvas also tests the SDK +schema and packer, consumes a packed SDK from a fresh project, and runs native +HTTP tests with generated fixture credentials. + +## Security boundaries + +Installed assistant code runs through the same isolated frame and capability +broker as every ShellCanvas app. It does not receive unrestricted native IPC, +local filesystem access, or a generic command executor. Repository hashes bind a +review to exact bytes, but they do not authenticate a publisher; users still +review the repository identity, version, digest, and grants. + +Model credentials stay in process memory or the platform credential store. The +app receives only public connection metadata and can send requests only to the +exact configured endpoint through the bounded network broker. + +## Current limits + +The installed-app proof currently targets Windows. Native app isolation on macOS +and Linux, provider OAuth, automatic context compaction, unattended background +work, and multi-host agent runs require separate validation before support is +claimed. diff --git a/docs/client-platform-compatibility.md b/docs/client-platform-compatibility.md new file mode 100644 index 0000000..6cc1a8d --- /dev/null +++ b/docs/client-platform-compatibility.md @@ -0,0 +1,35 @@ +# Client platform compatibility + +An app runs inside the **ShellCanvas client**. The connected host's OS and services are separate: an Android client connected to a Linux server is still Android. + +Runtime app source manifests (`shellcanvas.json`) and built packages accept an optional `clientPlatforms` list: + +```json +"clientPlatforms": ["windows", "macos", "linux"] +``` + +Supported identifiers are `windows`, `macos`, `linux`, `android`, `ios`, and `web`. These are client targets, not a claim that ShellCanvas currently ships on all of them. A browser preview is `web`, even on a Windows computer. Native clients report their Rust compile target through the parent application's IPC; remote labels and browser user-agent guesses are not used. + +Omission preserves existing packages and means **no platform restriction declared**, not tested compatibility everywhere. Explicit lists must be nonempty, contain known targets, and contain no duplicates. The package builder preserves the list; it is covered by the reviewed artifact fingerprint. + +The app manager shows incompatibility and refuses installation or updates that cannot run on this client. Already installed packages remain listed and removable, but cannot be enabled or launched on an incompatible client. Launch revalidates authoritative catalog storage, so restored catalogs and stale launchers cannot bypass the check. Unsupported updates leave the installed version intact. This is a compatibility rule, not a replacement for permissions or runtime isolation. + +Apps inspect the client independently of the remote workspace: + +```ts +const environment = await canvas.environment.get(); +const platform = environment.client?.platform; // absent on older ShellCanvas +const remoteHost = environment.host; // remote display metadata +const services = await canvas.services.list(); // availability and permissions +``` + +Use service availability and operation discovery for optional features; platform matching does not guarantee a driver, filesystem operation, or permission. `environment.client` stays tied to the client when switching remote workspaces. `unknown` is reported if a native target is not recognized and does not satisfy an explicit platform list. + +Drive Bridge is currently a separately installed native helper, not a runtime UI package. Its settings and native install/attach entry points independently restrict it to Windows, Linux, and macOS. Driver checks remain separate. The core filesystem provider contract does not acquire a driver dependency. + +## Deferred + +- Android/iOS builds and native app-frame isolation verification. +- Minimum OS/WebView versions and architecture-specific native artifacts (runtime UI packages are JavaScript/CSS; executable connection adapters already have their own platform contract). +- Optional client-feature requirements in manifests if service discovery proves insufficient. Avoid a second overlapping permission system. +- Package publication/versioning for this additive SDK change follows the release track; existing clients reject manifests with unknown fields, so packages using this field need an updated ShellCanvas client. diff --git a/docs/connection-lifecycle.md b/docs/connection-lifecycle.md index 1aa2430..b756358 100644 --- a/docs/connection-lifecycle.md +++ b/docs/connection-lifecycle.md @@ -18,6 +18,6 @@ The subsequent [workspace binding layer](workspace-bindings.md) implements expli Four native resource tests cover shared callers, canceled waiters, independent connections, cached teardown failures, fresh reconnect identity, adapter panic and bounded stalled teardown. Existing session-registry tests continue to cover terminal ownership and cleanup. The live `terminal_probe` now exercises the production connection resource around its two SSH consoles, verifies local health and distinct identity, and calls disconnect twice after terminal cleanup. -The 2026-09-08 validation passed all 46 Rust tests, 65 frontend tests, all-target Clippy and the standard Windows debug build. The authorized evtinsait probe passed two independent consoles, PTY resize, surviving-console I/O after one closes, stale/cross-session refusal, neutral lifecycle health and repeated disconnect. It changed no remote files. +The 2026-09-08 validation passed all 46 Rust tests, 65 frontend tests, all-target Clippy and the standard Windows debug build. An authorized Linux-host probe passed two independent consoles, PTY resize, surviving-console I/O after one closes, stale/cross-session refusal, neutral lifecycle health and repeated disconnect. It changed no remote files. No visual change or new protocol support is claimed by this slice. diff --git a/docs/connection-recovery.md b/docs/connection-recovery.md index 73c7b11..8f35066 100644 --- a/docs/connection-recovery.md +++ b/docs/connection-recovery.md @@ -31,6 +31,6 @@ The connection dialog offers Cancel connection while connecting. Escape or closi - Fresh-page browser check: disconnected new-window/launcher actions were disabled, existing Files and Terminal windows remained accessible, cached listing/output stayed visible, and terminal refitting did not replace the disconnected reason with a resize error. Terminal callbacks now check current connection state, suppress stale errors and close late-opened handles. - Two active browser workspaces remained isolated: deliberately disconnecting session 103 left session 104's original shell accepting further input, with no new shell opening in the survivor. - The workspace-state regression now verifies disconnected capabilities in addition to retained desktop/draft state. All 70 frontend tests and the standard Windows debug build passed; no Rust code changed in this slice. -- The Windows native app connected to authorized evtinsait, opened an empty editor and completed Disconnect with all three windows retained and remote actions disabled. The app then exited normally. No remote files were changed. Native text entry was not verified: accessibility reported only the root document as focused, and `set_value` failed with `Requested property was not in the CacheRequest (0x80070057)`. Native dirty-draft reconnect remains a follow-up; browser evidence does not substitute for it. +- The Windows native app connected to an authorized Linux test host, opened an empty editor and completed Disconnect with all three windows retained and remote actions disabled. The app then exited normally. No remote files were changed. Native text entry was not verified: accessibility reported only the root document as focused, and `set_value` failed with `Requested property was not in the CacheRequest (0x80070057)`. Native dirty-draft reconnect remains a follow-up; browser evidence does not substitute for it. This separates current whole-workspace recovery from future independent-connection reconnect. Service-specific replacement, configuration and generation contracts remain in BASE-09. diff --git a/docs/file-actions.md b/docs/file-actions.md index 881e7dc..a7efc30 100644 --- a/docs/file-actions.md +++ b/docs/file-actions.md @@ -54,4 +54,4 @@ The browser workspace fixture passed new-folder/duplicate-name feedback, F2 rena The move slice passed 38 frontend tests, 32 Rust tests, all-target Clippy and browser checks for browsing/review, collision refusal, permission denial/retry, refresh in two Files windows, opaque locations, invalid addresses and Escape cancellation. The dialog was visually checked at 1280×720. File-location tests cover synthetic Unix, drive and opaque models; these do not establish production support for other systems. -`cargo run -p shellcanvas-core --example move_probe -- HOST USER KEY_PATH` passed on evtinsait inside a new `/tmp/shellcanvas-move-UUID` directory: collision/data preservation, stale/same/missing/invalid destination refusal, Unicode names, file content/basic metadata, nonempty folders, symlinks, self/child/alias refusal, exact cleanup and disconnect. The extended `permission_probe` passed real unprivileged source/destination refusal and later permitted moves with cleanup. Cross-filesystem refusal, physical connection loss during move and a native GUI move walkthrough remain integration checks. +`cargo run -p shellcanvas-core --example move_probe -- HOST USER KEY_PATH` passed on an authorized Linux test host inside a new `/tmp/shellcanvas-move-UUID` directory: collision/data preservation, stale/same/missing/invalid destination refusal, Unicode names, file content/basic metadata, nonempty folders, symlinks, self/child/alias refusal, exact cleanup and disconnect. The extended `permission_probe` passed real unprivileged source/destination refusal and later permitted moves with cleanup. Cross-filesystem refusal, physical connection loss during move and a native GUI move walkthrough remain integration checks. diff --git a/docs/filesystem-integration-progress.md b/docs/filesystem-integration-progress.md new file mode 100644 index 0000000..fc8069c --- /dev/null +++ b/docs/filesystem-integration-progress.md @@ -0,0 +1,491 @@ +# Filesystem integration implementation checkpoint + +Updated 2026-09-11. 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 + +- Unconfirmed SFTP CLOSE now retains cleanup ownership in a guard until the + server acknowledges success. Caller cancellation, rejection, timeout, or a + dropped cleanup task retires the mount's dedicated channel, releasing remote + handles instead of leaving the mount healthy with an untracked ID. The + cancellation regression failed before this fix and now passes. Protocol + fixtures additionally verify successful CLOSE keeps its channel available, + and cancelled file/directory OPEN closes a late returned handle. All 37 core + unit tests and core lint checks pass; no SDK or wire changes were required. +- Native Linux 6.8 FUSE acceptance as UID 65534 now covers a deliberately stalled + dedicated SFTP stream while SSH and bridge IPC stay healthy. The local + synchronous write reports ETIMEDOUT within the asserted 25-second deadline; + close reports failure, the helper exits with failure, and the mount is removed + before recovery. Independent backing-file inspection confirms prior bytes + remain intact, and the other SFTP channel still lists the source afterwards. + This uses bridge `0bf4b9a` from CI `34528217955`, SHA-256 + `1e605de4345c8b13b20c033d52cb34d0a7e5ad17d4d90b21e85fd57242aea1f2`. + Fixture `194e224f-5e6e-4ea3-93e5-b7220a22054e` and staged binary directory removal + were independently verified. `SHELLCANVAS_PROBE_SFTP_STALL=1` injects dropped + SFTP traffic only in the opt-in probe; it does not change host networking. + Full TCP-network blackholes and remote disk-full behavior remain separate gates. +- Windows creation/overwrite read-only projection passes at bridge `0bf4b9a`, + CI `34528217955` (native suite 28.95 seconds). The original creating/overwriting + handle can finish writing; later writes are blocked by the read-only flag. + CopyFileW of a read-only source preserves its flag and exact backing bytes. + Unsupported hidden creation leaves no entry, and unsupported overwrite does + not truncate the destination. The overwrite test uses `NtCreateFile` with + `FILE_OVERWRITE`, verified against a local Windows baseline; the ordinary Rust + reopen/truncate path does not forward supplied attributes to this callback. + Archive flags supplied during create/overwrite are advisory and not persisted; + other unsupported DOS flags are rejected before mutation. A post-create + metadata failure returns an error and warns about the possibly created entry, + rather than deleting a path another remote actor might have replaced. + All platform builds/tests, local Windows lint checks and prior native lifecycle + checks pass. No new required provider methods or protocol fields were added. +- Ordinary Windows `CopyFileW` acceptance passes at bridge `f6e3ee8`, CI + `34526423658` (native suite 29.44 seconds): both copy directions, exact backing + bytes, modification-time preservation, exclusive creation and overwrite. + CopyFileW bundles metadata-change time with modification time and ignores a + rejected metadata update. The bridge therefore preserves supported access/ + modification times in that combination, omits unavailable change time, and + warns the parent once per attachment. Change-time-only, creation-time and + pre-epoch requests still fail. This supersedes the earlier blanket rejection + of all mixed change-time requests below; it does not add new timestamp fields. +- The core SFTP probe now separately verifies initial read-only permissions and + combined truncation/permissions while retaining the original writable handle. + Live acceptance on the authorized Linux test host passed; removal of disposable fixture + `e6cbf9a3-e45a-4e57-b3d5-2a83cb526056` was independently verified. This is provider + evidence, separate from native Windows and desktop-created mapping acceptance. +- Windows timestamp handling is verified at bridge `3f66501`, CI run + `34524595837` (native suite 29.14 seconds). Missing creation/change timestamps + remain unavailable instead of copying modification time; missing/out-of-range + provider timestamps are not fabricated. Access/modification updates use the + contract's whole-second precision. Unsupported creation/change-only updates and + pre-Unix-epoch dates fail before other metadata is changed. Native WinFsp + verifies round-trip access/modification times and unchanged backing timestamps + and permissions after rejected mixed requests; prior native checks pass. + Linux/macOS builds/tests and Windows clippy pass. The live core SFTP probe also + passes single-field time updates preserving the other timestamp and rejection + of a date beyond SFTP v3's range with no partial permission change. Disposable + fixture `6862dc70-0b9b-482e-b09d-40c131de09b2` removal was independently verified. + This does not add creation/change-time storage to providers lacking it. +- Windows existing-file read-only attributes now project through remote Unix + permission bits at bridge `447b1c3`. Set removes write bits; clear restores owner + write only. `SetBasicInfo` rejects unsupported DOS flags before mutating other + metadata. It also rejects unsupported directory/special-mode transitions. + Attribute-only opens request a remote read handle instead of data-write access. + The core permits non-size metadata changes on read handles within writable + roots; read-only roots and read-handle truncation remain rejected. No new + required provider methods or protocol fields were added. + Core 34 tests and both core/bridge clippy pass. Live SFTP `mount_probe` passes + permission set/clear via a read handle, rejected truncation and read-only-root + enforcement; fixture `aa48fc99-234b-412a-80e6-5b984c1cbcb6` removal was independently + confirmed. CI `34523633220` passes all platforms and native WinFsp acceptance + (37.15 seconds): read-only set/clear reaches backing metadata, new writes and + deletion are blocked, and combined hidden/read-only rejection has no partial + effects. This is separate bridge/provider evidence, not a desktop SFTP mapping. + Creation/overwrite attributes remain open; later timestamp handling is above; + arbitrary DOS flags are not persisted by the present portable contract. +- FUSE inode lifetime coverage passes at bridge `558d1f2`, CI run + `34521983619`: lookup and open references retain an inode until both are gone, + in either cleanup order; retiring an old inode cannot remove a recreated path. + Its Linux artifact (SHA-256 + `f13570f87abe01de5268eedb96de86ae52e9c0eb43f76c712042a6b70dc980ec`) + also passes native Linux 6.8 acceptance as UID 65534 through the core SFTP + provider. Unlinking an open file and recreating its path yields a distinct inode; + the old descriptor still reads/writes the old object and its close leaves the + replacement intact. Source-owner inspection independently verifies replacement + bytes. Prior directory rewind, mmap, busy detach and ordinary unmount checks + pass. Fixture `62945821-3521-4387-bae4-d16d1117b9f7`, mount and staged binary + were independently confirmed removed. All platform CI jobs pass, including + native Windows acceptance (29.24 seconds). This closes the inode retention/forget + coverage item; it does not establish concurrent edits by another remote client. +- Native Windows volume-wide flush now passes at bridge `9c4fbe1`, CI run + `34521265017` (full native suite: 28.88 seconds). The fixture opens the actual + disposable volume through the Windows device path and calls FlushFileBuffers + via `File::sync_all`. All three writable files are attempted even when one + provider flush fails; Windows receives ERROR_IO_DEVICE. Clearing the injected + fault allows the next volume flush to succeed, with every file's backing bytes + independently verified. All platform builds/tests and prior native checks pass. +- Native Linux 6.8 acceptance now also passes as the existing unprivileged + account `nobody` (UID 65534), using bridge `87a28c9`. The opt-in harness mode + `SHELLCANVAS_PROBE_UNPRIVILEGED=1` runs the bridge and local file operations + without root. The source owner independently verifies confirmed backing bytes. + File operations, directory rewind/rename, mmap, busy-detach protection and + ordinary unmount pass. A second run with `SHELLCANVAS_PROBE_TRANSPORT_LOSS=1` + returns ENOTCONN for write/close, preserves confirmed source data, reports a + failure exit and removes the mount before privileged recovery is attempted. + Both disposable fixtures (`ed155bc7-c391-4767-927d-9b0018847016` and + `882ecebf-e1b7-4adf-a56b-7f56686a6afb`) and the staged binary were independently + confirmed removed. No accounts, FUSE configuration or driver setup changed. +- Bridge `87a28c9` fixes FUSE directory rewind recovery: retired handles and + buffered entries are cleared before reopening. A failed close/reopen or invalid + reply cannot leave a later seek using a closed handle. Two regression tests + pass on Linux/macOS in CI run `34519068114`; all platform jobs pass. Its Linux + artifact (SHA-256 `24babf890037503ae93d3f2ac7c8ee855b9e4a093208d82f9b8a30c155bc515a`) + passes native Linux 6.8 acceptance over the real core SFTP provider: the same + directory descriptor enumerates exact pages after repeated rewind and after + directory rename. Prior I/O, mapped-file and busy-detach checks also pass. + The fixture `d7d47df5-69e2-4b6b-a9b6-70e2b6615214` and staged binary directory + were independently confirmed removed, with no remaining mount-table entry. + Failure recovery is regression-fixture evidence; native injected reopen failure + remains a separate check; non-root acceptance is recorded above. +- Bridge `e58d386` passes native Windows acceptance in CI run `34518237898` + (32.21 seconds), including directory rename with two open directory aliases. + An open descendant correctly blocks rename with error 5 and preserves source + contents/handles; after closing the child, rename succeeds and both directory + handles still return metadata. Replacing a nonempty destination is rejected + without damaging either tree. A separate local NTFS baseline and Microsoft's + MS-FSA FileRenameInformation specification confirm that the open-child rule + differs from POSIX. The first test expected POSIX behavior and failed; its + Windows expectation was corrected, with no driver behavior changed. All prior + native Windows acceptance markers and Linux/macOS builds/tests also pass. +- 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 + 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 source repository: `ShellCanvas-DriveBridge`, maintained separately from this desktop checkout. + `.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. 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. +- 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, + 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. +- 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. +- 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. +- 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; + modern macOS and actual SSH outages remain separate; non-root Linux is verified above. +- 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. +- 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 + 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 + 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 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 + 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. + 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. +- 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 + 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 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. + +## Client boundary and desktop wrapper follow-up + +The desktop mounted-filesystem wrapper now permits timestamp/permission changes +through read handles on writable mounts, matching the provider contract used by +Windows attribute-only opens. Truncation still requires a writable data handle; +read-only mounts and retired workspace bindings reject changes before reaching +the provider. The production wrapper regression test passes locally. + +Runtime app manifests now declare optional client platform compatibility. The +package manager enforces it for installs, updates, enabling, and launch, and the +SDK exposes client identity independently of the remote host. Drive Bridge also +gates its native installation and attach commands to Windows/Linux/macOS clients. +See [client platform compatibility](client-platform-compatibility.md) for the +contract and deferred mobile work. This does not claim an Android/iOS app build. +Local SDK packaging/schema tests, targeted catalog/runtime tests, native bridge +installation/reservation tests, and the production frontend build passed. No CI +run was triggered for this follow-up. Batch future CI changes and use local +verification where possible to respect the user's GitHub plan. + +## Startup cancellation follow-up + +Attachment preparation now exposes **Cancel attachment** while opening the +remote root or verifying the local executable. Cancellation drops preparation +resources and never starts the native helper. A serialized launch boundary +rejects a late cancel request; once native startup begins, the existing detach +and recovery machinery retains ownership. Canceled entries are labeled Canceled +and can be dismissed after their connection reservation is released. Root +availability is rechecked after executable verification, before native launch. +This adds no filesystem-provider or bridge-protocol requirement. + +Local verification covers cancellation before polling, during preparation, and +as a ready result arrives; the production desktop `run` path verifies no helper +starts and root resources are dropped. The SFTP wire fixture cancels REALPATH and +LSTAT during root preparation, observes channel EOF, and confirms another channel +remains open. The isolated browser fixture verifies Cancel → Canceled → Dismiss +and the rendered dialog. These are synthetic lifecycle/UI checks, not evidence +of a desktop-created WinFsp mount. + +The ignored native-helper acceptance test was explicitly run locally with the +built `shellcanvas-filesystem-sdk` `lifecycle_fixture` example. Stalled startup, +invalid protocol, retained reservation for an occupied system location (read-only +occupancy inspection), busy-detach refusal and explicit retry all passed. No +driver, remote account, normal app profile, or CI run was used by these checks. + +## Remaining completion gates + +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. 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 full TCP-network blackholes, remote-initiated + native SFTP-only EOF and remote permission/disk-full errors. A stalled SFTP + channel with healthy SSH now passes native acceptance as recorded above. + 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 and late OPEN reply cancellation are protocol-fixture + tested. Cancelled/timed-out/rejected CLOSE cleanup is fixed and verified above. + Cancellation while preparing the root is now verified above. The native + chooser/source-retirement race remains to be exercised 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, + Explorer/ordinary editor acceptance (native directory rename/open-handle + semantics are now verified above), + disconnect behavior and failure recovery. Local driver setup is still pending. +5. Native Linux FUSE file operations, directory rewind/rename, truncate, flush, + mapped files, busy detach and ordinary unmount now pass as root and as an + unprivileged user on Linux 6.8. Pipe loss with an open file also passes in both + contexts, including source preservation and confirmed mount disappearance. + Inode retention/forget coverage now passes as recorded above. + Remaining: the permission-error matrix and + other supported runtimes. Do not infer those from one kernel/runtime. +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 warnings and busy-detach control are implemented + with busy detach now passing native WinFsp acceptance. Cleanup-time failure + warnings now pass native failure injection. Volume-wide flush and cross-handle rename + are implemented with bookkeeping regression tests. Native directory alias + rename and volume-wide flush checks now pass. Existing-file read-only changes + now pass native Windows and separate live SFTP checks. Timestamp mapping and + explicit rejection of unavailable creation/change-only updates pass as recorded + above. Creation/overwrite read-only handling and ordinary native copy now pass + as recorded above; full DOS-attribute/ACL persistence is not provided. + Linux shared/private/read-only memory-mapping acceptance + 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 + 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..36b1369 --- /dev/null +++ b/docs/local-drive-bridge.md @@ -0,0 +1,155 @@ +# 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. + +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 | +| --- | --- | --- | +| 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. Settings → Files shows host, remote/local path, access and attachment state, + 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. + +## 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 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. diff --git a/docs/native-directory-readers.md b/docs/native-directory-readers.md index 3931fd9..e9f7487 100644 --- a/docs/native-directory-readers.md +++ b/docs/native-directory-readers.md @@ -129,7 +129,7 @@ This fixture uses no remote host or system clipboard; native adapter integration is recorded separately in [adapter packages](adapter-packages.md). -The authorized read-only `root@evtinsait` probe on 2026-09-09 also passed the new +The authorized read-only Linux-host probe on 2026-09-09 also passed the new SFTP reader's first page and early close, complete home/root/parent browsing, canonical text locations, UTF-8 preview, PTY input/output/resize and disconnect. It changed no remote files. This is Linux-host integration from Windows; it does diff --git a/docs/native-file-workflows.md b/docs/native-file-workflows.md index 14275e0..931f383 100644 --- a/docs/native-file-workflows.md +++ b/docs/native-file-workflows.md @@ -19,7 +19,7 @@ The helper accepts `setup`, `verify` or `cleanup` and one exact UUID path. Run i ## Earlier clipboard and editor walkthrough -Verified 2026-09-08 against the production Tauri frontend and SSH/SFTP services at code checkpoint `0843068`, using the native workspace fixture and the authorized root connection to evtinsait. A new `/tmp/shellcanvas-ui-UUID` directory contained only two generated text files and an empty destination folder. No existing host files were edited. +Verified 2026-09-08 against the production Tauri frontend and SSH/SFTP services at code checkpoint `0843068`, using the native workspace fixture and an authorized Linux test host. A new `/tmp/shellcanvas-ui-UUID` directory contained only two generated text files and an empty destination folder. No existing host files were edited. ## Clipboard and independent Files windows diff --git a/docs/permission-validation.md b/docs/permission-validation.md index 5822805..12c6992 100644 --- a/docs/permission-validation.md +++ b/docs/permission-validation.md @@ -4,7 +4,7 @@ The probe creates one `/tmp/shellcanvas-permissions-UUID` directory. Root-owned readable and private files exercise refusals; a separate subdirectory is owned by the existing unprivileged account for allowed operations. Only this new directory's ownership and modes change. Independent SSH exec channels run the existing SFTP server as `nobody`; production connection setup is unchanged. -The authorized evtinsait run on 2026-09-08 verified: +The authorized Linux-host run on 2026-09-08 verified: - Directory listing and public preview remain usable, while private preview, editor reads and downloads return the SFTP PermissionDenied status. - Folder creation, text creation, editor save, rename, delete and upload fail with actual permission-denied responses in the root-owned parent. Original contents remain unchanged and no temporary files remain. diff --git a/docs/post-goal-backlog.md b/docs/post-goal-backlog.md index 57d1e3d..3dba7a8 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. @@ -294,13 +310,13 @@ are contract demonstrations, not production device support. Name/domain/trademark checks and launch timing remain decisions. Existing SDK starters and contributor guidance should be improved, not recreated. - [ ] **COMM-02 — Website and documentation at shellcanvas.com.** The user purchased - the domain and pointed it at evtinsait (2026-09-10). Build the site in a - private repository. Plan a later public release of the ShellCanvas app - repository. The standalone assistant repository is approved to be public - now so repository installation can work from any machine. + the domain and published the landing site (2026-09-10). Maintain the site + separately. The ShellCanvas app repository has its own public-release + process. The standalone assistant repository is public so repository + installation can work from any machine. - [ ] **POST-24 — Commercial boundaries. Decision.** Paid desktop/mobile/web or hosted offerings, private components and pricing remain undecided. Current - MPL-2.0 core licensing and the private source repository are established; do + MPL-2.0 core licensing and the public source repository are established; do not change them or promise a paid tier as part of closing this backlog. ## Boundaries to preserve diff --git a/docs/public-release.md b/docs/public-release.md new file mode 100644 index 0000000..13c76a8 --- /dev/null +++ b/docs/public-release.md @@ -0,0 +1,21 @@ +# Public release and package publishing + +This repository publishes the Windows desktop through GitHub Releases, the TypeScript app SDK through npm, and the Rust adapter/filesystem SDKs through crates.io. These are the package channels that match the current implementation. Store submissions and additional package managers can follow after platform validation and signing are in place. + +## Release preparation + +1. Update the version in `package.json`, `packages/app-sdk/package.json`, the publishable crate manifests and `src-tauri/tauri.conf.json`. +2. Update `CHANGELOG.md` and confirm the README's platform boundaries. +3. Run `npm ci`, `npm run verify`, `npm run verify:sdk`, `npm run verify:adapter-sdk`, `cargo package --locked` for each published crate, and `npm pack --workspace @shellcanvas/app-sdk --dry-run`. +4. Build the Windows installers with `npm run tauri -- build --bundles nsis,msi`. Install and launch both formats on disposable Windows test environments before release. +5. Create and push a signed `vX.Y.Z` tag only from a clean, reviewed commit. The release workflow attaches installers and SHA-256 checksums. + +The current installer pipeline does not apply Authenticode signing. Windows may warn about an unknown publisher. Add a protected Windows signing certificate before calling the desktop broadly trusted. Do not store certificate material in Git or workflow files. + +## SDK registries + +`@shellcanvas/app-sdk` is a public scoped npm package. The first publish needs an npm account allowed to publish the `@shellcanvas` scope. After that, configure npm trusted publishing for `techartdev/ShellCanvas` and `publish-sdks.yml`; OIDC then avoids a long-lived npm token and supplies provenance from the public repository. + +`shellcanvas-adapter-sdk` and `shellcanvas-filesystem-sdk` publish to crates.io. The workflow uses a protected `CARGO_REGISTRY_TOKEN` secret. Keep the token out of local files and logs. Publish the filesystem SDK first if the adapter SDK later depends on it. + +Registry releases are immutable. Inspect every `npm pack --dry-run` and `cargo package --list` result before publishing, then verify the public registry metadata and install each package into a new temporary consumer project. diff --git a/docs/ssh-host-trust.md b/docs/ssh-host-trust.md index 6a78841..ccafdf9 100644 --- a/docs/ssh-host-trust.md +++ b/docs/ssh-host-trust.md @@ -26,6 +26,6 @@ No replacement/removal UI or automatic key rotation is included. SSH agent, keyb Tests cover plain/wildcard/negated/hashed entries, aliases, ports, IPv6, whitespace, multiple permitted keys, mismatches, revocation order, unrelated markers, CA-only refusal, malformed entries, invalid endpoints and file bounds. Local generated-key SSH servers additionally check unknown/changed/revoked/malformed cases in either trust store before authentication, known/user/app-trusted cases through authentication, and rejection of a different reviewed key despite a matching current trust file. They bind only to loopback, use temporary known_hosts fixtures and an in-memory generated host key, and never alter the user's real trust file or credentials. -The 2026-09-08 run passed 38 Rust tests and all-target Clippy. The additional alias and different-key-algorithm cases passed in the focused host-key suite. The authorized read-only evtinsait regression passed existing known-host verification, key authentication, Linux inspection, SFTP locations/preview, PTY input/resize and disconnect. HMAC/SHA-1 for hashed hostname matching and the test RNG reuse dependency versions already present through russh; no new package versions remain in the lockfile. +The 2026-09-08 run passed 38 Rust tests and all-target Clippy. The additional alias and different-key-algorithm cases passed in the focused host-key suite. The authorized read-only Linux-host regression passed existing known-host verification, key authentication, Linux inspection, SFTP locations/preview, PTY input/resize and disconnect. HMAC/SHA-1 for hashed hostname matching and the test RNG reuse dependency versions already present through russh; no new package versions remain in the lockfile. -The enrollment checkpoint passed 43 frontend tests, 41 Rust tests, production frontend build, all-target Clippy and the standard Windows debug build. The authorized evtinsait read-only SSH/SFTP/PTY regression also passed after the connection changes. Native unit tests cover token ownership, one-use/expiry/cancel cleanup, exact port persistence, duplicate approval, concurrent writes and preservation of conflicting/corrupt files. Bridge tests cover missing/rejecting reviewers, endpoint mismatch, failed review and late approval after cancellation/expiry. The synthetic `tests/fixtures/host-trust.html` browser flow passed fingerprint copying, disabled/default-focus controls, rejection, Escape cancellation, approval, remembered reconnect, changed/revoked refusal, expiry recovery and key change after review. Its 1280×720 review panel fits without scrolling. Browser tests use fake trust and credentials; they do not prove native IPC enrollment or OS clipboard access. A full native enrollment walkthrough remains unverified. +The enrollment checkpoint passed 43 frontend tests, 41 Rust tests, production frontend build, all-target Clippy and the standard Windows debug build. The authorized read-only Linux-host SSH/SFTP/PTY regression also passed after the connection changes. Native unit tests cover token ownership, one-use/expiry/cancel cleanup, exact port persistence, duplicate approval, concurrent writes and preservation of conflicting/corrupt files. Bridge tests cover missing/rejecting reviewers, endpoint mismatch, failed review and late approval after cancellation/expiry. The synthetic `tests/fixtures/host-trust.html` browser flow passed fingerprint copying, disabled/default-focus controls, rejection, Escape cancellation, approval, remembered reconnect, changed/revoked refusal, expiry recovery and key change after review. Its 1280×720 review panel fits without scrolling. Browser tests use fake trust and credentials; they do not prove native IPC enrollment or OS clipboard access. A full native enrollment walkthrough remains unverified. diff --git a/docs/system-clipboard.md b/docs/system-clipboard.md index f338e3f..eb9d844 100644 --- a/docs/system-clipboard.md +++ b/docs/system-clipboard.md @@ -60,23 +60,23 @@ Remote plans use revision-checked directory listings and reject copying into the - 84 frontend tests and 62 Rust tests passed, including caller/app ownership, stale preparation cleanup, copy cancellation, immutable selections, Unicode file-list decoding, invalid inputs, stream seeking, empty files and changed-source failure. All-target Clippy passed. - `/tests/fixtures/copy-paste.html` verified multi-selection Ctrl+C and the Copy menu, two-window remote Paste, one-picker multi-download, failure/cancellation, local-file replacement of a remote selection, and text replacement refusing stale-file Paste. Its synthetic services are separate from native verification. -- The opt-in `explorer_clipboard_probe` successfully pasted two generated virtual files through real Explorer. Neither stream opened before Paste; both 8,388,625-byte results matched SHA-256 `485a584410e070b9d289cb2a75ee695b20860585e15736e6871364f3680e6526`. The user also confirmed multi-file remote-to-PC copying on evtinsait. -- The updated native workspace then received those two local files through Explorer Copy and the real Files **Paste here** menu. Both uploads reported Completed, appeared in the remote listing, and independent SSH readback matched both hashes. This exercised the authorized root connection to evtinsait and only an owned UUID test directory. Exact generated remote-file cleanup passed. +- The opt-in `explorer_clipboard_probe` successfully pasted two generated virtual files through real Explorer. Neither stream opened before Paste; both 8,388,625-byte results matched SHA-256 `485a584410e070b9d289cb2a75ee695b20860585e15736e6871364f3680e6526`. The user also confirmed multi-file remote-to-PC copying on an authorized Linux test host. +- The updated native workspace then received those two local files through Explorer Copy and the real Files **Paste here** menu. Both uploads reported Completed, appeared in the remote listing, and independent SSH readback matched both hashes. This exercised an authorized Linux test host and only an owned UUID test directory. Exact generated remote-file cleanup passed. - Native Ctrl+V input is covered by the shared handler/browser checks; the live incoming walkthrough used the menu. Network interruption during Explorer paste, native Cut interoperability and other platforms remain separate checks. ### Folder verification — 2026-09-08 - Regression coverage includes parent relationships with opaque provider identifiers, empty directories, cycle/link/traversal rejection, case collisions, descendant destinations, no-merge publication, cancellation with explicit partial results, and Windows directory descriptors with correct file-stream indices. -- Opt-in `live_folder_roundtrip` exercised the production native tree engine against evtinsait: local folder upload, remote folder copy, downloads, exact byte comparison, Unicode names, a zero-byte file and an empty nested folder. The disposable UUID remote root was removed. +- Opt-in `live_folder_roundtrip` exercised the production native tree engine against an authorized Linux test host: local folder upload, remote folder copy, downloads, exact byte comparison, Unicode names, a zero-byte file and an empty nested folder. The disposable UUID remote root was removed. - `SHELLCANVAS_FOLDER_PROBE=1` with `explorer_clipboard_probe` pasted a real Explorer folder containing `Nested/First.bin`, `Second.bin` and `Empty/`. Both 8,388,625-byte files matched SHA-256 `485a584410e070b9d289cb2a75ee695b20860585e15736e6871364f3680e6526`; no stream opened before Paste. Directory metadata follows the Windows [FILEDESCRIPTORW contract](https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-filedescriptorw). -- The Explorer-copied folder was then read through the production native file-list decoder and clipboard upload preparation. Upload, remote copy and downloads on evtinsait preserved every byte of both 8 MB files and both empty/nested directories. Its disposable remote root and the exact owned local files were removed. This checks the native clipboard/transfer path; a full folder-paste walkthrough inside the packaged Files window remains a user acceptance check. +- The Explorer-copied folder was then read through the production native file-list decoder and clipboard upload preparation. Upload, remote copy and downloads on an authorized Linux test host preserved every byte of both 8 MB files and both empty/nested directories. Its disposable remote root and the exact owned local files were removed. This checks the native clipboard/transfer path; a full folder-paste walkthrough inside the packaged Files window remains a user acceptance check. - Current regression gate: 84 frontend tests, 67 Rust tests, and all-target Clippy passed; both opt-in native/live probes also passed. ### Scalable discovery verification — 2026-09-08 - Replaced the original entry/depth caps with paged discovery and a disposable metadata catalog. Synthetic fixtures scanned 50,000 leaves and a 512-level provider tree with one directory cursor at a time and no file-content reads. A stalled cursor canceled and closed; a 2,500-file local scan retained no source handles and rejected a subsequently replaced file. - A Windows unit fixture produced 10,000 catalog-backed descriptors without opening source files; requesting one indexed stream opened only that source. The browser slow-preparation fixture displayed 50,000 discovered items and recovered its controls after Cancel. Replacing the service binding during a scan canceled the old request and restored the new binding's controls. -- The live evtinsait folder round trip passed again through the new engine, including local upload, remote copy, exact download bytes, Unicode names and empty directories. The owned remote fixture was removed. +- The live authorized-host folder round trip passed again through the new engine, including local upload, remote copy, exact download bytes, Unicode names and empty directories. The owned remote fixture was removed. - Real Explorer pasted the new catalog-backed folder offer: both 8,388,625-byte files matched the SHA-256 above and nested/empty directories were preserved. No file stream opened before Paste. An earlier probe timed out while the automation was navigating; repeating after the destination was ready passed with two verified streams. - Regression gate: 85 frontend tests, 72 Rust tests and all-target Clippy. Large-tree tests use synthetic providers; they are not a 50,000-file live-network benchmark. diff --git a/docs/terminal-services.md b/docs/terminal-services.md index e9ea381..424c2dc 100644 --- a/docs/terminal-services.md +++ b/docs/terminal-services.md @@ -13,7 +13,7 @@ The session registry owns a cancellation sender for each terminal. Removing a wi - Runtime fixtures cover output during blocked input, explicit close with a retained input handle, split multibyte output, independent consoles, non-resizable consoles, dimension clamping, invalid chunk sizes and provider read/write errors. Cleanup runs once in those fixtures. - Registry checks preserve ownership across session removal and reject stale/cross-session input. - `cargo run -p shellcanvas --example terminal_probe -- HOST USER KEY_PATH` exercises the production SSH terminal adapter, native pump and registry. It opens two shells, uses only temporary shell variables and terminal-size queries, closes one while retaining its input handle, verifies the other still responds, then closes both and disconnects. It does not print credentials or remote terminal contents. -- The authorized `root@evtinsait` run passed independent variables, 120×40 and 90×25 dimensions, survivor input/output, stale/cross-session refusal and cleanup. This is a native Rust integration probe, not a Windows GUI clipboard/keyboard walkthrough or a physical serial test. +- The authorized Linux-host run passed independent variables, 120×40 and 90×25 dimensions, survivor input/output, stale/cross-session refusal and cleanup. This is a native Rust integration probe, not a Windows GUI clipboard/keyboard walkthrough or a physical serial test. Connection identity and established-resource lifecycle now use the [neutral connection contract](connection-lifecycle.md), including live console cleanup verification. Composite service bindings, connector-specific settings and per-binding availability remain open. No automatic fallback from SSH to an insecure protocol is introduced. diff --git a/docs/workspace-bindings.md b/docs/workspace-bindings.md index 2de739e..8f87149 100644 --- a/docs/workspace-bindings.md +++ b/docs/workspace-bindings.md @@ -53,7 +53,7 @@ The runner records `.local/native-extension-probe/result.json`. Restore the norm The ownership regression suite covers independent FTP-like files and serial-like console sources, failure of the file leg with continued console byte I/O, shared leases across workspaces, last-owner release, duplicate/foreign binding refusal, file-source isolation, delayed reads, uncertain writes, late console cleanup, transfer I/O refusal and abort after closure. Replacement cases additionally cover a surviving open console, abandoned/rejected/competing proposals, retired-generation reuse, capability loss/recovery, post-commit cleanup failure and preserved custom bindings. They use fake adapters through real neutral service interfaces; they do not implement FTP or serial protocols. -At the workspace-owner checkpoint, 53 Rust tests, all-target Clippy and the standard Windows debug build passed. The authorized evtinsait probe exercised the production owner with read-only SFTP, two independent terminals, resizing, surviving-console input, closed-workspace file refusal and teardown. It changed no remote files. The existing terminal layout was rechecked at 500px and 260px: unused row space matches the theme and stays above the footer; no further terminal source change was needed. +At the workspace-owner checkpoint, 53 Rust tests, all-target Clippy and the standard Windows debug build passed. An authorized Linux-host probe exercised the production owner with read-only SFTP, two independent terminals, resizing, surviving-console input, closed-workspace file refusal and teardown. It changed no remote files. The existing terminal layout was rechecked at 500px and 260px: unused row space matches the theme and stays above the footer; no further terminal source change was needed. Production setup now also supports [installed native adapters](adapter-packages.md), with explicit file/console assignments and typed configuration. Those processes use this same workspace owner, and each selected source has its own connection identity. Whole-workspace reconnect preserves windows and acquires new handles. Native integration checks cover distinct file/console processes, unavailable capabilities and running connections surviving package updates/removal. diff --git a/package-lock.json b/package-lock.json index 45905a3..2a6e13e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "prettier": "^3.9.6", "typescript": "~5.8.3", "vite": "^6.4.3", - "vitest": "^3.2.0" + "vitest": "^4.1.11" } }, "node_modules/@babel/code-frame": { @@ -1160,6 +1160,13 @@ "resolved": "packages/app-sdk", "link": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tauri-apps/api": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", @@ -1508,39 +1515,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", - "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.7", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1552,42 +1560,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", - "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", - "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -1595,28 +1603,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", - "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", - "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1711,16 +1716,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001810", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", @@ -1743,32 +1738,15 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1815,16 +1793,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/dialog-polyfill": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/dialog-polyfill/-/dialog-polyfill-0.5.6.tgz", @@ -1839,9 +1807,9 @@ "license": "ISC" }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -2023,13 +1991,6 @@ "node": ">=6" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2095,6 +2056,20 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2102,16 +2077,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2305,29 +2270,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -2339,11 +2284,14 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.17", @@ -2362,30 +2310,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", - "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -2512,89 +2440,80 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", - "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -2605,6 +2524,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, diff --git a/package.json b/package.json index 5b2d429..a5cee23 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "test:sdk": "npm run build:sdk && npm test --workspace @shellcanvas/app-sdk", "verify:sdk": "node scripts/verify-sdk.mjs", "verify:adapter-sdk": "node scripts/verify-adapter-sdk.mjs", + "release:check": "node scripts/check-release-version.mjs", + "release:windows": "npm run tauri -- build --bundles nsis,msi", + "public:check": "node scripts/check-public-repo.mjs", "dev": "vite --host 127.0.0.1", "build": "npm run build:sdk && tsc -b && vite build", "test": "vitest run", @@ -40,6 +43,6 @@ "prettier": "^3.9.6", "typescript": "~5.8.3", "vite": "^6.4.3", - "vitest": "^3.2.0" + "vitest": "^4.1.11" } } diff --git a/packages/app-sdk/package.json b/packages/app-sdk/package.json index d37c8cd..a000df8 100644 --- a/packages/app-sdk/package.json +++ b/packages/app-sdk/package.json @@ -2,8 +2,20 @@ "name": "@shellcanvas/app-sdk", "version": "0.1.0", "description": "Typed runtime app API and app packaging tools for ShellCanvas", + "keywords": ["shellcanvas", "desktop", "remote", "sdk", "extensions"], + "homepage": "https://shellcanvas.com/docs/app-sdk/quickstart.html", + "repository": { + "type": "git", + "url": "git+https://github.com/techartdev/ShellCanvas.git", + "directory": "packages/app-sdk" + }, + "bugs": { "url": "https://github.com/techartdev/ShellCanvas/issues" }, "type": "module", "license": "MPL-2.0", + "publishConfig": { + "access": "public", + "provenance": true + }, "engines": { "node": ">=20" }, diff --git a/packages/app-sdk/schemas/app-manifest.schema.json b/packages/app-sdk/schemas/app-manifest.schema.json index 2c8b7ed..b0fb48b 100644 --- a/packages/app-sdk/schemas/app-manifest.schema.json +++ b/packages/app-sdk/schemas/app-manifest.schema.json @@ -6,6 +6,15 @@ "additionalProperties": false, "required": ["format", "kind", "id", "version", "title", "permissions"], "properties": { + "clientPlatforms": { + "description": "Allowed ShellCanvas client targets, independent of remote host OS. Omit for no platform restriction.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": ["windows", "macos", "linux", "android", "ios", "web"] + } + }, "format": { "const": 1 }, diff --git a/packages/app-sdk/schemas/app-package.schema.json b/packages/app-sdk/schemas/app-package.schema.json index 9db00ec..4976a49 100644 --- a/packages/app-sdk/schemas/app-package.schema.json +++ b/packages/app-sdk/schemas/app-package.schema.json @@ -15,6 +15,15 @@ "style" ], "properties": { + "clientPlatforms": { + "description": "Allowed ShellCanvas client targets, independent of remote host OS. Omit for no platform restriction.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": ["windows", "macos", "linux", "android", "ios", "web"] + } + }, "format": { "const": 1 }, diff --git a/packages/app-sdk/src/client-platform.ts b/packages/app-sdk/src/client-platform.ts new file mode 100644 index 0000000..974441d --- /dev/null +++ b/packages/app-sdk/src/client-platform.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +/** The ShellCanvas client target, never the connected remote host's OS. */ +export const clientPlatforms = [ + "windows", + "macos", + "linux", + "android", + "ios", + "web", +] as const; +export type ClientPlatform = (typeof clientPlatforms)[number]; +export interface ClientEnvironment { + readonly platform: ClientPlatform | "unknown"; +} +export const clientPlatformLabels: Readonly< + Record +> = { + windows: "Windows", + macos: "macOS", + linux: "Linux", + android: "Android", + ios: "iOS", + web: "Web", + unknown: "Unknown client", +}; +/** Omission means no platform restriction, not a certification of compatibility. */ +export function clientCompatibilityReason( + app: { readonly clientPlatforms?: readonly ClientPlatform[] }, + client: ClientEnvironment, +): string | undefined { + if ( + !app.clientPlatforms || + app.clientPlatforms.some((platform) => platform === client.platform) + ) + return; + return `Not available on ${clientPlatformLabels[client.platform]}. Supports ${app.clientPlatforms.map((platform) => clientPlatformLabels[platform]).join(", ")} clients.`; +} diff --git a/packages/app-sdk/src/environment-api.ts b/packages/app-sdk/src/environment-api.ts index 7972aa3..1ef714a 100644 --- a/packages/app-sdk/src/environment-api.ts +++ b/packages/app-sdk/src/environment-api.ts @@ -1,7 +1,10 @@ // SPDX-License-Identifier: MPL-2.0 import type { Json } from "./rpc.js"; +import type { ClientEnvironment } from "./client-platform.js"; export interface AppEnvironment { readonly apiVersion: 1; + /** Client target independent of the remote host. Absent on older ShellCanvas versions. */ + readonly client?: ClientEnvironment; readonly connection: "local" | "connected" | "disconnected" | "review-required"; /** Opaque identity of the explicitly accepted binding, never a native session handle. */ diff --git a/packages/app-sdk/src/index.ts b/packages/app-sdk/src/index.ts index d1f720c..0608424 100644 --- a/packages/app-sdk/src/index.ts +++ b/packages/app-sdk/src/index.ts @@ -1,7 +1,17 @@ // SPDX-License-Identifier: MPL-2.0 export { connectToShellCanvas } from "./client.js"; export type { ExtensionClient } from "./client.js"; -export type { AppNetworkAPI, AppHTTPResponse, AppConnection } from "./network-client.js"; +export { + clientPlatforms, + clientPlatformLabels, + clientCompatibilityReason, +} from "./client-platform.js"; +export type { ClientPlatform, ClientEnvironment } from "./client-platform.js"; +export type { + AppNetworkAPI, + AppHTTPResponse, + AppConnection, +} from "./network-client.js"; export { RpcError } from "./rpc.js"; export type { RpcCode, Json } from "./rpc.js"; export { SystemError } from "./system-api.js"; diff --git a/packages/app-sdk/src/package.ts b/packages/app-sdk/src/package.ts index 6299315..51ce94d 100644 --- a/packages/app-sdk/src/package.ts +++ b/packages/app-sdk/src/package.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MPL-2.0 import { RpcError } from "./rpc.js"; +import { clientPlatforms, type ClientPlatform } from "./client-platform.js"; /** Experimental self-contained UI package. Native adapters use a separate executable kind. */ export interface AppPackage { readonly format: 1; @@ -8,6 +9,8 @@ export interface AppPackage { readonly version: string; readonly title: string; readonly permissions: readonly string[]; + /** Allowed ShellCanvas client targets. Omit for an unrestricted portable app. */ + readonly clientPlatforms?: readonly ClientPlatform[]; readonly script: string; readonly style: string; } @@ -59,6 +62,14 @@ export function parseAppPackage(raw: string): AppPackage { !/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/.test(permission), ) || new Set(item.permissions).size !== item.permissions.length || + (item.clientPlatforms !== undefined && + (!Array.isArray(item.clientPlatforms) || + !item.clientPlatforms.length || + item.clientPlatforms.some( + (platform: unknown) => + !clientPlatforms.some((known) => known === platform), + ) || + new Set(item.clientPlatforms).size !== item.clientPlatforms.length)) || Object.keys(item).some( (key) => ![ @@ -68,6 +79,7 @@ export function parseAppPackage(raw: string): AppPackage { "version", "title", "permissions", + "clientPlatforms", "script", "style", ].includes(key), @@ -78,5 +90,8 @@ export function parseAppPackage(raw: string): AppPackage { return Object.freeze({ ...item, permissions: Object.freeze([...item.permissions]), + ...(item.clientPlatforms === undefined + ? {} + : { clientPlatforms: Object.freeze([...item.clientPlatforms]) }), }); } diff --git a/packages/app-sdk/tests/package.check.mjs b/packages/app-sdk/tests/package.check.mjs index ebce456..f9f06aa 100644 --- a/packages/app-sdk/tests/package.check.mjs +++ b/packages/app-sdk/tests/package.check.mjs @@ -47,6 +47,13 @@ test("schemas and the host parser agree on executable packages and source manife ); for (const [patch, valid] of [ [{}, true], + [{ clientPlatforms: ["windows", "macos", "linux"] }, true], + [{ clientPlatforms: ["android", "ios", "web"] }, true], + [{ clientPlatforms: [] }, false], + [{ clientPlatforms: ["windows", "windows"] }, false], + [{ clientPlatforms: ["unknown"] }, false], + [{ clientPlatforms: "android" }, false], + [{ clientPlatforms: null }, false], [{ format: 2 }, false], [{ kind: "adapter" }, false], [{ id: "../escape" }, false], @@ -115,7 +122,7 @@ test("packer preserves the last valid artifact after bad source, manifest or ver const directory = await fixture(t, "shellcanvas-sdk-packer-"); await writeFile( join(directory, "shellcanvas.json"), - JSON.stringify(manifest), + JSON.stringify({ ...manifest, clientPlatforms: ["windows", "android"] }), ); await writeFile( join(directory, "main.ts"), @@ -125,6 +132,9 @@ test("packer preserves the last valid artifact after bad source, manifest or ver const output = await packApp(directory); const before = await readFile(output, "utf8"); assert.equal(parseAppPackage(before).id, manifest.id); + const platforms = parseAppPackage(before).clientPlatforms; + assert.deepEqual(platforms, ["windows", "android"]); + assert.ok(Object.isFrozen(platforms)); await assert.rejects(packApp(directory, "bad")); await writeFile(join(directory, "main.ts"), 'import "./missing-file.js";'); await assert.rejects(packApp(directory)); diff --git a/scripts/check-public-repo.mjs b/scripts/check-public-repo.mjs new file mode 100644 index 0000000..be2abe2 --- /dev/null +++ b/scripts/check-public-repo.mjs @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MPL-2.0 +import { readFile, stat } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { dirname, extname, resolve } from "node:path"; + +const repository = new URL("../", import.meta.url); +const root = decodeURIComponent(repository.pathname).replace(/^\/([A-Za-z]:)/, "$1"); +const files = execFileSync( + "git", + ["ls-files", "--cached", "--others", "--exclude-standard"], + { cwd: root, encoding: "utf8" }, +) + .trim().split(/\r?\n/).filter(Boolean); +const forbidden = [ + /[A-Z]:\\(?:Users|Mine)\\/i, + /49\.13\.19\.149/, + new RegExp(`\\b${["evtin", "sait"].join("")}\\b`, "i"), + /-----BEGIN [A-Z ]*PRIVATE KEY-----/, + /\b(?:gh[opsu]_|npm_)[A-Za-z0-9_-]{20,}\b/, +]; +const errors = []; +let markdownFiles = 0; +for (const file of files) { + const path = resolve(root, file); + const contents = await readFile(path); + if (contents.includes(0)) continue; + const source = contents.toString("utf8"); + for (const pattern of forbidden) if (pattern.test(source)) errors.push(`${file}: contains private release data matching ${pattern}`); + if (extname(file).toLowerCase() !== ".md") continue; + markdownFiles++; + for (const match of source.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) { + const target = match[1].trim().replace(/^<|>$/g, ""); + if (!target || /^(?:https?:|mailto:|#)/i.test(target)) continue; + const local = decodeURIComponent(target.split(/[?#]/, 1)[0]); + const candidate = resolve(dirname(path), local); + try { + const info = await stat(candidate); + if (info.isDirectory()) await stat(resolve(candidate, "README.md")); + } catch { + errors.push(`${file}: missing local link target ${target}`); + } + } +} +if (errors.length) throw new Error(`Public repository check failed:\n${errors.join("\n")}`); +console.log(`Public repository check passed for ${files.length} text/binary files and ${markdownFiles} Markdown documents.`); diff --git a/scripts/check-release-version.mjs b/scripts/check-release-version.mjs new file mode 100644 index 0000000..0016993 --- /dev/null +++ b/scripts/check-release-version.mjs @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +import { readFile } from "node:fs/promises"; + +const expected = (process.argv[2] ?? process.env.GITHUB_REF_NAME ?? "").replace(/^v/, ""); +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(expected)) { + throw new Error("Pass a vX.Y.Z release tag or set GITHUB_REF_NAME"); +} +const json = async (path) => JSON.parse(await readFile(new URL(`../${path}`, import.meta.url), "utf8")); +const rootPackage = await json("package.json"); +const appSdk = await json("packages/app-sdk/package.json"); +const tauri = await json("src-tauri/tauri.conf.json"); +const cargoVersion = async (path) => { + const source = await readFile(new URL(`../${path}`, import.meta.url), "utf8"); + return source.match(/^version\s*=\s*"([^"]+)"/m)?.[1]; +}; +const versions = { + "package.json": rootPackage.version, + "packages/app-sdk/package.json": appSdk.version, + "src-tauri/tauri.conf.json": tauri.version, + "crates/adapter-sdk/Cargo.toml": await cargoVersion("crates/adapter-sdk/Cargo.toml"), + "crates/filesystem-sdk/Cargo.toml": await cargoVersion("crates/filesystem-sdk/Cargo.toml"), +}; +for (const [path, version] of Object.entries(versions)) { + if (version !== expected) throw new Error(`${path} is ${version}; expected ${expected}`); +} +console.log(`Release versions agree: ${expected}`); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 86c343d..1370656 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" @@ -36,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/capabilities/default.json b/src-tauri/capabilities/default.json index 460c407..d26f0d7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main-window", - "description": "Bundled desktop shell only. No remote web content or third-party extensions.", + "description": "Trusted desktop shell only. Installed apps run in isolated child frames and do not receive this capability set.", "windows": ["main"], "permissions": [ "core:default", 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..7cf8b34 --- /dev/null +++ b/src-tauri/src/drive_bridge_install.rs @@ -0,0 +1,431 @@ +// 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); +pub fn supported_client(platform: &str) -> bool { + matches!(platform, "windows" | "linux" | "macos") +} +pub fn require_supported_client() -> Result<(), String> { + if supported_client(std::env::consts::OS) { + Ok(()) + } else { + Err("Drive Bridge requires a Windows, Linux or macOS ShellCanvas client.".into()) + } +} +#[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> { + require_supported_client()?; + 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 { + require_supported_client()?; + 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 bridge_support_is_a_client_platform_decision() { + for platform in ["windows", "linux", "macos"] { + assert!(supported_client(platform)); + } + for platform in ["android", "ios", "web", "unknown"] { + assert!(!supported_client(platform)); + } + } + #[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..3b29703 --- /dev/null +++ b/src-tauri/src/drive_mappings.rs @@ -0,0 +1,958 @@ +// 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, + pub can_retry_cleanup: bool, + pub can_cancel_startup: bool, + pub startup_canceled: bool, +} +struct Mapping { + info: MappingInfo, + control: Arc, + _lease: Option, + recovery: Arc, + startup: Arc, +} +#[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())?; + let (warning, can_retry) = m.recovery.snapshot()?; + info.cleanup_warning = warning.or(info.cleanup_warning); + info.can_retry_cleanup = can_retry; + info.can_cancel_startup = m.info.running + && info.status.phase == BridgePhase::Starting + && m.startup.can_cancel(); + info.startup_canceled = m.startup.was_canceled(); + 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, + recovery: Arc::new(crate::drive_recovery::Recovery::default()), + startup: Arc::new(crate::drive_startup::Startup::default()), + }, + ); + 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; + item.startup.finish(); + } + } + } + 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()) + } + fn startup(&self, id: &str) -> Result, String> { + Ok(self + .lock()? + .get(id) + .ok_or("Attachment no longer exists")? + .startup + .clone()) + } + 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] +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 cancel_drive_startup(id: String, state: State<'_, DesktopState>) -> Result<(), String> { + state.mappings.startup(&id)?.cancel() +} +#[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(()) +} + +#[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 { + 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( + session_id: u64, + binding: ConnectionIdentity, + app: tauri::AppHandle, + state: State<'_, DesktopState>, +) -> Result { + if !crate::drive_bridge_install::supported_client(std::env::consts::OS) { + return Ok(Availability { + supported: false, + installed: false, + windows: false, + available_drives: None, + }); + } + 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(); + #[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, + }) +} + +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> { + crate::drive_bridge_install::require_supported_client()?; + 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; + // 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 + .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, + can_retry_cleanup: false, + can_cancel_startup: false, + startup_canceled: false, + }; + state.mappings.reserve(info, control.clone(), Some(lease))?; + let recovery = state.mappings.recovery(&id)?; + let startup = state.mappings.startup(&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. + 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(), + recovery, + startup, + ) + .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)) +} + +#[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, + files: Arc, + path: String, + writable: bool, + control: Arc, + safe: Arc, + recovery: Arc, + startup: Arc, +) -> Result<(), String> { + let (root, executable) = startup + .prepare(async { + 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())??; + // A source can retire while local verification is in progress. + root.check_available().map_err(|e| e.to_string())?; + Ok((root, executable)) + }) + .await?; + let mut command = Command::new(executable); + command.arg("--mount").arg(&target); + supervise( + command, + root, + control, + safe, + Duration::from_secs(30), + recovery, + Some(target), + ) + .await +} + +async fn supervise( + mut command: Command, + root: Arc, + control: Arc, + safe: Arc, + startup_timeout: Duration, + recovery: Arc, + target: Option, +) -> 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. + // 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 + .is_err() + { + server.abort(); + } + drain.abort(); + 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 + .lock() + .map(|b| String::from_utf8_lossy(&b).into_owned()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use shellcanvas_services::*; + + struct PendingRoot { + entered: tokio::sync::Notify, + dropped: Arc, + } + struct PreparingResource(Arc); + impl Drop for PreparingResource { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } + } + #[async_trait::async_trait] + impl FileSystemProvider for PendingRoot { + async fn mount_root(&self, _: &str, _: bool) -> FsResult> { + let _resource = PreparingResource(self.dropped.clone()); + self.entered.notify_one(); + std::future::pending().await + } + async fn list(&self, _: Option<&str>) -> anyhow::Result { + anyhow::bail!("unused") + } + async fn locate(&self, _: &str) -> anyhow::Result { + anyhow::bail!("unused") + } + async fn preview(&self, _: &str) -> anyhow::Result { + anyhow::bail!("unused") + } + } + #[tokio::test] + async fn cancel_during_root_preparation_never_starts_a_native_helper() { + let dropped = Arc::new(AtomicBool::new(false)); + let provider = Arc::new(PendingRoot { + entered: Default::default(), + dropped: dropped.clone(), + }); + let startup = Arc::new(crate::drive_startup::Startup::default()); + let safe = Arc::new(AtomicBool::new(true)); + // Deliberately unusable installation: cancellation must finish before + // even reading this path, rather than fall through to verification/spawn. + let installation = crate::drive_bridge_install::Installation { + version: 1, + name: "Unused fixture".into(), + sha256: "0".repeat(64), + size: 1, + }; + let task = tokio::spawn(run( + (installation, PathBuf::from("nonexistent-canceled-bridge")), + PathBuf::from("unused-target"), + provider.clone(), + "/fixture".into(), + false, + Arc::new(BridgeControl::default()), + safe.clone(), + Arc::new(crate::drive_recovery::Recovery::default()), + startup.clone(), + )); + provider.entered.notified().await; + startup.cancel().unwrap(); + let result = tokio::time::timeout(Duration::from_secs(1), task) + .await + .unwrap() + .unwrap(); + assert!(result + .unwrap_err() + .contains("canceled before native startup")); + assert!( + dropped.load(Ordering::Acquire), + "Preparing root resources were retained" + ); + assert!( + safe.load(Ordering::Acquire), + "Cancellation acquired native ownership" + ); + } + + 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), + Arc::new(crate::drive_recovery::Recovery::default()), + None, + ), + ) + .await + .unwrap(); + assert!(result.is_err(), "{mode}"); + assert!( + safe.load(Ordering::Acquire), + "{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); + command.arg("busy"); + let task = tokio::spawn(supervise( + command, + Arc::new(EmptyRoot), + 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 { + 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, + can_retry_cleanup: false, + can_cancel_startup: false, + startup_canceled: false, + }, + 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, + can_retry_cleanup: false, + can_cancel_startup: false, + startup_canceled: 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 { + 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(); + 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(); + 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/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/drive_startup.rs b/src-tauri/src/drive_startup.rs new file mode 100644 index 0000000..a0d553c --- /dev/null +++ b/src-tauri/src/drive_startup.rs @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Cancellation stops at the native launch boundary. Live mounts use detach. +use std::{future::Future, sync::Mutex}; +use tokio::sync::Notify; + +#[derive(Default, PartialEq)] +enum Phase { + #[default] + Preparing, + Canceled, + Launching, + Finished, +} +#[derive(Default)] +pub(crate) struct Startup { + phase: Mutex, + canceled: Notify, +} +const CANCELED: &str = "Attachment canceled before native startup."; +impl Startup { + pub fn can_cancel(&self) -> bool { + self.phase + .lock() + .is_ok_and(|phase| *phase == Phase::Preparing) + } + pub fn cancel(&self) -> Result<(), String> { + let mut phase = self + .phase + .lock() + .map_err(|_| "Attachment state unavailable")?; + match *phase { + Phase::Preparing => { + *phase = Phase::Canceled; + self.canceled.notify_one(); + Ok(()) + } + Phase::Canceled => Ok(()), + _ => Err( + "Native startup has already begun. Wait for the attachment, then use Detach." + .into(), + ), + } + } + pub fn finish(&self) { + let mut phase = self.phase.lock().unwrap_or_else(|e| e.into_inner()); + if *phase != Phase::Canceled { + *phase = Phase::Finished; + } + } + pub fn was_canceled(&self) -> bool { + self.phase + .lock() + .is_ok_and(|phase| *phase == Phase::Canceled) + } + async fn cancellation(&self) { + // notify_one retains a permit for cancellation before this await. + self.canceled.notified().await; + } + pub async fn prepare( + &self, + preparation: impl Future>, + ) -> Result { + if !self.can_cancel() { + return Err(CANCELED.into()); + } + let prepared = tokio::select! { + biased; + _ = self.cancellation() => return Err(CANCELED.into()), + result = preparation => result?, + }; + // Serialize with cancel(): after this claim no cancellation may drop + // native ownership, including the spawn() await in ProcessTree. + let mut phase = self + .phase + .lock() + .map_err(|_| "Attachment state unavailable")?; + if *phase != Phase::Preparing { + return Err(CANCELED.into()); + } + *phase = Phase::Launching; + Ok(prepared) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + struct Resource(Arc); + impl Drop for Resource { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + #[tokio::test] + async fn canceled_preparation_drops_owned_resources_without_claiming_launch() { + let startup = Arc::new(Startup::default()); + let worker = startup.clone(); + let drops = Arc::new(AtomicUsize::new(0)); + let resource = Resource(drops.clone()); + let (entered, wait) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + worker + .prepare(async move { + let _resource = resource; + entered.send(()).unwrap(); + std::future::pending::>().await + }) + .await + }); + wait.await.unwrap(); + startup.cancel().unwrap(); + startup.cancel().unwrap(); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_secs(1), task) + .await + .unwrap() + .unwrap() + .unwrap_err(), + CANCELED + ); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert!(!startup.can_cancel()); + startup.finish(); + assert!(startup.was_canceled()); + assert!(!startup.can_cancel()); + } + #[tokio::test] + async fn cancellation_wins_a_ready_result_but_cannot_cancel_claimed_native_startup() { + let startup = Startup::default(); + startup.cancel().unwrap(); + assert_eq!( + startup + .prepare(async { + panic!("Canceled work was polled"); + #[allow(unreachable_code)] + Ok(()) + }) + .await + .unwrap_err(), + CANCELED + ); + let startup = Startup::default(); + let drops = Arc::new(AtomicUsize::new(0)); + let resource = Resource(drops.clone()); + let result = startup + .prepare(async { + startup.cancel().unwrap(); + Ok(resource) + }) + .await; + assert_eq!(result.err().unwrap(), CANCELED); + assert_eq!(drops.load(Ordering::SeqCst), 1); + let startup = Startup::default(); + assert_eq!(startup.prepare(async { Ok(42) }).await.unwrap(), 42); + assert!(!startup.can_cancel()); + assert!(startup.cancel().is_err()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04edd94..b448832 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,10 +20,15 @@ mod connection_resource; mod custom_binding; mod custom_services; mod directories; +mod drive_bridge_install; +mod drive_mappings; +mod drive_recovery; +mod drive_startup; mod extension_frames; #[cfg(debug_assertions)] mod extension_probe; mod host_trust; +mod local_mounts; mod native_ipc; mod prepared_source; mod profile_store; @@ -45,6 +50,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, @@ -65,6 +72,12 @@ fn error(e: impl std::fmt::Display) -> String { e.to_string() } +/// Client compile target, independent of all remote workspace connections. +#[tauri::command] +fn client_platform() -> &'static str { + std::env::consts::OS +} + #[tauri::command] async fn profiles(app: tauri::AppHandle) -> Result, String> { let dir = profile_store::storage_dir(&app)?; @@ -270,7 +283,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 +420,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 +734,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 +763,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 +793,19 @@ pub fn run() { return handler(invoke); } let handler: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![ + client_platform, + drive_mappings::cancel_drive_startup, + 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, + 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, @@ -838,6 +885,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/local_mounts.rs b/src-tauri/src/local_mounts.rs new file mode 100644 index 0000000..dccc27e --- /dev/null +++ b/src-tauri/src/local_mounts.rs @@ -0,0 +1,149 @@ +// 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 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(); + 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-tauri/src/mounted_binding.rs b/src-tauri/src/mounted_binding.rs new file mode 100644 index 0000000..3ab375d --- /dev/null +++ b/src-tauri/src/mounted_binding.rs @@ -0,0 +1,397 @@ +// 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, + writable: bool, +} +impl FileSystem { + 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] +impl MountedFileSystem for FileSystem { + fn check_available(&self) -> FsResult<()> { + self.binding.mount_check()?; + self.inner.check_available() + } + fn capabilities(&self) -> FsCapabilities { + FsCapabilities { + writable: self.writable, + ..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()?; + 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 + .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, + writable: self.writable && options.write, + metadata_writable: self.writable, + })) + } + 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<()> { + 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 + } +} +struct File { + binding: Binding, + inner: Arc, + writable: bool, + metadata_writable: bool, +} +#[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<()> { + 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.metadata_writable)?; + if metadata.size.is_some() { + write_allowed(self.writable)?; + } + 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, + metadata_writes: 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<()> { + self.metadata_writes.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn flush(&self) -> FsResult<()> { + Ok(()) + } + async fn close(&self) -> FsResult<()> { + self.closes.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + struct Root(Arc); + fn unsupported() -> FsResult { + Err(FsError::new( + FsErrorKind::Unsupported, + "unused fixture operation", + )) + } + #[async_trait] + impl MountedFileSystem for Root { + fn capabilities(&self) -> FsCapabilities { + FsCapabilities { + writable: true, + atomic_replace: false, + durable_flush: false, + } + } + async fn metadata(&self, _: &MountPath) -> FsResult { + unsupported() + } + async fn open(&self, _: &MountPath, _: FsOpenOptions) -> FsResult> { + Ok(self.0.clone()) + } + 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] + 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), + metadata_writes: AtomicUsize::new(0), + }); + let path = MountPath::root().child("file").unwrap(); + let read_options = FsOpenOptions { + read: true, + write: false, + create: FsCreate::OpenExisting, + truncate: false, + }; + let root = Arc::new(Root(inner.clone())); + let writable_root = FileSystem::new(binding.clone(), root.clone(), true); + let file = writable_root + .open( + &path, + FsOpenOptions { + write: true, + ..read_options + }, + ) + .await + .unwrap(); + let read_only = FileSystem::new(binding.clone(), root, false) + .open(&path, read_options) + .await + .unwrap(); + let attributes_only = writable_root.open(&path, read_options).await.unwrap(); + for metadata in [ + FsSetMetadata { + permissions: Some(0o444), + ..Default::default() + }, + FsSetMetadata { + modified: Some(1_600_000_123), + ..Default::default() + }, + ] { + attributes_only.set_metadata(metadata).await.unwrap(); + } + assert_eq!(inner.metadata_writes.load(Ordering::SeqCst), 2); + assert_eq!( + attributes_only + .set_metadata(FsSetMetadata { + size: Some(0), + permissions: Some(0o444), + ..Default::default() + }) + .await + .unwrap_err() + .kind, + FsErrorKind::ReadOnly + ); + assert_eq!( + attributes_only.write_at(0, &[8]).await.unwrap_err().kind, + FsErrorKind::ReadOnly + ); + assert_eq!( + inner.metadata_writes.load(Ordering::SeqCst), + 2, + "Rejected truncation forwarded a partial metadata change" + ); + 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!( + inner.metadata_writes.load(Ordering::SeqCst), + 2, + "Read-only mount forwarded a metadata update" + ); + 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 + ); + assert_eq!( + attributes_only + .set_metadata(FsSetMetadata { + modified: Some(1_600_000_124), + ..Default::default() + }) + .await + .unwrap_err() + .kind, + FsErrorKind::Offline + ); + assert_eq!( + inner.metadata_writes.load(Ordering::SeqCst), + 2, + "Retired binding forwarded a metadata update" + ); + file.close().await.unwrap(); + assert_eq!(inner.closes.load(Ordering::SeqCst), 1); + } +} diff --git a/src-tauri/src/transfer_catalog.rs b/src-tauri/src/transfer_catalog.rs index c5d325b..2ebad37 100644 --- a/src-tauri/src/transfer_catalog.rs +++ b/src-tauri/src/transfer_catalog.rs @@ -13,6 +13,24 @@ use std::{ time::SystemTime, }; +const MAX_CATALOG_ITEMS: u64 = 250_000; +const MAX_CATALOG_METADATA_BYTES: u64 = 128 * 1024 * 1024; +fn checked_catalog_totals( + count: u64, + metadata_bytes: u64, + next_bytes: usize, +) -> Result<(u64, u64)> { + let count = count + .checked_add(1) + .filter(|value| *value <= MAX_CATALOG_ITEMS) + .context("Transfer catalog exceeded the 250,000-item discovery budget")?; + let metadata_bytes = metadata_bytes + .checked_add(next_bytes as u64) + .filter(|value| *value <= MAX_CATALOG_METADATA_BYTES) + .context("Transfer catalog exceeded the 128 MiB metadata budget")?; + Ok((count, metadata_bytes)) +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub(crate) struct Stamp { pub size: u64, @@ -53,6 +71,7 @@ pub(crate) struct Catalog { _directory: tempfile::TempDir, count: AtomicU64, size: AtomicU64, + metadata_bytes: AtomicU64, portable: bool, } impl Catalog { @@ -72,6 +91,7 @@ impl Catalog { _directory: directory, count: AtomicU64::new(0), size: AtomicU64::new(0), + metadata_bytes: AtomicU64::new(0), portable, }) } @@ -118,6 +138,7 @@ impl Catalog { let tx = db.transaction()?; let mut count = self.len(); let mut size = self.size(); + let mut metadata_bytes = self.metadata_bytes.load(Ordering::Relaxed); for (entry, local) in entries { if !matches!(entry.kind.as_str(), "file" | "directory") { bail!("Folder contains a link or special file: {}", entry.name); @@ -136,17 +157,16 @@ impl Catalog { if self.portable { super::download_name(&entry.name).map_err(anyhow::Error::msg)?; } - count = count - .checked_add(1) - .filter(|v| *v <= i64::MAX as u64) - .context("Transfer catalog is too large")?; if entry.kind == "file" { size = size .checked_add(entry.size) .context("Transfer size overflow")?; } + let next_id = count + .checked_add(1) + .context("Transfer catalog is too large")?; let node = Node { - id: count, + id: next_id, parent: parent.map_or(0, |p| p.id), display: parent .map(|p| format!("{}\\{}", p.display, entry.name)) @@ -159,12 +179,16 @@ impl Catalog { } else { node.entry.name.clone() }; - tx.execute("INSERT INTO nodes(id,parent,path,name_key,directory,data) VALUES(?1,?2,?3,?4,?5,?6)", params![node.id, node.parent, node.entry.path, key, node.entry.kind == "directory", serde_json::to_string(&node)?]) + let data = serde_json::to_string(&node)?; + (count, metadata_bytes) = checked_catalog_totals(count, metadata_bytes, data.len())?; + debug_assert_eq!(count, next_id); + tx.execute("INSERT INTO nodes(id,parent,path,name_key,directory,data) VALUES(?1,?2,?3,?4,?5,?6)", params![node.id, node.parent, node.entry.path, key, node.entry.kind == "directory", data]) .with_context(|| format!("Duplicate, cyclic, or conflicting folder entry: {}", node.entry.name))?; } tx.commit()?; self.count.store(count, Ordering::Relaxed); self.size.store(size, Ordering::Relaxed); + self.metadata_bytes.store(metadata_bytes, Ordering::Relaxed); Ok(()) } pub fn get(&self, id: u64) -> Result { @@ -222,3 +246,16 @@ impl Catalog { self._directory.path() } } + +#[cfg(test)] +mod budget_tests { + use super::*; + + #[test] + fn rejects_item_and_metadata_budget_overflow() { + assert!(checked_catalog_totals(MAX_CATALOG_ITEMS - 1, 0, 1).is_ok()); + assert!(checked_catalog_totals(MAX_CATALOG_ITEMS, 0, 1).is_err()); + assert!(checked_catalog_totals(0, MAX_CATALOG_METADATA_BYTES - 1, 1).is_ok()); + assert!(checked_catalog_totals(0, MAX_CATALOG_METADATA_BYTES, 1).is_err()); + } +} diff --git a/src-tauri/src/transfer_tests.rs b/src-tauri/src/transfer_tests.rs index e2eb105..2444a3a 100644 --- a/src-tauri/src/transfer_tests.rs +++ b/src-tauri/src/transfer_tests.rs @@ -329,7 +329,7 @@ async fn chooser_download_selection_uses_one_queue_slot_and_one_stream_for_many_ panic!("Expected transfer"); }; let mut last = (0, 0); - let result = execute(job, service, &claimed.cancel, &mut |event| { + let result = execute(*job, service, &claimed.cancel, &mut |event| { last = (event.bytes, event.total); }) .await @@ -569,7 +569,7 @@ async fn queued_transfer_keeps_its_provider_and_cannot_follow_source_replacement let Work::Transfer { job, service } = claimed.work else { panic!("Expected transfer"); }; - assert!(execute(job, service, &claimed.cancel, &mut |_| {}) + assert!(execute(*job, service, &claimed.cancel, &mut |_| {}) .await .is_err()); assert_eq!(old_memory.commits.load(Ordering::SeqCst), 0); @@ -587,7 +587,7 @@ async fn queued_transfer_keeps_its_provider_and_cannot_follow_source_replacement let Work::Transfer { job, service } = claimed.work else { panic!("Expected transfer"); }; - execute(job, service, &claimed.cancel, &mut |_| {}) + execute(*job, service, &claimed.cancel, &mut |_| {}) .await .unwrap(); assert_eq!(fresh_memory.commits.load(Ordering::SeqCst), 1); diff --git a/src-tauri/src/transfers.rs b/src-tauri/src/transfers.rs index 327acaf..d54826b 100644 --- a/src-tauri/src/transfers.rs +++ b/src-tauri/src/transfers.rs @@ -285,7 +285,7 @@ enum Job { } enum Work { Transfer { - job: Job, + job: Box, service: Arc, }, #[cfg(any(windows, test))] @@ -330,7 +330,7 @@ impl TransferRegistry { .map(|(job, name, size, direction)| { ( Work::Transfer { - job, + job: Box::new(job), service: service.clone(), }, name, @@ -1330,7 +1330,7 @@ pub async fn run_transfer( let relocation = claim.run(&cancel, &tracked.unwrap_or_default()).await?; Ok((relocation.path.clone(), Some(relocation))) } - Work::Transfer { job, service } => execute(job, service, &cancel, &mut |event| { + Work::Transfer { job, service } => execute(*job, service, &cancel, &mut |event| { if event.phase != last.phase || sent.elapsed().as_millis() >= 100 || event.bytes == event.total diff --git a/src-tauri/src/workspace_services.rs b/src-tauri/src/workspace_services.rs index 1999e46..b788933 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,19 @@ 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, + writable, + ))) + } async fn list(&self, path: Option<&str>) -> Result { self.binding.run(false, self.service.list(path)).await } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 68209fd..4f550ce 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,5 +23,22 @@ "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost data: blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost; object-src 'none'; frame-src shellcanvas-app: http://shellcanvas-app.localhost; base-uri 'self'" } }, - "bundle": { "active": false } + "bundle": { + "active": true, + "targets": ["nsis", "msi"], + "publisher": "ShellCanvas", + "homepage": "https://shellcanvas.com/", + "shortDescription": "A graphical desktop for remote and headless systems", + "longDescription": "ShellCanvas turns remote and headless systems into graphical workspaces with files, terminals, editors and extensible apps.", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.ico", + "icons/icon.icns" + ], + "windows": { + "nsis": { "installMode": "currentUser" } + } + } } 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..44f0a57 --- /dev/null +++ b/src/components/DriveBridgeSettings.tsx @@ -0,0 +1,247 @@ +// 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"; +import { readClientEnvironment } from "../extensions/client-platform"; +import { clientCompatibilityReason } from "../../packages/app-sdk/src/client-platform"; + +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 [compatible, setCompatible] = useState(false); + const alive = useRef(true); + const pending = useRef(null); + const working = useRef(false); + const native = isTauri(); + useEffect(() => { + alive.current = true; + if (native) + void readClientEnvironment() + .then(async (client) => { + const reason = clientCompatibilityReason( + { clientPlatforms: ["windows", "macos", "linux"] }, + client, + ); + if (!alive.current) return undefined; + setCompatible(!reason); + if (reason) { + setInstallationStatus(reason); + return undefined; + } + return invoke("drive_bridge_installation"); + }) + .then((value) => { + if (alive.current && value !== undefined) { + 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 && compatible + ? "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..06135e3 --- /dev/null +++ b/src/components/DriveMappings.css @@ -0,0 +1,138 @@ +/* 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); +} +.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)); +} +.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..8316425 --- /dev/null +++ b/src/components/DriveMappings.tsx @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MPL-2.0 +import { useEffect, useState } from "react"; +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { + FolderOpen, + HardDrive, + LoaderCircle, + RotateCw, + 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; + canRetryCleanup?: boolean; + canCancelStartup?: boolean; + startupCanceled?: boolean; +} +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, command: string) { + if (working) return; + setWorking(item.id); + setError(""); + try { + await invoke(command, { + id: item.id, + }); + 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 { + 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.startupCanceled && + (item.status.phase === "Starting" || + item.status.phase === "Detaching"); + return ( +
+
+ + {item.localPath} + {item.writable ? "Read & write" : "Read only"} +
+

{item.remotePath}

+ {item.hostLabel} +
+ + {pending && } + {item.startupCanceled ? "Canceled" : item.status.phase} + {item.status.phase === "Detached" && item.running + ? " · finishing cleanup" + : ""} + +
+ {item.running && item.status.phase === "Attached" && ( + + )} + {item.canCancelStartup ? ( + + ) : item.canRetryCleanup ? ( + + ) : ( + + )} +
+
+ {(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/directory-scan.test.ts b/src/directory-scan.test.ts index 58c93f3..93eaa1d 100644 --- a/src/directory-scan.test.ts +++ b/src/directory-scan.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 import { expect, it, vi } from "vitest"; import { directoryLocation, scanDirectory } from "./directory-scan"; -import type { Directory, DirectoryPage } from "./sdk"; +import type { Directory, DirectoryPage, DirectoryReader } from "./sdk"; const directory = (start: number, count: number): Directory => ({ path: "opaque:root", @@ -95,10 +95,7 @@ it("closes a canceled scan and never publishes a late page from the old location }); it("releases a late open without reading and reports cleanup failure without publishing completion", async () => { - const pending = deferred<{ - next: ReturnType; - close: ReturnType; - }>(); + const pending = deferred(); const reader = { next: vi.fn(), close: vi.fn(async () => {}) }; const controller = new AbortController(); const publish = vi.fn(); diff --git a/src/extensions/ExtensionFrame.tsx b/src/extensions/ExtensionFrame.tsx index e2d1bc5..21a6801 100644 --- a/src/extensions/ExtensionFrame.tsx +++ b/src/extensions/ExtensionFrame.tsx @@ -116,7 +116,17 @@ export function ExtensionFrame({ const documentState = useRef(onDocumentState); const controls = useRef(windowControls); controls.current = windowControls; - const [fallbackEnvironment] = useState(() => new RuntimeEnvironment()); + const [fallbackEnvironment] = useState( + () => + new RuntimeEnvironment({ + apiVersion: 1, + connection: "local", + binding: null, + visible: true, + capabilities: [], + client: lease?.client ?? { platform: "unknown" }, + }), + ); const environment = suppliedEnvironment ?? fallbackEnvironment; documentState.current = onDocumentState; useEffect(() => { @@ -132,7 +142,8 @@ export function ExtensionFrame({ ]); let stopEnvironment: (() => void) | undefined; const clipboardOwner = clipboard ? new AppClipboard(clipboard) : undefined; - const network = new AppNetwork(app.id, app.title, (request) => + const principal = lease?.installed.principal ?? app.id; + const network = new AppNetwork(principal, app.title, (request) => configureConnection.current(request), ); const directories = fileSource ? new AppDirectories(fileSource) : undefined; @@ -248,7 +259,7 @@ export function ExtensionFrame({ ); methods.set("system.services.call", customService.call); if (storage) - for (const [name, method] of appStorageMethods(app.id, storage)) + for (const [name, method] of appStorageMethods(principal, storage)) methods.set(name, method); if (clipboardOwner) for (const [name, method] of clipboardOwner.methods()) diff --git a/src/extensions/ExtensionManager.tsx b/src/extensions/ExtensionManager.tsx index 6ab94de..db7b41c 100644 --- a/src/extensions/ExtensionManager.tsx +++ b/src/extensions/ExtensionManager.tsx @@ -5,6 +5,7 @@ import { capabilityLabels, type Capability } from "../sdk"; import { AppCatalog, type AppLease, type InstallReview } from "./catalog"; import "./ExtensionManager.css"; import { inspectRepository } from "./repository"; +import { clientPlatformLabels } from "../../packages/app-sdk/src/client-platform"; function permissionName(name: string) { if (name === "system.network") @@ -138,7 +139,10 @@ export function ExtensionManager({

YOUR WORKSPACE, EXTENDED

Apps

-

Add tools that make this desktop yours.

+

+ Add tools that make this desktop yours. Client:{" "} + {clientPlatformLabels[catalog.client.platform]}. +

)}
); diff --git a/src/extensions/app-storage.test.ts b/src/extensions/app-storage.test.ts index 8dff34c..a6ea221 100644 --- a/src/extensions/app-storage.test.ts +++ b/src/extensions/app-storage.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MPL-2.0 import { expect, it, vi } from "vitest"; -import { appStorageMethods } from "./app-storage"; +import { appStorageMethods, enforceStorageQuota } from "./app-storage"; import type { AppStorageBackend } from "./storage-api"; import { RpcPeer, messagePortTransport, type Json } from "./rpc"; @@ -20,6 +20,16 @@ function setup() { signal: new AbortController().signal, }; } +it("enforces cumulative per-app key and storage quotas", () => { + expect(() => enforceStorageQuota(255, 100, 0, 100)).not.toThrow(); + expect(() => enforceStorageQuota(256, 100, 0, 1)).toThrow("256-key"); + expect(() => + enforceStorageQuota(1, 16 * 1024 * 1024, 1, 2), + ).toThrow("16 Mi"); + expect(() => + enforceStorageQuota(1, 16 * 1024 * 1024, 2, 1), + ).not.toThrow(); +}); it("binds data and settings to the host-owned app identity and keeps the buckets separate", async () => { const { backend, methods, signal } = setup(); await methods diff --git a/src/extensions/app-storage.ts b/src/extensions/app-storage.ts index 9c22e08..c61c415 100644 --- a/src/extensions/app-storage.ts +++ b/src/extensions/app-storage.ts @@ -2,6 +2,24 @@ import { isJsonValue, RpcError, type Json, type RpcMethod } from "./rpc"; import type { AppStorageBackend, AppValue, StorageBucket } from "./storage-api"; +const maxNamespaceKeys = 256; +const maxNamespaceUnits = 16 * 1024 * 1024; +export function enforceStorageQuota( + count: number, + units: number, + currentUnits: number, + nextUnits: number, +) { + if ( + count + (currentUnits ? 0 : 1) > maxNamespaceKeys || + units - currentUnits + nextUnits > maxNamespaceUnits + ) + throw new RpcError( + "unavailable", + "This app storage namespace reached its 256-key or 16 Mi unit quota.", + ); +} + function key(value: unknown): asserts value is string { if ( typeof value !== "string" || @@ -257,13 +275,43 @@ export function indexedAppStorage( "busy", "This value changed in another window or app version. Read it again before saving.", ); - const next = - value === undefined - ? null - : { revision: crypto.randomUUID(), value }; - if (next) store.put(next, path); - else store.delete(path); - result(next); + if (value === undefined) { + store.delete(path); + result(null); + return; + } + const next = { revision: crypto.randomUUID(), value }; + const range = IDBKeyRange.bound( + [owner, bucket], + [owner, bucket, []], + true, + true, + ); + let count = 0; + let units = 0; + const inventory = store.openCursor(range); + inventory.onsuccess = () => { + try { + const cursor = inventory.result; + if (cursor) { + const item = stored(cursor.value); + count++; + units += JSON.stringify(item!.value).length; + cursor.continue(); + return; + } + enforceStorageQuota( + count, + units, + current ? JSON.stringify(current.value).length : 0, + JSON.stringify(value).length, + ); + store.put(next, path); + result(next); + } catch (error) { + fail(error); + } + }; } catch (error) { fail(error); } diff --git a/src/extensions/catalog.test.ts b/src/extensions/catalog.test.ts index dab5bc2..3724084 100644 --- a/src/extensions/catalog.test.ts +++ b/src/extensions/catalog.test.ts @@ -17,6 +17,56 @@ const raw = (version = "1.0.0", permissions = ["system.dialogs"]) => script: `console.log('${version}')`, style: "", }); + +it("enforces client compatibility for install, updates, enable and persisted launches", async () => { + const saved = storage(); + const desktop = new AppCatalog(saved.api, async () => ({ + platform: "windows", + })); + const android = new AppCatalog(saved.api, async () => ({ + platform: "android", + })); + await Promise.all([desktop.load(), android.load()]); + const restricted = JSON.stringify({ + ...JSON.parse(raw()), + clientPlatforms: ["windows", "macos", "linux"], + }); + const review = await android.review(restricted); + expect(android.compatibilityReason(review.package)).toContain( + "Not available on Android", + ); + await expect(android.install(review, [])).rejects.toThrow( + "Not available on Android", + ); + expect(saved.api.compareAndSet).not.toHaveBeenCalled(); + const entry = await desktop.install(await desktop.review(restricted), []); + const lease = await desktop.launch(entry.package.id); + expect(lease.client).toEqual({ platform: "windows" }); + lease.close(); + await android.load(); + expect(android.snapshot()).toHaveLength(1); + await expect(android.launch(entry.package.id)).rejects.toThrow( + "Not available on Android", + ); + await android.setEnabled(entry.package.id, entry.generation, false); + await expect( + android.setEnabled(entry.package.id, entry.generation, true), + ).rejects.toThrow("Not available on Android"); + // Portable replacement can be installed and launched on either client. + const portable = await android.install( + await android.review(raw("2.0.0")), + [], + ); + const mobileLease = await android.launch(portable.package.id); + expect(mobileLease.client.platform).toBe("android"); + mobileLease.close(); + await expect( + android.install(await android.review(restricted), []), + ).rejects.toThrow("Not available on Android"); + expect(android.snapshot()[0].package.version).toBe("2.0.0"); + await android.remove(portable.package.id, portable.generation); + expect(android.snapshot()).toHaveLength(0); +}); function storage() { let value: unknown = null; const api: CatalogStorage = { @@ -142,6 +192,7 @@ it("persists installed packages and grants, and pins existing windows across upd [], ); expect(updated.generation).not.toBe(first.generation); + expect(updated.principal).toBe(first.principal); expect(window.installed.package.version).toBe("1.0.0"); expect(window.installed.grants).toEqual(["system.dialogs"]); expect(window.closed).toBe(false); @@ -155,6 +206,19 @@ it("persists installed packages and grants, and pins existing windows across upd window.close(); next.close(); }); +it("preserves an installation principal across updates and rotates it after removal", async () => { + const catalog = new AppCatalog(storage().api); + await catalog.load(); + const first = await catalog.install(await catalog.review(raw()), []); + const updated = await catalog.install( + await catalog.review(raw("2.0.0")), + [], + ); + expect(updated.principal).toBe(first.principal); + await catalog.remove(updated.package.id, updated.generation); + const reinstalled = await catalog.install(await catalog.review(raw()), []); + expect(reinstalled.principal).not.toBe(first.principal); +}); it("disables new launches without destroying existing work and refuses removal until all generations close", async () => { const catalog = new AppCatalog(storage().api); await catalog.load(); diff --git a/src/extensions/catalog.ts b/src/extensions/catalog.ts index 40713ab..045b2d5 100644 --- a/src/extensions/catalog.ts +++ b/src/extensions/catalog.ts @@ -1,6 +1,11 @@ // SPDX-License-Identifier: MPL-2.0 import { parseAppPackage, type AppPackage } from "./package"; import { RpcError } from "./rpc"; +import { readClientEnvironment } from "./client-platform"; +import { + clientCompatibilityReason, + type ClientEnvironment, +} from "../../packages/app-sdk/src/client-platform"; import { catalogNotifications, holdCatalogLock } from "./catalog-coordination"; import { parseRepositorySource, @@ -10,6 +15,8 @@ import { export interface InstalledApp { readonly source?: RepositorySource; readonly package: AppPackage; + /** Unpredictable installation lineage used to isolate retained local state. */ + readonly principal: string; readonly generation: string; readonly grants: readonly string[]; readonly enabled: boolean; @@ -72,6 +79,8 @@ export function parseCatalog(value: unknown): CatalogSnapshot | null { ids.has(app.id) || typeof entry.generation !== "string" || !identity.test(entry.generation) || + (entry.principal !== undefined && + (typeof entry.principal !== "string" || !identity.test(entry.principal))) || generations.has(entry.generation) || typeof entry.enabled !== "boolean" || !Array.isArray(entry.grants) || @@ -85,6 +94,12 @@ export function parseCatalog(value: unknown): CatalogSnapshot | null { generations.add(entry.generation); return Object.freeze({ package: app, + // Older catalogs predate principals. Their current generation becomes the + // lineage once and is then preserved by subsequent updates. + principal: + typeof entry.principal === "string" + ? entry.principal + : entry.generation, generation: entry.generation, grants: grantsFor(app, entry.grants as string[]), enabled: entry.enabled, @@ -108,6 +123,7 @@ export class AppLease { readonly id: string, readonly installed: InstalledApp, private release: () => void, + readonly client: ClientEnvironment = Object.freeze({ platform: "unknown" }), ) {} get closed() { return this.stopped; @@ -146,7 +162,23 @@ export class AppCatalog { private leases = new Map(); private listeners = new Set<() => void>(); private empty: readonly InstalledApp[] = Object.freeze([]); - constructor(private storage: CatalogStorage) {} + private clientInfo: ClientEnvironment = Object.freeze({ + platform: "unknown", + }); + constructor( + private storage: CatalogStorage, + private readClient = readClientEnvironment, + ) {} + get client() { + return this.clientInfo; + } + compatibilityReason(app: AppPackage) { + return clientCompatibilityReason(app, this.clientInfo); + } + private requireCompatible(app: AppPackage) { + const reason = this.compatibilityReason(app); + if (reason) throw new RpcError("unavailable", reason); + } snapshot = () => this.state?.apps ?? this.empty; subscribe = (listener: () => void) => { this.listeners.add(listener); @@ -172,9 +204,16 @@ export class AppCatalog { return this.serial(() => this.refresh()); } private async refresh() { + const client = await this.readClient(); // A failed/corrupt read never replaces the previous state or writes an empty catalog. const next = parseCatalog(await this.storage.read()); - if (!this.loaded || next?.revision !== this.state?.revision) { + const clientChanged = this.clientInfo.platform !== client.platform; + this.clientInfo = Object.freeze({ ...client }); + if ( + !this.loaded || + clientChanged || + next?.revision !== this.state?.revision + ) { this.state = next; this.loaded = true; this.publish(); @@ -272,8 +311,10 @@ export class AppCatalog { "invalid", "This app changed after review. Review the current update again.", ); + this.requireCompatible(review.package); const installed: InstalledApp = Object.freeze({ package: review.package, + principal: current?.principal ?? crypto.randomUUID(), generation: crypto.randomUUID(), grants, enabled: true, @@ -302,6 +343,7 @@ export class AppCatalog { setEnabled(id: string, generation: string, enabled: boolean) { return this.serial(async () => { const current = this.current(id, generation); + if (enabled) this.requireCompatible(current.package); await this.commit( this.snapshot().map((entry) => entry === current ? Object.freeze({ ...current, enabled }) : entry, @@ -357,11 +399,17 @@ export class AppCatalog { "unavailable", "This app is disabled or no longer installed.", ); + this.requireCompatible(installed.package); const identity = crypto.randomUUID(); - const lease = new AppLease(identity, installed, () => { - this.leases.delete(identity); - release?.(); - }); + const lease = new AppLease( + identity, + installed, + () => { + this.leases.delete(identity); + release?.(); + }, + this.clientInfo, + ); this.leases.set(identity, lease); return lease; } catch (error) { diff --git a/src/extensions/client-platform-runtime.test.ts b/src/extensions/client-platform-runtime.test.ts new file mode 100644 index 0000000..6c8c23a --- /dev/null +++ b/src/extensions/client-platform-runtime.test.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MPL-2.0 +import { it, expect } from "vitest"; +import { AppCatalog, type CatalogSnapshot } from "./catalog"; +import { DesktopRuntime } from "./desktop-runtime"; +import { initialDesktop } from "../desktop"; + +it("retains incompatible saved apps in the launcher but refuses new windows", async () => { + let value: CatalogSnapshot | null = null; + const storage = { + read: async () => value, + compareAndSet: async (_: string | null, next: CatalogSnapshot) => { + value = next; + }, + }; + const desktop = new AppCatalog(storage, async () => ({ + platform: "windows", + })); + await desktop.load(); + const entry = await desktop.install( + await desktop.review( + JSON.stringify({ + format: 1, + kind: "app", + id: "org.example.desktop", + title: "Desktop tool", + version: "1.0.0", + permissions: [], + script: "void 0", + style: "", + clientPlatforms: ["windows", "linux", "macos"], + }), + ), + [], + ); + const mobile = new AppCatalog(storage, async () => ({ platform: "android" })); + const runtime = new DesktopRuntime(mobile, []); + await mobile.load(); + expect(runtime.disabledReason(entry.package.id)).toContain( + "Not available on Android", + ); + expect(runtime.snapshot().some((app) => app.id === entry.package.id)).toBe( + true, + ); + await expect( + runtime.prepare( + { type: "new", id: entry.package.id }, + initialDesktop(runtime.snapshot()), + ), + ).rejects.toThrow("Not available on Android"); +}); diff --git a/src/extensions/client-platform.test.ts b/src/extensions/client-platform.test.ts new file mode 100644 index 0000000..3c7dda0 --- /dev/null +++ b/src/extensions/client-platform.test.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +import { expect, it, vi, afterEach } from "vitest"; +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { readClientEnvironment } from "./client-platform"; +import { clientCompatibilityReason } from "../../packages/app-sdk/src/client-platform"; +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), isTauri: vi.fn() })); +afterEach(() => vi.resetAllMocks()); +it("uses the native compile target, never browser or remote-host labels", async () => { + vi.mocked(isTauri).mockReturnValue(true); + for (const platform of ["windows", "macos", "linux", "android", "ios"]) { + vi.mocked(invoke).mockResolvedValue(platform); + expect(await readClientEnvironment()).toEqual({ platform }); + } + expect(invoke).toHaveBeenCalledWith("client_platform"); + vi.mocked(invoke).mockResolvedValue("future-os"); + const unknown = await readClientEnvironment(); + expect(unknown.platform).toBe("unknown"); + expect( + clientCompatibilityReason({ clientPlatforms: ["linux"] }, unknown), + ).toContain("Not available"); + vi.mocked(invoke).mockRejectedValue(new Error("IPC unavailable")); + await expect(readClientEnvironment()).rejects.toThrow("IPC unavailable"); +}); +it("treats browser previews as web clients and preserves unrestricted legacy packages", async () => { + vi.mocked(isTauri).mockReturnValue(false); + const client = await readClientEnvironment(); + expect(client.platform).toBe("web"); + expect(invoke).not.toHaveBeenCalled(); + expect(clientCompatibilityReason({}, client)).toBeUndefined(); + expect( + clientCompatibilityReason({ clientPlatforms: ["android"] }, client), + ).toContain("Not available on Web"); + expect( + clientCompatibilityReason({ clientPlatforms: ["web"] }, client), + ).toBeUndefined(); +}); diff --git a/src/extensions/client-platform.ts b/src/extensions/client-platform.ts new file mode 100644 index 0000000..9d461b8 --- /dev/null +++ b/src/extensions/client-platform.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MPL-2.0 +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { + clientPlatforms, + type ClientEnvironment, +} from "../../packages/app-sdk/src/client-platform"; + +/** Native builds report their compile target; browser previews are always web. */ +export async function readClientEnvironment(): Promise { + if (!isTauri()) return Object.freeze({ platform: "web" }); + const platform = await invoke("client_platform"); + return Object.freeze({ + platform: clientPlatforms.find((value) => value === platform) ?? "unknown", + }); +} diff --git a/src/extensions/clipboard-api.test.ts b/src/extensions/clipboard-api.test.ts index 958090d..c07bb79 100644 --- a/src/extensions/clipboard-api.test.ts +++ b/src/extensions/clipboard-api.test.ts @@ -55,6 +55,20 @@ it("requires distinct read/write grants before touching the native backend", asy expect(fixture.backend.writeText).not.toHaveBeenCalled(); fixture.close(); }); +it("rejects text streams above the host memory budget before staging", async () => { + const fixture = setup(); + try { + await expect( + fixture.peer.call("system.clipboard.writeStart", { + id: "too-large", + length: 4 * 1024 * 1024 + 1, + }), + ).rejects.toMatchObject({ code: "invalid" }); + expect(fixture.backend.writeText).not.toHaveBeenCalled(); + } finally { + fixture.close(); + } +}); it("captures a read snapshot and refuses foreign handles, reordering and incomplete commits", async () => { const first = setup(), second = setup(); diff --git a/src/extensions/clipboard-api.ts b/src/extensions/clipboard-api.ts index 0e0c90a..f7fe65c 100644 --- a/src/extensions/clipboard-api.ts +++ b/src/extensions/clipboard-api.ts @@ -4,6 +4,8 @@ import { RpcError, type Json, type RpcMethod } from "./rpc"; import { AppImageClipboard } from "./image-clipboard-api"; export type { AppClipboardAPI } from "../../packages/app-sdk/src/clipboard-client"; const chunkSize = 64 * 1024; +/** Prevent a granted app from staging an unbounded string in the host frame. */ +const maxTextLength = 4 * 1024 * 1024; function identity(value: unknown): asserts value is string { if (typeof value !== "string" || !/^[a-zA-Z0-9-]{1,100}$/.test(value)) throw new RpcError("invalid", "Invalid clipboard operation identity."); @@ -130,9 +132,13 @@ export class AppClipboard { if ( typeof args.length !== "number" || !Number.isSafeInteger(args.length) || - args.length < 0 + args.length < 0 || + args.length > maxTextLength ) - throw new RpcError("invalid", "Invalid clipboard text length."); + throw new RpcError( + "invalid", + "Clipboard text must not exceed 4 Mi UTF-16 units.", + ); if (this.writer || this.committing) throw new RpcError( "busy", diff --git a/src/extensions/desktop-runtime.tsx b/src/extensions/desktop-runtime.tsx index ae7d576..deef522 100644 --- a/src/extensions/desktop-runtime.tsx +++ b/src/extensions/desktop-runtime.tsx @@ -108,6 +108,7 @@ function RuntimeDocument({ }; const state: AppEnvironment = { apiVersion: 1, + client: lease.client, connection: !context.session ? "local" : !context.connected @@ -303,10 +304,11 @@ export class DesktopRuntime { disabledReason(id: string) { if (this.bundled.some((app) => app.id === id) || id === this.manager.id) return undefined; - return this.catalog + const entry = this.catalog .snapshot() - .some((entry) => entry.package.id === id && entry.enabled) - ? undefined + .find((entry) => entry.package.id === id); + return entry?.enabled + ? this.catalog.compatibilityReason(entry.package) : "This app is disabled or no longer installed."; } private launchEpoch = 0; diff --git a/src/extensions/file-clipboard-api.test.ts b/src/extensions/file-clipboard-api.test.ts index 526e970..8562c41 100644 --- a/src/extensions/file-clipboard-api.test.ts +++ b/src/extensions/file-clipboard-api.test.ts @@ -94,6 +94,34 @@ it("chunks large selections, snapshots revisions and exports once without exposi t.close(); } }); +it("bounds the aggregate number of staged file references", async () => { + const t = setup(); + try { + await t.peer.call("system.clipboard.files.start", { + id: "bounded-copy", + binding: "first", + }); + for (let offset = 0; offset < 4096; offset += 128) + await t.peer.call("system.clipboard.files.append", { + id: "bounded-copy", + offset, + entries: Array.from({ length: 128 }, (_, index) => ({ + path: `entry:${offset + index}`, + revision: "r1", + })), + }); + await expect( + t.peer.call("system.clipboard.files.append", { + id: "bounded-copy", + offset: 4096, + entries: [{ path: "entry:overflow", revision: "r1" }], + }), + ).rejects.toMatchObject({ code: "invalid" }); + expect(t.services.copyToSystem).not.toHaveBeenCalled(); + } finally { + t.close(); + } +}); it("publishes Cut with move and clipboard-write grants without requiring download support", async () => { const t = setup(["system.clipboard.files.write", "files.move"]); try { @@ -243,10 +271,10 @@ it("requires separate export and file-download permissions before accessing the } } }); -it("keeps long opaque references below the RPC envelope without a total selection cap", async () => { +it("keeps long opaque references below the RPC envelope and aggregate budget", async () => { const t = setup(); try { - const files = Array.from({ length: 140 }, (_, i) => ({ + const files = Array.from({ length: 20 }, (_, i) => ({ ...entries[0], path: `entry-${i}:` + "a".repeat(40000), })); diff --git a/src/extensions/file-clipboard-api.ts b/src/extensions/file-clipboard-api.ts index 243b1d7..f56e3fa 100644 --- a/src/extensions/file-clipboard-api.ts +++ b/src/extensions/file-clipboard-api.ts @@ -283,6 +283,20 @@ export class AppFileClipboard { ); return { path: entry.path, revision: entry.revision }; }); + if (staged.entries.length + entries.length > 4096) + throw new RpcError( + "invalid", + "Copy at most 4096 root entries in one clipboard operation.", + ); + const metadataUnits = [...staged.entries, ...entries].reduce( + (total, entry) => total + entry.path.length + entry.revision.length, + 0, + ); + if (metadataUnits > 1024 * 1024) + throw new RpcError( + "invalid", + "Clipboard file references must not exceed 1 Mi UTF-16 units.", + ); staged.entries.push(...entries); return null; }), diff --git a/src/extensions/image-clipboard-api.test.ts b/src/extensions/image-clipboard-api.test.ts index f084404..65aeff9 100644 --- a/src/extensions/image-clipboard-api.test.ts +++ b/src/extensions/image-clipboard-api.test.ts @@ -113,6 +113,7 @@ it("enforces image dimensions, exact ordering, complete publication and window o [0, 1], [-1, 1], [1.5, 1], + [4097, 4096], [0xffffffff, 0xffffffff], ]) await expect( diff --git a/src/extensions/image-clipboard-api.ts b/src/extensions/image-clipboard-api.ts index 615754f..cba911e 100644 --- a/src/extensions/image-clipboard-api.ts +++ b/src/extensions/image-clipboard-api.ts @@ -8,6 +8,9 @@ import { type ClipboardImage, } from "../../packages/app-sdk/src/clipboard-image"; +/** 4096 x 4096 RGBA; large enough for normal clipboard images, bounded in host memory. */ +const maxImageBytes = 64 * 1024 * 1024; + function options(value: Json, fields: readonly string[]) { if ( !value || @@ -135,6 +138,11 @@ export class AppImageClipboard { method("write", (params) => { const args = options(params, ["id", "width", "height"]); const length = imageByteLength(args.width, args.height); + if (length > maxImageBytes) + throw new RpcError( + "invalid", + "Clipboard images must not exceed 64 MiB of RGBA data.", + ); if (this.writer || this.committing) throw new RpcError( "busy", diff --git a/src/sdk.ts b/src/sdk.ts index 9dbc7d1..ae5b690 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -228,7 +228,21 @@ export interface ClipboardFileState { sequence: number; intent?: "copy" | "move"; } +export interface DriveMappingAvailability { + supported: boolean; + installed: boolean; + windows: boolean; + availableDrives?: string[]; +} 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 +387,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; diff --git a/tests/fixtures/system-api.tsx b/tests/fixtures/system-api.tsx index b215073..c96c258 100644 --- a/tests/fixtures/system-api.tsx +++ b/tests/fixtures/system-api.tsx @@ -48,12 +48,12 @@ function Sample({ system }: AppContext) { system!.dialogs.messageBox({ title: "Choose the conversation host", message: - "This conversation belongs to SSH host. You are now on evtinsait-host1. Start a new chat, return to the original host, or create a separate continuation. No request has been sent.", + "This conversation belongs to SSH host. You are now on example-host-1. Start a new chat, return to the original host, or create a separate continuation. No request has been sent.", kind: "warning", buttons: [ - { id: "new", label: "New chat on evtinsait-host1" }, + { id: "new", label: "New chat on example-host-1" }, { id: "back", label: "Use original host" }, - { id: "branch", label: "Create continuation on evtinsait-host1" }, + { id: "branch", label: "Create continuation on example-host-1" }, { id: "cancel", label: "Cancel" }, ], defaultId: "new",