Skip to main content

bock_pkg/
install.rs

1//! High-level package installation: fetch from registry, extract, lock.
2//!
3//! The install flow glues the pieces together:
4//!
5//! 1. Resolve a version for `name` (from `version_req`, or the registry's
6//!    `latest` if none is given).
7//! 2. Download the tarball via [`NetworkRegistry`], which caches it under
8//!    the cache directory and verifies its SHA-256 checksum.
9//! 3. Extract the tarball into `.bock/packages/<name>/<version>/`.
10//! 4. Update `bock.package` to list the dependency.
11//! 5. Update `bock.lock` with a [`LockedPackage`] entry carrying the exact
12//!    resolved version and checksum for reproducibility.
13//!
14//! Offline mode: when no registry can be reached and a matching tarball
15//! already sits in the cache, installation is still possible — the tarball
16//! is extracted and the lockfile kept consistent, but the manifest entry
17//! records whichever version was already cached.
18
19use std::path::{Path, PathBuf};
20
21use flate2::read::GzDecoder;
22use semver::{Version, VersionReq};
23use tar::Archive;
24
25use crate::commands;
26use crate::error::{PkgError, PkgResult};
27use crate::lockfile::{LockedPackage, Lockfile};
28use crate::manifest::{DependencySpec, Manifest};
29use crate::network::{normalize_checksum, FetchedPackage, NetworkRegistry};
30use crate::version::parse_version_req;
31
32/// Relative path (under a project) where extracted packages are installed.
33pub const PACKAGES_SUBDIR: &str = ".bock/packages";
34
35/// Relative path (under a project) of the tarball cache.
36pub const CACHE_SUBDIR: &str = ".bock/cache";
37
38/// Options controlling a single package install.
39#[derive(Debug, Clone, Default)]
40pub struct InstallOptions {
41    /// Do not hit the network — use only cached tarballs. Errors if the
42    /// requested version is not already present in the cache.
43    pub offline: bool,
44    /// Version requirement (e.g. `"^1.0"`). `None` → install the registry's
45    /// latest version.
46    pub version_req: Option<String>,
47}
48
49/// Information about a newly installed package.
50#[derive(Debug, Clone)]
51pub struct InstalledPackage {
52    /// Package name as listed in the manifest.
53    pub name: String,
54    /// Exact resolved version (semver).
55    pub version: Version,
56    /// Where the package was extracted (absolute path).
57    pub install_dir: PathBuf,
58    /// SHA-256 hex of the tarball, written to the lockfile.
59    pub checksum: String,
60    /// Source URL the package was fetched from (registry base), or `"cache"`
61    /// when the install was served entirely from the local cache.
62    pub source: String,
63}
64
65/// Install a package: download, extract, and update the manifest + lockfile.
66///
67/// `project_dir` is the directory containing `bock.package` (and where the
68/// `.bock/` subtree will live). `registry` must already be configured with
69/// the caller's cache directory and, optionally, an auth token.
70pub fn install_package(
71    project_dir: &Path,
72    registry: &NetworkRegistry,
73    name: &str,
74    options: &InstallOptions,
75) -> PkgResult<InstalledPackage> {
76    let manifest_path = project_dir.join(commands::MANIFEST_FILE);
77    if !manifest_path.exists() {
78        return Err(PkgError::Io(format!(
79            "no {} found in {}",
80            commands::MANIFEST_FILE,
81            project_dir.display()
82        )));
83    }
84
85    let resolved = resolve_and_fetch(registry, name, options)?;
86
87    let install_dir = project_dir
88        .join(PACKAGES_SUBDIR)
89        .join(name)
90        .join(resolved.version.to_string());
91    extract_tarball(&resolved.tarball_path, &install_dir)?;
92
93    let version_spec = options
94        .version_req
95        .clone()
96        .unwrap_or_else(|| format!("^{}", resolved.version));
97    commands::add(&manifest_path, name, Some(&version_spec))?;
98
99    let lock_path = project_dir.join(commands::LOCKFILE);
100    let lockfile = update_lockfile(
101        &lock_path,
102        &manifest_path,
103        name,
104        &resolved.version,
105        &resolved.checksum,
106        &resolved.source,
107    )?;
108    lockfile.write_to_file(&lock_path)?;
109
110    Ok(InstalledPackage {
111        name: name.to_string(),
112        version: resolved.version,
113        install_dir,
114        checksum: resolved.checksum,
115        source: resolved.source,
116    })
117}
118
119/// Wipe every tarball out of the cache directory.
120///
121/// The directory itself is kept so subsequent operations can repopulate it.
122/// Returns the number of files removed.
123pub fn clear_cache(cache_dir: &Path) -> PkgResult<usize> {
124    if !cache_dir.exists() {
125        return Ok(0);
126    }
127    let mut removed = 0;
128    for entry in std::fs::read_dir(cache_dir).map_err(|e| PkgError::Io(e.to_string()))? {
129        let entry = entry.map_err(|e| PkgError::Io(e.to_string()))?;
130        let path = entry.path();
131        if path.is_file() {
132            std::fs::remove_file(&path).map_err(|e| PkgError::Io(e.to_string()))?;
133            removed += 1;
134        }
135    }
136    Ok(removed)
137}
138
139/// Extract a `.tar.gz` archive into `target_dir`, creating parents as needed.
140///
141/// Existing contents of `target_dir` are wiped first so a re-install always
142/// starts from a clean state.
143pub fn extract_tarball(tarball_path: &Path, target_dir: &Path) -> PkgResult<()> {
144    if target_dir.exists() {
145        std::fs::remove_dir_all(target_dir).map_err(|e| PkgError::Io(e.to_string()))?;
146    }
147    std::fs::create_dir_all(target_dir).map_err(|e| PkgError::Io(e.to_string()))?;
148
149    let file = std::fs::File::open(tarball_path).map_err(|e| PkgError::Io(e.to_string()))?;
150    let decoder = GzDecoder::new(file);
151    let mut archive = Archive::new(decoder);
152    archive
153        .unpack(target_dir)
154        .map_err(|e| PkgError::Io(format!("extracting {}: {}", tarball_path.display(), e)))?;
155    Ok(())
156}
157
158/// Resolution outcome shared by online and offline paths.
159struct Resolved {
160    version: Version,
161    tarball_path: PathBuf,
162    checksum: String,
163    source: String,
164}
165
166fn resolve_and_fetch(
167    registry: &NetworkRegistry,
168    name: &str,
169    options: &InstallOptions,
170) -> PkgResult<Resolved> {
171    let req = match &options.version_req {
172        Some(s) => Some(parse_version_req(s)?),
173        None => None,
174    };
175
176    if options.offline {
177        let (version, tarball_path, checksum) = resolve_from_cache(
178            registry.cache_dir(),
179            name,
180            req.as_ref(),
181        )?;
182        return Ok(Resolved {
183            version,
184            tarball_path,
185            checksum,
186            source: "cache".to_string(),
187        });
188    }
189
190    // Online path: ask the registry, fall back to cache on transport failures.
191    match fetch_from_network(registry, name, req.as_ref()) {
192        Ok((version, fetched)) => Ok(Resolved {
193            version,
194            tarball_path: fetched.tarball_path,
195            checksum: fetched.checksum,
196            source: registry.base_url().to_string(),
197        }),
198        Err(PkgError::Network(msg)) => {
199            // Offline fallback: see if the cache can satisfy the request.
200            if let Ok((version, tarball_path, checksum)) =
201                resolve_from_cache(registry.cache_dir(), name, req.as_ref())
202            {
203                return Ok(Resolved {
204                    version,
205                    tarball_path,
206                    checksum,
207                    source: "cache".to_string(),
208                });
209            }
210            Err(PkgError::Network(format!(
211                "{msg}\n\nhint: pass --offline to use a cached tarball, or check your network connection"
212            )))
213        }
214        Err(e) => Err(e),
215    }
216}
217
218fn fetch_from_network(
219    registry: &NetworkRegistry,
220    name: &str,
221    req: Option<&VersionReq>,
222) -> PkgResult<(Version, FetchedPackage)> {
223    let versions = registry.fetch_versions(name)?;
224    let version = pick_version(&versions.versions, req)?.unwrap_or_else(|| versions.latest.clone());
225    let fetched = registry.fetch_package(name, &version)?;
226    let parsed = crate::version::parse_version(&version)?;
227    Ok((parsed, fetched))
228}
229
230fn resolve_from_cache(
231    cache_dir: &Path,
232    name: &str,
233    req: Option<&VersionReq>,
234) -> PkgResult<(Version, PathBuf, String)> {
235    let prefix = format!("{name}-");
236    let suffix = ".tar.gz";
237    let mut candidates: Vec<(Version, PathBuf)> = Vec::new();
238
239    if cache_dir.exists() {
240        for entry in std::fs::read_dir(cache_dir).map_err(|e| PkgError::Io(e.to_string()))? {
241            let entry = entry.map_err(|e| PkgError::Io(e.to_string()))?;
242            let Some(fname) = entry.file_name().to_str().map(str::to_string) else {
243                continue;
244            };
245            if !fname.starts_with(&prefix) || !fname.ends_with(suffix) {
246                continue;
247            }
248            let ver_str = &fname[prefix.len()..fname.len() - suffix.len()];
249            if let Ok(ver) = crate::version::parse_version(ver_str) {
250                if req.is_none_or(|r| r.matches(&ver)) {
251                    candidates.push((ver, entry.path()));
252                }
253            }
254        }
255    }
256
257    candidates.sort_by(|a, b| b.0.cmp(&a.0));
258    let Some((version, path)) = candidates.into_iter().next() else {
259        return Err(PkgError::PackageNotFound(format!(
260            "no cached tarball for '{name}' matches the requested version (cache: {})",
261            cache_dir.display()
262        )));
263    };
264
265    let bytes = std::fs::read(&path).map_err(|e| PkgError::Io(e.to_string()))?;
266    let checksum = crate::network::sha256_hex(&bytes);
267    Ok((version, path, checksum))
268}
269
270/// Pick the highest version from `versions` satisfying `req` (or `None` when
271/// no requirement is given — the caller falls back to the registry's `latest`).
272fn pick_version(versions: &[String], req: Option<&VersionReq>) -> PkgResult<Option<String>> {
273    let Some(req) = req else {
274        return Ok(None);
275    };
276    let mut best: Option<Version> = None;
277    for v in versions {
278        let parsed = match crate::version::parse_version(v) {
279            Ok(v) => v,
280            Err(_) => continue,
281        };
282        if req.matches(&parsed) && best.as_ref().is_none_or(|b| parsed > *b) {
283            best = Some(parsed);
284        }
285    }
286    match best {
287        Some(v) => Ok(Some(v.to_string())),
288        None => Err(PkgError::ResolutionFailed(format!(
289            "no version of this package matches `{req}`"
290        ))),
291    }
292}
293
294fn update_lockfile(
295    lock_path: &Path,
296    manifest_path: &Path,
297    new_name: &str,
298    new_version: &Version,
299    new_checksum: &str,
300    new_source: &str,
301) -> PkgResult<Lockfile> {
302    let manifest = Manifest::from_file(manifest_path)?;
303    let mut lockfile = if lock_path.exists() {
304        Lockfile::from_file(lock_path)?
305    } else {
306        Lockfile {
307            version: 1,
308            packages: Vec::new(),
309        }
310    };
311
312    // Drop any stale entry for this package so we can replace it.
313    lockfile.packages.retain(|p| p.name != new_name);
314    lockfile.packages.push(LockedPackage {
315        name: new_name.to_string(),
316        version: new_version.to_string(),
317        source: Some(new_source.to_string()),
318        checksum: Some(format!("sha256:{}", normalize_checksum(new_checksum))),
319        dependencies: manifest
320            .dependencies
321            .common
322            .iter()
323            .filter_map(|(dep_name, spec)| {
324                if dep_name == new_name {
325                    return None;
326                }
327                match spec {
328                    DependencySpec::Simple(v) => Some((dep_name.clone(), v.clone())),
329                    DependencySpec::Detailed(d) => d
330                        .version
331                        .as_ref()
332                        .map(|v| (dep_name.clone(), v.clone())),
333                }
334            })
335            .collect(),
336    });
337    lockfile
338        .packages
339        .sort_by(|a, b| a.name.cmp(&b.name));
340    Ok(lockfile)
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use flate2::write::GzEncoder;
347    use flate2::Compression;
348    use mockito::Server;
349
350    fn make_tarball(files: &[(&str, &[u8])]) -> Vec<u8> {
351        let mut out = Vec::new();
352        {
353            let encoder = GzEncoder::new(&mut out, Compression::default());
354            let mut builder = tar::Builder::new(encoder);
355            for (path, contents) in files {
356                let mut header = tar::Header::new_gnu();
357                header.set_size(contents.len() as u64);
358                header.set_mode(0o644);
359                header.set_cksum();
360                builder.append_data(&mut header, path, *contents).unwrap();
361            }
362            let encoder = builder.into_inner().unwrap();
363            encoder.finish().unwrap();
364        }
365        out
366    }
367
368    fn write_manifest(dir: &Path, name: &str) {
369        std::fs::write(
370            dir.join(commands::MANIFEST_FILE),
371            format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"),
372        )
373        .unwrap();
374    }
375
376    fn ensure_empty_token_env() {
377        // Tests share process state; clear any token from the host shell so
378        // requests aren't unexpectedly Bearer-authed during mockito assertions.
379        std::env::remove_var(crate::network::AUTH_TOKEN_ENV);
380    }
381
382    #[test]
383    fn extract_tarball_places_files() {
384        let tmp = tempfile::tempdir().unwrap();
385        let tar_path = tmp.path().join("pkg.tar.gz");
386        let bytes = make_tarball(&[("src/main.bock", b"module main"), ("README.md", b"hi")]);
387        std::fs::write(&tar_path, &bytes).unwrap();
388
389        let target = tmp.path().join("out");
390        extract_tarball(&tar_path, &target).unwrap();
391
392        assert_eq!(
393            std::fs::read_to_string(target.join("src/main.bock")).unwrap(),
394            "module main"
395        );
396        assert_eq!(std::fs::read_to_string(target.join("README.md")).unwrap(), "hi");
397    }
398
399    #[test]
400    fn install_package_writes_manifest_lockfile_and_unpacks() {
401        ensure_empty_token_env();
402        let project = tempfile::tempdir().unwrap();
403        write_manifest(project.path(), "my-app");
404
405        let tarball_bytes = make_tarball(&[("src/lib.bock", b"module foo\n")]);
406        let checksum = crate::network::sha256_hex(&tarball_bytes);
407
408        let mut server = Server::new();
409        let _versions = server
410            .mock("GET", "/packages/foo")
411            .with_status(200)
412            .with_body(r#"{"versions":["1.0.0","1.2.0"],"latest":"1.2.0"}"#)
413            .create();
414        let meta_body = format!(
415            r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
416        );
417        let _meta = server
418            .mock("GET", "/packages/foo/1.2.0")
419            .with_status(200)
420            .with_body(meta_body)
421            .create();
422        let _download = server
423            .mock("GET", "/packages/foo/1.2.0/download")
424            .with_status(200)
425            .with_body(tarball_bytes.clone())
426            .create();
427
428        let cache_dir = project.path().join(CACHE_SUBDIR);
429        let registry = NetworkRegistry::new(server.url(), &cache_dir).unwrap();
430
431        let installed = install_package(
432            project.path(),
433            &registry,
434            "foo",
435            &InstallOptions::default(),
436        )
437        .unwrap();
438
439        assert_eq!(installed.version.to_string(), "1.2.0");
440        assert!(installed.install_dir.ends_with(".bock/packages/foo/1.2.0"));
441        assert_eq!(
442            std::fs::read_to_string(installed.install_dir.join("src/lib.bock")).unwrap(),
443            "module foo\n"
444        );
445
446        // Manifest updated.
447        let manifest =
448            Manifest::from_file(&project.path().join(commands::MANIFEST_FILE)).unwrap();
449        assert_eq!(manifest.dependencies["foo"].version_req(), Some("^1.2.0"));
450
451        // Lockfile written with checksum and source.
452        let lockfile = Lockfile::from_file(&project.path().join(commands::LOCKFILE)).unwrap();
453        let entry = lockfile
454            .packages
455            .iter()
456            .find(|p| p.name == "foo")
457            .expect("lockfile entry missing");
458        assert_eq!(entry.version, "1.2.0");
459        assert_eq!(
460            entry.checksum.as_deref(),
461            Some(format!("sha256:{checksum}").as_str())
462        );
463        assert_eq!(entry.source.as_deref(), Some(server.url().as_str()));
464    }
465
466    #[test]
467    fn install_respects_version_requirement() {
468        ensure_empty_token_env();
469        let project = tempfile::tempdir().unwrap();
470        write_manifest(project.path(), "my-app");
471
472        let tarball_bytes = make_tarball(&[("src/lib.bock", b"")]);
473        let checksum = crate::network::sha256_hex(&tarball_bytes);
474
475        let mut server = Server::new();
476        let _versions = server
477            .mock("GET", "/packages/foo")
478            .with_status(200)
479            .with_body(r#"{"versions":["1.0.0","1.5.0","2.0.0"],"latest":"2.0.0"}"#)
480            .create();
481        let meta_body = format!(
482            r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
483        );
484        let _meta = server
485            .mock("GET", "/packages/foo/1.5.0")
486            .with_status(200)
487            .with_body(meta_body)
488            .create();
489        let _download = server
490            .mock("GET", "/packages/foo/1.5.0/download")
491            .with_status(200)
492            .with_body(tarball_bytes)
493            .create();
494
495        let cache_dir = project.path().join(CACHE_SUBDIR);
496        let registry = NetworkRegistry::new(server.url(), &cache_dir).unwrap();
497
498        let options = InstallOptions {
499            version_req: Some("^1.0".to_string()),
500            offline: false,
501        };
502        let installed =
503            install_package(project.path(), &registry, "foo", &options).unwrap();
504
505        assert_eq!(installed.version.to_string(), "1.5.0");
506    }
507
508    #[test]
509    fn install_uses_cache_in_offline_mode() {
510        ensure_empty_token_env();
511        let project = tempfile::tempdir().unwrap();
512        write_manifest(project.path(), "my-app");
513
514        let cache_dir = project.path().join(CACHE_SUBDIR);
515        std::fs::create_dir_all(&cache_dir).unwrap();
516
517        let tarball_bytes = make_tarball(&[("README", b"offline")]);
518        let cached = cache_dir.join("foo-1.4.0.tar.gz");
519        std::fs::write(&cached, &tarball_bytes).unwrap();
520        let checksum = crate::network::sha256_hex(&tarball_bytes);
521
522        // Point at a dead URL so any network access would fail.
523        let registry = NetworkRegistry::new("http://127.0.0.1:1/", &cache_dir).unwrap();
524        let options = InstallOptions {
525            offline: true,
526            version_req: None,
527        };
528        let installed =
529            install_package(project.path(), &registry, "foo", &options).unwrap();
530
531        assert_eq!(installed.version.to_string(), "1.4.0");
532        assert_eq!(installed.checksum, checksum);
533        assert_eq!(installed.source, "cache");
534    }
535
536    #[test]
537    fn install_offline_errors_when_not_cached() {
538        ensure_empty_token_env();
539        let project = tempfile::tempdir().unwrap();
540        write_manifest(project.path(), "my-app");
541        let cache_dir = project.path().join(CACHE_SUBDIR);
542
543        let registry = NetworkRegistry::new("http://127.0.0.1:1/", &cache_dir).unwrap();
544        let err = install_package(
545            project.path(),
546            &registry,
547            "foo",
548            &InstallOptions { offline: true, version_req: None },
549        )
550        .unwrap_err();
551        assert!(matches!(err, PkgError::PackageNotFound(_)));
552    }
553
554    #[test]
555    fn clear_cache_removes_tarballs() {
556        let tmp = tempfile::tempdir().unwrap();
557        let cache = tmp.path().join("cache");
558        std::fs::create_dir_all(&cache).unwrap();
559        std::fs::write(cache.join("a-1.0.0.tar.gz"), b"x").unwrap();
560        std::fs::write(cache.join("b-2.0.0.tar.gz"), b"y").unwrap();
561
562        let removed = clear_cache(&cache).unwrap();
563        assert_eq!(removed, 2);
564        assert!(cache.exists());
565        assert!(std::fs::read_dir(&cache).unwrap().next().is_none());
566    }
567
568    #[test]
569    fn pick_version_selects_highest_matching() {
570        let versions = vec!["0.9.0".into(), "1.0.0".into(), "1.2.3".into(), "2.0.0".into()];
571        let req = VersionReq::parse("^1.0").unwrap();
572        let picked = pick_version(&versions, Some(&req)).unwrap();
573        assert_eq!(picked.as_deref(), Some("1.2.3"));
574    }
575
576    #[test]
577    fn pick_version_errors_when_nothing_matches() {
578        let versions = vec!["0.1.0".into(), "0.2.0".into()];
579        let req = VersionReq::parse("^1.0").unwrap();
580        let err = pick_version(&versions, Some(&req)).unwrap_err();
581        assert!(matches!(err, PkgError::ResolutionFailed(_)));
582    }
583
584    #[test]
585    fn install_sends_bearer_auth_when_configured() {
586        ensure_empty_token_env();
587        let project = tempfile::tempdir().unwrap();
588        write_manifest(project.path(), "my-app");
589
590        let tarball_bytes = make_tarball(&[("README", b"tok")]);
591        let checksum = crate::network::sha256_hex(&tarball_bytes);
592
593        let mut server = Server::new();
594        let _v = server
595            .mock("GET", "/packages/foo")
596            .match_header("authorization", "Bearer secret-xyz")
597            .with_status(200)
598            .with_body(r#"{"versions":["1.0.0"],"latest":"1.0.0"}"#)
599            .create();
600        let meta_body = format!(
601            r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
602        );
603        let _m = server
604            .mock("GET", "/packages/foo/1.0.0")
605            .match_header("authorization", "Bearer secret-xyz")
606            .with_status(200)
607            .with_body(meta_body)
608            .create();
609        let _d = server
610            .mock("GET", "/packages/foo/1.0.0/download")
611            .match_header("authorization", "Bearer secret-xyz")
612            .with_status(200)
613            .with_body(tarball_bytes)
614            .create();
615
616        let cache_dir = project.path().join(CACHE_SUBDIR);
617        let registry = NetworkRegistry::new(server.url(), &cache_dir)
618            .unwrap()
619            .with_auth_token(Some("secret-xyz".to_string()));
620        install_package(
621            project.path(),
622            &registry,
623            "foo",
624            &InstallOptions::default(),
625        )
626        .unwrap();
627    }
628}