@jini-ai/platform 0.6.19 → 0.6.23
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 +8 -0
- package/dist/aws-sigv4.d.ts +1 -1
- package/dist/aws-sigv4.js +27 -7
- package/dist/fs/directory-user-config.js +1 -1
- package/dist/fs/index.d.ts +1 -0
- package/dist/fs/index.js +1 -0
- package/dist/fs/reviewed-file.d.ts +42 -0
- package/dist/fs/reviewed-file.js +76 -0
- package/dist/web/index.d.ts +1 -0
- package/dist/web/index.js +1 -0
- package/dist/web/strip-html-presentation.d.ts +15 -0
- package/dist/web/strip-html-presentation.js +60 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
## 0.6.23
|
|
2
|
+
|
|
3
|
+
- Security hardening.
|
|
4
|
+
|
|
1
5
|
# @jini-ai/platform
|
|
2
6
|
|
|
7
|
+
## 0.6.22
|
|
8
|
+
|
|
9
|
+
- `readReviewedFile` and `reviewedFileIdentity` for file reads tied to a reviewed identity. SigV4 signs mixed-case headers correctly.
|
|
10
|
+
|
|
3
11
|
## 0.6.19
|
|
4
12
|
|
|
5
13
|
- File locks: a dead holder recorded on this machine under another hostname spelling (macOS `name.lan` / `name.local` / `name`) is reclaimed when it was taken in this same kernel boot (its recorded boot id, or for a record without one, an `acquiredAt` after this boot began), so a never-ageing (`staleMs: Infinity`) lock a killed process left no longer wedges every later acquisition. Other hosts, unrecorded boots and live owners are still never evicted. `@jini-ai/platform/fs/file-lock` re-exports `readBootIdentity` and `BootIdentity` for host process ports.
|
package/dist/aws-sigv4.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface SignSigV4Input {
|
|
|
15
15
|
path: string;
|
|
16
16
|
/** Already-stringified canonical query (sorted `key=value` join). Pass `''` when the request has no query. */
|
|
17
17
|
query: string;
|
|
18
|
-
/** Mutable lower-case
|
|
18
|
+
/** Mutable header map; names are normalized to lower-case before signing. The signer adds the four amz headers and the Authorization header. */
|
|
19
19
|
headers: Record<string, string>;
|
|
20
20
|
/** Raw body bytes; pass an empty Buffer for no-body requests. */
|
|
21
21
|
body: Buffer;
|
package/dist/aws-sigv4.js
CHANGED
|
@@ -36,6 +36,10 @@ export function signSigV4(input) {
|
|
|
36
36
|
const amzDate = formatAmzDate(now);
|
|
37
37
|
const dateStamp = amzDate.slice(0, 8);
|
|
38
38
|
const contentSha256 = sha256Hex(input.body);
|
|
39
|
+
// Normalise the outgoing map FIRST so each header has exactly one (lower-case) spelling: a caller's
|
|
40
|
+
// `X-Amz-Date` left beside the generated `x-amz-date` would let a transport send a value other than
|
|
41
|
+
// the signed one (FU-B, Codex on F1).
|
|
42
|
+
normaliseHeaders({ headers: input.headers });
|
|
39
43
|
// Add the required amz headers BEFORE composing the canonical request, since they're part of
|
|
40
44
|
// the signed-headers list.
|
|
41
45
|
input.headers['x-amz-date'] = amzDate;
|
|
@@ -43,13 +47,14 @@ export function signSigV4(input) {
|
|
|
43
47
|
if (input.credentials.sessionToken) {
|
|
44
48
|
input.headers['x-amz-security-token'] = input.credentials.sessionToken;
|
|
45
49
|
}
|
|
46
|
-
// Canonical headers: lower-case
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
.
|
|
52
|
-
|
|
50
|
+
// Canonical headers: keys are already lower-case (normaliseHeaders) + trim values; sort by key. The
|
|
51
|
+
// values signed are exactly the values left in the map: looking them up by a lower-cased key once
|
|
52
|
+
// signed a mixed-case header as empty (F1).
|
|
53
|
+
const canonicalEntries = Object.entries(input.headers)
|
|
54
|
+
// Keys are unique after lower-casing, so code-unit order never ties (same order as the old .sort()).
|
|
55
|
+
.sort(([a], [b]) => (a < b ? -1 : 1));
|
|
56
|
+
const headerKeys = canonicalEntries.map(([k]) => k);
|
|
57
|
+
const canonicalHeaders = canonicalEntries.map(([k, v]) => `${k}:${trimValue(v)}\n`).join('');
|
|
53
58
|
const signedHeaders = headerKeys.join(';');
|
|
54
59
|
const canonicalRequest = [
|
|
55
60
|
input.method.toUpperCase(),
|
|
@@ -76,6 +81,21 @@ export function signSigV4(input) {
|
|
|
76
81
|
input.headers['authorization'] = authorization;
|
|
77
82
|
return { authorization, amzDate, contentSha256 };
|
|
78
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Rewrites `headers` in place so every key is lower-case. Spellings that collide after lower-casing are
|
|
86
|
+
* merged into one comma-separated value in input order (AWS's canonical form for multi-value headers).
|
|
87
|
+
*/
|
|
88
|
+
function normaliseHeaders({ headers }, _options = {}) {
|
|
89
|
+
const merged = new Map();
|
|
90
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
91
|
+
const lower = key.toLowerCase();
|
|
92
|
+
const previous = merged.get(lower);
|
|
93
|
+
merged.set(lower, previous === undefined ? value : `${previous},${value}`);
|
|
94
|
+
delete headers[key];
|
|
95
|
+
}
|
|
96
|
+
for (const [key, value] of merged)
|
|
97
|
+
headers[key] = value;
|
|
98
|
+
}
|
|
79
99
|
function sha256Hex(body) {
|
|
80
100
|
return createHash('sha256').update(body).digest('hex');
|
|
81
101
|
}
|
|
@@ -173,7 +173,7 @@ export function createDirectoryUserConfig({ homeDir, defaultFolder, envName, pol
|
|
|
173
173
|
function databasePath({ dir, id, filename, placement }, _options = {}) {
|
|
174
174
|
assertDatabaseFilename(filename);
|
|
175
175
|
const inSite = path.join(dir, "data.nosync", filename), legacy = path.join(dir, filename);
|
|
176
|
-
if (!SAFE_SITE_ID.test(id))
|
|
176
|
+
if (typeof id !== "string" || !SAFE_SITE_ID.test(id))
|
|
177
177
|
return unsafeIdentityPath(legacy, placement);
|
|
178
178
|
const appData = path.join(directories.appData, "databases", id, filename);
|
|
179
179
|
if (placement)
|
package/dist/fs/index.d.ts
CHANGED
|
@@ -12,4 +12,5 @@ export * from "./directory-workspace.js";
|
|
|
12
12
|
export { DirectoryUserConfigRefusal, validateDirectoryUserConfig } from "./directory-user-config.js";
|
|
13
13
|
export * from "./workspace-directory.js";
|
|
14
14
|
export * from "./verified-directory-move.js";
|
|
15
|
+
export * from "./reviewed-file.js";
|
|
15
16
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/fs/index.js
CHANGED
|
@@ -13,4 +13,5 @@ export * from "./directory-workspace.js";
|
|
|
13
13
|
export { DirectoryUserConfigRefusal, validateDirectoryUserConfig } from "./directory-user-config.js";
|
|
14
14
|
export * from "./workspace-directory.js";
|
|
15
15
|
export * from "./verified-directory-move.js";
|
|
16
|
+
export * from "./reviewed-file.js";
|
|
16
17
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type BigIntStats, type Stats } from "node:fs";
|
|
2
|
+
/** Metadata captured when a caller reviews a file, before its later read. */
|
|
3
|
+
export interface ReviewedFile {
|
|
4
|
+
readonly root: string;
|
|
5
|
+
readonly absPath: string;
|
|
6
|
+
readonly bytes: number;
|
|
7
|
+
readonly mtimeMs: number;
|
|
8
|
+
readonly identity: string | null;
|
|
9
|
+
readonly nlink: number;
|
|
10
|
+
}
|
|
11
|
+
/** Metadata and bytes must come from the same open descriptor. */
|
|
12
|
+
export interface ReviewedFileHandle {
|
|
13
|
+
stat(): Promise<Pick<Stats, "isFile" | "size" | "mtimeMs">>;
|
|
14
|
+
stat(options: {
|
|
15
|
+
bigint: true;
|
|
16
|
+
}): Promise<Pick<BigIntStats, "dev" | "ino">>;
|
|
17
|
+
readFile(): Promise<Buffer>;
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
type PathEntry = Pick<BigIntStats, "dev" | "ino" | "nlink" | "isSymbolicLink" | "isDirectory">;
|
|
21
|
+
/** Injectable filesystem boundary for deterministic path-swap probes. */
|
|
22
|
+
export interface ReviewedFileSystem {
|
|
23
|
+
open(file: string, flags: number): Promise<ReviewedFileHandle>;
|
|
24
|
+
lstat(file: string): Promise<PathEntry>;
|
|
25
|
+
realpath(file: string): Promise<string>;
|
|
26
|
+
}
|
|
27
|
+
/** Bigint preserves 64-bit file IDs; inode zero means the filesystem supplies no identity. */
|
|
28
|
+
export declare function reviewedFileIdentity({ info }: {
|
|
29
|
+
info: Pick<BigIntStats, "dev" | "ino">;
|
|
30
|
+
}, _options?: {}): string | null;
|
|
31
|
+
/**
|
|
32
|
+
* Reads only the reviewed inode, size and mtime through a plain path with no newly added hardlinks.
|
|
33
|
+
* Returns null for stale paths/open failures; descriptor I/O errors propagate after closing it.
|
|
34
|
+
* @complexity O(bytes + path depth), one payload read, two fstats, depth lstats and two realpaths.
|
|
35
|
+
*/
|
|
36
|
+
export declare function readReviewedFile({ file }: {
|
|
37
|
+
file: ReviewedFile;
|
|
38
|
+
}, { fileSystem }?: {
|
|
39
|
+
fileSystem?: ReviewedFileSystem;
|
|
40
|
+
}): Promise<Buffer | null>;
|
|
41
|
+
export {};
|
|
42
|
+
//# sourceMappingURL=reviewed-file.d.ts.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const FILE_SYSTEM = { open, lstat: file => lstat(file, { bigint: true }), realpath };
|
|
5
|
+
/** Bigint preserves 64-bit file IDs; inode zero means the filesystem supplies no identity. */
|
|
6
|
+
export function reviewedFileIdentity({ info }, _options = {}) {
|
|
7
|
+
return info.ino === 0n ? null : `${info.dev}:${info.ino}`;
|
|
8
|
+
}
|
|
9
|
+
// NOFOLLOW guards the leaf only. NONBLOCK prevents a swapped FIFO hanging the read; NOCTTY prevents
|
|
10
|
+
// a swapped terminal becoming the server's controlling terminal. Windows lacks these flags.
|
|
11
|
+
const READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0) | (constants.O_NOCTTY ?? 0);
|
|
12
|
+
/**
|
|
13
|
+
* Reads only the reviewed inode, size and mtime through a plain path with no newly added hardlinks.
|
|
14
|
+
* Returns null for stale paths/open failures; descriptor I/O errors propagate after closing it.
|
|
15
|
+
* @complexity O(bytes + path depth), one payload read, two fstats, depth lstats and two realpaths.
|
|
16
|
+
*/
|
|
17
|
+
export async function readReviewedFile({ file }, { fileSystem = FILE_SYSTEM } = {}) {
|
|
18
|
+
let handle;
|
|
19
|
+
try {
|
|
20
|
+
handle = await fileSystem.open(file.absPath, READ_FLAGS);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
if (!(await openedIsReviewed({ file, handle, fileSystem })))
|
|
27
|
+
return null;
|
|
28
|
+
const bytes = await handle.readFile();
|
|
29
|
+
return bytes.length === file.bytes ? bytes : null;
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await handle.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Identity closes ancestor/root swap-and-swap-back races that path walks alone cannot prove. */
|
|
36
|
+
async function openedIsReviewed({ file, handle, fileSystem }) {
|
|
37
|
+
const info = await handle.stat();
|
|
38
|
+
if (!info.isFile() || info.size !== file.bytes || info.mtimeMs !== file.mtimeMs)
|
|
39
|
+
return false;
|
|
40
|
+
const opened = reviewedFileIdentity({ info: await handle.stat({ bigint: true }) });
|
|
41
|
+
if (file.identity !== null && opened !== file.identity)
|
|
42
|
+
return false;
|
|
43
|
+
const reached = await walkReviewedPath({ file, fileSystem });
|
|
44
|
+
// Both null only on inode-zero volumes: the path proof is the remaining protection there.
|
|
45
|
+
// A plan raced onto an outside inode can reach it through real folders only via an extra hardlink.
|
|
46
|
+
return reached !== null && reviewedFileIdentity({ info: reached }) === opened && Number(reached.nlink) <= file.nlink;
|
|
47
|
+
}
|
|
48
|
+
/** Proves each descendant is unlinked and the resolved leaf stays under the trusted scope root. */
|
|
49
|
+
async function walkReviewedPath({ file, fileSystem }) {
|
|
50
|
+
const relative = path.relative(file.root, file.absPath);
|
|
51
|
+
const names = relative.split(path.sep);
|
|
52
|
+
if (relative === "" || path.isAbsolute(relative) || names[0] === "..")
|
|
53
|
+
return null;
|
|
54
|
+
try {
|
|
55
|
+
const leaf = await lstatNames({ root: file.root, names, fileSystem });
|
|
56
|
+
if (leaf === null)
|
|
57
|
+
return null;
|
|
58
|
+
return await fileSystem.realpath(file.absPath) === path.join(await fileSystem.realpath(file.root), relative) ? leaf : null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** O(path depth) metadata reads, without entering links or non-directory ancestors. */
|
|
65
|
+
async function lstatNames({ root, names, fileSystem }) {
|
|
66
|
+
let current = root;
|
|
67
|
+
let leaf = null;
|
|
68
|
+
for (const [index, name] of names.entries()) {
|
|
69
|
+
current = path.join(current, name);
|
|
70
|
+
leaf = await fileSystem.lstat(current);
|
|
71
|
+
if (leaf.isSymbolicLink() || (index < names.length - 1 && !leaf.isDirectory()))
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return leaf;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=reviewed-file.js.map
|
package/dist/web/index.d.ts
CHANGED
package/dist/web/index.js
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Remove presentation using source ranges; untouched bytes (including comments) stay exact.
|
|
2
|
+
* Scripts, classes and functional attributes are preserved. This is not an HTML sanitizer.
|
|
3
|
+
* @param required.html - Document or fragment to transform; never mutated.
|
|
4
|
+
* @param optional.preservedStylesheetHrefs - Exact hrefs retained for host sentinels/vendor CSS.
|
|
5
|
+
* @returns HTML without style elements/attributes or unretained stylesheet links; no I/O.
|
|
6
|
+
* @complexity O(n + p + r log r) time and O(n + p + r) space for HTML bytes n,
|
|
7
|
+
* preserved href bytes p and removal ranges r.
|
|
8
|
+
* @example stripHtmlPresentation({ html: '<p style="color:red">Hi</p>' })
|
|
9
|
+
*/
|
|
10
|
+
export declare function stripHtmlPresentation({ html }: {
|
|
11
|
+
html: string;
|
|
12
|
+
}, { preservedStylesheetHrefs }?: {
|
|
13
|
+
preservedStylesheetHrefs?: readonly string[];
|
|
14
|
+
}): string;
|
|
15
|
+
//# sourceMappingURL=strip-html-presentation.d.ts.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { parse } from "parse5";
|
|
2
|
+
import { attr } from "./html-document.js";
|
|
3
|
+
/** Select whole elements before attributes; the ordered edit pass skips overlapping ranges. */
|
|
4
|
+
function presentationRange(node, preserved) {
|
|
5
|
+
const location = node.sourceCodeLocation;
|
|
6
|
+
if (!location)
|
|
7
|
+
return undefined;
|
|
8
|
+
const rel = (attr({ element: node, name: "rel" }) ?? "").toLowerCase().split(/\s+/);
|
|
9
|
+
const href = attr({ element: node, name: "href" }) ?? "";
|
|
10
|
+
if (node.tagName === "style")
|
|
11
|
+
return location;
|
|
12
|
+
if (node.tagName === "link" && rel.includes("stylesheet") && !preserved.has(href))
|
|
13
|
+
return location;
|
|
14
|
+
return location.attrs?.style;
|
|
15
|
+
}
|
|
16
|
+
/** Iterative traversal includes inert template content without recursion or argument spreading. */
|
|
17
|
+
function* walk(root) {
|
|
18
|
+
const pending = [root];
|
|
19
|
+
while (pending.length) {
|
|
20
|
+
const node = pending.pop();
|
|
21
|
+
yield node;
|
|
22
|
+
if ("childNodes" in node)
|
|
23
|
+
for (const child of node.childNodes)
|
|
24
|
+
pending.push(child);
|
|
25
|
+
if ("content" in node)
|
|
26
|
+
pending.push(node.content);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Remove presentation using source ranges; untouched bytes (including comments) stay exact.
|
|
30
|
+
* Scripts, classes and functional attributes are preserved. This is not an HTML sanitizer.
|
|
31
|
+
* @param required.html - Document or fragment to transform; never mutated.
|
|
32
|
+
* @param optional.preservedStylesheetHrefs - Exact hrefs retained for host sentinels/vendor CSS.
|
|
33
|
+
* @returns HTML without style elements/attributes or unretained stylesheet links; no I/O.
|
|
34
|
+
* @complexity O(n + p + r log r) time and O(n + p + r) space for HTML bytes n,
|
|
35
|
+
* preserved href bytes p and removal ranges r.
|
|
36
|
+
* @example stripHtmlPresentation({ html: '<p style="color:red">Hi</p>' })
|
|
37
|
+
*/
|
|
38
|
+
export function stripHtmlPresentation({ html }, { preservedStylesheetHrefs = [] } = {}) {
|
|
39
|
+
const preserved = new Set(preservedStylesheetHrefs);
|
|
40
|
+
const ranges = [];
|
|
41
|
+
for (const node of walk(parse(html, { sourceCodeLocationInfo: true, scriptingEnabled: false }))) {
|
|
42
|
+
if (!("tagName" in node))
|
|
43
|
+
continue;
|
|
44
|
+
const range = presentationRange(node, preserved);
|
|
45
|
+
if (range)
|
|
46
|
+
ranges.push(range);
|
|
47
|
+
}
|
|
48
|
+
ranges.sort((a, b) => a.startOffset - b.startOffset);
|
|
49
|
+
const parts = [];
|
|
50
|
+
let end = 0;
|
|
51
|
+
for (const range of ranges) {
|
|
52
|
+
if (range.startOffset < end)
|
|
53
|
+
continue;
|
|
54
|
+
parts.push(html.slice(end, range.startOffset));
|
|
55
|
+
end = range.endOffset;
|
|
56
|
+
}
|
|
57
|
+
parts.push(html.slice(end));
|
|
58
|
+
return parts.join("");
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=strip-html-presentation.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/platform",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.23",
|
|
4
4
|
"description": "Generic OS/platform primitives: process exec and lifecycle, filesystem containment, HTTP readiness polling, toolchain discovery, blob storage, AWS signing, and an SSRF-safe asset cache.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|