Files

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)
}
}
}