Skip to main content

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}