Files
2026-09-09 14:47:29 +08:00

109 lines
6.5 KiB
JavaScript

import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { PROJECT, inventory, hash } from './upload.mjs'
import { loadCosMediaManifest, validateCosMediaManifest } from '../../build/tang-detective-cos-media.mjs'
import { validatePruneReceipt, readSourceManifest } from '../../build/tang-detective-source-validation.mjs'
// Deliberately task-scoped, recoverable cleanup. Never removes a cloud object or recurses over a deletion target.
const mode = process.argv[2]
if (!['check', 'apply'].includes(mode)) throw new Error('USE_CHECK_OR_APPLY')
const backupDirectory = '/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE'
const archive = path.join(backupDirectory, 'original-media.tar.gz')
const recoveryDirectory = path.join(backupDirectory, 'pruned-originals')
const expectedArchiveSha256 = '656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61'
const unused = ['static/background.svg', 'static/calling-logo.png', 'static/check.png',
'static/user/home.png', 'static/user/home_no.png', 'static/wjw.png', 'static/ys.png',
'static/yy.png', 'static/zs.jpg', 'training/static/footprint.svg']
const sourceDirectory = path.join(PROJECT, 'native/tang-detective')
const receiptPath = path.join(PROJECT, 'build/tang-detective-media-prune-receipt.json')
if (fs.existsSync(receiptPath) || fs.existsSync(recoveryDirectory)) throw new Error('CLEANUP_ALREADY_STARTED_OR_COMPLETED')
const source = inventory(PROJECT)
const manifest = JSON.parse(fs.readFileSync(path.join(PROJECT, 'build/tang-detective-cos-manifest.json')))
const media = loadCosMediaManifest({ sourceDirectory })
const uploaded = JSON.parse(fs.readFileSync(path.join(PROJECT, 'build/tang-detective-admin-upload-receipt.json')))
if (!media || uploaded.complete !== true || uploaded.runId !== media.uploadRunId
|| uploaded.mediaManifestSha256 !== media.manifestSha256 || uploaded.objects.length !== media.objectCount) {
throw new Error('REAL_COMPLETE_UPLOAD_RECEIPT_REQUIRED')
}
for (const entry of media.entries.values()) {
const proof = uploaded.objects.find(item => item.url === entry.url)
if (!proof || proof.sha256 !== entry.sha256 || proof.bytes !== entry.bytes || proof.remoteVerifiedSha256 !== entry.sha256
|| proof.publicReadVerified !== true || proof.uploaded !== true
|| (entry.kind === 'audio' && proof.rangeVerified !== true)) throw new Error('UPLOAD_PROOF_MISMATCH')
}
const archiveStat = fs.lstatSync(archive)
if (!archiveStat.isFile() || archiveStat.isSymbolicLink()) throw new Error('BACKUP_NOT_REGULAR_FILE')
const archiveBytes = fs.readFileSync(archive)
if (hash(archiveBytes) !== expectedArchiveSha256) throw new Error('BACKUP_ARCHIVE_CHANGED')
const targets = [...source.entries.map(entry => ({ projectPath: `native/tang-detective/${entry.sourcePath}`,
bytes: entry.bytes, sha256: entry.sha256, reason: 'verified-cos-replacement' })),
...unused.map(projectPath => {
const bytes = fs.readFileSync(path.join(PROJECT, projectPath))
return { projectPath, bytes: bytes.length, sha256: hash(bytes), reason: 'no-runtime-reference' }
})]
if (targets.length !== 214 || new Set(targets.map(e => e.projectPath)).size !== 214) throw new Error('UNEXPECTED_CLEANUP_SET')
function verifyLocal(entry) {
let current = PROJECT
for (const part of entry.projectPath.split('/')) {
current = path.join(current, part)
if (fs.lstatSync(current).isSymbolicLink()) throw new Error('CLEANUP_SYMLINK_NOT_ALLOWED')
}
const stat = fs.lstatSync(current)
const bytes = fs.readFileSync(current)
if (!stat.isFile() || bytes.length !== entry.bytes || hash(bytes) !== entry.sha256) throw new Error('CLEANUP_SOURCE_CHANGED')
}
for (const entry of targets) {
verifyLocal(entry)
const result = spawnSync('tar', ['-xOf', archive, entry.projectPath], { maxBuffer: entry.bytes + 65536 })
if (result.status !== 0 || result.stdout.length !== entry.bytes || hash(result.stdout) !== entry.sha256) {
throw new Error(`BACKUP_ENTRY_NOT_IDENTICAL: ${entry.projectPath}`)
}
}
const receipt = { schemaVersion: 1, status: 'completed', uploadRunId: media.uploadRunId,
sourceManifestSha256: media.sourceManifestSha256, mediaManifestSha256: media.manifestSha256,
backup: { sha256: expectedArchiveSha256, bytes: archiveBytes.length, format: 'tar.gz' },
entries: [...media.entries.values()].map(({ sourcePath, bytes, sha256, objectKey, url }) => ({
sourcePath, bytes, sha256, objectKey, url, backupSha256: expectedArchiveSha256 })),
additionalUnusedFiles: targets.filter(entry => entry.reason === 'no-runtime-reference'),
sourceMediaBytes: source.entries.reduce((n, e) => n + e.bytes, 0),
removedProjectBytes: targets.reduce((n, e) => n + e.bytes, 0),
method: 'moved-byte-identical-originals-outside-project-with-verified-archive',
contentRegenerated: false, cloudObjectsDeleted: false }
validatePruneReceipt(receipt, { source: readSourceManifest(), media })
if (mode === 'check') {
console.log(JSON.stringify({ ready: true, files: targets.length, bytes: receipt.removedProjectBytes,
backupEntriesByteVerified: targets.length, originalsChanged: false }))
} else {
// Preflight every target again before the first move. On a failure, roll back only this task's exact paths.
targets.forEach(verifyLocal)
fs.mkdirSync(recoveryDirectory, { recursive: false })
const moved = []
try {
for (const entry of targets) {
verifyLocal(entry)
const to = path.join(recoveryDirectory, entry.projectPath)
fs.mkdirSync(path.dirname(to), { recursive: true })
if (fs.existsSync(to)) throw new Error('RECOVERY_TARGET_EXISTS')
fs.renameSync(path.join(PROJECT, entry.projectPath), to)
moved.push(entry)
}
validateCosMediaManifest(manifest, { sourceDirectory, pruneReceipt: receipt })
receipt.completedAt = new Date().toISOString()
fs.writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n', { flag: 'wx' })
console.log(JSON.stringify({ completed: true, filesRemovedFromProject: moved.length,
bytesRemovedFromProject: receipt.removedProjectBytes, recoveryDirectory, archive, receiptPath }))
} catch (error) {
const recoveryFailures = []
for (const entry of moved.reverse()) {
try {
const original = path.join(PROJECT, entry.projectPath)
if (fs.existsSync(original)) throw new Error('ORIGINAL_PATH_NOW_OCCUPIED')
fs.renameSync(path.join(recoveryDirectory, entry.projectPath), original)
} catch { recoveryFailures.push(entry.projectPath) }
}
if (recoveryFailures.length) console.error(JSON.stringify({ recoveryFailures, recoveryDirectory, archive }))
throw error
}
}