Open a WebDAV server as a folder under the signature plugin #1
@@ -0,0 +1 @@
|
|||||||
|
warn-on-all-wildcard-imports = true
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: Agents
|
||||||
|
run-name: >-
|
||||||
|
AI bot at
|
||||||
|
#${{ gitea.event.issue.number || gitea.event.pull_request.number }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
pull_request:
|
||||||
|
types: [opened, review_requested]
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: >-
|
||||||
|
agents-${{ gitea.event.sender.login == 'bot' && gitea.run_id ||
|
||||||
|
gitea.event.issue.number || gitea.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
agent:
|
||||||
|
if: >-
|
||||||
|
gitea.actor != 'bot' && gitea.event.sender.login != 'bot' &&
|
||||||
|
((gitea.event_name == 'issues' &&
|
||||||
|
contains(gitea.event.issue.body, '@bot')) ||
|
||||||
|
(gitea.event_name == 'pull_request' &&
|
||||||
|
((gitea.event.action == 'opened' &&
|
||||||
|
contains(gitea.event.pull_request.body, '@bot')) ||
|
||||||
|
gitea.event.requested_reviewer.login == 'bot')) ||
|
||||||
|
(gitea.event_name == 'issue_comment' &&
|
||||||
|
contains(gitea.event.comment.body, '@bot')) ||
|
||||||
|
(gitea.event_name == 'pull_request_review_comment' &&
|
||||||
|
contains(gitea.event.review.content, '@bot')))
|
||||||
|
permissions:
|
||||||
|
code: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
runs-on: self-hosted
|
||||||
|
container:
|
||||||
|
volumes:
|
||||||
|
- bot-home:/root
|
||||||
|
steps:
|
||||||
|
- name: Run AI bot
|
||||||
|
uses: https://git.capsulizers.com/commons/bot-agents@main
|
||||||
|
with:
|
||||||
|
master-key: ${{ secrets.BOT_AGENTS_MASTER_KEY }}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/target/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
style_edition = "2024"
|
||||||
|
max_width = 80
|
||||||
|
tab_spaces = 2
|
||||||
Generated
+336
@@ -0,0 +1,336 @@
|
|||||||
|
# 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-signature"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"quick-xml",
|
||||||
|
"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 = "quick-xml"
|
||||||
|
version = "0.37.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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"
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "memona-plugin-signature"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license = "MIT"
|
||||||
|
description = "A WebDAV folder for Memona that signs Markdown files with an ENS name, built as a WASI component."
|
||||||
|
repository = "https://git.capsulizers.com/commons/memona-plugin-signature"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wit-bindgen = "=0.62.0"
|
||||||
|
quick-xml = { version = "0.37", default-features = false }
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
panic = "deny"
|
||||||
|
unwrap_used = "deny"
|
||||||
|
expect_used = "deny"
|
||||||
|
wildcard_imports = "deny"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
debug = 0
|
||||||
@@ -1,7 +1,68 @@
|
|||||||
# Memona signature plugin
|
# Memona signature plugin
|
||||||
|
|
||||||
Opens a WebDAV server as a folder inside Memona and signs every Markdown file
|
Opens a WebDAV server as a folder inside Memona. It is the foundation for
|
||||||
|
|
|||||||
saved to it with an ENS name, writing `signer` and `signature` into the file's
|
signing Markdown files with an ENS name as they are saved, writing `signer` and
|
||||||
frontmatter so anyone can check who wrote it against the name's current ENSv2
|
`signature` into each file's frontmatter so anyone can check who wrote it
|
||||||
records. Signing and verification come from the
|
against the name's current ENSv2 records, using the
|
||||||
[md-ens-signature](https://github.com/capsulizers/md-ens-signature) library.
|
[md-ens-signature](https://github.com/capsulizers/md-ens-signature) library.
|
||||||
|
This version is the WebDAV folder alone; signing is not in it yet.
|
||||||
|
|
||||||
|
The plugin is a WASI component: it speaks no sockets of its own and makes every
|
||||||
|
request through the host's HTTP interface, under the connection's permissions.
|
||||||
|
It registers one provider, `signature`, for the `signed-webdav` scheme.
|
||||||
|
|
||||||
|
## Connecting
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| Server address | The folder's address, for example `https://dav.example.com/files/` |
|
||||||
|
| Username | Optional account name for HTTP basic authentication |
|
||||||
|
| Password | Optional password, stored as a secret |
|
||||||
|
|
||||||
|
Listing, reading, writing, creating directories, renaming and deleting behave
|
||||||
|
as in the WebDAV plugin: reads and writes stream in pieces of at most 1 MiB,
|
||||||
|
conditional writes use `If-Match` and `If-None-Match`, change polling is not
|
||||||
|
supported, a rename never replaces an existing target, and a folder with
|
||||||
|
contents is refused rather than removed whole.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rustup target add wasm32-wasip2
|
||||||
|
cargo build --locked --release --target wasm32-wasip2
|
||||||
|
cargo test --locked --lib
|
||||||
|
```
|
||||||
|
|
||||||
|
The component lands at
|
||||||
|
`target/wasm32-wasip2/release/memona_plugin_signature.wasm`.
|
||||||
|
|
||||||
|
`wit/filesystem.wit` is a copy of the canonical Memona filesystem contract, and
|
||||||
|
`src/sdk.rs` generates the guest bindings from it, so this repository builds
|
||||||
|
without a Memona checkout. Refresh the copy from `plugin_system/sdk/wit` in the
|
||||||
|
[Memona repository](https://git.capsulizers.com/lead/memona) when the contract
|
||||||
|
moves, and keep `apiVersion` in `manifest.json` in step.
|
||||||
|
|
||||||
|
## Packaging for the plugin store
|
||||||
|
|
||||||
|
A `.memona-plugin` package is a ZIP archive with the files at its root, never
|
||||||
|
inside a directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --locked --release --target wasm32-wasip2
|
||||||
|
cp target/wasm32-wasip2/release/memona_plugin_signature.wasm plugin.wasm
|
||||||
|
zip memona-plugin-signature.memona-plugin \
|
||||||
|
manifest.json plugin.wasm README.md LICENSE
|
||||||
|
```
|
||||||
|
|
||||||
|
`manifest.json` and `plugin.wasm` are required; `README.md` becomes the guide on
|
||||||
|
the plugin's public page and `LICENSE` is shown beside it.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
The WebDAV code in `src/lib.rs`, `src/dav.rs` and `src/sdk.rs` is copied from
|
||||||
|
the MIT-licensed
|
||||||
|
[Memona WebDAV plugin](https://git.capsulizers.com/commons/memona-plugin-webdav)
|
||||||
|
at commit `3d69750`. That crate keeps its provider, reader and writer private
|
||||||
|
and exports the component's entry points itself, so it cannot be wrapped as a
|
||||||
|
dependency without forking it; the copy stays as close to the original as the
|
||||||
|
signing hook allows, so fixes there carry over with a plain diff.
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "memona/signature",
|
||||||
|
"name": "Signature",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"apiVersion": "0.1.0",
|
||||||
|
"description": "Open a WebDAV server as a folder, and sign the Markdown saved to it with an ENS name.",
|
||||||
|
"license": "MIT",
|
||||||
|
"entry": "plugin.wasm",
|
||||||
|
"networkHosts": ["*"],
|
||||||
|
"networkPorts": [],
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"id": "signature",
|
||||||
|
"name": "Signed WebDAV",
|
||||||
|
"schemes": ["signed-webdav"],
|
||||||
|
"configFields": [
|
||||||
|
{
|
||||||
|
"key": "url",
|
||||||
|
"label": "Server address",
|
||||||
|
"description": "The folder's address, for example https://dav.example.com/files/",
|
||||||
|
"kind": "text",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "username",
|
||||||
|
"label": "Username",
|
||||||
|
"kind": "text",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "password",
|
||||||
|
"label": "Password",
|
||||||
|
"kind": "password",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+417
@@ -0,0 +1,417 @@
|
|||||||
|
//! WebDAV wire details: addressing, credentials, and the one XML reply shape
|
||||||
|
//! this provider reads.
|
||||||
|
|
||||||
|
use crate::sdk::ErrorCode;
|
||||||
|
|
||||||
|
const UNRESERVED: &[u8] = b"-._~";
|
||||||
|
const BASE64: &[u8; 64] =
|
||||||
|
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
/// Percent-encodes one path segment. Slashes are excluded deliberately: the
|
||||||
|
/// caller joins segments, so an encoded name can never grow a path boundary.
|
||||||
|
fn segment(value: &str) -> String {
|
||||||
|
let mut encoded = String::with_capacity(value.len());
|
||||||
|
for byte in value.as_bytes() {
|
||||||
|
if byte.is_ascii_alphanumeric() || UNRESERVED.contains(byte) {
|
||||||
|
encoded.push(*byte as char);
|
||||||
|
} else {
|
||||||
|
encoded.push_str(&format!("%{byte:02X}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Joins a connection-relative path onto the configured root.
|
||||||
|
pub fn url(root: &str, path: &str) -> String {
|
||||||
|
let mut url = root.trim_end_matches('/').to_owned();
|
||||||
|
for part in path.split('/').filter(|part| !part.is_empty()) {
|
||||||
|
url.push('/');
|
||||||
|
url.push_str(&segment(part));
|
||||||
|
}
|
||||||
|
if path.ends_with('/') && path.len() > 1 {
|
||||||
|
url.push('/');
|
||||||
|
}
|
||||||
|
url
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn basic(username: &str, password: &str) -> String {
|
||||||
|
let raw = format!("{username}:{password}");
|
||||||
|
let bytes = raw.as_bytes();
|
||||||
|
let mut encoded = String::from("Basic ");
|
||||||
|
for chunk in bytes.chunks(3) {
|
||||||
|
let mut block = [0u8; 3];
|
||||||
|
block[..chunk.len()].copy_from_slice(chunk);
|
||||||
|
let packed = u32::from(block[0]) << 16
|
||||||
|
| u32::from(block[1]) << 8
|
||||||
|
| u32::from(block[2]);
|
||||||
|
for index in 0..4 {
|
||||||
|
if index <= chunk.len() {
|
||||||
|
let position = (packed >> (18 - index * 6)) & 0x3f;
|
||||||
|
encoded.push(BASE64[position as usize] as char);
|
||||||
|
} else {
|
||||||
|
encoded.push('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a WebDAV status means to a filesystem caller. A body is never shown to
|
||||||
|
/// the user, so only the code is translated.
|
||||||
|
pub fn outcome(status: u16) -> Result<(), ErrorCode> {
|
||||||
|
match status {
|
||||||
|
200..=299 => Ok(()),
|
||||||
|
401 | 403 => Err(ErrorCode::Denied),
|
||||||
|
404 | 410 => Err(ErrorCode::NotFound),
|
||||||
|
405 | 409 | 412 => Err(ErrorCode::Conflict),
|
||||||
|
// A lock is someone else holding the resource, which is a conflict to
|
||||||
|
// resolve rather than a permission to grant.
|
||||||
|
423 => Err(ErrorCode::Conflict),
|
||||||
|
501 => Err(ErrorCode::Unsupported),
|
||||||
|
_ => Err(ErrorCode::Unavailable),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a ranged GET's status means to a reader: whether a body follows. A
|
||||||
|
/// range that starts at the end of the file draws 416 from Apache and most
|
||||||
|
/// other servers, which for a reader is simply end of file, not a failure.
|
||||||
|
pub fn range_outcome(status: u16) -> Result<bool, ErrorCode> {
|
||||||
|
match status {
|
||||||
|
416 => Ok(false),
|
||||||
|
_ => outcome(status).map(|()| true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A response body served in pieces no larger than the caller asked for. The
|
||||||
|
/// host may hand over more than one pull wanted, so the surplus waits here for
|
||||||
|
/// the next pull rather than being dropped or passed through oversize.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Body {
|
||||||
|
spare: Vec<u8>,
|
||||||
|
ended: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Body {
|
||||||
|
/// A body that was never sent: every pull reports end of file.
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
spare: Vec::new(),
|
||||||
|
ended: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns at most `limit` bytes, drawing on `next` until the limit is met
|
||||||
|
/// or the body ends. `next` is asked for the shortfall and may answer with
|
||||||
|
/// more or less than that.
|
||||||
|
pub fn pull(
|
||||||
|
&mut self,
|
||||||
|
limit: usize,
|
||||||
|
mut next: impl FnMut(u32) -> Result<Vec<u8>, ErrorCode>,
|
||||||
|
) -> Result<Vec<u8>, ErrorCode> {
|
||||||
|
let mut data = std::mem::take(&mut self.spare);
|
||||||
|
while data.len() < limit && !self.ended {
|
||||||
|
let chunk = next((limit - data.len()) as u32)?;
|
||||||
|
if chunk.is_empty() {
|
||||||
|
self.ended = true;
|
||||||
|
} else {
|
||||||
|
data.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data.len() > limit {
|
||||||
|
self.spare = data.split_off(limit);
|
||||||
|
}
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry from a multistatus reply.
|
||||||
|
pub struct Found {
|
||||||
|
pub href: String,
|
||||||
|
pub collection: bool,
|
||||||
|
pub length: u64,
|
||||||
|
pub etag: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the `response` elements of a multistatus body, keeping only the four
|
||||||
|
/// properties this provider asked for. Unknown elements and namespaces other
|
||||||
|
/// than DAV: are skipped rather than rejected, because servers add their own.
|
||||||
|
pub fn multistatus(body: &[u8]) -> Result<Vec<Found>, ErrorCode> {
|
||||||
|
let mut reader = quick_xml::Reader::from_reader(body);
|
||||||
|
reader.config_mut().trim_text(true);
|
||||||
|
let mut found = Vec::new();
|
||||||
|
let mut current: Option<Found> = None;
|
||||||
|
let mut field = None;
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
loop {
|
||||||
|
let event = reader
|
||||||
|
.read_event_into(&mut buffer)
|
||||||
|
.map_err(|_| ErrorCode::Unavailable)?;
|
||||||
|
match event {
|
||||||
|
quick_xml::events::Event::Eof => break,
|
||||||
|
quick_xml::events::Event::Start(tag) => match local(tag.name().0) {
|
||||||
|
b"response" => {
|
||||||
|
current = Some(Found {
|
||||||
|
href: String::new(),
|
||||||
|
collection: false,
|
||||||
|
length: 0,
|
||||||
|
etag: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
b"collection" => {
|
||||||
|
if let Some(entry) = current.as_mut() {
|
||||||
|
entry.collection = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name @ (b"href" | b"getcontentlength" | b"getetag") => {
|
||||||
|
field = Some(name.to_vec());
|
||||||
|
}
|
||||||
|
_ => field = None,
|
||||||
|
},
|
||||||
|
quick_xml::events::Event::Empty(tag) => {
|
||||||
|
if local(tag.name().0) == b"collection"
|
||||||
|
&& let Some(entry) = current.as_mut()
|
||||||
|
{
|
||||||
|
entry.collection = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quick_xml::events::Event::Text(text) => {
|
||||||
|
let (Some(entry), Some(name)) = (current.as_mut(), field.as_deref())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = text.unescape().map_err(|_| ErrorCode::Unavailable)?;
|
||||||
|
match name {
|
||||||
|
b"href" => entry.href = value.into_owned(),
|
||||||
|
b"getcontentlength" => {
|
||||||
|
entry.length = value.trim().parse().unwrap_or(0);
|
||||||
|
}
|
||||||
|
b"getetag" => entry.etag = Some(value.trim().to_owned()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quick_xml::events::Event::End(tag) => {
|
||||||
|
field = None;
|
||||||
|
if local(tag.name().0) == b"response"
|
||||||
|
&& let Some(entry) = current.take()
|
||||||
|
{
|
||||||
|
found.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
buffer.clear();
|
||||||
|
if found.len() > 4096 {
|
||||||
|
return Err(ErrorCode::Unavailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips any namespace prefix, so `d:href` and `D:href` read the same.
|
||||||
|
fn local(name: &[u8]) -> &[u8] {
|
||||||
|
match name.iter().position(|byte| *byte == b':') {
|
||||||
|
Some(index) => &name[index + 1..],
|
||||||
|
None => name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last non-empty segment of an href, percent-decoded.
|
||||||
|
pub fn name_of(href: &str) -> String {
|
||||||
|
let trimmed = href.trim_end_matches('/');
|
||||||
|
let tail = trimmed.rsplit('/').next().unwrap_or(trimmed);
|
||||||
|
let bytes = tail.as_bytes();
|
||||||
|
let mut decoded = Vec::with_capacity(bytes.len());
|
||||||
|
let mut index = 0;
|
||||||
|
while index < bytes.len() {
|
||||||
|
if bytes[index] == b'%' && index + 2 < bytes.len() {
|
||||||
|
let high = (bytes[index + 1] as char).to_digit(16);
|
||||||
|
let low = (bytes[index + 2] as char).to_digit(16);
|
||||||
|
if let (Some(high), Some(low)) = (high, low) {
|
||||||
|
decoded.push((high * 16 + low) as u8);
|
||||||
|
index += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decoded.push(bytes[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&decoded).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The direct children in a Depth 1 reply. The first response describes the
|
||||||
|
/// resource asked about, so it must be a collection before the rest can be
|
||||||
|
/// called its children.
|
||||||
|
pub fn children(found: &[Found]) -> Result<&[Found], ErrorCode> {
|
||||||
|
match found.split_first() {
|
||||||
|
None => Err(ErrorCode::NotFound),
|
||||||
|
Some((first, rest)) if first.collection => Ok(rest),
|
||||||
|
Some(_) => Err(ErrorCode::InvalidInput),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a Depth 1 reply describes something `remove` may delete: a file,
|
||||||
|
/// or a collection with no children. DELETE on a collection is recursive in
|
||||||
|
/// WebDAV, so this check is the only thing keeping a whole tree.
|
||||||
|
pub fn removable(found: &[Found]) -> Result<(), ErrorCode> {
|
||||||
|
match children(found) {
|
||||||
|
Ok([]) => Ok(()),
|
||||||
|
Ok(_) => Err(ErrorCode::Conflict),
|
||||||
|
Err(ErrorCode::InvalidInput) => Ok(()),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
Body, Found, basic, children, multistatus, name_of, outcome, range_outcome,
|
||||||
|
removable, url,
|
||||||
|
};
|
||||||
|
use crate::sdk::ErrorCode;
|
||||||
|
|
||||||
|
fn found(collection: bool) -> Found {
|
||||||
|
Found {
|
||||||
|
href: String::new(),
|
||||||
|
collection,
|
||||||
|
length: 0,
|
||||||
|
etag: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paths_join_onto_the_root_without_growing_a_boundary() {
|
||||||
|
assert_eq!(
|
||||||
|
url("https://dav.example/files/", "/"),
|
||||||
|
"https://dav.example/files"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
url("https://dav.example/files", "/notes/today.md"),
|
||||||
|
"https://dav.example/files/notes/today.md"
|
||||||
|
);
|
||||||
|
// A slash inside a name is encoded, so it cannot become a new segment.
|
||||||
|
assert_eq!(
|
||||||
|
url("https://dav.example", "/a b/c%d"),
|
||||||
|
"https://dav.example/a%20b/c%25d"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
url("https://dav.example", "/papers/"),
|
||||||
|
"https://dav.example/papers/"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credentials_encode_the_way_basic_auth_expects() {
|
||||||
|
assert_eq!(basic("user", "pass"), "Basic dXNlcjpwYXNz");
|
||||||
|
assert_eq!(basic("a", ""), "Basic YTo=");
|
||||||
|
assert_eq!(basic("ab", ""), "Basic YWI6");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn statuses_keep_denied_missing_and_conflicting_apart() {
|
||||||
|
assert!(outcome(207).is_ok());
|
||||||
|
assert!(matches!(outcome(401), Err(ErrorCode::Denied)));
|
||||||
|
assert!(matches!(outcome(404), Err(ErrorCode::NotFound)));
|
||||||
|
assert!(matches!(outcome(412), Err(ErrorCode::Conflict)));
|
||||||
|
// A locked resource is someone else holding it, not a permission to grant.
|
||||||
|
assert!(matches!(outcome(423), Err(ErrorCode::Conflict)));
|
||||||
|
assert!(matches!(outcome(500), Err(ErrorCode::Unavailable)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_range_past_the_end_is_end_of_file_not_a_failure() {
|
||||||
|
assert_eq!(range_outcome(206), Ok(true));
|
||||||
|
assert_eq!(range_outcome(200), Ok(true));
|
||||||
|
assert_eq!(range_outcome(416), Ok(false));
|
||||||
|
assert_eq!(range_outcome(404), Err(ErrorCode::NotFound));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_body_never_hands_over_more_than_the_limit() -> Result<(), ErrorCode> {
|
||||||
|
// The host answers every pull with a whole 10-byte piece, whatever the
|
||||||
|
// shortfall asked for.
|
||||||
|
let mut asked = Vec::new();
|
||||||
|
let mut source = |wanted: u32| {
|
||||||
|
asked.push(wanted);
|
||||||
|
Ok(if asked.len() > 2 {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![asked.len() as u8; 10]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let mut body = Body::default();
|
||||||
|
assert_eq!(body.pull(4, &mut source)?, vec![1, 1, 1, 1]);
|
||||||
|
// The surplus of the first piece is served before anything new is read.
|
||||||
|
assert_eq!(body.pull(8, &mut source)?, vec![1, 1, 1, 1, 1, 1, 2, 2]);
|
||||||
|
assert_eq!(body.pull(100, &mut source)?, vec![2; 8]);
|
||||||
|
assert_eq!(body.pull(100, &mut source)?, Vec::<u8>::new());
|
||||||
|
// Only the shortfall is asked for, and nothing after the body ended.
|
||||||
|
assert_eq!(asked, vec![4, 2, 92]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_body_keeps_reading_until_the_limit_is_met() -> Result<(), ErrorCode> {
|
||||||
|
// A host that answers in pieces smaller than the limit is drained until
|
||||||
|
// the limit is met, so a short piece is never mistaken for end of file.
|
||||||
|
let mut body = Body::default();
|
||||||
|
let mut left = 7;
|
||||||
|
let mut source = |_wanted: u32| {
|
||||||
|
let piece = left.min(3);
|
||||||
|
left -= piece;
|
||||||
|
Ok(vec![9; piece])
|
||||||
|
};
|
||||||
|
assert_eq!(body.pull(5, &mut source)?, vec![9; 5]);
|
||||||
|
assert_eq!(body.pull(5, &mut source)?, vec![9; 2]);
|
||||||
|
assert_eq!(Body::empty().pull(5, &mut source)?, Vec::<u8>::new());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_an_empty_directory_or_a_file_may_be_removed() {
|
||||||
|
assert_eq!(removable(&[found(false)]), Ok(()));
|
||||||
|
assert_eq!(removable(&[found(true)]), Ok(()));
|
||||||
|
assert_eq!(
|
||||||
|
removable(&[found(true), found(false)]),
|
||||||
|
Err(ErrorCode::Conflict)
|
||||||
|
);
|
||||||
|
assert_eq!(removable(&[]), Err(ErrorCode::NotFound));
|
||||||
|
assert_eq!(
|
||||||
|
children(&[found(false)]).err(),
|
||||||
|
Some(ErrorCode::InvalidInput)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
children(&[found(true), found(false), found(true)]).map(<[Found]>::len),
|
||||||
|
Ok(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_multistatus_reply_yields_the_collection_then_its_children()
|
||||||
|
-> Result<(), ErrorCode> {
|
||||||
|
let body = br#"<?xml version="1.0"?>
|
||||||
|
<D:multistatus xmlns:D="DAV:">
|
||||||
|
<D:response>
|
||||||
|
<D:href>/files/notes/</D:href>
|
||||||
|
<D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype>
|
||||||
|
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>
|
||||||
|
</D:response>
|
||||||
|
<D:response>
|
||||||
|
<D:href>/files/notes/a%20b.md</D:href>
|
||||||
|
<D:propstat><D:prop>
|
||||||
|
<D:resourcetype/>
|
||||||
|
<D:getcontentlength>42</D:getcontentlength>
|
||||||
|
<D:getetag>"v1"</D:getetag>
|
||||||
|
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>"#;
|
||||||
|
let found = multistatus(body)?;
|
||||||
|
assert_eq!(found.len(), 2);
|
||||||
|
assert!(found[0].collection);
|
||||||
|
assert!(!found[1].collection);
|
||||||
|
assert_eq!(found[1].length, 42);
|
||||||
|
assert_eq!(found[1].etag.as_deref(), Some("\"v1\""));
|
||||||
|
assert_eq!(name_of(&found[0].href), "notes");
|
||||||
|
assert_eq!(name_of(&found[1].href), "a b.md");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+414
@@ -0,0 +1,414 @@
|
|||||||
|
//! A WebDAV folder for Memona that signs Markdown with an ENS name, speaking
|
||||||
|
//! only the host's HTTP.
|
||||||
|
|
||||||
|
mod dav;
|
||||||
|
mod sdk;
|
||||||
|
|
||||||
|
use sdk::host;
|
||||||
|
use sdk::{
|
||||||
|
Capabilities, ChangePage, Entry, EntryKind, ErrorCode, FileInfo, Guest,
|
||||||
|
GuestReader, GuestWriter, ListPage, Locator, Reader, Setting, WriteCondition,
|
||||||
|
Writer,
|
||||||
|
};
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
|
||||||
|
/// The PROPFIND body. Asking for named properties rather than allprop keeps
|
||||||
|
/// replies small on servers that store a great many of them.
|
||||||
|
const PROPS: &str = concat!(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>"#,
|
||||||
|
r#"<d:propfind xmlns:d="DAV:"><d:prop>"#,
|
||||||
|
"<d:resourcetype/><d:getcontentlength/><d:getetag/>",
|
||||||
|
"</d:prop></d:propfind>",
|
||||||
|
);
|
||||||
|
const MAX_CHUNK: u32 = 1024 * 1024;
|
||||||
|
|
||||||
|
struct Session {
|
||||||
|
root: String,
|
||||||
|
authorization: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Webdav;
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path a locator names, the only way the contract addresses a resource.
|
||||||
|
fn route(target: &Locator) -> String {
|
||||||
|
let Locator::Path(path) = target;
|
||||||
|
path.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session<T>(
|
||||||
|
read: impl FnOnce(&Session) -> Result<T, ErrorCode>,
|
||||||
|
) -> Result<T, ErrorCode> {
|
||||||
|
SESSION.with_borrow(|session| match session {
|
||||||
|
Some(session) => read(session),
|
||||||
|
None => Err(ErrorCode::Internal),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts one request with this connection's credentials attached.
|
||||||
|
fn begin(
|
||||||
|
method: host::Method,
|
||||||
|
path: &str,
|
||||||
|
extra: Vec<host::Header>,
|
||||||
|
) -> Result<host::Exchange, ErrorCode> {
|
||||||
|
let (url, authorization) = session(|session| {
|
||||||
|
Ok((dav::url(&session.root, path), session.authorization.clone()))
|
||||||
|
})?;
|
||||||
|
let mut headers = extra;
|
||||||
|
if let Some(value) = authorization {
|
||||||
|
headers.push(host::Header {
|
||||||
|
name: "authorization".to_owned(),
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
host::http(&host::Request {
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
.map_err(transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs one request that carries a small body and answers a small body.
|
||||||
|
fn exchange(
|
||||||
|
method: host::Method,
|
||||||
|
path: &str,
|
||||||
|
extra: Vec<host::Header>,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<Vec<u8>, ErrorCode> {
|
||||||
|
let call = begin(method, path, extra)?;
|
||||||
|
if !body.is_empty() {
|
||||||
|
call.push(body).map_err(transport)?;
|
||||||
|
}
|
||||||
|
let head = call.respond().map_err(transport)?;
|
||||||
|
dav::outcome(head.status)?;
|
||||||
|
let mut data = Vec::new();
|
||||||
|
loop {
|
||||||
|
let chunk = call.pull(MAX_CHUNK).map_err(transport)?;
|
||||||
|
if chunk.is_empty() {
|
||||||
|
return Ok(data);
|
||||||
|
}
|
||||||
|
data.extend_from_slice(&chunk);
|
||||||
|
if data.len() > 8 * 1024 * 1024 {
|
||||||
|
return Err(ErrorCode::Unavailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn depth(value: &str) -> host::Header {
|
||||||
|
host::Header {
|
||||||
|
name: "depth".to_owned(),
|
||||||
|
value: value.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_type() -> host::Header {
|
||||||
|
host::Header {
|
||||||
|
name: "content-type".to_owned(),
|
||||||
|
value: "application/xml; charset=utf-8".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the server about one path, and its children when `depth` is 1.
|
||||||
|
fn describe(
|
||||||
|
path: &str,
|
||||||
|
depth_value: &str,
|
||||||
|
) -> Result<Vec<dav::Found>, ErrorCode> {
|
||||||
|
let body = exchange(
|
||||||
|
host::Method::Propfind,
|
||||||
|
path,
|
||||||
|
vec![depth(depth_value), content_type()],
|
||||||
|
PROPS.as_bytes(),
|
||||||
|
)?;
|
||||||
|
dav::multistatus(&body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info_of(found: &dav::Found) -> FileInfo {
|
||||||
|
FileInfo {
|
||||||
|
kind: if found.collection {
|
||||||
|
EntryKind::Directory
|
||||||
|
} else {
|
||||||
|
EntryKind::File
|
||||||
|
},
|
||||||
|
size: found.length,
|
||||||
|
revision: found.etag.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one file through a GET that started at an offset.
|
||||||
|
struct DavReader {
|
||||||
|
call: host::Exchange,
|
||||||
|
revision: Option<String>,
|
||||||
|
body: RefCell<dav::Body>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GuestReader for DavReader {
|
||||||
|
fn pull(&self, limit: u32) -> Result<Vec<u8>, ErrorCode> {
|
||||||
|
self
|
||||||
|
.body
|
||||||
|
.borrow_mut()
|
||||||
|
.pull(limit.min(MAX_CHUNK) as usize, |wanted| {
|
||||||
|
self.call.pull(wanted).map_err(transport)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn revision(&self) -> Option<String> {
|
||||||
|
self.revision.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes one file through a PUT whose body is pushed in pieces.
|
||||||
|
struct DavWriter {
|
||||||
|
call: host::Exchange,
|
||||||
|
done: RefCell<bool>,
|
||||||
|
written: Cell<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GuestWriter for DavWriter {
|
||||||
|
fn push(&self, data: Vec<u8>) -> Result<(), ErrorCode> {
|
||||||
|
if *self.done.borrow() {
|
||||||
|
return Err(ErrorCode::InvalidInput);
|
||||||
|
}
|
||||||
|
self.call.push(&data).map_err(transport)?;
|
||||||
|
self.written.set(self.written.get() + data.len() as u64);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
/// One call is enough: the request body is already complete, so the response
|
||||||
|
/// head settles the write. The etag it returns, when the server sends one,
|
||||||
|
/// is the revision a later conditional write compares against.
|
||||||
|
fn finish(&self) -> Result<Option<FileInfo>, ErrorCode> {
|
||||||
|
if *self.done.borrow() {
|
||||||
|
return Err(ErrorCode::InvalidInput);
|
||||||
|
}
|
||||||
|
*self.done.borrow_mut() = true;
|
||||||
|
let head = self.call.respond().map_err(transport)?;
|
||||||
|
dav::outcome(head.status)?;
|
||||||
|
let revision = head
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.find(|header| header.name.eq_ignore_ascii_case("etag"))
|
||||||
|
.map(|header| header.value.clone());
|
||||||
|
Ok(Some(FileInfo {
|
||||||
|
kind: EntryKind::File,
|
||||||
|
size: self.written.get(),
|
||||||
|
revision,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Guest for Webdav {
|
||||||
|
type Reader = DavReader;
|
||||||
|
type Writer = DavWriter;
|
||||||
|
|
||||||
|
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.clone())))
|
||||||
|
};
|
||||||
|
let root = lookup("url").ok_or(ErrorCode::InvalidInput)?;
|
||||||
|
if !root.starts_with("https://") && !root.starts_with("http://") {
|
||||||
|
return Err(ErrorCode::InvalidInput);
|
||||||
|
}
|
||||||
|
let authorization = match (lookup("username"), lookup("password")) {
|
||||||
|
(Some(username), Some(password)) => {
|
||||||
|
Some(dav::basic(&username, &password))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
SESSION.set(Some(Session {
|
||||||
|
root,
|
||||||
|
authorization,
|
||||||
|
}));
|
||||||
|
Ok(Capabilities {
|
||||||
|
write: true,
|
||||||
|
mkdir: true,
|
||||||
|
rename: true,
|
||||||
|
remove: true,
|
||||||
|
// A server that returns etags supports If-Match, and one that does not
|
||||||
|
// will refuse the header rather than silently overwrite.
|
||||||
|
conditional_write: true,
|
||||||
|
watch: false,
|
||||||
|
virtual_directories: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close() {
|
||||||
|
SESSION.set(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stat(target: Locator) -> Result<FileInfo, ErrorCode> {
|
||||||
|
let found = describe(&route(&target), "0")?;
|
||||||
|
let entry = found.first().ok_or(ErrorCode::NotFound)?;
|
||||||
|
Ok(info_of(entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebDAV has no cursor of its own, so one PROPFIND returns the whole
|
||||||
|
/// directory and the page is taken from the front of it.
|
||||||
|
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 found = describe(&path, "1")?;
|
||||||
|
let children: Vec<_> = dav::children(&found)?
|
||||||
|
.iter()
|
||||||
|
.map(|entry| Entry {
|
||||||
|
name: dav::name_of(&entry.href),
|
||||||
|
info: info_of(entry),
|
||||||
|
})
|
||||||
|
.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()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A GET on a collection answers with whatever page the server likes to
|
||||||
|
/// show, so the path is checked to be a file before any byte is read.
|
||||||
|
fn open_read(target: Locator, offset: u64) -> Result<Reader, ErrorCode> {
|
||||||
|
let path = route(&target);
|
||||||
|
let found = describe(&path, "0")?;
|
||||||
|
let entry = found.first().ok_or(ErrorCode::NotFound)?;
|
||||||
|
if entry.collection {
|
||||||
|
return Err(ErrorCode::InvalidInput);
|
||||||
|
}
|
||||||
|
let call = begin(
|
||||||
|
host::Method::Get,
|
||||||
|
&path,
|
||||||
|
vec![host::Header {
|
||||||
|
name: "range".to_owned(),
|
||||||
|
value: format!("bytes={offset}-"),
|
||||||
|
}],
|
||||||
|
)?;
|
||||||
|
let head = call.respond().map_err(transport)?;
|
||||||
|
let body = if dav::range_outcome(head.status)? {
|
||||||
|
dav::Body::default()
|
||||||
|
} else {
|
||||||
|
dav::Body::empty()
|
||||||
|
};
|
||||||
|
// The listing's etag is the one stat and list report, and the strong form
|
||||||
|
// a later conditional write must send; the GET's may be a weak variant.
|
||||||
|
let revision = entry.etag.clone().or_else(|| {
|
||||||
|
head
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.find(|header| header.name.eq_ignore_ascii_case("etag"))
|
||||||
|
.map(|header| header.value.clone())
|
||||||
|
});
|
||||||
|
Ok(Reader::new(DavReader {
|
||||||
|
call,
|
||||||
|
revision,
|
||||||
|
body: RefCell::new(body),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_write(
|
||||||
|
target: Locator,
|
||||||
|
condition: WriteCondition,
|
||||||
|
_size_hint: Option<u64>,
|
||||||
|
) -> Result<Writer, ErrorCode> {
|
||||||
|
let path = route(&target);
|
||||||
|
let guard = match condition {
|
||||||
|
WriteCondition::CreateOnly => Some(host::Header {
|
||||||
|
name: "if-none-match".to_owned(),
|
||||||
|
value: "*".to_owned(),
|
||||||
|
}),
|
||||||
|
WriteCondition::IfMatch(revision) => Some(host::Header {
|
||||||
|
name: "if-match".to_owned(),
|
||||||
|
value: revision,
|
||||||
|
}),
|
||||||
|
WriteCondition::Overwrite => None,
|
||||||
|
};
|
||||||
|
let call = begin(host::Method::Put, &path, guard.into_iter().collect())?;
|
||||||
|
Ok(Writer::new(DavWriter {
|
||||||
|
call,
|
||||||
|
done: RefCell::new(false),
|
||||||
|
written: Cell::new(0),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mkdir(target: Locator) -> Result<FileInfo, ErrorCode> {
|
||||||
|
let path = route(&target);
|
||||||
|
exchange(host::Method::Mkcol, &path, Vec::new(), &[])?;
|
||||||
|
Ok(FileInfo {
|
||||||
|
kind: EntryKind::Directory,
|
||||||
|
size: 0,
|
||||||
|
revision: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rename(
|
||||||
|
target: Locator,
|
||||||
|
destination: String,
|
||||||
|
) -> Result<FileInfo, ErrorCode> {
|
||||||
|
let path = route(&target);
|
||||||
|
let to = session(|session| Ok(dav::url(&session.root, &destination)))?;
|
||||||
|
exchange(
|
||||||
|
host::Method::Move,
|
||||||
|
&path,
|
||||||
|
vec![
|
||||||
|
host::Header {
|
||||||
|
name: "destination".to_owned(),
|
||||||
|
value: to,
|
||||||
|
},
|
||||||
|
// Never clobber an existing target: a rename that would replace one is
|
||||||
|
// a conflict for the caller to resolve.
|
||||||
|
host::Header {
|
||||||
|
name: "overwrite".to_owned(),
|
||||||
|
value: "F".to_owned(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)?;
|
||||||
|
Self::stat(Locator::Path(destination))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE takes a whole collection at once, so a directory is looked at
|
||||||
|
/// first and refused unless it is empty.
|
||||||
|
fn remove(target: Locator) -> Result<(), ErrorCode> {
|
||||||
|
let path = route(&target);
|
||||||
|
dav::removable(&describe(&path, "1")?)?;
|
||||||
|
exchange(host::Method::Delete, &path, Vec::new(), &[])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll(_cursor: Option<String>) -> Result<ChangePage, ErrorCode> {
|
||||||
|
Err(ErrorCode::Unsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sdk::export!(Webdav with_types_in sdk);
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
//! Rust guest bindings for the versioned Memona filesystem component contract,
|
||||||
|
//! generated from the copy of the canonical WIT under `wit/`.
|
||||||
|
|
||||||
|
wit_bindgen::generate!({
|
||||||
|
path: "wit",
|
||||||
|
world: "filesystem",
|
||||||
|
pub_export_macro: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
pub use exports::memona::filesystem::provider::{
|
||||||
|
Capabilities, ChangePage, Entry, EntryKind, ErrorCode, FileInfo, Guest,
|
||||||
|
GuestReader, GuestWriter, ListPage, Locator, Reader, WriteCondition, Writer,
|
||||||
|
};
|
||||||
|
pub use memona::filesystem::host;
|
||||||
|
pub use memona::filesystem::host::Setting;
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
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>;
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
/// How one resource is named: a path within the connection root.
|
||||||
|
variant locator { path(string) }
|
||||||
|
|
||||||
|
/// Optional operations. False capabilities must return unsupported.
|
||||||
|
/// Conditional-write promises atomic create-only and revision comparisons.
|
||||||
|
/// 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,
|
||||||
|
virtual-directories: bool,
|
||||||
|
}
|
||||||
|
/// Byte length and an opaque revision, each if known.
|
||||||
|
record file-info {
|
||||||
|
kind: entry-kind,
|
||||||
|
size: u64,
|
||||||
|
revision: option<revision>,
|
||||||
|
}
|
||||||
|
/// 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user
This component is still an unchanged WebDAV copy:
open_writestreams its input directly to PUT and Cargo.toml has no signing dependency or signer configuration. The PR description defers signing, so these present-tense claims will make installed users expect a feature that is not shipped. Describe this PR as the WebDAV foundation (or add the signing hook) before merging.