Files
tdir/render.ts
Gregor Lohaus 3110eefcbd hardening
2026-05-22 08:55:28 +02:00

234 lines
7.2 KiB
TypeScript

import {
copyFileSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs"
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
import { homedir } from "node:os"
import { TextDecoder } from "node:util"
const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
const PATH_RE = /^context\.(.+)$/
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
if (!expr) throw new Error("Missing condition expression")
const eqMatch = expr.match(EQ_RE)
if (eqMatch) {
return resolveContext(context, eqMatch[1]!) === eqMatch[2]
}
const pathMatch = expr.match(PATH_RE)
if (pathMatch) {
return !!resolveContext(context, pathMatch[1]!)
}
throw new Error(`Invalid condition expression: ${expr}`)
}
function resolveContext(context: Record<string, unknown>, path: string): unknown {
const segments = path.split(".")
let current: unknown = context
for (const seg of segments) {
if (current === null || current === undefined || typeof current !== "object") return undefined
current = (current as Record<string, unknown>)[seg]
}
return current
}
function isInsidePath(parent: string, child: string): boolean {
const rel = relative(parent, child)
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))
}
function assertSafeRenderTarget(srcDir: string, destDir: string) {
const sourceRoot = resolvePath(srcDir)
const destRoot = resolvePath(destDir)
if (destRoot === resolvePath(destRoot, "..")) {
throw new Error("Refusing to render into filesystem root")
}
if (destRoot === resolvePath(process.cwd())) {
throw new Error("Refusing to render into the current working directory")
}
if (destRoot === resolvePath(homedir())) {
throw new Error("Refusing to render into the home directory")
}
if (isInsidePath(sourceRoot, destRoot) || isInsidePath(destRoot, sourceRoot)) {
throw new Error("Refusing to render when source and target directories overlap")
}
}
function resolveOutputPath(destRoot: string, outputName: string): string {
const outputPath = resolvePath(destRoot, outputName)
if (!isInsidePath(destRoot, outputPath)) {
throw new Error(`Refusing to write outside target directory: ${outputName}`)
}
return outputPath
}
function getOutputName(entry: string, context: Record<string, unknown>): string | null {
const ifMatch = entry.match(IF_PATH_RE)
let outputName = entry
if (ifMatch) {
if (!evalCondition(ifMatch[1], context)) return null
outputName = ifMatch[2]!
}
return outputName.replace(VAR_RE, (_match, path: string) => {
return String(resolveContext(context, path) ?? "")
})
}
function isUtf8Text(buffer: Buffer): boolean {
if (buffer.indexOf(0) !== -1) return false
try {
new TextDecoder("utf-8", { fatal: true }).decode(buffer)
return true
} catch {
return false
}
}
// Each stack frame tracks: did any branch match yet, is the current branch active
type IfFrame = { matched: boolean; active: boolean; sawElse: boolean }
function processIfBlocks(content: string, context: Record<string, unknown>): string {
let result = ""
let pos = 0
const stack: IfFrame[] = []
const re = new RegExp(DIRECTIVE_RE.source, "g")
let match: RegExpExecArray | null
function isEmitting(): boolean {
return stack.every(f => f.active)
}
while ((match = re.exec(content)) !== null) {
const directive = match[1]!
const condPath = match[2]
if (directive === "if") {
if (isEmitting()) result += content.slice(pos, match.index)
const truthy = evalCondition(condPath, context)
stack.push({ matched: truthy, active: truthy, sawElse: false })
pos = re.lastIndex
} else if (directive === "elseif") {
if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>")
const top = stack[stack.length - 1]!
if (top.sawElse) throw new Error("Unexpected <@elseif> after <@else>")
// Emit text before this directive if current branch was active
if (isEmitting()) result += content.slice(pos, match.index)
if (top.matched) {
// A previous branch already matched — skip this one
top.active = false
} else {
const truthy = evalCondition(condPath, context)
top.matched = truthy
top.active = truthy
}
pos = re.lastIndex
} else if (directive === "else") {
if (stack.length === 0) throw new Error("Unexpected <@else> without <@if>")
const top = stack[stack.length - 1]!
if (top.sawElse) throw new Error("Unexpected duplicate <@else>")
if (isEmitting()) result += content.slice(pos, match.index)
top.active = !top.matched
top.matched = true
top.sawElse = true
pos = re.lastIndex
} else if (directive === "endif") {
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>")
if (isEmitting()) result += content.slice(pos, match.index)
stack.pop()
pos = re.lastIndex
}
}
if (stack.length > 0) {
throw new Error("Unmatched <@if> without <@endif>")
}
result += content.slice(pos)
return result
}
function renderContent(content: string, context: Record<string, unknown>): string {
const processed = processIfBlocks(content, context)
return processed.replace(VAR_RE, (_match, path: string) => {
return String(resolveContext(context, path) ?? "")
})
}
function renderDir(
srcDir: string,
destDir: string,
context: Record<string, unknown>,
) {
const destRoot = resolvePath(destDir)
assertSafeRenderTarget(srcDir, destDir)
validateOutputPaths(srcDir, destRoot, context)
rmSync(destDir, { recursive: true, force: true })
renderDirInner(srcDir, destRoot, context)
}
function validateOutputPaths(
srcDir: string,
destDir: string,
context: Record<string, unknown>,
) {
const entries = readdirSync(srcDir).sort()
for (const entry of entries) {
const srcPath = resolvePath(srcDir, entry)
const stat = statSync(srcPath)
const outputName = getOutputName(entry, context)
if (outputName === null) continue
const destPath = resolveOutputPath(destDir, outputName)
if (stat.isDirectory()) {
validateOutputPaths(srcPath, destPath, context)
}
}
}
function renderDirInner(
srcDir: string,
destDir: string,
context: Record<string, unknown>,
) {
mkdirSync(destDir, { recursive: true })
const entries = readdirSync(srcDir).sort()
for (const entry of entries) {
const srcPath = resolvePath(srcDir, entry)
const stat = statSync(srcPath)
const outputName = getOutputName(entry, context)
if (outputName === null) continue
const destPath = resolveOutputPath(destDir, outputName)
if (stat.isDirectory()) {
renderDirInner(srcPath, destPath, context)
} else {
mkdirSync(dirname(destPath), { recursive: true })
const content = readFileSync(srcPath)
if (isUtf8Text(content)) {
writeFileSync(destPath, renderContent(content.toString("utf-8"), context))
} else {
copyFileSync(srcPath, destPath)
}
}
}
}
export { renderDir, renderContent }