WebDAV validates connections and preserves directory listings, with CI checking every change (#1)
CI / checks (push) Successful in 1m17s

Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
Danny Kim
2026-09-30 13:10:14 +00:00
committed by temeddix
parent 3d69750a26
commit e0f4cf2c4b
9 changed files with 475 additions and 65 deletions
+49
View File
@@ -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 }}
+51
View File
@@ -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
'
Generated
+29 -1
View File
@@ -96,12 +96,19 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]] [[package]]
name = "memona-plugin-webdav" name = "memona-plugin-webdav"
version = "0.1.1" version = "0.1.2"
dependencies = [ dependencies = [
"quick-xml", "quick-xml",
"tokio",
"wit-bindgen", "wit-bindgen",
] ]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.3.0" version = "0.3.0"
@@ -198,6 +205,27 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.26" version = "1.0.26"
+4 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "memona-plugin-webdav" name = "memona-plugin-webdav"
version = "0.1.1" version = "0.1.2"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
description = "A WebDAV filesystem provider for Memona, built as a WASI component." 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" wit-bindgen = "=0.62.0"
quick-xml = { version = "0.37", default-features = false } quick-xml = { version = "0.37", default-features = false }
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }
[lints.clippy] [lints.clippy]
panic = "deny" panic = "deny"
unwrap_used = "deny" unwrap_used = "deny"
+6
View File
@@ -7,6 +7,8 @@ interface, under the connection's permissions.
It registers one provider, `webdav`, for the `webdav` and `davs` schemes. It registers one provider, `webdav`, for the `webdav` and `davs` schemes.
This package requires a Memona version that supports plugin manifest schema 2.
## Connecting ## Connecting
| Field | Meaning | | 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 | | Username | Optional account name for HTTP basic authentication |
| Password | Optional password, stored as a secret | | 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 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 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 size moves without either side holding it whole. Conditional writes use
+2 -3
View File
@@ -1,13 +1,12 @@
{ {
"schemaVersion": 1, "schemaVersion": 2,
"id": "memona/webdav", "id": "memona/webdav",
"name": "WebDAV", "name": "WebDAV",
"version": "0.1.1", "version": "0.1.2",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Open a WebDAV server as a folder, and let agents work in it.", "description": "Open a WebDAV server as a folder, and let agents work in it.",
"license": "MIT", "license": "MIT",
"entry": "plugin.wasm", "entry": "plugin.wasm",
"networkHosts": ["*"],
"networkPorts": [], "networkPorts": [],
"providers": [ "providers": [
{ {
+116 -25
View File
@@ -23,6 +23,9 @@ fn segment(value: &str) -> String {
/// Joins a connection-relative path onto the configured root. /// Joins a connection-relative path onto the configured root.
pub fn url(root: &str, path: &str) -> String { 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(); let mut url = root.trim_end_matches('/').to_owned();
for part in path.split('/').filter(|part| !part.is_empty()) { for part in path.split('/').filter(|part| !part.is_empty()) {
url.push('/'); url.push('/');
@@ -238,23 +241,62 @@ pub fn name_of(href: &str) -> String {
String::from_utf8_lossy(&decoded).into_owned() String::from_utf8_lossy(&decoded).into_owned()
} }
/// The direct children in a Depth 1 reply. The first response describes the /// An href can be an absolute URL or an absolute path (RFC 4918, section 8.3).
/// resource asked about, so it must be a collection before the rest can be fn href_path(href: &str) -> &str {
/// called its children. let path = match href.split_once("://") {
pub fn children(found: &[Found]) -> Result<&[Found], ErrorCode> { Some((_, authority_and_path)) => authority_and_path
match found.split_first() { .find('/')
None => Err(ErrorCode::NotFound), .map(|start| &authority_and_path[start..])
Some((first, rest)) if first.collection => Ok(rest), .unwrap_or("/"),
Some(_) => Err(ErrorCode::InvalidInput), 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<Vec<&'a Found>, 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, /// 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 /// 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. /// WebDAV, so this check is the only thing keeping a whole tree.
pub fn removable(found: &[Found]) -> Result<(), ErrorCode> { pub fn removable(found: &[Found], url: &str) -> Result<(), ErrorCode> {
match children(found) { match children(found, url) {
Ok([]) => Ok(()), Ok(children) if children.is_empty() => Ok(()),
Ok(_) => Err(ErrorCode::Conflict), Ok(_) => Err(ErrorCode::Conflict),
Err(ErrorCode::InvalidInput) => Ok(()), Err(ErrorCode::InvalidInput) => Ok(()),
Err(error) => Err(error), Err(error) => Err(error),
@@ -265,24 +307,24 @@ pub fn removable(found: &[Found]) -> Result<(), ErrorCode> {
mod tests { mod tests {
use super::{ use super::{
Body, Found, basic, children, multistatus, name_of, outcome, range_outcome, Body, Found, basic, children, multistatus, name_of, outcome, range_outcome,
removable, url, removable, resource, url,
}; };
use crate::sdk::ErrorCode; use crate::sdk::ErrorCode;
fn found(collection: bool) -> Found { fn found(href: &str, collection: bool) -> Found {
Found { Found {
href: String::new(), href: href.to_owned(),
collection, collection,
length: 0, length: 0,
etag: None, etag: None,
} }
} }
#[test] #[tokio::test]
fn paths_join_onto_the_root_without_growing_a_boundary() { async fn paths_join_onto_the_root_without_growing_a_boundary() {
assert_eq!( assert_eq!(
url("https://dav.example/files/", "/"), url("https://dav.example/files/", "/"),
"https://dav.example/files" "https://dav.example/files/"
); );
assert_eq!( assert_eq!(
url("https://dav.example/files", "/notes/today.md"), 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] #[test]
fn credentials_encode_the_way_basic_auth_expects() { fn credentials_encode_the_way_basic_auth_expects() {
assert_eq!(basic("user", "pass"), "Basic dXNlcjpwYXNz"); assert_eq!(basic("user", "pass"), "Basic dXNlcjpwYXNz");
@@ -366,21 +448,30 @@ mod tests {
Ok(()) Ok(())
} }
#[test] #[tokio::test]
fn only_an_empty_directory_or_a_file_may_be_removed() { async fn only_an_empty_directory_or_a_file_may_be_removed() {
assert_eq!(removable(&[found(false)]), Ok(())); let url = "https://dav.example/files/";
assert_eq!(removable(&[found(true)]), Ok(())); assert_eq!(removable(&[found("/files/", false)], url), Ok(()));
assert_eq!(removable(&[found("/files/", true)], url), Ok(()));
assert_eq!( assert_eq!(
removable(&[found(true), found(false)]), removable(&[found("/files/", true), found("/files/a.md", false)], url),
Err(ErrorCode::Conflict) Err(ErrorCode::Conflict)
); );
assert_eq!(removable(&[]), Err(ErrorCode::NotFound)); assert_eq!(removable(&[], url), Err(ErrorCode::NotFound));
assert_eq!( assert_eq!(
children(&[found(false)]).err(), children(&[found("/files/", false)], url).err(),
Some(ErrorCode::InvalidInput) Some(ErrorCode::InvalidInput)
); );
assert_eq!( 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) Ok(2)
); );
} }
+204 -22
View File
@@ -23,9 +23,43 @@ const MAX_CHUNK: u32 = 1024 * 1024;
struct Session { struct Session {
root: String, root: String,
/// The server's canonical href is only used to identify listing responses.
root_href: String,
authorization: Option<String>, authorization: Option<String>,
} }
impl Session {
fn from_config(config: &[(String, Setting)]) -> Result<Self, ErrorCode> {
let lookup = |wanted: &str| {
config
.iter()
.find(|(key, _)| key == wanted)
.and_then(|(_, value)| text(Some(value.clone())))
};
let root = lookup("url").ok_or(ErrorCode::InvalidInput)?;
if !root.starts_with("https://") && !root.starts_with("http://") {
return Err(ErrorCode::InvalidInput);
}
let authorization = 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! { thread_local! {
static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) }; static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
} }
@@ -34,10 +68,11 @@ struct Webdav;
fn text(setting: Option<Setting>) -> Option<String> { fn text(setting: Option<Setting>) -> Option<String> {
match setting { match setting {
Some(Setting::Text(value)) | Some(Setting::Secret(value)) => { Some(Setting::Text(value)) => {
let value = value.trim().to_owned(); let value = value.trim().to_owned();
(!value.is_empty()).then_some(value) (!value.is_empty()).then_some(value)
} }
Some(Setting::Secret(value)) => Some(value),
Some(Setting::Toggle(_)) | None => None, Some(Setting::Toggle(_)) | None => None,
} }
} }
@@ -225,26 +260,20 @@ impl Guest for Webdav {
_provider_id: String, _provider_id: String,
config: Vec<(String, Setting)>, config: Vec<(String, Setting)>,
) -> Result<Capabilities, ErrorCode> { ) -> Result<Capabilities, ErrorCode> {
let lookup = |wanted: &str| { SESSION.set(None);
config SESSION.set(Some(Session::from_config(&config)?));
.iter() let probe = describe("/", "0").and_then(|found| {
.find(|(key, _)| key == wanted) SESSION.with_borrow_mut(|session| {
.and_then(|(_, value)| text(Some(value.clone()))) session
}; .as_mut()
let root = lookup("url").ok_or(ErrorCode::InvalidInput)?; .ok_or(ErrorCode::Internal)?
if !root.starts_with("https://") && !root.starts_with("http://") { .record_root(&found)
return Err(ErrorCode::InvalidInput); })
});
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 { Ok(Capabilities {
write: true, write: true,
mkdir: true, mkdir: true,
@@ -281,7 +310,8 @@ impl Guest for Webdav {
None => 0, None => 0,
}; };
let found = describe(&path, "1")?; 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() .iter()
.map(|entry| Entry { .map(|entry| Entry {
name: dav::name_of(&entry.href), name: dav::name_of(&entry.href),
@@ -400,7 +430,8 @@ impl Guest for Webdav {
/// first and refused unless it is empty. /// first and refused unless it is empty.
fn remove(target: Locator) -> Result<(), ErrorCode> { fn remove(target: Locator) -> Result<(), ErrorCode> {
let path = route(&target); 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(), &[])?; exchange(host::Method::Delete, &path, Vec::new(), &[])?;
Ok(()) Ok(())
} }
@@ -410,4 +441,155 @@ impl Guest for Webdav {
} }
} }
#[cfg(not(test))]
sdk::export!(Webdav with_types_in sdk); 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));
}
}
+14 -13
View File
@@ -35,11 +35,11 @@ interface host {
/// `respond`. An empty result means the body is complete. /// `respond`. An empty result means the body is complete.
pull: func(limit: u32) -> result<list<u8>, host-error>; pull: func(limit: u32) -> result<list<u8>, host-error>;
} }
/// Begins a request to a host both declared by the package and granted on /// Begins a request to any HTTP or HTTPS host, including LAN and loopback
/// the connection. Each redirect and DNS result is checked, and DNS is /// addresses. This API has no per-connection network grant. Each redirect
/// pinned. Private/LAN addresses additionally require the private-network /// and DNS result is checked, and DNS is pinned. Cross-origin redirects
/// grant. Cross-origin redirects remove request headers; HTTPS cannot /// remove request headers; HTTPS cannot downgrade. The host bounds total
/// downgrade. The host bounds total bytes per exchange, not buffer size. /// bytes per exchange, not buffer size.
http: func(request: request) -> result<exchange, host-error>; http: func(request: request) -> result<exchange, host-error>;
/// One open TCP connection. Dropping the handle closes the socket, and the /// 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. /// Writes at most 1,048,576 bytes and returns how many were accepted.
write: func(data: list<u8>) -> result<u32, host-error>; write: func(data: list<u8>) -> result<u32, host-error>;
} }
/// Opens a socket to a host and port both declared by the package and /// Opens a socket to any host, including LAN and loopback addresses, on a
/// granted on the connection. The host resolves and pins DNS, applies the /// package-declared port. This API has no per-connection network grant.
/// same private-address rule as `http`, and starts TLS when asked. Ports /// The host resolves and pins DNS and starts TLS when asked. Ports
/// negotiated at runtime, such as an FTP data channel, still need a grant. /// negotiated at runtime, such as an FTP data channel, must be declared.
connect: func(host: string, port: u16, tls: bool) -> result<tcp, host-error>; connect: func(host: string, port: u16, tls: bool) -> result<tcp, host-error>;
/// One datagram socket connected to a single granted peer. /// One datagram socket connected to a single peer.
resource udp { resource udp {
/// Sends one datagram of at most 65,507 bytes. /// Sends one datagram of at most 65,507 bytes.
send: func(data: list<u8>) -> result<u32, host-error>; send: func(data: list<u8>) -> result<u32, host-error>;
@@ -65,8 +65,8 @@ interface host {
/// means none arrived inside the host's wait, not that the peer left. /// means none arrived inside the host's wait, not that the peer left.
receive: func(limit: u32) -> result<list<u8>, host-error>; receive: func(limit: u32) -> result<list<u8>, host-error>;
} }
/// Connects a datagram socket under the same declaration and grant rules /// Connects a datagram socket under the same address and port rules as
/// as `connect`, for the few filesystem protocols that still need one. /// `connect`, for the few filesystem protocols that still need one.
bind: func(host: string, port: u16) -> result<udp, host-error>; bind: func(host: string, port: u16) -> result<udp, host-error>;
/// A configuration value, typed as the package's manifest declared it. /// 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 /// Filesystem operations for one connection. Paths are absolute within its
/// root, starting with `/`; they are never local OS paths or resource URIs. /// 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. /// Every invocation is bounded by host fuel, memory, time, and cancellation.
interface provider { interface provider {
use host.{setting}; use host.{setting};