feat(ext/sw): upload_attachment + download_attachment router handlers
Both popup-only. upload_attachment encrypts via WASM, putBlobs via GitHost (Git Data API fallback for >900 KB), persists the AttachmentRef on the item + manifest summaries. Duplicate uploads (same content = same id from sha256) return the existing ref without a re-upload. download_attachment reads + decrypts and returns plaintext bytes for the popup to wrap in a Blob. 4 new router tests (accept × 2, reject × 2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -732,3 +732,49 @@ describe('get_vault_settings / update_vault_settings', () => {
|
||||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
});
|
||||
|
||||
// --- upload_attachment / download_attachment (γ₁ Task 6) ---
|
||||
|
||||
describe('upload_attachment / download_attachment', () => {
|
||||
it('upload_attachment accepted from popup', async () => {
|
||||
const state = makeState();
|
||||
const result = await route(
|
||||
{ type: 'upload_attachment', itemId: 'abc', filename: 'f.pdf', mimeType: 'application/pdf', bytes: new ArrayBuffer(10) },
|
||||
state,
|
||||
makePopupSender(),
|
||||
);
|
||||
// The handler may return ok: false (vault_locked) since session is not primed,
|
||||
// but the router MUST reach the handler — i.e. not return unauthorized_sender.
|
||||
expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
|
||||
it('upload_attachment rejected from content script', async () => {
|
||||
const state = makeState();
|
||||
const result = await route(
|
||||
{ type: 'upload_attachment', itemId: 'abc', filename: 'f.pdf', mimeType: 'application/pdf', bytes: new ArrayBuffer(10) },
|
||||
state,
|
||||
makeContentSender(),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
|
||||
it('download_attachment accepted from popup', async () => {
|
||||
const state = makeState();
|
||||
const result = await route(
|
||||
{ type: 'download_attachment', itemId: 'abc', attachmentId: 'aid' },
|
||||
state,
|
||||
makePopupSender(),
|
||||
);
|
||||
expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
|
||||
it('download_attachment rejected from content script', async () => {
|
||||
const state = makeState();
|
||||
const result = await route(
|
||||
{ type: 'download_attachment', itemId: 'abc', attachmentId: 'aid' },
|
||||
state,
|
||||
makeContentSender(),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// via sender.url === popup.html (or setup.html for save_setup).
|
||||
|
||||
import type { PopupMessage, Response } from '../../shared/messages';
|
||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig } from '../../shared/types';
|
||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types';
|
||||
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
||||
import type { GitHost } from '../git-host';
|
||||
import { createGitHost, base64ToUint8Array } from '../git-host';
|
||||
@@ -201,6 +201,91 @@ export async function handle(
|
||||
await saveBlacklist(bl.filter((h) => h !== msg.hostname));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'upload_attachment': {
|
||||
try {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
|
||||
// Look up the per-attachment size cap from vault settings (if any).
|
||||
let maxBytes = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
try {
|
||||
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
||||
const cap = settings.attachment_caps?.per_attachment_max_bytes;
|
||||
if (cap !== undefined) maxBytes = BigInt(cap);
|
||||
} catch {
|
||||
// If settings are unavailable, proceed uncapped.
|
||||
}
|
||||
|
||||
const plaintext = new Uint8Array(msg.bytes);
|
||||
const encrypted = state.wasm.attachment_encrypt(handle, plaintext, maxBytes);
|
||||
// encrypted: EncryptedAttachment — exposes .aid (string) and .bytes (Uint8Array)
|
||||
const aid: string = encrypted.aid;
|
||||
const encBytes: Uint8Array = encrypted.bytes;
|
||||
|
||||
// Duplicate-id check: same content hash → same id → no-op re-upload.
|
||||
let existingRef: AttachmentRef | undefined;
|
||||
try {
|
||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.itemId);
|
||||
existingRef = item.attachments.find((a) => a.id === aid);
|
||||
} catch {
|
||||
// Item not yet persisted or not found — will fail at addAttachmentToItem below.
|
||||
}
|
||||
if (existingRef) {
|
||||
return { ok: true, data: { attachment: existingRef } };
|
||||
}
|
||||
|
||||
// Store the encrypted blob.
|
||||
const blobPath = `attachments/${aid}.bin`;
|
||||
await state.gitHost.putBlob(blobPath, encBytes, `attach: ${msg.filename}`);
|
||||
|
||||
// Build the AttachmentRef and append it to the item + manifest.
|
||||
const ref: AttachmentRef = {
|
||||
id: aid,
|
||||
filename: msg.filename,
|
||||
mime_type: msg.mimeType,
|
||||
size: plaintext.length,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await vault.addAttachmentToItem(state.gitHost, handle, msg.itemId, ref);
|
||||
|
||||
return { ok: true, data: { attachment: ref } };
|
||||
} catch (e) {
|
||||
console.error('[relicario] upload_attachment failed', e);
|
||||
return { ok: false, error: 'upload_failed' };
|
||||
}
|
||||
}
|
||||
|
||||
case 'download_attachment': {
|
||||
try {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
|
||||
// Fetch the item to recover the filename + mime type from the AttachmentRef.
|
||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.itemId);
|
||||
const ref = item.attachments.find((a) => a.id === msg.attachmentId);
|
||||
if (!ref) return { ok: false, error: 'attachment_not_found' };
|
||||
|
||||
const blobPath = `attachments/${msg.attachmentId}.bin`;
|
||||
const encBytes = await state.gitHost.getBlob(blobPath);
|
||||
const decrypted = state.wasm.attachment_decrypt(handle, encBytes);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
bytes: decrypted.buffer.slice(
|
||||
decrypted.byteOffset,
|
||||
decrypted.byteOffset + decrypted.byteLength,
|
||||
) as ArrayBuffer,
|
||||
filename: ref.filename,
|
||||
mimeType: ref.mime_type,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('[relicario] download_attachment failed', e);
|
||||
return { ok: false, error: 'download_failed' };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState,
|
||||
DeviceSettings, GeneratorRequest, VaultSettings,
|
||||
DeviceSettings, GeneratorRequest, VaultSettings, AttachmentRef,
|
||||
} from './types';
|
||||
|
||||
// --- Messages a popup (or setup page) may send ---
|
||||
@@ -28,7 +28,9 @@ export type PopupMessage =
|
||||
| { type: 'get_vault_settings' }
|
||||
| { type: 'update_vault_settings'; settings: VaultSettings }
|
||||
| { type: 'get_blacklist' }
|
||||
| { type: 'remove_blacklist'; hostname: string };
|
||||
| { type: 'remove_blacklist'; hostname: string }
|
||||
| { type: 'upload_attachment'; itemId: string; filename: string; mimeType: string; bytes: ArrayBuffer }
|
||||
| { type: 'download_attachment'; itemId: string; attachmentId: string };
|
||||
|
||||
// --- Messages a content script may send ---
|
||||
|
||||
@@ -95,6 +97,14 @@ export interface VaultSettingsResponse extends Extract<Response, { ok: true }> {
|
||||
data: { settings: VaultSettings };
|
||||
}
|
||||
|
||||
export interface UploadAttachmentResponse extends Extract<Response, { ok: true }> {
|
||||
data: { attachment: AttachmentRef };
|
||||
}
|
||||
|
||||
export interface DownloadAttachmentResponse extends Extract<Response, { ok: true }> {
|
||||
data: { bytes: ArrayBuffer; filename: string; mimeType: string };
|
||||
}
|
||||
|
||||
// --- Capability sets (consumed by the router) ---
|
||||
|
||||
export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
||||
@@ -104,7 +114,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
||||
'fill_credentials',
|
||||
'ack_autofill_origin', 'get_settings', 'update_settings',
|
||||
'get_vault_settings', 'update_vault_settings', 'get_blacklist',
|
||||
'remove_blacklist',
|
||||
'remove_blacklist', 'upload_attachment', 'download_attachment',
|
||||
] as PopupMessage['type'][]);
|
||||
|
||||
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
|
||||
|
||||
Reference in New Issue
Block a user