Compare commits
8 Commits
eb0002d753
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e77f3ca67 | |||
| 6677e8ad72 | |||
| 967f257902 | |||
| bf0e4561c0 | |||
| 1d36760782 | |||
| 5d46485dad | |||
| 5db8ed318a | |||
| a4d1fae1d3 |
@@ -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
+1016
File diff suppressed because it is too large
Load Diff
+28
@@ -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
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
| 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.
|
||||
|
||||

|
||||
|
||||
## 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 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
| Unauthorized | Unsigned |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
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 |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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