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