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