Files
gitmost/apps/server/src/integrations/import/utils/file.utils.ts
T
claude_code 0b2af34029 test(integrations/client/packages): batch 2-4 unit coverage + zip-slip guard extraction
Batch 2-4 of the test-strategy rollout. Test-only except one minimal,
behaviour-preserving extraction in file.utils.ts. All suites green:
server 82 suites/836+1todo, editor-ext 86, mcp 270, client (new files) 86.

integrations (server):
- file.utils.ts: extract pure `isEntryPathSafe(entryName, targetDir)` from
  extractZipInternal so the zip-slip/path-traversal guard is unit-testable;
  call site rerouted, behaviour identical (only a warn-message string merged).
- file.utils.zip-safety.spec.ts: traversal/strip/__MACOSX/prefix-confusion
  cases (mutation-resistant: fails if containment loses the path.sep).
- import-formatter / import.utils / table-utils / export utils / import.service
  extractTitleAndRemoveHeading: pure import/export transforms, Notion/XWiki
  formatting, table colspan widths (idempotent), slug/link rewriting.

client:
- safeRedirectPath: open-redirect guard, every reject branch independently.
- buildChatMarkdown (fence anti-breakout), label-colors, normalize-label,
  share tree build, page URL builders, notification time-grouping (fake clock).

packages:
- editor-ext: deriveFootnoteId golden table, parseHtmlEmbedHeight crafted
  values, orphan footnote extraction.
- mcp: deriveFootnoteId parity (drift guard vs editor-ext), applyTextEdits
  idempotency + cross-block replaceAll, diffDocs/summarizeChange on reorder.

Reviewed (APPROVE): extraction behaviour-preserving, assertions mutation-resistant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:22:15 +03:00

236 lines
7.2 KiB
TypeScript

import * as yauzl from 'yauzl';
import * as path from 'path';
import * as fs from 'node:fs';
export enum FileTaskType {
Import = 'import',
Export = 'export',
}
export enum FileImportSource {
Generic = 'generic',
Notion = 'notion',
}
export enum FileTaskStatus {
Processing = 'processing',
Success = 'success',
Failed = 'failed',
}
export function getFileTaskFolderPath(
type: FileTaskType,
workspaceId: string,
): string {
switch (type) {
case FileTaskType.Import:
return `${workspaceId}/imports`;
case FileTaskType.Export:
return `${workspaceId}/exports`;
}
}
/**
* Pure path-safety decision for a single ZIP entry (zip-slip / path-traversal guard).
*
* Reproduces exactly the inline check previously embedded in `extractZipInternal`:
* 1. Strip any leading slashes from the entry name.
* 2. Reject names that fail `yauzl.validateFileName` (e.g. backslashes,
* relative `..` segments, drive letters).
* 3. Reject `__MACOSX/` metadata entries.
* 4. Resolve the entry against the target directory and require it to stay
* strictly inside `targetDir` using a `targetResolved + path.sep` prefix check
* (the trailing separator prevents sibling-directory prefix confusion, e.g.
* `/tmp/x` must not match `/tmp/x-evil`).
*
* @param entryName The decoded (UTF-8) entry file name from the archive.
* @param targetDir Directory the archive is being extracted into.
* @returns `{ safe }` and, when safe, the resolved absolute path of the entry.
*/
export function isEntryPathSafe(
entryName: string,
targetDir: string,
): { safe: boolean; resolved?: string } {
// Strip leading slashes so absolute-looking entries cannot escape the target.
const safe = entryName.replace(/^\/+/, '');
const validationError = yauzl.validateFileName(safe);
if (validationError) {
return { safe: false };
}
// Skip macOS resource-fork metadata entries.
if (safe.startsWith('__MACOSX/')) {
return { safe: false };
}
const fullPath = path.join(targetDir, safe);
const resolved = path.resolve(fullPath);
const targetResolved = path.resolve(targetDir);
// Containment check: resolved path must live strictly inside the target dir.
if (!resolved.startsWith(targetResolved + path.sep)) {
return { safe: false };
}
return { safe: true, resolved };
}
/**
* Extracts a ZIP archive.
*/
export async function extractZip(
source: string,
target: string,
): Promise<void> {
return extractZipInternal(source, target, true);
}
/**
* Internal helper to extract a ZIP, with optional single-nested-ZIP handling.
* @param source Path to the ZIP file
* @param target Directory to extract into
* @param allowNested Whether to check and unwrap one level of nested ZIP
*/
function extractZipInternal(
source: string,
target: string,
allowNested: boolean,
): Promise<void> {
return new Promise((resolve, reject) => {
yauzl.open(
source,
{ lazyEntries: true, decodeStrings: false, autoClose: true },
(err, zipfile) => {
if (err) return reject(err);
// Handle one level of nested ZIP if allowed
if (allowNested && zipfile.entryCount === 1) {
zipfile.readEntry();
zipfile.once('entry', (entry) => {
const name = entry.fileName.toString('utf8').replace(/^\/+/, '');
const isZip =
!/\/$/.test(entry.fileName) &&
name.toLowerCase().endsWith('.zip');
if (isZip) {
// temporary name to avoid overwriting file
const nestedPath = source.endsWith('.zip')
? source.slice(0, -4) + '.inner.zip'
: source + '.inner.zip';
zipfile.openReadStream(entry, (openErr, rs) => {
if (openErr) return reject(openErr);
const ws = fs.createWriteStream(nestedPath);
rs.on('error', reject);
ws.on('error', reject);
ws.on('finish', () => {
zipfile.close();
extractZipInternal(nestedPath, target, false)
.then(() => {
fs.unlinkSync(nestedPath);
resolve();
})
.catch(reject);
});
rs.pipe(ws);
});
} else {
zipfile.close();
extractZipInternal(source, target, false).then(resolve, reject);
}
});
zipfile.once('error', reject);
return;
}
// Normal extraction
zipfile.readEntry();
zipfile.on('entry', (entry) => {
const name = entry.fileName.toString('utf8');
const safe = name.replace(/^\/+/, '');
// Zip-slip / path-traversal guard (see isEntryPathSafe).
if (!isEntryPathSafe(name, target).safe) {
console.warn(`Skipping unsafe entry: ${safe}`);
zipfile.readEntry();
return;
}
const fullPath = path.join(target, safe);
// Handle directories
if (/\/$/.test(name)) {
try {
fs.mkdirSync(fullPath, { recursive: true });
} catch (mkdirErr: any) {
if (mkdirErr.code === 'ENAMETOOLONG') {
console.warn(`Skipping directory (path too long): ${fullPath}`);
zipfile.readEntry();
return;
}
return reject(mkdirErr);
}
zipfile.readEntry();
return;
}
// Handle files
try {
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
} catch (mkdirErr: any) {
if (mkdirErr.code === 'ENAMETOOLONG') {
console.warn(
`Skipping file directory creation (path too long): ${fullPath}`,
);
zipfile.readEntry();
return;
}
return reject(mkdirErr);
}
zipfile.openReadStream(entry, (openErr, rs) => {
if (openErr) return reject(openErr);
let ws: fs.WriteStream;
try {
ws = fs.createWriteStream(fullPath);
} catch (openWsErr: any) {
if (openWsErr.code === 'ENAMETOOLONG') {
console.warn(
`Skipping file write (path too long): ${fullPath}`,
);
zipfile.readEntry();
return;
}
return reject(openWsErr);
}
rs.on('error', (err) => reject(err));
ws.on('error', (err) => {
if ((err as any).code === 'ENAMETOOLONG') {
console.warn(
`Skipping file write on stream (path too long): ${fullPath}`,
);
zipfile.readEntry();
} else {
reject(err);
}
});
ws.on('finish', () => zipfile.readEntry());
rs.pipe(ws);
});
});
zipfile.on('end', () => resolve());
zipfile.on('error', (err) => reject(err));
},
);
});
}
export function cleanUrlString(url: string): string {
if (!url) return null;
const [mainUrl] = url.split('?', 1);
return mainUrl;
}