Updates card: scan npm/cargo deps, surface security advisories, install compatible updates

This commit is contained in:
Avi 2026-08-24 09:54:04 -05:00
commit 8bce42810a
11 changed files with 762 additions and 5 deletions

View file

@ -139,10 +139,11 @@ function startBackend(): void {
/** /**
* Upper bound for one backend round-trip. Generous on purpose: a publish can * 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 * wait on relays and dependency updates run npm/cargo commands that can take
* still gets reaped instead of leaking promises forever. * 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<string, unknown>): Promise<unknown> { async function backendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
startBackend(); startBackend();
@ -196,6 +197,8 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
'relay_set_enabled', 'relay_set_enabled',
'relay_test', 'relay_test',
'settings_update', 'settings_update',
'update_check',
'update_apply',
'backup_now', 'backup_now',
'set_vault_password', 'set_vault_password',
'unlock_vault', 'unlock_vault',

View file

@ -11,6 +11,8 @@ import type {
RevealedKey, RevealedKey,
Settings, Settings,
SignerStatus, SignerStatus,
UpdateApplyReport,
UpdateCheckReport,
UploadedImage, UploadedImage,
} from './types'; } from './types';
@ -80,6 +82,8 @@ export const api = {
relaySetEnabled: (url: string, enabled: boolean) => relaySetEnabled: (url: string, enabled: boolean) =>
call<Settings>('relay_set_enabled', { url, enabled }), call<Settings>('relay_set_enabled', { url, enabled }),
relayTest: (url: string) => call<RelayTestResult>('relay_test', { url }), relayTest: (url: string) => call<RelayTestResult>('relay_test', { url }),
updateCheck: () => call<UpdateCheckReport>('update_check'),
updateApply: () => call<UpdateApplyReport>('update_apply'),
settingsUpdate: ( settingsUpdate: (
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>, patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
) => call<Settings>('settings_update', patch), ) => call<Settings>('settings_update', patch),

View file

@ -94,6 +94,36 @@ export interface RelayTestResult {
latency_ms?: number | null; 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 { export interface AppState {
version: string; version: string;
vault_path: string; vault_path: string;

View file

@ -1,17 +1,24 @@
import { useState } from 'react'; import { useState } from 'react';
import { Alert } from '../components/Alert'; import { Alert } from '../components/Alert';
import { Badge } from '../components/Badge';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { CopyButton } from '../components/CopyButton'; import { CopyButton } from '../components/CopyButton';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { Spinner } from '../components/Spinner';
import { Toggle } from '../components/Toggle'; import { Toggle } from '../components/Toggle';
import { VaultPasswordModal, type VaultPasswordMode } from '../components/VaultPasswordModal'; 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'; import { useApp } from '../state/AppProvider';
export function SettingsScreen() { 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 [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [passwordModal, setPasswordModal] = useState<VaultPasswordMode | null>(null); const [passwordModal, setPasswordModal] = useState<VaultPasswordMode | null>(null);
const [updateReport, setUpdateReport] = useState<UpdateCheckReport | null>(null);
const [checking, setChecking] = useState(false);
const [installing, setInstalling] = useState(false);
const [updateError, setUpdateError] = useState<string | null>(null);
const [applyMessage, setApplyMessage] = useState<string[] | null>(null);
const settings = state?.settings; const settings = state?.settings;
const vaultPath = state?.vault_path ?? ''; 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 ( return (
<div className="screen"> <div className="screen">
<div className="screen-inner"> <div className="screen-inner">
@ -153,6 +192,127 @@ export function SettingsScreen() {
</div> </div>
</section> </section>
<section className="card">
<header className="card-header">
<h2>Updates</h2>
</header>
<div className="card-body settings-block">
<p className="hint">
Scans the JavaScript packages and Rust crates this app is built on. Known security
advisories are always listed first; installing applies compatible updates only.
</p>
<div className="settings-inline">
<Button variant="secondary" onClick={() => void onCheckUpdates()} loading={checking}>
<Icon name="refresh" size={16} />
Check for updates
</Button>
<Button
variant="primary"
onClick={() => void onInstallUpdates()}
loading={installing}
disabled={checking}
>
<Icon name="shield" size={16} />
Install updates
</Button>
</div>
{updateError && (
<Alert tone="error" title="Update problem">
{updateError}
</Alert>
)}
{checking && <Spinner label="Scanning dependencies…" />}
{applyMessage && (
<Alert tone="info" title="Updates">
<ul className="update-summary">
{applyMessage.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</Alert>
)}
{updateReport && !checking && (
<>
{updateReport.advisories.length > 0 ? (
<Alert
tone="warning"
title={`${updateReport.advisories.length} security issue(s) found`}
>
<ul className="update-list">
{updateReport.advisories.map((advisory) => (
<li key={advisory.package}>
<Badge
tone={
advisory.severity === 'critical' || advisory.severity === 'high'
? 'danger'
: advisory.severity === 'moderate'
? 'warning'
: 'neutral'
}
>
{advisory.severity}
</Badge>{' '}
<code className="mono">{advisory.package}</code>
{advisory.title ? ` — ${advisory.title}` : ''}
</li>
))}
</ul>
</Alert>
) : (
<Alert
tone="success"
title="No known security advisories in the npm dependencies."
/>
)}
{updateReport.outdated_npm.length > 0 && (
<div>
<span className="field-label">JavaScript packages</span>
<ul className="update-list">
{updateReport.outdated_npm.map((pkg) => (
<li key={pkg.name}>
<code className="mono">{pkg.name}</code> {pkg.current} →{' '}
<strong>{pkg.available}</strong>
</li>
))}
</ul>
</div>
)}
{updateReport.outdated_cargo.length > 0 && (
<div>
<span className="field-label">Rust crates</span>
<ul className="update-list">
{updateReport.outdated_cargo.map((crateName) => (
<li key={crateName.name}>
<code className="mono">{crateName.name}</code> {crateName.current} →{' '}
<strong>{crateName.available}</strong>
</li>
))}
</ul>
</div>
)}
{updateReport.notes.map((note) => (
<p className="hint" key={note}>
{note}
</p>
))}
{updateReport.advisories.length === 0 &&
updateReport.outdated_npm.length === 0 &&
updateReport.outdated_cargo.length === 0 && (
<Alert tone="success">Everything is up to date.</Alert>
)}
</>
)}
</div>
</section>
<section className="card"> <section className="card">
<header className="card-header"> <header className="card-header">
<h2>Advanced</h2> <h2>Advanced</h2>

View file

@ -21,6 +21,8 @@ import type {
Settings, Settings,
SignerStatus, SignerStatus,
Theme, Theme,
UpdateApplyReport,
UpdateCheckReport,
UploadedImage, UploadedImage,
} from '../lib/types'; } from '../lib/types';
@ -51,6 +53,8 @@ interface AppContextValue {
relayRemove: (url: string) => Promise<Settings>; relayRemove: (url: string) => Promise<Settings>;
relaySetEnabled: (url: string, enabled: boolean) => Promise<Settings>; relaySetEnabled: (url: string, enabled: boolean) => Promise<Settings>;
relayTest: (url: string) => Promise<RelayTestResult>; relayTest: (url: string) => Promise<RelayTestResult>;
updateCheck: () => Promise<UpdateCheckReport>;
updateApply: () => Promise<UpdateApplyReport>;
updateSettings: ( updateSettings: (
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>, patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
) => Promise<Settings>; ) => Promise<Settings>;
@ -190,6 +194,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
[applySettings], [applySettings],
); );
const relayTest = useCallback((url: string) => api.relayTest(url), []); const relayTest = useCallback((url: string) => api.relayTest(url), []);
const updateCheck = useCallback(() => api.updateCheck(), []);
const updateApply = useCallback(() => api.updateApply(), []);
const updateSettings = useCallback( const updateSettings = useCallback(
async (patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>) => async (patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>) =>
applySettings(await api.settingsUpdate(patch)), applySettings(await api.settingsUpdate(patch)),
@ -252,6 +258,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
relayRemove, relayRemove,
relaySetEnabled, relaySetEnabled,
relayTest, relayTest,
updateCheck,
updateApply,
updateSettings, updateSettings,
backupNow, backupNow,
setVaultPassword, setVaultPassword,
@ -296,6 +304,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
relayRemove, relayRemove,
relaySetEnabled, relaySetEnabled,
relayTest, relayTest,
updateCheck,
updateApply,
updateSettings, updateSettings,
backupNow, backupNow,
setVaultPassword, setVaultPassword,

View file

@ -256,6 +256,21 @@ a {
height: 34px; 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 Sidebar
------------------------------------------------------------------------- */ ------------------------------------------------------------------------- */

View file

@ -64,4 +64,64 @@ describe('SettingsScreen', () => {
expect(await screen.findByText(/Keynectr v/)).toBeInTheDocument(); expect(await screen.findByText(/Keynectr v/)).toBeInTheDocument();
expect(screen.getByText('Rust (nostr-sdk)')).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(<SettingsScreen />);
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(<SettingsScreen />);
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(<SettingsScreen />);
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(<SettingsScreen />);
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();
});
}); });

View file

@ -7,6 +7,8 @@ import type {
RelayTestResult, RelayTestResult,
Settings, Settings,
SignerStatus, SignerStatus,
UpdateApplyReport,
UpdateCheckReport,
} from '../lib/types'; } from '../lib/types';
import { ALICE, makePublishReport, makeRelayTest, makeSignerStatus, makeState } from './apiMock'; import { ALICE, makePublishReport, makeRelayTest, makeSignerStatus, makeState } from './apiMock';
@ -45,6 +47,10 @@ export interface FakeBackend {
contactFeedItems: FeedItem[]; contactFeedItems: FeedItem[];
/** Notes returned by `feed_get` with an `author` filter. */ /** Notes returned by `feed_get` with an `author` filter. */
profileFeedItems: FeedItem[]; profileFeedItems: FeedItem[];
/** Report returned by `update_check`. */
updateReport: UpdateCheckReport;
/** Result returned by `update_apply`. */
updateApplyResult: UpdateApplyReport;
} }
export function createFakeBackend(initial?: AppState): FakeBackend { export function createFakeBackend(initial?: AppState): FakeBackend {
@ -139,6 +145,23 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
relays: ['wss://relay.damus.io'], 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<string, unknown>): Promise<unknown> { async function dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
@ -361,6 +384,12 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
return result; return result;
} }
case 'update_check':
return backend.updateReport;
case 'update_apply':
return backend.updateApplyResult;
case 'settings_update': { case 'settings_update': {
const nextSettings: Settings = { ...state.settings, ...params }; const nextSettings: Settings = { ...state.settings, ...params };
backend.setState({ ...state, settings: nextSettings }); backend.setState({ ...state, settings: nextSettings });

View file

@ -13,6 +13,7 @@ use crate::publish;
use crate::relays; use crate::relays;
use crate::settings::Theme; use crate::settings::Theme;
use crate::signer::Signer; use crate::signer::Signer;
use crate::updates;
/// How long to wait for a relay connection test. /// How long to wait for a relay connection test.
const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8); const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8);
@ -90,6 +91,11 @@ pub enum Request {
RelayTest { RelayTest {
url: String, 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, SettingsGet,
SettingsUpdate { SettingsUpdate {
theme: Option<Theme>, theme: Option<Theme>,
@ -312,6 +318,16 @@ async fn run(
let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?; let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?;
Ok(json!(result)) 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 { Request::FeedGet {
limit, limit,
contacts_only, contacts_only,

View file

@ -8,6 +8,7 @@ pub mod publish;
pub mod relays; pub mod relays;
pub mod settings; pub mod settings;
pub mod signer; pub mod signer;
pub mod updates;
pub mod uploads; pub mod uploads;
pub mod vault; pub mod vault;

429
src/updates.rs Normal file
View file

@ -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<String>,
}
/// Everything the scan found, ready to show in one screen.
#[derive(Debug, Clone, Serialize)]
pub struct UpdateCheckReport {
pub outdated_npm: Vec<PackageUpdate>,
pub advisories: Vec<SecurityAdvisory>,
pub outdated_cargo: Vec<PackageUpdate>,
/// Hints about optional tooling or skipped parts of the scan.
pub notes: Vec<String>,
}
/// Result of applying updates.
#[derive(Debug, Clone, Serialize)]
pub struct UpdateApplyReport {
pub applied: Vec<String>,
pub failed: Vec<String>,
/// 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<std::process::Output, AppError> {
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<PackageUpdate> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(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<SecurityAdvisory> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(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<PackageUpdate> {
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<UpdateCheckReport, AppError> {
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<UpdateApplyReport, AppError> {
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::<Vec<_>>()
.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());
}
}