Skip to main content

spec_driven_docs/release/
crates_io.rs

1//! Read another release from the registry that published it.
2//!
3//! This is the one network read in the tool, and it produces bytes a later
4//! apply may write into a repository, so every step is bounded and every
5//! byte is verified. The registry's own protocol decides the URLs: the
6//! sparse index serves `config.json` and one line per version, and the
7//! `dl` template in that configuration says where an archive lives. None
8//! of it is a hard-coded download layout.
9//!
10//! The archive is untrusted input. It is verified against the index
11//! checksum before it is parsed, expanded under caps on compressed bytes,
12//! expanded bytes, entry count, and per-file bytes, and admitted only for
13//! regular files under a declared payload root. A path that is absolute,
14//! that climbs out, that is a link of either kind, or that repeats is
15//! refused rather than skipped, because an archive that carries one is not
16//! the archive the registry says it is.
17//!
18//! Nothing is executed. A bundle is data the planner reads.
19
20use std::collections::{BTreeMap, BTreeSet};
21use std::io::Read as _;
22use std::time::Duration;
23
24use camino::{Utf8Path, Utf8PathBuf};
25use serde::{Deserialize, Serialize};
26
27use crate::domain::ownership::Sha256;
28use crate::domain::projection::Declaration;
29use crate::error::AppError;
30use crate::release::legacy::LegacyCatalog;
31use crate::release::{
32    Provenance, ReleaseBundle, ReleaseManifest, ReleaseResolver, ResolvedRelease, Selector,
33    Version, blob_from, manifest_from,
34};
35
36/// The crate this tool distributes itself as.
37pub const CRATE_NAME: &str = "spec-driven-docs";
38
39/// The sparse index this tool reads.
40pub const INDEX_ROOT: &str = "https://index.crates.io";
41
42/// How long a connection may take to open.
43const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
44/// How long the response headers may take to arrive.
45const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
46/// How long one whole read may take.
47const TOTAL_TIMEOUT: Duration = Duration::from_secs(120);
48/// How many times a transient read is retried.
49const RETRY_BUDGET: u32 = 2;
50/// The longest a `Retry-After` is honoured.
51const MAX_RETRY_AFTER: Duration = Duration::from_secs(10);
52/// The largest index document this tool reads.
53const MAX_INDEX_BYTES: u64 = 16 * 1024 * 1024;
54/// The largest archive this tool reads.
55const MAX_COMPRESSED_BYTES: u64 = 32 * 1024 * 1024;
56/// The most bytes an archive may expand to.
57const MAX_EXPANDED_BYTES: u64 = 128 * 1024 * 1024;
58/// The most entries an archive may hold.
59const MAX_ENTRIES: usize = 20_000;
60/// The largest single file an archive may hold.
61const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024;
62
63/// One version, as the sparse index describes it.
64#[derive(Debug, Clone, Deserialize)]
65struct IndexEntry {
66    vers: String,
67    cksum: String,
68    #[serde(default)]
69    yanked: bool,
70}
71
72/// What the registry's own configuration says about download URLs.
73#[derive(Debug, Clone, Deserialize)]
74struct RegistryConfig {
75    dl: String,
76}
77
78/// The frozen identity of one cached release.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80struct CachedIdentity {
81    version: String,
82    cksum: String,
83    yanked: bool,
84}
85
86/// A release read from its published crate.
87#[derive(Debug, Clone)]
88pub struct CrateReleaseBundle {
89    version: Version,
90    payload_schema: u32,
91    provenance: Provenance,
92    descriptor_sha256: Option<Sha256>,
93    files: BTreeMap<String, Vec<u8>>,
94    metadata: BTreeMap<String, Vec<u8>>,
95}
96
97impl ReleaseBundle for CrateReleaseBundle {
98    fn manifest(&self) -> Result<ReleaseManifest, AppError> {
99        Ok(manifest_from(
100            self.version.clone(),
101            self.payload_schema,
102            self.provenance,
103            self.descriptor_sha256.clone(),
104            &self.files,
105            &self.metadata,
106        ))
107    }
108
109    fn blob(&self, digest: &Sha256) -> Result<Vec<u8>, AppError> {
110        blob_from(&self.files, &self.metadata, digest)
111    }
112}
113
114/// Resolve a selector against crates.io, or against the cache alone.
115#[derive(Debug, Clone)]
116pub struct CratesIoResolver {
117    cache: Utf8PathBuf,
118    offline: bool,
119    index_root: String,
120    catalog: LegacyCatalog,
121}
122
123impl CratesIoResolver {
124    /// A resolver caching under `cache`.
125    #[must_use]
126    pub fn new(cache: &Utf8Path) -> Self {
127        Self {
128            cache: cache.to_owned(),
129            // The variable is the host saying the network is not there,
130            // which is a stronger statement than a flag nobody passed.
131            // The doctor already reads it; so does every read that would
132            // otherwise fail slowly at a name it cannot resolve.
133            offline: crate::domain::paths::variable(crate::domain::paths::OFFLINE_VAR).is_some(),
134            index_root: INDEX_ROOT.to_string(),
135            catalog: LegacyCatalog::embedded(),
136        }
137    }
138
139    /// The same resolver, forbidden to reach the network.
140    #[must_use]
141    pub const fn offline(mut self, offline: bool) -> Self {
142        self.offline = self.offline || offline;
143        self
144    }
145
146    /// The same resolver, reading a different index root.
147    ///
148    /// A test serves the protocol from a local directory this way, so the
149    /// suite exercises the resolver rather than a stub of it.
150    #[must_use]
151    pub fn with_index_root(mut self, root: &str) -> Self {
152        self.index_root = root.to_string();
153        self
154    }
155
156    fn identity_path(&self, version: &str) -> Utf8PathBuf {
157        self.cache.join(format!("{version}.json"))
158    }
159
160    fn archive_path(&self, cksum: &str) -> Utf8PathBuf {
161        self.cache.join(format!("{cksum}.crate"))
162    }
163
164    /// The sparse index path for a crate name, as the protocol defines it.
165    #[must_use]
166    pub fn index_path(name: &str) -> String {
167        let lower = name.to_lowercase();
168        match lower.len() {
169            0 => lower,
170            1 => format!("1/{lower}"),
171            2 => format!("2/{lower}"),
172            3 => format!("3/{}/{lower}", &lower[..1]),
173            _ => format!("{}/{}/{lower}", &lower[..2], &lower[2..4]),
174        }
175    }
176
177    /// One HTTP read, bounded and retried only where a retry is honest.
178    fn read(&self, url: &str, limit: u64) -> Result<Vec<u8>, AppError> {
179        if self.offline {
180            return Err(AppError::Refused(format!(
181                "--offline forbids the read of {url}; drop --offline or resolve a version already cached"
182            )));
183        }
184        let agent: ureq::Agent = ureq::Agent::config_builder()
185            .timeout_connect(Some(CONNECT_TIMEOUT))
186            .timeout_recv_response(Some(RESPONSE_TIMEOUT))
187            .timeout_global(Some(TOTAL_TIMEOUT))
188            .user_agent(format!(
189                "sdd/{} (+{})",
190                env!("CARGO_PKG_VERSION"),
191                CRATE_NAME
192            ))
193            .build()
194            .into();
195        let mut attempt = 0;
196        loop {
197            match agent.get(url).call() {
198                Ok(mut response) => {
199                    let status = response.status().as_u16();
200                    if transient(status) && attempt < RETRY_BUDGET {
201                        std::thread::sleep(retry_after(&response));
202                        attempt += 1;
203                        continue;
204                    }
205                    if status != 200 {
206                        return Err(AppError::Refused(format!("{url} answered {status}")));
207                    }
208                    return response
209                        .body_mut()
210                        .with_config()
211                        .limit(limit)
212                        .read_to_vec()
213                        .map_err(|source| {
214                            AppError::Refused(format!(
215                                "{url} did not read within {limit} bytes: {source}"
216                            ))
217                        });
218                }
219                Err(source) if attempt < RETRY_BUDGET && is_transport(&source) => {
220                    std::thread::sleep(Duration::from_millis(250));
221                    attempt += 1;
222                }
223                Err(source) => {
224                    return Err(AppError::Refused(format!(
225                        "{url} could not be read: {source}"
226                    )));
227                }
228            }
229        }
230    }
231
232    /// Every version the index serves, in index order.
233    fn index(&self) -> Result<Vec<IndexEntry>, AppError> {
234        let url = format!("{}/{}", self.index_root, Self::index_path(CRATE_NAME));
235        let bytes = self.read(&url, MAX_INDEX_BYTES)?;
236        let text = String::from_utf8(bytes)
237            .map_err(|source| AppError::Refused(format!("{url} is not text: {source}")))?;
238        let mut entries = Vec::new();
239        for line in text.lines().filter(|line| !line.trim().is_empty()) {
240            let entry: IndexEntry = serde_json::from_str(line).map_err(|source| {
241                AppError::Refused(format!(
242                    "{url} carries a line this engine cannot read: {source}"
243                ))
244            })?;
245            entries.push(entry);
246        }
247        if entries.is_empty() {
248            return Err(AppError::Refused(format!("{url} lists no version")));
249        }
250        Ok(entries)
251    }
252
253    /// Where the registry says an archive for one version lives.
254    fn download_url(&self, version: &str, cksum: &str) -> Result<String, AppError> {
255        let url = format!("{}/config.json", self.index_root);
256        let bytes = self.read(&url, MAX_INDEX_BYTES)?;
257        let config: RegistryConfig = serde_json::from_slice(&bytes)
258            .map_err(|source| AppError::Refused(format!("{url} does not parse: {source}")))?;
259        Ok(expand_download(&config.dl, CRATE_NAME, version, cksum))
260    }
261
262    /// The identity a previous resolution froze for this version.
263    fn cached_identity(&self, version: &str) -> Option<CachedIdentity> {
264        let text = std::fs::read_to_string(self.identity_path(version)).ok()?;
265        serde_json::from_str(&text).ok()
266    }
267
268    fn remember(&self, identity: &CachedIdentity, archive: &[u8]) -> Result<(), AppError> {
269        std::fs::create_dir_all(&self.cache)?;
270        crate::adapters::fs::write_atomic(&self.archive_path(&identity.cksum), archive)?;
271        let text = serde_json::to_string_pretty(identity)
272            .map_err(|source| anyhow::anyhow!("the cached identity did not serialize: {source}"))?;
273        crate::adapters::fs::write_atomic(
274            &self.identity_path(&identity.version),
275            format!("{text}\n").as_bytes(),
276        )?;
277        Ok(())
278    }
279
280    /// The verified archive for one identity, from the cache or the network.
281    ///
282    /// The second value says whether these bytes are already cached. A
283    /// checksum that matches the index proves the bytes are the published
284    /// crate, and nothing more: the archive can still hold a traversal, a
285    /// duplicate path, or a declaration this engine cannot read. Caching
286    /// it here would serve a refusal offline forever, so the caller
287    /// publishes it only after the whole bundle is admitted.
288    fn archive(&self, identity: &CachedIdentity) -> Result<(Vec<u8>, bool), AppError> {
289        let held = self.archive_path(&identity.cksum);
290        if let Ok(bytes) = std::fs::read(&held)
291            && Sha256::of(&bytes).as_str() == identity.cksum
292        {
293            return Ok((bytes, true));
294        }
295        let url = self.download_url(&identity.version, &identity.cksum)?;
296        let bytes = self.read(&url, MAX_COMPRESSED_BYTES)?;
297        let found = Sha256::of(&bytes);
298        if found.as_str() != identity.cksum {
299            // Nothing is cached: an archive whose checksum disagrees with
300            // the registry is not the release, and keeping it would serve
301            // the disagreement again offline.
302            return Err(AppError::Refused(format!(
303                "the archive for {} hashes to {found} and the index says {}; nothing was cached",
304                identity.version, identity.cksum
305            )));
306        }
307        Ok((bytes, false))
308    }
309
310    /// Build the bundle one verified archive carries.
311    fn bundle(&self, identity: &CachedIdentity) -> Result<CrateReleaseBundle, AppError> {
312        let version: Version = identity.version.parse().map_err(|_| {
313            AppError::Refused(format!("{} is not a semantic version", identity.version))
314        })?;
315        // A release the audit already classified unavailable is refused
316        // before anything is fetched: a download whose answer is a refusal
317        // is a download nobody needed.
318        if let Some(entry) = self.catalog.entry(&identity.version)
319            && !entry.eligible
320        {
321            self.catalog.descriptor(&identity.version)?;
322        }
323        let (archive, cached) = self.archive(identity)?;
324        let files = admit(&archive, &format!("{CRATE_NAME}-{}", identity.version))?;
325        let native = files
326            .get(crate::domain::projection::DECLARATION_PATH)
327            .map(|bytes| Declaration::parse(bytes))
328            .transpose()
329            .map_err(|source| AppError::Refused(source.to_string()))?;
330        if let Some(declaration) = native {
331            if !cached {
332                self.remember(identity, &archive)?;
333            }
334            return Ok(CrateReleaseBundle {
335                version,
336                payload_schema: declaration.payload_schema,
337                provenance: Provenance::Native,
338                descriptor_sha256: None,
339                files,
340                metadata: BTreeMap::new(),
341            });
342        }
343        let adapted = self.catalog.adapt(&identity.version, &files)?;
344        if !cached {
345            self.remember(identity, &archive)?;
346        }
347        Ok(CrateReleaseBundle {
348            version,
349            payload_schema: adapted.payload_schema,
350            provenance: Provenance::LegacyAdapted,
351            descriptor_sha256: Some(adapted.descriptor_sha256),
352            files,
353            metadata: adapted.metadata,
354        })
355    }
356}
357
358impl ReleaseResolver for CratesIoResolver {
359    fn resolve(&self, selector: &Selector) -> Result<ResolvedRelease, AppError> {
360        let identity = match selector {
361            Selector::Embedded => {
362                return Err(AppError::Usage(
363                    "the embedded release is not resolved through the registry".to_string(),
364                ));
365            }
366            Selector::Exact(version) => {
367                // A release the audit already classified unavailable is
368                // answered from the catalog, before anything is fetched.
369                // Its identity would cost an index read that only ever
370                // leads to the same refusal, and on a host with no network
371                // that read fails at the name rather than at the verdict.
372                if self
373                    .catalog
374                    .entry(&version.to_string())
375                    .is_some_and(|entry| !entry.eligible)
376                {
377                    self.catalog.descriptor(&version.to_string())?;
378                }
379                // An exact selector may be answered from a verified cache,
380                // because the identity of an exact version cannot change.
381                if let Some(held) = self.cached_identity(&version.to_string()) {
382                    held
383                } else {
384                    let wanted = version.to_string();
385                    let entries = self.index()?;
386                    let found = entries
387                        .iter()
388                        .find(|entry| entry.vers == wanted)
389                        .ok_or_else(|| {
390                            AppError::Refused(format!(
391                                "the registry serves no {CRATE_NAME} {wanted}"
392                            ))
393                        })?;
394                    CachedIdentity {
395                        version: found.vers.clone(),
396                        cksum: found.cksum.clone(),
397                        yanked: found.yanked,
398                    }
399                }
400            }
401            Selector::Latest => {
402                // A cached answer cannot prove freshness, so `latest` always
403                // reaches the index and `--offline` refuses it outright.
404                if self.offline {
405                    return Err(AppError::Refused(
406                        "--offline cannot resolve latest, because only the index says which release is newest; name an exact version instead".to_string(),
407                    ));
408                }
409                let entries = self.index()?;
410                let mut stable: Vec<(Version, &IndexEntry)> = entries
411                    .iter()
412                    .filter(|entry| !entry.yanked)
413                    .filter_map(|entry| Some((entry.vers.parse::<Version>().ok()?, entry)))
414                    .filter(|(version, _)| version.pre.is_empty())
415                    .collect();
416                stable.sort_by(|left, right| left.0.cmp(&right.0));
417                let (_, found) = stable.last().ok_or_else(|| {
418                    AppError::Refused(format!("the registry serves no stable {CRATE_NAME}"))
419                })?;
420                CachedIdentity {
421                    version: found.vers.clone(),
422                    cksum: found.cksum.clone(),
423                    yanked: found.yanked,
424                }
425            }
426        };
427        let bundle = self.bundle(&identity)?;
428        let manifest = bundle.manifest()?;
429        Ok(ResolvedRelease {
430            selector: selector.clone(),
431            version: manifest.version.clone(),
432            registry_checksum: identity.cksum.parse().ok(),
433            payload_sha256: manifest.payload_sha256,
434            yanked: identity.yanked,
435            bundle: Box::new(bundle),
436        })
437    }
438}
439
440/// Whether a status is worth one more try.
441const fn transient(status: u16) -> bool {
442    status == 429 || matches!(status, 500..=599)
443}
444
445/// Whether a failure was the transport rather than the answer.
446const fn is_transport(error: &ureq::Error) -> bool {
447    matches!(
448        error,
449        ureq::Error::Io(_) | ureq::Error::Timeout(_) | ureq::Error::ConnectionFailed
450    )
451}
452
453/// What the response asks a client to wait, bounded.
454fn retry_after(response: &ureq::http::Response<ureq::Body>) -> Duration {
455    response
456        .headers()
457        .get("retry-after")
458        .and_then(|value| value.to_str().ok())
459        .and_then(|value| value.trim().parse::<u64>().ok())
460        .map_or(Duration::from_millis(500), |seconds| {
461            Duration::from_secs(seconds).min(MAX_RETRY_AFTER)
462        })
463}
464
465/// Fill the registry's download template, or fall back to the default form.
466#[must_use]
467#[allow(
468    clippy::literal_string_with_formatting_args,
469    reason = "the braces are the registry protocol's markers, not formatting arguments"
470)]
471pub fn expand_download(template: &str, name: &str, version: &str, cksum: &str) -> String {
472    // Each entry is a marker the registry protocol defines, not a
473    // formatting argument this function fills in.
474    const MARKERS: [&str; 5] = [
475        "{crate}",
476        "{version}",
477        "{prefix}",
478        "{lowerprefix}",
479        "{sha256-checksum}",
480    ];
481    if !MARKERS.iter().any(|marker| template.contains(marker)) {
482        return format!("{template}/{name}/{version}/download");
483    }
484    let prefix = CratesIoResolver::index_path(name)
485        .rsplit_once('/')
486        .map_or_else(String::new, |(head, _)| head.to_string());
487    template
488        .replace("{crate}", name)
489        .replace("{version}", version)
490        .replace("{prefix}", &prefix)
491        .replace("{lowerprefix}", &prefix.to_lowercase())
492        .replace("{sha256-checksum}", cksum)
493}
494
495/// Read an archive into the payload files it is allowed to carry.
496///
497/// # Errors
498///
499/// [`AppError::Refused`] on any bound, on any entry that is not a regular
500/// file under the package prefix, and on any duplicate logical path.
501pub fn admit(archive: &[u8], prefix: &str) -> Result<BTreeMap<String, Vec<u8>>, AppError> {
502    let refuse = |what: &str| AppError::Refused(format!("the archive is refused: {what}"));
503    if archive.len() as u64 > MAX_COMPRESSED_BYTES {
504        return Err(refuse("it is larger than the compressed cap"));
505    }
506    let decoder = flate2::read::GzDecoder::new(archive);
507    let mut tar = tar::Archive::new(decoder.take(MAX_EXPANDED_BYTES));
508    let roots: Vec<String> = crate::embedded::PAYLOAD_ROOTS
509        .iter()
510        .map(|root| format!("{root}/"))
511        .collect();
512    let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
513    let mut seen: BTreeSet<String> = BTreeSet::new();
514    let mut entries = 0usize;
515    let mut expanded = 0u64;
516    for entry in tar
517        .entries()
518        .map_err(|source| refuse(&format!("its index does not read: {source}")))?
519    {
520        let mut entry =
521            entry.map_err(|source| refuse(&format!("an entry does not read: {source}")))?;
522        entries += 1;
523        if entries > MAX_ENTRIES {
524            return Err(refuse("it holds more entries than the cap"));
525        }
526        let path = entry
527            .path()
528            .map_err(|source| refuse(&format!("an entry has no readable path: {source}")))?
529            .to_string_lossy()
530            .to_string();
531        if path.starts_with('/') || path.split('/').any(|part| part == "..") {
532            return Err(refuse(&format!("{path} leaves the package")));
533        }
534        if !seen.insert(path.clone()) {
535            return Err(refuse(&format!("{path} appears twice")));
536        }
537        let kind = entry.header().entry_type();
538        if kind.is_dir() {
539            continue;
540        }
541        if !kind.is_file() {
542            return Err(refuse(&format!("{path} is not a regular file")));
543        }
544        let Some(relative) = path
545            .strip_prefix(prefix)
546            .and_then(|rest| rest.strip_prefix('/'))
547        else {
548            return Err(refuse(&format!("{path} is outside {prefix}")));
549        };
550        if !roots.iter().any(|root| relative.starts_with(root)) {
551            continue;
552        }
553        let size = entry.header().size().unwrap_or(u64::MAX);
554        if size > MAX_FILE_BYTES {
555            return Err(refuse(&format!(
556                "{relative} is larger than the per-file cap"
557            )));
558        }
559        expanded = expanded.saturating_add(size);
560        if expanded > MAX_EXPANDED_BYTES {
561            return Err(refuse("it expands past the cap"));
562        }
563        let mut bytes = Vec::new();
564        entry
565            .read_to_end(&mut bytes)
566            .map_err(|source| refuse(&format!("{relative} does not read: {source}")))?;
567        files.insert(relative.to_string(), bytes);
568    }
569    if files.is_empty() {
570        return Err(refuse("it carries no payload root"));
571    }
572    Ok(files)
573}
574
575#[cfg(test)]
576mod tests {
577    #![allow(
578        clippy::unwrap_used,
579        reason = "a test panics as its failure signal, not as control flow"
580    )]
581
582    use super::*;
583
584    /// One `.crate`-shaped archive built in memory.
585    fn archive(entries: &[(&str, &[u8])]) -> Vec<u8> {
586        let mut builder = tar::Builder::new(Vec::new());
587        for (path, bytes) in entries {
588            let mut header = tar::Header::new_gnu();
589            header.set_size(bytes.len() as u64);
590            header.set_mode(0o644);
591            header.set_cksum();
592            builder.append_data(&mut header, path, *bytes).unwrap();
593        }
594        let tarred = builder.into_inner().unwrap();
595        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
596        std::io::Write::write_all(&mut encoder, &tarred).unwrap();
597        encoder.finish().unwrap()
598    }
599
600    #[test]
601    fn the_index_path_follows_the_registry_protocol() {
602        assert_eq!(CratesIoResolver::index_path("a"), "1/a");
603        assert_eq!(CratesIoResolver::index_path("ab"), "2/ab");
604        assert_eq!(CratesIoResolver::index_path("abc"), "3/a/abc");
605        assert_eq!(
606            CratesIoResolver::index_path("spec-driven-docs"),
607            "sp/ec/spec-driven-docs"
608        );
609    }
610
611    #[test]
612    fn a_download_template_without_markers_takes_the_default_form() {
613        assert_eq!(
614            expand_download("https://static.crates.io/crates", "x", "1.0.0", "ab"),
615            "https://static.crates.io/crates/x/1.0.0/download"
616        );
617    }
618
619    #[test]
620    fn a_download_template_with_markers_is_filled() {
621        assert_eq!(
622            expand_download(
623                "https://example.test/{prefix}/{crate}/{version}/{sha256-checksum}",
624                "spec-driven-docs",
625                "1.0.0",
626                "abc"
627            ),
628            "https://example.test/sp/ec/spec-driven-docs/1.0.0/abc"
629        );
630    }
631
632    #[test]
633    fn an_archive_admits_only_payload_roots_under_the_package_prefix() {
634        let bytes = archive(&[
635            ("spec-driven-docs-1.0.0/method/one.md", b"one\n"),
636            ("spec-driven-docs-1.0.0/src/main.rs", b"fn main() {}\n"),
637            ("spec-driven-docs-1.0.0/Cargo.toml", b"[package]\n"),
638        ]);
639        let files = admit(&bytes, "spec-driven-docs-1.0.0").unwrap();
640        assert_eq!(files.keys().collect::<Vec<_>>(), ["method/one.md"]);
641    }
642
643    #[test]
644    fn an_archive_with_no_payload_root_refuses() {
645        let bytes = archive(&[("spec-driven-docs-1.0.0/src/main.rs", b"x")]);
646        let error = admit(&bytes, "spec-driven-docs-1.0.0").unwrap_err();
647        assert!(error.to_string().contains("no payload root"), "{error}");
648    }
649
650    #[test]
651    fn an_entry_outside_the_package_prefix_refuses() {
652        let bytes = archive(&[("elsewhere/method/one.md", b"one\n")]);
653        let error = admit(&bytes, "spec-driven-docs-1.0.0").unwrap_err();
654        assert!(error.to_string().contains("outside"), "{error}");
655    }
656
657    /// One archive whose entry name the `tar` writer would refuse.
658    ///
659    /// The header is built by hand because the library will not produce a
660    /// hostile name, and a hostile name is exactly what the admission rule
661    /// exists to refuse.
662    fn hostile(name: &str, body: &[u8]) -> Vec<u8> {
663        let mut header = [0u8; 512];
664        header[..name.len()].copy_from_slice(name.as_bytes());
665        header[100..108].copy_from_slice(b"0000644\0");
666        header[108..116].copy_from_slice(b"0000000\0");
667        header[116..124].copy_from_slice(b"0000000\0");
668        header[124..136].copy_from_slice(format!("{:011o}\0", body.len()).as_bytes());
669        header[136..148].copy_from_slice(b"00000000000\0");
670        header[148..156].copy_from_slice(b"        ");
671        header[156] = b'0';
672        header[257..263].copy_from_slice(b"ustar\0");
673        header[263..265].copy_from_slice(b"00");
674        let sum: u32 = header.iter().map(|byte| u32::from(*byte)).sum();
675        header[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
676
677        let mut tarred = header.to_vec();
678        tarred.extend_from_slice(body);
679        tarred.resize(tarred.len().next_multiple_of(512), 0);
680        tarred.extend_from_slice(&[0u8; 1024]);
681
682        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
683        std::io::Write::write_all(&mut encoder, &tarred).unwrap();
684        encoder.finish().unwrap()
685    }
686
687    #[test]
688    fn traversal_and_absolute_paths_refuse() {
689        for name in ["spec-driven-docs-1.0.0/../escape.md", "/etc/passwd"] {
690            let bytes = hostile(name, b"x");
691            let error = admit(&bytes, "spec-driven-docs-1.0.0").unwrap_err();
692            assert!(
693                error.to_string().contains("leaves the package"),
694                "{name}: {error}"
695            );
696        }
697    }
698
699    #[test]
700    fn a_duplicate_logical_path_refuses() {
701        let bytes = archive(&[
702            ("spec-driven-docs-1.0.0/method/one.md", b"one\n"),
703            ("spec-driven-docs-1.0.0/method/one.md", b"two\n"),
704        ]);
705        let error = admit(&bytes, "spec-driven-docs-1.0.0").unwrap_err();
706        assert!(error.to_string().contains("appears twice"), "{error}");
707    }
708
709    #[test]
710    fn a_link_entry_refuses() {
711        let mut builder = tar::Builder::new(Vec::new());
712        let mut header = tar::Header::new_gnu();
713        header.set_size(0);
714        header.set_entry_type(tar::EntryType::Symlink);
715        header.set_mode(0o777);
716        builder
717            .append_link(
718                &mut header,
719                "spec-driven-docs-1.0.0/method/link.md",
720                "/etc/passwd",
721            )
722            .unwrap();
723        let tarred = builder.into_inner().unwrap();
724        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
725        std::io::Write::write_all(&mut encoder, &tarred).unwrap();
726        let bytes = encoder.finish().unwrap();
727        let error = admit(&bytes, "spec-driven-docs-1.0.0").unwrap_err();
728        assert!(error.to_string().contains("not a regular file"), "{error}");
729    }
730
731    #[test]
732    fn a_file_over_the_per_file_cap_refuses() {
733        let big = vec![b'x'; usize::try_from(MAX_FILE_BYTES).unwrap() + 1];
734        let bytes = archive(&[("spec-driven-docs-1.0.0/method/big.md", &big)]);
735        let error = admit(&bytes, "spec-driven-docs-1.0.0").unwrap_err();
736        assert!(error.to_string().contains("per-file cap"), "{error}");
737    }
738
739    #[test]
740    fn offline_refuses_latest_and_an_uncached_exact() {
741        let dir = tempfile::tempdir().unwrap();
742        let cache = Utf8PathBuf::from(dir.path().to_str().unwrap());
743        let resolver = CratesIoResolver::new(&cache).offline(true);
744        let error = resolver.resolve(&Selector::Latest).unwrap_err();
745        assert!(
746            error.to_string().contains("cannot resolve latest"),
747            "{error}"
748        );
749        let error = resolver
750            .resolve(&Selector::Exact("0.8.0".parse().unwrap()))
751            .unwrap_err();
752        assert!(error.to_string().contains("--offline forbids"), "{error}");
753    }
754
755    #[test]
756    fn the_embedded_selector_is_not_the_registry_resolvers_business() {
757        let dir = tempfile::tempdir().unwrap();
758        let cache = Utf8PathBuf::from(dir.path().to_str().unwrap());
759        let error = CratesIoResolver::new(&cache)
760            .resolve(&Selector::Embedded)
761            .unwrap_err();
762        assert_eq!(error.exit_code(), 64);
763    }
764}