Skip to main content

netget/client/webdav/
mod.rs

1//! WebDAV client implementation
2pub mod actions;
3
4pub use actions::WebdavClientProtocol;
5
6use anyhow::{Context, Result};
7use std::collections::HashMap;
8use std::net::SocketAddr;
9use std::sync::Arc;
10use std::sync::OnceLock;
11use std::time::Duration;
12use tokio::sync::mpsc;
13use tracing::{error, info};
14
15use crate::client::llm_budget::call_llm_for_client;
16use crate::client::webdav::actions::WEBDAV_CLIENT_RESPONSE_RECEIVED_EVENT;
17use crate::llm::actions::client_trait::{Client, ClientActionResult};
18use crate::llm::actions::protocol_trait::Protocol;
19use crate::llm::ollama_client::OllamaClient;
20use crate::llm::ClientLlmResult;
21use crate::logging::emit::Log;
22use crate::protocol::Event;
23use crate::state::app_state::AppState;
24use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
25use crate::state::{AccessLogOwner, ClientId, ClientStatus};
26
27/// One `reqwest::Client` per base URL, built once and reused.
28///
29/// `Client::builder().build()` is a *blocking* operation: it sets up the rustls stack and
30/// loads the platform root store, which on macOS reads the keychain through
31/// Security.framework, synchronously and serialised across processes. It used to run inside
32/// `perform_request` -- on the async runtime, once per WebDAV request -- parking a tokio
33/// worker every time, and a second one was built at connect and dropped unused. It also
34/// bypassed `client_for_endpoint_with_timeout`, so a base URL that is a literal IP still went
35/// through `getaddrinfo`; that has been measured at 8.25s under concurrency on macOS.
36static HTTP_CLIENTS: OnceLock<tokio::sync::Mutex<HashMap<String, reqwest::Client>>> =
37    OnceLock::new();
38
39/// How long a single WebDAV request may take.
40const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
41
42/// Return the shared client for `base_url`, building it off the runtime the first time.
43async fn http_client_for(base_url: &str) -> reqwest::Client {
44    let clients = HTTP_CLIENTS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new()));
45
46    // Scoped so the guard is dropped before the `spawn_blocking` await below; a guard held
47    // across an await that does work is the deadlock shape this tree keeps rediscovering.
48    let existing = { clients.lock().await.get(base_url).cloned() };
49    if let Some(client) = existing {
50        return client;
51    }
52
53    let owned = base_url.to_string();
54    let built = tokio::task::spawn_blocking(move || {
55        crate::llm::ollama_client::configured_for_endpoint(
56            reqwest::Client::builder()
57                .timeout(REQUEST_TIMEOUT)
58                .redirect(crate::client::http_fetch::same_origin_redirects()),
59            &owned,
60        )
61        .build()
62        .unwrap_or_else(|_| reqwest::Client::new())
63    })
64    .await
65    .unwrap_or_else(|_| reqwest::Client::new());
66
67    clients
68        .lock()
69        .await
70        .entry(base_url.to_string())
71        .or_insert(built)
72        .clone()
73}
74
75/// WebDAV client that makes requests to remote WebDAV servers
76pub struct WebdavClient;
77
78impl WebdavClient {
79    /// Connect to a WebDAV server with integrated LLM actions
80    pub async fn connect_with_llm_actions(
81        remote_addr: String,
82        _llm_client: OllamaClient,
83        app_state: Arc<AppState>,
84        status_tx: mpsc::UnboundedSender<String>,
85        client_id: ClientId,
86        startup_params: Option<crate::protocol::StartupParams>,
87    ) -> Result<SocketAddr> {
88        // For WebDAV, "connection" is logical, not a persistent TCP connection
89        // We'll create an HTTP client and store it in protocol_data
90
91        info!(
92            "WebDAV client {} initialized for {}",
93            client_id, remote_addr
94        );
95
96        // No client is built here. `perform_request` asks `http_client_for` for the one
97        // belonging to this base URL, which builds it once, off the runtime. The client that
98        // used to be built on this line was bound to `_http_client` and dropped immediately,
99        // so it cost a synchronous keychain read at connect and served nothing.
100
101        // `default_headers` and `auth` were both declared and both unread: headers promised
102        // on every request never reached the wire, and a `username:password` handed to
103        // `auth` was simply dropped, so an authenticated share answered 401 with no hint
104        // why. Both are resolved once here into the header set `perform_request` sends.
105        //
106        // `auth` becomes an `Authorization: Basic <base64(user:pass)>` header, which is
107        // what its `username:password` shape describes. It is stored resolved rather than
108        // as the raw credential so the password is not re-encoded per request; it is still
109        // in this client's `protocol_data` either way, exactly like every other client's
110        // credentials.
111        let (default_headers, auth) = match &startup_params {
112            Some(params) => (
113                params.get_optional_object("default_headers")?.cloned(),
114                params.get_optional_string("auth")?,
115            ),
116            None => (None, None),
117        };
118
119        let mut startup_headers = serde_json::Map::new();
120        for (key, value) in default_headers.unwrap_or_default() {
121            startup_headers.insert(key.to_ascii_lowercase(), value);
122        }
123        if let Some(credentials) = auth {
124            use base64::Engine;
125            let encoded = base64::engine::general_purpose::STANDARD.encode(credentials.as_bytes());
126            startup_headers.insert(
127                "authorization".to_string(),
128                serde_json::json!(format!("Basic {encoded}")),
129            );
130        }
131
132        // Store client in protocol_data
133        app_state
134            .with_client_mut(client_id, |client| {
135                client.set_protocol_field(
136                    "http_client".to_string(),
137                    serde_json::json!("initialized"),
138                );
139                client.set_protocol_field("base_url".to_string(), serde_json::json!(remote_addr));
140                if !startup_headers.is_empty() {
141                    client.set_protocol_field(
142                        "startup_headers".to_string(),
143                        serde_json::Value::Object(startup_headers.clone()),
144                    );
145                }
146            })
147            .await;
148
149        // Update status
150        app_state
151            .update_client_status(client_id, ClientStatus::Connected)
152            .await;
153        Log::new(Some(&status_tx)).info(format!(
154            "WebDAV client {} ready for {}",
155            client_id, remote_addr
156        ));
157        let _ = status_tx.send("__UPDATE_UI__".to_string());
158
159        // Command channel for injected actions (the dashboard's [ send ] row).
160        // Registered - and already being drained by its own task - BEFORE the
161        // connected-event LLM call, which a manual `*` rule can park for minutes: the
162        // operator must be able to reach the client while it waits.
163        let command_rx =
164            crate::client::command_support::register_command_channel(&app_state, client_id).await;
165        let cmd_task = tokio::spawn(Self::command_loop(
166            command_rx,
167            client_id,
168            app_state.clone(),
169            _llm_client.clone(),
170            status_tx.clone(),
171        ));
172        app_state.register_client_task(client_id, cmd_task).await;
173
174        // Send initial connected event to LLM
175        // Registered with AppState so stop_client can abort this task —
176        // dropping a JoinHandle only detaches it in Tokio.
177        let task_registrar = app_state.clone();
178        let task_handle = tokio::spawn(async move {
179            if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
180                let protocol =
181                    Arc::new(crate::client::webdav::actions::WebdavClientProtocol::new());
182                let event = Event::new(
183                    &crate::client::webdav::actions::WEBDAV_CLIENT_CONNECTED_EVENT,
184                    serde_json::json!({
185                        "base_url": app_state.with_client_mut(client_id, |c|
186                            c.get_protocol_field("base_url").and_then(|v| v.as_str().map(|s| s.to_string()))
187                        ).await.flatten().unwrap_or_default(),
188                    }),
189                );
190
191                let memory = app_state
192                    .get_memory_for_client(client_id)
193                    .await
194                    .unwrap_or_default();
195
196                match call_llm_for_client(
197                    &_llm_client,
198                    &app_state,
199                    client_id.to_string(),
200                    &instruction,
201                    &memory,
202                    Some(&event),
203                    protocol.as_ref(),
204                    &status_tx,
205                )
206                .await
207                {
208                    Ok(ClientLlmResult {
209                        actions,
210                        memory_updates,
211                    }) => {
212                        // Update memory
213                        if let Some(mem) = memory_updates {
214                            app_state.set_memory_for_client(client_id, mem).await;
215                        }
216
217                        // Execute actions
218                        for action in actions {
219                            match protocol.as_ref().execute_action(action.clone()) {
220                                Ok(result) => {
221                                    if let Err(e) = Self::apply_action(
222                                        result,
223                                        Dispatch::Spawn,
224                                        &_llm_client,
225                                        &app_state,
226                                        &status_tx,
227                                        client_id,
228                                    )
229                                    .await
230                                    {
231                                        error!("Failed to execute WebDAV action: {}", e);
232                                    }
233                                }
234                                Err(e) => {
235                                    error!("WebDAV action execution error: {}", e);
236                                }
237                            }
238                        }
239                    }
240                    Err(e) => {
241                        error!("LLM error for WebDAV client {}: {}", client_id, e);
242                    }
243                }
244            }
245        });
246        task_registrar
247            .register_client_task(client_id, task_handle)
248            .await;
249
250        // Return a dummy local address (WebDAV is connectionless)
251        Ok("0.0.0.0:0".parse().unwrap())
252    }
253
254    /// Drain injected commands until the channel closes (client removed) or an injected
255    /// `disconnect` ends the session.
256    ///
257    /// `command_support::handle_stream_client_command` cannot serve this client: it writes
258    /// `SendData` to a socket, and every WebDAV verb yields `ClientActionResult::Custom`
259    /// that has to become an HTTP request. So the action goes through
260    /// [`Self::apply_action`] - the same function the connected-event path uses - and the
261    /// outcome is recorded and replied exactly the way the generic arm does it.
262    async fn command_loop(
263        mut command_rx: tokio::sync::mpsc::Receiver<ClientCommand>,
264        client_id: ClientId,
265        app_state: Arc<AppState>,
266        llm_client: OllamaClient,
267        status_tx: mpsc::UnboundedSender<String>,
268    ) {
269        let protocol = crate::client::webdav::actions::WebdavClientProtocol::new();
270
271        while let Some(command) = command_rx.recv().await {
272            let action = command.action.clone();
273            let outcome = match protocol.execute_action(action.clone()) {
274                Err(e) => Ok(ClientSendOutcome::Rejected {
275                    error: e.to_string(),
276                }),
277                // Dispatch::Await: the request is awaited, so the reported outcome
278                // describes a round-trip that has actually completed. The
279                // webdav_response_received event is delivered from its own registered
280                // task, so a manual handler parked for a human's think time cannot wedge
281                // this loop or time out the dashboard's [ send ].
282                Ok(result) => Self::apply_action(
283                    result,
284                    Dispatch::Await,
285                    &llm_client,
286                    &app_state,
287                    &status_tx,
288                    client_id,
289                )
290                .await
291                .map(|applied| match applied {
292                    Applied::Disconnect => ClientSendOutcome::Disconnected,
293                    Applied::Executed(detail) => ClientSendOutcome::Executed { detail },
294                }),
295            };
296
297            let outcome_json = match &outcome {
298                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
299                Err(e) => serde_json::json!({"error": e.to_string()}),
300            };
301            app_state
302                .record_access_log(
303                    AccessLogOwner::Client(client_id.as_u32()),
304                    protocol.protocol_name(),
305                    None,
306                    "injected_action",
307                    action,
308                    vec![outcome_json],
309                )
310                .await;
311
312            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
313            if let Err(e) = &outcome {
314                error!("WebDAV client {} injected action failed: {}", client_id, e);
315                let _ = status_tx.send(format!(
316                    "[WARN] Client {} injected action failed: {}",
317                    client_id, e
318                ));
319            }
320            let _ = status_tx.send("__UPDATE_UI__".to_string());
321            crate::client::command_support::reply(command, outcome);
322
323            if disconnect {
324                break;
325            }
326        }
327
328        info!("WebDAV client {} command loop stopped", client_id);
329        app_state.remove_client_handle(client_id).await;
330        let _ = status_tx.send("__UPDATE_UI__".to_string());
331    }
332
333    /// Apply one already-executed action result. The single place a WebDAV request is
334    /// built and sent from, so an injected action behaves exactly like an LLM-produced one.
335    async fn apply_action(
336        result: ClientActionResult,
337        dispatch: Dispatch,
338        llm_client: &OllamaClient,
339        app_state: &Arc<AppState>,
340        status_tx: &mpsc::UnboundedSender<String>,
341        client_id: ClientId,
342    ) -> Result<Applied> {
343        match result {
344            ClientActionResult::Custom { name, data } if name == "webdav_request" => {
345                let method = data
346                    .get("method")
347                    .and_then(|v| v.as_str())
348                    .context("Missing 'method' in webdav_request")?
349                    .to_string();
350
351                let path = data
352                    .get("path")
353                    .and_then(|v| v.as_str())
354                    .context("Missing 'path' in webdav_request")?
355                    .to_string();
356
357                // Build headers from action data
358                let mut headers = Vec::new();
359
360                // Add Depth header for PROPFIND/COPY
361                if let Some(depth) = data.get("depth").and_then(|v| v.as_str()) {
362                    headers.push(("Depth".to_string(), depth.to_string()));
363                }
364
365                // Add Destination header for COPY/MOVE
366                if let Some(destination) = data.get("destination").and_then(|v| v.as_str()) {
367                    // Get base URL and construct full destination URL
368                    let base_url = app_state
369                        .with_client_mut(client_id, |c| {
370                            c.get_protocol_field("base_url")
371                                .and_then(|v| v.as_str().map(|s| s.to_string()))
372                        })
373                        .await
374                        .flatten()
375                        .unwrap_or_default();
376
377                    // On the client's own origin or refused, like the request URL itself.
378                    let dest_url =
379                        crate::client::http_fetch::resolve_same_origin(&base_url, destination)?;
380                    headers.push(("Destination".to_string(), dest_url));
381                }
382
383                // Add Overwrite header for COPY/MOVE
384                if let Some(overwrite) = data.get("overwrite").and_then(|v| v.as_bool()) {
385                    headers.push((
386                        "Overwrite".to_string(),
387                        if overwrite { "T" } else { "F" }.to_string(),
388                    ));
389                }
390
391                // Add Content-Type for PUT
392                if let Some(content_type) = data.get("content_type").and_then(|v| v.as_str()) {
393                    headers.push(("Content-Type".to_string(), content_type.to_string()));
394                } else if method == "PUT" {
395                    headers.push((
396                        "Content-Type".to_string(),
397                        "application/octet-stream".to_string(),
398                    ));
399                } else if method == "PROPFIND" {
400                    headers.push(("Content-Type".to_string(), "application/xml".to_string()));
401                }
402
403                // Build request body
404                let body = if method == "PROPFIND" {
405                    // Build PROPFIND XML body
406                    let properties = data
407                        .get("properties")
408                        .and_then(|v| v.as_array())
409                        .map(|arr| {
410                            arr.iter()
411                                .filter_map(|v| v.as_str().map(|s| s.to_string()))
412                                .collect::<Vec<String>>()
413                        });
414                    Some(Self::build_propfind_body(properties))
415                } else if method == "PUT" {
416                    // Use content from action
417                    data.get("content")
418                        .and_then(|v| v.as_str())
419                        .map(|s| s.to_string())
420                } else {
421                    None
422                };
423
424                match dispatch {
425                    Dispatch::Spawn => {
426                        let state_clone = Arc::clone(app_state);
427                        let llm_clone = llm_client.clone();
428                        let status_clone = status_tx.clone();
429                        let (spawn_method, spawn_path) = (method.clone(), path.clone());
430                        let request_handle = tokio::spawn(async move {
431                            if let Err(e) = WebdavClient::make_request(
432                                client_id,
433                                spawn_method,
434                                spawn_path,
435                                Some(headers),
436                                body,
437                                state_clone,
438                                llm_clone,
439                                status_clone,
440                            )
441                            .await
442                            {
443                                error!("WebDAV request failed: {}", e);
444                            }
445                        });
446                        app_state
447                            .register_client_task(client_id, request_handle)
448                            .await;
449                        Ok(Applied::Executed(format!("{method} {path} dispatched")))
450                    }
451                    Dispatch::Await => {
452                        let exchange = Self::perform_request(
453                            client_id,
454                            method.clone(),
455                            path.clone(),
456                            Some(headers),
457                            body,
458                            app_state,
459                            status_tx,
460                        )
461                        .await?;
462                        let detail = format!(
463                            "{method} {path} completed -> {} ({} byte body)",
464                            exchange.status_code, exchange.body_len
465                        );
466                        Self::spawn_notify(
467                            client_id,
468                            exchange.event_data,
469                            app_state,
470                            llm_client,
471                            status_tx,
472                        )
473                        .await;
474                        Ok(Applied::Executed(detail))
475                    }
476                }
477            }
478            ClientActionResult::Disconnect => {
479                app_state
480                    .update_client_status(client_id, ClientStatus::Disconnected)
481                    .await;
482                app_state.remove_client_handle(client_id).await;
483                Log::new(Some(status_tx)).info(format!("WebDAV client {} disconnected", client_id));
484                let _ = status_tx.send("__UPDATE_UI__".to_string());
485                Ok(Applied::Disconnect)
486            }
487            other => Err(anyhow::anyhow!("Unexpected action result: {:?}", other)),
488        }
489    }
490
491    /// Make a WebDAV request and hand the response to the LLM.
492    #[allow(clippy::too_many_arguments)]
493    pub async fn make_request(
494        client_id: ClientId,
495        method: String,
496        path: String,
497        headers: Option<Vec<(String, String)>>,
498        body: Option<String>,
499        app_state: Arc<AppState>,
500        llm_client: OllamaClient,
501        status_tx: mpsc::UnboundedSender<String>,
502    ) -> Result<()> {
503        let exchange = Self::perform_request(
504            client_id, method, path, headers, body, &app_state, &status_tx,
505        )
506        .await?;
507        Self::notify_response(
508            client_id,
509            exchange.event_data,
510            app_state,
511            llm_client,
512            status_tx,
513        )
514        .await;
515        Ok(())
516    }
517
518    /// Deliver one exchange's `webdav_response_received` event from **its own registered
519    /// task** and return immediately.
520    ///
521    /// This is the point of the perform/notify split. The injected-command loop already
522    /// holds the truthful network result and must reply to the operator before that
523    /// event's handler runs: a dashboard-created client defaults to a `*` -> manual rule,
524    /// so the handler can park for a human's think time (300s by default), far longer
525    /// than the composer's 30s send timeout.
526    async fn spawn_notify(
527        client_id: ClientId,
528        event_data: serde_json::Value,
529        app_state: &Arc<AppState>,
530        llm_client: &OllamaClient,
531        status_tx: &mpsc::UnboundedSender<String>,
532    ) {
533        let state_clone = Arc::clone(app_state);
534        let llm_clone = llm_client.clone();
535        let status_clone = status_tx.clone();
536        let notify_handle = tokio::spawn(async move {
537            WebdavClient::notify_response(
538                client_id,
539                event_data,
540                state_clone,
541                llm_clone,
542                status_clone,
543            )
544            .await;
545        });
546        // Registered so the notification - and the LLM call it makes - is aborted when
547        // the client is stopped.
548        app_state
549            .register_client_task(client_id, notify_handle)
550            .await;
551    }
552
553    /// Fire one `webdav_response_received` event at the LLM and apply any memory update.
554    /// Run the actions the model returned for a response event.
555    ///
556    /// They were discarded (`actions: _`), so answering webdav_response_received did nothing -- the whole
557    /// point of raising it. Dispatch goes through `perform_request`, which raises no event: that
558    /// bounds the loop, and avoids a notify -> perform -> notify chain that rustc cannot
559    /// prove `Send`.
560    async fn run_follow_ups(
561        client_id: ClientId,
562        actions: Vec<serde_json::Value>,
563        app_state: &Arc<AppState>,
564        status_tx: &mpsc::UnboundedSender<String>,
565    ) {
566        use crate::llm::actions::client_trait::{Client, ClientActionResult};
567        let protocol = crate::client::webdav::actions::WebdavClientProtocol::new();
568        for action in actions {
569            let Ok(ClientActionResult::Custom { name, data }) =
570                protocol.execute_action(action.clone())
571            else {
572                continue;
573            };
574            if name != "webdav_request" {
575                info!(
576                    "WebDAV client {} follow-up '{}' has no non-notifying path; skipped",
577                    client_id, name
578                );
579                continue;
580            }
581            if let Err(e) = Self::perform_request(
582                client_id,
583                data["method"].as_str().unwrap_or("GET").to_string(),
584                data["path"].as_str().unwrap_or("/").to_string(),
585                None,
586                data["body"].as_str().map(|s| s.to_string()),
587                app_state,
588                status_tx,
589            )
590            .await
591            {
592                error!("WebDAV client {} follow-up action failed: {}", client_id, e);
593            }
594        }
595    }
596
597    async fn notify_response(
598        client_id: ClientId,
599        event_data: serde_json::Value,
600        app_state: Arc<AppState>,
601        llm_client: OllamaClient,
602        status_tx: mpsc::UnboundedSender<String>,
603    ) {
604        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
605            return;
606        };
607        let protocol = Arc::new(crate::client::webdav::actions::WebdavClientProtocol::new());
608        let event = Event::new(&WEBDAV_CLIENT_RESPONSE_RECEIVED_EVENT, event_data);
609        let memory = app_state
610            .get_memory_for_client(client_id)
611            .await
612            .unwrap_or_default();
613
614        match call_llm_for_client(
615            &llm_client,
616            &app_state,
617            client_id.to_string(),
618            &instruction,
619            &memory,
620            Some(&event),
621            protocol.as_ref(),
622            &status_tx,
623        )
624        .await
625        {
626            Ok(ClientLlmResult {
627                actions,
628                memory_updates,
629            }) => {
630                if let Some(mem) = memory_updates {
631                    app_state.set_memory_for_client(client_id, mem).await;
632                }
633                Self::run_follow_ups(client_id, actions, &app_state, &status_tx).await;
634            }
635            Err(e) => {
636                error!("LLM error for WebDAV client {}: {}", client_id, e);
637            }
638        }
639    }
640
641    /// Perform the WebDAV round-trip only. No LLM involvement, so a caller can await this
642    /// and know exactly what the server answered.
643    #[allow(clippy::too_many_arguments)]
644    pub async fn perform_request(
645        client_id: ClientId,
646        method: String,
647        path: String,
648        headers: Option<Vec<(String, String)>>,
649        body: Option<String>,
650        app_state: &Arc<AppState>,
651        status_tx: &mpsc::UnboundedSender<String>,
652    ) -> Result<WebdavExchange> {
653        // Base URL and the startup headers (`default_headers` plus the `auth` credential,
654        // already resolved to an `authorization` value), read together under one guard.
655        let (base_url, startup_headers) = app_state
656            .with_client_mut(client_id, |client| {
657                (
658                    client
659                        .get_protocol_field("base_url")
660                        .and_then(|v| v.as_str())
661                        .map(|s| s.to_string()),
662                    client
663                        .get_protocol_field("startup_headers")
664                        .and_then(|v| v.as_object().cloned()),
665                )
666            })
667            .await
668            .unwrap_or((None, None));
669        let base_url = base_url.context("No base URL found")?;
670
671        // Same origin as `remote_addr` or refused: see `resolve_same_origin`. The `auth`
672        // credential rides on every request as `Authorization: Basic`, so a request to
673        // another host would hand the share password to it.
674        let url = crate::client::http_fetch::resolve_same_origin(&base_url, &path)?;
675
676        info!(
677            "WebDAV client {} making request: {} {}",
678            client_id, method, url
679        );
680
681        // Build request with custom method support for WebDAV
682        let http_client = http_client_for(&base_url).await;
683
684        let method_upper = method.to_uppercase();
685        let request_method = match method_upper.as_str() {
686            "GET" => reqwest::Method::GET,
687            "PUT" => reqwest::Method::PUT,
688            "POST" => reqwest::Method::POST,
689            "DELETE" => reqwest::Method::DELETE,
690            "HEAD" => reqwest::Method::HEAD,
691            "OPTIONS" => reqwest::Method::OPTIONS,
692            "PROPFIND" => reqwest::Method::from_bytes(b"PROPFIND")?,
693            "PROPPATCH" => reqwest::Method::from_bytes(b"PROPPATCH")?,
694            "MKCOL" => reqwest::Method::from_bytes(b"MKCOL")?,
695            "COPY" => reqwest::Method::from_bytes(b"COPY")?,
696            "MOVE" => reqwest::Method::from_bytes(b"MOVE")?,
697            "LOCK" => reqwest::Method::from_bytes(b"LOCK")?,
698            "UNLOCK" => reqwest::Method::from_bytes(b"UNLOCK")?,
699            _ => return Err(anyhow::anyhow!("Unsupported WebDAV method: {}", method)),
700        };
701
702        let mut request = http_client.request(request_method, &url);
703
704        // Startup headers merged *underneath* the per-request ones (Depth, Destination,
705        // Content-Type, ...), keyed by the lowercased name. Merged into one map before
706        // anything is applied, because `RequestBuilder::header` appends: a per-request
707        // `Content-Type` alongside a default one would send both.
708        let mut merged = serde_json::Map::new();
709        for (key, value) in startup_headers.unwrap_or_default() {
710            merged.insert(key.to_ascii_lowercase(), value);
711        }
712        if let Some(hdrs) = headers {
713            for (key, value) in hdrs {
714                merged.insert(key.to_ascii_lowercase(), serde_json::json!(value));
715            }
716        }
717        for (key, value) in merged {
718            if let Some(val_str) = value.as_str() {
719                request = request.header(&key, val_str);
720            }
721        }
722
723        // Add body
724        if let Some(body_str) = body {
725            request = request.body(body_str);
726        }
727
728        // Make request
729        match request.send().await {
730            Ok(response) => {
731                let status = response.status();
732                let status_code = status.as_u16();
733
734                // Get headers
735                let mut resp_headers = serde_json::Map::new();
736                for (name, value) in response.headers() {
737                    if let Ok(val_str) = value.to_str() {
738                        resp_headers.insert(name.to_string(), serde_json::json!(val_str));
739                    }
740                }
741
742                // Get body
743                let body_text = response.text().await?;
744
745                info!(
746                    "WebDAV client {} received response: {} ({})",
747                    client_id, status_code, status
748                );
749
750                // Built here, delivered by `notify_response` - inline for the LLM path,
751                // from its own task for the injected-command path.
752                let body_len = body_text.len();
753                Ok(WebdavExchange {
754                    status_code,
755                    body_len,
756                    event_data: serde_json::json!({
757                        "method": method,
758                        "status_code": status_code,
759                        "status_text": status.to_string(),
760                        "headers": resp_headers,
761                        "body": body_text,
762                    }),
763                })
764            }
765            Err(e) => {
766                Log::new(Some(status_tx))
767                    .error(format!("WebDAV client {} request failed: {}", client_id, e));
768                Err(e.into())
769            }
770        }
771    }
772
773    /// Build XML body for PROPFIND request
774    pub fn build_propfind_body(properties: Option<Vec<String>>) -> String {
775        match properties {
776            Some(props) => {
777                let mut prop_elements = String::new();
778                for prop in props {
779                    prop_elements.push_str(&format!("<D:{}/>\n", prop));
780                }
781                format!(
782                    r#"<?xml version="1.0" encoding="utf-8"?>
783<D:propfind xmlns:D="DAV:">
784  <D:prop>
785{}
786  </D:prop>
787</D:propfind>"#,
788                    prop_elements
789                )
790            }
791            None => {
792                // Request all properties
793                r#"<?xml version="1.0" encoding="utf-8"?>
794<D:propfind xmlns:D="DAV:">
795  <D:allprop/>
796</D:propfind>"#
797                    .to_string()
798            }
799        }
800    }
801}
802
803/// How a WebDAV request is issued.
804#[derive(Clone, Copy)]
805enum Dispatch {
806    /// Spawn the request and return immediately. Used by the connected-event handler.
807    Spawn,
808    /// Await the round-trip so the caller can report what actually happened. Used by the
809    /// injected-command loop. The response event is still delivered to the LLM, from its
810    /// own registered task, so a parked manual handler cannot wedge the command loop.
811    Await,
812}
813
814/// One completed WebDAV exchange.
815///
816/// Split out of [`WebdavClient::make_request`] so the injected-command loop can await the
817/// network round-trip - and report a truthful outcome - without also awaiting the LLM call
818/// the response event triggers.
819pub struct WebdavExchange {
820    pub status_code: u16,
821    pub body_len: usize,
822    /// The `webdav_response_received` payload this exchange produced.
823    pub event_data: serde_json::Value,
824}
825
826/// What [`WebdavClient::apply_action`] did with one action. The WebDAV client owns no
827/// socket - reqwest does - so there is no honest byte count to report, only "the request
828/// ran" or "the session should end".
829enum Applied {
830    /// The action ran; the string says what, for the injected action's outcome detail.
831    Executed(String),
832    /// The session should end.
833    Disconnect,
834}