fix(fleet): isolate corrupt snapshot file decrypt failures (#1650)

* fix(fleet): isolate corrupt snapshot file decrypt failures

A single damaged encrypted fleet-snapshot row no longer fails detail, restore, or off-site upload for the whole snapshot. Unavailable members are marked, restore is blocked before mutation, and cloud upload fails closed with no PutObject.

* fix(fleet): fail closed on damaged enc snapshot envelopes

Unrecognized enc: payloads no longer fall through as usable plaintext. Only clear legacy prose stays readable; delimiter-byte and similar envelope damage stays unavailable through restore and cloud upload.

* fix(fleet): subordinate legacy enc prose to envelope shape

Legacy exceptions no longer trigger from = or whitespace alone. Encryption-shaped payloads (length and hex density) stay unavailable through restore and cloud upload, while short genuine prose such as enc:hello remains usable.

* fix(fleet): preserve non-envelope enc legacy plaintext

Any non-empty enc: payload that is not encryption-shaped is kept verbatim, including punctuation forms such as enc:hello-world, while envelope-shaped damage remains unavailable.
This commit is contained in:
Anso
2026-07-19 20:08:31 -04:00
committed by GitHub
parent d94e586af3
commit 3b027957c4
11 changed files with 935 additions and 48 deletions
@@ -260,6 +260,72 @@ describe('CloudBackupService — uploadSnapshot', () => {
expect(status.error).toContain('bad creds');
});
it('fails closed with no PutObject when a snapshot file is unavailable', async () => {
seedCustomProvider();
const db = DatabaseService.getInstance();
const snapshotId = db.createSnapshot('Corrupt member', 'admin', 1, 1, '[]');
const cipher = CryptoService.getInstance().encrypt('services: {}\n');
const payload = cipher.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(snapshotId, 1, 'gateway', 'web', 'compose.yaml', `enc:${iv}:${tag}:${ct.slice(0, 3)}`);
sentSpy.mockClear();
await expect(CloudBackupService.getInstance().uploadSnapshot(snapshotId)).rejects.toThrow(/could not be decrypted/i);
const putCalls = sentSpy.mock.calls.filter(c => c[0].name === 'PutObjectCommand');
expect(putCalls).toHaveLength(0);
const status = CloudBackupService.getInstance().getUploadStatus(snapshotId);
expect(status.status).toBe('failed');
expect(status.error).toMatch(/could not be decrypted/i);
});
it('fails closed with no PutObject when a healthy sibling file exists alongside an unavailable one', async () => {
seedCustomProvider();
const db = DatabaseService.getInstance();
const snapshotId = db.createSnapshot('Mixed members', 'admin', 1, 1, '[]');
const good = CryptoService.getInstance().encrypt('services: { ok: {} }\n');
const bad = CryptoService.getInstance().encrypt('SECRET=long-enough-to-truncate\n');
const payload = bad.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(snapshotId, 1, 'gateway', 'web', 'compose.yaml', good);
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(snapshotId, 1, 'gateway', 'web', '.env', `enc:${iv}:${tag}:${ct.slice(0, 3)}`);
sentSpy.mockClear();
await expect(CloudBackupService.getInstance().uploadSnapshot(snapshotId)).rejects.toThrow(/could not be decrypted/i);
const putCalls = sentSpy.mock.calls.filter(c => c[0].name === 'PutObjectCommand');
expect(putCalls).toHaveLength(0);
const status = CloudBackupService.getInstance().getUploadStatus(snapshotId);
expect(status.status).toBe('failed');
expect(status.error).toMatch(/could not be decrypted/i);
});
it('fails closed with no PutObject when a delimiter byte is corrupted', async () => {
seedCustomProvider();
const db = DatabaseService.getInstance();
const snapshotId = db.createSnapshot('Delim damage', 'admin', 1, 1, '[]');
const cipher = CryptoService.getInstance().encrypt('services: { ok: {} }\n');
const payload = cipher.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(snapshotId, 1, 'gateway', 'web', 'compose.yaml', `enc:${iv}=${tag}:${ct}`);
sentSpy.mockClear();
await expect(CloudBackupService.getInstance().uploadSnapshot(snapshotId)).rejects.toThrow(/could not be decrypted/i);
const putCalls = sentSpy.mock.calls.filter(c => c[0].name === 'PutObjectCommand');
expect(putCalls).toHaveLength(0);
const status = CloudBackupService.getInstance().getUploadStatus(snapshotId);
expect(status.status).toBe('failed');
});
it('throws when no provider is configured', async () => {
await expect(CloudBackupService.getInstance().uploadSnapshot(999)).rejects.toThrow(/not configured/i);
});
@@ -101,14 +101,16 @@ describe('Snapshot content-at-rest encryption', () => {
expect(raw.content).not.toContain('s3cr3t');
const env = db.getSnapshotFiles(snapshotId).find(f => f.filename === '.env');
expect(env?.content).toBe(ENV_SECRET);
expect(env?.available).toBe(true);
if (env?.available) expect(env.content).toBe(ENV_SECRET);
});
it('decrypts content on the restore read path (getSnapshotStackFiles)', () => {
const db = DatabaseService.getInstance();
const files = db.getSnapshotStackFiles(snapshotId, 1, 'web');
const env = files.find(f => f.filename === '.env');
expect(env?.content).toBe(ENV_SECRET);
expect(env?.available).toBe(true);
if (env?.available) expect(env.content).toBe(ENV_SECRET);
});
it('reads a legacy plaintext row back verbatim (decrypt tolerates non-ciphertext)', () => {
@@ -121,7 +123,57 @@ describe('Snapshot content-at-rest encryption', () => {
).run(legacyId, 1, 'local', 'legacy', 'compose.yaml', 'plain: text\n');
const files = db.getSnapshotFiles(legacyId);
expect(files[0].content).toBe('plain: text\n');
expect(files[0].available).toBe(true);
if (files[0].available) expect(files[0].content).toBe('plain: text\n');
});
it('reads punctuated legacy enc: plaintext rows back verbatim', () => {
const db = DatabaseService.getInstance();
const legacyId = db.createSnapshot('legacy-punct', 'admin', 1, 1, '[]', '[]');
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(legacyId, 1, 'local', 'legacy', 'compose.yaml', 'enc:hello-world');
const files = db.getSnapshotFiles(legacyId);
expect(files[0].available).toBe(true);
if (files[0].available) expect(files[0].content).toBe('enc:hello-world');
});
it('isolates a corrupt encrypted sibling without failing the read', () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('partial-corrupt', 'admin', 1, 2, '[]', '[]');
const good = CryptoService.getInstance().encrypt('services: {}\n');
const bad = CryptoService.getInstance().encrypt('SECRET=x\n');
const payload = bad.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
const damaged = `enc:${iv}:${tag}:${ct.slice(0, 3)}`;
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, 1, 'local', 'good', 'compose.yaml', good);
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, 1, 'local', 'bad', 'compose.yaml', damaged);
const files = db.getSnapshotFiles(id);
expect(files).toHaveLength(2);
const goodFile = files.find(f => f.stack_name === 'good');
const badFile = files.find(f => f.stack_name === 'bad');
expect(goodFile?.available).toBe(true);
if (goodFile?.available) expect(goodFile.content).toBe('services: {}\n');
expect(badFile?.available).toBe(false);
});
it('preserves a valid empty file as available empty content', () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('empty-env', 'admin', 1, 1, '[]', '[]');
db.insertSnapshotFiles(id, [
{ nodeId: 1, nodeName: 'local', stackName: 'web', filename: '.env', content: '' },
{ nodeId: 1, nodeName: 'local', stackName: 'web', filename: 'compose.yaml', content: 'services: {}\n' },
]);
const files = db.getSnapshotFiles(id);
const env = files.find(f => f.filename === '.env');
expect(env?.available).toBe(true);
if (env?.available) expect(env.content).toBe('');
});
});
@@ -221,6 +273,118 @@ describe('Single-stack snapshot restore (behavior lock)', () => {
expect(res.status).toBe(409);
});
it('returns 409 SNAPSHOT_FILE_UNAVAILABLE and does not mutate live files', async () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('restore-corrupt', 'admin', 1, 1, '[]', '[]');
const cipher = CryptoService.getInstance().encrypt('services: {}\n');
const payload = cipher.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
const damaged = `enc:${iv}:${tag}:${ct.slice(0, 3)}`;
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, LOCAL_NODE_ID, 'local', 'corrupt-web', 'compose.yaml', damaged);
seedStackDir('corrupt-web');
const beforeCompose = 'services:\n keep: {}\n';
const beforeEnv = 'KEEP=1\n';
fs.writeFileSync(composePath('corrupt-web'), beforeCompose);
fs.writeFileSync(envPath('corrupt-web'), beforeEnv);
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue();
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: LOCAL_NODE_ID, stackName: 'corrupt-web', redeploy: true, restoreNotes: true });
expect(res.status).toBe(409);
expect(res.body.code).toBe('SNAPSHOT_FILE_UNAVAILABLE');
expect(fs.readFileSync(composePath('corrupt-web'), 'utf-8')).toBe(beforeCompose);
expect(fs.readFileSync(envPath('corrupt-web'), 'utf-8')).toBe(beforeEnv);
expect(deploySpy).not.toHaveBeenCalled();
});
it('returns 409 for mixed available and unavailable files in the same stack without writing', async () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('restore-mixed', 'admin', 1, 1, '[]', '[]');
const good = CryptoService.getInstance().encrypt('services: { ok: {} }\n');
const bad = CryptoService.getInstance().encrypt('SECRET=long-enough-to-truncate\n');
const payload = bad.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, LOCAL_NODE_ID, 'local', 'mixed-web', 'compose.yaml', good);
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, LOCAL_NODE_ID, 'local', 'mixed-web', '.env', `enc:${iv}:${tag}:${ct.slice(0, 3)}`);
seedStackDir('mixed-web');
const beforeCompose = 'services:\n keep: {}\n';
const beforeEnv = 'KEEP=1\n';
fs.writeFileSync(composePath('mixed-web'), beforeCompose);
fs.writeFileSync(envPath('mixed-web'), beforeEnv);
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue();
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: LOCAL_NODE_ID, stackName: 'mixed-web', redeploy: true });
expect(res.status).toBe(409);
expect(res.body.code).toBe('SNAPSHOT_FILE_UNAVAILABLE');
expect(fs.readFileSync(composePath('mixed-web'), 'utf-8')).toBe(beforeCompose);
expect(fs.readFileSync(envPath('mixed-web'), 'utf-8')).toBe(beforeEnv);
expect(deploySpy).not.toHaveBeenCalled();
});
it('returns 409 when a delimiter byte is corrupted and does not write ciphertext', async () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('restore-delim', 'admin', 1, 1, '[]', '[]');
const cipher = CryptoService.getInstance().encrypt('services: { ok: {} }\n');
const payload = cipher.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
const damaged = `enc:${iv}=${tag}:${ct}`;
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, LOCAL_NODE_ID, 'local', 'delim-web', 'compose.yaml', damaged);
seedStackDir('delim-web');
const beforeCompose = 'services:\n keep: {}\n';
fs.writeFileSync(composePath('delim-web'), beforeCompose);
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue();
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore`)
.set('Cookie', adminCookie)
.send({ nodeId: LOCAL_NODE_ID, stackName: 'delim-web', redeploy: true });
expect(res.status).toBe(409);
expect(res.body.code).toBe('SNAPSHOT_FILE_UNAVAILABLE');
expect(fs.readFileSync(composePath('delim-web'), 'utf-8')).toBe(beforeCompose);
expect(deploySpy).not.toHaveBeenCalled();
});
it('detail returns 200 with unavailable marker and intact sibling content', async () => {
const db = DatabaseService.getInstance();
const id = db.createSnapshot('detail-corrupt', 'admin', 1, 2, '[]', '[]');
const good = CryptoService.getInstance().encrypt('services: { ok: {} }\n');
const bad = CryptoService.getInstance().encrypt('SECRET=long-enough-to-truncate\n');
const payload = bad.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, LOCAL_NODE_ID, 'local', 'ok', 'compose.yaml', good);
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, LOCAL_NODE_ID, 'local', 'bad', 'compose.yaml', `enc:${iv}:${tag}:${ct.slice(0, 3)}`);
const res = await request(app).get(`/api/fleet/snapshots/${id}`).set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.fileDecryptWarnings).toEqual([
expect.objectContaining({ stackName: 'bad', filename: 'compose.yaml' }),
]);
const okStack = res.body.nodes[0].stacks.find((s: { stackName: string }) => s.stackName === 'ok');
const badStack = res.body.nodes[0].stacks.find((s: { stackName: string }) => s.stackName === 'bad');
expect(okStack.files[0].content).toBe('services: { ok: {} }\n');
expect(okStack.files[0].unavailable).toBeUndefined();
expect(badStack.files[0].unavailable).toBe(true);
expect(badStack.files[0].content).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain('enc:');
});
it('redeploys after restore when requested', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue();
@@ -583,6 +747,83 @@ describe('Restore-all', () => {
expect(bad?.error).toMatch(/no longer exists/i);
});
it('isolates corrupt decrypt stacks before any mutation with notes and redeploy requested', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue();
const db = DatabaseService.getInstance();
const id = db.createSnapshot('restore-all-corrupt', 'admin', 1, 2, '[]', '[]');
const good = CryptoService.getInstance().encrypt('services:\n app: {}\n');
const bad = CryptoService.getInstance().encrypt('SECRET=x\n');
const payload = bad.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
const damaged = `enc:${iv}:${tag}:${ct.slice(0, 3)}`;
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, LOCAL_NODE_ID, 'local', 'healthy', 'compose.yaml', good);
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, LOCAL_NODE_ID, 'local', 'corrupt', 'compose.yaml', damaged);
seedStackDir('healthy');
seedStackDir('corrupt');
fs.writeFileSync(composePath('corrupt'), 'services:\n keep: {}\n');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore-all`)
.set('Cookie', adminCookie)
.send({ redeploy: true, restoreNotes: true });
expect(res.status).toBe(200);
expect(res.body.restored).toBe(1);
expect(res.body.failed).toBe(1);
const corrupt = (res.body.results as Array<{ stackName: string; success: boolean; error?: string; redeployed: boolean }>)
.find(r => r.stackName === 'corrupt');
expect(corrupt?.success).toBe(false);
expect(corrupt?.error).toMatch(/could not be decrypted/i);
expect(corrupt?.redeployed).toBe(false);
expect(fs.readFileSync(composePath('corrupt'), 'utf-8')).toContain('keep: {}');
expect(fs.readFileSync(composePath('healthy'), 'utf-8')).toContain('app: {}');
expect(deploySpy).toHaveBeenCalledTimes(1);
expect(deploySpy).toHaveBeenCalledWith('healthy', undefined, undefined, {
source: 'fleet_snapshot',
actor: 'system:fleet-snapshot',
});
expect(deploySpy.mock.calls.every(call => call[0] !== 'corrupt')).toBe(true);
});
it('isolates delimiter-byte corruption before restore-all mutation', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue();
const db = DatabaseService.getInstance();
const id = db.createSnapshot('restore-all-delim', 'admin', 1, 2, '[]', '[]');
const good = CryptoService.getInstance().encrypt('services:\n app: {}\n');
const bad = CryptoService.getInstance().encrypt('SECRET=long-enough-to-mutate\n');
const payload = bad.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
const damaged = `enc:${iv} ${tag}:${ct}`;
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, LOCAL_NODE_ID, 'local', 'healthy', 'compose.yaml', good);
db.getDb().prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, LOCAL_NODE_ID, 'local', 'corrupt', 'compose.yaml', damaged);
seedStackDir('healthy');
seedStackDir('corrupt');
fs.writeFileSync(composePath('corrupt'), 'services:\n keep: {}\n');
const res = await request(app)
.post(`/api/fleet/snapshots/${id}/restore-all`)
.set('Cookie', adminCookie)
.send({ redeploy: true });
expect(res.status).toBe(200);
expect(res.body.restored).toBe(1);
expect(res.body.failed).toBe(1);
const corrupt = (res.body.results as Array<{ stackName: string; success: boolean; error?: string }>)
.find(r => r.stackName === 'corrupt');
expect(corrupt?.success).toBe(false);
expect(fs.readFileSync(composePath('corrupt'), 'utf-8')).toContain('keep: {}');
expect(deploySpy).toHaveBeenCalledTimes(1);
expect(deploySpy.mock.calls.every(call => call[0] !== 'corrupt')).toBe(true);
});
it('redeploys each restored stack when requested', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue();
@@ -0,0 +1,153 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CryptoService } from '../services/CryptoService';
import {
classifySnapshotFileContent,
isEnvelopeLikeDamage,
isEnvelopeShapedPayload,
isStructurallyValidSnapshotEnvelope,
} from '../helpers/snapshotFileDecrypt';
describe('snapshotFileDecrypt classification', () => {
const encrypt = (plain: string) => CryptoService.getInstance().encrypt(plain);
it('returns plaintext for non-enc values including empty string', () => {
expect(classifySnapshotFileContent('')).toEqual({ kind: 'usable', content: '' });
expect(classifySnapshotFileContent('services:\n web:\n')).toEqual({
kind: 'usable',
content: 'services:\n web:\n',
});
});
it('decrypts a structurally valid envelope', () => {
const cipher = encrypt('SECRET=1\n');
expect(isStructurallyValidSnapshotEnvelope(cipher)).toBe(true);
expect(classifySnapshotFileContent(cipher)).toEqual({ kind: 'usable', content: 'SECRET=1\n' });
});
it('marks auth-failing valid envelopes unavailable', () => {
const cipher = encrypt('ok');
// Flip last hex nibble of ciphertext to keep structure valid but break auth
const parts = cipher.split(':');
const last = parts[parts.length - 1];
const flipped = (last.slice(0, -1) + (last.endsWith('0') ? '1' : '0'));
const tampered = [...parts.slice(0, -1), flipped].join(':');
expect(isStructurallyValidSnapshotEnvelope(tampered)).toBe(true);
expect(classifySnapshotFileContent(tampered)).toEqual(
expect.objectContaining({ kind: 'unavailable', reason: 'decrypt_failed' }),
);
});
it('preserves clearly non-envelope legacy enc: prose including punctuation', () => {
const legacyValues = [
'enc:hello',
'enc:FOO_BAR=baz',
'enc: path with spaces',
'enc:hello-world',
'enc:hello/world',
'enc:{legacy}',
'enc:hello.world',
'enc:hello!',
];
for (const value of legacyValues) {
expect(isEnvelopeShapedPayload(value.slice('enc:'.length)), value).toBe(false);
expect(classifySnapshotFileContent(value), value).toEqual({ kind: 'usable', content: value });
expect(isEnvelopeLikeDamage(value), value).toBe(false);
}
expect(isEnvelopeLikeDamage('enc:deadbeef')).toBe(true);
expect(classifySnapshotFileContent('enc:deadbeef')).toEqual({
kind: 'unavailable',
reason: 'envelope_damage',
});
});
describe('encrypt-then-corrupt detectable family', () => {
let good: string;
beforeEach(() => {
good = encrypt('compose content\n');
});
const envelopeDamage = { kind: 'unavailable' as const, reason: 'envelope_damage' as const };
it('fails closed on empty truncation to enc:', () => {
expect(classifySnapshotFileContent('enc:')).toEqual(envelopeDamage);
});
it('fails closed on short hex-only truncation', () => {
expect(classifySnapshotFileContent('enc:deadbeef')).toEqual(envelopeDamage);
});
it('fails closed when IV is truncated', () => {
const payload = good.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
const damaged = `enc:${iv.slice(0, 10)}:${tag}:${ct}`;
expect(classifySnapshotFileContent(damaged)).toEqual(envelopeDamage);
});
it('fails closed when tag is truncated', () => {
const payload = good.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
const damaged = `enc:${iv}:${tag.slice(0, 8)}:${ct}`;
expect(classifySnapshotFileContent(damaged)).toEqual(envelopeDamage);
});
it('fails closed when ciphertext is truncated or odd-length', () => {
const payload = good.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
expect(classifySnapshotFileContent(`enc:${iv}:${tag}:${ct.slice(0, -1)}`)).toEqual(envelopeDamage);
expect(classifySnapshotFileContent(`enc:${iv}:${tag}:${ct.slice(0, 3)}`)).toEqual(envelopeDamage);
});
it('fails closed on non-hex mutation in IV, tag, and ciphertext', () => {
const payload = good.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
const ivDamaged = `enc:${iv.slice(0, 5)}g${iv.slice(6)}:${tag}:${ct}`;
const tagDamaged = `enc:${iv}:${tag.slice(0, 5)}g${tag.slice(6)}:${ct}`;
const ctDamaged = `enc:${iv}:${tag}:${ct.slice(0, 5)}g${ct.slice(6)}`;
expect(classifySnapshotFileContent(ivDamaged)).toEqual(envelopeDamage);
expect(classifySnapshotFileContent(tagDamaged)).toEqual(envelopeDamage);
expect(classifySnapshotFileContent(ctDamaged)).toEqual(envelopeDamage);
});
it('fails closed when a delimiter is removed or added', () => {
const payload = good.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
expect(classifySnapshotFileContent(`enc:${iv}:${tag}${ct}`)).toEqual(envelopeDamage);
expect(classifySnapshotFileContent(`enc:${iv}:${tag}:${ct}:00`)).toEqual(envelopeDamage);
});
it('fails closed for single-character delimiter substitutions including = and whitespace', () => {
const payload = good.slice('enc:'.length);
const colonIdx = payload.indexOf(':');
expect(colonIdx).toBeGreaterThan(0);
const mutants = ['Z', '=', ' ', '\t', '|', '/', '+', '@', '.', ',', ';', '_', '-'];
for (const ch of mutants) {
const damaged = `enc:${payload.slice(0, colonIdx)}${ch}${payload.slice(colonIdx + 1)}`;
expect(isStructurallyValidSnapshotEnvelope(damaged), `delim=${JSON.stringify(ch)}`).toBe(false);
expect(isEnvelopeLikeDamage(damaged), `delim=${JSON.stringify(ch)}`).toBe(true);
expect(classifySnapshotFileContent(damaged), `delim=${JSON.stringify(ch)}`).toEqual(envelopeDamage);
}
});
it('fails closed when = or whitespace is inserted into IV, tag, or ciphertext fields', () => {
const payload = good.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
const cases = [
`enc:${iv.slice(0, 8)}=${iv.slice(8)}:${tag}:${ct}`,
`enc:${iv}:${tag.slice(0, 8)} ${tag.slice(8)}:${ct}`,
`enc:${iv}:${tag}:${ct.slice(0, 4)}=${ct.slice(4)}`,
`enc:${iv}:${tag}:${ct.slice(0, 4)} ${ct.slice(4)}`,
];
for (const damaged of cases) {
expect(classifySnapshotFileContent(damaged)).toEqual(envelopeDamage);
}
});
it('fails closed when a non-hex extra field is appended', () => {
const payload = good.slice('enc:'.length);
const [iv, tag, ct] = payload.split(':');
const extraField = `enc:${iv}:${tag}:${ct}:oops`;
expect(isEnvelopeLikeDamage(extraField)).toBe(true);
expect(classifySnapshotFileContent(extraField)).toEqual(envelopeDamage);
});
});
});