@procedures/archive 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/root.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The root of the archive procedures: one folder that confines each path (roadmap R33).
3
+ *
4
+ * The rules are the rules of `root.ts` of `@procedures/fs` (`createFsModule({ root })`):
5
+ * - A relative path starts at the root, not at the working folder of the process.
6
+ * - A path outside the root gives `FORBIDDEN` with the reason `outside-root`. This includes a
7
+ * path that goes up with `..`, an absolute path outside the root, a path on another drive, and
8
+ * a symlink or a junction that leads out of the root.
9
+ * - The check uses the real path (`fs.realpath`) of the path. For a path that does not exist, it
10
+ * uses the real path of the nearest existing ancestor. So a symlinked parent folder cannot
11
+ * lead a write or a `mkdir` out of the root.
12
+ *
13
+ * On Windows, the comparison ignores the case of the letters, and `/` and `\` are the same.
14
+ */
15
+ import { readlink, realpath } from "node:fs/promises";
16
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
17
+ import { InvocationError } from "@procedures/client";
18
+ /** The reason of the `FORBIDDEN` error for a path outside the root. */
19
+ export const OUTSIDE_ROOT = "outside-root";
20
+ /** The maximum number of symlinks that one check follows. After it, the check fails with `ELOOP`. */
21
+ const MAX_LINKS = 40;
22
+ /**
23
+ * This function tells if `path` is `folder` or a path in `folder`. The two paths are absolute.
24
+ * On Windows, the case of the letters is not significant.
25
+ */
26
+ export function isInside(folder, path) {
27
+ const rel = relative(folder, path);
28
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
29
+ }
30
+ /** True for the error of a path that does not exist. */
31
+ function isMissing(error) {
32
+ const code = error.code;
33
+ return code === "ENOENT" || code === "ENOTDIR";
34
+ }
35
+ /** The target of a symlink, or undefined when the path is not a symlink or does not exist. */
36
+ async function linkTarget(path) {
37
+ try {
38
+ return await readlink(path);
39
+ }
40
+ catch (error) {
41
+ // EINVAL: the path is not a symlink
42
+ const code = error.code;
43
+ if (code === "EINVAL" || isMissing(error))
44
+ return undefined;
45
+ throw error;
46
+ }
47
+ }
48
+ /**
49
+ * This function gives the real path of an absolute path. For a path that does not exist, it
50
+ * gives the real path of the nearest existing ancestor and the rest of the path. A symlink whose
51
+ * target does not exist leads to its target, because a write follows it.
52
+ */
53
+ export async function realPathOf(path, links = 0) {
54
+ const rest = [];
55
+ let current = path;
56
+ for (;;) {
57
+ try {
58
+ return join(await realpath(current), ...rest);
59
+ }
60
+ catch (error) {
61
+ if (!isMissing(error))
62
+ throw error;
63
+ }
64
+ const target = await linkTarget(current);
65
+ if (target !== undefined) {
66
+ if (links >= MAX_LINKS) {
67
+ const loop = new Error(`Too many symlinks in the path ${path}`);
68
+ loop.code = "ELOOP";
69
+ throw loop;
70
+ }
71
+ return join(await realPathOf(resolve(dirname(current), target), links + 1), ...rest);
72
+ }
73
+ const parent = dirname(current);
74
+ // No ancestor exists, for example a drive that is not there
75
+ if (parent === current)
76
+ return join(current, ...rest);
77
+ rest.unshift(basename(current));
78
+ current = parent;
79
+ }
80
+ }
81
+ /** This function gives the absolute path and the real path of a root. The root must exist. */
82
+ export async function resolveRoot(root) {
83
+ const folder = resolve(root);
84
+ try {
85
+ return { folder, real: await realpath(folder) };
86
+ }
87
+ catch (error) {
88
+ if (!isMissing(error))
89
+ throw error;
90
+ const missing = new Error(`The root of the archive procedures does not exist: ${folder}`);
91
+ missing.code = "ENOENT";
92
+ throw missing;
93
+ }
94
+ }
95
+ /** The `FORBIDDEN` error for a path outside the root. */
96
+ export function outsideRoot(procedure, message) {
97
+ return new InvocationError("FORBIDDEN", `${procedure.join(".")}: ${message}`, procedure, OUTSIDE_ROOT);
98
+ }
99
+ /** True for the `FORBIDDEN` error of a path outside the root. */
100
+ export function isOutsideRoot(error) {
101
+ return error instanceof InvocationError && error.code === "FORBIDDEN" && error.reason === OUTSIDE_ROOT;
102
+ }
103
+ /**
104
+ * This function gives the absolute path of `path` in the root. A relative path starts at the
105
+ * root. The real path decides: a path whose real path is outside the real path of the root gives
106
+ * `FORBIDDEN` with the reason `outside-root`.
107
+ */
108
+ export async function inRoot(root, path, procedure) {
109
+ const absolute = resolve(root.folder, path);
110
+ const real = await realPathOf(absolute);
111
+ // A path counts as inside by its given form or by its real form. On Windows, the two forms of a
112
+ // folder can differ by more than the links: a short 8.3 name (RUNNER~1) has a long real name.
113
+ if (!isInside(root.folder, absolute) && !isInside(root.real, absolute) && !isInside(root.real, real)) {
114
+ throw outsideRoot(procedure, `the path ${JSON.stringify(path)} is outside the root ${root.folder}`);
115
+ }
116
+ if (!isInside(root.real, real)) {
117
+ throw outsideRoot(procedure, `the path ${JSON.stringify(path)} leads out of the root ${root.folder} through a symlink`);
118
+ }
119
+ return absolute;
120
+ }
121
+ /** This function gives the paths of one call. With a root, the root must exist when the call starts. */
122
+ export async function placesOf(root, procedure) {
123
+ if (root === undefined)
124
+ return { root: undefined, place: async (path) => resolve(path) };
125
+ const resolved = await resolveRoot(root);
126
+ return { root: resolved, place: (path) => inRoot(resolved, path, procedure) };
127
+ }
128
+ //# sourceMappingURL=root.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"root.js","sourceRoot":"","sources":["../src/root.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@procedures/archive",
4
- "version": "0.4.0",
4
+ "version": "0.6.0",
5
5
  "description": "Archives as procedures: make, list and extract tar.gz and zip, with a check of each path against zip slip and links out of the target.",
6
6
  "keywords": [
7
7
  "procedures",
@@ -62,7 +62,7 @@
62
62
  "procedures": "./dist/register.js"
63
63
  },
64
64
  "dependencies": {
65
- "@procedures/client": "0.4.0",
65
+ "@procedures/client": "0.6.0",
66
66
  "tar": "^7.5.0",
67
67
  "zod": "^4.6.5"
68
68
  },
package/src/format.ts CHANGED
@@ -7,12 +7,14 @@ import { open } from "node:fs/promises";
7
7
  import { ArchiveError } from "./errors.js";
8
8
  import type { ArchiveFormat } from "./types.js";
9
9
 
10
- /** The limits of the procedures of a module. */
10
+ /** The limits and the root of the procedures of a module. */
11
11
  export interface ArchiveSettings {
12
12
  /** The most bytes of the files of one archive: `extract` and `create` refuse more. */
13
13
  readonly maxBytes: number;
14
14
  /** The most entries of one archive. */
15
15
  readonly maxEntries: number;
16
+ /** The absolute folder that confines each path of the inputs (roadmap R33), or undefined for no root. */
17
+ readonly root?: string | undefined;
16
18
  }
17
19
 
18
20
  /** The default most bytes: 1 GiB. */
package/src/index.ts CHANGED
@@ -6,4 +6,6 @@ export { archiveCreate, archiveExtract, archiveList } from "./procedures/archive
6
6
  export { ArchiveError, type ArchiveErrorCode } from "./errors.js";
7
7
  export { DEFAULT_ARCHIVE_SETTINGS, DEFAULT_MAX_BYTES, DEFAULT_MAX_ENTRIES, formatOfFile, formatOfName, sniffFormat, type ArchiveSettings } from "./format.js";
8
8
  export { entryParts, isAbsoluteName, planExtract, type PlanOptions, type PlannedEntry, type RawEntry } from "./paths.js";
9
+ // The root that confines each path (roadmap R33)
10
+ export { OUTSIDE_ROOT, isInside, isOutsideRoot } from "./root.js";
9
11
  export { crc32, readZipDirectory, writeZip, type ZipEntry, type ZipWriteEntry } from "./zip.js";
package/src/module.ts CHANGED
@@ -4,9 +4,11 @@
4
4
  * Scaffold a procedure with: mark procedure new <name> --path packages/archive
5
5
  *
6
6
  * `archiveModule` has the default limits: 1 GiB of files and 100000 entries in one archive.
7
- * `createArchiveModule()` gives the same procedures with other limits (roadmap R46).
7
+ * `createArchiveModule()` gives the same procedures with other limits (roadmap R46), or with a
8
+ * root that confines each path of the inputs (roadmap R33, `root.ts`).
8
9
  */
9
10
 
11
+ import { resolve } from "node:path";
10
12
  import { z } from "zod";
11
13
  import {
12
14
  defineModule,
@@ -43,7 +45,7 @@ const ArchiveCreateInputSchema = z.object({
43
45
  output: path("The archive to make"),
44
46
  source: path("A folder: the archive gets its content").optional(),
45
47
  files: z.array(z.string().min(1).max(4096)).min(1).max(10_000).optional().describe("Files and folders, relative to cwd. A folder goes in with its content"),
46
- cwd: path("The base folder of files (default: the working folder)").optional(),
48
+ cwd: path("The base folder of files (default: the working folder, or the root of the module)").optional(),
47
49
  format: format.optional().describe("The format (default: from the name of output: .tar.gz, .tgz, .tar or .zip)"),
48
50
  overwrite: z.boolean().optional().describe("True: replace an existing archive (default false)"),
49
51
  });
@@ -164,28 +166,39 @@ export interface ArchiveModuleOptions {
164
166
  readonly maxBytes?: number | undefined;
165
167
  /** The most entries of one archive. The default is 100000. */
166
168
  readonly maxEntries?: number | undefined;
169
+ /**
170
+ * The folder that confines each path of the procedures (roadmap R33), as `createFsModule({ root })`
171
+ * does. A relative path of an input starts at the root. A path outside the root gives `FORBIDDEN`
172
+ * with the reason `outside-root`. A relative root starts at the working folder of the process.
173
+ */
174
+ readonly root?: string | undefined;
167
175
  }
168
176
 
169
177
  /**
170
- * This function gives the module of this package with other limits. Without options, it gives
171
- * `archiveModule`. A host gives it to `createRegistry()` before its bundles, so it replaces the
172
- * `archiveModule` that a bundle requires (the first module of a name counts).
178
+ * This function gives the module of this package with other limits, or with a root. Without
179
+ * options, it gives `archiveModule`. A host gives it to `createRegistry()` before its bundles, so it
180
+ * replaces the `archiveModule` that a bundle requires (the first module of a name counts).
173
181
  */
174
182
  export function createArchiveModule(options: ArchiveModuleOptions = {}): ProcedureModule {
175
183
  if (Object.values(options).every((value) => value === undefined)) return archiveModule;
176
- const settings: ArchiveSettings = {
184
+ const limits = {
177
185
  maxBytes: options.maxBytes ?? DEFAULT_ARCHIVE_SETTINGS.maxBytes,
178
186
  maxEntries: options.maxEntries ?? DEFAULT_ARCHIVE_SETTINGS.maxEntries,
179
187
  };
180
- for (const [name, value] of Object.entries(settings)) {
188
+ for (const [name, value] of Object.entries(limits)) {
181
189
  if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`createArchiveModule: ${name} must be a whole number of 0 or more`);
182
190
  }
191
+ if (options.root !== undefined && (typeof options.root !== "string" || options.root === "")) {
192
+ throw new TypeError("createArchiveModule: root must be the path of a folder");
193
+ }
194
+ // The root resolves against the working folder of the process now, as the root of @procedures/fs
195
+ const settings: ArchiveSettings = { ...limits, ...(options.root !== undefined ? { root: resolve(options.root) } : {}) };
183
196
  return defineModule("@procedures/archive", archiveProcedures(settings));
184
197
  }
185
198
 
186
199
  /**
187
200
  * This function registers the procedures of @procedures/archive in a registry. The default is the
188
- * global registry. The options are the options of `createArchiveModule()`.
201
+ * global registry. The options are the options of `createArchiveModule()`, with the root too.
189
202
  */
190
203
  export function registerArchiveProcedures(registry: ProcedureRegistry = PROCEDURE_REGISTRY, options: ArchiveModuleOptions = {}): void {
191
204
  registerModules(registry, [createArchiveModule(options)]);
@@ -4,36 +4,42 @@
4
4
  * Make a tar.gz, tar or zip archive of a folder or of files. The procedure writes a temporary
5
5
  * file next to the archive, and renames it at the end: a failed call leaves no part of an
6
6
  * archive. The walk does not follow a symbolic link: the archive gets the link.
7
+ *
8
+ * With the root of the module, the archive, the source, `cwd` and the folder of each file must be
9
+ * in the root. A link in the input goes into the archive as a link: the walk does not read its target.
7
10
  */
8
11
 
9
12
  import { randomBytes } from "node:crypto";
10
13
  import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
11
- import { dirname, resolve } from "node:path";
14
+ import { dirname } from "node:path";
12
15
  import { ArchiveError } from "../../errors.js";
13
16
  import { DEFAULT_ARCHIVE_SETTINGS, formatOfName, type ArchiveSettings } from "../../format.js";
14
17
  import { entryParts } from "../../paths.js";
18
+ import { placesOf, type Places } from "../../root.js";
15
19
  import { createTar } from "../../tar.js";
16
- import { folderNames, walk } from "../../walk.js";
20
+ import { folderNames, under, walk } from "../../walk.js";
17
21
  import { writeZip, type ZipWriteEntry } from "../../zip.js";
18
22
  import type { ArchiveCreateInput, ArchiveCreateOutput } from "../../types.js";
19
23
 
20
24
  /** The base folder and the names of the input. Exactly one of `source` and `files`. */
21
- async function inputNames(input: ArchiveCreateInput): Promise<{ base: string; names: string[] }> {
25
+ async function inputNames(input: ArchiveCreateInput, places: Places): Promise<{ base: string; names: string[] }> {
22
26
  if ((input.source === undefined) === (input.files === undefined)) {
23
27
  throw new ArchiveError("UNSAFE_PATH", "archive.create needs exactly one of source and files");
24
28
  }
25
29
  if (input.source !== undefined) {
26
- const base = resolve(input.source);
30
+ const base = await places.place(input.source);
27
31
  const stats = await stat(base).catch(() => undefined);
28
32
  if (!stats?.isDirectory()) throw new ArchiveError("UNSAFE_PATH", `The source ${base} is not a folder`);
29
33
  return { base, names: await folderNames(base) };
30
34
  }
31
- const base = resolve(input.cwd ?? process.cwd());
35
+ const base = await places.place(input.cwd ?? (places.root === undefined ? process.cwd() : "."));
32
36
  const names = (input.files ?? []).map((name) => {
33
37
  const parts = entryParts(name);
34
38
  if (parts.length === 0) throw new ArchiveError("UNSAFE_PATH", `The file ${JSON.stringify(name)} names the base folder: give source for a whole folder`, name);
35
39
  return parts.join("/");
36
40
  });
41
+ // The walk reads through the folders of a name: they must be in the root
42
+ if (places.root !== undefined) for (const name of names) await places.place(dirname(under(base, name)));
37
43
  return { base, names };
38
44
  }
39
45
 
@@ -41,13 +47,14 @@ async function inputNames(input: ArchiveCreateInput): Promise<{ base: string; na
41
47
  * Make a tar.gz or zip archive of a folder or of files
42
48
  */
43
49
  export async function archiveCreate(input: ArchiveCreateInput, settings: ArchiveSettings = DEFAULT_ARCHIVE_SETTINGS): Promise<ArchiveCreateOutput> {
50
+ const places = await placesOf(settings.root, ["archive", "create"]);
51
+ const output = await places.place(input.output);
44
52
  const format = formatOfName(input.output, input.format);
45
- const output = resolve(input.output);
46
53
  const existing = await stat(output).catch(() => undefined);
47
54
  if (existing?.isDirectory()) throw new ArchiveError("EXISTS", `${output} is a folder`);
48
55
  if (existing !== undefined && input.overwrite !== true) throw new ArchiveError("EXISTS", `${output} exists: give overwrite: true to replace it`);
49
56
 
50
- const { base, names } = await inputNames(input);
57
+ const { base, names } = await inputNames(input, places);
51
58
  const temporary = `${output}.${randomBytes(6).toString("hex")}.tmp`;
52
59
  const entries = await walk(base, names, { ...settings, exclude: new Set([output, temporary]) });
53
60
 
@@ -7,12 +7,16 @@
7
7
  * `overwrite`, or an archive over the limits changes nothing.
8
8
  *
9
9
  * A damaged entry (its size or its CRC-32) stops the extract, and the files before it stay.
10
+ *
11
+ * With the root of the module, the archive and the target must be in the root. The checks of the
12
+ * entries keep each entry in the target, so the extract stays in the root.
10
13
  */
11
14
 
12
15
  import { lstat, mkdir, realpath, rm, symlink, unlink, utimes } from "node:fs/promises";
13
- import { dirname, join, resolve } from "node:path";
16
+ import { dirname, join } from "node:path";
14
17
  import { DEFAULT_ARCHIVE_SETTINGS, formatOfFile, type ArchiveSettings } from "../../format.js";
15
18
  import { planExtract, type PlannedEntry, type RawEntry } from "../../paths.js";
19
+ import { isInside, outsideRoot, placesOf } from "../../root.js";
16
20
  import { extractTar, readTarEntries } from "../../tar.js";
17
21
  import { extractZipFile, readZipDirectory, readZipLink, type ZipEntry } from "../../zip.js";
18
22
  import type { ArchiveExtractInput, ArchiveExtractOutput } from "../../types.js";
@@ -72,7 +76,9 @@ function countsOf(planned: readonly PlannedEntry[]): Pick<ArchiveExtractOutput,
72
76
  * Extract a tar.gz or zip archive into a folder, with a check of each path
73
77
  */
74
78
  export async function archiveExtract(input: ArchiveExtractInput, settings: ArchiveSettings = DEFAULT_ARCHIVE_SETTINGS): Promise<ArchiveExtractOutput> {
75
- const archive = resolve(input.archive);
79
+ const places = await placesOf(settings.root, ["archive", "extract"]);
80
+ const archive = await places.place(input.archive);
81
+ const folder = await places.place(input.target);
76
82
  const format = await formatOfFile(archive, input.format);
77
83
  const overwrite = input.overwrite === true;
78
84
 
@@ -89,9 +95,12 @@ export async function archiveExtract(input: ArchiveExtractInput, settings: Archi
89
95
  raw = (await readTarEntries(archive, settings)).map((entry) => ({ name: entry.name, kind: entry.kind, size: entry.size, linkTarget: entry.linkTarget }));
90
96
  }
91
97
 
92
- const folder = resolve(input.target);
93
98
  await mkdir(folder, { recursive: true });
94
99
  const target = await realpath(folder);
100
+ // A link that came after the check of the root does not lead the extract out of it
101
+ if (places.root !== undefined && !isInside(places.root.real, target)) {
102
+ throw outsideRoot(["archive", "extract"], `the target ${JSON.stringify(input.target)} leads out of the root ${places.root.folder}`);
103
+ }
95
104
  const planned = planExtract(raw, target, { overwrite, maxBytes: settings.maxBytes, maxEntries: settings.maxEntries });
96
105
 
97
106
  if (format === "zip") {
@@ -2,11 +2,12 @@
2
2
  * archive.list procedure
3
3
  *
4
4
  * The entries of a tar.gz, tar or zip archive: the path, the kind, the size, the mode, the time
5
- * and the target of a link. The list reads the archive and writes nothing.
5
+ * and the target of a link. The list reads the archive and writes nothing. With the root of the
6
+ * module, the archive must be in the root.
6
7
  */
7
8
 
8
- import { resolve } from "node:path";
9
9
  import { DEFAULT_ARCHIVE_SETTINGS, formatOfFile, type ArchiveSettings } from "../../format.js";
10
+ import { placesOf } from "../../root.js";
10
11
  import { readTarEntries } from "../../tar.js";
11
12
  import { readZipDirectory, readZipLink } from "../../zip.js";
12
13
  import type { ArchiveEntry, ArchiveFormat, ArchiveListInput, ArchiveListOutput } from "../../types.js";
@@ -43,7 +44,7 @@ export async function readEntries(path: string, format: ArchiveFormat, settings:
43
44
  * The entries of a tar.gz or zip archive
44
45
  */
45
46
  export async function archiveList(input: ArchiveListInput, settings: ArchiveSettings = DEFAULT_ARCHIVE_SETTINGS): Promise<ArchiveListOutput> {
46
- const path = resolve(input.archive);
47
+ const path = await (await placesOf(settings.root, ["archive", "list"])).place(input.archive);
47
48
  const format = await formatOfFile(path, input.format);
48
49
  const entries = await readEntries(path, format, { ...settings, maxBytes: Number.MAX_SAFE_INTEGER });
49
50
  return { format, entries, bytes: entries.reduce((sum, entry) => sum + (entry.kind === "file" ? entry.size : 0), 0) };
package/src/root.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The root of the archive procedures: one folder that confines each path (roadmap R33).
3
+ *
4
+ * The rules are the rules of `root.ts` of `@procedures/fs` (`createFsModule({ root })`):
5
+ * - A relative path starts at the root, not at the working folder of the process.
6
+ * - A path outside the root gives `FORBIDDEN` with the reason `outside-root`. This includes a
7
+ * path that goes up with `..`, an absolute path outside the root, a path on another drive, and
8
+ * a symlink or a junction that leads out of the root.
9
+ * - The check uses the real path (`fs.realpath`) of the path. For a path that does not exist, it
10
+ * uses the real path of the nearest existing ancestor. So a symlinked parent folder cannot
11
+ * lead a write or a `mkdir` out of the root.
12
+ *
13
+ * On Windows, the comparison ignores the case of the letters, and `/` and `\` are the same.
14
+ */
15
+
16
+ import { readlink, realpath } from "node:fs/promises";
17
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
18
+ import { InvocationError, type ProcedurePath } from "@procedures/client";
19
+
20
+ /** The reason of the `FORBIDDEN` error for a path outside the root. */
21
+ export const OUTSIDE_ROOT = "outside-root";
22
+
23
+ /** The maximum number of symlinks that one check follows. After it, the check fails with `ELOOP`. */
24
+ const MAX_LINKS = 40;
25
+
26
+ /**
27
+ * This function tells if `path` is `folder` or a path in `folder`. The two paths are absolute.
28
+ * On Windows, the case of the letters is not significant.
29
+ */
30
+ export function isInside(folder: string, path: string): boolean {
31
+ const rel = relative(folder, path);
32
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
33
+ }
34
+
35
+ /** True for the error of a path that does not exist. */
36
+ function isMissing(error: unknown): boolean {
37
+ const code = (error as NodeJS.ErrnoException).code;
38
+ return code === "ENOENT" || code === "ENOTDIR";
39
+ }
40
+
41
+ /** The target of a symlink, or undefined when the path is not a symlink or does not exist. */
42
+ async function linkTarget(path: string): Promise<string | undefined> {
43
+ try {
44
+ return await readlink(path);
45
+ } catch (error) {
46
+ // EINVAL: the path is not a symlink
47
+ const code = (error as NodeJS.ErrnoException).code;
48
+ if (code === "EINVAL" || isMissing(error)) return undefined;
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * This function gives the real path of an absolute path. For a path that does not exist, it
55
+ * gives the real path of the nearest existing ancestor and the rest of the path. A symlink whose
56
+ * target does not exist leads to its target, because a write follows it.
57
+ */
58
+ export async function realPathOf(path: string, links = 0): Promise<string> {
59
+ const rest: string[] = [];
60
+ let current = path;
61
+ for (;;) {
62
+ try {
63
+ return join(await realpath(current), ...rest);
64
+ } catch (error) {
65
+ if (!isMissing(error)) throw error;
66
+ }
67
+ const target = await linkTarget(current);
68
+ if (target !== undefined) {
69
+ if (links >= MAX_LINKS) {
70
+ const loop = new Error(`Too many symlinks in the path ${path}`) as NodeJS.ErrnoException;
71
+ loop.code = "ELOOP";
72
+ throw loop;
73
+ }
74
+ return join(await realPathOf(resolve(dirname(current), target), links + 1), ...rest);
75
+ }
76
+ const parent = dirname(current);
77
+ // No ancestor exists, for example a drive that is not there
78
+ if (parent === current) return join(current, ...rest);
79
+ rest.unshift(basename(current));
80
+ current = parent;
81
+ }
82
+ }
83
+
84
+ /** A root: the absolute path that the host gave, and its real path. */
85
+ export interface ResolvedRoot {
86
+ folder: string;
87
+ real: string;
88
+ }
89
+
90
+ /** This function gives the absolute path and the real path of a root. The root must exist. */
91
+ export async function resolveRoot(root: string): Promise<ResolvedRoot> {
92
+ const folder = resolve(root);
93
+ try {
94
+ return { folder, real: await realpath(folder) };
95
+ } catch (error) {
96
+ if (!isMissing(error)) throw error;
97
+ const missing = new Error(`The root of the archive procedures does not exist: ${folder}`) as NodeJS.ErrnoException;
98
+ missing.code = "ENOENT";
99
+ throw missing;
100
+ }
101
+ }
102
+
103
+ /** The `FORBIDDEN` error for a path outside the root. */
104
+ export function outsideRoot(procedure: ProcedurePath, message: string): InvocationError {
105
+ return new InvocationError("FORBIDDEN", `${procedure.join(".")}: ${message}`, procedure, OUTSIDE_ROOT);
106
+ }
107
+
108
+ /** True for the `FORBIDDEN` error of a path outside the root. */
109
+ export function isOutsideRoot(error: unknown): boolean {
110
+ return error instanceof InvocationError && error.code === "FORBIDDEN" && error.reason === OUTSIDE_ROOT;
111
+ }
112
+
113
+ /**
114
+ * This function gives the absolute path of `path` in the root. A relative path starts at the
115
+ * root. The real path decides: a path whose real path is outside the real path of the root gives
116
+ * `FORBIDDEN` with the reason `outside-root`.
117
+ */
118
+ export async function inRoot(root: ResolvedRoot, path: string, procedure: ProcedurePath): Promise<string> {
119
+ const absolute = resolve(root.folder, path);
120
+ const real = await realPathOf(absolute);
121
+ // A path counts as inside by its given form or by its real form. On Windows, the two forms of a
122
+ // folder can differ by more than the links: a short 8.3 name (RUNNER~1) has a long real name.
123
+ if (!isInside(root.folder, absolute) && !isInside(root.real, absolute) && !isInside(root.real, real)) {
124
+ throw outsideRoot(procedure, `the path ${JSON.stringify(path)} is outside the root ${root.folder}`);
125
+ }
126
+ if (!isInside(root.real, real)) {
127
+ throw outsideRoot(procedure, `the path ${JSON.stringify(path)} leads out of the root ${root.folder} through a symlink`);
128
+ }
129
+ return absolute;
130
+ }
131
+
132
+ /** The paths of one call of a procedure. */
133
+ export interface Places {
134
+ /** The root of the call, or undefined for a module without a root. */
135
+ readonly root: ResolvedRoot | undefined;
136
+ /**
137
+ * This function gives the absolute path of a path of the input. Without a root, a relative path
138
+ * starts at the working folder. With a root, it starts at the root, and a path outside it gives
139
+ * `FORBIDDEN` with the reason `outside-root`.
140
+ */
141
+ place(path: string): Promise<string>;
142
+ }
143
+
144
+ /** This function gives the paths of one call. With a root, the root must exist when the call starts. */
145
+ export async function placesOf(root: string | undefined, procedure: ProcedurePath): Promise<Places> {
146
+ if (root === undefined) return { root: undefined, place: async (path) => resolve(path) };
147
+ const resolved = await resolveRoot(root);
148
+ return { root: resolved, place: (path) => inRoot(resolved, path, procedure) };
149
+ }