From 95a2b1a08679006d3755fd856caf862ae5dc12ed Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Fri, 25 Sep 2026 11:57:46 +0900 Subject: [PATCH] Open an FTP or FTPS server as a folder in Memona --- .clippy.toml | 1 + .gitignore | 1 + .rustfmt.toml | 3 + Cargo.lock | 333 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 27 ++++ LICENSE | 21 +++ README.md | 66 ++++++++ manifest.json | 58 +++++++ sdk/Cargo.toml | 11 ++ sdk/src/lib.rs | 18 +++ sdk/wit/filesystem.wit | 215 +++++++++++++++++++++++++ src/control.rs | 175 ++++++++++++++++++++ src/lib.rs | 351 +++++++++++++++++++++++++++++++++++++++++ src/listing.rs | 79 ++++++++++ 14 files changed, 1359 insertions(+) create mode 100644 .clippy.toml create mode 100644 .gitignore create mode 100644 .rustfmt.toml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 manifest.json create mode 100644 sdk/Cargo.toml create mode 100644 sdk/src/lib.rs create mode 100644 sdk/wit/filesystem.wit create mode 100644 src/control.rs create mode 100644 src/lib.rs create mode 100644 src/listing.rs diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 0000000..875aaee --- /dev/null +++ b/.clippy.toml @@ -0,0 +1 @@ +warn-on-all-wildcard-imports = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..33043ba --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,3 @@ +style_edition = "2024" +max_width = 80 +tab_spaces = 2 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c7aa4a0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,333 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memona-plugin-ftp" +version = "0.1.0" +dependencies = [ + "memona-plugin-sdk", +] + +[[package]] +name = "memona-plugin-sdk" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "wasm-encoder" +version = "0.259.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d0246511d901aacf25d2dc9111f0054947de5e093fe662099e709ff7530dc3" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.259.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7bf119eb01f4a246864c10bd87c8f26c566350abb25aa4d5273c415bbab338" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.259.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f7c12eac7bb587801590f6a67ff0bc84d0748513864b71108e4b311cc3df694" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cb4b5556c3a791e86838ea287782bdafa704d55b0e68b5b81a3a16b9ea5f4b" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ad22a37ecbc0e1fdca34d2b670ba41368cb0920735643e00fdf39a16ee5431" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ba5213d4332c260a0d4e69e34b0f7e4b85ddae10b6ebfbac00d9c185ba628a" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f19d63da8e478a370ef8192d90ccb517965b7747d6b46cd2d831dd0ef4d2541" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.259.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092f783dee3253265fcde131475055f8c275ac0176a9c0396db1dc7b5544bfb4" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.259.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6390f4f02493ce678d509c859cf1698e38929fb75c8012a6ee2f7d6b6cb66cd7" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e1ad925 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "memona-plugin-ftp" +version = "0.1.0" +edition = "2024" +license = "MIT" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +memona-plugin-sdk = { path = "sdk" } + +[lints] +workspace = true + +[workspace] +resolver = "3" +members = ["sdk"] + +[workspace.lints.clippy] +panic = "deny" +unwrap_used = "deny" +expect_used = "deny" +wildcard_imports = "deny" + +[profile.dev] +debug = 0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f5540b0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Memona contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d22a85 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Memona FTP plugin + +Opens an FTP or FTPS server as a folder in [Memona](https://memona.app), so +files on it are browsed, read, written and renamed like local ones, and agents +work in them. + +The plugin is a WebAssembly component built against the Memona filesystem +contract, `memona:filesystem@0.1.0`. It uses only the host's sockets; it has no +ambient filesystem, environment or network access of its own. + +## What it registers + +One provider, `ftp`, for the `ftp` and `ftps` schemes. A connection is +configured with a server name, an optional port, a TLS toggle for FTPS, and an +optional username and password. An empty username signs in anonymously. + +Listing needs a server that answers `MLSD`, the machine-readable listing +command. A server without it reports the folder as unsupported rather than +guessing at the older, human-formatted listing. FTP carries no version token, +so a conditional write is reported as unsupported rather than simulated. + +## Passive mode and the port range + +Transfers are passive: the plugin asks the server for a data port with `EPSV` +or `PASV` and connects out to it. The server picks a fresh port for every +transfer and announces it only as the transfer begins, so the range that has to +be reachable is wide. + +`manifest.json` declares `networkPorts` as 21 and 1024-65535, and `networkHosts` +as `*` because a server can be anywhere. Declaring is not granting: a +connection reaches only the host and ports the person grants it, and a grant +never accepts a wildcard host. Grant the control port and the span the server +uses for its data channel, usually `21` and `1024-65535`. + +## Build + +```sh +rustup target add wasm32-wasip2 +cargo build --locked --release --target wasm32-wasip2 +``` + +The component lands at +`target/wasm32-wasip2/release/memona_plugin_ftp.wasm`. + +`sdk/` is a vendored copy of the Memona plugin SDK, the WIT contract plus its +generated Rust bindings, so this repository builds without the Memona repository +present. + +## Package for the plugin store + +Copy the component to `plugin.wasm` and ZIP it at the archive root together +with `manifest.json`, `README.md` and `LICENSE`: + +```sh +cp target/wasm32-wasip2/release/memona_plugin_ftp.wasm plugin.wasm +zip memona-plugin-ftp.memona-plugin \ + manifest.json plugin.wasm README.md LICENSE +``` + +The archive must have no wrapping directory. Memona validates the package and +its digest, and checks the component's imports against the supported WIT API, +before activation. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..a4a1ee6 --- /dev/null +++ b/manifest.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 1, + "id": "memona/ftp", + "name": "FTP", + "version": "0.1.0", + "apiVersion": "0.1.0", + "description": "Open an FTP server as a folder, and let agents work in it.", + "license": "MIT", + "entry": "plugin.wasm", + "networkHosts": ["*"], + "networkPorts": [ + { "from": 21, "to": 21 }, + { "from": 1024, "to": 65535 } + ], + "providers": [ + { + "id": "ftp", + "name": "FTP", + "schemes": ["ftp", "ftps"], + "configFields": [ + { + "key": "host", + "label": "Server", + "description": "The server's name, for example files.example.com", + "kind": "text", + "required": true + }, + { + "key": "port", + "label": "Port", + "description": "Leave empty for the usual port, 21.", + "kind": "text", + "required": false + }, + { + "key": "tls", + "label": "Connect over TLS", + "kind": "boolean", + "required": false, + "default": false + }, + { + "key": "username", + "label": "Username", + "description": "Leave empty to sign in anonymously.", + "kind": "text", + "required": false + }, + { + "key": "password", + "label": "Password", + "kind": "password", + "required": false + } + ] + } + ] +} diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml new file mode 100644 index 0000000..5071a01 --- /dev/null +++ b/sdk/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "memona-plugin-sdk" +version = "0.1.0" +edition = "2024" +license = "MIT" + +[dependencies] +wit-bindgen = "=0.62.0" + +[lints] +workspace = true diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs new file mode 100644 index 0000000..cc09842 --- /dev/null +++ b/sdk/src/lib.rs @@ -0,0 +1,18 @@ +//! Rust guest bindings for the versioned Memona filesystem component contract. + +wit_bindgen::generate!({ + path: "wit", + world: "filesystem", + pub_export_macro: true, + default_bindings_module: "memona_plugin_sdk", +}); + +pub use exports::memona::filesystem::provider::{ + Capabilities, Change, ChangeKind, ChangePage, Entry, EntryKind, ErrorCode, + FileInfo, Guest, GuestReader, GuestWriter, ListPage, Locator, Reader, + WriteCondition, Writer, +}; +pub use memona::filesystem::host::{ + Exchange, Header, HostError, LogLevel, Method, Request, Response, Setting, + Tcp, Udp, bind, config, connect, http, invalidate_token, log, +}; diff --git a/sdk/wit/filesystem.wit b/sdk/wit/filesystem.wit new file mode 100644 index 0000000..560695e --- /dev/null +++ b/sdk/wit/filesystem.wit @@ -0,0 +1,215 @@ +package memona:filesystem@0.1.0; + +/// Host services scoped to the current connection. No ambient filesystem, +/// environment, sockets, credentials, or network permissions are inherited. +interface host { + /// Stable failures without backend messages or credential-bearing URLs. + enum host-error { denied, unavailable, invalid-input, cancelled, internal } + /// Severity of a diagnostic event. The host may discard message contents. + enum log-level { debug, info, warn, error } + + /// Every method the host will send. A verb outside this list cannot be + /// spelled, so no plugin can smuggle one past the transport rules. + enum method { + get, head, post, put, patch, delete, + propfind, proppatch, mkcol, copy, move, lock, unlock, report, + } + /// An HTTP header. Host-controlled transport headers cannot be overridden. + record header { name: string, value: string } + /// Request head. At most 64 headers of 8 KiB each; the body is streamed. + record request { method: method, url: string, headers: list
} + /// Response head. The body is read from the exchange that produced it. + record response { status: u16, headers: list
} + /// One HTTP request and its response, streamed in bounded chunks so a + /// transfer is never held whole in memory. A body of any size crosses in + /// pieces, which is what multipart uploads of at least 5 MiB require. + /// Dropping the handle abandons the request. + resource exchange { + /// Appends at most 1,048,576 bytes to the request body. Every push + /// must land before `respond`. + push: func(data: list) -> result<_, host-error>; + /// Ends the request body and waits for the response head. Calling it + /// twice is invalid-input. + respond: func() -> result; + /// Reads at most 1,048,576 bytes of the response body, after + /// `respond`. An empty result means the body is complete. + pull: func(limit: u32) -> result, host-error>; + } + /// Begins a request to a host both declared by the package and granted on + /// the connection. Each redirect and DNS result is checked, and DNS is + /// pinned. Private/LAN addresses additionally require the private-network + /// grant. Cross-origin redirects remove request headers; HTTPS cannot + /// downgrade. The host bounds total bytes per exchange, not buffer size. + http: func(request: request) -> result; + + /// One open TCP connection. Dropping the handle closes the socket, and the + /// host closes every socket when the connection is disposed. + resource tcp { + /// Reads at most `limit` bytes, between 1 and 1,048,576. An empty + /// result means the peer closed its side, not that data is pending. + read: func(limit: u32) -> result, host-error>; + /// Writes at most 1,048,576 bytes and returns how many were accepted. + write: func(data: list) -> result; + } + /// Opens a socket to a host and port both declared by the package and + /// granted on the connection. The host resolves and pins DNS, applies the + /// same private-address rule as `http`, and starts TLS when asked. Ports + /// negotiated at runtime, such as an FTP data channel, still need a grant. + connect: func(host: string, port: u16, tls: bool) -> result; + + /// One datagram socket connected to a single granted peer. + resource udp { + /// Sends one datagram of at most 65,507 bytes. + send: func(data: list) -> result; + /// Receives one datagram of at most `limit` bytes. An empty result + /// means none arrived inside the host's wait, not that the peer left. + receive: func(limit: u32) -> result, host-error>; + } + /// Connects a datagram socket under the same declaration and grant rules + /// as `connect`, for the few filesystem protocols that still need one. + bind: func(host: string, port: u16) -> result; + + /// A configuration value, typed as the package's manifest declared it. + /// A secret is never logged by the host and never leaves the connection. + variant setting { text(string), secret(string), toggle(bool) } + /// Reads configuration or a session-only credential for this connection. + config: func(key: string) -> option; + /// Reports that the current access token was rejected. The host refreshes + /// it once; a second rejection surfaces to the caller as denied. + invalidate-token: func(); + + /// Emits a diagnostic event. Never include secrets or private document data. + log: func(level: log-level, message: string); +} + +/// Filesystem operations for one connection. Paths are absolute within its +/// root, starting with `/`; they are never local OS paths or resource URIs. +/// Dot segments, backslashes, percent escapes, colons, and controls are invalid. +/// Every invocation is bounded by host fuel, memory, time, and cancellation. +interface provider { + use host.{setting}; + + /// Stable operation failures. A mutation interrupted after dispatch can + /// report outcome-unknown: inspect remote state before deciding to retry. + enum error-code { + not-found, denied, unsupported, conflict, unavailable, invalid-input, + cancelled, outcome-unknown, internal + } + /// Anything that is neither a file nor a directory, such as a link, is + /// reported as other rather than flattened into one of the two. + enum entry-kind { file, directory, other } + + /// An opaque version token, at most 4096 UTF-8 bytes. + type revision = string; + /// An opaque backend identity, at most 4096 UTF-8 bytes. The host never + /// parses, orders, or shows one, and never keeps one past the connection, + /// because a backend may reuse it. + type resource-id = string; + + /// How one resource is named. A provider that hands out ids must accept + /// them back; one that does not may reject every id as invalid-input. + variant locator { path(string), id(resource-id) } + + /// Optional operations. False capabilities must return unsupported. + /// Conditional-write promises atomic create-only and revision comparisons, + /// and cannot be combined with staged-write, where nothing to compare + /// exists until a publish lands. Virtual-directories says a directory is + /// only ever a prefix of something stored, as in a flat object store: the + /// host then never offers to create an empty one, warns that removing the + /// last child removes the folder, and does not call a vanished folder an + /// error. Such a provider may still accept mkdir by writing a marker, and + /// must answer unsupported when asked to rename a directory. + record capabilities { + write: bool, mkdir: bool, rename: bool, remove: bool, + conditional-write: bool, watch: bool, + staged-write: bool, publishable: bool, + virtual-directories: bool, + } + /// Byte length, an opaque revision, and a backend identity, each if known. + record file-info { + kind: entry-kind, + size: u64, + revision: option, + id: option, + } + /// One child name, without slashes, dot segments, or control characters. + record entry { name: string, info: file-info } + /// At most the requested number of entries. A cursor (maximum 4096 bytes) + /// identifies another page; absence means enumeration is complete. + record list-page { entries: list, cursor: option } + /// Atomic preconditions: absent file, exact opaque revision, or unconditional + /// replacement. Providers must never simulate comparisons non-atomically. + variant write-condition { create-only, if-match(revision), overwrite } + /// Changes since a previous polling cursor. + enum change-kind { created, modified, removed } + /// A changed resource path relative to the connection root. + record change { path: string, kind: change-kind } + /// At most 256 changes and a cursor of at most 4096 UTF-8 bytes. + record change-page { changes: list, cursor: option } + + /// One transfer being read. The handle survives between host calls, so a + /// file of any size is read as a series of separately bounded calls. + /// Dropping it releases whatever the provider held open. + resource reader { + /// Reads onward from the last position, at most `limit` bytes, with + /// `limit` between 1 and 1,048,576. An empty result means end of file. + pull: func(limit: u32) -> result, error-code>; + /// The revision this transfer is reading, once the provider knows it. + /// A provider that sees the revision change mid-transfer must fail the + /// next pull with conflict rather than mix two versions. + revision: func() -> option; + } + /// One transfer being written. Dropping the handle before it finishes + /// abandons the transfer, and the provider must leave no partial file. + resource writer { + /// Appends at most 1,048,576 bytes. Oversized pushes are rejected, + /// never truncated. + push: func(data: list) -> result<_, error-code>; + /// Commits the transfer and evaluates the write condition now, against + /// the state the caller last saw. A none result means the commit needs + /// another call: keep calling until it answers some, and treat an + /// interruption in between as outcome-unknown. + finish: func() -> result, error-code>; + } + + /// Opens exactly one host-owned connection per component instance. The host + /// passes only this connection's configuration and in-memory credentials. + open: func(provider-id: string, config: list>) -> result; + /// Best-effort cleanup before disposal. The host may dispose immediately + /// after a trap, cancellation, permission change, or process shutdown. + close: func(); + /// Reads metadata without changing the resource. + stat: func(target: locator) -> result; + /// Enumerates 1-256 direct children per request, without recursive traversal. + %list: func(target: locator, cursor: option, limit: u32) -> result; + /// Begins reading at a byte offset. Streaming, rather than one capped read, + /// is what lets a provider serve a file larger than a single call allows. + open-read: func(target: locator, offset: u64) -> result; + /// Begins replacing one complete file if the write capability is present. + /// The size hint, when the caller knows it, lets a provider choose between + /// a single request and a multipart upload before the first byte. + open-write: func(target: locator, condition: write-condition, size-hint: option) -> result; + /// Creates one directory if the mkdir capability is present. + mkdir: func(target: locator) -> result; + /// Renames within this connection if the rename capability is present. + rename: func(target: locator, destination: string) -> result; + /// Removes one file or empty directory if the remove capability is present. + remove: func(target: locator) -> result<_, error-code>; + /// Polls for a bounded change page if the watch capability is present. No + /// background callback or persistent subscription is installed by the host. + poll: func(cursor: option) -> result; + /// Makes every staged write visible as one new root, if the publishable + /// capability is present, and names it, for example as a content + /// identifier. A none result means the publish needs another call. + publish: func() -> result, error-code>; +} + +/// Memona filesystem extension ABI 0.1.0, distributed as a WebAssembly component. +/// Rust guests can target wasm32-wasip2. Selected WASI 0.2 CLI, clocks, random, +/// IO and filesystem interfaces are allowed for the standard library, with +/// empty arguments/environment, empty preopens, and discarded stdio. WASI +/// sockets and HTTP imports are not allowed; networking uses the host interface. +world filesystem { + import host; + export provider; +} diff --git a/src/control.rs b/src/control.rs new file mode 100644 index 0000000..d4dd3e2 --- /dev/null +++ b/src/control.rs @@ -0,0 +1,175 @@ +//! The FTP control channel: one socket, line replies, and passive transfers. + +use memona_plugin_sdk::ErrorCode; +use memona_plugin_sdk::memona::filesystem::host; + +const MAX_LINE: usize = 8192; +const MAX_REPLY_LINES: usize = 256; + +pub fn transport(error: host::HostError) -> ErrorCode { + match error { + host::HostError::Denied => ErrorCode::Denied, + host::HostError::Cancelled => ErrorCode::Cancelled, + host::HostError::InvalidInput => ErrorCode::InvalidInput, + host::HostError::Internal => ErrorCode::Internal, + host::HostError::Unavailable => ErrorCode::Unavailable, + } +} + +/// What an FTP reply code means to a filesystem caller. +pub fn outcome(code: u16) -> Result<(), ErrorCode> { + match code { + 200..=399 => Ok(()), + // 530 is a refused login and 550 covers both a missing file and a denied + // one; the server does not distinguish them, so neither does this. + 530 | 532 => Err(ErrorCode::Denied), + 550 => Err(ErrorCode::NotFound), + 450 | 452 | 552 => Err(ErrorCode::Conflict), + 502 | 504 => Err(ErrorCode::Unsupported), + _ => Err(ErrorCode::Unavailable), + } +} + +pub struct Control { + socket: host::Tcp, + host: String, + buffer: Vec, +} + +impl Control { + pub fn open( + host_name: String, + port: u16, + tls: bool, + ) -> Result { + let socket = host::connect(&host_name, port, tls).map_err(transport)?; + let mut control = Self { + socket, + host: host_name, + buffer: Vec::new(), + }; + // The greeting is a reply like any other, and a 4xx or 5xx one means the + // server is refusing the session before a command was even sent. + let (code, _) = control.reply()?; + outcome(code)?; + Ok(control) + } + + fn line(&mut self) -> Result { + loop { + if let Some(index) = self.buffer.iter().position(|byte| *byte == b'\n') { + let line: Vec = self.buffer.drain(..=index).collect(); + return Ok( + String::from_utf8_lossy(&line) + .trim_end_matches(['\r', '\n']) + .to_owned(), + ); + } + if self.buffer.len() > MAX_LINE { + return Err(ErrorCode::Unavailable); + } + let chunk = self.socket.read(4096).map_err(transport)?; + if chunk.is_empty() { + return Err(ErrorCode::Unavailable); + } + self.buffer.extend_from_slice(&chunk); + } + } + + /// Reads one complete reply, following the `NNN-` continuation form. + pub fn reply(&mut self) -> Result<(u16, String), ErrorCode> { + let first = self.line()?; + let code: u16 = first + .get(..3) + .and_then(|text| text.parse().ok()) + .ok_or(ErrorCode::Unavailable)?; + if first.as_bytes().get(3) != Some(&b'-') { + return Ok((code, first)); + } + let terminator = format!("{code} "); + for _ in 0..MAX_REPLY_LINES { + let line = self.line()?; + if line.starts_with(&terminator) { + return Ok((code, line)); + } + } + Err(ErrorCode::Unavailable) + } + + /// Sends one command. A newline in an argument would forge a second command, + /// so it is refused rather than escaped. + pub fn send(&mut self, command: &str) -> Result<(u16, String), ErrorCode> { + if command.contains(['\r', '\n']) || command.len() > MAX_LINE { + return Err(ErrorCode::InvalidInput); + } + let line = format!("{command}\r\n"); + let mut sent = 0; + while sent < line.len() { + let written = self + .socket + .write(&line.as_bytes()[sent..]) + .map_err(transport)? as usize; + if written == 0 { + return Err(ErrorCode::Unavailable); + } + sent += written; + } + self.reply() + } + + pub fn expect(&mut self, command: &str) -> Result { + let (code, text) = self.send(command)?; + outcome(code)?; + Ok(text) + } + + /// Opens a passive data connection, preferring EPSV so the address the + /// server names is never one this plugin could be redirected to. + pub fn passive(&mut self) -> Result { + let text = self.expect("EPSV")?; + let port = extended_port(&text).ok_or(ErrorCode::Unavailable)?; + host::connect(&self.host, port, false).map_err(transport) + } +} + +/// Reads the port out of `229 Entering Extended Passive Mode (|||1234|)`. +fn extended_port(text: &str) -> Option { + let start = text.find('(')?; + let end = text[start..].find(')')? + start; + let inner = text.get(start + 1..end)?; + let separator = inner.chars().next()?; + inner + .rsplit(separator) + .find(|part| !part.is_empty())? + .parse() + .ok() +} + +#[cfg(test)] +mod tests { + use super::{extended_port, outcome}; + use memona_plugin_sdk::ErrorCode; + + #[test] + fn reads_the_port_from_an_extended_passive_reply() { + assert_eq!( + extended_port("229 Entering Extended Passive Mode (|||49152|)"), + Some(49152) + ); + // The separator is whatever the server chose, not always a pipe. + assert_eq!(extended_port("229 ok (!!!1024!)"), Some(1024)); + assert_eq!(extended_port("229 ok (|||0|)"), Some(0)); + assert_eq!(extended_port("229 ok"), None); + assert_eq!(extended_port("229 ok (|||not-a-port|)"), None); + } + + #[test] + fn reply_codes_keep_refused_and_missing_apart() { + assert!(outcome(226).is_ok()); + assert!(matches!(outcome(530), Err(ErrorCode::Denied))); + assert!(matches!(outcome(550), Err(ErrorCode::NotFound))); + assert!(matches!(outcome(452), Err(ErrorCode::Conflict))); + assert!(matches!(outcome(502), Err(ErrorCode::Unsupported))); + assert!(matches!(outcome(421), Err(ErrorCode::Unavailable))); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..76b3a2a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,351 @@ +//! An FTP filesystem provider for Memona, speaking over the host's sockets. + +mod control; +mod listing; + +use control::{Control, outcome, transport}; +use listing::machine_entry; +use memona_plugin_sdk::memona::filesystem::host; +use memona_plugin_sdk::{ + Capabilities, ChangePage, Entry, EntryKind, ErrorCode, FileInfo, Guest, + GuestReader, GuestWriter, ListPage, Locator, Reader, Setting, WriteCondition, + Writer, +}; +use std::cell::RefCell; + +const MAX_CHUNK: u32 = 1024 * 1024; +const MAX_ENTRIES: usize = 4096; + +thread_local! { + static CONTROL: RefCell> = const { RefCell::new(None) }; +} + +struct Ftp; + +fn text(setting: Option<&Setting>) -> Option { + match setting { + Some(Setting::Text(value)) | Some(Setting::Secret(value)) => { + let value = value.trim().to_owned(); + (!value.is_empty()).then_some(value) + } + Some(Setting::Toggle(_)) | None => None, + } +} + +/// The path a locator names. FTP addresses by path only. +fn route(target: &Locator) -> Result { + match target { + Locator::Path(path) => Ok(path.clone()), + Locator::Id(_) => Err(ErrorCode::InvalidInput), + } +} + +fn with_control( + action: impl FnOnce(&mut Control) -> Result, +) -> Result { + CONTROL.with_borrow_mut(|slot| match slot.as_mut() { + Some(control) => action(control), + None => Err(ErrorCode::Internal), + }) +} + +/// Starts a passive transfer and leaves the data socket open for the caller. +fn transfer(command: &str) -> Result { + with_control(|control| { + let data = control.passive()?; + let (code, _) = control.send(command)?; + outcome(code)?; + Ok(data) + }) +} + +/// Consumes the reply that follows a completed transfer, so the next command +/// does not read someone else's answer. +fn settle() -> Result<(), ErrorCode> { + with_control(|control| { + let (code, _) = control.reply()?; + outcome(code) + }) +} + +/// Reads a data socket to the end, bounded so one listing cannot grow forever. +fn drain(socket: &host::Tcp, limit: usize) -> Result, ErrorCode> { + let mut data = Vec::new(); + loop { + let chunk = socket.read(MAX_CHUNK).map_err(transport)?; + if chunk.is_empty() { + return Ok(data); + } + data.extend_from_slice(&chunk); + if data.len() > limit { + return Err(ErrorCode::Unavailable); + } + } +} + +/// Reads one file from a passive data socket. +struct FtpReader { + socket: host::Tcp, + settled: RefCell, +} + +impl GuestReader for FtpReader { + fn pull(&self, limit: u32) -> Result, ErrorCode> { + if *self.settled.borrow() { + return Ok(Vec::new()); + } + let data = self.socket.read(limit.min(MAX_CHUNK)).map_err(transport)?; + if data.is_empty() { + *self.settled.borrow_mut() = true; + settle()?; + } + Ok(data) + } + /// FTP has no version token, so there is nothing honest to return. + fn revision(&self) -> Option { + None + } +} + +/// Writes one file to a passive data socket. +struct FtpWriter { + socket: host::Tcp, + done: RefCell, +} + +impl GuestWriter for FtpWriter { + fn push(&self, data: Vec) -> Result<(), ErrorCode> { + if *self.done.borrow() { + return Err(ErrorCode::InvalidInput); + } + let mut sent = 0; + while sent < data.len() { + let written = + self.socket.write(&data[sent..]).map_err(transport)? as usize; + if written == 0 { + return Err(ErrorCode::Unavailable); + } + sent += written; + } + Ok(()) + } + /// Closing the data socket is what ends an FTP upload, so the handle is + /// dropped here and the control reply read once. + fn finish(&self) -> Result, ErrorCode> { + if *self.done.borrow() { + return Err(ErrorCode::InvalidInput); + } + *self.done.borrow_mut() = true; + settle()?; + Ok(Some(FileInfo { + kind: EntryKind::File, + size: 0, + revision: None, + id: None, + })) + } +} + +impl Guest for Ftp { + type Reader = FtpReader; + type Writer = FtpWriter; + + fn open( + _provider_id: String, + config: Vec<(String, Setting)>, + ) -> Result { + let lookup = |wanted: &str| { + config + .iter() + .find(|(key, _)| key == wanted) + .and_then(|(_, value)| text(Some(value))) + }; + let host_name = lookup("host").ok_or(ErrorCode::InvalidInput)?; + let port: u16 = lookup("port") + .as_deref() + .unwrap_or("21") + .parse() + .map_err(|_| ErrorCode::InvalidInput)?; + let tls = matches!( + config.iter().find(|(key, _)| key == "tls").map(|(_, v)| v), + Some(Setting::Toggle(true)) + ); + let mut control = Control::open(host_name, port, tls)?; + let user = lookup("username").unwrap_or_else(|| "anonymous".to_owned()); + let (code, _) = control.send(&format!("USER {user}"))?; + if code == 331 { + let password = lookup("password").unwrap_or_default(); + let (code, _) = control.send(&format!("PASS {password}"))?; + outcome(code)?; + } else { + outcome(code)?; + } + // Binary throughout: text mode would rewrite line endings inside files. + control.expect("TYPE I")?; + CONTROL.set(Some(control)); + Ok(Capabilities { + write: true, + mkdir: true, + rename: true, + remove: true, + // FTP has no precondition on a store, so a conditional write cannot be + // promised without simulating one, which the contract forbids. + conditional_write: false, + watch: false, + staged_write: false, + publishable: false, + virtual_directories: false, + }) + } + + fn close() { + CONTROL.with_borrow_mut(|slot| { + if let Some(control) = slot.as_mut() { + let _ = control.send("QUIT"); + } + *slot = None; + }); + } + + /// FTP reports a size and a directory separately, so a stat is SIZE first + /// and a directory change only when that fails. + fn stat(target: Locator) -> Result { + let path = route(&target)?; + if path == "/" { + return Ok(FileInfo { + kind: EntryKind::Directory, + size: 0, + revision: None, + id: None, + }); + } + with_control(|control| { + let (code, text) = control.send(&format!("SIZE {path}"))?; + if (200..300).contains(&code) { + let size = text + .split_whitespace() + .nth(1) + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + return Ok(FileInfo { + kind: EntryKind::File, + size, + revision: None, + id: None, + }); + } + let (code, _) = control.send(&format!("CWD {path}"))?; + outcome(code)?; + Ok(FileInfo { + kind: EntryKind::Directory, + size: 0, + revision: None, + id: None, + }) + }) + } + + /// MLSD is the machine-readable listing; a server without it cannot be read + /// reliably, so this reports unsupported rather than guessing at LIST text. + fn list( + target: Locator, + cursor: Option, + limit: u32, + ) -> Result { + let path = route(&target)?; + let start: usize = match cursor { + Some(value) => value.parse().map_err(|_| ErrorCode::InvalidInput)?, + None => 0, + }; + let socket = transfer(&format!("MLSD {path}"))?; + let data = drain(&socket, 8 * 1024 * 1024)?; + drop(socket); + settle()?; + let children: Vec = String::from_utf8_lossy(&data) + .lines() + .filter_map(machine_entry) + .take(MAX_ENTRIES) + .collect(); + let end = start.saturating_add(limit as usize).min(children.len()); + let entries = children.get(start..end).unwrap_or_default().to_vec(); + Ok(ListPage { + entries, + cursor: (end < children.len()).then(|| end.to_string()), + }) + } + + fn open_read(target: Locator, offset: u64) -> Result { + let path = route(&target)?; + if offset > 0 { + with_control(|control| control.expect(&format!("REST {offset}")))?; + } + let socket = transfer(&format!("RETR {path}"))?; + Ok(Reader::new(FtpReader { + socket, + settled: RefCell::new(false), + })) + } + + fn open_write( + target: Locator, + condition: WriteCondition, + _size_hint: Option, + ) -> Result { + let path = route(&target)?; + if !matches!(condition, WriteCondition::Overwrite) { + return Err(ErrorCode::Unsupported); + } + let socket = transfer(&format!("STOR {path}"))?; + Ok(Writer::new(FtpWriter { + socket, + done: RefCell::new(false), + })) + } + + fn mkdir(target: Locator) -> Result { + let path = route(&target)?; + with_control(|control| control.expect(&format!("MKD {path}")))?; + Ok(FileInfo { + kind: EntryKind::Directory, + size: 0, + revision: None, + id: None, + }) + } + + fn rename( + target: Locator, + destination: String, + ) -> Result { + let path = route(&target)?; + with_control(|control| { + control.expect(&format!("RNFR {path}"))?; + control.expect(&format!("RNTO {destination}")) + })?; + Self::stat(Locator::Path(destination)) + } + + /// A file and an empty directory take different commands, and the server + /// only says which was wrong after the attempt. + fn remove(target: Locator) -> Result<(), ErrorCode> { + let path = route(&target)?; + with_control(|control| { + let (code, _) = control.send(&format!("DELE {path}"))?; + if (200..300).contains(&code) { + return Ok(()); + } + let (code, _) = control.send(&format!("RMD {path}"))?; + outcome(code) + }) + } + + fn poll(_cursor: Option) -> Result { + Err(ErrorCode::Unsupported) + } + + fn publish() -> Result, ErrorCode> { + Err(ErrorCode::Unsupported) + } +} + +memona_plugin_sdk::export!(Ftp); diff --git a/src/listing.rs b/src/listing.rs new file mode 100644 index 0000000..fe46573 --- /dev/null +++ b/src/listing.rs @@ -0,0 +1,79 @@ +//! Reading an MLSD listing, which is the one machine-readable form FTP has. + +use memona_plugin_sdk::{Entry, EntryKind, FileInfo}; + +/// One line of an MLSD listing: `fact=value;...; name`. +pub fn machine_entry(line: &str) -> Option { + let (facts, name) = line.split_once(' ')?; + let name = name.trim(); + if name.is_empty() || name == "." || name == ".." { + return None; + } + let mut kind = EntryKind::Other; + let mut size = 0u64; + for fact in facts.split(';').filter(|fact| !fact.is_empty()) { + let (key, value) = fact.split_once('=')?; + match key.to_ascii_lowercase().as_str() { + "type" => { + kind = match value.to_ascii_lowercase().as_str() { + "file" => EntryKind::File, + "dir" | "cdir" | "pdir" => EntryKind::Directory, + _ => EntryKind::Other, + }; + } + "size" => size = value.parse().unwrap_or(0), + _ => {} + } + } + if matches!(kind, EntryKind::Directory) && name.starts_with('.') { + // A listing may repeat the directory itself; its own name is never a child. + return None; + } + Some(Entry { + name: name.to_owned(), + info: FileInfo { + kind, + size, + revision: None, + id: None, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::machine_entry; + use memona_plugin_sdk::EntryKind; + + #[test] + fn reads_kind_and_size_and_skips_the_directory_itself() + -> Result<(), &'static str> { + let file = + machine_entry("type=file;size=1234;modify=20260101000000; notes.md") + .ok_or("a file entry")?; + assert_eq!(file.name, "notes.md"); + assert_eq!(file.info.size, 1234); + assert!(matches!(file.info.kind, EntryKind::File)); + + let directory = + machine_entry("type=dir;sizd=4096; papers").ok_or("a directory entry")?; + assert!(matches!(directory.info.kind, EntryKind::Directory)); + assert_eq!(directory.info.size, 0); + + // A name of its own or its parent is not a child of the listing. + assert!(machine_entry("type=cdir; .").is_none()); + assert!(machine_entry("type=pdir; ..").is_none()); + assert!(machine_entry("type=file;size=1; ").is_none()); + assert!(machine_entry("no-space-anywhere").is_none()); + Ok(()) + } + + #[test] + fn an_unknown_type_is_reported_rather_than_guessed() + -> Result<(), &'static str> { + let entry = machine_entry("type=OS.unix=slink:/elsewhere;size=0; link") + .ok_or("an entry")?; + assert!(matches!(entry.info.kind, EntryKind::Other)); + Ok(()) + } +}