WebDAV validates connections and preserves directory listings, with CI checking every change #1
@@ -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 }}
|
||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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};
|
||||||
|
|||||||
Reference in New Issue
Block a user
LAN and loopback access still requires the connection's private-network grant. Restore that requirement here; otherwise this copied contract tells plugin authors that private hosts are reachable without consent.