1use 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#[derive(Debug, Clone, Default)]
45pub struct FetchOptions {
46 pub locked: bool,
48 pub offline: bool,
50 pub update: Option<Vec<String>>,
53}
54
55#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
86pub struct Prepared {
87 pub wrote_lock: bool,
89 pub fetched: Vec<String>,
91 pub warnings: Vec<String>,
94 pub pending: Vec<LockedNpm>,
97}
98
99pub 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 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 Err(e) => {
120 return Err(fail(
121 LOCK_INVALID,
122 format!("could not read `{}`: {e}", lock_path.display()),
123 ));
124 }
125 };
126 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 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#[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: BTreeMap<String, LockedPackage>,
245 prepared: Prepared,
246 updated: BTreeSet<String>,
248 dirs: BTreeMap<String, PathBuf>,
251 client: crate::registry::Client,
254 ranges: BTreeMap<String, BTreeSet<(String, String)>>,
255 used_registries: BTreeSet<String>,
256}
257
258const 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 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(()); };
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 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 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 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(®istry, &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 crate::registry::select::NoVersion::Withdrawn(version, reason) => self
560 .client
561 .withdrawn(®istry, &spec.package, &version, &reason, &spec.alias),
562 })?
563 .clone();
564 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(®istry, &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(®istry),
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 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 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
672fn 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 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
712fn 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 } else {
758 prepared.pending.push(row.clone());
759 }
760 }
761 rows
762}
763
764fn 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 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
802fn 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
840fn 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 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
883fn 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
899fn 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 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
938pub(crate) fn remove_entry(dir: &Path) -> std::io::Result<()> {
940 let _ = make_writable(dir);
941 std::fs::remove_dir_all(dir)
942}
943
944pub(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
955pub(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 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
992fn 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
1012pub 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
1033pub(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
1118fn 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
1151pub 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 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}