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 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 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 #[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#[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#[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#[derive(Debug, Clone, Deserialize, PartialEq)]
965pub struct LxcIpAddress {
966 #[serde(rename = "ip-address")]
967 pub address: String,
968}
969
970pub 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
993pub 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}