Skip to main content

bock_pkg/
network.rs

1//! Network-backed package registry client.
2//!
3//! Implements the registry protocol defined in spec §19.5:
4//!
5//! ```text
6//! GET /packages/{name}                   → { versions, latest }
7//! GET /packages/{name}/{version}         → { manifest, checksum, download_url }
8//! GET /packages/{name}/{version}/download → tarball bytes
9//! ```
10//!
11//! Tarballs are cached under `cache_dir` after SHA-256 verification.
12//! A [`PackageRegistry`] can be passed as a fallback for offline use
13//! or private overrides.
14
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19use serde::Deserialize;
20use sha2::{Digest, Sha256};
21
22use crate::error::PkgError;
23use crate::resolver::{PackageRegistry, PackageVersionMeta};
24
25/// Response body for `GET /packages/{name}`.
26#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
27pub struct VersionsResponse {
28    /// All versions known for this package (semver strings).
29    pub versions: Vec<String>,
30    /// The latest (highest) stable version, hinted by the registry.
31    pub latest: String,
32}
33
34/// Response body for `GET /packages/{name}/{version}`.
35#[derive(Debug, Clone, Deserialize)]
36pub struct VersionMetaResponse {
37    /// Subset of the package manifest relevant for resolution.
38    pub manifest: ManifestData,
39    /// SHA-256 digest of the tarball, optionally prefixed with `"sha256:"`.
40    pub checksum: String,
41    /// URL from which the tarball can be fetched. If empty, the default
42    /// `/packages/{name}/{version}/download` endpoint is used.
43    #[serde(default)]
44    pub download_url: String,
45}
46
47/// Manifest fragment served by the registry for a specific version.
48#[derive(Debug, Clone, Deserialize, Default)]
49pub struct ManifestData {
50    /// Direct dependencies: name → version requirement string.
51    #[serde(default)]
52    pub dependencies: BTreeMap<String, String>,
53    /// Targets this version supports. `None` = all targets.
54    #[serde(default)]
55    pub supported_targets: Option<Vec<String>>,
56    /// Features declared by this version.
57    #[serde(default)]
58    pub available_features: BTreeMap<String, Vec<String>>,
59    /// Features requested from each dependency.
60    #[serde(default)]
61    pub dep_features: BTreeMap<String, Vec<String>>,
62}
63
64/// Environment variable that supplies a Bearer auth token for private registries.
65pub const AUTH_TOKEN_ENV: &str = "BOCK_REGISTRY_TOKEN";
66
67/// A network-backed registry that fetches metadata and tarballs over HTTPS.
68///
69/// Hydrate into a [`PackageRegistry`] via [`Self::hydrate`] before running
70/// resolution, since resolution is driven by the in-memory provider.
71pub struct NetworkRegistry {
72    base_url: String,
73    client: reqwest::blocking::Client,
74    cache_dir: PathBuf,
75    fallback: Option<PackageRegistry>,
76    auth_token: Option<String>,
77}
78
79impl NetworkRegistry {
80    /// Build a client pointed at `base_url` with tarballs cached under `cache_dir`.
81    ///
82    /// The cache directory is created if it does not exist.
83    pub fn new(
84        base_url: impl Into<String>,
85        cache_dir: impl Into<PathBuf>,
86    ) -> Result<Self, PkgError> {
87        let client = reqwest::blocking::Client::builder()
88            .timeout(Duration::from_secs(30))
89            .user_agent(concat!("bock-pkg/", env!("CARGO_PKG_VERSION")))
90            .build()
91            .map_err(|e| PkgError::Network(e.to_string()))?;
92        let cache_dir = cache_dir.into();
93        std::fs::create_dir_all(&cache_dir).map_err(|e| PkgError::Io(e.to_string()))?;
94        let base_url = base_url.into().trim_end_matches('/').to_string();
95        Ok(Self {
96            base_url,
97            client,
98            cache_dir,
99            fallback: None,
100            auth_token: std::env::var(AUTH_TOKEN_ENV).ok().filter(|s| !s.is_empty()),
101        })
102    }
103
104    /// Attach an in-memory [`PackageRegistry`] to serve entries the network
105    /// does not know about (or cannot be reached for).
106    #[must_use]
107    pub fn with_fallback(mut self, fallback: PackageRegistry) -> Self {
108        self.fallback = Some(fallback);
109        self
110    }
111
112    /// Override the Bearer auth token used for registry requests.
113    ///
114    /// Pass `None` to clear the token (useful for tests that want to skip the
115    /// environment-provided value). By default, the value of the
116    /// [`AUTH_TOKEN_ENV`] environment variable is used.
117    #[must_use]
118    pub fn with_auth_token(mut self, token: Option<String>) -> Self {
119        self.auth_token = token.filter(|s| !s.is_empty());
120        self
121    }
122
123    /// The Bearer auth token currently in effect, if any.
124    #[must_use]
125    pub fn auth_token(&self) -> Option<&str> {
126        self.auth_token.as_deref()
127    }
128
129    fn authed_get(&self, url: &str) -> reqwest::blocking::RequestBuilder {
130        let mut req = self.client.get(url);
131        if let Some(token) = &self.auth_token {
132            req = req.bearer_auth(token);
133        }
134        req
135    }
136
137    /// Base URL of the registry (with any trailing slash stripped).
138    #[must_use]
139    pub fn base_url(&self) -> &str {
140        &self.base_url
141    }
142
143    /// Directory where downloaded tarballs are cached.
144    #[must_use]
145    pub fn cache_dir(&self) -> &Path {
146        &self.cache_dir
147    }
148
149    /// Fetch the list of versions available for `name`.
150    pub fn fetch_versions(&self, name: &str) -> Result<VersionsResponse, PkgError> {
151        let url = format!("{}/packages/{}", self.base_url, name);
152        let response = self
153            .authed_get(&url)
154            .send()
155            .map_err(|e| PkgError::Network(format!("GET {url}: {e}")))?;
156        if response.status() == reqwest::StatusCode::NOT_FOUND {
157            return Err(PkgError::PackageNotFound(name.to_string()));
158        }
159        if !response.status().is_success() {
160            return Err(PkgError::Network(format!(
161                "GET {url}: status {}",
162                response.status()
163            )));
164        }
165        response
166            .json()
167            .map_err(|e| PkgError::Network(format!("decoding {url}: {e}")))
168    }
169
170    /// Fetch the metadata for a specific `version` of `name`.
171    pub fn fetch_version_meta(
172        &self,
173        name: &str,
174        version: &str,
175    ) -> Result<VersionMetaResponse, PkgError> {
176        let url = format!("{}/packages/{}/{}", self.base_url, name, version);
177        let response = self
178            .authed_get(&url)
179            .send()
180            .map_err(|e| PkgError::Network(format!("GET {url}: {e}")))?;
181        if response.status() == reqwest::StatusCode::NOT_FOUND {
182            return Err(PkgError::PackageNotFound(format!("{name}@{version}")));
183        }
184        if !response.status().is_success() {
185            return Err(PkgError::Network(format!(
186                "GET {url}: status {}",
187                response.status()
188            )));
189        }
190        response
191            .json()
192            .map_err(|e| PkgError::Network(format!("decoding {url}: {e}")))
193    }
194
195    /// Download a package tarball, verifying its checksum, and cache it.
196    ///
197    /// Returns the path of the cached tarball. If the file already exists in
198    /// the cache, it is returned without re-fetching or re-verifying — the
199    /// tarball name embeds the version, so the cache key is version-scoped.
200    pub fn download_package(&self, name: &str, version: &str) -> Result<PathBuf, PkgError> {
201        let cache_path = self.cache_dir.join(format!("{name}-{version}.tar.gz"));
202        if cache_path.exists() {
203            return Ok(cache_path);
204        }
205
206        let meta = self.fetch_version_meta(name, version)?;
207        let tarball_url = if meta.download_url.is_empty() {
208            format!("{}/packages/{}/{}/download", self.base_url, name, version)
209        } else {
210            meta.download_url.clone()
211        };
212
213        let response = self
214            .authed_get(&tarball_url)
215            .send()
216            .map_err(|e| PkgError::Network(format!("GET {tarball_url}: {e}")))?;
217        if !response.status().is_success() {
218            return Err(PkgError::Network(format!(
219                "GET {tarball_url}: status {}",
220                response.status()
221            )));
222        }
223        let bytes = response
224            .bytes()
225            .map_err(|e| PkgError::Network(format!("reading {tarball_url}: {e}")))?;
226
227        verify_checksum(&bytes, &meta.checksum)?;
228
229        std::fs::write(&cache_path, &bytes).map_err(|e| PkgError::Io(e.to_string()))?;
230        Ok(cache_path)
231    }
232
233    /// Fetch a package: resolve metadata, download (or reuse cached) tarball,
234    /// and return the cached path together with the metadata response.
235    ///
236    /// When the tarball is already cached, the checksum in the returned meta
237    /// is still fetched from the registry to keep lockfile entries accurate.
238    pub fn fetch_package(&self, name: &str, version: &str) -> Result<FetchedPackage, PkgError> {
239        let meta = self.fetch_version_meta(name, version)?;
240        let cache_path = self.cache_dir.join(format!("{name}-{version}.tar.gz"));
241
242        if !cache_path.exists() {
243            let tarball_url = if meta.download_url.is_empty() {
244                format!("{}/packages/{}/{}/download", self.base_url, name, version)
245            } else {
246                meta.download_url.clone()
247            };
248
249            let response = self
250                .authed_get(&tarball_url)
251                .send()
252                .map_err(|e| PkgError::Network(format!("GET {tarball_url}: {e}")))?;
253            if !response.status().is_success() {
254                return Err(PkgError::Network(format!(
255                    "GET {tarball_url}: status {}",
256                    response.status()
257                )));
258            }
259            let bytes = response
260                .bytes()
261                .map_err(|e| PkgError::Network(format!("reading {tarball_url}: {e}")))?;
262
263            verify_checksum(&bytes, &meta.checksum)?;
264
265            std::fs::write(&cache_path, &bytes).map_err(|e| PkgError::Io(e.to_string()))?;
266        } else {
267            // Cache hit — sanity-check the cached bytes against the registry checksum.
268            let bytes = std::fs::read(&cache_path).map_err(|e| PkgError::Io(e.to_string()))?;
269            verify_checksum(&bytes, &meta.checksum)?;
270        }
271
272        Ok(FetchedPackage {
273            tarball_path: cache_path,
274            checksum: normalize_checksum(&meta.checksum),
275            meta,
276        })
277    }
278
279    /// Hydrate an in-memory [`PackageRegistry`] by fetching metadata for each
280    /// of the named packages.
281    ///
282    /// If a `fallback` was attached, it seeds the returned registry and absorbs
283    /// any packages the network layer could not resolve (offline or not found).
284    /// Per-package network errors are swallowed when a fallback is present so
285    /// offline operation can continue; otherwise they propagate.
286    pub fn hydrate(&self, names: &[&str]) -> Result<PackageRegistry, PkgError> {
287        let mut registry = self.fallback.clone().unwrap_or_default();
288        for name in names {
289            match self.fetch_versions(name) {
290                Ok(versions) => {
291                    for version in &versions.versions {
292                        match self.fetch_version_meta(name, version) {
293                            Ok(meta) => {
294                                let pkg_meta = PackageVersionMeta {
295                                    deps: meta.manifest.dependencies,
296                                    dep_features: meta.manifest.dep_features,
297                                    supported_targets: meta.manifest.supported_targets,
298                                    available_features: meta.manifest.available_features,
299                                };
300                                registry.register_with_meta(name, version, pkg_meta)?;
301                            }
302                            Err(PkgError::Network(_)) if self.fallback.is_some() => {}
303                            Err(e) => return Err(e),
304                        }
305                    }
306                }
307                Err(PkgError::Network(_)) if self.fallback.is_some() => {}
308                Err(PkgError::PackageNotFound(_)) if registry.has_package(name) => {
309                    // Fallback already provides it — continue.
310                }
311                Err(e) => return Err(e),
312            }
313        }
314        Ok(registry)
315    }
316}
317
318/// Result of [`NetworkRegistry::fetch_package`].
319#[derive(Debug, Clone)]
320pub struct FetchedPackage {
321    /// Path to the verified tarball in the cache directory.
322    pub tarball_path: PathBuf,
323    /// Canonicalized checksum (bare hex, lowercased) recorded for the lockfile.
324    pub checksum: String,
325    /// Full metadata response from the registry.
326    pub meta: VersionMetaResponse,
327}
328
329/// Strip any `sha256:` prefix and lowercase the remaining hex for storage.
330#[must_use]
331pub fn normalize_checksum(checksum: &str) -> String {
332    checksum
333        .strip_prefix("sha256:")
334        .unwrap_or(checksum)
335        .to_ascii_lowercase()
336}
337
338/// Verify a byte buffer against a SHA-256 checksum.
339///
340/// Accepts both bare hex (`"a1b2..."`) and the `"sha256:"`-prefixed form used
341/// by the registry. Returns [`PkgError::ChecksumMismatch`] on disagreement.
342pub fn verify_checksum(data: &[u8], expected: &str) -> Result<(), PkgError> {
343    let expected_hex = expected.strip_prefix("sha256:").unwrap_or(expected);
344    let actual = sha256_hex(data);
345    if !actual.eq_ignore_ascii_case(expected_hex) {
346        return Err(PkgError::ChecksumMismatch {
347            expected: expected_hex.to_string(),
348            actual,
349        });
350    }
351    Ok(())
352}
353
354/// Compute the hex-encoded SHA-256 digest of `data`.
355#[must_use]
356pub fn sha256_hex(data: &[u8]) -> String {
357    let digest = Sha256::digest(data);
358    let mut out = String::with_capacity(digest.len() * 2);
359    for byte in digest {
360        use std::fmt::Write;
361        let _ = write!(out, "{byte:02x}");
362    }
363    out
364}
365
366/// The `[registries]` section of an `bock.project` file.
367#[derive(Debug, Clone, Deserialize, Default)]
368pub struct RegistriesSection {
369    /// URL of the default registry. Falls back to the built-in public
370    /// registry if unset.
371    pub default: Option<String>,
372    /// Named private registries (e.g. `internal = "https://..."`).
373    #[serde(flatten)]
374    pub named: BTreeMap<String, String>,
375}
376
377/// Parse just the `[registries]` section out of an `bock.project` TOML string.
378///
379/// Other sections are ignored, so this is safe to call on any project file.
380pub fn parse_registries(project_toml: &str) -> Result<RegistriesSection, PkgError> {
381    #[derive(Deserialize)]
382    struct Wrapper {
383        #[serde(default)]
384        registries: RegistriesSection,
385    }
386    let wrapper: Wrapper = toml::from_str(project_toml)
387        .map_err(|e| PkgError::ManifestParse(format!("bock.project: {e}")))?;
388    Ok(wrapper.registries)
389}
390
391/// Resolve the effective default registry URL for a project.
392///
393/// Reads `bock.project` in `project_dir`. If the file is missing or lacks a
394/// `[registries]` section, returns `None` — callers should fall back to the
395/// in-memory registry in that case.
396pub fn default_registry_url(project_dir: &Path) -> Option<String> {
397    let path = project_dir.join("bock.project");
398    let content = std::fs::read_to_string(&path).ok()?;
399    parse_registries(&content).ok()?.default
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use mockito::Server;
406
407    fn tmp_cache() -> (tempfile::TempDir, PathBuf) {
408        let dir = tempfile::tempdir().unwrap();
409        let path = dir.path().join("cache");
410        (dir, path)
411    }
412
413    #[test]
414    fn sha256_hex_matches_known_vector() {
415        // SHA-256("abc") = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
416        assert_eq!(
417            sha256_hex(b"abc"),
418            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
419        );
420    }
421
422    #[test]
423    fn verify_checksum_accepts_matching_hex() {
424        let bytes = b"hello world";
425        let hex = sha256_hex(bytes);
426        verify_checksum(bytes, &hex).unwrap();
427    }
428
429    #[test]
430    fn verify_checksum_accepts_sha256_prefix() {
431        let bytes = b"hello world";
432        let hex = sha256_hex(bytes);
433        verify_checksum(bytes, &format!("sha256:{hex}")).unwrap();
434    }
435
436    #[test]
437    fn verify_checksum_rejects_mismatch() {
438        let result = verify_checksum(b"hello", "sha256:deadbeef");
439        assert!(matches!(result, Err(PkgError::ChecksumMismatch { .. })));
440    }
441
442    #[test]
443    fn fetch_versions_parses_response() {
444        let mut server = Server::new();
445        let mock = server
446            .mock("GET", "/packages/foo")
447            .with_status(200)
448            .with_header("content-type", "application/json")
449            .with_body(r#"{"versions":["1.0.0","1.1.0"],"latest":"1.1.0"}"#)
450            .create();
451
452        let (_tmp, cache) = tmp_cache();
453        let reg = NetworkRegistry::new(server.url(), cache).unwrap();
454        let resp = reg.fetch_versions("foo").unwrap();
455
456        assert_eq!(resp.versions, vec!["1.0.0", "1.1.0"]);
457        assert_eq!(resp.latest, "1.1.0");
458        mock.assert();
459    }
460
461    #[test]
462    fn fetch_versions_maps_404_to_package_not_found() {
463        let mut server = Server::new();
464        let _mock = server
465            .mock("GET", "/packages/missing")
466            .with_status(404)
467            .create();
468
469        let (_tmp, cache) = tmp_cache();
470        let reg = NetworkRegistry::new(server.url(), cache).unwrap();
471        let err = reg.fetch_versions("missing").unwrap_err();
472        assert!(matches!(err, PkgError::PackageNotFound(_)));
473    }
474
475    #[test]
476    fn fetch_version_meta_parses_manifest() {
477        let mut server = Server::new();
478        let body = r#"{
479            "manifest": {
480                "dependencies": {"bar": "^1.0"},
481                "supported_targets": ["js", "rust"],
482                "available_features": {"json": []}
483            },
484            "checksum": "sha256:abc",
485            "download_url": ""
486        }"#;
487        let mock = server
488            .mock("GET", "/packages/foo/1.0.0")
489            .with_status(200)
490            .with_header("content-type", "application/json")
491            .with_body(body)
492            .create();
493
494        let (_tmp, cache) = tmp_cache();
495        let reg = NetworkRegistry::new(server.url(), cache).unwrap();
496        let meta = reg.fetch_version_meta("foo", "1.0.0").unwrap();
497
498        assert_eq!(meta.checksum, "sha256:abc");
499        assert_eq!(meta.manifest.dependencies["bar"], "^1.0");
500        assert_eq!(
501            meta.manifest.supported_targets,
502            Some(vec!["js".into(), "rust".into()])
503        );
504        mock.assert();
505    }
506
507    #[test]
508    fn download_package_verifies_and_caches() {
509        let mut server = Server::new();
510        let tarball = b"fake tarball contents";
511        let checksum = sha256_hex(tarball);
512        let body = format!(
513            r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
514        );
515        let _meta = server
516            .mock("GET", "/packages/foo/1.0.0")
517            .with_status(200)
518            .with_header("content-type", "application/json")
519            .with_body(body)
520            .create();
521        let _download = server
522            .mock("GET", "/packages/foo/1.0.0/download")
523            .with_status(200)
524            .with_body(tarball)
525            .create();
526
527        let (_tmp, cache) = tmp_cache();
528        let reg = NetworkRegistry::new(server.url(), &cache).unwrap();
529        let path = reg.download_package("foo", "1.0.0").unwrap();
530
531        assert!(path.exists());
532        assert_eq!(std::fs::read(&path).unwrap(), tarball);
533
534        // Second call should be served from cache — no new mock expectations.
535        let again = reg.download_package("foo", "1.0.0").unwrap();
536        assert_eq!(again, path);
537    }
538
539    #[test]
540    fn download_package_rejects_bad_checksum() {
541        let mut server = Server::new();
542        let tarball = b"bytes that do not match";
543        let body =
544            r#"{"manifest":{"dependencies":{}},"checksum":"sha256:deadbeef","download_url":""}"#;
545        let _meta = server
546            .mock("GET", "/packages/foo/1.0.0")
547            .with_status(200)
548            .with_header("content-type", "application/json")
549            .with_body(body)
550            .create();
551        let _download = server
552            .mock("GET", "/packages/foo/1.0.0/download")
553            .with_status(200)
554            .with_body(tarball)
555            .create();
556
557        let (_tmp, cache) = tmp_cache();
558        let reg = NetworkRegistry::new(server.url(), &cache).unwrap();
559        let err = reg.download_package("foo", "1.0.0").unwrap_err();
560        assert!(matches!(err, PkgError::ChecksumMismatch { .. }));
561        // Nothing written to cache on failure.
562        assert!(!cache.join("foo-1.0.0.tar.gz").exists());
563    }
564
565    #[test]
566    fn download_package_honors_custom_download_url() {
567        let mut server = Server::new();
568        let tarball = b"custom url payload";
569        let checksum = sha256_hex(tarball);
570        let custom_url = format!("{}/mirror/foo-1.0.0.tgz", server.url());
571        let body = format!(
572            r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":"{custom_url}"}}"#
573        );
574        let _meta = server
575            .mock("GET", "/packages/foo/1.0.0")
576            .with_status(200)
577            .with_header("content-type", "application/json")
578            .with_body(body)
579            .create();
580        let _download = server
581            .mock("GET", "/mirror/foo-1.0.0.tgz")
582            .with_status(200)
583            .with_body(tarball)
584            .create();
585
586        let (_tmp, cache) = tmp_cache();
587        let reg = NetworkRegistry::new(server.url(), &cache).unwrap();
588        let path = reg.download_package("foo", "1.0.0").unwrap();
589        assert_eq!(std::fs::read(&path).unwrap(), tarball);
590    }
591
592    #[test]
593    fn hydrate_populates_registry_from_network() {
594        let mut server = Server::new();
595        let _v = server
596            .mock("GET", "/packages/foo")
597            .with_status(200)
598            .with_body(r#"{"versions":["1.0.0"],"latest":"1.0.0"}"#)
599            .create();
600        let _m = server
601            .mock("GET", "/packages/foo/1.0.0")
602            .with_status(200)
603            .with_body(
604                r#"{"manifest":{"dependencies":{"bar":"^1.0"}},"checksum":"sha256:x","download_url":""}"#,
605            )
606            .create();
607
608        let (_tmp, cache) = tmp_cache();
609        let reg = NetworkRegistry::new(server.url(), cache).unwrap();
610        let registry = reg.hydrate(&["foo"]).unwrap();
611
612        assert!(registry.has_package("foo"));
613        assert_eq!(registry.available_versions("foo").len(), 1);
614    }
615
616    #[test]
617    fn hydrate_falls_back_when_network_unreachable() {
618        // Point at a URL with no server listening; with a fallback, hydration
619        // should swallow the transport error and return the fallback entries.
620        let mut fallback = PackageRegistry::new();
621        fallback.register("foo", "1.0.0", BTreeMap::new()).unwrap();
622
623        let (_tmp, cache) = tmp_cache();
624        let reg = NetworkRegistry::new("http://127.0.0.1:1/", cache)
625            .unwrap()
626            .with_fallback(fallback);
627
628        let registry = reg.hydrate(&["foo"]).unwrap();
629        assert!(registry.has_package("foo"));
630    }
631
632    #[test]
633    fn parse_registries_reads_default_and_named() {
634        let project = r#"
635[project]
636name = "test"
637version = "0.1.0"
638
639[registries]
640default = "https://registry.bock-lang.dev/api/v1"
641internal = "https://bock.company.internal"
642"#;
643        let regs = parse_registries(project).unwrap();
644        assert_eq!(
645            regs.default.as_deref(),
646            Some("https://registry.bock-lang.dev/api/v1")
647        );
648        assert_eq!(
649            regs.named.get("internal").map(String::as_str),
650            Some("https://bock.company.internal"),
651        );
652    }
653
654    #[test]
655    fn parse_registries_missing_section_is_empty() {
656        let project = r#"
657[project]
658name = "test"
659version = "0.1.0"
660"#;
661        let regs = parse_registries(project).unwrap();
662        assert!(regs.default.is_none());
663        assert!(regs.named.is_empty());
664    }
665
666    #[test]
667    fn default_registry_url_reads_from_file() {
668        let dir = tempfile::tempdir().unwrap();
669        std::fs::write(
670            dir.path().join("bock.project"),
671            "[project]\nname = \"t\"\nversion = \"0.1.0\"\n\n[registries]\ndefault = \"https://example.com/api/v1\"\n",
672        )
673        .unwrap();
674        assert_eq!(
675            default_registry_url(dir.path()).as_deref(),
676            Some("https://example.com/api/v1")
677        );
678    }
679
680    #[test]
681    fn default_registry_url_missing_file_is_none() {
682        let dir = tempfile::tempdir().unwrap();
683        assert!(default_registry_url(dir.path()).is_none());
684    }
685}