Skip to main content

dre_core/
manager.rs

1//! The plugin manager: a static JSON registry index of plugin packages, checksum-verified
2//! downloads, side-by-side versioned installs, and `dre.lock`.
3//!
4//! Index location: `DRE_REGISTRY_URL`, default [`DEFAULT_REGISTRY`]. It may be an `https://`
5//! URL, a `file://` URL or a plain path. Format: `docs/registry.md`.
6
7use 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
24/// The index's current schema. Schema 1 listed single plugins (`kind` and `name`); each still
25/// reads as a package of that one plugin.
26pub 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/// A plugin package: one executable per platform, serving every plugin in `provides`.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(from = "RawPackage")]
37pub struct IndexPackage {
38    pub name: String,
39    #[serde(default)]
40    pub description: String,
41    /// Empty when the source doesn't say (a GitHub release): the executable is asked once
42    /// installed.
43    #[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    /// Schema 1: the one plugin's kind.
52    #[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    /// Plugin protocol version this release speaks.
82    #[serde(default)]
83    pub protocol: u32,
84    /// Platform (see [`platform`]) → artifact.
85    pub artifacts: std::collections::BTreeMap<String, Artifact>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Artifact {
90    pub url: String,
91    /// Hex SHA-256 of the download. Empty when the source doesn't publish one: then it comes
92    /// from `sha256_url`, or failing that, from the first download (and `dre.lock` pins it).
93    #[serde(default)]
94    pub sha256: String,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub sha256_url: Option<String>,
97}
98
99/// Name the package to declare for each `undeclared-plugin` error, from DRE's registry, or say
100/// that the registry has no such plugin. The registry is fetched now, so a newly published
101/// package is known without a new `dre`. Unreachable: unchanged.
102pub 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
146/// This machine's platform key in the index: `<os>-<arch>`, e.g. `macos-aarch64`.
147pub 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
155/// The GitHub API DRE talks to: `DRE_GITHUB_API_URL` (GitHub Enterprise, tests), else GitHub's.
156pub 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
163/// Whether requests to `url` should carry `GITHUB_TOKEN`: GitHub itself or the configured API.
164fn is_github(url: &str) -> bool {
165    url.starts_with("https://github.com/") || url.starts_with(&format!("{}/", github_api()))
166}
167
168/// Download `url` (or read a local path or `file://` URL). Downloads give up on a connection
169/// after 30s and on a response that sends nothing for [`http_timeout`] (no total limit, so a
170/// slow but healthy download finishes), and are tried 3 times on connection errors, timeouts,
171/// 429 (honouring `Retry-After`) and 5xx.
172pub 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
180/// How long to wait for a connection.
181const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
182/// Seconds a download may receive nothing, by default.
183pub const DEFAULT_HTTP_TIMEOUT: u64 = 60;
184const ATTEMPTS: u32 = 3;
185
186static PROJECT_HTTP_TIMEOUT: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
187
188/// `flags: http_timeout` from the project being run (set once, when it loads).
189pub fn set_project_http_timeout(secs: u64) {
190    let _ = PROJECT_HTTP_TIMEOUT.set(secs);
191}
192
193/// How long a download may receive nothing: `DRE_HTTP_TIMEOUT`, else the project's
194/// `flags: http_timeout`, else 60 seconds.
195pub 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
204/// Why one try failed.
205enum Try {
206    /// Trying again may work; `after` is the server's `Retry-After`.
207    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
237/// About 1s, then 4s, give or take a quarter.
238fn 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        // A release asset's API URL answers with its metadata unless asked for the file.
257        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
302/// Read `r` to the end, failing if no bytes arrive for `idle`. The read runs on a helper thread so
303/// a stalled connection can't block the caller; it ends with the process if it never returns.
304fn 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    /// The default registry.
346    pub fn load() -> Result<Index, String> {
347        Index::load_from(&registry_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    /// The index a package installs from, given where the project declares it comes from.
357    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    /// The packages that provide `id`.
373    pub fn providers(&self, id: &PluginId) -> Vec<&IndexPackage> {
374        self.plugins.iter().filter(|p| p.provides.contains(id)).collect()
375    }
376
377    /// Packages providing a plugin called `name`, of any kind.
378    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    /// The highest version matching `req` that has an artifact for this platform and speaks a
388    /// protocol this core supports. Pre-releases only when no stable version matches (see
389    /// [`prefer_stable`]).
390    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
406/// The highest of `items` whose version matches `req`. When none does, a pre-release counts if
407/// its release would match, so `*` finds `0.0.1-alpha` when a plugin has no stable release yet
408/// (semver on its own only matches a pre-release that `req` names explicitly).
409pub 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
426/// Which version is newer: semver precedence, except that numbers inside a pre-release
427/// identifier compare as numbers. Semver compares `alpha-10` and `alpha-9` as text, which puts
428/// `0.0.1-alpha-10` before `0.0.1-alpha-9`; DRE's own pre-releases are named `0.0.1-alpha-<n>`.
429pub 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
455/// Compare two identifiers run by run: digit runs as numbers (below text, as in semver), the
456/// rest as text.
457fn 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/// One published (non-draft) release of a GitHub repository whose tag is a version.
490#[derive(Debug, Clone)]
491pub struct Release {
492    pub tag: String,
493    pub version: Version,
494    /// Asset names and their API URLs (which [`fetch`] downloads).
495    pub assets: Vec<(String, String)>,
496}
497
498/// `repo`'s releases from the GitHub API (`DRE_GITHUB_API_URL`, `GITHUB_TOKEN`), skipping
499/// drafts and tags that aren't versions, newest first by [`version_order`]. Every page is read:
500/// a repository that also releases its plugins (`duckdb-v1.0.0`) can list many of those first.
501pub 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
518/// Every release of `repo`, page by page, as the API lists them (newest first).
519fn 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    /// The API URL: it takes `GITHUB_TOKEN` (private repos, GitHub Enterprise) and answers the
552    /// file itself with `Accept: application/octet-stream`.
553    url: String,
554}
555
556/// A one-package index built from `owner/repo`'s GitHub Releases. A release tagged `v1.2.0`,
557/// `1.2.0` or `<name>-v1.2.0` (a repository releasing several packages) offers version 1.2.0 for
558/// each platform it has an asset for, named like the registry's:
559/// `dre-plugin-<name>-<version>-<platform>.tar.gz`, or the bare executable. A single plugin's
560/// release named `dre-<kind>-<name>-<version>-<platform>` works too.
561fn 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; // `.sha256` and other side files
595            }
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    // A package of several plugins says what it provides once installed.
624    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
638/// Download, verify and install one version into `<dir>/<package>/<version>/`. Returns its lock
639/// entry.
640pub 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    // The published checksum: the index's, a `.sha256` file's, or none yet.
653    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    // What the lock expects on this platform. A lock that doesn't name platforms (from an older
663    // DRE) was written on one of them: its checksum must be one the registry publishes for
664    // this version, whichever platform that is.
665    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    // Nothing published: the lock's pin, if any, is what the download must match.
684    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    // A source that doesn't list what the package provides: ask the executable.
711    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
736/// The checksums the registry publishes for `v`, by platform.
737fn 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
747/// The package's executable in an archive: `dre-plugin-<package>`, or a single plugin's
748/// `dre-<kind>-<package>`. Returns its file name and bytes.
749fn 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/// What `sync` did for one package.
776#[derive(Debug, Clone, PartialEq, Eq)]
777pub enum Synced {
778    AlreadyInstalled { name: String, version: Option<Version> },
779    Installed { name: String, version: Version },
780}
781
782/// Make every declared package available, installing what's missing (unless `install` is
783/// false, in which case missing packages are errors). Honours `dre.lock` pins, writes new pins.
784///
785/// With `resolve` (`dre deps`), a package that `dre.lock` doesn't pin is resolved against the
786/// registry even when some matching version is already installed, so deleting `dre.lock` picks
787/// up the newest allowed release. Without it (auto-install before run/validate), any installed
788/// match will do and the registry is only consulted for what's missing.
789pub 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        // A pin from another source doesn't count: the package is resolved again, and an
823        // installed copy (from the old source) isn't reused.
824        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            // A hand-placed (flat) package satisfies any pin; versioned installs must match it,
836            // and when resolving, an unpinned versioned install goes back to the registry.
837            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                // A lock from before `provides` was recorded learns it from the install.
844                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        // Already downloaded for another project: link the pinned version from the cache. The
859        // cache only holds the default registry's builds: it's keyed by name and version.
860        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        // One index per source; a GitHub repo's covers just the package it was built for.
885        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
933/// Install the pinned version, or the best match when unpinned. The flag is false when that
934/// exact version was already in `dir`, identical to the registry's, and only needed its lock
935/// entry.
936fn 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    // Reuse the installed file only when it is byte for byte the registry's artifact: a rebuild
975    // published under the same version must be installed again, not pinned to a checksum the
976    // installed file doesn't have. (An archived artifact's checksum can't be compared with the
977    // extracted executable, so those are always reinstalled.)
978    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
1002/// Install into `dir` through the shared cache: download there once, then link into `dir`.
1003pub 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    /// A local HTTP server answering each connection with the next of `replies` (raw bytes;
1025    /// `None`: accept and say nothing), keeping every connection open. Returns its URL and a count of connections.
1026    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                // Keep it open: the client decides when it's done.
1042                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}