Open an FTP or FTPS server as a folder in Memona
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
warn-on-all-wildcard-imports = true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/target/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
style_edition = "2024"
|
||||||
|
max_width = 80
|
||||||
|
tab_spaces = 2
|
||||||
Generated
+333
@@ -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"
|
||||||
+27
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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<header> }
|
||||||
|
/// Response head. The body is read from the exchange that produced it.
|
||||||
|
record response { status: u16, headers: list<header> }
|
||||||
|
/// 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<u8>) -> result<_, host-error>;
|
||||||
|
/// Ends the request body and waits for the response head. Calling it
|
||||||
|
/// twice is invalid-input.
|
||||||
|
respond: func() -> result<response, host-error>;
|
||||||
|
/// 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<list<u8>, 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<exchange, host-error>;
|
||||||
|
|
||||||
|
/// 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<list<u8>, host-error>;
|
||||||
|
/// Writes at most 1,048,576 bytes and returns how many were accepted.
|
||||||
|
write: func(data: list<u8>) -> result<u32, host-error>;
|
||||||
|
}
|
||||||
|
/// 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<tcp, host-error>;
|
||||||
|
|
||||||
|
/// One datagram socket connected to a single granted peer.
|
||||||
|
resource udp {
|
||||||
|
/// Sends one datagram of at most 65,507 bytes.
|
||||||
|
send: func(data: list<u8>) -> result<u32, host-error>;
|
||||||
|
/// 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<list<u8>, 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<udp, host-error>;
|
||||||
|
|
||||||
|
/// 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<setting>;
|
||||||
|
/// 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<revision>,
|
||||||
|
id: option<resource-id>,
|
||||||
|
}
|
||||||
|
/// 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<entry>, cursor: option<string> }
|
||||||
|
/// 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<change>, cursor: option<string> }
|
||||||
|
|
||||||
|
/// 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<list<u8>, 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<revision>;
|
||||||
|
}
|
||||||
|
/// 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<u8>) -> 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<option<file-info>, 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<tuple<string, setting>>) -> result<capabilities, error-code>;
|
||||||
|
/// 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<file-info, error-code>;
|
||||||
|
/// Enumerates 1-256 direct children per request, without recursive traversal.
|
||||||
|
%list: func(target: locator, cursor: option<string>, limit: u32) -> result<list-page, error-code>;
|
||||||
|
/// 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<reader, error-code>;
|
||||||
|
/// 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<u64>) -> result<writer, error-code>;
|
||||||
|
/// Creates one directory if the mkdir capability is present.
|
||||||
|
mkdir: func(target: locator) -> result<file-info, error-code>;
|
||||||
|
/// Renames within this connection if the rename capability is present.
|
||||||
|
rename: func(target: locator, destination: string) -> result<file-info, error-code>;
|
||||||
|
/// 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<string>) -> result<change-page, error-code>;
|
||||||
|
/// 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<option<revision>, 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;
|
||||||
|
}
|
||||||
+175
@@ -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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Control {
|
||||||
|
pub fn open(
|
||||||
|
host_name: String,
|
||||||
|
port: u16,
|
||||||
|
tls: bool,
|
||||||
|
) -> Result<Self, ErrorCode> {
|
||||||
|
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<String, ErrorCode> {
|
||||||
|
loop {
|
||||||
|
if let Some(index) = self.buffer.iter().position(|byte| *byte == b'\n') {
|
||||||
|
let line: Vec<u8> = 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<String, ErrorCode> {
|
||||||
|
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<host::Tcp, ErrorCode> {
|
||||||
|
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<u16> {
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+351
@@ -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<Option<Control>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Ftp;
|
||||||
|
|
||||||
|
fn text(setting: Option<&Setting>) -> Option<String> {
|
||||||
|
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<String, ErrorCode> {
|
||||||
|
match target {
|
||||||
|
Locator::Path(path) => Ok(path.clone()),
|
||||||
|
Locator::Id(_) => Err(ErrorCode::InvalidInput),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_control<T>(
|
||||||
|
action: impl FnOnce(&mut Control) -> Result<T, ErrorCode>,
|
||||||
|
) -> Result<T, ErrorCode> {
|
||||||
|
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<host::Tcp, ErrorCode> {
|
||||||
|
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<Vec<u8>, 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<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GuestReader for FtpReader {
|
||||||
|
fn pull(&self, limit: u32) -> Result<Vec<u8>, 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<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes one file to a passive data socket.
|
||||||
|
struct FtpWriter {
|
||||||
|
socket: host::Tcp,
|
||||||
|
done: RefCell<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GuestWriter for FtpWriter {
|
||||||
|
fn push(&self, data: Vec<u8>) -> 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<Option<FileInfo>, 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<Capabilities, ErrorCode> {
|
||||||
|
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<FileInfo, ErrorCode> {
|
||||||
|
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<String>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<ListPage, ErrorCode> {
|
||||||
|
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<Entry> = 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<Reader, ErrorCode> {
|
||||||
|
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<u64>,
|
||||||
|
) -> Result<Writer, ErrorCode> {
|
||||||
|
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<FileInfo, ErrorCode> {
|
||||||
|
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<FileInfo, ErrorCode> {
|
||||||
|
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<String>) -> Result<ChangePage, ErrorCode> {
|
||||||
|
Err(ErrorCode::Unsupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish() -> Result<Option<String>, ErrorCode> {
|
||||||
|
Err(ErrorCode::Unsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memona_plugin_sdk::export!(Ftp);
|
||||||
@@ -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<Entry> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user