]> git.proxmox.com Git - proxmox-apt.git/blob - src/repositories/repository.rs
bump version to 0.9.3-1
[proxmox-apt.git] / src / repositories / repository.rs
1 use std::fmt::Display;
2 use std::io::{BufRead, BufReader, Write};
3 use std::path::PathBuf;
4
5 use anyhow::{bail, format_err, Error};
6 use serde::{Deserialize, Serialize};
7
8 use proxmox_schema::api;
9
10 use crate::repositories::standard::APTRepositoryHandle;
11
12 #[api]
13 #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)]
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]
44 #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)]
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
271 /// Checks if the repository is the one referenced by the handle.
272 pub fn is_referenced_repository(
273 &self,
274 handle: APTRepositoryHandle,
275 product: &str,
276 suite: &str,
277 ) -> bool {
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
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)
293 }
294
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> {
299 for uri in self.uris.iter() {
300 if let Some(host) = host_from_uri(uri) {
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());
307 }
308 }
309 }
310
311 None
312 }
313
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
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
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) {
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) };
400 encoded = format!("{}%{}", encoded, hex);
401 } else {
402 encoded.push(*b as char);
403 }
404 }
405
406 encoded.replace('/', "_")
407 }
408
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")?;
412 let host = host.strip_prefix('s').unwrap_or(host);
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
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
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, "[ ")?;
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
495 write!(w, "] ")?;
496 };
497
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 )?;
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 }
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 }