@linxin666/dsh-pet 0.2.9 → 0.3.0

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.
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { closeSync, existsSync, ftruncateSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import {
@@ -8,6 +8,7 @@ import {
8
8
  DEFAULT_TRACK_PATTERNS,
9
9
  PET_ROW_ORDER,
10
10
  PET_SCAN_JSON_CAP,
11
+ PET_SCAN_LIVE2D_MODEL_CAP,
11
12
  codexPetsDir,
12
13
  loadPetRegistry,
13
14
  petAtlasFile,
@@ -261,6 +262,41 @@ describe('loadPetRegistry', () => {
261
262
  rmSync(root, { recursive: true, force: true })
262
263
  }
263
264
  })
265
+
266
+ it('skips an oversized pet.json with a warning instead of reading it', () => {
267
+ const root = tempDir()
268
+ try {
269
+ const petsDir = join(root, 'pets')
270
+ mkdirSync(join(petsDir, 'loud'), { recursive: true })
271
+ writeFileSync(join(petsDir, 'loud', 'pet.json'), '{ ' + 'x'.repeat(PET_SCAN_JSON_CAP) + ' }', 'utf8')
272
+ // A healthy neighbor keeps listing while the pathological one is skipped.
273
+ mkdirSync(join(petsDir, 'plain'), { recursive: true })
274
+ writeFileSync(join(petsDir, 'plain', 'pet.json'), JSON.stringify({
275
+ id: 'plain', displayName: 'Plain', spritesheetPath: 'spritesheet.webp',
276
+ }), 'utf8')
277
+ writeFileSync(join(petsDir, 'plain', 'spritesheet.webp'), 'webp', 'utf8')
278
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
279
+ expect(registry.byId('loud')).toBeUndefined()
280
+ expect(registry.byId('plain')).toBeDefined()
281
+ expect(registry.warnings.some(w => w.includes('scan ceiling'))).toBe(true)
282
+ } finally {
283
+ rmSync(root, { recursive: true, force: true })
284
+ }
285
+ })
286
+
287
+ it('skips a non-regular pet.json with a warning', () => {
288
+ const root = tempDir()
289
+ try {
290
+ const petsDir = join(root, 'pets')
291
+ mkdirSync(join(petsDir, 'odd', 'pet.json'), { recursive: true })
292
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
293
+ expect(registry.entries.map(entry => entry.id)).toEqual([])
294
+ expect(registry.warnings.some(w => w.includes('not a regular file'))).toBe(true)
295
+ expect(registry.diagnostics.some(d => d.level === 'warning' && d.message.includes('pet manifest is not a regular file'))).toBe(true)
296
+ } finally {
297
+ rmSync(root, { recursive: true, force: true })
298
+ }
299
+ })
264
300
  })
265
301
 
266
302
  describe('codexPetsDir', () => {
@@ -479,6 +515,52 @@ describe('loadPetRegistry pet-center v2 (issue #623)', () => {
479
515
  rmSync(root, { recursive: true, force: true })
480
516
  }
481
517
  })
518
+
519
+ it('skips a live2d pet whose model3.json exceeds the model scan ceiling with a warning', () => {
520
+ const root = tempDir()
521
+ try {
522
+ const petsDir = join(root, 'pets')
523
+ mkdirSync(join(petsDir, 'haru'), { recursive: true })
524
+ writeFileSync(join(petsDir, 'haru', 'pet.json'), JSON.stringify({
525
+ petManifestVersion: 2, id: 'haru', displayName: 'Haru', license: 'Live2D-Sample',
526
+ renderer: 'live2d', live2d: { model: 'haru.model3.json', motions: { idle: 'Idle' } },
527
+ }), 'utf8')
528
+ // A sparse file one byte past the ceiling: stat reports the size
529
+ // without materializing 32 MB of bytes on disk.
530
+ const fd = openSync(join(petsDir, 'haru', 'haru.model3.json'), 'w')
531
+ try {
532
+ ftruncateSync(fd, PET_SCAN_LIVE2D_MODEL_CAP + 1)
533
+ } finally {
534
+ closeSync(fd)
535
+ }
536
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
537
+ expect(registry.byId('haru')).toBeUndefined()
538
+ expect(registry.warnings.some(w => w.includes('scan ceiling'))).toBe(true)
539
+ expect(registry.diagnostics.some(d => d.level === 'warning'
540
+ && d.message.includes('exceeds the ' + PET_SCAN_LIVE2D_MODEL_CAP + '-byte scan ceiling'))).toBe(true)
541
+ } finally {
542
+ rmSync(root, { recursive: true, force: true })
543
+ }
544
+ })
545
+
546
+ it('skips a live2d pet whose model3.json is not a regular file with a warning', () => {
547
+ const root = tempDir()
548
+ try {
549
+ const petsDir = join(root, 'pets')
550
+ mkdirSync(join(petsDir, 'haru', 'haru.model3.json'), { recursive: true })
551
+ writeFileSync(join(petsDir, 'haru', 'pet.json'), JSON.stringify({
552
+ petManifestVersion: 2, id: 'haru', displayName: 'Haru', license: 'Live2D-Sample',
553
+ renderer: 'live2d', live2d: { model: 'haru.model3.json', motions: { idle: 'Idle' } },
554
+ }), 'utf8')
555
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
556
+ expect(registry.byId('haru')).toBeUndefined()
557
+ expect(registry.warnings.some(w => w.includes('not a regular file'))).toBe(true)
558
+ expect(registry.diagnostics.some(d => d.level === 'warning'
559
+ && d.message.includes('live2d model haru.model3.json is not a regular file'))).toBe(true)
560
+ } finally {
561
+ rmSync(root, { recursive: true, force: true })
562
+ }
563
+ })
482
564
  })
483
565
 
484
566
  describe('voice packs (pet-center M4, issue #677)', () => {
package/src/registry.ts CHANGED
@@ -516,9 +516,20 @@ function resolveLive2dEntry(
516
516
  record('error', 'pet ' + manifest.id + ': renderer live2d requires a live2d block')
517
517
  return undefined
518
518
  }
519
+ const modelFile = join(dir, block.model)
519
520
  let model3: unknown
520
521
  try {
521
- model3 = JSON.parse(readFileSync(join(dir, block.model), 'utf8'))
522
+ // Stat guard before the read: a pathological model file — huge, or a
523
+ // FIFO/device — is skipped with a warning instead of stalling the host
524
+ // at scan time, mirroring the voice/decoration descriptor discipline.
525
+ // The guard stays silent on stat errors, so a missing or unreadable
526
+ // path is re-stat'ed here to fall through to the original fail-closed
527
+ // 'not readable' diagnostic below.
528
+ if (guardedScannedJsonStat(modelFile, options, 'live2d model ' + block.model, PET_SCAN_LIVE2D_MODEL_CAP) === undefined) {
529
+ statSync(modelFile)
530
+ return undefined
531
+ }
532
+ model3 = JSON.parse(readFileSync(modelFile, 'utf8'))
522
533
  } catch (error) {
523
534
  record('error', 'pet ' + manifest.id + ': live2d model ' + block.model + ' is not readable: '
524
535
  + (error instanceof Error ? error.message : String(error)))
@@ -583,7 +594,7 @@ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: str
583
594
  for (const name of names) {
584
595
  const manifestFile = join(dir, name, 'pet.json')
585
596
  if (!existsSync(manifestFile)) continue
586
- const parsed = readPetJson(manifestFile, options.warnings)
597
+ const parsed = readPetJson(manifestFile, options)
587
598
  if (parsed === undefined) continue
588
599
  const entryDir = join(dir, name)
589
600
  const verdict = parsePetManifest(parsed, entryDir)
@@ -613,12 +624,21 @@ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: str
613
624
  return entries
614
625
  }
615
626
 
616
- /** Read and parse one manifest file; undefined (warning recorded) on failure. */
617
- function readPetJson(file: string, warnings: string[] | undefined): unknown {
627
+ /**
628
+ * Read and parse one pet.json manifest; undefined (warning recorded) on
629
+ * failure. The descriptor stat guard applies first: a pathological file —
630
+ * huge, or a FIFO/device — is skipped with a warning instead of stalling
631
+ * or OOM-ing the host at scan time (same discipline as voice/decoration).
632
+ */
633
+ function readPetJson(
634
+ file: string,
635
+ options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
636
+ ): unknown {
637
+ if (guardedScannedJsonStat(file, options, 'pet manifest') === undefined) return undefined
618
638
  try {
619
639
  return JSON.parse(readFileSync(file, 'utf8'))
620
640
  } catch (error) {
621
- warnings?.push('skipping ' + file + ': ' + (error instanceof Error ? error.message : String(error)))
641
+ options.warnings?.push('skipping ' + file + ': ' + (error instanceof Error ? error.message : String(error)))
622
642
  return undefined
623
643
  }
624
644
  }
@@ -632,16 +652,28 @@ function readPetJson(file: string, warnings: string[] | undefined): unknown {
632
652
  */
633
653
  export const PET_SCAN_JSON_CAP = 64 * 1024
634
654
 
655
+ /**
656
+ * Scan-time read ceiling for a live2d model3.json, matching the asset
657
+ * route's model cap (PET_ASSET_CAPS.model). Model descriptors are far
658
+ * larger than the other scanned JSON, but a pathological file — huge, or a
659
+ * FIFO/device — must still be skipped with a warning instead of stalling
660
+ * or OOM-ing the host at plugin startup (same review-spd follow-up).
661
+ */
662
+ export const PET_SCAN_LIVE2D_MODEL_CAP = 32 * 1024 * 1024
663
+
635
664
  /**
636
665
  * Stat one scanned JSON descriptor with a regular-file + size guard, so a
637
666
  * pathological user file is skipped with a warning instead of stalling or
638
667
  * OOM-ing the host at startup. Returns the Stats, or undefined when the
639
- * caller must skip the file (a warning was recorded).
668
+ * caller must skip the file (a warning was recorded). 'cap' defaults to
669
+ * the descriptor ceiling (PET_SCAN_JSON_CAP); model descriptors pass the
670
+ * larger live2d ceiling.
640
671
  */
641
672
  function guardedScannedJsonStat(
642
673
  file: string,
643
674
  options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
644
675
  what: string,
676
+ cap: number = PET_SCAN_JSON_CAP,
645
677
  ): ReturnType<typeof statSync> | undefined {
646
678
  let st: ReturnType<typeof statSync>
647
679
  try {
@@ -657,8 +689,8 @@ function guardedScannedJsonStat(
657
689
  warn(what + ' is not a regular file; ignored')
658
690
  return undefined
659
691
  }
660
- if (st.size > PET_SCAN_JSON_CAP) {
661
- warn(what + ' exceeds the ' + PET_SCAN_JSON_CAP + '-byte scan ceiling; ignored')
692
+ if (st.size > cap) {
693
+ warn(what + ' exceeds the ' + cap + '-byte scan ceiling; ignored')
662
694
  return undefined
663
695
  }
664
696
  return st
package/src/routes.ts CHANGED
@@ -23,6 +23,7 @@ import type { PetInteraction } from './affinity.ts'
23
23
  import { DECORATION_ASSET_PREFIX, petEntryView, petPackageRoot, type PetEntry, type PetRegistry } from './registry.ts'
24
24
  import { isPetAllowed } from './access.ts'
25
25
  import { dshHome } from './dsh-home.ts'
26
+ import { readJsonBody, writeJson } from './http.ts'
26
27
 
27
28
  /** Browser-facing base path of the pet API. */
28
29
  export const PET_API_PREFIX = '/api/pet'
@@ -97,52 +98,17 @@ function mimeFor(file: string): string {
97
98
  return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? 'application/octet-stream'
98
99
  }
99
100
 
100
- /** Write one JSON response. */
101
- function json(res: ServerResponse, status: number, body: unknown): void {
102
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
103
- res.end(JSON.stringify(body))
104
- }
105
-
106
101
  /** Require the method or answer 405. */
107
102
  function requireMethod(req: IncomingMessage, res: ServerResponse, method: string): boolean {
108
103
  if (req.method === method) return true
109
- json(res, 405, { ok: false, error: 'method-not-allowed' })
104
+ writeJson(res, 405, { ok: false, error: 'method-not-allowed' })
110
105
  return false
111
106
  }
112
107
 
113
- /** Read a JSON request body (bounded). */
114
- function readJsonBody(req: IncomingMessage): Promise<unknown> {
115
- return new Promise((resolve, reject) => {
116
- let size = 0
117
- const chunks: Buffer[] = []
118
- req.on('data', (chunk: Buffer) => {
119
- size += chunk.length
120
- if (size > 64 * 1024) {
121
- reject(new Error('body-too-large'))
122
- queueMicrotask(() => req.destroy())
123
- return
124
- }
125
- chunks.push(chunk)
126
- })
127
- req.on('end', () => {
128
- if (chunks.length === 0) {
129
- resolve({})
130
- return
131
- }
132
- try {
133
- resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
134
- } catch {
135
- reject(new Error('invalid-json'))
136
- }
137
- })
138
- req.on('error', reject)
139
- })
140
- }
141
-
142
108
  /** Shared route fence: loopback always passes; a live paired-device cookie is an extra allow path. */
143
109
  function guard(ctx: Context, req: IncomingMessage, res: ServerResponse): boolean {
144
110
  if (isPetAllowed(ctx, req)) return true
145
- json(res, 403, { ok: false, error: 'forbidden: loopback-only' })
111
+ writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
146
112
  return false
147
113
  }
148
114
 
@@ -154,8 +120,8 @@ function getRoute(ctx: Context, path: string, run: () => Promise<unknown>): WebR
154
120
  handler: (req: IncomingMessage, res: ServerResponse): void => {
155
121
  if (!guard(ctx, req, res)) return
156
122
  if (!requireMethod(req, res, 'GET')) return
157
- run().then((value) => json(res, 200, value), (error) => {
158
- json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
123
+ run().then((value) => writeJson(res, 200, value), (error) => {
124
+ writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
159
125
  })
160
126
  },
161
127
  }
@@ -169,16 +135,21 @@ function postRoute(ctx: Context, path: string, run: (body: Record<string, unknow
169
135
  handler: (req: IncomingMessage, res: ServerResponse): Promise<void> => {
170
136
  if (!guard(ctx, req, res)) return Promise.resolve()
171
137
  if (!requireMethod(req, res, 'POST')) return Promise.resolve()
172
- return readJsonBody(req).then((body) => {
173
- const record = (typeof body === 'object' && body !== null) ? body as Record<string, unknown> : {}
138
+ // Shared lenient reader (64 KiB cap): an empty body yields null and is
139
+ // restored to {} at the call site (legacy empty-body semantics); invalid
140
+ // JSON and over-limit bodies also yield null, so the endpoint validators
141
+ // below keep answering 400 with the same { ok: false, error } envelope.
142
+ return readJsonBody(req, { maxBytes: 64 * 1024 }).then((parsed) => {
143
+ const payload = parsed ?? {}
144
+ const record = (typeof payload === 'object' && payload !== null) ? payload as Record<string, unknown> : {}
174
145
  return run(record).then(
175
- (value) => json(res, 200, value),
146
+ (value) => writeJson(res, 200, value),
176
147
  (error) => {
177
- json(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
148
+ writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
178
149
  },
179
150
  )
180
151
  }, (error) => {
181
- json(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
152
+ writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
182
153
  })
183
154
  },
184
155
  }
@@ -393,7 +364,7 @@ function runtimeHandler(ctx: Context, roots: { runtimeDir: string; vendorDir: st
393
364
  const base = spec.root === 'runtimeDir' ? roots.runtimeDir : roots.vendorDir
394
365
  const file = join(base, name)
395
366
  if (!existsSync(file)) {
396
- json(res, 404, { ok: false, error: 'runtime-file-missing', file: name })
367
+ writeJson(res, 404, { ok: false, error: 'runtime-file-missing', file: name })
397
368
  return
398
369
  }
399
370
  const resolved = containedRealpath(base, file)