This commit is contained in:
@@ -11,6 +11,7 @@ const idPattern = /^[A-Za-z0-9_-]{22}$/
|
||||
const verifierPattern = /^[A-Za-z0-9_-]{43}$/
|
||||
const blobPattern = /^[A-Za-z0-9_-]+$/
|
||||
const notFoundBody = '{"error":"not_found"}'
|
||||
const allowedRetentionSeconds = new Set([86_400, 259_200, 604_800, 2_678_400])
|
||||
|
||||
function isCanonicalBase64Url(value, byteLength, pattern) {
|
||||
if (typeof value !== 'string' || !pattern.test(value)) return false
|
||||
@@ -21,7 +22,9 @@ function isCanonicalBase64Url(value, byteLength, pattern) {
|
||||
function isValidBackup(payload) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false
|
||||
const keys = Object.keys(payload).sort()
|
||||
if (keys.join(',') !== 'blob,deleteVerifier,id') return false
|
||||
const shape = keys.join(',')
|
||||
if (shape !== 'blob,deleteVerifier,id' && shape !== 'blob,deleteVerifier,expiresInSeconds,id') return false
|
||||
if (shape.includes('expiresInSeconds') && payload.expiresInSeconds !== null && !allowedRetentionSeconds.has(payload.expiresInSeconds)) return false
|
||||
if (!isCanonicalBase64Url(payload.id, 16, idPattern)) return false
|
||||
if (!isCanonicalBase64Url(payload.deleteVerifier, 32, verifierPattern)) return false
|
||||
if (typeof payload.blob !== 'string' || !blobPattern.test(payload.blob)) return false
|
||||
@@ -164,6 +167,36 @@ async function directoryUsage(rootDir) {
|
||||
return { bytes, records }
|
||||
}
|
||||
|
||||
function isCanonicalTimestamp(value) {
|
||||
if (typeof value !== 'string') return false
|
||||
const timestamp = new Date(value)
|
||||
return Number.isFinite(timestamp.getTime()) && timestamp.toISOString() === value
|
||||
}
|
||||
|
||||
function recordExpired(record, nowMs) {
|
||||
return record.expiresAt !== null && new Date(record.expiresAt).getTime() <= nowMs
|
||||
}
|
||||
|
||||
async function cleanupExpiredRecords(rootDir, nowMs) {
|
||||
const entries = await readdir(rootDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !/^[A-Za-z0-9_-]{22}\.json$/.test(entry.name)) continue
|
||||
const id = entry.name.slice(0, -5)
|
||||
let record
|
||||
try {
|
||||
record = await readRecord(rootDir, id)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!record || !recordExpired(record, nowMs)) continue
|
||||
try {
|
||||
await unlink(recordPath(rootDir, id))
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDirectory(rootDir) {
|
||||
let handle
|
||||
try {
|
||||
@@ -219,15 +252,21 @@ async function recordExists(rootDir, id) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createRecord(rootDir, payload, maxStorageBytes, maxRecords) {
|
||||
async function createRecord(rootDir, payload, maxStorageBytes, maxRecords, nowMs) {
|
||||
await mkdir(rootDir, { recursive: true })
|
||||
await cleanupTemporaryFiles(rootDir)
|
||||
await cleanupExpiredRecords(rootDir, nowMs)
|
||||
if (await recordExists(rootDir, payload.id)) return 'duplicate'
|
||||
const expiresInSeconds = Object.prototype.hasOwnProperty.call(payload, 'expiresInSeconds')
|
||||
? payload.expiresInSeconds
|
||||
: null
|
||||
const expiresAt = expiresInSeconds === null ? null : new Date(nowMs + expiresInSeconds * 1000).toISOString()
|
||||
const contents = Buffer.from(JSON.stringify({
|
||||
version: 1,
|
||||
version: 2,
|
||||
blob: payload.blob,
|
||||
deleteVerifier: payload.deleteVerifier,
|
||||
createdAt: new Date().toISOString()
|
||||
createdAt: new Date(nowMs).toISOString(),
|
||||
expiresAt
|
||||
}), 'utf8')
|
||||
const usage = await directoryUsage(rootDir)
|
||||
if (usage.bytes + contents.length > maxStorageBytes || usage.records >= maxRecords) return 'full'
|
||||
@@ -282,10 +321,26 @@ async function readRecord(rootDir, id) {
|
||||
try {
|
||||
const raw = await readFile(recordPath(rootDir, id), 'utf8')
|
||||
const record = JSON.parse(raw)
|
||||
if (record?.version !== 1 || typeof record.blob !== 'string' || !isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)) {
|
||||
const keys = record && typeof record === 'object' && !Array.isArray(record) ? Object.keys(record).sort().join(',') : ''
|
||||
const validBlob = typeof record?.blob === 'string'
|
||||
&& blobPattern.test(record.blob)
|
||||
&& Buffer.from(record.blob, 'base64url').length <= maxBlobBytes
|
||||
&& Buffer.from(record.blob, 'base64url').toString('base64url') === record.blob
|
||||
const legacy = keys === 'blob,createdAt,deleteVerifier,version'
|
||||
&& record.version === 1
|
||||
&& validBlob
|
||||
&& isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)
|
||||
&& isCanonicalTimestamp(record.createdAt)
|
||||
const expiring = keys === 'blob,createdAt,deleteVerifier,expiresAt,version'
|
||||
&& record.version === 2
|
||||
&& validBlob
|
||||
&& isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)
|
||||
&& isCanonicalTimestamp(record.createdAt)
|
||||
&& (record.expiresAt === null || (isCanonicalTimestamp(record.expiresAt) && record.expiresAt > record.createdAt))
|
||||
if (!legacy && !expiring) {
|
||||
throw new Error('Invalid stored record')
|
||||
}
|
||||
return record
|
||||
return legacy ? { ...record, expiresAt: null } : record
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return null
|
||||
throw error
|
||||
@@ -383,6 +438,7 @@ export function createBackupServer(options) {
|
||||
const maxConcurrentPerClient = options.maxConcurrentPerClient ?? 8
|
||||
const maxConcurrentTotal = options.maxConcurrentTotal ?? 64
|
||||
const trustedProxyAddresses = new Set(options.trustedProxyAddresses ?? [])
|
||||
const now = options.now ?? (() => Date.now())
|
||||
if (!Number.isSafeInteger(rateLimit.max) || rateLimit.max < 1 || !Number.isSafeInteger(rateLimit.windowMs) || rateLimit.windowMs < 1) {
|
||||
throw new Error('Invalid rate limit')
|
||||
}
|
||||
@@ -398,6 +454,7 @@ export function createBackupServer(options) {
|
||||
if (!Number.isSafeInteger(healthCacheMs) || healthCacheMs < 1) throw new Error('Invalid health cache')
|
||||
if (!Number.isSafeInteger(maxConcurrentPerClient) || maxConcurrentPerClient < 1) throw new Error('Invalid per-client concurrency')
|
||||
if (!Number.isSafeInteger(maxConcurrentTotal) || maxConcurrentTotal < maxConcurrentPerClient) throw new Error('Invalid total concurrency')
|
||||
if (typeof now !== 'function' || !Number.isFinite(Number(now()))) throw new Error('Invalid clock')
|
||||
const consumeRateLimit = createRateLimiter(rateLimit)
|
||||
const consumeUploadRateLimit = createRateLimiter(uploadRateLimit)
|
||||
const consumeRequestRateLimit = createRateLimiter(requestRateLimit)
|
||||
@@ -471,7 +528,18 @@ export function createBackupServer(options) {
|
||||
return
|
||||
}
|
||||
const record = await readRecord(options.rootDir, parsed.value.id)
|
||||
if (!record) {
|
||||
if (!record || recordExpired(record, Number(now()))) {
|
||||
if (record) {
|
||||
await runStorageMutation(() => withStorageLock(options.rootDir, async () => {
|
||||
const current = await readRecord(options.rootDir, parsed.value.id)
|
||||
if (!current || !recordExpired(current, Number(now()))) return
|
||||
try {
|
||||
await unlink(recordPath(options.rootDir, parsed.value.id))
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
}
|
||||
}))
|
||||
}
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
@@ -522,7 +590,7 @@ export function createBackupServer(options) {
|
||||
}
|
||||
const result = await runStorageMutation(() => withStorageLock(
|
||||
options.rootDir,
|
||||
() => createRecord(options.rootDir, parsed.value, maxStorageBytes, maxRecords)
|
||||
() => createRecord(options.rootDir, parsed.value, maxStorageBytes, maxRecords, Number(now()))
|
||||
))
|
||||
if (result === 'duplicate') {
|
||||
sendJson(response, 409, { error: 'already_exists' })
|
||||
|
||||
@@ -110,7 +110,10 @@ test('validates payload shape, content type and decoded blob size', async (t) =>
|
||||
{ ...valid.payload, blob: 'not+base64url' },
|
||||
{ ...valid.payload, deleteVerifier: 'short' },
|
||||
{ id: valid.payload.id, blob: valid.payload.blob },
|
||||
{ ...valid.payload, extra: true }
|
||||
{ ...valid.payload, extra: true },
|
||||
{ ...valid.payload, expiresInSeconds: 0 },
|
||||
{ ...valid.payload, expiresInSeconds: 30 * 24 * 60 * 60 },
|
||||
{ ...valid.payload, expiresInSeconds: '604800' }
|
||||
]
|
||||
|
||||
for (const body of invalid) {
|
||||
@@ -139,6 +142,82 @@ test('validates payload shape, content type and decoded blob size', async (t) =>
|
||||
assert.equal(tooLarge.status, 413)
|
||||
})
|
||||
|
||||
test('expires finite backups at the exact deadline and removes their ciphertext', async (t) => {
|
||||
let nowMs = Date.parse('2026-09-01T10:00:00.000Z')
|
||||
const api = await startApi({ now: () => nowMs })
|
||||
t.after(() => api.close())
|
||||
const backup = fixture()
|
||||
backup.payload.expiresInSeconds = 86_400
|
||||
|
||||
const created = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(backup.payload)
|
||||
})
|
||||
assert.equal(created.status, 201)
|
||||
const storedPath = join(api.rootDir, `${backup.payload.id}.json`)
|
||||
const stored = JSON.parse(await readFile(storedPath, 'utf8'))
|
||||
assert.equal(stored.version, 2)
|
||||
assert.equal(stored.expiresAt, '2026-09-02T10:00:00.000Z')
|
||||
|
||||
const beforeDeadline = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: backup.payload.id })
|
||||
})
|
||||
assert.equal(beforeDeadline.status, 200)
|
||||
|
||||
nowMs += 86_400_000
|
||||
const expired = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: backup.payload.id })
|
||||
})
|
||||
assert.equal(expired.status, 404)
|
||||
await assert.rejects(stat(storedPath), error => error.code === 'ENOENT')
|
||||
})
|
||||
|
||||
test('keeps legacy records unlimited and reclaims expired records on the next upload', async (t) => {
|
||||
let nowMs = Date.parse('2026-09-01T10:00:00.000Z')
|
||||
const api = await startApi({ now: () => nowMs })
|
||||
t.after(() => api.close())
|
||||
const legacy = fixture()
|
||||
await writeFile(join(api.rootDir, `${legacy.payload.id}.json`), JSON.stringify({
|
||||
version: 1,
|
||||
blob: legacy.payload.blob,
|
||||
deleteVerifier: legacy.payload.deleteVerifier,
|
||||
createdAt: '2026-01-01T00:00:00.000Z'
|
||||
}))
|
||||
const finite = fixture()
|
||||
finite.payload.expiresInSeconds = 86_400
|
||||
await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(finite.payload)
|
||||
})
|
||||
|
||||
nowMs += 2 * 86_400_000
|
||||
const replacement = fixture()
|
||||
replacement.payload.expiresInSeconds = null
|
||||
assert.equal((await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(replacement.payload)
|
||||
})).status, 201)
|
||||
|
||||
await assert.rejects(stat(join(api.rootDir, `${finite.payload.id}.json`)), error => error.code === 'ENOENT')
|
||||
assert.equal((await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: legacy.payload.id })
|
||||
})).status, 200)
|
||||
assert.equal((await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: replacement.payload.id })
|
||||
})).status, 200)
|
||||
})
|
||||
|
||||
test('deletes only with the matching client secret and returns constant not-found responses', async (t) => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
|
||||
Reference in New Issue
Block a user