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