feat: add backup integrity verification

This commit is contained in:
2026-07-14 16:05:28 +03:00
parent c3ad0afc17
commit 9b3cc54646
31 changed files with 1198 additions and 2462 deletions

View File

@@ -11,6 +11,14 @@
@UX_FEEDBACK Error toasts on failure (creation, schedule update, data load).
@UX_RECOVERY Request timeout (30s) via AbortSignal; schedule optimistic update rollback.
@UX_RECOVERY Auto-opens TaskDrawer on backup task creation.
@UX_STATE VerificationIdle -> Backup files show neutral integrity state until checked.
@UX_STATE VerificationLoading -> Verify action is busy and details panel shows progress.
@UX_STATE VerificationSuccess -> Details panel shows archive and content hash matches.
@UX_STATE VerificationError -> Restore-sensitive action remains unavailable and recovery is shown.
@UX_FEEDBACK Verification result is shown inline and announced through aria-live.
@UX_RECOVERY Retry verification or create a new backup when metadata is missing/corrupt.
@UX_REACTIVITY Props -> $props(), LocalState -> $state(), Derived -> $derived().
@UX_TEST: VerificationIdle -> {click: "Verify", expected: VerificationLoading -> VerificationSuccess}.
@RATIONALE FileList replaced custom BackupList — shared component provides download, delete,
bulk actions, search, sort, and pagination out of the box.
-->
@@ -22,12 +30,25 @@
import { addToast } from '$lib/toasts.svelte.js';
import { openDrawerForTaskIfPreferred } from '$lib/stores/taskDrawer.svelte.js';
import { Button, Card, Select, Input, ConfirmDialog } from '$lib/ui';
import { listFiles, deleteFile } from '../../../services/storageService';
import { listFiles, deleteFile, verifyBackup } from '../../../services/storageService';
import FileList from '$lib/components/storage/FileList.svelte';
import { appTimezone } from '$lib/stores/timezone.svelte.js';
import {
normalizeBackupIntegrityResult,
type BackupIntegrityResult,
type BackupIntegrityStatus,
} from '../../../types/backup';
// ── State ───────────────────────────────────────────────────────
let files: any[] = $state([]);
type BackupFile = {
name: string;
path: string;
category: string;
size: number;
created_at: string;
mime_type: string;
};
let files: BackupFile[] = $state([]);
let environments: any[] = $state([]);
let selectedEnvId = $state('');
let loading = $state(true);
@@ -36,6 +57,8 @@
let currentPath = $state('backups');
let cronNextRun = $state<string | null>(null);
let cronError = $state('');
let verifyingPath = $state<string | null>(null);
let selectedIntegrity = $state<{ file: BackupFile; result: BackupIntegrityResult } | null>(null);
// Schedule state
let scheduleEnabled = $state(false);
@@ -111,7 +134,7 @@
}
} catch { /* env context not available */ }
}
files = storageData || [];
files = (storageData || []).filter((file: BackupFile) => !file.name.endsWith('.manifest.json'));
log("BackupManager", "REFLECT", "Data loaded", { count: files.length });
} catch (error: any) {
log("BackupManager", "EXPLORE", "Load failed", {}, error?.message || 'Unknown');
@@ -125,6 +148,54 @@
}
}
// #region verifyIntegrity [C:3] [TYPE Function] [SEMANTICS backup,verify,integrity,hash]
// @ingroup Components
// @BRIEF Verify one backup archive server-side and expose safe hash details.
// @PRE file identifies a stored backup archive.
// @POST selectedIntegrity contains the latest server verification result.
// @SIDE_EFFECT Calls the authenticated storage verification API and updates UI state.
// @RELATION CALLS -> [verifyBackup]
async function handleVerify(file: BackupFile): Promise<void> {
if (verifyingPath) return;
log("BackupManager", "REASON", "Verifying backup integrity", { path: file.path });
verifyingPath = file.path;
try {
const raw = await verifyBackup(file.category, file.path);
const result = normalizeBackupIntegrityResult(raw);
selectedIntegrity = { file, result };
log("BackupManager", "REFLECT", "Backup integrity verified", { path: file.path, status: result.status });
addToast(statusLabel(result.status), result.status === 'ok' || result.status === 'verified' ? 'success' : 'error');
} catch (error: unknown) {
log("BackupManager", "EXPLORE", "Backup integrity verification failed", { path: file.path }, error instanceof Error ? error.message : 'Unknown error');
selectedIntegrity = { file, result: { status: 'unknown', error: error instanceof Error ? error.message : 'Unknown error' } };
addToast($t.storage.integrity.verify_failed, 'error');
} finally {
verifyingPath = null;
}
}
// #endregion verifyIntegrity
function shortHash(value: unknown): string {
const hash = typeof value === 'string' ? value : '';
return hash ? `${hash.slice(0, 12)}…` : '—';
}
function statusLabel(status: BackupIntegrityStatus | 'ok' | undefined): string {
if (status === 'ok' || status === 'verified') return $t.storage.integrity.verified;
if (status === 'manifest_missing') return $t.storage.integrity.missing;
if (status === 'manifest_corrupted') return $t.storage.integrity.corrupted;
if (status === 'integrity_violated') return $t.storage.integrity.violated;
if (status === 'metadata_failed') return $t.storage.integrity.failed;
return $t.storage.integrity.unchecked;
}
function statusClass(status: BackupIntegrityStatus | 'ok' | undefined): string {
if (status === 'ok' || status === 'verified') return 'bg-success-light text-success border-success-ring';
if (status === 'manifest_corrupted' || status === 'integrity_violated') return 'bg-destructive-light text-destructive border-destructive-ring';
if (status === 'manifest_missing' || status === 'metadata_failed') return 'bg-warning-light text-warning border-warning-ring';
return 'bg-surface-muted text-text-muted border-border';
}
// ── Backup trigger ──────────────────────────────────────────────
async function handleCreateBackup() {
if (!selectedEnvId) { addToast($t.tasks.select_env, 'error'); return; }
@@ -299,16 +370,60 @@
<div class="space-y-3">
<h2 class="text-lg font-semibold text-text">{$t.storage.backups}</h2>
<FileList
<FileList
{files}
{currentPath}
{loading}
ondelete={handleDelete}
onnavigate={handleNavigate}
onnavigateup={handleNavigateUp}
onbulkdelete={handleBulkDelete}
/>
</div>
onbulkdelete={handleBulkDelete}
onverify={handleVerify}
showVerify={true}
{verifyingPath}
/>
{#if selectedIntegrity}
<Card title={$t.storage.integrity.details_title}>
<div class="space-y-3" aria-live="polite">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="font-medium text-text">{selectedIntegrity.file.name}</p>
<p class="text-xs text-text-muted">{selectedIntegrity.file.path}</p>
</div>
<span class={`rounded-full border px-2.5 py-1 text-xs font-medium ${statusClass(selectedIntegrity.result.status)}`}>
{statusLabel(selectedIntegrity.result.status)}
</span>
</div>
<dl class="grid gap-2 text-sm sm:grid-cols-2">
<div>
<dt class="text-text-muted">{$t.storage.integrity.archive_hash}</dt>
<dd class="font-mono text-text" title={selectedIntegrity.result.actual_sha256 || selectedIntegrity.result.archive_sha256 || ''}>
{shortHash(selectedIntegrity.result.actual_sha256 || selectedIntegrity.result.archive_sha256)}
</dd>
</div>
<div>
<dt class="text-text-muted">{$t.storage.integrity.content_hash}</dt>
<dd class="font-mono text-text" title={selectedIntegrity.result.actual_content_hash || selectedIntegrity.result.content_hash || ''}>
{shortHash(selectedIntegrity.result.actual_content_hash || selectedIntegrity.result.content_hash)}
</dd>
</div>
</dl>
{#if selectedIntegrity.result.error}
<p class="rounded-md border border-warning-ring bg-warning-light px-3 py-2 text-sm text-warning">
{selectedIntegrity.result.error}
</p>
{/if}
<div class="flex flex-wrap items-center gap-2">
<Button size="sm" variant="secondary" onclick={() => handleVerify(selectedIntegrity!.file)} disabled={Boolean(verifyingPath)} aria-busy={verifyingPath === selectedIntegrity.file.path}>
{verifyingPath === selectedIntegrity.file.path ? $t.storage.integrity.verifying : $t.storage.integrity.retry}
</Button>
<p class="text-xs text-text-muted">{$t.storage.integrity.restore_hint}</p>
</div>
</div>
</Card>
{/if}
</div>
</div>
<ConfirmDialog

View File

@@ -14,6 +14,9 @@
<!-- @UX_FEEDBACK Pagination "Showing X-Y of Z" -->
<!-- @UX_FEEDBACK Search input filters in real-time -->
<!-- @UX_RECOVERY Clear search to restore full list -->
<!-- @UX_STATE Verifying -> Verify action disabled and aria-busy=true. -->
<!-- @UX_FEEDBACK Verification result is rendered as a status badge in the parent. -->
<!-- @UX_TEST: Table -> {click: "verify", expected: Parent receives file metadata}. -->
<script lang="ts">
import { downloadFile } from '../../../services/storageService.js';
import { t } from '$lib/i18n/index.svelte.js';
@@ -39,6 +42,9 @@
onnavigate = (_path: string) => {},
onnavigateup = () => {},
onbulkdelete = (_files: FileEntry[]) => {},
onverify = (_file: FileEntry) => {},
showVerify = false,
verifyingPath = null as string | null,
} = $props();
// ── Helpers ─────────────────────────────────────────────────────
@@ -324,6 +330,19 @@
<Button size="sm" variant="ghost" onclick={() => handleDownload(file)}>
{$t.storage.table.download || "Download"}
</Button>
{#if showVerify}
<Button
size="sm"
variant="ghost"
onclick={() => onverify(file)}
disabled={Boolean(verifyingPath)}
aria-busy={verifyingPath === file.path}
>
{verifyingPath === file.path
? $t.storage.integrity.verifying
: $t.storage.verify}
</Button>
{/if}
{/if}
<Button size="sm" variant="ghost"
onclick={() => ondelete({ category: file.category, path: file.path, name: file.name })}>

View File

@@ -14,6 +14,22 @@
"drag_drop": "or drag and drop",
"supported_formats": "ZIP, YAML, JSON up to 50MB",
"uploading": "Uploading...",
"verify": "Verify",
"integrity": {
"details_title": "Backup integrity",
"archive_hash": "Archive SHA-256",
"content_hash": "Content hash",
"verified": "Verified",
"missing": "Verification required",
"corrupted": "Manifest corrupted",
"violated": "Integrity check failed",
"failed": "Metadata unavailable",
"unchecked": "Not checked",
"verifying": "Verifying...",
"retry": "Check again",
"verify_failed": "Backup verification failed.",
"restore_hint": "Restore is safe only after a successful verification."
},
"table": {
"name": "Name",
"category": "Category",

View File

@@ -14,6 +14,22 @@
"drag_drop": "или перетащите сюда",
"supported_formats": "ZIP, YAML, JSON до 50МБ",
"uploading": "Загрузка...",
"verify": "Проверить",
"integrity": {
"details_title": "Целостность бэкапа",
"archive_hash": "SHA-256 архива",
"content_hash": "Хеш содержимого",
"verified": "Проверен",
"missing": "Требуется проверка",
"corrupted": "Manifest повреждён",
"violated": "Целостность нарушена",
"failed": "Метаданные недоступны",
"unchecked": "Не проверен",
"verifying": "Проверка...",
"retry": "Проверить снова",
"verify_failed": "Не удалось проверить бэкап.",
"restore_hint": "Восстановление безопасно только после успешной проверки."
},
"table": {
"name": "Имя",
"category": "Категория",

View File

@@ -33,6 +33,7 @@ import {
getAssistantHistory,
deleteAssistantConversation,
} from "$lib/api/assistant.js";
import { fetchApi } from "$lib/api";
import { addToast } from "$lib/toasts.svelte.js";
import { log } from "$lib/cot-logger";
import { t } from "$lib/i18n/index.svelte.js";
@@ -923,14 +924,13 @@ export class AgentChatModel {
/** Check LLM provider connectivity via backend endpoint. */
async checkLlmStatus(): Promise<void> {
try {
const resp = await fetch("/api/agent/llm-status");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
this.llmStatus = data.status || "unknown";
if (data.status !== "ok" && !this.llmBannerDismissed) {
this.llmBannerMessage = this._bannerMessageForStatus(data.status);
this._startRetryCountdown(data.retry_after_s || 30);
} else if (data.status === "ok") {
const data = await fetchApi<Record<string, unknown>>("/agent/llm-status", { suppressToast: true });
const status = typeof data.status === "string" ? data.status : "unknown";
this.llmStatus = status;
if (status !== "ok" && !this.llmBannerDismissed) {
this.llmBannerMessage = this._bannerMessageForStatus(status);
this._startRetryCountdown(typeof data.retry_after_s === "number" ? data.retry_after_s : 30);
} else if (status === "ok") {
this.llmBannerDismissed = false;
this.llmRetryCountdown = 0;
this.llmBannerMessage = "";

View File

@@ -27,6 +27,10 @@ vi.mock("$lib/api/assistant.js", () => ({
deleteAssistantConversation: vi.fn().mockResolvedValue({ deleted: true }),
}));
vi.mock("$lib/api", () => ({
fetchApi: vi.fn(),
}));
// Mock $lib/stores/assistantChat.svelte.js
vi.mock("$lib/stores/assistantChat.svelte.js", () => ({
assistantChatStore: { value: { isOpen: false, conversationId: null } },
@@ -44,6 +48,7 @@ vi.mock("$lib/cot-logger", () => ({
}));
import { AgentChatModel } from "../AgentChatModel.svelte.ts";
import { fetchApi } from "$lib/api";
// #region TestAgentChat.Model.StateMachine [C:2] [TYPE Function] [SEMANTICS test,model,state]
// @BRIEF State transition tests: idle->streaming, streaming->idle, awaiting_confirmation->idle.
@@ -1209,13 +1214,13 @@ describe("AgentChatModel — LLM Status", () => {
});
it("checkLlmStatus handles fetch failure", async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error("Network fail"));
vi.mocked(fetchApi).mockRejectedValue(new Error("Network fail"));
await model.checkLlmStatus();
expect(model.llmStatus).toBe("unknown");
});
it("checkLlmStatus handles HTTP error status", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });
vi.mocked(fetchApi).mockRejectedValue(new Error("HTTP 503"));
await model.checkLlmStatus();
expect(model.llmStatus).toBe("unknown");
});
@@ -1224,10 +1229,7 @@ describe("AgentChatModel — LLM Status", () => {
model.llmBannerDismissed = true;
model.llmRetryCountdown = 10;
model.llmBannerMessage = "old error";
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ status: "ok" }),
});
vi.mocked(fetchApi).mockResolvedValue({ status: "ok" });
await model.checkLlmStatus();
expect(model.llmStatus).toBe("ok");
expect(model.llmBannerDismissed).toBe(false);
@@ -1237,10 +1239,7 @@ describe("AgentChatModel — LLM Status", () => {
it("checkLlmStatus sets banner for non-ok status", async () => {
model.llmBannerDismissed = false;
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ status: "timeout", retry_after_s: 30 }),
});
vi.mocked(fetchApi).mockResolvedValue({ status: "timeout", retry_after_s: 30 });
const bannerSpy = vi.spyOn(model as any, "_bannerMessageForStatus").mockReturnValue("LLM timeout banner");
await model.checkLlmStatus();
expect(model.llmBannerMessage).toBe("LLM timeout banner");

View File

@@ -67,6 +67,7 @@
});
let isLoginPage = $derived(page.url.pathname === '/login');
let isAgentPage = $derived(page.url.pathname === '/agent');
let isExpanded = $derived(sidebarStore.value?.isExpanded ?? true);
let isProductionContext = $derived($isProductionContextStore);
let selectedEnvironment = $derived($selectedEnvironmentStore);
@@ -117,7 +118,9 @@
<!-- Global Task Drawer -->
<TaskDrawer />
<AssistantChatPanel />
{#if !isAgentPage}
<AssistantChatPanel />
{/if}
</ProtectedRoute>
{/if}
</main>

View File

@@ -3,6 +3,11 @@
// @LAYER Service
// @RELATION DEPENDS_ON -> [ApiModule]
import { deleteApi, fetchApi, fetchApiBlob, requestApi, uploadFile as uploadApiFile } from '$lib/api';
import type { BackupIntegrityApiResponse } from '../types/backup';
const API_BASE = '/api/storage';
// #region storageService:Module [TYPE Function]
// @PURPOSE: Default purpose
// @RELATION USES -> [EXT:frontend:App]
@@ -27,31 +32,20 @@ interface UploadResponse {
[key: string]: unknown;
}
interface ErrorDetail {
detail?: string;
[key: string]: unknown;
// #region verifyBackup [C:2] [TYPE Function] [SEMANTICS backup,verify,integrity]
// @BRIEF Verify a backup archive through the authenticated storage API.
// @PRE category and path identify a stored archive.
// @POST Returns server-side archive and semantic hash comparison.
// @RELATION DEPENDS_ON -> [StorageApi]
export async function verifyBackup(category: string, path: string): Promise<BackupIntegrityApiResponse> {
return requestApi<BackupIntegrityApiResponse>(
`/storage/verify/${encodeURIComponent(category)}/${encodeStoragePath(path)}`,
'GET',
null,
{ suppressToast: true },
);
}
const API_BASE = '/api/storage';
// #region getStorageAuthHeaders:Function [TYPE Function]
/**
* @purpose Returns headers with Authorization for storage API calls.
* @returns Headers object with Authorization if token exists.
* @NOTE Unlike api.js getAuthHeaders, this doesn't set Content-Type
* to allow FormData to set its own multipart boundary.
*/
function getStorageAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
}
// #endregion getStorageAuthHeaders:Function
// #endregion verifyBackup
// #region encodeStoragePath:Function [TYPE Function]
/**
@@ -85,13 +79,7 @@ export async function listFiles(category?: string, path?: string): Promise<Store
if (path) {
params.append('path', path);
}
const response = await fetch(`${API_BASE}/files?${params.toString()}`, {
headers: getStorageAuthHeaders()
});
if (!response.ok) {
throw new Error(`Failed to fetch files: ${response.statusText}`);
}
return await response.json();
return fetchApi<StoredFile[]>(`/storage/files?${params.toString()}`);
}
// #endregion listFiles:Function
@@ -106,24 +94,10 @@ export async function listFiles(category?: string, path?: string): Promise<Store
* @POST Returns a promise resolving to the metadata of the uploaded file.
*/
export async function uploadFile(file: File, category: string, path?: string): Promise<UploadResponse> {
const formData = new FormData();
formData.append('file', file);
formData.append('category', category);
if (path) {
formData.append('path', path);
}
const response = await fetch(`${API_BASE}/upload`, {
method: 'POST',
headers: getStorageAuthHeaders(),
body: formData
return uploadApiFile<UploadResponse>('/storage/upload', file, {
category,
...(path ? { path } : {}),
});
if (!response.ok) {
const errorData: ErrorDetail = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to upload file: ${response.statusText}`);
}
return await response.json();
}
// #endregion uploadFile:Function
@@ -137,15 +111,7 @@ export async function uploadFile(file: File, category: string, path?: string): P
* @POST The specified file or directory is removed from storage.
*/
export async function deleteFile(category: string, path: string): Promise<void> {
const response = await fetch(`${API_BASE}/files/${category}/${path}`, {
method: 'DELETE',
headers: getStorageAuthHeaders()
});
if (!response.ok) {
const errorData: ErrorDetail = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to delete: ${response.statusText}`);
}
await deleteApi(`/storage/files/${encodeURIComponent(category)}/${encodeStoragePath(path)}`);
}
// #endregion deleteFile:Function
@@ -178,16 +144,7 @@ export function downloadFileUrl(category: string, path: string): string {
* @POST Browser download is triggered or an Error is thrown.
*/
export async function downloadFile(category: string, path: string, filename?: string): Promise<void> {
const response = await fetch(downloadFileUrl(category, path), {
headers: getStorageAuthHeaders(),
});
if (!response.ok) {
const errorData: ErrorDetail = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to download file: ${response.statusText}`);
}
const blob = await response.blob();
const blob = await fetchApiBlob(`/storage/download/${encodeURIComponent(category)}/${encodeStoragePath(path)}`);
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;

View File

@@ -0,0 +1,81 @@
// #region Test.BackupTypes [C:2] [TYPE Module] [SEMANTICS test,backup,integrity,normalization]
// @BRIEF Tests for backup integrity DTO normalization at the frontend API boundary.
// @RELATION BINDS_TO -> [BackupTypes]
// @TEST_INVARIANT Known backend statuses are preserved without trusting untyped payloads.
// @TEST_INVARIANT Unknown or malformed statuses normalize to unknown.
// @TEST_EDGE: errors_array -> String errors are joined; non-string entries are ignored.
import { describe, expect, it } from 'vitest';
import { normalizeBackupIntegrityResult } from '../backup';
describe('normalizeBackupIntegrityResult', () => {
it('maps a successful backend response to the frontend DTO', () => {
expect(normalizeBackupIntegrityResult({
status: 'ok',
manifest_sha256: 'manifest-archive-hash',
actual_sha256: 'actual-archive-hash',
manifest_content_hash: 'manifest-content-hash',
actual_content_hash: 'actual-content-hash',
manifest: 'backup.manifest.json',
})).toEqual({
status: 'ok',
archive_sha256: 'manifest-archive-hash',
actual_sha256: 'actual-archive-hash',
content_hash: 'manifest-content-hash',
actual_content_hash: 'actual-content-hash',
manifest_path: 'backup.manifest.json',
error: null,
});
});
it('preserves every supported verification status', () => {
const statuses = [
'verified',
'manifest_missing',
'manifest_corrupted',
'integrity_violated',
'metadata_failed',
'no_archive',
'unknown',
] as const;
for (const status of statuses) {
expect(normalizeBackupIntegrityResult({ status }).status).toBe(status);
}
});
it('normalizes malformed payloads and joins backend errors safely', () => {
expect(normalizeBackupIntegrityResult({
status: 'unexpected',
archive_sha256: 123,
errors: ['archive_sha256_mismatch', 42, 'content_hash_mismatch'],
})).toEqual({
status: 'unknown',
archive_sha256: null,
actual_sha256: null,
content_hash: null,
actual_content_hash: null,
manifest_path: null,
error: 'archive_sha256_mismatch, content_hash_mismatch',
});
});
it('accepts the canonical field names when manifest aliases are absent', () => {
expect(normalizeBackupIntegrityResult({
status: 'verified',
archive_sha256: 'archive-hash',
content_hash: 'content-hash',
manifest_path: 'manifest.json',
error: 'already checked',
})).toEqual({
status: 'verified',
archive_sha256: 'archive-hash',
actual_sha256: null,
content_hash: 'content-hash',
actual_content_hash: null,
manifest_path: 'manifest.json',
error: 'already checked',
});
});
});
// #endregion Test.BackupTypes

View File

@@ -45,7 +45,89 @@ export interface BackupTaskResult {
total_dashboards: number;
backed_up_dashboards: number;
failed_dashboards: number;
dashboards: Array<{ id: number; title: string; path: string }>;
dashboards: Array<{
id: number;
title: string;
path: string;
archive_sha256?: string | null;
content_hash?: string | null;
integrity_status?: BackupIntegrityStatus;
manifest_path?: string | null;
}>;
failures: Array<{ id: number; title: string; error: string }>;
}
export type BackupIntegrityStatus =
| 'ok'
| 'verified'
| 'manifest_missing'
| 'manifest_corrupted'
| 'integrity_violated'
| 'metadata_failed'
| 'no_archive'
| 'unknown';
export interface BackupIntegrityResult {
status: BackupIntegrityStatus;
archive_sha256?: string | null;
actual_sha256?: string | null;
content_hash?: string | null;
actual_content_hash?: string | null;
manifest_path?: string | null;
error?: string | null;
}
export interface BackupIntegrityApiResponse {
status?: unknown;
manifest_sha256?: unknown;
archive_sha256?: unknown;
actual_sha256?: unknown;
manifest_content_hash?: unknown;
content_hash?: unknown;
actual_content_hash?: unknown;
manifest?: unknown;
manifest_path?: unknown;
error?: unknown;
errors?: unknown;
}
// #region normalizeBackupIntegrityResult [C:2] [TYPE Function] [SEMANTICS backup,verify,integrity,normalization]
// @BRIEF Normalize an untrusted verification response into the frontend integrity DTO.
// @PRE raw may contain an unknown backend response shape.
// @POST Returns a typed result with a safe status and nullable hash/error fields.
// @DATA_CONTRACT Input: unknown API payload -> Output: BackupIntegrityResult
export function isBackupIntegrityStatus(value: unknown): value is BackupIntegrityStatus {
return [
'ok',
'verified',
'manifest_missing',
'manifest_corrupted',
'integrity_violated',
'metadata_failed',
'no_archive',
'unknown',
].includes(String(value));
}
function readString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
export function normalizeBackupIntegrityResult(raw: unknown): BackupIntegrityResult {
const payload = (raw && typeof raw === 'object' ? raw : {}) as BackupIntegrityApiResponse;
const errors = Array.isArray(payload.errors)
? payload.errors.filter((item): item is string => typeof item === 'string').join(', ')
: null;
return {
status: isBackupIntegrityStatus(payload.status) ? payload.status : 'unknown',
archive_sha256: readString(payload.manifest_sha256) ?? readString(payload.archive_sha256),
actual_sha256: readString(payload.actual_sha256),
content_hash: readString(payload.manifest_content_hash) ?? readString(payload.content_hash),
actual_content_hash: readString(payload.actual_content_hash),
manifest_path: readString(payload.manifest) ?? readString(payload.manifest_path),
error: readString(payload.error) ?? errors,
};
}
// #endregion normalizeBackupIntegrityResult
// #endregion BackupTypes