1use 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
36pub const CRATE_NAME: &str = "spec-driven-docs";
38
39pub const INDEX_ROOT: &str = "https://index.crates.io";
41
42const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
44const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
46const TOTAL_TIMEOUT: Duration = Duration::from_secs(120);
48const RETRY_BUDGET: u32 = 2;
50const MAX_RETRY_AFTER: Duration = Duration::from_secs(10);
52const MAX_INDEX_BYTES: u64 = 16 * 1024 * 1024;
54const MAX_COMPRESSED_BYTES: u64 = 32 * 1024 * 1024;
56const MAX_EXPANDED_BYTES: u64 = 128 * 1024 * 1024;
58const MAX_ENTRIES: usize = 20_000;
60const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024;
62
63#[derive(Debug, Clone, Deserialize)]
65struct IndexEntry {
66 vers: String,
67 cksum: String,
68 #[serde(default)]
69 yanked: bool,
70}
71
72#[derive(Debug, Clone, Deserialize)]
74struct RegistryConfig {
75 dl: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80struct CachedIdentity {
81 version: String,
82 cksum: String,
83 yanked: bool,
84}
85
86#[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#[derive(Debug, Clone)]
116pub struct CratesIoResolver {
117 cache: Utf8PathBuf,
118 offline: bool,
119 index_root: String,
120 catalog: LegacyCatalog,
121}
122
123impl CratesIoResolver {
124 #[must_use]
126 pub fn new(cache: &Utf8Path) -> Self {
127 Self {
128 cache: cache.to_owned(),
129 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 #[must_use]
141 pub const fn offline(mut self, offline: bool) -> Self {
142 self.offline = self.offline || offline;
143 self
144 }
145
146 #[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 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 #[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 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 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 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 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 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 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 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 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 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 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 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
466const fn transient(status: u16) -> bool {
468 status == 429 || matches!(status, 500..=599)
469}
470
471const fn is_transport(error: &ureq::Error) -> bool {
473 matches!(
474 error,
475 ureq::Error::Io(_) | ureq::Error::Timeout(_) | ureq::Error::ConnectionFailed
476 )
477}
478
479fn 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#[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 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
521pub 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 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 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}