Merge pull request #113 from misenhower/codex/archive-pruner
Some checks failed
Update query hashes / check-and-create-pr (push) Has been cancelled
Build frontend / build (22.x) (push) Has been cancelled
Deploy / deploy-frontend (push) Has been cancelled
Deploy / deploy-backend (push) Has been cancelled
Lint / lint (push) Has been cancelled
Tests / test (22.x) (push) Has been cancelled

Add archive pruning and verification commands
This commit is contained in:
Matt Isenhower
2026-08-22 15:03:54 -07:00
committed by GitHub
17 changed files with 1664 additions and 243 deletions

147
app/data/ArchiveBuilder.mjs Normal file
View File

@@ -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,
}));
}
}

View File

@@ -1,21 +1,9 @@
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 } from './ArchiveManifest.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) {
@@ -54,9 +42,10 @@ export default class ArchiveCompressor
dryRun = false;
maxDays = Infinity;
constructor(s3Client, archiveWriter = new TarZstdWriter) {
constructor(s3Client, archiveBuilder, archiveInventory) {
this._client = s3Client;
this.archiveWriter = archiveWriter;
this._archiveBuilder = archiveBuilder;
this._archiveInventory = archiveInventory;
}
async process() {
@@ -134,6 +123,14 @@ 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) {
@@ -156,44 +153,14 @@ export default class ArchiveCompressor
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(`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;
}
@@ -208,179 +175,13 @@ export default class ArchiveCompressor
return objects;
}
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 Set(listing.objects.map(object => object.Key));
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`;
if (existing.has(archiveKey) && existing.has(manifestKey)) {
continue;
}
candidates.push({ archiveKey, date, 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 {
key: object.Key,
bytes: object.Size,
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;
}
}

View File

@@ -6,8 +6,30 @@ 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';
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
{
completed = false;
@@ -16,6 +38,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 = [];
@@ -27,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)) };
}
@@ -54,7 +88,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,
},
] : [],
};
}
@@ -62,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,
@@ -120,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();
@@ -157,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() };
@@ -166,21 +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();
expect(archiveBuilder.build).not.toHaveBeenCalled();
});
it('stops after a failed day and removes its temporary files', async () => {
@@ -195,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');
@@ -215,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();
@@ -235,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() };

View File

@@ -0,0 +1,194 @@
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');
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 ArchiveContentVerifier
{
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 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);
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]));
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
|| 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 ArchiveContentError('Tar archive is missing files from its manifest');
}
}
async verifyEntry(header, entry, manifestByPath, sourceByPath, verified) {
if (header.type !== 'file') {
throw new ArchiveContentError(`Tar archive contains a non-file entry: ${header.name}`);
}
if (verified.has(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 || sourceByPath && !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);
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 ArchiveContentError(`Size does not match the manifest for ${header.name}`);
}
if (`sha256:${sha256.digest('hex')}` !== manifestFile.hash) {
throw new ArchiveContentError(`SHA-256 does not match the manifest 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}`);
}
}
}
}

View File

@@ -0,0 +1,155 @@
import { Readable } from 'node:stream';
import { describe, expect, it } from 'vitest';
import * as tar from 'tar-stream';
import ArchiveContentVerifier, { AppleDoubleArchiveError } from './ArchiveContentVerifier.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('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 ArchiveContentVerifier(identityDecompressor);
await expect(verifier.verify(
Readable.from(archive),
manifestFor(archive),
[sourceObject()],
)).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 ArchiveContentVerifier(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 ArchiveContentVerifier(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 ArchiveContentVerifier(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('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 ArchiveContentVerifier(stream => ({
completed: Promise.reject(new Error('zstd failed (exit 70): Broken pipe')),
stream,
}));
await expect(verifier.verify(
Readable.from(archive),
manifestFor(archive),
[sourceObject()],
)).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 ArchiveContentVerifier(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 ArchiveContentVerifier(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');
});
});

View File

@@ -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;
}
}

206
app/data/ArchivePruner.mjs Normal file
View File

@@ -0,0 +1,206 @@
import * as Sentry from '@sentry/node';
import {
DeleteObjectsCommand,
S3Client,
} from '@aws-sdk/client-s3';
import prefixedConsole from '../common/prefixedConsole.mjs';
import ArchiveInventory from './ArchiveInventory.mjs';
import RemoteArchiveVerifier from './RemoteArchiveVerifier.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, remoteArchiveVerifier, archiveInventory) {
this._client = s3Client;
this._remoteArchiveVerifier = remoteArchiveVerifier;
this._archiveInventory = archiveInventory;
}
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.remoteArchiveVerifier.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.archiveInventory.getSourceObjects(candidate.prefix);
await this.remoteArchiveVerifier.verify(candidate, sourceObjects, manifest);
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.archiveInventory.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,
},
});
}
get remoteArchiveVerifier() {
return this._remoteArchiveVerifier ??= new RemoteArchiveVerifier(this.s3Client);
}
get archiveInventory() {
return this._archiveInventory ??= new ArchiveInventory(this.s3Client);
}
async getCandidates() {
return (await this.archiveInventory.getDates())
.filter(item => item.hasSource && item.hasArchive);
}
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}`);
}
}
}
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'}`;
}
}

View File

@@ -0,0 +1,195 @@
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',
LastModified: new Date('2025-03-05T00:00:00.000Z'),
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 = {
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() };
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('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');
}),
};
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 = { getManifest: vi.fn(), 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 = {
getManifest: vi.fn(async () => s3Client.manifest),
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([]);
});
});

View File

@@ -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);
}
}

View File

@@ -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();
});
});

View File

@@ -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;
}
}

View File

@@ -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);
});
});

View File

@@ -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', [

View File

@@ -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 });
}
});

View File

@@ -10,7 +10,9 @@ 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 { 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';
@@ -31,7 +33,9 @@ const actions = {
warmCaches,
dataArchive: archiveData,
archiveCompress: (...args) => compressArchivesFromCli(args),
archivePrune: (...args) => pruneArchivesFromCli(args),
archiveStats: (...args) => reportArchiveStatsFromCli(args),
archiveVerify: (...args) => verifyArchivesFromCli(args),
sync,
syncUpload,
syncDownload,

21
package-lock.json generated
View File

@@ -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"
}

View File

@@ -25,7 +25,11 @@
"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",
"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"
@@ -53,6 +57,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",