]> git.proxmox.com Git - proxmox-apt.git/blame - src/deb822/release_file.rs
cleanup non-closure parse helpers
[proxmox-apt.git] / src / deb822 / release_file.rs
CommitLineData
8cdd2311
FG
1use std::collections::HashMap;
2
3use anyhow::{bail, format_err, Error};
4use rfc822_like::de::Deserializer;
5use serde::Deserialize;
6use serde_json::Value;
7
8use super::CheckSums;
9
10#[derive(Debug, Deserialize)]
11#[serde(rename_all = "PascalCase")]
12pub struct ReleaseFileRaw {
13 pub architectures: Option<String>,
14 pub changelogs: Option<String>,
15 pub codename: Option<String>,
16 pub components: Option<String>,
17 pub date: Option<String>,
18 pub description: Option<String>,
19 pub label: Option<String>,
20 pub origin: Option<String>,
21 pub suite: Option<String>,
22 pub version: Option<String>,
23
24 #[serde(rename = "MD5Sum")]
25 pub md5_sum: Option<String>,
26 #[serde(rename = "SHA1")]
27 pub sha1: Option<String>,
28 #[serde(rename = "SHA256")]
29 pub sha256: Option<String>,
30 #[serde(rename = "SHA512")]
31 pub sha512: Option<String>,
32
33 #[serde(flatten)]
34 pub extra_fields: HashMap<String, Value>,
35}
36
37#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
38pub enum CompressionType {
39 Bzip2,
40 Gzip,
41 Lzma,
42 Xz,
43}
44
45pub type Architecture = String;
46pub type Component = String;
47
48#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
49/// Type of file reference extraced from path.
50///
51/// `Packages` and `Sources` will contain further reference to binary or source package files.
52/// These are handled in `PackagesFile` and `SourcesFile` respectively.
53pub enum FileReferenceType {
54 /// A `Contents` index listing contents of binary packages
55 Contents(Architecture, Option<CompressionType>),
56 /// A `Contents` index listing contents of binary udeb packages
57 ContentsUdeb(Architecture, Option<CompressionType>),
58 /// A DEP11 `Components` metadata file or `icons` archive
59 Dep11(Option<CompressionType>),
60 /// Referenced files which are not really part of the APT repository but only signed for trust-anchor reasons
61 Ignored,
62 /// PDiff indices
63 PDiff,
64 /// A `Packages` index listing binary package metadata and references
65 Packages(Architecture, Option<CompressionType>),
66 /// A compat `Release` file with no relevant content
67 PseudoRelease(Option<Architecture>),
68 /// A `Sources` index listing source package metadata and references
69 Sources(Option<CompressionType>),
70 /// A `Translation` file
71 Translation(Option<CompressionType>),
72 /// Unknown file reference
73 Unknown,
74}
75
76impl FileReferenceType {
77 fn match_compression(value: &str) -> Result<Option<CompressionType>, Error> {
78 if value.is_empty() {
79 return Ok(None);
80 }
81
82 let value = if let Some(stripped) = value.strip_prefix('.') {
83 stripped
84 } else {
85 value
86 };
87
88 match value {
89 "bz2" => Ok(Some(CompressionType::Bzip2)),
90 "gz" => Ok(Some(CompressionType::Gzip)),
91 "lzma" => Ok(Some(CompressionType::Lzma)),
92 "xz" => Ok(Some(CompressionType::Xz)),
93 other => bail!("Unexpected file extension '{other}'."),
94 }
95 }
96 pub fn parse(component: &str, path: &str) -> Result<FileReferenceType, Error> {
97 // everything referenced in a release file should be component-specific
98 let rest = path
99 .strip_prefix(&format!("{component}/"))
100 .ok_or_else(|| format_err!("Doesn't start with component '{component}'"))?;
101
0e845221
WB
102 let parse_binary_dir =
103 |file_name: &str, arch: &str| parse_binary_dir(file_name, arch, path);
8cdd2311 104
72dc88fb 105 if let Some((dir, rest)) = rest.split_once('/') {
8cdd2311
FG
106 // reference into another subdir
107 match dir {
108 "source" => {
109 // Sources or compat-Release
72dc88fb 110 if let Some((dir, _rest)) = rest.split_once('/') {
8cdd2311
FG
111 if dir == "Sources.diff" {
112 Ok(FileReferenceType::PDiff)
113 } else {
114 Ok(FileReferenceType::Unknown)
115 }
116 } else if rest == "Release" {
117 Ok(FileReferenceType::PseudoRelease(None))
118 } else if let Some(ext) = rest.strip_prefix("Sources") {
119 let comp = FileReferenceType::match_compression(ext)?;
120 Ok(FileReferenceType::Sources(comp))
121 } else {
122 Ok(FileReferenceType::Unknown)
123 }
124 }
125 "dep11" => {
126 if let Some((_path, ext)) = rest.rsplit_once('.') {
127 Ok(FileReferenceType::Dep11(
128 FileReferenceType::match_compression(ext).ok().flatten(),
129 ))
130 } else {
131 Ok(FileReferenceType::Dep11(None))
132 }
133 }
134 "debian-installer" => {
135 // another layer, then like regular repo but pointing at udebs
72dc88fb 136 if let Some((dir, rest)) = rest.split_once('/') {
8cdd2311
FG
137 if let Some(arch) = dir.strip_prefix("binary-") {
138 // Packages or compat-Release
139 return parse_binary_dir(rest, arch);
140 }
141 }
142
143 // all the rest
144 Ok(FileReferenceType::Unknown)
145 }
146 "i18n" => {
72dc88fb 147 if let Some((dir, _rest)) = rest.split_once('/') {
8cdd2311
FG
148 if dir.starts_with("Translation") && dir.ends_with(".diff") {
149 Ok(FileReferenceType::PDiff)
150 } else {
151 Ok(FileReferenceType::Unknown)
152 }
153 } else if let Some((_, ext)) = rest.split_once('.') {
154 Ok(FileReferenceType::Translation(
155 FileReferenceType::match_compression(ext)?,
156 ))
157 } else {
158 Ok(FileReferenceType::Translation(None))
159 }
160 }
161 _ => {
162 if let Some(arch) = dir.strip_prefix("binary-") {
163 // Packages or compat-Release
164 parse_binary_dir(rest, arch)
165 } else if let Some(_arch) = dir.strip_prefix("installer-") {
166 // netboot installer checksum files
167 Ok(FileReferenceType::Ignored)
168 } else {
169 // all the rest
170 Ok(FileReferenceType::Unknown)
171 }
172 }
173 }
174 } else if let Some(rest) = rest.strip_prefix("Contents-") {
175 // reference to a top-level file - Contents-*
176 let (rest, udeb) = if let Some(rest) = rest.strip_prefix("udeb-") {
177 (rest, true)
178 } else {
179 (rest, false)
180 };
72dc88fb 181 let (arch, comp) = match rest.split_once('.') {
8cdd2311
FG
182 Some((arch, comp_str)) => (
183 arch.to_owned(),
184 FileReferenceType::match_compression(comp_str)?,
185 ),
186 None => (rest.to_owned(), None),
187 };
188 if udeb {
189 Ok(FileReferenceType::ContentsUdeb(arch, comp))
190 } else {
191 Ok(FileReferenceType::Contents(arch, comp))
192 }
193 } else {
194 Ok(FileReferenceType::Unknown)
195 }
196 }
197
198 pub fn compression(&self) -> Option<CompressionType> {
199 match *self {
200 FileReferenceType::Contents(_, comp)
201 | FileReferenceType::ContentsUdeb(_, comp)
202 | FileReferenceType::Packages(_, comp)
203 | FileReferenceType::Sources(comp)
204 | FileReferenceType::Translation(comp)
205 | FileReferenceType::Dep11(comp) => comp,
206 FileReferenceType::Unknown
207 | FileReferenceType::PDiff
208 | FileReferenceType::PseudoRelease(_)
209 | FileReferenceType::Ignored => None,
210 }
211 }
212
213 pub fn is_package_index(&self) -> bool {
72dc88fb 214 matches!(self, FileReferenceType::Packages(_, _))
8cdd2311
FG
215 }
216}
217
218#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
219pub struct FileReference {
220 pub path: String,
221 pub size: usize,
222 pub checksums: CheckSums,
223 pub component: Component,
224 pub file_type: FileReferenceType,
225}
226
227impl FileReference {
228 pub fn basename(&self) -> Result<String, Error> {
229 match self.file_type.compression() {
230 Some(_) => {
231 let (base, _ext) = self
232 .path
72dc88fb 233 .rsplit_once('.')
8cdd2311
FG
234 .ok_or_else(|| format_err!("compressed file without file extension"))?;
235 Ok(base.to_owned())
236 }
237 None => Ok(self.path.clone()),
238 }
239 }
240}
241
242#[derive(Debug, Default, PartialEq, Eq)]
243/// A parsed representation of a Release file
244pub struct ReleaseFile {
245 /// List of architectures, e.g., `amd64` or `all`.
246 pub architectures: Vec<String>,
247 // TODO No-Support-for-Architecture-all
248 /// URL for changelog queries via `apt changelog`.
249 pub changelogs: Option<String>,
250 /// Release codename - single word, e.g., `bullseye`.
251 pub codename: Option<String>,
252 /// List of repository areas, e.g., `main`.
253 pub components: Vec<String>,
254 /// UTC timestamp of release file generation
255 pub date: Option<u64>,
256 /// UTC timestamp of release file expiration
257 pub valid_until: Option<u64>,
258 /// Repository description -
259 // TODO exact format?
260 pub description: Option<String>,
261 /// Repository label - single line
262 pub label: Option<String>,
263 /// Repository origin - single line
264 pub origin: Option<String>,
265 /// Release suite - single word, e.g., `stable`.
266 pub suite: Option<String>,
267 /// Release version
268 pub version: Option<String>,
269
270 /// Whether by-hash retrieval of referenced files is possible
271 pub aquire_by_hash: bool,
272
273 /// Files referenced by this `Release` file, e.g., packages indices.
274 ///
275 /// Grouped by basename, since only the compressed version needs to actually exist on the repository server.
276 pub files: HashMap<String, Vec<FileReference>>,
277}
278
279impl TryFrom<ReleaseFileRaw> for ReleaseFile {
280 type Error = Error;
281
282 fn try_from(value: ReleaseFileRaw) -> Result<Self, Self::Error> {
283 let mut parsed = ReleaseFile::default();
284
0e845221
WB
285 parsed.architectures = whitespace_split_to_vec(
286 &value
8cdd2311
FG
287 .architectures
288 .ok_or_else(|| format_err!("'Architectures' field missing."))?,
289 );
0e845221
WB
290 parsed.components = whitespace_split_to_vec(
291 &value
8cdd2311
FG
292 .components
293 .ok_or_else(|| format_err!("'Components' field missing."))?,
294 );
295
296 parsed.changelogs = value.changelogs;
297 parsed.codename = value.codename;
298
0e845221 299 parsed.date = value.date.as_deref().map(parse_date);
8cdd2311
FG
300 parsed.valid_until = value
301 .extra_fields
302 .get("Valid-Until")
0e845221 303 .map(|val| parse_date(&val.to_string()));
8cdd2311
FG
304
305 parsed.description = value.description;
306 parsed.label = value.label;
307 parsed.origin = value.origin;
308 parsed.suite = value.suite;
309 parsed.version = value.version;
310
311 parsed.aquire_by_hash = match value.extra_fields.get("Aquire-By-Hash") {
312 Some(val) => *val == "yes",
313 None => false,
314 };
315
316 // Fixup bullseye-security release files which have invalid components
317 if parsed.label.as_deref() == Some("Debian-Security")
318 && parsed.codename.as_deref() == Some("bullseye-security")
319 {
320 parsed.components = parsed
321 .components
322 .into_iter()
323 .map(|comp| {
324 if let Some(stripped) = comp.strip_prefix("updates/") {
325 stripped.to_owned()
326 } else {
327 comp
328 }
329 })
330 .collect();
331 }
332
333 let mut references_map: HashMap<String, HashMap<String, FileReference>> = HashMap::new();
334
8cdd2311
FG
335 if let Some(md5) = value.md5_sum {
336 for line in md5.lines() {
337 let (mut file_ref, checksum) =
338 parse_file_reference(line, 16, parsed.components.as_ref())?;
339
340 let checksum = checksum
341 .try_into()
342 .map_err(|_err| format_err!("unexpected checksum length"))?;
343
344 file_ref.checksums.md5 = Some(checksum);
345
346 merge_references(&mut references_map, file_ref)?;
347 }
348 }
349
350 if let Some(sha1) = value.sha1 {
351 for line in sha1.lines() {
352 let (mut file_ref, checksum) =
353 parse_file_reference(line, 20, parsed.components.as_ref())?;
354 let checksum = checksum
355 .try_into()
356 .map_err(|_err| format_err!("unexpected checksum length"))?;
357
358 file_ref.checksums.sha1 = Some(checksum);
359 merge_references(&mut references_map, file_ref)?;
360 }
361 }
362
363 if let Some(sha256) = value.sha256 {
364 for line in sha256.lines() {
365 let (mut file_ref, checksum) =
366 parse_file_reference(line, 32, parsed.components.as_ref())?;
367 let checksum = checksum
368 .try_into()
369 .map_err(|_err| format_err!("unexpected checksum length"))?;
370
371 file_ref.checksums.sha256 = Some(checksum);
372 merge_references(&mut references_map, file_ref)?;
373 }
374 }
375
376 if let Some(sha512) = value.sha512 {
377 for line in sha512.lines() {
378 let (mut file_ref, checksum) =
379 parse_file_reference(line, 64, parsed.components.as_ref())?;
380 let checksum = checksum
381 .try_into()
382 .map_err(|_err| format_err!("unexpected checksum length"))?;
383
384 file_ref.checksums.sha512 = Some(checksum);
385 merge_references(&mut references_map, file_ref)?;
386 }
387 }
388
389 parsed.files =
390 references_map
391 .into_iter()
392 .fold(HashMap::new(), |mut map, (base, inner_map)| {
393 map.insert(base, inner_map.into_values().collect());
394 map
395 });
396
397 if let Some(insecure) = parsed
398 .files
399 .values()
400 .flatten()
401 .find(|file| !file.checksums.is_secure())
402 {
403 bail!(
404 "found file reference without strong checksum: {}",
405 insecure.path
406 );
407 }
408
409 Ok(parsed)
410 }
411}
412
413impl TryFrom<String> for ReleaseFile {
414 type Error = Error;
415
416 fn try_from(value: String) -> Result<Self, Self::Error> {
417 value.as_bytes().try_into()
418 }
419}
420
421impl TryFrom<&[u8]> for ReleaseFile {
422 type Error = Error;
423
424 fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
425 let deserialized = ReleaseFileRaw::deserialize(Deserializer::new(value))?;
426 deserialized.try_into()
427 }
428}
429
0e845221
WB
430fn whitespace_split_to_vec(list_str: &str) -> Vec<String> {
431 list_str
432 .split_ascii_whitespace()
433 .map(|arch| arch.to_owned())
434 .collect()
435}
436
437fn parse_file_reference(
438 line: &str,
439 csum_len: usize,
440 components: &[String],
441) -> Result<(FileReference, Vec<u8>), Error> {
442 let mut split = line.split_ascii_whitespace();
443 let checksum = split.next().ok_or_else(|| format_err!("bla"))?;
444 if checksum.len() > csum_len * 2 {
445 bail!(
446 "invalid checksum length: '{}', expected {} bytes",
447 checksum,
448 csum_len
449 );
450 }
451
452 let checksum = hex::decode(checksum)?;
453
454 let size = split
455 .next()
456 .ok_or_else(|| format_err!("No 'size' field in file reference line."))?
457 .parse::<usize>()?;
458
459 let file = split
460 .next()
461 .ok_or_else(|| format_err!("No 'path' field in file reference line."))?
462 .to_string();
463
464 let (component, file_type) = components
465 .iter()
466 .find_map(|component| {
467 if !file.starts_with(&format!("{component}/")) {
468 return None;
469 }
470
471 Some(
472 FileReferenceType::parse(component, &file)
473 .map(|file_type| (component.clone(), file_type)),
474 )
475 })
476 .unwrap_or_else(|| Ok(("UNKNOWN".to_string(), FileReferenceType::Unknown)))?;
477
478 Ok((
479 FileReference {
480 path: file,
481 size,
482 checksums: CheckSums::default(),
483 component,
484 file_type,
485 },
486 checksum,
487 ))
488}
489
490fn parse_date(_date_str: &str) -> u64 {
491 // TODO implement
492 0
493}
494
495fn parse_binary_dir(file_name: &str, arch: &str, path: &str) -> Result<FileReferenceType, Error> {
496 if let Some((dir, _rest)) = file_name.split_once('/') {
497 if dir == "Packages.diff" {
498 // TODO re-evaluate?
499 Ok(FileReferenceType::PDiff)
500 } else {
501 Ok(FileReferenceType::Unknown)
502 }
503 } else if file_name == "Release" {
504 Ok(FileReferenceType::PseudoRelease(Some(arch.to_owned())))
505 } else {
506 let comp = match file_name.strip_prefix("Packages") {
507 None => {
508 bail!("found unexpected non-Packages reference to '{path}'")
509 }
510 Some(ext) => FileReferenceType::match_compression(ext)?,
511 };
512 //println!("compression: {comp:?}");
513 Ok(FileReferenceType::Packages(arch.to_owned(), comp))
514 }
515}
516
517fn merge_references(
518 base_map: &mut HashMap<String, HashMap<String, FileReference>>,
519 file_ref: FileReference,
520) -> Result<(), Error> {
521 let base = file_ref.basename()?;
522
523 match base_map.get_mut(&base) {
524 None => {
525 let mut map = HashMap::new();
526 map.insert(file_ref.path.clone(), file_ref);
527 base_map.insert(base, map);
528 }
529 Some(entries) => {
530 match entries.get_mut(&file_ref.path) {
531 Some(entry) => {
532 if entry.size != file_ref.size {
533 bail!(
534 "Multiple entries for '{}' with size mismatch: {} / {}",
535 entry.path,
536 file_ref.size,
537 entry.size
538 );
539 }
540
541 entry.checksums.merge(&file_ref.checksums).map_err(|err| {
542 format_err!("Multiple checksums for '{}' - {err}", entry.path)
543 })?;
544 }
545 None => {
546 entries.insert(file_ref.path.clone(), file_ref);
547 }
548 };
549 }
550 };
551
552 Ok(())
553}
554
8cdd2311
FG
555#[test]
556pub fn test_deb_release_file() {
557 let input = include_str!(concat!(
558 env!("CARGO_MANIFEST_DIR"),
559 "/tests/deb822/release/deb.debian.org_debian_dists_bullseye_Release"
560 ));
561
562 let deserialized = ReleaseFileRaw::deserialize(Deserializer::new(input.as_bytes())).unwrap();
563 //println!("{:?}", deserialized);
564
565 let parsed: ReleaseFile = deserialized.try_into().unwrap();
566 //println!("{:?}", parsed);
567
568 assert_eq!(parsed.files.len(), 315);
569}
570
571#[test]
572pub fn test_deb_release_file_insecure() {
573 let input = include_str!(concat!(
574 env!("CARGO_MANIFEST_DIR"),
575 "/tests/deb822/release/deb.debian.org_debian_dists_bullseye_Release_insecure"
576 ));
577
578 let deserialized = ReleaseFileRaw::deserialize(Deserializer::new(input.as_bytes())).unwrap();
579 //println!("{:?}", deserialized);
580
581 let parsed: Result<ReleaseFile, Error> = deserialized.try_into();
582 assert!(parsed.is_err());
583
584 println!("{:?}", parsed);
585}