@blamejs/core 0.18.51 → 0.18.54

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 (44) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/NOTICE +1 -1
  3. package/README.md +3 -3
  4. package/lib/ai-adverse-decision.js +18 -2
  5. package/lib/codepoint-class.js +72 -0
  6. package/lib/cookies.js +7 -10
  7. package/lib/credential-hash.js +8 -1
  8. package/lib/crypto.js +7 -5
  9. package/lib/guard-auth.js +34 -11
  10. package/lib/guard-filename.js +39 -16
  11. package/lib/guard-managesieve-command.js +49 -9
  12. package/lib/guard-regex.js +3 -5
  13. package/lib/guard-yaml.js +212 -31
  14. package/lib/mail-agent.js +23 -9
  15. package/lib/mail-arc-sign.js +40 -7
  16. package/lib/mail-auth.js +75 -20
  17. package/lib/mail-crypto-pgp.js +1 -1
  18. package/lib/mail-dkim.js +80 -11
  19. package/lib/mail-helo.js +10 -0
  20. package/lib/mail-rbl.js +10 -3
  21. package/lib/mail-send-deliver.js +151 -32
  22. package/lib/mail-server-imap.js +121 -55
  23. package/lib/mail-server-jmap.js +31 -4
  24. package/lib/mail-server-managesieve.js +168 -25
  25. package/lib/mail-server-mx.js +76 -4
  26. package/lib/mail-server-net.js +126 -0
  27. package/lib/mail-server-pop3.js +73 -22
  28. package/lib/mail-server-submission.js +21 -2
  29. package/lib/mail-store.js +33 -11
  30. package/lib/mail.js +355 -17
  31. package/lib/middleware/bearer-auth.js +6 -1
  32. package/lib/middleware/fetch-metadata.js +5 -1
  33. package/lib/middleware/headers.js +7 -10
  34. package/lib/network-dns-resolver.js +71 -8
  35. package/lib/network-dns.js +26 -0
  36. package/lib/network-smtp-policy.js +42 -10
  37. package/lib/parsers/safe-yaml.js +24 -3
  38. package/lib/redact.js +13 -3
  39. package/lib/retention.js +22 -2
  40. package/lib/vendor/MANIFEST.json +12 -12
  41. package/lib/vendor/blamejs-pki.cjs +278 -40
  42. package/lib/yaml-lex.js +587 -0
  43. package/package.json +1 -1
  44. package/sbom.cdx.json +6 -6
@@ -253,15 +253,13 @@ function _foldSuperscriptDigits(s) {
253
253
  // A `..` segment: the whole name, or bounded by path separators on both sides.
254
254
  // Bounded, not merely present — `..foo` and `a..b` are ordinary names.
255
255
  function _hasTraversalSegment(name) {
256
- for (var i = 0; i + 1 < name.length; i += 1) {
257
- if (name.charAt(i) !== "." || name.charAt(i + 1) !== ".") continue;
256
+ return codepointClass.hasPairWhere(name, ".", ".", function (i) {
258
257
  var beforeOk = i === 0 ||
259
258
  PATH_SEPARATORS.indexOf(name.charAt(i - 1)) !== -1;
260
259
  var afterOk = i + 2 === name.length ||
261
260
  PATH_SEPARATORS.indexOf(name.charAt(i + 2)) !== -1;
262
- if (beforeOk && afterOk) return true;
263
- }
264
- return false;
261
+ return beforeOk && afterOk;
262
+ });
265
263
  }
266
264
 
267
265
  function _hasAnyFolded(name, needles) {
@@ -280,6 +278,15 @@ function _hasUncPrefix(name) {
280
278
 
281
279
  // An NTFS alternate-data-stream suffix: a colon followed by a run with no
282
280
  // further colon and no separator in it, at the very end of the name.
281
+ // One wording for every path that refuses a stream-suffixed name — sanitize in
282
+ // either mode, validate, gate, verifyExtractionPath — so a caller meets the same
283
+ // answer whichever door they arrive at, and the way out is named where they are
284
+ // standing rather than somewhere else in the documentation.
285
+ var ADS_SNIPPET = "NTFS alternate data stream syntax (name:stream); set " +
286
+ "adsPolicy \"allow\" when the target filesystem is not NTFS and a colon is " +
287
+ "an ordinary filename character there";
288
+ var ADS_MESSAGE = "filename contains " + ADS_SNIPPET;
289
+
283
290
  function _hasAdsSuffix(name) {
284
291
  var colon = name.lastIndexOf(":");
285
292
  if (colon === -1 || colon === name.length - 1) return false;
@@ -461,15 +468,17 @@ function _detectIssues(input, opts) {
461
468
 
462
469
  // 6. NTFS alternate data streams — `name:stream`. Unconditional: a write to
463
470
  // `name:stream` lands on a hidden stream of the base file rather than the
464
- // file the caller named, which no policy value makes safe.
471
+ // file the caller named on Windows. On the Linux targets `adsPolicy`
472
+ // exists for, a colon is an ordinary filename character, so the operator
473
+ // holds the switch and the finding is suppressed when they set it.
465
474
  {
466
- if (_hasAdsSuffix(name) && name.charAt(0) !== "/") {
475
+ if (opts.adsPolicy !== "allow" && _hasAdsSuffix(name) && name.charAt(0) !== "/") {
467
476
  // Only flag when there's a `:` followed by stream-name characters
468
477
  // and we're NOT at the start (relative path indicator).
469
478
  issues.push({
470
479
  kind: "ntfs-ads", severity: "critical",
471
480
  ruleId: "filename.ntfs-ads",
472
- snippet: "NTFS alternate data stream syntax (name:stream)",
481
+ snippet: ADS_SNIPPET,
473
482
  });
474
483
  }
475
484
  }
@@ -660,9 +669,22 @@ function _sanitize(input, opts) {
660
669
  name = "_" + name;
661
670
  }
662
671
 
663
- // ADS detection.
664
- if (_hasAdsSuffix(name)) {
665
- throw _err("filename.ntfs-ads", "filename contains NTFS alternate data stream syntax");
672
+ // ADS detection. On Windows a `name:stream` write lands on a hidden stream of
673
+ // the base file rather than on a file anyone can see, so this is refused by
674
+ // default in every profile.
675
+ //
676
+ // It is an opt-out rather than an absolute, because the check is lexical and
677
+ // a colon is an ordinary filename character on the Linux targets these
678
+ // policies exist for: `12:30 notes.txt` has the same shape as an attack and
679
+ // is a timestamped note (#623). Only the operator knows which filesystem the
680
+ // name will be written to, which is why they get the switch.
681
+ //
682
+ // An earlier attempt refused regardless and put the scope in the message,
683
+ // which reads as a considered boundary right up until a caller has set every
684
+ // documented opt-out and still cannot store the file. An option that is
685
+ // accepted and documented as opting out must opt out.
686
+ if (opts.adsPolicy !== "allow" && _hasAdsSuffix(name)) {
687
+ throw _err("filename.ntfs-ads", ADS_MESSAGE);
666
688
  }
667
689
 
668
690
  // Length cap.
@@ -713,9 +735,10 @@ function _sanitize(input, opts) {
713
735
  * // than refusing is safe
714
736
  * reservedCharPolicy: "reject"|"strip"|"allow",
715
737
  * reservedNamePolicy: "reject"|"audit"|"allow",
716
- * adsPolicy: "reject"|"allow", // reject here; "allow"
717
- * // is honoured only
718
- * // by verifyExtractionPath
738
+ * adsPolicy: "reject"|"allow", // "allow" when the target
739
+ * // filesystem is not NTFS
740
+ * // and a colon is ordinary
741
+ * // there (Linux, macOS)
719
742
  * leadingTrailingPolicy: "reject"|"strip"|"allow",
720
743
  * shellExecExtPolicy: "reject"|"audit"|"allow",
721
744
  * pathSeparatorsPolicy: "reject"|"audit"|"allow",
@@ -795,8 +818,8 @@ function _sanitizeStripMode(input, opts) {
795
818
  if (_hasUncPrefix(name)) {
796
819
  throw _err("filename.unc", "UNC path syntax");
797
820
  }
798
- if (_hasAdsSuffix(name) && name.charAt(0) !== "/") {
799
- throw _err("filename.ntfs-ads", "filename contains NTFS alternate data stream syntax");
821
+ if (opts.adsPolicy !== "allow" && _hasAdsSuffix(name) && name.charAt(0) !== "/") {
822
+ throw _err("filename.ntfs-ads", ADS_MESSAGE);
800
823
  }
801
824
  if (Buffer.byteLength(name, "utf8") > opts.maxBytes) {
802
825
  throw _err("filename.length", "filename exceeds maxBytes " + opts.maxBytes);
@@ -102,6 +102,7 @@
102
102
  * under strict (RFC 4954 §4 class), validates per-verb shape.
103
103
  */
104
104
 
105
+ var C = require("./constants");
105
106
  var { defineClass } = require("./framework-error");
106
107
  var gateContract = require("./gate-contract");
107
108
  var codepointClass = require("./codepoint-class");
@@ -112,6 +113,12 @@ var GuardManageSieveCommandError = defineClass("GuardManageSieveCommandError",
112
113
 
113
114
  var DEFAULT_PROFILE = "strict";
114
115
 
116
+ // A SASL token — the AUTHENTICATE initial response and every later response in
117
+ // a multi-step exchange. Bounded far below the script cap because it is a
118
+ // base64 blob of credentials, not a script body, and because the client
119
+ // declaring its size is by definition not yet authenticated.
120
+ var MAX_SASL_TOKEN_BYTES = C.BYTES.kib(4);
121
+
115
122
  var PROFILES = Object.freeze({
116
123
  strict: {
117
124
  maxLineBytes: 8192, // 8 KiB per-line cap (strict)
@@ -339,13 +346,15 @@ function _validateAuthenticate(rest, caps, profileName, opts) {
339
346
  throw new GuardManageSieveCommandError("guard-managesieve-command/literal-plus-refused",
340
347
  "guardManageSieveCommand.validate: LITERAL+ refused under profile '" + profileName + "'");
341
348
  }
342
- // Base64-initial-response cap: bound by the script-name cap
343
- // (initial-response is a SASL token, not a script body; 4 KiB
344
- // is generous).
345
- if (n > 4096) { // 4 KiB SASL initial-response cap
349
+ // A SASL token, not a script body, so it is bounded well below the
350
+ // script cap. The same number bounds the client's LATER responses in a
351
+ // multi-step exchange (b.mail.server.managesieve reads it from
352
+ // MAX_SASL_TOKEN_BYTES): one exchange, one bound, whichever round the
353
+ // token arrives on and whichever representation it uses.
354
+ if (n > MAX_SASL_TOKEN_BYTES) {
346
355
  throw new GuardManageSieveCommandError("guard-managesieve-command/literal-too-large",
347
356
  "guardManageSieveCommand.validate: AUTHENTICATE initial-response " +
348
- n + " bytes exceeds 4096-byte cap");
357
+ n + " bytes exceeds " + MAX_SASL_TOKEN_BYTES + "-byte cap");
349
358
  }
350
359
  literalBytes = n;
351
360
  literalPlus = isPlus;
@@ -478,10 +487,34 @@ function _validateRenamescript(rest, caps) {
478
487
  return { verb: "RENAMESCRIPT", args: [first.value, second.value] };
479
488
  }
480
489
 
481
- // _parseQuotedString — extract a leading `"..."` quoted string from
482
- // `s` and return `{ value, rest }`, where `rest` is whitespace-trimmed.
483
- // Returns null if `s` does not begin with a DQUOTE. RFC 5804 §1.2
484
- // quoted strings allow UTF-8 content and `\"` / `\\` escape sequences.
490
+ /**
491
+ * @primitive b.guardManageSieveCommand.parseQuotedString
492
+ * @signature b.guardManageSieveCommand.parseQuotedString(s)
493
+ * @since 0.18.54
494
+ * @status stable
495
+ * @related b.guardManageSieveCommand.validate, b.mail.server.managesieve.create
496
+ *
497
+ * Read a leading RFC 5804 §1.2 quoted string off `s` and return
498
+ * `{ value, rest }`, where `value` is the unescaped content and `rest` is
499
+ * what follows with leading whitespace removed. Returns `null` when `s` does
500
+ * not begin with a double quote.
501
+ *
502
+ * The production honours the `\"` and `\\` escapes, and refuses NUL, CR and
503
+ * LF inside the quotes: those end a line-oriented protocol record, so a
504
+ * string carrying one would split the command it appears in.
505
+ *
506
+ * Exposed because a `string` also arrives outside a command, as the client's
507
+ * reply to a SASL challenge. That line never reaches `validate`, and a second
508
+ * hand-rolled unquoting there would be free to lose the escape handling and
509
+ * the control-byte refusal this one has. `b.mail.server.managesieve` reads
510
+ * its SASL responses through this.
511
+ *
512
+ * @example
513
+ * b.guardManageSieveCommand.parseQuotedString('"PLAIN" {12+}');
514
+ * // → { value: "PLAIN", rest: "{12+}" }
515
+ * b.guardManageSieveCommand.parseQuotedString("PLAIN");
516
+ * // → null
517
+ */
485
518
  function _parseQuotedString(s) {
486
519
  if (s.length === 0 || s.charCodeAt(0) !== 0x22) return null; // DQUOTE
487
520
  var out = "";
@@ -572,5 +605,12 @@ module.exports = gateContract.defineParser({
572
605
  extra: {
573
606
  KNOWN_VERBS: KNOWN_VERBS,
574
607
  ZERO_ARG_VERBS: ZERO_ARG_VERBS,
608
+ MAX_SASL_TOKEN_BYTES: MAX_SASL_TOKEN_BYTES,
609
+ // The RFC 5804 §1.2 "string" production. The listener needs it for the
610
+ // client's reply to a SASL challenge, which arrives as a bare string on its
611
+ // own line rather than as a command argument — so it never reaches
612
+ // validate(), and hand-rolling a second dquote-strip there would drop this
613
+ // one's escape handling and its refusal of NUL / CR / LF inside the quotes.
614
+ parseQuotedString: _parseQuotedString,
575
615
  },
576
616
  });
@@ -454,8 +454,7 @@ function _scanBraces(src, at) {
454
454
  // running the construct it screens over operator-supplied text, which is the
455
455
  // shape this module exists to keep away from.
456
456
  function _turnsFoldingOn(src) {
457
- for (var i = 0; i + 2 < src.length; i += 1) {
458
- if (src.charAt(i) !== "(" || src.charAt(i + 1) !== "?") continue;
457
+ return codepointClass.hasPairWhere(src, "(", "?", function (i) {
459
458
  var at = i + 2;
460
459
  var enablesFold = false;
461
460
  while (at < src.length && _isFlagLetter(src.charAt(at))) {
@@ -466,9 +465,8 @@ function _turnsFoldingOn(src) {
466
465
  at += 1;
467
466
  while (at < src.length && _isFlagLetter(src.charAt(at))) at += 1;
468
467
  }
469
- if (enablesFold && src.charAt(at) === ":") return true;
470
- }
471
- return false;
468
+ return enablesFold && src.charAt(at) === ":";
469
+ });
472
470
  }
473
471
 
474
472
  function _parsePattern(src, flags, budget) {
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;
@@ -175,17 +179,33 @@ function _hasLeadingZeroOctal(text) {
175
179
 
176
180
  // A merge key with an anchor reference — `<<` then optional whitespace, `:`,
177
181
  // optional whitespace, `*`.
178
- function _hasMergeKeyAlias(text) {
179
- for (var i = 0; i + 1 < text.length; i += 1) {
180
- if (text.charAt(i) !== "<" || text.charAt(i + 1) !== "<") continue;
182
+ // A merge key is `<<: *anchor` — a mapping key, so a structural question, and
183
+ // asked of the lexer rather than of the source.
184
+ //
185
+ // The test is whether a NODE can begin at the `<<`, not whether what follows is
186
+ // well-formed: `<<:*d` with no space after the colon is not a mapping entry and
187
+ // is reported anyway, because that is the shape being smuggled past a parser
188
+ // variant that reads it as a merge. A node cannot begin inside a comment, a
189
+ // quoted body, a block scalar's body, or a plain scalar's continuation line, so
190
+ // one question covers all of them.
191
+ //
192
+ // Enumerating those regions instead was tried and is the wrong shape. Without
193
+ // any of this, `<<: *base` written inside a block scalar — a line of someone's
194
+ // shell script — was reported as a merge key; excluding block bodies and quoted
195
+ // bodies then still reported one written in a comment, on a continuation line,
196
+ // and after a document marker. Same root as the duplicate-key screen (#642): a
197
+ // structural rule reading raw source, and a fix that lists regions is the same
198
+ // mistake with a longer list.
199
+ function _hasMergeKeyAlias(text, nodeStarts) {
200
+ return codepointClass.hasPairWhere(text, "<", "<", function (i) {
201
+ if (nodeStarts && !nodeStarts[i]) return false;
181
202
  var j = i + 2;
182
203
  while (j < text.length && _isSpace(text.charCodeAt(j))) j += 1;
183
- if (text.charAt(j) !== ":") continue;
204
+ if (text.charAt(j) !== ":") return false;
184
205
  j += 1;
185
206
  while (j < text.length && _isSpace(text.charCodeAt(j))) j += 1;
186
- if (text.charAt(j) === "*") return true;
187
- }
188
- return false;
207
+ return text.charAt(j) === "*";
208
+ });
189
209
  }
190
210
 
191
211
  // ---- Profile presets ----
@@ -325,9 +345,11 @@ function _scanTags(text) {
325
345
  var tags = [];
326
346
  for (var i = 0; i < text.length; i += 1) {
327
347
  if (text.charAt(i) !== "!") continue;
328
- var atStart = i === 0 ||
329
- codepointClass.inRanges(text.charCodeAt(i - 1), codepointClass.WHITESPACE_RANGES);
330
- if (!atStart) continue;
348
+ // No "preceded by whitespace" test. That WAS the original defect, and once
349
+ // the mask decides position it is not merely redundant but harmful: in
350
+ // `{"a":!!python/object x}` the character before the tag is the colon of a
351
+ // JSON-style key, so the test skipped it and a deserialization tag reached
352
+ // both screens unreported. Position is the mask's question now.
331
353
  var nameAt = text.charAt(i + 1) === "!" ? i + 2 : i + 1;
332
354
  if (!codepointClass.isAsciiLetter(text.charCodeAt(nameAt))) continue;
333
355
  var end = nameAt + 1;
@@ -354,8 +376,29 @@ function _detectIssues(input, opts) {
354
376
  if (pre.done) return pre.issues;
355
377
  var issues = pre.issues;
356
378
 
379
+ // Sigil scans run against the MASK, not the source. A `!`, `&` or `*` means
380
+ // what it looks like only at a node start, and the previous rule — "after
381
+ // whitespace" — cannot tell a node start from the middle of a scalar. It
382
+ // reported a tag for the bang in a comment, in a quoted string, in a block
383
+ // scalar's shell script, and in ordinary prose: `x: 1 # note !bang` was
384
+ // refused. The mask is index-aligned with the source and the same length, so
385
+ // every location and line number reported below is still the source's.
386
+ //
387
+ // Every STRUCTURAL screen uses it: the three sigil scans, the duplicate-key
388
+ // scan, and the merge key. The value-shaped detectors (the Norway problem,
389
+ // leading zeros) are asking about scalar CONTENT, which is exactly what the
390
+ // mask removes, so those keep reading the source.
391
+ //
392
+ // Merge keys were on the wrong side of that line, listed with the
393
+ // value-shaped ones: `<<:` is a mapping key, and reading it from the source
394
+ // reported one written inside a block scalar. The test is not what the rule
395
+ // is looking for but what kind of question it asks — "is this a mapping
396
+ // entry?" is structure however scalar-shaped the token looks.
397
+ var lexed = yamlLex.lexLines(input);
398
+ var masked = lexed.masked;
399
+
357
400
  // 1. Tag-injection scan.
358
- var tagHits = _scanTags(input);
401
+ var tagHits = _scanTags(masked);
359
402
  for (var ti = 0; ti < tagHits.length; ti += 1) {
360
403
  var t = tagHits[ti];
361
404
  if (t.kind === "dangerous") {
@@ -392,8 +435,8 @@ function _detectIssues(input, opts) {
392
435
  }
393
436
 
394
437
  // 2. Anchor / alias recursion scan.
395
- var anchors = _collectSigilNames(input, "&", ANCHOR_LEAD_CHARS);
396
- var aliases = _collectSigilNames(input, "*", ALIAS_LEAD_CHARS);
438
+ var anchors = _collectSigilNames(masked, "&");
439
+ var aliases = _collectSigilNames(masked, "*");
397
440
  if (anchors.length > opts.maxAnchors) {
398
441
  issues.push({
399
442
  kind: "anchor-cap", severity: "high",
@@ -468,7 +511,8 @@ function _detectIssues(input, opts) {
468
511
  }
469
512
 
470
513
  // 6. Merge-key chain depth.
471
- if (opts.mergeKeyPolicy !== "allow" && _hasMergeKeyAlias(input)) {
514
+ if (opts.mergeKeyPolicy !== "allow" &&
515
+ _hasMergeKeyAlias(input, lexed.nodeStarts)) {
472
516
  issues.push({
473
517
  kind: "merge-key",
474
518
  severity: opts.mergeKeyPolicy === "reject" ? "high" : "warn",
@@ -517,12 +561,25 @@ function _detectIssues(input, opts) {
517
561
  // FIRST colon that is followed by whitespace or ends the line, which is where
518
562
  // a value begins — a colon inside the key (a timestamp, a URL) does not end
519
563
  // it unless whitespace follows.
520
- function _mappingEntryAt(line) {
564
+ // `masked` is the same line with every non-structural region blanked by
565
+ // yaml-lex. It decides WHICH colons are mapping separators; the raw line
566
+ // supplies the key text, because the mask blanks that too.
567
+ //
568
+ // Without it, a colon inside a block scalar's body or a quoted value read as a
569
+ // mapping entry, and two such lines inside one scalar were reported as a
570
+ // duplicate key (#642). That is the same root as #631/#632 — a screen deciding
571
+ // structure by reading the source — which survived here because only the sigil
572
+ // scans were moved onto the mask.
573
+ function _mappingEntryAt(line, masked) {
521
574
  var indent = 0;
522
575
  while (indent < line.length && _isSpace(line.charCodeAt(indent))) indent += 1;
523
576
  if (indent === line.length) return null;
524
577
  for (var i = indent; i < line.length; i += 1) {
525
578
  if (line.charAt(i) !== ":") continue;
579
+ // Structural only: the mask keeps a mapping separator and blanks a colon
580
+ // that is part of a scalar. Absent a mask, every colon counts, which is the
581
+ // pre-#642 behaviour and is what the non-masked callers still want.
582
+ if (masked !== undefined && masked.charAt(i) !== ":") continue;
526
583
  var after = line.charCodeAt(i + 1);
527
584
  if (i + 1 < line.length && !_isSpace(after)) continue;
528
585
  if (i === indent) return null; // no key before the colon
@@ -531,6 +588,24 @@ function _mappingEntryAt(line) {
531
588
  return null;
532
589
  }
533
590
 
591
+ // The column the line's content starts in.
592
+ function _indentOfLine(line) {
593
+ var i = 0;
594
+ while (i < line.length && _isSpace(line.charCodeAt(i))) i += 1;
595
+ return i;
596
+ }
597
+
598
+ // The indent of a sequence dash opening this line, or -1 when the line does not
599
+ // open a sequence item. A dash counts only when it stands alone as a token: `-`
600
+ // at end of line, or followed by a space. `-quux` and `-1` are scalars.
601
+ function _sequenceDashIndent(line) {
602
+ var i = 0;
603
+ while (i < line.length && _isSpace(line.charCodeAt(i))) i += 1;
604
+ if (i >= line.length || line.charAt(i) !== "-") return -1;
605
+ if (i + 1 < line.length && !_isSpace(line.charCodeAt(i + 1))) return -1;
606
+ return i;
607
+ }
608
+
534
609
  // Is the line blank, or a comment?
535
610
  function _isCommentLine(line) {
536
611
  var i = 0;
@@ -543,20 +618,126 @@ var _splitLines = codepointClass.splitLines;
543
618
  function _detectDuplicateKeysYaml(text) {
544
619
  var dups = Object.create(null);
545
620
  var lines = _splitLines(text);
621
+ // Whether a line carries a mapping entry is a STRUCTURAL question, so it is
622
+ // asked of the lexer's mask rather than of the source. Split the same way, so
623
+ // line i of one is line i of the other.
624
+ var maskedLines = _splitLines(yamlLex.maskNonStructural(text));
546
625
  var indentScopes = Object.create(null);
626
+ // The sequence items currently open, outermost first, each with the column
627
+ // its first key sat in. Every key of an item at its top level is filed under
628
+ // one scope whatever column it is written in, because it is one mapping.
629
+ //
630
+ // A STACK, because sequences nest. Holding the innermost item in a pair of
631
+ // variables let a nested sequence overwrite its parent's scope and never give
632
+ // it back, so a key repeated in the OUTER item after the nested one closed
633
+ // was filed somewhere else and went unreported:
634
+ //
635
+ // - a: 1
636
+ // inner:
637
+ // - x: 1
638
+ // a: 2 <- a duplicate of the first `a`, and it was missed
639
+ //
640
+ // The same shape as the defect this detector reports, one level up: a single
641
+ // slot standing in for something there can be several of.
642
+ var itemStack = [];
547
643
  for (var i = 0; i < lines.length; i += 1) {
548
644
  var line = lines[i];
549
645
  if (line.length === 0 || _isCommentLine(line)) continue;
550
- var entry = _mappingEntryAt(line);
646
+ // A sequence item OPENS A NEW MAPPING, so it ends the previous item's and
647
+ // everything nested inside it. That is decided from the DASH, before
648
+ // anything else, because a line may be a sequence item and carry no mapping
649
+ // entry of its own:
650
+ //
651
+ // steps:
652
+ // -
653
+ // p: 1
654
+ // -
655
+ // p: 2
656
+ //
657
+ // A dash alone has no `key: value` on it, so a reset that waited for one
658
+ // never ran and the second item's `p` was still read as a duplicate of the
659
+ // first's. The boundary is the dash; whether the item writes its first key
660
+ // beside it or underneath it is a matter of layout.
661
+ var dashAt = _sequenceDashIndent(line);
662
+ // An item is over once the document comes back out to its dash's column or
663
+ // further left. That is true of the next item's own dash as much as of a
664
+ // key belonging to the enclosing mapping, so it is measured here, before
665
+ // this line is classified at all.
666
+ var lineIndent = dashAt >= 0 ? dashAt : _indentOfLine(line);
667
+ while (itemStack.length &&
668
+ itemStack[itemStack.length - 1].dash >= lineIndent) itemStack.pop();
669
+ if (dashAt >= 0) {
670
+ Object.keys(indentScopes).forEach(function (k) {
671
+ if (Number(k) > dashAt) delete indentScopes[k];
672
+ });
673
+ // keyIndent is set by this item's first key, wherever it is written.
674
+ itemStack.push({ dash: dashAt, keyIndent: -1 });
675
+ }
676
+ // Passed straight through, with no `|| ""` fallback: an empty mask line
677
+ // blanks every colon, so a missing one would exempt the line from the screen
678
+ // altogether. `undefined` means "no mask" instead, which counts every colon
679
+ // — over-reporting rather than under-reporting if the two ever desynchronise.
680
+ var entry = _mappingEntryAt(line, maskedLines[i]);
551
681
  if (!entry) continue;
552
682
  var indent = entry.indent;
553
683
  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;
684
+ if (key.charAt(0) === "[" || key.charAt(0) === "{") continue;
685
+ // The key written INLINE with the dash belongs to the item's mapping, and
686
+ // sits at the indent AFTER the dash and its space — so that is the scope it
687
+ // is registered in, alongside the keys written underneath it. It used to be
688
+ // skipped entirely, which meant repeating it went unreported.
689
+ var dash = key.charAt(0) === "-" &&
690
+ (key.length === 1 || _isSpace(key.charCodeAt(1)));
691
+ var scopeAt = indent;
692
+ if (dash) {
693
+ var after = 1;
694
+ while (after < key.length && _isSpace(key.charCodeAt(after))) after += 1;
695
+ key = key.slice(after).trim();
696
+ // `- ` alone, or `- - x`: no inline key of this item's own to register.
697
+ if (!key || key.charAt(0) === "-" || key.charAt(0) === "[" ||
698
+ key.charAt(0) === "{") continue;
699
+ scopeAt = indent + after;
700
+ }
701
+ // The item's mapping is ONE mapping however its keys are laid out, so the
702
+ // scope it is tracked under must not depend on spacing. `- a: 1` puts its
703
+ // inline key at column 4 while the key written under it sits at column 2,
704
+ // and keying on the raw column filed them separately — so a repeat across
705
+ // those two lines went unreported, which is exactly the smuggling shape
706
+ // this detector exists for (one parser reads two keys, another reads one).
707
+ //
708
+ // The item's top level is every key deeper than the dash and no deeper than
709
+ // the first key it saw. Anything past that is genuinely nested and keeps
710
+ // its own column, so `- a: 1` / ` b:` / ` a: 2` is still not a
711
+ // duplicate.
712
+ // Half a column past the dash: a number, so the pruning comparisons below
713
+ // and at the dash keep working unchanged, and one that sorts between the
714
+ // dash and anything nested inside the item.
715
+ var item = itemStack.length ? itemStack[itemStack.length - 1] : null;
716
+ if (item && scopeAt > item.dash) {
717
+ // The item's top level is every key no deeper than the SHALLOWEST key it
718
+ // has shown, and the bound moves down as shallower ones appear. Two
719
+ // failures pinned this from opposite sides:
720
+ //
721
+ // - a: 1 the inline key sits at column 4 because of the extra
722
+ // b: spacing, but the item's mapping is written at 2. If
723
+ // a: 2 4 is taken as the bound, the NESTED `a` at 4 counts
724
+ // as top level and reads as a duplicate.
725
+ //
726
+ // - a: here the inline key IS the bound, at 2. If it sets
727
+ // x: 1 nothing, the first nested key at 4 becomes the bound
728
+ // a: 2 and the nested `a` reads as a duplicate instead.
729
+ //
730
+ // So the inline key establishes the bound and a later, shallower key
731
+ // lowers it. Extra spacing after the indicator is presentation; the
732
+ // shallowest key is the structure.
733
+ if (item.keyIndent === -1 || scopeAt < item.keyIndent) item.keyIndent = scopeAt;
734
+ if (scopeAt <= item.keyIndent) scopeAt = item.dash + 0.5;
735
+ }
736
+ if (!indentScopes[scopeAt]) indentScopes[scopeAt] = Object.create(null);
737
+ if (indentScopes[scopeAt][key]) dups[key] = true;
738
+ else indentScopes[scopeAt][key] = true;
558
739
  Object.keys(indentScopes).forEach(function (k) {
559
- if (Number(k) > indent) delete indentScopes[k];
740
+ if (Number(k) > scopeAt) delete indentScopes[k];
560
741
  });
561
742
  }
562
743
  return Object.keys(dups);
package/lib/mail-agent.js CHANGED
@@ -539,19 +539,33 @@ async function _expunge(ctx, args) {
539
539
  "agent.expunge: { folder, objectIds, [candidateTtlMs] } required");
540
540
  }
541
541
 
542
- // Look up the regulator-mandated retention floor for the operator's
543
- // active posture. For expunge semantics, the floor IS the minimum
544
- // TTL — messages younger than the floor MUST NOT be hard-deleted,
545
- // even on operator request. Distinct from `b.retention.
546
- // complianceFloor(posture, candidateTtl)` which composes the
547
- // candidate TTL into a max that primitive's "candidate must be
548
- // positive" contract doesn't apply here because expunge means TTL=0.
549
- // Read the floor table directly.
542
+ // Look up the regulator-mandated retention floor for the operator's active
543
+ // posture. For expunge semantics the floor IS the minimum TTL — messages
544
+ // younger than it MUST NOT be hard-deleted, even on operator request.
545
+ //
546
+ // Through b.retention.complianceFloor, not the floor table it wraps. The
547
+ // direct read fell back to zero for anything the table did not contain, so a
548
+ // misspelled posture, a capitalised one ("HIPAA"), or one the table simply
549
+ // does not carry all permitted an unbounded hard delete with no error — a
550
+ // typo refused where retention windows are computed and accepted at the one
551
+ // call that destroys mail permanently. It also read through the prototype:
552
+ // posture "constructor" returned a function, which is truthy, so the `|| 0`
553
+ // fallback kept it and every age comparison against it was nonsense.
554
+ //
555
+ // No posture at all is a legitimate configuration and keeps the zero floor.
556
+ // A posture that was supplied and is not understood now throws.
550
557
  var retentionModule = require("./retention"); // allow:inline-require — lazy-load until first expunge call
551
558
  var posture = (ctx && ctx.posture) || (args && args.posture) || null;
552
559
  var floorMs = 0;
553
560
  if (typeof posture === "string" && posture.length > 0) {
554
- floorMs = retentionModule.COMPLIANCE_RETENTION_FLOOR_MS[posture] || 0;
561
+ try {
562
+ floorMs = retentionModule.complianceFloor(posture);
563
+ } catch (e) {
564
+ throw new MailAgentError("mail-agent/unknown-posture",
565
+ "expunge: posture '" + posture + "' is not a posture the framework knows, " +
566
+ "so no retention floor can be established and the hard delete is refused " +
567
+ "rather than run unbounded: " + ((e && e.message) || String(e)));
568
+ }
555
569
  }
556
570
 
557
571
  // Read message metadata BEFORE invoking hardExpunge so the per-id