Updates card: scan npm/cargo deps, surface security advisories, install compatible updates
This commit is contained in:
parent
3dfd15f9c1
commit
8bce42810a
11 changed files with 762 additions and 5 deletions
|
|
@ -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<string, unknown>): Promise<unknown> {
|
||||
startBackend();
|
||||
|
|
@ -196,6 +197,8 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
|
|||
'relay_set_enabled',
|
||||
'relay_test',
|
||||
'settings_update',
|
||||
'update_check',
|
||||
'update_apply',
|
||||
'backup_now',
|
||||
'set_vault_password',
|
||||
'unlock_vault',
|
||||
|
|
|
|||
|
|
@ -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<Settings>('relay_set_enabled', { url, enabled }),
|
||||
relayTest: (url: string) => call<RelayTestResult>('relay_test', { url }),
|
||||
updateCheck: () => call<UpdateCheckReport>('update_check'),
|
||||
updateApply: () => call<UpdateApplyReport>('update_apply'),
|
||||
settingsUpdate: (
|
||||
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
|
||||
) => call<Settings>('settings_update', patch),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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 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 (
|
||||
<div className="screen">
|
||||
<div className="screen-inner">
|
||||
|
|
@ -153,6 +192,127 @@ export function SettingsScreen() {
|
|||
</div>
|
||||
</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">
|
||||
<header className="card-header">
|
||||
<h2>Advanced</h2>
|
||||
|
|
|
|||
|
|
@ -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<Settings>;
|
||||
relaySetEnabled: (url: string, enabled: boolean) => Promise<Settings>;
|
||||
relayTest: (url: string) => Promise<RelayTestResult>;
|
||||
updateCheck: () => Promise<UpdateCheckReport>;
|
||||
updateApply: () => Promise<UpdateApplyReport>;
|
||||
updateSettings: (
|
||||
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
|
||||
) => Promise<Settings>;
|
||||
|
|
@ -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<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>) =>
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
------------------------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -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(<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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): Promise<unknown> {
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
16
src/ipc.rs
16
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<Theme>,
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
429
src/updates.rs
Normal file
429
src/updates.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue