]>
Commit | Line | Data |
---|---|---|
b6be0f39 | 1 | use std::fmt::Display; |
bb0ff2ac FE |
2 | use std::io::{BufRead, BufReader, Write}; |
3 | use std::path::PathBuf; | |
b6be0f39 | 4 | |
bb0ff2ac | 5 | use anyhow::{bail, format_err, Error}; |
b6be0f39 FE |
6 | use serde::{Deserialize, Serialize}; |
7 | ||
c7b17de1 | 8 | use proxmox_schema::api; |
b6be0f39 | 9 | |
8ada1785 FE |
10 | use crate::repositories::standard::APTRepositoryHandle; |
11 | ||
b6be0f39 | 12 | #[api] |
661b8837 | 13 | #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] |
b6be0f39 FE |
14 | #[serde(rename_all = "lowercase")] |
15 | pub enum APTRepositoryFileType { | |
16 | /// One-line-style format | |
17 | List, | |
18 | /// DEB822-style format | |
19 | Sources, | |
20 | } | |
21 | ||
22 | impl TryFrom<&str> for APTRepositoryFileType { | |
23 | type Error = Error; | |
24 | ||
25 | fn try_from(string: &str) -> Result<Self, Error> { | |
26 | match string { | |
27 | "list" => Ok(APTRepositoryFileType::List), | |
28 | "sources" => Ok(APTRepositoryFileType::Sources), | |
29 | _ => bail!("invalid file type '{}'", string), | |
30 | } | |
31 | } | |
32 | } | |
33 | ||
34 | impl Display for APTRepositoryFileType { | |
35 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
36 | match self { | |
37 | APTRepositoryFileType::List => write!(f, "list"), | |
38 | APTRepositoryFileType::Sources => write!(f, "sources"), | |
39 | } | |
40 | } | |
41 | } | |
42 | ||
43 | #[api] | |
661b8837 | 44 | #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] |
b6be0f39 FE |
45 | #[serde(rename_all = "kebab-case")] |
46 | pub enum APTRepositoryPackageType { | |
47 | /// Debian package | |
48 | Deb, | |
49 | /// Debian source package | |
50 | DebSrc, | |
51 | } | |
52 | ||
53 | impl TryFrom<&str> for APTRepositoryPackageType { | |
54 | type Error = Error; | |
55 | ||
56 | fn try_from(string: &str) -> Result<Self, Error> { | |
57 | match string { | |
58 | "deb" => Ok(APTRepositoryPackageType::Deb), | |
59 | "deb-src" => Ok(APTRepositoryPackageType::DebSrc), | |
60 | _ => bail!("invalid package type '{}'", string), | |
61 | } | |
62 | } | |
63 | } | |
64 | ||
65 | impl Display for APTRepositoryPackageType { | |
66 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
67 | match self { | |
68 | APTRepositoryPackageType::Deb => write!(f, "deb"), | |
69 | APTRepositoryPackageType::DebSrc => write!(f, "deb-src"), | |
70 | } | |
71 | } | |
72 | } | |
73 | ||
74 | #[api( | |
75 | properties: { | |
76 | Key: { | |
77 | description: "Option key.", | |
78 | type: String, | |
79 | }, | |
80 | Values: { | |
81 | description: "Option values.", | |
82 | type: Array, | |
83 | items: { | |
84 | description: "Value.", | |
85 | type: String, | |
86 | }, | |
87 | }, | |
88 | }, | |
89 | )] | |
90 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
91 | #[serde(rename_all = "PascalCase")] // for consistency | |
92 | /// Additional options for an APT repository. | |
93 | /// Used for both single- and mutli-value options. | |
94 | pub struct APTRepositoryOption { | |
95 | /// Option key. | |
96 | pub key: String, | |
97 | /// Option value(s). | |
98 | pub values: Vec<String>, | |
99 | } | |
100 | ||
101 | #[api( | |
102 | properties: { | |
103 | Types: { | |
104 | description: "List of package types.", | |
105 | type: Array, | |
106 | items: { | |
107 | type: APTRepositoryPackageType, | |
108 | }, | |
109 | }, | |
110 | URIs: { | |
111 | description: "List of repository URIs.", | |
112 | type: Array, | |
113 | items: { | |
114 | description: "Repository URI.", | |
115 | type: String, | |
116 | }, | |
117 | }, | |
118 | Suites: { | |
119 | description: "List of distributions.", | |
120 | type: Array, | |
121 | items: { | |
122 | description: "Package distribution.", | |
123 | type: String, | |
124 | }, | |
125 | }, | |
126 | Components: { | |
127 | description: "List of repository components.", | |
128 | type: Array, | |
129 | items: { | |
130 | description: "Repository component.", | |
131 | type: String, | |
132 | }, | |
133 | }, | |
134 | Options: { | |
135 | type: Array, | |
136 | optional: true, | |
137 | items: { | |
138 | type: APTRepositoryOption, | |
139 | }, | |
140 | }, | |
141 | Comment: { | |
142 | description: "Associated comment.", | |
143 | type: String, | |
144 | optional: true, | |
145 | }, | |
146 | FileType: { | |
147 | type: APTRepositoryFileType, | |
148 | }, | |
149 | Enabled: { | |
150 | description: "Whether the repository is enabled or not.", | |
151 | type: Boolean, | |
152 | }, | |
153 | }, | |
154 | )] | |
155 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
156 | #[serde(rename_all = "PascalCase")] | |
157 | /// Describes an APT repository. | |
158 | pub struct APTRepository { | |
159 | /// List of package types. | |
160 | #[serde(skip_serializing_if = "Vec::is_empty")] | |
161 | pub types: Vec<APTRepositoryPackageType>, | |
162 | ||
163 | /// List of repository URIs. | |
164 | #[serde(skip_serializing_if = "Vec::is_empty")] | |
165 | #[serde(rename = "URIs")] | |
166 | pub uris: Vec<String>, | |
167 | ||
168 | /// List of package distributions. | |
169 | #[serde(skip_serializing_if = "Vec::is_empty")] | |
170 | pub suites: Vec<String>, | |
171 | ||
172 | /// List of repository components. | |
173 | #[serde(skip_serializing_if = "Vec::is_empty")] | |
174 | pub components: Vec<String>, | |
175 | ||
176 | /// Additional options. | |
177 | #[serde(skip_serializing_if = "Vec::is_empty")] | |
178 | pub options: Vec<APTRepositoryOption>, | |
179 | ||
180 | /// Associated comment. | |
181 | #[serde(skip_serializing_if = "String::is_empty")] | |
182 | pub comment: String, | |
183 | ||
184 | /// Format of the defining file. | |
185 | pub file_type: APTRepositoryFileType, | |
186 | ||
187 | /// Whether the repository is enabled or not. | |
188 | pub enabled: bool, | |
189 | } | |
190 | ||
191 | impl APTRepository { | |
192 | /// Crates an empty repository. | |
193 | pub fn new(file_type: APTRepositoryFileType) -> Self { | |
194 | Self { | |
195 | types: vec![], | |
196 | uris: vec![], | |
197 | suites: vec![], | |
198 | components: vec![], | |
199 | options: vec![], | |
200 | comment: String::new(), | |
201 | file_type, | |
202 | enabled: true, | |
203 | } | |
204 | } | |
205 | ||
206 | /// Changes the `enabled` flag and makes sure the `Enabled` option for | |
207 | /// `APTRepositoryPackageType::Sources` repositories is updated too. | |
208 | pub fn set_enabled(&mut self, enabled: bool) { | |
209 | self.enabled = enabled; | |
210 | ||
211 | if self.file_type == APTRepositoryFileType::Sources { | |
212 | let enabled_string = match enabled { | |
213 | true => "true".to_string(), | |
214 | false => "false".to_string(), | |
215 | }; | |
216 | for option in self.options.iter_mut() { | |
217 | if option.key == "Enabled" { | |
218 | option.values = vec![enabled_string]; | |
219 | return; | |
220 | } | |
221 | } | |
222 | self.options.push(APTRepositoryOption { | |
223 | key: "Enabled".to_string(), | |
224 | values: vec![enabled_string], | |
225 | }); | |
226 | } | |
227 | } | |
228 | ||
229 | /// Makes sure that all basic properties of a repository are present and | |
230 | /// not obviously invalid. | |
231 | pub fn basic_check(&self) -> Result<(), Error> { | |
232 | if self.types.is_empty() { | |
233 | bail!("missing package type(s)"); | |
234 | } | |
235 | if self.uris.is_empty() { | |
236 | bail!("missing URI(s)"); | |
237 | } | |
238 | if self.suites.is_empty() { | |
239 | bail!("missing suite(s)"); | |
240 | } | |
241 | ||
242 | for uri in self.uris.iter() { | |
243 | if !uri.contains(':') || uri.len() < 3 { | |
244 | bail!("invalid URI: '{}'", uri); | |
245 | } | |
246 | } | |
247 | ||
248 | for suite in self.suites.iter() { | |
249 | if !suite.ends_with('/') && self.components.is_empty() { | |
250 | bail!("missing component(s)"); | |
251 | } else if suite.ends_with('/') && !self.components.is_empty() { | |
252 | bail!("absolute suite '{}' does not allow component(s)", suite); | |
253 | } | |
254 | } | |
255 | ||
256 | if self.file_type == APTRepositoryFileType::List { | |
257 | if self.types.len() > 1 { | |
258 | bail!("more than one package type"); | |
259 | } | |
260 | if self.uris.len() > 1 { | |
261 | bail!("more than one URI"); | |
262 | } | |
263 | if self.suites.len() > 1 { | |
264 | bail!("more than one suite"); | |
265 | } | |
266 | } | |
267 | ||
268 | Ok(()) | |
269 | } | |
270 | ||
8ada1785 | 271 | /// Checks if the repository is the one referenced by the handle. |
f48c12b0 FE |
272 | pub fn is_referenced_repository( |
273 | &self, | |
274 | handle: APTRepositoryHandle, | |
275 | product: &str, | |
276 | suite: &str, | |
277 | ) -> bool { | |
2a1fb9bf FE |
278 | let (package_type, handle_uris, component) = handle.info(product); |
279 | ||
280 | let mut found_uri = false; | |
281 | ||
282 | for uri in self.uris.iter() { | |
283 | let uri = uri.trim_end_matches('/'); | |
284 | ||
285 | found_uri = found_uri || handle_uris.iter().any(|handle_uri| handle_uri == uri); | |
286 | } | |
287 | ||
f48c12b0 FE |
288 | self.types.contains(&package_type) |
289 | && found_uri | |
290 | // using contains would require a &String | |
291 | && self.suites.iter().any(|self_suite| self_suite == suite) | |
292 | && self.components.contains(&component) | |
8ada1785 FE |
293 | } |
294 | ||
87ea23ec FE |
295 | /// Guess the origin from the repository's URIs. |
296 | /// | |
297 | /// Intended to be used as a fallback for get_cached_origin. | |
298 | pub fn origin_from_uris(&self) -> Option<String> { | |
76d3a5ba FE |
299 | for uri in self.uris.iter() { |
300 | if let Some(host) = host_from_uri(uri) { | |
87ea23ec FE |
301 | if host == "proxmox.com" || host.ends_with(".proxmox.com") { |
302 | return Some("Proxmox".to_string()); | |
303 | } | |
304 | ||
305 | if host == "debian.org" || host.ends_with(".debian.org") { | |
306 | return Some("Debian".to_string()); | |
76d3a5ba FE |
307 | } |
308 | } | |
309 | } | |
310 | ||
87ea23ec | 311 | None |
76d3a5ba FE |
312 | } |
313 | ||
bb0ff2ac FE |
314 | /// Get the `Origin:` value from a cached InRelease file. |
315 | pub fn get_cached_origin(&self) -> Result<Option<String>, Error> { | |
316 | for uri in self.uris.iter() { | |
317 | for suite in self.suites.iter() { | |
318 | let file = in_release_filename(uri, suite); | |
319 | ||
320 | if !file.exists() { | |
321 | continue; | |
322 | } | |
323 | ||
324 | let raw = std::fs::read(&file) | |
325 | .map_err(|err| format_err!("unable to read {:?} - {}", file, err))?; | |
326 | let reader = BufReader::new(&*raw); | |
327 | ||
328 | for line in reader.lines() { | |
329 | let line = | |
330 | line.map_err(|err| format_err!("unable to read {:?} - {}", file, err))?; | |
331 | ||
332 | if let Some(value) = line.strip_prefix("Origin:") { | |
333 | return Ok(Some( | |
334 | value | |
335 | .trim_matches(|c| char::is_ascii_whitespace(&c)) | |
336 | .to_string(), | |
337 | )); | |
338 | } | |
339 | } | |
340 | } | |
341 | } | |
342 | ||
343 | Ok(None) | |
344 | } | |
345 | ||
b6be0f39 FE |
346 | /// Writes a repository in the corresponding format followed by a blank. |
347 | /// | |
348 | /// Expects that `basic_check()` for the repository was successful. | |
349 | pub fn write(&self, w: &mut dyn Write) -> Result<(), Error> { | |
350 | match self.file_type { | |
351 | APTRepositoryFileType::List => write_one_line(self, w), | |
352 | APTRepositoryFileType::Sources => write_stanza(self, w), | |
353 | } | |
354 | } | |
355 | } | |
356 | ||
bb0ff2ac FE |
357 | /// Get the path to the cached InRelease file. |
358 | fn in_release_filename(uri: &str, suite: &str) -> PathBuf { | |
359 | let mut path = PathBuf::from(&crate::config::get().dir_state); | |
360 | path.push(&crate::config::get().dir_state_lists); | |
361 | ||
362 | let filename = uri_to_filename(uri); | |
363 | ||
364 | path.push(format!( | |
365 | "{}_dists_{}_InRelease", | |
366 | filename, | |
367 | suite.replace('/', "_"), // e.g. for buster/updates | |
368 | )); | |
369 | ||
370 | path | |
371 | } | |
372 | ||
373 | /// See APT's URItoFileName in contrib/strutl.cc | |
374 | fn uri_to_filename(uri: &str) -> String { | |
375 | let mut filename = uri; | |
376 | ||
377 | if let Some(begin) = filename.find("://") { | |
378 | filename = &filename[(begin + 3)..]; | |
379 | } | |
380 | ||
381 | if uri.starts_with("http://") || uri.starts_with("https://") { | |
382 | if let Some(begin) = filename.find('@') { | |
383 | filename = &filename[(begin + 1)..]; | |
384 | } | |
385 | } | |
386 | ||
387 | // APT seems to only strip one final slash, so do the same | |
388 | filename = filename.strip_suffix('/').unwrap_or(filename); | |
389 | ||
390 | let encode_chars = "\\|{}[]<>\"^~_=!@#$%^&*"; | |
391 | ||
392 | let mut encoded = String::with_capacity(filename.len()); | |
393 | ||
394 | for b in filename.as_bytes().iter() { | |
395 | if *b <= 0x20 || *b >= 0x7F || encode_chars.contains(*b as char) { | |
c7b17de1 WB |
396 | let mut hex = [0u8; 2]; |
397 | // unwrap: we're hex-encoding a single byte into a 2-byte slice | |
398 | hex::encode_to_slice(&[*b], &mut hex).unwrap(); | |
399 | let hex = unsafe { std::str::from_utf8_unchecked(&hex) }; | |
bb0ff2ac FE |
400 | encoded = format!("{}%{}", encoded, hex); |
401 | } else { | |
402 | encoded.push(*b as char); | |
403 | } | |
404 | } | |
405 | ||
406 | encoded.replace('/', "_") | |
407 | } | |
408 | ||
76d3a5ba FE |
409 | /// Get the host part from a given URI. |
410 | fn host_from_uri(uri: &str) -> Option<&str> { | |
411 | let host = uri.strip_prefix("http")?; | |
4bdd6a51 | 412 | let host = host.strip_prefix('s').unwrap_or(host); |
76d3a5ba FE |
413 | let mut host = host.strip_prefix("://")?; |
414 | ||
415 | if let Some(end) = host.find('/') { | |
416 | host = &host[..end]; | |
417 | } | |
418 | ||
419 | if let Some(begin) = host.find('@') { | |
420 | host = &host[(begin + 1)..]; | |
421 | } | |
422 | ||
423 | if let Some(end) = host.find(':') { | |
424 | host = &host[..end]; | |
425 | } | |
426 | ||
427 | Some(host) | |
428 | } | |
429 | ||
ae7e2360 FE |
430 | /// Strips existing double quotes from the string first, and then adds double quotes at |
431 | /// the beginning and end if there is an ASCII whitespace in the `string`, which is not | |
432 | /// escaped by `[]`. | |
433 | fn quote_for_one_line(string: &str) -> String { | |
434 | let mut add_quotes = false; | |
435 | let mut wait_for_bracket = false; | |
436 | ||
437 | // easier to just quote the whole string, so ignore pre-existing quotes | |
438 | // currently, parsing removes them anyways, but being on the safe side is rather cheap | |
439 | let string = string.replace('"', ""); | |
440 | ||
441 | for c in string.chars() { | |
442 | if wait_for_bracket { | |
443 | if c == ']' { | |
444 | wait_for_bracket = false; | |
445 | } | |
446 | continue; | |
447 | } | |
448 | ||
449 | if char::is_ascii_whitespace(&c) { | |
450 | add_quotes = true; | |
451 | break; | |
452 | } | |
453 | ||
454 | if c == '[' { | |
455 | wait_for_bracket = true; | |
456 | } | |
457 | } | |
458 | ||
459 | match add_quotes { | |
460 | true => format!("\"{}\"", string), | |
461 | false => string, | |
462 | } | |
463 | } | |
464 | ||
b6be0f39 FE |
465 | /// Writes a repository in one-line format followed by a blank line. |
466 | /// | |
467 | /// Expects that `repo.file_type == APTRepositoryFileType::List`. | |
468 | /// | |
469 | /// Expects that `basic_check()` for the repository was successful. | |
470 | fn write_one_line(repo: &APTRepository, w: &mut dyn Write) -> Result<(), Error> { | |
471 | if repo.file_type != APTRepositoryFileType::List { | |
472 | bail!("not a .list repository"); | |
473 | } | |
474 | ||
475 | if !repo.comment.is_empty() { | |
476 | for line in repo.comment.lines() { | |
477 | writeln!(w, "#{}", line)?; | |
478 | } | |
479 | } | |
480 | ||
481 | if !repo.enabled { | |
482 | write!(w, "# ")?; | |
483 | } | |
484 | ||
485 | write!(w, "{} ", repo.types[0])?; | |
486 | ||
487 | if !repo.options.is_empty() { | |
488 | write!(w, "[ ")?; | |
ae7e2360 FE |
489 | |
490 | for option in repo.options.iter() { | |
491 | let option = quote_for_one_line(&format!("{}={}", option.key, option.values.join(","))); | |
492 | write!(w, "{} ", option)?; | |
493 | } | |
494 | ||
b6be0f39 FE |
495 | write!(w, "] ")?; |
496 | }; | |
497 | ||
ae7e2360 FE |
498 | write!(w, "{} ", quote_for_one_line(&repo.uris[0]))?; |
499 | write!(w, "{} ", quote_for_one_line(&repo.suites[0]))?; | |
500 | writeln!( | |
501 | w, | |
502 | "{}", | |
503 | repo.components | |
504 | .iter() | |
505 | .map(|comp| quote_for_one_line(comp)) | |
506 | .collect::<Vec<String>>() | |
507 | .join(" ") | |
508 | )?; | |
b6be0f39 FE |
509 | |
510 | writeln!(w)?; | |
511 | ||
512 | Ok(()) | |
513 | } | |
514 | ||
515 | /// Writes a single stanza followed by a blank line. | |
516 | /// | |
517 | /// Expects that `repo.file_type == APTRepositoryFileType::Sources`. | |
518 | fn write_stanza(repo: &APTRepository, w: &mut dyn Write) -> Result<(), Error> { | |
519 | if repo.file_type != APTRepositoryFileType::Sources { | |
520 | bail!("not a .sources repository"); | |
521 | } | |
522 | ||
523 | if !repo.comment.is_empty() { | |
524 | for line in repo.comment.lines() { | |
525 | writeln!(w, "#{}", line)?; | |
526 | } | |
527 | } | |
528 | ||
529 | write!(w, "Types:")?; | |
530 | repo.types | |
531 | .iter() | |
532 | .try_for_each(|package_type| write!(w, " {}", package_type))?; | |
533 | writeln!(w)?; | |
534 | ||
535 | writeln!(w, "URIs: {}", repo.uris.join(" "))?; | |
536 | writeln!(w, "Suites: {}", repo.suites.join(" "))?; | |
537 | ||
538 | if !repo.components.is_empty() { | |
539 | writeln!(w, "Components: {}", repo.components.join(" "))?; | |
540 | } | |
541 | ||
542 | for option in repo.options.iter() { | |
543 | writeln!(w, "{}: {}", option.key, option.values.join(" "))?; | |
544 | } | |
545 | ||
546 | writeln!(w)?; | |
547 | ||
548 | Ok(()) | |
549 | } | |
bb0ff2ac FE |
550 | |
551 | #[test] | |
552 | fn test_uri_to_filename() { | |
553 | let filename = uri_to_filename("https://some_host/some/path"); | |
554 | assert_eq!(filename, "some%5fhost_some_path".to_string()); | |
555 | } |