From e0f4cf2c4b0f91de0cbd94b36095c84f62d68220 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Wed, 30 Sep 2026 13:10:14 +0000 Subject: [PATCH] WebDAV validates connections and preserves directory listings, with CI checking every change (#1) Co-authored-by: Danny Kim Co-committed-by: Danny Kim --- .gitea/workflows/agents.yml | 49 ++++++++ .gitea/workflows/ci.yml | 51 ++++++++ Cargo.lock | 30 ++++- Cargo.toml | 5 +- README.md | 6 + manifest.json | 5 +- src/dav.rs | 141 ++++++++++++++++++---- src/lib.rs | 226 ++++++++++++++++++++++++++++++++---- wit/filesystem.wit | 27 ++--- 9 files changed, 475 insertions(+), 65 deletions(-) create mode 100644 .gitea/workflows/agents.yml create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/agents.yml b/.gitea/workflows/agents.yml new file mode 100644 index 0000000..7f49767 --- /dev/null +++ b/.gitea/workflows/agents.yml @@ -0,0 +1,49 @@ +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] + +# New human events replace stale runs for the same work item. AI-bot comments +# get unique groups so their skipped workflows cannot cancel an active login. +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: + bot: + # Nothing the bot account posts can start a run, so it cannot trigger itself. + if: > + 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'))) + runs-on: self-hosted + container: + volumes: + - bot-home:/root + steps: + - name: Run AI bot + # Intentionally follow protected main so centrally managed profiles and + # quota fallbacks update without per-repository credential changes. + uses: https://git.capsulizers.com/commons/bot-agents@main + with: + master-key: ${{ secrets.BOT_AGENTS_MASTER_KEY }} diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..32da818 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +concurrency: + group: ${{ gitea.workflow }}-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: self-hosted + container: + volumes: + - memona-plugin-target:/cargo-target + env: + CARGO_BUILD_JOBS: 2 + CARGO_TARGET_DIR: /cargo-target/serialized + CARGO_HOME: /cargo-target/cargo-home + RUSTUP_HOME: /cargo-target/rustup-home + RUSTUP_TOOLCHAIN: stable + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install system packages + run: >- + apt-get update && apt-get install -y --no-install-recommends + build-essential ca-certificates curl util-linux + + # The same lock protects the shared toolchain and every Cargo command. + # Keep its target and lock paths in sync with Memona's plugin checks. + - name: Check and build plugin + run: | + flock --no-fork "$CARGO_TARGET_DIR.lock" sh -ec ' + export PATH="$CARGO_HOME/bin:$PATH" + if [ ! -x "$CARGO_HOME/bin/rustup" ]; then + mkdir -p "$CARGO_HOME" + curl --proto =https --tlsv1.2 -fsSL https://sh.rustup.rs -o "$CARGO_HOME/rustup-init.sh" + sh "$CARGO_HOME/rustup-init.sh" -y --no-modify-path --default-toolchain none + rm "$CARGO_HOME/rustup-init.sh" + fi + rustup toolchain install stable --profile minimal --component clippy,rustfmt --target wasm32-wasip2 --no-self-update + cargo fmt --check + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked --lib + cargo build --locked --release --target wasm32-wasip2 + ' diff --git a/Cargo.lock b/Cargo.lock index a0b5338..8806c61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,12 +96,19 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memona-plugin-webdav" -version = "0.1.1" +version = "0.1.2" dependencies = [ "quick-xml", + "tokio", "wit-bindgen", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "prettyplease" version = "0.3.0" @@ -198,6 +205,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.26" diff --git a/Cargo.toml b/Cargo.toml index 960aa8c..a8fadb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "memona-plugin-webdav" -version = "0.1.1" +version = "0.1.2" edition = "2024" license = "MIT" description = "A WebDAV filesystem provider for Memona, built as a WASI component." @@ -13,6 +13,9 @@ crate-type = ["cdylib", "rlib"] wit-bindgen = "=0.62.0" quick-xml = { version = "0.37", default-features = false } +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } + [lints.clippy] panic = "deny" unwrap_used = "deny" diff --git a/README.md b/README.md index f1672b9..0a9b050 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ interface, under the connection's permissions. It registers one provider, `webdav`, for the `webdav` and `davs` schemes. +This package requires a Memona version that supports plugin manifest schema 2. + ## Connecting | Field | Meaning | @@ -15,6 +17,10 @@ It registers one provider, `webdav`, for the `webdav` and `davs` schemes. | Username | Optional account name for HTTP basic authentication | | Password | Optional password, stored as a secret | +With the compatible schema 2 Memona version required above, public, LAN, VPN, +link-local and localhost addresses work directly from the server address; +that API has no separate connection network grant. + Listing, reading, writing, creating directories, renaming and deleting are supported. Reads and writes stream in pieces of at most 1 MiB, so a file of any size moves without either side holding it whole. Conditional writes use diff --git a/manifest.json b/manifest.json index 977049e..e44e761 100644 --- a/manifest.json +++ b/manifest.json @@ -1,13 +1,12 @@ { - "schemaVersion": 1, + "schemaVersion": 2, "id": "memona/webdav", "name": "WebDAV", - "version": "0.1.1", + "version": "0.1.2", "apiVersion": "0.1.0", "description": "Open a WebDAV server as a folder, and let agents work in it.", "license": "MIT", "entry": "plugin.wasm", - "networkHosts": ["*"], "networkPorts": [], "providers": [ { diff --git a/src/dav.rs b/src/dav.rs index 8b8dae4..611a832 100644 --- a/src/dav.rs +++ b/src/dav.rs @@ -23,6 +23,9 @@ fn segment(value: &str) -> String { /// Joins a connection-relative path onto the configured root. pub fn url(root: &str, path: &str) -> String { + if path == "/" || path.is_empty() { + return root.to_owned(); + } let mut url = root.trim_end_matches('/').to_owned(); for part in path.split('/').filter(|part| !part.is_empty()) { url.push('/'); @@ -238,23 +241,62 @@ pub fn name_of(href: &str) -> String { 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), +/// An href can be an absolute URL or an absolute path (RFC 4918, section 8.3). +fn href_path(href: &str) -> &str { + let path = match href.split_once("://") { + Some((_, authority_and_path)) => authority_and_path + .find('/') + .map(|start| &authority_and_path[start..]) + .unwrap_or("/"), + None => href, + }; + path + .split(['?', '#']) + .next() + .unwrap_or(path) + .trim_end_matches('/') +} + +/// Responses may arrive in any order. Match the requested resource by its +/// path, allowing collection slashes and equivalent percent encodings. +pub fn resource<'a>( + found: &'a [Found], + url: &str, +) -> Result<&'a Found, ErrorCode> { + found + .iter() + .find(|entry| { + href_path(&entry.href) + .split('/') + .map(name_of) + .eq(href_path(url).split('/').map(name_of)) + }) + .ok_or(ErrorCode::NotFound) +} + +/// The direct children in a Depth 1 reply, excluding the requested directory. +pub fn children<'a>( + found: &'a [Found], + url: &str, +) -> Result, ErrorCode> { + let parent = resource(found, url)?; + if !parent.collection { + return Err(ErrorCode::InvalidInput); } + Ok( + found + .iter() + .filter(|entry| !std::ptr::eq(*entry, parent)) + .collect(), + ) } /// 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(()), +pub fn removable(found: &[Found], url: &str) -> Result<(), ErrorCode> { + match children(found, url) { + Ok(children) if children.is_empty() => Ok(()), Ok(_) => Err(ErrorCode::Conflict), Err(ErrorCode::InvalidInput) => Ok(()), Err(error) => Err(error), @@ -265,24 +307,24 @@ pub fn removable(found: &[Found]) -> Result<(), ErrorCode> { mod tests { use super::{ Body, Found, basic, children, multistatus, name_of, outcome, range_outcome, - removable, url, + removable, resource, url, }; use crate::sdk::ErrorCode; - fn found(collection: bool) -> Found { + fn found(href: &str, collection: bool) -> Found { Found { - href: String::new(), + href: href.to_owned(), collection, length: 0, etag: None, } } - #[test] - fn paths_join_onto_the_root_without_growing_a_boundary() { + #[tokio::test] + async fn paths_join_onto_the_root_without_growing_a_boundary() { assert_eq!( url("https://dav.example/files/", "/"), - "https://dav.example/files" + "https://dav.example/files/" ); assert_eq!( url("https://dav.example/files", "/notes/today.md"), @@ -299,6 +341,46 @@ mod tests { ); } + #[tokio::test] + async fn listings_keep_children_when_the_directory_is_not_first() + -> Result<(), ErrorCode> { + let entries = [found("/files/a.md", false), found("/files/", true)]; + let listed = children(&entries, "https://dav.example/files/")?; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].href, "/files/a.md"); + Ok(()) + } + + #[tokio::test] + async fn a_file_before_its_directory_never_allows_recursive_delete() { + let entries = [found("/files/a.md", false), found("/files/", true)]; + assert_eq!( + removable(&entries, "https://dav.example/files/"), + Err(ErrorCode::Conflict) + ); + } + + #[tokio::test] + async fn hrefs_match_absolute_urls_and_equivalent_encoded_paths() + -> Result<(), ErrorCode> { + let entries = [ + found("https://dav.example/my%20files/%e9%9b%aa.md", false), + found("https://dav.example/my%20files/", true), + ]; + let listed = children(&entries, "https://dav.example/my%20files")?; + assert_eq!(listed.len(), 1); + assert_eq!(name_of(&listed[0].href), "雪.md"); + assert!( + !resource(&entries, "https://dav.example/my%20files/%E9%9B%AA.md")? + .collection + ); + assert_eq!( + removable(&entries, "https://dav.example/unrelated/"), + Err(ErrorCode::NotFound) + ); + Ok(()) + } + #[test] fn credentials_encode_the_way_basic_auth_expects() { assert_eq!(basic("user", "pass"), "Basic dXNlcjpwYXNz"); @@ -366,21 +448,30 @@ mod tests { 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(())); + #[tokio::test] + async fn only_an_empty_directory_or_a_file_may_be_removed() { + let url = "https://dav.example/files/"; + assert_eq!(removable(&[found("/files/", false)], url), Ok(())); + assert_eq!(removable(&[found("/files/", true)], url), Ok(())); assert_eq!( - removable(&[found(true), found(false)]), + removable(&[found("/files/", true), found("/files/a.md", false)], url), Err(ErrorCode::Conflict) ); - assert_eq!(removable(&[]), Err(ErrorCode::NotFound)); + assert_eq!(removable(&[], url), Err(ErrorCode::NotFound)); assert_eq!( - children(&[found(false)]).err(), + children(&[found("/files/", false)], url).err(), Some(ErrorCode::InvalidInput) ); assert_eq!( - children(&[found(true), found(false), found(true)]).map(<[Found]>::len), + children( + &[ + found("/files/", true), + found("/files/a.md", false), + found("/files/notes/", true) + ], + url, + ) + .map(|children| children.len()), Ok(2) ); } diff --git a/src/lib.rs b/src/lib.rs index fef0338..abd2df0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,9 +23,43 @@ const MAX_CHUNK: u32 = 1024 * 1024; struct Session { root: String, + /// The server's canonical href is only used to identify listing responses. + root_href: String, authorization: Option, } +impl Session { + fn from_config(config: &[(String, Setting)]) -> Result { + let lookup = |wanted: &str| { + config + .iter() + .find(|(key, _)| key == wanted) + .and_then(|(_, value)| text(Some(value.clone()))) + }; + let root = lookup("url").ok_or(ErrorCode::InvalidInput)?; + if !root.starts_with("https://") && !root.starts_with("http://") { + return Err(ErrorCode::InvalidInput); + } + let authorization = lookup("username").map(|username| { + dav::basic(&username, &lookup("password").unwrap_or_default()) + }); + Ok(Self { + root_href: root.clone(), + root, + authorization, + }) + } + + fn record_root(&mut self, found: &[dav::Found]) -> Result<(), ErrorCode> { + let root = found.first().ok_or(ErrorCode::NotFound)?; + if !root.collection || root.href.is_empty() { + return Err(ErrorCode::InvalidInput); + } + self.root_href.clone_from(&root.href); + Ok(()) + } +} + thread_local! { static SESSION: RefCell> = const { RefCell::new(None) }; } @@ -34,10 +68,11 @@ struct Webdav; fn text(setting: Option) -> Option { match setting { - Some(Setting::Text(value)) | Some(Setting::Secret(value)) => { + Some(Setting::Text(value)) => { let value = value.trim().to_owned(); (!value.is_empty()).then_some(value) } + Some(Setting::Secret(value)) => Some(value), Some(Setting::Toggle(_)) | None => None, } } @@ -225,26 +260,20 @@ impl Guest for Webdav { _provider_id: String, config: Vec<(String, Setting)>, ) -> Result { - let lookup = |wanted: &str| { - config - .iter() - .find(|(key, _)| key == wanted) - .and_then(|(_, value)| text(Some(value.clone()))) - }; - let root = lookup("url").ok_or(ErrorCode::InvalidInput)?; - if !root.starts_with("https://") && !root.starts_with("http://") { - return Err(ErrorCode::InvalidInput); + SESSION.set(None); + SESSION.set(Some(Session::from_config(&config)?)); + let probe = describe("/", "0").and_then(|found| { + SESSION.with_borrow_mut(|session| { + session + .as_mut() + .ok_or(ErrorCode::Internal)? + .record_root(&found) + }) + }); + if let Err(error) = probe { + SESSION.set(None); + return Err(error); } - let authorization = match (lookup("username"), lookup("password")) { - (Some(username), Some(password)) => { - Some(dav::basic(&username, &password)) - } - _ => None, - }; - SESSION.set(Some(Session { - root, - authorization, - })); Ok(Capabilities { write: true, mkdir: true, @@ -281,7 +310,8 @@ impl Guest for Webdav { None => 0, }; let found = describe(&path, "1")?; - let children: Vec<_> = dav::children(&found)? + let url = session(|session| Ok(dav::url(&session.root_href, &path)))?; + let children: Vec<_> = dav::children(&found, &url)? .iter() .map(|entry| Entry { name: dav::name_of(&entry.href), @@ -400,7 +430,8 @@ impl Guest for Webdav { /// first and refused unless it is empty. fn remove(target: Locator) -> Result<(), ErrorCode> { let path = route(&target); - dav::removable(&describe(&path, "1")?)?; + let url = session(|session| Ok(dav::url(&session.root_href, &path)))?; + dav::removable(&describe(&path, "1")?, &url)?; exchange(host::Method::Delete, &path, Vec::new(), &[])?; Ok(()) } @@ -410,4 +441,155 @@ impl Guest for Webdav { } } +#[cfg(not(test))] sdk::export!(Webdav with_types_in sdk); + +#[cfg(test)] +mod tests { + use super::{SESSION, Session, Webdav}; + use crate::dav; + use crate::sdk::{ErrorCode, Guest, Setting}; + + #[tokio::test] + async fn passwords_keep_leading_and_trailing_whitespace() + -> Result<(), ErrorCode> { + let config = vec![ + ( + "url".to_owned(), + Setting::Text("https://dav.example/".to_owned()), + ), + ("username".to_owned(), Setting::Text("user".to_owned())), + ( + "password".to_owned(), + Setting::Secret(" pass word \t".to_owned()), + ), + ]; + assert_eq!( + Session::from_config(&config)?.authorization.as_deref(), + Some("Basic dXNlcjogcGFzcyB3b3JkIAk=") + ); + Ok(()) + } + + #[tokio::test] + async fn a_username_without_a_password_setting_sends_basic_auth() + -> Result<(), ErrorCode> { + let config = vec![ + ( + "url".to_owned(), + Setting::Text("https://dav.example/".to_owned()), + ), + ("username".to_owned(), Setting::Text("user".to_owned())), + ]; + assert_eq!( + Session::from_config(&config)?.authorization.as_deref(), + Some("Basic dXNlcjo=") + ); + Ok(()) + } + + #[tokio::test] + async fn no_username_keeps_the_connection_anonymous() -> Result<(), ErrorCode> + { + let config = vec![ + ( + "url".to_owned(), + Setting::Text("https://dav.example/".to_owned()), + ), + ("password".to_owned(), Setting::Secret("unused".to_owned())), + ]; + assert!(Session::from_config(&config)?.authorization.is_none()); + Ok(()) + } + + #[tokio::test] + async fn a_redirected_root_lists_its_canonical_directory() + -> Result<(), ErrorCode> { + let config = vec![( + "url".to_owned(), + Setting::Text("https://dav.example/webdav".to_owned()), + )]; + let mut session = Session::from_config(&config)?; + let entries = [ + dav::Found { + href: "/files/notes.md".to_owned(), + collection: false, + length: 12, + etag: None, + }, + dav::Found { + href: "/files/".to_owned(), + collection: true, + length: 0, + etag: None, + }, + ]; + session.record_root(&entries[1..])?; + let expected_url = dav::url(&session.root_href, "/"); + let listed = dav::children(&entries, &expected_url)?; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].href, "/files/notes.md"); + + let nested = [ + dav::Found { + href: "/files/documents/child.md".to_owned(), + collection: false, + length: 7, + etag: None, + }, + dav::Found { + href: "/files/documents/".to_owned(), + collection: true, + length: 0, + etag: None, + }, + ]; + let expected_url = dav::url(&session.root_href, "/documents"); + let listed = dav::children(&nested, &expected_url)?; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].href, "/files/documents/child.md"); + assert_eq!( + dav::removable(&nested, &expected_url), + Err(ErrorCode::Conflict) + ); + Ok(()) + } + + #[tokio::test] + async fn a_canonical_href_does_not_change_the_authenticated_request_origin() + -> Result<(), ErrorCode> { + let config = vec![ + ( + "url".to_owned(), + Setting::Text("https://dav.example/webdav".to_owned()), + ), + ("username".to_owned(), Setting::Text("user".to_owned())), + ]; + let mut session = Session::from_config(&config)?; + session.record_root(&[dav::Found { + href: "https://other.example/files/".to_owned(), + collection: true, + length: 0, + etag: None, + }])?; + assert_eq!(dav::url(&session.root, "/"), "https://dav.example/webdav"); + assert_eq!( + dav::url(&session.root, "/documents/child.md"), + "https://dav.example/webdav/documents/child.md" + ); + assert_eq!(session.authorization.as_deref(), Some("Basic dXNlcjo=")); + Ok(()) + } + + #[tokio::test] + async fn a_failed_reopen_discards_the_previous_connection() { + SESSION.set(Some(Session { + root: "https://dav.example/files/".to_owned(), + root_href: "/files/".to_owned(), + authorization: None, + })); + let result = Webdav::open("webdav".to_owned(), Vec::new()); + assert_eq!(result.err(), Some(ErrorCode::InvalidInput)); + assert!(SESSION.with_borrow(Option::is_none)); + } +} diff --git a/wit/filesystem.wit b/wit/filesystem.wit index 4529372..624ee9d 100644 --- a/wit/filesystem.wit +++ b/wit/filesystem.wit @@ -35,11 +35,11 @@ interface host { /// `respond`. An empty result means the body is complete. pull: func(limit: u32) -> result, host-error>; } - /// Begins a request to a host both declared by the package and granted on - /// the connection. Each redirect and DNS result is checked, and DNS is - /// pinned. Private/LAN addresses additionally require the private-network - /// grant. Cross-origin redirects remove request headers; HTTPS cannot - /// downgrade. The host bounds total bytes per exchange, not buffer size. + /// Begins a request to any HTTP or HTTPS host, including LAN and loopback + /// addresses. This API has no per-connection network grant. Each redirect + /// and DNS result is checked, and DNS is pinned. Cross-origin redirects + /// remove request headers; HTTPS cannot downgrade. The host bounds total + /// bytes per exchange, not buffer size. http: func(request: request) -> result; /// One open TCP connection. Dropping the handle closes the socket, and the @@ -51,13 +51,13 @@ interface host { /// Writes at most 1,048,576 bytes and returns how many were accepted. write: func(data: list) -> result; } - /// Opens a socket to a host and port both declared by the package and - /// granted on the connection. The host resolves and pins DNS, applies the - /// same private-address rule as `http`, and starts TLS when asked. Ports - /// negotiated at runtime, such as an FTP data channel, still need a grant. + /// Opens a socket to any host, including LAN and loopback addresses, on a + /// package-declared port. This API has no per-connection network grant. + /// The host resolves and pins DNS and starts TLS when asked. Ports + /// negotiated at runtime, such as an FTP data channel, must be declared. connect: func(host: string, port: u16, tls: bool) -> result; - /// One datagram socket connected to a single granted peer. + /// One datagram socket connected to a single peer. resource udp { /// Sends one datagram of at most 65,507 bytes. send: func(data: list) -> result; @@ -65,8 +65,8 @@ interface host { /// means none arrived inside the host's wait, not that the peer left. receive: func(limit: u32) -> result, host-error>; } - /// Connects a datagram socket under the same declaration and grant rules - /// as `connect`, for the few filesystem protocols that still need one. + /// Connects a datagram socket under the same address and port rules as + /// `connect`, for the few filesystem protocols that still need one. bind: func(host: string, port: u16) -> result; /// A configuration value, typed as the package's manifest declared it. @@ -81,7 +81,8 @@ interface host { /// 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. +/// Colons and percent signs are literal; paths must never be URL-decoded. +/// Dot segments, backslashes, and controls are invalid. /// Every invocation is bounded by host fuel, memory, time, and cancellation. interface provider { use host.{setting};