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) =
178            resolve_from_cache(registry.cache_dir(), name, req.as_ref())?;
179        return Ok(Resolved {
180            version,
181            tarball_path,
182            checksum,
183            source: "cache".to_string(),
184        });
185    }
186
187    // Online path: ask the registry, fall back to cache on transport failures.
188    match fetch_from_network(registry, name, req.as_ref()) {
189        Ok((version, fetched)) => Ok(Resolved {
190            version,
191            tarball_path: fetched.tarball_path,
192            checksum: fetched.checksum,
193            source: registry.base_url().to_string(),
194        }),
195        Err(PkgError::Network(msg)) => {
196            // Offline fallback: see if the cache can satisfy the request.
197            if let Ok((version, tarball_path, checksum)) =
198                resolve_from_cache(registry.cache_dir(), name, req.as_ref())
199            {
200                return Ok(Resolved {
201                    version,
202                    tarball_path,
203                    checksum,
204                    source: "cache".to_string(),
205                });
206            }
207            Err(PkgError::Network(format!(
208                "{msg}\n\nhint: pass --offline to use a cached tarball, or check your network connection"
209            )))
210        }
211        Err(e) => Err(e),
212    }
213}
214
215fn fetch_from_network(
216    registry: &NetworkRegistry,
217    name: &str,
218    req: Option<&VersionReq>,
219) -> PkgResult<(Version, FetchedPackage)> {
220    let versions = registry.fetch_versions(name)?;
221    let version = pick_version(&versions.versions, req)?.unwrap_or_else(|| versions.latest.clone());
222    let fetched = registry.fetch_package(name, &version)?;
223    let parsed = crate::version::parse_version(&version)?;
224    Ok((parsed, fetched))
225}
226
227fn resolve_from_cache(
228    cache_dir: &Path,
229    name: &str,
230    req: Option<&VersionReq>,
231) -> PkgResult<(Version, PathBuf, String)> {
232    let prefix = format!("{name}-");
233    let suffix = ".tar.gz";
234    let mut candidates: Vec<(Version, PathBuf)> = Vec::new();
235
236    if cache_dir.exists() {
237        for entry in std::fs::read_dir(cache_dir).map_err(|e| PkgError::Io(e.to_string()))? {
238            let entry = entry.map_err(|e| PkgError::Io(e.to_string()))?;
239            let Some(fname) = entry.file_name().to_str().map(str::to_string) else {
240                continue;
241            };
242            if !fname.starts_with(&prefix) || !fname.ends_with(suffix) {
243                continue;
244            }
245            let ver_str = &fname[prefix.len()..fname.len() - suffix.len()];
246            if let Ok(ver) = crate::version::parse_version(ver_str) {
247                if req.is_none_or(|r| r.matches(&ver)) {
248                    candidates.push((ver, entry.path()));
249                }
250            }
251        }
252    }
253
254    candidates.sort_by(|a, b| b.0.cmp(&a.0));
255    let Some((version, path)) = candidates.into_iter().next() else {
256        return Err(PkgError::PackageNotFound(format!(
257            "no cached tarball for '{name}' matches the requested version (cache: {})",
258            cache_dir.display()
259        )));
260    };
261
262    let bytes = std::fs::read(&path).map_err(|e| PkgError::Io(e.to_string()))?;
263    let checksum = crate::network::sha256_hex(&bytes);
264    Ok((version, path, checksum))
265}
266
267/// Pick the highest version from `versions` satisfying `req` (or `None` when
268/// no requirement is given — the caller falls back to the registry's `latest`).
269fn pick_version(versions: &[String], req: Option<&VersionReq>) -> PkgResult<Option<String>> {
270    let Some(req) = req else {
271        return Ok(None);
272    };
273    let mut best: Option<Version> = None;
274    for v in versions {
275        let parsed = match crate::version::parse_version(v) {
276            Ok(v) => v,
277            Err(_) => continue,
278        };
279        if req.matches(&parsed) && best.as_ref().is_none_or(|b| parsed > *b) {
280            best = Some(parsed);
281        }
282    }
283    match best {
284        Some(v) => Ok(Some(v.to_string())),
285        None => Err(PkgError::ResolutionFailed(format!(
286            "no version of this package matches `{req}`"
287        ))),
288    }
289}
290
291fn update_lockfile(
292    lock_path: &Path,
293    manifest_path: &Path,
294    new_name: &str,
295    new_version: &Version,
296    new_checksum: &str,
297    new_source: &str,
298) -> PkgResult<Lockfile> {
299    let manifest = Manifest::from_file(manifest_path)?;
300    let mut lockfile = if lock_path.exists() {
301        Lockfile::from_file(lock_path)?
302    } else {
303        Lockfile {
304            version: 1,
305            packages: Vec::new(),
306        }
307    };
308
309    // Drop any stale entry for this package so we can replace it.
310    lockfile.packages.retain(|p| p.name != new_name);
311    lockfile.packages.push(LockedPackage {
312        name: new_name.to_string(),
313        version: new_version.to_string(),
314        source: Some(new_source.to_string()),
315        checksum: Some(format!("sha256:{}", normalize_checksum(new_checksum))),
316        dependencies: manifest
317            .dependencies
318            .common
319            .iter()
320            .filter_map(|(dep_name, spec)| {
321                if dep_name == new_name {
322                    return None;
323                }
324                match spec {
325                    DependencySpec::Simple(v) => Some((dep_name.clone(), v.clone())),
326                    DependencySpec::Detailed(d) => {
327                        d.version.as_ref().map(|v| (dep_name.clone(), v.clone()))
328                    }
329                }
330            })
331            .collect(),
332    });
333    lockfile.packages.sort_by(|a, b| a.name.cmp(&b.name));
334    Ok(lockfile)
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use flate2::write::GzEncoder;
341    use flate2::Compression;
342    use mockito::Server;
343
344    fn make_tarball(files: &[(&str, &[u8])]) -> Vec<u8> {
345        let mut out = Vec::new();
346        {
347            let encoder = GzEncoder::new(&mut out, Compression::default());
348            let mut builder = tar::Builder::new(encoder);
349            for (path, contents) in files {
350                let mut header = tar::Header::new_gnu();
351                header.set_size(contents.len() as u64);
352                header.set_mode(0o644);
353                header.set_cksum();
354                builder.append_data(&mut header, path, *contents).unwrap();
355            }
356            let encoder = builder.into_inner().unwrap();
357            encoder.finish().unwrap();
358        }
359        out
360    }
361
362    fn write_manifest(dir: &Path, name: &str) {
363        std::fs::write(
364            dir.join(commands::MANIFEST_FILE),
365            format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"),
366        )
367        .unwrap();
368    }
369
370    fn ensure_empty_token_env() {
371        // Tests share process state; clear any token from the host shell so
372        // requests aren't unexpectedly Bearer-authed during mockito assertions.
373        std::env::remove_var(crate::network::AUTH_TOKEN_ENV);
374    }
375
376    #[test]
377    fn extract_tarball_places_files() {
378        let tmp = tempfile::tempdir().unwrap();
379        let tar_path = tmp.path().join("pkg.tar.gz");
380        let bytes = make_tarball(&[("src/main.bock", b"module main"), ("README.md", b"hi")]);
381        std::fs::write(&tar_path, &bytes).unwrap();
382
383        let target = tmp.path().join("out");
384        extract_tarball(&tar_path, &target).unwrap();
385
386        assert_eq!(
387            std::fs::read_to_string(target.join("src/main.bock")).unwrap(),
388            "module main"
389        );
390        assert_eq!(
391            std::fs::read_to_string(target.join("README.md")).unwrap(),
392            "hi"
393        );
394    }
395
396    #[test]
397    fn install_package_writes_manifest_lockfile_and_unpacks() {
398        ensure_empty_token_env();
399        let project = tempfile::tempdir().unwrap();
400        write_manifest(project.path(), "my-app");
401
402        let tarball_bytes = make_tarball(&[("src/lib.bock", b"module foo\n")]);
403        let checksum = crate::network::sha256_hex(&tarball_bytes);
404
405        let mut server = Server::new();
406        let _versions = server
407            .mock("GET", "/packages/foo")
408            .with_status(200)
409            .with_body(r#"{"versions":["1.0.0","1.2.0"],"latest":"1.2.0"}"#)
410            .create();
411        let meta_body = format!(
412            r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
413        );
414        let _meta = server
415            .mock("GET", "/packages/foo/1.2.0")
416            .with_status(200)
417            .with_body(meta_body)
418            .create();
419        let _download = server
420            .mock("GET", "/packages/foo/1.2.0/download")
421            .with_status(200)
422            .with_body(tarball_bytes.clone())
423            .create();
424
425        let cache_dir = project.path().join(CACHE_SUBDIR);
426        let registry = NetworkRegistry::new(server.url(), &cache_dir).unwrap();
427
428        let installed =
429            install_package(project.path(), &registry, "foo", &InstallOptions::default()).unwrap();
430
431        assert_eq!(installed.version.to_string(), "1.2.0");
432        assert!(installed.install_dir.ends_with(".bock/packages/foo/1.2.0"));
433        assert_eq!(
434            std::fs::read_to_string(installed.install_dir.join("src/lib.bock")).unwrap(),
435            "module foo\n"
436        );
437
438        // Manifest updated.
439        let manifest = Manifest::from_file(&project.path().join(commands::MANIFEST_FILE)).unwrap();
440        assert_eq!(manifest.dependencies["foo"].version_req(), Some("^1.2.0"));
441
442        // Lockfile written with checksum and source.
443        let lockfile = Lockfile::from_file(&project.path().join(commands::LOCKFILE)).unwrap();
444        let entry = lockfile
445            .packages
446            .iter()
447            .find(|p| p.name == "foo")
448            .expect("lockfile entry missing");
449        assert_eq!(entry.version, "1.2.0");
450        assert_eq!(
451            entry.checksum.as_deref(),
452            Some(format!("sha256:{checksum}").as_str())
453        );
454        assert_eq!(entry.source.as_deref(), Some(server.url().as_str()));
455    }
456
457    #[test]
458    fn install_respects_version_requirement() {
459        ensure_empty_token_env();
460        let project = tempfile::tempdir().unwrap();
461        write_manifest(project.path(), "my-app");
462
463        let tarball_bytes = make_tarball(&[("src/lib.bock", b"")]);
464        let checksum = crate::network::sha256_hex(&tarball_bytes);
465
466        let mut server = Server::new();
467        let _versions = server
468            .mock("GET", "/packages/foo")
469            .with_status(200)
470            .with_body(r#"{"versions":["1.0.0","1.5.0","2.0.0"],"latest":"2.0.0"}"#)
471            .create();
472        let meta_body = format!(
473            r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
474        );
475        let _meta = server
476            .mock("GET", "/packages/foo/1.5.0")
477            .with_status(200)
478            .with_body(meta_body)
479            .create();
480        let _download = server
481            .mock("GET", "/packages/foo/1.5.0/download")
482            .with_status(200)
483            .with_body(tarball_bytes)
484            .create();
485
486        let cache_dir = project.path().join(CACHE_SUBDIR);
487        let registry = NetworkRegistry::new(server.url(), &cache_dir).unwrap();
488
489        let options = InstallOptions {
490            version_req: Some("^1.0".to_string()),
491            offline: false,
492        };
493        let installed = install_package(project.path(), &registry, "foo", &options).unwrap();
494
495        assert_eq!(installed.version.to_string(), "1.5.0");
496    }
497
498    #[test]
499    fn install_uses_cache_in_offline_mode() {
500        ensure_empty_token_env();
501        let project = tempfile::tempdir().unwrap();
502        write_manifest(project.path(), "my-app");
503
504        let cache_dir = project.path().join(CACHE_SUBDIR);
505        std::fs::create_dir_all(&cache_dir).unwrap();
506
507        let tarball_bytes = make_tarball(&[("README", b"offline")]);
508        let cached = cache_dir.join("foo-1.4.0.tar.gz");
509        std::fs::write(&cached, &tarball_bytes).unwrap();
510        let checksum = crate::network::sha256_hex(&tarball_bytes);
511
512        // Point at a dead URL so any network access would fail.
513        let registry = NetworkRegistry::new("http://127.0.0.1:1/", &cache_dir).unwrap();
514        let options = InstallOptions {
515            offline: true,
516            version_req: None,
517        };
518        let installed = install_package(project.path(), &registry, "foo", &options).unwrap();
519
520        assert_eq!(installed.version.to_string(), "1.4.0");
521        assert_eq!(installed.checksum, checksum);
522        assert_eq!(installed.source, "cache");
523    }
524
525    #[test]
526    fn install_offline_errors_when_not_cached() {
527        ensure_empty_token_env();
528        let project = tempfile::tempdir().unwrap();
529        write_manifest(project.path(), "my-app");
530        let cache_dir = project.path().join(CACHE_SUBDIR);
531
532        let registry = NetworkRegistry::new("http://127.0.0.1:1/", &cache_dir).unwrap();
533        let err = install_package(
534            project.path(),
535            &registry,
536            "foo",
537            &InstallOptions {
538                offline: true,
539                version_req: None,
540            },
541        )
542        .unwrap_err();
543        assert!(matches!(err, PkgError::PackageNotFound(_)));
544    }
545
546    #[test]
547    fn clear_cache_removes_tarballs() {
548        let tmp = tempfile::tempdir().unwrap();
549        let cache = tmp.path().join("cache");
550        std::fs::create_dir_all(&cache).unwrap();
551        std::fs::write(cache.join("a-1.0.0.tar.gz"), b"x").unwrap();
552        std::fs::write(cache.join("b-2.0.0.tar.gz"), b"y").unwrap();
553
554        let removed = clear_cache(&cache).unwrap();
555        assert_eq!(removed, 2);
556        assert!(cache.exists());
557        assert!(std::fs::read_dir(&cache).unwrap().next().is_none());
558    }
559
560    #[test]
561    fn pick_version_selects_highest_matching() {
562        let versions = vec![
563            "0.9.0".into(),
564            "1.0.0".into(),
565            "1.2.3".into(),
566            "2.0.0".into(),
567        ];
568        let req = VersionReq::parse("^1.0").unwrap();
569        let picked = pick_version(&versions, Some(&req)).unwrap();
570        assert_eq!(picked.as_deref(), Some("1.2.3"));
571    }
572
573    #[test]
574    fn pick_version_errors_when_nothing_matches() {
575        let versions = vec!["0.1.0".into(), "0.2.0".into()];
576        let req = VersionReq::parse("^1.0").unwrap();
577        let err = pick_version(&versions, Some(&req)).unwrap_err();
578        assert!(matches!(err, PkgError::ResolutionFailed(_)));
579    }
580
581    #[test]
582    fn install_sends_bearer_auth_when_configured() {
583        ensure_empty_token_env();
584        let project = tempfile::tempdir().unwrap();
585        write_manifest(project.path(), "my-app");
586
587        let tarball_bytes = make_tarball(&[("README", b"tok")]);
588        let checksum = crate::network::sha256_hex(&tarball_bytes);
589
590        let mut server = Server::new();
591        let _v = server
592            .mock("GET", "/packages/foo")
593            .match_header("authorization", "Bearer secret-xyz")
594            .with_status(200)
595            .with_body(r#"{"versions":["1.0.0"],"latest":"1.0.0"}"#)
596            .create();
597        let meta_body = format!(
598            r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
599        );
600        let _m = server
601            .mock("GET", "/packages/foo/1.0.0")
602            .match_header("authorization", "Bearer secret-xyz")
603            .with_status(200)
604            .with_body(meta_body)
605            .create();
606        let _d = server
607            .mock("GET", "/packages/foo/1.0.0/download")
608            .match_header("authorization", "Bearer secret-xyz")
609            .with_status(200)
610            .with_body(tarball_bytes)
611            .create();
612
613        let cache_dir = project.path().join(CACHE_SUBDIR);
614        let registry = NetworkRegistry::new(server.url(), &cache_dir)
615            .unwrap()
616            .with_auth_token(Some("secret-xyz".to_string()));
617        install_package(project.path(), &registry, "foo", &InstallOptions::default()).unwrap();
618    }
619}