summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/tools/path-utils.ts
blob: 3bba0d3ae201f7f084080ff4d427e05a0e2dc7af (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import { realpath } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";

/**
 * Resolve a path to its canonical absolute form, following symlinks at
 * every level.
 *
 * When the leaf does not exist (common for `write_file` creating a new
 * file), walks up to the nearest existing ancestor, canonicalizes that,
 * then re-appends the missing trailing segments — ensuring a symlink in
 * the *middle* of the path is still resolved. Without this, a
 * `workdir/escape-link/new-file.txt` write where `escape-link` points
 * outside the workdir would slip the containment check.
 *
 * Used everywhere we compare a user-supplied path against a trusted
 * root (workdir, SPILL_ROOT). Resolving symlinks consistently is the
 * only reliable way to detect a symlink-in-workdir-pointing-outside
 * escape; lexical-only checks let those through.
 *
 * Argument semantics match `path.resolve(...paths)`: later absolute
 * segments override earlier ones, relative segments are joined.
 */
export async function canonicalize(...paths: string[]): Promise<string> {
	const lexical = resolve(...paths);

	// Fast path: full path exists, realpath resolves all symlinks.
	try {
		return await realpath(lexical);
	} catch {
		// Path doesn't exist — fall through to ancestor walk.
	}

	// Walk up until we hit an existing ancestor we can realpath, then
	// re-append the missing trailing segments. This handles cases like
	// write_file creating /workdir/symlink/new/dir/file.txt where the
	// "symlink" segment exists but the rest doesn't.
	let current = lexical;
	const trailing: string[] = [];
	while (true) {
		const parent = dirname(current);
		if (parent === current) break; // hit filesystem root
		trailing.unshift(basename(current));
		try {
			const realParent = await realpath(parent);
			return join(realParent, ...trailing);
		} catch {
			current = parent;
		}
	}

	// No existing ancestor (pathological — e.g. the entire mount is gone).
	// Return the lexical path; downstream containment checks will still
	// catch obvious escapes via `..` etc.
	return lexical;
}