@openclaw/lobster 2026.9.5 → 2026.9.7

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 (163) hide show
  1. package/assets/icon.png +0 -0
  2. package/dist/index.js +8 -294
  3. package/node_modules/@clawdbot/lobster/README.md +20 -133
  4. package/node_modules/@clawdbot/lobster/VISION.md +15 -238
  5. package/node_modules/@clawdbot/lobster/bin/clawd.invoke.js +2 -21
  6. package/node_modules/@clawdbot/lobster/bin/invoke.js +19 -0
  7. package/node_modules/@clawdbot/lobster/bin/lobster.js +2 -21
  8. package/node_modules/@clawdbot/lobster/bin/openclaw.invoke.js +2 -24
  9. package/node_modules/@clawdbot/lobster/dist/src/cli.js +32 -48
  10. package/node_modules/@clawdbot/lobster/dist/src/cli.js.map +1 -1
  11. package/node_modules/@clawdbot/lobster/dist/src/command_tokens.js +81 -0
  12. package/node_modules/@clawdbot/lobster/dist/src/command_tokens.js.map +1 -0
  13. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/dedupe.js +1 -12
  14. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/dedupe.js.map +1 -1
  15. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/group_by.js +2 -14
  16. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/group_by.js.map +1 -1
  17. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/llm_invoke.js +1 -337
  18. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/llm_invoke.js.map +1 -1
  19. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/map.js +2 -13
  20. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/map.js.map +1 -1
  21. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/sort.js +4 -21
  22. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/sort.js.map +1 -1
  23. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/state.js +1 -1
  24. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/state.js.map +1 -1
  25. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/template.js +2 -13
  26. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/template.js.map +1 -1
  27. package/node_modules/@clawdbot/lobster/dist/src/commands/workflows/workflows_run.js +0 -18
  28. package/node_modules/@clawdbot/lobster/dist/src/commands/workflows/workflows_run.js.map +1 -1
  29. package/node_modules/@clawdbot/lobster/dist/src/core/llm_accounting.js +304 -0
  30. package/node_modules/@clawdbot/lobster/dist/src/core/llm_accounting.js.map +1 -0
  31. package/node_modules/@clawdbot/lobster/dist/src/core/value_path.js +11 -0
  32. package/node_modules/@clawdbot/lobster/dist/src/core/value_path.js.map +1 -0
  33. package/node_modules/@clawdbot/lobster/dist/src/parser.js +2 -67
  34. package/node_modules/@clawdbot/lobster/dist/src/parser.js.map +1 -1
  35. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/index.js +1 -1
  36. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/pr-monitor.js +5 -64
  37. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/pr-monitor.js.map +1 -1
  38. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/read_pr.js +45 -0
  39. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/read_pr.js.map +1 -0
  40. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/snapshot.js +45 -0
  41. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/snapshot.js.map +1 -0
  42. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/stages/pr-view.js +9 -47
  43. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/stages/pr-view.js.map +1 -1
  44. package/node_modules/@clawdbot/lobster/dist/src/sdk/Lobster.js +18 -135
  45. package/node_modules/@clawdbot/lobster/dist/src/sdk/Lobster.js.map +1 -1
  46. package/node_modules/@clawdbot/lobster/dist/src/sdk/index.js +1 -1
  47. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/approve.js +1 -2
  48. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/approve.js.map +1 -1
  49. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/diff.js +2 -8
  50. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/diff.js.map +1 -1
  51. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/exec.js +15 -81
  52. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/exec.js.map +1 -1
  53. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/state.js +3 -9
  54. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/state.js.map +1 -1
  55. package/node_modules/@clawdbot/lobster/dist/src/sdk/runtime.js +0 -12
  56. package/node_modules/@clawdbot/lobster/dist/src/sdk/runtime.js.map +1 -1
  57. package/node_modules/@clawdbot/lobster/dist/src/sdk/state_env.js +6 -0
  58. package/node_modules/@clawdbot/lobster/dist/src/sdk/state_env.js.map +1 -0
  59. package/node_modules/@clawdbot/lobster/dist/src/state/atomic_file.js +192 -0
  60. package/node_modules/@clawdbot/lobster/dist/src/state/atomic_file.js.map +1 -0
  61. package/node_modules/@clawdbot/lobster/dist/src/state/store.js +2 -188
  62. package/node_modules/@clawdbot/lobster/dist/src/state/store.js.map +1 -1
  63. package/node_modules/@clawdbot/lobster/dist/src/workflows/dry_run.js +262 -0
  64. package/node_modules/@clawdbot/lobster/dist/src/workflows/dry_run.js.map +1 -0
  65. package/node_modules/@clawdbot/lobster/dist/src/workflows/expressions.js +345 -0
  66. package/node_modules/@clawdbot/lobster/dist/src/workflows/expressions.js.map +1 -0
  67. package/node_modules/@clawdbot/lobster/dist/src/workflows/file.js +21 -1036
  68. package/node_modules/@clawdbot/lobster/dist/src/workflows/file.js.map +1 -1
  69. package/node_modules/@clawdbot/lobster/dist/src/workflows/github_pr_monitor.js +8 -77
  70. package/node_modules/@clawdbot/lobster/dist/src/workflows/github_pr_monitor.js.map +1 -1
  71. package/node_modules/@clawdbot/lobster/dist/src/workflows/graph.js +1 -7
  72. package/node_modules/@clawdbot/lobster/dist/src/workflows/graph.js.map +1 -1
  73. package/node_modules/@clawdbot/lobster/dist/src/workflows/load.js +347 -0
  74. package/node_modules/@clawdbot/lobster/dist/src/workflows/load.js.map +1 -0
  75. package/node_modules/@clawdbot/lobster/dist/src/workflows/types.js +2 -0
  76. package/node_modules/@clawdbot/lobster/dist/src/workflows/types.js.map +1 -0
  77. package/node_modules/@clawdbot/lobster/dist/src/workflows/values.js +93 -0
  78. package/node_modules/@clawdbot/lobster/dist/src/workflows/values.js.map +1 -0
  79. package/node_modules/@clawdbot/lobster/dist/test/cancellation.test.js +1 -0
  80. package/node_modules/@clawdbot/lobster/dist/test/cancellation.test.js.map +1 -1
  81. package/node_modules/@clawdbot/lobster/dist/test/cli_run_errors.test.js +53 -0
  82. package/node_modules/@clawdbot/lobster/dist/test/cli_run_errors.test.js.map +1 -0
  83. package/node_modules/@clawdbot/lobster/dist/test/command_arguments.test.js +82 -0
  84. package/node_modules/@clawdbot/lobster/dist/test/command_arguments.test.js.map +1 -0
  85. package/node_modules/@clawdbot/lobster/dist/test/cost_tracker.test.js +49 -19
  86. package/node_modules/@clawdbot/lobster/dist/test/cost_tracker.test.js.map +1 -1
  87. package/node_modules/@clawdbot/lobster/dist/test/helpers/node_shell_fixture.js +17 -0
  88. package/node_modules/@clawdbot/lobster/dist/test/helpers/node_shell_fixture.js.map +1 -0
  89. package/node_modules/@clawdbot/lobster/dist/test/llm_invoke.test.js +2 -0
  90. package/node_modules/@clawdbot/lobster/dist/test/llm_invoke.test.js.map +1 -1
  91. package/node_modules/@clawdbot/lobster/dist/test/pipeline_resume_state.test.js +65 -0
  92. package/node_modules/@clawdbot/lobster/dist/test/pipeline_resume_state.test.js.map +1 -0
  93. package/node_modules/@clawdbot/lobster/dist/test/process_output_limit.test.js +5 -3
  94. package/node_modules/@clawdbot/lobster/dist/test/process_output_limit.test.js.map +1 -1
  95. package/node_modules/@clawdbot/lobster/dist/test/sdk_exec.test.js +72 -25
  96. package/node_modules/@clawdbot/lobster/dist/test/sdk_exec.test.js.map +1 -1
  97. package/node_modules/@clawdbot/lobster/dist/test/workflow_graph.test.js +1 -1
  98. package/node_modules/@clawdbot/lobster/dist/test/workflow_graph.test.js.map +1 -1
  99. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/README.md +1 -0
  100. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/SECURITY.md +62 -13
  101. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/index.js +40 -9
  102. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/lib/schemes.js +76 -16
  103. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/lib/utils.js +54 -10
  104. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/package.json +1 -1
  105. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/fixtures/uri-js-parse.json +1 -0
  106. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/ipv6-validation.test.js +130 -0
  107. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/mailto-authority-security.test.js +125 -0
  108. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/mailto-field-name-security.test.js +151 -0
  109. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/test/security-normalization.test.js +38 -0
  110. package/node_modules/@clawdbot/lobster/node_modules/fast-uri/types/index.d.ts +16 -0
  111. package/node_modules/@clawdbot/lobster/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
  112. package/node_modules/@clawdbot/lobster/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
  113. package/node_modules/@clawdbot/lobster/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
  114. package/node_modules/@clawdbot/lobster/node_modules/yaml/dist/nodes/Alias.js +25 -23
  115. package/node_modules/@clawdbot/lobster/node_modules/yaml/package.json +1 -1
  116. package/node_modules/@clawdbot/lobster/package.json +6 -8
  117. package/node_modules/typebox/build/compile/code.mjs +1 -2
  118. package/node_modules/typebox/build/compile/validator.mjs +5 -1
  119. package/node_modules/typebox/build/format/iri.mjs +16 -9
  120. package/node_modules/typebox/build/guard/emit.mjs +2 -2
  121. package/node_modules/typebox/build/guard/guard.d.mts +7 -5
  122. package/node_modules/typebox/build/guard/guard.mjs +14 -9
  123. package/node_modules/typebox/build/guard/index.mjs +6 -0
  124. package/node_modules/typebox/build/guard/unicode/unicode.d.mts +6 -0
  125. package/node_modules/typebox/build/guard/unicode/unicode.mjs +35 -0
  126. package/node_modules/typebox/build/guard/{string.mjs → unicode/unicode_segment.mjs} +16 -19
  127. package/node_modules/typebox/build/schema/build.mjs +2 -3
  128. package/node_modules/typebox/build/schema/engine/_pathing.d.mts +1 -0
  129. package/node_modules/typebox/build/schema/engine/_pathing.mjs +11 -0
  130. package/node_modules/typebox/build/schema/engine/_refine.mjs +13 -5
  131. package/node_modules/typebox/build/schema/engine/additionalProperties.mjs +2 -1
  132. package/node_modules/typebox/build/schema/engine/dependencies.mjs +2 -1
  133. package/node_modules/typebox/build/schema/engine/dependentSchemas.mjs +2 -1
  134. package/node_modules/typebox/build/schema/engine/dynamicRef.d.mts +1 -1
  135. package/node_modules/typebox/build/schema/engine/dynamicRef.mjs +2 -2
  136. package/node_modules/typebox/build/schema/engine/format.mjs +9 -2
  137. package/node_modules/typebox/build/schema/engine/patternProperties.mjs +3 -2
  138. package/node_modules/typebox/build/schema/engine/properties.mjs +3 -2
  139. package/node_modules/typebox/build/schema/engine/propertyNames.mjs +2 -1
  140. package/node_modules/typebox/build/schema/engine/recursiveRef.d.mts +1 -1
  141. package/node_modules/typebox/build/schema/engine/recursiveRef.mjs +2 -2
  142. package/node_modules/typebox/build/schema/engine/ref.d.mts +1 -1
  143. package/node_modules/typebox/build/schema/engine/ref.mjs +2 -2
  144. package/node_modules/typebox/build/schema/engine/schema.mjs +24 -24
  145. package/node_modules/typebox/build/schema/parse.mjs +3 -0
  146. package/node_modules/typebox/build/schema/pointer/pointer.d.mts +1 -6
  147. package/node_modules/typebox/build/schema/pointer/pointer.mjs +47 -64
  148. package/node_modules/typebox/build/system/settings/internal.d.mts +2 -0
  149. package/node_modules/typebox/build/system/settings/internal.mjs +20 -0
  150. package/node_modules/typebox/build/system/settings/settings.d.mts +11 -0
  151. package/node_modules/typebox/build/system/settings/settings.mjs +14 -0
  152. package/node_modules/typebox/build/type/extends/inference.d.mts +1 -1
  153. package/node_modules/typebox/build/type/extends/inference.mjs +15 -2
  154. package/node_modules/typebox/build/type/extends/tuple.d.mts +2 -1
  155. package/node_modules/typebox/build/type/extends/tuple.mjs +21 -1
  156. package/node_modules/typebox/build/value/parse/parse.mjs +10 -4
  157. package/node_modules/typebox/package.json +1 -1
  158. package/node_modules/typebox/readme.md +3 -1
  159. package/package.json +6 -6
  160. package/dist/runtime-api.js +0 -3
  161. package/node_modules/@clawdbot/lobster/dist/src/sdk/token.js +0 -9
  162. package/node_modules/@clawdbot/lobster/dist/src/sdk/token.js.map +0 -1
  163. /package/node_modules/typebox/build/guard/{string.d.mts → unicode/unicode_segment.d.mts} +0 -0
@@ -15,38 +15,87 @@ Reports based solely on comparing or mixing parsers that follow these different
15
15
  standards are out of scope. Applications must use the same parsing and
16
16
  normalization rules for both security decisions and subsequent URI use.
17
17
 
18
+ A WHATWG comparison may be useful supporting evidence, but a parser difference
19
+ is not by itself a vulnerability. A report remains in scope when it independently
20
+ demonstrates a defect in `fast-uri`'s RFC or documented behavior for a supported
21
+ scheme and a concrete security impact when the application uses `fast-uri`
22
+ consistently. An exploit that requires validating with `fast-uri` and then
23
+ passing the original, unmodified input to a parser with different rules is an
24
+ application parser-confusion issue.
25
+
18
26
  Reports are assessed based on reproducibility, documented supported usage, and
19
27
  concrete security impact—not merely on whether they mention WHATWG URL
20
- behavior. Reports demonstrating a parsing or normalization flaw in a supported
21
- scheme remain eligible for evaluation.
28
+ behavior.
22
29
 
23
30
  ## Threat model
24
31
 
25
32
  `fast-uri`'s threat model extends the
26
33
  [Node.js threat model](https://github.com/nodejs/node/blob/main/SECURITY.md#the-nodejs-threat-model).
27
34
 
28
- **Trusted:** Application code, parser options, configuration, and the runtime
29
- environment.
35
+ **Trusted:** Application code, structured component objects constructed or
36
+ modified by application code and passed directly to `serialize()`, parser
37
+ options, configuration, and the runtime environment.
30
38
 
31
39
  **Untrusted:** URI and IRI strings passed to the package's public APIs.
32
40
 
41
+ ## Vulnerability classification
42
+
43
+ A report is treated as a vulnerability when it demonstrates all of the
44
+ following:
45
+
46
+ 1. Attacker-controlled input reaches a public API through documented,
47
+ supported usage.
48
+ 2. `fast-uri` violates an applicable RFC or its documented API contract.
49
+ 3. The defect creates a concrete confidentiality, integrity, or availability
50
+ impact in a realistic supported use case.
51
+ 4. The impact does not depend solely on application misuse, an unsupported
52
+ scheme, a different parser interpreting the original input, or another
53
+ package's behavior.
54
+
55
+ A reproducible bug can still merit a normal issue and fix without meeting the
56
+ security-vulnerability threshold.
57
+
33
58
  ### Examples of vulnerabilities
34
59
 
35
- - RFC 3986 parsing or normalization flaws that bypass security controls
36
- - Denial of service through malformed input
37
- - Inconsistent parsing or normalization between `fast-uri` APIs
60
+ - `parse()`, `normalize()`, or `resolve()` changes an attacker-controlled
61
+ authority within `fast-uri`, and the report demonstrates a host security
62
+ control being bypassed using that output
63
+ - `equal()` aliases distinct URIs under the rules of a supported scheme, and
64
+ the report demonstrates an authorization, signature, or cache boundary being
65
+ bypassed
66
+ - One public API call on a reasonably sized malformed URI causes
67
+ disproportionate CPU or memory consumption, with reproducible availability
68
+ impact
69
+ - Parsing or normalization turns component data into a structural delimiter
70
+ and the report demonstrates a security-control bypass in documented usage
71
+
72
+ ### Denial-of-service reports
73
+
74
+ A synchronous exception, parse error, or non-idempotent result is not by itself
75
+ a denial-of-service vulnerability. A report must demonstrate disproportionate
76
+ resource consumption in a public API call, or loss of availability in a
77
+ supported deployment despite normal exception handling. An application that
78
+ terminates on an uncaught exception, or repeatedly normalizes until reaching a
79
+ fixpoint, does not establish a package vulnerability by itself.
38
80
 
39
81
  ### Examples of non-vulnerabilities
40
82
 
41
83
  The following are **not** considered vulnerabilities in `fast-uri`:
42
84
 
43
- - **Different URL standards:** Differences between RFC 3986 behavior and
44
- WHATWG URL behavior, including differences exposed by mixing parsers that
45
- implement those standards
46
- - **Unsupported scheme semantics:** Scheme-specific behavior for schemes that
47
- `fast-uri` does not document as supported
85
+ - **Different URL standards:** `fast-uri` returns one host, but an application
86
+ passes the original input to WHATWG `URL`, `fetch`, or another parser that
87
+ applies different recovery, whitespace, or legacy-IP rules
88
+ - **Unsupported scheme semantics:** Behavior specific to schemes such as
89
+ `ftp` or `file`, which `fast-uri` does not document as supported
90
+ - **Correctness without security impact:** Non-idempotent normalization,
91
+ surprising equality, truncation, re-encoding, or malformed-input handling
92
+ without a demonstrated security boundary or availability impact
93
+ - **Caller-created resource use:** An application repeatedly parses or
94
+ normalizes a value, or fails to catch a synchronous exception
95
+ - **Trusted structured input:** Boundary characters supplied in a component
96
+ object that application code constructs and passes to `serialize()`
48
97
  - **Application code vulnerabilities:** Security flaws in code that consumes
49
- `fast-uri` output
98
+ `fast-uri` output contrary to its documented contract
50
99
  - **Configuration mistakes:** Security issues caused by incorrect parser
51
100
  options or application configuration
52
101
  - **Missing security features:** Application-level protections that are not
@@ -89,6 +89,22 @@ function resolve (baseURI, relativeURI, options) {
89
89
  return serialize(resolved, schemelessOptions)
90
90
  }
91
91
 
92
+ /**
93
+ * Copies a host component together with its parse-time zone identifier (if
94
+ * any), so downstream recomposition does not re-derive an IPv6 zone from the
95
+ * ambiguous single-"%" component form. The zone is validated on use by
96
+ * `recomposeAuthority` rather than blindly trusted.
97
+ *
98
+ * @param {import('./types/index').URIComponent} target
99
+ * @param {import('./types/index').URIComponent} source
100
+ */
101
+ function copyHost (target, source) {
102
+ target.host = source.host
103
+ if (source.ipv6Zone !== undefined) {
104
+ target.ipv6Zone = source.ipv6Zone
105
+ }
106
+ }
107
+
92
108
  /**
93
109
  * @param {import ('./types/index').URIComponent} base
94
110
  * @param {import ('./types/index').URIComponent} relative
@@ -109,7 +125,7 @@ function resolveComponent (base, relative, options, skipNormalization) {
109
125
  target.scheme = relative.scheme
110
126
  // target.authority = relative.authority;
111
127
  target.userinfo = relative.userinfo
112
- target.host = relative.host
128
+ copyHost(target, relative)
113
129
  target.port = relative.port
114
130
  target.path = removeDotSegments(relative.path || '')
115
131
  target.query = relative.query
@@ -117,7 +133,7 @@ function resolveComponent (base, relative, options, skipNormalization) {
117
133
  if (relative.userinfo !== undefined || relative.host !== undefined || relative.port !== undefined) {
118
134
  // target.authority = relative.authority;
119
135
  target.userinfo = relative.userinfo
120
- target.host = relative.host
136
+ copyHost(target, relative)
121
137
  target.port = relative.port
122
138
  target.path = removeDotSegments(relative.path || '')
123
139
  target.query = relative.query
@@ -146,7 +162,7 @@ function resolveComponent (base, relative, options, skipNormalization) {
146
162
  }
147
163
  // target.authority = base.authority;
148
164
  target.userinfo = base.userinfo
149
- target.host = base.host
165
+ copyHost(target, base)
150
166
  target.port = base.port
151
167
  }
152
168
  target.scheme = base.scheme
@@ -178,6 +194,7 @@ function equal (uriA, uriB, options) {
178
194
  function serialize (cmpts, opts) {
179
195
  const component = {
180
196
  host: cmpts.host,
197
+ ipv6Zone: cmpts.ipv6Zone,
181
198
  scheme: cmpts.scheme,
182
199
  userinfo: cmpts.userinfo,
183
200
  port: cmpts.port,
@@ -506,6 +523,16 @@ function parseWithStatus (uri, opts) {
506
523
  malformedIPLiteral = hasIPLiteralBracket && (!bracketedIPLiteral || ipv6result.error === true)
507
524
  parsed.host = isIP ? ipv6result.host : ipv6result.host.toLowerCase()
508
525
 
526
+ if (isIP && ipv6result.isIPV6 === true && parsed.host.indexOf('%') !== -1) {
527
+ // Persist the zone identifier so recomposition never has to re-derive
528
+ // it from the ambiguous single-"%" component form, which misreads a
529
+ // zone that begins with "25" as a %25 separator. It is validated on
530
+ // use during recomposition, so a host reassigned after parse or an
531
+ // input zone that does not describe the host is ignored. Only zoned
532
+ // literals are ambiguous; plain IPv6 addresses round-trip without it.
533
+ parsed.ipv6Zone = parsed.host.slice(parsed.host.indexOf('%') + 1)
534
+ }
535
+
509
536
  if (malformedIPLiteral) {
510
537
  parsed.error = parsed.error || 'URI host is malformed.'
511
538
  malformedAuthorityOrPort = true
@@ -537,13 +564,17 @@ function parseWithStatus (uri, opts) {
537
564
  malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
538
565
  }
539
566
 
540
- if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
541
- if (uri.indexOf('%') !== -1) {
542
- if (parsed.host !== undefined && !malformedIPLiteral) {
543
- const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
544
- parsed.host = reescapeHostDelimiters(host, isIP)
545
- }
567
+ if (uri.indexOf('%') !== -1 && parsed.host !== undefined && !malformedIPLiteral) {
568
+ let host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
569
+ if (!isIP) {
570
+ // Fold reg-name case after decoding unreserved octets. The second
571
+ // pass only restores uppercase hex in escapes that remain encoded.
572
+ host = normalizePercentEncoding(host.toLowerCase())
546
573
  }
574
+ parsed.host = reescapeHostDelimiters(host, isIP)
575
+ }
576
+
577
+ if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
547
578
  if (parsed.path) {
548
579
  parsed.path = normalizePathEncoding(parsed.path)
549
580
  }
@@ -290,6 +290,7 @@ const DTEXT_IRI = allowSet(DTEXT_ALLOWED, true)
290
290
  const HEX_PAIR = /^[\da-f]{2}$/iu
291
291
  const MAILTO_DOMAIN_LITERAL = /^\[[\x21-\x5A\x5E-\x7E]*\]$/u
292
292
  const MAILTO_DOMAIN_ERROR = 'URI mailto has an invalid recipient domain.'
293
+ const MAILTO_AUTHORITY_ERROR = 'URI mailto must not have an authority component.'
293
294
  const HAS_SURROGATE = /[\uD800-\uDFFF]/u
294
295
 
295
296
  /**
@@ -495,29 +496,51 @@ function mailtoParse (component, options) {
495
496
  mailtoComponent.error = mailtoComponent.error || 'URI mailto has malformed header fields.'
496
497
  continue
497
498
  }
498
- const name = eqIdx === -1 ? token : token.slice(0, eqIdx)
499
+ const name = decodeHex(eqIdx === -1 ? token : token.slice(0, eqIdx))
500
+ const normalizedName = name.toLowerCase()
499
501
  const value = eqIdx === -1 ? '' : token.slice(eqIdx + 1)
500
- if (name === 'to') {
502
+ if (normalizedName === 'to') {
501
503
  const addrs = value.split(',')
502
504
  for (let j = 0; j < addrs.length; j++) to.push(addrs[j])
503
505
  continue
504
506
  }
505
- if (name === 'subject') {
507
+ if (normalizedName === 'subject') {
506
508
  mailtoComponent.subject = decodeHex(value)
507
509
  continue
508
510
  }
509
- if (name === 'body') {
511
+ if (normalizedName === 'body') {
510
512
  mailtoComponent.body = decodeHex(value)
511
513
  continue
512
514
  }
513
515
  if (headers === null) headers = /** @type {Record<string,string>} */ (Object.create(null))
514
- headers[decodeHex(name)] = decodeHex(value)
516
+ headers[name] = decodeHex(value)
515
517
  }
516
518
  if (headers !== null) mailtoComponent.headers = headers
517
519
  }
518
520
 
519
521
  mailtoComponent.query = undefined
520
522
 
523
+ // RFC 6068 defines no authority for the `mailto` scheme. A `mailto://` input
524
+ // that retains an authority and also carries recipients (from the path or a
525
+ // `to=` hfield) folds a leading "/" into the recipient local part on the
526
+ // next parse; the generic serializer then reinserts a "/" before that path
527
+ // on every pass, so serialize(parse(x)) is not a fixpoint and a
528
+ // renormalize-to-fixpoint loop accretes one "%2F" per pass. Drop the
529
+ // retained authority so the parse -> serialize round trip is stable. A bare
530
+ // `mailto://host` with no recipients is left as-is: it is idempotent and host
531
+ // normalization for skip-normalize schemes stays intact.
532
+ if (
533
+ to.length > 0 &&
534
+ (mailtoComponent.userinfo !== undefined ||
535
+ mailtoComponent.host !== undefined ||
536
+ mailtoComponent.port !== undefined)
537
+ ) {
538
+ mailtoComponent.userinfo = undefined
539
+ mailtoComponent.host = undefined
540
+ mailtoComponent.port = undefined
541
+ mailtoComponent.error = mailtoComponent.error || MAILTO_AUTHORITY_ERROR
542
+ }
543
+
521
544
  for (let i = 0; i < to.length; i++) {
522
545
  const rawAddr = to[i]
523
546
  const atIdx = rawAddr.lastIndexOf('@')
@@ -536,6 +559,18 @@ function mailtoParse (component, options) {
536
559
  return mailtoComponent
537
560
  }
538
561
 
562
+ /**
563
+ * Encodes a decoded mailto header name. Unlike `encodeWithAllow`, a percent
564
+ * sign is always data here, even when followed by two hex digits. Preserving
565
+ * such a sequence as an existing escape would decode the name again on the
566
+ * next parse and could turn a generic header into `to`, `subject`, or `body`.
567
+ * @param {string} name
568
+ * @returns {string}
569
+ */
570
+ function mailtoEncodeHeaderName (name) {
571
+ return encodeWithAllow(name.replace(/%/gu, '%25'), HFNAME)
572
+ }
573
+
539
574
  /**
540
575
  * @param {import('../types/index').URIComponent} component
541
576
  * @param {import('../types/index').Options} options
@@ -544,7 +579,40 @@ function mailtoParse (component, options) {
544
579
  function mailtoSerialize (component, options) {
545
580
  const mailtoComponent = component
546
581
  const to = Array.isArray(mailtoComponent.to) ? mailtoComponent.to.slice() : []
582
+ const sourceHeaders = mailtoComponent.headers && typeof mailtoComponent.headers === 'object'
583
+ ? Object.assign(Object.create(null), mailtoComponent.headers)
584
+ : Object.create(null)
585
+ const headers = Object.create(null)
586
+
587
+ // Header field names are case-insensitive. Canonicalize the fields with
588
+ // dedicated component properties so callers cannot smuggle a second spelling
589
+ // through `headers`. As during parsing, all `to` fields are accumulated;
590
+ // repeated subject/body fields use the last value.
591
+ for (const name in sourceHeaders) {
592
+ const normalizedName = name.toLowerCase()
593
+ if (normalizedName === 'to') {
594
+ const addrs = String(sourceHeaders[name]).split(',')
595
+ for (let i = 0; i < addrs.length; i++) to.push(addrs[i])
596
+ } else if (normalizedName === 'subject' || normalizedName === 'body') {
597
+ headers[normalizedName] = sourceHeaders[name]
598
+ } else {
599
+ headers[name] = sourceHeaders[name]
600
+ }
601
+ }
602
+
603
+ if (mailtoComponent.subject !== undefined) headers.subject = mailtoComponent.subject
604
+ if (mailtoComponent.body !== undefined) headers.body = mailtoComponent.body
605
+ mailtoComponent.headers = headers
606
+
547
607
  if (to.length) {
608
+ // RFC 6068 defines no authority for the `mailto` scheme. If an
609
+ // application constructs a component with both an authority and recipients,
610
+ // drop the authority before the generic serializer can insert a "/" path
611
+ // separator that becomes part of the first recipient on the next parse.
612
+ mailtoComponent.userinfo = undefined
613
+ mailtoComponent.host = undefined
614
+ mailtoComponent.port = undefined
615
+
548
616
  for (let i = 0; i < to.length; i++) {
549
617
  const addr = String(to[i])
550
618
  const atIdx = addr.lastIndexOf('@')
@@ -570,21 +638,13 @@ function mailtoSerialize (component, options) {
570
638
  mailtoComponent.path = undefined
571
639
  }
572
640
 
573
- const headers = mailtoComponent.headers && typeof mailtoComponent.headers === 'object'
574
- ? Object.assign(Object.create(null), mailtoComponent.headers)
575
- : Object.create(null)
576
-
577
- if (mailtoComponent.subject) headers.subject = mailtoComponent.subject
578
- if (mailtoComponent.body) headers.body = mailtoComponent.body
579
- mailtoComponent.headers = headers
580
-
581
- // `headers` has a null prototype and was filled via Object.assign, so every
582
- // enumerable key is an own property and no hasOwnProperty guard is needed.
641
+ // `headers` has a null prototype, so every enumerable key is an own property
642
+ // and no hasOwnProperty guard is needed.
583
643
  let query = ''
584
644
  let count = 0
585
645
  for (const name in headers) {
586
646
  if (count++ !== 0) query += '&'
587
- query += encodeWithAllow(name, HFNAME) + '=' + encodeWithAllow(String(headers[name]), HFNAME)
647
+ query += mailtoEncodeHeaderName(name) + '=' + encodeWithAllow(String(headers[name]), HFNAME)
588
648
  }
589
649
 
590
650
  if (count !== 0) {
@@ -740,6 +740,37 @@ function escapePreservingEscapes (input) {
740
740
  return output
741
741
  }
742
742
 
743
+ /**
744
+ * Rebuilds the bracketed form of a zoned IPv6 authority from the current
745
+ * `component.host` and its zone identifier (`component.ipv6Zone`), but only
746
+ * when they are consistent and describe a real zoned IPv6 literal that
747
+ * normalizes back to `component.host`. This validates the zone on use, so a
748
+ * caller-supplied `ipv6Zone` that does not match the host (or a host
749
+ * reassigned after `parse`) is never trusted and recomposition falls back to
750
+ * the normal path.
751
+ *
752
+ * @param {import('../types/index').URIComponent} component
753
+ * @returns {string|undefined}
754
+ */
755
+ function recomposeZonedIPv6Host (component) {
756
+ const zone = component.ipv6Zone
757
+ // Without an ipv6Zone the single-"%" host form is genuinely ambiguous when
758
+ // the zone begins with "25", so we fall through to the best-effort heuristic
759
+ // (which matches the pre-existing behavior for hand-built components that do
760
+ // not carry a zone).
761
+ if (zone === undefined) return undefined
762
+ const host = component.host
763
+ const separator = host.indexOf('%')
764
+ if (separator === -1) return undefined
765
+ const address = host.slice(0, separator)
766
+ const escaped = address + '%25' + zone
767
+ const derived = normalizeIPv6('[' + escaped + ']')
768
+ if (derived.isIPV6 === true && derived.host === host) {
769
+ return '[' + escaped + ']'
770
+ }
771
+ return undefined
772
+ }
773
+
743
774
  /**
744
775
  * @param {import('../types/index').URIComponent} component
745
776
  * @returns {string|undefined}
@@ -755,17 +786,30 @@ function recomposeAuthority (component) {
755
786
  if (component.host !== undefined) {
756
787
  let host = component.host
757
788
  if (!isIPv4(host)) {
758
- let ipV6res = normalizeIPv6(host)
759
- if (ipV6res.isIPV6 !== true && ipV6res.isIPVFuture !== true) {
760
- // Decode only unreserved bytes, once. In particular, keep %25 encoded
761
- // so it cannot introduce a second escape during recomposition.
762
- host = normalizePercentEncoding(host, true)
763
- ipV6res = normalizeIPv6(host)
764
- }
765
- if (ipV6res.isIPV6 === true || ipV6res.isIPVFuture === true) {
766
- host = `[${ipV6res.escapedHost}]`
789
+ // A parse-time zone identifier is authoritative only while it still
790
+ // describes the current host. Re-deriving an IPv6 zone from the
791
+ // single-"%" component form is ambiguous when the zone begins with
792
+ // "25": it is misread as a %25 separator, rewriting e.g. zone
793
+ // "25eth0" into "eth0" (or destroying a zone of just "25"). A
794
+ // validated zone keeps parse, normalize, serialize and equal in
795
+ // agreement.
796
+ const zonedHost = recomposeZonedIPv6Host(component)
797
+ if (zonedHost !== undefined) {
798
+ host = zonedHost
767
799
  } else {
768
- host = reescapeHostDelimiters(host, false)
800
+ let ipV6res = normalizeIPv6(host)
801
+ if (ipV6res.isIPV6 !== true && ipV6res.isIPVFuture !== true) {
802
+ // Decode only unreserved bytes, once. In particular, keep %25
803
+ // encoded so it cannot introduce a second escape during
804
+ // recomposition.
805
+ host = normalizePercentEncoding(host, true)
806
+ ipV6res = normalizeIPv6(host)
807
+ }
808
+ if (ipV6res.isIPV6 === true || ipV6res.isIPVFuture === true) {
809
+ host = `[${ipV6res.escapedHost}]`
810
+ } else {
811
+ host = reescapeHostDelimiters(host, false)
812
+ }
769
813
  }
770
814
  }
771
815
  uriTokens.push(host)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fast-uri",
3
3
  "description": "Dependency-free RFC 3986 URI toolbox",
4
- "version": "4.1.4",
4
+ "version": "4.2.1",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
7
7
  "types": "types/index.d.ts",
@@ -43,6 +43,7 @@
43
43
  "//[2001:db8::7%en0]",
44
44
  {
45
45
  "host": "2001:db8::7%en0",
46
+ "ipv6Zone": "en0",
46
47
  "path": "",
47
48
  "reference": "relative"
48
49
  }
@@ -119,6 +119,136 @@ test('IPv6 zone identifiers are validated correctly', (t) => {
119
119
  t.end()
120
120
  })
121
121
 
122
+ test('zoned IPv6 hosts beginning with "25" round-trip through parse/normalize/serialize/equal', (t) => {
123
+ const cases = [
124
+ // zones beginning with "25" (once ambiguous with a %25 separator)
125
+ ['http://[fe80::1%2525]/', 'fe80::1%25', 'http://[fe80::1%2525]/'],
126
+ ['http://[fe80::1%2525eth0]/', 'fe80::1%25eth0', 'http://[fe80::1%2525eth0]/'],
127
+ ['http://[fe80::1%2525en1]/', 'fe80::1%25en1', 'http://[fe80::1%2525en1]/'],
128
+ // zones that do not begin with "25" must stay idempotent
129
+ ['http://[fe80::1%25eth0]/', 'fe80::1%eth0', 'http://[fe80::1%25eth0]/'],
130
+ ['http://[fe80::a%en1]/', 'fe80::a%en1', 'http://[fe80::a%25en1]/'],
131
+ ['http://[fe80::a%25eth%2D0]/', 'fe80::a%eth%2D0', 'http://[fe80::a%25eth%2D0]/']
132
+ ]
133
+
134
+ for (const [input, host, normalized] of cases) {
135
+ const parsed = fastURI.parse(input)
136
+ t.equal(parsed.error, undefined, `${input} parses without error`)
137
+ t.equal(parsed.host, host, `${input} exposes the expected host`)
138
+ t.equal(fastURI.normalize(input), normalized, `${input} normalizes to the canonical form`)
139
+ t.equal(fastURI.serialize(parsed), normalized, `${input} serializes to the canonical form`)
140
+ const reparsed = fastURI.parse(normalized)
141
+ t.equal(reparsed.error, undefined, `${input} canonical output reparses without error`)
142
+ t.equal(reparsed.host, host, `${input} parse-after-normalize is stable`)
143
+ }
144
+
145
+ // distinct zones must not be conflated by equal
146
+ t.equal(
147
+ fastURI.equal('http://[fe80::1%2525eth0]/', 'http://[fe80::1%25eth0]/'),
148
+ false,
149
+ 'equal distinguishes zones "25eth0" and "eth0"'
150
+ )
151
+ t.equal(
152
+ fastURI.equal('http://[fe80::1%2525]/', 'http://[fe80::1%25eth0]/'),
153
+ false,
154
+ 'equal distinguishes zones "25" and "eth0"'
155
+ )
156
+ // the same zone still compares equal
157
+ t.equal(
158
+ fastURI.equal('http://[fe80::1%2525eth0]/', 'http://[fe80::1%2525eth0]/'),
159
+ true,
160
+ 'an identical zoned authority is equal'
161
+ )
162
+ t.equal(
163
+ fastURI.equal('http://[fe80::1%2525]/', 'http://[fe80::1%2525]/'),
164
+ true,
165
+ 'zone "25" equals itself'
166
+ )
167
+
168
+ // resolve must preserve the zone too
169
+ t.equal(
170
+ fastURI.resolve('http://example.com/', 'http://[fe80::1%2525eth0]/path'),
171
+ 'http://[fe80::1%2525eth0]/path',
172
+ 'resolve preserves a zone beginning with "25"'
173
+ )
174
+ t.equal(
175
+ fastURI.resolve('http://[fe80::1%2525eth0]/', 'child'),
176
+ 'http://[fe80::1%2525eth0]/child',
177
+ 'resolve preserves a zoned base'
178
+ )
179
+
180
+ t.end()
181
+ })
182
+
183
+ test('serialize only trusts the parse-derived IPv6 zone while the host is unchanged', (t) => {
184
+ // Reassigning `host` after parse must take effect for serialize.
185
+ const parsed = fastURI.parse('http://[::1]/admin')
186
+ parsed.host = 'example.com'
187
+ t.equal(
188
+ fastURI.serialize(parsed),
189
+ 'http://example.com/admin',
190
+ 'a host reassigned after parse wins over the stale zone'
191
+ )
192
+
193
+ // A caller-supplied `ipv6Zone` that does not match `host` must be ignored.
194
+ const forged = fastURI.serialize({
195
+ scheme: 'http',
196
+ host: 'fe80::1%25en1',
197
+ ipv6Zone: 'eth0',
198
+ path: '/'
199
+ })
200
+ t.equal(forged, 'http://[fe80::1%25en1]/', 'a mismatched ipv6Zone is ignored')
201
+ t.equal(fastURI.parse(forged).host, 'fe80::1%en1', 'mismatched ipv6Zone cannot redirect the parsed host')
202
+
203
+ // A caller-supplied `ipv6Zone` on a non-zoned host must be ignored too.
204
+ const noZone = fastURI.serialize({
205
+ scheme: 'http',
206
+ host: 'good.example',
207
+ ipv6Zone: 'eth0',
208
+ path: '/'
209
+ })
210
+ t.equal(noZone, 'http://good.example/', 'ipv6Zone on a non-zoned host is ignored')
211
+
212
+ // Reassigning a zoned host to a different zone must recompute, not reuse.
213
+ const zoned = fastURI.parse('http://[fe80::1%2525eth0]/')
214
+ zoned.host = 'fe80::1%25en1'
215
+ t.equal(
216
+ fastURI.serialize(zoned),
217
+ 'http://[fe80::1%25en1]/',
218
+ 'rewriting the zone recomposes from the new host'
219
+ )
220
+
221
+ // A consistent caller-supplied zone is honored (explicit disambiguation).
222
+ const disambiguated = fastURI.serialize({
223
+ scheme: 'http',
224
+ host: 'fe80::1%25eth0',
225
+ ipv6Zone: '25eth0',
226
+ path: '/'
227
+ })
228
+ t.equal(
229
+ disambiguated,
230
+ 'http://[fe80::1%2525eth0]/',
231
+ 'a consistent ipv6Zone disambiguates the component host'
232
+ )
233
+
234
+ // Without ipv6Zone, an ambiguous single-"%" host falls back to the
235
+ // best-effort heuristic (documented behavior for hand-built components).
236
+ t.equal(
237
+ fastURI.serialize({ host: 'fe80::1%25eth0' }),
238
+ '//[fe80::1%25eth0]',
239
+ 'an ambiguous host without ipv6Zone uses the best-effort heuristic'
240
+ )
241
+
242
+ // Mutating the host must not poison resolve either.
243
+ t.equal(
244
+ fastURI.resolve('http://example.com/', 'http://[fe80::1%2525eth0]/path'),
245
+ 'http://[fe80::1%2525eth0]/path',
246
+ 'resolve preserves the parse-derived zone'
247
+ )
248
+
249
+ t.end()
250
+ })
251
+
122
252
  test('hosts with unbalanced or misplaced IP-literal brackets are rejected', (t) => {
123
253
  const malformed = [
124
254
  'http://[fe80',