Skip to main content

bynk/
fetch.rs

1//! Packaging slice 2b (#2240): fetching git dependencies and writing
2//! `bynk.lock` — the one place the toolchain touches the network (track
3//! `design/tracks/packaging.md` §6.4, decision A).
4//!
5//! Before `check`, `test`, `dev` or `deploy` compiles, [`prepare`] resolves
6//! the project's (or its workspace's) dependencies against `bynk.lock`:
7//!
8//! - a locked git dependency whose manifest entry is unchanged keeps its
9//!   locked commit; one missing from the store is fetched at that commit;
10//! - a new or changed one (or every one named by `bynk update`) is resolved
11//!   from its `rev`, fetched, and hashed;
12//! - every git package's store entry is re-hashed against the lock, so a
13//!   tampered or corrupted entry fails the build (`checksum_mismatch`);
14//! - the lock is rewritten when the graph changed — or, under `--locked`,
15//!   the command fails instead (`lock_out_of_date`). Under `--offline`, any
16//!   needed fetch fails.
17//!
18//! Git runs as the user's own `git`, hardened per track §6.3 (decision B):
19//! only `https` and `ssh` transports (plus `file` from the project's own
20//! manifests), no hooks, no submodules, `--` before positional arguments,
21//! and no URL or revision that looks like an option. Bynk passes no
22//! credentials: a private repository works through the user's git setup.
23//!
24//! The compile never fetches: `bynkc`, the language server and the
25//! playground read the lock and the store ([`bynk_emit::project`]'s graph
26//! resolution reports `not_fetched` for a missing entry).
27
28use std::collections::{BTreeMap, BTreeSet};
29use std::path::{Path, PathBuf};
30use std::process::Command;
31
32use bynk_emit::project::{is_workspace_root, load_workspace};
33use sha2::{Digest, Sha256};
34
35use bynk_project::lock::{
36    LOCK_FILE, Lock, LockedNpm, LockedPackage, adapter_requires, bynk_home, checksum_hex,
37    checksum_of, git_source, lock_root, parse_lock, path_source, store_dir,
38};
39use bynk_project::manifest::{
40    DependencySource, DependencySpec, dependencies_from, package_config_from,
41};
42
43/// How a `bynk` command may change the lock and the store.
44#[derive(Debug, Clone, Default)]
45pub struct FetchOptions {
46    /// Fail instead of changing `bynk.lock`.
47    pub locked: bool,
48    /// Fail instead of fetching.
49    pub offline: bool,
50    /// `bynk update`: re-resolve these packages' revisions (`Some(empty)`
51    /// means every git dependency).
52    pub update: Option<Vec<String>>,
53}
54
55/// A failure, as one diagnostic line: its code and message.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct FetchError {
58    pub code: &'static str,
59    pub message: String,
60}
61
62impl std::fmt::Display for FetchError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        write!(f, "error[{}]: {}", self.code, self.message)
65    }
66}
67
68const NOT_FETCHED: &str = "bynk.package.not_fetched";
69const LOCK_OUT_OF_DATE: &str = "bynk.package.lock_out_of_date";
70pub(crate) const CHECKSUM_MISMATCH: &str = "bynk.package.checksum_mismatch";
71const VERSION_CONFLICT: &str = "bynk.package.version_conflict";
72const LOCK_INVALID: &str = "bynk.package.lock_invalid";
73const GIT_FAILED: &str = "bynk.package.git_failed";
74const UNSAFE_SOURCE: &str = "bynk.package.unsafe_source";
75const PATH_OUTSIDE_ROOT: &str = "bynk.dependency.path_outside_root";
76
77pub(crate) fn fail(code: &'static str, message: impl Into<String>) -> FetchError {
78    FetchError {
79        code,
80        message: message.into(),
81    }
82}
83
84/// What [`prepare`] did.
85#[derive(Debug, Clone, Default, PartialEq, Eq)]
86pub struct Prepared {
87    /// Whether `bynk.lock` was written.
88    pub wrote_lock: bool,
89    /// The git packages fetched into the store, as `org/package@commit`.
90    pub fetched: Vec<String>,
91    /// Things worth saying that aren't failures: a `bynk update` name that
92    /// matched no git dependency.
93    pub warnings: Vec<String>,
94    /// #2296: the lock's npm requirements awaiting approval — each needed by
95    /// a fetched package's adapter, and not yet approved.
96    pub pending: Vec<LockedNpm>,
97}
98
99/// Resolve, fetch and lock the dependencies of the project at `project_dir`
100/// (and of every member of its workspace, which shares one lock). A project
101/// with no dependencies and no lock is untouched.
102pub fn prepare(project_dir: &Path, opts: &FetchOptions) -> Result<Prepared, FetchError> {
103    let root = lock_root(project_dir);
104    let members: Vec<PathBuf> = if is_workspace_root(&root) {
105        // A workspace that doesn't load is the command's own to report, in
106        // the words it always has.
107        match load_workspace(&root) {
108            Ok(w) => w.members.iter().map(|m| root.join(m)).collect(),
109            Err(_) => return Ok(Prepared::default()),
110        }
111    } else {
112        vec![project_dir.to_path_buf()]
113    };
114    let lock_path = root.join(LOCK_FILE);
115    let existing = match std::fs::read_to_string(&lock_path) {
116        Ok(text) => Some(parse_lock(&text).map_err(|e| fail(LOCK_INVALID, e.0))?),
117        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
118        // Any other failure must not read as "no lock" and re-resolve.
119        Err(e) => {
120            return Err(fail(
121                LOCK_INVALID,
122                format!("could not read `{}`: {e}", lock_path.display()),
123            ));
124        }
125    };
126    // #2274: registry versions are chosen against every range that reaches a
127    // package, and a choice's own dependencies add ranges — so resolution
128    // repeats until no new range appears (decision C; no backtracking).
129    let mut client = crate::registry::Client::new(
130        crate::registry::commands::registries_of(&root),
131        existing.as_ref(),
132    );
133    let mut ranges: BTreeMap<String, BTreeSet<(String, String)>> = BTreeMap::new();
134    let mut prepared = Prepared::default();
135    let mut any;
136    let mut pass = 0;
137    let mut walk = loop {
138        pass += 1;
139        let before = ranges.clone();
140        let mut walk = Walk {
141            root: root.clone(),
142            opts,
143            existing: existing.clone().unwrap_or_default(),
144            home: None,
145            resolved: BTreeMap::new(),
146            prepared: std::mem::take(&mut prepared),
147            updated: BTreeSet::new(),
148            dirs: BTreeMap::new(),
149            client,
150            ranges,
151            used_registries: BTreeSet::new(),
152        };
153        any = false;
154        for member in &members {
155            let Some(doc) = read_manifest(member) else {
156                continue;
157            };
158            let mut declared = specs(&doc, "dependencies");
159            declared.extend(specs(&doc, "dev-dependencies"));
160            any |= !declared.is_empty();
161            for spec in declared {
162                walk.resolve(member, Declarer::Own, &spec)?;
163            }
164        }
165        if walk.ranges == before || pass >= MAX_PASSES {
166            break walk;
167        }
168        client = walk.client;
169        ranges = walk.ranges;
170        prepared = walk.prepared;
171    };
172    walk.prepared.warnings.append(&mut walk.client.notices);
173    if let Some(names) = &opts.update {
174        for n in names.iter().filter(|n| !walk.updated.contains(*n)) {
175            walk.prepared.warnings.push(format!(
176                "`{n}` names no git dependency, so nothing was updated for it"
177            ));
178        }
179    }
180    if !any && existing.is_none() {
181        return Ok(walk.prepared);
182    }
183    // A registry this resolution read is recorded with the trust it used; one
184    // only reached through the lock keeps its locked record.
185    let registries = walk
186        .used_registries
187        .iter()
188        .filter_map(|name| {
189            walk.client
190                .trust
191                .get(name)
192                .cloned()
193                .or_else(|| walk.existing.registry(name).cloned())
194        })
195        .collect();
196    let npm = approvals(
197        npm_requirements(&walk.dirs),
198        &walk.resolved,
199        &walk.existing,
200        &mut walk.prepared,
201    );
202    let lock = Lock {
203        packages: walk.resolved.values().cloned().collect(),
204        registries,
205        npm,
206    };
207    let rendered = lock.render();
208    let current = existing.as_ref().map(Lock::render);
209    if current.as_deref() != Some(rendered.as_str()) {
210        if opts.locked {
211            return Err(fail(
212                LOCK_OUT_OF_DATE,
213                format!(
214                    "`{}` is out of date with the manifests, and `--locked` was given",
215                    lock_path.display()
216                ),
217            ));
218        }
219        write_atomically(&lock_path, &rendered).map_err(|e| {
220            fail(
221                LOCK_INVALID,
222                format!("could not write `{}`: {e}", lock_path.display()),
223            )
224        })?;
225        walk.prepared.wrote_lock = true;
226    }
227    Ok(walk.prepared)
228}
229
230/// Who declared a dependency: the project (or a workspace member, or a path
231/// package — the author's own files), or a fetched package.
232#[derive(Clone, Copy, PartialEq, Eq)]
233enum Declarer {
234    Own,
235    Fetched,
236}
237
238struct Walk<'o> {
239    root: PathBuf,
240    opts: &'o FetchOptions,
241    existing: Lock,
242    home: Option<PathBuf>,
243    /// Resolved packages by name.
244    resolved: BTreeMap<String, LockedPackage>,
245    prepared: Prepared,
246    /// The `bynk update` names some git dependency matched.
247    updated: BTreeSet<String>,
248    /// #2255: each resolved package's directory (its working copy, or its
249    /// store entry), by name, for its adapters' npm requirements.
250    dirs: BTreeMap<String, PathBuf>,
251    /// #2274: the registries in use, and every range reaching each registry
252    /// package, as `(requirer, range)`.
253    client: crate::registry::Client,
254    ranges: BTreeMap<String, BTreeSet<(String, String)>>,
255    used_registries: BTreeSet<String>,
256}
257
258/// #2274: resolution passes before a growing set of ranges is reported as is.
259const MAX_PASSES: usize = 8;
260
261impl Walk<'_> {
262    fn home(&mut self) -> Result<PathBuf, FetchError> {
263        if let Some(h) = &self.home {
264            return Ok(h.clone());
265        }
266        let h = bynk_home().ok_or_else(|| {
267            fail(
268                NOT_FETCHED,
269                "can't find a cache directory for the store: set `BYNK_HOME`",
270            )
271        })?;
272        self.home = Some(h.clone());
273        Ok(h)
274    }
275
276    /// Resolve one dependency of a package in `dir`, then its own.
277    fn resolve(
278        &mut self,
279        dir: &Path,
280        declarer: Declarer,
281        spec: &DependencySpec,
282    ) -> Result<(), FetchError> {
283        match &spec.source {
284            DependencySource::Version(range) => self.resolve_registry(dir, spec, range),
285            DependencySource::Path(p) => {
286                if declarer == Declarer::Fetched {
287                    return Err(fail(
288                        PATH_OUTSIDE_ROOT,
289                        format!(
290                            "the fetched package declaring `{}` names the path dependency `{p}`, and a fetched package's path dependencies are never honoured",
291                            spec.alias
292                        ),
293                    ));
294                }
295                let target = dir.join(p);
296                let Some(doc) = read_manifest(&target) else {
297                    return Ok(()); // the graph reports a missing directory
298                };
299                let source = path_source(&relative(&self.root, &target));
300                let version = package_version(&doc);
301                let deps = specs(&doc, "dependencies");
302                if !self.record(spec, source, None, version, &deps)? {
303                    return Ok(());
304                }
305                self.dirs.insert(spec.package.clone(), target.clone());
306                for d in deps {
307                    self.resolve(&target, Declarer::Own, &d)?;
308                }
309                Ok(())
310            }
311            DependencySource::Git { url, rev } => {
312                check_source(url, rev, declarer)?;
313                let allow_file = declarer == Declarer::Own;
314                let refresh = self.opts.update.as_ref().is_some_and(|names| {
315                    names.is_empty() || names.iter().any(|n| *n == spec.alias || *n == spec.package)
316                });
317                if let Some(names) = &self.opts.update {
318                    for n in names {
319                        if *n == spec.alias || *n == spec.package {
320                            self.updated.insert(n.clone());
321                        }
322                    }
323                }
324                let locked = (!refresh)
325                    .then(|| self.existing.git(&spec.package, url, rev))
326                    .flatten()
327                    .map(|(p, commit)| (p.checksum.clone(), commit));
328                if let Some((_, commit)) = &locked
329                    && !(commit.len() >= 40 && commit.chars().all(|c| c.is_ascii_hexdigit()))
330                {
331                    return Err(fail(
332                        LOCK_INVALID,
333                        format!(
334                            "`bynk.lock` locks `{}` to `{commit}`, which isn't a commit id",
335                            spec.package
336                        ),
337                    ));
338                }
339                if matches!(&locked, Some((None, _))) {
340                    return Err(fail(
341                        LOCK_INVALID,
342                        format!(
343                            "`bynk.lock` locks the git package `{}` without a `checksum`",
344                            spec.package
345                        ),
346                    ));
347                }
348                let home = self.home()?;
349                let (commit, checksum) = match locked {
350                    Some((Some(checksum), commit)) => {
351                        let entry = store_dir(&home, &checksum).ok_or_else(|| {
352                            fail(
353                                LOCK_INVALID,
354                                format!(
355                                    "`bynk.lock`'s checksum for `{}` is malformed",
356                                    spec.package
357                                ),
358                            )
359                        })?;
360                        if !entry.is_dir() {
361                            if self.opts.offline {
362                                return Err(fail(
363                                    NOT_FETCHED,
364                                    format!(
365                                        "`{}` ({url} at {commit}) isn't in the store, and `--offline` was given",
366                                        spec.package
367                                    ),
368                                ));
369                            }
370                            let got = fetch_into_store(&home, url, &commit, allow_file)?;
371                            if got != checksum {
372                                return Err(fail(
373                                    CHECKSUM_MISMATCH,
374                                    format!(
375                                        "`{}` at {commit} hashes to {got}, but `bynk.lock` says {checksum}",
376                                        spec.package
377                                    ),
378                                ));
379                            }
380                            self.prepared
381                                .fetched
382                                .push(format!("{}@{commit}", spec.package));
383                        }
384                        (commit, checksum)
385                    }
386                    _ => {
387                        if self.opts.locked {
388                            return Err(fail(
389                                LOCK_OUT_OF_DATE,
390                                format!(
391                                    "`{}` ({url} at {rev}) isn't locked, and `--locked` was given",
392                                    spec.package
393                                ),
394                            ));
395                        }
396                        if self.opts.offline {
397                            return Err(fail(
398                                NOT_FETCHED,
399                                format!(
400                                    "`{}` ({url} at {rev}) needs fetching, and `--offline` was given",
401                                    spec.package
402                                ),
403                            ));
404                        }
405                        let commit = resolve_rev(&home, url, rev, allow_file)?;
406                        let checksum = fetch_into_store(&home, url, &commit, allow_file)?;
407                        self.prepared
408                            .fetched
409                            .push(format!("{}@{commit}", spec.package));
410                        (commit, checksum)
411                    }
412                };
413                let entry = store_dir(&home, &checksum).expect("a checksum this module wrote");
414                verify(&entry, &checksum, &spec.package)?;
415                let Some(doc) = read_manifest(&entry) else {
416                    return Err(fail(
417                        NOT_FETCHED,
418                        format!("`{url}` at {commit} has no `bynk.toml`"),
419                    ));
420                };
421                let version = package_version(&doc);
422                let deps = specs(&doc, "dependencies");
423                let source = git_source(url, rev, &commit);
424                if !self.record(spec, source, Some(checksum), version, &deps)? {
425                    return Ok(());
426                }
427                self.dirs.insert(spec.package.clone(), entry.clone());
428                for d in deps {
429                    self.resolve(&entry, Declarer::Fetched, &d)?;
430                }
431                Ok(())
432            }
433        }
434    }
435
436    /// #2274: a registry dependency — chosen against every range reaching it,
437    /// fetched into the store, verified, recorded, and its own dependencies
438    /// walked.
439    fn resolve_registry(
440        &mut self,
441        dir: &Path,
442        spec: &DependencySpec,
443        range: &str,
444    ) -> Result<(), FetchError> {
445        use bynk_project::lock::{parse_registry_source, registry_source};
446        let requirer = read_manifest(dir)
447            .and_then(|doc| {
448                doc.get("package")
449                    .and_then(|v| v.as_table())
450                    .and_then(|t| package_config_from(t).ok())
451            })
452            .map_or_else(
453                || "this project".to_string(),
454                |p| format!("{}/{}", p.org, p.package),
455            );
456        self.ranges
457            .entry(spec.package.clone())
458            .or_default()
459            .insert((requirer, range.to_string()));
460        let org = spec.package.split('/').next().unwrap_or_default();
461        let registry = self.client.route(org);
462        self.used_registries.insert(registry.clone());
463        let locked = self.existing.package(&spec.package).cloned();
464        if let Some(other) = locked
465            .as_ref()
466            .and_then(|p| parse_registry_source(&p.source))
467            && other != registry
468        {
469            return Err(fail(
470                crate::registry::SOURCE_MISMATCH,
471                format!(
472                    "`bynk.lock` locks `{}` to the registry `{other}`, but its organisation `{org}` now routes to `{registry}`",
473                    spec.package
474                ),
475            ));
476        }
477        if self.resolved.contains_key(&spec.package) {
478            // Chosen already this pass; a new range shows up in `ranges`, and
479            // the next pass chooses against it.
480            return Ok(());
481        }
482        let all: Vec<String> = self.ranges[&spec.package]
483            .iter()
484            .map(|(_, r)| r.clone())
485            .collect();
486        let refresh = self.opts.update.as_ref().is_some_and(|names| {
487            names.is_empty() || names.iter().any(|n| *n == spec.alias || *n == spec.package)
488        });
489        if let Some(names) = &self.opts.update {
490            for n in names {
491                if *n == spec.alias || *n == spec.package {
492                    self.updated.insert(n.clone());
493                }
494            }
495        }
496        let locked = locked.filter(|p| !refresh && parse_registry_source(&p.source).is_some());
497        let home = self.home()?;
498        let satisfies = |version: &str| {
499            semver::Version::parse(version).is_ok_and(|v| {
500                all.iter()
501                    .filter_map(|r| semver::VersionReq::parse(r).ok())
502                    .all(|r| r.matches(&v))
503            })
504        };
505        // The locked version, while it still satisfies every range and its
506        // tree is in the store: no network at all.
507        let fast = locked.as_ref().and_then(|p| {
508            let version = p.version.clone()?;
509            let checksum = p.checksum.clone()?;
510            let entry = store_dir(&home, &checksum)?;
511            (satisfies(&version) && entry.is_dir()).then_some((version, checksum, entry))
512        });
513        let (version, checksum, entry) = match fast {
514            Some(hit) => hit,
515            None => {
516                if self.opts.offline {
517                    return Err(fail(
518                        NOT_FETCHED,
519                        format!(
520                            "`{}` ({range} from `{registry}`) needs fetching, and `--offline` was given",
521                            spec.package
522                        ),
523                    ));
524                }
525                let lines = self.client.lines(&registry, &spec.package)?;
526                let toolchain = semver::Version::parse(crate::DRIVER_VERSION)
527                    .unwrap_or(semver::Version::new(0, 0, 0));
528                let locked_version = locked.as_ref().and_then(|p| p.version.clone());
529                let chosen = crate::registry::select::choose(
530                    &lines,
531                    &all,
532                    locked_version.as_deref(),
533                    &toolchain,
534                )
535                .map_err(|why| match why {
536                    crate::registry::select::NoVersion::Conflict => fail(
537                        VERSION_CONFLICT,
538                        format!(
539                            "no version of `{}` on `{registry}` satisfies every range: {}",
540                            spec.package,
541                            self.ranges[&spec.package]
542                                .iter()
543                                .map(|(who, r)| format!("`{r}` (from {who})"))
544                                .collect::<Vec<_>>()
545                                .join(", ")
546                        ),
547                    ),
548                    crate::registry::select::NoVersion::Toolchain(min) => fail(
549                        VERSION_CONFLICT,
550                        format!(
551                            "every version of `{}` matching {} requires bynk ≥ {min}",
552                            spec.package,
553                            all.join(", ")
554                        ),
555                    ),
556                    // #2330 (B): the lock names a version the registry took
557                    // down, and its tree isn't in the store (that took the
558                    // no-network path above and still builds).
559                    crate::registry::select::NoVersion::Withdrawn(version, reason) => self
560                        .client
561                        .withdrawn(&registry, &spec.package, &version, &reason, &spec.alias),
562                })?
563                .clone();
564                // The lock pins the locked version's tree: a line for that
565                // version naming another checksum (re-signed contents) is
566                // refused, with or without `--locked`.
567                if let Some(p) = &locked
568                    && p.version.as_deref() == Some(chosen.version.as_str())
569                    && let Some(pinned) = &p.checksum
570                    && *pinned != chosen.checksum
571                {
572                    return Err(fail(
573                        CHECKSUM_MISMATCH,
574                        format!(
575                            "`{}` {} on `{registry}` now names the tree {}, but `bynk.lock` pins {pinned} — a published version's contents changed",
576                            spec.package, chosen.version, chosen.checksum
577                        ),
578                    ));
579                }
580                if self.opts.locked && locked_version.as_deref() != Some(chosen.version.as_str()) {
581                    return Err(fail(
582                        LOCK_OUT_OF_DATE,
583                        format!(
584                            "`{}` would resolve to {} on `{registry}`, which isn't what `bynk.lock` says, and `--locked` was given",
585                            spec.package, chosen.version
586                        ),
587                    ));
588                }
589                let fresh = !store_dir(&home, &chosen.checksum).is_some_and(|d| d.is_dir());
590                let entry = self.client.fetch(&registry, &chosen, &home)?;
591                if fresh {
592                    self.prepared
593                        .fetched
594                        .push(format!("{}@{}", spec.package, chosen.version));
595                }
596                (chosen.version.clone(), chosen.checksum.clone(), entry)
597            }
598        };
599        verify(&entry, &checksum, &spec.package)?;
600        let Some(doc) = read_manifest(&entry) else {
601            return Err(fail(
602                NOT_FETCHED,
603                format!("`{}` {version} has no `bynk.toml`", spec.package),
604            ));
605        };
606        let deps = specs(&doc, "dependencies");
607        if !self.record(
608            spec,
609            registry_source(&registry),
610            Some(checksum),
611            Some(version),
612            &deps,
613        )? {
614            return Ok(());
615        }
616        self.dirs.insert(spec.package.clone(), entry.clone());
617        for d in deps {
618            self.resolve(&entry, Declarer::Fetched, &d)?;
619        }
620        Ok(())
621    }
622
623    /// Record a resolved package. `Ok(false)` when it was already resolved
624    /// identically (its dependencies are walked once); a different source for
625    /// the same package is a version conflict.
626    fn record(
627        &mut self,
628        spec: &DependencySpec,
629        source: String,
630        checksum: Option<String>,
631        version: Option<String>,
632        deps: &[DependencySpec],
633    ) -> Result<bool, FetchError> {
634        if let Some(seen) = self.resolved.get(&spec.package) {
635            // Two directories for one path package is the compile's
636            // `duplicate_source` to report, with its fuller context.
637            if seen.source == source
638                || !(seen.source.starts_with("git+") || source.starts_with("git+"))
639            {
640                return Ok(false);
641            }
642            return Err(fail(
643                VERSION_CONFLICT,
644                format!(
645                    "the package `{}` is reached from two sources: `{}` and `{source}`",
646                    spec.package, seen.source
647                ),
648            ));
649        }
650        let mut dependencies: Vec<String> = deps
651            .iter()
652            .filter(|d| !matches!(d.source, DependencySource::Version(_)))
653            .map(|d| d.package.clone())
654            .collect::<BTreeSet<_>>()
655            .into_iter()
656            .collect();
657        dependencies.sort();
658        self.resolved.insert(
659            spec.package.clone(),
660            LockedPackage {
661                name: spec.package.clone(),
662                version,
663                source,
664                checksum,
665                dependencies,
666            },
667        );
668        Ok(true)
669    }
670}
671
672/// #2255: the npm packages every resolved dependency's adapters require, as
673/// the lock's `[[npm]]` rows: one row per `(package, range)`, its
674/// `required-by` the `org/package:adapter`s naming it. Every adapter counts,
675/// reached or not — the lock records what *could* enter a build.
676fn npm_requirements(dirs: &BTreeMap<String, PathBuf>) -> Vec<LockedNpm> {
677    let mut rows: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
678    for (name, dir) in dirs {
679        let Some(doc) = read_manifest(dir) else {
680            continue;
681        };
682        for root in bynk_project::packages::package_source_roots(dir, &doc) {
683            // The compile's own walker, so the lock sees exactly the files a
684            // build compiles.
685            let mut files = bynk_project::discover_bynk_files(&root, &[]).unwrap_or_default();
686            files.sort();
687            for file in files {
688                let Ok(text) = std::fs::read_to_string(&file) else {
689                    continue;
690                };
691                let Some((adapter, requires)) = adapter_requires(&text) else {
692                    continue;
693                };
694                for (package, range) in requires {
695                    rows.entry((package, range))
696                        .or_default()
697                        .insert(format!("{name}:{adapter}"));
698                }
699            }
700        }
701    }
702    rows.into_iter()
703        .map(|((package, range), by)| LockedNpm {
704            package,
705            range,
706            required_by: by.into_iter().collect(),
707            approved: false,
708        })
709        .collect()
710}
711
712/// #2296 (decisions A, C): settle each row's approval. A row the lock already
713/// approved, by `(package, range)`, stays approved — saying so when another
714/// adapter now requires it too. A row only path packages require is the
715/// author's own: it needs no approval and is left as it was, so a lock written
716/// before approvals existed reads back unchanged (and `--locked` still holds).
717/// Anything else is pending.
718fn approvals(
719    mut rows: Vec<LockedNpm>,
720    resolved: &BTreeMap<String, LockedPackage>,
721    existing: &Lock,
722    prepared: &mut Prepared,
723) -> Vec<LockedNpm> {
724    let own = |by: &str| {
725        let package = by.split(':').next().unwrap_or_default();
726        resolved
727            .get(package)
728            .is_some_and(|p| p.source.starts_with("path+"))
729    };
730    for row in &mut rows {
731        let before = existing
732            .npm
733            .iter()
734            .find(|n| n.package == row.package && n.range == row.range);
735        if let Some(before) = before.filter(|b| b.approved) {
736            row.approved = true;
737            let added: Vec<&str> = row
738                .required_by
739                .iter()
740                .filter(|by| !before.required_by.contains(by))
741                .map(String::as_str)
742                .collect();
743            if !added.is_empty() {
744                prepared.warnings.push(format!(
745                    "the approved npm package `{}` {} is now also required by {}",
746                    row.package,
747                    row.range,
748                    added
749                        .iter()
750                        .map(|a| format!("`{a}`"))
751                        .collect::<Vec<_>>()
752                        .join(", ")
753                ));
754            }
755        } else if row.required_by.iter().all(|by| own(by)) {
756            // Never gated: the compile checks only fetched packages' adapters.
757        } else {
758            prepared.pending.push(row.clone());
759        }
760    }
761    rows
762}
763
764/// Refuse a git source that could run a command or reach outside the
765/// project (track §6.3): an option-shaped URL or revision, a transport that
766/// isn't `https`/`ssh` (or `file`, from the author's own manifests).
767fn check_source(url: &str, rev: &str, declarer: Declarer) -> Result<(), FetchError> {
768    if url.starts_with('-') || rev.starts_with('-') {
769        return Err(fail(
770            UNSAFE_SOURCE,
771            format!("the git source `{url}` at `{rev}` looks like a command-line option"),
772        ));
773    }
774    let lower = url.to_ascii_lowercase();
775    // `user@host:path` (scp-like) is git's rule: no `://`, and the first `:`
776    // comes before any `/`. Anything else with a scheme must be https or ssh.
777    let scp_like = !lower.contains("://")
778        && !lower.contains("::")
779        && lower.contains('@')
780        && lower
781            .find(':')
782            .is_some_and(|colon| lower.find('/').is_none_or(|slash| colon < slash));
783    let scheme_ok = lower.starts_with("https://") || lower.starts_with("ssh://") || scp_like;
784    let file = lower.starts_with("file://");
785    if file && declarer == Declarer::Fetched {
786        return Err(fail(
787            PATH_OUTSIDE_ROOT,
788            format!(
789                "a fetched package names the local git repository `{url}`, and a fetched package can't reach one"
790            ),
791        ));
792    }
793    if !(scheme_ok || file) {
794        return Err(fail(
795            UNSAFE_SOURCE,
796            format!("the git source `{url}` isn't an `https://`, `ssh://` or `user@host:path` URL"),
797        ));
798    }
799    Ok(())
800}
801
802/// `git` with the hardening every invocation carries.
803fn git(allow_file: bool) -> Command {
804    let mut cmd = Command::new("git");
805    cmd.args(["-c", "protocol.allow=never"])
806        .args(["-c", "protocol.https.allow=always"])
807        .args(["-c", "protocol.ssh.allow=always"])
808        .args(["-c", "core.hooksPath=/dev/null"])
809        .args(["-c", "core.autocrlf=false"])
810        .args(["-c", "core.symlinks=false"])
811        .args(["-c", "submodule.recurse=false"])
812        .env("GIT_TERMINAL_PROMPT", "0")
813        .env_remove("GIT_DIR")
814        .env_remove("GIT_WORK_TREE");
815    if allow_file {
816        cmd.args(["-c", "protocol.file.allow=always"]);
817    }
818    cmd
819}
820
821fn run_git(mut cmd: Command, what: &str) -> Result<String, FetchError> {
822    let out = cmd.output().map_err(|e| {
823        fail(
824            GIT_FAILED,
825            format!("couldn't run `git` to {what}: {e} — is git installed? (`bynk doctor` checks)"),
826        )
827    })?;
828    if !out.status.success() {
829        return Err(fail(
830            GIT_FAILED,
831            format!(
832                "`git` failed to {what}: {}",
833                String::from_utf8_lossy(&out.stderr).trim()
834            ),
835        ));
836    }
837    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
838}
839
840/// The bare clone of `url` in the cache, created or brought up to date.
841fn bare_clone(home: &Path, url: &str, allow_file: bool) -> Result<PathBuf, FetchError> {
842    let digest: [u8; 32] = Sha256::digest(url.as_bytes()).into();
843    let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
844    let dir = home.join("git").join(hex);
845    if dir.join("HEAD").is_file() {
846        let mut cmd = git(allow_file);
847        cmd.arg("--git-dir")
848            .arg(&dir)
849            .args(["fetch", "--quiet", "--tags", "--force", "--"])
850            .arg(url)
851            .arg("+refs/heads/*:refs/heads/*");
852        run_git(cmd, &format!("fetch `{url}`"))?;
853        return Ok(dir);
854    }
855    std::fs::create_dir_all(dir.parent().unwrap())
856        .map_err(|e| fail(GIT_FAILED, format!("could not create the git cache: {e}")))?;
857    let tmp = dir.with_extension(format!("partial.{}", std::process::id()));
858    let _ = std::fs::remove_dir_all(&tmp);
859    let mut cmd = git(allow_file);
860    cmd.args([
861        "clone",
862        "--bare",
863        "--quiet",
864        "--no-recurse-submodules",
865        "--",
866    ])
867    .arg(url)
868    .arg(&tmp);
869    run_git(cmd, &format!("clone `{url}`"))?;
870    if let Err(e) = std::fs::rename(&tmp, &dir) {
871        // Another `bynk` placed it first: theirs is as good as ours.
872        let _ = std::fs::remove_dir_all(&tmp);
873        if !dir.join("HEAD").is_file() {
874            return Err(fail(
875                GIT_FAILED,
876                format!("could not place the clone of `{url}`: {e}"),
877            ));
878        }
879    }
880    Ok(dir)
881}
882
883/// The commit `rev` names in `url`.
884fn resolve_rev(home: &Path, url: &str, rev: &str, allow_file: bool) -> Result<String, FetchError> {
885    let dir = bare_clone(home, url, allow_file)?;
886    let mut cmd = git(allow_file);
887    cmd.arg("--git-dir")
888        .arg(&dir)
889        .args(["rev-parse", "--verify", "--quiet"])
890        .arg(format!("{rev}^{{commit}}"));
891    run_git(cmd, &format!("find `{rev}` in `{url}`")).map_err(|_| {
892        fail(
893            GIT_FAILED,
894            format!("`{url}` has no tag, branch or commit `{rev}`"),
895        )
896    })
897}
898
899/// Check `commit` of `url` out into the store, returning its checksum. An
900/// entry already there is left alone.
901fn fetch_into_store(
902    home: &Path,
903    url: &str,
904    commit: &str,
905    allow_file: bool,
906) -> Result<String, FetchError> {
907    let dir = bare_clone(home, url, allow_file)?;
908    // Per process, so two `bynk` runs fetching one commit never share (or
909    // delete) each other's checkout mid-hash.
910    let staging = staging_dir(home, commit);
911    std::fs::create_dir_all(&staging)
912        .map_err(|e| fail(GIT_FAILED, format!("could not stage `{url}`: {e}")))?;
913    let index = home
914        .join("store")
915        .join("staging")
916        .join(format!("{commit}.{}.index", std::process::id()));
917    let mut read = git(allow_file);
918    read.arg("--git-dir")
919        .arg(&dir)
920        .arg("--work-tree")
921        .arg(&staging)
922        .env("GIT_INDEX_FILE", &index)
923        .args(["read-tree", commit]);
924    run_git(read, &format!("read `{url}` at {commit}"))?;
925    let mut checkout = git(allow_file);
926    checkout
927        .arg("--git-dir")
928        .arg(&dir)
929        .arg("--work-tree")
930        .arg(&staging)
931        .env("GIT_INDEX_FILE", &index)
932        .args(["checkout-index", "--all", "--force"]);
933    run_git(checkout, &format!("check out `{url}` at {commit}"))?;
934    let _ = std::fs::remove_file(&index);
935    place_in_store(home, &staging, &format!("`{url}` at {commit}"), GIT_FAILED)
936}
937
938/// Remove a store entry (its files are read-only).
939pub(crate) fn remove_entry(dir: &Path) -> std::io::Result<()> {
940    let _ = make_writable(dir);
941    std::fs::remove_dir_all(dir)
942}
943
944/// A fresh, per-process staging directory under the store for `tag`.
945pub(crate) fn staging_dir(home: &Path, tag: &str) -> PathBuf {
946    let staging = home
947        .join("store")
948        .join("staging")
949        .join(format!("{tag}.{}", std::process::id()));
950    let _ = make_writable(&staging);
951    let _ = std::fs::remove_dir_all(&staging);
952    staging
953}
954
955/// Hash a staged tree and move it into the store under its checksum (made
956/// read-only), returning the checksum. An identical entry already there —
957/// placed by an earlier run, or by a concurrent one that won the rename — is
958/// kept, and the staging copy discarded.
959pub(crate) fn place_in_store(
960    home: &Path,
961    staging: &Path,
962    what: &str,
963    code: &'static str,
964) -> Result<String, FetchError> {
965    let checksum =
966        tree_checksum(staging).map_err(|e| fail(code, format!("could not hash {what}: {e}")))?;
967    let entry = store_dir(home, &checksum).expect("a checksum this module wrote");
968    if entry.is_dir() {
969        let _ = std::fs::remove_dir_all(staging);
970    } else {
971        std::fs::create_dir_all(entry.parent().unwrap())
972            .map_err(|e| fail(code, format!("could not create the store: {e}")))?;
973        match std::fs::rename(staging, &entry) {
974            Ok(()) => {
975                let _ = make_read_only(&entry);
976            }
977            // Another `bynk` placed the same tree first.
978            Err(_) if entry.is_dir() => {
979                let _ = std::fs::remove_dir_all(staging);
980            }
981            Err(e) => {
982                return Err(fail(
983                    code,
984                    format!("could not place {what} in the store: {e}"),
985                ));
986            }
987        }
988    }
989    Ok(checksum)
990}
991
992/// Re-hash a store entry against its locked checksum.
993fn verify(entry: &Path, checksum: &str, package: &str) -> Result<(), FetchError> {
994    let got = tree_checksum(entry).map_err(|e| {
995        fail(
996            CHECKSUM_MISMATCH,
997            format!("could not read the store entry for `{package}`: {e}"),
998        )
999    })?;
1000    if got != checksum {
1001        return Err(fail(
1002            CHECKSUM_MISMATCH,
1003            format!(
1004                "the store entry for `{package}` (`{}`) hashes to {got}, but `bynk.lock` says {checksum} — delete it and run `bynk fetch` (its files are read-only: clear that first, with `chmod -R u+w` or `attrib -r /s`)",
1005                entry.display()
1006            ),
1007        ));
1008    }
1009    Ok(())
1010}
1011
1012/// The canonical-tree checksum (decision C): every regular file under `dir`
1013/// except `.git`, in byte-wise sorted `/`-separated path order, each
1014/// contributing `path \0 length \0 bytes`. Symbolic links aren't files and
1015/// contribute nothing.
1016pub fn tree_checksum(dir: &Path) -> std::io::Result<String> {
1017    let mut files: Vec<(String, PathBuf)> = Vec::new();
1018    collect(dir, dir, &mut files)?;
1019    files.sort();
1020    let mut hasher = Sha256::new();
1021    for (rel, path) in files {
1022        let bytes = std::fs::read(&path)?;
1023        hasher.update(rel.as_bytes());
1024        hasher.update([0]);
1025        hasher.update(bytes.len().to_string().as_bytes());
1026        hasher.update([0]);
1027        hasher.update(&bytes);
1028    }
1029    let digest: [u8; 32] = hasher.finalize().into();
1030    Ok(checksum_of(&digest))
1031}
1032
1033/// #2274: every regular file under `dir` except `.git`, as `(/-separated
1034/// relative path, path)`, sorted byte-wise — what [`tree_checksum`] hashes,
1035/// and what a registry archive carries.
1036pub(crate) fn tree_files(dir: &Path) -> std::io::Result<Vec<(String, PathBuf)>> {
1037    let mut files = Vec::new();
1038    collect(dir, dir, &mut files)?;
1039    files.sort();
1040    Ok(files)
1041}
1042
1043fn collect(root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> std::io::Result<()> {
1044    for entry in std::fs::read_dir(dir)? {
1045        let entry = entry?;
1046        let path = entry.path();
1047        let kind = entry.file_type()?;
1048        if kind.is_dir() {
1049            if entry.file_name() == ".git" {
1050                continue;
1051            }
1052            collect(root, &path, out)?;
1053        } else if kind.is_file() {
1054            let rel: Vec<String> = path
1055                .strip_prefix(root)
1056                .unwrap_or(&path)
1057                .components()
1058                .map(|c| c.as_os_str().to_string_lossy().into_owned())
1059                .collect();
1060            out.push((rel.join("/"), path));
1061        }
1062    }
1063    Ok(())
1064}
1065
1066fn make_read_only(dir: &Path) -> std::io::Result<()> {
1067    for entry in std::fs::read_dir(dir)? {
1068        let entry = entry?;
1069        let path = entry.path();
1070        if entry.file_type()?.is_dir() {
1071            make_read_only(&path)?;
1072        } else {
1073            let mut perms = std::fs::metadata(&path)?.permissions();
1074            perms.set_readonly(true);
1075            std::fs::set_permissions(&path, perms)?;
1076        }
1077    }
1078    Ok(())
1079}
1080
1081#[allow(clippy::permissions_set_readonly_false)]
1082fn make_writable(dir: &Path) -> std::io::Result<()> {
1083    for entry in std::fs::read_dir(dir)? {
1084        let entry = entry?;
1085        let path = entry.path();
1086        if entry.file_type()?.is_dir() {
1087            make_writable(&path)?;
1088        } else {
1089            let mut perms = std::fs::metadata(&path)?.permissions();
1090            perms.set_readonly(false);
1091            std::fs::set_permissions(&path, perms)?;
1092        }
1093    }
1094    Ok(())
1095}
1096
1097pub(crate) fn read_manifest(dir: &Path) -> Option<toml::Table> {
1098    std::fs::read_to_string(dir.join("bynk.toml"))
1099        .ok()?
1100        .parse()
1101        .ok()
1102}
1103
1104pub(crate) fn specs(doc: &toml::Table, table: &'static str) -> Vec<DependencySpec> {
1105    doc.get(table)
1106        .and_then(|v| v.as_table())
1107        .and_then(|t| dependencies_from(table, t).ok())
1108        .unwrap_or_default()
1109}
1110
1111pub(crate) fn package_version(doc: &toml::Table) -> Option<String> {
1112    doc.get("package")
1113        .and_then(|v| v.as_table())
1114        .and_then(|t| package_config_from(t).ok())
1115        .and_then(|c| c.version)
1116}
1117
1118/// `target` relative to `base`, both made absolute first; `target` itself
1119/// when they share no root.
1120fn relative(base: &Path, target: &Path) -> PathBuf {
1121    let abs = |p: &Path| {
1122        let p = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
1123        std::path::absolute(&p).unwrap_or(p)
1124    };
1125    let (b, t) = (abs(base), abs(target));
1126    let bc: Vec<_> = b.components().collect();
1127    let tc: Vec<_> = t.components().collect();
1128    let common = bc.iter().zip(&tc).take_while(|(x, y)| x == y).count();
1129    if common == 0 {
1130        return t;
1131    }
1132    let mut out = PathBuf::new();
1133    for _ in common..bc.len() {
1134        out.push("..");
1135    }
1136    for c in &tc[common..] {
1137        out.push(c.as_os_str());
1138    }
1139    if out.as_os_str().is_empty() {
1140        out.push(".");
1141    }
1142    out
1143}
1144
1145pub(crate) fn write_atomically(path: &Path, text: &str) -> std::io::Result<()> {
1146    let tmp = path.with_extension("lock.tmp");
1147    std::fs::write(&tmp, text)?;
1148    std::fs::rename(&tmp, path)
1149}
1150
1151/// The checksum's store directory name, for messages and tests.
1152pub fn store_hex(checksum: &str) -> Option<String> {
1153    checksum_hex(checksum)
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158    use super::*;
1159
1160    #[test]
1161    fn option_shaped_and_exotic_sources_are_refused() {
1162        for (url, rev) in [
1163            ("-uhttps://x", "v1"),
1164            ("https://x", "--upload-pack=evil"),
1165            ("ext::sh -c touch% /tmp/pwned", "v1"),
1166            ("fd::17", "v1"),
1167            ("http://plain.example/x", "v1"),
1168            ("http://u@h:80/x", "v1"),
1169            ("git://u@h:9418/x", "v1"),
1170        ] {
1171            assert_eq!(
1172                check_source(url, rev, Declarer::Own).unwrap_err().code,
1173                UNSAFE_SOURCE,
1174                "{url} {rev}"
1175            );
1176        }
1177        assert!(check_source("https://github.com/acme/x", "v1", Declarer::Own).is_ok());
1178        assert!(check_source("git@github.com:acme/x.git", "main", Declarer::Fetched).is_ok());
1179        assert!(check_source("file:///tmp/repo", "main", Declarer::Own).is_ok());
1180        assert_eq!(
1181            check_source("file:///tmp/repo", "main", Declarer::Fetched)
1182                .unwrap_err()
1183                .code,
1184            PATH_OUTSIDE_ROOT
1185        );
1186    }
1187
1188    #[test]
1189    fn the_tree_checksum_is_pinned() {
1190        let dir = std::env::temp_dir().join(format!("bynk-tree-hash-{}", std::process::id()));
1191        let _ = std::fs::remove_dir_all(&dir);
1192        std::fs::create_dir_all(dir.join("src")).unwrap();
1193        std::fs::create_dir_all(dir.join(".git")).unwrap();
1194        std::fs::write(dir.join("bynk.toml"), "[package]\nname = \"a/b\"\n").unwrap();
1195        std::fs::write(dir.join("src/x.bynk"), "commons x\n").unwrap();
1196        std::fs::write(dir.join(".git/HEAD"), "ignored").unwrap();
1197        // sha256 of `path \0 length \0 bytes`, per file, in path order.
1198        let mut h = Sha256::new();
1199        h.update(b"bynk.toml\x0023\x00[package]\nname = \"a/b\"\n");
1200        h.update(b"src/x.bynk\x0010\x00commons x\n");
1201        let want: [u8; 32] = h.finalize().into();
1202        assert_eq!(tree_checksum(&dir).unwrap(), checksum_of(&want));
1203        let _ = std::fs::remove_dir_all(&dir);
1204    }
1205}