This commit is contained in:
jarek committed 2026-07-25 08:41:01 +02:00
1 parent 9f09d60a83
commit 64b6dcd744
177 files changed
+21182 -1042

No files matched your search

@@ -0,0 +1,132 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackupDestination } from '$lib/server/audit';
import {
getBackupDestinations,
createBackupDestination,
decryptBackupDestination,
updateBackupDestination,
updateBackupDestinationTestStatus
} from '$lib/server/db';
import { initRepository, testRepository } from '$lib/server/backups';
import { registerSchedule } from '$lib/server/scheduler';
import { validateRepositoryForSave, validateFlags, validatePolicySchedules } from '$lib/server/backups/helpers';
/**
* Prepare destination for API response — strip password, parse env vars.
*
* The LIST endpoint omits envVars entirely. Cloud-credential env vars
* (AWS_SECRET_ACCESS_KEY, AZURE_ACCOUNT_KEY, etc.) used to ship decrypted to
* any user with backups:view permission, even though the LIST view doesn't
* need them. The edit modal re-fetches via GET /destinations/[id] (which
* still returns envVars decrypted so the form can pre-fill credential
* fields). Single GET is what populates the modal; LIST never needs them.
*/
function prepareDestination(dest: any, opts: { includeEnvVars: boolean }): any {
const decrypted = decryptBackupDestination(dest);
const result = { ...dest };
delete result.password;
if (opts.includeEnvVars) {
result.envVars = decrypted.decryptedEnvVars;
} else {
delete result.envVars;
}
return result;
}
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const destinations = await getBackupDestinations();
// LIST: strip envVars (cloud creds). Modal re-fetches single destination to edit.
return json(destinations.map(d => prepareDestination(d, { includeEnvVars: false })));
};
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const body = await request.json();
if (!body.name || !body.repository || !body.password) {
return json({ error: 'Missing required fields: name, repository, password' }, { status: 400 });
}
// Validate repository scheme + SSRF host (audit #7/#53) and restic flags (#7)
// BEFORE persisting, so nothing is saved on bad input.
const repoError = validateRepositoryForSave(body.repository);
if (repoError) return json({ error: repoError }, { status: 400 });
const flagError = validateFlags(body.flags);
if (flagError) return json({ error: flagError }, { status: 400 });
// Validate any cron schedules in the supplied policies (audit #7)
const policyCronError = validatePolicySchedules(body.policies);
if (policyCronError) {
return json({ error: policyCronError }, { status: 400 });
}
try {
// Set default policies if not provided
const defaultPolicies = JSON.stringify({
pruneEnabled: true, pruneSchedule: '0 0 1 * *', pruneMaxUnused: '10',
checkEnabled: true, checkSchedule: '0 0 1 * *',
verifyEnabled: false, verifySchedule: '0 0 1 * *', verifyDataSubset: '5%',
autoUnlock: true
});
const destination = await createBackupDestination({
name: body.name,
repository: body.repository,
password: body.password,
envVars: body.envVars ? JSON.stringify(body.envVars) : null,
flags: body.flags ?? null,
hostPath: body.hostPath ?? null,
policies: body.policies ?? defaultPolicies
});
// Auto-initialize the repository, then TEST it before recording success.
// `restic init` succeeds on an already-existing repo without decrypting it,
// so init alone doesn't prove the stored password is correct. testRepository
// reads the repo config (which requires the password), so a wrong password
// against a pre-existing repo is recorded as failed instead of success.
try {
await initRepository(destination.id);
const test = await testRepository(destination.id);
if (test.ok) {
await updateBackupDestination(destination.id, {
lastTestStatus: 'success',
lastTestAt: new Date().toISOString()
});
} else {
await updateBackupDestinationTestStatus(destination.id, 'failed', test.error ?? 'Repository is not reachable or the password is incorrect');
}
} catch (initErr) {
// Init failed — destination saved but not initialized. Record the
// failure on the destination so the UI reflects it (audit #9).
const initMsg = initErr instanceof Error ? initErr.message : String(initErr);
await updateBackupDestinationTestStatus(destination.id, 'failed', initMsg);
}
// Register repo maintenance schedules from default policies
try {
await registerSchedule(destination.id, 'repo_prune', null);
await registerSchedule(destination.id, 'repo_check', null);
await registerSchedule(destination.id, 'repo_verify', null);
} catch {}
await auditBackupDestination(event, 'create', destination.id, destination.name, { repository: body.repository });
// POST returns envVars — user just provided them, no point re-hiding.
return json(prepareDestination(destination, { includeEnvVars: true }), { status: 201 });
} catch (error: any) {
if (error.message?.includes('UNIQUE constraint')) {
return json({ error: 'A destination with this name already exists' }, { status: 409 });
}
return json({ error: error.message }, { status: 500 });
}
};
@@ -0,0 +1,199 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackupDestination } from '$lib/server/audit';
import {
getBackupDestination,
updateBackupDestination,
deleteBackupDestination,
decryptBackupDestination,
getBackupConfigs,
getEnvironment
} from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { validatePolicySchedules, validateRepositoryForSave, validateFlags } from '$lib/server/backups/helpers';
import { destinationHasRunningBackup } from '$lib/server/backups';
// Inlined predicates (kept in sync with the config routes) — see
// configs/+server.ts. A local repo cannot back up a remote environment.
function isLocalRepo(repository: string): boolean {
return repository.startsWith('/') || repository.startsWith('./');
}
function isRemoteEnv(connectionType?: string | null, host?: string | null): boolean {
if (!connectionType) return false;
if (connectionType === 'hawser-standard' || connectionType === 'hawser-edge') return true;
if (connectionType === 'direct' && !!host) return true;
return false;
}
/**
* Single-destination response shape. envVars (decrypted cloud credentials) are
* ONLY included for callers with backups:manage — the edit form that pre-fills
* credential fields. A backups:view (Viewer) caller must never receive them
* (audit #26/#29). The password is always stripped. The LIST endpoint omits
* envVars entirely — see /api/backup/destinations/+server.ts.
*/
function prepareDestination(dest: any, includeSecrets: boolean): any {
const result = { ...dest };
delete result.password;
if (includeSecrets) {
result.envVars = decryptBackupDestination(dest).decryptedEnvVars;
} else {
delete result.envVars;
}
return result;
}
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
const destination = await getBackupDestination(id);
if (!destination) return json({ error: 'Destination not found' }, { status: 404 });
// Only surface decrypted cloud credentials to users who can edit the
// destination; Viewers get the row without secrets.
const canManage = !auth.authEnabled || await auth.can('backups', 'manage');
return json(prepareDestination(destination, canManage));
};
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
const existing = await getBackupDestination(id);
if (!existing) return json({ error: 'Destination not found' }, { status: 404 });
const body = await request.json();
// Changing the repo target or password while a backup is writing to this
// destination would break the in-flight run. A policy/name-only edit is safe.
if ((body.repository !== undefined || body.password !== undefined) && await destinationHasRunningBackup(id)) {
return json({ error: 'A backup using this destination is currently running. Try again once it finishes.' }, { status: 409 });
}
// Validate any cron schedules in the supplied policies (audit #7)
const policyCronError = validatePolicySchedules(body.policies);
if (policyCronError) {
return json({ error: policyCronError }, { status: 400 });
}
// (audit #7/#53) The PUT edit path previously never re-validated: validate the
// repository scheme/SSRF host and restic flags when supplied, before persisting.
if (body.repository !== undefined) {
const repoError = validateRepositoryForSave(body.repository);
if (repoError) return json({ error: repoError }, { status: 400 });
}
const flagError = validateFlags(body.flags);
if (flagError) return json({ error: flagError }, { status: 400 });
// (audit #8) Changing the repository to a LOCAL path bypasses the config-route
// guard: refuse if any config on this destination targets a remote environment
// (the restic helper runs on the remote daemon, which can't see Dockhand's fs).
const effectiveRepository = body.repository !== undefined ? body.repository : existing.repository;
if (body.repository !== undefined && isLocalRepo(effectiveRepository)) {
const configs = await getBackupConfigs();
const envIds = [...new Set(
configs.filter((c: any) => c.destinationId === id && c.environmentId).map((c: any) => c.environmentId as number)
)];
const envs = await Promise.all(envIds.map((eid) => getEnvironment(eid)));
const remote = envs.find((env) => env && isRemoteEnv(env.connectionType, env.host));
if (remote) {
return json({
error: `Local repository cannot back up containers on remote environment "${remote.name}". Use S3, REST, or another non-local backend.`
}, { status: 400 });
}
}
try {
const updated = await updateBackupDestination(id, {
name: body.name,
repository: body.repository,
password: body.password,
envVars: body.envVars !== undefined ? JSON.stringify(body.envVars) : undefined,
flags: body.flags,
hostPath: body.hostPath,
policies: body.policies
});
if (!updated) return json({ error: 'Update failed' }, { status: 500 });
// Re-register maintenance schedules only when the policies actually changed
// (audit #22) — a metadata-only edit (rename, flags, etc.) shouldn't churn
// the repo_prune/check/verify cron jobs.
if (body.policies !== undefined) {
try {
await registerSchedule(id, 'repo_prune', null);
await registerSchedule(id, 'repo_check', null);
await registerSchedule(id, 'repo_verify', null);
} catch {}
}
// Audit: don't log the new password — only the fact that it changed
await auditBackupDestination(event, 'update', id, updated.name, {
repositoryChanged: body.repository !== undefined && body.repository !== existing.repository,
passwordChanged: body.password !== undefined,
policiesChanged: body.policies !== undefined
});
// PUT caller already holds backups:manage — safe to echo secrets back.
return json(prepareDestination(updated, true));
} catch (error: any) {
if (error.message?.includes('UNIQUE constraint')) {
return json({ error: 'A destination with this name already exists' }, { status: 409 });
}
return json({ error: error.message }, { status: 500 });
}
};
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
const existing = await getBackupDestination(id);
if (!existing) return json({ error: 'Destination not found' }, { status: 404 });
// Don't delete a destination out from under a backup that's writing to its repo.
if (await destinationHasRunningBackup(id)) {
return json({ error: 'A backup using this destination is currently running. Try again once it finishes.' }, { status: 409 });
}
// Unregister all maintenance schedules for this destination
unregisterSchedule(id, 'repo_prune');
unregisterSchedule(id, 'repo_check');
unregisterSchedule(id, 'repo_verify');
// Tear down in-memory croner jobs for any backup configs that reference this
// destination (audit #47). The config rows may cascade-delete, but the croner
// jobs would otherwise stay registered as orphans. Mirrors env-delete teardown.
try {
const allConfigs = await getBackupConfigs();
for (const config of allConfigs) {
if (config.destinationId === id) {
unregisterSchedule(config.id, 'backup');
}
}
} catch (err) {
console.error(`Failed to unregister backup schedules for destination "${existing.name}":`, err);
}
await deleteBackupDestination(id);
await auditBackupDestination(event, 'delete', id, existing.name, { repository: existing.repository });
return json({ success: true });
};
@@ -0,0 +1,34 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackupDestination } from '$lib/server/audit';
import { getBackupDestination, updateBackupDestinationTestStatus } from '$lib/server/db';
import { initRepository } from '$lib/server/backups';
export const POST: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
const dest = await getBackupDestination(id);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
try {
await initRepository(id);
await updateBackupDestinationTestStatus(id, 'success');
// Repo init is a state change on the destination, treat it as an
// 'update' so it's distinguishable from create-destination events.
await auditBackupDestination(event, 'update', id, dest.name, { action: 'init', status: 'success' });
return json({ success: true, message: 'Repository initialized' });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
await updateBackupDestinationTestStatus(id, 'failed', msg);
await auditBackupDestination(event, 'update', id, dest.name, { action: 'init', status: 'failed', error: msg });
return json({ success: false, error: msg }, { status: 500 });
}
};
@@ -0,0 +1,68 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackupDestination } from '$lib/server/audit';
import { getBackupDestination } from '$lib/server/db';
import { rotateDestinationPassword } from '$lib/server/backups';
/**
* Rotate the restic repository password for a destination.
*
* Body: { currentPassword: string, newPassword: string }
*
* 200 — rotated, DB updated
* 400 — input invalid or current password wrong
* 404 — destination not found
* 409 — restic rotated but DB write failed (manual recovery needed; details
* include dbOutOfSync: true)
* 500 — restic call failed for an unrelated reason
*/
export const POST: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
const body = await request.json().catch(() => ({}));
const currentPassword = typeof body.currentPassword === 'string' ? body.currentPassword : '';
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : '';
if (!currentPassword || !newPassword) {
return json({ error: 'currentPassword and newPassword are required' }, { status: 400 });
}
const dest = await getBackupDestination(id);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
const result = await rotateDestinationPassword(id, currentPassword, newPassword);
if (!result.ok) {
// Audit the failed attempt — useful signal for spotting wrong-password
// probes against destinations.
await auditBackupDestination(event, 'update', id, dest.name, {
action: 'rotate-key',
success: false,
error: result.error,
dbOutOfSync: result.dbOutOfSync ?? false
});
if (result.dbOutOfSync) {
return json({ error: result.error, dbOutOfSync: true }, { status: 409 });
}
// Restic reports an incorrect current password as "wrong password" or
// "no key found" — surface a clearer 400 so the UI can say the current
// password is incorrect rather than a generic failure.
if (/wrong password|no key found/i.test(result.error)) {
return json({ error: 'Current password incorrect' }, { status: 400 });
}
if (result.error.toLowerCase().includes('does not match') || result.error.toLowerCase().includes('must')) {
return json({ error: result.error }, { status: 400 });
}
return json({ error: result.error }, { status: 500 });
}
await auditBackupDestination(event, 'update', id, dest.name, { action: 'rotate-key', success: true });
return json({ success: true });
};
@@ -0,0 +1,60 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackupDestination } from '$lib/server/audit';
import { getBackupDestination, getBackupConfigs } from '$lib/server/db';
import { runRepoTask } from '$lib/server/backups';
export const POST: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const destId = parseInt(params.id);
if (isNaN(destId)) return json({ error: 'Invalid destination ID' }, { status: 400 });
const body = await request.json();
const task = body.task as string;
if (!['unlock', 'check', 'prune', 'stats', 'repair-index', 'repair-snapshots'].includes(task)) {
return json({ error: `Invalid task: ${task}` }, { status: 400 });
}
const dest = await getBackupDestination(destId);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
// Repo maintenance (prune/check/repair) is destructive and operates on the
// shared repo. Destinations aren't environment-scoped, so gate on every
// environment whose configs use this destination: an enterprise user scoped
// to one env must not run maintenance on a repo shared with another env's data.
if (auth.isEnterprise) {
const configs = await getBackupConfigs();
const envIds = [...new Set(
configs.filter((c: any) => c.destinationId === destId && c.environmentId).map((c: any) => c.environmentId as number)
)];
for (const envId of envIds) {
if (!await auth.canAccessEnvironment(envId)) {
return json({ error: 'Access denied to an environment using this destination' }, { status: 403 });
}
}
}
try {
// `task` is validated against the allowed list above.
const result = await runRepoTask(destId, task as import('$lib/server/backups').RepoTask);
// 'prune' and 'check' map to canonical audit actions; the rest land on
// 'update' so they're still recoverable from the log.
const action = task === 'prune' ? 'prune' : task === 'check' ? 'verify' : 'update';
// runRepoTask can resolve with { success: false, error } WITHOUT throwing
// (e.g. repo unreachable / needs init). Derive the audited success and
// error from the result rather than hardcoding success (audit #53).
await auditBackupDestination(event, action, destId, dest.name, { task, success: result.success, error: result.error });
return json(result, { status: result.success ? 200 : 500 });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
await auditBackupDestination(event, 'update', destId, dest.name, { task, success: false, error: msg });
return json({ error: msg }, { status: 500 });
}
};
@@ -0,0 +1,33 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
getBackupDestination,
updateBackupDestinationTestStatus
} from '$lib/server/db';
import { testRepository } from '$lib/server/backups';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
const destination = await getBackupDestination(id);
if (!destination) return json({ error: 'Destination not found' }, { status: 404 });
const result = await testRepository(id);
if (result.ok) {
await updateBackupDestinationTestStatus(id, 'success');
return json({ success: true, status: 'success' });
}
if (result.needsInit) {
await updateBackupDestinationTestStatus(id, 'needs_init', result.error);
return json({ success: false, status: 'needs_init', error: result.error });
}
await updateBackupDestinationTestStatus(id, 'failed', result.error);
return json({ success: false, status: 'failed', error: result.error });
};
@@ -0,0 +1,43 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackupDestination } from '$lib/server/audit';
import { getBackupDestination } from '$lib/server/db';
import { verifyBackup } from '$lib/server/backups';
import { createJobResponse } from '$lib/server/sse';
export const POST: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const destinationId = parseInt(params.id);
if (isNaN(destinationId)) return json({ error: 'Invalid destination ID' }, { status: 400 });
const body = await request.json().catch(() => ({}));
const dataSubset = body.dataSubset || '5%';
const dest = await getBackupDestination(destinationId);
if (dest) {
await auditBackupDestination(event, 'verify', destinationId, dest.name, { dataSubset });
}
return createJobResponse(async (send) => {
send('progress', { message: `Verifying backup integrity (reading ${dataSubset} of data)...` });
const result = await verifyBackup(destinationId, {
dataSubset,
onProgress: (message) => {
send('progress', { message });
}
});
send('result', result);
if (!result.success) {
throw new Error(result.error || 'Verification failed');
}
}, request);
};
@@ -0,0 +1,96 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getBackupDestination, decryptBackupDestination, updateBackupDestinationTestStatus } from '$lib/server/db';
import { spawn } from 'child_process';
import { buildResticEnv, cleanErrorMsg, isRepoNotInitializedError, RESTIC_EXIT_REPO_NOT_FOUND, validateRepositoryForSave } from '$lib/server/backups/helpers';
/**
* Test a backup destination configuration.
* - With `destinationId`: uses saved credentials from DB
* - Without: uses inline credentials from request body (for testing before save)
*/
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const body = await request.json();
let repository: string;
let password: string;
let envVars: Record<string, string> = {};
if (body.destinationId) {
// Use saved destination from DB
const dest = await getBackupDestination(body.destinationId);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
const decrypted = decryptBackupDestination(dest);
repository = dest.repository;
password = decrypted.decryptedPassword;
envVars = decrypted.decryptedEnvVars;
} else {
// Inline test (before save)
if (!body.repository || !body.password) {
return json({ error: 'Repository and password are required' }, { status: 400 });
}
// Reject unsupported backends AND SSRF-dangerous hosts (loopback/metadata)
// before spawning restic — same guard the save path uses, so an inline test
// can't probe an internal host that a saved destination couldn't reach.
const repoError = validateRepositoryForSave(body.repository);
if (repoError) {
return json({ error: repoError }, { status: 400 });
}
repository = body.repository;
password = body.password;
if (body.envVars && typeof body.envVars === 'object') {
for (const [key, value] of Object.entries(body.envVars)) {
if (typeof value === 'string') envVars[key] = value;
}
}
}
const env = buildResticEnv(process.env, { repository, password, envVars });
try {
const result = await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
const proc = spawn('restic', ['snapshots', '--json', '--no-lock', '--latest', '1'], { env, timeout: 30000 });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (d) => { stdout += d; });
proc.stderr.on('data', (d) => { stderr += d; });
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
proc.on('error', (err) => resolve({ code: 1, stdout: '', stderr: err.message }));
});
if (result.code === 0) {
if (body.destinationId) await updateBackupDestinationTestStatus(body.destinationId, 'success');
return json({ success: true });
}
// Detect the repo-not-initialized case (audit #24): restic exits 10, or
// stderr matches the "no repository" family of messages. Return a distinct,
// friendlier status so the UI can offer to initialize.
const rawStderr = result.stderr.trim();
const notInitialized =
result.code === RESTIC_EXIT_REPO_NOT_FOUND ||
isRepoNotInitializedError({ exitCode: result.code }) ||
/unable to open (config|repository)|Is there a repository/i.test(rawStderr);
if (notInitialized) {
if (body.destinationId) await updateBackupDestinationTestStatus(body.destinationId, 'needs_init', 'Repository not initialized');
return json({ success: false, needsInit: true, error: 'Repository not initialized' });
}
// Surface the real restic error (audit #8): derive detail from stderr,
// cleaned of embedded JSON/ANSI, falling back to the exit code.
const detail = rawStderr ? cleanErrorMsg(rawStderr) : `restic exited with code ${result.code}`;
if (body.destinationId) await updateBackupDestinationTestStatus(body.destinationId, 'failed', detail);
return json({ success: false, error: detail });
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
if (body.destinationId) await updateBackupDestinationTestStatus(body.destinationId, 'failed', errorMsg);
return json({ success: false, error: errorMsg });
}
};