mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
130 lines
4.5 KiB
TypeScript
130 lines
4.5 KiB
TypeScript
import { createHash } from 'node:crypto'
|
|
import { createReadStream } from 'node:fs'
|
|
import { readdir, lstat } from 'node:fs/promises'
|
|
import { join, relative } from 'node:path'
|
|
import { pluginPathSegmentError } from '../../shared/plugins/plugin-path-safety'
|
|
|
|
/**
|
|
* Deterministic hash of a plugin's file tree. The hash names the immutable
|
|
* install directory (`<userData>/plugins/<publisher>.<id>/<hash>/`), so two
|
|
* installs of identical content share a name and a mutated install is
|
|
* detectable. Hashes relative paths + file bytes in sorted order; symlinks
|
|
* are refused outright (installed trees must be self-contained).
|
|
*/
|
|
|
|
const MAX_PLUGIN_FILES = 2_000
|
|
const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024
|
|
|
|
type PluginFile = { path: string; size: number }
|
|
|
|
export type PluginTreeHashResult =
|
|
| { ok: true; hash: string; fileCount: number; totalBytes: number }
|
|
| { ok: false; error: string }
|
|
|
|
async function collectFiles(
|
|
root: string,
|
|
dir: string,
|
|
files: PluginFile[],
|
|
counters: { entries: number; bytes: number }
|
|
): Promise<string | null> {
|
|
const entries = await readdir(dir, { withFileTypes: true })
|
|
// Why: localeCompare ordering varies with host locale/ICU data; content
|
|
// addresses must sort identically on macOS, Linux, and Windows.
|
|
entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
|
|
for (const entry of entries) {
|
|
if (dir === root && entry.name === '.git') {
|
|
continue
|
|
}
|
|
const segmentError = pluginPathSegmentError(entry.name)
|
|
if (segmentError) {
|
|
return `unsafe plugin path segment "${entry.name}": ${segmentError}`
|
|
}
|
|
const full = join(dir, entry.name)
|
|
const stat = await lstat(full)
|
|
counters.entries += 1
|
|
if (counters.entries > MAX_PLUGIN_FILES) {
|
|
return `plugin exceeds the ${MAX_PLUGIN_FILES}-entry limit`
|
|
}
|
|
if (stat.isSymbolicLink()) {
|
|
return `symlink not allowed in plugin content: ${relative(root, full)}`
|
|
}
|
|
if (stat.isDirectory()) {
|
|
const error = await collectFiles(root, full, files, counters)
|
|
if (error) {
|
|
return error
|
|
}
|
|
} else if (stat.isFile()) {
|
|
counters.bytes += stat.size
|
|
if (counters.bytes > MAX_PLUGIN_TOTAL_BYTES) {
|
|
return `plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit`
|
|
}
|
|
files.push({ path: full, size: stat.size })
|
|
} else {
|
|
return `unsupported plugin entry type: ${relative(root, full)}`
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
async function hashFileBounded(
|
|
hash: ReturnType<typeof createHash>,
|
|
file: PluginFile
|
|
): Promise<number> {
|
|
let bytesRead = 0
|
|
for await (const chunk of createReadStream(file.path)) {
|
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
bytesRead += bytes.byteLength
|
|
if (bytesRead > file.size || bytesRead > MAX_PLUGIN_TOTAL_BYTES) {
|
|
throw new Error(`plugin file changed while hashing: ${file.path}`)
|
|
}
|
|
hash.update(bytes)
|
|
}
|
|
if (bytesRead !== file.size) {
|
|
throw new Error(`plugin file changed while hashing: ${file.path}`)
|
|
}
|
|
return bytesRead
|
|
}
|
|
|
|
export async function hashPluginTree(root: string): Promise<PluginTreeHashResult> {
|
|
const files: PluginFile[] = []
|
|
try {
|
|
const counters = { entries: 0, bytes: 0 }
|
|
const error = await collectFiles(root, root, files, counters)
|
|
if (error) {
|
|
return { ok: false, error }
|
|
}
|
|
const hash = createHash('sha256')
|
|
// Why: every record is length-framed so path/content delimiters inside a
|
|
// plugin file cannot make two different trees share one hash preimage.
|
|
hash.update('orca-plugin-tree-v1\0')
|
|
let totalBytes = 0
|
|
for (const file of files) {
|
|
totalBytes += file.size
|
|
if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) {
|
|
return { ok: false, error: `plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit` }
|
|
}
|
|
// Normalize separators so the same tree hashes identically on Windows.
|
|
const rel = relative(root, file.path).replaceAll('\\', '/')
|
|
hashLength(hash, Buffer.byteLength(rel, 'utf8'))
|
|
hash.update(rel, 'utf8')
|
|
hashLength(hash, file.size)
|
|
await hashFileBounded(hash, file)
|
|
}
|
|
// Hex (not base64) because the hash becomes a directory name.
|
|
return {
|
|
ok: true,
|
|
hash: hash.digest('hex'),
|
|
fileCount: files.length,
|
|
totalBytes
|
|
}
|
|
} catch (error) {
|
|
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
}
|
|
}
|
|
|
|
function hashLength(hash: ReturnType<typeof createHash>, length: number): void {
|
|
const framedLength = Buffer.allocUnsafe(8)
|
|
framedLength.writeBigUInt64BE(BigInt(length))
|
|
hash.update(framedLength)
|
|
}
|