ferroday_cage/provision/debian/repository.rs
1//! An archive source: a suite reached through one or more interchangeable
2//! mirror URLs, with its own components and trust anchor.
3//!
4//! A bootstrap resolves against one or more [`Repository`] values. The first is
5//! the primary — configured through the [`DebianBuilder`](super::DebianBuilder)
6//! setters, so a single-mirror bootstrap needs no repository at all — and any
7//! further ones are merged in through
8//! [`DebianBuilder::repository`](super::DebianBuilder::repository). Each carries
9//! its own trust anchor and freshness posture, so multiplying repositories
10//! repeats the same authenticity checks per source rather than weakening them.
11//!
12//! One repository can list several mirror URLs. They are interchangeable views
13//! of the *same* source — a live mirror and a `snapshot.debian.org` backstop,
14//! say — tried in order when an earlier one reports a resource missing. Several
15//! repositories are several distinct sources; several URLs in one repository are
16//! one source reached several ways.
17
18use std::fmt;
19use std::path::{Path, PathBuf};
20
21use super::DebianError;
22use super::bootstrap::{self, TRUST_ANCHOR_STEM};
23use super::check_coordinate;
24use crate::provision::coordinate::Nesting;
25
26/// A repository's trust anchor: the keyring its signature is verified against,
27/// or an explicit decision to trust it unsigned.
28#[derive(Clone)]
29pub(crate) enum Trust {
30 /// The release signature is verified against these keyring bytes.
31 Signed(Vec<u8>),
32 /// The release is trusted without a signature, apt's `[trusted=yes]`.
33 Unsigned,
34}
35
36impl fmt::Debug for Trust {
37 /// Names the anchor rather than dumping it. A keyring is bulky and its
38 /// bytes say nothing a reader of a rendering can act on; its length is kept
39 /// because a truncated or empty keyring is a real misconfiguration and is
40 /// otherwise indistinguishable from a whole one.
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 match self {
43 Trust::Signed(keyring) => write!(f, "Signed({} bytes)", keyring.len()),
44 Trust::Unsigned => f.write_str("Unsigned"),
45 }
46 }
47}
48
49/// An archive source: a suite, its components, an ordered list of mirror URLs,
50/// and a trust anchor.
51///
52/// Built with [`Repository::builder`] for an additional source, or produced
53/// internally from the [`DebianBuilder`](super::DebianBuilder) setters for the
54/// primary. Several repositories contribute to one merged resolution; see the
55/// module documentation for how the two axes — distinct sources and
56/// interchangeable URLs — fall out of this one type.
57///
58/// `Clone` so a caller that assembles a set of repositories once can hand the
59/// same set to more than one provisioner; the clone copies the keyring bytes.
60#[derive(Clone)]
61#[non_exhaustive]
62pub struct Repository {
63 /// The mirror URLs, the primary first and any backstops after it. A fetch
64 /// tries them in order, advancing past one that reports the resource
65 /// missing or fails at the transport.
66 pub(crate) mirrors: Vec<String>,
67 /// The suite (`trixie`, `stable`) this repository publishes.
68 pub(crate) suite: String,
69 /// The archive components to read.
70 pub(crate) components: Vec<String>,
71 /// How the repository's authenticity is established.
72 pub(crate) trust: Trust,
73 /// Whether a signed-but-expired release is accepted, repository-wide.
74 pub(crate) allow_stale: bool,
75 /// The `sources.list.d` entry and trust-anchor file name, for an additional
76 /// repository written into the finished rootfs. `None` for the primary,
77 /// which owns `sources.list` and the canonical keyring path.
78 pub(crate) name: Option<String>,
79}
80
81impl fmt::Debug for Repository {
82 /// Renders the repository, naming its trust anchor by its form and length
83 /// rather than dumping it.
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 f.debug_struct("Repository")
86 .field("mirrors", &self.mirrors)
87 .field("suite", &self.suite)
88 .field("components", &self.components)
89 .field("trust", &self.trust)
90 .field("allow_stale", &self.allow_stale)
91 .field("name", &self.name)
92 .finish()
93 }
94}
95
96impl Repository {
97 /// Returns a builder for a repository publishing `suite`.
98 ///
99 /// A mirror URL is required; the components default to `main`, and the
100 /// repository must declare its trust anchor with either
101 /// [`keyring`](RepositoryBuilder::keyring) or
102 /// [`trust_unsigned`](RepositoryBuilder::trust_unsigned).
103 pub fn builder(suite: impl Into<String>) -> RepositoryBuilder {
104 RepositoryBuilder {
105 suite: suite.into(),
106 mirror: None,
107 fallbacks: Vec::new(),
108 components: Vec::new(),
109 keyring_path: None,
110 trust_unsigned: false,
111 allow_stale: false,
112 name: None,
113 }
114 }
115
116 /// Builds the primary repository from the flattened
117 /// [`DebianBuilder`](super::DebianBuilder) configuration.
118 ///
119 /// The primary owns `sources.list` and the canonical keyring path rather
120 /// than a named `sources.list.d` entry, so it carries no name. Its trust
121 /// anchor is the resolved keyring bytes — the embedded archive keyring, or a
122 /// caller substitution — unless the caller trusts it unsigned.
123 pub(crate) fn primary(
124 suite: String,
125 mirrors: Vec<String>,
126 components: Vec<String>,
127 keyring: Vec<u8>,
128 trust_unsigned: bool,
129 allow_stale: bool,
130 ) -> Result<Repository, DebianError> {
131 let trust = if trust_unsigned {
132 Trust::Unsigned
133 } else {
134 Trust::Signed(keyring)
135 };
136 let repository = Repository {
137 mirrors,
138 suite,
139 components,
140 trust,
141 allow_stale,
142 name: None,
143 };
144 repository.validate()?;
145 Ok(repository)
146 }
147
148 /// The file-name stem this repository's `sources.list.d` entry and keyring
149 /// take in the finished rootfs: the caller's
150 /// [`name`](RepositoryBuilder::name) when given, or one generated from its
151 /// position in the set otherwise.
152 ///
153 /// Only an additional repository takes a stem; the primary owns
154 /// `sources.list` and the canonical trust-anchor path. `index` is the
155 /// repository's position in the resolved set, the primary at zero.
156 pub(crate) fn entry_stem(&self, index: usize) -> String {
157 self.name
158 .clone()
159 .unwrap_or_else(|| format!("fcage-repo-{index}"))
160 }
161
162 /// Whether the repository is trusted without a signature.
163 pub(crate) fn trust_unsigned(&self) -> bool {
164 matches!(self.trust, Trust::Unsigned)
165 }
166
167 /// The keyring the release is verified against, or `None` when the
168 /// repository is trusted unsigned.
169 pub(crate) fn keyring(&self) -> Option<&[u8]> {
170 match &self.trust {
171 Trust::Signed(bytes) => Some(bytes),
172 Trust::Unsigned => None,
173 }
174 }
175
176 /// Whether freshness is enforced: signed and not stale-relaxed. An unsigned
177 /// repository is never freshness-checked.
178 pub(crate) fn require_fresh(&self) -> bool {
179 !self.trust_unsigned() && !self.allow_stale
180 }
181
182 /// Rejects a configuration the repository cannot be addressed by, or that
183 /// would install unauthenticated packages.
184 ///
185 /// The suite and each component become path segments of every URL this
186 /// repository is fetched through, and words of the `deb` line written into
187 /// the finished rootfs's apt sources, so both pass the archive-coordinate
188 /// check: a `..` segment climbs out of the mirror root — a local read for a
189 /// `file://` mirror — and a control character splits the request line the
190 /// value is interpolated into, or adds a source line to the rootfs that the
191 /// caller never wrote. It is the same check [`Pool`](super::Pool) makes of
192 /// the coordinates it publishes under, so a pool this crate writes and a
193 /// repository this crate reads agree on what an archive may be called.
194 ///
195 /// `trust_unsigned` skips the archive signature, so authenticity rests on
196 /// the transport alone, and the check is that the transport provides some:
197 /// every mirror of an unsigned repository must be `file://` — local, under
198 /// the caller's control — or `https://`, which authenticates the server it
199 /// came from. The check is per-URL.
200 ///
201 /// It is an allow-list rather than a refusal of `http://`, because a
202 /// refusal names only the plaintext scheme the crate happened to think of:
203 /// a caller-supplied fetcher speaking any other unauthenticated transport
204 /// would pass, and the pairing would install wholly unverified packages
205 /// over an unauthenticated wire. Schemes are case-insensitive (RFC 3986
206 /// §3.1), so the comparison is too — a refusal that read `http://`
207 /// literally would let `HTTP://` name the same plaintext transport and slip
208 /// past.
209 fn validate(&self) -> Result<(), DebianError> {
210 if self.mirrors.is_empty() {
211 return Err(DebianError::Config {
212 reason: "a repository needs at least one mirror URL".to_string(),
213 });
214 }
215 check_coordinate("the repository's suite", &self.suite, Nesting::Nested)?;
216 for component in &self.components {
217 check_coordinate("the repository's component", component, Nesting::Nested)?;
218 }
219 if self.trust_unsigned() {
220 for mirror in &self.mirrors {
221 if !is_authenticated_transport(mirror) {
222 return Err(DebianError::Config {
223 reason: format!(
224 "trust_unsigned cannot be used with the mirror {mirror}: skipping the \
225 archive signature leaves authenticity to the transport alone, and only \
226 file:// (local, under your control) and https:// (which authenticates the \
227 server) supply any; use one of those, or drop trust_unsigned so the \
228 signature is checked"
229 ),
230 });
231 }
232 }
233 }
234 Ok(())
235 }
236}
237
238/// Builder for a [`Repository`].
239///
240/// The keyring is held as the path it will be read from, so a rendering of a
241/// builder names the trust anchor without ever holding it.
242#[derive(Debug)]
243pub struct RepositoryBuilder {
244 suite: String,
245 mirror: Option<String>,
246 fallbacks: Vec<String>,
247 components: Vec<String>,
248 keyring_path: Option<PathBuf>,
249 trust_unsigned: bool,
250 allow_stale: bool,
251 name: Option<String>,
252}
253
254impl RepositoryBuilder {
255 /// Sets the primary mirror URL, the first one every fetch tries.
256 ///
257 /// The default transport speaks `http://` and `file://` only, so an
258 /// `https://` mirror needs a fetcher of the caller's own, set with
259 /// [`DebianBuilder::fetcher`]; without one the URL is refused when the
260 /// solve first reaches for it. Carrying a TLS stack is the consumer's
261 /// decision, not the crate's — the archive signature, not the transport, is
262 /// what authenticates a package.
263 ///
264 /// [`DebianBuilder::fetcher`]: super::DebianBuilder::fetcher
265 pub fn mirror(mut self, url: impl Into<String>) -> Self {
266 self.mirror = Some(url.into());
267 self
268 }
269
270 /// Adds a backstop mirror URL, tried in order after the primary when a
271 /// fetch reports the resource missing or fails at the transport.
272 ///
273 /// This expresses a live mirror with a `snapshot.debian.org` fallback: the
274 /// same suite and the same content reached through a second URL. The
275 /// freshness posture is repository-wide, so a repository with a snapshot
276 /// backstop normally sets
277 /// [`allow_stale_release`](Self::allow_stale_release), the snapshot's
278 /// release being expired by design.
279 ///
280 /// A fallback is fetched through the same transport as the primary, so the
281 /// scheme constraint of [`mirror`](Self::mirror) applies to it too.
282 pub fn mirror_fallback(mut self, url: impl Into<String>) -> Self {
283 self.fallbacks.push(url.into());
284 self
285 }
286
287 /// Sets the archive components. The default is `main`.
288 pub fn components<I, S>(mut self, components: I) -> Self
289 where
290 I: IntoIterator<Item = S>,
291 S: Into<String>,
292 {
293 self.components = components.into_iter().map(Into::into).collect();
294 self
295 }
296
297 /// Verifies the repository's release against the binary OpenPGP keyring
298 /// read from `path`.
299 ///
300 /// A signed repository must declare a keyring: unlike the primary, an
301 /// additional repository has no embedded default, since its signing key is
302 /// its own.
303 pub fn keyring(mut self, path: impl AsRef<Path>) -> Self {
304 self.keyring_path = Some(path.as_ref().to_path_buf());
305 self
306 }
307
308 /// Trusts the repository without verifying its signature, apt's
309 /// `[trusted=yes]`.
310 ///
311 /// Appropriate for a local or `file://` repository under the caller's
312 /// control — a build's own freshly-signed-nothing `.deb` pool — never for a
313 /// remote one. With no signature, authenticity rests on the transport
314 /// alone, so [`build`](Self::build) requires one that supplies some: every
315 /// mirror must be `file://` or `https://`, and any other scheme —
316 /// `http://` included — is refused.
317 pub fn trust_unsigned(mut self, trust: bool) -> Self {
318 self.trust_unsigned = trust;
319 self
320 }
321
322 /// Accepts a signed release that is past its `Valid-Until`.
323 ///
324 /// The posture is repository-wide: it relaxes freshness for every mirror,
325 /// which a live-plus-snapshot repository wants because the snapshot's
326 /// release is expired by design. The signature is still verified. It has no
327 /// effect on a [`trust_unsigned`](Self::trust_unsigned) repository, which is
328 /// never freshness-checked.
329 pub fn allow_stale_release(mut self, allow: bool) -> Self {
330 self.allow_stale = allow;
331 self
332 }
333
334 /// Names the repository, for its `/etc/apt/sources.list.d/<name>.list`
335 /// entry and, when signed, its `/usr/share/keyrings/<name>.gpg` trust
336 /// anchor in the finished rootfs.
337 ///
338 /// The name must be a portable file-name stem — ASCII letters, digits, and
339 /// `.`, `-`, `_` — so it cannot escape those directories. Without one, a
340 /// generated stem is used, which is enough for provisioning but rarely what
341 /// a caller wants in the finished sources.
342 pub fn name(mut self, name: impl Into<String>) -> Self {
343 self.name = Some(name.into());
344 self
345 }
346
347 /// Validates the configuration and freezes it into a [`Repository`].
348 ///
349 /// # Errors
350 ///
351 /// Returns a [`DebianError::Config`] when no mirror was set, when a signed
352 /// repository names no keyring, when the keyring cannot be read, when an
353 /// unsigned repository is reached over `http://`, when a supplied name is
354 /// not a portable file-name stem, or when the suite or a component is one
355 /// the repository cannot be addressed by: an empty value, one carrying
356 /// whitespace or a control character, or one that would resolve outside the
357 /// mirror root through a leading `/`, a `..` segment, or an empty or `.`
358 /// segment. Either may otherwise contain slashes, since both may name a
359 /// subtree.
360 pub fn build(self) -> Result<Repository, DebianError> {
361 let Some(primary) = self.mirror else {
362 return Err(DebianError::Config {
363 reason: "a repository needs a mirror URL; set one with mirror()".to_string(),
364 });
365 };
366 let mut mirrors = Vec::with_capacity(1 + self.fallbacks.len());
367 mirrors.push(primary);
368 mirrors.extend(self.fallbacks);
369
370 let components = if self.components.is_empty() {
371 vec!["main".to_string()]
372 } else {
373 self.components
374 };
375
376 if let Some(name) = &self.name {
377 validate_name(name)?;
378 }
379
380 let trust = if self.trust_unsigned {
381 Trust::Unsigned
382 } else {
383 let path = self.keyring_path.ok_or_else(|| DebianError::Config {
384 reason: "a signed repository needs a keyring; set one with keyring(), or trust it \
385 unsigned with trust_unsigned(true)"
386 .to_string(),
387 })?;
388 let bytes = std::fs::read(&path)
389 .map_err(DebianError::at("reading the repository keyring", &path))?;
390 Trust::Signed(bytes)
391 };
392
393 let repository = Repository {
394 mirrors,
395 suite: self.suite,
396 components,
397 trust,
398 allow_stale: self.allow_stale,
399 name: self.name,
400 };
401 repository.validate()?;
402 Ok(repository)
403 }
404}
405
406/// The mirror schemes that authenticate what they serve, and so are the ones an
407/// unsigned repository may be reached through.
408///
409/// `file://` is a local path under the caller's own control; `https://`
410/// authenticates the server the bytes came from. Nothing else does, whether it
411/// is `http://` or a scheme this crate has never heard of, so nothing else may
412/// carry an archive whose signature is not checked.
413const AUTHENTICATED_SCHEMES: [&str; 2] = ["file", "https"];
414
415/// Whether `url` names a transport that authenticates what it serves.
416///
417/// A URL naming no scheme at all is not one: the built-in fetcher and every
418/// caller-supplied one address a mirror by scheme, so a bare path is a
419/// misconfiguration rather than a transport with a known posture.
420fn is_authenticated_transport(url: &str) -> bool {
421 crate::provision::fetch::scheme_of(url).is_some_and(|scheme| {
422 AUTHENTICATED_SCHEMES
423 .iter()
424 .any(|allowed| scheme.eq_ignore_ascii_case(allowed))
425 })
426}
427
428/// Rejects a repository name that is not a portable file-name stem, so it
429/// cannot traverse out of `sources.list.d` or the keyrings directory when the
430/// finished rootfs's sources are written.
431///
432/// The name is one directory entry, so it passes [`check_coordinate`] as a
433/// [`Nesting::Single`] value first: that is where the empty name, the traversal,
434/// the separator, and the shapes a deb822 field cannot carry are refused, and
435/// refusing them here as well would be a second answer to a question already
436/// settled. What remains is the narrower rule this name alone needs — an
437/// allow-list, because the stem also becomes a shell-visible file name in a
438/// finished rootfs, where a caller's locale-dependent or shell-significant
439/// character is a liability rather than merely unusual.
440fn validate_name(name: &str) -> Result<(), DebianError> {
441 check_coordinate("the repository name", name, Nesting::Single)?;
442 if name
443 .chars()
444 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
445 {
446 Ok(())
447 } else {
448 Err(DebianError::Config {
449 reason: format!(
450 "the repository name {name:?} is not a portable file-name stem: use ASCII letters, \
451 digits, and '.', '-', '_'"
452 ),
453 })
454 }
455}
456
457/// Rejects a repository set whose repositories would write over each other in
458/// the finished rootfs.
459///
460/// Each additional repository writes `sources.list.d/<stem>.list` and its own
461/// `<stem>.gpg` keyring, so two sharing a stem leave only the last one's source
462/// line and keyring behind — the earlier repository would be silently absent
463/// from the installed system's apt configuration even though its packages were
464/// installed. Collisions arise between two caller-supplied names, and between a
465/// caller-supplied name and another repository's generated stem.
466///
467/// The primary repository takes no stem of its own — it owns `sources.list`
468/// rather than an entry under `sources.list.d` — but its trust anchor lands in
469/// the same keyrings directory under [`TRUST_ANCHOR_STEM`], so that name is in
470/// the set from the start. It is the one collision that matters most: the
471/// primary's `sources.list` still names the anchor by path, so an additional
472/// repository claiming the name would leave the finished rootfs verifying the
473/// primary archive against a third party's key.
474pub(crate) fn validate_distinct_entries(repositories: &[Repository]) -> Result<(), DebianError> {
475 let mut seen: Vec<String> = Vec::with_capacity(repositories.len());
476 seen.push(TRUST_ANCHOR_STEM.to_string());
477 for (index, repository) in repositories.iter().enumerate().skip(1) {
478 let stem = repository.entry_stem(index);
479 if seen.contains(&stem) {
480 return Err(DebianError::Config {
481 reason: format!(
482 "two repositories would both write the keyring {:?}: \
483 give each repository a distinct name",
484 bootstrap::keyring_path(&stem)
485 ),
486 });
487 }
488 seen.push(stem);
489 }
490 Ok(())
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 /// An additional repository carrying `name`, otherwise minimal.
498 fn named(name: Option<&str>) -> Repository {
499 let mut builder = Repository::builder("trixie")
500 .mirror("file:///srv/debs")
501 .trust_unsigned(true);
502 if let Some(name) = name {
503 builder = builder.name(name);
504 }
505 builder.build().expect("the repository should build")
506 }
507
508 #[test]
509 fn two_repositories_sharing_a_name_are_refused() {
510 let set = [named(None), named(Some("local")), named(Some("local"))];
511 let err = validate_distinct_entries(&set).unwrap_err();
512 assert!(
513 matches!(&err, DebianError::Config { reason } if reason.contains("local")),
514 "{err}"
515 );
516 }
517
518 #[test]
519 fn a_name_colliding_with_a_generated_stem_is_refused() {
520 // The unnamed repository at index 2 generates `fcage-repo-2`, which the
521 // named one at index 1 already claimed.
522 let set = [named(None), named(Some("fcage-repo-2")), named(None)];
523 assert!(matches!(
524 validate_distinct_entries(&set),
525 Err(DebianError::Config { .. })
526 ));
527 }
528
529 #[test]
530 fn a_name_colliding_with_the_primarys_trust_anchor_is_refused() {
531 // The primary writes its keyring under this stem and its sources.list
532 // names that path, so an additional repository claiming the name would
533 // leave the finished rootfs verifying the primary archive against this
534 // repository's key.
535 let set = [named(None), named(Some(TRUST_ANCHOR_STEM))];
536 let err = validate_distinct_entries(&set).unwrap_err();
537 assert!(
538 matches!(&err, DebianError::Config { reason } if reason.contains(TRUST_ANCHOR_STEM)),
539 "{err}"
540 );
541 }
542
543 #[test]
544 fn distinct_names_and_generated_stems_are_accepted() {
545 let set = [named(None), named(Some("local")), named(None), named(None)];
546 assert!(validate_distinct_entries(&set).is_ok());
547 }
548
549 #[test]
550 fn a_mirror_is_required() {
551 assert!(matches!(
552 Repository::builder("trixie").trust_unsigned(true).build(),
553 Err(DebianError::Config { .. })
554 ));
555 }
556
557 #[test]
558 fn a_signed_repository_needs_a_keyring() {
559 // Neither a keyring nor trust_unsigned: the trust anchor is undeclared.
560 let err = Repository::builder("trixie")
561 .mirror("https://example.invalid/debian")
562 .build()
563 .unwrap_err();
564 assert!(matches!(err, DebianError::Config { .. }));
565 }
566
567 #[test]
568 fn an_unsigned_repository_on_an_unauthenticated_transport_is_refused() {
569 // Only file:// and https:// authenticate what they serve. Everything
570 // else is refused by name, including a scheme spelled in a case the
571 // guard is not written in — URI schemes are case-insensitive, so a
572 // literal `http://` comparison would let `HTTP://` name the same
573 // plaintext transport and pass — and including a scheme only a
574 // caller-supplied fetcher would speak.
575 for mirror in [
576 "http://example.invalid/debian",
577 "HTTP://example.invalid/debian",
578 "Http://example.invalid/debian",
579 "ftp://example.invalid/debian",
580 "s3://bucket/debian",
581 "/srv/debs",
582 ] {
583 let err = Repository::builder("trixie")
584 .mirror(mirror)
585 .trust_unsigned(true)
586 .build()
587 .unwrap_err();
588 assert!(matches!(err, DebianError::Config { .. }), "{mirror}");
589 }
590 }
591
592 #[test]
593 fn an_unsigned_repository_on_an_authenticated_transport_is_accepted() {
594 for mirror in [
595 "file:///srv/debs",
596 "FILE:///srv/debs",
597 "https://packages.example/debian",
598 "HTTPS://packages.example/debian",
599 ] {
600 assert!(
601 Repository::builder("trixie")
602 .mirror(mirror)
603 .trust_unsigned(true)
604 .build()
605 .is_ok(),
606 "{mirror}",
607 );
608 }
609 }
610
611 #[test]
612 fn an_unsigned_unauthenticated_fallback_is_refused() {
613 // The transport check is per-URL: an authenticated primary does not
614 // excuse a plaintext backstop.
615 let err = Repository::builder("trixie")
616 .mirror("file:///srv/debs")
617 .mirror_fallback("http://example.invalid/debian")
618 .trust_unsigned(true)
619 .build()
620 .unwrap_err();
621 assert!(matches!(err, DebianError::Config { .. }));
622 }
623
624 #[test]
625 fn mirrors_are_ordered_primary_first() {
626 let repo = Repository::builder("trixie")
627 .mirror("file:///primary")
628 .mirror_fallback("file:///backstop")
629 .trust_unsigned(true)
630 .build()
631 .expect("the repository validates");
632 assert_eq!(repo.mirrors, ["file:///primary", "file:///backstop"]);
633 assert_eq!(repo.components, ["main"]);
634 assert!(repo.trust_unsigned());
635 assert!(repo.keyring().is_none());
636 }
637
638 #[test]
639 fn a_traversing_name_is_refused() {
640 // The name is one directory entry, so it is refused for every reason a
641 // single-segment coordinate is — traversal, the separator, the empty
642 // name, and the shapes a deb822 field or a request line cannot carry —
643 // and additionally for a character that is not a portable file-name
644 // stem.
645 for name in [
646 "../evil", "a/b", "", " ", ".", "..", "my repo", "a\nb", "/abs", "star*",
647 ] {
648 match Repository::builder("trixie")
649 .mirror("file:///srv/debs")
650 .trust_unsigned(true)
651 .name(name)
652 .build()
653 {
654 Err(DebianError::Config { .. }) => {}
655 Err(other) => panic!("{name:?} was refused as {other}"),
656 Ok(_) => panic!("{name:?} was accepted"),
657 }
658 }
659 }
660
661 #[test]
662 fn a_portable_name_is_accepted() {
663 let repo = Repository::builder("trixie")
664 .mirror("file:///srv/debs")
665 .trust_unsigned(true)
666 .name("local-debs")
667 .build()
668 .expect("the repository validates");
669 assert_eq!(repo.name.as_deref(), Some("local-debs"));
670 }
671}