@blamejs/core 0.18.51 → 0.18.53

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 CHANGED
@@ -8,6 +8,73 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.53 (2026-08-24) — **The YAML screens refused ordinary documents: a list of records, and an exclamation mark in a sentence.** `b.guardYaml.parse` refused any sequence whose items carry more than one key, which is the shape of nearly every list of records anyone writes. Separately, both `b.guardYaml` and `b.parsers.yaml` decided whether a `!`, `&` or `*` opened a tag, anchor or alias by looking at the character in front of it, so they reported one inside a quoted string, inside a block scalar's shell script, in the middle of a value, and inside a comment. `x: 1 # note !bang` was refused. Both screens now read the document's structure, through one scanner shared by the two modules. **Fixed:** *`b.guardYaml` no longer reports a duplicate key for a list of records* — Key uniqueness was tracked per indentation level, and a sequence-item line never reset that tracking. The second item's keys were therefore checked against the first item's, so every key except the one written inline with the dash was reported as a duplicate:
12
+
13
+ ```yaml
14
+ steps:
15
+ - name: build
16
+ run: make
17
+ - name: test
18
+ run: make check
19
+ ```
20
+
21
+ That document was refused with `duplicate key "run"`, and it has no duplicate key in it. Under `duplicateKeyPolicy: "reject"` — the strict profile's default — the parse threw.
22
+
23
+ Each item of a sequence is now its own mapping, so a key may appear once per item. Two things that were already true stay true, and both are pinned by tests: a key repeated *within* one item is still a duplicate, and so is a key repeated in an ordinary mapping.
24
+
25
+ The key written inline with the dash was being skipped entirely rather than tracked, so `- name: a` followed by `name: b` inside the same item went unreported. It is now tracked with the rest of its mapping. · *A tag, anchor or alias is recognised where it can actually appear* — `!`, `&` and `*` introduce a tag, an anchor and an alias only at a node start. Both YAML screens decided the question by looking at the preceding character — whitespace meant a sigil, anything else meant not — and that rule cannot tell a node start from the middle of a scalar. Every one of these was refused:
26
+
27
+ ```yaml
28
+ x: "hello !world" # a bang in a quoted string
29
+ x: hello !world # a bang in the middle of a value
30
+ x: 1 # note !bang # a bang in a comment
31
+ x: |
32
+ echo !boom # a bang in a shell script
33
+ text: fish &chips # an ampersand in prose
34
+ ```
35
+
36
+ The two modules had separate implementations of the check and separate gaps. `b.parsers.yaml` masked quoted strings but copied comment text through verbatim and had no block-scalar handling, despite a note in it saying both were covered; `b.guardYaml` had none of the three. So a document refused by one was sometimes accepted by the other.
37
+
38
+ Both now use one scanner that tracks where each character sits: inside a quoted scalar, a comment, a block-scalar body, a plain scalar, or in the structure. A sigil is reported only where it opens a node. Scalars that run across several lines are followed rather than read afresh, so neither the continuation of a quoted string nor of an unquoted value is mistaken for structure, and a `!` after the closing quote or the ending comma still is one.
39
+
40
+ **What this changes for a document you already have:** a sigil inside a plain scalar is no longer reported. `text: this &notanchor` and `x: -&a` used to raise an anchor finding and no longer do. Neither declares an anchor — an anchor is a separate token — so nothing can reference it, and the amplification these findings exist to stop has no path through them. A merge key written without spaces, `<<:*d`, is still refused, by the merge-key screen rather than the alias one.
41
+
42
+ `b.parsers.yaml` continues to refuse every real tag, anchor, alias and directive, and the strict profile of `b.guardYaml` continues to report them. · *The residency gate's raw-SQL timing check no longer depends on how busy the machine is* — The check that a padded raw `UPDATE` costs no extra parse time compared the padded call against an unpadded one and allowed 500ms between them. A difference of two timings carries the noise of both, and on a loaded runner the padded sample alone drifted past that allowance while the statement itself parses in a fraction of a millisecond.
43
+
44
+ It now bounds the padded call on its own, and takes the fastest of fifteen samples rather than three. The defect it guards cost roughly seven seconds against 0.04ms once fixed, so the ceiling sits between the two behaviours with several orders of magnitude to spare rather than tracking the machine. The unpadded baseline is still measured and still printed, because a reader looking at a failure needs to know whether the box was slow or the parse was.
45
+
46
+ - v0.18.52 (2026-08-23) — **A refusal that would not say why, and a currency gate that skipped the one pin able to move underneath it.** `b.guardFilename` refuses a filename carrying NTFS alternate-data-stream syntax whatever `adsPolicy` says, which is correct and was documented, but the refusal never mentioned the option — so a caller who set `"allow"` was left comparing their code against a message that said nothing about the setting they had changed. Separately, the pinned-actions currency gate could only see a `uses:` pinned to a commit SHA, so the one workflow reference pinned to a tag was absent from every run rather than reported, and its `--json` output had a human summary line after the document, so nothing could parse it. **Fixed:** *The NTFS-ADS refusal now says where `adsPolicy: "allow"` applies* — A filename carrying alternate-data-stream syntax (`report.txt:stream`) is refused by `b.guardFilename.sanitize`, `validate` and `gate` regardless of `adsPolicy`. That is deliberate: on Windows the write lands on a hidden stream of the base file rather than on a file anyone can see, so it is one of the shapes a filename guard always refuses. `adsPolicy` still takes `"allow"` because `verifyExtractionPath` honours it, for an operator deliberately extracting stream-suffixed entries into a root they chose.
47
+
48
+ What was missing is that none of this was visible from the call site. The refusal said only that the name contained stream syntax, so setting `"allow"` and watching nothing change read as the option being broken rather than as it being scoped. The message now names the boundary, and the split is pinned by tests at all four entry points.
49
+
50
+ One thing worth knowing if you go looking: a colon is also a Windows reserved character, so `reservedCharPolicy` refuses such a name before the ADS check is reached. Setting `adsPolicy` alone never changes the outcome for a name like `12:30 notes.txt` — `reservedCharPolicy` is the one governing it, and `"strip"` there yields `12_30 notes.txt`.
51
+
52
+ This also records a change that shipped in 0.18.48 without being written down: before it, `sanitize` honoured `adsPolicy: "reject"` and so could be opted out of. It no longer can be, and there is no replacement value, because a stream-suffixed name has no safe repair. · *The pinned-actions currency gate could not see a tag-pinned workflow* — `check-actions-currency.js` matched a `uses:` line only when it was pinned to a 40-character commit SHA. A reference pinned to anything else matched nothing, was collected by nothing, and appeared in the run as neither current nor stale — absent, while the summary counted what it had looked at and read as a clean result.
53
+
54
+ The pin that fell through is the one least able to look after itself. A commit SHA is immutable, so a stale SHA pin becomes visible as soon as upstream cuts a release; a tag can be repointed at new code with no local diff at all. The single reference here pinned to a tag is pinned that way by necessity, because the SLSA provenance generator refuses to run from a commit SHA — so the one exception to the pinning discipline was also the one thing never checked.
55
+
56
+ Tag pins are now collected, version-checked like any other, and reported as tag pins. `--fix` deliberately leaves them alone and says so: there is no old SHA to compare against and none to write, and the review material `--fix` prints is a diff between two SHAs, which is exactly what a tag cannot give.
57
+
58
+ An action can also be pinned by SHA in one workflow and by tag in another, and the two need different answers in different places. The pin type is therefore tracked per reference: the ready-to-paste replacement line is printed only where a SHA reference can take it, each tag reference is marked as one, and `--fix` skips such an action entirely rather than bumping the references that were already current and leaving the stale one behind.
59
+
60
+ The cause of the gap is worth naming because it repeated one level down: the first pattern written to catch these anchored the version at end-of-line, and the line in question carries a trailing comment explaining why it is not SHA-pinned. It matched nothing either.
61
+
62
+ So the gate no longer relies on recognising every shape. It reads the `uses:` scalar first, quoting and all, then classifies what it found; a reference it cannot classify is listed and **fails the run** rather than dropping out of the report. That covers quoted values (`uses: "owner/repo@v1"`), version tags carrying a prerelease or build suffix (`@v2.1.0-rc.1`), a SHA pin whose `# vX.Y.Z` comment is missing, and a reference pinned to a branch. Local actions (`./…`) and `docker://` images are skipped deliberately, because neither has an upstream release to compare against, and the body of a `run: |` block is skipped because those lines are script rather than YAML.
63
+
64
+ Two consequences of reading more shapes are worth calling out. Version comparison now follows semver precedence for prerelease identifiers, so `rc.1` is older than `rc.2` and older than `rc.10`, and a release candidate left pinned after the final release ships reports stale rather than current. And `--fix` now verifies that each rewrite actually landed: it reaches through a closing quote to replace a quoted pin, and if a collected reference does not match the replacement it says so and exits non-zero instead of reporting the action fixed over an unchanged file.
65
+
66
+ The pattern matching is gone. Whether a `uses` token is a key is a question about YAML structure, and a pattern cannot answer it: every attempt to widen one admitted a shape it read wrongly, and every attempt to narrow one dropped a shape it should have read. The collector now scans, tracking the three things that actually decide it — quoting, comments, and flow-collection depth.
67
+
68
+ Every form is read as a result: block style, flow mappings whether `uses` is the first key or the fifth, mappings spanning lines, quoted keys (`"uses":`), quoted values, and values inside nested flow collections. And in the other direction, `uses` inside a quoted string, inside a comment, or inside a `run: |` body is text rather than a key, so `- { run: "echo a, uses: owner/repo@main" }` names no action at all. Both block-scalar indicator orders are handled, since `|2-` and `|-2` are equally valid and misreading one scans a shell script as YAML.
69
+
70
+ Position decides what counts, not the value. A reference is a `uses` under `steps`, or a job's own `uses` for a reusable workflow; anything else spelled `uses` is data. That distinction cannot be made from the value, because `owner/repo@main` is a perfectly ordinary string to put in an `env` block or pass through `with`, and matrix `include` entries carry properties an operator names themselves. Naming the two positions the schema defines is finite; listing every container that is not one is not.
71
+
72
+ What the scan covers is printed on every run, as a `scope` line in the report and a `scope` field in `--json`. The original defect was a form the gate did not read and did not say so; that is now two separate things it cannot do.
73
+
74
+ If you run this gate against your own workflows, expect it to fail on references it could previously not see. That is the point of the change; each one it names is a pin whose currency was never being checked. · *`check-actions-currency.js --json` emits only the JSON document* — The JSON branch wrote the document and then fell through into the summary blocks, which wrote to the same stream regardless of the flag. Every `--json` run therefore ended with a `[actions-currency] …` line after the closing brace, and `JSON.parse` on the stream failed with "Unexpected non-whitespace character after JSON". `--fix --json` was the same shape with more trailing text.
75
+
76
+ Every human-readable line now goes through a writer that is silent under `--json`. Exit codes are unchanged: a machine reader still gets a non-zero exit when something is stale.
77
+
11
78
  - v0.18.51 (2026-08-22) — **Five parsers could be made to spend their time on the thing they were parsing.** Three prompt-injection detectors, the BIMI logo parser and the raw-write data-residency gate each ran a pattern whose cost grew with the square or the cube of its input while an ordinary input of the same length cost a millisecond. A 64 KiB prompt took up to 4.5 seconds to classify; a 32 KiB logo took 409 milliseconds to parse; a 4 KB raw UPDATE took 7 seconds to get a verdict from a gate that accepts statements twenty-four times longer. All five now cost what ordinary input of that length costs, and each was checked against a corpus to confirm it still decides what it decided before. Separately, a verified VMC logo was reported as absent when its SVG began with an XML declaration and a DOCTYPE. **Changed:** *`b.guardFilename` no longer repairs a null byte, and five options that accept one value now say so* — 0.18.47 accepted `nullBytePolicy: "strip"` on `b.guardFilename` and removed the null byte from the name. 0.18.48 stopped accepting it. That removal was correct and was not written down, which is the part being fixed here.
12
79
 
13
80
  It was correct because a null byte in a filename is a truncation attack, not a typo: the name the check reads and the name the operating system acts on differ at the byte, so repairing it produces a name nobody validated. The guard refuses with `filename.null-byte` and the message has said `null-byte truncation is never sanitizable` throughout. There is no replacement policy value, because there is no safe repair. A caller that was asking for the strip should refuse the input instead, or rename before validating.
@@ -280,6 +280,19 @@ function _hasUncPrefix(name) {
280
280
 
281
281
  // An NTFS alternate-data-stream suffix: a colon followed by a run with no
282
282
  // further colon and no separator in it, at the very end of the name.
283
+ // One wording for every path that refuses a stream-suffixed name. A caller who
284
+ // set `adsPolicy: "allow"` can arrive at any of them — sanitize in either mode,
285
+ // validate, or gate — and each has to answer the same question, or the option
286
+ // reads as broken from whichever door they happened to use. Saying it in one
287
+ // place and only one is how three of the four ended up silent the first time.
288
+ //
289
+ // `verifyExtractionPath` is deliberately absent: it is the one entry point that
290
+ // HONOURS the option, so it never reaches this text.
291
+ var ADS_SCOPE_NOTE = "always refused here; adsPolicy \"allow\" applies only " +
292
+ "to verifyExtractionPath";
293
+ var ADS_SNIPPET = "NTFS alternate data stream syntax (name:stream) — " + ADS_SCOPE_NOTE;
294
+ var ADS_MESSAGE = "filename contains " + ADS_SNIPPET;
295
+
283
296
  function _hasAdsSuffix(name) {
284
297
  var colon = name.lastIndexOf(":");
285
298
  if (colon === -1 || colon === name.length - 1) return false;
@@ -469,7 +482,7 @@ function _detectIssues(input, opts) {
469
482
  issues.push({
470
483
  kind: "ntfs-ads", severity: "critical",
471
484
  ruleId: "filename.ntfs-ads",
472
- snippet: "NTFS alternate data stream syntax (name:stream)",
485
+ snippet: ADS_SNIPPET,
473
486
  });
474
487
  }
475
488
  }
@@ -660,9 +673,18 @@ function _sanitize(input, opts) {
660
673
  name = "_" + name;
661
674
  }
662
675
 
663
- // ADS detection.
676
+ // ADS detection. Not gated on `adsPolicy`, and the message says so: a
677
+ // stream-suffixed name is one of the shapes a filename guard always refuses,
678
+ // because on Windows the write lands on a hidden stream of the base file
679
+ // rather than on a file anyone can see. `adsPolicy` still takes "allow", for
680
+ // `verifyExtractionPath` alone, where an operator may be deliberately
681
+ // extracting stream-suffixed entries to a root they chose.
682
+ //
683
+ // The scope is in the message because it is invisible from the call site: a
684
+ // caller who sets "allow" here gets a refusal that, without it, never
685
+ // mentions the setting they changed and so reads as the option being broken.
664
686
  if (_hasAdsSuffix(name)) {
665
- throw _err("filename.ntfs-ads", "filename contains NTFS alternate data stream syntax");
687
+ throw _err("filename.ntfs-ads", ADS_MESSAGE);
666
688
  }
667
689
 
668
690
  // Length cap.
@@ -796,7 +818,7 @@ function _sanitizeStripMode(input, opts) {
796
818
  throw _err("filename.unc", "UNC path syntax");
797
819
  }
798
820
  if (_hasAdsSuffix(name) && name.charAt(0) !== "/") {
799
- throw _err("filename.ntfs-ads", "filename contains NTFS alternate data stream syntax");
821
+ throw _err("filename.ntfs-ads", ADS_MESSAGE);
800
822
  }
801
823
  if (Buffer.byteLength(name, "utf8") > opts.maxBytes) {
802
824
  throw _err("filename.length", "filename exceeds maxBytes " + opts.maxBytes);
package/lib/guard-yaml.js CHANGED
@@ -68,6 +68,7 @@
68
68
  */
69
69
 
70
70
  var codepointClass = require("./codepoint-class");
71
+ var yamlLex = require("./yaml-lex");
71
72
  var lazyRequire = require("./lazy-require");
72
73
  var gateContract = require("./gate-contract");
73
74
  var C = require("./constants");
@@ -95,11 +96,11 @@ var SAFE_CORE_TAGS = Object.freeze([
95
96
  "!!binary", "!!timestamp", "!!merge",
96
97
  ]);
97
98
 
98
- // Characters that may precede an anchor declaration (`&name`) or an alias
99
- // reference (`*name`) the start of the document, whitespace, or one of the
100
- // structural characters a name can follow.
101
- var ANCHOR_LEAD_CHARS = ":-";
102
- var ALIAS_LEAD_CHARS = ":-[{,";
99
+ // There is no table of characters an anchor or alias is allowed to follow any
100
+ // more. Two of them existed, one per sigil, and each was a guess at YAML's
101
+ // grammar written as a character class: they admitted a sigil in the middle of
102
+ // a plain scalar and excluded the compact flow form `{"a":&anchor v}` that YAML
103
+ // permits. Position is decided once, by the shared lexer's mask.
103
104
 
104
105
  // The YAML 1.1 boolean-shaped tokens that make an unquoted scalar change type.
105
106
  // `true` and `false` are valid YAML 1.2 booleans and are not flagged; these
@@ -119,14 +120,17 @@ function _isSpace(cc) {
119
120
 
120
121
  // Every `&name` / `*name` in the document, given the sigil and the characters
121
122
  // that may precede it. Returns the names, in order.
122
- function _collectSigilNames(text, sigil, leadChars) {
123
+ // `text` is the MASK, in which a sigil survives only where it opens a node, so
124
+ // there is no test on the preceding character here at all. That judgement used
125
+ // to be a hand-written list of characters a sigil was allowed to follow, kept
126
+ // separately for anchors and for aliases, and it was wrong in both directions:
127
+ // it admitted a `&` in the middle of a plain scalar, and it had no way to allow
128
+ // `{"a":&anchor v}`, where YAML's JSON compatibility lets a quoted key take its
129
+ // colon with no space after it. One scanner decides position now.
130
+ function _collectSigilNames(text, sigil) {
123
131
  var out = [];
124
132
  for (var i = 0; i < text.length; i += 1) {
125
133
  if (text.charAt(i) !== sigil) continue;
126
- if (i > 0) {
127
- var lead = text.charCodeAt(i - 1);
128
- if (!_isSpace(lead) && leadChars.indexOf(text.charAt(i - 1)) === -1) continue;
129
- }
130
134
  if (!_isNameStart(text.charCodeAt(i + 1))) continue;
131
135
  var end = i + 2;
132
136
  while (end < text.length && _isNameChar(text.charCodeAt(end))) end += 1;
@@ -325,9 +329,11 @@ function _scanTags(text) {
325
329
  var tags = [];
326
330
  for (var i = 0; i < text.length; i += 1) {
327
331
  if (text.charAt(i) !== "!") continue;
328
- var atStart = i === 0 ||
329
- codepointClass.inRanges(text.charCodeAt(i - 1), codepointClass.WHITESPACE_RANGES);
330
- if (!atStart) continue;
332
+ // No "preceded by whitespace" test. That WAS the original defect, and once
333
+ // the mask decides position it is not merely redundant but harmful: in
334
+ // `{"a":!!python/object x}` the character before the tag is the colon of a
335
+ // JSON-style key, so the test skipped it and a deserialization tag reached
336
+ // both screens unreported. Position is the mask's question now.
331
337
  var nameAt = text.charAt(i + 1) === "!" ? i + 2 : i + 1;
332
338
  if (!codepointClass.isAsciiLetter(text.charCodeAt(nameAt))) continue;
333
339
  var end = nameAt + 1;
@@ -354,8 +360,22 @@ function _detectIssues(input, opts) {
354
360
  if (pre.done) return pre.issues;
355
361
  var issues = pre.issues;
356
362
 
363
+ // Sigil scans run against the MASK, not the source. A `!`, `&` or `*` means
364
+ // what it looks like only at a node start, and the previous rule — "after
365
+ // whitespace" — cannot tell a node start from the middle of a scalar. It
366
+ // reported a tag for the bang in a comment, in a quoted string, in a block
367
+ // scalar's shell script, and in ordinary prose: `x: 1 # note !bang` was
368
+ // refused. The mask is index-aligned with the source and the same length, so
369
+ // every location and line number reported below is still the source's.
370
+ //
371
+ // Only the three sigil scans use it. The value-shaped detectors further down
372
+ // (the Norway problem, leading zeros, merge keys) are asking about scalar
373
+ // CONTENT, which is exactly what the mask removes, so they keep reading the
374
+ // source.
375
+ var masked = yamlLex.maskNonStructural(input);
376
+
357
377
  // 1. Tag-injection scan.
358
- var tagHits = _scanTags(input);
378
+ var tagHits = _scanTags(masked);
359
379
  for (var ti = 0; ti < tagHits.length; ti += 1) {
360
380
  var t = tagHits[ti];
361
381
  if (t.kind === "dangerous") {
@@ -392,8 +412,8 @@ function _detectIssues(input, opts) {
392
412
  }
393
413
 
394
414
  // 2. Anchor / alias recursion scan.
395
- var anchors = _collectSigilNames(input, "&", ANCHOR_LEAD_CHARS);
396
- var aliases = _collectSigilNames(input, "*", ALIAS_LEAD_CHARS);
415
+ var anchors = _collectSigilNames(masked, "&");
416
+ var aliases = _collectSigilNames(masked, "*");
397
417
  if (anchors.length > opts.maxAnchors) {
398
418
  issues.push({
399
419
  kind: "anchor-cap", severity: "high",
@@ -531,6 +551,24 @@ function _mappingEntryAt(line) {
531
551
  return null;
532
552
  }
533
553
 
554
+ // The column the line's content starts in.
555
+ function _indentOfLine(line) {
556
+ var i = 0;
557
+ while (i < line.length && _isSpace(line.charCodeAt(i))) i += 1;
558
+ return i;
559
+ }
560
+
561
+ // The indent of a sequence dash opening this line, or -1 when the line does not
562
+ // open a sequence item. A dash counts only when it stands alone as a token: `-`
563
+ // at end of line, or followed by a space. `-quux` and `-1` are scalars.
564
+ function _sequenceDashIndent(line) {
565
+ var i = 0;
566
+ while (i < line.length && _isSpace(line.charCodeAt(i))) i += 1;
567
+ if (i >= line.length || line.charAt(i) !== "-") return -1;
568
+ if (i + 1 < line.length && !_isSpace(line.charCodeAt(i + 1))) return -1;
569
+ return i;
570
+ }
571
+
534
572
  // Is the line blank, or a comment?
535
573
  function _isCommentLine(line) {
536
574
  var i = 0;
@@ -544,19 +582,117 @@ function _detectDuplicateKeysYaml(text) {
544
582
  var dups = Object.create(null);
545
583
  var lines = _splitLines(text);
546
584
  var indentScopes = Object.create(null);
585
+ // The sequence items currently open, outermost first, each with the column
586
+ // its first key sat in. Every key of an item at its top level is filed under
587
+ // one scope whatever column it is written in, because it is one mapping.
588
+ //
589
+ // A STACK, because sequences nest. Holding the innermost item in a pair of
590
+ // variables let a nested sequence overwrite its parent's scope and never give
591
+ // it back, so a key repeated in the OUTER item after the nested one closed
592
+ // was filed somewhere else and went unreported:
593
+ //
594
+ // - a: 1
595
+ // inner:
596
+ // - x: 1
597
+ // a: 2 <- a duplicate of the first `a`, and it was missed
598
+ //
599
+ // The same shape as the defect this detector reports, one level up: a single
600
+ // slot standing in for something there can be several of.
601
+ var itemStack = [];
547
602
  for (var i = 0; i < lines.length; i += 1) {
548
603
  var line = lines[i];
549
604
  if (line.length === 0 || _isCommentLine(line)) continue;
605
+ // A sequence item OPENS A NEW MAPPING, so it ends the previous item's and
606
+ // everything nested inside it. That is decided from the DASH, before
607
+ // anything else, because a line may be a sequence item and carry no mapping
608
+ // entry of its own:
609
+ //
610
+ // steps:
611
+ // -
612
+ // p: 1
613
+ // -
614
+ // p: 2
615
+ //
616
+ // A dash alone has no `key: value` on it, so a reset that waited for one
617
+ // never ran and the second item's `p` was still read as a duplicate of the
618
+ // first's. The boundary is the dash; whether the item writes its first key
619
+ // beside it or underneath it is a matter of layout.
620
+ var dashAt = _sequenceDashIndent(line);
621
+ // An item is over once the document comes back out to its dash's column or
622
+ // further left. That is true of the next item's own dash as much as of a
623
+ // key belonging to the enclosing mapping, so it is measured here, before
624
+ // this line is classified at all.
625
+ var lineIndent = dashAt >= 0 ? dashAt : _indentOfLine(line);
626
+ while (itemStack.length &&
627
+ itemStack[itemStack.length - 1].dash >= lineIndent) itemStack.pop();
628
+ if (dashAt >= 0) {
629
+ Object.keys(indentScopes).forEach(function (k) {
630
+ if (Number(k) > dashAt) delete indentScopes[k];
631
+ });
632
+ // keyIndent is set by this item's first key, wherever it is written.
633
+ itemStack.push({ dash: dashAt, keyIndent: -1 });
634
+ }
550
635
  var entry = _mappingEntryAt(line);
551
636
  if (!entry) continue;
552
637
  var indent = entry.indent;
553
638
  var key = entry.key.trim();
554
- if (key.charAt(0) === "-" || key.charAt(0) === "[" || key.charAt(0) === "{") continue;
555
- if (!indentScopes[indent]) indentScopes[indent] = Object.create(null);
556
- if (indentScopes[indent][key]) dups[key] = true;
557
- else indentScopes[indent][key] = true;
639
+ if (key.charAt(0) === "[" || key.charAt(0) === "{") continue;
640
+ // The key written INLINE with the dash belongs to the item's mapping, and
641
+ // sits at the indent AFTER the dash and its space — so that is the scope it
642
+ // is registered in, alongside the keys written underneath it. It used to be
643
+ // skipped entirely, which meant repeating it went unreported.
644
+ var dash = key.charAt(0) === "-" &&
645
+ (key.length === 1 || _isSpace(key.charCodeAt(1)));
646
+ var scopeAt = indent;
647
+ if (dash) {
648
+ var after = 1;
649
+ while (after < key.length && _isSpace(key.charCodeAt(after))) after += 1;
650
+ key = key.slice(after).trim();
651
+ // `- ` alone, or `- - x`: no inline key of this item's own to register.
652
+ if (!key || key.charAt(0) === "-" || key.charAt(0) === "[" ||
653
+ key.charAt(0) === "{") continue;
654
+ scopeAt = indent + after;
655
+ }
656
+ // The item's mapping is ONE mapping however its keys are laid out, so the
657
+ // scope it is tracked under must not depend on spacing. `- a: 1` puts its
658
+ // inline key at column 4 while the key written under it sits at column 2,
659
+ // and keying on the raw column filed them separately — so a repeat across
660
+ // those two lines went unreported, which is exactly the smuggling shape
661
+ // this detector exists for (one parser reads two keys, another reads one).
662
+ //
663
+ // The item's top level is every key deeper than the dash and no deeper than
664
+ // the first key it saw. Anything past that is genuinely nested and keeps
665
+ // its own column, so `- a: 1` / ` b:` / ` a: 2` is still not a
666
+ // duplicate.
667
+ // Half a column past the dash: a number, so the pruning comparisons below
668
+ // and at the dash keep working unchanged, and one that sorts between the
669
+ // dash and anything nested inside the item.
670
+ var item = itemStack.length ? itemStack[itemStack.length - 1] : null;
671
+ if (item && scopeAt > item.dash) {
672
+ // The item's top level is every key no deeper than the SHALLOWEST key it
673
+ // has shown, and the bound moves down as shallower ones appear. Two
674
+ // failures pinned this from opposite sides:
675
+ //
676
+ // - a: 1 the inline key sits at column 4 because of the extra
677
+ // b: spacing, but the item's mapping is written at 2. If
678
+ // a: 2 4 is taken as the bound, the NESTED `a` at 4 counts
679
+ // as top level and reads as a duplicate.
680
+ //
681
+ // - a: here the inline key IS the bound, at 2. If it sets
682
+ // x: 1 nothing, the first nested key at 4 becomes the bound
683
+ // a: 2 and the nested `a` reads as a duplicate instead.
684
+ //
685
+ // So the inline key establishes the bound and a later, shallower key
686
+ // lowers it. Extra spacing after the indicator is presentation; the
687
+ // shallowest key is the structure.
688
+ if (item.keyIndent === -1 || scopeAt < item.keyIndent) item.keyIndent = scopeAt;
689
+ if (scopeAt <= item.keyIndent) scopeAt = item.dash + 0.5;
690
+ }
691
+ if (!indentScopes[scopeAt]) indentScopes[scopeAt] = Object.create(null);
692
+ if (indentScopes[scopeAt][key]) dups[key] = true;
693
+ else indentScopes[scopeAt][key] = true;
558
694
  Object.keys(indentScopes).forEach(function (k) {
559
- if (Number(k) > indent) delete indentScopes[k];
695
+ if (Number(k) > scopeAt) delete indentScopes[k];
560
696
  });
561
697
  }
562
698
  return Object.keys(dups);
@@ -60,6 +60,7 @@ var boundedMap = require("../bounded-map");
60
60
  var numericBounds = require("../numeric-bounds");
61
61
  var safeBuffer = require("../safe-buffer");
62
62
  var codepointClass = require("../codepoint-class");
63
+ var yamlLex = require("../yaml-lex");
63
64
  var { FrameworkError } = require("../framework-error");
64
65
 
65
66
  class SafeYamlError extends FrameworkError {
@@ -227,7 +228,10 @@ function _findAnchorOrAlias(text) {
227
228
  var ch = text.charAt(i);
228
229
  if (ch !== "&" && ch !== "*") continue;
229
230
  var atStart = i === 0;
230
- if (!atStart && !_isWhitespaceChar(text.charAt(i - 1))) continue;
231
+ // No test on the preceding character: `text` is the shared mask, in which a
232
+ // sigil survives only where it opens a node. The whitespace test was the
233
+ // original defect and it also had no way to admit `{"a":&anchor v}`, where
234
+ // a quoted key takes its colon with no space after it.
231
235
  if (_NAME_HEAD.indexOf(text.charAt(i + 1)) === -1 || text.charAt(i + 1) === "") continue;
232
236
  return { index: atStart ? i : i - 1, sigil: ch };
233
237
  }
@@ -239,8 +243,10 @@ function _findTag(text) {
239
243
  for (var i = 0; i < text.length; i += 1) {
240
244
  if (text.charAt(i) !== "!") continue;
241
245
  var atStart = i === 0;
242
- var before = text.charAt(i - 1);
243
- if (!atStart && !(before === "-" || _isWhitespaceChar(before))) continue;
246
+ // Same as above: position is the mask's judgement. Requiring whitespace or
247
+ // a dash before the tag let `{"a":!!python/object x}` through, because the
248
+ // character in front of it is the colon of a JSON-style key — and a
249
+ // deserialization tag is the one thing this ban exists for.
244
250
  // The pattern is greedy, so it takes two `!` when both are present.
245
251
  var after = text.charAt(i + 1) === "!" ? text.charAt(i + 2) : text.charAt(i + 1);
246
252
  if (after.length !== 1) continue;
@@ -1194,6 +1200,21 @@ function _preValidate(input) {
1194
1200
  advance();
1195
1201
  }
1196
1202
 
1203
+ // The masking above is kept for ONE thing: it is the scan that notices an
1204
+ // unterminated quote, and that diagnosis is this parser's to give. What it
1205
+ // produced was not a usable screen. Its own header promised that block-scalar
1206
+ // bodies were masked and there was no block-scalar handling in it at all, and
1207
+ // comment text was copied through verbatim under a comment saying so — which
1208
+ // is why `x: 1 # note !bang` and `x: |` / ` echo !boom` were both refused as
1209
+ // tags. A plain scalar was not understood either, so `x: hello !world` was
1210
+ // refused for a bang sitting in the middle of a value.
1211
+ //
1212
+ // The banned-construct scan therefore runs against the shared lexer's mask.
1213
+ // `guard-yaml` had the same three gaps in a separate implementation, and one
1214
+ // scan answering "which region is this character in" is what stops the two
1215
+ // drifting apart again.
1216
+ safe = yamlLex.maskNonStructural(input);
1217
+
1197
1218
  // Now scan `safe` for banned constructs.
1198
1219
  // Banned tokens (must be at line-start or after whitespace, not in keys):
1199
1220
  // &name anchor
@@ -0,0 +1,533 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // Where in a YAML document a character actually SITS.
6
+ //
7
+ // Two modules were answering that question separately and getting different
8
+ // wrong answers. `guard-yaml` decided a `!` opened a tag if it followed
9
+ // whitespace, so it reported one inside a quoted scalar, inside a block-scalar
10
+ // body, and inside a comment. `parsers/safe-yaml` masked quoted scalars but
11
+ // passed comment text through verbatim and had no block-scalar handling at all,
12
+ // despite its own note promising both. Between them every ordinary document
13
+ // carrying an exclamation mark in prose was refused, by one module or the other.
14
+ //
15
+ // The question is not "what precedes this character" — it is "what region is
16
+ // this character in", and no amount of looking at the previous byte answers it.
17
+ // Region is a property of everything before, so it takes a scan. This module is
18
+ // that scan, written once, so the two callers cannot drift apart again.
19
+ //
20
+ // `maskNonStructural` returns a string the SAME LENGTH as its input, with every
21
+ // non-structural region replaced by spaces and newlines preserved. Callers keep
22
+ // their existing sigil searches and run them against the mask instead of the
23
+ // source: an index into one is an index into the other, so reported locations
24
+ // and line numbers stay true.
25
+ //
26
+ // What survives the mask, because it is what the callers are looking for:
27
+ // structural punctuation, node properties (`!tag`, `&anchor`, `*alias`),
28
+ // directive lines, and document markers. What is masked is scalar content in
29
+ // every form it takes: quoted, plain, and block.
30
+ //
31
+ // The mask is deliberately readier to KEEP than to hide. Masking something
32
+ // structural is the dangerous direction: it does not refuse a good document, it
33
+ // hides a real tag inside a bad one and hands the callers something they then
34
+ // call clean. Every case below that looked like a tidy simplification and was
35
+ // not — blanking the rest of a line, measuring a block body from the wrong
36
+ // column — failed in exactly that direction.
37
+
38
+ // No line-splitting helper is used here on purpose: `codepointClass.splitLines`
39
+ // strips the carriage return of a CRLF pair, and this function's whole contract
40
+ // is that its output is the same length as its input. See the split below.
41
+
42
+ // Deliberately not a regex, in either sense. The guard and safe families forbid
43
+ // them, and a scanner is what this file exists to be.
44
+ function _isSpace(code) { return code === 0x20 || code === 0x09; }
45
+
46
+ function _isPlainSpace(ch) { return ch === " " || ch === "\t"; }
47
+
48
+ // A node property is a tag, an anchor, or an alias. They chain, so `!tag &a`
49
+ // and `&a !tag` are both a single node's properties and the scalar begins after
50
+ // the last of them.
51
+ function _isPropertySigil(ch) { return ch === "!" || ch === "&" || ch === "*"; }
52
+
53
+ // A block-scalar header is `|` or `>`, optionally followed by the chomping and
54
+ // indentation indicators in EITHER order (`|2-` and `|-2` are equally valid),
55
+ // and then nothing but whitespace or a comment. Misreading one scans a shell
56
+ // script as if it were YAML.
57
+ // Returns `{ end, declared }` or null. `declared` is the indentation indicator's
58
+ // digit when the header carries one, or 0. That digit is not decoration: it
59
+ // DECLARES the body's indentation relative to the parent node, so a body whose
60
+ // first line happens to be indented further does not move it. Detecting from
61
+ // the first line in that case ends the block early and hands the rest of a
62
+ // scalar to the structural scan.
63
+ function _blockHeaderEnd(line, at) {
64
+ var ch = line.charAt(at);
65
+ if (ch !== "|" && ch !== ">") return null;
66
+ var i = at + 1;
67
+ var declared = 0, sawChomp = false;
68
+ while (i < line.length) {
69
+ var c = line.charAt(i);
70
+ if (c >= "1" && c <= "9" && !declared) { declared = Number(c); i += 1; continue; }
71
+ if ((c === "-" || c === "+") && !sawChomp) { sawChomp = true; i += 1; continue; }
72
+ break;
73
+ }
74
+ // Only a header if the rest of the line is blank or a comment; `x: |foo` is
75
+ // an ordinary plain scalar that happens to start with a bar.
76
+ var j = i;
77
+ while (j < line.length && _isPlainSpace(line.charAt(j))) j += 1;
78
+ if (j < line.length && line.charAt(j) !== "#") return null;
79
+ return { end: i, declared: declared };
80
+ }
81
+
82
+ // The document markers and directive lines a caller still needs to see. A
83
+ // directive is only a directive at column zero, and `---` / `...` only at the
84
+ // start of a line.
85
+ function _isVerbatimLine(line, indent) {
86
+ if (line.charAt(0) === "%") return true;
87
+ // Column ZERO only. YAML puts document markers there and nowhere else, so an
88
+ // indented `---` is scalar content — `- hello` / ` ---` / ` !world` is one
89
+ // plain scalar. Matching it wherever it appeared ended the document, threw
90
+ // away the scalar state, and left the continuation to be read as structure.
91
+ if (indent !== 0) return false;
92
+ if (line.indexOf("---") === 0 &&
93
+ (line.length === 3 || _isPlainSpace(line.charAt(3)))) return true;
94
+ if (line.indexOf("...") === 0 &&
95
+ (line.length === 3 || _isPlainSpace(line.charAt(3)))) return true;
96
+ return false;
97
+ }
98
+
99
+ function maskNonStructural(text) {
100
+ var src = String(text == null ? "" : text);
101
+ // Split on the newline ALONE, keeping any `\r` with the line it terminates.
102
+ // `codepointClass.splitLines` strips it, and rejoining those with "\n" drops
103
+ // one character per CRLF line — which would shift every index after the first
104
+ // such line and silently break the alignment this whole function exists to
105
+ // provide. A mask that is not the same length as its source is worse than no
106
+ // mask, because the locations it reports are confidently wrong.
107
+ var lines = src.split("\n");
108
+ var out = [];
109
+ // A block scalar's body indentation is DETECTED from its first content line,
110
+ // which is what YAML specifies and the only thing that works. Two fixed
111
+ // columns were tried and each failed the other's case: measuring from the
112
+ // key's column masks nothing when extra spacing after a dash puts the body in
113
+ // that same column, and measuring from a canonical column swallows a sibling
114
+ // key written between the two. With extra spacing the body can sit anywhere
115
+ // past the dash, so only the body itself says where it starts.
116
+ //
117
+ // `blockOwner` is the column the body must beat for the block to have one at
118
+ // all; `blockBody` is the detected indentation, -1 until the first content
119
+ // line sets it. Both -1 when no block is open.
120
+ var blockOwner = -1;
121
+ var blockBody = -1;
122
+ // A quoted scalar may span lines, so the quote that opened one carries across
123
+ // until it closes. Scanning each line independently would read the
124
+ // CONTINUATION as structure, which puts back the exact false positive this
125
+ // module exists to remove: `x: "hello` / ` !world"` would name a tag on the
126
+ // second line.
127
+ var openQuote = null;
128
+ // A flow collection may also span lines, so its depth carries across too. It
129
+ // decides where a plain scalar ends, and resetting it per line would end one
130
+ // at the wrong place inside a multi-line `[ ... ]`.
131
+ var flowDepth = 0;
132
+ // A PLAIN scalar spans lines as well — that is how a long description gets
133
+ // written without quotes — and its continuation lines are indented further
134
+ // than the node that opened it. Reading one as a fresh line puts the false
135
+ // positive back a fourth way: in
136
+ //
137
+ // x: hello
138
+ // !world
139
+ //
140
+ // the value is `hello !world` and the bang introduces nothing.
141
+ //
142
+ // -1 when no plain scalar is open. Otherwise the indent of the node that
143
+ // opened it, which a continuation must beat. This is set ONLY when a line
144
+ // actually read plain-scalar content, so `x:` with its value underneath does
145
+ // not arm it and a nested mapping is still read as structure.
146
+ var plainOpen = -1;
147
+ // ...and whether that scalar is a sequence item's own, which decides whether
148
+ // a continuation at the SAME column counts.
149
+ var plainFromDash = false;
150
+
151
+ for (var li = 0; li < lines.length; li += 1) {
152
+ var raw = lines[li];
153
+ // The carriage return of a CRLF pair is a line TERMINATOR, not content, so
154
+ // it is held aside and put back verbatim. Letting it into the scan would
155
+ // have it read as the first character of a plain scalar and masked to a
156
+ // space, which changes the bytes of a document the mask is meant to mirror.
157
+ var cr = raw.length && raw.charAt(raw.length - 1) === "\r";
158
+ var line = cr ? raw.slice(0, raw.length - 1) : raw;
159
+ var indent = 0;
160
+ while (indent < line.length && _isSpace(line.charCodeAt(indent))) indent += 1;
161
+ var blank = indent === line.length;
162
+
163
+ // A quoted scalar carried over from an earlier line owns this one until it
164
+ // closes, and nothing before that point is structure — not a `---` that
165
+ // looks like a document marker, and not a `%` in column zero that looks
166
+ // like a directive. So this is answered before either of those.
167
+ //
168
+ // What follows the closing quote on that line IS structure again, and the
169
+ // scan resumes there rather than blanking it. Blanking hid a real tag: in
170
+ // `x: ["first` / ` second", !tag value]` the comma and the tag after the
171
+ // scalar belong to the collection, and masking them takes a tagged document
172
+ // and hands both screens something they call clean.
173
+ var resumeAt = -1;
174
+ var resumePrefix = "";
175
+ if (openQuote !== null) {
176
+ var cont = _maskQuotedBody(line, 0, openQuote);
177
+ if (!cont.closed) {
178
+ out.push(cont.masked + (cr ? "\r" : ""));
179
+ continue; // the whole line is body
180
+ }
181
+ openQuote = null;
182
+ resumeAt = cont.end;
183
+ resumePrefix = cont.masked;
184
+ }
185
+
186
+ if (resumeAt === -1) {
187
+ if (blockOwner >= 0) {
188
+ if (blank) { out.push(_blanked(line) + (cr ? "\r" : "")); continue; }
189
+ if (blockBody === -1) {
190
+ // The first content line sets the body's indentation — provided it is
191
+ // past the owner at all. If it is not, the block has an empty body and
192
+ // this line is structure.
193
+ if (indent > blockOwner) {
194
+ blockBody = indent;
195
+ out.push(_blanked(line) + (cr ? "\r" : ""));
196
+ continue;
197
+ }
198
+ blockOwner = -1;
199
+ } else if (indent >= blockBody) {
200
+ out.push(_blanked(line) + (cr ? "\r" : ""));
201
+ continue;
202
+ } else {
203
+ blockOwner = -1;
204
+ blockBody = -1; // the body ended here
205
+ }
206
+ }
207
+ if (blank) { out.push(raw); continue; }
208
+ // A directive or a document marker is structure the callers still need to
209
+ // see, but only the marker itself is. A comment may follow one on the same
210
+ // line, and its text is no more YAML there than anywhere else — passing
211
+ // the whole line through left `--- # note !bang` naming a tag, which is
212
+ // the very class this module removes, surviving in the one branch that
213
+ // skipped the scan.
214
+ if (_isVerbatimLine(line, indent)) {
215
+ out.push(_maskTrailingComment(line) + (cr ? "\r" : ""));
216
+ // A document marker ends the document, so EVERY piece of carried state
217
+ // ends with it. Resetting only the flow depth left a scalar open across
218
+ // the boundary, and with the sequence-item rule that meant
219
+ // `- hello` / `---` / ` !tag v` read the new document's first line as
220
+ // a continuation of the old document's last item — masking a real tag.
221
+ // A marker is the one place where "carry this across lines" is always
222
+ // wrong, so the reset is total rather than itemised.
223
+ flowDepth = 0;
224
+ plainOpen = -1;
225
+ plainFromDash = false;
226
+ blockOwner = -1;
227
+ blockBody = -1;
228
+ continue;
229
+ }
230
+ }
231
+
232
+ // Resuming after a closed continuation starts where the quote ended, with
233
+ // that scalar already behind us; otherwise the line's own content begins.
234
+ var masked = resumeAt === -1 ? line.slice(0, indent) : resumePrefix;
235
+ var i = resumeAt === -1 ? indent : resumeAt;
236
+ // A node may begin here: at the start of the line's content, and again
237
+ // after every structural token that introduces one.
238
+ //
239
+ // Two things mean it does NOT begin here. A resumed line has just finished
240
+ // reading a quoted scalar. And a line continuing a PLAIN scalar opened
241
+ // earlier is more of that scalar, so its first character is content:
242
+ //
243
+ // x: hello
244
+ // !world
245
+ //
246
+ // The continuation is scanned rather than blanked, because inside a flow
247
+ // collection it may still end at a `,` or a `]` that belongs to the
248
+ // collection — `x: [hello` / ` !world]` closes a sequence on its second
249
+ // line. Closing the node position is enough to make the leading sigil fall
250
+ // into the plain-scalar branch, and it costs no special case.
251
+ // A scalar that IS a sequence item has its continuation aligned with the
252
+ // item's content column rather than deeper than it — `- hello` then
253
+ // ` !world` is the scalar `hello !world`, and both sit at column 2. So
254
+ // equality continues that one, while a mapping entry's value still needs a
255
+ // strictly deeper line, where equality would be a sibling key.
256
+ var continuesPlain = resumeAt === -1 && plainOpen >= 0 && !blank &&
257
+ (plainFromDash ? indent >= plainOpen : indent > plainOpen);
258
+ var atNodeStart = resumeAt === -1 && !continuesPlain;
259
+ // Where the NODE on this line begins, which is the line's indent until a
260
+ // sequence dash moves it along. A block scalar's body is measured from
261
+ // here, not from the leading whitespace.
262
+ var nodeIndent = indent;
263
+ // The column of the innermost sequence dash read on this line, or -1. A
264
+ // block scalar standing where that dash's own node goes belongs to the
265
+ // ITEM, so this is what bounds its body.
266
+ var dashIndent = -1;
267
+ // Was the token just read a JSON-LIKE key? YAML's JSON compatibility lets
268
+ // one take its value colon with no space after it, and there are two kinds:
269
+ // a quoted scalar, and a flow collection. Both end this flag set.
270
+ var prevWasJsonKey = false;
271
+ // Did the line finish inside a plain scalar? Set by the plain-scalar branch
272
+ // when it runs to the end of the line, and false the moment anything else
273
+ // is read after it.
274
+ var sawPlainToEol = false;
275
+ // The column the last plain scalar on this line started in.
276
+ var plainStartCol = -1;
277
+
278
+ while (i < line.length) {
279
+ var ch = line.charAt(i);
280
+ // Cleared here and re-set only by the quoted-scalar branch, so no branch
281
+ // can leave it true by forgetting to. The colon rule reads the captured
282
+ // copy rather than the live flag.
283
+ var jsonKeyBefore = prevWasJsonKey;
284
+ prevWasJsonKey = false;
285
+
286
+ // A comment opens on `#` at the start of the content or after whitespace,
287
+ // and runs to the end of the line. Its text is not YAML, whatever it
288
+ // says: `x: 1 # note !bang` names no tag.
289
+ if (ch === "#" && (i === indent || _isPlainSpace(line.charAt(i - 1)))) {
290
+ masked += _blanked(line.slice(i));
291
+ break;
292
+ }
293
+
294
+ // Whitespace carries the quoted-key flag rather than clearing it. YAML
295
+ // allows separation between a JSON-style key and its adjacent value, so
296
+ // `{"a" :!!python/object x}` is as valid as `{"a":!!python/object x}` —
297
+ // and clearing here left the second form closed and the first one open,
298
+ // which is a deserialization tag hidden behind one space.
299
+ if (_isPlainSpace(ch)) {
300
+ masked += ch; i += 1; prevWasJsonKey = jsonKeyBefore; continue;
301
+ }
302
+
303
+ // Structural punctuation stays visible, and each of these opens a node
304
+ // position after it.
305
+ if (ch === "{" || ch === "[") {
306
+ masked += ch; i += 1; flowDepth += 1; atNodeStart = true; continue;
307
+ }
308
+ if (ch === "}" || ch === "]") {
309
+ masked += ch; i += 1; if (flowDepth > 0) flowDepth -= 1;
310
+ atNodeStart = false;
311
+ // A closing delimiter ends a JSON-LIKE key just as a closing quote
312
+ // does, and YAML lets that kind of key take its colon with no space
313
+ // after it. `{{a: b}:!!python/object x}` is valid, and reading the `:`
314
+ // as ordinary text left the tag masked — the same bypass as the quoted
315
+ // key, through the other production for the same rule.
316
+ prevWasJsonKey = true;
317
+ continue;
318
+ }
319
+ if (ch === ",") { masked += ch; i += 1; atNodeStart = true; continue; }
320
+ if (ch === "-" && (i + 1 >= line.length || _isPlainSpace(line.charAt(i + 1)))) {
321
+ masked += ch; i += 1; atNodeStart = true;
322
+ // A sequence entry written inline starts a node PAST the dash, and any
323
+ // block scalar it opens is measured from there rather than from the
324
+ // line's leading whitespace. Getting this wrong is not a false refusal
325
+ // but a false ACCEPT: in
326
+ //
327
+ // - key: |
328
+ // body
329
+ // evil: !tag x
330
+ //
331
+ // `evil` is a sibling of `key`, and measuring the body against the
332
+ // dash's indent of zero swallows it — masking a real tag and handing
333
+ // both screens a document they then call clean.
334
+ var afterDash = i;
335
+ while (afterDash < line.length && _isPlainSpace(line.charAt(afterDash))) afterDash += 1;
336
+ if (afterDash < line.length) nodeIndent = afterDash;
337
+ // ...unless the item's node IS a block scalar, with no mapping in
338
+ // between: `- |` then ` !hello`. There the body starts at the same
339
+ // column the `|` sits in, so measuring from that column masks nothing
340
+ // and the scalar's first line reads as a tag. The owner of the block is
341
+ // the sequence ITEM, so the dash's column is what bounds it.
342
+ dashIndent = i - 1;
343
+ continue;
344
+ }
345
+ if (ch === "?" && (i + 1 >= line.length || _isPlainSpace(line.charAt(i + 1)))) {
346
+ masked += ch; i += 1; atNodeStart = true; continue;
347
+ }
348
+ // A colon separates a key from its value when whitespace follows, when a
349
+ // flow delimiter does — and, inside a flow collection, when the key was
350
+ // QUOTED. That last form is YAML's JSON compatibility: `{"a":value}` is
351
+ // valid and needs no space, so requiring one left the colon unread, the
352
+ // node position closed, and the value masked as though it were more of
353
+ // the key's scalar. `{"a":!!python/object x}` therefore reached both
354
+ // screens with its tag hidden — a deserialization tag, which is the
355
+ // single most dangerous thing this scan exists to surface.
356
+ if (ch === ":" &&
357
+ (i + 1 >= line.length || _isPlainSpace(line.charAt(i + 1)) ||
358
+ (flowDepth > 0 && ",}]".indexOf(line.charAt(i + 1)) !== -1) ||
359
+ (flowDepth > 0 && jsonKeyBefore))) {
360
+ masked += ch; i += 1; atNodeStart = true; continue;
361
+ }
362
+
363
+ // A node's properties survive: they are exactly what the callers scan for,
364
+ // and they are the ONLY place a `!`, `&` or `*` means what it looks like.
365
+ if (atNodeStart && _isPropertySigil(ch)) {
366
+ var pEnd = i + 1;
367
+ if (ch === "!" && line.charAt(pEnd) === "!") pEnd += 1;
368
+ while (pEnd < line.length && !_isPlainSpace(line.charAt(pEnd)) &&
369
+ (flowDepth === 0 || ",}]".indexOf(line.charAt(pEnd)) === -1)) pEnd += 1;
370
+ masked += line.slice(i, pEnd);
371
+ i = pEnd;
372
+ continue; // properties chain
373
+ }
374
+
375
+ // A quoted scalar: the quotes stay, the body goes. The body is content by
376
+ // construction, so nothing in it is ever structure.
377
+ if (ch === '"' || ch === "'") {
378
+ var qr = _maskQuotedBody(line, i + 1, ch);
379
+ masked += ch + qr.masked;
380
+ i = qr.end;
381
+ // Not closed on this line means the scalar CONTINUES, which YAML allows
382
+ // and which the line-at-a-time reading would otherwise lose. The quote
383
+ // is remembered so the next line is read as its body rather than as
384
+ // structure.
385
+ if (!qr.closed) { openQuote = ch; break; }
386
+ atNodeStart = false;
387
+ // Remembered for the colon rule above: a quoted scalar is one of the
388
+ // two JSON-like key forms that may take its colon with no space between.
389
+ prevWasJsonKey = true;
390
+ continue;
391
+ }
392
+
393
+ // A block-scalar header ends the line's structure; the body is masked by
394
+ // the outer loop.
395
+ var bHead = _blockHeaderEnd(line, i);
396
+ if (bHead) {
397
+ masked += line.slice(i, bHead.end);
398
+ // The body must beat the DASH when there is one, and the line's node
399
+ // otherwise. That is only a floor: where the body actually starts is
400
+ // detected from its first content line, because with extra spacing
401
+ // after a dash it can sit anywhere past that floor.
402
+ // The owner is the node the block hangs off: the ITEM when the header
403
+ // stands where the item's own node goes (`- |`), and the MAPPING ENTRY
404
+ // when it follows a key (`- key: |`). Using the dash for the second
405
+ // case made a sibling written straight after an empty block look like
406
+ // its first body line — `- key: |` / ` evil: !tag x` masked the tag.
407
+ //
408
+ // It also settles the padded case the honest way. `- key: |` puts the
409
+ // mapping at column 4, so a body at column 4 is not content by YAML's
410
+ // own indentation rule; that document is malformed, and reading its
411
+ // next line as structure surfaces whatever it says rather than hiding
412
+ // it. Where a shape is ambiguous, the reading that keeps a sibling
413
+ // VISIBLE is the one to take — the other hides real tags.
414
+ blockOwner = (dashIndent >= 0 && i === nodeIndent) ? dashIndent : nodeIndent;
415
+ // ...unless the header DECLARED it. `|2` fixes the body's indentation
416
+ // relative to the parent, so a first line indented further does not
417
+ // move it, and a later line back at the declared column is still body.
418
+ // Detecting in that case ends the block early and hands the rest of the
419
+ // scalar to the structural scan.
420
+ //
421
+ // Counted from that same owner: for `- key: |2` the indicator is
422
+ // relative to the mapping entry, not the dash, and counting from the
423
+ // dash puts the body two columns too far left and swallows the entry's
424
+ // siblings.
425
+ blockBody = bHead.declared ? blockOwner + bHead.declared : -1;
426
+ i = bHead.end;
427
+ atNodeStart = false;
428
+ continue;
429
+ }
430
+
431
+ // Anything else begins a PLAIN scalar, and everything to the end of it is
432
+ // content. This is the case the previous implementations had no notion of:
433
+ // in `x: hello !world` the `!world` sits inside a scalar that started at
434
+ // `hello`, so it names no tag, and only knowing a scalar had already begun
435
+ // can tell you that.
436
+ var s = i;
437
+ while (s < line.length) {
438
+ var c3 = line.charAt(s);
439
+ if (c3 === "#" && _isPlainSpace(line.charAt(s - 1))) break;
440
+ if (c3 === ":" &&
441
+ (s + 1 >= line.length || _isPlainSpace(line.charAt(s + 1)) ||
442
+ (flowDepth > 0 && ",}]".indexOf(line.charAt(s + 1)) !== -1))) break;
443
+ if (flowDepth > 0 && ",}][{".indexOf(c3) !== -1) break;
444
+ s += 1;
445
+ }
446
+ masked += _blanked(line.slice(i, s));
447
+ // Where this scalar BEGAN. A dash earlier on the line is not enough to
448
+ // make the scalar the item's own: in `- key: hello` the scalar belongs to
449
+ // `key`, and treating the next line at the mapping's column as its
450
+ // continuation masked the tag on a sibling key.
451
+ plainStartCol = i;
452
+ i = s;
453
+ atNodeStart = false;
454
+ // A plain scalar reaching the end of the line may continue on the next
455
+ // one. Only the LAST thing read on a line can, so this is recorded here
456
+ // and cleared by anything that follows it.
457
+ sawPlainToEol = s >= line.length;
458
+ }
459
+ // Armed only when the line ended inside a plain scalar, and measured
460
+ // against the node that opened it rather than the line's own indent. `x:`
461
+ // with nothing after it never arms this, so the mapping written underneath
462
+ // it is still read as structure rather than swallowed as text.
463
+ // Armed inside a flow collection too: a plain scalar spans lines there just
464
+ // as it does outside one, and requiring depth zero left `x: [hello` /
465
+ // ` !world]` reading its second line at a fresh node start.
466
+ if (sawPlainToEol && openQuote === null && blockOwner < 0) {
467
+ if (!continuesPlain) {
468
+ plainOpen = nodeIndent;
469
+ // Whether the scalar hangs directly off a sequence dash decides how its
470
+ // continuation is measured, so it is remembered with the column.
471
+ // Only when the scalar IS the item's node: a dash on the line, and the
472
+ // scalar starting exactly where that item's node begins.
473
+ plainFromDash = dashIndent >= 0 && plainStartCol === nodeIndent;
474
+ }
475
+ } else {
476
+ plainOpen = -1;
477
+ plainFromDash = false;
478
+ }
479
+ out.push(masked + (cr ? "\r" : ""));
480
+ }
481
+ return out.join("\n");
482
+ }
483
+
484
+ // A line kept for its structure, with any comment on it masked. Used for the
485
+ // directive and document-marker lines, which are passed through whole because
486
+ // what makes them structural is their shape rather than a scan of their parts.
487
+ // The comment is still content and still has to go.
488
+ function _maskTrailingComment(line) {
489
+ for (var i = 0; i < line.length; i += 1) {
490
+ if (line.charAt(i) !== "#") continue;
491
+ if (i !== 0 && !_isPlainSpace(line.charAt(i - 1))) continue;
492
+ return line.slice(0, i) + _blanked(line.slice(i));
493
+ }
494
+ return line;
495
+ }
496
+
497
+ // The body of a quoted scalar from `at`, masked, stopping at the closing quote.
498
+ // Returns where the scan ended (past the quote when it closed) and whether it
499
+ // did close, which is what tells the caller the scalar runs onto the next line.
500
+ //
501
+ // Both the escape forms are honoured because both hide a quote that would
502
+ // otherwise look like the end: `\"` inside a double-quoted scalar and `''`
503
+ // inside a single-quoted one. Reading either as a terminator ends the mask
504
+ // early and hands the rest of the scalar back to the structural scan as though
505
+ // it were YAML.
506
+ function _maskQuotedBody(line, at, quote) {
507
+ var body = "";
508
+ var k = at;
509
+ while (k < line.length) {
510
+ var c = line.charAt(k);
511
+ if (quote === '"' && c === "\\" && k + 1 < line.length) { body += " "; k += 2; continue; }
512
+ if (c === quote) {
513
+ if (quote === "'" && line.charAt(k + 1) === "'") { body += " "; k += 2; continue; }
514
+ return { masked: body + quote, end: k + 1, closed: true };
515
+ }
516
+ body += c === "\t" ? "\t" : " ";
517
+ k += 1;
518
+ }
519
+ return { masked: body, end: k, closed: false };
520
+ }
521
+
522
+ // Same length, spaces throughout. Newlines cannot appear here — the caller
523
+ // splits on them first — but a tab is preserved so column arithmetic that
524
+ // counts it as one character still agrees with the source.
525
+ function _blanked(s) {
526
+ var o = "";
527
+ for (var i = 0; i < s.length; i += 1) o += s.charAt(i) === "\t" ? "\t" : " ";
528
+ return o;
529
+ }
530
+
531
+ module.exports = {
532
+ maskNonStructural: maskNonStructural,
533
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.18.51",
3
+ "version": "0.18.53",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:7276e0db-b00d-4f53-8f63-c7572db5520b",
5
+ "serialNumber": "urn:uuid:f52f2ee6-253b-4bf4-9915-22d453a4407a",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-23T08:40:11.763Z",
8
+ "timestamp": "2026-08-24T12:08:00.505Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.18.51",
22
+ "bom-ref": "@blamejs/core@0.18.53",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.18.51",
25
+ "version": "0.18.53",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.18.51",
29
+ "purl": "pkg:npm/%40blamejs/core@0.18.53",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.18.51",
57
+ "ref": "@blamejs/core@0.18.53",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]