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;
9
10use dre_protocol::{
11    Kind, PluginId, package_executable_name, parse_executable_name, parse_package_executable_name,
12};
13use semver::{Version, VersionReq};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17use crate::lock::{Checksums, Lock, Locked};
18use crate::plugins::{Manifest, version_dir};
19use crate::project::{PluginRequirement, PluginSource, Project};
20
21pub const DEFAULT_REGISTRY: &str = "https://github.com/get-dre/dre/releases/download/registry/packages.json";
22
23/// The index's current schema. Schema 1 listed single plugins (`kind` and `name`); each still
24/// reads as a package of that one plugin.
25pub const INDEX_SCHEMA: u32 = 2;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Index {
29    pub schema: u32,
30    pub plugins: Vec<IndexPackage>,
31}
32
33/// A plugin package: one executable per platform, serving every plugin in `provides`.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(from = "RawPackage")]
36pub struct IndexPackage {
37    pub name: String,
38    #[serde(default)]
39    pub description: String,
40    /// Empty when the source doesn't say (a GitHub release): the executable is asked once
41    /// installed.
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub provides: Vec<PluginId>,
44    pub versions: Vec<IndexVersion>,
45}
46
47#[derive(Deserialize)]
48struct RawPackage {
49    name: String,
50    /// Schema 1: the one plugin's kind.
51    #[serde(default)]
52    kind: Option<Kind>,
53    #[serde(default)]
54    description: String,
55    #[serde(default)]
56    provides: Vec<PluginId>,
57    versions: Vec<IndexVersion>,
58}
59
60impl From<RawPackage> for IndexPackage {
61    fn from(r: RawPackage) -> IndexPackage {
62        let mut provides = r.provides;
63        if provides.is_empty()
64            && let Some(kind) = r.kind
65        {
66            provides.push(PluginId::new(kind, r.name.clone()));
67        }
68        IndexPackage {
69            name: r.name,
70            description: r.description,
71            provides,
72            versions: r.versions,
73        }
74    }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct IndexVersion {
79    pub version: Version,
80    /// Plugin protocol version this release speaks.
81    #[serde(default)]
82    pub protocol: u32,
83    /// Platform (see [`platform`]) → artifact.
84    pub artifacts: std::collections::BTreeMap<String, Artifact>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Artifact {
89    pub url: String,
90    /// Hex SHA-256 of the download. Empty when the source doesn't publish one: then it comes
91    /// from `sha256_url`, or failing that, from the first download (and `dre.lock` pins it).
92    #[serde(default)]
93    pub sha256: String,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub sha256_url: Option<String>,
96}
97
98/// Name the package to declare for each `undeclared-plugin` error, from DRE's registry, or say
99/// that the registry has no such plugin. The registry is fetched now, so a newly published
100/// package is known without a new `dre`. Unreachable: unchanged.
101pub fn explain_undeclared(diags: &mut crate::Diagnostics) {
102    if !diags.iter_mut().any(|d| d.plugin.is_some()) {
103        return;
104    }
105    let Ok(index) = Index::load() else { return };
106    for d in diags.iter_mut() {
107        let Some((kind, name)) = &d.plugin else { continue };
108        let id = PluginId::new(*kind, name.clone());
109        let what = d.message.split(" — ").next().unwrap_or_default().to_string();
110        let providers: Vec<&str> = index.providers(&id).iter().map(|p| p.name.as_str()).collect();
111        let others: Vec<String> = index
112            .plugins
113            .iter()
114            .flat_map(|p| &p.provides)
115            .filter(|p| &p.name == name && p.kind != *kind)
116            .map(|p| p.kind.to_string())
117            .collect();
118        d.message = match (providers.as_slice(), others.is_empty()) {
119            ([p], _) if *p == name => {
120                format!("{what} — add `{p}` under `plugins:` in dependencies.yml, then run `dre deps`")
121            }
122            ([p], _) => format!(
123                "{what} — the `{name}` {kind} is in the `{p}` package: add `{p}` under `plugins:` in dependencies.yml, then run `dre deps`"
124            ),
125            ([], true) => {
126                format!(
127                    "{what} — DRE's plugin registry has no {kind} plugin called `{name}`; check the spelling"
128                )
129            }
130            ([], false) => format!(
131                "{what} — `{name}` in DRE's plugin registry is a {} plugin, not a {kind}",
132                others.join(" and ")
133            ),
134            (many, _) => format!(
135                "{what} — add one of the packages that provide it under `plugins:` in dependencies.yml ({}), then run `dre deps`",
136                many.iter()
137                    .map(|p| format!("`{p}`"))
138                    .collect::<Vec<_>>()
139                    .join(", ")
140            ),
141        };
142    }
143}
144
145/// This machine's platform key in the index: `<os>-<arch>`, e.g. `macos-aarch64`.
146pub fn platform() -> String {
147    format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH)
148}
149
150pub fn registry_url() -> String {
151    std::env::var("DRE_REGISTRY_URL")
152        .ok()
153        .filter(|s| !s.is_empty())
154        .unwrap_or_else(|| DEFAULT_REGISTRY.to_string())
155}
156
157/// The GitHub API DRE talks to: `DRE_GITHUB_API_URL` (GitHub Enterprise, tests), else GitHub's.
158pub fn github_api() -> String {
159    std::env::var("DRE_GITHUB_API_URL")
160        .ok()
161        .filter(|s| !s.is_empty())
162        .unwrap_or_else(|| "https://api.github.com".into())
163        .trim_end_matches('/')
164        .to_string()
165}
166
167/// Whether requests to `url` should carry `GITHUB_TOKEN`: GitHub itself or the configured API.
168fn is_github(url: &str) -> bool {
169    url.starts_with("https://github.com/") || url.starts_with(&format!("{}/", github_api()))
170}
171
172pub fn fetch(url: &str) -> Result<Vec<u8>, String> {
173    if url.starts_with("http://") || url.starts_with("https://") {
174        let mut req = ureq::get(url).header("User-Agent", "dre");
175        if is_github(url) {
176            // A release asset's API URL answers with its metadata unless asked for the file.
177            let accept = if url.contains("/releases/assets/") {
178                "application/octet-stream"
179            } else {
180                "application/vnd.github+json"
181            };
182            req = req.header("Accept", accept);
183            if let Ok(t) = std::env::var("GITHUB_TOKEN")
184                && !t.is_empty()
185            {
186                req = req.header("Authorization", &format!("Bearer {t}"));
187            }
188        }
189        let mut resp = req.call().map_err(|e| format!("can't download {url}: {e}"))?;
190        let mut body = Vec::new();
191        resp.body_mut()
192            .as_reader()
193            .read_to_end(&mut body)
194            .map_err(|e| format!("can't download {url}: {e}"))?;
195        return Ok(body);
196    }
197    let path = url.strip_prefix("file://").unwrap_or(url);
198    std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))
199}
200
201impl Index {
202    /// The default registry.
203    pub fn load() -> Result<Index, String> {
204        Index::load_from(&registry_url())
205    }
206
207    pub fn load_from(url: &str) -> Result<Index, String> {
208        let body = fetch(url)?;
209        serde_json::from_slice(&body)
210            .map_err(|e| format!("the plugin registry at {url} isn't a valid index: {e}"))
211    }
212
213    /// The index a package installs from, given where the project declares it comes from.
214    pub fn for_source(source: &PluginSource, name: &str) -> Result<Index, String> {
215        match source {
216            PluginSource::Default => Index::load(),
217            PluginSource::Registry(u) => Index::load_from(u),
218            PluginSource::Github(repo) => github_index(repo, name),
219            PluginSource::Local(p) => Err(format!(
220                "plugin package `{name}` is used from {p}; there's nothing to install"
221            )),
222        }
223    }
224
225    pub fn package(&self, name: &str) -> Option<&IndexPackage> {
226        self.plugins.iter().find(|p| p.name == name)
227    }
228
229    /// The packages that provide `id`.
230    pub fn providers(&self, id: &PluginId) -> Vec<&IndexPackage> {
231        self.plugins.iter().filter(|p| p.provides.contains(id)).collect()
232    }
233
234    /// Packages providing a plugin called `name`, of any kind.
235    pub fn providers_of_name(&self, name: &str) -> Vec<&IndexPackage> {
236        self.plugins
237            .iter()
238            .filter(|p| p.provides.iter().any(|i| i.name == name))
239            .collect()
240    }
241}
242
243impl IndexPackage {
244    /// The highest version matching `req` that has an artifact for this platform and speaks a
245    /// protocol this core supports. Pre-releases only when no stable version matches (see
246    /// [`prefer_stable`]).
247    pub fn best(&self, req: &VersionReq) -> Option<&IndexVersion> {
248        let plat = platform();
249        let usable: Vec<&IndexVersion> = self
250            .versions
251            .iter()
252            .filter(|v| (dre_protocol::MIN_VERSION..=dre_protocol::MAX_VERSION).contains(&v.protocol))
253            .filter(|v| v.artifacts.contains_key(&plat))
254            .collect();
255        prefer_stable(usable, req, |v| &v.version)
256    }
257
258    pub fn exact(&self, v: &Version) -> Option<&IndexVersion> {
259        self.versions.iter().find(|x| &x.version == v)
260    }
261}
262
263/// The highest of `items` whose version matches `req`. When none does, a pre-release counts if
264/// its release would match, so `*` finds `0.0.1-alpha` when a plugin has no stable release yet
265/// (semver on its own only matches a pre-release that `req` names explicitly).
266pub fn prefer_stable<T>(items: Vec<T>, req: &VersionReq, version: impl Fn(&T) -> &Version) -> Option<T> {
267    let highest = |ok: &dyn Fn(&Version) -> bool, items: Vec<T>| {
268        items
269            .into_iter()
270            .filter(|i| ok(version(i)))
271            .max_by(|a, b| version_order(version(a), version(b)))
272    };
273    let exact = |v: &Version| req.matches(v);
274    if items.iter().any(|i| exact(version(i))) {
275        return highest(&exact, items);
276    }
277    highest(
278        &|v: &Version| !v.pre.is_empty() && req.matches(&Version::new(v.major, v.minor, v.patch)),
279        items,
280    )
281}
282
283/// Which version is newer: semver precedence, except that numbers inside a pre-release
284/// identifier compare as numbers. Semver compares `alpha-10` and `alpha-9` as text, which puts
285/// `0.0.1-alpha-10` before `0.0.1-alpha-9`; DRE's own pre-releases are named `0.0.1-alpha-<n>`.
286pub fn version_order(a: &Version, b: &Version) -> std::cmp::Ordering {
287    use std::cmp::Ordering::*;
288    (a.major, a.minor, a.patch)
289        .cmp(&(b.major, b.minor, b.patch))
290        .then_with(|| match (a.pre.is_empty(), b.pre.is_empty()) {
291            (true, true) => Equal,
292            (true, false) => Greater,
293            (false, true) => Less,
294            (false, false) => {
295                let (mut x, mut y) = (a.pre.split('.'), b.pre.split('.'));
296                loop {
297                    match (x.next(), y.next()) {
298                        (None, None) => break Equal,
299                        (None, Some(_)) => break Less,
300                        (Some(_), None) => break Greater,
301                        (Some(p), Some(q)) => match natural_order(p, q) {
302                            Equal => {}
303                            o => break o,
304                        },
305                    }
306                }
307            }
308        })
309        .then_with(|| a.cmp(b))
310}
311
312/// Compare two identifiers run by run: digit runs as numbers (below text, as in semver), the
313/// rest as text.
314fn natural_order(a: &str, b: &str) -> std::cmp::Ordering {
315    fn runs(s: &str) -> Vec<&str> {
316        let mut out = Vec::new();
317        let mut start = 0;
318        let bytes = s.as_bytes();
319        for i in 1..=bytes.len() {
320            if i == bytes.len() || bytes[i].is_ascii_digit() != bytes[start].is_ascii_digit() {
321                out.push(&s[start..i]);
322                start = i;
323            }
324        }
325        out
326    }
327    let (ra, rb) = (runs(a), runs(b));
328    for (p, q) in ra.iter().zip(&rb) {
329        let digits = |s: &str| s.bytes().all(|c| c.is_ascii_digit());
330        let o = match (digits(p), digits(q)) {
331            (true, true) => {
332                let (p, q) = (p.trim_start_matches('0'), q.trim_start_matches('0'));
333                p.len().cmp(&q.len()).then_with(|| p.cmp(q))
334            }
335            (true, false) => std::cmp::Ordering::Less,
336            (false, true) => std::cmp::Ordering::Greater,
337            (false, false) => p.cmp(q),
338        };
339        if o != std::cmp::Ordering::Equal {
340            return o;
341        }
342    }
343    ra.len().cmp(&rb.len())
344}
345
346/// One published (non-draft) release of a GitHub repository whose tag is a version.
347#[derive(Debug, Clone)]
348pub struct Release {
349    pub tag: String,
350    pub version: Version,
351    /// Asset names and their API URLs (which [`fetch`] downloads).
352    pub assets: Vec<(String, String)>,
353}
354
355/// `repo`'s releases from the GitHub API (`DRE_GITHUB_API_URL`, `GITHUB_TOKEN`), skipping
356/// drafts and tags that aren't versions, newest first by [`version_order`]. Every page is read:
357/// a repository that also releases its plugins (`duckdb-v1.0.0`) can list many of those first.
358pub fn github_releases(repo: &str) -> Result<Vec<Release>, String> {
359    let mut out: Vec<Release> = list_github_releases(repo)?
360        .into_iter()
361        .filter(|r| !r.draft)
362        .filter_map(|r| {
363            let version = Version::parse(r.tag_name.trim_start_matches('v')).ok()?;
364            Some(Release {
365                tag: r.tag_name,
366                version,
367                assets: r.assets.into_iter().map(|a| (a.name, a.url)).collect(),
368            })
369        })
370        .collect();
371    out.sort_by(|a, b| version_order(&b.version, &a.version));
372    Ok(out)
373}
374
375/// Every release of `repo`, page by page, as the API lists them (newest first).
376fn list_github_releases(repo: &str) -> Result<Vec<GithubRelease>, String> {
377    const PER_PAGE: usize = 100;
378    let mut releases = Vec::new();
379    for page in 1.. {
380        let url = format!(
381            "{}/repos/{repo}/releases?per_page={PER_PAGE}&page={page}",
382            github_api()
383        );
384        let body = fetch(&url)?;
385        let batch: Vec<GithubRelease> =
386            serde_json::from_slice(&body).map_err(|e| format!("{url}: unexpected reply from GitHub: {e}"))?;
387        let last = batch.len() < PER_PAGE;
388        releases.extend(batch);
389        if last {
390            break;
391        }
392    }
393    Ok(releases)
394}
395
396#[derive(Deserialize)]
397struct GithubRelease {
398    tag_name: String,
399    #[serde(default)]
400    draft: bool,
401    #[serde(default)]
402    assets: Vec<GithubAsset>,
403}
404
405#[derive(Deserialize)]
406struct GithubAsset {
407    name: String,
408    /// The API URL: it takes `GITHUB_TOKEN` (private repos, GitHub Enterprise) and answers the
409    /// file itself with `Accept: application/octet-stream`.
410    url: String,
411}
412
413/// A one-package index built from `owner/repo`'s GitHub Releases. A release tagged `v1.2.0`,
414/// `1.2.0` or `<name>-v1.2.0` (a repository releasing several packages) offers version 1.2.0 for
415/// each platform it has an asset for, named like the registry's:
416/// `dre-plugin-<name>-<version>-<platform>.tar.gz`, or the bare executable. A single plugin's
417/// release named `dre-<kind>-<name>-<version>-<platform>` works too.
418fn github_index(repo: &str, name: &str) -> Result<Index, String> {
419    let releases = list_github_releases(repo)?;
420    let package_stem = format!("dre-plugin-{name}");
421    let own_tag = format!("{name}-v");
422    let mut provides: Vec<PluginId> = Vec::new();
423    let mut versions = Vec::new();
424    for r in releases.iter().filter(|r| !r.draft) {
425        let tag = r.tag_name.strip_prefix(&own_tag).unwrap_or(&r.tag_name);
426        let Ok(version) = Version::parse(tag.trim_start_matches('v')) else {
427            continue;
428        };
429        let suffix = format!("-{version}-");
430        let mut artifacts = std::collections::BTreeMap::new();
431        for a in &r.assets {
432            let Some((stem, rest)) = a.name.split_once(&suffix) else {
433                continue;
434            };
435            if stem != package_stem {
436                match parse_executable_name(&format!("{stem}{}", std::env::consts::EXE_SUFFIX)) {
437                    Some((kind, n)) if n == name => {
438                        let id = PluginId::new(kind, n);
439                        if !provides.contains(&id) {
440                            provides.push(id);
441                        }
442                    }
443                    _ => continue,
444                }
445            }
446            let plat = rest
447                .trim_end_matches(".tar.gz")
448                .trim_end_matches(".tgz")
449                .trim_end_matches(".exe");
450            if plat.contains('.') || plat.is_empty() {
451                continue; // `.sha256` and other side files
452            }
453            let sha256_url = r
454                .assets
455                .iter()
456                .find(|s| s.name == format!("{}.sha256", a.name))
457                .map(|s| s.url.clone());
458            artifacts.insert(
459                plat.to_string(),
460                Artifact {
461                    url: a.url.clone(),
462                    sha256: String::new(),
463                    sha256_url,
464                },
465            );
466        }
467        if !artifacts.is_empty() {
468            versions.push(IndexVersion {
469                version,
470                protocol: dre_protocol::MAX_VERSION,
471                artifacts,
472            });
473        }
474    }
475    if versions.is_empty() {
476        return Err(format!(
477            "no release of github.com/{repo} has a `{package_stem}-<version>-<platform>` asset for any platform"
478        ));
479    }
480    // A package of several plugins says what it provides once installed.
481    if provides.len() > 1 {
482        provides.clear();
483    }
484    Ok(Index {
485        schema: INDEX_SCHEMA,
486        plugins: vec![IndexPackage {
487            name: name.to_string(),
488            description: format!("from github.com/{repo}"),
489            provides,
490            versions,
491        }],
492    })
493}
494
495/// Download, verify and install one version into `<dir>/<package>/<version>/`. Returns its lock
496/// entry.
497pub fn install(
498    dir: &Path,
499    package: &IndexPackage,
500    v: &IndexVersion,
501    pin: Option<&Checksums>,
502) -> Result<Locked, String> {
503    let plat = platform();
504    let what = format!("plugin package `{}` {}", package.name, v.version);
505    let art = v
506        .artifacts
507        .get(&plat)
508        .ok_or_else(|| format!("{what} has no build for {plat}"))?;
509    // The published checksum: the index's, a `.sha256` file's, or none yet.
510    let published = if !art.sha256.is_empty() {
511        Some(art.sha256.clone())
512    } else if let Some(u) = &art.sha256_url {
513        let text = String::from_utf8_lossy(&fetch(u)?).to_string();
514        Some(text.split_whitespace().next().unwrap_or_default().to_string())
515    } else {
516        None
517    };
518    let refuse = || format!("{what}: the registry's checksum doesn't match dre.lock; refusing to install");
519    // What the lock expects on this platform. A lock that doesn't name platforms (from an older
520    // DRE) was written on one of them: its checksum must be one the registry publishes for
521    // this version, whichever platform that is.
522    let mut want = pin.and_then(|c| c.get(&plat));
523    if let (Some(want), Some(published)) = (want, &published)
524        && !want.eq_ignore_ascii_case(published)
525    {
526        return Err(refuse());
527    }
528    if want.is_none()
529        && let Some(legacy) = pin.and_then(Checksums::legacy)
530    {
531        let all = published_checksums(v);
532        if all.is_empty() {
533            want = Some(legacy);
534        } else if !all.contains(legacy) {
535            return Err(refuse());
536        }
537    }
538    let bytes = fetch(&art.url)?;
539    let got = hex(&Sha256::digest(&bytes));
540    // Nothing published: the lock's pin, if any, is what the download must match.
541    let expected = published.as_deref().or(want);
542    if let Some(want) = expected
543        && !got.eq_ignore_ascii_case(want)
544    {
545        return Err(format!(
546            "checksum mismatch for {what} (expected {want}, got {got}); the download was discarded"
547        ));
548    }
549    let (exe_name, exe) = if art.url.ends_with(".tar.gz") || art.url.ends_with(".tgz") {
550        extract_tar_gz(&bytes, &package.name)?
551    } else {
552        let name = crate::plugins::legacy_executable(&package.provides)
553            .unwrap_or_else(|| package_executable_name(&package.name));
554        (name, bytes)
555    };
556    let vdir = version_dir(dir, &package.name, &v.version);
557    std::fs::create_dir_all(&vdir).map_err(|e| format!("can't create {}: {e}", vdir.display()))?;
558    let dst = vdir.join(&exe_name);
559    let tmp = vdir.join(format!(".download-{}", std::process::id()));
560    std::fs::write(&tmp, &exe).map_err(|e| format!("can't write {}: {e}", tmp.display()))?;
561    #[cfg(unix)]
562    {
563        use std::os::unix::fs::PermissionsExt;
564        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?;
565    }
566    std::fs::rename(&tmp, &dst).map_err(|e| format!("can't install {}: {e}", dst.display()))?;
567    // A source that doesn't list what the package provides: ask the executable.
568    let provides = if package.provides.is_empty() {
569        crate::plugins::probe(&dst)?
570    } else {
571        package.provides.clone()
572    };
573    crate::plugins::write_manifest(
574        &vdir,
575        &Manifest {
576            executable: exe_name,
577            provides: provides.clone(),
578        },
579    )?;
580    let mut sha256 = published_checksums(v);
581    if let Some(p) = pin {
582        sha256.merge(p);
583    }
584    sha256.insert(&plat, &got);
585    Ok(Locked {
586        version: v.version.clone(),
587        sha256,
588        from: None,
589        provides,
590    })
591}
592
593/// The checksums the registry publishes for `v`, by platform.
594fn published_checksums(v: &IndexVersion) -> Checksums {
595    let mut c = Checksums::new();
596    for (p, a) in &v.artifacts {
597        if !a.sha256.is_empty() {
598            c.insert(p, &a.sha256);
599        }
600    }
601    c
602}
603
604/// The package's executable in an archive: `dre-plugin-<package>`, or a single plugin's
605/// `dre-<kind>-<package>`. Returns its file name and bytes.
606fn extract_tar_gz(bytes: &[u8], package: &str) -> Result<(String, Vec<u8>), String> {
607    let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(bytes));
608    for entry in archive.entries().map_err(|e| e.to_string())? {
609        let mut entry = entry.map_err(|e| e.to_string())?;
610        let path = entry.path().map_err(|e| e.to_string())?.to_path_buf();
611        let Some(file) = path.file_name().map(|f| f.to_string_lossy().to_string()) else {
612            continue;
613        };
614        let ours = parse_package_executable_name(&file).is_some_and(|n| n == package)
615            || parse_executable_name(&file).is_some_and(|(_, n)| n == package);
616        if ours {
617            let mut out = Vec::new();
618            entry.read_to_end(&mut out).map_err(|e| e.to_string())?;
619            return Ok((file, out));
620        }
621    }
622    Err(format!(
623        "the archive doesn't contain `{}`",
624        package_executable_name(package)
625    ))
626}
627
628fn hex(b: &[u8]) -> String {
629    b.iter().map(|x| format!("{x:02x}")).collect()
630}
631
632/// What `sync` did for one package.
633#[derive(Debug, Clone, PartialEq, Eq)]
634pub enum Synced {
635    AlreadyInstalled { name: String, version: Option<Version> },
636    Installed { name: String, version: Version },
637}
638
639/// Make every declared package available, installing what's missing (unless `install` is
640/// false, in which case missing packages are errors). Honours `dre.lock` pins, writes new pins.
641///
642/// With `resolve` (`dre deps`), a package that `dre.lock` doesn't pin is resolved against the
643/// registry even when some matching version is already installed, so deleting `dre.lock` picks
644/// up the newest allowed release. Without it (auto-install before run/validate), any installed
645/// match will do and the registry is only consulted for what's missing.
646pub fn sync(
647    project: &Project,
648    install_missing: bool,
649    resolve: bool,
650    mut log: impl FnMut(&str),
651) -> Result<Vec<Synced>, Vec<String>> {
652    let dir = crate::plugins::plugins_dir(Some(&project.root));
653    let mut lock = Lock::load(&project.root).map_err(|e| vec![e])?;
654    let mut indexes: std::collections::BTreeMap<String, Index> = std::collections::BTreeMap::new();
655    let mut out = Vec::new();
656    let mut errors = Vec::new();
657    let mut lock_changed = false;
658    for req in &project.plugins {
659        let name = &req.name;
660        if let PluginSource::Local(p) = &req.source {
661            if !project.root.join(p).is_file() {
662                errors.push(format!(
663                    "the plugin package `{name}` is declared `local: {p}`, but there's no file there"
664                ));
665                continue;
666            }
667            if lock.local.get(name) != Some(p) {
668                lock.local.insert(name.clone(), p.clone());
669                lock_changed = true;
670            }
671            lock_changed |= lock.plugins.remove(name).is_some();
672            out.push(Synced::AlreadyInstalled {
673                name: name.clone(),
674                version: None,
675            });
676            continue;
677        }
678        lock_changed |= lock.local.remove(name).is_some();
679        // A pin from another source doesn't count: the package is resolved again, and an
680        // installed copy (from the old source) isn't reused.
681        let moved = lock.get(name).is_some_and(|l| l.from != req.source.lock_key());
682        let pin = lock
683            .get(name)
684            .filter(|l| l.from == req.source.lock_key())
685            .cloned();
686        if !moved
687            && let Some(p) =
688                crate::plugins::find(&dir, name, Some(&req.req()), pin.as_ref().map(|l| &l.version))
689                    .into_iter()
690                    .next()
691        {
692            // A hand-placed (flat) package satisfies any pin; versioned installs must match it,
693            // and when resolving, an unpinned versioned install goes back to the registry.
694            let ok = match (&pin, &p.version) {
695                (Some(l), Some(v)) => &l.version == v,
696                (None, Some(_)) => !resolve,
697                _ => true,
698            };
699            if ok {
700                // A lock from before `provides` was recorded learns it from the install.
701                if let (Some(l), Some(_)) = (lock.plugins.get_mut(name), &p.version)
702                    && l.provides.is_empty()
703                    && !p.provides.is_empty()
704                {
705                    l.provides = p.provides.clone();
706                    lock_changed = true;
707                }
708                out.push(Synced::AlreadyInstalled {
709                    name: name.clone(),
710                    version: p.version,
711                });
712                continue;
713            }
714        }
715        // Already downloaded for another project: link the pinned version from the cache. The
716        // cache only holds the default registry's builds: it's keyed by name and version.
717        if let Some(l) = &pin
718            && req.source.is_default()
719            && dir != crate::plugins::cache_dir()
720        {
721            let cached = version_dir(&crate::plugins::cache_dir(), name, &l.version);
722            if crate::plugins::read_manifest(&cached).is_some()
723                && crate::plugins::link_version(&cached, &version_dir(&dir, name, &l.version)).is_ok()
724            {
725                out.push(Synced::AlreadyInstalled {
726                    name: name.clone(),
727                    version: Some(l.version.clone()),
728                });
729                continue;
730            }
731        }
732        if !install_missing {
733            errors.push(format!(
734                "the plugin package `{name}` ({}) isn't installed and auto-install is off; run `dre deps`",
735                pin.as_ref()
736                    .map(|l| format!("locked at {}", l.version))
737                    .unwrap_or_else(|| req.version.clone())
738            ));
739            continue;
740        }
741        // One index per source; a GitHub repo's covers just the package it was built for.
742        let ikey = match &req.source {
743            PluginSource::Github(_) => format!("{}#{name}", req.source.lock_key().unwrap_or_default()),
744            s => s.lock_key().unwrap_or_default(),
745        };
746        if !indexes.contains_key(&ikey) {
747            match Index::for_source(&req.source, name) {
748                Ok(i) => {
749                    indexes.insert(ikey.clone(), i);
750                }
751                Err(e) if req.source.is_default() => return Err(vec![e]),
752                Err(e) => {
753                    errors.push(e);
754                    continue;
755                }
756            }
757        }
758        match install_one(&dir, &indexes[&ikey], req, pin.as_ref()).map(|(mut l, fresh)| {
759            l.from = req.source.lock_key();
760            (l, fresh)
761        }) {
762            Ok((locked, false)) => {
763                lock.plugins.insert(name.clone(), locked.clone());
764                lock_changed = true;
765                out.push(Synced::AlreadyInstalled {
766                    name: name.clone(),
767                    version: Some(locked.version),
768                });
769            }
770            Ok((locked, true)) => {
771                log(&format!("installed plugin package `{name}` {}", locked.version));
772                if lock.get(name) != Some(&locked) {
773                    lock.plugins.insert(name.clone(), locked.clone());
774                    lock_changed = true;
775                }
776                out.push(Synced::Installed {
777                    name: name.clone(),
778                    version: locked.version,
779                });
780            }
781            Err(e) => errors.push(e),
782        }
783    }
784    if lock_changed && let Err(e) = lock.save(&project.root) {
785        errors.push(e);
786    }
787    if errors.is_empty() { Ok(out) } else { Err(errors) }
788}
789
790/// Install the pinned version, or the best match when unpinned. The flag is false when that
791/// exact version was already in `dir`, identical to the registry's, and only needed its lock
792/// entry.
793fn install_one(
794    dir: &Path,
795    index: &Index,
796    req: &PluginRequirement,
797    pin: Option<&Locked>,
798) -> Result<(Locked, bool), String> {
799    let name = &req.name;
800    let package = index.package(name).ok_or_else(|| {
801        let inside: Vec<String> = index
802            .plugins
803            .iter()
804            .filter(|p| p.provides.iter().any(|i| &i.name == name))
805            .map(|p| format!("`{}`", p.name))
806            .collect();
807        if inside.is_empty() {
808            format!("the registry has no plugin package `{name}`")
809        } else {
810            format!(
811                "the registry has no plugin package `{name}`; `{name}` is a plugin in {}: declare that under `plugins:` instead",
812                inside.join(", ")
813            )
814        }
815    })?;
816    let v = match pin {
817        Some(l) => package.exact(&l.version).ok_or_else(|| {
818            format!(
819                "dre.lock pins plugin package `{name}` {}, which the registry doesn't list",
820                l.version
821            )
822        })?,
823        None => package.best(&req.req()).ok_or_else(|| {
824            format!(
825                "no version of the plugin package `{name}` matches `{}` for {}",
826                req.version,
827                platform()
828            )
829        })?,
830    };
831    // Reuse the installed file only when it is byte for byte the registry's artifact: a rebuild
832    // published under the same version must be installed again, not pinned to a checksum the
833    // installed file doesn't have. (An archived artifact's checksum can't be compared with the
834    // extracted executable, so those are always reinstalled.)
835    let vdir = version_dir(dir, name, &v.version);
836    if pin.is_none()
837        && let Some(art) = v.artifacts.get(&platform())
838        && !(art.url.ends_with(".tar.gz") || art.url.ends_with(".tgz"))
839        && let Some(m) = crate::plugins::read_manifest(&vdir)
840        && std::fs::read(vdir.join(&m.executable))
841            .is_ok_and(|b| hex(&Sha256::digest(&b)).eq_ignore_ascii_case(&art.sha256))
842    {
843        let locked = Locked {
844            version: v.version.clone(),
845            sha256: published_checksums(v),
846            from: None,
847            provides: m.provides,
848        };
849        return Ok((locked, false));
850    }
851    let expect = pin.map(|l| &l.sha256);
852    if req.source.is_default() {
853        install_linked(dir, package, v, expect).map(|l| (l, true))
854    } else {
855        install(dir, package, v, expect).map(|l| (l, true))
856    }
857}
858
859/// Install into `dir` through the shared cache: download there once, then link into `dir`.
860pub fn install_linked(
861    dir: &Path,
862    package: &IndexPackage,
863    v: &IndexVersion,
864    pin: Option<&Checksums>,
865) -> Result<Locked, String> {
866    let cache = crate::plugins::cache_dir();
867    let locked = install(&cache, package, v, pin)?;
868    if dir != cache {
869        crate::plugins::link_version(
870            &version_dir(&cache, &package.name, &v.version),
871            &version_dir(dir, &package.name, &v.version),
872        )?;
873    }
874    Ok(locked)
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880
881    #[test]
882    fn default_registry_lives_under_get_dre() {
883        assert_eq!(
884            DEFAULT_REGISTRY,
885            "https://github.com/get-dre/dre/releases/download/registry/packages.json"
886        );
887    }
888
889    fn pick(versions: &[&str], req: &str) -> Option<String> {
890        let vs: Vec<Version> = versions.iter().map(|v| Version::parse(v).unwrap()).collect();
891        prefer_stable(vs.iter().collect(), &VersionReq::parse(req).unwrap(), |v| v).map(|v| v.to_string())
892    }
893
894    #[test]
895    fn pre_releases_only_when_nothing_stable_matches() {
896        assert_eq!(pick(&["0.0.1-alpha"], "*").as_deref(), Some("0.0.1-alpha"));
897        assert_eq!(
898            pick(&["0.0.1-alpha", "0.0.2-beta"], "*").as_deref(),
899            Some("0.0.2-beta")
900        );
901        assert_eq!(pick(&["0.1.0", "0.2.0-rc.1"], "*").as_deref(), Some("0.1.0"));
902        assert_eq!(
903            pick(&["0.0.1-alpha"], "^0.0.1-alpha").as_deref(),
904            Some("0.0.1-alpha")
905        );
906        assert_eq!(pick(&["0.0.1-alpha"], "^1").as_deref(), None);
907        assert_eq!(pick(&[], "*").as_deref(), None);
908    }
909
910    #[test]
911    fn alpha_10_is_newer_than_alpha_9() {
912        assert_eq!(
913            pick(&["0.0.1-alpha-8", "0.0.1-alpha-10", "0.0.1-alpha-9"], "*").as_deref(),
914            Some("0.0.1-alpha-10")
915        );
916        let v = |s: &str| Version::parse(s).unwrap();
917        let mut vs: Vec<Version> = [
918            "0.0.1",
919            "0.0.1-alpha-10",
920            "0.0.1-beta-1",
921            "0.0.1-alpha",
922            "0.0.1-alpha-9",
923            "0.0.1-alpha-2",
924            "0.0.1-rc.10",
925            "0.0.1-rc.9",
926            "0.0.2-alpha-1",
927        ]
928        .iter()
929        .map(|s| v(s))
930        .collect();
931        vs.sort_by(version_order);
932        let got: Vec<String> = vs.iter().map(ToString::to_string).collect();
933        assert_eq!(
934            got,
935            [
936                "0.0.1-alpha",
937                "0.0.1-alpha-2",
938                "0.0.1-alpha-9",
939                "0.0.1-alpha-10",
940                "0.0.1-beta-1",
941                "0.0.1-rc.9",
942                "0.0.1-rc.10",
943                "0.0.1",
944                "0.0.2-alpha-1"
945            ]
946        );
947    }
948}