Files
dsh/upstream/container/control-plane/services/management/settings-document.mjs
T

83 lines
3.0 KiB
JavaScript

import { createHash, randomUUID } from 'node:crypto'
import { chown, lstat, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
export const MAX_SETTINGS_DOCUMENT_BYTES = 1024 * 1024
const UTF8 = new TextDecoder('utf-8', { fatal: true })
function revision(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function assertRegularFile(details) {
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error('settings document must be a regular file')
}
}
export class SettingsDocumentConflictError extends Error {}
export class SettingsDocumentStore {
constructor(dshHome, { maxBytes = MAX_SETTINGS_DOCUMENT_BYTES } = {}) {
this.dshHome = resolve(dshHome)
this.path = join(this.dshHome, 'settings.yaml')
this.maxBytes = maxBytes
}
async read() {
let details
try {
details = await lstat(this.path)
} catch (error) {
if (error?.code === 'ENOENT') {
const bytes = Buffer.alloc(0)
return Object.freeze({ content: '', revision: revision(bytes), exists: false })
}
throw error
}
assertRegularFile(details)
if (details.size > this.maxBytes) throw new Error('settings document is too large')
const bytes = await readFile(this.path)
if (bytes.byteLength > this.maxBytes) throw new Error('settings document is too large')
let content
try {
content = UTF8.decode(bytes)
} catch {
throw new Error('settings document is not valid UTF-8')
}
return Object.freeze({ content, revision: revision(bytes), exists: true })
}
async write(content, expectedRevision) {
if (typeof content !== 'string') throw new Error('settings document content must be a string')
if (typeof expectedRevision !== 'string' || !/^[a-f0-9]{64}$/.test(expectedRevision)) {
throw new Error('settings document revision is invalid')
}
const bytes = Buffer.from(content)
if (bytes.byteLength > this.maxBytes) throw new Error('settings document is too large')
const current = await this.read()
if (current.revision !== expectedRevision) {
throw new SettingsDocumentConflictError('settings document changed since it was loaded')
}
await mkdir(this.dshHome, { recursive: true, mode: 0o700 })
const owner = current.exists ? await lstat(this.path) : await stat(this.dshHome)
if (current.exists) assertRegularFile(owner)
const temporary = join(this.dshHome, `.settings.yaml.${randomUUID()}.tmp`)
try {
await writeFile(temporary, bytes, {
flag: 'wx',
mode: current.exists ? owner.mode & 0o777 : 0o600,
})
if (process.getuid?.() === 0) await chown(temporary, owner.uid, owner.gid)
if ((await this.read()).revision !== expectedRevision) {
throw new SettingsDocumentConflictError('settings document changed while it was being saved')
}
await rename(temporary, this.path)
} finally {
await rm(temporary, { force: true })
}
return Object.freeze({ content, revision: revision(bytes), exists: true })
}
}