netget/server/webdav/mod.rs
1//! WebDAV server (RFC 4918) with an LLM-supplied virtual filesystem.
2//!
3//! The server owns HTTP/1.1 framing (via `hyper`) and the DAV:multistatus XML; the model owns
4//! everything a client can observe. There is **no filesystem** behind this protocol — not on
5//! disk, not in memory. `PROPFIND` lists what the model says is there, `GET` returns what the
6//! model says the file contains, and `PUT`/`MKCOL`/`DELETE`/`COPY`/`MOVE` succeed or fail
7//! exactly as the model decides.
8//!
9//! This replaced an implementation that handed every request to `dav_server::memfs::MemFs` — a
10//! real read/write filesystem inside the process — and dropped the `OllamaClient` on startup,
11//! so the server instruction was read by nobody. See `src/server/webdav/CLAUDE.md` for why
12//! implementing `dav_server::fs::DavFileSystem` against the model was rejected in favour of
13//! answering the verbs directly.
14//!
15//! Three methods never reach the model, because they are handshakes with no content to decide:
16//! `OPTIONS` (capability advertisement), `LOCK` and `UNLOCK` (a synthetic, never-enforced lock
17//! token, so clients that refuse to write without one can proceed). Everything else raises
18//! `webdav_request`.
19pub mod actions;
20
21use crate::server::connection::ConnectionId;
22use anyhow::{Context, Result};
23use std::collections::HashMap;
24use std::convert::Infallible;
25use std::net::SocketAddr;
26use std::sync::Arc;
27use tokio::sync::mpsc;
28use tracing::{error, info, trace, warn};
29
30use crate::llm::action_helper::call_llm;
31use crate::llm::actions::protocol_trait::ActionResult;
32use crate::llm::ollama_client::OllamaClient;
33use crate::logging::emit::Log;
34use crate::protocol::Event;
35use crate::server::WebDavProtocol;
36use crate::state::app_state::AppState;
37use actions::WEBDAV_REQUEST_EVENT;
38
39use bytes::Bytes;
40use http_body_util::{BodyExt, Full};
41use hyper::body::Incoming;
42use hyper::server::conn::http1;
43use hyper::service::service_fn;
44use hyper::{Request, Response};
45use hyper_util::rt::TokioIo;
46
47/// Methods a client may use against this server, advertised in `Allow` on `OPTIONS`.
48const ALLOWED_METHODS: &str = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, \
49 COPY, MOVE, LOCK, UNLOCK";
50
51/// Largest request body this server will read, in bytes.
52///
53/// WebDAV has no authentication step, so anything a peer can reach it can also `PUT` to. The
54/// body used to be read with a bare `.collect()`, which is unbounded: a single request decided
55/// how much memory this process allocated, and the whole of it was then interpolated into the
56/// model's prompt. 8 MiB is far past any real `PROPFIND`/`PROPPATCH` body and still bounded.
57/// A larger body gets `413 Payload Too Large` before any of it reaches the model.
58pub const MAX_REQUEST_BODY: usize = 8 * 1024 * 1024;
59
60/// How long to wait for a peer's first byte after it connects.
61///
62/// HTTP is client-speaks-first — the server says nothing until it has a request line — so a peer
63/// that has connected and sent nothing has begun no request at all, which is the state an
64/// unauthenticated flood lives in, and WebDAV has no authentication step at all. Enforced with
65/// `TcpStream::peek` *before* the socket reaches hyper, so the request line is still there for
66/// hyper to parse afterwards.
67const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
68
69/// How long a connection may do nothing at all between requests.
70///
71/// **This bounds the silence, not the transfer.** A `GET` returns whatever the model says the
72/// file contains, built into a `Full<Bytes>` before the response is returned, so hyper writes an
73/// already-complete body and a client dragging a large file down is draining bytes rather than
74/// idling. The watchdog reads `ConnectionActivity`, which reports a connection with a request in
75/// flight as **not idle at all**, so an LLM round-trip — or a `manual` rule parking a `PUT` for
76/// a human to approve — can never close the connection it is an answer for.
77///
78/// Fifteen minutes is the wait between one request finishing and the next one starting. WebDAV
79/// clients that mount a share (Finder, gvfs, `reqwest_dav`) keep a connection pooled and poll
80/// far more often than that; one that has said nothing for fifteen minutes has unmounted or
81/// died without closing.
82const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
83
84/// Concurrent connections this server admits.
85///
86/// Each admitted connection may buffer a request body of up to [`MAX_REQUEST_BODY`] (8 MiB,
87/// larger than most protocols here) and hold a model round-trip open, so the cap is what turns
88/// that per-connection bound into a total one.
89const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
90
91/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
92///
93/// WebDAV is HTTP, so the protocol's own vocabulary for "come back later" is a `503` with
94/// `Retry-After`. A DAV client reports the status; an unexplained reset would have it record a
95/// permanent fault instead.
96const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
97 Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
98
99/// WebDAV server whose entire filesystem is supplied by the LLM
100pub struct WebDavServer;
101
102impl WebDavServer {
103 /// Spawn WebDAV server with integrated LLM actions
104 pub async fn spawn_with_llm_actions(
105 listen_addr: SocketAddr,
106 llm_client: OllamaClient,
107 app_state: Arc<AppState>,
108 status_tx: mpsc::UnboundedSender<String>,
109 server_id: crate::state::ServerId,
110 ) -> Result<SocketAddr> {
111 info!("WebDAV server starting on {}", listen_addr);
112
113 let protocol = Arc::new(WebDavProtocol::new());
114
115 // Bind before spawning. Binding inside the accept task made every failure invisible:
116 // the task logged and returned while spawn_with_llm_actions had already answered Ok,
117 // so the server sat in Running with no socket. It also meant port 0 was reported back
118 // to the caller verbatim instead of the port the kernel actually chose.
119 let listener = crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr)
120 .await
121 .with_context(|| format!("Failed to bind WebDAV listener on {}", listen_addr))?;
122 let local_addr = listener.local_addr()?;
123
124 Log::new(Some(&status_tx)).info(format!("WebDAV server listening on {}", local_addr));
125
126 let task_registrar = app_state.clone();
127 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
128 let accept_handle = tokio::spawn(async move {
129 loop {
130 match crate::server::accept_bounded::accept_bounded(
131 &listener,
132 &limiter,
133 CONNECTION_CAP_REFUSAL,
134 "WebDAV",
135 Some(&status_tx),
136 )
137 .await
138 {
139 Ok((stream, peer_addr, permit)) => {
140 let connection_id =
141 ConnectionId::new(app_state.get_next_unified_id().await);
142 Log::new(Some(&status_tx)).debug(format!(
143 "WebDAV connection {} from {}",
144 connection_id, peer_addr
145 ));
146
147 // Add connection to ServerInstance
148 use crate::state::server::{
149 ConnectionState as ServerConnectionState, ConnectionStatus,
150 ProtocolConnectionInfo,
151 };
152 let now = crate::utils::clock::Instant::now();
153 let conn_state = ServerConnectionState {
154 id: connection_id,
155 remote_addr: peer_addr,
156 local_addr,
157 bytes_sent: 0,
158 bytes_received: 0,
159 packets_sent: 0,
160 packets_received: 0,
161 last_activity: now,
162 status: ConnectionStatus::Active,
163 status_changed_at: now,
164 protocol_info: ProtocolConnectionInfo::empty(),
165 };
166 app_state
167 .add_connection_to_server(server_id, conn_state)
168 .await;
169 let _ = status_tx.send("__UPDATE_UI__".to_string());
170
171 let llm_clone = llm_client.clone();
172 let app_clone = app_state.clone();
173 let status_clone = status_tx.clone();
174 let protocol_clone = protocol.clone();
175 let app_for_close = app_state.clone();
176 let status_for_close = status_tx.clone();
177
178 let task_owner = app_state.clone();
179 task_owner
180 .spawn_server_task(server_id, async move {
181 // Held for the life of the connection: dropping it early
182 // releases the slot while the peer is still here, which
183 // silently un-caps the server.
184 let _permit = permit;
185
186 // First-byte bound, before hyper sees the socket. hyper owns
187 // every read once `serve_connection` starts and keeps polling
188 // for frames *while a request is being answered*, so a deadline
189 // on reads would fire in the middle of an LLM round-trip.
190 // `peek` waits for data without consuming it, so the request
191 // line is still there for hyper afterwards.
192 match tokio::time::timeout(
193 FIRST_BYTE_READ_TIMEOUT,
194 stream.peek(&mut [0u8; 1]),
195 )
196 .await
197 {
198 Ok(Ok(0)) | Ok(Err(_)) => {
199 app_for_close
200 .close_connection_on_server(server_id, connection_id)
201 .await;
202 let _ = status_for_close.send("__UPDATE_UI__".to_string());
203 return;
204 }
205 Ok(Ok(_)) => {}
206 Err(_) => {
207 Log::new(Some(&status_for_close)).debug(format!(
208 "WebDAV peer {} sent nothing for {}s; closing before \
209 the request line",
210 peer_addr,
211 FIRST_BYTE_READ_TIMEOUT.as_secs()
212 ));
213 app_for_close
214 .close_connection_on_server(server_id, connection_id)
215 .await;
216 let _ = status_for_close.send("__UPDATE_UI__".to_string());
217 return;
218 }
219 }
220
221 let io = TokioIo::new(stream);
222
223 // Tracks whether this connection is answering anything. A
224 // request waiting on the model, or parked for a human at the
225 // dashboard, holds the count above zero, so the idle watchdog
226 // below cannot close the connection the answer belongs to
227 // however long it takes — only genuine silence counts.
228 let activity = Arc::new(
229 crate::server::accept_bounded::ConnectionActivity::new(),
230 );
231 let activity_for_service = Arc::clone(&activity);
232
233 let service = service_fn(move |req: Request<Incoming>| {
234 let llm = llm_clone.clone();
235 let state = app_clone.clone();
236 let status = status_clone.clone();
237 let proto = protocol_clone.clone();
238 let activity = Arc::clone(&activity_for_service);
239 async move {
240 let _busy = activity.busy();
241 handle_webdav_request(
242 req,
243 connection_id,
244 server_id,
245 llm,
246 state,
247 status,
248 proto,
249 )
250 .await
251 }
252 });
253
254 let conn = http1::Builder::new().serve_connection(io, service);
255 tokio::pin!(conn);
256 tokio::select! {
257 result = &mut conn => {
258 if let Err(err) = result {
259 error!("WebDAV connection error: {:?}", err);
260 }
261 }
262 _ = crate::server::accept_bounded::watch_idle(
263 Arc::clone(&activity),
264 IDLE_BETWEEN_REQUESTS_TIMEOUT,
265 ) => {
266 trace!(
267 "WebDAV connection {} idle for {}s; closing",
268 connection_id,
269 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
270 );
271 }
272 }
273
274 app_for_close
275 .close_connection_on_server(server_id, connection_id)
276 .await;
277 Log::new(Some(&status_for_close))
278 .info(format!("WebDAV connection {} closed", connection_id));
279 let _ = status_for_close.send("__UPDATE_UI__".to_string());
280 })
281 .await;
282 }
283 Err(e) => {
284 Log::new(Some(&status_tx))
285 .error(format!("Failed to accept WebDAV connection: {}", e));
286 break;
287 }
288 }
289 }
290 });
291
292 task_registrar
293 .register_server_task(server_id, accept_handle)
294 .await;
295
296 Ok(local_addr)
297 }
298}
299
300/// Everything the server extracted from one request before deciding what to do with it.
301struct WebDavRequest {
302 method: String,
303 path: String,
304 headers: HashMap<String, String>,
305 body: Bytes,
306 /// The peer sent (or declared) more body than `MAX_REQUEST_BODY`. The request is refused
307 /// with 413 without a model call: there is no content decision to make about a body the
308 /// server declined to read.
309 body_too_large: bool,
310}
311
312async fn extract_request(req: Request<Incoming>) -> WebDavRequest {
313 let method = req.method().to_string().to_ascii_uppercase();
314 // Percent-decoded, query string dropped: a WebDAV path is a resource name, and the model
315 // is asked to echo it back into `send_webdav_listing`, so it must be the human-readable
316 // form rather than the wire form.
317 let raw_path = req.uri().path().to_string();
318 let path = actions::percent_decode(&raw_path);
319
320 let mut headers = HashMap::new();
321 for (name, value) in req.headers() {
322 if let Ok(v) = value.to_str() {
323 headers.insert(name.as_str().to_ascii_lowercase(), v.to_string());
324 }
325 }
326
327 // Refuse on the declared length before reading a byte where the client gave one, then
328 // bound the stream itself for the chunked case where it did not. Checking only the
329 // remainder-after-headers, or only the declared value, is the mistake that let thirty
330 // bytes on the wire buffer toward gigabytes elsewhere in this tree.
331 let declared_too_large = headers
332 .get("content-length")
333 .and_then(|v| v.parse::<u64>().ok())
334 .is_some_and(|len| len > MAX_REQUEST_BODY as u64);
335
336 if declared_too_large {
337 return WebDavRequest {
338 method,
339 path,
340 headers,
341 body: Bytes::new(),
342 body_too_large: true,
343 };
344 }
345
346 let limited = http_body_util::Limited::new(req.into_body(), MAX_REQUEST_BODY);
347 let (body, body_too_large) = match limited.collect().await {
348 Ok(collected) => (collected.to_bytes(), false),
349 Err(e) => {
350 // `Limited` reports the cap as an error indistinguishable from a transport
351 // failure, and both end the request the same way here: nothing usable was read.
352 warn!("WebDAV: request body rejected: {}", e);
353 (Bytes::new(), true)
354 }
355 };
356
357 WebDavRequest {
358 method,
359 path,
360 headers,
361 body,
362 body_too_large,
363 }
364}
365
366/// Build a response without ever panicking.
367///
368/// Status and headers here are model-influenced, so an out-of-range status becomes 500 and an
369/// illegal header (notably one containing CR/LF, i.e. a response-splitting attempt) is dropped
370/// individually rather than taken down the connection task with a panic.
371fn build_safe_response(
372 status: u16,
373 headers: Vec<(String, String)>,
374 body: String,
375) -> Response<Full<Bytes>> {
376 let status_code = hyper::StatusCode::from_u16(status).unwrap_or_else(|_| {
377 error!(
378 "WebDAV: invalid HTTP status {} (must be 100-599), sending 500 instead",
379 status
380 );
381 hyper::StatusCode::INTERNAL_SERVER_ERROR
382 });
383
384 // Advertised on every response: clients decide whether a server speaks WebDAV from this
385 // header, not from the multistatus body.
386 let mut builder = Response::builder()
387 .status(status_code)
388 .header("DAV", "1, 2")
389 .header("MS-Author-Via", "DAV");
390
391 for (name, value) in headers {
392 match (
393 hyper::header::HeaderName::from_bytes(name.as_bytes()),
394 hyper::header::HeaderValue::from_str(&value),
395 ) {
396 (Ok(n), Ok(v)) => builder = builder.header(n, v),
397 _ => warn!(
398 "WebDAV: dropping invalid response header {:?} (name or value is not legal HTTP)",
399 name
400 ),
401 }
402 }
403
404 builder
405 .body(Full::new(Bytes::from(body)))
406 .unwrap_or_else(|e| {
407 error!("WebDAV: failed to build response ({}), sending bare 500", e);
408 let mut fallback =
409 Response::new(Full::new(Bytes::from_static(b"Internal Server Error")));
410 *fallback.status_mut() = hyper::StatusCode::INTERNAL_SERVER_ERROR;
411 fallback
412 })
413}
414
415/// Turn the model's action results into an HTTP response.
416///
417/// **Fails closed.** If nothing usable came back — the model emitted no WebDAV action, or the
418/// executor rejected the one it did emit — the client gets `503 Service Unavailable`, not a
419/// permissive default. A model that means to allow something says so with
420/// `send_webdav_status`; silence is not consent.
421///
422/// 503 is *not* reserved: `send_webdav_status` can send one too, so a packet capture cannot
423/// tell "it refused" from "it never answered". The `decision=` token on every exit path is
424/// what does — `model_answer` / `model_reject` when the model decided,
425/// `model_silent` / `fail_closed_bad_action` here, `fail_closed_llm_*` on a backend failure.
426fn build_webdav_response(
427 results: Vec<ActionResult>,
428 had_action_failures: bool,
429 method: &str,
430 path: &str,
431 status_tx: &mpsc::UnboundedSender<String>,
432) -> Response<Full<Bytes>> {
433 fn find(results: &[ActionResult]) -> Option<serde_json::Value> {
434 for result in results {
435 match result {
436 ActionResult::Output(bytes) => {
437 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
438 if v.get("status").is_some() {
439 return Some(v);
440 }
441 }
442 }
443 ActionResult::Multiple(inner) => {
444 if let Some(v) = find(inner) {
445 return Some(v);
446 }
447 }
448 _ => {}
449 }
450 }
451 None
452 }
453
454 let Some(payload) = find(&results) else {
455 // Two different failures land here and the tag keeps them apart: the model emitted
456 // an action the executor rejected (`fail_closed_bad_action`), or it emitted nothing
457 // usable at all (`model_silent`). Both answer 503, and 503 is *not* a status the
458 // model is prevented from choosing — `send_webdav_status` will send one — so the log
459 // is the only place these stay distinct from a deliberate refusal.
460 let log = Log::new(Some(status_tx));
461 if had_action_failures {
462 log.error(format!(
463 "WebDAV {} {} decision=fail_closed_bad_action: the model's action(s) failed to \
464 execute; answering 503",
465 method, path
466 ));
467 } else {
468 log.warn(format!(
469 "WebDAV {} {} decision=model_silent: no send_webdav_* action; answering 503",
470 method, path
471 ));
472 }
473 return build_safe_response(
474 503,
475 vec![("Content-Type".to_string(), "text/plain".to_string())],
476 "WebDAV server has no answer for this request".to_string(),
477 );
478 };
479
480 let status = payload
481 .get("status")
482 .and_then(|v| v.as_u64())
483 .unwrap_or(500)
484 .min(599) as u16;
485 let headers = payload
486 .get("headers")
487 .and_then(|v| v.as_object())
488 .map(|obj| {
489 obj.iter()
490 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
491 .collect::<Vec<_>>()
492 })
493 .unwrap_or_default();
494 let body = payload
495 .get("body")
496 .and_then(|v| v.as_str())
497 .unwrap_or("")
498 .to_string();
499
500 // WebDAV has a real refusal vocabulary (403, 409, 423 Locked, 507 Insufficient Storage),
501 // so a model that means to refuse says so with a status of its own. That is still the
502 // *model* deciding, which is why it is `model_reject` and never `fail_closed_*`.
503 let decision = if status >= 400 {
504 "model_reject"
505 } else {
506 "model_answer"
507 };
508 Log::new(Some(status_tx)).info(format!(
509 "WebDAV {} {} decision={}: answering {} ({} bytes)",
510 method,
511 path,
512 decision,
513 status,
514 body.len()
515 ));
516
517 build_safe_response(status, headers, body)
518}
519
520/// Handle one request, maintaining the per-connection counters on every exit path.
521///
522/// Semantics match the other hyper-based servers: one "packet" is one HTTP message, and the
523/// byte counts are message bodies only — hyper has parsed the request line and headers away
524/// before this sees them. `last_activity` matters beyond bookkeeping:
525/// `AppState::cleanup_old_connections` runs the 10s idle sweep only for servers that declare
526/// `ProtocolMetadataV2::connectionless`, which WebDAV does not — correctly, since clients hold
527/// keep-alive sessions open between bursts and pipeline PROPFIND/GET aggressively.
528async fn handle_webdav_request(
529 req: Request<Incoming>,
530 connection_id: ConnectionId,
531 server_id: crate::state::ServerId,
532 llm_client: OllamaClient,
533 app_state: Arc<AppState>,
534 status_tx: mpsc::UnboundedSender<String>,
535 protocol: Arc<WebDavProtocol>,
536) -> std::result::Result<Response<Full<Bytes>>, Infallible> {
537 app_state
538 .update_connection_stats(server_id, connection_id, None, None, Some(1), None)
539 .await;
540
541 let response = handle_webdav_request_inner(
542 req,
543 connection_id,
544 server_id,
545 llm_client,
546 app_state.clone(),
547 status_tx,
548 protocol,
549 )
550 .await;
551
552 let bytes_sent = {
553 use hyper::body::Body;
554 response.body().size_hint().exact().unwrap_or(0)
555 };
556 app_state
557 .update_connection_stats(
558 server_id,
559 connection_id,
560 None,
561 Some(bytes_sent),
562 None,
563 Some(1),
564 )
565 .await;
566
567 Ok(response)
568}
569
570async fn handle_webdav_request_inner(
571 req: Request<Incoming>,
572 connection_id: ConnectionId,
573 server_id: crate::state::ServerId,
574 llm_client: OllamaClient,
575 app_state: Arc<AppState>,
576 status_tx: mpsc::UnboundedSender<String>,
577 protocol: Arc<WebDavProtocol>,
578) -> Response<Full<Bytes>> {
579 let request = extract_request(req).await;
580
581 if request.body_too_large {
582 Log::new(Some(&status_tx)).warn(format!(
583 "WebDAV {} {} decision=refused_body_too_large: body over {} bytes, answering 413 \
584 with no LLM call",
585 request.method, request.path, MAX_REQUEST_BODY
586 ));
587 return build_safe_response(
588 413,
589 vec![("Content-Type".to_string(), "text/plain".to_string())],
590 "WebDAV request body too large".to_string(),
591 );
592 }
593
594 Log::new(Some(&status_tx)).debug(format!(
595 "WebDAV request: {} {} ({} bytes)",
596 request.method,
597 request.path,
598 request.body.len()
599 ));
600 for (name, value) in &request.headers {
601 trace!("WebDAV header: {}: {}", name, value);
602 }
603
604 if !request.body.is_empty() {
605 app_state
606 .update_connection_stats(
607 server_id,
608 connection_id,
609 Some(request.body.len() as u64),
610 None,
611 None,
612 None,
613 )
614 .await;
615 }
616
617 // Handshake methods: answered by the server, no model call. These carry no content
618 // decision — OPTIONS advertises what this build can parse, and LOCK/UNLOCK exist so that
619 // clients which refuse to write without a lock (macOS Finder, the Windows redirector) can
620 // proceed. The lock is synthetic and never enforced; nothing consults it.
621 match request.method.as_str() {
622 "OPTIONS" => {
623 Log::new(Some(&status_tx)).debug(format!(
624 "WebDAV OPTIONS {} decision=static_answer: 200, capability advertisement, no \
625 LLM call",
626 request.path
627 ));
628 return build_safe_response(
629 200,
630 // Content-Length is left to hyper: the empty body already yields 0, and
631 // setting it here as well risks a duplicated header.
632 vec![("Allow".to_string(), ALLOWED_METHODS.to_string())],
633 String::new(),
634 );
635 }
636 "LOCK" => {
637 let token = format!("opaquelocktoken:{}", uuid::Uuid::new_v4());
638 let body = format!(
639 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
640 <D:prop xmlns:D=\"DAV:\"><D:lockdiscovery><D:activelock>\
641 <D:locktype><D:write/></D:locktype>\
642 <D:lockscope><D:exclusive/></D:lockscope>\
643 <D:depth>infinity</D:depth>\
644 <D:timeout>Second-3600</D:timeout>\
645 <D:locktoken><D:href>{}</D:href></D:locktoken>\
646 </D:activelock></D:lockdiscovery></D:prop>",
647 token
648 );
649 // **LOCK always grants.** The model is never asked, nothing is recorded, and no
650 // request is ever refused with `423 Locked` on this path — so the grant is an
651 // affirmative answer the server invents on every call. It is deliberately NOT a
652 // `fail_closed_*` tag, because the peer got a lock token; `protocol_synthetic_lock`
653 // is the honest name and is grep-able for exactly that reason. See CLAUDE.md.
654 Log::new(Some(&status_tx)).warn(format!(
655 "WebDAV LOCK {} decision=protocol_synthetic_lock: granting an unconditional, \
656 never-enforced lock; no LLM call and no refusal is possible here",
657 request.path
658 ));
659 return build_safe_response(
660 200,
661 vec![
662 (
663 "Content-Type".to_string(),
664 "application/xml; charset=utf-8".to_string(),
665 ),
666 ("Lock-Token".to_string(), format!("<{}>", token)),
667 ],
668 body,
669 );
670 }
671 "UNLOCK" => {
672 Log::new(Some(&status_tx)).debug(format!(
673 "WebDAV UNLOCK {} decision=static_answer: 204, no LLM call (nothing was ever \
674 locked)",
675 request.path
676 ));
677 return build_safe_response(204, Vec::new(), String::new());
678 }
679 _ => {}
680 }
681
682 // Everything else is a content decision, and belongs to the model.
683 let body_is_binary = std::str::from_utf8(&request.body).is_err();
684 let body_text = String::from_utf8_lossy(&request.body);
685 let mut event_data = serde_json::json!({
686 "method": request.method,
687 "path": request.path,
688 "headers": request.headers,
689 "body": body_text.as_ref(),
690 "body_bytes": request.body.len(),
691 });
692 if body_is_binary {
693 event_data["body_is_binary"] = serde_json::Value::Bool(true);
694 }
695 if let Some(depth) = request.headers.get("depth") {
696 event_data["depth"] = serde_json::Value::String(depth.clone());
697 }
698 if let Some(destination) = request.headers.get("destination") {
699 event_data["destination"] =
700 serde_json::Value::String(actions::percent_decode(&destination_path(destination)));
701 }
702 if let Some(overwrite) = request.headers.get("overwrite") {
703 event_data["overwrite"] = serde_json::Value::String(overwrite.to_ascii_uppercase());
704 }
705
706 let event = Event::new(&WEBDAV_REQUEST_EVENT, event_data);
707
708 match call_llm(
709 &llm_client,
710 &app_state,
711 server_id,
712 Some(connection_id),
713 &event,
714 protocol.as_ref(),
715 )
716 .await
717 {
718 Ok(execution_result) => {
719 // Read `failures` before `protocol_results` is moved into the builder: the
720 // decision has to be computed while both halves are still available.
721 let had_action_failures = !execution_result.failures.is_empty();
722 for msg in execution_result.messages {
723 let _ = status_tx.send(msg);
724 }
725 build_webdav_response(
726 execution_result.protocol_results,
727 had_action_failures,
728 &request.method,
729 &request.path,
730 &status_tx,
731 )
732 }
733 Err(e) => {
734 // An LLM failure is the server's fault, not the client's request being wrong, and
735 // it is distinct from both a refusal (whatever status the model chose) and a
736 // no-answer (503).
737 let decision = match crate::utils::WireFailure::classify(&e) {
738 crate::utils::WireFailure::Overloaded => "fail_closed_llm_overloaded",
739 crate::utils::WireFailure::Unavailable => "fail_closed_llm_error",
740 };
741 Log::new(Some(&status_tx)).error(format!(
742 "WebDAV {} {} decision={}: answering 500: {}",
743 request.method, request.path, decision, e
744 ));
745 build_safe_response(
746 500,
747 vec![("Content-Type".to_string(), "text/plain".to_string())],
748 "WebDAV server error".to_string(),
749 )
750 }
751 }
752}
753
754/// Reduce a `Destination` header to a path.
755///
756/// RFC 4918 requires an absolute URI (`http://host/path`), but clients send both forms, and
757/// only the path is meaningful to a model that has no idea what host it is being addressed as.
758fn destination_path(destination: &str) -> String {
759 match destination.find("://") {
760 Some(scheme_end) => match destination[scheme_end + 3..].find('/') {
761 Some(path_start) => destination[scheme_end + 3 + path_start..].to_string(),
762 None => "/".to_string(),
763 },
764 None => destination.to_string(),
765 }
766}