From 08570dfa43ae5f82059eca4498edaecfcaedbca1 Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Sat, 22 Aug 2026 08:54:39 -0700 Subject: [PATCH 1/4] Add verified archive pruning command --- app/data/ArchivePruner.mjs | 328 ++++++++++++++++++++++++++++++ app/data/ArchivePruner.test.mjs | 171 ++++++++++++++++ app/data/ArchiveVerifier.mjs | 164 +++++++++++++++ app/data/ArchiveVerifier.test.mjs | 124 +++++++++++ app/index.mjs | 2 + package-lock.json | 21 +- package.json | 3 + 7 files changed, 801 insertions(+), 12 deletions(-) create mode 100644 app/data/ArchivePruner.mjs create mode 100644 app/data/ArchivePruner.test.mjs create mode 100644 app/data/ArchiveVerifier.mjs create mode 100644 app/data/ArchiveVerifier.test.mjs diff --git a/app/data/ArchivePruner.mjs b/app/data/ArchivePruner.mjs new file mode 100644 index 0000000..b84ff36 --- /dev/null +++ b/app/data/ArchivePruner.mjs @@ -0,0 +1,328 @@ +import path from 'node:path'; +import * as Sentry from '@sentry/node'; +import { + DeleteObjectsCommand, + GetObjectCommand, + ListObjectsV2Command, + S3Client, +} from '@aws-sdk/client-s3'; +import prefixedConsole from '../common/prefixedConsole.mjs'; +import { parseArchiveManifest } from './ArchiveManifest.mjs'; +import ArchiveVerifier from './ArchiveVerifier.mjs'; + +const retentionPeriod = 7 * 24 * 60 * 60 * 1000; + +export function pruneArchives(maxDays = Infinity, dryRun = false) { + let pruner = new ArchivePruner; + pruner.maxDays = maxDays; + pruner.dryRun = dryRun; + + return pruner.process(); +} + +export function pruneArchivesFromCli(args) { + let dryRun = false; + let maxDays = Infinity; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--dry-run') { + dryRun = true; + } else if (args[i] === '--max-days' && /^\d+$/.test(args[i + 1])) { + maxDays = Number(args[++i]); + } else { + throw new Error( + 'Usage: npm run data:archive:prune -- [--dry-run] [--max-days DAYS]', + ); + } + } + + if (maxDays < 1) { + throw new Error('--max-days must be at least 1'); + } + + return pruneArchives(maxDays, dryRun); +} + +export default class ArchivePruner +{ + dryRun = false; + maxDays = Infinity; + + constructor(s3Client, archiveVerifier = new ArchiveVerifier) { + this._client = s3Client; + this.archiveVerifier = archiveVerifier; + } + + async process() { + if (!this.canRun) { + this.console.log('Skipping archive pruner'); + + return; + } + + let currentDate; + + try { + let candidates = await this.getCandidates(); + this.console.log(`Found ${candidates.length} dates to verify`); + let processedDays = 0; + + for (let candidate of candidates) { + if (processedDays >= this.maxDays) { + break; + } + + currentDate = candidate.date; + let manifest = await this.getManifest(candidate); + if (!this.dryRun && !this.isEligible(manifest)) { + this.console.log( + `Skipping ${candidate.date}; not eligible for deletion until ${this.eligibleDate(manifest)}`, + ); + continue; + } + + let sourceObjects = await this.getSourceObjects(candidate.prefix); + let archiveStream = await this.getArchive(candidate.archiveKey); + await this.archiveVerifier.verify(archiveStream, manifest, sourceObjects); + processedDays++; + + if (this.dryRun) { + if (this.isEligible(manifest)) { + this.console.log( + `Verified ${candidate.date}; would delete ${this.fileCount(sourceObjects)}`, + ); + } else { + this.console.log( + `Verified ${candidate.date}; not eligible for deletion until ${this.eligibleDate(manifest)}`, + ); + } + continue; + } + + let currentObjects = await this.getSourceObjects(candidate.prefix); + this.assertObjectsUnchanged(sourceObjects, currentObjects, candidate.date); + await this.deleteObjects(currentObjects); + this.console.log(`Pruned ${candidate.date}; deleted ${this.fileCount(currentObjects)}`); + } + + this.console.log( + this.dryRun + ? `Verified ${processedDays} daily archives` + : `Pruned ${processedDays} daily archives`, + ); + } catch (e) { + this.console.error(e); + Sentry.withScope(scope => { + if (currentDate) { + scope.setTag('archive.date', currentDate); + } + Sentry.captureException(e); + }); + await Sentry.flush(2000).catch(() => {}); + throw e; + } + } + + get console() { + this._console ??= prefixedConsole('Archive Pruner'); + + return this._console; + } + + get canRun() { + return process.env.AWS_S3_ENDPOINT + && process.env.AWS_REGION + && process.env.AWS_S3_ARCHIVE_BUCKET + && process.env.AWS_ACCESS_KEY_ID + && process.env.AWS_SECRET_ACCESS_KEY; + } + + get s3Client() { + return this._client ??= new S3Client({ + endpoint: process.env.AWS_S3_ENDPOINT, + region: process.env.AWS_REGION, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }); + } + + async getCandidates() { + let candidates = []; + let years = (await this.list('', '/')).prefixes.filter(prefix => /^\d{4}\/$/.test(prefix)); + + for (let year of years.sort()) { + let months = (await this.list(year, '/')).prefixes + .filter(prefix => /^\d{4}\/\d{2}\/$/.test(prefix)); + + for (let month of months.sort()) { + let listing = await this.list(month, '/'); + let existing = new Set(listing.objects.map(object => object.Key)); + + for (let prefix of listing.prefixes.sort()) { + let date = this.dateFromPrefix(prefix); + if (!date) { + continue; + } + + let archiveKey = `${month}${date}.tar.zst`; + let manifestKey = `${archiveKey}.manifest.json`; + let hasArchive = existing.has(archiveKey); + let hasManifest = existing.has(manifestKey); + if (hasArchive !== hasManifest) { + throw new Error(`Archive and manifest are incomplete for ${date}`); + } + if (hasArchive) { + candidates.push({ archiveKey, date, manifestKey, prefix }); + } + } + } + } + + return candidates.sort((a, b) => a.date.localeCompare(b.date)); + } + + async getManifest(candidate) { + let response = await this.s3Client.send(new GetObjectCommand({ + Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, + Key: candidate.manifestKey, + })); + if (!response.Body) { + throw new Error(`S3 returned no body for ${candidate.manifestKey}`); + } + + return parseArchiveManifest( + await response.Body.transformToString(), + candidate.archiveKey, + ); + } + + async getArchive(key) { + let response = await this.s3Client.send(new GetObjectCommand({ + Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, + Key: key, + })); + if (!response.Body) { + throw new Error(`S3 returned no body for ${key}`); + } + + return response.Body; + } + + async getSourceObjects(prefix) { + let listing = await this.list(prefix); + + return listing.objects + .filter(object => object.Key && object.Key !== prefix && !object.Key.endsWith('/')) + .map(object => { + if (!Number.isFinite(object.Size) || typeof object.ETag !== 'string') { + throw new Error(`S3 returned incomplete metadata for ${object.Key}`); + } + + return { + path: this.relativePath(prefix, object.Key), + bytes: object.Size, + etag: object.ETag, + key: object.Key, + }; + }) + .sort((a, b) => a.path.localeCompare(b.path)); + } + + async deleteObjects(objects) { + for (let i = 0; i < objects.length; i += 1000) { + let batch = objects.slice(i, i + 1000); + let response = await this.s3Client.send(new DeleteObjectsCommand({ + Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, + Delete: { + Objects: batch.map(object => ({ Key: object.key })), + Quiet: true, + }, + })); + if (response.Errors?.length) { + let failures = response.Errors.map(error => `${error.Key}: ${error.Code}`).join(', '); + throw new Error(`Could not delete archive source files: ${failures}`); + } + } + } + + async list(prefix, delimiter) { + let prefixes = []; + let objects = []; + let continuationToken; + + do { + let response = await this.s3Client.send(new ListObjectsV2Command({ + Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, + ContinuationToken: continuationToken, + Delimiter: delimiter, + Prefix: prefix, + })); + prefixes.push(...(response.CommonPrefixes ?? []).map(item => item.Prefix).filter(Boolean)); + objects.push(...(response.Contents ?? [])); + + if (response.IsTruncated && !response.NextContinuationToken) { + throw new Error(`S3 listing for ${prefix} was truncated without a continuation token`); + } + continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; + } while (continuationToken); + + return { objects, prefixes }; + } + + isEligible(manifest) { + return Date.parse(manifest.createdAt) <= Date.now() - retentionPeriod; + } + + eligibleDate(manifest) { + return new Date(Date.parse(manifest.createdAt) + retentionPeriod).toISOString().slice(0, 10); + } + + assertObjectsUnchanged(expected, actual, date) { + let unchanged = expected.length === actual.length + && expected.every((object, index) => { + let current = actual[index]; + + return object.key === current.key + && object.bytes === current.bytes + && object.etag === current.etag; + }); + if (!unchanged) { + throw new Error(`Source files changed while verifying ${date}`); + } + } + + fileCount(objects) { + return `${objects.length} ${objects.length === 1 ? 'file' : 'files'}`; + } + + dateFromPrefix(prefix) { + let match = prefix.match(/^(\d{4})\/(\d{2})\/(\d{2})\/$/); + if (!match) { + return null; + } + + let date = `${match[1]}-${match[2]}-${match[3]}`; + let parsedDate = new Date(`${date}T00:00:00.000Z`); + + return !Number.isNaN(parsedDate.getTime()) && parsedDate.toISOString().slice(0, 10) === date + ? date + : null; + } + + relativePath(prefix, key) { + let relativePath = key.slice(prefix.length); + if (!key.startsWith(prefix) + || !relativePath + || path.posix.normalize(relativePath) !== relativePath + || path.posix.isAbsolute(relativePath) + || relativePath.split('/').includes('..')) { + throw new Error(`Invalid archive object path: ${key}`); + } + + return relativePath; + } +} diff --git a/app/data/ArchivePruner.test.mjs b/app/data/ArchivePruner.test.mjs new file mode 100644 index 0000000..bbc34a8 --- /dev/null +++ b/app/data/ArchivePruner.test.mjs @@ -0,0 +1,171 @@ +import { Readable } from 'node:stream'; +import { + DeleteObjectsCommand, + GetObjectCommand, + ListObjectsV2Command, +} from '@aws-sdk/client-s3'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ArchivePruner from './ArchivePruner.mjs'; + +const archiveKey = '2025/03/2025-03-05.tar.zst'; +const manifestKey = `${archiveKey}.manifest.json`; + +function manifest(createdAt = '2026-08-21T00:00:00.000Z') { + return { + version: 1, + date: '2025-03-05', + createdAt, + archive: { + path: archiveKey, + bytes: 7, + hash: `sha256:${'a'.repeat(64)}`, + }, + files: [{ + path: 'alpha.json', + bytes: 6, + hash: `sha256:${'b'.repeat(64)}`, + }], + }; +} + +class FakeS3Client +{ + deletes = []; + events = []; + manifest = manifest(); + manifestMissing = false; + + async send(command) { + if (command instanceof ListObjectsV2Command) { + return this.list(command.input.Prefix); + } + if (command instanceof GetObjectCommand && command.input.Key === manifestKey) { + return { + Body: { + transformToString: async () => JSON.stringify(this.manifest), + }, + }; + } + if (command instanceof GetObjectCommand && command.input.Key === archiveKey) { + return { Body: Readable.from('archive') }; + } + if (command instanceof DeleteObjectsCommand) { + this.events.push('delete'); + this.deletes.push(...command.input.Delete.Objects.map(object => object.Key)); + + return {}; + } + + throw new Error(`Unexpected S3 command: ${command.constructor.name}`); + } + + list(prefix) { + if (prefix === '') { + return { CommonPrefixes: [{ Prefix: '2025/' }] }; + } + if (prefix === '2025/') { + return { CommonPrefixes: [{ Prefix: '2025/03/' }] }; + } + if (prefix === '2025/03/') { + return { + CommonPrefixes: [{ Prefix: '2025/03/05/' }], + Contents: [ + { Key: archiveKey }, + ...(this.manifestMissing ? [] : [{ Key: manifestKey }]), + ], + }; + } + if (prefix === '2025/03/05/') { + return { + Contents: [{ + ETag: '"9f9f90dbe3e5ee1218c86b8839db1995"', + Key: '2025/03/05/alpha.json', + Size: 6, + }], + }; + } + + throw new Error(`Unexpected S3 prefix: ${prefix}`); + } +} + +describe('ArchivePruner', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-22T12:00:00.000Z')); + process.env.AWS_S3_ENDPOINT = 'https://example.invalid'; + process.env.AWS_REGION = 'test'; + process.env.AWS_S3_ARCHIVE_BUCKET = 'archive'; + process.env.AWS_ACCESS_KEY_ID = 'key'; + process.env.AWS_SECRET_ACCESS_KEY = 'secret'; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('fully verifies recent archives during a dry run without deleting files', async () => { + let s3Client = new FakeS3Client; + let verifier = { verify: vi.fn() }; + let pruner = new ArchivePruner(s3Client, verifier); + pruner.dryRun = true; + pruner._console = { error: vi.fn(), log: vi.fn() }; + + await pruner.process(); + + expect(verifier.verify).toHaveBeenCalledOnce(); + expect(s3Client.deletes).toEqual([]); + expect(pruner._console.log).toHaveBeenCalledWith( + 'Verified 2025-03-05; not eligible for deletion until 2026-08-28', + ); + }); + + it('deletes an eligible day only after its archive is verified', async () => { + let s3Client = new FakeS3Client; + s3Client.manifest = manifest('2026-08-01T00:00:00.000Z'); + let verifier = { + verify: vi.fn(async () => { + s3Client.events.push('verify'); + }), + }; + let pruner = new ArchivePruner(s3Client, verifier); + pruner._console = { error: vi.fn(), log: vi.fn() }; + + await pruner.process(); + + expect(s3Client.events).toEqual(['verify', 'delete']); + expect(s3Client.deletes).toEqual(['2025/03/05/alpha.json']); + expect(pruner._console.log).toHaveBeenCalledWith('Pruned 2025-03-05; deleted 1 file'); + }); + + it('stops when an archive exists without its manifest', async () => { + let s3Client = new FakeS3Client; + s3Client.manifestMissing = true; + let verifier = { verify: vi.fn() }; + let pruner = new ArchivePruner(s3Client, verifier); + pruner._console = { error: vi.fn(), log: vi.fn() }; + + await expect(pruner.process()).rejects.toThrow( + 'Archive and manifest are incomplete for 2025-03-05', + ); + + expect(verifier.verify).not.toHaveBeenCalled(); + expect(s3Client.deletes).toEqual([]); + }); + + it('stops without deleting when archive verification fails', async () => { + let s3Client = new FakeS3Client; + s3Client.manifest = manifest('2026-08-01T00:00:00.000Z'); + let verifier = { + verify: vi.fn(async () => { + throw new Error('archive does not match'); + }), + }; + let pruner = new ArchivePruner(s3Client, verifier); + pruner._console = { error: vi.fn(), log: vi.fn() }; + + await expect(pruner.process()).rejects.toThrow('archive does not match'); + + expect(s3Client.deletes).toEqual([]); + }); +}); diff --git a/app/data/ArchiveVerifier.mjs b/app/data/ArchiveVerifier.mjs new file mode 100644 index 0000000..6040c91 --- /dev/null +++ b/app/data/ArchiveVerifier.mjs @@ -0,0 +1,164 @@ +import crypto from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import * as tar from 'tar-stream'; + +function processCompletion(child) { + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', chunk => { + stderr += chunk; + }); + + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => { + if (code === 0) { + resolve(); + } else { + reject(new Error( + `zstd failed (${signal ? `signal ${signal}` : `exit ${code}`}): ${stderr.trim()}`, + )); + } + }); + }); +} + +function decompressZstd(stream) { + let zstd = spawn('zstd', ['-dc'], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + return { + completed: Promise.all([ + pipeline(stream, zstd.stdin), + processCompletion(zstd), + ]), + stream: zstd.stdout, + }; +} + +export default class ArchiveVerifier +{ + constructor(decompress = decompressZstd) { + this.decompress = decompress; + } + + async verify(archiveStream, manifest, sourceObjects) { + let inventory; + try { + inventory = this.matchFiles(manifest.files, sourceObjects); + } catch (error) { + archiveStream.destroy?.(); + throw error; + } + + let archiveBytes = 0; + let archiveHash = crypto.createHash('sha256'); + let inspector = new Transform({ + transform(chunk, encoding, callback) { + archiveBytes += chunk.length; + archiveHash.update(chunk); + callback(null, chunk); + }, + }); + let decompressor = this.decompress(inspector); + let results = await Promise.allSettled([ + pipeline(archiveStream, inspector), + decompressor.completed, + this.verifyTar(decompressor.stream, inventory), + ]); + let errors = results + .filter(result => result.status === 'rejected') + .map(result => result.reason); + if (errors.length === 1) { + throw errors[0]; + } + if (errors.length > 0) { + throw new AggregateError(errors, 'Could not verify archive'); + } + + if (archiveBytes !== manifest.archive.bytes) { + throw new Error('Archive size does not match its manifest'); + } + if (`sha256:${archiveHash.digest('hex')}` !== manifest.archive.hash) { + throw new Error('Archive SHA-256 does not match its manifest'); + } + } + + matchFiles(manifestFiles, sourceObjects) { + let manifestByPath = new Map(manifestFiles.map(file => [file.path, file])); + let sourceByPath = new Map(sourceObjects.map(object => [object.path, object])); + if (manifestByPath.size !== manifestFiles.length + || sourceByPath.size !== sourceObjects.length + || manifestByPath.size !== sourceByPath.size + || [...manifestByPath].some(([filePath, file]) => { + let source = sourceByPath.get(filePath); + + return !source || source.bytes !== file.bytes; + })) { + throw new Error('Live source files do not match the archive manifest'); + } + + return { manifestByPath, sourceByPath }; + } + + async verifyTar(stream, { manifestByPath, sourceByPath }) { + let extract = tar.extract(); + let verified = new Set; + extract.on('entry', (header, entry, next) => { + this.verifyEntry(header, entry, manifestByPath, sourceByPath, verified) + .then(next) + .catch(error => extract.destroy(error)); + }); + await pipeline(stream, extract); + + if (verified.size !== manifestByPath.size) { + throw new Error('Tar archive is missing files from its manifest'); + } + } + + async verifyEntry(header, entry, manifestByPath, sourceByPath, verified) { + if (header.type !== 'file') { + throw new Error(`Tar archive contains a non-file entry: ${header.name}`); + } + if (verified.has(header.name)) { + throw new Error(`Tar archive contains a duplicate file: ${header.name}`); + } + + let manifestFile = manifestByPath.get(header.name); + let sourceObject = sourceByPath.get(header.name); + if (!manifestFile || !sourceObject) { + throw new Error(`Tar archive contains an unexpected file: ${header.name}`); + } + verified.add(header.name); + + let bytes = 0; + let md5 = crypto.createHash('md5'); + let sha256 = crypto.createHash('sha256'); + for await (let chunk of entry) { + bytes += chunk.length; + md5.update(chunk); + sha256.update(chunk); + } + + if (bytes !== manifestFile.bytes) { + throw new Error(`Size does not match the manifest for ${header.name}`); + } + if (`sha256:${sha256.digest('hex')}` !== manifestFile.hash) { + throw new Error(`SHA-256 does not match the manifest for ${header.name}`); + } + + let etag = sourceObject.etag; + if (etag.startsWith('"') && etag.endsWith('"')) { + etag = etag.slice(1, -1); + } + if (!/^[a-fA-F0-9]{32}$/.test(etag)) { + throw new Error(`S3 ETag is not an MD5 hash for ${header.name}`); + } + if (md5.digest('hex') !== etag.toLowerCase()) { + throw new Error(`MD5 does not match the S3 ETag for ${header.name}`); + } + } +} diff --git a/app/data/ArchiveVerifier.test.mjs b/app/data/ArchiveVerifier.test.mjs new file mode 100644 index 0000000..be02155 --- /dev/null +++ b/app/data/ArchiveVerifier.test.mjs @@ -0,0 +1,124 @@ +import { Readable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import * as tar from 'tar-stream'; +import ArchiveVerifier from './ArchiveVerifier.mjs'; + +async function createTar(files) { + let pack = tar.pack(); + for (let [name, contents] of files) { + pack.entry({ + mode: 0o644, + mtime: new Date(0), + name, + }, contents); + } + pack.finalize(); + + let chunks = []; + for await (let chunk of pack) { + chunks.push(chunk); + } + + return Buffer.concat(chunks); +} + +function manifestFor(archive, fileHash = 'b6a98d9ce9a2d9149288fa3df42d377c3e42737afdcdaf714e33c0a100b51060') { + return { + archive: { + bytes: archive.length, + hash: 'sha256:5a500b4490cc8d86f7c6970ae673b15933dc1d29f45e6098b730572ec5d2396a', + }, + files: [{ + path: 'alpha.json', + bytes: 6, + hash: `sha256:${fileHash}`, + }], + }; +} + +function sourceObject(etag = '9f9f90dbe3e5ee1218c86b8839db1995') { + return { + path: 'alpha.json', + bytes: 6, + etag: `"${etag}"`, + }; +} + +const identityDecompressor = stream => ({ completed: Promise.resolve(), stream }); + +describe('ArchiveVerifier', () => { + it('verifies a tar stream against its manifest and live S3 objects', async () => { + let archive = await createTar([['alpha.json', 'alpha\n']]); + let verifier = new ArchiveVerifier(identityDecompressor); + + await expect(verifier.verify( + Readable.from(archive), + manifestFor(archive), + [sourceObject()], + )).resolves.toBeUndefined(); + }); + + it('rejects duplicate live S3 paths', async () => { + let archive = await createTar([['alpha.json', 'alpha\n']]); + let source = sourceObject(); + let verifier = new ArchiveVerifier(identityDecompressor); + let archiveStream = new Readable({ + read() { + this.destroy(new Error('Archive should not be read')); + }, + }); + + await expect(verifier.verify( + archiveStream, + manifestFor(archive), + [source, source], + )).rejects.toThrow('Live source files do not match the archive manifest'); + }); + + it('rejects a file whose SHA-256 does not match the manifest', async () => { + let archive = await createTar([['alpha.json', 'alpha\n']]); + let verifier = new ArchiveVerifier(identityDecompressor); + + await expect(verifier.verify( + Readable.from(archive), + manifestFor(archive, '0'.repeat(64)), + [sourceObject()], + )).rejects.toThrow('SHA-256 does not match the manifest for alpha.json'); + }); + + it('rejects an S3 ETag that is not a plain MD5 hash', async () => { + let archive = await createTar([['alpha.json', 'alpha\n']]); + let verifier = new ArchiveVerifier(identityDecompressor); + + await expect(verifier.verify( + Readable.from(archive), + manifestFor(archive), + [sourceObject('multipart-etag-2')], + )).rejects.toThrow('S3 ETag is not an MD5 hash for alpha.json'); + }); + + it('rejects a file that exists only in the tar archive', async () => { + let archive = await createTar([ + ['alpha.json', 'alpha\n'], + ['extra.json', 'extra\n'], + ]); + let verifier = new ArchiveVerifier(identityDecompressor); + + await expect(verifier.verify( + Readable.from(archive), + manifestFor(archive), + [sourceObject()], + )).rejects.toThrow('Tar archive contains an unexpected file: extra.json'); + }); + + it('rejects a file whose MD5 does not match its S3 ETag', async () => { + let archive = await createTar([['alpha.json', 'alpha\n']]); + let verifier = new ArchiveVerifier(identityDecompressor); + + await expect(verifier.verify( + Readable.from(archive), + manifestFor(archive), + [sourceObject('0'.repeat(32))], + )).rejects.toThrow('MD5 does not match the S3 ETag for alpha.json'); + }); +}); diff --git a/app/index.mjs b/app/index.mjs index d9ce218..be96b89 100644 --- a/app/index.mjs +++ b/app/index.mjs @@ -10,6 +10,7 @@ import BlueskyClient from './social/clients/BlueskyClient.mjs'; import ThreadsClient from './social/clients/ThreadsClient.mjs'; import { archiveData } from './data/DataArchiver.mjs'; import { compressArchivesFromCli } from './data/ArchiveCompressor.mjs'; +import { pruneArchivesFromCli } from './data/ArchivePruner.mjs'; import { reportArchiveStatsFromCli } from './data/ArchiveStats.mjs'; import { sentryInit } from './common/sentry.mjs'; import { sync, syncUpload, syncDownload } from './sync/index.mjs'; @@ -31,6 +32,7 @@ const actions = { warmCaches, dataArchive: archiveData, archiveCompress: (...args) => compressArchivesFromCli(args), + archivePrune: (...args) => pruneArchivesFromCli(args), archiveStats: (...args) => reportArchiveStatsFromCli(args), sync, syncUpload, diff --git a/package-lock.json b/package-lock.json index ec33a84..fdea65b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "s3-sync-client": "^4.3.1", "sharp": "^0.34.5", "sirv": "^3.0.2", + "tar-stream": "^3.2.0", "twitter-api-v2": "^1.29.0", "vue": "^3.5.28", "vue-i18n": "^11.2.8", @@ -5872,11 +5873,10 @@ } }, "node_modules/bare-fs": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", - "integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.0.tgz", + "integrity": "sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", @@ -5885,7 +5885,7 @@ "fast-fifo": "^1.3.2" }, "engines": { - "bare": ">=1.16.0" + "bare": ">=1.28.0" }, "peerDependencies": { "bare-buffer": "*" @@ -5901,7 +5901,6 @@ "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", "license": "Apache-2.0", - "optional": true, "engines": { "bare": ">=1.14.0" } @@ -5911,7 +5910,6 @@ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-os": "^3.0.1" } @@ -5921,7 +5919,6 @@ "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", "license": "Apache-2.0", - "optional": true, "dependencies": { "streamx": "^2.21.0" }, @@ -5943,7 +5940,6 @@ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-path": "^3.0.0" } @@ -10914,12 +10910,13 @@ } }, "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", "license": "MIT", "dependencies": { "b4a": "^1.6.4", + "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } diff --git a/package.json b/package.json index d709956..63427ed 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "data:archive": "node app/index.mjs dataArchive", "data:archive:compress": "node app/index.mjs archiveCompress", "data:archive:compress:dry-run": "node app/index.mjs archiveCompress --dry-run", + "data:archive:prune": "node app/index.mjs archivePrune", + "data:archive:prune:dry-run": "node app/index.mjs archivePrune --dry-run", "data:archive:stats": "node app/index.mjs archiveStats", "sync": "node app/index.mjs sync", "sync:upload": "node app/index.mjs syncUpload", @@ -53,6 +55,7 @@ "s3-sync-client": "^4.3.1", "sharp": "^0.34.5", "sirv": "^3.0.2", + "tar-stream": "^3.2.0", "twitter-api-v2": "^1.29.0", "vue": "^3.5.28", "vue-i18n": "^11.2.8", From 4bba3a887399a2a10d3df8b68102fcd954259ff4 Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Sat, 22 Aug 2026 09:47:21 -0700 Subject: [PATCH 2/4] Prevent macOS metadata in archives --- app/data/ArchiveCompressor.mjs | 36 +++++++++++++----- app/data/ArchiveCompressor.test.mjs | 41 ++++++++++++++++++++- app/data/ArchiveVerifier.mjs | 23 ++++++++---- app/data/ArchiveVerifier.test.mjs | 7 +++- app/data/TarZstdWriter.mjs | 1 + app/data/TarZstdWriter.test.mjs | 57 +++++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 20 deletions(-) create mode 100644 app/data/TarZstdWriter.test.mjs diff --git a/app/data/ArchiveCompressor.mjs b/app/data/ArchiveCompressor.mjs index 22cac15..1aea019 100644 --- a/app/data/ArchiveCompressor.mjs +++ b/app/data/ArchiveCompressor.mjs @@ -18,10 +18,11 @@ import TarZstdWriter from './TarZstdWriter.mjs'; const downloadLimit = 5; const quietPeriod = 30 * 60 * 1000; -export function compressArchives(maxDays = Infinity, dryRun = false) { +export function compressArchives(maxDays = Infinity, dryRun = false, rebuildBefore = null) { let compressor = new ArchiveCompressor; compressor.maxDays = maxDays; compressor.dryRun = dryRun; + compressor.rebuildBefore = rebuildBefore; return compressor.process(); } @@ -29,15 +30,19 @@ export function compressArchives(maxDays = Infinity, dryRun = false) { export function compressArchivesFromCli(args) { let dryRun = false; let maxDays = Infinity; + let rebuildBefore = null; for (let i = 0; i < args.length; i++) { if (args[i] === '--dry-run') { dryRun = true; + } else if (args[i] === '--rebuild-before' && Number.isFinite(Date.parse(args[i + 1]))) { + rebuildBefore = Date.parse(args[++i]); } else if (args[i] === '--max-days' && /^\d+$/.test(args[i + 1])) { maxDays = Number(args[++i]); } else { throw new Error( - 'Usage: npm run data:archive:compress -- [--dry-run] [--max-days DAYS]', + 'Usage: npm run data:archive:compress -- ' + + '[--dry-run] [--max-days DAYS] [--rebuild-before TIMESTAMP]', ); } } @@ -46,13 +51,14 @@ export function compressArchivesFromCli(args) { throw new Error('--max-days must be at least 1'); } - return compressArchives(maxDays, dryRun); + return compressArchives(maxDays, dryRun, rebuildBefore); } export default class ArchiveCompressor { dryRun = false; maxDays = Infinity; + rebuildBefore = null; constructor(s3Client, archiveWriter = new TarZstdWriter) { this._client = s3Client; @@ -143,7 +149,8 @@ export default class ArchiveCompressor } this.console.log( - `Would create ${candidate.archiveKey} and ${candidate.manifestKey} ` + `Would ${candidate.exists ? 'replace' : 'create'} ` + + `${candidate.archiveKey} and ${candidate.manifestKey} ` + `from ${objects.length} files`, ); @@ -178,7 +185,7 @@ export default class ArchiveCompressor }; let manifest = createArchiveManifest(candidate.date, archive, files); - this.console.log(`Uploading ${candidate.archiveKey}`); + this.console.log(`${candidate.exists ? 'Replacing' : 'Uploading'} ${candidate.archiveKey}`); await this.upload(candidate.archiveKey, await fs.readFile(archivePath), 'application/zstd'); await this.upload( candidate.manifestKey, @@ -290,7 +297,7 @@ export default class ArchiveCompressor for (let month of months.sort()) { let listing = await this.list(month, '/'); - let existing = new Set(listing.objects.map(object => object.Key)); + let existing = new Map(listing.objects.map(object => [object.Key, object])); for (let prefix of listing.prefixes.sort()) { let date = this.dateFromPrefix(prefix); @@ -300,11 +307,22 @@ export default class ArchiveCompressor let archiveKey = `${month}${date}.tar.zst`; let manifestKey = `${archiveKey}.manifest.json`; - if (existing.has(archiveKey) && existing.has(manifestKey)) { - continue; + let exists = existing.has(archiveKey) && existing.has(manifestKey); + if (exists) { + if (this.rebuildBefore === null) { + continue; + } + + let modified = existing.get(manifestKey).LastModified?.getTime(); + if (!Number.isFinite(modified)) { + throw new Error(`S3 returned incomplete metadata for ${manifestKey}`); + } + if (modified >= this.rebuildBefore) { + continue; + } } - candidates.push({ archiveKey, date, manifestKey, prefix }); + candidates.push({ archiveKey, date, exists, manifestKey, prefix }); } } } diff --git a/app/data/ArchiveCompressor.test.mjs b/app/data/ArchiveCompressor.test.mjs index 597ca97..105ee51 100644 --- a/app/data/ArchiveCompressor.test.mjs +++ b/app/data/ArchiveCompressor.test.mjs @@ -16,6 +16,7 @@ class FakeS3Client ['2025/03/05/alpha.json', Buffer.from('alpha\n')], ['2025/03/05/nested/beta.json', Buffer.from('beta\n')], ]); + manifestLastModified = new Date('2026-08-20T00:00:00.000Z'); secondDay = false; uploads = []; @@ -54,7 +55,10 @@ class FakeS3Client ], Contents: this.completed ? [ { Key: '2025/03/2025-03-05.tar.zst' }, - { Key: '2025/03/2025-03-05.tar.zst.manifest.json' }, + { + Key: '2025/03/2025-03-05.tar.zst.manifest.json', + LastModified: this.manifestLastModified, + }, ] : [], }; } @@ -183,6 +187,41 @@ describe('ArchiveCompressor', () => { expect(archiveWriter.write).not.toHaveBeenCalled(); }); + it('rebuilds a completed archive older than the repair cutoff', async () => { + let s3Client = new FakeS3Client; + s3Client.completed = true; + let archiveWriter = { + async write(sourceDirectory, archivePath) { + await fs.writeFile(archivePath, 'replacement archive'); + }, + }; + let compressor = new ArchiveCompressor(s3Client, archiveWriter); + compressor.rebuildBefore = Date.parse('2026-08-22T16:49:12.000Z'); + compressor._console = { error: vi.fn(), log: vi.fn() }; + + await compressor.process(); + + expect(s3Client.uploads.map(upload => upload.Key)).toEqual([ + '2025/03/2025-03-05.tar.zst', + '2025/03/2025-03-05.tar.zst.manifest.json', + ]); + }); + + it('leaves a rebuilt archive newer than the repair cutoff alone', async () => { + let s3Client = new FakeS3Client; + s3Client.completed = true; + s3Client.manifestLastModified = new Date('2026-08-23T00:00:00.000Z'); + let archiveWriter = { write: vi.fn() }; + let compressor = new ArchiveCompressor(s3Client, archiveWriter); + compressor.rebuildBefore = Date.parse('2026-08-22T16:49:12.000Z'); + compressor._console = { error: vi.fn(), log: vi.fn() }; + + await compressor.process(); + + expect(s3Client.uploads).toEqual([]); + expect(archiveWriter.write).not.toHaveBeenCalled(); + }); + it('stops after a failed day and removes its temporary files', async () => { vi.useRealTimers(); let s3Client = new FakeS3Client; diff --git a/app/data/ArchiveVerifier.mjs b/app/data/ArchiveVerifier.mjs index 6040c91..cfb4b22 100644 --- a/app/data/ArchiveVerifier.mjs +++ b/app/data/ArchiveVerifier.mjs @@ -4,6 +4,8 @@ import { Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import * as tar from 'tar-stream'; +class ArchiveContentError extends Error {} + function processCompletion(child) { let stderr = ''; child.stderr.setEncoding('utf8'); @@ -69,6 +71,11 @@ export default class ArchiveVerifier decompressor.completed, this.verifyTar(decompressor.stream, inventory), ]); + let verificationResult = results[2]; + if (verificationResult.status === 'rejected' + && verificationResult.reason instanceof ArchiveContentError) { + throw verificationResult.reason; + } let errors = results .filter(result => result.status === 'rejected') .map(result => result.reason); @@ -115,22 +122,22 @@ export default class ArchiveVerifier await pipeline(stream, extract); if (verified.size !== manifestByPath.size) { - throw new Error('Tar archive is missing files from its manifest'); + throw new ArchiveContentError('Tar archive is missing files from its manifest'); } } async verifyEntry(header, entry, manifestByPath, sourceByPath, verified) { if (header.type !== 'file') { - throw new Error(`Tar archive contains a non-file entry: ${header.name}`); + throw new ArchiveContentError(`Tar archive contains a non-file entry: ${header.name}`); } if (verified.has(header.name)) { - throw new Error(`Tar archive contains a duplicate file: ${header.name}`); + throw new ArchiveContentError(`Tar archive contains a duplicate file: ${header.name}`); } let manifestFile = manifestByPath.get(header.name); let sourceObject = sourceByPath.get(header.name); if (!manifestFile || !sourceObject) { - throw new Error(`Tar archive contains an unexpected file: ${header.name}`); + throw new ArchiveContentError(`Tar archive contains an unexpected file: ${header.name}`); } verified.add(header.name); @@ -144,10 +151,10 @@ export default class ArchiveVerifier } if (bytes !== manifestFile.bytes) { - throw new Error(`Size does not match the manifest for ${header.name}`); + throw new ArchiveContentError(`Size does not match the manifest for ${header.name}`); } if (`sha256:${sha256.digest('hex')}` !== manifestFile.hash) { - throw new Error(`SHA-256 does not match the manifest for ${header.name}`); + throw new ArchiveContentError(`SHA-256 does not match the manifest for ${header.name}`); } let etag = sourceObject.etag; @@ -155,10 +162,10 @@ export default class ArchiveVerifier etag = etag.slice(1, -1); } if (!/^[a-fA-F0-9]{32}$/.test(etag)) { - throw new Error(`S3 ETag is not an MD5 hash for ${header.name}`); + throw new ArchiveContentError(`S3 ETag is not an MD5 hash for ${header.name}`); } if (md5.digest('hex') !== etag.toLowerCase()) { - throw new Error(`MD5 does not match the S3 ETag for ${header.name}`); + throw new ArchiveContentError(`MD5 does not match the S3 ETag for ${header.name}`); } } } diff --git a/app/data/ArchiveVerifier.test.mjs b/app/data/ArchiveVerifier.test.mjs index be02155..490de7b 100644 --- a/app/data/ArchiveVerifier.test.mjs +++ b/app/data/ArchiveVerifier.test.mjs @@ -97,12 +97,15 @@ describe('ArchiveVerifier', () => { )).rejects.toThrow('S3 ETag is not an MD5 hash for alpha.json'); }); - it('rejects a file that exists only in the tar archive', async () => { + it('reports an unexpected tar file instead of the resulting broken pipe', async () => { let archive = await createTar([ ['alpha.json', 'alpha\n'], ['extra.json', 'extra\n'], ]); - let verifier = new ArchiveVerifier(identityDecompressor); + let verifier = new ArchiveVerifier(stream => ({ + completed: Promise.reject(new Error('zstd failed (exit 70): Broken pipe')), + stream, + })); await expect(verifier.verify( Readable.from(archive), diff --git a/app/data/TarZstdWriter.mjs b/app/data/TarZstdWriter.mjs index dd266e6..b4e0811 100644 --- a/app/data/TarZstdWriter.mjs +++ b/app/data/TarZstdWriter.mjs @@ -27,6 +27,7 @@ export default class TarZstdWriter { async write(sourceDirectory, archivePath, files) { let tar = spawn('tar', ['-cf', '-', '-C', sourceDirectory, '--', ...files], { + env: { ...process.env, COPYFILE_DISABLE: '1' }, stdio: ['ignore', 'pipe', 'pipe'], }); let zstd = spawn('zstd', [ diff --git a/app/data/TarZstdWriter.test.mjs b/app/data/TarZstdWriter.test.mjs new file mode 100644 index 0000000..10d46f9 --- /dev/null +++ b/app/data/TarZstdWriter.test.mjs @@ -0,0 +1,57 @@ +import { execFile, spawn } from 'node:child_process'; +import { createReadStream } from 'node:fs'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { promisify } from 'node:util'; +import { expect, it } from 'vitest'; +import * as tar from 'tar-stream'; +import TarZstdWriter from './TarZstdWriter.mjs'; + +const execFileAsync = promisify(execFile); + +async function listFiles(stream) { + let files = []; + let extract = tar.extract(); + extract.on('entry', (header, entry, next) => { + files.push(header.name); + entry.on('end', next); + entry.resume(); + }); + await pipeline(stream, extract); + + return files; +} + +it.runIf(process.platform === 'darwin')('does not add macOS metadata files', async () => { + let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'splatoon3ink-tar-test-')); + let sourceDirectory = path.join(temporaryDirectory, 'source'); + let archivePath = path.join(temporaryDirectory, 'archive.tar.zst'); + let rawArchivePath = path.join(temporaryDirectory, 'archive.tar'); + + try { + await fs.mkdir(sourceDirectory); + let sourcePath = path.join(sourceDirectory, 'sample.json'); + await fs.writeFile(sourcePath, '{}\n'); + await execFileAsync('xattr', ['-w', 'com.splatoon3ink.test', 'metadata', sourcePath]); + let rawTarEnvironment = { ...process.env }; + delete rawTarEnvironment.COPYFILE_DISABLE; + await execFileAsync('tar', [ + '-cf', rawArchivePath, + '-C', sourceDirectory, + '--', 'sample.json', + ], { env: rawTarEnvironment }); + expect(await listFiles(createReadStream(rawArchivePath))).toEqual([ + '._sample.json', + 'sample.json', + ]); + + await new TarZstdWriter().write(sourceDirectory, archivePath, ['sample.json']); + + let zstd = spawn('zstd', ['-dc', archivePath]); + expect(await listFiles(zstd.stdout)).toEqual(['sample.json']); + } finally { + await fs.rm(temporaryDirectory, { force: true, recursive: true }); + } +}); From b854bd1fd8a158e8c4064646865da4e73e318182 Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Sat, 22 Aug 2026 10:22:57 -0700 Subject: [PATCH 3/4] Only rebuild archives with macOS metadata --- app/data/ArchiveCompressor.mjs | 77 +++++++++++++++++++++++++++-- app/data/ArchiveCompressor.test.mjs | 59 +++++++++++++++++++++- app/data/ArchiveVerifier.mjs | 13 +++++ app/data/ArchiveVerifier.test.mjs | 19 ++++++- 4 files changed, 160 insertions(+), 8 deletions(-) diff --git a/app/data/ArchiveCompressor.mjs b/app/data/ArchiveCompressor.mjs index 1aea019..f68d6d9 100644 --- a/app/data/ArchiveCompressor.mjs +++ b/app/data/ArchiveCompressor.mjs @@ -12,7 +12,8 @@ import { S3Client, } from '@aws-sdk/client-s3'; import prefixedConsole from '../common/prefixedConsole.mjs'; -import { createArchiveManifest } from './ArchiveManifest.mjs'; +import { createArchiveManifest, parseArchiveManifest } from './ArchiveManifest.mjs'; +import ArchiveVerifier, { AppleDoubleArchiveError } from './ArchiveVerifier.mjs'; import TarZstdWriter from './TarZstdWriter.mjs'; const downloadLimit = 5; @@ -60,9 +61,14 @@ export default class ArchiveCompressor maxDays = Infinity; rebuildBefore = null; - constructor(s3Client, archiveWriter = new TarZstdWriter) { + constructor( + s3Client, + archiveWriter = new TarZstdWriter, + archiveVerifier = new ArchiveVerifier, + ) { this._client = s3Client; this.archiveWriter = archiveWriter; + this.archiveVerifier = archiveVerifier; } async process() { @@ -77,7 +83,9 @@ export default class ArchiveCompressor try { let candidates = await this.getCandidates(); - this.console.log(`Found ${candidates.length} dates to archive`); + this.console.log( + `Found ${candidates.length} dates to ${this.rebuildBefore === null ? 'archive' : 'inspect'}`, + ); for (let candidate of candidates) { if (compressed >= this.maxDays) { @@ -144,7 +152,7 @@ export default class ArchiveCompressor async previewDate(candidate) { let objects = await this.getReadyObjects(candidate); - if (!objects) { + if (!objects || candidate.exists && !await this.needsRebuild(candidate, objects)) { return false; } @@ -159,7 +167,7 @@ export default class ArchiveCompressor async archiveDate(candidate) { let objects = await this.getReadyObjects(candidate); - if (!objects) { + if (!objects || candidate.exists && !await this.needsRebuild(candidate, objects)) { return false; } @@ -215,6 +223,63 @@ export default class ArchiveCompressor return objects; } + async needsRebuild(candidate, objects) { + let manifest = await this.getManifest(candidate); + let archiveStream = await this.getArchive(candidate.archiveKey); + let sourceObjects = objects.map(object => { + if (typeof object.etag !== 'string') { + throw new Error(`S3 returned incomplete metadata for ${object.key}`); + } + + return { + path: object.path, + bytes: object.bytes, + etag: object.etag, + }; + }); + + try { + await this.archiveVerifier.verify(archiveStream, manifest, sourceObjects); + } catch (error) { + if (error instanceof AppleDoubleArchiveError) { + return true; + } + + throw error; + } + + this.console.log(`Skipping ${candidate.date}; existing archive is valid`); + + return false; + } + + async getManifest(candidate) { + let response = await this.s3Client.send(new GetObjectCommand({ + Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, + Key: candidate.manifestKey, + })); + if (!response.Body) { + throw new Error(`S3 returned no body for ${candidate.manifestKey}`); + } + + return parseArchiveManifest( + await response.Body.transformToString(), + candidate.archiveKey, + ); + } + + async getArchive(key) { + let response = await this.s3Client.send(new GetObjectCommand({ + Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, + Key: key, + })); + if (!response.Body) { + throw new Error(`S3 returned no body for ${key}`); + } + + return response.Body; + } + async downloadObjects(prefix, objects, sourceDirectory) { let files = []; let nextObject = 0; @@ -341,8 +406,10 @@ export default class ArchiveCompressor } return { + path: this.relativePath(prefix, object.Key), key: object.Key, bytes: object.Size, + etag: object.ETag, lastModified: object.LastModified, }; }); diff --git a/app/data/ArchiveCompressor.test.mjs b/app/data/ArchiveCompressor.test.mjs index 105ee51..afaeb3d 100644 --- a/app/data/ArchiveCompressor.test.mjs +++ b/app/data/ArchiveCompressor.test.mjs @@ -7,6 +7,28 @@ import { } from '@aws-sdk/client-s3'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import ArchiveCompressor from './ArchiveCompressor.mjs'; +import { AppleDoubleArchiveError } from './ArchiveVerifier.mjs'; + +const archiveKey = '2025/03/2025-03-05.tar.zst'; +const manifestKey = `${archiveKey}.manifest.json`; + +function completedManifest() { + return { + version: 1, + date: '2025-03-05', + createdAt: '2026-08-20T00:00:00.000Z', + archive: { + path: archiveKey, + bytes: 7, + hash: `sha256:${'a'.repeat(64)}`, + }, + files: [{ + path: 'alpha.json', + bytes: 6, + hash: `sha256:${'b'.repeat(64)}`, + }], + }; +} class FakeS3Client { @@ -28,6 +50,17 @@ class FakeS3Client if (command instanceof GetObjectCommand) { this.downloads.push(command.input.Key); + if (command.input.Key === manifestKey) { + return { + Body: { + transformToString: async () => JSON.stringify(completedManifest()), + }, + }; + } + if (command.input.Key === archiveKey) { + return { Body: Readable.from('archive') }; + } + return { Body: Readable.from(this.files.get(command.input.Key)) }; } @@ -66,6 +99,7 @@ class FakeS3Client return { Contents: [...this.files] .filter(([Key]) => Key.startsWith(prefix)) .map(([Key, body]) => ({ + ETag: '"9f9f90dbe3e5ee1218c86b8839db1995"', Key, LastModified: new Date('2025-03-05T23:45:00.000Z'), Size: body.length, @@ -187,15 +221,36 @@ describe('ArchiveCompressor', () => { expect(archiveWriter.write).not.toHaveBeenCalled(); }); - it('rebuilds a completed archive older than the repair cutoff', async () => { + it('leaves a valid completed archive older than the repair cutoff alone', async () => { let s3Client = new FakeS3Client; s3Client.completed = true; + let verifier = { verify: vi.fn() }; + let archiveWriter = { write: vi.fn() }; + let compressor = new ArchiveCompressor(s3Client, archiveWriter, verifier); + compressor.rebuildBefore = Date.parse('2026-08-22T16:49:12.000Z'); + compressor._console = { error: vi.fn(), log: vi.fn() }; + + await compressor.process(); + + expect(verifier.verify).toHaveBeenCalledOnce(); + expect(s3Client.uploads).toEqual([]); + expect(archiveWriter.write).not.toHaveBeenCalled(); + }); + + it('rebuilds a completed archive containing macOS metadata', async () => { + let s3Client = new FakeS3Client; + s3Client.completed = true; + let verifier = { + verify: vi.fn(async () => { + throw new AppleDoubleArchiveError('._alpha.json'); + }), + }; let archiveWriter = { async write(sourceDirectory, archivePath) { await fs.writeFile(archivePath, 'replacement archive'); }, }; - let compressor = new ArchiveCompressor(s3Client, archiveWriter); + let compressor = new ArchiveCompressor(s3Client, archiveWriter, verifier); compressor.rebuildBefore = Date.parse('2026-08-22T16:49:12.000Z'); compressor._console = { error: vi.fn(), log: vi.fn() }; diff --git a/app/data/ArchiveVerifier.mjs b/app/data/ArchiveVerifier.mjs index cfb4b22..1f687a6 100644 --- a/app/data/ArchiveVerifier.mjs +++ b/app/data/ArchiveVerifier.mjs @@ -1,11 +1,20 @@ import crypto from 'node:crypto'; import { spawn } from 'node:child_process'; +import path from 'node:path'; import { Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import * as tar from 'tar-stream'; class ArchiveContentError extends Error {} +export class AppleDoubleArchiveError extends ArchiveContentError +{ + constructor(filePath) { + super(`Tar archive contains an unexpected file: ${filePath}`); + this.path = filePath; + } +} + function processCompletion(child) { let stderr = ''; child.stderr.setEncoding('utf8'); @@ -137,6 +146,10 @@ export default class ArchiveVerifier let manifestFile = manifestByPath.get(header.name); let sourceObject = sourceByPath.get(header.name); if (!manifestFile || !sourceObject) { + if (path.posix.basename(header.name).startsWith('._')) { + throw new AppleDoubleArchiveError(header.name); + } + throw new ArchiveContentError(`Tar archive contains an unexpected file: ${header.name}`); } verified.add(header.name); diff --git a/app/data/ArchiveVerifier.test.mjs b/app/data/ArchiveVerifier.test.mjs index 490de7b..1687791 100644 --- a/app/data/ArchiveVerifier.test.mjs +++ b/app/data/ArchiveVerifier.test.mjs @@ -1,7 +1,7 @@ import { Readable } from 'node:stream'; import { describe, expect, it } from 'vitest'; import * as tar from 'tar-stream'; -import ArchiveVerifier from './ArchiveVerifier.mjs'; +import ArchiveVerifier, { AppleDoubleArchiveError } from './ArchiveVerifier.mjs'; async function createTar(files) { let pack = tar.pack(); @@ -114,6 +114,23 @@ describe('ArchiveVerifier', () => { )).rejects.toThrow('Tar archive contains an unexpected file: extra.json'); }); + it('identifies unexpected macOS AppleDouble files', async () => { + let archive = await createTar([ + ['._alpha.json', 'metadata'], + ['alpha.json', 'alpha\n'], + ]); + let verifier = new ArchiveVerifier(identityDecompressor); + + let error = await verifier.verify( + Readable.from(archive), + manifestFor(archive), + [sourceObject()], + ).catch(reason => reason); + + expect(error).toBeInstanceOf(AppleDoubleArchiveError); + expect(error.path).toBe('._alpha.json'); + }); + it('rejects a file whose MD5 does not match its S3 ETag', async () => { let archive = await createTar([['alpha.json', 'alpha\n']]); let verifier = new ArchiveVerifier(identityDecompressor); From b3f46b8e2523fd1c252239bceda590419367b550 Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Sat, 22 Aug 2026 10:43:45 -0700 Subject: [PATCH 4/4] Separate archive verification and repair --- app/data/ArchiveBuilder.mjs | 147 ++++++++ app/data/ArchiveCompressor.mjs | 340 ++---------------- app/data/ArchiveCompressor.test.mjs | 86 +---- ...erifier.mjs => ArchiveContentVerifier.mjs} | 34 +- ...st.mjs => ArchiveContentVerifier.test.mjs} | 29 +- app/data/ArchiveInventory.mjs | 169 +++++++++ app/data/ArchivePruner.mjs | 160 +-------- app/data/ArchivePruner.test.mjs | 28 +- app/data/ArchiveVerifyCommand.mjs | 161 +++++++++ app/data/ArchiveVerifyCommand.test.mjs | 157 ++++++++ app/data/RemoteArchiveVerifier.mjs | 62 ++++ app/data/RemoteArchiveVerifier.test.mjs | 64 ++++ app/index.mjs | 2 + package.json | 2 + 14 files changed, 898 insertions(+), 543 deletions(-) create mode 100644 app/data/ArchiveBuilder.mjs rename app/data/{ArchiveVerifier.mjs => ArchiveContentVerifier.mjs} (86%) rename app/data/{ArchiveVerifier.test.mjs => ArchiveContentVerifier.test.mjs} (80%) create mode 100644 app/data/ArchiveInventory.mjs create mode 100644 app/data/ArchiveVerifyCommand.mjs create mode 100644 app/data/ArchiveVerifyCommand.test.mjs create mode 100644 app/data/RemoteArchiveVerifier.mjs create mode 100644 app/data/RemoteArchiveVerifier.test.mjs diff --git a/app/data/ArchiveBuilder.mjs b/app/data/ArchiveBuilder.mjs new file mode 100644 index 0000000..7c6e2de --- /dev/null +++ b/app/data/ArchiveBuilder.mjs @@ -0,0 +1,147 @@ +import crypto from 'node:crypto'; +import { createReadStream, createWriteStream } from 'node:fs'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { + GetObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import prefixedConsole from '../common/prefixedConsole.mjs'; +import { createArchiveManifest } from './ArchiveManifest.mjs'; +import TarZstdWriter from './TarZstdWriter.mjs'; + +const downloadLimit = 5; + +export default class ArchiveBuilder +{ + constructor(s3Client, archiveWriter = new TarZstdWriter) { + this._client = s3Client; + this.archiveWriter = archiveWriter; + } + + async build(candidate, objects) { + let temporaryDirectory = await fs.mkdtemp( + path.join(os.tmpdir(), `splatoon3ink-${candidate.date}-`), + ); + let sourceDirectory = path.join(temporaryDirectory, 'source'); + let archivePath = path.join(temporaryDirectory, `${candidate.date}.tar.zst`); + + try { + await fs.mkdir(sourceDirectory); + this.console.log(`Downloading ${objects.length} files for ${candidate.date}`); + let files = await this.downloadObjects(objects, sourceDirectory); + files.sort((a, b) => a.path.localeCompare(b.path)); + + this.console.log(`Compressing ${candidate.date}`); + await this.archiveWriter.write(sourceDirectory, archivePath, files.map(file => file.path)); + + let archive = { + path: candidate.archiveKey, + bytes: (await fs.stat(archivePath)).size, + hash: `sha256:${await this.hashFile(archivePath)}`, + }; + let manifest = createArchiveManifest(candidate.date, archive, files); + + await this.upload(candidate.archiveKey, await fs.readFile(archivePath), 'application/zstd'); + await this.upload( + candidate.manifestKey, + Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`), + 'application/json', + ); + } finally { + await fs.rm(temporaryDirectory, { force: true, recursive: true }); + } + } + + get console() { + this._console ??= prefixedConsole('Archive Builder'); + + return this._console; + } + + get s3Client() { + return this._client ??= new S3Client({ + endpoint: process.env.AWS_S3_ENDPOINT, + region: process.env.AWS_REGION, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }); + } + + async downloadObjects(objects, sourceDirectory) { + let files = []; + let nextObject = 0; + let error; + let worker = async () => { + while (!error && nextObject < objects.length) { + let index = nextObject++; + try { + files[index] = await this.downloadObject(objects[index], sourceDirectory); + } catch (e) { + error ??= e; + } + } + }; + + let workers = Array.from({ length: Math.min(downloadLimit, objects.length) }, worker); + await Promise.all(workers); + if (error) { + throw error; + } + + return files; + } + + async downloadObject(object, sourceDirectory) { + let destination = path.join(sourceDirectory, object.path); + await fs.mkdir(path.dirname(destination), { recursive: true }); + + let response = await this.s3Client.send(new GetObjectCommand({ + Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, + Key: object.key, + })); + if (!response.Body) { + throw new Error(`S3 returned no body for ${object.key}`); + } + + await pipeline(response.Body, createWriteStream(destination, { flags: 'wx' })); + let bytes = (await fs.stat(destination)).size; + if (bytes !== object.bytes) { + throw new Error(`Downloaded size does not match S3 listing for ${object.key}`); + } + + return { + path: object.path, + bytes, + hash: `sha256:${await this.hashFile(destination)}`, + }; + } + + async hashFile(file) { + let hash = crypto.createHash('sha256'); + for await (let chunk of createReadStream(file)) { + hash.update(chunk); + } + + return hash.digest('hex'); + } + + async upload(key, body, contentType) { + await this.s3Client.send(new PutObjectCommand({ + ACL: 'public-read', + Body: body, + Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, + ContentLength: body.length, + ContentType: contentType, + Key: key, + })); + } + +} diff --git a/app/data/ArchiveCompressor.mjs b/app/data/ArchiveCompressor.mjs index f68d6d9..a14a944 100644 --- a/app/data/ArchiveCompressor.mjs +++ b/app/data/ArchiveCompressor.mjs @@ -1,29 +1,15 @@ -import crypto from 'node:crypto'; -import { createReadStream, createWriteStream } from 'node:fs'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { pipeline } from 'node:stream/promises'; import * as Sentry from '@sentry/node'; -import { - GetObjectCommand, - ListObjectsV2Command, - PutObjectCommand, - S3Client, -} from '@aws-sdk/client-s3'; +import { S3Client } from '@aws-sdk/client-s3'; import prefixedConsole from '../common/prefixedConsole.mjs'; -import { createArchiveManifest, parseArchiveManifest } from './ArchiveManifest.mjs'; -import ArchiveVerifier, { AppleDoubleArchiveError } from './ArchiveVerifier.mjs'; -import TarZstdWriter from './TarZstdWriter.mjs'; +import ArchiveBuilder from './ArchiveBuilder.mjs'; +import ArchiveInventory from './ArchiveInventory.mjs'; -const downloadLimit = 5; const quietPeriod = 30 * 60 * 1000; -export function compressArchives(maxDays = Infinity, dryRun = false, rebuildBefore = null) { +export function compressArchives(maxDays = Infinity, dryRun = false) { let compressor = new ArchiveCompressor; compressor.maxDays = maxDays; compressor.dryRun = dryRun; - compressor.rebuildBefore = rebuildBefore; return compressor.process(); } @@ -31,19 +17,15 @@ export function compressArchives(maxDays = Infinity, dryRun = false, rebuildBefo export function compressArchivesFromCli(args) { let dryRun = false; let maxDays = Infinity; - let rebuildBefore = null; for (let i = 0; i < args.length; i++) { if (args[i] === '--dry-run') { dryRun = true; - } else if (args[i] === '--rebuild-before' && Number.isFinite(Date.parse(args[i + 1]))) { - rebuildBefore = Date.parse(args[++i]); } else if (args[i] === '--max-days' && /^\d+$/.test(args[i + 1])) { maxDays = Number(args[++i]); } else { throw new Error( - 'Usage: npm run data:archive:compress -- ' - + '[--dry-run] [--max-days DAYS] [--rebuild-before TIMESTAMP]', + 'Usage: npm run data:archive:compress -- [--dry-run] [--max-days DAYS]', ); } } @@ -52,23 +34,18 @@ export function compressArchivesFromCli(args) { throw new Error('--max-days must be at least 1'); } - return compressArchives(maxDays, dryRun, rebuildBefore); + return compressArchives(maxDays, dryRun); } export default class ArchiveCompressor { dryRun = false; maxDays = Infinity; - rebuildBefore = null; - constructor( - s3Client, - archiveWriter = new TarZstdWriter, - archiveVerifier = new ArchiveVerifier, - ) { + constructor(s3Client, archiveBuilder, archiveInventory) { this._client = s3Client; - this.archiveWriter = archiveWriter; - this.archiveVerifier = archiveVerifier; + this._archiveBuilder = archiveBuilder; + this._archiveInventory = archiveInventory; } async process() { @@ -83,9 +60,7 @@ export default class ArchiveCompressor try { let candidates = await this.getCandidates(); - this.console.log( - `Found ${candidates.length} dates to ${this.rebuildBefore === null ? 'archive' : 'inspect'}`, - ); + this.console.log(`Found ${candidates.length} dates to archive`); for (let candidate of candidates) { if (compressed >= this.maxDays) { @@ -148,17 +123,24 @@ export default class ArchiveCompressor }); } + get archiveBuilder() { + return this._archiveBuilder ??= new ArchiveBuilder(this.s3Client); + } + + get archiveInventory() { + return this._archiveInventory ??= new ArchiveInventory(this.s3Client); + } + // Archive compression async previewDate(candidate) { let objects = await this.getReadyObjects(candidate); - if (!objects || candidate.exists && !await this.needsRebuild(candidate, objects)) { + if (!objects) { return false; } this.console.log( - `Would ${candidate.exists ? 'replace' : 'create'} ` - + `${candidate.archiveKey} and ${candidate.manifestKey} ` + `Would create ${candidate.archiveKey} and ${candidate.manifestKey} ` + `from ${objects.length} files`, ); @@ -167,48 +149,18 @@ export default class ArchiveCompressor async archiveDate(candidate) { let objects = await this.getReadyObjects(candidate); - if (!objects || candidate.exists && !await this.needsRebuild(candidate, objects)) { + if (!objects) { return false; } - let temporaryDirectory = await fs.mkdtemp( - path.join(os.tmpdir(), `splatoon3ink-${candidate.date}-`), - ); - let sourceDirectory = path.join(temporaryDirectory, 'source'); - let archivePath = path.join(temporaryDirectory, `${candidate.date}.tar.zst`); + this.console.log(`Creating ${candidate.archiveKey}`); + await this.archiveBuilder.build(candidate, objects); - try { - await fs.mkdir(sourceDirectory); - this.console.log(`Downloading ${objects.length} files for ${candidate.date}`); - let files = await this.downloadObjects(candidate.prefix, objects, sourceDirectory); - files.sort((a, b) => a.path.localeCompare(b.path)); - - this.console.log(`Compressing ${candidate.date}`); - await this.archiveWriter.write(sourceDirectory, archivePath, files.map(file => file.path)); - - let archive = { - path: candidate.archiveKey, - bytes: (await fs.stat(archivePath)).size, - hash: `sha256:${await this.hashFile(archivePath)}`, - }; - let manifest = createArchiveManifest(candidate.date, archive, files); - - this.console.log(`${candidate.exists ? 'Replacing' : 'Uploading'} ${candidate.archiveKey}`); - await this.upload(candidate.archiveKey, await fs.readFile(archivePath), 'application/zstd'); - await this.upload( - candidate.manifestKey, - Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`), - 'application/json', - ); - - return true; - } finally { - await fs.rm(temporaryDirectory, { force: true, recursive: true }); - } + return true; } async getReadyObjects(candidate) { - let objects = await this.getSourceObjects(candidate.prefix); + let objects = await this.archiveInventory.getSourceObjects(candidate.prefix); if (objects.length === 0) { return null; } @@ -223,249 +175,13 @@ export default class ArchiveCompressor return objects; } - async needsRebuild(candidate, objects) { - let manifest = await this.getManifest(candidate); - let archiveStream = await this.getArchive(candidate.archiveKey); - let sourceObjects = objects.map(object => { - if (typeof object.etag !== 'string') { - throw new Error(`S3 returned incomplete metadata for ${object.key}`); - } - - return { - path: object.path, - bytes: object.bytes, - etag: object.etag, - }; - }); - - try { - await this.archiveVerifier.verify(archiveStream, manifest, sourceObjects); - } catch (error) { - if (error instanceof AppleDoubleArchiveError) { - return true; - } - - throw error; - } - - this.console.log(`Skipping ${candidate.date}; existing archive is valid`); - - return false; - } - - async getManifest(candidate) { - let response = await this.s3Client.send(new GetObjectCommand({ - Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, - Key: candidate.manifestKey, - })); - if (!response.Body) { - throw new Error(`S3 returned no body for ${candidate.manifestKey}`); - } - - return parseArchiveManifest( - await response.Body.transformToString(), - candidate.archiveKey, - ); - } - - async getArchive(key) { - let response = await this.s3Client.send(new GetObjectCommand({ - Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, - Key: key, - })); - if (!response.Body) { - throw new Error(`S3 returned no body for ${key}`); - } - - return response.Body; - } - - async downloadObjects(prefix, objects, sourceDirectory) { - let files = []; - let nextObject = 0; - let error; - let worker = async () => { - while (!error && nextObject < objects.length) { - let index = nextObject++; - try { - files[index] = await this.downloadObject(prefix, objects[index], sourceDirectory); - } catch (e) { - error ??= e; - } - } - }; - - let workers = Array.from({ length: Math.min(downloadLimit, objects.length) }, worker); - await Promise.all(workers); - if (error) { - throw error; - } - - return files; - } - - async downloadObject(prefix, object, sourceDirectory) { - let relativePath = this.relativePath(prefix, object.key); - let destination = path.join(sourceDirectory, relativePath); - await fs.mkdir(path.dirname(destination), { recursive: true }); - - let response = await this.s3Client.send(new GetObjectCommand({ - Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, - Key: object.key, - })); - if (!response.Body) { - throw new Error(`S3 returned no body for ${object.key}`); - } - - await pipeline(response.Body, createWriteStream(destination, { flags: 'wx' })); - let bytes = (await fs.stat(destination)).size; - if (bytes !== object.bytes) { - throw new Error(`Downloaded size does not match S3 listing for ${object.key}`); - } - - return { - path: relativePath, - bytes, - hash: `sha256:${await this.hashFile(destination)}`, - }; - } - - async hashFile(file) { - let hash = crypto.createHash('sha256'); - for await (let chunk of createReadStream(file)) { - hash.update(chunk); - } - - return hash.digest('hex'); - } - - async upload(key, body, contentType) { - await this.s3Client.send(new PutObjectCommand({ - ACL: 'public-read', - Body: body, - Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, - ContentLength: body.length, - ContentType: contentType, - Key: key, - })); - } - // S3 discovery async getCandidates() { - let candidates = []; - let years = (await this.list('', '/')).prefixes.filter(prefix => /^\d{4}\/$/.test(prefix)); + let today = new Date().toISOString().slice(0, 10); - for (let year of years.sort()) { - let months = (await this.list(year, '/')).prefixes - .filter(prefix => /^\d{4}\/\d{2}\/$/.test(prefix)); - - for (let month of months.sort()) { - let listing = await this.list(month, '/'); - let existing = new Map(listing.objects.map(object => [object.Key, object])); - - for (let prefix of listing.prefixes.sort()) { - let date = this.dateFromPrefix(prefix); - if (!date || date >= new Date().toISOString().slice(0, 10)) { - continue; - } - - let archiveKey = `${month}${date}.tar.zst`; - let manifestKey = `${archiveKey}.manifest.json`; - let exists = existing.has(archiveKey) && existing.has(manifestKey); - if (exists) { - if (this.rebuildBefore === null) { - continue; - } - - let modified = existing.get(manifestKey).LastModified?.getTime(); - if (!Number.isFinite(modified)) { - throw new Error(`S3 returned incomplete metadata for ${manifestKey}`); - } - if (modified >= this.rebuildBefore) { - continue; - } - } - - candidates.push({ archiveKey, date, exists, manifestKey, prefix }); - } - } - } - - return candidates.sort((a, b) => a.date.localeCompare(b.date)); + return (await this.archiveInventory.getDates()) + .filter(item => item.hasSource && !item.hasArchive && item.date < today); } - async getSourceObjects(prefix) { - let listing = await this.list(prefix); - - return listing.objects - .filter(object => object.Key && object.Key !== prefix && !object.Key.endsWith('/')) - .map(object => { - if (!object.LastModified || !Number.isFinite(object.Size)) { - throw new Error(`S3 returned incomplete metadata for ${object.Key}`); - } - - return { - path: this.relativePath(prefix, object.Key), - key: object.Key, - bytes: object.Size, - etag: object.ETag, - lastModified: object.LastModified, - }; - }); - } - - async list(prefix, delimiter) { - let prefixes = []; - let objects = []; - let continuationToken; - - do { - let response = await this.s3Client.send(new ListObjectsV2Command({ - Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, - ContinuationToken: continuationToken, - Delimiter: delimiter, - Prefix: prefix, - })); - prefixes.push(...(response.CommonPrefixes ?? []).map(item => item.Prefix).filter(Boolean)); - objects.push(...(response.Contents ?? [])); - - if (response.IsTruncated && !response.NextContinuationToken) { - throw new Error(`S3 listing for ${prefix} was truncated without a continuation token`); - } - continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; - } while (continuationToken); - - return { objects, prefixes }; - } - - // Helpers - - dateFromPrefix(prefix) { - let match = prefix.match(/^(\d{4})\/(\d{2})\/(\d{2})\/$/); - if (!match) { - return null; - } - - let date = `${match[1]}-${match[2]}-${match[3]}`; - let parsedDate = new Date(`${date}T00:00:00.000Z`); - if (Number.isNaN(parsedDate.getTime()) || parsedDate.toISOString().slice(0, 10) !== date) { - return null; - } - - return date; - } - - relativePath(prefix, key) { - let relativePath = key.slice(prefix.length); - if (!key.startsWith(prefix) - || !relativePath - || path.posix.normalize(relativePath) !== relativePath - || path.posix.isAbsolute(relativePath) - || relativePath.split('/').includes('..')) { - throw new Error(`Invalid archive object path: ${key}`); - } - - return relativePath; - } } diff --git a/app/data/ArchiveCompressor.test.mjs b/app/data/ArchiveCompressor.test.mjs index afaeb3d..9ac3914 100644 --- a/app/data/ArchiveCompressor.test.mjs +++ b/app/data/ArchiveCompressor.test.mjs @@ -6,8 +6,8 @@ import { PutObjectCommand, } from '@aws-sdk/client-s3'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ArchiveBuilder from './ArchiveBuilder.mjs'; import ArchiveCompressor from './ArchiveCompressor.mjs'; -import { AppleDoubleArchiveError } from './ArchiveVerifier.mjs'; const archiveKey = '2025/03/2025-03-05.tar.zst'; const manifestKey = `${archiveKey}.manifest.json`; @@ -158,7 +158,9 @@ describe('ArchiveCompressor', () => { await fs.writeFile(archivePath, 'fake compressed archive'); }, }; - let compressor = new ArchiveCompressor(s3Client, archiveWriter); + let archiveBuilder = new ArchiveBuilder(s3Client, archiveWriter); + archiveBuilder._console = { log: vi.fn() }; + let compressor = new ArchiveCompressor(s3Client, archiveBuilder); compressor._console = { error: vi.fn(), log: vi.fn() }; await compressor.process(); @@ -195,8 +197,8 @@ describe('ArchiveCompressor', () => { it('does not download or upload files during a dry run', async () => { let s3Client = new FakeS3Client; - let archiveWriter = { write: vi.fn() }; - let compressor = new ArchiveCompressor(s3Client, archiveWriter); + let archiveBuilder = { build: vi.fn() }; + let compressor = new ArchiveCompressor(s3Client, archiveBuilder); compressor.dryRun = true; compressor._console = { error: vi.fn(), log: vi.fn() }; @@ -204,77 +206,21 @@ describe('ArchiveCompressor', () => { expect(s3Client.downloads).toEqual([]); expect(s3Client.uploads).toEqual([]); - expect(archiveWriter.write).not.toHaveBeenCalled(); + expect(archiveBuilder.build).not.toHaveBeenCalled(); }); it('leaves a completed archive alone', async () => { let s3Client = new FakeS3Client; s3Client.completed = true; - let archiveWriter = { write: vi.fn() }; - let compressor = new ArchiveCompressor(s3Client, archiveWriter); + let archiveBuilder = { build: vi.fn() }; + let compressor = new ArchiveCompressor(s3Client, archiveBuilder); compressor._console = { error: vi.fn(), log: vi.fn() }; await compressor.process(); expect(s3Client.downloads).toEqual([]); expect(s3Client.uploads).toEqual([]); - expect(archiveWriter.write).not.toHaveBeenCalled(); - }); - - it('leaves a valid completed archive older than the repair cutoff alone', async () => { - let s3Client = new FakeS3Client; - s3Client.completed = true; - let verifier = { verify: vi.fn() }; - let archiveWriter = { write: vi.fn() }; - let compressor = new ArchiveCompressor(s3Client, archiveWriter, verifier); - compressor.rebuildBefore = Date.parse('2026-08-22T16:49:12.000Z'); - compressor._console = { error: vi.fn(), log: vi.fn() }; - - await compressor.process(); - - expect(verifier.verify).toHaveBeenCalledOnce(); - expect(s3Client.uploads).toEqual([]); - expect(archiveWriter.write).not.toHaveBeenCalled(); - }); - - it('rebuilds a completed archive containing macOS metadata', async () => { - let s3Client = new FakeS3Client; - s3Client.completed = true; - let verifier = { - verify: vi.fn(async () => { - throw new AppleDoubleArchiveError('._alpha.json'); - }), - }; - let archiveWriter = { - async write(sourceDirectory, archivePath) { - await fs.writeFile(archivePath, 'replacement archive'); - }, - }; - let compressor = new ArchiveCompressor(s3Client, archiveWriter, verifier); - compressor.rebuildBefore = Date.parse('2026-08-22T16:49:12.000Z'); - compressor._console = { error: vi.fn(), log: vi.fn() }; - - await compressor.process(); - - expect(s3Client.uploads.map(upload => upload.Key)).toEqual([ - '2025/03/2025-03-05.tar.zst', - '2025/03/2025-03-05.tar.zst.manifest.json', - ]); - }); - - it('leaves a rebuilt archive newer than the repair cutoff alone', async () => { - let s3Client = new FakeS3Client; - s3Client.completed = true; - s3Client.manifestLastModified = new Date('2026-08-23T00:00:00.000Z'); - let archiveWriter = { write: vi.fn() }; - let compressor = new ArchiveCompressor(s3Client, archiveWriter); - compressor.rebuildBefore = Date.parse('2026-08-22T16:49:12.000Z'); - compressor._console = { error: vi.fn(), log: vi.fn() }; - - await compressor.process(); - - expect(s3Client.uploads).toEqual([]); - expect(archiveWriter.write).not.toHaveBeenCalled(); + expect(archiveBuilder.build).not.toHaveBeenCalled(); }); it('stops after a failed day and removes its temporary files', async () => { @@ -289,7 +235,9 @@ describe('ArchiveCompressor', () => { throw new Error('compression failed'); }, }; - let compressor = new ArchiveCompressor(s3Client, archiveWriter); + let archiveBuilder = new ArchiveBuilder(s3Client, archiveWriter); + archiveBuilder._console = { log: vi.fn() }; + let compressor = new ArchiveCompressor(s3Client, archiveBuilder); compressor._console = { error: vi.fn(), log: vi.fn() }; await expect(compressor.process()).rejects.toThrow('compression failed'); @@ -309,7 +257,9 @@ describe('ArchiveCompressor', () => { await fs.writeFile(archivePath, 'archive'); }, }; - let compressor = new ArchiveCompressor(s3Client, archiveWriter); + let archiveBuilder = new ArchiveBuilder(s3Client, archiveWriter); + archiveBuilder._console = { log: vi.fn() }; + let compressor = new ArchiveCompressor(s3Client, archiveBuilder); compressor._console = { error: vi.fn(), log: vi.fn() }; await compressor.process(); @@ -329,7 +279,9 @@ describe('ArchiveCompressor', () => { await fs.writeFile(archivePath, 'archive'); }, }; - let compressor = new ArchiveCompressor(s3Client, archiveWriter); + let archiveBuilder = new ArchiveBuilder(s3Client, archiveWriter); + archiveBuilder._console = { log: vi.fn() }; + let compressor = new ArchiveCompressor(s3Client, archiveBuilder); compressor.maxDays = 1; compressor._console = { error: vi.fn(), log: vi.fn() }; diff --git a/app/data/ArchiveVerifier.mjs b/app/data/ArchiveContentVerifier.mjs similarity index 86% rename from app/data/ArchiveVerifier.mjs rename to app/data/ArchiveContentVerifier.mjs index 1f687a6..5e0531e 100644 --- a/app/data/ArchiveVerifier.mjs +++ b/app/data/ArchiveContentVerifier.mjs @@ -50,7 +50,7 @@ function decompressZstd(stream) { }; } -export default class ArchiveVerifier +export default class ArchiveContentVerifier { constructor(decompress = decompressZstd) { this.decompress = decompress; @@ -105,6 +105,14 @@ export default class ArchiveVerifier matchFiles(manifestFiles, sourceObjects) { let manifestByPath = new Map(manifestFiles.map(file => [file.path, file])); + if (sourceObjects === null) { + if (manifestByPath.size !== manifestFiles.length) { + throw new Error('Archive manifest contains duplicate file paths'); + } + + return { manifestByPath, sourceByPath: null }; + } + let sourceByPath = new Map(sourceObjects.map(object => [object.path, object])); if (manifestByPath.size !== manifestFiles.length || sourceByPath.size !== sourceObjects.length @@ -144,8 +152,8 @@ export default class ArchiveVerifier } let manifestFile = manifestByPath.get(header.name); - let sourceObject = sourceByPath.get(header.name); - if (!manifestFile || !sourceObject) { + let sourceObject = sourceByPath?.get(header.name); + if (!manifestFile || sourceByPath && !sourceObject) { if (path.posix.basename(header.name).startsWith('._')) { throw new AppleDoubleArchiveError(header.name); } @@ -170,15 +178,17 @@ export default class ArchiveVerifier throw new ArchiveContentError(`SHA-256 does not match the manifest for ${header.name}`); } - let etag = sourceObject.etag; - if (etag.startsWith('"') && etag.endsWith('"')) { - etag = etag.slice(1, -1); - } - if (!/^[a-fA-F0-9]{32}$/.test(etag)) { - throw new ArchiveContentError(`S3 ETag is not an MD5 hash for ${header.name}`); - } - if (md5.digest('hex') !== etag.toLowerCase()) { - throw new ArchiveContentError(`MD5 does not match the S3 ETag for ${header.name}`); + if (sourceObject) { + let etag = sourceObject.etag; + if (etag.startsWith('"') && etag.endsWith('"')) { + etag = etag.slice(1, -1); + } + if (!/^[a-fA-F0-9]{32}$/.test(etag)) { + throw new ArchiveContentError(`S3 ETag is not an MD5 hash for ${header.name}`); + } + if (md5.digest('hex') !== etag.toLowerCase()) { + throw new ArchiveContentError(`MD5 does not match the S3 ETag for ${header.name}`); + } } } } diff --git a/app/data/ArchiveVerifier.test.mjs b/app/data/ArchiveContentVerifier.test.mjs similarity index 80% rename from app/data/ArchiveVerifier.test.mjs rename to app/data/ArchiveContentVerifier.test.mjs index 1687791..c83e8de 100644 --- a/app/data/ArchiveVerifier.test.mjs +++ b/app/data/ArchiveContentVerifier.test.mjs @@ -1,7 +1,7 @@ import { Readable } from 'node:stream'; import { describe, expect, it } from 'vitest'; import * as tar from 'tar-stream'; -import ArchiveVerifier, { AppleDoubleArchiveError } from './ArchiveVerifier.mjs'; +import ArchiveContentVerifier, { AppleDoubleArchiveError } from './ArchiveContentVerifier.mjs'; async function createTar(files) { let pack = tar.pack(); @@ -46,10 +46,10 @@ function sourceObject(etag = '9f9f90dbe3e5ee1218c86b8839db1995') { const identityDecompressor = stream => ({ completed: Promise.resolve(), stream }); -describe('ArchiveVerifier', () => { +describe('ArchiveContentVerifier', () => { it('verifies a tar stream against its manifest and live S3 objects', async () => { let archive = await createTar([['alpha.json', 'alpha\n']]); - let verifier = new ArchiveVerifier(identityDecompressor); + let verifier = new ArchiveContentVerifier(identityDecompressor); await expect(verifier.verify( Readable.from(archive), @@ -58,10 +58,21 @@ describe('ArchiveVerifier', () => { )).resolves.toBeUndefined(); }); + it('verifies a pruned tar stream against its manifest', async () => { + let archive = await createTar([['alpha.json', 'alpha\n']]); + let verifier = new ArchiveContentVerifier(identityDecompressor); + + await expect(verifier.verify( + Readable.from(archive), + manifestFor(archive), + null, + )).resolves.toBeUndefined(); + }); + it('rejects duplicate live S3 paths', async () => { let archive = await createTar([['alpha.json', 'alpha\n']]); let source = sourceObject(); - let verifier = new ArchiveVerifier(identityDecompressor); + let verifier = new ArchiveContentVerifier(identityDecompressor); let archiveStream = new Readable({ read() { this.destroy(new Error('Archive should not be read')); @@ -77,7 +88,7 @@ describe('ArchiveVerifier', () => { it('rejects a file whose SHA-256 does not match the manifest', async () => { let archive = await createTar([['alpha.json', 'alpha\n']]); - let verifier = new ArchiveVerifier(identityDecompressor); + let verifier = new ArchiveContentVerifier(identityDecompressor); await expect(verifier.verify( Readable.from(archive), @@ -88,7 +99,7 @@ describe('ArchiveVerifier', () => { it('rejects an S3 ETag that is not a plain MD5 hash', async () => { let archive = await createTar([['alpha.json', 'alpha\n']]); - let verifier = new ArchiveVerifier(identityDecompressor); + let verifier = new ArchiveContentVerifier(identityDecompressor); await expect(verifier.verify( Readable.from(archive), @@ -102,7 +113,7 @@ describe('ArchiveVerifier', () => { ['alpha.json', 'alpha\n'], ['extra.json', 'extra\n'], ]); - let verifier = new ArchiveVerifier(stream => ({ + let verifier = new ArchiveContentVerifier(stream => ({ completed: Promise.reject(new Error('zstd failed (exit 70): Broken pipe')), stream, })); @@ -119,7 +130,7 @@ describe('ArchiveVerifier', () => { ['._alpha.json', 'metadata'], ['alpha.json', 'alpha\n'], ]); - let verifier = new ArchiveVerifier(identityDecompressor); + let verifier = new ArchiveContentVerifier(identityDecompressor); let error = await verifier.verify( Readable.from(archive), @@ -133,7 +144,7 @@ describe('ArchiveVerifier', () => { it('rejects a file whose MD5 does not match its S3 ETag', async () => { let archive = await createTar([['alpha.json', 'alpha\n']]); - let verifier = new ArchiveVerifier(identityDecompressor); + let verifier = new ArchiveContentVerifier(identityDecompressor); await expect(verifier.verify( Readable.from(archive), diff --git a/app/data/ArchiveInventory.mjs b/app/data/ArchiveInventory.mjs new file mode 100644 index 0000000..19142d9 --- /dev/null +++ b/app/data/ArchiveInventory.mjs @@ -0,0 +1,169 @@ +import path from 'node:path'; +import { + ListObjectsV2Command, + S3Client, +} from '@aws-sdk/client-s3'; + +export default class ArchiveInventory +{ + constructor(s3Client) { + this._client = s3Client; + } + + async getDates() { + let dates = new Map; + let years = (await this.list('', '/')).prefixes.filter(prefix => /^\d{4}\/$/.test(prefix)); + + for (let year of years.sort()) { + let months = (await this.list(year, '/')).prefixes + .filter(prefix => /^\d{4}\/\d{2}\/$/.test(prefix)); + + for (let month of months.sort()) { + let listing = await this.list(month, '/'); + + for (let prefix of listing.prefixes) { + let date = this.dateFromPrefix(prefix); + if (date) { + dates.set(date, this.createDate(date, prefix, true)); + } + } + + for (let object of listing.objects) { + let match = object.Key?.match(/^(\d{4}\/\d{2})\/(\d{4}-\d{2}-\d{2})\.tar\.zst(\.manifest\.json)?$/); + if (!match || `${match[1]}/` !== month || !this.isDate(match[2])) { + continue; + } + + let date = match[2]; + if (`${date.slice(0, 4)}/${date.slice(5, 7)}/` !== month) { + continue; + } + let item = dates.get(date) ?? this.createDate( + date, + `${date.slice(0, 4)}/${date.slice(5, 7)}/${date.slice(8, 10)}/`, + false, + ); + if (match[3]) { + item.hasManifest = true; + } else { + item.hasArchive = true; + } + dates.set(date, item); + } + } + } + + for (let item of dates.values()) { + if (item.hasArchive !== item.hasManifest) { + throw new Error(`Archive and manifest are incomplete for ${item.date}`); + } + } + + return [...dates.values()].sort((a, b) => a.date.localeCompare(b.date)); + } + + async getSourceObjects(prefix) { + let listing = await this.list(prefix); + + return listing.objects + .filter(object => object.Key && object.Key !== prefix && !object.Key.endsWith('/')) + .map(object => { + if (!object.LastModified + || !Number.isFinite(object.Size) + || typeof object.ETag !== 'string') { + throw new Error(`S3 returned incomplete metadata for ${object.Key}`); + } + + return { + path: this.relativePath(prefix, object.Key), + bytes: object.Size, + etag: object.ETag, + key: object.Key, + lastModified: object.LastModified, + }; + }) + .sort((a, b) => a.path.localeCompare(b.path)); + } + + get s3Client() { + return this._client ??= new S3Client({ + endpoint: process.env.AWS_S3_ENDPOINT, + region: process.env.AWS_REGION, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }); + } + + async list(prefix, delimiter) { + let prefixes = []; + let objects = []; + let continuationToken; + + do { + let response = await this.s3Client.send(new ListObjectsV2Command({ + Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, + ContinuationToken: continuationToken, + Delimiter: delimiter, + Prefix: prefix, + })); + prefixes.push(...(response.CommonPrefixes ?? []).map(item => item.Prefix).filter(Boolean)); + objects.push(...(response.Contents ?? [])); + + if (response.IsTruncated && !response.NextContinuationToken) { + throw new Error(`S3 listing for ${prefix} was truncated without a continuation token`); + } + continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; + } while (continuationToken); + + return { objects, prefixes }; + } + + createDate(date, prefix, hasSource) { + let month = `${date.slice(0, 4)}/${date.slice(5, 7)}/`; + let archiveKey = `${month}${date}.tar.zst`; + + return { + archiveKey, + date, + hasArchive: false, + hasManifest: false, + hasSource, + manifestKey: `${archiveKey}.manifest.json`, + prefix, + }; + } + + dateFromPrefix(prefix) { + let match = prefix.match(/^(\d{4})\/(\d{2})\/(\d{2})\/$/); + if (!match) { + return null; + } + + let date = `${match[1]}-${match[2]}-${match[3]}`; + + return this.isDate(date) ? date : null; + } + + isDate(date) { + let parsedDate = new Date(`${date}T00:00:00.000Z`); + + return !Number.isNaN(parsedDate.getTime()) && parsedDate.toISOString().slice(0, 10) === date; + } + + relativePath(prefix, key) { + let relativePath = key.slice(prefix.length); + if (!key.startsWith(prefix) + || !relativePath + || path.posix.normalize(relativePath) !== relativePath + || path.posix.isAbsolute(relativePath) + || relativePath.split('/').includes('..')) { + throw new Error(`Invalid archive object path: ${key}`); + } + + return relativePath; + } +} diff --git a/app/data/ArchivePruner.mjs b/app/data/ArchivePruner.mjs index b84ff36..6a9c9c3 100644 --- a/app/data/ArchivePruner.mjs +++ b/app/data/ArchivePruner.mjs @@ -1,14 +1,11 @@ -import path from 'node:path'; import * as Sentry from '@sentry/node'; import { DeleteObjectsCommand, - GetObjectCommand, - ListObjectsV2Command, S3Client, } from '@aws-sdk/client-s3'; import prefixedConsole from '../common/prefixedConsole.mjs'; -import { parseArchiveManifest } from './ArchiveManifest.mjs'; -import ArchiveVerifier from './ArchiveVerifier.mjs'; +import ArchiveInventory from './ArchiveInventory.mjs'; +import RemoteArchiveVerifier from './RemoteArchiveVerifier.mjs'; const retentionPeriod = 7 * 24 * 60 * 60 * 1000; @@ -48,9 +45,10 @@ export default class ArchivePruner dryRun = false; maxDays = Infinity; - constructor(s3Client, archiveVerifier = new ArchiveVerifier) { + constructor(s3Client, remoteArchiveVerifier, archiveInventory) { this._client = s3Client; - this.archiveVerifier = archiveVerifier; + this._remoteArchiveVerifier = remoteArchiveVerifier; + this._archiveInventory = archiveInventory; } async process() { @@ -73,7 +71,7 @@ export default class ArchivePruner } currentDate = candidate.date; - let manifest = await this.getManifest(candidate); + let manifest = await this.remoteArchiveVerifier.getManifest(candidate); if (!this.dryRun && !this.isEligible(manifest)) { this.console.log( `Skipping ${candidate.date}; not eligible for deletion until ${this.eligibleDate(manifest)}`, @@ -81,9 +79,8 @@ export default class ArchivePruner continue; } - let sourceObjects = await this.getSourceObjects(candidate.prefix); - let archiveStream = await this.getArchive(candidate.archiveKey); - await this.archiveVerifier.verify(archiveStream, manifest, sourceObjects); + let sourceObjects = await this.archiveInventory.getSourceObjects(candidate.prefix); + await this.remoteArchiveVerifier.verify(candidate, sourceObjects, manifest); processedDays++; if (this.dryRun) { @@ -99,7 +96,7 @@ export default class ArchivePruner continue; } - let currentObjects = await this.getSourceObjects(candidate.prefix); + let currentObjects = await this.archiveInventory.getSourceObjects(candidate.prefix); this.assertObjectsUnchanged(sourceObjects, currentObjects, candidate.date); await this.deleteObjects(currentObjects); this.console.log(`Pruned ${candidate.date}; deleted ${this.fileCount(currentObjects)}`); @@ -150,86 +147,17 @@ export default class ArchivePruner }); } + get remoteArchiveVerifier() { + return this._remoteArchiveVerifier ??= new RemoteArchiveVerifier(this.s3Client); + } + + get archiveInventory() { + return this._archiveInventory ??= new ArchiveInventory(this.s3Client); + } + async getCandidates() { - let candidates = []; - let years = (await this.list('', '/')).prefixes.filter(prefix => /^\d{4}\/$/.test(prefix)); - - for (let year of years.sort()) { - let months = (await this.list(year, '/')).prefixes - .filter(prefix => /^\d{4}\/\d{2}\/$/.test(prefix)); - - for (let month of months.sort()) { - let listing = await this.list(month, '/'); - let existing = new Set(listing.objects.map(object => object.Key)); - - for (let prefix of listing.prefixes.sort()) { - let date = this.dateFromPrefix(prefix); - if (!date) { - continue; - } - - let archiveKey = `${month}${date}.tar.zst`; - let manifestKey = `${archiveKey}.manifest.json`; - let hasArchive = existing.has(archiveKey); - let hasManifest = existing.has(manifestKey); - if (hasArchive !== hasManifest) { - throw new Error(`Archive and manifest are incomplete for ${date}`); - } - if (hasArchive) { - candidates.push({ archiveKey, date, manifestKey, prefix }); - } - } - } - } - - return candidates.sort((a, b) => a.date.localeCompare(b.date)); - } - - async getManifest(candidate) { - let response = await this.s3Client.send(new GetObjectCommand({ - Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, - Key: candidate.manifestKey, - })); - if (!response.Body) { - throw new Error(`S3 returned no body for ${candidate.manifestKey}`); - } - - return parseArchiveManifest( - await response.Body.transformToString(), - candidate.archiveKey, - ); - } - - async getArchive(key) { - let response = await this.s3Client.send(new GetObjectCommand({ - Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, - Key: key, - })); - if (!response.Body) { - throw new Error(`S3 returned no body for ${key}`); - } - - return response.Body; - } - - async getSourceObjects(prefix) { - let listing = await this.list(prefix); - - return listing.objects - .filter(object => object.Key && object.Key !== prefix && !object.Key.endsWith('/')) - .map(object => { - if (!Number.isFinite(object.Size) || typeof object.ETag !== 'string') { - throw new Error(`S3 returned incomplete metadata for ${object.Key}`); - } - - return { - path: this.relativePath(prefix, object.Key), - bytes: object.Size, - etag: object.ETag, - key: object.Key, - }; - }) - .sort((a, b) => a.path.localeCompare(b.path)); + return (await this.archiveInventory.getDates()) + .filter(item => item.hasSource && item.hasArchive); } async deleteObjects(objects) { @@ -249,30 +177,6 @@ export default class ArchivePruner } } - async list(prefix, delimiter) { - let prefixes = []; - let objects = []; - let continuationToken; - - do { - let response = await this.s3Client.send(new ListObjectsV2Command({ - Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, - ContinuationToken: continuationToken, - Delimiter: delimiter, - Prefix: prefix, - })); - prefixes.push(...(response.CommonPrefixes ?? []).map(item => item.Prefix).filter(Boolean)); - objects.push(...(response.Contents ?? [])); - - if (response.IsTruncated && !response.NextContinuationToken) { - throw new Error(`S3 listing for ${prefix} was truncated without a continuation token`); - } - continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; - } while (continuationToken); - - return { objects, prefixes }; - } - isEligible(manifest) { return Date.parse(manifest.createdAt) <= Date.now() - retentionPeriod; } @@ -299,30 +203,4 @@ export default class ArchivePruner return `${objects.length} ${objects.length === 1 ? 'file' : 'files'}`; } - dateFromPrefix(prefix) { - let match = prefix.match(/^(\d{4})\/(\d{2})\/(\d{2})\/$/); - if (!match) { - return null; - } - - let date = `${match[1]}-${match[2]}-${match[3]}`; - let parsedDate = new Date(`${date}T00:00:00.000Z`); - - return !Number.isNaN(parsedDate.getTime()) && parsedDate.toISOString().slice(0, 10) === date - ? date - : null; - } - - relativePath(prefix, key) { - let relativePath = key.slice(prefix.length); - if (!key.startsWith(prefix) - || !relativePath - || path.posix.normalize(relativePath) !== relativePath - || path.posix.isAbsolute(relativePath) - || relativePath.split('/').includes('..')) { - throw new Error(`Invalid archive object path: ${key}`); - } - - return relativePath; - } } diff --git a/app/data/ArchivePruner.test.mjs b/app/data/ArchivePruner.test.mjs index bbc34a8..8d42279 100644 --- a/app/data/ArchivePruner.test.mjs +++ b/app/data/ArchivePruner.test.mjs @@ -80,6 +80,7 @@ class FakeS3Client Contents: [{ ETag: '"9f9f90dbe3e5ee1218c86b8839db1995"', Key: '2025/03/05/alpha.json', + LastModified: new Date('2025-03-05T00:00:00.000Z'), Size: 6, }], }; @@ -106,7 +107,10 @@ describe('ArchivePruner', () => { it('fully verifies recent archives during a dry run without deleting files', async () => { let s3Client = new FakeS3Client; - let verifier = { verify: vi.fn() }; + let verifier = { + getManifest: vi.fn(async () => s3Client.manifest), + verify: vi.fn(), + }; let pruner = new ArchivePruner(s3Client, verifier); pruner.dryRun = true; pruner._console = { error: vi.fn(), log: vi.fn() }; @@ -120,10 +124,29 @@ describe('ArchivePruner', () => { ); }); + it('skips recent archives before downloading and verifying them', async () => { + let s3Client = new FakeS3Client; + let verifier = { + getManifest: vi.fn(async () => s3Client.manifest), + verify: vi.fn(), + }; + let pruner = new ArchivePruner(s3Client, verifier); + pruner._console = { error: vi.fn(), log: vi.fn() }; + + await pruner.process(); + + expect(verifier.verify).not.toHaveBeenCalled(); + expect(s3Client.deletes).toEqual([]); + expect(pruner._console.log).toHaveBeenCalledWith( + 'Skipping 2025-03-05; not eligible for deletion until 2026-08-28', + ); + }); + it('deletes an eligible day only after its archive is verified', async () => { let s3Client = new FakeS3Client; s3Client.manifest = manifest('2026-08-01T00:00:00.000Z'); let verifier = { + getManifest: vi.fn(async () => s3Client.manifest), verify: vi.fn(async () => { s3Client.events.push('verify'); }), @@ -141,7 +164,7 @@ describe('ArchivePruner', () => { it('stops when an archive exists without its manifest', async () => { let s3Client = new FakeS3Client; s3Client.manifestMissing = true; - let verifier = { verify: vi.fn() }; + let verifier = { getManifest: vi.fn(), verify: vi.fn() }; let pruner = new ArchivePruner(s3Client, verifier); pruner._console = { error: vi.fn(), log: vi.fn() }; @@ -157,6 +180,7 @@ describe('ArchivePruner', () => { let s3Client = new FakeS3Client; s3Client.manifest = manifest('2026-08-01T00:00:00.000Z'); let verifier = { + getManifest: vi.fn(async () => s3Client.manifest), verify: vi.fn(async () => { throw new Error('archive does not match'); }), diff --git a/app/data/ArchiveVerifyCommand.mjs b/app/data/ArchiveVerifyCommand.mjs new file mode 100644 index 0000000..b1f7080 --- /dev/null +++ b/app/data/ArchiveVerifyCommand.mjs @@ -0,0 +1,161 @@ +import * as Sentry from '@sentry/node'; +import { S3Client } from '@aws-sdk/client-s3'; +import prefixedConsole from '../common/prefixedConsole.mjs'; +import ArchiveBuilder from './ArchiveBuilder.mjs'; +import ArchiveInventory from './ArchiveInventory.mjs'; +import { AppleDoubleArchiveError } from './ArchiveContentVerifier.mjs'; +import RemoteArchiveVerifier from './RemoteArchiveVerifier.mjs'; + +export function verifyArchives(maxDays = Infinity, repair = false) { + let verification = new ArchiveVerifyCommand; + verification.maxDays = maxDays; + verification.repair = repair; + + return verification.process(); +} + +export function verifyArchivesFromCli(args) { + let maxDays = Infinity; + let repair = false; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--repair') { + repair = true; + } else if (args[i] === '--max-days' && /^\d+$/.test(args[i + 1])) { + maxDays = Number(args[++i]); + } else { + throw new Error( + 'Usage: npm run data:archive:verify -- [--repair] [--max-days DAYS]', + ); + } + } + + if (maxDays < 1) { + throw new Error('--max-days must be at least 1'); + } + + return verifyArchives(maxDays, repair); +} + +export default class ArchiveVerifyCommand +{ + maxDays = Infinity; + repair = false; + + constructor(s3Client, remoteArchiveVerifier, archiveBuilder, archiveInventory) { + this._client = s3Client; + this._remoteArchiveVerifier = remoteArchiveVerifier; + this._archiveBuilder = archiveBuilder; + this._archiveInventory = archiveInventory; + } + + async process() { + if (!this.canRun) { + this.console.log('Skipping archive verification'); + + return; + } + + let currentDate; + let affected = 0; + let checked = 0; + let repaired = 0; + + try { + let candidates = await this.getCandidates(); + this.console.log(`Found ${candidates.length} dates to verify`); + + for (let candidate of candidates) { + if ((this.repair ? affected : checked) >= this.maxDays) { + break; + } + + currentDate = candidate.date; + let sourceObjects = candidate.hasSource + ? await this.archiveInventory.getSourceObjects(candidate.prefix) + : null; + checked++; + + try { + await this.remoteArchiveVerifier.verify(candidate, sourceObjects); + this.console.log(`Verified ${candidate.date}`); + } catch (error) { + if (!(error instanceof AppleDoubleArchiveError)) { + throw error; + } + + affected++; + if (!this.repair) { + this.console.log(`${candidate.date} contains unexpected macOS metadata`); + } else { + if (!sourceObjects) { + throw new Error(`Cannot repair ${candidate.date}; its source files have been pruned`); + } + await this.archiveBuilder.build(candidate, sourceObjects); + repaired++; + this.console.log(`Repaired ${candidate.date}`); + } + } + } + } catch (error) { + this.console.error(error); + Sentry.withScope(scope => { + if (currentDate) { + scope.setTag('archive.date', currentDate); + } + Sentry.captureException(error); + }); + await Sentry.flush(2000).catch(() => {}); + throw error; + } + + if (this.repair) { + this.console.log(`Verified ${checked} daily archives; repaired ${repaired}`); + } else { + this.console.log(`Verified ${checked} daily archives; ${affected} need repair`); + } + } + + get console() { + this._console ??= prefixedConsole('Archive Verification'); + + return this._console; + } + + get canRun() { + return process.env.AWS_S3_ENDPOINT + && process.env.AWS_REGION + && process.env.AWS_S3_ARCHIVE_BUCKET + && process.env.AWS_ACCESS_KEY_ID + && process.env.AWS_SECRET_ACCESS_KEY; + } + + get s3Client() { + return this._client ??= new S3Client({ + endpoint: process.env.AWS_S3_ENDPOINT, + region: process.env.AWS_REGION, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }); + } + + get remoteArchiveVerifier() { + return this._remoteArchiveVerifier ??= new RemoteArchiveVerifier(this.s3Client); + } + + get archiveBuilder() { + return this._archiveBuilder ??= new ArchiveBuilder(this.s3Client); + } + + get archiveInventory() { + return this._archiveInventory ??= new ArchiveInventory(this.s3Client); + } + + async getCandidates() { + return (await this.archiveInventory.getDates()).filter(item => item.hasArchive); + } +} diff --git a/app/data/ArchiveVerifyCommand.test.mjs b/app/data/ArchiveVerifyCommand.test.mjs new file mode 100644 index 0000000..cb8c207 --- /dev/null +++ b/app/data/ArchiveVerifyCommand.test.mjs @@ -0,0 +1,157 @@ +import { + ListObjectsV2Command, +} from '@aws-sdk/client-s3'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ArchiveVerifyCommand, { verifyArchivesFromCli } from './ArchiveVerifyCommand.mjs'; +import { AppleDoubleArchiveError } from './ArchiveContentVerifier.mjs'; + +const candidate = { + archiveKey: '2025/03/2025-03-05.tar.zst', + date: '2025-03-05', + manifestKey: '2025/03/2025-03-05.tar.zst.manifest.json', + prefix: '2025/03/05/', +}; + +class FakeS3Client +{ + sourcePruned = false; + + async send(command) { + if (command instanceof ListObjectsV2Command) { + return this.list(command.input.Prefix); + } + + throw new Error(`Unexpected S3 command: ${command.constructor.name}`); + } + + list(prefix) { + if (prefix === '') { + return { CommonPrefixes: [{ Prefix: '2025/' }] }; + } + if (prefix === '2025/') { + return { CommonPrefixes: [{ Prefix: '2025/03/' }] }; + } + if (prefix === '2025/03/') { + return { + CommonPrefixes: this.sourcePruned ? [] : [{ Prefix: candidate.prefix }], + Contents: [ + { Key: candidate.archiveKey }, + { Key: candidate.manifestKey }, + ], + }; + } + if (prefix === candidate.prefix) { + return { + Contents: [{ + ETag: '"9f9f90dbe3e5ee1218c86b8839db1995"', + Key: `${candidate.prefix}alpha.json`, + LastModified: new Date('2025-03-05T00:00:00.000Z'), + Size: 6, + }], + }; + } + + throw new Error(`Unexpected S3 prefix: ${prefix}`); + } +} + +describe('ArchiveVerifyCommand', () => { + beforeEach(() => { + process.env.AWS_S3_ENDPOINT = 'https://example.invalid'; + process.env.AWS_REGION = 'test'; + process.env.AWS_S3_ARCHIVE_BUCKET = 'archive'; + process.env.AWS_ACCESS_KEY_ID = 'key'; + process.env.AWS_SECRET_ACCESS_KEY = 'secret'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not accept a redundant dry-run option', () => { + expect(() => verifyArchivesFromCli(['--dry-run'])).toThrow( + 'Usage: npm run data:archive:verify', + ); + }); + + it('verifies archives without rebuilding valid ones', async () => { + let verifier = { verify: vi.fn() }; + let builder = { build: vi.fn() }; + let verification = new ArchiveVerifyCommand(new FakeS3Client, verifier, builder); + verification._console = { error: vi.fn(), log: vi.fn() }; + + await verification.process(); + + expect(verifier.verify).toHaveBeenCalledOnce(); + expect(builder.build).not.toHaveBeenCalled(); + expect(verification._console.log).toHaveBeenCalledWith('Verified 2025-03-05'); + }); + + it('verifies pruned archives against their manifests', async () => { + let s3Client = new FakeS3Client; + s3Client.sourcePruned = true; + let verifier = { verify: vi.fn() }; + let verification = new ArchiveVerifyCommand(s3Client, verifier, { build: vi.fn() }); + verification._console = { error: vi.fn(), log: vi.fn() }; + + await verification.process(); + + expect(verifier.verify).toHaveBeenCalledWith( + expect.objectContaining({ date: '2025-03-05' }), + null, + ); + expect(verification._console.log).toHaveBeenCalledWith('Verified 2025-03-05'); + }); + + it('reports AppleDouble archives without changing them by default', async () => { + let verifier = { + verify: vi.fn(async () => { + throw new AppleDoubleArchiveError('._alpha.json'); + }), + }; + let builder = { build: vi.fn() }; + let verification = new ArchiveVerifyCommand(new FakeS3Client, verifier, builder); + verification._console = { error: vi.fn(), log: vi.fn() }; + + await verification.process(); + + expect(builder.build).not.toHaveBeenCalled(); + expect(verification._console.log).toHaveBeenCalledWith( + '2025-03-05 contains unexpected macOS metadata', + ); + }); + + it('repairs AppleDouble archives only when repair is enabled', async () => { + let verifier = { + verify: vi.fn(async () => { + throw new AppleDoubleArchiveError('._alpha.json'); + }), + }; + let builder = { build: vi.fn() }; + let verification = new ArchiveVerifyCommand(new FakeS3Client, verifier, builder); + verification.repair = true; + verification._console = { error: vi.fn(), log: vi.fn() }; + + await verification.process(); + + expect(builder.build).toHaveBeenCalledOnce(); + expect(verification._console.log).toHaveBeenCalledWith('Repaired 2025-03-05'); + }); + + it('stops instead of repairing unrelated verification failures', async () => { + let verifier = { + verify: vi.fn(async () => { + throw new Error('Archive SHA-256 does not match its manifest'); + }), + }; + let builder = { build: vi.fn() }; + let verification = new ArchiveVerifyCommand(new FakeS3Client, verifier, builder); + verification.repair = true; + verification._console = { error: vi.fn(), log: vi.fn() }; + + await expect(verification.process()).rejects.toThrow( + 'Archive SHA-256 does not match its manifest', + ); + expect(builder.build).not.toHaveBeenCalled(); + }); +}); diff --git a/app/data/RemoteArchiveVerifier.mjs b/app/data/RemoteArchiveVerifier.mjs new file mode 100644 index 0000000..94f8b5d --- /dev/null +++ b/app/data/RemoteArchiveVerifier.mjs @@ -0,0 +1,62 @@ +import { + GetObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { parseArchiveManifest } from './ArchiveManifest.mjs'; +import ArchiveContentVerifier from './ArchiveContentVerifier.mjs'; + +export default class RemoteArchiveVerifier +{ + constructor(s3Client, contentVerifier = new ArchiveContentVerifier) { + this._client = s3Client; + this.contentVerifier = contentVerifier; + } + + async verify(candidate, sourceObjects, manifest = null) { + manifest ??= await this.getManifest(candidate); + let archiveStream = await this.getArchive(candidate.archiveKey); + await this.contentVerifier.verify(archiveStream, manifest, sourceObjects); + + return manifest; + } + + get s3Client() { + return this._client ??= new S3Client({ + endpoint: process.env.AWS_S3_ENDPOINT, + region: process.env.AWS_REGION, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }); + } + + async getManifest(candidate) { + let response = await this.s3Client.send(new GetObjectCommand({ + Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, + Key: candidate.manifestKey, + })); + if (!response.Body) { + throw new Error(`S3 returned no body for ${candidate.manifestKey}`); + } + + return parseArchiveManifest( + await response.Body.transformToString(), + candidate.archiveKey, + ); + } + + async getArchive(key) { + let response = await this.s3Client.send(new GetObjectCommand({ + Bucket: process.env.AWS_S3_ARCHIVE_BUCKET, + Key: key, + })); + if (!response.Body) { + throw new Error(`S3 returned no body for ${key}`); + } + + return response.Body; + } +} diff --git a/app/data/RemoteArchiveVerifier.test.mjs b/app/data/RemoteArchiveVerifier.test.mjs new file mode 100644 index 0000000..b3ea97b --- /dev/null +++ b/app/data/RemoteArchiveVerifier.test.mjs @@ -0,0 +1,64 @@ +import { Readable } from 'node:stream'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { describe, expect, it, vi } from 'vitest'; +import RemoteArchiveVerifier from './RemoteArchiveVerifier.mjs'; + +const candidate = { + archiveKey: '2025/03/2025-03-05.tar.zst', + date: '2025-03-05', + manifestKey: '2025/03/2025-03-05.tar.zst.manifest.json', +}; + +const manifest = { + version: 1, + date: candidate.date, + createdAt: '2026-08-20T00:00:00.000Z', + archive: { + path: candidate.archiveKey, + bytes: 7, + hash: `sha256:${'a'.repeat(64)}`, + }, + files: [{ + path: 'alpha.json', + bytes: 6, + hash: `sha256:${'b'.repeat(64)}`, + }], +}; + +class FakeS3Client +{ + async send(command) { + if (!(command instanceof GetObjectCommand)) { + throw new Error(`Unexpected S3 command: ${command.constructor.name}`); + } + if (command.input.Key === candidate.manifestKey) { + return { + Body: { + transformToString: async () => JSON.stringify(manifest), + }, + }; + } + if (command.input.Key === candidate.archiveKey) { + return { Body: Readable.from('archive') }; + } + + throw new Error(`Unexpected S3 key: ${command.input.Key}`); + } +} + +describe('RemoteArchiveVerifier', () => { + it('downloads and verifies an archive and its manifest', async () => { + let sourceObjects = [{ + path: 'alpha.json', + bytes: 6, + etag: '"9f9f90dbe3e5ee1218c86b8839db1995"', + }]; + let contentVerifier = { verify: vi.fn() }; + let verifier = new RemoteArchiveVerifier(new FakeS3Client, contentVerifier); + + await expect(verifier.verify(candidate, sourceObjects)).resolves.toEqual(manifest); + expect(contentVerifier.verify).toHaveBeenCalledOnce(); + expect(contentVerifier.verify.mock.calls[0][1]).toEqual(manifest); + expect(contentVerifier.verify.mock.calls[0][2]).toBe(sourceObjects); + }); +}); diff --git a/app/index.mjs b/app/index.mjs index be96b89..2d82ced 100644 --- a/app/index.mjs +++ b/app/index.mjs @@ -12,6 +12,7 @@ import { archiveData } from './data/DataArchiver.mjs'; import { compressArchivesFromCli } from './data/ArchiveCompressor.mjs'; import { pruneArchivesFromCli } from './data/ArchivePruner.mjs'; import { reportArchiveStatsFromCli } from './data/ArchiveStats.mjs'; +import { verifyArchivesFromCli } from './data/ArchiveVerifyCommand.mjs'; import { sentryInit } from './common/sentry.mjs'; import { sync, syncUpload, syncDownload } from './sync/index.mjs'; import { updateAvatars } from './social/updateAvatars.mjs'; @@ -34,6 +35,7 @@ const actions = { archiveCompress: (...args) => compressArchivesFromCli(args), archivePrune: (...args) => pruneArchivesFromCli(args), archiveStats: (...args) => reportArchiveStatsFromCli(args), + archiveVerify: (...args) => verifyArchivesFromCli(args), sync, syncUpload, syncDownload, diff --git a/package.json b/package.json index 63427ed..6120c5b 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "data:archive:prune": "node app/index.mjs archivePrune", "data:archive:prune:dry-run": "node app/index.mjs archivePrune --dry-run", "data:archive:stats": "node app/index.mjs archiveStats", + "data:archive:verify": "node app/index.mjs archiveVerify", + "data:archive:verify:repair": "node app/index.mjs archiveVerify --repair", "sync": "node app/index.mjs sync", "sync:upload": "node app/index.mjs syncUpload", "sync:download": "node app/index.mjs syncDownload"