From 8bce42810a2bd36a0fddc1240a1ffd51b6abedbb Mon Sep 17 00:00:00 2001 From: Avi Date: Mon, 24 Aug 2026 09:54:04 -0500 Subject: [PATCH] Updates card: scan npm/cargo deps, surface security advisories, install compatible updates --- frontend/electron/main.ts | 9 +- frontend/src/lib/api.ts | 4 + frontend/src/lib/types.ts | 30 ++ frontend/src/screens/SettingsScreen.tsx | 164 ++++++++- frontend/src/state/AppProvider.tsx | 10 + frontend/src/styles.css | 15 + frontend/src/test/SettingsScreen.test.tsx | 60 +++ frontend/src/test/fakeBackend.ts | 29 ++ src/ipc.rs | 16 + src/lib.rs | 1 + src/updates.rs | 429 ++++++++++++++++++++++ 11 files changed, 762 insertions(+), 5 deletions(-) create mode 100644 src/updates.rs diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index 22ff4f6..6759ddc 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -139,10 +139,11 @@ function startBackend(): void { /** * Upper bound for one backend round-trip. Generous on purpose: a publish can - * wait for each relay in turn (10s connect + 15s send each). A hung backend - * still gets reaped instead of leaking promises forever. + * wait on relays and dependency updates run npm/cargo commands that can take + * minutes on cold caches. A hung backend still gets reaped instead of leaking + * promises forever. */ -const BACKEND_TIMEOUT_MS = 120_000; +const BACKEND_TIMEOUT_MS = 300_000; async function backendRequest(method: string, params: Record): Promise { startBackend(); @@ -196,6 +197,8 @@ const RENDERER_METHODS: ReadonlySet = new Set([ 'relay_set_enabled', 'relay_test', 'settings_update', + 'update_check', + 'update_apply', 'backup_now', 'set_vault_password', 'unlock_vault', diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ba56bd5..cd8cae6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -11,6 +11,8 @@ import type { RevealedKey, Settings, SignerStatus, + UpdateApplyReport, + UpdateCheckReport, UploadedImage, } from './types'; @@ -80,6 +82,8 @@ export const api = { relaySetEnabled: (url: string, enabled: boolean) => call('relay_set_enabled', { url, enabled }), relayTest: (url: string) => call('relay_test', { url }), + updateCheck: () => call('update_check'), + updateApply: () => call('update_apply'), settingsUpdate: ( patch: Partial>, ) => call('settings_update', patch), diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 15c241e..2dba643 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -94,6 +94,36 @@ export interface RelayTestResult { latency_ms?: number | null; } +/** One dependency with a newer compatible version available. */ +export interface PackageUpdate { + name: string; + current: string; + available: string; +} + +/** A known security advisory affecting an npm dependency. */ +export interface SecurityAdvisory { + package: string; + /** npm severity label: critical / high / moderate / low / info. */ + severity: string; + title: string | null; +} + +/** Result of scanning both dependency sets for updates. */ +export interface UpdateCheckReport { + outdated_npm: PackageUpdate[]; + advisories: SecurityAdvisory[]; + outdated_cargo: PackageUpdate[]; + notes: string[]; +} + +/** Result of applying dependency updates. */ +export interface UpdateApplyReport { + applied: string[]; + failed: string[]; + restart_required: boolean; +} + export interface AppState { version: string; vault_path: string; diff --git a/frontend/src/screens/SettingsScreen.tsx b/frontend/src/screens/SettingsScreen.tsx index 88d013f..421bf70 100644 --- a/frontend/src/screens/SettingsScreen.tsx +++ b/frontend/src/screens/SettingsScreen.tsx @@ -1,17 +1,24 @@ import { useState } from 'react'; import { Alert } from '../components/Alert'; +import { Badge } from '../components/Badge'; import { Button } from '../components/Button'; import { CopyButton } from '../components/CopyButton'; import { Icon } from '../components/Icon'; +import { Spinner } from '../components/Spinner'; import { Toggle } from '../components/Toggle'; import { VaultPasswordModal, type VaultPasswordMode } from '../components/VaultPasswordModal'; -import type { Theme } from '../lib/types'; +import type { Theme, UpdateCheckReport } from '../lib/types'; import { useApp } from '../state/AppProvider'; export function SettingsScreen() { - const { state, updateSettings, backupNow } = useApp(); + const { state, updateSettings, backupNow, updateCheck, updateApply } = useApp(); const [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null); const [passwordModal, setPasswordModal] = useState(null); + const [updateReport, setUpdateReport] = useState(null); + const [checking, setChecking] = useState(false); + const [installing, setInstalling] = useState(false); + const [updateError, setUpdateError] = useState(null); + const [applyMessage, setApplyMessage] = useState(null); const settings = state?.settings; const vaultPath = state?.vault_path ?? ''; @@ -44,6 +51,38 @@ export function SettingsScreen() { } }; + const onCheckUpdates = async () => { + setChecking(true); + setUpdateError(null); + setApplyMessage(null); + setUpdateReport(null); + try { + setUpdateReport(await updateCheck()); + } catch (err) { + setUpdateError(err instanceof Error ? err.message : String(err)); + } finally { + setChecking(false); + } + }; + + const onInstallUpdates = async () => { + setInstalling(true); + setUpdateError(null); + setApplyMessage(null); + try { + const result = await updateApply(); + const lines = [...result.applied, ...result.failed.map((failure) => `Failed: ${failure}`)]; + if (result.restart_required) { + lines.push('Rebuild and restart the app (cargo build --release, then relaunch) to finish.'); + } + setApplyMessage(lines.length > 0 ? lines : ['Everything is already up to date.']); + } catch (err) { + setUpdateError(err instanceof Error ? err.message : String(err)); + } finally { + setInstalling(false); + } + }; + return (
@@ -153,6 +192,127 @@ export function SettingsScreen() {
+
+
+

Updates

+
+
+

+ Scans the JavaScript packages and Rust crates this app is built on. Known security + advisories are always listed first; installing applies compatible updates only. +

+
+ + +
+ + {updateError && ( + + {updateError} + + )} + + {checking && } + + {applyMessage && ( + +
    + {applyMessage.map((line) => ( +
  • {line}
  • + ))} +
+
+ )} + + {updateReport && !checking && ( + <> + {updateReport.advisories.length > 0 ? ( + +
    + {updateReport.advisories.map((advisory) => ( +
  • + + {advisory.severity} + {' '} + {advisory.package} + {advisory.title ? ` — ${advisory.title}` : ''} +
  • + ))} +
+
+ ) : ( + + )} + + {updateReport.outdated_npm.length > 0 && ( +
+ JavaScript packages +
    + {updateReport.outdated_npm.map((pkg) => ( +
  • + {pkg.name} {pkg.current} →{' '} + {pkg.available} +
  • + ))} +
+
+ )} + + {updateReport.outdated_cargo.length > 0 && ( +
+ Rust crates +
    + {updateReport.outdated_cargo.map((crateName) => ( +
  • + {crateName.name} {crateName.current} →{' '} + {crateName.available} +
  • + ))} +
+
+ )} + + {updateReport.notes.map((note) => ( +

+ {note} +

+ ))} + + {updateReport.advisories.length === 0 && + updateReport.outdated_npm.length === 0 && + updateReport.outdated_cargo.length === 0 && ( + Everything is up to date. + )} + + )} +
+
+

Advanced

diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 421cd26..0b5541d 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -21,6 +21,8 @@ import type { Settings, SignerStatus, Theme, + UpdateApplyReport, + UpdateCheckReport, UploadedImage, } from '../lib/types'; @@ -51,6 +53,8 @@ interface AppContextValue { relayRemove: (url: string) => Promise; relaySetEnabled: (url: string, enabled: boolean) => Promise; relayTest: (url: string) => Promise; + updateCheck: () => Promise; + updateApply: () => Promise; updateSettings: ( patch: Partial>, ) => Promise; @@ -190,6 +194,8 @@ export function AppProvider({ children }: { children: ReactNode }) { [applySettings], ); const relayTest = useCallback((url: string) => api.relayTest(url), []); + const updateCheck = useCallback(() => api.updateCheck(), []); + const updateApply = useCallback(() => api.updateApply(), []); const updateSettings = useCallback( async (patch: Partial>) => applySettings(await api.settingsUpdate(patch)), @@ -252,6 +258,8 @@ export function AppProvider({ children }: { children: ReactNode }) { relayRemove, relaySetEnabled, relayTest, + updateCheck, + updateApply, updateSettings, backupNow, setVaultPassword, @@ -296,6 +304,8 @@ export function AppProvider({ children }: { children: ReactNode }) { relayRemove, relaySetEnabled, relayTest, + updateCheck, + updateApply, updateSettings, backupNow, setVaultPassword, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6029927..d8541ee 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -256,6 +256,21 @@ a { height: 34px; } +.update-list { + margin: 6px 0 0; + padding-left: 18px; + display: grid; + gap: 4px; + font-size: 14px; +} + +.update-summary { + margin: 6px 0 0; + padding-left: 18px; + display: grid; + gap: 4px; +} + /* ------------------------------------------------------------------------- Sidebar ------------------------------------------------------------------------- */ diff --git a/frontend/src/test/SettingsScreen.test.tsx b/frontend/src/test/SettingsScreen.test.tsx index 928f5aa..bc5a4e6 100644 --- a/frontend/src/test/SettingsScreen.test.tsx +++ b/frontend/src/test/SettingsScreen.test.tsx @@ -64,4 +64,64 @@ describe('SettingsScreen', () => { expect(await screen.findByText(/Keynectr v/)).toBeInTheDocument(); expect(screen.getByText('Rust (nostr-sdk)')).toBeInTheDocument(); }); + + it('checks for updates and lists security advisories first', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await user.click(await screen.findByRole('button', { name: /Check for updates/i })); + + expect(await screen.findByText('1 security issue(s) found')).toBeInTheDocument(); + expect(screen.getByText(/minimist/)).toBeInTheDocument(); + expect(screen.getByText('JavaScript packages')).toBeInTheDocument(); + expect(screen.getByText('Rust crates')).toBeInTheDocument(); + const checkRequest = backend.requests.find((r) => r.method === 'update_check'); + expect(checkRequest).toBeDefined(); + }); + + it('reports an up-to-date app when the scan finds nothing', async () => { + const backend = createFakeBackend(); + backend.updateReport = { + outdated_npm: [], + advisories: [], + outdated_cargo: [], + notes: [], + }; + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await user.click(await screen.findByRole('button', { name: /Check for updates/i })); + + expect(await screen.findByText('Everything is up to date.')).toBeInTheDocument(); + }); + + it('installs updates and asks for a rebuild + restart', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await user.click(await screen.findByRole('button', { name: /Install updates/i })); + + expect(await screen.findByText(/Rebuild and restart the app/)).toBeInTheDocument(); + expect(screen.getByText('JavaScript security fixes applied')).toBeInTheDocument(); + const applyRequest = backend.requests.find((r) => r.method === 'update_apply'); + expect(applyRequest).toBeDefined(); + }); + + it('surfaces update failures as errors', async () => { + const backend = createFakeBackend(); + backend.nextErrors.update_check = { message: 'npm was not found on this computer.' }; + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await user.click(await screen.findByRole('button', { name: /Check for updates/i })); + + expect(await screen.findByText('Update problem')).toBeInTheDocument(); + expect(screen.getByText('npm was not found on this computer.')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index 5850434..0819a58 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -7,6 +7,8 @@ import type { RelayTestResult, Settings, SignerStatus, + UpdateApplyReport, + UpdateCheckReport, } from '../lib/types'; import { ALICE, makePublishReport, makeRelayTest, makeSignerStatus, makeState } from './apiMock'; @@ -45,6 +47,10 @@ export interface FakeBackend { contactFeedItems: FeedItem[]; /** Notes returned by `feed_get` with an `author` filter. */ profileFeedItems: FeedItem[]; + /** Report returned by `update_check`. */ + updateReport: UpdateCheckReport; + /** Result returned by `update_apply`. */ + updateApplyResult: UpdateApplyReport; } export function createFakeBackend(initial?: AppState): FakeBackend { @@ -139,6 +145,23 @@ export function createFakeBackend(initial?: AppState): FakeBackend { relays: ['wss://relay.damus.io'], }, ], + updateReport: { + outdated_npm: [{ name: 'vite', current: '4.0.0', available: '5.1.0' }], + advisories: [ + { + package: 'minimist', + severity: 'high', + title: 'Prototype Pollution', + }, + ], + outdated_cargo: [{ name: 'serde', current: '1.0.200', available: '1.0.219' }], + notes: [], + }, + updateApplyResult: { + applied: ['JavaScript security fixes applied', 'Rust crates updated in Cargo.lock'], + failed: [], + restart_required: true, + }, }; async function dispatch(method: string, params: Record): Promise { @@ -361,6 +384,12 @@ export function createFakeBackend(initial?: AppState): FakeBackend { return result; } + case 'update_check': + return backend.updateReport; + + case 'update_apply': + return backend.updateApplyResult; + case 'settings_update': { const nextSettings: Settings = { ...state.settings, ...params }; backend.setState({ ...state, settings: nextSettings }); diff --git a/src/ipc.rs b/src/ipc.rs index ccb4ca8..e1ec407 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -13,6 +13,7 @@ use crate::publish; use crate::relays; use crate::settings::Theme; use crate::signer::Signer; +use crate::updates; /// How long to wait for a relay connection test. const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8); @@ -90,6 +91,11 @@ pub enum Request { RelayTest { url: String, }, + /// Scan both dependency sets (npm + cargo) for available updates and + /// security advisories, without changing anything. + UpdateCheck, + /// Install compatible dependency updates (security fixes first). + UpdateApply, SettingsGet, SettingsUpdate { theme: Option, @@ -312,6 +318,16 @@ async fn run( let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?; Ok(json!(result)) } + Request::UpdateCheck => { + // Long-running package-manager scan; never touches shared state. + let report = updates::check().await?; + Ok(json!(report)) + } + Request::UpdateApply => { + // Installs updates on disk; a rebuild + restart picks them up. + let report = updates::apply().await?; + Ok(json!(report)) + } Request::FeedGet { limit, contacts_only, diff --git a/src/lib.rs b/src/lib.rs index b489c08..7ca39ef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod publish; pub mod relays; pub mod settings; pub mod signer; +pub mod updates; pub mod uploads; pub mod vault; diff --git a/src/updates.rs b/src/updates.rs new file mode 100644 index 0000000..5ab6c8b --- /dev/null +++ b/src/updates.rs @@ -0,0 +1,429 @@ +//! Dependency update scanning and installation for both halves of the app. +//! +//! The app runs from a source checkout, so "updating" means refreshing the +//! JavaScript dependencies (`frontend/`) and the Rust crates (`Cargo.lock`) +//! to their newest compatible versions. Security advisories from `npm audit` +//! are surfaced first; `cargo update` picks up semver-compatible patches for +//! the Rust side. +//! +//! Nothing here touches secret material: only fixed, read-mostly package +//! manager commands are run in the project directories. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::Serialize; +use tokio::process::Command; + +use crate::errors::{AppError, ErrorKind}; + +/// Upper bound for a single package-manager command. Installs can be slow on +/// cold caches, but a hung command must still be reaped eventually. +const COMMAND_TIMEOUT: Duration = Duration::from_secs(150); + +/// One dependency with a newer compatible version available. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct PackageUpdate { + pub name: String, + pub current: String, + pub available: String, +} + +/// A known security advisory affecting an npm dependency. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct SecurityAdvisory { + pub package: String, + /// npm severity label: critical / high / moderate / low / info. + pub severity: String, + /// Short advisory title, when npm reported one. + pub title: Option, +} + +/// Everything the scan found, ready to show in one screen. +#[derive(Debug, Clone, Serialize)] +pub struct UpdateCheckReport { + pub outdated_npm: Vec, + pub advisories: Vec, + pub outdated_cargo: Vec, + /// Hints about optional tooling or skipped parts of the scan. + pub notes: Vec, +} + +/// Result of applying updates. +#[derive(Debug, Clone, Serialize)] +pub struct UpdateApplyReport { + pub applied: Vec, + pub failed: Vec, + /// The running binary/bundle cannot hot-swap; the app must be rebuilt + /// and restarted to load the refreshed dependencies. + pub restart_required: bool, +} + +/// The directory this backend was compiled from (the project root). +fn source_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +/// The frontend source directory (where `package.json` lives). +fn frontend_dir() -> PathBuf { + source_dir().join("frontend") +} + +/// Verify the app is running from its source checkout before shelling out. +fn require_source_checkout() -> Result<(), AppError> { + let manifest = source_dir().join("Cargo.toml"); + let package = frontend_dir().join("package.json"); + if manifest.exists() && package.exists() { + return Ok(()); + } + Err(AppError::simple( + ErrorKind::Config, + "Updates need the app's source folder, which was not found next to the running program.", + )) +} + +/// Run a command with a timeout, capturing stdout/stderr separately. +async fn run(dir: &Path, program: &str, args: &[&str]) -> Result { + let mut command = Command::new(program); + command.current_dir(dir).args(args).kill_on_drop(true); + let future = command.output(); + match tokio::time::timeout(COMMAND_TIMEOUT, future).await { + Ok(Ok(output)) => Ok(output), + Ok(Err(err)) => { + let hint = if err.kind() == std::io::ErrorKind::NotFound { + format!("'{program}' was not found on this computer.") + } else { + format!("Could not run '{program}': {err}") + }; + Err(AppError::simple(ErrorKind::Internal, hint)) + } + Err(_) => Err(AppError::with_details( + ErrorKind::Network, + format!("'{program}' took too long and was stopped."), + "The command exceeded its time limit while updating dependencies.", + )), + } +} + +/// Decode command output as UTF-8, falling back to lossy text. +fn text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +/// Parse `npm outdated --json`. +/// +/// npm exits non-zero when anything is outdated, so exit status is ignored: +/// the JSON body is the data. Unparseable or missing output means no data. +fn parse_npm_outdated(json: &str) -> Vec { + let Ok(value) = serde_json::from_str::(json) else { + return Vec::new(); + }; + let Some(map) = value.as_object() else { + return Vec::new(); + }; + let mut updates = Vec::new(); + for (name, info) in map { + let get = |key: &str| { + info.get(key) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string() + }; + let current = get("current"); + let available = if get("latest").is_empty() { + get("wanted") + } else { + get("latest") + }; + updates.push(PackageUpdate { + name: name.clone(), + current, + available, + }); + } + updates.sort_by(|a, b| a.name.cmp(&b.name)); + updates +} + +/// Parse `npm audit --json` into a flat advisory list. +fn parse_npm_audit(json: &str) -> Vec { + let Ok(value) = serde_json::from_str::(json) else { + return Vec::new(); + }; + let Some(vulnerabilities) = value.get("vulnerabilities").and_then(|v| v.as_object()) else { + return Vec::new(); + }; + let mut advisories = Vec::new(); + for (name, info) in vulnerabilities { + let severity = info + .get("severity") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + // `via` holds either plain title strings or full advisory objects. + let title = info.get("via").and_then(|v| v.as_array()).and_then(|list| { + list.iter().find_map(|entry| match entry { + serde_json::Value::String(text) => Some(text.clone()), + serde_json::Value::Object(object) => object + .get("title") + .and_then(|t| t.as_str()) + .map(|t| t.to_string()), + _ => None, + }) + }); + advisories.push(SecurityAdvisory { + package: name.clone(), + severity, + title, + }); + } + const ORDER: [&str; 5] = ["critical", "high", "moderate", "low", "info"]; + advisories.sort_by(|a, b| { + let rank = |s: &str| { + ORDER + .iter() + .position(|known| known.eq_ignore_ascii_case(s)) + .unwrap_or(ORDER.len()) + }; + rank(&a.severity) + .cmp(&rank(&b.severity)) + .then_with(|| a.package.cmp(&b.package)) + }); + advisories +} + +/// Parse `cargo update --dry-run` status lines into crate updates. +fn parse_cargo_updates(text: &str) -> Vec { + let mut updates = Vec::new(); + for line in text.lines() { + let tokens: Vec<&str> = line.split_whitespace().collect(); + // e.g. "Updating serde v1.0.200 -> v1.0.219" (+ optional suffixes). + if tokens.len() >= 5 && tokens[3] == "->" { + let verb = tokens.first().copied().unwrap_or_default(); + if matches!(verb, "Updating" | "Downgrading") { + updates.push(PackageUpdate { + name: tokens[1].to_string(), + current: tokens[2].trim_start_matches('v').to_string(), + available: tokens[4].trim_start_matches('v').to_string(), + }); + } + } + } + updates.sort_by(|a, b| a.name.cmp(&b.name)); + updates +} + +/// Whether the optional RustSec scanner is installed. +async fn cargo_audit_available() -> bool { + run(Path::new("."), "cargo", &["audit", "--version"]) + .await + .map(|output| output.status.success()) + .unwrap_or(false) +} + +/// Scan both dependency sets without changing anything. +pub async fn check() -> Result { + require_source_checkout()?; + let root = source_dir(); + let frontend = frontend_dir(); + + let outdated = run(&frontend, "npm", &["outdated", "--json"]).await?; + let outdated_npm = parse_npm_outdated(&text(&outdated.stdout)); + + let audit = run(&frontend, "npm", &["audit", "--json"]).await?; + let advisories = parse_npm_audit(&text(&audit.stdout)); + + let dry_run = run(&root, "cargo", &["update", "--dry-run"]).await?; + let cargo_text = format!("{}{}", text(&dry_run.stdout), text(&dry_run.stderr)); + let outdated_cargo = parse_cargo_updates(&cargo_text); + + let mut notes = Vec::new(); + if !cargo_audit_available().await { + notes.push( + "Rust advisory scan unavailable: install cargo-audit (cargo install cargo-audit) \ + to also check Rust crates against the RustSec database." + .to_string(), + ); + } + + Ok(UpdateCheckReport { + outdated_npm, + advisories, + outdated_cargo, + notes, + }) +} + +/// Install compatible updates: npm security fixes, then general bumps, then +/// the Rust lockfile. +pub async fn apply() -> Result { + require_source_checkout()?; + let root = source_dir(); + let frontend = frontend_dir(); + + let steps: [(&Path, &str, &[&str], &str); 3] = [ + ( + &frontend, + "npm", + &["audit", "fix"], + "JavaScript security fixes applied", + ), + ( + &frontend, + "npm", + &["update"], + "JavaScript packages updated to their newest compatible versions", + ), + ( + &root, + "cargo", + &["update"], + "Rust crates updated in Cargo.lock", + ), + ]; + + let mut applied = Vec::new(); + let mut failed = Vec::new(); + for (dir, program, args, summary) in steps { + match run(dir, program, args).await { + Ok(output) => { + if output.status.success() { + applied.push(summary.to_string()); + } else { + let stderr = text(&output.stderr); + let tail: String = stderr + .lines() + .filter(|line| !line.trim().is_empty()) + .rev() + .take(3) + .collect::>() + .join(" | "); + failed.push(format!("{program} {args:?}: {tail}")); + } + } + Err(err) => failed.push(format!("{program} {args:?}: {}", err.message())), + } + } + + if applied.is_empty() && !failed.is_empty() { + return Err(AppError::with_details( + ErrorKind::Internal, + "No updates could be installed.", + failed.join("\n"), + )); + } + + let restart_required = !applied.is_empty(); + Ok(UpdateApplyReport { + applied, + failed, + restart_required, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_npm_outdated_entries() { + let json = r#"{ + "left-pad": { "current": "1.0.0", "wanted": "1.3.0", "latest": "1.3.0" }, + "react": { "current": "18.2.0", "wanted": "18.2.0", "latest": "19.0.0" } + }"#; + let updates = parse_npm_outdated(json); + assert_eq!(updates.len(), 2); + assert_eq!( + updates[0], + PackageUpdate { + name: "left-pad".to_string(), + current: "1.0.0".to_string(), + available: "1.3.0".to_string(), + } + ); + assert_eq!(updates[1].name, "react"); + assert_eq!(updates[1].available, "19.0.0"); + } + + #[test] + fn empty_or_garbage_outdated_output_yields_nothing() { + assert!(parse_npm_outdated("").is_empty()); + assert!(parse_npm_outdated("not json at all").is_empty()); + assert!(parse_npm_outdated("{}").is_empty()); + } + + #[test] + fn parses_npm_audit_with_title_objects_and_strings() { + let json = r#"{ + "vulnerabilities": { + "minimist": { + "severity": "high", + "via": [{ "title": "Prototype Pollution", "url": "https://example.com/a" }] + }, + "tar": { "severity": "low", "via": ["Regular Expression Denial of Service"] } + } + }"#; + let advisories = parse_npm_audit(json); + assert_eq!(advisories.len(), 2); + assert_eq!(advisories[0].package, "minimist"); + assert_eq!(advisories[0].severity, "high"); + assert_eq!(advisories[0].title.as_deref(), Some("Prototype Pollution")); + assert_eq!(advisories[1].package, "tar"); + assert_eq!( + advisories[1].title.as_deref(), + Some("Regular Expression Denial of Service") + ); + } + + #[test] + fn sorts_advisories_by_severity_then_name() { + let json = r#"{ + "vulnerabilities": { + "zeta": { "severity": "critical", "via": [] }, + "alpha": { "severity": "high", "via": [] }, + "beta": { "severity": "critical", "via": [] } + } + }"#; + let advisories = parse_npm_audit(json); + let order: Vec<&str> = advisories.iter().map(|a| a.package.as_str()).collect(); + assert_eq!(order, vec!["beta", "zeta", "alpha"]); + } + + #[test] + fn empty_audit_yields_no_advisories() { + assert!(parse_npm_audit("").is_empty()); + assert!(parse_npm_audit("{}").is_empty()); + assert!(parse_npm_audit("{\"vulnerabilities\":{}}").is_empty()); + } + + #[test] + fn parses_cargo_update_lines() { + let text = "\ + Updating crates.io index\n\ + Locking 2 packages to their latest compatible version\n\ + Updating serde v1.0.200 -> v1.0.219\n\ + Downgrading leftpad v2.0.0 -> v1.9.0 (features: [\"std\"])\n\ + Adding newcrate v0.1.0\n"; + let updates = parse_cargo_updates(text); + assert_eq!(updates.len(), 2); + assert_eq!(updates[0].name, "leftpad"); + assert_eq!(updates[0].current, "2.0.0"); + assert_eq!(updates[0].available, "1.9.0"); + assert_eq!(updates[1].name, "serde"); + assert_eq!(updates[1].available, "1.0.219"); + } + + #[test] + fn unchanged_lockfile_yields_no_updates() { + assert!(parse_cargo_updates("Unchanged").is_empty()); + assert!(parse_cargo_updates("").is_empty()); + } + + #[test] + fn source_layout_holds_for_this_repo() { + // The compile-time paths must exist in the real checkout, otherwise + // every runtime check would fail with a misleading error. + assert!(source_dir().join("Cargo.toml").exists()); + assert!(frontend_dir().join("package.json").exists()); + } +}