commit 95a2b1a08679006d3755fd856caf862ae5dc12ed Author: Danny Kim Date: Fri Sep 25 11:57:46 2026 +0900 Open an FTP or FTPS server as a folder in Memona 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(()) + } +}