1use std::io::Read;
8use std::path::Path;
9use std::time::Duration;
10
11use dre_protocol::{
12 Kind, PluginId, package_executable_name, parse_executable_name, parse_package_executable_name,
13};
14use semver::{Version, VersionReq};
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18use crate::lock::{Checksums, Lock, Locked};
19use crate::plugins::{Manifest, version_dir};
20use crate::project::{PluginRequirement, PluginSource, Project};
21
22pub const DEFAULT_REGISTRY: &str = "https://github.com/get-dre/dre/releases/download/registry/packages.json";
23
24pub const INDEX_SCHEMA: u32 = 2;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Index {
30 pub schema: u32,
31 pub plugins: Vec<IndexPackage>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(from = "RawPackage")]
37pub struct IndexPackage {
38 pub name: String,
39 #[serde(default)]
40 pub description: String,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub provides: Vec<PluginId>,
45 pub versions: Vec<IndexVersion>,
46}
47
48#[derive(Deserialize)]
49struct RawPackage {
50 name: String,
51 #[serde(default)]
53 kind: Option<Kind>,
54 #[serde(default)]
55 description: String,
56 #[serde(default)]
57 provides: Vec<PluginId>,
58 versions: Vec<IndexVersion>,
59}
60
61impl From<RawPackage> for IndexPackage {
62 fn from(r: RawPackage) -> IndexPackage {
63 let mut provides = r.provides;
64 if provides.is_empty()
65 && let Some(kind) = r.kind
66 {
67 provides.push(PluginId::new(kind, r.name.clone()));
68 }
69 IndexPackage {
70 name: r.name,
71 description: r.description,
72 provides,
73 versions: r.versions,
74 }
75 }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct IndexVersion {
80 pub version: Version,
81 #[serde(default)]
83 pub protocol: u32,
84 pub artifacts: std::collections::BTreeMap<String, Artifact>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Artifact {
90 pub url: String,
91 #[serde(default)]
94 pub sha256: String,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub sha256_url: Option<String>,
97}
98
99pub fn explain_undeclared(diags: &mut crate::Diagnostics) {
103 if !diags.iter_mut().any(|d| d.plugin.is_some()) {
104 return;
105 }
106 let Ok(index) = Index::load() else { return };
107 for d in diags.iter_mut() {
108 let Some((kind, name)) = &d.plugin else { continue };
109 let id = PluginId::new(*kind, name.clone());
110 let what = d.message.split(" — ").next().unwrap_or_default().to_string();
111 let providers: Vec<&str> = index.providers(&id).iter().map(|p| p.name.as_str()).collect();
112 let others: Vec<String> = index
113 .plugins
114 .iter()
115 .flat_map(|p| &p.provides)
116 .filter(|p| &p.name == name && p.kind != *kind)
117 .map(|p| p.kind.to_string())
118 .collect();
119 d.message = match (providers.as_slice(), others.is_empty()) {
120 ([p], _) if *p == name => {
121 format!("{what} — add `{p}` under `plugins:` in dependencies.yml, then run `dre deps`")
122 }
123 ([p], _) => format!(
124 "{what} — the `{name}` {kind} is in the `{p}` package: add `{p}` under `plugins:` in dependencies.yml, then run `dre deps`"
125 ),
126 ([], true) => {
127 format!(
128 "{what} — DRE's plugin registry has no {kind} plugin called `{name}`; check the spelling"
129 )
130 }
131 ([], false) => format!(
132 "{what} — `{name}` in DRE's plugin registry is a {} plugin, not a {kind}",
133 others.join(" and ")
134 ),
135 (many, _) => format!(
136 "{what} — add one of the packages that provide it under `plugins:` in dependencies.yml ({}), then run `dre deps`",
137 many.iter()
138 .map(|p| format!("`{p}`"))
139 .collect::<Vec<_>>()
140 .join(", ")
141 ),
142 };
143 }
144}
145
146pub fn platform() -> String {
148 format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH)
149}
150
151pub fn registry_url() -> String {
152 crate::settings::env(crate::settings::REGISTRY_URL).unwrap_or_else(|| DEFAULT_REGISTRY.to_string())
153}
154
155pub fn github_api() -> String {
157 crate::settings::env(crate::settings::GITHUB_API_URL)
158 .unwrap_or_else(|| "https://api.github.com".into())
159 .trim_end_matches('/')
160 .to_string()
161}
162
163fn is_github(url: &str) -> bool {
165 url.starts_with("https://github.com/") || url.starts_with(&format!("{}/", github_api()))
166}
167
168pub fn fetch(url: &str) -> Result<Vec<u8>, String> {
173 if url.starts_with("http://") || url.starts_with("https://") {
174 return fetch_http(url, http_timeout(), &mut std::thread::sleep);
175 }
176 let path = url.strip_prefix("file://").unwrap_or(url);
177 std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))
178}
179
180const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
182pub const DEFAULT_HTTP_TIMEOUT: u64 = 60;
184const ATTEMPTS: u32 = 3;
185
186static PROJECT_HTTP_TIMEOUT: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
187
188pub fn set_project_http_timeout(secs: u64) {
190 let _ = PROJECT_HTTP_TIMEOUT.set(secs);
191}
192
193pub fn http_timeout() -> Duration {
196 let env = crate::settings::env(crate::settings::HTTP_TIMEOUT).and_then(|v| v.trim().parse::<u64>().ok());
197 Duration::from_secs(
198 env.or_else(|| PROJECT_HTTP_TIMEOUT.get().copied())
199 .filter(|s| *s > 0)
200 .unwrap_or(DEFAULT_HTTP_TIMEOUT),
201 )
202}
203
204enum Try {
206 Again {
208 why: String,
209 after: Option<Duration>,
210 },
211 Fail(String),
212}
213
214fn fetch_http(url: &str, idle: Duration, sleep: &mut dyn FnMut(Duration)) -> Result<Vec<u8>, String> {
215 let mut attempt = 1;
216 loop {
217 match fetch_once(url, idle) {
218 Ok(body) => return Ok(body),
219 Err(Try::Again { after, .. }) if attempt < ATTEMPTS => {
220 let wait = after.unwrap_or_else(|| backoff(attempt));
221 sleep(wait);
222 attempt += 1;
223 }
224 Err(Try::Again { why, .. }) => {
225 return Err(format!(
226 "can't download {url}: {why} (after {ATTEMPTS} tries). If the network is slow, raise the \
227 timeout with DRE_HTTP_TIMEOUT or `flags: http_timeout:` in dre_project.yml (seconds, \
228 default {DEFAULT_HTTP_TIMEOUT}); to run without downloading, install plugins ahead of \
229 time with `dre deps` or point DRE_PLUGINS_DIR at installed ones"
230 ));
231 }
232 Err(Try::Fail(why)) => return Err(format!("can't download {url}: {why}")),
233 }
234 }
235}
236
237fn backoff(attempt: u32) -> Duration {
239 let base = 4f64.powi(attempt as i32 - 1);
240 let nanos = std::time::SystemTime::now()
241 .duration_since(std::time::UNIX_EPOCH)
242 .map(|d| d.subsec_nanos())
243 .unwrap_or(0);
244 Duration::from_secs_f64(base * (0.75 + f64::from(nanos % 1000) / 2000.0))
245}
246
247fn fetch_once(url: &str, idle: Duration) -> Result<Vec<u8>, Try> {
248 let agent: ureq::Agent = ureq::Agent::config_builder()
249 .timeout_connect(Some(CONNECT_TIMEOUT))
250 .timeout_recv_response(Some(idle))
251 .http_status_as_error(false)
252 .build()
253 .into();
254 let mut req = agent.get(url).header("User-Agent", "dre");
255 if is_github(url) {
256 let accept = if url.contains("/releases/assets/") {
258 "application/octet-stream"
259 } else {
260 "application/vnd.github+json"
261 };
262 req = req.header("Accept", accept);
263 if let Ok(t) = std::env::var("GITHUB_TOKEN")
264 && !t.is_empty()
265 {
266 req = req.header("Authorization", &format!("Bearer {t}"));
267 }
268 }
269 let resp = req.call().map_err(|e| match e {
270 ureq::Error::Timeout(_) => Try::Again {
271 why: format!("no answer for {}s", idle.as_secs()),
272 after: None,
273 },
274 ureq::Error::Io(_)
275 | ureq::Error::ConnectionFailed
276 | ureq::Error::HostNotFound
277 | ureq::Error::Protocol(_) => Try::Again {
278 why: e.to_string(),
279 after: None,
280 },
281 e => Try::Fail(e.to_string()),
282 })?;
283 let status = resp.status().as_u16();
284 if status == 429 || (500..600).contains(&status) {
285 let after = resp
286 .headers()
287 .get("retry-after")
288 .and_then(|v| v.to_str().ok())
289 .and_then(|v| v.trim().parse::<u64>().ok())
290 .map(|s| Duration::from_secs(s.min(120)));
291 return Err(Try::Again {
292 why: format!("HTTP {status}"),
293 after,
294 });
295 }
296 if !(200..300).contains(&status) {
297 return Err(Try::Fail(format!("HTTP {status}")));
298 }
299 read_with_idle_timeout(resp.into_body().into_reader(), idle)
300}
301
302fn read_with_idle_timeout(
305 mut r: impl std::io::Read + Send + 'static,
306 idle: Duration,
307) -> Result<Vec<u8>, Try> {
308 let (tx, rx) = std::sync::mpsc::sync_channel::<std::io::Result<Vec<u8>>>(4);
309 std::thread::spawn(move || {
310 let mut buf = vec![0u8; 64 * 1024];
311 loop {
312 let chunk = match r.read(&mut buf) {
313 Ok(0) => Ok(Vec::new()),
314 Ok(n) => Ok(buf[..n].to_vec()),
315 Err(e) => Err(e),
316 };
317 let done = !matches!(&chunk, Ok(c) if !c.is_empty());
318 if tx.send(chunk).is_err() || done {
319 return;
320 }
321 }
322 });
323 let mut body = Vec::new();
324 loop {
325 match rx.recv_timeout(idle) {
326 Ok(Ok(c)) if c.is_empty() => return Ok(body),
327 Ok(Ok(c)) => body.extend_from_slice(&c),
328 Ok(Err(e)) => {
329 return Err(Try::Again {
330 why: e.to_string(),
331 after: None,
332 });
333 }
334 Err(_) => {
335 return Err(Try::Again {
336 why: format!("no data for {}s", idle.as_secs()),
337 after: None,
338 });
339 }
340 }
341 }
342}
343
344impl Index {
345 pub fn load() -> Result<Index, String> {
347 Index::load_from(®istry_url())
348 }
349
350 pub fn load_from(url: &str) -> Result<Index, String> {
351 let body = fetch(url)?;
352 serde_json::from_slice(&body)
353 .map_err(|e| format!("the plugin registry at {url} isn't a valid index: {e}"))
354 }
355
356 pub fn for_source(source: &PluginSource, name: &str) -> Result<Index, String> {
358 match source {
359 PluginSource::Default => Index::load(),
360 PluginSource::Registry(u) => Index::load_from(u),
361 PluginSource::Github(repo) => github_index(repo, name),
362 PluginSource::Local(p) => Err(format!(
363 "plugin package `{name}` is used from {p}; there's nothing to install"
364 )),
365 }
366 }
367
368 pub fn package(&self, name: &str) -> Option<&IndexPackage> {
369 self.plugins.iter().find(|p| p.name == name)
370 }
371
372 pub fn providers(&self, id: &PluginId) -> Vec<&IndexPackage> {
374 self.plugins.iter().filter(|p| p.provides.contains(id)).collect()
375 }
376
377 pub fn providers_of_name(&self, name: &str) -> Vec<&IndexPackage> {
379 self.plugins
380 .iter()
381 .filter(|p| p.provides.iter().any(|i| i.name == name))
382 .collect()
383 }
384}
385
386impl IndexPackage {
387 pub fn best(&self, req: &VersionReq) -> Option<&IndexVersion> {
391 let plat = platform();
392 let usable: Vec<&IndexVersion> = self
393 .versions
394 .iter()
395 .filter(|v| (dre_protocol::MIN_VERSION..=dre_protocol::MAX_VERSION).contains(&v.protocol))
396 .filter(|v| v.artifacts.contains_key(&plat))
397 .collect();
398 prefer_stable(usable, req, |v| &v.version)
399 }
400
401 pub fn exact(&self, v: &Version) -> Option<&IndexVersion> {
402 self.versions.iter().find(|x| &x.version == v)
403 }
404}
405
406pub fn prefer_stable<T>(items: Vec<T>, req: &VersionReq, version: impl Fn(&T) -> &Version) -> Option<T> {
410 let highest = |ok: &dyn Fn(&Version) -> bool, items: Vec<T>| {
411 items
412 .into_iter()
413 .filter(|i| ok(version(i)))
414 .max_by(|a, b| version_order(version(a), version(b)))
415 };
416 let exact = |v: &Version| req.matches(v);
417 if items.iter().any(|i| exact(version(i))) {
418 return highest(&exact, items);
419 }
420 highest(
421 &|v: &Version| !v.pre.is_empty() && req.matches(&Version::new(v.major, v.minor, v.patch)),
422 items,
423 )
424}
425
426pub fn version_order(a: &Version, b: &Version) -> std::cmp::Ordering {
430 use std::cmp::Ordering::*;
431 (a.major, a.minor, a.patch)
432 .cmp(&(b.major, b.minor, b.patch))
433 .then_with(|| match (a.pre.is_empty(), b.pre.is_empty()) {
434 (true, true) => Equal,
435 (true, false) => Greater,
436 (false, true) => Less,
437 (false, false) => {
438 let (mut x, mut y) = (a.pre.split('.'), b.pre.split('.'));
439 loop {
440 match (x.next(), y.next()) {
441 (None, None) => break Equal,
442 (None, Some(_)) => break Less,
443 (Some(_), None) => break Greater,
444 (Some(p), Some(q)) => match natural_order(p, q) {
445 Equal => {}
446 o => break o,
447 },
448 }
449 }
450 }
451 })
452 .then_with(|| a.cmp(b))
453}
454
455fn natural_order(a: &str, b: &str) -> std::cmp::Ordering {
458 fn runs(s: &str) -> Vec<&str> {
459 let mut out = Vec::new();
460 let mut start = 0;
461 let bytes = s.as_bytes();
462 for i in 1..=bytes.len() {
463 if i == bytes.len() || bytes[i].is_ascii_digit() != bytes[start].is_ascii_digit() {
464 out.push(&s[start..i]);
465 start = i;
466 }
467 }
468 out
469 }
470 let (ra, rb) = (runs(a), runs(b));
471 for (p, q) in ra.iter().zip(&rb) {
472 let digits = |s: &str| s.bytes().all(|c| c.is_ascii_digit());
473 let o = match (digits(p), digits(q)) {
474 (true, true) => {
475 let (p, q) = (p.trim_start_matches('0'), q.trim_start_matches('0'));
476 p.len().cmp(&q.len()).then_with(|| p.cmp(q))
477 }
478 (true, false) => std::cmp::Ordering::Less,
479 (false, true) => std::cmp::Ordering::Greater,
480 (false, false) => p.cmp(q),
481 };
482 if o != std::cmp::Ordering::Equal {
483 return o;
484 }
485 }
486 ra.len().cmp(&rb.len())
487}
488
489#[derive(Debug, Clone)]
491pub struct Release {
492 pub tag: String,
493 pub version: Version,
494 pub assets: Vec<(String, String)>,
496}
497
498pub fn github_releases(repo: &str) -> Result<Vec<Release>, String> {
502 let mut out: Vec<Release> = list_github_releases(repo)?
503 .into_iter()
504 .filter(|r| !r.draft)
505 .filter_map(|r| {
506 let version = Version::parse(r.tag_name.trim_start_matches('v')).ok()?;
507 Some(Release {
508 tag: r.tag_name,
509 version,
510 assets: r.assets.into_iter().map(|a| (a.name, a.url)).collect(),
511 })
512 })
513 .collect();
514 out.sort_by(|a, b| version_order(&b.version, &a.version));
515 Ok(out)
516}
517
518fn list_github_releases(repo: &str) -> Result<Vec<GithubRelease>, String> {
520 const PER_PAGE: usize = 100;
521 let mut releases = Vec::new();
522 for page in 1.. {
523 let url = format!(
524 "{}/repos/{repo}/releases?per_page={PER_PAGE}&page={page}",
525 github_api()
526 );
527 let body = fetch(&url)?;
528 let batch: Vec<GithubRelease> =
529 serde_json::from_slice(&body).map_err(|e| format!("{url}: unexpected reply from GitHub: {e}"))?;
530 let last = batch.len() < PER_PAGE;
531 releases.extend(batch);
532 if last {
533 break;
534 }
535 }
536 Ok(releases)
537}
538
539#[derive(Deserialize)]
540struct GithubRelease {
541 tag_name: String,
542 #[serde(default)]
543 draft: bool,
544 #[serde(default)]
545 assets: Vec<GithubAsset>,
546}
547
548#[derive(Deserialize)]
549struct GithubAsset {
550 name: String,
551 url: String,
554}
555
556fn github_index(repo: &str, name: &str) -> Result<Index, String> {
562 let releases = list_github_releases(repo)?;
563 let package_stem = format!("dre-plugin-{name}");
564 let own_tag = format!("{name}-v");
565 let mut provides: Vec<PluginId> = Vec::new();
566 let mut versions = Vec::new();
567 for r in releases.iter().filter(|r| !r.draft) {
568 let tag = r.tag_name.strip_prefix(&own_tag).unwrap_or(&r.tag_name);
569 let Ok(version) = Version::parse(tag.trim_start_matches('v')) else {
570 continue;
571 };
572 let suffix = format!("-{version}-");
573 let mut artifacts = std::collections::BTreeMap::new();
574 for a in &r.assets {
575 let Some((stem, rest)) = a.name.split_once(&suffix) else {
576 continue;
577 };
578 if stem != package_stem {
579 match parse_executable_name(&format!("{stem}{}", std::env::consts::EXE_SUFFIX)) {
580 Some((kind, n)) if n == name => {
581 let id = PluginId::new(kind, n);
582 if !provides.contains(&id) {
583 provides.push(id);
584 }
585 }
586 _ => continue,
587 }
588 }
589 let plat = rest
590 .trim_end_matches(".tar.gz")
591 .trim_end_matches(".tgz")
592 .trim_end_matches(".exe");
593 if plat.contains('.') || plat.is_empty() {
594 continue; }
596 let sha256_url = r
597 .assets
598 .iter()
599 .find(|s| s.name == format!("{}.sha256", a.name))
600 .map(|s| s.url.clone());
601 artifacts.insert(
602 plat.to_string(),
603 Artifact {
604 url: a.url.clone(),
605 sha256: String::new(),
606 sha256_url,
607 },
608 );
609 }
610 if !artifacts.is_empty() {
611 versions.push(IndexVersion {
612 version,
613 protocol: dre_protocol::MAX_VERSION,
614 artifacts,
615 });
616 }
617 }
618 if versions.is_empty() {
619 return Err(format!(
620 "no release of github.com/{repo} has a `{package_stem}-<version>-<platform>` asset for any platform"
621 ));
622 }
623 if provides.len() > 1 {
625 provides.clear();
626 }
627 Ok(Index {
628 schema: INDEX_SCHEMA,
629 plugins: vec![IndexPackage {
630 name: name.to_string(),
631 description: format!("from github.com/{repo}"),
632 provides,
633 versions,
634 }],
635 })
636}
637
638pub fn install(
641 dir: &Path,
642 package: &IndexPackage,
643 v: &IndexVersion,
644 pin: Option<&Checksums>,
645) -> Result<Locked, String> {
646 let plat = platform();
647 let what = format!("plugin package `{}` {}", package.name, v.version);
648 let art = v
649 .artifacts
650 .get(&plat)
651 .ok_or_else(|| format!("{what} has no build for {plat}"))?;
652 let published = if !art.sha256.is_empty() {
654 Some(art.sha256.clone())
655 } else if let Some(u) = &art.sha256_url {
656 let text = String::from_utf8_lossy(&fetch(u)?).to_string();
657 Some(text.split_whitespace().next().unwrap_or_default().to_string())
658 } else {
659 None
660 };
661 let refuse = || format!("{what}: the registry's checksum doesn't match dre.lock; refusing to install");
662 let mut want = pin.and_then(|c| c.get(&plat));
666 if let (Some(want), Some(published)) = (want, &published)
667 && !want.eq_ignore_ascii_case(published)
668 {
669 return Err(refuse());
670 }
671 if want.is_none()
672 && let Some(legacy) = pin.and_then(Checksums::legacy)
673 {
674 let all = published_checksums(v);
675 if all.is_empty() {
676 want = Some(legacy);
677 } else if !all.contains(legacy) {
678 return Err(refuse());
679 }
680 }
681 let bytes = fetch(&art.url)?;
682 let got = hex(&Sha256::digest(&bytes));
683 let expected = published.as_deref().or(want);
685 if let Some(want) = expected
686 && !got.eq_ignore_ascii_case(want)
687 {
688 return Err(format!(
689 "checksum mismatch for {what} (expected {want}, got {got}); the download was discarded"
690 ));
691 }
692 let (exe_name, exe) = if art.url.ends_with(".tar.gz") || art.url.ends_with(".tgz") {
693 extract_tar_gz(&bytes, &package.name)?
694 } else {
695 let name = crate::plugins::legacy_executable(&package.provides)
696 .unwrap_or_else(|| package_executable_name(&package.name));
697 (name, bytes)
698 };
699 let vdir = version_dir(dir, &package.name, &v.version);
700 std::fs::create_dir_all(&vdir).map_err(|e| format!("can't create {}: {e}", vdir.display()))?;
701 let dst = vdir.join(&exe_name);
702 let tmp = vdir.join(format!(".download-{}", std::process::id()));
703 std::fs::write(&tmp, &exe).map_err(|e| format!("can't write {}: {e}", tmp.display()))?;
704 #[cfg(unix)]
705 {
706 use std::os::unix::fs::PermissionsExt;
707 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?;
708 }
709 std::fs::rename(&tmp, &dst).map_err(|e| format!("can't install {}: {e}", dst.display()))?;
710 let provides = if package.provides.is_empty() {
712 crate::plugins::probe(&dst)?
713 } else {
714 package.provides.clone()
715 };
716 crate::plugins::write_manifest(
717 &vdir,
718 &Manifest {
719 executable: exe_name,
720 provides: provides.clone(),
721 },
722 )?;
723 let mut sha256 = published_checksums(v);
724 if let Some(p) = pin {
725 sha256.merge(p);
726 }
727 sha256.insert(&plat, &got);
728 Ok(Locked {
729 version: v.version.clone(),
730 sha256,
731 from: None,
732 provides,
733 })
734}
735
736fn published_checksums(v: &IndexVersion) -> Checksums {
738 let mut c = Checksums::new();
739 for (p, a) in &v.artifacts {
740 if !a.sha256.is_empty() {
741 c.insert(p, &a.sha256);
742 }
743 }
744 c
745}
746
747fn extract_tar_gz(bytes: &[u8], package: &str) -> Result<(String, Vec<u8>), String> {
750 let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(bytes));
751 for entry in archive.entries().map_err(|e| e.to_string())? {
752 let mut entry = entry.map_err(|e| e.to_string())?;
753 let path = entry.path().map_err(|e| e.to_string())?.to_path_buf();
754 let Some(file) = path.file_name().map(|f| f.to_string_lossy().to_string()) else {
755 continue;
756 };
757 let ours = parse_package_executable_name(&file).is_some_and(|n| n == package)
758 || parse_executable_name(&file).is_some_and(|(_, n)| n == package);
759 if ours {
760 let mut out = Vec::new();
761 entry.read_to_end(&mut out).map_err(|e| e.to_string())?;
762 return Ok((file, out));
763 }
764 }
765 Err(format!(
766 "the archive doesn't contain `{}`",
767 package_executable_name(package)
768 ))
769}
770
771fn hex(b: &[u8]) -> String {
772 b.iter().map(|x| format!("{x:02x}")).collect()
773}
774
775#[derive(Debug, Clone, PartialEq, Eq)]
777pub enum Synced {
778 AlreadyInstalled { name: String, version: Option<Version> },
779 Installed { name: String, version: Version },
780}
781
782pub fn sync(
790 project: &Project,
791 install_missing: bool,
792 resolve: bool,
793 mut log: impl FnMut(&str),
794) -> Result<Vec<Synced>, Vec<String>> {
795 let dir = crate::plugins::plugins_dir(Some(&project.root));
796 let mut lock = Lock::load(&project.root).map_err(|e| vec![e])?;
797 let mut indexes: std::collections::BTreeMap<String, Index> = std::collections::BTreeMap::new();
798 let mut out = Vec::new();
799 let mut errors = Vec::new();
800 let mut lock_changed = false;
801 for req in &project.plugins {
802 let name = &req.name;
803 if let PluginSource::Local(p) = &req.source {
804 if !project.root.join(p).is_file() {
805 errors.push(format!(
806 "the plugin package `{name}` is declared `local: {p}`, but there's no file there"
807 ));
808 continue;
809 }
810 if lock.local.get(name) != Some(p) {
811 lock.local.insert(name.clone(), p.clone());
812 lock_changed = true;
813 }
814 lock_changed |= lock.plugins.remove(name).is_some();
815 out.push(Synced::AlreadyInstalled {
816 name: name.clone(),
817 version: None,
818 });
819 continue;
820 }
821 lock_changed |= lock.local.remove(name).is_some();
822 let moved = lock.get(name).is_some_and(|l| l.from != req.source.lock_key());
825 let pin = lock
826 .get(name)
827 .filter(|l| l.from == req.source.lock_key())
828 .cloned();
829 if !moved
830 && let Some(p) =
831 crate::plugins::find(&dir, name, Some(&req.req()), pin.as_ref().map(|l| &l.version))
832 .into_iter()
833 .next()
834 {
835 let ok = match (&pin, &p.version) {
838 (Some(l), Some(v)) => &l.version == v,
839 (None, Some(_)) => !resolve,
840 _ => true,
841 };
842 if ok {
843 if let (Some(l), Some(_)) = (lock.plugins.get_mut(name), &p.version)
845 && l.provides.is_empty()
846 && !p.provides.is_empty()
847 {
848 l.provides = p.provides.clone();
849 lock_changed = true;
850 }
851 out.push(Synced::AlreadyInstalled {
852 name: name.clone(),
853 version: p.version,
854 });
855 continue;
856 }
857 }
858 if let Some(l) = &pin
861 && req.source.is_default()
862 && dir != crate::plugins::cache_dir()
863 {
864 let cached = version_dir(&crate::plugins::cache_dir(), name, &l.version);
865 if crate::plugins::read_manifest(&cached).is_some()
866 && crate::plugins::link_version(&cached, &version_dir(&dir, name, &l.version)).is_ok()
867 {
868 out.push(Synced::AlreadyInstalled {
869 name: name.clone(),
870 version: Some(l.version.clone()),
871 });
872 continue;
873 }
874 }
875 if !install_missing {
876 errors.push(format!(
877 "the plugin package `{name}` ({}) isn't installed and auto-install is off; run `dre deps`",
878 pin.as_ref()
879 .map(|l| format!("locked at {}", l.version))
880 .unwrap_or_else(|| req.version.clone())
881 ));
882 continue;
883 }
884 let ikey = match &req.source {
886 PluginSource::Github(_) => format!("{}#{name}", req.source.lock_key().unwrap_or_default()),
887 s => s.lock_key().unwrap_or_default(),
888 };
889 if !indexes.contains_key(&ikey) {
890 match Index::for_source(&req.source, name) {
891 Ok(i) => {
892 indexes.insert(ikey.clone(), i);
893 }
894 Err(e) if req.source.is_default() => return Err(vec![e]),
895 Err(e) => {
896 errors.push(e);
897 continue;
898 }
899 }
900 }
901 match install_one(&dir, &indexes[&ikey], req, pin.as_ref()).map(|(mut l, fresh)| {
902 l.from = req.source.lock_key();
903 (l, fresh)
904 }) {
905 Ok((locked, false)) => {
906 lock.plugins.insert(name.clone(), locked.clone());
907 lock_changed = true;
908 out.push(Synced::AlreadyInstalled {
909 name: name.clone(),
910 version: Some(locked.version),
911 });
912 }
913 Ok((locked, true)) => {
914 log(&format!("installed plugin package `{name}` {}", locked.version));
915 if lock.get(name) != Some(&locked) {
916 lock.plugins.insert(name.clone(), locked.clone());
917 lock_changed = true;
918 }
919 out.push(Synced::Installed {
920 name: name.clone(),
921 version: locked.version,
922 });
923 }
924 Err(e) => errors.push(e),
925 }
926 }
927 if lock_changed && let Err(e) = lock.save(&project.root) {
928 errors.push(e);
929 }
930 if errors.is_empty() { Ok(out) } else { Err(errors) }
931}
932
933fn install_one(
937 dir: &Path,
938 index: &Index,
939 req: &PluginRequirement,
940 pin: Option<&Locked>,
941) -> Result<(Locked, bool), String> {
942 let name = &req.name;
943 let package = index.package(name).ok_or_else(|| {
944 let inside: Vec<String> = index
945 .plugins
946 .iter()
947 .filter(|p| p.provides.iter().any(|i| &i.name == name))
948 .map(|p| format!("`{}`", p.name))
949 .collect();
950 if inside.is_empty() {
951 format!("the registry has no plugin package `{name}`")
952 } else {
953 format!(
954 "the registry has no plugin package `{name}`; `{name}` is a plugin in {}: declare that under `plugins:` instead",
955 inside.join(", ")
956 )
957 }
958 })?;
959 let v = match pin {
960 Some(l) => package.exact(&l.version).ok_or_else(|| {
961 format!(
962 "dre.lock pins plugin package `{name}` {}, which the registry doesn't list",
963 l.version
964 )
965 })?,
966 None => package.best(&req.req()).ok_or_else(|| {
967 format!(
968 "no version of the plugin package `{name}` matches `{}` for {}",
969 req.version,
970 platform()
971 )
972 })?,
973 };
974 let vdir = version_dir(dir, name, &v.version);
979 if pin.is_none()
980 && let Some(art) = v.artifacts.get(&platform())
981 && !(art.url.ends_with(".tar.gz") || art.url.ends_with(".tgz"))
982 && let Some(m) = crate::plugins::read_manifest(&vdir)
983 && std::fs::read(vdir.join(&m.executable))
984 .is_ok_and(|b| hex(&Sha256::digest(&b)).eq_ignore_ascii_case(&art.sha256))
985 {
986 let locked = Locked {
987 version: v.version.clone(),
988 sha256: published_checksums(v),
989 from: None,
990 provides: m.provides,
991 };
992 return Ok((locked, false));
993 }
994 let expect = pin.map(|l| &l.sha256);
995 if req.source.is_default() {
996 install_linked(dir, package, v, expect).map(|l| (l, true))
997 } else {
998 install(dir, package, v, expect).map(|l| (l, true))
999 }
1000}
1001
1002pub fn install_linked(
1004 dir: &Path,
1005 package: &IndexPackage,
1006 v: &IndexVersion,
1007 pin: Option<&Checksums>,
1008) -> Result<Locked, String> {
1009 let cache = crate::plugins::cache_dir();
1010 let locked = install(&cache, package, v, pin)?;
1011 if dir != cache {
1012 crate::plugins::link_version(
1013 &version_dir(&cache, &package.name, &v.version),
1014 &version_dir(dir, &package.name, &v.version),
1015 )?;
1016 }
1017 Ok(locked)
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022 use super::*;
1023
1024 fn serve(replies: Vec<Option<&'static str>>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
1027 use std::io::Write;
1028 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1029 let url = format!("http://{}/index.json", listener.local_addr().unwrap());
1030 let count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1031 let seen = count.clone();
1032 std::thread::spawn(move || {
1033 let mut held = Vec::new();
1034 for (mut conn, reply) in listener.incoming().flatten().zip(replies) {
1035 seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1036 let mut buf = [0u8; 4096];
1037 let _ = std::io::Read::read(&mut conn, &mut buf);
1038 if let Some(r) = reply {
1039 let _ = conn.write_all(r.as_bytes());
1040 }
1041 held.push(conn);
1043 }
1044 std::thread::sleep(Duration::from_secs(30));
1045 drop(held);
1046 });
1047 (url, count)
1048 }
1049
1050 const OK: &str = "HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\n{}";
1051 const UNAVAILABLE: &str =
1052 "HTTP/1.1 503 Service Unavailable\r\ncontent-length: 0\r\nconnection: close\r\n\r\n";
1053
1054 fn get(url: &str, waits: &mut Vec<Duration>) -> Result<Vec<u8>, String> {
1055 fetch_http(url, Duration::from_millis(500), &mut |w| waits.push(w))
1056 }
1057
1058 #[test]
1059 fn a_server_that_never_answers_times_out_after_three_tries() {
1060 let (url, count) = serve(vec![None, None, None]);
1061 let mut waits = Vec::new();
1062 let e = get(&url, &mut waits).unwrap_err();
1063 assert!(
1064 e.contains("no answer") && e.contains("after 3 tries") && e.contains("DRE_HTTP_TIMEOUT"),
1065 "{e}"
1066 );
1067 assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 3);
1068 assert_eq!(waits.len(), 2);
1069 assert!(waits[0] < waits[1]);
1070 }
1071
1072 #[test]
1073 fn a_body_that_stalls_times_out() {
1074 let stall = "HTTP/1.1 200 OK\r\ncontent-length: 100\r\n\r\n{\"partial";
1075 let (url, _) = serve(vec![Some(stall), Some(stall), Some(stall)]);
1076 let e = get(&url, &mut Vec::new()).unwrap_err();
1077 assert!(e.contains("no data for"), "{e}");
1078 }
1079
1080 #[test]
1081 fn server_errors_are_retried_then_succeed() {
1082 let (url, count) = serve(vec![Some(UNAVAILABLE), Some(OK)]);
1083 assert_eq!(get(&url, &mut Vec::new()).unwrap(), b"{}");
1084 assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 2);
1085 }
1086
1087 #[test]
1088 fn retry_after_is_honoured_and_a_404_is_not_retried() {
1089 let busy = "HTTP/1.1 429 Too Many Requests\r\nretry-after: 7\r\ncontent-length: 0\r\nconnection: close\r\n\r\n";
1090 let (url, _) = serve(vec![Some(busy), Some(OK)]);
1091 let mut waits = Vec::new();
1092 get(&url, &mut waits).unwrap();
1093 assert_eq!(waits, [Duration::from_secs(7)]);
1094 let missing = "HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n";
1095 let (url, count) = serve(vec![Some(missing), Some(OK)]);
1096 let e = get(&url, &mut Vec::new()).unwrap_err();
1097 assert!(e.contains("HTTP 404") && !e.contains("tries"), "{e}");
1098 assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
1099 }
1100
1101 #[test]
1102 fn default_registry_lives_under_get_dre() {
1103 assert_eq!(
1104 DEFAULT_REGISTRY,
1105 "https://github.com/get-dre/dre/releases/download/registry/packages.json"
1106 );
1107 }
1108
1109 fn pick(versions: &[&str], req: &str) -> Option<String> {
1110 let vs: Vec<Version> = versions.iter().map(|v| Version::parse(v).unwrap()).collect();
1111 prefer_stable(vs.iter().collect(), &VersionReq::parse(req).unwrap(), |v| v).map(|v| v.to_string())
1112 }
1113
1114 #[test]
1115 fn pre_releases_only_when_nothing_stable_matches() {
1116 assert_eq!(pick(&["0.0.1-alpha"], "*").as_deref(), Some("0.0.1-alpha"));
1117 assert_eq!(
1118 pick(&["0.0.1-alpha", "0.0.2-beta"], "*").as_deref(),
1119 Some("0.0.2-beta")
1120 );
1121 assert_eq!(pick(&["0.1.0", "0.2.0-rc.1"], "*").as_deref(), Some("0.1.0"));
1122 assert_eq!(
1123 pick(&["0.0.1-alpha"], "^0.0.1-alpha").as_deref(),
1124 Some("0.0.1-alpha")
1125 );
1126 assert_eq!(pick(&["0.0.1-alpha"], "^1").as_deref(), None);
1127 assert_eq!(pick(&[], "*").as_deref(), None);
1128 }
1129
1130 #[test]
1131 fn alpha_10_is_newer_than_alpha_9() {
1132 assert_eq!(
1133 pick(&["0.0.1-alpha-8", "0.0.1-alpha-10", "0.0.1-alpha-9"], "*").as_deref(),
1134 Some("0.0.1-alpha-10")
1135 );
1136 let v = |s: &str| Version::parse(s).unwrap();
1137 let mut vs: Vec<Version> = [
1138 "0.0.1",
1139 "0.0.1-alpha-10",
1140 "0.0.1-beta-1",
1141 "0.0.1-alpha",
1142 "0.0.1-alpha-9",
1143 "0.0.1-alpha-2",
1144 "0.0.1-rc.10",
1145 "0.0.1-rc.9",
1146 "0.0.2-alpha-1",
1147 ]
1148 .iter()
1149 .map(|s| v(s))
1150 .collect();
1151 vs.sort_by(version_order);
1152 let got: Vec<String> = vs.iter().map(ToString::to_string).collect();
1153 assert_eq!(
1154 got,
1155 [
1156 "0.0.1-alpha",
1157 "0.0.1-alpha-2",
1158 "0.0.1-alpha-9",
1159 "0.0.1-alpha-10",
1160 "0.0.1-beta-1",
1161 "0.0.1-rc.9",
1162 "0.0.1-rc.10",
1163 "0.0.1",
1164 "0.0.2-alpha-1"
1165 ]
1166 );
1167 }
1168}