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 #[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 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 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 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 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 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 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 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 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 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 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 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
440const fn transient(status: u16) -> bool {
442 status == 429 || matches!(status, 500..=599)
443}
444
445const fn is_transport(error: &ureq::Error) -> bool {
447 matches!(
448 error,
449 ureq::Error::Io(_) | ureq::Error::Timeout(_) | ureq::Error::ConnectionFailed
450 )
451}
452
453fn 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#[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 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
495pub 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 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 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}