1use std::{
6 fmt::{Display, Formatter, Result as FmtResult, Write},
7 str::FromStr,
8};
9
10use alpm_types::{
11 Architecture,
12 Base64OpenPGPSignature,
13 BuildDate,
14 CompressedSize,
15 FullVersion,
16 Group,
17 InstalledSize,
18 License,
19 Md5Checksum,
20 Name,
21 OptionalDependency,
22 PackageBaseName,
23 PackageDescription,
24 PackageFileName,
25 PackageRelation,
26 Packager,
27 RelationOrSoname,
28 Sha256Checksum,
29 Url,
30};
31use winnow::Parser;
32
33use crate::{
34 Error,
35 desc::{
36 Section,
37 parser::{SectionKeyword, sections},
38 },
39};
40
41#[derive(Clone, Debug, serde::Deserialize, PartialEq, serde::Serialize)]
114#[serde(deny_unknown_fields)]
115#[serde(rename_all = "lowercase")]
116pub struct RepoDescFileV1 {
117 pub file_name: PackageFileName,
119
120 pub name: Name,
122
123 pub base: PackageBaseName,
125
126 pub version: FullVersion,
128
129 pub description: PackageDescription,
133
134 pub groups: Vec<Group>,
138
139 pub compressed_size: CompressedSize,
141
142 pub installed_size: InstalledSize,
146
147 pub md5_checksum: Md5Checksum,
149
150 pub sha256_checksum: Sha256Checksum,
152
153 pub pgp_signature: Base64OpenPGPSignature,
155
156 pub url: Option<Url>,
158
159 pub license: Vec<License>,
163
164 pub arch: Architecture,
166
167 pub build_date: BuildDate,
169
170 pub packager: Packager,
172
173 pub replaces: Vec<PackageRelation>,
177
178 pub conflicts: Vec<PackageRelation>,
182
183 pub provides: Vec<RelationOrSoname>,
187
188 pub dependencies: Vec<RelationOrSoname>,
192
193 pub optional_dependencies: Vec<OptionalDependency>,
197
198 pub make_dependencies: Vec<PackageRelation>,
202
203 pub check_dependencies: Vec<PackageRelation>,
207}
208
209impl Display for RepoDescFileV1 {
210 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
211 fn single<T: Display, W: Write>(f: &mut W, key: &str, val: &T) -> FmtResult {
213 writeln!(f, "%{key}%\n{val}\n")
214 }
215
216 fn section<T: Display, W: Write>(f: &mut W, key: &str, vals: &[T]) -> FmtResult {
218 if vals.is_empty() {
219 return Ok(());
220 }
221 writeln!(f, "%{key}%")?;
222 for v in vals {
223 writeln!(f, "{v}")?;
224 }
225 writeln!(f)
226 }
227
228 single(f, "FILENAME", &self.file_name)?;
229 single(f, "NAME", &self.name)?;
230 single(f, "BASE", &self.base)?;
231 single(f, "VERSION", &self.version)?;
232 if !self.description.as_ref().is_empty() {
233 single(f, "DESC", &self.description)?;
234 }
235 section(f, "GROUPS", &self.groups)?;
236 single(f, "CSIZE", &self.compressed_size)?;
237 single(f, "ISIZE", &self.installed_size)?;
238 single(f, "MD5SUM", &self.md5_checksum)?;
239 single(f, "SHA256SUM", &self.sha256_checksum)?;
240 single(f, "PGPSIG", &self.pgp_signature)?;
241 if let Some(url) = &self.url {
242 single(f, "URL", url)?;
243 }
244 section(f, "LICENSE", &self.license)?;
245 single(f, "ARCH", &self.arch)?;
246 single(f, "BUILDDATE", &self.build_date)?;
247 single(f, "PACKAGER", &self.packager)?;
248 section(f, "REPLACES", &self.replaces)?;
249 section(f, "CONFLICTS", &self.conflicts)?;
250 section(f, "PROVIDES", &self.provides)?;
251 section(f, "DEPENDS", &self.dependencies)?;
252 section(f, "OPTDEPENDS", &self.optional_dependencies)?;
253 section(f, "MAKEDEPENDS", &self.make_dependencies)?;
254 section(f, "CHECKDEPENDS", &self.check_dependencies)?;
255 Ok(())
256 }
257}
258
259impl FromStr for RepoDescFileV1 {
260 type Err = Error;
261
262 fn from_str(s: &str) -> Result<Self, Self::Err> {
337 let sections = sections.parse(s)?;
338 Self::try_from(sections)
339 }
340}
341
342impl TryFrom<Vec<Section>> for RepoDescFileV1 {
343 type Error = Error;
344
345 fn try_from(sections: Vec<Section>) -> Result<Self, Self::Error> {
355 let mut file_name = None;
356 let mut name = None;
357 let mut base = None;
358 let mut version = None;
359 let mut description = None;
360 let mut groups: Vec<Group> = Vec::new();
361 let mut compressed_size = None;
362 let mut installed_size = None;
363 let mut md5_checksum = None;
364 let mut sha256_checksum = None;
365 let mut pgp_signature = None;
366 let mut url = None;
367 let mut license: Vec<License> = Vec::new();
368 let mut arch = None;
369 let mut build_date = None;
370 let mut packager = None;
371 let mut replaces: Vec<PackageRelation> = Vec::new();
372 let mut conflicts: Vec<PackageRelation> = Vec::new();
373 let mut provides: Vec<RelationOrSoname> = Vec::new();
374 let mut dependencies: Vec<RelationOrSoname> = Vec::new();
375 let mut optional_dependencies: Vec<OptionalDependency> = Vec::new();
376 let mut make_dependencies: Vec<PackageRelation> = Vec::new();
377 let mut check_dependencies: Vec<PackageRelation> = Vec::new();
378
379 macro_rules! set_once {
381 ($field:ident, $val:expr, $kw:expr) => {{
382 if $field.is_some() {
383 return Err(Error::DuplicateSection($kw));
384 }
385 $field = Some($val);
386 }};
387 }
388
389 macro_rules! set_vec_once {
392 ($field:ident, $val:expr, $kw:expr) => {{
393 if !$field.is_empty() {
394 return Err(Error::DuplicateSection($kw));
395 }
396 if $val.is_empty() {
397 return Err(Error::EmptySection($kw));
398 }
399 $field = $val;
400 }};
401 }
402
403 for section in sections {
404 match section {
405 Section::Filename(val) => set_once!(file_name, val, SectionKeyword::Filename),
406 Section::Name(val) => set_once!(name, val, SectionKeyword::Name),
407 Section::Base(val) => set_once!(base, val, SectionKeyword::Base),
408 Section::Version(val) => set_once!(version, val, SectionKeyword::Version),
409 Section::Desc(val) => set_once!(description, val, SectionKeyword::Desc),
410 Section::Groups(val) => set_vec_once!(groups, val, SectionKeyword::Groups),
411 Section::CSize(val) => set_once!(compressed_size, val, SectionKeyword::CSize),
412 Section::ISize(val) => set_once!(installed_size, val, SectionKeyword::ISize),
413 Section::Md5Sum(val) => set_once!(md5_checksum, val, SectionKeyword::Md5Sum),
414 Section::Sha256Sum(val) => {
415 set_once!(sha256_checksum, val, SectionKeyword::Sha256Sum)
416 }
417 Section::PgpSig(val) => set_once!(pgp_signature, val, SectionKeyword::PgpSig),
418 Section::Url(val) => set_once!(url, val, SectionKeyword::Url),
419 Section::License(val) => set_vec_once!(license, val, SectionKeyword::License),
420 Section::Arch(val) => set_once!(arch, val, SectionKeyword::Arch),
421 Section::BuildDate(val) => set_once!(build_date, val, SectionKeyword::BuildDate),
422 Section::Packager(val) => set_once!(packager, val, SectionKeyword::Packager),
423 Section::Replaces(val) => set_vec_once!(replaces, val, SectionKeyword::Replaces),
424 Section::Conflicts(val) => set_vec_once!(conflicts, val, SectionKeyword::Conflicts),
425 Section::Provides(val) => set_vec_once!(provides, val, SectionKeyword::Provides),
426 Section::Depends(val) => set_vec_once!(dependencies, val, SectionKeyword::Depends),
427 Section::OptDepends(val) => {
428 set_vec_once!(optional_dependencies, val, SectionKeyword::OptDepends)
429 }
430 Section::MakeDepends(val) => {
431 set_vec_once!(make_dependencies, val, SectionKeyword::MakeDepends)
432 }
433 Section::CheckDepends(val) => {
434 set_vec_once!(check_dependencies, val, SectionKeyword::CheckDepends)
435 }
436 }
437 }
438
439 Ok(RepoDescFileV1 {
440 file_name: file_name.ok_or(Error::MissingSection(SectionKeyword::Filename))?,
441 name: name.ok_or(Error::MissingSection(SectionKeyword::Name))?,
442 base: base.ok_or(Error::MissingSection(SectionKeyword::Base))?,
443 version: version.ok_or(Error::MissingSection(SectionKeyword::Version))?,
444 description: description.unwrap_or_default(),
445 groups,
446 compressed_size: compressed_size.ok_or(Error::MissingSection(SectionKeyword::CSize))?,
447 installed_size: installed_size.ok_or(Error::MissingSection(SectionKeyword::ISize))?,
448 md5_checksum: md5_checksum.ok_or(Error::MissingSection(SectionKeyword::Md5Sum))?,
449 sha256_checksum: sha256_checksum
450 .ok_or(Error::MissingSection(SectionKeyword::Sha256Sum))?,
451 pgp_signature: pgp_signature.ok_or(Error::MissingSection(SectionKeyword::PgpSig))?,
452 url: url.unwrap_or(None),
453 license,
454 arch: arch.ok_or(Error::MissingSection(SectionKeyword::Arch))?,
455 build_date: build_date.ok_or(Error::MissingSection(SectionKeyword::BuildDate))?,
456 packager: packager.ok_or(Error::MissingSection(SectionKeyword::Packager))?,
457 replaces,
458 conflicts,
459 provides,
460 dependencies,
461 optional_dependencies,
462 make_dependencies,
463 check_dependencies,
464 })
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use pretty_assertions::assert_eq;
471 use rstest::*;
472 use testresult::TestResult;
473
474 use super::*;
475
476 const VALID_DESC_FILE: &str = r#"%FILENAME%
477example-1.0.0-1-any.pkg.tar.zst
478
479%NAME%
480example
481
482%BASE%
483example
484
485%VERSION%
4861.0.0-1
487
488%DESC%
489An example package
490
491%GROUPS%
492example-group
493other-group
494
495%CSIZE%
4961818463
497
498%ISIZE%
49918184634
500
501%MD5SUM%
502d3b07384d113edec49eaa6238ad5ff00
503
504%SHA256SUM%
505b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c
506
507%PGPSIG%
508iHUEABYKAB0WIQRizHP4hOUpV7L92IObeih9mi7GCAUCaBZuVAAKCRCbeih9mi7GCIlMAP9ws/jU4f580ZRQlTQKvUiLbAZOdcB7mQQj83hD1Nc/GwD/WIHhO1/OQkpMERejUrLo3AgVmY3b4/uGhx9XufWEbgE=
509
510%URL%
511https://example.org/
512
513%LICENSE%
514MIT
515Apache-2.0
516
517%ARCH%
518x86_64
519
520%BUILDDATE%
5211729181726
522
523%PACKAGER%
524Foobar McFooface <foobar@mcfooface.org>
525
526%REPLACES%
527other-pkg-replaced
528
529%CONFLICTS%
530other-pkg-conflicts
531
532%PROVIDES%
533example-component
534lib:libexample.so.1
535
536%DEPENDS%
537glibc
538gcc-libs
539libdep.so=1-64
540
541%OPTDEPENDS%
542bash: for a script
543
544%MAKEDEPENDS%
545cmake
546
547%CHECKDEPENDS%
548bats
549
550"#;
551
552 const VALID_DESC_FILE_MINIMAL: &str = r#"%FILENAME%
553example-1.0.0-1-any.pkg.tar.zst
554
555%NAME%
556example
557
558%BASE%
559example
560
561%VERSION%
5621.0.0-1
563
564%CSIZE%
5651818463
566
567%ISIZE%
56818184634
569
570%MD5SUM%
571d3b07384d113edec49eaa6238ad5ff00
572
573%SHA256SUM%
574b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c
575
576%PGPSIG%
577iHUEABYKAB0WIQRizHP4hOUpV7L92IObeih9mi7GCAUCaBZuVAAKCRCbeih9mi7GCIlMAP9ws/jU4f580ZRQlTQKvUiLbAZOdcB7mQQj83hD1Nc/GwD/WIHhO1/OQkpMERejUrLo3AgVmY3b4/uGhx9XufWEbgE=
578
579%ARCH%
580x86_64
581
582%BUILDDATE%
5831729181726
584
585%PACKAGER%
586Foobar McFooface <foobar@mcfooface.org>
587
588"#;
589
590 const VALID_DESC_FILE_EMPTY_FIELDS: &str = r#"%FILENAME%
591example-1.0.0-1-any.pkg.tar.zst
592
593%NAME%
594example
595
596%BASE%
597example
598
599%VERSION%
6001.0.0-1
601
602%DESC%
603
604%CSIZE%
6051818463
606
607%ISIZE%
60818184634
609
610%MD5SUM%
611d3b07384d113edec49eaa6238ad5ff00
612
613%SHA256SUM%
614b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c
615
616%PGPSIG%
617iHUEABYKAB0WIQRizHP4hOUpV7L92IObeih9mi7GCAUCaBZuVAAKCRCbeih9mi7GCIlMAP9ws/jU4f580ZRQlTQKvUiLbAZOdcB7mQQj83hD1Nc/GwD/WIHhO1/OQkpMERejUrLo3AgVmY3b4/uGhx9XufWEbgE=
618
619%URL%
620
621%ARCH%
622x86_64
623
624%BUILDDATE%
6251729181726
626
627%PACKAGER%
628Foobar McFooface <foobar@mcfooface.org>
629
630"#;
631
632 #[test]
633 fn parse_valid_v1_desc() -> TestResult {
634 let actual = RepoDescFileV1::from_str(VALID_DESC_FILE)?;
635 let expected = RepoDescFileV1 {
636 file_name: PackageFileName::from_str("example-1.0.0-1-any.pkg.tar.zst")?,
637 name: Name::from_str("example")?,
638 base: PackageBaseName::from_str("example")?,
639 version: FullVersion::from_str("1.0.0-1")?,
640 description: PackageDescription::from("An example package"),
641 groups: vec![
642 Group::from_str("example-group")?,
643 Group::from_str("other-group")?,
644 ],
645 compressed_size: CompressedSize::from_str("1818463")?,
646 installed_size: InstalledSize::from_str("18184634")?,
647 md5_checksum: Md5Checksum::from_str("d3b07384d113edec49eaa6238ad5ff00")?,
648 sha256_checksum: Sha256Checksum::from_str(
649 "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c",
650 )?,
651 pgp_signature: Base64OpenPGPSignature::from_str(
652 "iHUEABYKAB0WIQRizHP4hOUpV7L92IObeih9mi7GCAUCaBZuVAAKCRCbeih9mi7GCIlMAP9ws/jU4f580ZRQlTQKvUiLbAZOdcB7mQQj83hD1Nc/GwD/WIHhO1/OQkpMERejUrLo3AgVmY3b4/uGhx9XufWEbgE=",
653 )?,
654 url: Some(Url::from_str("https://example.org")?),
655 license: vec![License::from_str("MIT")?, License::from_str("Apache-2.0")?],
656 arch: Architecture::from_str("x86_64")?,
657 build_date: BuildDate::from_str("1729181726")?,
658 packager: Packager::from_str("Foobar McFooface <foobar@mcfooface.org>")?,
659 replaces: vec![PackageRelation::from_str("other-pkg-replaced")?],
660 conflicts: vec![PackageRelation::from_str("other-pkg-conflicts")?],
661 provides: vec![
662 RelationOrSoname::from_str("example-component")?,
663 RelationOrSoname::from_str("lib:libexample.so.1")?,
664 ],
665 dependencies: vec![
666 RelationOrSoname::from_str("glibc")?,
667 RelationOrSoname::from_str("gcc-libs")?,
668 RelationOrSoname::from_str("libdep.so=1-64")?,
669 ],
670 optional_dependencies: vec![OptionalDependency::from_str("bash: for a script")?],
671 make_dependencies: vec![PackageRelation::from_str("cmake")?],
672 check_dependencies: vec![PackageRelation::from_str("bats")?],
673 };
674 assert_eq!(actual, expected);
675 assert_eq!(VALID_DESC_FILE, actual.to_string());
676 Ok(())
677 }
678
679 #[test]
680 fn parse_valid_v1_desc_minimal() -> TestResult {
681 let actual = RepoDescFileV1::from_str(VALID_DESC_FILE_MINIMAL)?;
682 let expected = RepoDescFileV1 {
683 file_name: PackageFileName::from_str("example-1.0.0-1-any.pkg.tar.zst")?,
684 name: Name::from_str("example")?,
685 base: PackageBaseName::from_str("example")?,
686 version: FullVersion::from_str("1.0.0-1")?,
687 description: PackageDescription::from(""),
688 groups: vec![],
689 compressed_size: CompressedSize::from_str("1818463")?,
690 installed_size: InstalledSize::from_str("18184634")?,
691 md5_checksum: Md5Checksum::from_str("d3b07384d113edec49eaa6238ad5ff00")?,
692 sha256_checksum: Sha256Checksum::from_str(
693 "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c",
694 )?,
695 pgp_signature: Base64OpenPGPSignature::from_str(
696 "iHUEABYKAB0WIQRizHP4hOUpV7L92IObeih9mi7GCAUCaBZuVAAKCRCbeih9mi7GCIlMAP9ws/jU4f580ZRQlTQKvUiLbAZOdcB7mQQj83hD1Nc/GwD/WIHhO1/OQkpMERejUrLo3AgVmY3b4/uGhx9XufWEbgE=",
697 )?,
698 url: None,
699 license: vec![],
700 arch: Architecture::from_str("x86_64")?,
701 build_date: BuildDate::from_str("1729181726")?,
702 packager: Packager::from_str("Foobar McFooface <foobar@mcfooface.org>")?,
703 replaces: vec![],
704 conflicts: vec![],
705 provides: vec![],
706 dependencies: vec![],
707 optional_dependencies: vec![],
708 make_dependencies: vec![],
709 check_dependencies: vec![],
710 };
711 assert_eq!(actual, expected);
712 assert_eq!(VALID_DESC_FILE_MINIMAL, actual.to_string());
713 Ok(())
714 }
715
716 #[rstest]
717 #[case(VALID_DESC_FILE, VALID_DESC_FILE)]
718 #[case(VALID_DESC_FILE_MINIMAL, VALID_DESC_FILE_MINIMAL)]
719 #[case(VALID_DESC_FILE_EMPTY_FIELDS, VALID_DESC_FILE_MINIMAL)]
721 fn parser_roundtrip(#[case] input: &str, #[case] expected: &str) -> TestResult {
722 let desc = RepoDescFileV1::from_str(input)?;
723 let output = desc.to_string();
724 assert_eq!(output, expected);
725 let desc_roundtrip = RepoDescFileV1::from_str(&output)?;
726 assert_eq!(desc, desc_roundtrip);
727 Ok(())
728 }
729
730 #[rstest]
731 #[case("%UNKNOWN%\nvalue", "invalid section name")]
732 #[case("%VERSION%\n1.0.0-1\n", "Missing section: %FILENAME%")]
733 fn invalid_desc_parser(#[case] input: &str, #[case] error_snippet: &str) {
734 let result = RepoDescFileV1::from_str(input);
735 assert!(result.is_err());
736 let err = result.unwrap_err();
737 let pretty_error = err.to_string();
738 assert!(
739 pretty_error.contains(error_snippet),
740 "Error:\n=====\n{pretty_error}\n=====\nshould contain snippet:\n\n{error_snippet}"
741 );
742 }
743
744 #[test]
745 fn missing_required_section_should_fail() {
746 let input = "%VERSION%\n1.0.0-1\n";
747 let result = RepoDescFileV1::from_str(input);
748 assert!(matches!(result, Err(Error::MissingSection(s)) if s == SectionKeyword::Filename));
749 }
750}