hardening
This commit is contained in:
146
render.ts
146
render.ts
@@ -1,5 +1,15 @@
|
||||
import { readdirSync, statSync, readFileSync, mkdirSync, writeFileSync, rmSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
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
|
||||
@@ -7,19 +17,20 @@ const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
|
||||
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
|
||||
const PATH_RE = /^context\.(.+)$/
|
||||
|
||||
function evalCondition(expr: string, context: Record<string, unknown>): boolean {
|
||||
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 resolve(context, eqMatch[1]!) === eqMatch[2]
|
||||
return resolveContext(context, eqMatch[1]!) === eqMatch[2]
|
||||
}
|
||||
const pathMatch = expr.match(PATH_RE)
|
||||
if (pathMatch) {
|
||||
return !!resolve(context, pathMatch[1]!)
|
||||
return !!resolveContext(context, pathMatch[1]!)
|
||||
}
|
||||
throw new Error(`Invalid condition expression: ${expr}`)
|
||||
}
|
||||
|
||||
function resolve(context: Record<string, unknown>, path: string): unknown {
|
||||
function resolveContext(context: Record<string, unknown>, path: string): unknown {
|
||||
const segments = path.split(".")
|
||||
let current: unknown = context
|
||||
for (const seg of segments) {
|
||||
@@ -29,8 +40,65 @@ function resolve(context: Record<string, unknown>, path: string): unknown {
|
||||
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 }
|
||||
type IfFrame = { matched: boolean; active: boolean; sawElse: boolean }
|
||||
|
||||
function processIfBlocks(content: string, context: Record<string, unknown>): string {
|
||||
let result = ""
|
||||
@@ -50,19 +118,20 @@ function processIfBlocks(content: string, context: Record<string, unknown>): str
|
||||
|
||||
if (directive === "if") {
|
||||
if (isEmitting()) result += content.slice(pos, match.index)
|
||||
const truthy = evalCondition(condPath!, context)
|
||||
stack.push({ matched: truthy, active: truthy })
|
||||
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)
|
||||
const truthy = evalCondition(condPath, context)
|
||||
top.matched = truthy
|
||||
top.active = truthy
|
||||
}
|
||||
@@ -70,9 +139,11 @@ function processIfBlocks(content: string, context: Record<string, unknown>): str
|
||||
} 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>")
|
||||
@@ -93,7 +164,7 @@ function processIfBlocks(content: string, context: Record<string, unknown>): str
|
||||
function renderContent(content: string, context: Record<string, unknown>): string {
|
||||
const processed = processIfBlocks(content, context)
|
||||
return processed.replace(VAR_RE, (_match, path: string) => {
|
||||
return String(resolve(context, path) ?? "")
|
||||
return String(resolveContext(context, path) ?? "")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -102,8 +173,31 @@ function renderDir(
|
||||
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, destDir, context)
|
||||
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(
|
||||
@@ -115,29 +209,23 @@ function renderDirInner(
|
||||
const entries = readdirSync(srcDir).sort()
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = join(srcDir, entry)
|
||||
const srcPath = resolvePath(srcDir, entry)
|
||||
const stat = statSync(srcPath)
|
||||
|
||||
// Check if path segment has an @if directive
|
||||
const ifMatch = entry.match(IF_PATH_RE)
|
||||
let outputName = entry
|
||||
if (ifMatch) {
|
||||
if (!evalCondition(ifMatch[1]!, context)) continue
|
||||
outputName = ifMatch[2]!
|
||||
}
|
||||
const outputName = getOutputName(entry, context)
|
||||
if (outputName === null) continue
|
||||
|
||||
// Process @var in the output name
|
||||
outputName = outputName.replace(VAR_RE, (_match, path: string) => {
|
||||
return String(resolve(context, path) ?? "")
|
||||
})
|
||||
|
||||
const destPath = join(destDir, outputName)
|
||||
const destPath = resolveOutputPath(destDir, outputName)
|
||||
if (stat.isDirectory()) {
|
||||
renderDirInner(srcPath, destPath, context)
|
||||
} else {
|
||||
mkdirSync(destDir, { recursive: true })
|
||||
const content = readFileSync(srcPath, "utf-8")
|
||||
writeFileSync(destPath, renderContent(content, context))
|
||||
mkdirSync(dirname(destPath), { recursive: true })
|
||||
const content = readFileSync(srcPath)
|
||||
if (isUtf8Text(content)) {
|
||||
writeFileSync(destPath, renderContent(content.toString("utf-8"), context))
|
||||
} else {
|
||||
copyFileSync(srcPath, destPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user