Skip to main content

netget/server/webdav/
actions.rs

1//! WebDAV protocol actions.
2//!
3//! WebDAV (RFC 4918) is answered here method-by-method: the server owns the HTTP framing and
4//! the DAV:multistatus XML, and the **model owns every byte of content**. There is no
5//! filesystem behind this protocol — not on disk, not in memory. A `GET` returns what the
6//! model says it returns; a `PROPFIND` lists what the model says is there; a `PUT` succeeds
7//! only because the model answered with a success status.
8//!
9//! This replaced a `dav_server::memfs::MemFs`-backed implementation. That version stored real
10//! bytes in the process, which is the storage a protocol is forbidden to implement, and it
11//! dropped the `OllamaClient` on startup so the server instruction was read by nobody. The
12//! `DavFileSystem` route was considered and rejected: `dav-server` calls `metadata()` once for
13//! the target and again for every directory entry, so a model-backed `DavFileSystem` costs a
14//! model round-trip per *property*, not per request. Answering the verbs directly costs
15//! exactly one round-trip per request and lets the model choose the status code, which the
16//! `FsError` enum cannot express.
17//!
18//! **Three actions, one event.** `webdav_request` fires for every method the model is allowed
19//! to answer; the model replies with `send_webdav_listing` (PROPFIND -> 207 multistatus),
20//! `send_webdav_file` (GET/HEAD -> 200 + body), or `send_webdav_status` (everything else:
21//! 201, 204, 404, 405, 409, 423, 507...). No action means the request is *refused* with 503,
22//! never silently accepted — see `build_webdav_response` in `mod.rs`.
23//!
24//! Nothing here carries raw bytes or base64. File content is a string; a directory listing is
25//! an array of `{name, is_collection, size, content_type}` objects and the XML is generated
26//! from it.
27
28use crate::llm::actions::{
29    protocol_trait::{ActionResult, Protocol, Server},
30    ActionDefinition, Parameter,
31};
32use crate::protocol::log_template::LogTemplate;
33use crate::protocol::EventType;
34use crate::state::app_state::AppState;
35use anyhow::{Context, Result};
36use serde_json::json;
37use std::sync::LazyLock;
38
39/// WebDAV protocol action handler
40pub struct WebDavProtocol;
41
42impl WebDavProtocol {
43    pub fn new() -> Self {
44        Self
45    }
46}
47
48impl Default for WebDavProtocol {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54// ============================================================================
55// XML / URI helpers
56//
57// WebDAV response bodies are XML and hrefs are URIs, so everything the model
58// supplies has to be encoded before it is spliced into either. Both encoders
59// are deliberately conservative: a name is percent-encoded down to the
60// unreserved set before it becomes an href, and every text node is XML-escaped.
61// ============================================================================
62
63/// Escape a string for use as XML character data or an attribute value.
64fn xml_escape(s: &str) -> String {
65    let mut out = String::with_capacity(s.len());
66    for c in s.chars() {
67        match c {
68            '&' => out.push_str("&"),
69            '<' => out.push_str("&lt;"),
70            '>' => out.push_str("&gt;"),
71            '"' => out.push_str("&quot;"),
72            '\'' => out.push_str("&apos;"),
73            _ => out.push(c),
74        }
75    }
76    out
77}
78
79/// Percent-encode a path for use inside `<D:href>`.
80///
81/// Only RFC 3986 unreserved characters and `/` survive unencoded. That is stricter than it
82/// needs to be, but a name the model invented may contain anything at all, and an
83/// under-encoded href produces a multistatus body that XML parsers reject outright.
84fn percent_encode_path(path: &str) -> String {
85    let mut out = String::with_capacity(path.len());
86    for &b in path.as_bytes() {
87        let c = b as char;
88        if c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.' | '~') {
89            out.push(c);
90        } else {
91            out.push_str(&format!("%{:02X}", b));
92        }
93    }
94    out
95}
96
97/// Decode `%XX` escapes in a request path. Invalid escapes are left verbatim, and bytes that
98/// are not valid UTF-8 are replaced rather than rejected — the path is attacker-controlled.
99pub(crate) fn percent_decode(s: &str) -> String {
100    fn hex(b: u8) -> Option<u8> {
101        match b {
102            b'0'..=b'9' => Some(b - b'0'),
103            b'a'..=b'f' => Some(b - b'a' + 10),
104            b'A'..=b'F' => Some(b - b'A' + 10),
105            _ => None,
106        }
107    }
108    let bytes = s.as_bytes();
109    let mut out = Vec::with_capacity(bytes.len());
110    let mut i = 0;
111    while i < bytes.len() {
112        if bytes[i] == b'%' && i + 2 < bytes.len() {
113            if let (Some(h), Some(l)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
114                out.push(h * 16 + l);
115                i += 3;
116                continue;
117            }
118        }
119        out.push(bytes[i]);
120        i += 1;
121    }
122    String::from_utf8_lossy(&out).into_owned()
123}
124
125/// An RFC 1123 / IMF-fixdate timestamp, the only format `getlastmodified` may use.
126///
127/// Every WebDAV client parses this field, and several (including `reqwest_dav`) treat a file
128/// entry without a parseable `getlastmodified` as a decode error, so one is always emitted
129/// even when the model supplied none.
130fn http_date_now() -> String {
131    chrono::Utc::now()
132        .format("%a, %d %b %Y %H:%M:%S GMT")
133        .to_string()
134}
135
136/// Ensure a path starts with `/`.
137fn absolute(path: &str) -> String {
138    if path.starts_with('/') {
139        path.to_string()
140    } else {
141        format!("/{}", path)
142    }
143}
144
145/// Ensure a collection path starts and ends with `/`.
146fn as_collection(path: &str) -> String {
147    let p = absolute(path);
148    if p.ends_with('/') {
149        p
150    } else {
151        format!("{}/", p)
152    }
153}
154
155/// The last segment of a path, used as `displayname`.
156fn display_name(path: &str) -> String {
157    path.trim_end_matches('/')
158        .rsplit('/')
159        .next()
160        .filter(|s| !s.is_empty())
161        .unwrap_or("/")
162        .to_string()
163}
164
165/// One `<D:response>` element of a multistatus body.
166struct DavResource {
167    href: String,
168    name: String,
169    is_collection: bool,
170    size: u64,
171    content_type: String,
172    last_modified: String,
173}
174
175impl DavResource {
176    fn render(&self, out: &mut String) {
177        out.push_str("<D:response><D:href>");
178        out.push_str(&xml_escape(&percent_encode_path(&self.href)));
179        out.push_str("</D:href><D:propstat><D:prop><D:displayname>");
180        out.push_str(&xml_escape(&self.name));
181        out.push_str("</D:displayname><D:getlastmodified>");
182        out.push_str(&xml_escape(&self.last_modified));
183        out.push_str("</D:getlastmodified>");
184        if self.is_collection {
185            out.push_str("<D:resourcetype><D:collection/></D:resourcetype>");
186        } else {
187            out.push_str("<D:resourcetype/>");
188            out.push_str(&format!(
189                "<D:getcontentlength>{}</D:getcontentlength>",
190                self.size
191            ));
192        }
193        out.push_str("<D:getcontenttype>");
194        out.push_str(&xml_escape(&self.content_type));
195        out.push_str("</D:getcontenttype></D:prop><D:status>HTTP/1.1 200 OK</D:status>");
196        out.push_str("</D:propstat></D:response>");
197    }
198}
199
200fn render_multistatus(resources: &[DavResource]) -> String {
201    let mut out = String::from(
202        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<D:multistatus xmlns:D=\"DAV:\">",
203    );
204    for r in resources {
205        r.render(&mut out);
206    }
207    out.push_str("</D:multistatus>");
208    out
209}
210
211/// Wrap a status/headers/body triple in the `ActionResult::Output` envelope that
212/// `build_webdav_response` (`mod.rs`) turns into the real HTTP response.
213fn webdav_output(
214    status: u16,
215    headers: Vec<(String, String)>,
216    body: String,
217) -> Result<ActionResult> {
218    let header_map: serde_json::Map<String, serde_json::Value> = headers
219        .into_iter()
220        .map(|(k, v)| (k, serde_json::Value::String(v)))
221        .collect();
222    let payload = json!({
223        "status": status,
224        "headers": serde_json::Value::Object(header_map),
225        "body": body,
226    });
227    Ok(ActionResult::Output(serde_json::to_vec(&payload)?))
228}
229
230/// Read a status code that a model may have quoted (`200` or `"200"`).
231fn parse_status(value: &serde_json::Value) -> Option<u16> {
232    let n = match value {
233        serde_json::Value::Number(n) => n.as_u64(),
234        serde_json::Value::String(s) => s.trim().parse::<u64>().ok(),
235        _ => None,
236    }?;
237    if (100..=599).contains(&n) {
238        Some(n as u16)
239    } else {
240        None
241    }
242}
243
244/// Read a field that should be text but may arrive as any JSON value.
245fn as_text(value: &serde_json::Value) -> String {
246    match value {
247        serde_json::Value::Null => String::new(),
248        serde_json::Value::String(s) => s.clone(),
249        other => other.to_string(),
250    }
251}
252
253// ============================================================================
254// Protocol trait
255// ============================================================================
256
257impl Protocol for WebDavProtocol {
258    /// No async actions: WebDAV is purely reactive, like HTTP. Nothing happens on a WebDAV
259    /// server except in response to a request.
260    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
261        Vec::new()
262    }
263
264    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
265        vec![
266            send_webdav_listing_action(),
267            send_webdav_file_action(),
268            send_webdav_status_action(),
269        ]
270    }
271
272    fn get_event_types(&self) -> Vec<EventType> {
273        vec![WEBDAV_REQUEST_EVENT.clone()]
274    }
275
276    fn protocol_name(&self) -> &'static str {
277        "WebDAV"
278    }
279    fn stack_name(&self) -> &'static str {
280        "ETH>IP>TCP>HTTP>WEBDAV"
281    }
282    fn keywords(&self) -> Vec<&'static str> {
283        vec!["webdav", "dav"]
284    }
285    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
286        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
287
288        ProtocolMetadataV2::builder()
289            // Beta: exercised against TWO real and independent WebDAV clients —
290            // `reqwest_dav` (PROPFIND parsed into typed entries, a PUT/GET round-trip,
291            // MKCOL, a refusal) and the real `curl` binary issuing PROPFIND/MKCOL/PUT/GET/
292            // COPY, with the multistatus body read back as XML. Neither test is `#[ignore]`d
293            // and neither skips: the curl one FAILS when curl is absent. That is the
294            // evidence Beta asks for, from two implementations rather than one.
295            //
296            // Not Stable: the property model is fixed (no PROPPATCH dead-property storage),
297            // locks are accepted and never enforced, and neither spec compliance nor
298            // scripting support has been reviewed.
299            //
300            // This block previously stacked two contradictory paragraphs — one arguing
301            // "Not Beta", one arguing "Beta" — immediately above `.state(Beta)`, and
302            // `CLAUDE.md` had copied the first. That is the shape the root CLAUDE.md warns
303            // about for `ssh`; keep one argument here, for the rating actually declared.
304            .state(DevelopmentState::Beta)
305            .implementation(
306                "hyper v1.0 HTTP/1.1 with WebDAV methods answered directly; DAV:multistatus \
307                 XML generated from model-supplied entries. No filesystem of any kind.",
308            )
309            .llm_control(
310                "Everything the client observes: directory listings, file content, and the \
311                 status code of every write (PUT/MKCOL/DELETE/COPY/MOVE/PROPPATCH)",
312            )
313            .e2e_testing(
314                "TWO independent clients. \
315                 (1) reqwest_dav + mocked LLM, tests/server/webdav/test.rs: PROPFIND listing \
316                 parsed into typed entries through the library's own serde_xml_rs schema, \
317                 PUT/GET round-trip, MKCOL, refusal. \
318                 (2) the real `curl` binary, tests/server/webdav/real_client_test.rs: \
319                 PROPFIND, MKCOL, PUT, GET and COPY in one session. curl is a GENERIC HTTP \
320                 client, which the root CLAUDE.md rules out as evidence for a protocol layered \
321                 ON HTTP -- but PROPFIND, MKCOL and COPY are not HTTP verbs, 207 is not an \
322                 HTTP status, and DAV:multistatus is not an HTTP document, so for the layer \
323                 this server actually implements curl is a real WebDAV client. It adds what \
324                 reqwest_dav cannot: the multistatus is read as XML rather than deserialised \
325                 into a fixed schema, so a body that is not well-formed fails outright; one \
326                 entry is named `notes & drafts.txt`, which requires RFC 3986 percent-encoding \
327                 in the href and XML escaping in the displayname -- two different rules on the \
328                 same string; and COPY's Destination header is echoed back through the model, \
329                 so the assertion is that a WebDAV header was parsed rather than that a 201 \
330                 came back. \
331                 It FAILS rather than skips when curl is absent, and is not #[ignore]d. \
332                 Verified non-vacuous by breaking the server twice: dropping xml_escape from \
333                 displayname still answered 207 but made the body invalid XML (`Cannot find \
334                 ';' after '&'`), and dropping percent_encode_path from the href left a \
335                 well-formed document whose href was `/documents/notes & drafts.txt`. \
336                 UNPROVEN by either client: PROPPATCH, LOCK/UNLOCK enforcement, Depth: \
337                 infinity, binary bodies, authentication and TLS.",
338            )
339            .notes(
340                "There is no storage: a PUT is remembered only if the model chooses to \
341                 remember it (server memory), so a GET after a PUT returns whatever the model \
342                 says. Text content only - no binary file bodies. OPTIONS, LOCK and UNLOCK are \
343                 answered by the server without a model call; locks are never enforced. No \
344                 authentication, no TLS, no PROPPATCH dead-property storage.",
345            )
346            .max_inbound_bytes(crate::server::webdav::MAX_REQUEST_BODY)
347            .request_only(
348                "WebDAV is HTTP request/response; a server cannot send a peer anything unprompted",
349            )
350            .build()
351    }
352    fn description(&self) -> &'static str {
353        "WebDAV file server with an LLM-supplied virtual filesystem"
354    }
355    fn example_prompt(&self) -> &'static str {
356        "Serve a WebDAV share on port 8080 with a /documents folder containing readme.txt"
357    }
358    fn group_name(&self) -> &'static str {
359        "Web & File"
360    }
361
362    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
363        use crate::llm::actions::StartupExamples;
364
365        StartupExamples::new(
366            // LLM mode: the model invents and remembers the filesystem.
367            json!({
368                "type": "open_server",
369                "port": 8080,
370                "base_stack": "webdav",
371                "instruction": "Serve a WebDAV share whose root contains a 'documents' \
372                                collection and a file 'readme.txt' holding 'Hello from \
373                                NetGet'. Accept PUT (201 for a new path, 204 for an existing \
374                                one) and record what was written in memory so a later GET \
375                                returns it."
376            }),
377            // Script mode: a deterministic read-only share, no model call per request.
378            json!({
379                "type": "open_server",
380                "port": 8080,
381                "base_stack": "webdav",
382                "event_handlers": [{
383                    "event_pattern": "webdav_request",
384                    "handler": {
385                        "type": "script",
386                        "language": "python",
387                        "code": "import sys, json\n\
388                                 e = json.load(sys.stdin)['event']\n\
389                                 m, p = e['method'], e['path']\n\
390                                 files = {'/readme.txt': 'Hello from NetGet'}\n\
391                                 if m == 'PROPFIND' and p in ('/', ''):\n\
392                                 \x20   a = [{'type': 'send_webdav_listing', 'path': '/',\n\
393                                 \x20         'entries': [{'name': 'readme.txt', 'size': 17,\n\
394                                 \x20                      'content_type': 'text/plain'}]}]\n\
395                                 elif m in ('GET', 'HEAD') and p in files:\n\
396                                 \x20   a = [{'type': 'send_webdav_file', 'content': files[p],\n\
397                                 \x20         'content_type': 'text/plain'}]\n\
398                                 else:\n\
399                                 \x20   a = [{'type': 'send_webdav_status', 'status': 404}]\n\
400                                 print(json.dumps({'actions': a}))"
401                    }
402                }]
403            }),
404            // Static mode: an empty, read-only share. Every request gets the same refusal.
405            json!({
406                "type": "open_server",
407                "port": 8080,
408                "base_stack": "webdav",
409                "event_handlers": [{
410                    "event_pattern": "webdav_request",
411                    "handler": {
412                        "type": "static",
413                        "actions": [{
414                            "type": "send_webdav_status",
415                            "status": 404,
416                            "body": "Not Found"
417                        }]
418                    }
419                }]
420            }),
421        )
422    }
423}
424
425// ============================================================================
426// Server trait
427// ============================================================================
428
429impl Server for WebDavProtocol {
430    fn spawn(
431        &self,
432        ctx: crate::protocol::SpawnContext,
433    ) -> std::pin::Pin<
434        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
435    > {
436        Box::pin(async move {
437            use crate::server::webdav::WebDavServer;
438            WebDavServer::spawn_with_llm_actions(
439                ctx.legacy_listen_addr(),
440                ctx.llm_client,
441                ctx.state,
442                ctx.status_tx,
443                ctx.server_id,
444            )
445            .await
446        })
447    }
448
449    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
450        let action_type = action
451            .get("type")
452            .and_then(|v| v.as_str())
453            .context("Missing 'type' field in action")?;
454
455        match action_type {
456            "send_webdav_listing" => self.execute_send_webdav_listing(&action),
457            "send_webdav_file" => self.execute_send_webdav_file(&action),
458            "send_webdav_status" => self.execute_send_webdav_status(&action),
459            other => Err(anyhow::anyhow!(
460                "Unknown WebDAV action '{}'. WebDAV understands send_webdav_listing, \
461                 send_webdav_file and send_webdav_status.",
462                other
463            )),
464        }
465    }
466}
467
468impl WebDavProtocol {
469    /// Build a 207 Multi-Status body from the resource the request named plus, for a
470    /// collection, the entries the model listed inside it.
471    fn execute_send_webdav_listing(&self, action: &serde_json::Value) -> Result<ActionResult> {
472        let raw_path = action
473            .get("path")
474            .and_then(|v| v.as_str())
475            .unwrap_or("/")
476            .trim();
477        let is_collection = action
478            .get("is_collection")
479            .and_then(|v| v.as_bool())
480            .unwrap_or(true);
481
482        let now = http_date_now();
483        let last_modified = action
484            .get("last_modified")
485            .and_then(|v| v.as_str())
486            .unwrap_or(&now)
487            .to_string();
488
489        let self_href = if is_collection {
490            as_collection(raw_path)
491        } else {
492            absolute(raw_path)
493        };
494
495        let mut resources = vec![DavResource {
496            name: display_name(&self_href),
497            is_collection,
498            size: action.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
499            content_type: action
500                .get("content_type")
501                .and_then(|v| v.as_str())
502                .unwrap_or(if is_collection {
503                    "httpd/unix-directory"
504                } else {
505                    "application/octet-stream"
506                })
507                .to_string(),
508            last_modified: last_modified.clone(),
509            href: self_href.clone(),
510        }];
511
512        // Entries are optional: a Depth: 0 PROPFIND, or a PROPFIND on a file, lists only the
513        // resource itself. An `entries` value that is present but not an array is a model
514        // mistake worth reporting rather than ignoring, since ignoring it silently produces a
515        // listing that looks empty instead of wrong.
516        match action.get("entries") {
517            None | Some(serde_json::Value::Null) => {}
518            Some(serde_json::Value::Array(items)) => {
519                let base = as_collection(&self_href);
520                for (i, item) in items.iter().enumerate() {
521                    let name = item
522                        .get("name")
523                        .and_then(|v| v.as_str())
524                        .map(|s| s.trim_matches('/'))
525                        .filter(|s| !s.is_empty())
526                        .with_context(|| {
527                            format!(
528                                "send_webdav_listing entry #{} has no non-empty 'name'; every \
529                                 entry needs the file or folder name it should be listed under",
530                                i
531                            )
532                        })?;
533                    let entry_is_collection = item
534                        .get("is_collection")
535                        .and_then(|v| v.as_bool())
536                        .unwrap_or(false);
537                    let href = if entry_is_collection {
538                        format!("{}{}/", base, name)
539                    } else {
540                        format!("{}{}", base, name)
541                    };
542                    resources.push(DavResource {
543                        href,
544                        name: name.to_string(),
545                        is_collection: entry_is_collection,
546                        size: item.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
547                        content_type: item
548                            .get("content_type")
549                            .and_then(|v| v.as_str())
550                            .unwrap_or(if entry_is_collection {
551                                "httpd/unix-directory"
552                            } else {
553                                "application/octet-stream"
554                            })
555                            .to_string(),
556                        last_modified: item
557                            .get("last_modified")
558                            .and_then(|v| v.as_str())
559                            .unwrap_or(&last_modified)
560                            .to_string(),
561                    });
562                }
563            }
564            Some(other) => {
565                return Err(anyhow::anyhow!(
566                    "send_webdav_listing 'entries' must be an array of \
567                     {{name, is_collection, size, content_type}} objects, got {}",
568                    other
569                ))
570            }
571        }
572
573        webdav_output(
574            207,
575            vec![(
576                "Content-Type".to_string(),
577                "application/xml; charset=utf-8".to_string(),
578            )],
579            render_multistatus(&resources),
580        )
581    }
582
583    /// Return file content for a GET/HEAD.
584    fn execute_send_webdav_file(&self, action: &serde_json::Value) -> Result<ActionResult> {
585        let content = action
586            .get("content")
587            .map(as_text)
588            .context("send_webdav_file requires 'content' (the file's text content)")?;
589
590        let status = match action.get("status") {
591            None | Some(serde_json::Value::Null) => 200,
592            Some(v) => parse_status(v).with_context(|| {
593                format!(
594                    "send_webdav_file 'status' {} is not an HTTP status code between 100 and 599",
595                    v
596                )
597            })?,
598        };
599
600        let content_type = action
601            .get("content_type")
602            .and_then(|v| v.as_str())
603            .unwrap_or("text/plain; charset=utf-8")
604            .to_string();
605
606        webdav_output(
607            status,
608            vec![("Content-Type".to_string(), content_type)],
609            content,
610        )
611    }
612
613    /// Answer with a bare status: the result of a write, or a refusal.
614    fn execute_send_webdav_status(&self, action: &serde_json::Value) -> Result<ActionResult> {
615        let status_value = action
616            .get("status")
617            .context("send_webdav_status requires 'status' (e.g. 201 for a created resource)")?;
618        let status = parse_status(status_value).with_context(|| {
619            format!(
620                "send_webdav_status 'status' {} is not an HTTP status code between 100 and 599",
621                status_value
622            )
623        })?;
624
625        let body = action.get("body").map(as_text).unwrap_or_default();
626
627        let headers = action
628            .get("headers")
629            .and_then(|v| v.as_object())
630            .map(|obj| {
631                obj.iter()
632                    .filter(|(_, v)| !v.is_null())
633                    .map(|(k, v)| (k.clone(), as_text(v)))
634                    .collect::<Vec<_>>()
635            })
636            .unwrap_or_default();
637
638        webdav_output(status, headers, body)
639    }
640}
641
642// ============================================================================
643// Action definitions
644// ============================================================================
645
646fn send_webdav_listing_action() -> ActionDefinition {
647    ActionDefinition {
648        name: "send_webdav_listing".to_string(),
649        description:
650            "Answer a PROPFIND with a 207 Multi-Status listing. This is the ONLY way to make a \
651             directory or a file's properties visible to a WebDAV client. The response always \
652             describes the resource named by 'path' itself, plus one entry per element of \
653             'entries' (which you omit for Depth: 0, or when the path is a file). There is no \
654             filesystem behind this server: whatever you list here IS the directory, so keep it \
655             consistent with what you have listed before and with any PUT you accepted. The \
656             DAV:multistatus XML is generated for you - never write XML into these fields."
657                .to_string(),
658        parameters: vec![
659            Parameter {
660                name: "path".to_string(),
661                type_hint: "string".to_string(),
662                description: "The path being listed. Echo the 'path' field of the webdav_request \
663                    event exactly; the client matches the href in the reply against the URL it \
664                    requested, so a different path makes the listing useless to it."
665                    .to_string(),
666                required: true,
667            },
668            Parameter {
669                name: "entries".to_string(),
670                type_hint: "array".to_string(),
671                description: "The members of this collection, as an array of objects. Each: \
672                    'name' (required, a single file or folder name, no slashes), 'is_collection' \
673                    (boolean, default false), 'size' (number, byte length of the file's content, \
674                    default 0), 'content_type' (string, e.g. \"text/plain\"), 'last_modified' \
675                    (RFC 1123 date string, defaults to now). Omit or use [] when the request had \
676                    Depth: 0 or when 'path' is a file."
677                    .to_string(),
678                required: false,
679            },
680            Parameter {
681                name: "is_collection".to_string(),
682                type_hint: "boolean".to_string(),
683                description: "Whether 'path' itself is a collection (directory). Default true. \
684                    Set false when answering a PROPFIND on a single file."
685                    .to_string(),
686                required: false,
687            },
688            Parameter {
689                name: "size".to_string(),
690                type_hint: "number".to_string(),
691                description:
692                    "Byte length of the resource at 'path' when it is a file (is_collection false)."
693                        .to_string(),
694                required: false,
695            },
696            Parameter {
697                name: "content_type".to_string(),
698                type_hint: "string".to_string(),
699                description: "MIME type of the resource at 'path'. Defaults to \
700                    httpd/unix-directory for a collection, application/octet-stream for a file."
701                    .to_string(),
702                required: false,
703            },
704            Parameter {
705                name: "last_modified".to_string(),
706                type_hint: "string".to_string(),
707                description: "RFC 1123 date, e.g. \"Wed, 10 Apr 2024 14:00:00 GMT\". Defaults to \
708                    the current time, and is inherited by entries that do not set their own."
709                    .to_string(),
710                required: false,
711            },
712        ],
713        example: json!({
714            "type": "send_webdav_listing",
715            "path": "/",
716            "entries": [
717                { "name": "documents", "is_collection": true },
718                { "name": "readme.txt", "size": 17, "content_type": "text/plain" }
719            ]
720        }),
721        log_template: Some(
722            LogTemplate::new()
723                .with_info("-> 207 {path} ({output_bytes}B)")
724                .with_debug("WebDAV multistatus for {path}")
725                .with_trace("WebDAV listing: {json_pretty(.)}"),
726        ),
727    }
728}
729
730fn send_webdav_file_action() -> ActionDefinition {
731    ActionDefinition {
732        name: "send_webdav_file".to_string(),
733        description:
734            "Return the content of a file for a GET or HEAD request. The body is sent as UTF-8 \
735             text in one piece; binary files (images, archives) cannot be produced, and there is \
736             no way to stream or resume. If the path should not exist, use send_webdav_status \
737             with 404 instead of returning empty content."
738                .to_string(),
739        parameters: vec![
740            Parameter {
741                name: "content".to_string(),
742                type_hint: "string".to_string(),
743                description: "The file's full content as text. Must be consistent with the size \
744                    you reported for this path in any send_webdav_listing."
745                    .to_string(),
746                required: true,
747            },
748            Parameter {
749                name: "content_type".to_string(),
750                type_hint: "string".to_string(),
751                description:
752                    "MIME type sent as Content-Type. Default \"text/plain; charset=utf-8\"."
753                        .to_string(),
754                required: false,
755            },
756            Parameter {
757                name: "status".to_string(),
758                type_hint: "number".to_string(),
759                description: "HTTP status, default 200. Use 206 only if you really are answering \
760                    a Range request with a partial body."
761                    .to_string(),
762                required: false,
763            },
764        ],
765        example: json!({
766            "type": "send_webdav_file",
767            "content": "Hello from NetGet",
768            "content_type": "text/plain"
769        }),
770        log_template: Some(
771            LogTemplate::new()
772                .with_info("-> {status} file ({output_bytes}B)")
773                .with_debug("WebDAV file body: {output_bytes}B, type={content_type}")
774                .with_trace("WebDAV file: {json_pretty(.)}"),
775        ),
776    }
777}
778
779fn send_webdav_status_action() -> ActionDefinition {
780    ActionDefinition {
781        name: "send_webdav_status".to_string(),
782        description:
783            "Answer with a status code and no file body. This is how every write is accepted or \
784             refused - PUT, DELETE, MKCOL, COPY, MOVE, PROPPATCH - and how a missing path is \
785             reported. RFC 4918 statuses worth knowing: 201 Created (PUT that created a new \
786             resource, successful MKCOL), 204 No Content (PUT that overwrote an existing one, \
787             successful DELETE/MOVE/COPY over an existing target), 403 Forbidden (read-only \
788             share), 404 Not Found, 405 Method Not Allowed (MKCOL on a path that already \
789             exists), 409 Conflict (parent collection does not exist), 412 Precondition Failed \
790             (Overwrite: F and the destination exists), 507 Insufficient Storage. Refusing is \
791             always a valid answer; saying nothing at all is not, and produces a 503."
792                .to_string(),
793        parameters: vec![
794            Parameter {
795                name: "status".to_string(),
796                type_hint: "number".to_string(),
797                description: "HTTP status code between 100 and 599.".to_string(),
798                required: true,
799            },
800            Parameter {
801                name: "body".to_string(),
802                type_hint: "string".to_string(),
803                description: "Optional explanatory text. Leave it out for 201/204, which must \
804                    have no body."
805                    .to_string(),
806                required: false,
807            },
808            Parameter {
809                name: "headers".to_string(),
810                type_hint: "object".to_string(),
811                description: "Optional response headers as a flat name->value object, e.g. \
812                    {\"Location\": \"/moved/here.txt\"}. Content-Length and Date are added \
813                    automatically; headers that are not legal HTTP are dropped."
814                    .to_string(),
815                required: false,
816            },
817        ],
818        example: json!({
819            "type": "send_webdav_status",
820            "status": 201
821        }),
822        log_template: Some(
823            LogTemplate::new()
824                .with_info("-> {status}")
825                .with_debug("WebDAV status {status}")
826                .with_trace("WebDAV status: {json_pretty(.)}"),
827        ),
828    }
829}
830
831pub static SEND_WEBDAV_LISTING_ACTION: LazyLock<ActionDefinition> =
832    LazyLock::new(send_webdav_listing_action);
833pub static SEND_WEBDAV_FILE_ACTION: LazyLock<ActionDefinition> =
834    LazyLock::new(send_webdav_file_action);
835pub static SEND_WEBDAV_STATUS_ACTION: LazyLock<ActionDefinition> =
836    LazyLock::new(send_webdav_status_action);
837
838// ============================================================================
839// Event type
840// ============================================================================
841
842/// Emitted once per WebDAV request that the model is allowed to answer.
843///
844/// OPTIONS, LOCK and UNLOCK do not raise it: those are protocol handshakes with no content to
845/// decide, and are answered by the server itself (see `mod.rs`). Everything else does —
846/// GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, POST.
847pub static WEBDAV_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
848    EventType::new(
849        "webdav_request",
850        "WebDAV request received (PROPFIND, GET, PUT, MKCOL, DELETE, COPY, MOVE, PROPPATCH)",
851        json!({
852            "type": "send_webdav_listing",
853            "path": "/",
854            "entries": [
855                { "name": "documents", "is_collection": true },
856                { "name": "readme.txt", "size": 17, "content_type": "text/plain" }
857            ]
858        }),
859    )
860    .with_parameters(vec![
861        Parameter {
862            name: "method".to_string(),
863            type_hint: "string".to_string(),
864            description: "WebDAV/HTTP method: PROPFIND, GET, HEAD, PUT, DELETE, MKCOL, COPY, \
865                MOVE, PROPPATCH or POST."
866                .to_string(),
867            required: true,
868        },
869        Parameter {
870            name: "path".to_string(),
871            type_hint: "string".to_string(),
872            description: "Requested path, percent-decoded, without query string (e.g. \
873                '/documents/readme.txt'). Echo this back in send_webdav_listing."
874                .to_string(),
875            required: true,
876        },
877        Parameter {
878            name: "depth".to_string(),
879            type_hint: "string".to_string(),
880            description: "Depth header for PROPFIND/COPY/MOVE: \"0\" (this resource only), \
881                \"1\" (this resource and its immediate members) or \"infinity\". Present only \
882                when the client sent it; PROPFIND without it means infinity."
883                .to_string(),
884            required: false,
885        },
886        Parameter {
887            name: "destination".to_string(),
888            type_hint: "string".to_string(),
889            description: "Target path of a COPY or MOVE, taken from the Destination header and \
890                reduced to a path."
891                .to_string(),
892            required: false,
893        },
894        Parameter {
895            name: "overwrite".to_string(),
896            type_hint: "string".to_string(),
897            description: "Overwrite header of a COPY/MOVE: \"T\" (replace the destination) or \
898                \"F\" (fail with 412 if it exists)."
899                .to_string(),
900            required: false,
901        },
902        Parameter {
903            name: "headers".to_string(),
904            type_hint: "object".to_string(),
905            description: "All request headers as a name->value object.".to_string(),
906            required: true,
907        },
908        Parameter {
909            name: "body".to_string(),
910            type_hint: "string".to_string(),
911            description: "Request body as UTF-8 text: the file content on a PUT, the XML \
912                <propfind>/<propertyupdate> document on PROPFIND/PROPPATCH. Empty string when \
913                there is no body. Lossy when body_is_binary is true."
914                .to_string(),
915            required: false,
916        },
917        Parameter {
918            name: "body_bytes".to_string(),
919            type_hint: "number".to_string(),
920            description: "Size of the request body in bytes before UTF-8 decoding.".to_string(),
921            required: false,
922        },
923        Parameter {
924            name: "body_is_binary".to_string(),
925            type_hint: "boolean".to_string(),
926            description: "Present and true when the body is not valid UTF-8, meaning 'body' is a \
927                lossy rendering and the exact bytes are not available to you."
928                .to_string(),
929            required: false,
930        },
931    ])
932    .with_actions(vec![
933        SEND_WEBDAV_LISTING_ACTION.clone(),
934        SEND_WEBDAV_FILE_ACTION.clone(),
935        SEND_WEBDAV_STATUS_ACTION.clone(),
936    ])
937    .with_alternative_example(json!({
938        "type": "send_webdav_file",
939        "content": "Hello from NetGet",
940        "content_type": "text/plain"
941    }))
942    .with_alternative_example(json!({
943        "type": "send_webdav_status",
944        "status": 201
945    }))
946    .with_log_template(
947        LogTemplate::new()
948            .with_info("{client_ip} {method} {path} -> {status} ({duration_ms}ms)")
949            .with_debug("WebDAV {method} {path} depth={depth} from {client_ip}")
950            .with_trace("WebDAV request: {json_pretty(.)}"),
951    )
952});