@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.
@@ -0,0 +1,96 @@
1
+ import { lstat, open } from 'node:fs/promises';
2
+ export class InputFileError extends Error {
3
+ constructor(code, filePath, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.filePath = filePath;
7
+ this.name = 'InputFileError';
8
+ }
9
+ }
10
+ const READ_CHUNK_BYTES = 64 * 1024;
11
+ export const getFileIdentity = (stats) => ({
12
+ dev: stats.dev,
13
+ ino: stats.ino,
14
+ });
15
+ export const sameFileIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino;
16
+ const assertRegularPath = (filePath, stats) => {
17
+ if (stats.isSymbolicLink()) {
18
+ throw new InputFileError('symlink', filePath, `Symbolic-link inputs are not allowed: ${filePath}`);
19
+ }
20
+ if (!stats.isFile()) {
21
+ throw new InputFileError('not-regular-file', filePath, `Expected a regular file: ${filePath}`);
22
+ }
23
+ };
24
+ const assertHandleIdentity = (filePath, expected, actual) => {
25
+ if (!actual.isFile() || !sameFileIdentity(expected, getFileIdentity(actual))) {
26
+ throw new InputFileError('identity-changed', filePath, `File identity changed while opening or reading: ${filePath}`);
27
+ }
28
+ };
29
+ const assertStableContentState = (filePath, expected, actual) => {
30
+ if (expected.size !== actual.size
31
+ || expected.mtimeMs !== actual.mtimeMs
32
+ || expected.ctimeMs !== actual.ctimeMs) {
33
+ throw new InputFileError('content-changed', filePath, `File content or metadata changed while opening or reading: ${filePath}`);
34
+ }
35
+ };
36
+ export const readBoundedRegularFile = async (filePath, maxBytes, createLimitError, options = {}) => {
37
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
38
+ throw new RangeError('Maximum file read size must be a non-negative safe integer');
39
+ }
40
+ const beforeOpen = await lstat(filePath);
41
+ assertRegularPath(filePath, beforeOpen);
42
+ const beforeIdentity = getFileIdentity(beforeOpen);
43
+ if (options.expectedIdentity && !sameFileIdentity(options.expectedIdentity, beforeIdentity)) {
44
+ throw new InputFileError('identity-changed', filePath, `File identity changed after directory discovery: ${filePath}`);
45
+ }
46
+ const handle = await open(filePath, 'r');
47
+ try {
48
+ const opened = await handle.stat();
49
+ assertHandleIdentity(filePath, beforeIdentity, opened);
50
+ assertStableContentState(filePath, beforeOpen, opened);
51
+ const afterOpen = await lstat(filePath);
52
+ assertRegularPath(filePath, afterOpen);
53
+ assertHandleIdentity(filePath, beforeIdentity, afterOpen);
54
+ assertStableContentState(filePath, opened, afterOpen);
55
+ if (opened.size > maxBytes)
56
+ throw createLimitError(opened.size);
57
+ const chunks = [];
58
+ let totalBytes = 0;
59
+ let chunkCount = 0;
60
+ while (totalBytes <= maxBytes) {
61
+ const remaining = maxBytes + 1 - totalBytes;
62
+ if (remaining <= 0)
63
+ break;
64
+ const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, remaining));
65
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, totalBytes);
66
+ if (bytesRead === 0)
67
+ break;
68
+ totalBytes += bytesRead;
69
+ chunkCount += 1;
70
+ if (totalBytes > maxBytes)
71
+ throw createLimitError(totalBytes);
72
+ chunks.push(buffer.subarray(0, bytesRead));
73
+ await options.onChunkRead?.({ handle, bytesRead: totalBytes, chunkCount });
74
+ }
75
+ const finalHandleStats = await handle.stat();
76
+ assertHandleIdentity(filePath, beforeIdentity, finalHandleStats);
77
+ const finalPathStats = await lstat(filePath);
78
+ assertRegularPath(filePath, finalPathStats);
79
+ assertHandleIdentity(filePath, beforeIdentity, finalPathStats);
80
+ assertStableContentState(filePath, opened, finalHandleStats);
81
+ assertStableContentState(filePath, opened, finalPathStats);
82
+ if (finalHandleStats.size !== totalBytes) {
83
+ throw new InputFileError('size-changed', filePath, `File size changed after the bounded read completed: ${filePath}`);
84
+ }
85
+ const output = new Uint8Array(totalBytes);
86
+ let outputOffset = 0;
87
+ for (const chunk of chunks) {
88
+ output.set(chunk, outputOffset);
89
+ outputOffset += chunk.byteLength;
90
+ }
91
+ return output;
92
+ }
93
+ finally {
94
+ await handle.close();
95
+ }
96
+ };
package/dist/cli.js CHANGED
@@ -50,6 +50,9 @@ const renderOutput = (output, pretty, noEvents) => {
50
50
  return JSON.stringify(payload, null, pretty ? 2 : 0);
51
51
  };
52
52
  export const MLA_PREFLIGHT_SCHEMA_VERSION = 'mla-preflight/v1';
53
+ const isTaskLifecycleProjection = (task) => {
54
+ return !task.uuid.startsWith('synthetic:resource_loading:');
55
+ };
53
56
  const EMPTY_FRAMEWORK_EXTRACTION = {
54
57
  sessions: [],
55
58
  summary: { status: 'none', versions: [] },
@@ -71,8 +74,9 @@ export const buildPreflightOutput = (output, framework = EMPTY_FRAMEWORK_EXTRACT
71
74
  warnings: framework.warnings,
72
75
  };
73
76
  }
77
+ const taskLifecycleCount = output.tasks.filter(isTaskLifecycleProjection).length;
74
78
  const reason = output.events.length > 0
75
- ? output.tasks.length > 0
79
+ ? taskLifecycleCount > 0
76
80
  ? 'notify_events_parsed'
77
81
  : 'no_task_lifecycle'
78
82
  : output.warnings.includes('Empty log content.')
@@ -83,7 +87,7 @@ export const buildPreflightOutput = (output, framework = EMPTY_FRAMEWORK_EXTRACT
83
87
  status: reason === 'notify_events_parsed' ? 'supported' : 'unsupported',
84
88
  reason,
85
89
  parserVersion: output.meta.parserVersion,
86
- taskCount: output.tasks.length,
90
+ taskCount: taskLifecycleCount,
87
91
  eventCount: output.events.length,
88
92
  nodeStatisticCount: output.stats.nodes.length,
89
93
  recognitionStatisticCount: output.stats.recognitionActions.length,
@@ -1,2 +1,6 @@
1
1
  import type { FrameworkLogSource } from './frameworkVersion.js';
2
- export declare const loadFrameworkLogSources: (targetPath: string) => Promise<FrameworkLogSource[]>;
2
+ import { type ArchiveLimits } from './archiveLimits.js';
3
+ export interface LoadFrameworkLogSourcesOptions {
4
+ archiveLimits?: Partial<ArchiveLimits>;
5
+ }
6
+ export declare const loadFrameworkLogSources: (targetPath: string, options?: LoadFrameworkLogSourcesOptions) => Promise<FrameworkLogSource[]>;
@@ -1,7 +1,7 @@
1
- import { readFile, readdir, stat } from 'node:fs/promises';
1
+ import { lstat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { unzipSync } from 'fflate';
4
- import { readNodeTextFileContent } from './nodeInput.js';
3
+ import { extractInspectedZipEntriesWithinLimits, inspectZipDirectory, resolveArchiveLimits, } from './archiveLimits.js';
4
+ import { createNodeInputBudgetContext, findExistingRegularNodeFile, InputFileError, readNodeArchiveFileBytes, readNodeTextFileContent, readNodeTextFilesContent, resolveNodeDebugDirectory, } from './nodeInput.js';
5
5
  const MAIN_LOG_NAMES = ['maafw.log', 'maa.log'];
6
6
  const BAK_LOG_NAMES = ['maafw.bak.log', 'maa.bak.log'];
7
7
  const toPosixPath = (value) => value.replace(/\\/g, '/');
@@ -31,9 +31,10 @@ const findZipBasePath = (paths) => {
31
31
  }
32
32
  return null;
33
33
  };
34
- const loadZipSources = async (zipPath) => {
35
- const files = unzipSync(new Uint8Array(await readFile(zipPath)));
36
- const paths = Object.keys(files);
34
+ const loadZipSources = async (zipPath, limits) => {
35
+ const bytes = await readNodeArchiveFileBytes(zipPath, limits);
36
+ const entries = inspectZipDirectory(bytes, limits);
37
+ const paths = entries.map((entry) => entry.name);
37
38
  const basePath = findZipBasePath(paths);
38
39
  if (basePath == null)
39
40
  return [];
@@ -45,6 +46,8 @@ const loadZipSources = async (zipPath) => {
45
46
  if (candidate && MAIN_LOG_NAMES.includes(name))
46
47
  break;
47
48
  }
49
+ const selectedPaths = new Set(selected);
50
+ const { files } = extractInspectedZipEntriesWithinLimits(bytes, entries, (entryPath) => selectedPaths.has(entryPath), limits);
48
51
  return selected.flatMap((entryPath) => {
49
52
  const bytes = files[entryPath];
50
53
  if (!bytes)
@@ -58,67 +61,43 @@ const loadZipSources = async (zipPath) => {
58
61
  }];
59
62
  });
60
63
  };
61
- const pathExists = async (candidate) => {
62
- try {
63
- await stat(candidate);
64
- return true;
65
- }
66
- catch {
67
- return false;
68
- }
69
- };
70
- const findDebugDirectory = async (root) => {
71
- for (const name of MAIN_LOG_NAMES) {
72
- if (await pathExists(path.join(root, name)))
73
- return root;
74
- }
75
- const directDebug = path.join(root, 'debug');
76
- for (const name of MAIN_LOG_NAMES) {
77
- if (await pathExists(path.join(directDebug, name)))
78
- return directDebug;
79
- }
80
- for (const entry of await readdir(root, { withFileTypes: true })) {
81
- if (!entry.isDirectory())
82
- continue;
83
- const found = await findDebugDirectory(path.join(root, entry.name));
84
- if (found)
85
- return found;
86
- }
87
- return null;
88
- };
89
- const firstExisting = async (root, names) => {
90
- for (const name of names) {
91
- const candidate = path.join(root, name);
92
- if (await pathExists(candidate))
93
- return candidate;
94
- }
95
- return null;
96
- };
97
- const loadDirectorySources = async (directoryPath) => {
98
- const debugPath = await findDebugDirectory(directoryPath);
64
+ const loadDirectorySources = async (directoryPath, limits) => {
65
+ const context = await createNodeInputBudgetContext(directoryPath, limits);
66
+ const debugPath = await resolveNodeDebugDirectory(directoryPath, context);
99
67
  if (!debugPath)
100
68
  return [];
101
69
  const selected = [
102
- await firstExisting(debugPath, BAK_LOG_NAMES),
103
- await firstExisting(debugPath, MAIN_LOG_NAMES),
70
+ await findExistingRegularNodeFile(context, debugPath, BAK_LOG_NAMES),
71
+ await findExistingRegularNodeFile(context, debugPath, MAIN_LOG_NAMES),
104
72
  ].filter((candidate) => candidate != null);
105
- return Promise.all(selected.map(async (absolutePath) => ({
73
+ const contents = await readNodeTextFilesContent(selected, {
74
+ archiveLimits: limits,
75
+ budgetContext: context,
76
+ });
77
+ return selected.map((absolutePath, index) => ({
106
78
  path: toPosixPath(path.relative(debugPath, absolutePath)),
107
79
  name: path.basename(absolutePath),
108
- content: await readNodeTextFileContent(absolutePath),
80
+ content: contents[index] ?? '',
109
81
  reference: `file:${toPosixPath(absolutePath)}`,
110
- })));
82
+ }));
111
83
  };
112
- export const loadFrameworkLogSources = async (targetPath) => {
113
- const targetStat = await stat(targetPath);
84
+ export const loadFrameworkLogSources = async (targetPath, options = {}) => {
85
+ const limits = resolveArchiveLimits(options.archiveLimits);
86
+ const targetStat = await lstat(targetPath);
87
+ if (targetStat.isSymbolicLink()) {
88
+ throw new InputFileError('symlink', targetPath, `Symbolic-link inputs are not allowed: ${targetPath}`);
89
+ }
114
90
  if (targetStat.isDirectory())
115
- return loadDirectorySources(targetPath);
91
+ return loadDirectorySources(targetPath, limits);
92
+ if (!targetStat.isFile()) {
93
+ throw new InputFileError('not-regular-file', targetPath, `Expected a regular file: ${targetPath}`);
94
+ }
116
95
  if (targetPath.toLowerCase().endsWith('.zip'))
117
- return loadZipSources(targetPath);
96
+ return loadZipSources(targetPath, limits);
118
97
  return [{
119
98
  path: toPosixPath(targetPath),
120
99
  name: path.basename(targetPath),
121
- content: await readNodeTextFileContent(targetPath),
100
+ content: await readNodeTextFileContent(targetPath, { archiveLimits: limits }),
122
101
  reference: `file:${toPosixPath(targetPath)}`,
123
102
  }];
124
103
  };
package/dist/index.d.ts CHANGED
@@ -1,23 +1,26 @@
1
1
  import type { KernelOutput } from '@windsland52/maa-log-kernel';
2
2
  import type { AnalyzeLogContentInput, ParseFileOptions } from '@windsland52/maa-log-runtime';
3
- import { type LogBundleFocus } from './nodeInput.js';
3
+ import { type ArchiveLimits, type LogBundleFocus } from './nodeInput.js';
4
4
  type ParseOptions = ParseFileOptions;
5
5
  export interface AnalyzeZipBufferInput {
6
6
  zipData: Uint8Array;
7
7
  sourceRef?: string;
8
8
  focus?: LogBundleFocus;
9
+ archiveLimits?: Partial<ArchiveLimits>;
9
10
  parseOptions?: ParseOptions;
10
11
  parserVersion?: string;
11
12
  }
12
13
  export interface AnalyzeZipFileInput {
13
14
  zipFilePath: string;
14
15
  focus?: LogBundleFocus;
16
+ archiveLimits?: Partial<ArchiveLimits>;
15
17
  parseOptions?: ParseOptions;
16
18
  parserVersion?: string;
17
19
  }
18
20
  export interface AnalyzeDirectoryInput {
19
21
  directoryPath: string;
20
22
  focus?: LogBundleFocus;
23
+ archiveLimits?: Partial<ArchiveLimits>;
21
24
  parseOptions?: ParseOptions;
22
25
  parserVersion?: string;
23
26
  }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export const analyzeLogContent = async (input) => {
10
10
  export const analyzeZipBuffer = async (input) => {
11
11
  const extracted = extractZipContentFromNodeBuffer(input.zipData, input.sourceRef, {
12
12
  focus: input.focus,
13
+ archiveLimits: input.archiveLimits,
13
14
  });
14
15
  if (!extracted)
15
16
  return null;
@@ -25,6 +26,7 @@ export const analyzeZipBuffer = async (input) => {
25
26
  export const analyzeZipFile = async (input) => {
26
27
  const extracted = await extractZipContentFromNodeFile(input.zipFilePath, {
27
28
  focus: input.focus,
29
+ archiveLimits: input.archiveLimits,
28
30
  });
29
31
  if (!extracted)
30
32
  return null;
@@ -40,6 +42,7 @@ export const analyzeZipFile = async (input) => {
40
42
  export const analyzeDirectory = async (input) => {
41
43
  const extracted = await loadNodeLogDirectory(input.directoryPath, {
42
44
  focus: input.focus,
45
+ archiveLimits: input.archiveLimits,
43
46
  });
44
47
  if (!extracted)
45
48
  return null;
@@ -1,4 +1,9 @@
1
1
  import type { SourceSegment } from './runtimeInspection.js';
2
+ import { type ArchiveDirectoryBudget, type ArchiveLimits, type ExtractionBudget } from './archiveLimits.js';
3
+ import { type FileIdentity } from './boundedFileReader.js';
4
+ export { ArchiveFormatError, ArchiveLimitError, DEFAULT_ARCHIVE_LIMITS, resolveArchiveLimits, } from './archiveLimits.js';
5
+ export { InputFileError } from './boundedFileReader.js';
6
+ export type { ArchiveFormatCode, ArchiveLimitCode, ArchiveLimits } from './archiveLimits.js';
2
7
  export interface KernelTextFile {
3
8
  path: string;
4
9
  name: string;
@@ -20,11 +25,32 @@ export interface LogBundleFocus {
20
25
  }
21
26
  export interface ExtractZipContentOptions {
22
27
  focus?: LogBundleFocus;
28
+ archiveLimits?: Partial<ArchiveLimits>;
23
29
  }
24
30
  export interface LoadNodeLogDirectoryOptions {
25
31
  focus?: LogBundleFocus;
32
+ archiveLimits?: Partial<ArchiveLimits>;
26
33
  }
27
- export declare const readNodeTextFileContent: (filePath: string) => Promise<string>;
34
+ export interface ReadNodeTextFileOptions {
35
+ archiveLimits?: Partial<ArchiveLimits>;
36
+ budgetContext?: NodeInputBudgetContext;
37
+ }
38
+ export interface NodeInputBudgetContext {
39
+ readonly limits: Readonly<ArchiveLimits>;
40
+ readonly rootPath: string;
41
+ readonly rootRealPath: string;
42
+ directory: ArchiveDirectoryBudget;
43
+ extraction: ExtractionBudget;
44
+ readonly chargedPaths: Set<string>;
45
+ readonly discoveredIdentities: Map<string, FileIdentity>;
46
+ }
47
+ export declare const createNodeInputBudgetContext: (rootPath: string, limits: Readonly<ArchiveLimits>, requireDirectoryRoot?: boolean) => Promise<NodeInputBudgetContext>;
48
+ export declare const readNodeTextFileContent: (filePath: string, options?: ReadNodeTextFileOptions) => Promise<string>;
49
+ export declare const readNodeTextFilesContent: (filePaths: readonly string[], options?: ReadNodeTextFileOptions) => Promise<string[]>;
28
50
  export declare const extractZipContentFromNodeBuffer: (zipData: Uint8Array, sourceRef?: string, options?: ExtractZipContentOptions) => NodeExtractedLogContent | null;
29
51
  export declare const extractZipContentFromNodeFile: (zipFilePath: string, options?: ExtractZipContentOptions) => Promise<NodeExtractedLogContent | null>;
52
+ export declare const readNodeArchiveFileBytes: (zipFilePath: string, limits: Readonly<ArchiveLimits>) => Promise<Uint8Array>;
53
+ export declare const hasNodeMainLogInDirectory: (context: NodeInputBudgetContext, directoryPath: string) => Promise<boolean>;
54
+ export declare const findExistingRegularNodeFile: (context: NodeInputBudgetContext, directoryPath: string, names: readonly string[]) => Promise<string | null>;
55
+ export declare const resolveNodeDebugDirectory: (inputPath: string, context: NodeInputBudgetContext) => Promise<string | null>;
30
56
  export declare const loadNodeLogDirectory: (inputDirectoryPath: string, options?: LoadNodeLogDirectoryOptions) => Promise<NodeExtractedLogContent | null>;