79 lines
3.2 KiB
JavaScript
79 lines
3.2 KiB
JavaScript
import { link, open, rename, rm, stat } from 'node:fs/promises'
|
|
import { dirname, join } from 'node:path'
|
|
import { randomUUID } from 'node:crypto'
|
|
import {
|
|
FileManagerError, FileRevisionConflictError, MAX_TEXT_BYTES, fileError,
|
|
fileManagerInternals, isManagedPath, normalizeAbsolutePath,
|
|
} from './index.mjs'
|
|
|
|
async function syncDirectory(path) {
|
|
const handle = await open(path, 'r')
|
|
try { await handle.sync() } finally { await handle.close() }
|
|
}
|
|
|
|
function bytes(value) {
|
|
if (typeof value !== 'string') throw new FileManagerError('content must be a string')
|
|
const result = Buffer.from(value, 'utf8')
|
|
if (result.byteLength > MAX_TEXT_BYTES) throw new FileManagerError('file is too large to edit', 413, 'FILE_TOO_LARGE')
|
|
if (result.includes(0)) throw new FileManagerError('content contains NUL', 415, 'FILE_TYPE_UNSUPPORTED')
|
|
return result
|
|
}
|
|
|
|
export class AtomicFileEditor {
|
|
constructor({ isManaged = isManagedPath } = {}) {
|
|
this.isManaged = isManaged
|
|
}
|
|
|
|
async write(value, content, revision, { create = false } = {}) {
|
|
const path = normalizeAbsolutePath(value)
|
|
const body = bytes(content)
|
|
const parent = dirname(path)
|
|
let original
|
|
try {
|
|
original = await stat(path, { bigint: true })
|
|
if (!original.isFile()) throw new FileManagerError('path is not a regular file', 415, 'FILE_TYPE_UNSUPPORTED')
|
|
if (create) throw new FileManagerError('file already exists', 409, 'FILE_EXISTS')
|
|
if (typeof revision !== 'string' || revision !== fileManagerInternals.revisionFor(path, original)) {
|
|
throw new FileRevisionConflictError()
|
|
}
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') throw fileError(error)
|
|
if (!create) throw fileError(error)
|
|
if (revision !== null && revision !== undefined) throw new FileManagerError('new file revision must be null')
|
|
}
|
|
const temporary = join(parent, `.dsh-edit-${randomUUID()}.tmp`)
|
|
let handle
|
|
try {
|
|
handle = await open(temporary, 'wx', original === undefined ? 0o644 : Number(original.mode & 0o7777n))
|
|
await handle.writeFile(body)
|
|
await handle.sync()
|
|
if (original !== undefined) {
|
|
await handle.chmod(Number(original.mode & 0o7777n))
|
|
const current = await handle.stat({ bigint: true })
|
|
if (current.uid !== original.uid || current.gid !== original.gid) {
|
|
try { await handle.chown(Number(original.uid), Number(original.gid)) } catch {
|
|
throw new FileManagerError('cannot preserve file ownership; use the terminal', 403, 'OWNERSHIP_PRESERVE_FAILED')
|
|
}
|
|
}
|
|
}
|
|
await handle.close()
|
|
handle = undefined
|
|
if (original === undefined) {
|
|
await link(temporary, path)
|
|
await rm(temporary)
|
|
} else await rename(temporary, path)
|
|
await syncDirectory(parent)
|
|
const saved = await stat(path, { bigint: true })
|
|
return {
|
|
path, revision: fileManagerInternals.revisionFor(path, saved), size: body.byteLength,
|
|
mode: Number(saved.mode & 0o7777n), modifiedAt: new Date(Number(saved.mtimeNs / 1_000_000n)).toISOString(),
|
|
managed: this.isManaged(path),
|
|
}
|
|
} catch (error) {
|
|
await handle?.close().catch(() => {})
|
|
await rm(temporary, { force: true }).catch(() => {})
|
|
throw fileError(error)
|
|
}
|
|
}
|
|
}
|