@procedures/archive 0.0.0-stage → 0.2.3

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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -2
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/errors.d.ts +27 -0
  5. package/dist/errors.d.ts.map +1 -0
  6. package/dist/errors.js +15 -0
  7. package/dist/errors.js.map +1 -0
  8. package/dist/format.d.ts +25 -0
  9. package/dist/format.d.ts.map +1 -0
  10. package/dist/format.js +60 -0
  11. package/dist/format.js.map +1 -0
  12. package/dist/index.d.ts +8 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +10 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/module.d.ts +34 -0
  17. package/dist/module.d.ts.map +1 -0
  18. package/dist/module.js +148 -0
  19. package/dist/module.js.map +1 -0
  20. package/dist/paths.d.ts +58 -0
  21. package/dist/paths.d.ts.map +1 -0
  22. package/dist/paths.js +205 -0
  23. package/dist/paths.js.map +1 -0
  24. package/dist/procedures/archive/create.d.ts +14 -0
  25. package/dist/procedures/archive/create.d.ts.map +1 -0
  26. package/dist/procedures/archive/create.js +81 -0
  27. package/dist/procedures/archive/create.js.map +1 -0
  28. package/dist/procedures/archive/extract.d.ts +17 -0
  29. package/dist/procedures/archive/extract.d.ts.map +1 -0
  30. package/dist/procedures/archive/extract.js +101 -0
  31. package/dist/procedures/archive/extract.js.map +1 -0
  32. package/dist/procedures/archive/index.d.ts +4 -0
  33. package/dist/procedures/archive/index.d.ts.map +1 -0
  34. package/dist/procedures/archive/index.js +4 -0
  35. package/dist/procedures/archive/index.js.map +1 -0
  36. package/dist/procedures/archive/list.d.ts +15 -0
  37. package/dist/procedures/archive/list.d.ts.map +1 -0
  38. package/dist/procedures/archive/list.js +47 -0
  39. package/dist/procedures/archive/list.js.map +1 -0
  40. package/dist/register.d.ts +6 -0
  41. package/dist/register.d.ts.map +1 -0
  42. package/dist/register.js +8 -0
  43. package/dist/register.js.map +1 -0
  44. package/dist/tar.d.ts +38 -0
  45. package/dist/tar.d.ts.map +1 -0
  46. package/dist/tar.js +193 -0
  47. package/dist/tar.js.map +1 -0
  48. package/dist/types.d.ts +81 -0
  49. package/dist/types.d.ts.map +1 -0
  50. package/dist/types.js +8 -0
  51. package/dist/types.js.map +1 -0
  52. package/dist/walk.d.ts +34 -0
  53. package/dist/walk.d.ts.map +1 -0
  54. package/dist/walk.js +67 -0
  55. package/dist/walk.js.map +1 -0
  56. package/dist/zip.d.ts +57 -0
  57. package/dist/zip.d.ts.map +1 -0
  58. package/dist/zip.js +397 -0
  59. package/dist/zip.js.map +1 -0
  60. package/package.json +77 -3
  61. package/src/errors.ts +33 -0
  62. package/src/format.ts +64 -0
  63. package/src/index.ts +9 -0
  64. package/src/module.ts +192 -0
  65. package/src/paths.ts +230 -0
  66. package/src/procedures/archive/create.ts +84 -0
  67. package/src/procedures/archive/extract.ts +103 -0
  68. package/src/procedures/archive/index.ts +3 -0
  69. package/src/procedures/archive/list.ts +50 -0
  70. package/src/register.ts +10 -0
  71. package/src/tar.ts +198 -0
  72. package/src/types.ts +101 -0
  73. package/src/walk.ts +85 -0
  74. package/src/zip.ts +419 -0
package/src/tar.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Tar and tar.gz archives with `tar` (node-tar), the tar library of npm.
3
+ *
4
+ * - The list reads the headers with a `Parser`. It stops at the limit of entries or of bytes, so a
5
+ * compressed archive with a very large content stops early.
6
+ * - The extract runs after the check of `planExtract()`. It keeps the protections of node-tar:
7
+ * no absolute path, no `..`, and no write through a symbolic link. Its filter lets only the
8
+ * checked entries through, and `strict` makes each warning an error.
9
+ */
10
+
11
+ import { createReadStream } from "node:fs";
12
+ import { createGunzip, gzipSync } from "node:zlib";
13
+ import { open, writeFile } from "node:fs/promises";
14
+ import { create, extract, Parser, type ReadEntry } from "tar";
15
+ import { ArchiveError } from "./errors.js";
16
+ import type { ArchiveEntryKind } from "./types.js";
17
+
18
+ /** One header of a tar archive. */
19
+ export interface TarEntryInfo {
20
+ readonly name: string;
21
+ readonly kind: ArchiveEntryKind;
22
+ readonly size: number;
23
+ readonly mode: number | undefined;
24
+ readonly mtime: Date | undefined;
25
+ readonly linkTarget: string | undefined;
26
+ }
27
+
28
+ /** The kind of a tar entry type. */
29
+ function kindOf(type: string): ArchiveEntryKind {
30
+ switch (type) {
31
+ case "File":
32
+ case "OldFile":
33
+ case "ContiguousFile":
34
+ return "file";
35
+ case "Directory":
36
+ return "directory";
37
+ case "SymbolicLink":
38
+ return "symlink";
39
+ case "Link":
40
+ return "hardlink";
41
+ default:
42
+ return "other";
43
+ }
44
+ }
45
+
46
+ /** An error of node-tar as an `ArchiveError`. */
47
+ function archiveErrorOf(error: unknown): ArchiveError {
48
+ if (error instanceof ArchiveError) return error;
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ return new ArchiveError("BAD_ARCHIVE", `The tar archive is damaged: ${message}`);
51
+ }
52
+
53
+ /** The most zero bytes of an empty tar: an empty tar of GNU tar has 10240. */
54
+ const MAX_EMPTY_TAR_BYTES = 1024 * 1024;
55
+
56
+ /**
57
+ * True when the content of a tar or tar.gz archive is only zero bytes: an archive without
58
+ * entries. node-tar calls such an archive "unrecognized". The check reads at most 1 MiB.
59
+ */
60
+ async function isEmptyTar(path: string): Promise<boolean> {
61
+ const head = Buffer.alloc(2);
62
+ const handle = await open(path, "r");
63
+ try {
64
+ await handle.read(head, 0, 2, 0);
65
+ } finally {
66
+ await handle.close();
67
+ }
68
+ const input = createReadStream(path);
69
+ const stream = head[0] === 0x1f && head[1] === 0x8b ? input.pipe(createGunzip()) : input;
70
+ let bytes = 0;
71
+ try {
72
+ for await (const chunk of stream as AsyncIterable<Buffer>) {
73
+ bytes += chunk.length;
74
+ if (bytes > MAX_EMPTY_TAR_BYTES || chunk.some((byte) => byte !== 0)) return false;
75
+ }
76
+ return bytes > 0;
77
+ } catch {
78
+ return false;
79
+ } finally {
80
+ input.destroy();
81
+ }
82
+ }
83
+
84
+ /**
85
+ * This function reads the headers of a tar or tar.gz archive. It throws `TOO_LARGE` at more than
86
+ * `maxEntries` entries or more than `maxBytes` bytes of files.
87
+ */
88
+ export async function readTarEntries(path: string, limits: { maxEntries: number; maxBytes: number }): Promise<TarEntryInfo[]> {
89
+ try {
90
+ return await parseTarEntries(path, limits);
91
+ } catch (error) {
92
+ if (error instanceof ArchiveError && error.code === "BAD_ARCHIVE" && error.message.includes("TAR_BAD_ARCHIVE") && (await isEmptyTar(path))) return [];
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ async function parseTarEntries(path: string, limits: { maxEntries: number; maxBytes: number }): Promise<TarEntryInfo[]> {
98
+ const entries: TarEntryInfo[] = [];
99
+ let bytes = 0;
100
+ await new Promise<void>((resolve, reject) => {
101
+ let settled = false;
102
+ const input = createReadStream(path);
103
+ const finish = (error?: unknown): void => {
104
+ if (settled) return;
105
+ settled = true;
106
+ if (error === undefined) {
107
+ resolve();
108
+ return;
109
+ }
110
+ input.destroy();
111
+ reject(archiveErrorOf(error));
112
+ };
113
+ const parser = new Parser({
114
+ strict: true,
115
+ onReadEntry: (entry: ReadEntry) => {
116
+ entry.resume();
117
+ if (settled) return;
118
+ const kind = kindOf(entry.type);
119
+ if (kind === "file") bytes += entry.size;
120
+ if (entries.length >= limits.maxEntries) {
121
+ finish(new ArchiveError("TOO_LARGE", `The archive has more entries than the limit of ${limits.maxEntries}`));
122
+ return;
123
+ }
124
+ if (bytes > limits.maxBytes) {
125
+ finish(new ArchiveError("TOO_LARGE", `The files of the archive have more than ${limits.maxBytes} bytes`, entry.path));
126
+ return;
127
+ }
128
+ entries.push({
129
+ name: entry.path,
130
+ kind,
131
+ size: entry.size,
132
+ mode: entry.mode,
133
+ mtime: entry.mtime,
134
+ linkTarget: entry.linkpath === undefined || entry.linkpath === "" ? undefined : entry.linkpath,
135
+ });
136
+ },
137
+ });
138
+ parser.on("warn", (code: string, message: string | Error) => finish(new ArchiveError("BAD_ARCHIVE", `The tar archive is damaged: ${code} ${message instanceof Error ? message.message : message}`)));
139
+ parser.on("error", (error: unknown) => finish(error));
140
+ parser.on("end", () => finish());
141
+ input.on("error", (error) => finish(error));
142
+ input.on("data", (chunk) => {
143
+ if (settled) return;
144
+ if (!parser.write(chunk as Buffer)) {
145
+ input.pause();
146
+ parser.once("drain", () => input.resume());
147
+ }
148
+ });
149
+ input.on("end", () => {
150
+ if (!settled) parser.end();
151
+ });
152
+ });
153
+ return entries;
154
+ }
155
+
156
+ /**
157
+ * This function extracts a tar or tar.gz archive into `target`, after the check. Only the names
158
+ * of `allowed` pass the filter. A name that the check did not see is an error at the end.
159
+ */
160
+ export async function extractTar(path: string, target: string, allowed: ReadonlySet<string>): Promise<void> {
161
+ const refused: string[] = [];
162
+ try {
163
+ await extract({
164
+ file: path,
165
+ cwd: target,
166
+ strict: true,
167
+ preservePaths: false,
168
+ preserveOwner: false,
169
+ // Remove an old file before the new one: the write never goes through a link or into a hard link
170
+ unlink: true,
171
+ filter: (name: string) => {
172
+ if (allowed.has(name)) return true;
173
+ refused.push(name);
174
+ return false;
175
+ },
176
+ });
177
+ } catch (error) {
178
+ throw archiveErrorOf(error);
179
+ }
180
+ const unexpected = refused.filter((name) => name.split(/[/\\]+/).some((part) => part !== "" && part !== "."));
181
+ if (unexpected.length > 0) {
182
+ throw new ArchiveError("BAD_ARCHIVE", `The archive changed during the extract: ${unexpected[0]} was not in the check`, unexpected[0]);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * This function writes a tar or tar.gz archive of the `names` (relative to `cwd`) to `file`. The
188
+ * names are the full list: the walk of `archive.create` gives each file and folder.
189
+ */
190
+ export async function createTar(file: string, cwd: string, names: readonly string[], gzip: boolean): Promise<void> {
191
+ if (names.length === 0) {
192
+ // An empty tar: two blocks of zeros
193
+ const empty = Buffer.alloc(1024);
194
+ await writeFile(file, gzip ? gzipSync(empty) : empty, { flag: "wx" });
195
+ return;
196
+ }
197
+ await create({ file, cwd, gzip, portable: true, follow: false, noDirRecurse: true }, [...names]);
198
+ }
package/src/types.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Type definitions of the procedures of this package
3
+ *
4
+ * mark procedure new adds the types of each new procedure here. The zod schemas are in
5
+ * `module.ts`: an exported schema needs a written type (isolatedDeclarations).
6
+ */
7
+
8
+ /** The formats of an archive. `tar` is a tar file without compression. */
9
+ export type ArchiveFormat = "tar.gz" | "tar" | "zip";
10
+
11
+ /** The kind of an entry of an archive. `other` is a device, a FIFO or another special file. */
12
+ export type ArchiveEntryKind = "file" | "directory" | "symlink" | "hardlink" | "other";
13
+
14
+ // =============================================================================
15
+ // archive.create
16
+ // =============================================================================
17
+
18
+ export interface ArchiveCreateInput {
19
+ /** The archive to make. */
20
+ output: string;
21
+ /** A folder: the archive gets its content, with paths relative to the folder. */
22
+ source?: string | undefined;
23
+ /** Files and folders, relative to `cwd`. A folder goes in with its content. */
24
+ files?: string[] | undefined;
25
+ /** The base folder of `files`. The default is the working folder of the process. */
26
+ cwd?: string | undefined;
27
+ /** The format. The default comes from the name of `output`: `.tar.gz`, `.tgz`, `.tar` or `.zip`. */
28
+ format?: ArchiveFormat | undefined;
29
+ /** True: replace an existing archive. The default is false: an existing archive is an error. */
30
+ overwrite?: boolean | undefined;
31
+ }
32
+
33
+ export interface ArchiveCreateOutput {
34
+ /** The absolute path of the archive. */
35
+ path: string;
36
+ format: ArchiveFormat;
37
+ /** The number of entries: files, folders and links. */
38
+ entries: number;
39
+ /** The size of the archive, in bytes. */
40
+ bytes: number;
41
+ }
42
+
43
+ // =============================================================================
44
+ // archive.extract
45
+ // =============================================================================
46
+
47
+ export interface ArchiveExtractInput {
48
+ /** The archive. */
49
+ archive: string;
50
+ /** The folder that gets the content. The procedure makes it when it does not exist. */
51
+ target: string;
52
+ /** The format. The default comes from the first bytes of the archive. */
53
+ format?: ArchiveFormat | undefined;
54
+ /** True: replace an existing file. The default is false: an existing file is an error, and nothing changes. */
55
+ overwrite?: boolean | undefined;
56
+ }
57
+
58
+ export interface ArchiveExtractOutput {
59
+ /** The absolute path of the target folder. */
60
+ target: string;
61
+ format: ArchiveFormat;
62
+ files: number;
63
+ directories: number;
64
+ /** The symbolic links and the hard links. */
65
+ links: number;
66
+ /** The bytes of the files. */
67
+ bytes: number;
68
+ }
69
+
70
+ // =============================================================================
71
+ // archive.list
72
+ // =============================================================================
73
+
74
+ export interface ArchiveListInput {
75
+ /** The archive. */
76
+ archive: string;
77
+ /** The format. The default comes from the first bytes of the archive. */
78
+ format?: ArchiveFormat | undefined;
79
+ }
80
+
81
+ /** One entry of an archive. */
82
+ export interface ArchiveEntry {
83
+ /** The path in the archive, as the archive has it. */
84
+ path: string;
85
+ kind: ArchiveEntryKind;
86
+ /** The size of the content, in bytes. */
87
+ size: number;
88
+ /** The mode bits (`0o644`), when the archive has them. */
89
+ mode?: number | undefined;
90
+ /** The time of the last change, as an ISO text, when the archive has it. */
91
+ mtime?: string | undefined;
92
+ /** The target of a link. */
93
+ linkTarget?: string | undefined;
94
+ }
95
+
96
+ export interface ArchiveListOutput {
97
+ format: ArchiveFormat;
98
+ entries: ArchiveEntry[];
99
+ /** The sum of the sizes of the entries, in bytes. */
100
+ bytes: number;
101
+ }
package/src/walk.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The walk of `archive.create`: each file, folder and symbolic link under the inputs, in a fixed
3
+ * order. The walk does not follow a link: the archive gets the link itself.
4
+ */
5
+
6
+ import { lstat, readdir, readlink } from "node:fs/promises";
7
+ import { join, resolve, sep } from "node:path";
8
+ import { ArchiveError } from "./errors.js";
9
+
10
+ /** One path for the archive. */
11
+ export interface WalkEntry {
12
+ /** The name in the archive: relative, with `/`. */
13
+ readonly name: string;
14
+ /** The absolute path on the disk. */
15
+ readonly path: string;
16
+ readonly kind: "file" | "directory" | "symlink";
17
+ readonly size: number;
18
+ readonly mode: number;
19
+ readonly mtime: Date;
20
+ readonly linkTarget?: string | undefined;
21
+ }
22
+
23
+ /** The limits and the paths to leave out (the new archive and its temporary file). */
24
+ export interface WalkOptions {
25
+ readonly maxEntries: number;
26
+ readonly maxBytes: number;
27
+ readonly exclude: ReadonlySet<string>;
28
+ }
29
+
30
+ const byName = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
31
+
32
+ /**
33
+ * This function gives the target of a link with `/`, as an archive keeps it. Windows gives `..\a.txt`
34
+ * for a link that Node made from `../a.txt`. On the other systems, a `\` is a part of a name.
35
+ */
36
+ export function portableLinkTarget(target: string, separator: string = sep): string {
37
+ return separator === "\\" ? target.replaceAll("\\", "/") : target;
38
+ }
39
+
40
+ /** This function walks the names (relative to `base`) and gives each entry for the archive. */
41
+ export async function walk(base: string, names: readonly string[], options: WalkOptions): Promise<WalkEntry[]> {
42
+ const entries: WalkEntry[] = [];
43
+ const seen = new Set<string>();
44
+ let bytes = 0;
45
+
46
+ const visit = async (name: string): Promise<void> => {
47
+ const path = resolve(base, ...name.split("/"));
48
+ if (options.exclude.has(path) || seen.has(name)) return;
49
+ seen.add(name);
50
+ let stats;
51
+ try {
52
+ stats = await lstat(path);
53
+ } catch {
54
+ throw new ArchiveError("UNSAFE_PATH", `${path} does not exist`, name);
55
+ }
56
+ if (entries.length >= options.maxEntries) throw new ArchiveError("TOO_LARGE", `The archive gets more entries than the limit of ${options.maxEntries}`, name);
57
+ const common = { name, path, size: 0, mode: stats.mode & 0o7777, mtime: stats.mtime };
58
+ if (stats.isSymbolicLink()) {
59
+ entries.push({ ...common, kind: "symlink", linkTarget: portableLinkTarget(await readlink(path)) });
60
+ } else if (stats.isDirectory()) {
61
+ entries.push({ ...common, kind: "directory" });
62
+ const children = (await readdir(path)).sort(byName);
63
+ for (const child of children) await visit(`${name}/${child}`);
64
+ } else if (stats.isFile()) {
65
+ bytes += stats.size;
66
+ if (bytes > options.maxBytes) throw new ArchiveError("TOO_LARGE", `The files have more than ${options.maxBytes} bytes`, name);
67
+ entries.push({ ...common, kind: "file", size: stats.size });
68
+ } else {
69
+ throw new ArchiveError("UNSUPPORTED_ENTRY", `${path} is a special file (a socket, a device or a FIFO)`, name);
70
+ }
71
+ };
72
+
73
+ for (const name of names) await visit(name);
74
+ return entries;
75
+ }
76
+
77
+ /** The names of the content of a folder, sorted. */
78
+ export async function folderNames(folder: string): Promise<string[]> {
79
+ return (await readdir(folder)).sort(byName);
80
+ }
81
+
82
+ /** The absolute path of a name under a base. */
83
+ export function under(base: string, name: string): string {
84
+ return join(base, ...name.split("/"));
85
+ }