@windsland52/maa-log-tools 1.2.2 → 1.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.
package/dist/nodeInput.js CHANGED
@@ -1,6 +1,9 @@
1
- import { readFile, readdir, stat } from 'node:fs/promises';
1
+ import { lstat, opendir, realpath } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { unzipSync } from 'fflate';
3
+ import { addArchiveDirectoryEntry, addSelectedEntry, ArchiveLimitError, assertArchiveInputsWithinLimits, createStoredFileMetadata, EMPTY_ARCHIVE_DIRECTORY_BUDGET, EMPTY_EXTRACTION_BUDGET, extractZipEntriesWithinLimits, resolveArchiveLimits, } from './archiveLimits.js';
4
+ import { getFileIdentity, InputFileError, readBoundedRegularFile, sameFileIdentity, } from './boundedFileReader.js';
5
+ export { ArchiveFormatError, ArchiveLimitError, DEFAULT_ARCHIVE_LIMITS, resolveArchiveLimits, } from './archiveLimits.js';
6
+ export { InputFileError } from './boundedFileReader.js';
4
7
  const MAIN_LOG_NAMES = ['maa.log', 'maafw.log'];
5
8
  const BAK_LOG_NAMES = ['maa.bak.log', 'maafw.bak.log'];
6
9
  const SEARCH_TEXT_EXTENSIONS = ['.log', '.txt', '.jsonl'];
@@ -214,10 +217,92 @@ const sortLogPaths = (paths) => {
214
217
  return left.localeCompare(right);
215
218
  });
216
219
  };
217
- const collectFocusedFileContents = async (logPaths, focus) => {
220
+ const pathKey = (value) => {
221
+ const resolved = path.resolve(value);
222
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
223
+ };
224
+ const isPathInside = (rootPath, candidatePath) => {
225
+ const relativePath = path.relative(rootPath, candidatePath);
226
+ return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
227
+ };
228
+ const assertPathInsideContext = async (context, fullPath) => {
229
+ const absolutePath = path.resolve(fullPath);
230
+ if (!isPathInside(context.rootPath, absolutePath)) {
231
+ throw new InputFileError('path-escape', fullPath, `Input path escapes the selected root: ${fullPath}`);
232
+ }
233
+ const physicalPath = await realpath(absolutePath);
234
+ if (!isPathInside(context.rootRealPath, physicalPath)) {
235
+ throw new InputFileError('path-escape', fullPath, `Input path resolves outside the selected root: ${fullPath}`);
236
+ }
237
+ };
238
+ export const createNodeInputBudgetContext = async (rootPath, limits, requireDirectoryRoot = true) => {
239
+ const absoluteRoot = path.resolve(rootPath);
240
+ const rootStats = await lstat(absoluteRoot);
241
+ if (rootStats.isSymbolicLink()) {
242
+ throw new InputFileError('symlink', absoluteRoot, `Symbolic-link roots are not allowed: ${absoluteRoot}`);
243
+ }
244
+ if (requireDirectoryRoot && !rootStats.isDirectory()) {
245
+ throw new InputFileError('not-directory', absoluteRoot, `Expected a directory root: ${absoluteRoot}`);
246
+ }
247
+ const physicalRoot = await realpath(absoluteRoot);
248
+ return {
249
+ limits,
250
+ rootPath: absoluteRoot,
251
+ rootRealPath: physicalRoot,
252
+ directory: EMPTY_ARCHIVE_DIRECTORY_BUDGET,
253
+ extraction: EMPTY_EXTRACTION_BUDGET,
254
+ chargedPaths: new Set(),
255
+ discoveredIdentities: new Map(),
256
+ };
257
+ };
258
+ const chargePath = (context, fullPath) => {
259
+ const key = pathKey(fullPath);
260
+ if (context.chargedPaths.has(key))
261
+ return;
262
+ const relativePath = toPosixPath(path.relative(context.rootPath, path.resolve(fullPath)));
263
+ context.directory = addArchiveDirectoryEntry(context.directory, {
264
+ name: relativePath,
265
+ size: 0,
266
+ originalSize: 0,
267
+ compression: 0,
268
+ }, context.limits);
269
+ context.chargedPaths.add(key);
270
+ };
271
+ const recordDiscoveredIdentity = (context, fullPath, stats) => {
272
+ const key = pathKey(fullPath);
273
+ const identity = getFileIdentity(stats);
274
+ const previous = context.discoveredIdentities.get(key);
275
+ if (previous && !sameFileIdentity(previous, identity)) {
276
+ throw new InputFileError('identity-changed', fullPath, `Input identity changed during directory analysis: ${fullPath}`);
277
+ }
278
+ context.discoveredIdentities.set(key, identity);
279
+ };
280
+ const inspectDirectoryEntry = async (context, fullPath) => {
281
+ const stats = await lstat(fullPath);
282
+ chargePath(context, fullPath);
283
+ if (stats.isSymbolicLink()) {
284
+ throw new InputFileError('symlink', fullPath, `Symbolic-link entries are not allowed: ${fullPath}`);
285
+ }
286
+ await assertPathInsideContext(context, fullPath);
287
+ recordDiscoveredIdentity(context, fullPath, stats);
288
+ return stats;
289
+ };
290
+ const readNodeTextFileWithinBudget = async (filePath, context) => {
291
+ chargePath(context, filePath);
292
+ await assertPathInsideContext(context, filePath);
293
+ const remainingBytes = context.limits.maxExtractedBytes - context.extraction.extractedBytes;
294
+ const maxBytes = Math.min(context.limits.maxFileBytes, remainingBytes);
295
+ const limitCode = context.limits.maxFileBytes <= remainingBytes ? 'file-size' : 'extracted-size';
296
+ const bytes = await readBoundedRegularFile(filePath, maxBytes, (actualBytes) => new ArchiveLimitError(limitCode, limitCode === 'extracted-size'
297
+ ? context.extraction.extractedBytes + actualBytes
298
+ : actualBytes, limitCode === 'extracted-size' ? context.limits.maxExtractedBytes : context.limits.maxFileBytes), { expectedIdentity: context.discoveredIdentities.get(pathKey(filePath)) });
299
+ context.extraction = addSelectedEntry(context.extraction, createStoredFileMetadata(toPosixPath(filePath), bytes.byteLength), context.limits, false);
300
+ return decodeNodeBytes(bytes);
301
+ };
302
+ const collectFocusedFileContents = async (logPaths, focus, context) => {
218
303
  const chunks = [];
219
304
  for (const logPath of sortLogPaths(logPaths)) {
220
- const content = await readNodeTextFileContent(logPath);
305
+ const content = await readNodeTextFileWithinBudget(logPath, context);
221
306
  if (!contentMatchesFocus(content, focus))
222
307
  continue;
223
308
  chunks.push({
@@ -282,14 +367,26 @@ const buildDefaultZipContent = (entries, paths, basePath, sourceRef) => {
282
367
  }
283
368
  return joinMergedWithSources(chunks);
284
369
  };
285
- export const readNodeTextFileContent = async (filePath) => {
286
- const bytes = await readFile(filePath);
287
- return decodeNodeBytes(new Uint8Array(bytes));
370
+ export const readNodeTextFileContent = async (filePath, options = {}) => {
371
+ const limits = resolveArchiveLimits(options.archiveLimits);
372
+ const context = options.budgetContext ?? await createNodeInputBudgetContext(path.dirname(path.resolve(filePath)), limits);
373
+ return readNodeTextFileWithinBudget(filePath, context);
374
+ };
375
+ export const readNodeTextFilesContent = async (filePaths, options = {}) => {
376
+ const limits = resolveArchiveLimits(options.archiveLimits);
377
+ const commonRoot = filePaths.length > 0
378
+ ? path.dirname(path.resolve(filePaths[0]))
379
+ : process.cwd();
380
+ const context = options.budgetContext ?? await createNodeInputBudgetContext(commonRoot, limits);
381
+ const contents = [];
382
+ for (const filePath of filePaths) {
383
+ contents.push(await readNodeTextFileWithinBudget(filePath, context));
384
+ }
385
+ return contents;
288
386
  };
289
387
  export const extractZipContentFromNodeBuffer = (zipData, sourceRef = 'memory.zip', options = {}) => {
290
- const files = unzipSync(zipData, {
291
- filter: (entry) => isNeededZipEntry(entry.name),
292
- });
388
+ const limits = resolveArchiveLimits(options.archiveLimits);
389
+ const { files } = extractZipEntriesWithinLimits(zipData, isNeededZipEntry, limits);
293
390
  const paths = Object.keys(files);
294
391
  const basePath = findBaseDirectory(paths);
295
392
  if (basePath == null)
@@ -343,71 +440,163 @@ export const extractZipContentFromNodeBuffer = (zipData, sourceRef = 'memory.zip
343
440
  return { content: merged.content, sourceSegments: merged.segments, errorImages, visionImages, waitFreezesImages, textFiles };
344
441
  };
345
442
  export const extractZipContentFromNodeFile = async (zipFilePath, options = {}) => {
346
- const bytes = await readFile(zipFilePath);
347
- return extractZipContentFromNodeBuffer(new Uint8Array(bytes), zipFilePath, options);
443
+ const limits = resolveArchiveLimits(options.archiveLimits);
444
+ const bytes = await readNodeArchiveFileBytes(zipFilePath, limits);
445
+ return extractZipContentFromNodeBuffer(bytes, zipFilePath, {
446
+ ...options,
447
+ archiveLimits: limits,
448
+ });
348
449
  };
349
- const pathExists = async (targetPath) => {
450
+ export const readNodeArchiveFileBytes = async (zipFilePath, limits) => {
451
+ assertArchiveInputsWithinLimits([{ size: 0 }], limits);
452
+ const context = await createNodeInputBudgetContext(path.dirname(path.resolve(zipFilePath)), limits);
453
+ chargePath(context, zipFilePath);
454
+ await assertPathInsideContext(context, zipFilePath);
455
+ const bytes = await readBoundedRegularFile(zipFilePath, limits.maxCompressedBytes, (actualBytes) => new ArchiveLimitError('compressed-size', actualBytes, limits.maxCompressedBytes));
456
+ assertArchiveInputsWithinLimits([{ size: bytes.byteLength }], limits);
457
+ return bytes;
458
+ };
459
+ const assertDirectoryIdentity = (directoryPath, expected, stats) => {
460
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
461
+ throw new InputFileError('not-directory', directoryPath, `Expected a stable directory: ${directoryPath}`);
462
+ }
463
+ if (!sameFileIdentity(expected, getFileIdentity(stats))) {
464
+ throw new InputFileError('identity-changed', directoryPath, `Directory identity changed during traversal: ${directoryPath}`);
465
+ }
466
+ };
467
+ const inspectDirectory = async (context, directoryPath) => {
468
+ if (pathKey(directoryPath) !== pathKey(context.rootPath))
469
+ chargePath(context, directoryPath);
470
+ const stats = await lstat(directoryPath);
471
+ if (stats.isSymbolicLink()) {
472
+ throw new InputFileError('symlink', directoryPath, `Symbolic-link directories are not allowed: ${directoryPath}`);
473
+ }
474
+ if (!stats.isDirectory()) {
475
+ throw new InputFileError('not-directory', directoryPath, `Expected a directory: ${directoryPath}`);
476
+ }
477
+ await assertPathInsideContext(context, directoryPath);
478
+ recordDiscoveredIdentity(context, directoryPath, stats);
479
+ return stats;
480
+ };
481
+ const withSafeDirectory = async (context, directoryPath, consume) => {
482
+ const beforeOpen = await inspectDirectory(context, directoryPath);
483
+ const expectedIdentity = getFileIdentity(beforeOpen);
484
+ const directory = await opendir(directoryPath);
485
+ const afterOpen = await lstat(directoryPath);
486
+ assertDirectoryIdentity(directoryPath, expectedIdentity, afterOpen);
350
487
  try {
351
- await stat(targetPath);
488
+ return await consume(directory);
489
+ }
490
+ finally {
491
+ await directory.close().catch(() => undefined);
492
+ const afterRead = await lstat(directoryPath);
493
+ assertDirectoryIdentity(directoryPath, expectedIdentity, afterRead);
494
+ }
495
+ };
496
+ const tryInspectDirectory = async (context, directoryPath) => {
497
+ try {
498
+ await inspectDirectory(context, directoryPath);
352
499
  return true;
353
500
  }
354
- catch {
355
- return false;
501
+ catch (error) {
502
+ if (error.code === 'ENOENT')
503
+ return false;
504
+ throw error;
356
505
  }
357
506
  };
358
- const hasMainLogInDirectory = async (dirPath) => {
507
+ export const hasNodeMainLogInDirectory = async (context, directoryPath) => {
508
+ if (!await tryInspectDirectory(context, directoryPath))
509
+ return false;
359
510
  for (const name of MAIN_LOG_NAMES) {
360
- if (await pathExists(path.join(dirPath, name))) {
361
- return true;
511
+ const candidatePath = path.join(directoryPath, name);
512
+ try {
513
+ const stats = await inspectDirectoryEntry(context, candidatePath);
514
+ if (stats.isFile())
515
+ return true;
516
+ }
517
+ catch (error) {
518
+ if (error.code === 'ENOENT')
519
+ continue;
520
+ throw error;
362
521
  }
363
522
  }
364
523
  return false;
365
524
  };
366
- const findDebugDirectoryRecursively = async (rootPath) => {
367
- const entries = await readdir(rootPath, { withFileTypes: true });
368
- for (const entry of entries) {
369
- if (!entry.isDirectory())
370
- continue;
371
- const subDirPath = path.join(rootPath, entry.name);
372
- if (await hasMainLogInDirectory(subDirPath)) {
373
- return subDirPath;
525
+ export const findExistingRegularNodeFile = async (context, directoryPath, names) => {
526
+ for (const name of names) {
527
+ const candidatePath = path.join(directoryPath, name);
528
+ try {
529
+ const stats = await inspectDirectoryEntry(context, candidatePath);
530
+ if (stats.isFile())
531
+ return candidatePath;
532
+ }
533
+ catch (error) {
534
+ if (error.code === 'ENOENT')
535
+ continue;
536
+ throw error;
374
537
  }
375
- const nested = await findDebugDirectoryRecursively(subDirPath);
376
- if (nested)
377
- return nested;
378
538
  }
379
539
  return null;
380
540
  };
381
- const resolveDebugDirectory = async (inputPath) => {
382
- if (await hasMainLogInDirectory(inputPath)) {
383
- return inputPath;
541
+ const findDebugDirectoryRecursively = async (rootPath, context) => {
542
+ const pending = [rootPath];
543
+ while (pending.length > 0) {
544
+ const currentPath = pending.pop();
545
+ if (!currentPath)
546
+ break;
547
+ const found = await withSafeDirectory(context, currentPath, async (directory) => {
548
+ for await (const entry of directory) {
549
+ const fullPath = path.join(currentPath, entry.name);
550
+ const stats = await inspectDirectoryEntry(context, fullPath);
551
+ if (!stats.isDirectory())
552
+ continue;
553
+ if (await hasNodeMainLogInDirectory(context, fullPath))
554
+ return fullPath;
555
+ pending.push(fullPath);
556
+ }
557
+ return null;
558
+ });
559
+ if (found)
560
+ return found;
384
561
  }
562
+ return null;
563
+ };
564
+ export const resolveNodeDebugDirectory = async (inputPath, context) => {
565
+ if (await hasNodeMainLogInDirectory(context, inputPath))
566
+ return inputPath;
385
567
  const directDebugPath = path.join(inputPath, 'debug');
386
- if (await hasMainLogInDirectory(directDebugPath)) {
568
+ if (await hasNodeMainLogInDirectory(context, directDebugPath))
387
569
  return directDebugPath;
388
- }
389
- return findDebugDirectoryRecursively(inputPath);
570
+ return findDebugDirectoryRecursively(inputPath, context);
390
571
  };
391
- const collectFilesRecursively = async (rootPath) => {
572
+ const collectFilesRecursively = async (rootPath, context) => {
392
573
  const collected = [];
393
- const entries = await readdir(rootPath, { withFileTypes: true });
394
- for (const entry of entries) {
395
- const fullPath = path.join(rootPath, entry.name);
396
- if (entry.isDirectory()) {
397
- const nested = await collectFilesRecursively(fullPath);
398
- collected.push(...nested);
399
- continue;
400
- }
401
- collected.push(fullPath);
574
+ const pending = [rootPath];
575
+ while (pending.length > 0) {
576
+ const currentPath = pending.pop();
577
+ if (!currentPath)
578
+ break;
579
+ await withSafeDirectory(context, currentPath, async (directory) => {
580
+ for await (const entry of directory) {
581
+ const fullPath = path.join(currentPath, entry.name);
582
+ const stats = await inspectDirectoryEntry(context, fullPath);
583
+ if (stats.isDirectory()) {
584
+ pending.push(fullPath);
585
+ }
586
+ else if (stats.isFile()) {
587
+ collected.push(fullPath);
588
+ }
589
+ }
590
+ });
402
591
  }
403
592
  return collected;
404
593
  };
405
594
  const pickPrimaryLogPath = async (debugPath, allFiles, candidates) => {
406
595
  for (const name of candidates) {
407
596
  const directPath = path.join(debugPath, name);
408
- if (await pathExists(directPath)) {
409
- return directPath;
410
- }
597
+ const directMatch = allFiles.find((filePath) => pathKey(filePath) === pathKey(directPath));
598
+ if (directMatch)
599
+ return directMatch;
411
600
  }
412
601
  const normalizedCandidates = new Set(candidates.map((name) => name.toLowerCase()));
413
602
  for (const filePath of allFiles) {
@@ -418,20 +607,20 @@ const pickPrimaryLogPath = async (debugPath, allFiles, candidates) => {
418
607
  }
419
608
  return null;
420
609
  };
421
- const buildDefaultDirectoryContent = async (debugPath, allFiles) => {
610
+ const buildDefaultDirectoryContent = async (debugPath, allFiles, context) => {
422
611
  const bakLogPath = await pickPrimaryLogPath(debugPath, allFiles, BAK_LOG_NAMES);
423
612
  const mainLogPath = await pickPrimaryLogPath(debugPath, allFiles, MAIN_LOG_NAMES);
424
613
  const chunks = [];
425
614
  if (bakLogPath) {
426
615
  chunks.push({
427
- content: await readNodeTextFileContent(bakLogPath),
616
+ content: await readNodeTextFileWithinBudget(bakLogPath, context),
428
617
  source: toFileReference(bakLogPath),
429
618
  path: toPosixPath(path.relative(debugPath, bakLogPath)),
430
619
  });
431
620
  }
432
621
  if (mainLogPath) {
433
622
  chunks.push({
434
- content: await readNodeTextFileContent(mainLogPath),
623
+ content: await readNodeTextFileWithinBudget(mainLogPath, context),
435
624
  source: toFileReference(mainLogPath),
436
625
  path: toPosixPath(path.relative(debugPath, mainLogPath)),
437
626
  });
@@ -439,13 +628,15 @@ const buildDefaultDirectoryContent = async (debugPath, allFiles) => {
439
628
  return joinMergedWithSources(chunks);
440
629
  };
441
630
  export const loadNodeLogDirectory = async (inputDirectoryPath, options = {}) => {
442
- const debugPath = await resolveDebugDirectory(inputDirectoryPath);
631
+ const limits = resolveArchiveLimits(options.archiveLimits);
632
+ const context = await createNodeInputBudgetContext(inputDirectoryPath, limits);
633
+ const debugPath = await resolveNodeDebugDirectory(inputDirectoryPath, context);
443
634
  if (!debugPath)
444
635
  return null;
445
- const allFiles = await collectFilesRecursively(debugPath);
636
+ const allFiles = await collectFilesRecursively(debugPath, context);
446
637
  const merged = options.focus
447
- ? await collectFocusedFileContents(allFiles.filter((filePath) => isCoreLogName(path.basename(filePath))), options.focus)
448
- : await buildDefaultDirectoryContent(debugPath, allFiles);
638
+ ? await collectFocusedFileContents(allFiles.filter((filePath) => isCoreLogName(path.basename(filePath))), options.focus, context)
639
+ : await buildDefaultDirectoryContent(debugPath, allFiles, context);
449
640
  if (!merged.content)
450
641
  return null;
451
642
  const errorImages = new Map();
@@ -479,7 +670,7 @@ export const loadNodeLogDirectory = async (inputDirectoryPath, options = {}) =>
479
670
  textFiles.push({
480
671
  path: relativePath,
481
672
  name: fileName,
482
- content: await readNodeTextFileContent(absolutePath),
673
+ content: await readNodeTextFileWithinBudget(absolutePath, context),
483
674
  reference: toFileReference(absolutePath),
484
675
  });
485
676
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windsland52/maa-log-tools",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "bin": {
@@ -42,13 +42,13 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "fflate": "^0.8.2",
45
- "@windsland52/maa-log-adapter": "1.0.1",
46
- "@windsland52/maa-log-runtime": "1.0.1",
47
- "@windsland52/maa-log-kernel": "1.0.1",
48
- "@windsland52/maa-log-parser": "1.0.1"
45
+ "@windsland52/maa-log-adapter": "1.1.0",
46
+ "@windsland52/maa-log-parser": "1.1.0",
47
+ "@windsland52/maa-log-kernel": "1.0.2",
48
+ "@windsland52/maa-log-runtime": "1.1.0"
49
49
  },
50
50
  "engines": {
51
- "node": ">=24.0.0"
51
+ "node": ">=20.18.0"
52
52
  },
53
53
  "publishConfig": {
54
54
  "access": "public"
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "scripts": {
66
66
  "typecheck": "tsc -p ./tsconfig.json",
67
- "build": "pnpm run clean && tsc -p ./tsconfig.build.json && node ../../scripts/fix-esm-imports.mjs ./dist",
67
+ "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p ./tsconfig.build.json && node ../../scripts/fix-esm-imports.mjs ./dist",
68
68
  "clean": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\""
69
69
  }
70
70
  }