Skip to main content

nap_core/server/
version.rs

1// SPDX-FileCopyrightText: 2026 Digital Creations
2// SPDX-License-Identifier: MIT
3//! Lore version detection and compatibility checking
4//!
5//! Provides utilities to detect installed Lore CLI/server versions and
6//! verify compatibility with the SDK's pinned version.
7//!
8//! NAP requires an **exact match** of the full version string against
9//! [`PINNED_LORE_VERSION`].
10
11use anyhow::{Context, Result};
12use semver::Version;
13use std::process::Command;
14
15/// Pinned Lore version that NAP SDK requires.
16///
17/// During initialization NAP verifies that the installed `lore` and
18/// `loreserver` binaries report **exactly** this version string.
19pub const PINNED_LORE_VERSION: &str = "0.8.4-portals.9";
20
21/// Release repository containing the Portals authentication-capable Lore
22/// client. Never fall back to the unaffiliated upstream release at runtime.
23pub const PINNED_LORE_REPOSITORY: &str = "portalshq/lore";
24
25/// SHA-256 of `scripts/install.sh` at the pinned Lore release tag. This makes
26/// `nap install lore` reject a moved tag or compromised download before
27/// executing it.
28pub const PINNED_LORE_INSTALLER_SHA256: &str =
29    "8e7cc96d1b9100610af6c1bd15ec2febbcb48d26cc7f19de3862496897810b74";
30
31/// Digest and Sigstore bundle for the Lore release's binary checksum
32/// manifest. Empty means this Nap source is not eligible for a secure cloud
33/// release even though local development can still use the pinned installer.
34pub const PINNED_LORE_ARTIFACT_MANIFEST_SHA256: &str =
35    "sha256:dc853999309ec32e2c91acb94f7ef2aad4f080ac4a0f7591d9371d5cd57eb089";
36pub const PINNED_LORE_ARTIFACT_MANIFEST_URL: &str =
37    "https://github.com/portalshq/lore/releases/download/v0.8.4-portals.9/SHA256SUMS";
38pub const PINNED_LORE_SIGNATURE_BUNDLE_URL: &str =
39    "https://github.com/portalshq/lore/releases/download/v0.8.4-portals.9/SHA256SUMS.sigstore.json";
40
41// ── Detected version info ───────────────────────────────────────────────
42
43/// A detected Lore version with both parsed semver and raw string forms.
44///
45/// The `raw` field preserves the full version string reported by the CLI
46/// so that compatibility checks can enforce an exact match — not just
47/// major.minor.patch.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct LoreVersionInfo {
50    /// Parsed semver (e.g. `0.8.4`).  The nightly suffix is stripped here
51    /// because `semver::Version` has no concept of release channels.
52    pub parsed: Version,
53    /// Raw version string exactly as reported by the binary
54    /// (e.g. `"0.8.4"`).
55    pub raw: String,
56}
57
58impl std::fmt::Display for LoreVersionInfo {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        write!(f, "{}", self.raw)
61    }
62}
63
64// ── Version detection ───────────────────────────────────────────────────
65
66/// Extract the raw version token from CLI output.
67///
68/// Given `"lore 0.8.4\n"`, returns `"0.8.4"`.
69fn extract_version_string(version_str: &str) -> Result<String> {
70    let version_part = version_str.split_whitespace().nth(1).context(format!(
71        "Failed to parse Lore version string '{}'. \
72             Expected format: 'lore <version>' (e.g., 'lore 0.8.4')",
73        version_str.trim()
74    ))?;
75    Ok(version_part.trim().to_string())
76}
77
78/// Detect the installed Lore CLI version
79pub fn detect_lore_version() -> Result<LoreVersionInfo> {
80    let output = Command::new("lore").arg("--version").output().context(
81        "Failed to execute 'lore --version'. \
82             Lore CLI is not installed or not on PATH. \
83             Install it with: nap install lore",
84    )?;
85
86    if !output.status.success() {
87        let stderr = String::from_utf8_lossy(&output.stderr);
88        anyhow::bail!(
89            "lore --version exited with status: {}. stderr: {}",
90            output.status,
91            stderr.trim()
92        );
93    }
94
95    let version_str = String::from_utf8_lossy(&output.stdout);
96    let raw = extract_version_string(&version_str)?;
97    let parsed = parse_lore_version(&version_str)?;
98    Ok(LoreVersionInfo { parsed, raw })
99}
100
101/// Detect the installed Lore server version
102pub fn detect_loreserver_version() -> Result<LoreVersionInfo> {
103    let output = Command::new("loreserver")
104        .arg("--version")
105        .output()
106        .context(
107            "Failed to execute 'loreserver --version'. \
108             Lore server is not installed or not on PATH. \
109             Install it with: nap install lore",
110        )?;
111
112    if !output.status.success() {
113        let stderr = String::from_utf8_lossy(&output.stderr);
114        anyhow::bail!(
115            "loreserver --version exited with status: {}. stderr: {}",
116            output.status,
117            stderr.trim()
118        );
119    }
120
121    let version_str = String::from_utf8_lossy(&output.stdout);
122    let raw = extract_version_string(&version_str)?;
123    let parsed = parse_lore_version(&version_str)?;
124    Ok(LoreVersionInfo { parsed, raw })
125}
126
127// ── Version parsing ─────────────────────────────────────────────────────
128
129/// Parse Lore version string into `semver::Version`.
130///
131/// Strips the nightly/release suffix before parsing because
132/// `semver::Version` does not model release channels.  Use
133/// [`extract_version_string`] when you need the full, unparsed token.
134fn parse_lore_version(version_str: &str) -> Result<Version> {
135    // Lore version format: "lore 0.8.4" or "loreserver 0.8.4"
136    let version_part = version_str.split_whitespace().nth(1).context(format!(
137        "Failed to parse Lore version string '{}'. \
138             Expected format: 'lore <version>' (e.g., 'lore 0.8.4')",
139        version_str.trim()
140    ))?;
141
142    // Handle nightly versions by stripping the suffix for semver parsing
143    let version_for_semver = version_part.trim_end_matches("-nightly");
144
145    Version::parse(version_for_semver).context(format!(
146        "Failed to parse '{}' as semver version. \
147             Lore version string may be in an unexpected format.",
148        version_for_semver
149    ))
150}
151
152// ── Compatibility gate ──────────────────────────────────────────────────
153
154/// Check if the installed Lore version **exactly** matches the pinned version.
155///
156/// Build metadata after `+` (e.g. `0.8.4+283`) is stripped before
157/// comparison per the semver spec. Pre-release tags like `-nightly` or
158/// `-stable` are **not** stripped and will cause a mismatch.
159pub fn check_lore_compatibility(installed: &LoreVersionInfo) -> Result<bool> {
160    let installed_version = installed.raw.split('+').next().unwrap_or(&installed.raw);
161    Ok(installed_version == PINNED_LORE_VERSION)
162}
163
164// ── Full installation verification ──────────────────────────────────────
165
166/// Verify Lore installation and compatibility
167pub fn verify_lore_installation() -> Result<LoreInstallationStatus> {
168    let cli_version = match detect_lore_version() {
169        Ok(v) => Some(v),
170        Err(e) => {
171            tracing::debug!("Lore CLI not detected: {}", e);
172            None
173        }
174    };
175
176    let server_version = match detect_loreserver_version() {
177        Ok(v) => Some(v),
178        Err(e) => {
179            tracing::debug!("Lore server not detected: {}", e);
180            None
181        }
182    };
183
184    let cli_compatible = cli_version
185        .as_ref()
186        .map(|v| check_lore_compatibility(v).unwrap_or(false))
187        .unwrap_or(false);
188
189    let server_compatible = server_version
190        .as_ref()
191        .map(|v| check_lore_compatibility(v).unwrap_or(false))
192        .unwrap_or(false);
193
194    Ok(LoreInstallationStatus {
195        cli_installed: cli_version.is_some(),
196        cli_version,
197        cli_compatible,
198        server_installed: server_version.is_some(),
199        server_version,
200        server_compatible,
201        pinned_version: PINNED_LORE_VERSION.to_string(),
202    })
203}
204
205// ── Installation status ─────────────────────────────────────────────────
206
207/// Status of Lore installation
208#[derive(Debug, Clone)]
209pub struct LoreInstallationStatus {
210    pub cli_installed: bool,
211    pub cli_version: Option<LoreVersionInfo>,
212    pub cli_compatible: bool,
213    pub server_installed: bool,
214    pub server_version: Option<LoreVersionInfo>,
215    pub server_compatible: bool,
216    pub pinned_version: String,
217}
218
219impl LoreInstallationStatus {
220    /// Check if installation is fully compatible
221    pub fn is_fully_compatible(&self) -> bool {
222        self.cli_installed && self.cli_compatible && self.server_installed && self.server_compatible
223    }
224
225    /// Get a human-readable status message
226    pub fn status_message(&self) -> String {
227        let mut messages = vec![];
228
229        if !self.cli_installed {
230            messages.push("Lore CLI is not installed".to_string());
231        } else if !self.cli_compatible {
232            messages.push(format!(
233                "Lore CLI version '{}' is incompatible with required version '{}'",
234                self.cli_version
235                    .as_ref()
236                    .map(|v| v.raw.as_str())
237                    .unwrap_or("unknown"),
238                self.pinned_version
239            ));
240        }
241
242        if !self.server_installed {
243            messages.push("Lore server is not installed".to_string());
244        } else if !self.server_compatible {
245            messages.push(format!(
246                "Lore server version '{}' is incompatible with required version '{}'",
247                self.server_version
248                    .as_ref()
249                    .map(|v| v.raw.as_str())
250                    .unwrap_or("unknown"),
251                self.pinned_version
252            ));
253        }
254
255        if messages.is_empty() {
256            "Lore installation is compatible".to_string()
257        } else {
258            messages.join("; ")
259        }
260    }
261}
262
263// ── Unit tests ──────────────────────────────────────────────────────────
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_extract_version_string() {
271        assert_eq!(extract_version_string("lore 0.8.4").unwrap(), "0.8.4");
272        assert_eq!(extract_version_string("loreserver 0.8.4").unwrap(), "0.8.4");
273        assert_eq!(extract_version_string("lore 0.8.4\n").unwrap(), "0.8.4");
274    }
275
276    #[test]
277    fn test_extract_version_string_failure() {
278        // Single-word input has no second token → should fail
279        assert!(extract_version_string("lore").is_err());
280        // Empty string → should fail
281        assert!(extract_version_string("").is_err());
282    }
283
284    #[test]
285    fn test_parse_lore_version() {
286        let version_str = "lore 0.8.4";
287        let version = parse_lore_version(version_str).unwrap();
288        assert_eq!(version.major, 0);
289        assert_eq!(version.minor, 8);
290        assert_eq!(version.patch, 4);
291    }
292
293    #[test]
294    fn test_parse_lore_version_with_nightly_suffix() {
295        // Nightly suffix is stripped for semver parsing
296        let version_str = "lore 0.8.4-nightly";
297        let version = parse_lore_version(version_str).unwrap();
298        assert_eq!(version.major, 0);
299        assert_eq!(version.minor, 8);
300        assert_eq!(version.patch, 4);
301    }
302
303    #[test]
304    fn test_parse_loreserver_version() {
305        let version_str = "loreserver 0.8.4";
306        let version = parse_lore_version(version_str).unwrap();
307        assert_eq!(version.major, 0);
308        assert_eq!(version.minor, 8);
309        assert_eq!(version.patch, 4);
310    }
311
312    #[test]
313    fn test_compatibility_exact_match() {
314        let installed = LoreVersionInfo {
315            parsed: Version::new(0, 8, 4),
316            raw: PINNED_LORE_VERSION.to_string(),
317        };
318        assert!(check_lore_compatibility(&installed).unwrap());
319    }
320
321    #[test]
322    fn test_compatibility_ignores_build_metadata() {
323        // "0.8.4-portals.9+283" must match pinned "0.8.4-portals.9" — build
324        // metadata is ignored per the semver specification.
325        let installed = LoreVersionInfo {
326            parsed: Version::new(0, 8, 4),
327            raw: format!("{}+283", PINNED_LORE_VERSION),
328        };
329        assert!(check_lore_compatibility(&installed).unwrap());
330    }
331
332    #[test]
333    fn test_compatibility_rejects_nightly_suffix() {
334        // "0.8.4-nightly" must NOT match pinned "0.8.4"
335        let installed = LoreVersionInfo {
336            parsed: Version::new(0, 8, 4),
337            raw: "0.8.4-nightly".to_string(),
338        };
339        assert!(!check_lore_compatibility(&installed).unwrap());
340    }
341
342    #[test]
343    fn test_compatibility_rejects_wrong_channel() {
344        let installed = LoreVersionInfo {
345            parsed: Version::new(0, 8, 4),
346            raw: "0.8.4-stable".to_string(),
347        };
348        assert!(!check_lore_compatibility(&installed).unwrap());
349    }
350
351    #[test]
352    fn test_compatibility_rejects_wrong_version() {
353        let installed = LoreVersionInfo {
354            parsed: Version::new(0, 7, 0),
355            raw: "0.7.0".to_string(),
356        };
357        assert!(!check_lore_compatibility(&installed).unwrap());
358    }
359
360    #[test]
361    fn test_installation_status_message() {
362        let status = LoreInstallationStatus {
363            cli_installed: false,
364            cli_version: None,
365            cli_compatible: false,
366            server_installed: false,
367            server_version: None,
368            server_compatible: false,
369            pinned_version: PINNED_LORE_VERSION.to_string(),
370        };
371
372        let message = status.status_message();
373        assert!(message.contains("Lore CLI is not installed"));
374        assert!(message.contains("Lore server is not installed"));
375    }
376
377    #[test]
378    fn test_installation_status_message_incompatible() {
379        let status = LoreInstallationStatus {
380            cli_installed: true,
381            cli_version: Some(LoreVersionInfo {
382                parsed: Version::new(0, 8, 4),
383                raw: "0.8.4-nightly".to_string(),
384            }),
385            cli_compatible: false,
386            server_installed: true,
387            server_version: Some(LoreVersionInfo {
388                parsed: Version::new(0, 8, 4),
389                raw: "0.8.4-nightly".to_string(),
390            }),
391            server_compatible: false,
392            pinned_version: PINNED_LORE_VERSION.to_string(),
393        };
394
395        let message = status.status_message();
396        assert!(message.contains("'0.8.4-nightly'"));
397        assert!(message.contains(&format!("'{}'", PINNED_LORE_VERSION)));
398        assert!(!status.is_fully_compatible());
399    }
400
401    #[test]
402    fn test_pinned_version_constant() {
403        // This test documents the contract: the pinned version must be
404        // "0.8.4-portals.9".  If you intentionally change it, update this
405        // test and the integration test as well.
406        assert_eq!(PINNED_LORE_VERSION, "0.8.4-portals.9");
407    }
408}