brane 0.0.2 → 0.3.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.
Files changed (338) hide show
  1. package/AGENTS.md +362 -0
  2. package/CHANGELOG.md +33 -0
  3. package/CONTRIBUTING.md +91 -0
  4. package/DESIGN.md +402 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +205 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +116 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +332 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1094 -0
  37. package/dist/commands/token.js +153 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6349 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +633 -0
  58. package/dist/db/tokens.js +583 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/block-glyphs.js +114 -0
  96. package/dist/shared/diagram-css.js +17 -0
  97. package/dist/shared/export-selection.js +101 -0
  98. package/dist/shared/find-in-text.js +110 -0
  99. package/dist/shared/glow-hues.js +225 -0
  100. package/dist/shared/hex-dump.js +62 -0
  101. package/dist/shared/hex-pages.js +52 -0
  102. package/dist/shared/hit-drum.js +79 -0
  103. package/dist/shared/known-nids.js +58 -0
  104. package/dist/shared/list-scroll-keys.js +56 -0
  105. package/dist/shared/markdown-lists.js +72 -0
  106. package/dist/shared/markdown-math.js +485 -0
  107. package/dist/shared/markdown-mentions.js +79 -0
  108. package/dist/shared/markdown-nids.js +41 -0
  109. package/dist/shared/markdown-protocols.js +53 -0
  110. package/dist/shared/markdown-refs.js +123 -0
  111. package/dist/shared/membrane-invasion.js +342 -0
  112. package/dist/shared/membrane-motion.js +83 -0
  113. package/dist/shared/membrane-palette.js +253 -0
  114. package/dist/shared/membrane-terrain.js +212 -0
  115. package/dist/shared/message-selection.js +123 -0
  116. package/dist/shared/mo-glyph.js +57 -0
  117. package/dist/shared/narrative-format.js +237 -0
  118. package/dist/shared/narrative-menu.js +82 -0
  119. package/dist/shared/narrative-sort.js +47 -0
  120. package/dist/shared/narrative-status.js +52 -0
  121. package/dist/shared/no-pan.js +85 -0
  122. package/dist/shared/pane-layout.js +50 -0
  123. package/dist/shared/payload-inline.js +83 -0
  124. package/dist/shared/speaker-color.js +61 -0
  125. package/dist/shared/token-rotation.js +14 -0
  126. package/dist/shared/view-url.js +197 -0
  127. package/dist/shared/wiki-links.js +70 -0
  128. package/dist/shared/wiki-window.js +4 -0
  129. package/dist/tailscale.js +55 -0
  130. package/dist/test/absorb-claude.test.js +892 -0
  131. package/dist/test/agent-scope.test.js +193 -0
  132. package/dist/test/agents.test.js +55 -0
  133. package/dist/test/api-request.test.js +122 -0
  134. package/dist/test/awaiting-glow.test.js +54 -0
  135. package/dist/test/awaiting.test.js +122 -0
  136. package/dist/test/bind-token.test.js +88 -0
  137. package/dist/test/block-glyphs.test.js +68 -0
  138. package/dist/test/cert-manager.test.js +262 -0
  139. package/dist/test/claude-live-session.test.js +110 -0
  140. package/dist/test/codex-tool-log.test.js +568 -0
  141. package/dist/test/config.test.js +71 -0
  142. package/dist/test/copilot-tool-log.test.js +93 -0
  143. package/dist/test/cors-preflight.test.js +49 -0
  144. package/dist/test/cost-linger.test.js +31 -0
  145. package/dist/test/cost-pricing.test.js +72 -0
  146. package/dist/test/cost-reprice.test.js +139 -0
  147. package/dist/test/cost-sessions.test.js +301 -0
  148. package/dist/test/crush-tool-log.test.js +289 -0
  149. package/dist/test/daemon-client.test.js +109 -0
  150. package/dist/test/daemon-socket.test.js +293 -0
  151. package/dist/test/diagram-arrows.test.js +205 -0
  152. package/dist/test/diagram-export.test.js +210 -0
  153. package/dist/test/diagram-labels.test.js +313 -0
  154. package/dist/test/diagram-parse.test.js +191 -0
  155. package/dist/test/diagram-render.test.js +175 -0
  156. package/dist/test/diagram-review.test.js +288 -0
  157. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  158. package/dist/test/embedder.test.js +84 -0
  159. package/dist/test/events.test.js +123 -0
  160. package/dist/test/export-selection.test.js +102 -0
  161. package/dist/test/export.test.js +505 -0
  162. package/dist/test/files-browse.test.js +393 -0
  163. package/dist/test/files-find.test.js +272 -0
  164. package/dist/test/find-in-text.test.js +86 -0
  165. package/dist/test/first-paint-viewport.test.js +59 -0
  166. package/dist/test/fixtures/embedding-golden.js +23 -0
  167. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  168. package/dist/test/glow-hues.test.js +202 -0
  169. package/dist/test/harness-cost.test.js +423 -0
  170. package/dist/test/helpers.js +21 -0
  171. package/dist/test/hex-pages.test.js +68 -0
  172. package/dist/test/highlight.test.js +69 -0
  173. package/dist/test/hit-drum.test.js +71 -0
  174. package/dist/test/hook-config.test.js +262 -0
  175. package/dist/test/json-gzip.test.js +70 -0
  176. package/dist/test/key-routing.test.js +58 -0
  177. package/dist/test/known-nids.test.js +106 -0
  178. package/dist/test/link-preview.test.js +67 -0
  179. package/dist/test/list-scroll-keys.test.js +66 -0
  180. package/dist/test/listen-own-words.test.js +147 -0
  181. package/dist/test/listen-timeout.test.js +33 -0
  182. package/dist/test/markdown-diagnostics.test.js +121 -0
  183. package/dist/test/markdown-lists.test.js +182 -0
  184. package/dist/test/markdown-math.test.js +256 -0
  185. package/dist/test/markdown-nids.test.js +146 -0
  186. package/dist/test/markdown-protocols.test.js +139 -0
  187. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  188. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  189. package/dist/test/mcp-narrative.test.js +193 -0
  190. package/dist/test/mcp.test.js +548 -0
  191. package/dist/test/meld.test.js +85 -0
  192. package/dist/test/membrane-invasion.test.js +482 -0
  193. package/dist/test/membrane-motion.test.js +101 -0
  194. package/dist/test/membrane-palette.test.js +166 -0
  195. package/dist/test/membrane-terrain.test.js +170 -0
  196. package/dist/test/memory-log.test.js +202 -0
  197. package/dist/test/mentions-e2e.test.js +261 -0
  198. package/dist/test/mentions-ledger.test.js +265 -0
  199. package/dist/test/mentions-listen.test.js +265 -0
  200. package/dist/test/mentions.test.js +238 -0
  201. package/dist/test/message-selection.test.js +114 -0
  202. package/dist/test/mo-glyph.test.js +22 -0
  203. package/dist/test/model-resolution.test.js +166 -0
  204. package/dist/test/narrate-validate.test.js +77 -0
  205. package/dist/test/narrate-wait.test.js +160 -0
  206. package/dist/test/narrative-format.test.js +219 -0
  207. package/dist/test/narrative-menu.test.js +137 -0
  208. package/dist/test/narrative-sort.test.js +75 -0
  209. package/dist/test/no-pan.test.js +60 -0
  210. package/dist/test/pane-layout.test.js +153 -0
  211. package/dist/test/paths.test.js +37 -0
  212. package/dist/test/payload-inline.test.js +91 -0
  213. package/dist/test/precache-fonts.test.js +72 -0
  214. package/dist/test/presence-gate.test.js +440 -0
  215. package/dist/test/presence.test.js +160 -0
  216. package/dist/test/read-events.test.js +220 -0
  217. package/dist/test/remote-meld.test.js +115 -0
  218. package/dist/test/revive-cli.test.js +93 -0
  219. package/dist/test/search-floor.test.js +206 -0
  220. package/dist/test/search-fusion.test.js +168 -0
  221. package/dist/test/search.test.js +205 -0
  222. package/dist/test/security.test.js +48 -0
  223. package/dist/test/selection-wiring.test.js +61 -0
  224. package/dist/test/session-affinity.test.js +295 -0
  225. package/dist/test/signal-source.test.js +32 -0
  226. package/dist/test/socket-account.test.js +268 -0
  227. package/dist/test/spawn-toml.test.js +72 -0
  228. package/dist/test/speaker-color.test.js +90 -0
  229. package/dist/test/tailscale-rebind.test.js +79 -0
  230. package/dist/test/terminal-cursor.test.js +61 -0
  231. package/dist/test/terminal-resync.test.js +23 -0
  232. package/dist/test/thin-cli.test.js +382 -0
  233. package/dist/test/token-bootstrap.test.js +272 -0
  234. package/dist/test/token-devices.test.js +587 -0
  235. package/dist/test/token-lapse.test.js +94 -0
  236. package/dist/test/token-ring.test.js +188 -0
  237. package/dist/test/token-rotate-cli.test.js +58 -0
  238. package/dist/test/token-routes.test.js +95 -0
  239. package/dist/test/tool-uses.test.js +1006 -0
  240. package/dist/test/usage-ledger.test.js +126 -0
  241. package/dist/test/view-url.test.js +581 -0
  242. package/dist/test/wiki-links.test.js +57 -0
  243. package/dist/test/wiki-sources.test.js +85 -0
  244. package/dist/test/wiki-window.test.js +31 -0
  245. package/dist/test/wiki.test.js +399 -0
  246. package/dist/test/wordpiece.test.js +68 -0
  247. package/dist/tls.js +148 -0
  248. package/dist/tmux.js +202 -0
  249. package/dist/user-agent.js +46 -0
  250. package/docker-entrypoint.sh +141 -0
  251. package/docs/README.md +3 -0
  252. package/docs/absorb.md +231 -0
  253. package/docs/cost-ledger.md +125 -0
  254. package/docs/daemon-api.md +189 -0
  255. package/docs/diagrams.md +163 -0
  256. package/docs/doctor.md +36 -0
  257. package/docs/export-scope.md +69 -0
  258. package/docs/files-view.md +133 -0
  259. package/docs/link-previews.md +38 -0
  260. package/docs/math.md +32 -0
  261. package/docs/mcp.md +69 -0
  262. package/docs/memory-search.md +75 -0
  263. package/docs/mentions.md +101 -0
  264. package/docs/multi-brane.md +14 -0
  265. package/docs/passkeys.md +165 -0
  266. package/docs/presence.md +202 -0
  267. package/docs/releasing.md +53 -0
  268. package/docs/rendering-warnings.md +22 -0
  269. package/docs/request-layer.md +72 -0
  270. package/docs/selection-store.md +50 -0
  271. package/docs/session-affinity.md +61 -0
  272. package/docs/terminal-mirroring.md +211 -0
  273. package/docs/token-rotation.md +413 -0
  274. package/docs/view-urls.md +114 -0
  275. package/docs/web-app.md +23 -0
  276. package/docs/wiki.md +43 -0
  277. package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
  278. package/expo/dist/apple-touch-icon.png +0 -0
  279. package/expo/dist/favicon.svg +3 -0
  280. package/expo/dist/index.html +177 -0
  281. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  282. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  283. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  284. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  285. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  301. package/expo/dist/katex/katex.min.css +1 -0
  302. package/expo/dist/manifest.json +16 -0
  303. package/expo/dist/metadata.json +1 -0
  304. package/expo/dist/og/membrane.v1.png +0 -0
  305. package/expo/dist/pdf.min.mjs +28 -0
  306. package/expo/dist/pdf.worker.min.mjs +28 -0
  307. package/expo/dist/sw.js +154 -0
  308. package/package.json +81 -23
  309. package/protocols/LIVE_loop.md +407 -0
  310. package/protocols/bebrief.md +41 -0
  311. package/protocols/digest.md +231 -0
  312. package/protocols/fly.md +266 -0
  313. package/protocols/idle.md +81 -0
  314. package/protocols/keeper.md +829 -0
  315. package/protocols/markdown.md +80 -0
  316. package/protocols/memory.md +419 -0
  317. package/protocols/mentions.md +93 -0
  318. package/protocols/payloads.md +53 -0
  319. package/protocols/prelude.md +91 -0
  320. package/protocols/ramify.md +105 -0
  321. package/protocols/review.md +356 -0
  322. package/protocols/rrr.md +232 -0
  323. package/protocols/scribe.md +429 -0
  324. package/protocols/summarize.md +203 -0
  325. package/protocols/twin.md +174 -0
  326. package/protocols/wiki.md +438 -0
  327. package/protocols/worker.md +38 -0
  328. package/wiki/shared/append-only-event-log.md +148 -0
  329. package/wiki/shared/index.md +48 -0
  330. package/wiki/shared/owning-your-narratives.md +161 -0
  331. package/wiki/shared/premature-ontologies.md +435 -0
  332. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  333. package/wiki/shared/sequestering-the-narrative.md +143 -0
  334. package/wiki/shared/why-wikis-work.md +216 -0
  335. package/.npmignore +0 -2
  336. package/.travis.yml +0 -5
  337. package/index.js +0 -8
  338. package/index.js~ +0 -12
@@ -0,0 +1,393 @@
1
+ import { describe, it, before } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { brotliCompressSync, deflateRawSync, gzipSync } from "node:zlib";
7
+ import { browseFold, parseOffsetParam, MAX_INLINE } from "../files/browse.js";
8
+ import { ReachError, streamSource } from "../files/bytes.js";
9
+ import { Readable } from "node:stream";
10
+ import { HEX_PAGE_BYTES, hexDumpRows, hexRowColumns, looksBinary } from "../shared/hex-dump.js";
11
+ import { archiveFormatFor, normalizeMemberName } from "../files/archive.js";
12
+ function crc32(buf) {
13
+ let c = ~0;
14
+ for (const b of buf) {
15
+ c ^= b;
16
+ for (let k = 0; k < 8; k++)
17
+ c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
18
+ }
19
+ return ~c >>> 0;
20
+ }
21
+ function makeZip(entries, prefix = Buffer.alloc(0)) {
22
+ const locals = [];
23
+ const centrals = [];
24
+ let offset = 0;
25
+ for (const e of entries) {
26
+ const raw = Buffer.isBuffer(e.data) ? e.data : Buffer.from(e.data ?? "");
27
+ const body = e.deflate ? deflateRawSync(raw) : raw;
28
+ const name = Buffer.from(e.name, "utf8");
29
+ const flags = (e.encrypted ? 1 : 0) | 0x800;
30
+ const method = e.deflate ? 8 : 0;
31
+ const crc = crc32(raw);
32
+ const local = Buffer.alloc(30);
33
+ local.writeUInt32LE(0x04034b50, 0);
34
+ local.writeUInt16LE(20, 4);
35
+ local.writeUInt16LE(flags, 6);
36
+ local.writeUInt16LE(method, 8);
37
+ local.writeUInt16LE(0, 10);
38
+ local.writeUInt16LE((46 << 9) | (1 << 5) | 1, 12); // 2026-01-01
39
+ local.writeUInt32LE(crc, 14);
40
+ local.writeUInt32LE(body.length, 18);
41
+ local.writeUInt32LE(raw.length, 22);
42
+ local.writeUInt16LE(name.length, 26);
43
+ local.writeUInt16LE(0, 28);
44
+ locals.push(local, name, body);
45
+ const central = Buffer.alloc(46);
46
+ central.writeUInt32LE(0x02014b50, 0);
47
+ central.writeUInt16LE(e.mode !== undefined ? (3 << 8) | 20 : 20, 4);
48
+ central.writeUInt16LE(20, 6);
49
+ central.writeUInt16LE(flags, 8);
50
+ central.writeUInt16LE(method, 10);
51
+ central.writeUInt16LE(0, 12);
52
+ central.writeUInt16LE((46 << 9) | (1 << 5) | 1, 14);
53
+ central.writeUInt32LE(crc, 16);
54
+ central.writeUInt32LE(body.length, 20);
55
+ central.writeUInt32LE(raw.length, 24);
56
+ central.writeUInt16LE(name.length, 28);
57
+ central.writeUInt32LE(((e.mode ?? 0) << 16) >>> 0, 38);
58
+ central.writeUInt32LE(offset, 42);
59
+ centrals.push(central, name);
60
+ offset += 30 + name.length + body.length;
61
+ }
62
+ const cd = Buffer.concat(centrals);
63
+ const eocd = Buffer.alloc(22);
64
+ eocd.writeUInt32LE(0x06054b50, 0);
65
+ eocd.writeUInt16LE(entries.length, 8);
66
+ eocd.writeUInt16LE(entries.length, 10);
67
+ eocd.writeUInt32LE(cd.length, 12);
68
+ eocd.writeUInt32LE(offset, 16);
69
+ return Buffer.concat([prefix, ...locals, cd, eocd]);
70
+ }
71
+ function tarHeader(name, size, type, link = "") {
72
+ const h = Buffer.alloc(512);
73
+ h.write(name.slice(0, 100), 0, "utf8");
74
+ h.write("0000644\0", 100);
75
+ h.write("0000000\0", 108);
76
+ h.write("0000000\0", 116);
77
+ h.write(size.toString(8).padStart(11, "0") + "\0", 124);
78
+ h.write("15107000000\0", 136);
79
+ h.write(" ", 148);
80
+ h.write(type, 156);
81
+ h.write(link, 157);
82
+ h.write("ustar\0", 257);
83
+ h.write("00", 263);
84
+ let sum = 0;
85
+ for (const b of h)
86
+ sum += b;
87
+ h.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
88
+ return h;
89
+ }
90
+ function pad(buf) {
91
+ const rem = buf.length % 512;
92
+ return rem ? Buffer.concat([buf, Buffer.alloc(512 - rem)]) : buf;
93
+ }
94
+ function makeTar(entries) {
95
+ const parts = [];
96
+ for (const e of entries) {
97
+ if (e.paxPath) {
98
+ const rec = (len) => `${len} path=${e.paxPath}\n`;
99
+ let len = rec(0).length;
100
+ while (rec(len).length !== len)
101
+ len = rec(len).length;
102
+ const body = Buffer.from(rec(len));
103
+ parts.push(tarHeader("PaxHeader", body.length, "x"), pad(body));
104
+ }
105
+ const data = Buffer.isBuffer(e.data) ? e.data : Buffer.from(e.data ?? "");
106
+ parts.push(tarHeader(e.name, data.length, e.type ?? "0", e.link), pad(data));
107
+ }
108
+ parts.push(Buffer.alloc(1024));
109
+ return Buffer.concat(parts);
110
+ }
111
+ // ── Fixture fold ────────────────────────────────────────────────────────────
112
+ let fold;
113
+ const MIME = { ".png": "image/png", ".pdf": "application/pdf" };
114
+ const browse = (rel, opts = {}) => browseFold(fold, rel, { mimeTypes: MIME, ...opts });
115
+ function json(r) {
116
+ assert.ok("json" in r, "expected a JSON answer");
117
+ assert.equal(r.status, 200, JSON.stringify(r.json));
118
+ return r.json;
119
+ }
120
+ function asFile(r) {
121
+ const j = json(r);
122
+ assert.equal(j.type, "file");
123
+ return j;
124
+ }
125
+ function asDir(r) {
126
+ const j = json(r);
127
+ assert.equal(j.type, "directory");
128
+ return j;
129
+ }
130
+ async function rawBytes(r) {
131
+ assert.ok("raw" in r, "expected a raw answer");
132
+ const parts = [];
133
+ for await (const c of r.raw.stream)
134
+ parts.push(c);
135
+ return Buffer.concat(parts);
136
+ }
137
+ const ELF = Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]), Buffer.alloc(40000, 0xab)]);
138
+ before(() => {
139
+ fold = mkdtempSync(join(tmpdir(), "brane-files-"));
140
+ mkdirSync(join(fold, "dir"));
141
+ writeFileSync(join(fold, "dir", "hello.ts"), "export const x = 1;\n");
142
+ writeFileSync(join(fold, "prog.bin"), ELF);
143
+ // Bigger than the inline limit, and binary: paged, never "too large".
144
+ writeFileSync(join(fold, "huge.bin"), Buffer.concat([Buffer.from([0]), Buffer.alloc(MAX_INLINE + 10, 1)]));
145
+ writeFileSync(join(fold, "pkg.zip"), makeZip([
146
+ { name: "README.md", data: "# pkg\n" },
147
+ { name: "src/main.ts", data: "const y: number = 2;\n".repeat(50), deflate: true },
148
+ { name: "src/deep/", data: "" },
149
+ { name: "bin/tool", data: ELF, deflate: true },
150
+ { name: "link", data: "src/main.ts", mode: 0o120777 },
151
+ ]));
152
+ writeFileSync(join(fold, "evil.zip"), makeZip([
153
+ { name: "../escape.txt", data: "nope" },
154
+ { name: "/etc/passwd", data: "root:x:0:0\n" },
155
+ { name: "a\\..\\..\\b.txt", data: "nope" },
156
+ { name: "ok.txt", data: "fine" },
157
+ { name: "secret.txt", data: "xxxx", encrypted: true },
158
+ ]));
159
+ // 64 MB of zeros that deflate to ~64 KB: the listing is instant, and a
160
+ // read of the member produces one page, never the whole thing.
161
+ writeFileSync(join(fold, "bomb.zip"), makeZip([{ name: "zeros", data: Buffer.alloc(64 * 1024 * 1024), deflate: true }]));
162
+ // A self-extractor's stub before the zip proper: every offset is short.
163
+ writeFileSync(join(fold, "sfx.zip"), makeZip([{ name: "inside.txt", data: "found me" }], Buffer.alloc(1000, 0x90)));
164
+ writeFileSync(join(fold, "fake.zip"), "this is not a zip at all\n");
165
+ const tar = makeTar([
166
+ { name: "./", type: "5" },
167
+ { name: "top.txt", data: "top\n" },
168
+ { name: "._top.txt", data: "AppleDouble" },
169
+ { name: "nested/dir/", type: "5" },
170
+ { name: "nested/dir/n.py", data: "print('hi')\n" },
171
+ { name: "short", paxPath: `${"long-".repeat(30)}name.txt`, data: "long named\n" },
172
+ { name: "sym", type: "2", link: "top.txt" },
173
+ { name: "hard", type: "1", link: "top.txt" },
174
+ { name: "../../etc/evil", data: "nope" },
175
+ ]);
176
+ writeFileSync(join(fold, "t.tar"), tar);
177
+ writeFileSync(join(fold, "t.tar.gz"), gzipSync(tar));
178
+ writeFileSync(join(fold, "t.tar.br"), brotliCompressSync(tar));
179
+ writeFileSync(join(fold, "notes.md.gz"), gzipSync("# compressed\n"));
180
+ writeFileSync(join(fold, "code.ts.gz"), gzipSync("let z = 3;\n"));
181
+ writeFileSync(join(fold, "junk.tar"), "not a tarball, just text that is long enough ".repeat(20));
182
+ });
183
+ // ── Hex ─────────────────────────────────────────────────────────────────────
184
+ describe("hex dump rules", () => {
185
+ it("calls a file binary on a NUL in its first 8000 bytes, as git does", () => {
186
+ assert.equal(looksBinary(Buffer.from("plain text\n")), false);
187
+ assert.equal(looksBinary(Buffer.from([0x41, 0x00, 0x42])), true);
188
+ const late = Buffer.alloc(9000, 0x41);
189
+ late[8500] = 0;
190
+ assert.equal(looksBinary(late), false);
191
+ });
192
+ it("lays rows out like xxd, numbered from the page's offset", () => {
193
+ const rows = hexDumpRows(Buffer.from("Hello, world!\n\x00\x01ABC"), 0x20);
194
+ assert.deepEqual(rows, [
195
+ "00000020 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 21 0a 00 01 Hello, world!...",
196
+ "00000030 41 42 43 ABC",
197
+ ]);
198
+ });
199
+ it("narrows to eight bytes a row for a phone, columns still aligned", () => {
200
+ const rows = hexDumpRows(Buffer.from("0123456789"), 0, 8);
201
+ assert.deepEqual(rows, [
202
+ "00000000 30 31 32 33 34 35 36 37 01234567",
203
+ "00000008 38 39 89",
204
+ ]);
205
+ assert.equal(rows[0].length, hexRowColumns(8));
206
+ assert.equal(hexDumpRows(new Uint8Array(16))[0].length, hexRowColumns(16));
207
+ });
208
+ });
209
+ describe("/api/files: plain files", () => {
210
+ it("serves text as before", async () => {
211
+ const f = asFile(await browse("dir/hello.ts"));
212
+ assert.equal(f.content, "export const x = 1;\n");
213
+ assert.equal(f.binary, undefined);
214
+ });
215
+ it("serves a binary file as its first hex page instead of mangled UTF-8", async () => {
216
+ const f = asFile(await browse("prog.bin"));
217
+ assert.equal(f.binary, true);
218
+ assert.equal(f.content, "");
219
+ assert.ok(f.hex);
220
+ assert.equal(f.hex.offset, 0);
221
+ assert.equal(f.hex.eof, false);
222
+ const page = Buffer.from(f.hex.data, "base64");
223
+ assert.equal(page.length, HEX_PAGE_BYTES);
224
+ assert.deepEqual(page.subarray(0, 4), Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
225
+ });
226
+ it("pages further in with offset=, and says when the last page is reached", async () => {
227
+ const f = asFile(await browse("prog.bin", { offset: 2 * HEX_PAGE_BYTES }));
228
+ assert.ok(f.hex);
229
+ assert.equal(f.hex.eof, true);
230
+ assert.equal(Buffer.from(f.hex.data, "base64").length, ELF.length - 2 * HEX_PAGE_BYTES);
231
+ });
232
+ it("pages a binary past the inline limit rather than calling it too large", async () => {
233
+ const f = asFile(await browse("huge.bin"));
234
+ assert.equal(f.truncated, undefined);
235
+ assert.ok(f.hex);
236
+ });
237
+ it("streams any file raw, text included, so Download works on large text", async () => {
238
+ const bytes = await rawBytes(await browse("dir/hello.ts", { raw: true }));
239
+ assert.equal(bytes.toString(), "export const x = 1;\n");
240
+ });
241
+ it("refuses paths that leave the fold", async () => {
242
+ const r = await browse("../outside");
243
+ assert.equal(r.status, 400);
244
+ assert.equal((await browse("/etc/passwd")).status, 400);
245
+ });
246
+ it("validates offset=", () => {
247
+ assert.equal(parseOffsetParam(null), null);
248
+ assert.equal(parseOffsetParam("4096"), 4096);
249
+ assert.equal(parseOffsetParam("-1"), undefined);
250
+ assert.equal(parseOffsetParam("1e3"), undefined);
251
+ assert.equal(parseOffsetParam("99999999999999999999"), undefined);
252
+ });
253
+ });
254
+ // ── Zip ─────────────────────────────────────────────────────────────────────
255
+ describe("/api/files: zip archives", () => {
256
+ it("marks archives in a directory listing", async () => {
257
+ const d = asDir(await browse(""));
258
+ const byName = new Map(d.entries.map((e) => [e.name, e]));
259
+ assert.equal(byName.get("pkg.zip")?.archive, true);
260
+ assert.equal(byName.get("t.tar.gz")?.archive, true);
261
+ assert.equal(byName.get("notes.md.gz")?.archive, undefined, "a single compressed file opens as a file");
262
+ assert.equal(byName.get("prog.bin")?.archive, undefined);
263
+ });
264
+ it("lists a zip as a directory, folders first, with implied folders", async () => {
265
+ const d = asDir(await browse("pkg.zip"));
266
+ assert.deepEqual(d.entries.map((e) => [e.name, e.is_dir]), [
267
+ ["bin", true], ["src", true], ["link", false], ["README.md", false],
268
+ ]);
269
+ assert.deepEqual(d.archive, { root: "pkg.zip", format: "zip" });
270
+ const src = asDir(await browse("pkg.zip/src"));
271
+ assert.deepEqual(src.entries.map((e) => e.name), ["deep", "main.ts"]);
272
+ assert.equal(src.path, "pkg.zip/src");
273
+ });
274
+ it("opens a deflated member with highlighting chosen by its own name", async () => {
275
+ const f = asFile(await browse("pkg.zip/src/main.ts"));
276
+ assert.equal(f.content, "const y: number = 2;\n".repeat(50));
277
+ assert.equal(f.highlight?.lang, "typescript");
278
+ assert.equal(f.archive?.root, "pkg.zip");
279
+ });
280
+ it("opens a binary member in the hex view, and streams it raw", async () => {
281
+ const f = asFile(await browse("pkg.zip/bin/tool"));
282
+ assert.ok(f.hex);
283
+ assert.deepEqual(Buffer.from(f.hex.data, "base64").subarray(0, 4), ELF.subarray(0, 4));
284
+ const bytes = await rawBytes(await browse("pkg.zip/bin/tool", { raw: true }));
285
+ assert.deepEqual(bytes, ELF);
286
+ });
287
+ it("shows a symlink member's target without following it", async () => {
288
+ const f = asFile(await browse("pkg.zip/link"));
289
+ assert.equal(f.link, "src/main.ts");
290
+ assert.equal(f.content, "");
291
+ });
292
+ it("still downloads the archive itself with raw=", async () => {
293
+ const bytes = await rawBytes(await browse("pkg.zip", { raw: true }));
294
+ assert.equal(bytes.readUInt32LE(0), 0x04034b50);
295
+ });
296
+ it("hides names that can't be placed, and says how many", async () => {
297
+ const d = asDir(await browse("evil.zip"));
298
+ assert.deepEqual(d.entries.map((e) => e.name).sort(), ["etc", "ok.txt", "secret.txt"]);
299
+ assert.equal(d.archive?.skipped, 2);
300
+ assert.equal(asFile(await browse("evil.zip/etc/passwd")).content, "root:x:0:0\n");
301
+ });
302
+ it("explains an encrypted member instead of failing", async () => {
303
+ const f = asFile(await browse("evil.zip/secret.txt"));
304
+ assert.match(f.notice ?? "", /encrypted/);
305
+ });
306
+ it("decodes only one page of a decompression bomb", async () => {
307
+ const d = asDir(await browse("bomb.zip"));
308
+ assert.equal(d.entries[0].size, 64 * 1024 * 1024, "the claimed size is displayed");
309
+ const before = process.memoryUsage().arrayBuffers;
310
+ const f = asFile(await browse("bomb.zip/zeros"));
311
+ assert.ok(f.hex);
312
+ assert.equal(Buffer.from(f.hex.data, "base64").length, HEX_PAGE_BYTES);
313
+ assert.ok(process.memoryUsage().arrayBuffers - before < 32 * 1024 * 1024);
314
+ });
315
+ it("finds a zip behind a self-extractor stub", async () => {
316
+ assert.equal(asFile(await browse("sfx.zip/inside.txt")).content, "found me");
317
+ });
318
+ it("shows a file named .zip that isn't one as the file it is", async () => {
319
+ const f = asFile(await browse("fake.zip"));
320
+ assert.equal(f.content, "this is not a zip at all\n");
321
+ assert.equal((await browse("fake.zip/anything")).status, 404);
322
+ });
323
+ it("404s a member that isn't there, and a path through a non-archive", async () => {
324
+ assert.equal((await browse("pkg.zip/nope.txt")).status, 404);
325
+ assert.equal((await browse("dir/hello.ts/x")).status, 404);
326
+ });
327
+ });
328
+ // ── Tar ─────────────────────────────────────────────────────────────────────
329
+ for (const name of ["t.tar", "t.tar.gz", "t.tar.br"]) {
330
+ describe(`/api/files: ${name}`, () => {
331
+ it("lists members, with pax long names, and unsafe names and dotfiles hidden", async () => {
332
+ const d = asDir(await browse(name));
333
+ assert.deepEqual(d.entries.map((e) => e.name), [
334
+ "nested", "hard", `${"long-".repeat(30)}name.txt`, "sym", "top.txt",
335
+ ]);
336
+ assert.equal(d.archive?.skipped, 1);
337
+ assert.equal(d.entries.find((e) => e.name === "sym")?.link, "top.txt");
338
+ });
339
+ it("reads members, including one past earlier data", async () => {
340
+ assert.equal(asFile(await browse(`${name}/top.txt`)).content, "top\n");
341
+ const py = asFile(await browse(`${name}/nested/dir/n.py`));
342
+ assert.equal(py.content, "print('hi')\n");
343
+ assert.equal(py.highlight?.lang, "python");
344
+ assert.equal(asFile(await browse(`${name}/${"long-".repeat(30)}name.txt`)).content, "long named\n");
345
+ });
346
+ it("opens a hard link as its target and shows a symlink's target", async () => {
347
+ assert.equal(asFile(await browse(`${name}/hard`)).content, "top\n");
348
+ assert.equal(asFile(await browse(`${name}/sym`)).link, "top.txt");
349
+ });
350
+ });
351
+ }
352
+ describe("/api/files: other compressed files", () => {
353
+ it("opens a .gz as what it compresses, named by what's inside", async () => {
354
+ const md = asFile(await browse("notes.md.gz"));
355
+ assert.equal(md.content, "# compressed\n");
356
+ assert.equal(md.path, "notes.md.gz");
357
+ const ts = asFile(await browse("code.ts.gz"));
358
+ assert.equal(ts.highlight?.lang, "typescript");
359
+ });
360
+ it("shows a .tar that isn't tar as the file it is", async () => {
361
+ const f = asFile(await browse("junk.tar"));
362
+ assert.match(f.content, /^not a tarball/);
363
+ });
364
+ });
365
+ describe("archive names and formats", () => {
366
+ it("recognizes zip-family and tar-family names", () => {
367
+ assert.equal(archiveFormatFor("a/b.jar")?.kind, "zip");
368
+ assert.equal(archiveFormatFor("report.DOCX")?.kind, "zip");
369
+ assert.equal(archiveFormatFor("x.tgz")?.kind, "tar");
370
+ assert.deepEqual(archiveFormatFor("log.txt.gz"), { kind: "single", label: "gz", codec: "gzip", inner: "log.txt" });
371
+ assert.equal(archiveFormatFor(".zip"), null, "a bare extension is a dotfile, not an archive");
372
+ assert.equal(archiveFormatFor("zipper.ts"), null);
373
+ });
374
+ it("normalizes member names, refusing any that climb", () => {
375
+ assert.equal(normalizeMemberName("/abs/x"), "abs/x");
376
+ assert.equal(normalizeMemberName("./a//b/./c"), "a/b/c");
377
+ assert.equal(normalizeMemberName("win\\style\\path"), "win/style/path");
378
+ assert.equal(normalizeMemberName("a/../b"), null);
379
+ assert.equal(normalizeMemberName("..\\x"), null);
380
+ assert.equal(normalizeMemberName("nul\0byte"), null);
381
+ assert.equal(normalizeMemberName("./"), "", "the root: nothing to place, nothing wrong");
382
+ });
383
+ });
384
+ describe("stream reach", () => {
385
+ it("refuses a read past the reach limit rather than decoding up to it", async () => {
386
+ let opened = 0;
387
+ const src = streamSource(() => { opened++; return Readable.from([Buffer.alloc(100)]); }, null, 0, 0, 50);
388
+ await assert.rejects(src.read(60, 10), ReachError);
389
+ assert.equal(opened, 0, "nothing was decoded");
390
+ assert.equal((await src.read(40, 100)).length, 10, "a read that straddles the limit is cut at it");
391
+ });
392
+ });
393
+ //# sourceMappingURL=files-browse.test.js.map
@@ -0,0 +1,272 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { Readable } from "node:stream";
7
+ import { gzipSync } from "node:zlib";
8
+ import { ReachError, fileSource, streamSource } from "../files/bytes.js";
9
+ import { openFoldSource } from "../files/browse.js";
10
+ import { FindSlots, MAX_NEEDLE_BYTES, parseByteQuery, parseFindQuery, scanSource, } from "../files/find.js";
11
+ /** A body delivered in the given chunk sizes, the way a decompressor
12
+ * delivers one: the scanner must not care where the seams fall. */
13
+ function chunked(data, sizes, reach) {
14
+ const open = () => {
15
+ const parts = [];
16
+ let at = 0;
17
+ for (let i = 0; at < data.length; i++) {
18
+ const n = Math.max(1, sizes[i % sizes.length]);
19
+ parts.push(data.subarray(at, at + n));
20
+ at += n;
21
+ }
22
+ return Readable.from(parts);
23
+ };
24
+ return streamSource(open, data.length, 0, 0, reach);
25
+ }
26
+ async function scan(source, needles, from = 0, opts = {}) {
27
+ const events = [];
28
+ const done = await scanSource(source, needles, from, (e) => events.push(e), opts);
29
+ const hits = events.flatMap((e) => (e.type === "hits" ? e.hits : []));
30
+ return { hits, done, events };
31
+ }
32
+ /** What a scan must find: each needle's non-overlapping hits, merged by offset. */
33
+ function reference(data, needles, from = 0) {
34
+ const lower = Buffer.from(data.map((b) => (b >= 0x41 && b <= 0x5a ? b | 0x20 : b)));
35
+ const out = [];
36
+ needles.forEach((needle, n) => {
37
+ const hay = needle.caseless ? lower : data;
38
+ let i = from;
39
+ while (true) {
40
+ const at = hay.indexOf(needle.bytes, i);
41
+ if (at < 0)
42
+ break;
43
+ out.push({ at, len: needle.bytes.length, n });
44
+ i = at + needle.bytes.length;
45
+ }
46
+ });
47
+ out.sort((a, b) => a.at - b.at || a.n - b.n);
48
+ return out.map((h) => [h.at, h.len]);
49
+ }
50
+ describe("parseByteQuery", () => {
51
+ it("reads lowercase text as caseless and mixed case as exact", () => {
52
+ assert.deepEqual(parseByteQuery("text").map((n) => [n.kind, n.bytes.toString(), n.caseless]), [["text", "text", true]]);
53
+ assert.deepEqual(parseByteQuery("TEXT").map((n) => [n.kind, n.bytes.toString(), n.caseless]), [["text", "TEXT", false]]);
54
+ });
55
+ it("reads a run of hex pairs as bytes too, with or without spaces or 0x", () => {
56
+ for (const q of ["cf fa ed fe", "cffaedfe", "0xCFFAEDFE", "CF FA ED FE"]) {
57
+ const hex = parseByteQuery(q).find((n) => n.kind === "hex");
58
+ assert.ok(hex, q);
59
+ assert.deepEqual([...hex.bytes], [0xcf, 0xfa, 0xed, 0xfe]);
60
+ assert.equal(hex.caseless, false);
61
+ }
62
+ });
63
+ it("keeps both readings of an ambiguous word", () => {
64
+ const needles = parseByteQuery("cafe");
65
+ assert.deepEqual(needles.map((n) => n.kind), ["text", "hex"]);
66
+ assert.deepEqual([...needles[1].bytes], [0xca, 0xfe]);
67
+ });
68
+ it("does not read an odd digit count or a non-hex word as hex", () => {
69
+ assert.deepEqual(parseByteQuery("abc").map((n) => n.kind), ["text"]);
70
+ assert.deepEqual(parseByteQuery("c0ffee!").map((n) => n.kind), ["text"]);
71
+ assert.deepEqual(parseByteQuery("a b").map((n) => n.kind), ["text"]);
72
+ });
73
+ it("does not trim: a lone space is a query", () => {
74
+ assert.deepEqual(parseByteQuery(" ").map((n) => [...n.bytes]), [[0x20]]);
75
+ assert.deepEqual(parseByteQuery(""), []);
76
+ });
77
+ it("lowercases ASCII only, so the bytes of `İ` stay put", () => {
78
+ const [text] = parseByteQuery("İx");
79
+ assert.equal(text.caseless, false); // `İ` is uppercase: exact
80
+ assert.deepEqual(text.bytes, Buffer.from("İx"));
81
+ });
82
+ it("refuses a needle past the cap", () => {
83
+ assert.ok("error" in parseFindQuery("x".repeat(MAX_NEEDLE_BYTES + 1)));
84
+ assert.ok(Array.isArray(parseFindQuery("x".repeat(MAX_NEEDLE_BYTES))));
85
+ assert.ok("error" in parseFindQuery(""));
86
+ });
87
+ });
88
+ describe("scanSource", () => {
89
+ it("finds what a naive search finds, wherever the chunk seams fall", async () => {
90
+ // A small alphabet makes needles recur, overlap each other, and straddle
91
+ // seams often; case varies so the fold is exercised too.
92
+ let seed = 7;
93
+ const rand = (n) => {
94
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
95
+ return seed % n;
96
+ };
97
+ const alphabet = Buffer.from("abAB\x00\xca\xfe");
98
+ for (let trial = 0; trial < 200; trial++) {
99
+ const data = Buffer.from(Array.from({ length: 50 + rand(400) }, () => alphabet[rand(alphabet.length)]));
100
+ const q = ["ab", "aba", "cafe", "b", "BA", "a\x00", "abab"][rand(7)];
101
+ const needles = parseByteQuery(q);
102
+ const sizes = Array.from({ length: 5 }, () => 1 + rand(9));
103
+ const from = rand(3) === 0 ? rand(data.length) : 0;
104
+ const { hits, done } = await scan(chunked(data, sizes), needles, from, { maxHits: 1e9, maxPages: 1e9 });
105
+ assert.deepEqual(hits, reference(data, needles, from), `trial ${trial}: ${JSON.stringify(q)} ${sizes}`);
106
+ assert.equal(done.stop, "eof");
107
+ assert.equal(done.next, null);
108
+ assert.equal(done.scanned, data.length);
109
+ }
110
+ });
111
+ it("reports hits in offset order even when a longer needle's hit completes a chunk later", async () => {
112
+ // `cafe` as text (4 bytes) starts at 2 and is whole only in the second
113
+ // chunk; the hex needle ca fe (2 bytes) at 6 is whole in the first. Order
114
+ // must still be 2 before 6.
115
+ const data = Buffer.concat([Buffer.from("xxca"), Buffer.from("fe"), Buffer.from([0xca, 0xfe]), Buffer.from("xx")]);
116
+ const { hits } = await scan(chunked(data, [3, 5]), parseByteQuery("cafe"));
117
+ assert.deepEqual(hits, [[2, 4], [6, 2]]);
118
+ });
119
+ it("stops at the page window and says where the next hit is", async () => {
120
+ // One hit per 16-byte page, pages 0..9.
121
+ const data = Buffer.alloc(160, 0x2e);
122
+ for (let p = 0; p < 10; p++)
123
+ data.write("Z", p * 16 + 3);
124
+ const needles = parseByteQuery("z");
125
+ const { hits, done } = await scan(chunked(data, [7]), needles, 0, { pageBytes: 16, maxPages: 4 });
126
+ assert.deepEqual(hits.map((h) => h[0]), [3, 19, 35, 51]);
127
+ assert.equal(done.stop, "pages");
128
+ assert.equal(done.next, 67);
129
+ // …and the next window picks up exactly there.
130
+ const again = await scan(chunked(data, [7]), needles, done.next, { pageBytes: 16, maxPages: 4 });
131
+ assert.deepEqual(again.hits.map((h) => h[0]), [67, 83, 99, 115]);
132
+ });
133
+ it("counts a hit that straddles a page boundary as both pages", async () => {
134
+ const data = Buffer.alloc(64, 0x2e);
135
+ data.write("xy", 15); // pages 0 and 1
136
+ data.write("xy", 40); // page 2
137
+ const { hits, done } = await scan(chunked(data, [64]), parseByteQuery("xy"), 0, { pageBytes: 16, maxPages: 2 });
138
+ assert.deepEqual(hits, [[15, 2]]);
139
+ assert.equal(done.stop, "pages");
140
+ assert.equal(done.next, 40);
141
+ });
142
+ it("stops at the hit cap: a one-byte query in a file made of that byte", async () => {
143
+ const data = Buffer.alloc(100_000);
144
+ const { hits, done } = await scan(chunked(data, [4096]), parseByteQuery("00"), 0, { maxHits: 50 });
145
+ assert.equal(hits.length, 50);
146
+ assert.equal(done.stop, "hits");
147
+ assert.equal(done.next, 50);
148
+ // It stopped in the first chunk, not after reading the file.
149
+ assert.ok(done.scanned <= 4096);
150
+ });
151
+ it("stops on its time budget with a place to resume", async () => {
152
+ let t = 0;
153
+ const data = Buffer.alloc(1000, 0x2e);
154
+ data.write("q", 900);
155
+ const { hits, done } = await scan(chunked(data, [100]), parseByteQuery("q"), 0, {
156
+ budgetMs: 250,
157
+ now: () => (t += 100),
158
+ });
159
+ assert.deepEqual(hits, []);
160
+ assert.equal(done.stop, "time");
161
+ assert.ok(done.next !== null && done.next < 900);
162
+ const rest = await scan(chunked(data, [100]), parseByteQuery("q"), done.next);
163
+ assert.deepEqual(rest.hits, [[900, 1]]);
164
+ });
165
+ it("stops at the reach limit in a compressed body, keeping what it found before it", async () => {
166
+ const data = Buffer.alloc(300, 0x2e);
167
+ data.write("hit", 10);
168
+ data.write("hit", 250);
169
+ const { hits, done } = await scan(chunked(data, [32], 200), parseByteQuery("hit"));
170
+ assert.deepEqual(hits, [[10, 3]]);
171
+ assert.equal(done.stop, "reach");
172
+ assert.equal(done.scanned, 200);
173
+ });
174
+ it("stops when cancelled", async () => {
175
+ const c = new AbortController();
176
+ c.abort();
177
+ const { done } = await scan(chunked(Buffer.alloc(100), [10]), parseByteQuery("00"), 0, { signal: c.signal });
178
+ assert.equal(done.stop, "cancelled");
179
+ });
180
+ it("resumes a cancelled scan without losing a hit it was holding at the seam", async () => {
181
+ // `NEEDLE` is split across the first seam. The scan is cancelled as the
182
+ // second chunk arrives, while `NEE` is still held back; resuming from the
183
+ // chunk's end would never see the hit.
184
+ const data = Buffer.from("........NEEDLE..........");
185
+ const c = new AbortController();
186
+ const src = streamSource(() => Readable.from((function* () {
187
+ yield data.subarray(0, 11);
188
+ c.abort();
189
+ yield data.subarray(11);
190
+ })()), data.length, 0);
191
+ const first = await scan(src, parseByteQuery("NEEDLE"), 0, { signal: c.signal });
192
+ assert.deepEqual(first.hits, []);
193
+ assert.equal(first.done.stop, "cancelled");
194
+ assert.ok(first.done.next !== null && first.done.next <= 8);
195
+ const rest = await scan(chunked(data, [5]), parseByteQuery("NEEDLE"), first.done.next);
196
+ assert.deepEqual(rest.hits, [[8, 6]]);
197
+ });
198
+ it("reads a file on disk from an offset", async () => {
199
+ const dir = mkdtempSync(join(tmpdir(), "brane-find-"));
200
+ const p = join(dir, "blob.bin");
201
+ const data = Buffer.alloc(3 * 1024 * 1024 + 5);
202
+ data.write("NEEDLE", 1024 * 1024 - 3); // across the first 1 MB read
203
+ data.write("NEEDLE", 3 * 1024 * 1024 - 1);
204
+ writeFileSync(p, data);
205
+ const src = fileSource(p, data.length, 0);
206
+ const { hits, done } = await scan(src, parseByteQuery("NEEDLE"));
207
+ assert.deepEqual(hits, [[1024 * 1024 - 3, 6], [3 * 1024 * 1024 - 1, 6]]);
208
+ assert.equal(done.stop, "eof");
209
+ const later = await scan(src, parseByteQuery("NEEDLE"), 1024 * 1024);
210
+ assert.deepEqual(later.hits, [[3 * 1024 * 1024 - 1, 6]]);
211
+ });
212
+ });
213
+ describe("ByteSource.chunks", () => {
214
+ it("throws ReachError past the reach limit rather than ending quietly", async () => {
215
+ const src = chunked(Buffer.alloc(100), [30], 50);
216
+ let got = 0;
217
+ await assert.rejects(async () => {
218
+ for await (const c of src.chunks(0))
219
+ got += c.length;
220
+ }, ReachError);
221
+ assert.equal(got, 50);
222
+ });
223
+ it("ends normally when the body ends exactly at the limit", async () => {
224
+ const src = chunked(Buffer.alloc(50), [30], 50);
225
+ let got = 0;
226
+ for await (const c of src.chunks(10))
227
+ got += c.length;
228
+ assert.equal(got, 40);
229
+ });
230
+ });
231
+ describe("FindSlots", () => {
232
+ it("supersedes the oldest scan rather than refusing a new one", () => {
233
+ const slots = new FindSlots(2);
234
+ const a = slots.admit();
235
+ const b = slots.admit();
236
+ assert.equal(slots.size, 2);
237
+ const c = slots.admit();
238
+ assert.equal(a.signal.aborted, true);
239
+ assert.equal(b.signal.aborted, false);
240
+ assert.equal(c.signal.aborted, false);
241
+ assert.equal(slots.size, 2);
242
+ b.release();
243
+ c.release();
244
+ a.release(); // releasing a superseded scan is harmless
245
+ assert.equal(slots.size, 0);
246
+ });
247
+ });
248
+ describe("openFoldSource", () => {
249
+ const fold = mkdtempSync(join(tmpdir(), "brane-find-fold-"));
250
+ mkdirSync(join(fold, "sub"));
251
+ writeFileSync(join(fold, "sub", "a.bin"), Buffer.from("\x00\x01hello\x00"));
252
+ writeFileSync(join(fold, "notes.txt.gz"), gzipSync(Buffer.from("x".repeat(1000) + "buried")));
253
+ it("opens a plain file", async () => {
254
+ const src = await openFoldSource(fold, "sub/a.bin");
255
+ assert.ok(!("error" in src));
256
+ const { hits } = await scan(src, parseByteQuery("hello"));
257
+ assert.deepEqual(hits, [[2, 5]]);
258
+ });
259
+ it("searches a .gz as what it compresses, as the hex view shows it", async () => {
260
+ const src = await openFoldSource(fold, "notes.txt.gz");
261
+ assert.ok(!("error" in src));
262
+ const { hits } = await scan(src, parseByteQuery("buried"));
263
+ assert.deepEqual(hits, [[1000, 6]]);
264
+ });
265
+ it("refuses what isn't a file, or isn't in the fold", async () => {
266
+ assert.deepEqual(await openFoldSource(fold, "sub"), { status: 400, error: "Not a file" });
267
+ assert.equal((await openFoldSource(fold, "../etc/passwd")).status, 400);
268
+ assert.equal((await openFoldSource(fold, "/etc/passwd")).status, 400);
269
+ assert.equal((await openFoldSource(fold, "nope.bin")).status, 404);
270
+ });
271
+ });
272
+ //# sourceMappingURL=files-find.test.js.map