Skip to main content

pbox_core/
pve.rs

1use crate::Secret;
2use reqwest::blocking::{Client, RequestBuilder};
3use reqwest::{Method, StatusCode};
4use serde::de::{DeserializeOwned, Deserializer, Error as DeError};
5use serde::{Deserialize, Serialize, Serializer};
6use std::fmt;
7use std::net::{Ipv4Addr, Ipv6Addr};
8use std::path::Path;
9use std::time::Duration;
10use thiserror::Error;
11const API_PREFIX: &str = "/api2/json";
12const PVE_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
13const PVE_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
14
15pub trait PveApi {
16    fn list_cluster_resources(&self) -> Result<Vec<ClusterResource>, PveError>;
17    fn list_nodes(&self) -> Result<Vec<PveNode>, PveError>;
18    fn list_node_storages(&self, node: &str) -> Result<Vec<PveStorage>, PveError>;
19    /// List network interfaces configured on a PVE node.
20    fn list_node_network_interfaces(
21        &self,
22        node: &str,
23    ) -> Result<Vec<PveNetworkInterface>, PveError> {
24        let _ = node;
25        Err(PveError::Unsupported(
26            "this PVE client does not support node network discovery".to_owned(),
27        ))
28    }
29    fn list_storage_content(
30        &self,
31        node: &str,
32        storage: &str,
33        content: &str,
34    ) -> Result<Vec<PveStorageContent>, PveError>;
35    /// List tags for an OCI repository through the PVE node.
36    fn list_oci_repo_tags(&self, node: &str, reference: &str) -> Result<Vec<String>, PveError> {
37        let _ = (node, reference);
38        Err(PveError::Unsupported(
39            "this PVE client does not support OCI registry tag queries".to_owned(),
40        ))
41    }
42    /// Ask the PVE node to pull an OCI image into a template storage.
43    fn pull_oci_registry(
44        &self,
45        node: &str,
46        storage: &str,
47        reference: &str,
48        filename: &str,
49    ) -> Result<PveTaskResponse, PveError> {
50        let _ = (node, storage, reference, filename);
51        Err(PveError::Unsupported(
52            "this PVE client does not support OCI registry pulls".to_owned(),
53        ))
54    }
55
56    /// Upload a compressed LXC template archive to PVE storage.
57    fn upload_storage_template(
58        &self,
59        node: &str,
60        storage: &str,
61        filename: &str,
62        path: &Path,
63    ) -> Result<PveTaskResponse, PveError> {
64        let _ = (node, storage, filename, path);
65        Err(PveError::Unsupported(
66            "this PVE client does not support storage template uploads".to_owned(),
67        ))
68    }
69    /// Delete only a temporary per-box bootstrap template owned by pbox.
70    fn delete_bootstrap_template(
71        &self,
72        node: &str,
73        storage: &str,
74        filename: &str,
75    ) -> Result<PveTaskResponse, PveError> {
76        let _ = (node, storage, filename);
77        Err(PveError::Unsupported(
78            "bootstrap template deletion is unavailable".to_owned(),
79        ))
80    }
81    /// Read the node's current state instead of the delayed cluster resource cache.
82    fn get_lxc_state(&self, node: &str, vmid: u64) -> Result<String, PveError> {
83        let _ = (node, vmid);
84        Err(PveError::Unsupported(
85            "current LXC state is unavailable".to_owned(),
86        ))
87    }
88    fn get_lxc_config(&self, node: &str, vmid: u64) -> Result<LxcConfig, PveError>;
89    fn list_lxc_interfaces(&self, node: &str, vmid: u64) -> Result<Vec<LxcInterface>, PveError>;
90    fn list_lxc_snapshots(&self, node: &str, vmid: u64) -> Result<Vec<LxcSnapshot>, PveError>;
91    fn get_task_status(&self, node: &str, upid: &str) -> Result<PveTaskStatus, PveError>;
92    /// Read a bounded slice of a PVE task log.
93    fn get_task_log(
94        &self,
95        node: &str,
96        upid: &str,
97        start: u64,
98        limit: u64,
99    ) -> Result<Vec<PveTaskLog>, PveError> {
100        let _ = (node, upid, start, limit);
101        Err(PveError::Unsupported(
102            "this PVE client does not support task log queries".to_owned(),
103        ))
104    }
105    fn create_lxc(
106        &self,
107        node: &str,
108        vmid: u64,
109        request: &LxcCreateRequest,
110    ) -> Result<PveTaskResponse, PveError>;
111    fn update_lxc_config(
112        &self,
113        node: &str,
114        vmid: u64,
115        request: &LxcConfigUpdateRequest,
116    ) -> Result<(), PveError>;
117    fn start_lxc(&self, node: &str, vmid: u64) -> Result<PveTaskResponse, PveError>;
118    fn shutdown_lxc(&self, node: &str, vmid: u64) -> Result<PveTaskResponse, PveError>;
119    fn stop_lxc(&self, node: &str, vmid: u64) -> Result<PveTaskResponse, PveError>;
120    fn delete_lxc(&self, node: &str, vmid: u64) -> Result<PveTaskResponse, PveError>;
121    fn force_delete_lxc(&self, node: &str, vmid: u64) -> Result<PveTaskResponse, PveError> {
122        let _ = (node, vmid);
123        Err(PveError::Unsupported(
124            "this PVE client does not support background deletion".to_owned(),
125        ))
126    }
127
128    fn clone_lxc(
129        &self,
130        node: &str,
131        vmid: u64,
132        request: &LxcCloneRequest,
133    ) -> Result<PveTaskResponse, PveError> {
134        let _ = (node, vmid, request);
135        Err(PveError::Unsupported(
136            "container cloning is unavailable".into(),
137        ))
138    }
139    fn template_lxc(&self, node: &str, vmid: u64) -> Result<(), PveError> {
140        let _ = (node, vmid);
141        Err(PveError::Unsupported(
142            "container templates are unavailable".into(),
143        ))
144    }
145    fn active_delete_tasks(&self, node: &str) -> Result<Vec<PveTaskResponse>, PveError> {
146        let _ = node;
147        Ok(Vec::new())
148    }
149    fn template_tasks(&self, node: &str, vmid: u64) -> Result<Vec<PveTaskResponse>, PveError> {
150        let _ = (node, vmid);
151        Err(PveError::Unsupported(
152            "template task listing is unavailable".into(),
153        ))
154    }
155    fn create_lxc_snapshot(
156        &self,
157        node: &str,
158        vmid: u64,
159        request: &LxcSnapshotRequest,
160    ) -> Result<PveTaskResponse, PveError>;
161    fn rollback_lxc_snapshot(
162        &self,
163        node: &str,
164        vmid: u64,
165        snapname: &str,
166        start: bool,
167    ) -> Result<PveTaskResponse, PveError>;
168    fn delete_lxc_snapshot(
169        &self,
170        node: &str,
171        vmid: u64,
172        snapname: &str,
173    ) -> Result<PveTaskResponse, PveError>;
174}
175
176#[derive(Clone)]
177pub struct PveClientConfig {
178    pub base_url: String,
179    pub token_id: String,
180    pub token_secret: Secret,
181    pub tls_insecure: bool,
182}
183
184impl fmt::Debug for PveClientConfig {
185    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
186        formatter
187            .debug_struct("PveClientConfig")
188            .field("base_url", &self.base_url)
189            .field("token_id", &self.token_id)
190            .field("token_secret", &"<redacted>")
191            .field("tls_insecure", &self.tls_insecure)
192            .finish()
193    }
194}
195
196impl PveClientConfig {
197    pub fn new(
198        base_url: impl Into<String>,
199        token_id: impl Into<String>,
200        token_secret: Secret,
201    ) -> Self {
202        Self {
203            base_url: base_url.into(),
204            token_id: token_id.into(),
205            token_secret,
206            tls_insecure: false,
207        }
208    }
209}
210
211pub struct PveClient {
212    http: Client,
213    base_url: String,
214    authorization: String,
215}
216
217impl fmt::Debug for PveClient {
218    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
219        formatter
220            .debug_struct("PveClient")
221            .field("base_url", &self.base_url)
222            .field("authorization", &"<redacted>")
223            .finish()
224    }
225}
226impl PveClient {
227    pub fn new(config: PveClientConfig) -> Result<Self, PveError> {
228        let http = Client::builder()
229            .connect_timeout(PVE_CONNECT_TIMEOUT)
230            .timeout(PVE_REQUEST_TIMEOUT)
231            .danger_accept_invalid_certs(config.tls_insecure)
232            .build()
233            .map_err(PveError::Client)?;
234        let base_url = normalise_base_url(&config.base_url)?;
235        let authorization = format!(
236            "PVEAPIToken={}= {}",
237            config.token_id,
238            config.token_secret.expose()
239        )
240        .replace("= ", "=");
241        Ok(Self {
242            http,
243            base_url,
244            authorization,
245        })
246    }
247
248    /// A bounded, read-only sample for interactive terminal status bars.
249    pub fn lxc_usage(&self, node: &str, vmid: u64) -> Result<LxcUsage, PveError> {
250        let response = self
251            .request(
252                Method::GET,
253                &format!("/nodes/{node}/lxc/{vmid}/status/current"),
254            )
255            .timeout(Duration::from_secs(3))
256            .send()
257            .map_err(PveError::Request)?;
258        decode_response(response)
259    }
260
261    fn request(&self, method: Method, path: &str) -> RequestBuilder {
262        self.http
263            .request(method, format!("{}{}", self.base_url, path))
264            .header("Authorization", &self.authorization)
265    }
266
267    fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, PveError> {
268        let response = self
269            .request(Method::GET, path)
270            .send()
271            .map_err(PveError::Request)?;
272        decode_response(response)
273    }
274
275    fn task<T: Serialize>(
276        &self,
277        method: Method,
278        path: &str,
279        form: &T,
280    ) -> Result<PveTaskResponse, PveError> {
281        let response = self
282            .request(method, path)
283            .form(form)
284            .send()
285            .map_err(PveError::Request)?;
286        decode_task_response(response)
287    }
288    fn empty<T: Serialize>(&self, method: Method, path: &str, form: &T) -> Result<(), PveError> {
289        let response = self
290            .request(method, path)
291            .form(form)
292            .send()
293            .map_err(PveError::Request)?;
294        let _: serde_json::Value = decode_response(response)?;
295        Ok(())
296    }
297
298    fn task_without_form(&self, method: Method, path: &str) -> Result<PveTaskResponse, PveError> {
299        let response = self
300            .request(method, path)
301            .send()
302            .map_err(PveError::Request)?;
303        decode_task_response(response)
304    }
305}
306
307impl PveApi for PveClient {
308    fn list_cluster_resources(&self) -> Result<Vec<ClusterResource>, PveError> {
309        self.get("/cluster/resources?type=vm")
310    }
311
312    fn get_lxc_config(&self, node: &str, vmid: u64) -> Result<LxcConfig, PveError> {
313        validate_path_segment(node, "node")?;
314        self.get(&format!("/nodes/{node}/lxc/{vmid}/config?current=1"))
315    }
316
317    fn list_lxc_interfaces(&self, node: &str, vmid: u64) -> Result<Vec<LxcInterface>, PveError> {
318        validate_path_segment(node, "node")?;
319        let interfaces: Option<Vec<LxcInterface>> =
320            self.get(&format!("/nodes/{node}/lxc/{vmid}/interfaces"))?;
321        Ok(interfaces.unwrap_or_default())
322    }
323
324    fn list_lxc_snapshots(&self, node: &str, vmid: u64) -> Result<Vec<LxcSnapshot>, PveError> {
325        validate_path_segment(node, "node")?;
326        self.get(&format!("/nodes/{node}/lxc/{vmid}/snapshot"))
327    }
328
329    fn get_task_status(&self, node: &str, upid: &str) -> Result<PveTaskStatus, PveError> {
330        validate_path_segment(node, "node")?;
331        validate_path_segment(upid, "UPID")?;
332        self.get(&format!("/nodes/{node}/tasks/{upid}/status"))
333    }
334    fn get_task_log(
335        &self,
336        node: &str,
337        upid: &str,
338        start: u64,
339        limit: u64,
340    ) -> Result<Vec<PveTaskLog>, PveError> {
341        validate_path_segment(node, "node")?;
342        validate_path_segment(upid, "UPID")?;
343        self.get(&format!(
344            "/nodes/{node}/tasks/{upid}/log?start={start}&limit={limit}"
345        ))
346    }
347
348    fn list_nodes(&self) -> Result<Vec<PveNode>, PveError> {
349        self.get("/nodes")
350    }
351
352    fn list_node_storages(&self, node: &str) -> Result<Vec<PveStorage>, PveError> {
353        validate_path_segment(node, "node")?;
354        self.get(&format!("/nodes/{node}/storage"))
355    }
356
357    fn list_node_network_interfaces(
358        &self,
359        node: &str,
360    ) -> Result<Vec<PveNetworkInterface>, PveError> {
361        validate_path_segment(node, "node")?;
362        let interfaces: Option<Vec<PveNetworkInterface>> =
363            self.get(&format!("/nodes/{node}/network"))?;
364        Ok(interfaces.unwrap_or_default())
365    }
366
367    fn list_storage_content(
368        &self,
369        node: &str,
370        storage: &str,
371        content: &str,
372    ) -> Result<Vec<PveStorageContent>, PveError> {
373        validate_path_segment(node, "node")?;
374        validate_path_segment(storage, "storage")?;
375        validate_path_segment(content, "content")?;
376        self.get(&format!(
377            "/nodes/{node}/storage/{storage}/content?content={content}"
378        ))
379    }
380    fn list_oci_repo_tags(&self, node: &str, reference: &str) -> Result<Vec<String>, PveError> {
381        validate_path_segment(node, "node")?;
382        validate_oci_reference(reference)?;
383        let reference = encode_query_component(reference);
384        self.get(&format!(
385            "/nodes/{node}/query-oci-repo-tags?reference={reference}"
386        ))
387    }
388
389    fn pull_oci_registry(
390        &self,
391        node: &str,
392        storage: &str,
393        reference: &str,
394        filename: &str,
395    ) -> Result<PveTaskResponse, PveError> {
396        validate_path_segment(node, "node")?;
397        validate_path_segment(storage, "storage")?;
398        validate_oci_reference(reference)?;
399        validate_path_segment(filename, "filename")?;
400        let form = OciRegistryPullForm {
401            reference,
402            filename,
403        };
404        self.task(
405            Method::POST,
406            &format!("/nodes/{node}/storage/{storage}/oci-registry-pull"),
407            &form,
408        )
409    }
410
411    fn upload_storage_template(
412        &self,
413        node: &str,
414        storage: &str,
415        filename: &str,
416        path: &Path,
417    ) -> Result<PveTaskResponse, PveError> {
418        validate_path_segment(node, "node")?;
419        validate_path_segment(storage, "storage")?;
420        validate_path_segment(filename, "filename")?;
421        let form = reqwest::blocking::multipart::Form::new()
422            .text("content", "vztmpl")
423            .file("filename", path)
424            .map_err(PveError::UploadFile)?;
425        let response = self
426            .request(
427                Method::POST,
428                &format!("/nodes/{node}/storage/{storage}/upload"),
429            )
430            .multipart(form)
431            .send()
432            .map_err(PveError::Request)?;
433        decode_task_response(response)
434    }
435
436    fn delete_bootstrap_template(
437        &self,
438        node: &str,
439        storage: &str,
440        filename: &str,
441    ) -> Result<PveTaskResponse, PveError> {
442        validate_path_segment(node, "node")?;
443        validate_path_segment(storage, "storage")?;
444        validate_path_segment(filename, "filename")?;
445        if !filename.starts_with("pbox-bootstrap-pbx_") || !filename.ends_with(".tar.zst") {
446            return Err(PveError::Unsupported(
447                "refusing to delete a non-bootstrap template".to_owned(),
448            ));
449        }
450        self.task_without_form(
451            Method::DELETE,
452            &format!("/nodes/{node}/storage/{storage}/content/{storage}:vztmpl%2F{filename}"),
453        )
454    }
455
456    fn get_lxc_state(&self, node: &str, vmid: u64) -> Result<String, PveError> {
457        validate_path_segment(node, "node")?;
458        #[derive(Deserialize)]
459        struct Current {
460            status: String,
461        }
462        let current: Current = self.get(&format!("/nodes/{node}/lxc/{vmid}/status/current"))?;
463        Ok(current.status)
464    }
465
466    fn create_lxc(
467        &self,
468        node: &str,
469        vmid: u64,
470        request: &LxcCreateRequest,
471    ) -> Result<PveTaskResponse, PveError> {
472        validate_path_segment(node, "node")?;
473        let form = LxcCreateForm { vmid, request };
474        self.task(Method::POST, &format!("/nodes/{node}/lxc"), &form)
475    }
476
477    fn update_lxc_config(
478        &self,
479        node: &str,
480        vmid: u64,
481        request: &LxcConfigUpdateRequest,
482    ) -> Result<(), PveError> {
483        validate_path_segment(node, "node")?;
484        self.empty(
485            Method::PUT,
486            &format!("/nodes/{node}/lxc/{vmid}/config"),
487            request,
488        )
489    }
490
491    fn start_lxc(&self, node: &str, vmid: u64) -> Result<PveTaskResponse, PveError> {
492        self.lxc_status_action(node, vmid, "start")
493    }
494
495    fn shutdown_lxc(&self, node: &str, vmid: u64) -> Result<PveTaskResponse, PveError> {
496        self.lxc_status_action(node, vmid, "shutdown")
497    }
498
499    fn stop_lxc(&self, node: &str, vmid: u64) -> Result<PveTaskResponse, PveError> {
500        self.lxc_status_action(node, vmid, "stop")
501    }
502
503    fn delete_lxc(&self, node: &str, vmid: u64) -> Result<PveTaskResponse, PveError> {
504        validate_path_segment(node, "node")?;
505        self.task_without_form(Method::DELETE, &format!("/nodes/{node}/lxc/{vmid}"))
506    }
507
508    fn force_delete_lxc(&self, node: &str, vmid: u64) -> Result<PveTaskResponse, PveError> {
509        validate_path_segment(node, "node")?;
510        self.task_without_form(Method::DELETE, &format!("/nodes/{node}/lxc/{vmid}?force=1"))
511    }
512
513    fn clone_lxc(
514        &self,
515        node: &str,
516        vmid: u64,
517        request: &LxcCloneRequest,
518    ) -> Result<PveTaskResponse, PveError> {
519        validate_path_segment(node, "node")?;
520        self.task(
521            Method::POST,
522            &format!("/nodes/{node}/lxc/{vmid}/clone"),
523            request,
524        )
525    }
526    fn template_lxc(&self, node: &str, vmid: u64) -> Result<(), PveError> {
527        validate_path_segment(node, "node")?;
528        let response = self
529            .request(Method::POST, &format!("/nodes/{node}/lxc/{vmid}/template"))
530            .send()
531            .map_err(PveError::Request)?;
532        let _: serde_json::Value = decode_response(response)?;
533        Ok(())
534    }
535    fn active_delete_tasks(&self, node: &str) -> Result<Vec<PveTaskResponse>, PveError> {
536        validate_path_segment(node, "node")?;
537        self.get(&format!(
538            "/nodes/{node}/tasks?source=active&typefilter=vzdestroy&limit=500"
539        ))
540    }
541    fn template_tasks(&self, node: &str, vmid: u64) -> Result<Vec<PveTaskResponse>, PveError> {
542        validate_path_segment(node, "node")?;
543        self.get(&format!(
544            "/nodes/{node}/tasks?vmid={vmid}&typefilter=vztemplate&limit=10"
545        ))
546    }
547    fn create_lxc_snapshot(
548        &self,
549        node: &str,
550        vmid: u64,
551        request: &LxcSnapshotRequest,
552    ) -> Result<PveTaskResponse, PveError> {
553        validate_path_segment(node, "node")?;
554        validate_snapshot_name(&request.snapname)?;
555        self.task(
556            Method::POST,
557            &format!("/nodes/{node}/lxc/{vmid}/snapshot"),
558            request,
559        )
560    }
561
562    fn rollback_lxc_snapshot(
563        &self,
564        node: &str,
565        vmid: u64,
566        snapname: &str,
567        start: bool,
568    ) -> Result<PveTaskResponse, PveError> {
569        validate_path_segment(node, "node")?;
570        validate_snapshot_name(snapname)?;
571        let form = LxcSnapshotRollbackForm {
572            start: u8::from(start),
573        };
574        self.task(
575            Method::POST,
576            &format!("/nodes/{node}/lxc/{vmid}/snapshot/{snapname}/rollback"),
577            &form,
578        )
579    }
580
581    fn delete_lxc_snapshot(
582        &self,
583        node: &str,
584        vmid: u64,
585        snapname: &str,
586    ) -> Result<PveTaskResponse, PveError> {
587        validate_path_segment(node, "node")?;
588        validate_snapshot_name(snapname)?;
589        self.task_without_form(
590            Method::DELETE,
591            &format!("/nodes/{node}/lxc/{vmid}/snapshot/{snapname}"),
592        )
593    }
594}
595
596impl PveClient {
597    fn lxc_status_action(
598        &self,
599        node: &str,
600        vmid: u64,
601        action: &str,
602    ) -> Result<PveTaskResponse, PveError> {
603        validate_path_segment(node, "node")?;
604        validate_path_segment(action, "action")?;
605        self.task_without_form(
606            Method::POST,
607            &format!("/nodes/{node}/lxc/{vmid}/status/{action}"),
608        )
609    }
610}
611
612fn normalise_base_url(value: &str) -> Result<String, PveError> {
613    let trimmed = value.trim().trim_end_matches('/');
614    if trimmed.is_empty() {
615        return Err(PveError::InvalidBaseUrl);
616    }
617    let parsed = reqwest::Url::parse(trimmed).map_err(|_| PveError::InvalidBaseUrl)?;
618    if parsed.scheme() != "https"
619        || parsed.host_str().is_none()
620        || !parsed.username().is_empty()
621        || parsed.password().is_some()
622        || parsed.query().is_some()
623        || parsed.fragment().is_some()
624    {
625        return Err(PveError::InvalidBaseUrl);
626    }
627    if trimmed.ends_with(API_PREFIX) {
628        Ok(trimmed.to_owned())
629    } else {
630        Ok(format!("{trimmed}{API_PREFIX}"))
631    }
632}
633
634fn validate_path_segment(value: &str, field: &str) -> Result<(), PveError> {
635    if value.is_empty()
636        || matches!(value, "." | "..")
637        || value
638            .chars()
639            .any(|character| matches!(character, '/' | '\\' | '?' | '#' | '%'))
640    {
641        return Err(PveError::InvalidPathSegment {
642            field: field.to_owned(),
643        });
644    }
645    Ok(())
646}
647
648fn validate_oci_reference(value: &str) -> Result<(), PveError> {
649    if value.is_empty()
650        || value.chars().any(|character| {
651            character.is_ascii_control() || matches!(character, '?' | '#' | '%' | '&' | '\\')
652        })
653    {
654        return Err(PveError::InvalidPathSegment {
655            field: "OCI reference".to_owned(),
656        });
657    }
658    Ok(())
659}
660
661fn encode_query_component(value: &str) -> String {
662    value
663        .bytes()
664        .flat_map(|byte| {
665            if byte.is_ascii_alphanumeric()
666                || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':' | b'@')
667            {
668                vec![byte as char]
669            } else {
670                format!("%{byte:02X}").chars().collect()
671            }
672        })
673        .collect()
674}
675
676fn validate_snapshot_name(value: &str) -> Result<(), PveError> {
677    let valid = value.len() >= 2
678        && !value.is_empty()
679        && value.len() <= 40
680        && value != "current"
681        && value != "vzdump"
682        && value
683            .chars()
684            .next()
685            .is_some_and(|character| character.is_ascii_alphabetic())
686        && value
687            .chars()
688            .skip(1)
689            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'));
690    if !valid {
691        return Err(PveError::InvalidSnapshotName {
692            name: value.to_owned(),
693        });
694    }
695    Ok(())
696}
697
698fn decode_response<T: DeserializeOwned>(
699    response: reqwest::blocking::Response,
700) -> Result<T, PveError> {
701    let status = response.status();
702    let body = response.text().map_err(PveError::Request)?;
703    if !status.is_success() {
704        let message = serde_json::from_str::<PveErrorEnvelope>(&body)
705            .ok()
706            .and_then(|envelope| envelope.errors)
707            .unwrap_or(body);
708        return Err(PveError::Http { status, message });
709    }
710    let envelope: PveResponse<T> = serde_json::from_str(&body).map_err(PveError::Decode)?;
711    Ok(envelope.data)
712}
713
714fn decode_task_response(
715    response: reqwest::blocking::Response,
716) -> Result<PveTaskResponse, PveError> {
717    decode_response(response)
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
721pub struct PveNode {
722    pub node: String,
723    pub status: Option<String>,
724    #[serde(flatten)]
725    pub extra: serde_json::Map<String, serde_json::Value>,
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
729pub struct PveStorage {
730    pub storage: String,
731    pub content: Option<String>,
732    pub active: Option<u64>,
733    pub enabled: Option<u64>,
734    #[serde(flatten)]
735    pub extra: serde_json::Map<String, serde_json::Value>,
736}
737
738#[derive(Debug, Clone, Deserialize, PartialEq)]
739pub struct PveNetworkInterface {
740    pub iface: String,
741    #[serde(rename = "type")]
742    pub interface_type: Option<String>,
743    pub cidr: Option<String>,
744    #[serde(flatten)]
745    pub extra: serde_json::Map<String, serde_json::Value>,
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
749pub struct PveStorageContent {
750    pub volid: String,
751    pub content: Option<String>,
752    pub format: Option<String>,
753    #[serde(rename = "isBase")]
754    pub is_base: Option<u64>,
755    #[serde(flatten)]
756    pub extra: serde_json::Map<String, serde_json::Value>,
757}
758#[derive(Debug, Default, Deserialize)]
759pub struct LxcUsage {
760    pub cpu: Option<f64>,
761    pub mem: Option<u64>,
762    pub maxmem: Option<u64>,
763    pub disk: Option<u64>,
764    pub maxdisk: Option<u64>,
765}
766
767#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
768
769pub struct ClusterResource {
770    #[serde(rename = "type")]
771    pub resource_type: String,
772    pub vmid: Option<u64>,
773    pub node: Option<String>,
774    pub status: Option<String>,
775    pub name: Option<String>,
776    pub tags: Option<String>,
777    pub uptime: Option<u64>,
778    pub mem: Option<u64>,
779    pub maxmem: Option<u64>,
780    pub disk: Option<u64>,
781    pub maxdisk: Option<u64>,
782    #[serde(flatten)]
783    pub extra: serde_json::Map<String, serde_json::Value>,
784}
785
786fn serialize_optional_bool_as_int<S>(value: &Option<bool>, serializer: S) -> Result<S::Ok, S::Error>
787where
788    S: Serializer,
789{
790    match value {
791        Some(value) => serializer.serialize_some(&u8::from(*value)),
792        None => serializer.serialize_none(),
793    }
794}
795
796#[derive(Debug, Deserialize)]
797#[serde(untagged)]
798enum PveBoolValue {
799    Bool(bool),
800    Integer(u64),
801    Text(String),
802}
803
804fn deserialize_optional_bool_from_pve<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
805where
806    D: Deserializer<'de>,
807{
808    let value = Option::<PveBoolValue>::deserialize(deserializer)?;
809    match value {
810        None => Ok(None),
811        Some(PveBoolValue::Bool(value)) => Ok(Some(value)),
812        Some(PveBoolValue::Integer(0)) => Ok(Some(false)),
813        Some(PveBoolValue::Integer(1)) => Ok(Some(true)),
814        Some(PveBoolValue::Integer(value)) => Err(D::Error::custom(format!(
815            "PVE boolean integer must be 0 or 1, got {value}"
816        ))),
817        Some(PveBoolValue::Text(value)) => match value.as_str() {
818            "0" | "false" => Ok(Some(false)),
819            "1" | "true" => Ok(Some(true)),
820            _ => Err(D::Error::custom(format!(
821                "invalid PVE boolean value: {value}"
822            ))),
823        },
824    }
825}
826
827#[derive(Debug, Clone, Serialize)]
828pub struct LxcCloneRequest {
829    pub newid: u64,
830    pub hostname: String,
831    pub description: String,
832    /// Always request an independent copy, including when cloning a template.
833    pub full: u8,
834    #[serde(skip_serializing_if = "Option::is_none")]
835    pub storage: Option<String>,
836}
837
838#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
839pub struct LxcCreateRequest {
840    #[serde(skip_serializing_if = "Option::is_none")]
841    pub entrypoint: Option<String>,
842    #[serde(skip_serializing_if = "Option::is_none")]
843    pub ostype: Option<String>,
844    #[serde(skip_serializing_if = "Option::is_none")]
845    pub ostemplate: Option<String>,
846    #[serde(skip_serializing_if = "Option::is_none")]
847    pub hostname: Option<String>,
848    #[serde(skip_serializing_if = "Option::is_none")]
849    pub memory: Option<u64>,
850    #[serde(skip_serializing_if = "Option::is_none")]
851    pub swap: Option<u64>,
852    #[serde(skip_serializing_if = "Option::is_none")]
853    pub cores: Option<u64>,
854    #[serde(skip_serializing_if = "Option::is_none")]
855    pub rootfs: Option<String>,
856    #[serde(skip_serializing_if = "Option::is_none")]
857    pub net0: Option<String>,
858    #[serde(
859        skip_serializing_if = "Option::is_none",
860        serialize_with = "serialize_optional_bool_as_int"
861    )]
862    pub unprivileged: Option<bool>,
863    #[serde(
864        skip_serializing_if = "Option::is_none",
865        serialize_with = "serialize_optional_bool_as_int"
866    )]
867    pub onboot: Option<bool>,
868    #[serde(skip_serializing_if = "Option::is_none")]
869    pub description: Option<String>,
870    #[serde(rename = "ssh-public-keys", skip_serializing_if = "Option::is_none")]
871    pub ssh_public_keys: Option<String>,
872    #[serde(
873        skip_serializing_if = "Option::is_none",
874        serialize_with = "serialize_optional_bool_as_int"
875    )]
876    pub start: Option<bool>,
877}
878
879#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
880pub struct LxcConfigUpdateRequest {
881    /// Additional netN interfaces configured through the standard PVE API.
882    #[serde(flatten, default)]
883    pub networks: std::collections::BTreeMap<String, String>,
884    #[serde(skip_serializing_if = "Option::is_none")]
885    pub digest: Option<String>,
886    #[serde(skip_serializing_if = "Option::is_none")]
887    pub hostname: Option<String>,
888    #[serde(skip_serializing_if = "Option::is_none")]
889    pub memory: Option<u64>,
890    #[serde(skip_serializing_if = "Option::is_none")]
891    pub swap: Option<u64>,
892    #[serde(skip_serializing_if = "Option::is_none")]
893    pub cores: Option<u64>,
894    #[serde(skip_serializing_if = "Option::is_none")]
895    pub rootfs: Option<String>,
896    #[serde(skip_serializing_if = "Option::is_none")]
897    pub net0: Option<String>,
898    #[serde(
899        skip_serializing_if = "Option::is_none",
900        serialize_with = "serialize_optional_bool_as_int"
901    )]
902    pub unprivileged: Option<bool>,
903    #[serde(skip_serializing_if = "Option::is_none")]
904    pub description: Option<String>,
905}
906
907#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
908pub struct LxcSnapshotRequest {
909    pub snapname: String,
910    #[serde(skip_serializing_if = "Option::is_none")]
911    pub description: Option<String>,
912}
913
914/// Snapshot data returned by the PVE LXC snapshot endpoint.
915///
916/// PVE includes a synthetic `current` entry and may add fields such as
917/// `digest`, `running`, or `snapstate`. Keep those fields in `extra` so the
918/// client remains compatible with PVE response additions.
919/// Source: https://github.com/proxmox/pve-container/blob/master/src/PVE/API2/LXC/Snapshot.pm
920#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
921pub struct LxcSnapshot {
922    pub name: String,
923    pub description: Option<String>,
924    pub snaptime: Option<u64>,
925    pub parent: Option<String>,
926    #[serde(flatten)]
927    pub extra: serde_json::Map<String, serde_json::Value>,
928}
929
930#[derive(Debug, Serialize)]
931struct LxcSnapshotRollbackForm {
932    start: u8,
933}
934
935#[derive(Debug, Serialize)]
936struct LxcCreateForm<'a> {
937    vmid: u64,
938    #[serde(flatten)]
939    request: &'a LxcCreateRequest,
940}
941#[derive(Debug, Serialize)]
942struct OciRegistryPullForm<'a> {
943    reference: &'a str,
944    filename: &'a str,
945}
946
947#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
948pub struct LxcConfig {
949    pub digest: Option<String>,
950    pub description: Option<String>,
951    pub hostname: Option<String>,
952    pub cores: Option<u64>,
953    pub memory: Option<u64>,
954    pub swap: Option<u64>,
955    pub rootfs: Option<String>,
956    #[serde(default, deserialize_with = "deserialize_optional_bool_from_pve")]
957    pub unprivileged: Option<bool>,
958    pub net0: Option<String>,
959    #[serde(flatten)]
960    pub extra: serde_json::Map<String, serde_json::Value>,
961}
962
963/// Runtime interface data returned by the PVE LXC interfaces endpoint.
964#[derive(Debug, Clone, Deserialize, PartialEq)]
965pub struct LxcInterface {
966    pub name: Option<String>,
967    pub hwaddr: Option<String>,
968    pub inet: Option<String>,
969    pub inet6: Option<String>,
970    #[serde(default, rename = "ip-addresses")]
971    pub ip_addresses: Vec<LxcIpAddress>,
972    pub address: Option<String>,
973    pub netmask: Option<String>,
974    pub gateway: Option<String>,
975    pub gateway6: Option<String>,
976    pub method: Option<String>,
977    #[serde(rename = "type")]
978    pub type_: Option<String>,
979    pub exists: Option<u64>,
980    pub active: Option<u64>,
981    #[serde(flatten)]
982    pub extra: serde_json::Map<String, serde_json::Value>,
983}
984
985/// One address from the runtime interface's complete address list.
986#[derive(Debug, Clone, Deserialize, PartialEq)]
987pub struct LxcIpAddress {
988    #[serde(rename = "ip-address")]
989    pub address: String,
990}
991
992/// Select a reachable-looking IPv4 address from runtime LXC interfaces.
993pub fn select_lxc_ipv4(interfaces: &[LxcInterface]) -> Option<Ipv4Addr> {
994    interfaces
995        .iter()
996        .filter_map(|interface| {
997            if interface.active == Some(0) || interface.exists == Some(0) {
998                return None;
999            }
1000            let address = interface.inet.as_deref()?.split('/').next()?;
1001            let address = address.parse::<Ipv4Addr>().ok()?;
1002            if address.is_unspecified() || address.is_loopback() || address.is_link_local() {
1003                return None;
1004            }
1005            Some((
1006                interface.name.as_deref() != Some("eth0"),
1007                address.octets(),
1008                address,
1009            ))
1010        })
1011        .min_by_key(|(not_eth0, octets, _)| (*not_eth0, *octets))
1012        .map(|(_, _, address)| address)
1013}
1014
1015/// Select an assigned IPv6 address, excluding loopback and interface-local addresses.
1016pub fn select_lxc_ipv6(interfaces: &[LxcInterface]) -> Option<Ipv6Addr> {
1017    interfaces
1018        .iter()
1019        .filter(|interface| interface.active != Some(0) && interface.exists != Some(0))
1020        .flat_map(|interface| {
1021            interface
1022                .ip_addresses
1023                .iter()
1024                .map(|entry| entry.address.as_str())
1025                .chain(interface.inet6.as_deref())
1026                .filter_map(|value| value.split('/').next()?.parse::<Ipv6Addr>().ok())
1027                .filter(|address| {
1028                    !address.is_unspecified()
1029                        && !address.is_loopback()
1030                        && !address.is_unicast_link_local()
1031                        && !address.is_multicast()
1032                })
1033                .map(move |address| (interface.name.as_deref() != Some("eth0"), address))
1034        })
1035        .min()
1036        .map(|(_, address)| address)
1037}
1038
1039#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
1040pub struct PveTaskResponse {
1041    pub upid: String,
1042}
1043
1044impl<'de> Deserialize<'de> for PveTaskResponse {
1045    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1046    where
1047        D: serde::Deserializer<'de>,
1048    {
1049        #[derive(Deserialize)]
1050        #[serde(untagged)]
1051        enum Wire {
1052            Raw(String),
1053            Object { upid: String },
1054        }
1055
1056        match Wire::deserialize(deserializer)? {
1057            Wire::Raw(upid) | Wire::Object { upid } => Ok(Self { upid }),
1058        }
1059    }
1060}
1061
1062#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1063pub struct PveTaskStatus {
1064    pub status: String,
1065    pub exitstatus: Option<String>,
1066    pub upid: Option<String>,
1067    pub node: Option<String>,
1068    pub pid: Option<u64>,
1069    pub starttime: Option<u64>,
1070    #[serde(rename = "type")]
1071    pub type_: Option<String>,
1072}
1073
1074impl PveTaskStatus {
1075    pub fn is_successful(&self) -> bool {
1076        self.status == "stopped"
1077            && self
1078                .exitstatus
1079                .as_deref()
1080                .is_some_and(|status| status == "OK" || status.starts_with("WARNINGS:"))
1081    }
1082}
1083
1084#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
1085pub struct PveTaskLog {
1086    pub n: u64,
1087    pub t: String,
1088}
1089
1090#[derive(Debug, Deserialize)]
1091struct PveResponse<T> {
1092    data: T,
1093}
1094
1095#[derive(Debug, Deserialize)]
1096struct PveErrorEnvelope {
1097    errors: Option<String>,
1098}
1099#[derive(Debug, Error)]
1100pub enum PveError {
1101    #[error("could not build PVE HTTP client: {0}")]
1102    Client(#[source] reqwest::Error),
1103    #[error("could not reach PVE API: {0}")]
1104    Request(#[source] reqwest::Error),
1105    #[error("could not read PVE upload file: {0}")]
1106    UploadFile(#[source] std::io::Error),
1107    #[error("invalid PVE API response: {0}")]
1108    Decode(#[source] serde_json::Error),
1109    #[error("PVE API returned HTTP {status}: {message}")]
1110    Http { status: StatusCode, message: String },
1111    #[error("PVE URL is empty")]
1112    InvalidBaseUrl,
1113    #[error("invalid {field} path segment")]
1114    InvalidPathSegment { field: String },
1115    #[error("invalid PVE snapshot name: {name}")]
1116    InvalidSnapshotName { name: String },
1117    #[error("PVE client does not support this operation: {0}")]
1118    Unsupported(String),
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123    use super::*;
1124
1125    #[test]
1126    fn debug_output_redacts_token_secret() {
1127        let config = PveClientConfig::new(
1128            "https://pve.example.test:8006",
1129            "pbox@pve!cli",
1130            Secret::new("secret"),
1131        );
1132        let rendered = format!("{config:?}");
1133        assert!(!rendered.contains(": \"secret\""));
1134        assert!(rendered.contains("<redacted>"));
1135    }
1136
1137    #[test]
1138    fn base_url_is_normalised_once() {
1139        assert_eq!(
1140            normalise_base_url("https://pve.test:8006/").unwrap(),
1141            "https://pve.test:8006/api2/json"
1142        );
1143        assert_eq!(
1144            normalise_base_url("https://pve.test:8006/api2/json").unwrap(),
1145            "https://pve.test:8006/api2/json"
1146        );
1147    }
1148
1149    #[test]
1150    fn base_url_rejects_plain_http() {
1151        assert!(normalise_base_url("http://pve.test:8006").is_err());
1152    }
1153    #[test]
1154    fn base_url_rejects_credentials_and_query_data() {
1155        assert!(normalise_base_url("https://user:secret@pve.test:8006").is_err());
1156        assert!(normalise_base_url("https://pve.test:8006/?token=secret").is_err());
1157    }
1158
1159    #[test]
1160    fn path_segments_cannot_escape_endpoint() {
1161        assert!(validate_path_segment("..", "node").is_err());
1162        assert!(validate_path_segment(".", "node").is_err());
1163        assert!(validate_path_segment(r"node\\child", "node").is_err());
1164        assert!(validate_path_segment("%2e%2e", "node").is_err());
1165        assert!(validate_path_segment("UPID:pve:1:2:3", "UPID").is_ok());
1166    }
1167
1168    #[test]
1169    fn raw_upid_response_decodes() {
1170        let response: PveResponse<PveTaskResponse> =
1171            serde_json::from_str(r#"{"data":"UPID:pve:1:2:3:create"}"#).unwrap();
1172        assert_eq!(response.data.upid, "UPID:pve:1:2:3:create");
1173    }
1174
1175    #[test]
1176    fn task_log_response_decodes() {
1177        let log: Vec<PveTaskLog> =
1178            serde_json::from_str(r#"[{"n":0,"t":"create started"},{"n":1,"t":"ERROR: no space"}]"#)
1179                .unwrap();
1180
1181        assert_eq!(
1182            log,
1183            vec![
1184                PveTaskLog {
1185                    n: 0,
1186                    t: "create started".to_owned(),
1187                },
1188                PveTaskLog {
1189                    n: 1,
1190                    t: "ERROR: no space".to_owned(),
1191                },
1192            ]
1193        );
1194    }
1195
1196    #[test]
1197    fn null_response_decodes_for_config_updates() {
1198        let response: PveResponse<serde_json::Value> =
1199            serde_json::from_str(r#"{"data":null}"#).unwrap();
1200        assert!(response.data.is_null());
1201    }
1202
1203    #[test]
1204    fn provisioning_discovery_responses_decode() {
1205        let nodes: Vec<PveNode> =
1206            serde_json::from_str(r#"[{"node":"pve-a","status":"online","cpu":0.1}]"#).unwrap();
1207        assert_eq!(nodes[0].node, "pve-a");
1208        assert_eq!(nodes[0].status.as_deref(), Some("online"));
1209        assert_eq!(nodes[0].extra["cpu"], 0.1);
1210
1211        let storages: Vec<PveStorage> = serde_json::from_str(
1212            r#"[{"storage":"local","content":"iso,vztmpl,rootdir","active":1,"enabled":1}]"#,
1213        )
1214        .unwrap();
1215        assert_eq!(storages[0].storage, "local");
1216        assert!(storages[0].content.as_deref().unwrap().contains("vztmpl"));
1217
1218        let content: Vec<PveStorageContent> = serde_json::from_str(
1219            r#"[{"volid":"local:vztmpl/debian-13-standard_13.0-1_amd64.tar.zst","content":"vztmpl","format":"tar.zst","isBase":1}]"#,
1220        )
1221        .unwrap();
1222        assert_eq!(
1223            content[0].volid,
1224            "local:vztmpl/debian-13-standard_13.0-1_amd64.tar.zst"
1225        );
1226        assert_eq!(content[0].is_base, Some(1));
1227    }
1228
1229    #[test]
1230    fn lxc_interfaces_decode_runtime_addresses_and_extra_fields() {
1231        let interfaces: Vec<LxcInterface> = serde_json::from_str(
1232            r#"[{"name":"eth0","hwaddr":"02:00:00:00:00:01","inet":"10.0.20.43/24","inet6":"fe80::1/64","type":"eth","active":1,"unexpected":"kept"}]"#,
1233        )
1234        .unwrap();
1235        assert_eq!(interfaces.len(), 1);
1236        assert_eq!(interfaces[0].name.as_deref(), Some("eth0"));
1237        assert_eq!(interfaces[0].inet.as_deref(), Some("10.0.20.43/24"));
1238        assert_eq!(interfaces[0].type_.as_deref(), Some("eth"));
1239        assert_eq!(interfaces[0].active, Some(1));
1240        assert_eq!(interfaces[0].extra["unexpected"], "kept");
1241    }
1242
1243    #[test]
1244    fn lxc_snapshots_decode_current_and_stored_entries() {
1245        let snapshots: Vec<LxcSnapshot> = serde_json::from_str(
1246            r#"[{"name":"before_recipe","description":"Before recipe","snaptime":1724520000,"parent":"base","snapstate":"prepare"},{"name":"current","description":"You are here!","running":1,"digest":"deadbeef"}]"#,
1247        )
1248        .unwrap();
1249        assert_eq!(snapshots[0].name, "before_recipe");
1250        assert_eq!(snapshots[0].snaptime, Some(1_724_520_000));
1251        assert_eq!(snapshots[0].parent.as_deref(), Some("base"));
1252        assert_eq!(snapshots[0].extra["snapstate"], "prepare");
1253        assert_eq!(snapshots[1].name, "current");
1254        assert_eq!(snapshots[1].snaptime, None);
1255        assert_eq!(snapshots[1].extra["running"], 1);
1256    }
1257
1258    #[test]
1259    fn lxc_snapshot_response_preserves_optional_fields() {
1260        let snapshot: LxcSnapshot =
1261            serde_json::from_str(r#"{"name":"checkpoint","description":""}"#).unwrap();
1262        assert_eq!(snapshot.description.as_deref(), Some(""));
1263        assert_eq!(snapshot.snaptime, None);
1264        assert_eq!(snapshot.parent, None);
1265    }
1266
1267    #[test]
1268    fn lxc_config_accepts_pve_integer_booleans() {
1269        let enabled: LxcConfig = serde_json::from_str(r#"{"unprivileged":1}"#).unwrap();
1270        let disabled: LxcConfig = serde_json::from_str(r#"{"unprivileged":0}"#).unwrap();
1271        let boolean: LxcConfig = serde_json::from_str(r#"{"unprivileged":true}"#).unwrap();
1272
1273        assert_eq!(enabled.unprivileged, Some(true));
1274        assert_eq!(disabled.unprivileged, Some(false));
1275        assert_eq!(boolean.unprivileged, Some(true));
1276    }
1277    #[test]
1278    fn task_status_maps_type_and_success() {
1279        let status: PveTaskStatus =
1280            serde_json::from_str(r#"{"status":"stopped","exitstatus":"OK","type":"vzcreate"}"#)
1281                .unwrap();
1282        assert_eq!(status.type_.as_deref(), Some("vzcreate"));
1283        assert!(status.is_successful());
1284    }
1285
1286    #[test]
1287    fn task_status_failure_is_not_successful() {
1288        let status = PveTaskStatus {
1289            status: "stopped".to_owned(),
1290            exitstatus: Some("ERROR: create failed".to_owned()),
1291            upid: None,
1292            node: None,
1293            pid: None,
1294            starttime: None,
1295            type_: None,
1296        };
1297        assert!(!status.is_successful());
1298    }
1299
1300    #[test]
1301    fn task_status_warnings_are_successful() {
1302        let status = PveTaskStatus {
1303            status: "stopped".to_owned(),
1304            exitstatus: Some("WARNINGS: 1".to_owned()),
1305            upid: None,
1306            node: None,
1307            pid: None,
1308            starttime: None,
1309            type_: None,
1310        };
1311        assert!(status.is_successful());
1312    }
1313
1314    #[test]
1315    fn pve_network_interfaces_decode_bridge_data() {
1316        let interfaces: Vec<PveNetworkInterface> = serde_json::from_str(
1317            r#"[{"iface":"vmbr0","type":"bridge","cidr":"192.0.2.1/24","active":1}]"#,
1318        )
1319        .unwrap();
1320
1321        assert_eq!(interfaces[0].iface, "vmbr0");
1322        assert_eq!(interfaces[0].interface_type.as_deref(), Some("bridge"));
1323        assert_eq!(interfaces[0].cidr.as_deref(), Some("192.0.2.1/24"));
1324        assert_eq!(interfaces[0].extra["active"], 1);
1325    }
1326    #[test]
1327
1328    fn lifecycle_requests_serialize_only_set_values() {
1329        let request = LxcCreateRequest {
1330            ostype: Some("archlinux".to_owned()),
1331            ostemplate: Some("local:vztmpl/debian-12.tar.zst".to_owned()),
1332            memory: Some(1024),
1333            net0: Some("name=eth0,bridge=vmbr0".to_owned()),
1334            onboot: Some(true),
1335            ssh_public_keys: Some("ssh-ed25519 AAAA bootstrap".to_owned()),
1336            start: Some(true),
1337            ..Default::default()
1338        };
1339        let form = LxcCreateForm {
1340            vmid: 100,
1341            request: &request,
1342        };
1343        let value = serde_json::to_value(form).unwrap();
1344        assert_eq!(value["vmid"], 100);
1345        assert_eq!(value["ostemplate"], "local:vztmpl/debian-12.tar.zst");
1346        assert_eq!(value["ostype"], "archlinux");
1347        assert_eq!(value["memory"], 1024);
1348        assert_eq!(value["net0"], "name=eth0,bridge=vmbr0");
1349        assert_eq!(value["ssh-public-keys"], "ssh-ed25519 AAAA bootstrap");
1350        assert_eq!(value["onboot"], 1);
1351        assert_eq!(value["start"], 1);
1352        assert!(value.get("hostname").is_none());
1353
1354        let update = LxcConfigUpdateRequest {
1355            digest: Some("deadbeef".to_owned()),
1356            description: Some("managed by pbox".to_owned()),
1357            ..Default::default()
1358        };
1359        let update_value = serde_json::to_value(update).unwrap();
1360        assert_eq!(update_value["digest"], "deadbeef");
1361        assert_eq!(update_value["description"], "managed by pbox");
1362        assert!(update_value.get("memory").is_none());
1363    }
1364
1365    #[test]
1366    fn snapshot_requests_follow_pve_forms() {
1367        let request = LxcSnapshotRequest {
1368            snapname: "before_recipe".to_owned(),
1369            description: Some("Before applying recipe desktop/xfce".to_owned()),
1370        };
1371        let value = serde_json::to_value(request).unwrap();
1372        assert_eq!(value["snapname"], "before_recipe");
1373        assert_eq!(value["description"], "Before applying recipe desktop/xfce");
1374
1375        let rollback = serde_json::to_value(LxcSnapshotRollbackForm { start: 1 }).unwrap();
1376        assert_eq!(rollback["start"], 1);
1377    }
1378
1379    #[test]
1380    fn snapshot_names_follow_proxmox_config_id_rules() {
1381        assert!(validate_snapshot_name("before_recipe").is_ok());
1382        assert!(validate_snapshot_name("A1-test").is_ok());
1383        for invalid in [
1384            "",
1385            "a",
1386            "1-before",
1387            "before recipe",
1388            "before.recipe",
1389            "current",
1390            "vzdump",
1391        ] {
1392            assert!(validate_snapshot_name(invalid).is_err(), "{invalid}");
1393        }
1394        assert!(validate_snapshot_name(&"a".repeat(41)).is_err());
1395    }
1396    #[test]
1397    fn lxc_ipv4_selection_prefers_eth0_and_skips_unusable_addresses() {
1398        let interfaces: Vec<LxcInterface> = serde_json::from_str(
1399            r#"[{"name":"lo","inet":"127.0.0.1/8"},{"name":"eth1","inet":"192.168.1.20/24"},{"name":"eth0","inet":"10.0.20.43/24"},{"name":"eth2","inet":"10.0.20.2/24","active":0}]"#,
1400        )
1401        .unwrap();
1402        assert_eq!(
1403            select_lxc_ipv4(&interfaces),
1404            Some("10.0.20.43".parse().unwrap())
1405        );
1406    }
1407
1408    #[test]
1409    fn lxc_ipv4_selection_returns_none_without_active_addresses() {
1410        let interfaces: Vec<LxcInterface> =
1411            serde_json::from_str(r#"[{"name":"eth0","inet":"10.0.20.43/24","active":0}]"#).unwrap();
1412        assert_eq!(select_lxc_ipv4(&interfaces), None);
1413    }
1414    #[test]
1415    fn ipv6_selection_handles_dual_stack_ula_and_unusable_addresses() {
1416        let interfaces: Vec<LxcInterface> = serde_json::from_str(
1417            r#"[
1418            {"name":"lo","inet6":"::1/128"},
1419            {"name":"eth1","inet6":"fd12::5/64"},
1420            {"name":"eth0","inet":"10.0.0.2/24","inet6":"fe80::2/64","ip-addresses":[{"ip-address":"10.0.0.2"},{"ip-address":"2001:db8::2"},{"ip-address":"fe80::2"}]},
1421            {"name":"eth2","inet6":"2001:db8::1/64","active":0}
1422        ]"#,
1423        )
1424        .unwrap();
1425        assert_eq!(
1426            select_lxc_ipv6(&interfaces),
1427            Some("2001:db8::2".parse().unwrap())
1428        );
1429        assert_eq!(
1430            select_lxc_ipv4(&interfaces),
1431            Some("10.0.0.2".parse().unwrap())
1432        );
1433        assert_eq!(
1434            select_lxc_ipv6(&interfaces[1..2]),
1435            Some("fd12::5".parse().unwrap())
1436        );
1437        let local: Vec<LxcInterface> =
1438            serde_json::from_str(r#"[{"inet6":"fe80::2/64"},{"inet6":"::"},{"inet6":"ff02::1"}]"#)
1439                .unwrap();
1440        assert_eq!(select_lxc_ipv6(&local), None);
1441    }
1442
1443    #[test]
1444    fn oci_pull_form_uses_reference_and_filename() {
1445        let form = OciRegistryPullForm {
1446            reference: "ghcr.io/example/base:latest",
1447            filename: "pbox-oci-ghcr.io-example-base-latest",
1448        };
1449        let value = serde_json::to_value(form).unwrap();
1450        assert_eq!(value["reference"], "ghcr.io/example/base:latest");
1451        assert_eq!(value["filename"], "pbox-oci-ghcr.io-example-base-latest");
1452    }
1453
1454    #[test]
1455    fn oci_reference_query_encoding_rejects_injection() {
1456        assert!(validate_oci_reference("ghcr.io/example/base:latest").is_ok());
1457        assert!(validate_oci_reference("ghcr.io/example/base?x=1").is_err());
1458        assert_eq!(
1459            encode_query_component("ghcr.io/example/base:latest"),
1460            "ghcr.io/example/base:latest"
1461        );
1462        assert_eq!(encode_query_component("repo tag"), "repo%20tag");
1463    }
1464}