shelving 1.267.4 → 1.267.5

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.5",
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)
@@ -219,16 +245,48 @@ export function renderTemplate(template, values, caller = renderTemplate) {
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
+ * Render a path-shaped template, percent-encoding each substituted value so it stays within its path segment.
249
+ *
250
+ * Unlike `renderTemplate()`, each placeholder value is run through `encodeURIComponent()` before substitution.
251
+ * This stops a value that contains `/`, `?`, `#`, `%`, or control characters from escaping its segment and
252
+ * altering the resolved URL — i.e. path traversal to a different endpoint, or query/fragment injection — when
253
+ * the result is resolved against a base URL (e.g. by `ClientAPIProvider`). Catchall placeholders (`**`,
254
+ * `{...path}`, …) legitimately span segments, so each `/`-separated part is encoded but the separators are kept.
255
+ *
256
+ * Note: because `encodeURIComponent()` does not encode `.`, a value that is exactly `.` or `..` still renders as
257
+ * `.`/`..`; callers that treat a placeholder as a trusted single segment should validate against those.
223
258
  *
224
259
  * @param template The path-shaped template including placeholders, e.g. `/users/{name}`.
225
260
  * @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
261
  * @param caller Function to attribute a thrown error to (defaults to `renderPathTemplate` itself).
227
- * @returns The rendered path, e.g. `/users/Dave`.
262
+ * @returns The rendered path with each value percent-encoded, e.g. `/users/Dave`.
228
263
  * @throws {RequiredError} If a placeholder in the template string is not specified in values.
229
- * @example renderPathTemplate("/users/{name}", { name: "Dave" }) // "/users/Dave"
264
+ * @example renderPathTemplate("/users/{name}", { name: "a/b" }) // "/users/a%2Fb"
230
265
  * @see https://shelving.cc/util/template/renderPathTemplate
231
266
  */
232
267
  export function renderPathTemplate(template, values, caller = renderPathTemplate) {
233
- return renderTemplate(template, values, caller);
268
+ const chunks = _splitTemplateCached(template, caller);
269
+ if (!chunks.length)
270
+ return template;
271
+ let output = template;
272
+ for (const { name, placeholder, catchall } of chunks) {
273
+ const value = _getTemplateValue(values, name, caller);
274
+ const encoded = catchall ? value.split("/").map(encodeURIComponent).join("/") : encodeURIComponent(value);
275
+ // Use a replacer function so `$` sequences in the (already-encoded) value are never treated as special replacement patterns.
276
+ output = output.replace(placeholder, () => encoded);
277
+ }
278
+ return output;
279
+ }
280
+ /** Resolve the string value for a single template placeholder from `TemplateValues` (mirrors `renderTemplate()`'s per-name resolution). */
281
+ function _getTemplateValue(values, name, caller) {
282
+ const value = isFunction(values)
283
+ ? values(name)
284
+ : isData(values)
285
+ ? getString(getDataProp(values, name))
286
+ : isArray(values)
287
+ ? getString(values[Number(name)])
288
+ : getString(values);
289
+ if (value === undefined)
290
+ throw new RequiredError(`Template placeholder "${name}" not found`, { received: values, name, caller });
291
+ return value;
234
292
  }