@hyperframes/share 0.9.27 → 0.9.28-alpha.1
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/README.md +58 -1
- package/dist/index.d.mts +1 -0
- package/dist/projectLock.d.mts +2 -2
- package/dist/projectSnapshot.d.mts +3 -1
- package/dist/reviewExport.d.mts +11 -1
- package/dist/reviewUpload.d.mts +2 -0
- package/dist/shareSources.d.mts +24 -0
- package/dist/sourceIgnore.d.mts +3 -0
- package/dist/sources/adapterTypes.d.ts +39 -0
- package/dist/sources/api.d.mts +29 -0
- package/dist/sources/capture.d.mts +24 -0
- package/dist/sources/contract.d.mts +2 -0
- package/dist/sources/errors.d.mts +24 -0
- package/dist/sources/limits.d.mts +12 -0
- package/dist/sources/manifest.d.mts +23 -0
- package/dist/sources/pathPolicy.d.mts +25 -0
- package/dist/sources/projectState.d.mts +9 -0
- package/dist/sources/store.d.mts +3 -0
- package/dist/sources/transfer.d.mts +45 -0
- package/dist/sources/types.d.ts +353 -0
- package/dist/sources/unicode.d.mts +15 -0
- package/dist/sources/upload.d.mts +9 -0
- package/dist/sources/uploadFiles.d.mts +31 -0
- package/dist/sources/wire.d.mts +21 -0
- package/package.json +1 -1
- package/scripts/copy-types.mjs +3 -0
- package/src/index.mjs +6 -0
- package/src/projectLock.mjs +9 -2
- package/src/projectSnapshot.mjs +22 -6
- package/src/reviewExport.mjs +156 -41
- package/src/reviewUpload.mjs +18 -16
- package/src/reviewVersionSentences.mjs +2 -1
- package/src/shareSources.mjs +236 -0
- package/src/sourceIgnore.mjs +69 -0
- package/src/sources/adapterTypes.d.ts +39 -0
- package/src/sources/api.mjs +114 -0
- package/src/sources/capture.mjs +264 -0
- package/src/sources/contract/LICENSE-Unicode.txt +39 -0
- package/src/sources/contract/path-policy.json +311 -0
- package/src/sources/contract/unicode-15.1.0.json +6179 -0
- package/src/sources/contract.mjs +13 -0
- package/src/sources/errors.mjs +77 -0
- package/src/sources/limits.mjs +48 -0
- package/src/sources/manifest.mjs +154 -0
- package/src/sources/pathPolicy.mjs +197 -0
- package/src/sources/projectState.mjs +59 -0
- package/src/sources/store.mjs +82 -0
- package/src/sources/transfer.mjs +196 -0
- package/src/sources/types.d.ts +353 -0
- package/src/sources/unicode.mjs +163 -0
- package/src/sources/upload.mjs +201 -0
- package/src/sources/uploadFiles.mjs +263 -0
- package/src/sources/wire.mjs +205 -0
- package/LICENSE +0 -190
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/** Explicit, project-relative exclusions. This only removes files; it cannot override safety exclusions.
|
|
7
|
+
* @param {string} root @returns {Promise<(path: string) => boolean>} */
|
|
8
|
+
export async function readSourceIgnore(root) {
|
|
9
|
+
const file = join(root, ".hyperframesignore");
|
|
10
|
+
const invalid = () =>
|
|
11
|
+
new Error(".hyperframesignore must be a regular file inside the project, smaller than 64 KiB.");
|
|
12
|
+
const handle = await open(
|
|
13
|
+
file,
|
|
14
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
|
|
15
|
+
).catch(async (/** @type {NodeJS.ErrnoException} */ error) => {
|
|
16
|
+
if (error.code !== "ENOENT") throw invalid();
|
|
17
|
+
// An absent file means no exclusions; a dangling symlink is invalid.
|
|
18
|
+
const named = await lstat(file).catch((/** @type {NodeJS.ErrnoException} */ missing) => {
|
|
19
|
+
if (missing.code === "ENOENT") return null;
|
|
20
|
+
throw invalid();
|
|
21
|
+
});
|
|
22
|
+
if (named) throw invalid();
|
|
23
|
+
return null;
|
|
24
|
+
});
|
|
25
|
+
if (!handle) return () => false;
|
|
26
|
+
let text;
|
|
27
|
+
try {
|
|
28
|
+
// Validate the opened descriptor before reading. lstat additionally rejects links on
|
|
29
|
+
// platforms without O_NOFOLLOW; it never authorizes a later open of this path.
|
|
30
|
+
const [stat, named] = await Promise.all([handle.stat(), lstat(file)]);
|
|
31
|
+
if (
|
|
32
|
+
!stat.isFile() ||
|
|
33
|
+
stat.size >= 65536 ||
|
|
34
|
+
!named.isFile() ||
|
|
35
|
+
stat.ino !== named.ino ||
|
|
36
|
+
stat.dev !== named.dev
|
|
37
|
+
)
|
|
38
|
+
throw invalid();
|
|
39
|
+
const buffer = Buffer.alloc(65536);
|
|
40
|
+
let length = 0;
|
|
41
|
+
while (length < buffer.length) {
|
|
42
|
+
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
|
|
43
|
+
if (!bytesRead) break;
|
|
44
|
+
length += bytesRead;
|
|
45
|
+
}
|
|
46
|
+
if (length === buffer.length) throw invalid();
|
|
47
|
+
text = buffer.subarray(0, length).toString("utf8");
|
|
48
|
+
} finally {
|
|
49
|
+
await handle.close();
|
|
50
|
+
}
|
|
51
|
+
const paths = text
|
|
52
|
+
.split(/\r?\n/)
|
|
53
|
+
.map((line) => line.trim())
|
|
54
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
55
|
+
const entries = paths.map((entry) => {
|
|
56
|
+
const path = entry.replace(/\/$/, "");
|
|
57
|
+
if (
|
|
58
|
+
path === "index.html" ||
|
|
59
|
+
/[\\*?![\]:]/.test(path) ||
|
|
60
|
+
path.includes("\0") ||
|
|
61
|
+
path.split("/").some((part) => !part || part === "." || part === "..")
|
|
62
|
+
)
|
|
63
|
+
throw new Error(
|
|
64
|
+
".hyperframesignore accepts literal project-relative file or folder paths, one per line. Wildcards and excluding index.html are not supported.",
|
|
65
|
+
);
|
|
66
|
+
return path;
|
|
67
|
+
});
|
|
68
|
+
return (path) => entries.some((entry) => path === entry || path.startsWith(`${entry}/`));
|
|
69
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Binding, BindingKey, Manifest } from "./types.js";
|
|
2
|
+
import type { SnapshotRef } from "../types/reviewWire.js";
|
|
3
|
+
import type { SignedInAccess } from "../signedInRequest.mjs";
|
|
4
|
+
import type { SourceApi } from "./api.mjs";
|
|
5
|
+
|
|
6
|
+
export interface SourceStore {
|
|
7
|
+
read(key: BindingKey): Promise<Binding | null>;
|
|
8
|
+
write(binding: Binding): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export type SourceResult =
|
|
11
|
+
| { at: "ready"; snapshot: SnapshotRef }
|
|
12
|
+
| { at: "unavailable"; reason: string };
|
|
13
|
+
export interface SourceOptions {
|
|
14
|
+
access: SignedInAccess;
|
|
15
|
+
spaceId: string;
|
|
16
|
+
store?: SourceStore;
|
|
17
|
+
enabled?: () => boolean;
|
|
18
|
+
fingerprint?: (path: string) => Promise<string | null>;
|
|
19
|
+
/** Host-owned Desktop bindings may be read for exact reuse, never overwritten. */
|
|
20
|
+
existingBinding?: (key: BindingKey) => Promise<Binding | null>;
|
|
21
|
+
fetchImpl?: typeof fetch;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
verificationTimeoutMs?: number;
|
|
24
|
+
onProgress?: (bytes: number) => void;
|
|
25
|
+
/** Test/host seams; the default is the authenticated v3 client. */
|
|
26
|
+
api?: SourceApi;
|
|
27
|
+
wait?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export interface SourceRun {
|
|
30
|
+
binding: Binding;
|
|
31
|
+
manifest: Manifest;
|
|
32
|
+
api: SourceApi;
|
|
33
|
+
store: SourceStore;
|
|
34
|
+
fetchImpl: typeof fetch;
|
|
35
|
+
signal: AbortSignal;
|
|
36
|
+
wait: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
37
|
+
timeoutMs: number;
|
|
38
|
+
onProgress: (bytes: number) => void;
|
|
39
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { signedInRequest } from "../signedInRequest.mjs";
|
|
3
|
+
import { SyncApiError, refusalOf } from "./errors.mjs";
|
|
4
|
+
import {
|
|
5
|
+
beginOf,
|
|
6
|
+
grantsOf,
|
|
7
|
+
objectOf,
|
|
8
|
+
uploadOf,
|
|
9
|
+
syncLimitsOf,
|
|
10
|
+
compositionOf,
|
|
11
|
+
snapshotOf,
|
|
12
|
+
} from "./wire.mjs";
|
|
13
|
+
|
|
14
|
+
/** @param {{ access: import("../signedInRequest.mjs").SignedInAccess, fetchImpl?: typeof fetch }} deps */
|
|
15
|
+
export function sourceApi({ access, fetchImpl = fetch }) {
|
|
16
|
+
const request = signedInRequest({
|
|
17
|
+
access,
|
|
18
|
+
fetchImpl,
|
|
19
|
+
timeoutMs: 120_000,
|
|
20
|
+
tag: "share-source",
|
|
21
|
+
host: "heygenApi",
|
|
22
|
+
offline: () => new SyncApiError("offline"),
|
|
23
|
+
signedOut: () => new SyncApiError("signed-out"),
|
|
24
|
+
});
|
|
25
|
+
/** @param {string} method @param {string} route @param {unknown} [body] @param {Record<string,string>} [headers] */
|
|
26
|
+
const send = async (method, route, body, headers) => {
|
|
27
|
+
const { reply } = await request(method, route, body, headers);
|
|
28
|
+
if (!reply.ok) throw await refusalOf(reply);
|
|
29
|
+
return objectOf(await reply.json(), "envelope").data;
|
|
30
|
+
};
|
|
31
|
+
/** @param {string} space @param {string} composition */
|
|
32
|
+
const path = (space, composition) =>
|
|
33
|
+
`/v3/hyperframes/workspaces/${encodeURIComponent(space)}/compositions/${encodeURIComponent(composition)}`;
|
|
34
|
+
return {
|
|
35
|
+
/** @param {string} space */
|
|
36
|
+
limits: async (space) =>
|
|
37
|
+
syncLimitsOf(
|
|
38
|
+
await send(
|
|
39
|
+
"GET",
|
|
40
|
+
`/v3/hyperframes/workspaces/${encodeURIComponent(space)}/composition-sync/limits`,
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
/** @param {string} space @param {string} composition @param {string} title */
|
|
44
|
+
create: async (space, composition, title) =>
|
|
45
|
+
compositionOf(await send("PUT", path(space, composition), { title })),
|
|
46
|
+
/** @param {string} space @param {string} composition @param {string} snapshot */
|
|
47
|
+
snapshot: async (space, composition, snapshot) =>
|
|
48
|
+
snapshotOf(
|
|
49
|
+
await send(
|
|
50
|
+
"GET",
|
|
51
|
+
`${path(space, composition)}/snapshots/${encodeURIComponent(snapshot)}?include=manifest`,
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
/** @param {string} space @param {string} composition @param {import("./types.js").BeginRequest} body */
|
|
55
|
+
begin: async (space, composition, body) =>
|
|
56
|
+
beginOf(
|
|
57
|
+
await send(
|
|
58
|
+
"POST",
|
|
59
|
+
`${path(space, composition)}/uploads`,
|
|
60
|
+
{
|
|
61
|
+
base_snapshot_id: body.baseSnapshotId,
|
|
62
|
+
resolves_conflict_snapshot_ids: [],
|
|
63
|
+
client_type: "cli",
|
|
64
|
+
manifest_sha256: body.manifest.sha256,
|
|
65
|
+
manifest: body.manifest.document,
|
|
66
|
+
},
|
|
67
|
+
{ "Idempotency-Key": body.idempotencyKey },
|
|
68
|
+
),
|
|
69
|
+
false,
|
|
70
|
+
),
|
|
71
|
+
/** @param {string} space @param {string} composition @param {string} upload @param {import("./types.js").GrantRequest[]} files */
|
|
72
|
+
uploadUrls: async (space, composition, upload, files) =>
|
|
73
|
+
grantsOf(
|
|
74
|
+
await send(
|
|
75
|
+
"POST",
|
|
76
|
+
`${path(space, composition)}/uploads/${encodeURIComponent(upload)}/upload-urls`,
|
|
77
|
+
{
|
|
78
|
+
files: files.map((file) => ({
|
|
79
|
+
sha256: file.sha256,
|
|
80
|
+
...(file.multipartUploadId && { multipart_upload_id: file.multipartUploadId }),
|
|
81
|
+
...(file.parts && {
|
|
82
|
+
parts: file.parts.map((part) => ({
|
|
83
|
+
part_number: part.partNumber,
|
|
84
|
+
sha256: part.sha256,
|
|
85
|
+
})),
|
|
86
|
+
}),
|
|
87
|
+
})),
|
|
88
|
+
},
|
|
89
|
+
),
|
|
90
|
+
),
|
|
91
|
+
/** @param {string} space @param {string} composition @param {string} upload */
|
|
92
|
+
status: async (space, composition, upload) =>
|
|
93
|
+
uploadOf(
|
|
94
|
+
await send("GET", `${path(space, composition)}/uploads/${encodeURIComponent(upload)}`),
|
|
95
|
+
),
|
|
96
|
+
/** @param {string} space @param {string} composition @param {string} upload */
|
|
97
|
+
cancel: async (space, composition, upload) =>
|
|
98
|
+
uploadOf(
|
|
99
|
+
await send(
|
|
100
|
+
"POST",
|
|
101
|
+
`${path(space, composition)}/uploads/${encodeURIComponent(upload)}/cancel`,
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
/** @param {string} space @param {string} composition @param {string} upload */
|
|
105
|
+
submit: async (space, composition, upload) =>
|
|
106
|
+
uploadOf(
|
|
107
|
+
await send(
|
|
108
|
+
"POST",
|
|
109
|
+
`${path(space, composition)}/uploads/${encodeURIComponent(upload)}/verification`,
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** @typedef {ReturnType<typeof sourceApi>} SourceApi */
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Captures a project folder as the canonical manifest the server accepts: every portable regular file with its
|
|
3
|
+
// SHA-256 and length. A file that cannot be read, a link, a special file or a name no client can store fails the
|
|
4
|
+
// capture with a list of them; it never becomes a deletion. Limits are checked from sizes alone, before any hashing.
|
|
5
|
+
import { readSourceIgnore } from "../sourceIgnore.mjs";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { constants } from "node:fs";
|
|
8
|
+
import { lstat, open, readdir, realpath } from "node:fs/promises";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { ENTRYPOINT, ManifestInvalid, canonicalBytes, parseManifest } from "./manifest.mjs";
|
|
12
|
+
import { V1_LIMITS, overLimit } from "./limits.mjs";
|
|
13
|
+
import {
|
|
14
|
+
PathViolation,
|
|
15
|
+
checkInventoryPaths,
|
|
16
|
+
classifyPath,
|
|
17
|
+
excludedFolder,
|
|
18
|
+
prefiltered,
|
|
19
|
+
skippedSymlink,
|
|
20
|
+
validatePath,
|
|
21
|
+
} from "./pathPolicy.mjs";
|
|
22
|
+
import { readRenderOutputs, settledState } from "./projectState.mjs";
|
|
23
|
+
|
|
24
|
+
/** @typedef {import("./types.js").CaptureProblem} CaptureProblem @typedef {import("./types.js").Found} Found
|
|
25
|
+
* @typedef {import("./types.js").Walk} Walk @typedef {import("./types.js").Capture} Capture */
|
|
26
|
+
|
|
27
|
+
/** Files up to this size use one bounded buffer; larger files stream from the verified handle. */
|
|
28
|
+
const INLINE_HASH_BYTES = 4 * 1024 * 1024;
|
|
29
|
+
const HASHING_AT_ONCE = 4;
|
|
30
|
+
|
|
31
|
+
/** @param {string} root @param {string} rel @param {import("node:fs").Dirent} entry @param {Walk} walk
|
|
32
|
+
* @param {string[]} dirs */
|
|
33
|
+
async function visit(root, rel, entry, walk, dirs) {
|
|
34
|
+
if (entry.isDirectory()) {
|
|
35
|
+
if (!excludedFolder(rel)) dirs.push(rel);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const action = classifyPath(rel).client_action;
|
|
39
|
+
if (action === "exclude") return;
|
|
40
|
+
if (action === "wait_then_exclude") return void walk.waiting.push(rel);
|
|
41
|
+
if (entry.isSymbolicLink()) {
|
|
42
|
+
if (skippedSymlink(rel)) walk.skippedLinks.push(rel);
|
|
43
|
+
else walk.problems.push({ path: rel, problem: "symlink" });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!entry.isFile()) return void walk.problems.push({ path: rel, problem: "special-file" });
|
|
47
|
+
await visitFile(root, rel, walk);
|
|
48
|
+
}
|
|
49
|
+
/** @param {string} root @param {string} rel @param {Walk} walk */
|
|
50
|
+
async function visitFile(root, rel, walk) {
|
|
51
|
+
try {
|
|
52
|
+
validatePath(rel);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (!(error instanceof PathViolation)) throw error;
|
|
55
|
+
return void walk.problems.push({ path: rel, problem: "invalid-name", code: error.code });
|
|
56
|
+
}
|
|
57
|
+
const full = join(root, rel);
|
|
58
|
+
try {
|
|
59
|
+
const { size, mtimeMs, ino, dev } = await lstat(full);
|
|
60
|
+
walk.files.push({ path: rel, full, size, mtimeMs, ino, dev });
|
|
61
|
+
} catch {
|
|
62
|
+
walk.problems.push({ path: rel, problem: "unreadable" });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A folder that was a link, or another folder, by the time it was read. */
|
|
67
|
+
class FolderMoved extends Error {}
|
|
68
|
+
|
|
69
|
+
/** A folder's entries, read only while the same real folder stands at its path before and after: one swapped for a
|
|
70
|
+
* link to somewhere else is never listed. @param {string} root @param {string} dir */
|
|
71
|
+
async function listFolder(root, dir) {
|
|
72
|
+
const full = join(root, dir);
|
|
73
|
+
const before = await lstat(full);
|
|
74
|
+
if (!before.isDirectory()) throw new FolderMoved();
|
|
75
|
+
const entries = await readdir(full, { withFileTypes: true });
|
|
76
|
+
const after = await lstat(full);
|
|
77
|
+
if (!after.isDirectory() || after.ino !== before.ino || after.dev !== before.dev)
|
|
78
|
+
throw new FolderMoved();
|
|
79
|
+
return entries;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Every file a capture would list, and what keeps one from being listed. @param {string} root
|
|
83
|
+
* @param {(rel: string) => boolean} renderOutput @returns {Promise<Walk>} */
|
|
84
|
+
async function walkProject(root, renderOutput) {
|
|
85
|
+
/** @type {Walk} */
|
|
86
|
+
const walk = { files: [], problems: [], waiting: [], skippedLinks: [] };
|
|
87
|
+
const ignored = await readSourceIgnore(root);
|
|
88
|
+
const dirs = [""];
|
|
89
|
+
for (let dir = dirs.pop(); dir !== undefined; dir = dirs.pop()) {
|
|
90
|
+
let entries;
|
|
91
|
+
try {
|
|
92
|
+
entries = await listFolder(root, dir);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
const changed = error instanceof FolderMoved;
|
|
95
|
+
walk.problems.push({ path: dir, problem: changed ? "changed" : "unreadable" });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
const rel = dir ? `${dir}/${entry.name}` : entry.name;
|
|
100
|
+
if (prefiltered(entry.name) || renderOutput(rel) || ignored(rel)) continue;
|
|
101
|
+
await visit(root, rel, entry, walk, dirs);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return walk;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The manifest's canonical length, from paths and sizes alone: every SHA-256 is 64 characters.
|
|
108
|
+
* @param {Found[]} files */
|
|
109
|
+
const manifestBytes = (files) =>
|
|
110
|
+
canonicalBytes({
|
|
111
|
+
format_version: 1,
|
|
112
|
+
entrypoint: ENTRYPOINT,
|
|
113
|
+
files: files.map(({ path, size }) => ({ path, sha256: "0".repeat(64), size })),
|
|
114
|
+
}).length;
|
|
115
|
+
|
|
116
|
+
/** A file changed this recently can be written again within its time's granularity, keeping its size and time. */
|
|
117
|
+
const SETTLED_MS = 2_000;
|
|
118
|
+
|
|
119
|
+
/** A file's SHA-256 by path, size, time and inode, kept for the launch so a check after a focus reads no bytes.
|
|
120
|
+
* Only a settled file's hash is kept. */
|
|
121
|
+
export function hashMemo(now = Date.now) {
|
|
122
|
+
/** @type {Map<string, { size: number, mtimeMs: number, ino: number, sha256: string }>} */
|
|
123
|
+
const known = new Map();
|
|
124
|
+
return {
|
|
125
|
+
/** @param {Found} file */
|
|
126
|
+
get: (file) => {
|
|
127
|
+
const kept = known.get(file.full);
|
|
128
|
+
const same =
|
|
129
|
+
kept?.size === file.size && kept.mtimeMs === file.mtimeMs && kept.ino === file.ino;
|
|
130
|
+
return same ? kept.sha256 : null;
|
|
131
|
+
},
|
|
132
|
+
/** @param {Found} file @param {string} sha256 */
|
|
133
|
+
set: (file, sha256) => {
|
|
134
|
+
if (now() - file.mtimeMs > SETTLED_MS) known.set(file.full, { ...file, sha256 });
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The file the walk listed, as an open handle: never through a link at its own name (O_NOFOLLOW), and only when the
|
|
140
|
+
* opened file is the same regular file (inode, device, size) found inside the project, so a file or folder swapped
|
|
141
|
+
* for a link after the walk is refused rather than followed. Null when it is not. @param {string} root
|
|
142
|
+
* @param {Found} file @param {typeof realpath} [resolve] */
|
|
143
|
+
async function openListed(root, file, resolve = realpath) {
|
|
144
|
+
const handle = await open(file.full, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)).catch(
|
|
145
|
+
(/** @type {NodeJS.ErrnoException} */ error) => {
|
|
146
|
+
if (error.code === "ELOOP" || error.code === "EMLINK") return null;
|
|
147
|
+
throw error;
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
if (!handle) return null;
|
|
151
|
+
try {
|
|
152
|
+
const opened = await handle.stat();
|
|
153
|
+
const same =
|
|
154
|
+
opened.isFile() &&
|
|
155
|
+
opened.ino === file.ino &&
|
|
156
|
+
opened.dev === file.dev &&
|
|
157
|
+
opened.size === file.size &&
|
|
158
|
+
(await resolve(dirname(file.full))) === join(root, dirname(file.path));
|
|
159
|
+
if (same) return handle;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
await handle.close();
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
await handle.close();
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The file's SHA-256, read through the handle `openListed` checked; null when the file is not the one listed, or its
|
|
169
|
+
* bytes changed while being read. `root` is the project's real path; `resolve` finds a folder's real path.
|
|
170
|
+
* @param {string} root @param {Found} file @param {typeof realpath} [resolve] */
|
|
171
|
+
export async function hashListedFile(root, file, resolve = realpath) {
|
|
172
|
+
const handle = await openListed(root, file, resolve);
|
|
173
|
+
if (!handle) return null;
|
|
174
|
+
try {
|
|
175
|
+
const sha256 =
|
|
176
|
+
file.size <= INLINE_HASH_BYTES
|
|
177
|
+
? await handle
|
|
178
|
+
.readFile()
|
|
179
|
+
.then((bytes) =>
|
|
180
|
+
bytes.length === file.size ? createHash("sha256").update(bytes).digest("hex") : null,
|
|
181
|
+
)
|
|
182
|
+
: await hashHandle(handle);
|
|
183
|
+
const after = await handle.stat();
|
|
184
|
+
return after.size === file.size && after.mtimeMs === file.mtimeMs ? sha256 : null;
|
|
185
|
+
} finally {
|
|
186
|
+
await handle.close();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @param {string} root @param {Found[]} files @param {ReturnType<typeof hashMemo>} memo
|
|
191
|
+
* @returns {Promise<{ hashed: import("./types.js").ManifestFile[], problems: CaptureProblem[] }>} */
|
|
192
|
+
async function hashAll(root, files, memo) {
|
|
193
|
+
/** @type {import("./types.js").ManifestFile[]} */
|
|
194
|
+
const hashed = [];
|
|
195
|
+
/** @type {CaptureProblem[]} */
|
|
196
|
+
const problems = [];
|
|
197
|
+
const queue = [...files];
|
|
198
|
+
const worker = async () => {
|
|
199
|
+
for (let file = queue.shift(); file; file = queue.shift()) {
|
|
200
|
+
const sha256 = memo.get(file) ?? (await hashListedFile(root, file).catch(() => undefined));
|
|
201
|
+
if (sha256 === undefined) problems.push({ path: file.path, problem: "unreadable" });
|
|
202
|
+
else if (sha256 === null) problems.push({ path: file.path, problem: "changed" });
|
|
203
|
+
else {
|
|
204
|
+
memo.set(file, sha256);
|
|
205
|
+
hashed.push({ path: file.path, sha256, size: file.size });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
await Promise.all(Array.from({ length: HASHING_AT_ONCE }, worker));
|
|
210
|
+
return { hashed, problems };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Problems that keep the files found from being one manifest: two names one disk stores as one, no index.html.
|
|
214
|
+
* @param {Found[]} files @returns {CaptureProblem[]} */
|
|
215
|
+
function inventoryProblems(files) {
|
|
216
|
+
if (!files.some((file) => file.path === ENTRYPOINT))
|
|
217
|
+
return [{ path: ENTRYPOINT, problem: "no-entrypoint" }];
|
|
218
|
+
try {
|
|
219
|
+
checkInventoryPaths(files.map((file) => file.path));
|
|
220
|
+
return [];
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (!(error instanceof PathViolation)) throw error;
|
|
223
|
+
return [{ path: error.path, problem: "invalid-name", code: error.code }];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Captures `root` (a project's real path). `memo` reuses hashes of unchanged files; a send should pass a fresh one.
|
|
228
|
+
* @param {string} root @param {{ memo?: ReturnType<typeof hashMemo>, limits?: import("./types.js").Limits }} [options]
|
|
229
|
+
* @returns {Promise<Capture>} */
|
|
230
|
+
export async function captureProject(root, { memo = hashMemo(), limits = V1_LIMITS } = {}) {
|
|
231
|
+
const renderOutput = await readRenderOutputs(root);
|
|
232
|
+
const walk = await walkProject(root, renderOutput);
|
|
233
|
+
const problems = walk.problems.length ? walk.problems : inventoryProblems(walk.files);
|
|
234
|
+
if (problems.length) return { at: "problems", problems };
|
|
235
|
+
const over = overLimit(walk.files, manifestBytes(walk.files), limits);
|
|
236
|
+
if (over) return { at: "too-big", over };
|
|
237
|
+
const { hashed, problems: unhashed } = await hashAll(await realpath(root), walk.files, memo);
|
|
238
|
+
if (unhashed.length) return { at: "problems", problems: unhashed };
|
|
239
|
+
try {
|
|
240
|
+
const manifest = parseManifest({ format_version: 1, entrypoint: ENTRYPOINT, files: hashed });
|
|
241
|
+
const settled = await settledState(root);
|
|
242
|
+
return {
|
|
243
|
+
at: "captured",
|
|
244
|
+
manifest,
|
|
245
|
+
settled,
|
|
246
|
+
waiting: walk.waiting,
|
|
247
|
+
skippedLinks: walk.skippedLinks,
|
|
248
|
+
};
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (!(error instanceof ManifestInvalid)) throw error;
|
|
251
|
+
const first = error.violations[0];
|
|
252
|
+
return {
|
|
253
|
+
at: "problems",
|
|
254
|
+
problems: [{ path: first?.path ?? "", problem: "invalid-name", code: error.code }],
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** @param {import("node:fs/promises").FileHandle} handle */
|
|
260
|
+
async function hashHandle(handle) {
|
|
261
|
+
const hash = createHash("sha256");
|
|
262
|
+
for await (const chunk of handle.createReadStream({ autoClose: false })) hash.update(chunk);
|
|
263
|
+
return hash.digest("hex");
|
|
264
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
UNICODE LICENSE V3
|
|
2
|
+
|
|
3
|
+
COPYRIGHT AND PERMISSION NOTICE
|
|
4
|
+
|
|
5
|
+
Copyright © 1991-2026 Unicode, Inc.
|
|
6
|
+
|
|
7
|
+
NOTICE TO USER: Carefully read the following legal agreement. BY
|
|
8
|
+
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
|
|
9
|
+
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
|
|
10
|
+
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
|
|
11
|
+
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a
|
|
14
|
+
copy of data files and any associated documentation (the "Data Files") or
|
|
15
|
+
software and any associated documentation (the "Software") to deal in the
|
|
16
|
+
Data Files or Software without restriction, including without limitation
|
|
17
|
+
the rights to use, copy, modify, merge, publish, distribute, and/or sell
|
|
18
|
+
copies of the Data Files or Software, and to permit persons to whom the
|
|
19
|
+
Data Files or Software are furnished to do so, provided that either (a)
|
|
20
|
+
this copyright and permission notice appear with all copies of the Data
|
|
21
|
+
Files or Software, or (b) this copyright and permission notice appear in
|
|
22
|
+
associated Documentation.
|
|
23
|
+
|
|
24
|
+
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
|
|
25
|
+
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
26
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
|
27
|
+
THIRD PARTY RIGHTS.
|
|
28
|
+
|
|
29
|
+
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
|
|
30
|
+
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
|
|
31
|
+
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
|
|
32
|
+
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
|
33
|
+
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
|
|
34
|
+
FILES OR SOFTWARE.
|
|
35
|
+
|
|
36
|
+
Except as contained in this notice, the name of a copyright holder shall
|
|
37
|
+
not be used in advertising or otherwise to promote the sale, use or other
|
|
38
|
+
dealings in these Data Files or Software without prior written
|
|
39
|
+
authorization of the copyright holder.
|