@astrale-os/cli 1.0.0-beta.14 → 1.0.0-beta.15

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/dist/astrale.js CHANGED
@@ -2578,7 +2578,7 @@ var package_default;
2578
2578
  var init_package = __esm(() => {
2579
2579
  package_default = {
2580
2580
  name: "@astrale-os/cli",
2581
- version: "1.0.0-beta.14",
2581
+ version: "1.0.0-beta.15",
2582
2582
  description: "Astrale CLI — connect to existing Astrale kernels",
2583
2583
  keywords: [
2584
2584
  "astrale",
@@ -83655,7 +83655,10 @@ async function readRegistry(commit, fetcher) {
83655
83655
  return { name: root.name ?? "astrale-ui", homepage: root.homepage, items: installable };
83656
83656
  }
83657
83657
  async function resolveRegistryItems(source2, sourceUrl, commit, fetcher, visited) {
83658
- const direct = Array.isArray(source2.items) ? source2.items : [];
83658
+ const releaseRoot = RAW + "/" + commit + "/";
83659
+ const sourceRelative = sourceUrl.slice(releaseRoot.length);
83660
+ const sourceDirectory = sourceRelative.slice(0, Math.max(0, sourceRelative.lastIndexOf("/")));
83661
+ const direct = Array.isArray(source2.items) ? source2.items.map((item) => qualifyRegistryItemPaths(item, sourceDirectory)) : [];
83659
83662
  const includes = source2.include ?? [];
83660
83663
  if (!Array.isArray(includes)) {
83661
83664
  throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry include must be an array.");
@@ -83664,12 +83667,12 @@ async function resolveRegistryItems(source2, sourceUrl, commit, fetcher, visited
83664
83667
  throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains too many included documents.");
83665
83668
  }
83666
83669
  const nested = await Promise.all(includes.map(async (include) => {
83667
- if (typeof include !== "string" || include.startsWith("/") || !include.endsWith("registry.json") || include.split("/").includes("..")) {
83670
+ if (typeof include !== "string" || !isSafeRelative2(include) || !/^[A-Za-z0-9._/-]+$/u.test(include) || !include.endsWith("registry.json") || include === "registry.json") {
83668
83671
  throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains an unsafe include.");
83669
83672
  }
83673
+ const sourceDirectoryUrl = sourceUrl.slice(0, sourceUrl.lastIndexOf("/") + 1);
83670
83674
  const url3 = new URL(include, sourceUrl).toString();
83671
- const releaseRoot = RAW + "/" + commit + "/";
83672
- if (!url3.startsWith(releaseRoot) || visited.has(url3)) {
83675
+ if (url3 !== sourceDirectoryUrl + include || !url3.startsWith(releaseRoot) || !url3.startsWith(sourceDirectoryUrl) || visited.has(url3)) {
83673
83676
  throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry include escaped or repeated the release snapshot.");
83674
83677
  }
83675
83678
  visited.add(url3);
@@ -83678,6 +83681,33 @@ async function resolveRegistryItems(source2, sourceUrl, commit, fetcher, visited
83678
83681
  }));
83679
83682
  return direct.concat(nested.flat());
83680
83683
  }
83684
+ function qualifyRegistryItemPaths(item, sourceDirectory) {
83685
+ if (!item || typeof item !== "object")
83686
+ return item;
83687
+ const candidate2 = item;
83688
+ if (!Array.isArray(candidate2.files))
83689
+ return item;
83690
+ return {
83691
+ ...candidate2,
83692
+ files: candidate2.files.map((file2) => {
83693
+ if (!file2 || typeof file2 !== "object")
83694
+ return file2;
83695
+ const candidateFile = file2;
83696
+ if (typeof candidateFile.path !== "string")
83697
+ return file2;
83698
+ if (!isSafeRelative2(candidateFile.path) || sourceDirectory !== "" && (candidateFile.path === sourceDirectory || candidateFile.path.startsWith(sourceDirectory + "/"))) {
83699
+ throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains an invalid installable item path.");
83700
+ }
83701
+ if (sourceDirectory === "")
83702
+ return file2;
83703
+ const qualified = sourceDirectory + "/" + candidateFile.path;
83704
+ if (!isSafeRelative2(qualified)) {
83705
+ throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains an invalid installable item path.");
83706
+ }
83707
+ return { ...candidateFile, path: qualified };
83708
+ })
83709
+ };
83710
+ }
83681
83711
  function isInstallableItem(item) {
83682
83712
  if (!item || typeof item !== "object")
83683
83713
  return false;
@@ -83695,7 +83725,11 @@ async function readUiRegistryItem(release, expected, fetcher = fetch) {
83695
83725
  return item;
83696
83726
  }
83697
83727
  function isSafeRelative2(value3) {
83698
- return value3.length > 0 && !value3.startsWith("/") && !/^[A-Za-z]:[\\/]/u.test(value3) && !value3.includes("\\") && !value3.split("/").includes("..");
83728
+ const segments = value3.split("/");
83729
+ return value3.length > 0 && !value3.startsWith("/") && !/^[A-Za-z]:[\\/]/u.test(value3) && !value3.includes("\\") && !/[?#%]/u.test(value3) && ![...value3].some((character) => {
83730
+ const code = character.codePointAt(0) ?? 0;
83731
+ return code <= 31 || code === 127;
83732
+ }) && segments.every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
83699
83733
  }
83700
83734
  function registryItemUrl(release, itemName) {
83701
83735
  return RAW + "/" + release.commit + "/registry/public/r/" + encodeURIComponent(itemName) + ".json";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/cli",
3
- "version": "1.0.0-beta.14",
3
+ "version": "1.0.0-beta.15",
4
4
  "description": "Astrale CLI — connect to existing Astrale kernels",
5
5
  "keywords": [
6
6
  "astrale",
@@ -135,7 +135,14 @@ function mockFetch(seen: string[] = []): typeof fetch {
135
135
  })
136
136
  }
137
137
  if (url.endsWith('/registry/patterns/chart/registry.json')) {
138
- return Response.json({ items: [item] })
138
+ return Response.json({
139
+ items: [
140
+ {
141
+ ...item,
142
+ files: item.files.map((file) => ({ ...file, path: 'line-basic.tsx' })),
143
+ },
144
+ ],
145
+ })
139
146
  }
140
147
  if (url.endsWith('/registry.json')) {
141
148
  return Response.json({ include: ['registry/patterns/chart/registry.json'] })
@@ -19,7 +19,7 @@ const registry: UiRegistry = {
19
19
  description: 'A controlled chart.',
20
20
  files: [
21
21
  {
22
- path: 'registry/patterns/chart/line-basic.tsx',
22
+ path: 'line-basic.tsx',
23
23
  type: 'registry:component',
24
24
  target: 'components/astrale/pattern/chart/line-basic.tsx',
25
25
  },
@@ -79,6 +79,7 @@ function builtItem(item: UiRegistry['items'][number]) {
79
79
  ...item,
80
80
  files: item.files.map((file, index) => ({
81
81
  ...file,
82
+ path: `registry/patterns/chart/${file.path}`,
82
83
  content: index === 0 ? 'export const Chart = true\n' : 'export const Summary = true\n',
83
84
  })),
84
85
  }
@@ -152,6 +153,7 @@ describe('UI release and runner contracts', () => {
152
153
  expect(release.commit).toBe(commit)
153
154
  expect(release.compatibility.base).toBe('base')
154
155
  expect(release.registry.items).toHaveLength(1)
156
+ expect(release.registry.items[0]?.files[0]?.path).toBe('registry/patterns/chart/line-basic.tsx')
155
157
  expect(seen.filter((url) => new URL(url).hostname === 'raw.githubusercontent.com')).toEqual(
156
158
  expect.arrayContaining([
157
159
  expect.stringContaining('/' + commit + '/tooling/compatibility.json'),
@@ -193,6 +195,114 @@ describe('UI release and runner contracts', () => {
193
195
  expect(seen.some((url) => url.endsWith('/registry/registry.json'))).toBe(false)
194
196
  })
195
197
 
198
+ test('rejects noncanonical and encoded registry includes before fetching them', async () => {
199
+ const unsafeIncludes = [
200
+ '%2e%2e/other/registry.json',
201
+ 'registry/%2Fother/registry.json',
202
+ 'registry/other/registry.json?raw=1',
203
+ 'registry/other/registry.json#item',
204
+ 'https://example.invalid/registry.json',
205
+ './registry/other/registry.json',
206
+ ]
207
+
208
+ for (const include of unsafeIncludes) {
209
+ const seen: string[] = []
210
+ const fallback = mockFetch(seen)
211
+ const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
212
+ const url = String(input)
213
+ if (url.endsWith('/' + commit + '/registry.json')) {
214
+ return Response.json({ include: [include] })
215
+ }
216
+ return fallback(input, init)
217
+ }) as typeof fetch
218
+
219
+ await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
220
+ code: 'UI_REGISTRY_UNAVAILABLE',
221
+ })
222
+ expect(seen.some((url) => url.includes('example.invalid') || url.includes('/other/'))).toBe(
223
+ false,
224
+ )
225
+ }
226
+ })
227
+
228
+ test('rejects malformed and already-qualified family-local item paths', async () => {
229
+ const unsafePaths = [
230
+ '',
231
+ '/absolute.tsx',
232
+ 'C:/windows.tsx',
233
+ './line-basic.tsx',
234
+ '../line-basic.tsx',
235
+ '%2e%2e/line-basic.tsx',
236
+ 'registry/patterns/chart/line-basic.tsx',
237
+ ]
238
+
239
+ for (const unsafePath of unsafePaths) {
240
+ const supplied = structuredClone(registry)
241
+ supplied.items[0]!.files[0]!.path = unsafePath
242
+ await expect(resolveUiRelease('0.3.0-beta.0', mockFetch([], supplied))).rejects.toMatchObject(
243
+ {
244
+ code: 'UI_REGISTRY_UNAVAILABLE',
245
+ },
246
+ )
247
+ }
248
+ })
249
+
250
+ test('qualifies every item file relative to its declaring nested registry', async () => {
251
+ const rootItem = {
252
+ ...structuredClone(registry.items[0]!),
253
+ name: 'pattern-chart-root',
254
+ files: [
255
+ {
256
+ ...structuredClone(registry.items[0]!.files[0]!),
257
+ path: 'registry/root.tsx',
258
+ target: 'components/astrale/pattern/chart/root.tsx',
259
+ },
260
+ ],
261
+ meta: { canonicalAddress: 'pattern/chart/root' },
262
+ }
263
+ const chartItem = {
264
+ ...structuredClone(registry.items[0]!),
265
+ files: [
266
+ structuredClone(registry.items[0]!.files[0]!),
267
+ {
268
+ ...structuredClone(registry.items[0]!.files[0]!),
269
+ path: 'parts/legend.tsx',
270
+ target: 'components/astrale/pattern/chart/parts/legend.tsx',
271
+ },
272
+ ],
273
+ }
274
+ const nestedItem = {
275
+ ...structuredClone(registry.items[0]!),
276
+ name: 'pattern-chart-nested-line',
277
+ files: [structuredClone(registry.items[0]!.files[0]!)],
278
+ meta: { canonicalAddress: 'pattern/chart/nested-line' },
279
+ }
280
+ const fallback = mockFetch()
281
+ const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
282
+ const url = String(input)
283
+ if (url.endsWith('/' + commit + '/registry.json')) {
284
+ return Response.json({
285
+ items: [rootItem],
286
+ include: ['registry/patterns/chart/registry.json'],
287
+ })
288
+ }
289
+ if (url.endsWith('/registry/patterns/chart/registry.json')) {
290
+ return Response.json({ items: [chartItem], include: ['nested/registry.json'] })
291
+ }
292
+ if (url.endsWith('/registry/patterns/chart/nested/registry.json')) {
293
+ return Response.json({ items: [nestedItem] })
294
+ }
295
+ return fallback(input, init)
296
+ }) as typeof fetch
297
+
298
+ const release = await resolveUiRelease('0.3.0-beta.0', fetcher)
299
+ expect(release.registry.items.map((item) => item.files.map((file) => file.path))).toEqual([
300
+ ['registry/root.tsx'],
301
+ ['registry/patterns/chart/line-basic.tsx', 'registry/patterns/chart/parts/legend.tsx'],
302
+ ['registry/patterns/chart/nested/line-basic.tsx'],
303
+ ])
304
+ })
305
+
196
306
  /** @evidence TEST-CLI-UI-BOUNDED-REMOTE-DOCUMENTS */
197
307
  test('bounds and normalizes malformed registry responses', async () => {
198
308
  const fallback = mockFetch()
@@ -537,7 +647,7 @@ describe('UI source operations', () => {
537
647
  files: [
538
648
  registry.items[0]!.files[0]!,
539
649
  {
540
- path: 'registry/patterns/chart/summary.tsx',
650
+ path: 'summary.tsx',
541
651
  type: 'registry:component',
542
652
  target: 'components/astrale/pattern/chart/summary.tsx',
543
653
  },
package/src/ui/release.ts CHANGED
@@ -152,7 +152,12 @@ async function resolveRegistryItems(
152
152
  fetcher: Fetch,
153
153
  visited: Set<string>,
154
154
  ): Promise<unknown[]> {
155
- const direct = Array.isArray(source.items) ? source.items : []
155
+ const releaseRoot = RAW + '/' + commit + '/'
156
+ const sourceRelative = sourceUrl.slice(releaseRoot.length)
157
+ const sourceDirectory = sourceRelative.slice(0, Math.max(0, sourceRelative.lastIndexOf('/')))
158
+ const direct = Array.isArray(source.items)
159
+ ? source.items.map((item) => qualifyRegistryItemPaths(item, sourceDirectory))
160
+ : []
156
161
  const includes = source.include ?? []
157
162
  if (!Array.isArray(includes)) {
158
163
  throw new UiError('UI_REGISTRY_UNAVAILABLE', 'UI registry include must be an array.')
@@ -167,15 +172,21 @@ async function resolveRegistryItems(
167
172
  includes.map(async (include) => {
168
173
  if (
169
174
  typeof include !== 'string' ||
170
- include.startsWith('/') ||
175
+ !isSafeRelative(include) ||
176
+ !/^[A-Za-z0-9._/-]+$/u.test(include) ||
171
177
  !include.endsWith('registry.json') ||
172
- include.split('/').includes('..')
178
+ include === 'registry.json'
173
179
  ) {
174
180
  throw new UiError('UI_REGISTRY_UNAVAILABLE', 'UI registry contains an unsafe include.')
175
181
  }
182
+ const sourceDirectoryUrl = sourceUrl.slice(0, sourceUrl.lastIndexOf('/') + 1)
176
183
  const url = new URL(include, sourceUrl).toString()
177
- const releaseRoot = RAW + '/' + commit + '/'
178
- if (!url.startsWith(releaseRoot) || visited.has(url)) {
184
+ if (
185
+ url !== sourceDirectoryUrl + include ||
186
+ !url.startsWith(releaseRoot) ||
187
+ !url.startsWith(sourceDirectoryUrl) ||
188
+ visited.has(url)
189
+ ) {
179
190
  throw new UiError(
180
191
  'UI_REGISTRY_UNAVAILABLE',
181
192
  'UI registry include escaped or repeated the release snapshot.',
@@ -189,6 +200,40 @@ async function resolveRegistryItems(
189
200
  return direct.concat(nested.flat())
190
201
  }
191
202
 
203
+ function qualifyRegistryItemPaths(item: unknown, sourceDirectory: string): unknown {
204
+ if (!item || typeof item !== 'object') return item
205
+ const candidate = item as { files?: unknown }
206
+ if (!Array.isArray(candidate.files)) return item
207
+ return {
208
+ ...candidate,
209
+ files: candidate.files.map((file) => {
210
+ if (!file || typeof file !== 'object') return file
211
+ const candidateFile = file as { path?: unknown }
212
+ if (typeof candidateFile.path !== 'string') return file
213
+ if (
214
+ !isSafeRelative(candidateFile.path) ||
215
+ (sourceDirectory !== '' &&
216
+ (candidateFile.path === sourceDirectory ||
217
+ candidateFile.path.startsWith(sourceDirectory + '/')))
218
+ ) {
219
+ throw new UiError(
220
+ 'UI_REGISTRY_UNAVAILABLE',
221
+ 'UI registry contains an invalid installable item path.',
222
+ )
223
+ }
224
+ if (sourceDirectory === '') return file
225
+ const qualified = sourceDirectory + '/' + candidateFile.path
226
+ if (!isSafeRelative(qualified)) {
227
+ throw new UiError(
228
+ 'UI_REGISTRY_UNAVAILABLE',
229
+ 'UI registry contains an invalid installable item path.',
230
+ )
231
+ }
232
+ return { ...candidateFile, path: qualified }
233
+ }),
234
+ }
235
+ }
236
+
192
237
  function isInstallableItem(item: unknown): item is UiRegistry['items'][number] {
193
238
  if (!item || typeof item !== 'object') return false
194
239
  const candidate = item as Partial<UiRegistry['items'][number]>
@@ -256,12 +301,18 @@ export async function readUiRegistryItem(
256
301
  }
257
302
 
258
303
  function isSafeRelative(value: string): boolean {
304
+ const segments = value.split('/')
259
305
  return (
260
306
  value.length > 0 &&
261
307
  !value.startsWith('/') &&
262
308
  !/^[A-Za-z]:[\\/]/u.test(value) &&
263
309
  !value.includes('\\') &&
264
- !value.split('/').includes('..')
310
+ !/[?#%]/u.test(value) &&
311
+ ![...value].some((character) => {
312
+ const code = character.codePointAt(0) ?? 0
313
+ return code <= 31 || code === 127
314
+ }) &&
315
+ segments.every((segment) => segment.length > 0 && segment !== '.' && segment !== '..')
265
316
  )
266
317
  }
267
318