Compare commits

...

8 Commits

Author SHA1 Message Date
Danny Kim 5e77f3ca67 A folder with no signer name or key verifies Markdown and saves it unsigned (#8)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-26 02:27:50 +00:00
Danny Kim 6677e8ad72 Markdown files up to 96 KiB show whether their ENS signature holds (#7)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-25 21:26:08 +00:00
Danny Kim 967f257902 The README shows what each verification badge means (#6)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-25 20:17:21 +00:00
Danny Kim bf0e4561c0 Markdown shows whether its ENS signature holds when read (#5)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-25 20:11:58 +00:00
Danny Kim 1d36760782 Signed files keep their signature a string for every YAML parser (#4)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-25 19:43:09 +00:00
temeddix 5d46485dad A file's revision reads the same after a save as it does when listed (#3) 2026-09-25 17:43:50 +00:00
Danny Kim 5db8ed318a Sign Markdown saved to the folder with the configured ENS name and key (#2)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-25 17:02:52 +00:00
Danny Kim a4d1fae1d3 Open a WebDAV server as a folder under the signature plugin (#1)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-25 16:54:15 +00:00
21 changed files with 3308 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
warn-on-all-wildcard-imports = true
+48
View File
@@ -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 }}
+1
View File
@@ -0,0 +1 @@
/target/
+3
View File
@@ -0,0 +1,3 @@
style_edition = "2024"
max_width = 80
tab_spaces = 2
Generated
+1016
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "memona-plugin-signature"
version = "0.1.1"
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 }
anyhow = "1.0.104"
md-ens-signature = { git = "https://github.com/capsulizers/md-ens-signature", rev = "3a9f5e2bea1a668adc84342b82b98fee77909379" }
[dev-dependencies]
alloy-primitives = { version = "1.7.3", default-features = false }
[lints.clippy]
panic = "deny"
unwrap_used = "deny"
expect_used = "deny"
wildcard_imports = "deny"
[profile.dev]
debug = 0
+124 -1
View File
@@ -3,5 +3,128 @@
Opens a WebDAV server as a folder inside Memona and signs every Markdown file
saved to it with an ENS name, writing `signer` and `signature` into the file's
frontmatter so anyone can check who wrote it against the name's current ENSv2
records. Signing and verification come from the
records. Signing comes from the
[md-ens-signature](https://github.com/capsulizers/md-ens-signature) library.
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
![The connection form](https://git.capsulizers.com/commons/memona-plugin-signature/raw/branch/main/images/connection-form.png)
| Field | Key | Meaning |
| --- | --- | --- |
| Server address | `url` | The folder's address, for example `https://dav.example.com/files/` |
| Username | `username` | Optional account name for HTTP basic authentication |
| Password | `password` | Optional password, stored as a secret |
| Signer name | `ensName` | Optional. The ENS name that signs, for example `laptop.alice.eth` |
| Signing key | `privateKey` | Optional. The private key of the address that owns the name, stored as a secret |
| Sepolia RPC address | `rpcUrl` | The endpoint used to check signers against ENS, by default `https://ethereum-sepolia-rpc.publicnode.com` |
| Trusted parent name | `trustedParent` | Optional. Only signers that are this name or one of its subnames count, for example `team.eth` |
Give the signer name and signing key together, or leave both empty for a
verify-only folder: reads still show a verdict, and a Markdown save is stored
unsigned, byte for byte except the verdict line, so a signature the file
already carries stays and an edit then reads as Tampered. Giving only one of
the two refuses the connection.
The connection's allowed hosts must include the RPC address's host as well as
the server's, or every check reads as Sepolia unreachable.
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.
## Signing
Saving a `.md` file holds it whole, up to 96 KiB, and writes it once finished,
with `signer` and `signature` set in its frontmatter; every other file streams
straight through. The signature is EIP-191 `personal_sign` over the signer name
and the body's SHA-256, made with the configured key; other frontmatter and the
body stay byte for byte. A key that is not 32 bytes of hex refuses the
connection, and a `.md` file that is not UTF-8 refuses the save.
![A note saved in Memona, signed and verified](https://git.capsulizers.com/commons/memona-plugin-signature/raw/branch/main/images/after-save.png)
## Verifying
Reading a `.md` file checks its signature against ENSv2 on Sepolia and adds an
`ens-verification` line to the frontmatter Memona receives, which Memona shows
as a badge. The owner of the signer name comes from the Universal Resolver's
`findOwner`, so a name its parent revoked stops counting at once.
| Badge | Meaning |
| --- | --- |
| `Verified: bob.team.eth` | The name's current owner signed this exact body |
| `Tampered: signed as bob.team.eth` | The body changed after signing, or another key signed it |
| `Unauthorized: bob.team.eth is not registered` | The signature holds, but no one owns the name now: it was revoked or never granted |
| `Unauthorized: bob.team.eth is not a member of team.eth` | The signer is outside the trusted parent name |
| `Unsigned` | The file has no `signer` and `signature` |
| `Not checked: Sepolia unreachable` | The RPC address did not answer, so nothing was decided |
| `Not checked: file too large` | The file is over 96 KiB, too large to hash within one call |
| Verified | Tampered |
| --- | --- |
| ![Verified](https://git.capsulizers.com/commons/memona-plugin-signature/raw/branch/main/images/verified.png) | ![Tampered](https://git.capsulizers.com/commons/memona-plugin-signature/raw/branch/main/images/tampered.png) |
| Unauthorized | Unsigned |
| --- | --- |
| ![Unauthorized](https://git.capsulizers.com/commons/memona-plugin-signature/raw/branch/main/images/unauthorized.png) | ![Unsigned](https://git.capsulizers.com/commons/memona-plugin-signature/raw/branch/main/images/unsigned.png) |
The line never reaches the server: a save drops it before signing, and one
stored on the server is replaced, so a file cannot claim its own verdict.
Verdicts are reused for 30 seconds per signed body, a failed lookup included,
so an editor rereading a file does not flood the RPC. Stat, list and saves
keep reporting the stored size and the server's etag, so `If-Match` works as
before.
Memona gives each call into a plugin a fixed instruction budget, which is where
the size limits come from: checking hashes the body and recovers a key, so it
covers files up to 96 KiB, the same as signing; a file up to 1 MiB is marked
not checked, and a larger one streams through without a badge.
## 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+69
View File
@@ -0,0 +1,69 @@
{
"schemaVersion": 1,
"id": "memona/signature",
"name": "Signature",
"version": "0.1.1",
"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
},
{
"key": "ensName",
"label": "Signer name",
"description": "The ENS name that signs saved Markdown, for example laptop.alice.eth. Leave it and the key empty to only verify",
"kind": "text",
"required": false
},
{
"key": "privateKey",
"label": "Signing key",
"description": "The private key of the address that owns this name. Leave it and the name empty to only verify",
"kind": "password",
"required": false
},
{
"key": "rpcUrl",
"label": "Sepolia RPC address",
"description": "The Ethereum Sepolia endpoint used to check signers against ENS",
"kind": "text",
"required": false,
"default": "https://ethereum-sepolia-rpc.publicnode.com"
},
{
"key": "trustedParent",
"label": "Trusted parent name",
"description": "Only count signers that are this ENS name or one of its subnames, for example team.eth",
"kind": "text",
"required": false
}
]
}
]
}
+287
View File
@@ -0,0 +1,287 @@
//! The one frontmatter line that tells Memona what verification found.
//!
//! Memona shows every frontmatter key as a badge, so the verdict travels as a
//! line the plugin adds to the bytes a read returns and removes from the bytes
//! a write receives. It never reaches the server, and a line the server holds
//! is never trusted: it is removed before the plugin adds its own.
use md_ens_signature::verify::{Reason, Verdict};
use std::borrow::Cow;
/// The frontmatter key the verdict is shown under.
pub const KEY: &str = "ens-verification";
/// The badge for a file too large to hash within one host call.
pub const TOO_LARGE: &str = "Not checked: file too large";
/// The line that opens and closes a frontmatter block.
const FENCE: &str = "---";
/// The badge text for a verdict, or for a lookup that could not be made.
pub fn label(verdict: &anyhow::Result<Verdict>) -> String {
match verdict {
Ok(Verdict::Unsigned) => "Unsigned".to_owned(),
Ok(Verdict::Tampered { signer }) => format!("Tampered: signed as {signer}"),
Ok(Verdict::Unauthorized {
signer,
reason: Reason::Unregistered,
}) => format!("Unauthorized: {signer} is not registered"),
Ok(Verdict::Unauthorized {
signer,
reason: Reason::NotMember { parent },
}) => format!("Unauthorized: {signer} is not a member of {parent}"),
Ok(Verdict::Verified { signer, .. }) => format!("Verified: {signer}"),
Err(_) => "Not checked: Sepolia unreachable".to_owned(),
}
}
/// Returns a line without its trailing `\n` or `\r\n`.
fn without_newline(line: &str) -> &str {
let line = line.strip_suffix('\n').unwrap_or(line);
line.strip_suffix('\r').unwrap_or(line)
}
/// Where a closed frontmatter block sits: the byte after the opening fence
/// line, the byte where the closing fence line starts, and the byte after it.
fn block(markdown: &str) -> Option<(usize, usize, usize)> {
let mut lines = markdown.split_inclusive('\n');
let first = lines.next()?;
if !first.ends_with('\n') || without_newline(first) != FENCE {
return None;
}
let mut offset = first.len();
for line in lines {
if without_newline(line) == FENCE {
return Some((first.len(), offset, offset + line.len()));
}
offset += line.len();
}
None
}
/// Whether a frontmatter line sets the verdict key at the top level.
fn is_badge_line(line: &str) -> bool {
line
.strip_prefix(KEY)
.is_some_and(|rest| rest.trim_start_matches([' ', '\t']).starts_with(':'))
}
/// Whether a line continues the value above it, as a folded or block YAML
/// value does, rather than starting a key of its own.
fn is_continuation(line: &str) -> bool {
line.starts_with([' ', '\t']) && !without_newline(line).trim().is_empty()
}
/// Removes every verdict line, and whatever continues it, from the
/// frontmatter. A block left holding nothing is removed whole, since the
/// plugin made it; the body is never touched, so its digest stays the same.
pub fn strip(markdown: &str) -> Cow<'_, str> {
let Some((inner, close, end)) = block(markdown) else {
return Cow::Borrowed(markdown);
};
let mut kept = String::with_capacity(close - inner);
let mut removed = false;
let mut skipping = false;
for line in markdown[inner..close].split_inclusive('\n') {
if is_badge_line(line) {
removed = true;
skipping = true;
continue;
}
if skipping && is_continuation(line) {
continue;
}
skipping = false;
kept.push_str(line);
}
if !removed {
return Cow::Borrowed(markdown);
}
if kept.is_empty() {
return Cow::Borrowed(&markdown[end..]);
}
Cow::Owned(format!(
"{}{kept}{}",
&markdown[..inner],
&markdown[close..]
))
}
/// Quotes a badge for a YAML double-quoted scalar. A signer name comes from
/// the file, so its quotes, backslashes and control characters are escaped
/// or dropped rather than allowed to end the value early.
fn quoted(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
out.push('"');
for character in text.chars() {
match character {
'"' | '\\' => {
out.push('\\');
out.push(character);
}
_ if character.is_control() => {}
_ => out.push(character),
}
}
out.push('"');
out
}
/// Returns the file with `text` as its only verdict line, first in the
/// frontmatter, so the badge leads the row. A file without frontmatter gets a
/// block holding just that line, which [`strip`] removes again.
///
/// Only the frontmatter is looked at and the rest is copied once, because a
/// host call has a fixed instruction budget that a large file must fit in.
pub fn inject(markdown: &str, text: &str) -> String {
let markdown = strip(markdown);
// The file's first line ending is the one the added lines follow.
let newline = match markdown.find('\n') {
Some(index) if markdown[..index].ends_with('\r') => "\r\n",
_ => "\n",
};
let line = format!("{KEY}: {}{newline}", quoted(text));
let (head, tail) = match block(&markdown) {
Some((inner, _, _)) => markdown.split_at(inner),
None => ("", markdown.as_ref()),
};
let mut out = String::with_capacity(markdown.len() + line.len() + 10);
if head.is_empty() {
out.push_str(FENCE);
out.push_str(newline);
out.push_str(&line);
out.push_str(FENCE);
out.push_str(newline);
} else {
out.push_str(head);
out.push_str(&line);
}
out.push_str(tail);
out
}
#[cfg(test)]
mod tests {
use super::{inject, label, strip};
use alloy_primitives::Address;
use anyhow::anyhow;
use md_ens_signature::document::body_digest;
use md_ens_signature::verify::{Reason, Verdict};
#[test]
fn every_verdict_reads_as_its_badge() {
let signer = "bob.team.eth".to_owned();
let cases = [
(Ok(Verdict::Unsigned), "Unsigned"),
(
Ok(Verdict::Tampered {
signer: signer.clone(),
}),
"Tampered: signed as bob.team.eth",
),
(
Ok(Verdict::Unauthorized {
signer: signer.clone(),
reason: Reason::Unregistered,
}),
"Unauthorized: bob.team.eth is not registered",
),
(
Ok(Verdict::Unauthorized {
signer: signer.clone(),
reason: Reason::NotMember {
parent: "team.eth".to_owned(),
},
}),
"Unauthorized: bob.team.eth is not a member of team.eth",
),
(
Ok(Verdict::Verified {
signer,
address: Address::ZERO,
}),
"Verified: bob.team.eth",
),
(Err(anyhow!("refused")), "Not checked: Sepolia unreachable"),
];
for (verdict, expected) in cases {
assert_eq!(label(&verdict), expected);
}
}
#[test]
fn the_badge_leads_existing_frontmatter() {
let file = "---\nsigner: a.eth\n---\n# Hi\n";
assert_eq!(
inject(file, "Verified: a.eth"),
"---\nens-verification: \"Verified: a.eth\"\nsigner: a.eth\n---\n# Hi\n"
);
}
#[test]
fn a_file_without_frontmatter_gets_a_block_that_strips_away() {
for file in ["# Hi\n", "", "---\nnot closed\n", "a\r\nb\r\n"] {
let shown = inject(file, "Unsigned");
assert!(shown.starts_with("---"), "{file:?}");
assert_eq!(strip(&shown), file);
}
assert_eq!(
inject("a\r\nb", "Unsigned"),
"---\r\nens-verification: \"Unsigned\"\r\n---\r\na\r\nb"
);
}
#[test]
fn stripping_restores_the_stored_bytes() {
for file in [
"---\nsigner: a.eth\nsignature: \"0x12\"\n---\nBody\n",
"---\r\ntitle: T\r\n---\r\nBody\r\n",
"---\n---\nBody",
"# No frontmatter\n",
] {
let shown = inject(file, "Verified: a.eth");
assert_ne!(shown, file);
let restored = strip(&shown);
// An empty block the badge filled is dropped, and the body with it
// keeps its digest.
assert_eq!(body_digest(&restored), body_digest(file));
if file != "---\n---\nBody" {
assert_eq!(restored, file);
}
}
}
#[test]
fn a_stored_verdict_is_never_trusted() {
let forged = "---\nens-verification: \"Verified: ceo.eth\"\n---\nPay me\n";
assert_eq!(
inject(forged, "Unsigned"),
"---\nens-verification: \"Unsigned\"\n---\nPay me\n"
);
}
#[test]
fn an_edited_or_reformatted_badge_is_still_stripped() {
let edited = concat!(
"---\n",
"title: T\n",
"ens-verification : 'Verified: x'\n",
"ens-verification: >-\n",
" folded\n",
" value\n",
"tags: [a]\n",
"---\n",
"ens-verification: body text stays\n",
);
assert_eq!(
strip(edited),
"---\ntitle: T\ntags: [a]\n---\nens-verification: body text stays\n"
);
}
#[test]
fn a_signer_cannot_break_out_of_the_quotes() {
assert_eq!(
inject("x", "Tampered: signed as a\"b\\c\u{7}.eth"),
"---\nens-verification: \"Tampered: signed as a\\\"b\\\\c.eth\"\n---\nx"
);
}
}
+372
View File
@@ -0,0 +1,372 @@
//! Verifying Markdown as it is read: the Sepolia lookup through the host's
//! HTTP, a verdict cache, and the badge added to what Memona is handed.
use crate::badge;
use crate::sdk::host;
use anyhow::{anyhow, bail};
use md_ens_signature::document::{body_digest, read_signature};
use md_ens_signature::ens::EthCall;
use md_ens_signature::verify::{EthCaller, Verdict, verify_fields};
use std::cell::RefCell;
use std::future::Future;
use std::pin::pin;
use std::task::{Context, Poll, Waker};
use std::time::{Duration, Instant};
/// How long a verdict is reused, so an editor that rereads a file every few
/// seconds asks Sepolia at most twice a minute about it.
const VERDICT_TTL: Duration = Duration::from_secs(30);
/// The largest JSON-RPC answer read. A `findOwner` answer is one word.
const MAX_RPC_ANSWER: usize = 64 * 1024;
/// The most bytes asked of the host per pull.
const RPC_CHUNK: u32 = 64 * 1024;
/// The largest file verified, the same as the largest one a save signs. A
/// first check costs about 4.9 million WebAssembly instructions plus 86 a
/// byte, or 98 with CRLF line endings, so a file this size leaves roughly 30
/// percent of Memona's 20 million per call to spare. Past this size the
/// call could run out and the file would not open at all.
const MAX_CHECKED: usize = 96 * 1024;
/// What a cached verdict belongs to: the signed body and the claim on it.
/// The trusted parent is fixed per connection, so it needs no place here.
type Claim = ([u8; 32], String, String);
/// One connection's view of Sepolia: where to ask, whom to trust, and what it
/// answered lately.
pub struct Checker {
rpc_url: String,
trusted_parent: Option<String>,
verdicts: RefCell<Vec<(Claim, Instant, String)>>,
}
/// Runs a future that never waits. Every [`EthCaller`] here makes a blocking
/// host call inside `call`, so the future finishes on its first poll; one
/// that did not would be a bug, reported as an error rather than spun on.
fn run<T>(future: impl Future<Output = T>) -> anyhow::Result<T> {
let mut context = Context::from_waker(Waker::noop());
match pin!(future).poll(&mut context) {
Poll::Ready(value) => Ok(value),
Poll::Pending => Err(anyhow!("The verification waited")),
}
}
impl Checker {
pub fn new(rpc_url: String, trusted_parent: Option<String>) -> Self {
Self {
rpc_url,
trusted_parent,
verdicts: RefCell::new(Vec::new()),
}
}
/// The badge text for a file, from the cache when the same body was signed
/// the same way less than [`VERDICT_TTL`] ago. A failed lookup is cached
/// too, so a node that is down is not asked again on every read.
pub fn badge(&self, markdown: &str, caller: &impl EthCaller) -> String {
let Some(fields) = read_signature(markdown) else {
return badge::label(&Ok(Verdict::Unsigned));
};
let claim = (
body_digest(markdown),
fields.signer.clone(),
fields.signature.clone(),
);
let now = Instant::now();
let mut verdicts = self.verdicts.borrow_mut();
verdicts.retain(|(_, at, _)| now.duration_since(*at) < VERDICT_TTL);
if let Some((_, _, text)) = verdicts.iter().find(|(key, ..)| *key == claim)
{
return text.clone();
}
// The digest taken for the cache is the one verified, so the body, most
// of the work for a large file, is hashed once.
let parent = self.trusted_parent.as_deref();
let verdict = run(verify_fields(&fields, &claim.0, parent, caller));
let text = badge::label(&verdict.flatten());
verdicts.push((claim, now, text.clone()));
text
}
/// The file as Memona should see it: the stored bytes with the verdict
/// line added. Bytes that are not UTF-8 are not Markdown to verify, and
/// pass through unchanged.
pub fn decorate(&self, stored: Vec<u8>, caller: &impl EthCaller) -> Vec<u8> {
let text = match String::from_utf8(stored) {
Ok(text) => text,
Err(error) => return error.into_bytes(),
};
let label = if text.len() > MAX_CHECKED {
badge::TOO_LARGE.to_owned()
} else {
self.badge(&text, caller)
};
badge::inject(&text, &label).into_bytes()
}
/// The caller that sends `eth_call` to this connection's RPC address.
pub fn rpc(&self) -> Rpc<'_> {
Rpc { url: &self.rpc_url }
}
}
/// Lowercase hex with a `0x` prefix, as JSON-RPC spells bytes.
fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(2 + bytes.len() * 2);
out.push_str("0x");
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
/// The JSON-RPC request body for one `eth_call` against the latest block.
pub fn request_body(call: &EthCall) -> String {
format!(
concat!(
r#"{{"jsonrpc":"2.0","id":1,"method":"eth_call","#,
r#""params":[{{"to":"{}","data":"{}"}},"latest"]}}"#,
),
hex(call.to.as_slice()),
hex(&call.data),
)
}
/// Reads the bytes of a JSON-RPC answer's `result`. An answer carrying
/// `error`, or no hex `result`, is an error. The answer is one flat object
/// from a node, so finding the key and its string is enough.
pub fn result_bytes(answer: &str) -> anyhow::Result<Vec<u8>> {
let Some(start) = answer.find(r#""result""#) else {
bail!("The node answered without a result");
};
let rest = answer[start + 8..].trim_start();
let rest = rest.strip_prefix(':').unwrap_or(rest).trim_start();
let digits = rest
.strip_prefix("\"0x")
.and_then(|rest| rest.split('"').next())
.ok_or_else(|| anyhow!("The node's result is not hex"))?;
if digits.len() % 2 != 0 {
bail!("The node's result has an odd length");
}
(0..digits.len())
.step_by(2)
.map(|index| {
u8::from_str_radix(&digits[index..index + 2], 16)
.map_err(|_| anyhow!("The node's result is not hex"))
})
.collect()
}
/// Sends `eth_call` through the host's `http`, which blocks until the node
/// answers, so [`EthCaller::call`] never waits.
pub struct Rpc<'a> {
url: &'a str,
}
impl EthCaller for Rpc<'_> {
async fn call(&self, call: &EthCall) -> anyhow::Result<Vec<u8>> {
let failed = |_| anyhow!("The Sepolia node could not be reached");
let exchange = host::http(&host::Request {
method: host::Method::Post,
url: self.url.to_owned(),
headers: vec![host::Header {
name: "content-type".to_owned(),
value: "application/json".to_owned(),
}],
})
.map_err(failed)?;
exchange
.push(request_body(call).as_bytes())
.map_err(failed)?;
let head = exchange.respond().map_err(failed)?;
if !(200..300).contains(&head.status) {
bail!("The Sepolia node answered {}", head.status);
}
let mut answer = Vec::new();
loop {
let chunk = exchange.pull(RPC_CHUNK).map_err(failed)?;
if chunk.is_empty() {
break;
}
answer.extend_from_slice(&chunk);
if answer.len() > MAX_RPC_ANSWER {
bail!("The Sepolia node's answer is too long");
}
}
result_bytes(&String::from_utf8_lossy(&answer))
}
}
#[cfg(test)]
mod tests {
use super::{Checker, request_body, result_bytes};
use alloy_primitives::Address;
use anyhow::bail;
use md_ens_signature::document::{
SignatureFields, body_digest, write_signature,
};
use md_ens_signature::ens::{EthCall, find_owner_call};
use md_ens_signature::signature::{address_of, sign};
use md_ens_signature::verify::EthCaller;
use std::cell::Cell;
const KEY: [u8; 32] = [7; 32];
const BODY: &str = "---\ntitle: Plan\n---\nShip it.\n";
/// A Sepolia that knows one owner per name, or none, and counts questions.
struct FakeNode {
owners: Vec<(&'static str, Address)>,
down: bool,
asked: Cell<usize>,
}
impl FakeNode {
fn new(owners: Vec<(&'static str, Address)>) -> Self {
Self {
owners,
down: false,
asked: Cell::new(0),
}
}
}
impl EthCaller for FakeNode {
async fn call(&self, call: &EthCall) -> anyhow::Result<Vec<u8>> {
self.asked.set(self.asked.get() + 1);
if self.down {
bail!("connection refused");
}
let mut word = vec![0; 32];
for (name, owner) in &self.owners {
if find_owner_call(name)?.data == call.data {
word[12..].copy_from_slice(owner.as_slice());
}
}
Ok(word)
}
}
fn signed(signer: &str, body: &str) -> anyhow::Result<String> {
let fields = SignatureFields {
signer: signer.to_owned(),
signature: sign(&KEY, signer, &body_digest(body))?,
};
Ok(write_signature(body, &fields))
}
fn shown(checker: &Checker, file: &str, node: &FakeNode) -> String {
let bytes = checker.decorate(file.as_bytes().to_vec(), node);
String::from_utf8_lossy(&bytes).into_owned()
}
#[test]
fn each_verdict_lands_in_the_frontmatter() -> anyhow::Result<()> {
let owner = address_of(&KEY)?;
let node =
FakeNode::new(vec![("bob.team.eth", owner), ("eve.x.eth", owner)]);
let checker = Checker::new(String::new(), Some("team.eth".to_owned()));
let signed_file = signed("bob.team.eth", BODY)?;
let cases = [
(BODY.to_owned(), "Unsigned"),
(signed_file.clone(), "Verified: bob.team.eth"),
(
signed_file.replace("Ship it.", "Ship it later."),
"Tampered: signed as bob.team.eth",
),
(
signed("carol.team.eth", BODY)?,
"Unauthorized: carol.team.eth is not registered",
),
(
signed("eve.x.eth", BODY)?,
"Unauthorized: eve.x.eth is not a member of team.eth",
),
];
for (file, badge) in cases {
let expected = format!("---\nens-verification: \"{badge}\"\n");
assert!(
shown(&checker, &file, &node).starts_with(&expected),
"{badge}"
);
}
Ok(())
}
#[test]
fn a_node_that_is_down_is_named_not_blamed() -> anyhow::Result<()> {
let mut node = FakeNode::new(Vec::new());
node.down = true;
let checker = Checker::new(String::new(), None);
let file = signed("bob.team.eth", BODY)?;
let expected =
"---\nens-verification: \"Not checked: Sepolia unreachable\"";
assert!(shown(&checker, &file, &node).starts_with(expected));
// The failure is remembered for a while rather than retried per read.
shown(&checker, &file, &node);
assert_eq!(node.asked.get(), 1);
Ok(())
}
#[test]
fn a_verdict_is_reused_only_for_the_same_signed_body() -> anyhow::Result<()> {
let node = FakeNode::new(vec![("bob.team.eth", address_of(&KEY)?)]);
let checker = Checker::new(String::new(), None);
let file = signed("bob.team.eth", BODY)?;
for _ in 0..3 {
shown(&checker, &file, &node);
}
assert_eq!(node.asked.get(), 1);
// Other frontmatter is not signed, so it shares the verdict.
shown(
&checker,
&file.replace("title: Plan", "title: Other"),
&node,
);
assert_eq!(node.asked.get(), 1);
shown(&checker, &signed("bob.team.eth", "New body")?, &node);
assert_eq!(node.asked.get(), 2);
Ok(())
}
#[test]
fn a_file_too_large_to_hash_is_not_checked() -> anyhow::Result<()> {
let node = FakeNode::new(Vec::new());
let checker = Checker::new(String::new(), None);
let body = "x".repeat(super::MAX_CHECKED);
let file = signed("bob.team.eth", &body)?;
let expected = "---\nens-verification: \"Not checked: file too large\"";
assert!(shown(&checker, &file, &node).starts_with(expected));
assert_eq!(node.asked.get(), 0);
Ok(())
}
#[test]
fn bytes_that_are_not_text_pass_through() {
let node = FakeNode::new(Vec::new());
let checker = Checker::new(String::new(), None);
assert_eq!(checker.decorate(vec![0xff, 0x00], &node), vec![0xff, 0x00]);
assert_eq!(node.asked.get(), 0);
}
#[test]
fn the_rpc_request_and_answer_are_json_rpc() -> anyhow::Result<()> {
let call = find_owner_call("mdsig91205.eth")?;
let body = request_body(&call);
assert!(body.starts_with(concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"#,
r#""0x85edf8b6b7d4211e2b07aa687506b746357b92cf","data":"0x97ad3b3b"#,
)));
assert!(body.ends_with(r#""},"latest"]}"#));
let answer = r#"{"jsonrpc":"2.0","id":1,"result": "0x00ff10"}"#;
assert_eq!(result_bytes(answer)?, vec![0x00, 0xff, 0x10]);
assert_eq!(result_bytes(r#"{"result":"0x"}"#)?, Vec::<u8>::new());
for bad in [
r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000}}"#,
r#"{"result":"0x0"}"#,
r#"{"result":"0xzz"}"#,
r#"{"result":null}"#,
] {
assert!(result_bytes(bad).is_err(), "{bad}");
}
Ok(())
}
}
+441
View File
@@ -0,0 +1,441 @@
//! 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(etag(&value)),
_ => {}
}
}
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)
}
/// An entity tag in the quoted form an `ETag` header carries.
///
/// Some servers, WsgiDAV among them, list `getetag` bare while their `ETag`
/// header quotes the same tag, so a revision read from a listing and one
/// returned by a write would differ for the same bytes. Quoting a bare tag
/// gives every revision one spelling, and `If-Match` accepts that form. A tag
/// that is already quoted, weak or strong, is kept as it is.
pub fn etag(value: &str) -> String {
let value = value.trim();
if value.starts_with('"') || value.starts_with("W/\"") {
value.to_owned()
} else {
format!("\"{value}\"")
}
}
/// 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, etag, 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(())
}
#[test]
fn bare_etags_are_quoted_like_headers() {
assert_eq!(etag("1996000-1790356720-264"), "\"1996000-1790356720-264\"");
assert_eq!(etag(" \"v1\" "), "\"v1\"");
assert_eq!(etag("W/\"v1\""), "W/\"v1\"");
assert_eq!(etag("W/v1"), "\"W/v1\"");
}
}
+548
View File
@@ -0,0 +1,548 @@
//! A WebDAV folder for Memona that signs Markdown with an ENS name and checks
//! the signature of every Markdown file it reads, speaking only the host's
//! HTTP.
mod badge;
mod check;
mod dav;
mod sdk;
mod sign;
use check::Checker;
use sdk::host;
use sdk::{
Capabilities, ChangePage, Entry, EntryKind, ErrorCode, FileInfo, Guest,
GuestReader, GuestWriter, ListPage, Locator, Reader, Setting, WriteCondition,
Writer,
};
use sign::Signer;
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;
/// The largest Markdown file a save signs. Memona gives each call into the
/// plugin 20 million WebAssembly instructions; signing costs about 5.6
/// million plus 85 per byte, or 97 with CRLF line endings, so a larger file
/// could run out mid-call and leave the save's outcome unknown. It is
/// refused up front instead.
const MAX_SIGNED: usize = 96 * 1024;
/// Room a save may carry beyond [`MAX_SIGNED`] before its verdict line is
/// dropped, so a file read back at the limit can be saved unedited.
const BADGE_ALLOWANCE: usize = 4 * 1024;
/// The largest Markdown file a read decorates with a verdict line. Memona
/// opens files of at most one transfer, and a larger one streams through.
const MAX_DECORATED: usize = MAX_CHUNK as usize;
/// Where signers are checked when the connection names no RPC address.
const DEFAULT_RPC_URL: &str = "https://ethereum-sepolia-rpc.publicnode.com";
struct Session {
root: String,
authorization: Option<String>,
/// None on a verify-only connection, which stores saves unsigned.
signer: Option<Signer>,
checker: Checker,
}
// The host creates one component instance per connection and calls into it
// on a single thread, so this slot holds exactly one connection's session and
// is not shared state between connections. A WASI component has no other place
// to keep state between exported calls.
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 is_http(url: &str) -> bool {
url.starts_with("https://") || url.starts_with("http://")
}
/// Reads a whole response body, refusing one longer than `cap` bytes.
fn drain(call: &host::Exchange, cap: usize) -> Result<Vec<u8>, ErrorCode> {
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() > cap {
return Err(ErrorCode::Unsupported);
}
}
}
fn info_of(found: &dav::Found) -> FileInfo {
FileInfo {
kind: if found.collection {
EntryKind::Directory
} else {
EntryKind::File
},
size: found.length,
revision: found.etag.clone(),
}
}
/// Where a reader's bytes come from.
enum Source {
/// A GET that started at the reader's offset, streamed as it arrives.
Stream {
call: host::Exchange,
body: RefCell<dav::Body>,
},
/// A Markdown file already fetched whole and given its verdict line, and
/// how far into it the reader has handed bytes over.
Decorated { data: Vec<u8>, at: Cell<usize> },
}
/// Reads one file, streamed from the server or served from a decorated copy.
struct DavReader {
source: Source,
revision: Option<String>,
}
impl GuestReader for DavReader {
fn pull(&self, limit: u32) -> Result<Vec<u8>, ErrorCode> {
let limit = limit.min(MAX_CHUNK) as usize;
match &self.source {
Source::Stream { call, body } => body
.borrow_mut()
.pull(limit, |wanted| call.pull(wanted).map_err(transport)),
Source::Decorated { data, at } => {
let start = at.get().min(data.len());
let end = start.saturating_add(limit).min(data.len());
at.set(end);
Ok(data[start..end].to_vec())
}
}
}
fn revision(&self) -> Option<String> {
self.revision.clone()
}
}
/// Whether a path names a Markdown file, the only kind this plugin signs.
fn is_markdown(path: &str) -> bool {
path.to_ascii_lowercase().ends_with(".md")
}
/// Writes one file through a PUT whose body is pushed in pieces.
struct DavWriter {
call: host::Exchange,
done: RefCell<bool>,
written: Cell<u64>,
/// A Markdown file waits here whole until `finish`, because its signature
/// covers the entire body and lands in the frontmatter at the front.
markdown: Option<RefCell<Vec<u8>>>,
}
impl DavWriter {
fn send(&self, data: &[u8]) -> Result<(), ErrorCode> {
self.call.push(data).map_err(transport)?;
self.written.set(self.written.get() + data.len() as u64);
Ok(())
}
}
impl GuestWriter for DavWriter {
fn push(&self, data: Vec<u8>) -> Result<(), ErrorCode> {
if *self.done.borrow() {
return Err(ErrorCode::InvalidInput);
}
let Some(markdown) = &self.markdown else {
return self.send(&data);
};
let mut markdown = markdown.borrow_mut();
if markdown.len() + data.len() > MAX_SIGNED + BADGE_ALLOWANCE {
return Err(ErrorCode::Unsupported);
}
markdown.extend_from_slice(&data);
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;
if let Some(markdown) = &self.markdown {
// The verdict line a read added is Memona's copy of it coming back; it
// is dropped before signing, or before storing unsigned on a
// verify-only connection, so the server never stores a verdict.
let markdown = markdown.borrow();
let text =
std::str::from_utf8(&markdown).map_err(|_| ErrorCode::InvalidInput)?;
let stored = badge::strip(text);
if stored.len() > MAX_SIGNED {
return Err(ErrorCode::Unsupported);
}
let saved =
session(|session| sign::stored(session.signer.as_ref(), &stored))?;
for piece in saved.as_bytes().chunks(MAX_CHUNK as usize) {
self.send(piece)?;
}
}
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| dav::etag(&header.value));
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)?;
let rpc_url =
lookup("rpcUrl").unwrap_or_else(|| DEFAULT_RPC_URL.to_owned());
if !is_http(&root) || !is_http(&rpc_url) {
return Err(ErrorCode::InvalidInput);
}
let authorization = match (lookup("username"), lookup("password")) {
(Some(username), Some(password)) => {
Some(dav::basic(&username, &password))
}
_ => None,
};
let signer = Signer::configured(lookup("ensName"), lookup("privateKey"))?;
SESSION.set(Some(Session {
root,
authorization,
signer,
checker: Checker::new(rpc_url, lookup("trustedParent")),
}));
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.
///
/// A Markdown file of at most [`MAX_DECORATED`] bytes is fetched whole,
/// verified, and served with an `ens-verification` line in its frontmatter,
/// from `offset` into those decorated bytes. Memona reads a file in slices,
/// each through a fresh reader, so every slice decorates the same way; the
/// verdict cache keeps the line identical between slices.
///
/// Size and revision always describe the file as stored, never the
/// decorated copy. The revision must stay the server's etag, because it is
/// what `If-Match` sends back on save, and one spelling across stat, list,
/// read and write is what lets Memona recognise its own save. The size
/// stays the stored length because learning the decorated one would take a
/// GET and a Sepolia lookup for every file a listing names; Memona uses the
/// size only to refuse oversized files up front and reads to end of file,
/// so a read running a line longer than the listed size is harmless.
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 decorate = is_markdown(&path) && entry.length <= MAX_DECORATED as u64;
let range = (!decorate).then(|| host::Header {
name: "range".to_owned(),
value: format!("bytes={offset}-"),
});
let call = begin(host::Method::Get, &path, range.into_iter().collect())?;
let head = call.respond().map_err(transport)?;
// 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| dav::etag(&header.value))
});
let source = if decorate {
dav::outcome(head.status)?;
let stored = drain(&call, MAX_DECORATED)?;
let shown = session(|session| {
Ok(session.checker.decorate(stored, &session.checker.rpc()))
})?;
Source::Decorated {
data: shown,
at: Cell::new(usize::try_from(offset).unwrap_or(usize::MAX)),
}
} else {
let body = if dav::range_outcome(head.status)? {
dav::Body::default()
} else {
dav::Body::empty()
};
Source::Stream {
call,
body: RefCell::new(body),
}
};
Ok(Reader::new(DavReader { source, revision }))
}
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),
markdown: is_markdown(&path).then(|| RefCell::new(Vec::new())),
}))
}
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
View File
@@ -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;
+155
View File
@@ -0,0 +1,155 @@
//! Signing a saved Markdown file, the only place this plugin touches its
//! bytes. Everything the md-ens-signature library does for the plugin goes
//! through the interface here.
use crate::sdk::ErrorCode;
use md_ens_signature::document::{
SignatureFields, body_digest, write_signature,
};
use md_ens_signature::signature::{address_of, sign};
/// One connection's signing identity: an ENS name and the private key bound
/// to it.
pub struct Signer {
name: String,
key: [u8; 32],
}
/// Reads a 32-byte private key written as 64 hex characters, with or without
/// a `0x` prefix.
fn parse_key(text: &str) -> Option<[u8; 32]> {
let digits = text.trim();
let digits = digits.strip_prefix("0x").unwrap_or(digits).as_bytes();
if digits.len() != 64 {
return None;
}
let mut key = [0u8; 32];
for (byte, pair) in key.iter_mut().zip(digits.chunks(2)) {
let text = std::str::from_utf8(pair).ok()?;
*byte = u8::from_str_radix(text, 16).ok()?;
}
Some(key)
}
impl Signer {
/// Takes the configured name and key, failing with `InvalidInput` when the
/// key cannot sign, so a bad key refuses the connection rather than every
/// later save.
pub fn new(name: &str, private_key: &str) -> Result<Self, ErrorCode> {
let name = name.trim().to_lowercase();
let key = parse_key(private_key).ok_or(ErrorCode::InvalidInput)?;
if name.is_empty() || address_of(&key).is_err() {
return Err(ErrorCode::InvalidInput);
}
Ok(Self { name, key })
}
/// The connection's identity from its optional name and key. Both sign
/// saves; neither leaves the folder verify-only; one without the other is
/// a mistake that refuses the connection.
pub fn configured(
name: Option<String>,
private_key: Option<String>,
) -> Result<Option<Self>, ErrorCode> {
match (name, private_key) {
(Some(name), Some(key)) => Self::new(&name, &key).map(Some),
(None, None) => Ok(None),
(Some(_), None) | (None, Some(_)) => Err(ErrorCode::InvalidInput),
}
}
/// Returns the whole file with its `signer` and `signature` frontmatter set
/// for this identity, other frontmatter and the body untouched. The caller
/// has already refused bytes that are not UTF-8, so they are not checked a
/// second time here.
pub fn sign(&self, markdown: &str) -> Result<String, ErrorCode> {
let signature = sign(&self.key, &self.name, &body_digest(markdown))
.map_err(|_| ErrorCode::Internal)?;
let fields = SignatureFields {
signer: self.name.clone(),
signature,
};
Ok(write_signature(markdown, &fields))
}
}
/// The bytes a save stores, its verdict line already dropped: signed when the
/// connection has a signer, and otherwise exactly as given, so a signature
/// the file already carries stays and an edit then reads as Tampered.
pub fn stored(
signer: Option<&Signer>,
markdown: &str,
) -> Result<String, ErrorCode> {
match signer {
Some(signer) => signer.sign(markdown),
None => Ok(markdown.to_owned()),
}
}
#[cfg(test)]
mod tests {
use super::{Signer, parse_key, stored};
use crate::badge;
use crate::sdk::ErrorCode;
use md_ens_signature::document::{body_digest, read_signature};
use md_ens_signature::signature::{address_of, recover};
const KEY: &str =
"0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
#[test]
fn a_signed_file_recovers_to_the_key_and_keeps_its_body()
-> Result<(), ErrorCode> {
let signer = Signer::new(" Laptop.Alice.eth ", KEY)?;
let saved = "---\ntitle: Notes\n---\n# Notes\n\nHello\n";
let signed = signer.sign(saved)?;
assert!(signed.starts_with("---\ntitle: Notes\n"));
assert!(signed.ends_with("---\n# Notes\n\nHello\n"));
// YAML reads a bare 0x... as an integer, so the signature stays a string.
assert!(signed.contains("\nsignature: \"0x"));
let fields = read_signature(&signed).ok_or(ErrorCode::Internal)?;
assert_eq!(fields.signer, "laptop.alice.eth");
let key = parse_key(KEY).ok_or(ErrorCode::Internal)?;
let recovered =
recover(&fields.signature, &fields.signer, &body_digest(&signed))
.map_err(|_| ErrorCode::Internal)?;
assert_eq!(Some(recovered), address_of(&key).ok());
Ok(())
}
#[test]
fn a_key_that_cannot_sign_refuses_the_connection() {
assert!(Signer::new("alice.eth", "0x1234").is_err());
assert!(Signer::new("alice.eth", &"zz".repeat(32)).is_err());
assert!(Signer::new("alice.eth", &"00".repeat(32)).is_err());
assert!(Signer::new(" ", KEY).is_err());
assert!(Signer::new("alice.eth", &KEY[2..]).is_ok());
}
#[test]
fn a_name_and_key_come_together_or_not_at_all() {
let given = |text: &str| Some(text.to_owned());
assert!(matches!(Signer::configured(None, None), Ok(None)));
assert!(matches!(
Signer::configured(given("alice.eth"), given(KEY)),
Ok(Some(_))
));
assert!(Signer::configured(given("alice.eth"), None).is_err());
assert!(Signer::configured(None, given(KEY)).is_err());
}
#[test]
fn a_verify_only_save_keeps_every_byte_but_the_verdict()
-> Result<(), ErrorCode> {
let file = concat!(
"---\r\ntitle: Notes\r\nsigner: bob.team.eth\r\n",
"signature: \"0xabc\"\r\n---\r\n# Notes, edited\r\n\r\n",
);
let shown = badge::inject(file, "Verified: bob.team.eth");
assert_ne!(shown, file);
assert_eq!(stored(None, &badge::strip(&shown))?, file);
let plain = "# No frontmatter\n";
assert_eq!(stored(None, &badge::strip(plain))?, plain);
Ok(())
}
}
+200
View File
@@ -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;
}