@huynle/hub 0.8.7 → 0.9.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 +129 -0
- package/README.md +69 -12
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +278 -35
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +23 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +5 -1
- package/packages/cli/src/mcp.ts +28 -8
- package/packages/cli/src/messaging.ts +14 -0
- package/packages/cli/src/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
- package/packages/protocol/src/artifacts.ts +43 -0
- package/packages/protocol/src/credentials.ts +38 -0
- package/packages/protocol/src/index.ts +56 -5
- package/packages/protocol/src/launch.ts +7 -2
- package/packages/protocol/src/markdown.ts +753 -0
- package/packages/protocol/src/media-allowlist.ts +240 -0
- package/packages/protocol/src/media.ts +120 -0
- package/packages/protocol/src/preview-bundle.ts +221 -0
- package/packages/protocol/src/voice-recording.ts +8 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +17 -3
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/team-intake.ts +8 -0
- package/packages/sdk/src/types.ts +16 -2
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/delivery.ts +35 -6
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/events.ts +11 -2
- package/src/central/agent/launch-engine.ts +65 -4
- package/src/central/agent/launch-routes.ts +8 -0
- package/src/central/agent/launch-spec.ts +18 -1
- package/src/central/agent/launch-store.ts +2 -2
- package/src/central/agent/message-routes.ts +49 -11
- package/src/central/agent/messages.ts +17 -4
- package/src/central/agent/not-after.ts +28 -0
- package/src/central/agent/placement.ts +36 -4
- package/src/central/agent/routes.ts +3 -1
- package/src/central/agent/webhook-routes.ts +21 -9
- package/src/central/agent/webhooks.ts +27 -9
- package/src/central/artifacts/host.ts +3 -1
- package/src/central/artifacts/maintenance.ts +2 -1
- package/src/central/artifacts/preview-bundle.ts +81 -0
- package/src/central/artifacts/previews.ts +94 -0
- package/src/central/artifacts/routes.ts +12 -1
- package/src/central/artifacts/store.ts +198 -8
- package/src/central/config.ts +34 -0
- package/src/central/fetch-budget.ts +59 -0
- package/src/central/fetcher.ts +372 -0
- package/src/central/http.ts +277 -22
- package/src/central/media-remote.ts +248 -0
- package/src/central/media-timeline.ts +317 -0
- package/src/central/media-unfurl.ts +119 -0
- package/src/central/profiles/content.ts +3 -35
- package/src/central/recording/routes.ts +2 -2
- package/src/central/recording/schema.ts +3 -1
- package/src/central/registry.ts +449 -21
- package/src/central/spoke-ws.ts +17 -3
- package/src/central/store.ts +21 -0
- package/src/central/teams/intake-routes.ts +2 -1
- package/src/central/teams/intake.ts +200 -15
- package/src/central/unfurl-parse.ts +313 -0
- package/src/index.ts +68 -8
- package/src/speakable.ts +33 -1
- package/src/spoke/conversation.ts +7 -4
- package/src/spoke/deliver.ts +19 -0
- package/src/spoke/media-bytes.ts +171 -0
- package/src/spoke/media-policy.ts +394 -0
- package/src/spoke/media.ts +189 -0
- package/src/spoke/own-server.ts +30 -0
- package/src/spoke/preview-bundle.ts +538 -0
- package/src/spoke/spoke.ts +387 -17
- package/src/spoke/timeline.ts +59 -5
- package/src/spoke/voice-front.ts +335 -0
- package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
- package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
- package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
- package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
- package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
- package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
- package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
- package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
- package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
- package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
- package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
- package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
- package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
- package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
- package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
- package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
- package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
- package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
- package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
- package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
- package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
- package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
- package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
- package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
- package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
- package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
- package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
- package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
- package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
- package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
- package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
- package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
- package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
- package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
- package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
- package/web/dist/assets/Library-TwvrwqSk.js +2 -0
- package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
- package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
- package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
- package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
- package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
- package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
- package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
- package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
- package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
- package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
- package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
- package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
- package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
- package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
- package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
- package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
- package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
- package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
- package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
- package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
- package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
- package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
- package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
- package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
- package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
- package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
- package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
- package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
- package/web/dist/assets/index-C3ci5UtY.css +1 -0
- package/web/dist/assets/index-D-xG9nrc.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-7q72Lyj2.js +5 -0
- package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
- package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
- package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
- package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
- package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
- package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
- package/web/dist/assets/source-BWlvaODa.js +1 -0
- package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
- package/web/dist/assets/ui-CFB4wQfF.js +3 -0
- package/web/dist/index.html +8 -6
- package/web/src/voice/turn/LICENSE +28 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
- package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
- package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
- package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
- package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
- package/web/dist/assets/hub-CrbItiwP.js +0 -13
- package/web/dist/assets/index-82Ce0IAf.css +0 -1
- package/web/dist/assets/index-D9SP3_0z.js +0 -22
- package/web/dist/assets/live-BNJgy0vg.js +0 -5
- package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
- package/web/dist/assets/source-Wbq35qGp.js +0 -1
- package/web/dist/assets/ui-j5FFk90B.js +0 -3
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// (S4 M2) What an image's bytes are: magic-byte sniffing (PNG, JPEG, WebP, GIF, SVG) and the width and height in
|
|
2
|
+
// a raster image's header, read without decoding it. The spoke serves an image only when its bytes are the type its
|
|
3
|
+
// source claims (an attachment's or tool item's mime, a path's extension) and its dimensions fit MEDIA_LIMITS.
|
|
4
|
+
import { MEDIA_LIMITS, MEDIA_MIMES, type MediaMime } from "../protocol"
|
|
5
|
+
|
|
6
|
+
export interface Dimensions {
|
|
7
|
+
readonly width: number
|
|
8
|
+
readonly height: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
12
|
+
const ascii = (b: Uint8Array, at: number, text: string) => b.length >= at + text.length && [...text].every((c, i) => b[at + i] === c.charCodeAt(0))
|
|
13
|
+
const be16 = (b: Uint8Array, at: number) => (b[at]! << 8) | b[at + 1]!
|
|
14
|
+
const be32 = (b: Uint8Array, at: number) => ((b[at]! << 24) | (b[at + 1]! << 16) | (b[at + 2]! << 8) | b[at + 3]!) >>> 0
|
|
15
|
+
const le16 = (b: Uint8Array, at: number) => b[at]! | (b[at + 1]! << 8)
|
|
16
|
+
const le24 = (b: Uint8Array, at: number) => b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16)
|
|
17
|
+
const le32 = (b: Uint8Array, at: number) => (b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16) | (b[at + 3]! << 24)) >>> 0
|
|
18
|
+
|
|
19
|
+
/** An SVG document: optional BOM, XML declaration, comments and doctype, then the root `<svg`. */
|
|
20
|
+
const SVG_START = /^\uFEFF?\s*(?:<\?xml[^>]*\?>\s*)?(?:<!--[\s\S]*?-->\s*)*(?:<!DOCTYPE\s+svg[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*)*<svg[\s>/]/i
|
|
21
|
+
const SVG_SNIFF_BYTES = 4096
|
|
22
|
+
|
|
23
|
+
/** The image type of `bytes` by their magic bytes, else undefined. */
|
|
24
|
+
export function sniffMime(bytes: Uint8Array): MediaMime | undefined {
|
|
25
|
+
if (bytes.length >= 8 && PNG_SIGNATURE.every((v, i) => bytes[i] === v)) return "image/png"
|
|
26
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg"
|
|
27
|
+
if (ascii(bytes, 0, "GIF87a") || ascii(bytes, 0, "GIF89a")) return "image/gif"
|
|
28
|
+
if (ascii(bytes, 0, "RIFF") && ascii(bytes, 8, "WEBP")) return "image/webp"
|
|
29
|
+
const head = new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(0, SVG_SNIFF_BYTES))
|
|
30
|
+
if (SVG_START.test(head)) return "image/svg+xml"
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A raster image's width and height from its (complete) header; undefined when it is truncated, malformed or SVG. */
|
|
35
|
+
export function imageDimensions(bytes: Uint8Array, mime: string): Dimensions | undefined {
|
|
36
|
+
switch (mime) {
|
|
37
|
+
case "image/png":
|
|
38
|
+
// Signature, then the IHDR chunk (length, type, 13 bytes of data, CRC).
|
|
39
|
+
if (bytes.length < 33 || !ascii(bytes, 12, "IHDR")) return undefined
|
|
40
|
+
return { width: be32(bytes, 16), height: be32(bytes, 20) }
|
|
41
|
+
case "image/gif":
|
|
42
|
+
// Header and the whole logical screen descriptor.
|
|
43
|
+
if (bytes.length < 13) return undefined
|
|
44
|
+
return { width: le16(bytes, 6), height: le16(bytes, 8) }
|
|
45
|
+
case "image/webp":
|
|
46
|
+
return webpDimensions(bytes)
|
|
47
|
+
case "image/jpeg":
|
|
48
|
+
return jpegDimensions(bytes)
|
|
49
|
+
default:
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function webpDimensions(b: Uint8Array): Dimensions | undefined {
|
|
55
|
+
if (b.length < 20) return undefined
|
|
56
|
+
const size = le32(b, 16)
|
|
57
|
+
if (ascii(b, 12, "VP8 ")) {
|
|
58
|
+
// Frame tag (3 bytes), start code 9d 01 2a, then 14-bit width and height.
|
|
59
|
+
if (size < 10 || b.length < 30 || b[23] !== 0x9d || b[24] !== 0x01 || b[25] !== 0x2a) return undefined
|
|
60
|
+
return { width: le16(b, 26) & 0x3fff, height: le16(b, 28) & 0x3fff }
|
|
61
|
+
}
|
|
62
|
+
if (ascii(b, 12, "VP8L")) {
|
|
63
|
+
if (size < 5 || b.length < 25 || b[20] !== 0x2f) return undefined
|
|
64
|
+
const bits = le32(b, 21)
|
|
65
|
+
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }
|
|
66
|
+
}
|
|
67
|
+
if (ascii(b, 12, "VP8X")) {
|
|
68
|
+
if (size < 10 || b.length < 30) return undefined
|
|
69
|
+
return { width: le24(b, 24) + 1, height: le24(b, 27) + 1 }
|
|
70
|
+
}
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Start-of-frame markers (every SOFn except DHT C4, JPG C8 and DAC CC). */
|
|
75
|
+
const isSOF = (marker: number) => marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc
|
|
76
|
+
|
|
77
|
+
function jpegDimensions(b: Uint8Array): Dimensions | undefined {
|
|
78
|
+
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return undefined
|
|
79
|
+
let at = 2
|
|
80
|
+
while (at < b.length) {
|
|
81
|
+
if (b[at] !== 0xff) return undefined
|
|
82
|
+
while (at < b.length && b[at] === 0xff) at++
|
|
83
|
+
if (at >= b.length) return undefined
|
|
84
|
+
const marker = b[at]!
|
|
85
|
+
at++
|
|
86
|
+
// Markers without a length: TEM, RSTn, SOI. EOI or SOS before a frame: no dimensions.
|
|
87
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue
|
|
88
|
+
if (marker === 0xd9 || marker === 0xda) return undefined
|
|
89
|
+
if (at + 2 > b.length) return undefined
|
|
90
|
+
const length = be16(b, at)
|
|
91
|
+
if (length < 2) return undefined
|
|
92
|
+
if (isSOF(marker)) {
|
|
93
|
+
// Length, precision, height, width.
|
|
94
|
+
if (length < 7 || at + 7 > b.length) return undefined
|
|
95
|
+
return { width: be16(b, at + 5), height: be16(b, at + 3) }
|
|
96
|
+
}
|
|
97
|
+
at += length
|
|
98
|
+
}
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const EXTENSIONS: Readonly<Record<string, MediaMime>> = {
|
|
103
|
+
png: "image/png",
|
|
104
|
+
jpg: "image/jpeg",
|
|
105
|
+
jpeg: "image/jpeg",
|
|
106
|
+
webp: "image/webp",
|
|
107
|
+
gif: "image/gif",
|
|
108
|
+
svg: "image/svg+xml",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The image type a path's extension claims, else undefined. */
|
|
112
|
+
export function mimeOfPath(path: string): MediaMime | undefined {
|
|
113
|
+
const match = /\.([A-Za-z0-9]+)$/.exec(path)
|
|
114
|
+
return match ? EXTENSIONS[match[1]!.toLowerCase()] : undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A claimed mime as one of MEDIA_MIMES (image/jpg is image/jpeg), else undefined. */
|
|
118
|
+
export function mediaMime(claim: string | undefined): MediaMime | undefined {
|
|
119
|
+
const mime = (claim ?? "").trim().toLowerCase().split(";")[0]!.trim()
|
|
120
|
+
const normal = mime === "image/jpg" ? "image/jpeg" : mime
|
|
121
|
+
return (MEDIA_MIMES as readonly string[]).includes(normal) ? (normal as MediaMime) : undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type ImageCheck = { readonly ok: { readonly mime: MediaMime } & Partial<Dimensions> } | { readonly refused: "type" | "dimensions" }
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether `bytes` may be served as `claim`: the claim is an allowed type and the magic bytes match it ("type"
|
|
128
|
+
* otherwise, also for a raster image whose header has no readable dimensions), and a raster image fits
|
|
129
|
+
* MEDIA_LIMITS' sides and pixels ("dimensions" otherwise).
|
|
130
|
+
*/
|
|
131
|
+
export function checkImage(bytes: Uint8Array, claim: string): ImageCheck {
|
|
132
|
+
const mime = mediaMime(claim)
|
|
133
|
+
if (!mime || sniffMime(bytes) !== mime) return { refused: "type" }
|
|
134
|
+
if (mime === "image/svg+xml") return { ok: { mime } }
|
|
135
|
+
const dims = imageDimensions(bytes, mime)
|
|
136
|
+
if (!dims || dims.width < 1 || dims.height < 1) return { refused: "type" }
|
|
137
|
+
if (dims.width > MEDIA_LIMITS.maxSide || dims.height > MEDIA_LIMITS.maxSide || dims.width * dims.height > MEDIA_LIMITS.maxPixels) return { refused: "dimensions" }
|
|
138
|
+
return { ok: { mime, ...dims } }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const DATA_URI = /^data:([^;,]*)((?:;[^;,]*)*);base64,([A-Za-z0-9+/=_-]*)$/i
|
|
142
|
+
|
|
143
|
+
/** The parts of a base64 `data:` URI, else undefined. */
|
|
144
|
+
export function parseDataURI(uri: string): { mime: string; base64: string } | undefined {
|
|
145
|
+
const match = DATA_URI.exec(uri)
|
|
146
|
+
return match ? { mime: match[1]!.toLowerCase(), base64: match[3]! } : undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The decoded size of base64 text (padding and URL-safe alphabet allowed). */
|
|
150
|
+
export function base64Bytes(base64: string): number {
|
|
151
|
+
const body = base64.replace(/=+$/, "")
|
|
152
|
+
return Math.floor((body.length * 3) / 4)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The first bytes of base64 text (at most `max`), for header sniffing without decoding all of it. */
|
|
156
|
+
export function base64Head(base64: string, max = HEAD_BYTES): Uint8Array {
|
|
157
|
+
const chars = Math.ceil(max / 3) * 4
|
|
158
|
+
return Uint8Array.from(Buffer.from(base64.slice(0, chars), base64.includes("-") || base64.includes("_") ? "base64url" : "base64"))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Bytes read to find a header's dimensions (a JPEG's frame may follow large EXIF segments). */
|
|
162
|
+
const HEAD_BYTES = 96 * 1024
|
|
163
|
+
|
|
164
|
+
/** A raster image's header dimensions from the start of its base64, when the bytes are the claimed type. */
|
|
165
|
+
export function base64Dimensions(base64: string, claim: string): Partial<Dimensions> {
|
|
166
|
+
const mime = mediaMime(claim)
|
|
167
|
+
if (!mime || mime === "image/svg+xml") return {}
|
|
168
|
+
const head = base64Head(base64)
|
|
169
|
+
if (sniffMime(head) !== mime) return {}
|
|
170
|
+
return imageDimensions(head, mime) ?? {}
|
|
171
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// (S4 M2, design §1) The path policy of `p:` media refs: which local image files an assistant message may show.
|
|
2
|
+
//
|
|
3
|
+
// 1. The path must be the source of a markdown image in that message's own text (re-parsed here, so the console
|
|
4
|
+
// cannot name an arbitrary path). `~` is never expanded; remote and other URL schemes are not paths.
|
|
5
|
+
// 2. After realpath (symlinks resolved), the file must be under the session's directory (its `.worktrees/` too),
|
|
6
|
+
// or in the session's touched set: the paths of its own read/write/edit calls and the names of tool file outputs.
|
|
7
|
+
// 3. A path with a segment starting with "." (`.git/`, `.env`, …) below the directory is refused unless the file is
|
|
8
|
+
// in the touched set. 4. Regular files only, and the open file must be what the checked realpath names
|
|
9
|
+
// (openChecked: on macOS the real path is opened with no symlink anywhere in it; elsewhere the path is opened first
|
|
10
|
+
// without following a final symlink, then checked). The bytes are checked in media-bytes.ts.
|
|
11
|
+
import { constants, type Stats } from "node:fs"
|
|
12
|
+
import { lstat, open, readlink, realpath, stat, type FileHandle } from "node:fs/promises"
|
|
13
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
|
|
14
|
+
import { fileURLToPath } from "node:url"
|
|
15
|
+
import { parseMarkdown, type Inline, type MdBlock } from "../../packages/protocol/src/markdown.ts"
|
|
16
|
+
import { MEDIA_LIMITS } from "../protocol"
|
|
17
|
+
|
|
18
|
+
const record = (value: unknown): Record<string, any> => (value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, any>) : {})
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Text with indented code blocks (4+ spaces or a tab after a blank line, outside fences) blanked: parseMarkdown keeps
|
|
22
|
+
* them as paragraphs, but such a line is code to CommonMark and to whoever wrote it, so its images never count.
|
|
23
|
+
*/
|
|
24
|
+
export function withoutIndentedCode(text: string): string {
|
|
25
|
+
const lines: string[] = []
|
|
26
|
+
let fence: string | undefined
|
|
27
|
+
let blank = true
|
|
28
|
+
let indented = false
|
|
29
|
+
for (const line of text.split("\n")) {
|
|
30
|
+
const open = /^ {0,3}(`{3,}|~{3,})/.exec(line)
|
|
31
|
+
const isBlank = line.trim() === ""
|
|
32
|
+
if (!fence && !isBlank && /^(?: {4}|\t)/.test(line) && (blank || indented)) {
|
|
33
|
+
indented = true
|
|
34
|
+
blank = false
|
|
35
|
+
lines.push("")
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if (!isBlank) indented = false
|
|
39
|
+
blank = isBlank
|
|
40
|
+
if (fence) {
|
|
41
|
+
if (open && open[1]![0] === fence[0] && open[1]!.length >= fence.length && line.trim() === open[1]) fence = undefined
|
|
42
|
+
} else if (open) fence = open[1]
|
|
43
|
+
lines.push(line)
|
|
44
|
+
}
|
|
45
|
+
return lines.join("\n")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The sources of the image nodes of a parsed tree (link labels, lists, quotes and tables too), in order. */
|
|
49
|
+
function imagesOf(blocks: readonly MdBlock[], out: string[] = []): string[] {
|
|
50
|
+
const inline = (nodes: readonly Inline[]) => {
|
|
51
|
+
for (const node of nodes) {
|
|
52
|
+
if (node.type === "image") out.push(node.src)
|
|
53
|
+
else if ("children" in node) inline(node.children)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const block of blocks) {
|
|
57
|
+
if (block.type === "paragraph" || block.type === "heading") inline(block.children)
|
|
58
|
+
else if (block.type === "quote") imagesOf(block.blocks, out)
|
|
59
|
+
else if (block.type === "list") for (const item of block.items) imagesOf(item.blocks, out)
|
|
60
|
+
else if (block.type === "table") for (const row of [block.head, ...block.rows]) for (const cell of row) inline(cell)
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Backstops (the parser is linear, but it runs synchronously in the harness's process on every timeline build): only
|
|
67
|
+
* the first 64 KiB characters of a text, up to its last blank line there (scannedPart), are read for images, and at
|
|
68
|
+
* most MEDIA_LIMITS.perRow sources are kept (a row lists no more). An image past either limit is neither listed nor
|
|
69
|
+
* served.
|
|
70
|
+
*/
|
|
71
|
+
const IMAGE_SCAN_CHARS = 64 * 1024
|
|
72
|
+
|
|
73
|
+
/** A blank line (only spaces, tabs or a CR on it), as the parser sees one. */
|
|
74
|
+
const BLANK_LINE = /\n[ \t\r]*\n/g
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The part of a text read for images: all of it up to IMAGE_SCAN_CHARS, else what comes before its last blank line
|
|
78
|
+
* within the first IMAGE_SCAN_CHARS (nothing when there is none), so the cut never splits a paragraph and the code
|
|
79
|
+
* spans in it. A fence still open at the cut makes the rest code: fewer images, never more.
|
|
80
|
+
*/
|
|
81
|
+
export function scannedPart(text: string): string {
|
|
82
|
+
if (text.length <= IMAGE_SCAN_CHARS) return text
|
|
83
|
+
const head = text.slice(0, IMAGE_SCAN_CHARS)
|
|
84
|
+
let cut = 0
|
|
85
|
+
BLANK_LINE.lastIndex = 0
|
|
86
|
+
for (let m = BLANK_LINE.exec(head); m; m = BLANK_LINE.exec(head)) {
|
|
87
|
+
cut = m.index
|
|
88
|
+
BLANK_LINE.lastIndex = m.index + 1
|
|
89
|
+
}
|
|
90
|
+
return head.slice(0, cut)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The image sources (``) of markdown text, in order, without duplicates, as the console's parser
|
|
95
|
+
* (parseMarkdown, packages/protocol) reads them: never in fenced code, code spans or after a backslash escape. The
|
|
96
|
+
* console does not render reference images (`![alt][ref]`), so they are not sources either. Indented code is left
|
|
97
|
+
* out as well: a source counts only when the text without indented code (withoutIndentedCode) still has it.
|
|
98
|
+
*/
|
|
99
|
+
export function markdownImageSources(markdown: string): string[] {
|
|
100
|
+
const text = scannedPart(markdown)
|
|
101
|
+
if (!text.includes("![")) return []
|
|
102
|
+
const outsideIndented = new Set(imagesOf(parseMarkdown(withoutIndentedCode(text))))
|
|
103
|
+
const out: string[] = []
|
|
104
|
+
for (const src of imagesOf(parseMarkdown(text))) {
|
|
105
|
+
if (src && outsideIndented.has(src) && !out.includes(src)) out.push(src)
|
|
106
|
+
if (out.length >= MEDIA_LIMITS.perRow) break
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The absolute (not yet realpath'd) path an image source names, relative ones against `directory`: plain paths and
|
|
115
|
+
* local `file:` URLs. Undefined for `~` paths (never expanded), remote and other URLs, and empty or NUL sources.
|
|
116
|
+
*/
|
|
117
|
+
export function sourcePath(src: string, directory: string): string | undefined {
|
|
118
|
+
if (!src || src.includes("\u0000") || src.startsWith("~")) return undefined
|
|
119
|
+
if (SCHEME.test(src)) {
|
|
120
|
+
if (!/^file:/i.test(src)) return undefined
|
|
121
|
+
try {
|
|
122
|
+
const url = new URL(src)
|
|
123
|
+
if (url.hostname !== "" && url.hostname !== "localhost") return undefined
|
|
124
|
+
const path = fileURLToPath(url)
|
|
125
|
+
return path.includes("\u0000") ? undefined : path
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return resolve(directory, src)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const TOUCHING_TOOLS = new Set(["read", "write", "edit"])
|
|
134
|
+
const PATH_KEYS = ["filePath", "path", "file_path"] as const
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The session's touched set: the paths of its own completed read/write/edit calls and the names of those calls' file
|
|
138
|
+
* outputs (the file actually read). A denied, failed or unfinished call touches nothing; other tools (bash, MCP)
|
|
139
|
+
* never widen disk access (their own file outputs are served by their t<part>.<item> refs).
|
|
140
|
+
*/
|
|
141
|
+
export function touchedPaths(messages: readonly unknown[], directory: string): string[] {
|
|
142
|
+
const out = new Set<string>()
|
|
143
|
+
const add = (value: unknown) => {
|
|
144
|
+
const path = typeof value === "string" ? sourcePath(value, directory) : undefined
|
|
145
|
+
if (path) out.add(path)
|
|
146
|
+
}
|
|
147
|
+
for (const raw of messages) {
|
|
148
|
+
const message = record(raw)
|
|
149
|
+
if (message.type !== "assistant" || !Array.isArray(message.content)) continue
|
|
150
|
+
for (const part of message.content) {
|
|
151
|
+
const state = record(record(part).state)
|
|
152
|
+
if (part.type !== "tool" || state.status !== "completed" || !TOUCHING_TOOLS.has(String(part.name ?? "").toLowerCase())) continue
|
|
153
|
+
const input = record(state.input)
|
|
154
|
+
for (const key of PATH_KEYS) add(input[key])
|
|
155
|
+
if (Array.isArray(state.content)) for (const item of state.content) if (record(item).type === "file") add(record(item).name)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return [...out]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A served path: its real path and the open file's device and inode. */
|
|
162
|
+
export type PathCheck = { readonly path: string; readonly dev: number; readonly ino: number } | { readonly refused: "path" }
|
|
163
|
+
|
|
164
|
+
const REFUSED = { refused: "path" } as const
|
|
165
|
+
|
|
166
|
+
/** Never follow a final symlink, never block (a FIFO opens at once and is refused as not a regular file). */
|
|
167
|
+
const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* macOS 11+ (<sys/fcntl.h>): open(2) fails with ELOOP when any component of the path is a symlink, not only the last.
|
|
171
|
+
* Node does not export it. Older macOS refuses it with EINVAL (then the Linux order below is used, without /proc).
|
|
172
|
+
*/
|
|
173
|
+
const O_NOFOLLOW_ANY = 0x20000000
|
|
174
|
+
|
|
175
|
+
/** The file system calls and platform openChecked uses; tests replace them to change the tree between lookups. */
|
|
176
|
+
export interface MediaFs {
|
|
177
|
+
readonly realpath?: typeof realpath
|
|
178
|
+
readonly open?: (path: string, flags: number) => Promise<FileHandle>
|
|
179
|
+
readonly readlink?: (path: string) => Promise<string>
|
|
180
|
+
readonly platform?: NodeJS.Platform
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
type Fs = Required<MediaFs>
|
|
184
|
+
|
|
185
|
+
export interface MediaPathInput {
|
|
186
|
+
readonly src: string
|
|
187
|
+
readonly text: string
|
|
188
|
+
readonly directory: string
|
|
189
|
+
readonly touched: readonly string[]
|
|
190
|
+
/** For tests: the file system calls and platform to use (defaults: node:fs/promises, process.platform). */
|
|
191
|
+
readonly fs?: MediaFs
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Whether a real path is under the real directory with no dot segment (but a leading .worktrees). */
|
|
195
|
+
function insideClean(real: string, realDir: string): boolean {
|
|
196
|
+
const rel = relative(realDir, real)
|
|
197
|
+
const inside = rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
|
|
198
|
+
return inside && !rel.split(sep).some((segment, index) => segment.startsWith(".") && !(index === 0 && segment === ".worktrees"))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Whether a real path may be served: under the directory (no dot segment but a leading .worktrees), or touched. */
|
|
202
|
+
async function allowed(real: string, input: PolicyContext, resolveReal: typeof realpath): Promise<boolean> {
|
|
203
|
+
const realDir = await resolveReal(input.directory).catch(() => undefined)
|
|
204
|
+
if (!realDir) return false
|
|
205
|
+
if (insideClean(real, realDir)) return true
|
|
206
|
+
if (input.touchedReal) return input.touchedReal.has(real)
|
|
207
|
+
const reals = await Promise.all(input.touched.map((p) => resolveReal(resolve(input.directory, p)).catch(() => undefined)))
|
|
208
|
+
return reals.includes(real)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The context with the touched set's real paths resolved once (and the directory's), for callers that check many
|
|
213
|
+
* paths (a preview bundle walk): allowed() then looks them up instead of resolving every touched path per check.
|
|
214
|
+
*/
|
|
215
|
+
export async function resolvedPolicy(input: PolicyContext): Promise<PolicyContext> {
|
|
216
|
+
const resolveReal = input.fs?.realpath ?? realpath
|
|
217
|
+
const reals = await Promise.all(input.touched.map((p) => resolveReal(resolve(input.directory, p)).catch(() => undefined)))
|
|
218
|
+
const realDir = await resolveReal(input.directory).catch(() => undefined)
|
|
219
|
+
return { ...input, touchedReal: new Set(reals.filter((r): r is string => r !== undefined)), ...(realDir ? { realDir } : {}) }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Whether an absolute path that cannot be opened is missing where the policy would allow it: nothing is at `path`,
|
|
224
|
+
* and its nearest existing ancestor's real path plus the rest of `path` lies under the directory with no dot segment.
|
|
225
|
+
* Anything else (outside, a dot segment, a symlinked directory leaving the workspace, a missing touched file) is not
|
|
226
|
+
* told apart from a refusal, so a preview never reveals whether a file outside the policy exists. At most `maxSteps`
|
|
227
|
+
* ancestors are looked up.
|
|
228
|
+
*/
|
|
229
|
+
export async function missingAllowed(path: string, input: PolicyContext, maxSteps = 64): Promise<boolean> {
|
|
230
|
+
const resolveReal = input.fs?.realpath ?? realpath
|
|
231
|
+
const realDir = input.realDir ?? (await resolveReal(input.directory).catch(() => undefined))
|
|
232
|
+
if (!realDir) return false
|
|
233
|
+
const gone = (e: NodeJS.ErrnoException) => e?.code === "ENOENT"
|
|
234
|
+
if (!(await lstat(path).then(() => false, gone))) return false
|
|
235
|
+
let ancestor = dirname(path)
|
|
236
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
237
|
+
const info = await lstat(ancestor).catch((e: NodeJS.ErrnoException) => (gone(e) ? undefined : null))
|
|
238
|
+
if (info === null) return false
|
|
239
|
+
if (info) {
|
|
240
|
+
if (!info.isDirectory() && !info.isSymbolicLink()) return false
|
|
241
|
+
const real = await resolveReal(ancestor).catch(() => undefined)
|
|
242
|
+
return real !== undefined && insideClean(join(real, relative(ancestor, path)), realDir)
|
|
243
|
+
}
|
|
244
|
+
const up = dirname(ancestor)
|
|
245
|
+
if (up === ancestor) return false
|
|
246
|
+
ancestor = up
|
|
247
|
+
}
|
|
248
|
+
return false
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
type Opened = { path: string; handle: FileHandle; info: Stats }
|
|
252
|
+
|
|
253
|
+
/** Where a policy check runs: the session's directory and touched set (and, for tests, the file system calls). */
|
|
254
|
+
export type PolicyContext = Pick<MediaPathInput, "directory" | "touched" | "fs"> & {
|
|
255
|
+
/** The touched set's real paths, resolved once (resolvedPolicy). */
|
|
256
|
+
readonly touchedReal?: ReadonlySet<string>
|
|
257
|
+
/** The directory's real path, resolved once (resolvedPolicy; missingAllowed only, allowed() resolves it per check). */
|
|
258
|
+
readonly realDir?: string
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Opens the image source `src` of a message whose text is `text` when it may be served. A process in the workspace
|
|
263
|
+
* may swap files and directories between lookups, so the open file itself is checked (see openAllowed).
|
|
264
|
+
* The caller reads only from the returned handle and closes it.
|
|
265
|
+
*/
|
|
266
|
+
async function openChecked(input: MediaPathInput): Promise<Opened | typeof REFUSED> {
|
|
267
|
+
if (!markdownImageSources(input.text).includes(input.src)) return REFUSED
|
|
268
|
+
const path = sourcePath(input.src, input.directory)
|
|
269
|
+
if (!path) return REFUSED
|
|
270
|
+
return openAllowed(path, input)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Opens the absolute `path` when the path policy allows it (rules 2–4 above; whoever calls checks rule 1, that the
|
|
275
|
+
* message names it), in the platform's order below. The caller reads only from the returned handle and closes it.
|
|
276
|
+
*/
|
|
277
|
+
export async function openAllowed(path: string, input: PolicyContext): Promise<Opened | typeof REFUSED> {
|
|
278
|
+
const fs: Fs = {
|
|
279
|
+
realpath: input.fs?.realpath ?? realpath,
|
|
280
|
+
open: input.fs?.open ?? ((p, flags) => open(p, flags)),
|
|
281
|
+
readlink: input.fs?.readlink ?? ((p) => readlink(p)),
|
|
282
|
+
platform: input.fs?.platform ?? process.platform,
|
|
283
|
+
}
|
|
284
|
+
if (fs.platform === "darwin") {
|
|
285
|
+
const opened = await openRealNoSymlinks(path, input, fs)
|
|
286
|
+
if (opened !== "unsupported") return opened
|
|
287
|
+
}
|
|
288
|
+
return openThenCheck(path, input, fs)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* macOS: realpath first, the policy on the real path, then open exactly that path with O_NOFOLLOW_ANY (no symlink
|
|
293
|
+
* anywhere in it, so no swapped parent directory can redirect the open), a regular file with the real path's device
|
|
294
|
+
* and inode. "unsupported" when the kernel does not know the flag.
|
|
295
|
+
*/
|
|
296
|
+
async function openRealNoSymlinks(path: string, input: PolicyContext, fs: Fs): Promise<Opened | typeof REFUSED | "unsupported"> {
|
|
297
|
+
// A final symlink is refused as on the other platforms (O_NOFOLLOW there); not for safety, the real path is checked.
|
|
298
|
+
if ((await lstat(path).catch(() => undefined))?.isSymbolicLink()) return REFUSED
|
|
299
|
+
const real = await fs.realpath(path).catch(() => undefined)
|
|
300
|
+
if (!real || !(await allowed(real, input, fs.realpath))) return REFUSED
|
|
301
|
+
let handle: FileHandle
|
|
302
|
+
try {
|
|
303
|
+
handle = await fs.open(real, OPEN_FLAGS | O_NOFOLLOW_ANY)
|
|
304
|
+
} catch (error) {
|
|
305
|
+
return (error as NodeJS.ErrnoException)?.code === "EINVAL" ? "unsupported" : REFUSED
|
|
306
|
+
}
|
|
307
|
+
return identified(real, handle)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Linux, Windows and the rest: the requested path is opened first, without following a final symlink and without
|
|
312
|
+
* blocking, and must be a regular file; only then is its realpath checked against the policy, and the realpath's
|
|
313
|
+
* device and inode must be the open file's. On Linux the kernel's path of the open file (/proc/self/fd) must also be
|
|
314
|
+
* the realpath, and a failing readlink refuses. Elsewhere there is no such path: on Windows (no O_NOFOLLOW, no
|
|
315
|
+
* O_NOFOLLOW_ANY) a parent directory swapped three times (a symlink to a file outside when opened, the directory
|
|
316
|
+
* during realpath, the symlink again for the identity check) can still serve a file outside the workspace; so can
|
|
317
|
+
* macOS before 11. Bun itself needs macOS 13+, where the O_NOFOLLOW_ANY order above applies.
|
|
318
|
+
*/
|
|
319
|
+
async function openThenCheck(path: string, input: PolicyContext, fs: Fs): Promise<Opened | typeof REFUSED> {
|
|
320
|
+
const handle = await fs.open(path, OPEN_FLAGS).catch(() => undefined)
|
|
321
|
+
if (!handle) return REFUSED
|
|
322
|
+
const real = await checkedReal(path, handle, input, fs).catch(() => undefined)
|
|
323
|
+
if (!real) {
|
|
324
|
+
await handle.close().catch(() => {})
|
|
325
|
+
return REFUSED
|
|
326
|
+
}
|
|
327
|
+
return identified(real, handle)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The realpath of the opened `path` when the policy allows it (and, on Linux, /proc names it for the open file). */
|
|
331
|
+
async function checkedReal(path: string, handle: FileHandle, input: PolicyContext, fs: Fs): Promise<string | undefined> {
|
|
332
|
+
if (!(await handle.stat()).isFile()) return undefined
|
|
333
|
+
const real = await fs.realpath(path).catch(() => undefined)
|
|
334
|
+
if (!real || !(await allowed(real, input, fs.realpath))) return undefined
|
|
335
|
+
if (fs.platform === "linux" && (await fs.readlink(`/proc/self/fd/${handle.fd}`).catch(() => undefined)) !== real) return undefined
|
|
336
|
+
return real
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The open file when it is regular and `real` names it now (same device and inode); else closed and refused. */
|
|
340
|
+
async function identified(real: string, handle: FileHandle): Promise<Opened | typeof REFUSED> {
|
|
341
|
+
try {
|
|
342
|
+
const info = await handle.stat()
|
|
343
|
+
const named = await stat(real).catch(() => undefined)
|
|
344
|
+
if (info.isFile() && named && named.dev === info.dev && named.ino === info.ino) return { path: real, handle, info }
|
|
345
|
+
} catch {}
|
|
346
|
+
await handle.close().catch(() => {})
|
|
347
|
+
return REFUSED
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Whether the image source `src` may be served (its real path and the file's identity), else refused. */
|
|
351
|
+
export async function checkMediaPath(input: MediaPathInput): Promise<PathCheck> {
|
|
352
|
+
const opened = await openChecked(input)
|
|
353
|
+
if ("refused" in opened) return opened
|
|
354
|
+
await opened.handle.close()
|
|
355
|
+
return { path: opened.path, dev: opened.info.dev, ino: opened.info.ino }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The bytes of the image source `src` (openChecked), read only from the checked handle and never more than `max`:
|
|
360
|
+
* `tooLarge` with the size seen when it is (or grows) larger; refused when it changes size while it is read.
|
|
361
|
+
*/
|
|
362
|
+
export async function readMediaPath(input: MediaPathInput & { readonly max: number }): Promise<ReadResult> {
|
|
363
|
+
const opened = await openChecked(input)
|
|
364
|
+
return "refused" in opened ? opened : readOpened(opened, input.max)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export type ReadResult = { path: string; bytes: Uint8Array } | { tooLarge: number } | typeof REFUSED
|
|
368
|
+
|
|
369
|
+
/** The bytes of the absolute `path` when the path policy allows it (openAllowed), as readMediaPath reads them. */
|
|
370
|
+
export async function readAllowedPath(path: string, input: PolicyContext & { readonly max: number }): Promise<ReadResult> {
|
|
371
|
+
const opened = await openAllowed(path, input)
|
|
372
|
+
return "refused" in opened ? opened : readOpened(opened, input.max)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function readOpened({ handle, info, path }: Opened, max: number): Promise<ReadResult> {
|
|
376
|
+
try {
|
|
377
|
+
if (info.size > max) return { tooLarge: info.size }
|
|
378
|
+
// One byte more than the file had: reading it means the file grew while it was read.
|
|
379
|
+
const buffer = new Uint8Array(info.size + 1)
|
|
380
|
+
let length = 0
|
|
381
|
+
while (length < buffer.length) {
|
|
382
|
+
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length)
|
|
383
|
+
if (bytesRead === 0) break
|
|
384
|
+
length += bytesRead
|
|
385
|
+
}
|
|
386
|
+
if (length !== info.size) {
|
|
387
|
+
const now = (await handle.stat()).size
|
|
388
|
+
return now > max ? { tooLarge: now } : REFUSED
|
|
389
|
+
}
|
|
390
|
+
return { path, bytes: buffer.subarray(0, length) }
|
|
391
|
+
} finally {
|
|
392
|
+
await handle.close()
|
|
393
|
+
}
|
|
394
|
+
}
|