221 lines
7.2 KiB
JavaScript
221 lines
7.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/** Pin the repository and each log-path component before creating or operating on private logs. */
|
|
|
|
import { constants } from 'node:fs'
|
|
import { chmod, lstat, mkdir, open, readdir, stat, unlink } from 'node:fs/promises'
|
|
import { isAbsolute, sep } from 'node:path'
|
|
|
|
const MAX_REQUEST_BYTES = 8 * 1024 * 1024
|
|
const LOG_NAME = /^[a-zA-Z0-9][a-zA-Z0-9.-]*\.log$/
|
|
|
|
function errorCode(error) {
|
|
return typeof error === 'object' && error !== null && 'code' in error
|
|
? error.code
|
|
: undefined
|
|
}
|
|
|
|
async function readRequest() {
|
|
const chunks = []
|
|
let bytes = 0
|
|
for await (const chunk of process.stdin) {
|
|
bytes += chunk.length
|
|
if (bytes > MAX_REQUEST_BYTES) throw new Error('request exceeds the gate-log helper limit')
|
|
chunks.push(chunk)
|
|
}
|
|
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
}
|
|
|
|
function assertInteger(value, label, minimum) {
|
|
if (!Number.isSafeInteger(value) || value < minimum) {
|
|
throw new Error(`${label} must be an integer of at least ${minimum}`)
|
|
}
|
|
}
|
|
|
|
function assertLogName(name) {
|
|
if (typeof name !== 'string' || !LOG_NAME.test(name)) {
|
|
throw new Error(`invalid gate-log filename ${JSON.stringify(name)}`)
|
|
}
|
|
}
|
|
|
|
function assertRequest(request) {
|
|
if (typeof request !== 'object' || request === null) throw new Error('gate-log request must be an object')
|
|
switch (request.operation) {
|
|
case 'write':
|
|
assertLogName(request.filename)
|
|
assertInteger(request.retention, 'retention', 1)
|
|
if (typeof request.content !== 'string') throw new Error('gate-log content must be a string')
|
|
return
|
|
case 'prune':
|
|
assertInteger(request.retain, 'retain', 0)
|
|
return
|
|
case 'clean':
|
|
return
|
|
default:
|
|
throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`)
|
|
}
|
|
}
|
|
|
|
function assertIdentity(value, label) {
|
|
if (
|
|
typeof value !== 'object'
|
|
|| value === null
|
|
|| typeof value.dev !== 'string'
|
|
|| typeof value.ino !== 'string'
|
|
) {
|
|
throw new Error(`missing expected ${label} identity`)
|
|
}
|
|
}
|
|
|
|
function identityOf(metadata) {
|
|
return { dev: String(metadata.dev), ino: String(metadata.ino) }
|
|
}
|
|
|
|
function sameIdentity(metadata, expected) {
|
|
return String(metadata.dev) === expected.dev && String(metadata.ino) === expected.ino
|
|
}
|
|
|
|
async function assertPinnedRepository(repository) {
|
|
if (
|
|
typeof repository !== 'object'
|
|
|| repository === null
|
|
|| typeof repository.root !== 'string'
|
|
|| !isAbsolute(repository.root)
|
|
|| typeof repository.relative !== 'string'
|
|
|| repository.relative === ''
|
|
|| repository.relative === '..'
|
|
|| repository.relative.startsWith(`..${sep}`)
|
|
|| isAbsolute(repository.relative)
|
|
) {
|
|
throw new Error('invalid repository-relative gate-log path')
|
|
}
|
|
assertIdentity(repository.identity, 'repository')
|
|
const pinnedMetadata = await stat('.', { bigint: true })
|
|
if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, repository.identity)) {
|
|
throw new Error('gate-log repository identity changed before the helper started')
|
|
}
|
|
const rootMetadata = await lstat(repository.root, { bigint: true })
|
|
if (
|
|
!rootMetadata.isDirectory()
|
|
|| rootMetadata.isSymbolicLink()
|
|
|| !sameIdentity(rootMetadata, repository.identity)
|
|
) {
|
|
throw new Error('gate-log repository root is not a real directory')
|
|
}
|
|
}
|
|
|
|
async function enterLogDirectory(relativePath, create) {
|
|
const traversed = []
|
|
for (const component of relativePath.split(sep)) {
|
|
if (component === '' || component === '.' || component === '..') {
|
|
throw new Error(`invalid gate-log path component ${JSON.stringify(component)}`)
|
|
}
|
|
traversed.push(component)
|
|
let componentMetadata
|
|
try {
|
|
componentMetadata = await lstat(component, { bigint: true })
|
|
} catch (error) {
|
|
if (errorCode(error) !== 'ENOENT') throw error
|
|
if (!create) return undefined
|
|
try {
|
|
await mkdir(component, { mode: 0o700 })
|
|
} catch (mkdirError) {
|
|
if (errorCode(mkdirError) !== 'EEXIST') throw mkdirError
|
|
}
|
|
componentMetadata = await lstat(component, { bigint: true })
|
|
}
|
|
const shown = traversed.join('/')
|
|
if (!componentMetadata.isDirectory() || componentMetadata.isSymbolicLink()) {
|
|
throw new Error(`gate-log path component is not a real directory: ${shown}`)
|
|
}
|
|
const expected = identityOf(componentMetadata)
|
|
process.chdir(component)
|
|
const pinnedMetadata = await stat('.', { bigint: true })
|
|
if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, expected)) {
|
|
throw new Error(`gate-log path component identity changed before pinning: ${shown}`)
|
|
}
|
|
}
|
|
await chmod('.', 0o700)
|
|
return identityOf(await stat('.', { bigint: true }))
|
|
}
|
|
|
|
async function removeOldLogs(retain, newest) {
|
|
assertInteger(retain, 'retain', 0)
|
|
const entries = await readdir('.', { withFileTypes: true })
|
|
const logs = []
|
|
for (const entry of entries) {
|
|
if (!entry.isFile() || !LOG_NAME.test(entry.name)) continue
|
|
let metadata
|
|
try {
|
|
metadata = await lstat(entry.name, { bigint: true })
|
|
} catch (error) {
|
|
if (errorCode(error) === 'ENOENT') continue
|
|
throw error
|
|
}
|
|
if (!metadata.isFile() || metadata.isSymbolicLink()) continue
|
|
logs.push({ name: entry.name, mtimeNs: metadata.mtimeNs })
|
|
}
|
|
logs.sort((left, right) => {
|
|
if (left.name === newest) return 1
|
|
if (right.name === newest) return -1
|
|
if (left.mtimeNs < right.mtimeNs) return -1
|
|
if (left.mtimeNs > right.mtimeNs) return 1
|
|
return left.name.localeCompare(right.name)
|
|
})
|
|
const removed = []
|
|
for (const entry of logs.slice(0, Math.max(0, logs.length - retain))) {
|
|
try {
|
|
await unlink(entry.name)
|
|
removed.push(entry.name)
|
|
} catch (error) {
|
|
if (errorCode(error) !== 'ENOENT') throw error
|
|
}
|
|
}
|
|
return removed
|
|
}
|
|
|
|
async function writeLog(request) {
|
|
assertLogName(request.filename)
|
|
assertInteger(request.retention, 'retention', 1)
|
|
if (typeof request.content !== 'string') throw new Error('gate-log content must be a string')
|
|
const handle = await open(
|
|
request.filename,
|
|
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
0o600,
|
|
)
|
|
try {
|
|
await handle.writeFile(request.content, 'utf8')
|
|
await handle.chmod(0o600)
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
const removed = await removeOldLogs(request.retention, request.filename)
|
|
return { filename: request.filename, removed }
|
|
}
|
|
|
|
async function main() {
|
|
const request = await readRequest()
|
|
assertRequest(request)
|
|
await assertPinnedRepository(request.repository)
|
|
const directory = await enterLogDirectory(request.repository.relative, request.operation === 'write')
|
|
if (directory === undefined) return { removed: [] }
|
|
switch (request.operation) {
|
|
case 'write': {
|
|
const result = await writeLog(request)
|
|
return { ...result, directory }
|
|
}
|
|
case 'prune':
|
|
return { directory, removed: await removeOldLogs(request.retain) }
|
|
case 'clean':
|
|
return { directory, removed: await removeOldLogs(0) }
|
|
default:
|
|
throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`)
|
|
}
|
|
}
|
|
|
|
try {
|
|
process.stdout.write(`${JSON.stringify(await main())}\n`)
|
|
} catch (error) {
|
|
process.stderr.write(`gate-log-helper: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
process.exitCode = 1
|
|
}
|