@procedures/archive 0.3.0 → 0.5.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/CHANGELOG.md +40 -0
- package/README.md +30 -7
- package/dist/.tsbuildinfo +1 -1
- package/dist/format.d.ts +3 -1
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +12 -5
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +15 -8
- package/dist/module.js.map +1 -1
- package/dist/procedures/archive/create.d.ts +3 -0
- package/dist/procedures/archive/create.d.ts.map +1 -1
- package/dist/procedures/archive/create.js +16 -7
- package/dist/procedures/archive/create.js.map +1 -1
- package/dist/procedures/archive/extract.d.ts +3 -0
- package/dist/procedures/archive/extract.d.ts.map +1 -1
- package/dist/procedures/archive/extract.js +12 -3
- package/dist/procedures/archive/extract.js.map +1 -1
- package/dist/procedures/archive/list.d.ts +2 -1
- package/dist/procedures/archive/list.d.ts.map +1 -1
- package/dist/procedures/archive/list.js +4 -3
- package/dist/procedures/archive/list.js.map +1 -1
- package/dist/root.d.ts +59 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +128 -0
- package/dist/root.js.map +1 -0
- package/package.json +2 -2
- package/src/format.ts +3 -1
- package/src/index.ts +2 -0
- package/src/module.ts +21 -8
- package/src/procedures/archive/create.ts +14 -7
- package/src/procedures/archive/extract.ts +12 -3
- package/src/procedures/archive/list.ts +4 -3
- package/src/root.ts +149 -0
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
|
package/dist/root.js.map
ADDED
|
@@ -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
|
+
"version": "0.5.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.
|
|
65
|
+
"@procedures/client": "0.5.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
|
|
171
|
-
* `archiveModule`. A host gives it to `createRegistry()` before its bundles, so it
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
+
}
|