@geml/geml 1.7.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/codemap/build.mjs CHANGED
@@ -36,7 +36,7 @@
36
36
  //
37
37
  // After building, run: geml codemap verify <out-dir>
38
38
  import { writeFileSync, mkdirSync, existsSync, readFileSync, statSync } from "node:fs";
39
- import { join, resolve, basename, dirname, relative } from "node:path";
39
+ import { join, resolve, basename, dirname, relative, sep } from "node:path";
40
40
  import { fileURLToPath } from "node:url";
41
41
  import { execFileSync, spawnSync } from "node:child_process";
42
42
  import { emit } from "./emit.mjs";
@@ -100,7 +100,7 @@ if (root && !inputs.length) {
100
100
  const { files, manifests, pkgs } = collectSourceFiles(rootAbs);
101
101
  const excluder = makeExcluder({
102
102
  root: rootAbs, globs: excludeGlobs0, gitignore: !args.includes("--no-gitignore"),
103
- files: [...files, ...manifests, ...pkgs], exec: execFileSync,
103
+ files: [...files, ...manifests, ...pkgs], run: execFileSync,
104
104
  });
105
105
  const jobs = detectLanguages(rootAbs, { files, manifests, pkgs, excluder });
106
106
  detectedLanguages = [...new Set(jobs.map((j) => j.language))];
@@ -136,7 +136,10 @@ if (root && !inputs.length) {
136
136
  // PATH (npx / joern) whose .cmd/.bat shim uses %~dp0 breaks if the name is
137
137
  // quoted — cmd then resolves %~dp0 against the cwd, not the shim's dir. A
138
138
  // spaced launcher PATH is a full path, so quoting it keeps %~dp0 correct.
139
- const q = (s) => (/[\s"]/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
139
+ // WHEN it does quote, it defers to shq below: escaping only `"` left a
140
+ // trailing backslash (a path ending `...\`) escaping our own closing quote,
141
+ // which merges the next token into this one. Same CRT rules, one source.
142
+ const q = (s) => (/[\s"]/.test(String(s)) ? shq(s) : String(s));
140
143
  // Hardened quote for command ARGUMENTS on win32. Node does NOT escape args
141
144
  // under shell:true — it only concatenates them (Node DEP0190) — so an
142
145
  // unquoted argument such as a source directory named `a&calc` reaching the
@@ -378,7 +381,7 @@ const excluder = makeExcluder({
378
381
  globs: excludeGlobs,
379
382
  gitignore: !args.includes("--no-gitignore"),
380
383
  files: [...new Set(symbols.map((s) => s.file))],
381
- exec: execFileSync,
384
+ run: execFileSync,
382
385
  });
383
386
  const kept = symbols.filter((s) => !excluder(s.file));
384
387
  const excludedCount = symbols.length - kept.length;
@@ -391,7 +394,7 @@ if (root && !recordRecipe && !entryHints.length) {
391
394
  const c = collectSourceFiles(rootAbs);
392
395
  const excl = makeExcluder({
393
396
  root: rootAbs, globs: excludeGlobs, gitignore: !args.includes("--no-gitignore"),
394
- files: [...c.files, ...c.manifests, ...c.pkgs], exec: execFileSync,
397
+ files: [...c.files, ...c.manifests, ...c.pkgs], run: execFileSync,
395
398
  });
396
399
  entryHints = detectEntries(rootAbs, {
397
400
  files: c.files.filter((f) => !excl(f)),
@@ -420,7 +423,18 @@ try {
420
423
  const { edges: httpEdges, audit } = buildCrossStackOverlay({
421
424
  symbols,
422
425
  files: scanFiles,
423
- readText: (rel) => { try { return readFileSync(join(rootAbs, ...rel.split("/")), "utf8"); } catch { return null; } },
426
+ // `rel` is indexer OUTPUT, not a path this build authored, so a `..`
427
+ // segment must not turn a source read into an escape from the scanned
428
+ // root. Same gate the document resolver uses: resolve first, then require
429
+ // the result to BE the root or sit under it — compared with the separator,
430
+ // so a sibling named `<root>-evil` is not mistaken for a child.
431
+ readText: (rel) => {
432
+ try {
433
+ const p = resolve(rootAbs, ...String(rel).split("/"));
434
+ if (p !== rootAbs && !p.startsWith(rootAbs + sep)) return null;
435
+ return readFileSync(p, "utf8");
436
+ } catch { return null; }
437
+ },
424
438
  });
425
439
  for (const e of httpEdges) edges.push(e);
426
440
  if (httpEdges.length) {
@@ -15,17 +15,17 @@ import { execFileSync as _execFileSync } from "node:child_process";
15
15
  // Minimal gitignore-flavoured glob: `**` spans path separators, `*` stays
16
16
  // within a segment, everything else is literal. Anchored to the whole path.
17
17
  export function globToRegExp(glob) {
18
+ // The glob comes from a `--exclude` argument, so nothing in it may reach the
19
+ // compiled pattern as *syntax*. Split on the wildcards, keeping them (the
20
+ // capture group), which leaves the array strictly alternating: even indices
21
+ // are literal text, odd indices are `*`, `**` or `**/`. Literals go through a
22
+ // total regex-metacharacter escape; wildcards map to fixed patterns. Neither
23
+ // path can carry an unescaped metacharacter through.
24
+ const parts = String(glob).split(/(\*\*\/?|\*)/);
18
25
  let re = "";
19
- for (let i = 0; i < glob.length; i++) {
20
- const c = glob[i];
21
- if (c === "*") {
22
- if (glob[i + 1] === "*") { re += ".*"; i++; if (glob[i + 1] === "/") i++; }
23
- else re += "[^/]*";
24
- } else if ("\\^$+?.()|{}[]".includes(c)) {
25
- re += "\\" + c;
26
- } else {
27
- re += c;
28
- }
26
+ for (let i = 0; i < parts.length; i++) {
27
+ if (i % 2 === 0) re += parts[i].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28
+ else re += parts[i] === "*" ? "[^/]*" : ".*"; // `**` and `**/` span separators
29
29
  }
30
30
  return new RegExp("^" + re + "$");
31
31
  }
@@ -33,10 +33,14 @@ export function globToRegExp(glob) {
33
33
  // Ask git which of `files` it ignores. Returns a Set of the ignored paths.
34
34
  // check-ignore exits 1 when nothing matches and 128 when git is unavailable /
35
35
  // the dir is not a repo — both mean "ignore nothing", not a build failure.
36
- export function gitIgnored(root, files, exec = _execFileSync) {
36
+ // The injected runner is named `run`, not `exec`: it is always an execFile-shaped
37
+ // (program, args[]) call that spawns NO shell, whereas a callback named `exec`
38
+ // reads — to a human skimming, and to a static analyser — as the shell-string
39
+ // child_process API. The name should not imply the dangerous one.
40
+ export function gitIgnored(root, files, run = _execFileSync) {
37
41
  if (!files.length) return new Set();
38
42
  try {
39
- const out = exec("git", ["-C", root, "check-ignore", "--stdin"], { input: files.join("\n"), encoding: "utf8" });
43
+ const out = run("git", ["-C", root, "check-ignore", "--stdin"], { input: files.join("\n"), encoding: "utf8" });
40
44
  return new Set(out.split(/\r?\n/).filter(Boolean));
41
45
  } catch (e) {
42
46
  const out = e && e.stdout ? String(e.stdout) : "";
@@ -45,8 +49,8 @@ export function gitIgnored(root, files, exec = _execFileSync) {
45
49
  }
46
50
 
47
51
  // Build a predicate (file) => shouldExclude.
48
- export function makeExcluder({ root, globs = [], gitignore = true, files = [], exec } = {}) {
52
+ export function makeExcluder({ root, globs = [], gitignore = true, files = [], run } = {}) {
49
53
  const res = globs.map(globToRegExp);
50
- const ignored = gitignore ? gitIgnored(root, files, exec) : new Set();
54
+ const ignored = gitignore ? gitIgnored(root, files, run) : new Set();
51
55
  return (file) => ignored.has(file) || res.some((r) => r.test(file));
52
56
  }
@@ -95,7 +95,10 @@ let trusted = isRecipeTrusted(fingerprint);
95
95
  // full path is quoted), every argument via shq (ALWAYS double-quoted, so
96
96
  // cmd.exe treats & | < > ( ) ^ and whitespace as literal). An injected
97
97
  // metachar inside a dir-name argument is therefore inert.
98
- const q = (s) => (/[\s"]/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
98
+ // q quotes only when it must; WHEN it does it defers to shq, so a program path
99
+ // ending in a backslash cannot escape our own closing quote and swallow the
100
+ // next token. One set of CRT rules, in one place.
101
+ const q = (s) => (/[\s"]/.test(String(s)) ? shq(s) : String(s));
99
102
  const shq = (s) => `"${String(s).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
100
103
  // Human-readable render of a step for the log / refusal message — DISPLAY ONLY,
101
104
  // never executed. Falls back to String() for a stale (non-structured) step.
@@ -37,7 +37,10 @@ if (!cli) cli = existsSync(localParser) ? localParser : "geml";
37
37
  // `.geml` filename containing & | ( ) would otherwise break out and inject.
38
38
  // cmd.exe treats those metacharacters and whitespace as literal inside quotes;
39
39
  // CRT rules for embedded " / trailing \.
40
- const q = (s) => (/[\s"]/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
40
+ // q quotes only when it must; WHEN it does it defers to shq, so a program path
41
+ // ending in a backslash cannot escape our own closing quote and swallow the
42
+ // next token. One set of CRT rules, in one place.
43
+ const q = (s) => (/[\s"]/.test(String(s)) ? shq(s) : String(s));
41
44
  const shq = (s) => `"${String(s).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
42
45
  // A codemap document's `src=` routes are written relative to the indexed
43
46
  // SOURCE root (`geml-parser/src/attrs.ts` from a document two levels down), so
package/dist/from-md.js CHANGED
@@ -55,7 +55,12 @@ function metaLine(line) {
55
55
  if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))
56
56
  v = v.slice(1, -1);
57
57
  const bareSafe = /^[^\s"]+$/.test(v);
58
- return bareSafe ? `${m[1]}=${v}` : `${m[1]}="${v.replace(/"/g, '\\"')}"`;
58
+ // No backslash escape here — a `data` block value is read by `coerce`, which
59
+ // simply strips the outer quotes (§4); GEML defines no `\"` escape at all.
60
+ // Emitting one wrote the backslash into the parsed value, so `a"b` came back
61
+ // as `a\"b`. Quoting the value verbatim round-trips instead: coerce keeps
62
+ // everything between the first and last quote, embedded quotes included.
63
+ return bareSafe ? `${m[1]}=${v}` : `${m[1]}="${v}"`;
59
64
  }
60
65
  // Rewrite Markdown autolinks `<https://…>` / `<mailto:…>` into GEML links
61
66
  // `[url](url)` (GEML has no autolink syntax). Inline code spans are left intact.
package/dist/geml.js CHANGED
@@ -909,6 +909,12 @@ function validateProjections(children, ctx, opts) {
909
909
  function relJoinPath(base, target) {
910
910
  if (base === "" || target === "" || target.startsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(target))
911
911
  return target;
912
+ // A POSIX-absolute base must stay absolute. The segment loop below drops empty
913
+ // segments, and the leading "" of "/tmp/x" IS the root — dropping it silently
914
+ // turned `/tmp/x/part.geml` into the relative `tmp/x/part.geml`, which then
915
+ // resolved against the wrong directory. Windows never showed it: a `C:\…` base
916
+ // has no "/", so relDirPath returns "" and the early return above takes over.
917
+ const rooted = base.startsWith("/");
912
918
  const out = [];
913
919
  for (const s of (base + "/" + target).split("/")) {
914
920
  if (s === "" || s === ".")
@@ -918,7 +924,7 @@ function relJoinPath(base, target) {
918
924
  else
919
925
  out.push(s);
920
926
  }
921
- return out.join("/");
927
+ return (rooted ? "/" : "") + out.join("/");
922
928
  }
923
929
  function relDirPath(p) {
924
930
  const i = p.lastIndexOf("/");
package/dist/render.js CHANGED
@@ -1990,7 +1990,24 @@ export function codeGraphRuntime(root) {
1990
1990
  // Rendered pages sit next to their codemap documents: a live mount
1991
1991
  // (viewer/playground) carries data-src, a CLI embed carries the src
1992
1992
  // path in data.start — either directory anchors doc-relative links.
1993
- var navBase = String(mount.getAttribute("data-src") || data.start || "").replace(/[^\/]*$/, "");
1993
+ // navBase prefixes EVERY url this view reaches for — the breadcrumb's
1994
+ // `location.href`, the search-index `<script src>`, a hit's jump target.
1995
+ // Both of its inputs are page data (a DOM attribute, the embedded graph
1996
+ // JSON), so a document that carried `data-src="javascript:…"` would turn
1997
+ // a breadcrumb click into script execution, and a `//host/` or
1998
+ // `https://host/` value would pull the search index off another origin.
1999
+ // A base is a doc-relative DIRECTORY and nothing else: anything bearing a
2000
+ // scheme or a network-path prefix is refused outright and links resolve
2001
+ // against the current page instead.
2002
+ // Everything this view navigates to or loads is built from page data, so
2003
+ // every such string is filtered here first: keep it if it is a
2004
+ // document-RELATIVE path, drop it to "" if it carries a scheme
2005
+ // (`javascript:`, `data:`) or a `//host` network-path prefix.
2006
+ function relOnly(u) {
2007
+ var s = String(u == null ? "" : u);
2008
+ return /^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(s) || s.slice(0, 2) === "//" ? "" : s;
2009
+ }
2010
+ var navBase = relOnly(String(mount.getAttribute("data-src") || data.start || "").replace(/[^\/]*$/, ""));
1994
2011
  // A live mount (viewer/playground/served page) navigates IN PLACE over
1995
2012
  // the geml documents through this loader; only truly static pages fall
1996
2013
  // back to their pre-rendered sibling .html pages. Read LAZILY on every
@@ -2028,7 +2045,16 @@ export function codeGraphRuntime(root) {
2028
2045
  }
2029
2046
  catch (e) { /* stub */ }
2030
2047
  }
2031
- function openDoc(rel, gpath) {
2048
+ function openDoc(rel0, gpath) {
2049
+ // The target can come from a node's own data (a `data-k` key), not just
2050
+ // from a breadcrumb we built — so it goes through the same relative-path
2051
+ // filter as navBase. An absolute or scheme-bearing target says why it
2052
+ // was refused rather than navigating.
2053
+ var rel = relOnly(rel0);
2054
+ if (!rel) {
2055
+ flash("refusing to open " + String(rel0) + " — not a document-relative path");
2056
+ return;
2057
+ }
2032
2058
  var lv = live();
2033
2059
  if (lv) {
2034
2060
  Promise.resolve(lv({ doc: rel })).then(function (nd) {
@@ -2340,8 +2366,15 @@ export function codeGraphRuntime(root) {
2340
2366
  });
2341
2367
  }
2342
2368
  }
2343
- function gotoHit(doc, id, locate) {
2369
+ function gotoHit(doc0, id, locate) {
2344
2370
  searchMenu.hidden = true;
2371
+ // `doc` is a row from the loaded search index — page data, same as any
2372
+ // other target, so it gets the same relative-path filter.
2373
+ var doc = relOnly(doc0);
2374
+ if (!doc) {
2375
+ flash("refusing to open " + String(doc0) + " — not a document-relative path");
2376
+ return;
2377
+ }
2345
2378
  if (live() && !locate) {
2346
2379
  showCallees(doc + "#" + id);
2347
2380
  return;
package/dist/to-md.js CHANGED
@@ -48,10 +48,32 @@ function inline(n) {
48
48
  function seq(ns) {
49
49
  return ns.map(inline).join("");
50
50
  }
51
+ // Escape a `|` so GFM keeps it inside the cell instead of splitting the row.
52
+ // GFM resolves backslash escapes in a row BEFORE it splits on `|`, so a
53
+ // backslash run sitting right in front of our escape would eat it: a code span
54
+ // holding `a\|b` became `a\\|b`, which reads as a literal backslash followed by
55
+ // an UNescaped pipe — a spurious cell break. Double any such run first, then
56
+ // escape the pipe. Runs already produced by escText (`\\` for a literal
57
+ // backslash) survive this unchanged, so pre-rendered Markdown stays intact.
58
+ //
59
+ // The backslash run is matched as `\\+\|?` — one ATOMIC token, run and pipe
60
+ // together — not as `(\\*)\|`. The latter is quadratic: on a cell holding a
61
+ // long run of backslashes and no pipe, the engine matches the run from every
62
+ // index in it and fails at the required `|` each time. Here the greedy `\\+`
63
+ // takes the whole run in one match and the trailing `\|?` is optional, so
64
+ // nothing backtracks and each character is visited once.
65
+ function escPipe(s) {
66
+ return s.replace(/\\+\|?|\|/g, (m) => {
67
+ if (m.charAt(m.length - 1) !== "|")
68
+ return m; // a run with no pipe after it
69
+ const bs = m.slice(0, -1); // the run that would otherwise eat our escape
70
+ return bs + bs + "\\|";
71
+ });
72
+ }
51
73
  // Inline text for a table cell: render inlines, then neutralise the two bytes
52
74
  // that would break a GFM cell.
53
75
  function cellText(c) {
54
- return seq(c.inlines).replace(/\|/g, "\\|").replace(/\n/g, " ");
76
+ return escPipe(seq(c.inlines)).replace(/\n/g, " ");
55
77
  }
56
78
  // ---------------------------------------------------------------------------
57
79
  // Tables
@@ -72,7 +94,7 @@ function tableToMd(t, notes) {
72
94
  const lines = [];
73
95
  if (t.caption)
74
96
  lines.push(`*${t.caption}*`, "");
75
- lines.push(`| ${cols.map((c) => c.replace(/\|/g, "\\|")).join(" | ")} |`);
97
+ lines.push(`| ${cols.map(escPipe).join(" | ")} |`);
76
98
  lines.push(`| ${cols.map((_, i) => sep(t.align[i])).join(" | ")} |`);
77
99
  const pad = (cells) => {
78
100
  while (cells.length < cols.length)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geml/geml",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "mcpName": "io.github.geml-spec/geml",
5
5
  "publishConfig": {
6
6
  "access": "public"