shelving 1.267.4 → 1.267.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.267.4",
3
+ "version": "1.267.6",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,6 +37,7 @@ function _matchRoute(routes, fallback, meta, path = meta.path, depth = 0) {
37
37
  if (typeof Route === "string") {
38
38
  if (depth > 10)
39
39
  throw new UnexpectedError("Infinite redirect loop", { received: route, expected: path, caller: _matchRoute });
40
+ // `placeholders` are already decoded by `matchPathTemplate`, so re-encode them into the redirect path with `renderPathTemplate` — the symmetric partner of the match above.
40
41
  return _matchRoute(routes, fallback, meta, renderPathTemplate(Route, placeholders), depth + 1);
41
42
  }
42
43
  // React element — render as-is.
@@ -61,6 +61,7 @@ function _matchRoute(
61
61
  // String value is a redirect; re-run matching with the new path. Guard against infinite redirect loops by limiting depth.
62
62
  if (typeof Route === "string") {
63
63
  if (depth > 10) throw new UnexpectedError("Infinite redirect loop", { received: route, expected: path, caller: _matchRoute });
64
+ // `placeholders` are already decoded by `matchPathTemplate`, so re-encode them into the redirect path with `renderPathTemplate` — the symmetric partner of the match above.
64
65
  return _matchRoute(routes, fallback, meta, renderPathTemplate(Route, placeholders), depth + 1);
65
66
  }
66
67
 
@@ -53,11 +53,13 @@ export declare function matchTemplate(template: string, target: string, caller?:
53
53
  * Match a path-shaped template against a target path.
54
54
  * - Like `matchTemplate()`, but with `/` segment semantics: non-catchall placeholders cannot span path segments; catchall placeholders can.
55
55
  * - A trailing catchall (e.g. `/files/{...path}`) also matches when the trailing separator is absent (e.g. `/files`), with the catchall value as `""`.
56
+ * - Matched values are **percent-decoded** (the inverse of `renderPathTemplate()`'s encoding), so a round trip is lossless: `matchPathTemplate(t, renderPathTemplate(t, values))` returns the original values. Catchall values keep their `/` separators (each segment is decoded).
57
+ * - Returns `undefined` if `target` is not validly percent-encoded (e.g. a bare `%` or `%zz`), treating a malformed path as a non-match.
56
58
  *
57
59
  * @param template The path-shaped template string, e.g. `/users/{name}`.
58
60
  * @param target The path containing values, e.g. `/users/Dave`.
59
61
  * @param caller Function to attribute a thrown error to (defaults to `matchPathTemplate` itself).
60
- * @returns An object containing values (e.g. `{ name: "Dave" }`), or `undefined` if no match.
62
+ * @returns An object containing decoded values (e.g. `{ name: "Dave" }`), or `undefined` if no match.
61
63
  * @throws {ValueError} If two template placeholders are not separated by at least one character.
62
64
  * @example matchPathTemplate("/users/{name}", "/users/Dave") // { name: "Dave" }
63
65
  * @see https://shelving.cc/util/template/matchPathTemplate
@@ -96,14 +98,23 @@ export declare function matchPathTemplates(templates: Iterable<AbsolutePath> & N
96
98
  */
97
99
  export declare function renderTemplate(template: string, values: TemplateValues, caller?: AnyCaller): string;
98
100
  /**
99
- * Render a path-shaped template. Behaviourally identical to `renderTemplate()` substitution doesn't need separator awareness — but provided as a sibling to `matchPathTemplate()` so callers can pair them.
101
+ * Render a path-shaped template, percent-encoding each substituted value so it stays within its path segment.
102
+ *
103
+ * Unlike `renderTemplate()`, each placeholder value is run through `encodeURIComponent()` before substitution.
104
+ * This stops a value that contains `/`, `?`, `#`, `%`, or control characters from escaping its segment and
105
+ * altering the resolved URL — i.e. path traversal to a different endpoint, or query/fragment injection — when
106
+ * the result is resolved against a base URL (e.g. by `ClientAPIProvider`). Catchall placeholders (`**`,
107
+ * `{...path}`, …) legitimately span segments, so each `/`-separated part is encoded but the separators are kept.
108
+ *
109
+ * Note: because `encodeURIComponent()` does not encode `.`, a value that is exactly `.` or `..` still renders as
110
+ * `.`/`..`; callers that treat a placeholder as a trusted single segment should validate against those.
100
111
  *
101
112
  * @param template The path-shaped template including placeholders, e.g. `/users/{name}`.
102
113
  * @param values An object containing values (functions are called, everything else converted to string), or a function or string to use for all placeholders.
103
114
  * @param caller Function to attribute a thrown error to (defaults to `renderPathTemplate` itself).
104
- * @returns The rendered path, e.g. `/users/Dave`.
115
+ * @returns The rendered path with each value percent-encoded, e.g. `/users/Dave`.
105
116
  * @throws {RequiredError} If a placeholder in the template string is not specified in values.
106
- * @example renderPathTemplate("/users/{name}", { name: "Dave" }) // "/users/Dave"
117
+ * @example renderPathTemplate("/users/{name}", { name: "a/b" }) // "/users/a%2Fb"
107
118
  * @see https://shelving.cc/util/template/renderPathTemplate
108
119
  */
109
120
  export declare function renderPathTemplate(template: AbsolutePath, values: TemplateValues, caller?: AnyCaller): AbsolutePath;
package/util/template.js CHANGED
@@ -83,25 +83,40 @@ function _getPlaceholder({ name }) {
83
83
  * @see https://shelving.cc/util/template/matchTemplate
84
84
  */
85
85
  export function matchTemplate(template, target, caller = matchTemplate) {
86
- return _matchTemplate(template, target, "", caller);
86
+ return _matchTemplate(template, target, "", false, caller);
87
87
  }
88
88
  /**
89
89
  * Match a path-shaped template against a target path.
90
90
  * - Like `matchTemplate()`, but with `/` segment semantics: non-catchall placeholders cannot span path segments; catchall placeholders can.
91
91
  * - A trailing catchall (e.g. `/files/{...path}`) also matches when the trailing separator is absent (e.g. `/files`), with the catchall value as `""`.
92
+ * - Matched values are **percent-decoded** (the inverse of `renderPathTemplate()`'s encoding), so a round trip is lossless: `matchPathTemplate(t, renderPathTemplate(t, values))` returns the original values. Catchall values keep their `/` separators (each segment is decoded).
93
+ * - Returns `undefined` if `target` is not validly percent-encoded (e.g. a bare `%` or `%zz`), treating a malformed path as a non-match.
92
94
  *
93
95
  * @param template The path-shaped template string, e.g. `/users/{name}`.
94
96
  * @param target The path containing values, e.g. `/users/Dave`.
95
97
  * @param caller Function to attribute a thrown error to (defaults to `matchPathTemplate` itself).
96
- * @returns An object containing values (e.g. `{ name: "Dave" }`), or `undefined` if no match.
98
+ * @returns An object containing decoded values (e.g. `{ name: "Dave" }`), or `undefined` if no match.
97
99
  * @throws {ValueError} If two template placeholders are not separated by at least one character.
98
100
  * @example matchPathTemplate("/users/{name}", "/users/Dave") // { name: "Dave" }
99
101
  * @see https://shelving.cc/util/template/matchPathTemplate
100
102
  */
101
103
  export function matchPathTemplate(template, target, caller = matchPathTemplate) {
102
- return _matchTemplate(template, target, "/", caller);
104
+ return _matchTemplate(template, target, "/", true, caller);
103
105
  }
104
- function _matchTemplate(template, target, separator, caller) {
106
+ /**
107
+ * Decode a matched path value — the inverse of `renderPathTemplate()`'s `encodeURIComponent`.
108
+ * - Catchall values keep their `/` separators (each segment is decoded independently, mirroring the per-segment encode).
109
+ * - Returns `undefined` for malformed percent-encoding (e.g. a bare `%` or `%zz`), so the caller can reject the whole match.
110
+ */
111
+ function _decodePathValue(value, catchall) {
112
+ try {
113
+ return catchall ? value.split("/").map(decodeURIComponent).join("/") : decodeURIComponent(value);
114
+ }
115
+ catch {
116
+ return undefined;
117
+ }
118
+ }
119
+ function _matchTemplate(template, target, separator, decode, caller) {
105
120
  // Get separators and placeholders from template.
106
121
  const chunks = _splitTemplateCached(template, caller);
107
122
  const firstChunk = chunks[0];
@@ -134,7 +149,18 @@ function _matchTemplate(template, target, separator, caller) {
134
149
  if (separator && value.includes(separator))
135
150
  return undefined; // Non-catchall placeholders can't span separators (when one is configured).
136
151
  }
137
- values[name] = value;
152
+ // In path mode, percent-decode the (still-encoded) value so it round-trips with `renderPathTemplate()`; a
153
+ // malformed encoding rejects the whole match. The separator check above runs on the encoded value first,
154
+ // so an encoded `%2F` can't smuggle a separator into a non-catchall segment.
155
+ if (decode) {
156
+ const decoded = _decodePathValue(value, catchall);
157
+ if (decoded === undefined)
158
+ return undefined;
159
+ values[name] = decoded;
160
+ }
161
+ else {
162
+ values[name] = value;
163
+ }
138
164
  startIndex = stopIndex + post.length;
139
165
  }
140
166
  if (startIndex < target.length)
@@ -191,14 +217,14 @@ export function renderTemplate(template, values, caller = renderTemplate) {
191
217
  let output = template;
192
218
  if (isFunction(values)) {
193
219
  for (const { name, placeholder } of chunks)
194
- output = output.replace(placeholder, values(name));
220
+ output = output.replace(placeholder, _escapeReplacement(values(name)));
195
221
  }
196
222
  else if (isData(values)) {
197
223
  for (const { name, placeholder } of chunks) {
198
224
  const v = getString(getDataProp(values, name));
199
225
  if (v === undefined)
200
226
  throw new RequiredError(`Template placeholder "${name}" not found in object`, { received: values, name, caller });
201
- output = output.replace(placeholder, v);
227
+ output = output.replace(placeholder, _escapeReplacement(v));
202
228
  }
203
229
  }
204
230
  else if (isArray(values)) {
@@ -206,7 +232,7 @@ export function renderTemplate(template, values, caller = renderTemplate) {
206
232
  const v = getString(values[Number(name)]);
207
233
  if (v === undefined)
208
234
  throw new RequiredError(`Template placeholder "${name}" not found in array`, { received: values, name, caller });
209
- output = output.replace(placeholder, v);
235
+ output = output.replace(placeholder, _escapeReplacement(v));
210
236
  }
211
237
  }
212
238
  else {
@@ -214,21 +240,62 @@ export function renderTemplate(template, values, caller = renderTemplate) {
214
240
  if (v === undefined)
215
241
  throw new RequiredError(`Template value must be string`, { received: values, caller });
216
242
  for (const { placeholder } of chunks)
217
- output = output.replace(placeholder, v);
243
+ output = output.replace(placeholder, _escapeReplacement(v));
218
244
  }
219
245
  return output;
220
246
  }
221
247
  /**
222
- * Render a path-shaped template. Behaviourally identical to `renderTemplate()` substitution doesn't need separator awareness — but provided as a sibling to `matchPathTemplate()` so callers can pair them.
248
+ * Escape `$` in a `String.prototype.replace()` replacement value so it's inserted literally.
249
+ * - Without this, `$&`, `$'`, `` $` ``, `$1`, `$$` in a (often user-supplied) value are interpreted as replacement
250
+ * patterns and pull surrounding template text into the value's slot.
251
+ * - Cheap: only rewrites when a `$` is actually present, so the common case is a single scan with no allocation.
252
+ */
253
+ function _escapeReplacement(value) {
254
+ return value.includes("$") ? value.replaceAll("$", "$$$$") : value;
255
+ }
256
+ /**
257
+ * Render a path-shaped template, percent-encoding each substituted value so it stays within its path segment.
258
+ *
259
+ * Unlike `renderTemplate()`, each placeholder value is run through `encodeURIComponent()` before substitution.
260
+ * This stops a value that contains `/`, `?`, `#`, `%`, or control characters from escaping its segment and
261
+ * altering the resolved URL — i.e. path traversal to a different endpoint, or query/fragment injection — when
262
+ * the result is resolved against a base URL (e.g. by `ClientAPIProvider`). Catchall placeholders (`**`,
263
+ * `{...path}`, …) legitimately span segments, so each `/`-separated part is encoded but the separators are kept.
264
+ *
265
+ * Note: because `encodeURIComponent()` does not encode `.`, a value that is exactly `.` or `..` still renders as
266
+ * `.`/`..`; callers that treat a placeholder as a trusted single segment should validate against those.
223
267
  *
224
268
  * @param template The path-shaped template including placeholders, e.g. `/users/{name}`.
225
269
  * @param values An object containing values (functions are called, everything else converted to string), or a function or string to use for all placeholders.
226
270
  * @param caller Function to attribute a thrown error to (defaults to `renderPathTemplate` itself).
227
- * @returns The rendered path, e.g. `/users/Dave`.
271
+ * @returns The rendered path with each value percent-encoded, e.g. `/users/Dave`.
228
272
  * @throws {RequiredError} If a placeholder in the template string is not specified in values.
229
- * @example renderPathTemplate("/users/{name}", { name: "Dave" }) // "/users/Dave"
273
+ * @example renderPathTemplate("/users/{name}", { name: "a/b" }) // "/users/a%2Fb"
230
274
  * @see https://shelving.cc/util/template/renderPathTemplate
231
275
  */
232
276
  export function renderPathTemplate(template, values, caller = renderPathTemplate) {
233
- return renderTemplate(template, values, caller);
277
+ const chunks = _splitTemplateCached(template, caller);
278
+ if (!chunks.length)
279
+ return template;
280
+ let output = template;
281
+ for (const { name, placeholder, catchall } of chunks) {
282
+ const value = _getTemplateValue(values, name, caller);
283
+ const encoded = catchall ? value.split("/").map(encodeURIComponent).join("/") : encodeURIComponent(value);
284
+ // Use a replacer function so `$` sequences in the (already-encoded) value are never treated as special replacement patterns.
285
+ output = output.replace(placeholder, () => encoded);
286
+ }
287
+ return output;
288
+ }
289
+ /** Resolve the string value for a single template placeholder from `TemplateValues` (mirrors `renderTemplate()`'s per-name resolution). */
290
+ function _getTemplateValue(values, name, caller) {
291
+ const value = isFunction(values)
292
+ ? values(name)
293
+ : isData(values)
294
+ ? getString(getDataProp(values, name))
295
+ : isArray(values)
296
+ ? getString(values[Number(name)])
297
+ : getString(values);
298
+ if (value === undefined)
299
+ throw new RequiredError(`Template placeholder "${name}" not found`, { received: values, name, caller });
300
+ return value;
234
301
  }