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 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 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 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 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 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 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 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 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 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 #[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#[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#[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#[derive(Debug, Clone, Deserialize, PartialEq)]
987pub struct LxcIpAddress {
988 #[serde(rename = "ip-address")]
989 pub address: String,
990}
991
992pub 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
1015pub 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}