mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-10 08:13:46 +00:00
* fix: Preserve unicode filenames * fix: Cap unicode filenames by bytes * fix: Preserve clean artifact directories * fix: Disambiguate normalized artifact names
618 lines
26 KiB
TypeScript
618 lines
26 KiB
TypeScript
import {
|
||
sanitizeFilename,
|
||
sanitizeArtifactPath,
|
||
flattenArtifactPath,
|
||
resolveUploadErrorMessage,
|
||
} from './files';
|
||
|
||
jest.mock('node:crypto', () => {
|
||
const actualModule = jest.requireActual('node:crypto');
|
||
return {
|
||
...actualModule,
|
||
randomBytes: jest.fn().mockReturnValue(Buffer.from('abc123', 'hex')),
|
||
};
|
||
});
|
||
|
||
/* `sanitizeArtifactPath` and `flattenArtifactPath` use `crypto.createHash`
|
||
* (real, not mocked) to produce a deterministic 6-hex disambiguator from
|
||
* the input. Tests compute the expected hash inline so we can compare
|
||
* exact strings without re-mocking. */
|
||
function expectedHexSuffix(input: string): string {
|
||
const { createHash } = jest.requireActual('node:crypto');
|
||
return createHash('sha256').update(input).digest('hex').slice(0, 6);
|
||
}
|
||
|
||
function utf8ByteLength(input: string): number {
|
||
return Buffer.byteLength(input, 'utf8');
|
||
}
|
||
|
||
describe('sanitizeFilename', () => {
|
||
test('removes directory components (1/2)', () => {
|
||
expect(sanitizeFilename('/path/to/file.txt')).toBe('file.txt');
|
||
});
|
||
|
||
test('removes directory components (2/2)', () => {
|
||
expect(sanitizeFilename('../../../../file.txt')).toBe('file.txt');
|
||
});
|
||
|
||
test('replaces non-alphanumeric characters', () => {
|
||
expect(sanitizeFilename('file name@#$.txt')).toBe('file_name___.txt');
|
||
});
|
||
|
||
test('preserves Unicode filenames', () => {
|
||
expect(sanitizeFilename('日本語レポート.xlsx')).toBe('日本語レポート.xlsx');
|
||
expect(sanitizeFilename('résumé-данные-تقرير-보고서📊.csv')).toBe(
|
||
'résumé-данные-تقرير-보고서📊.csv',
|
||
);
|
||
});
|
||
|
||
test('normalizes decomposed Unicode marks before sanitizing', () => {
|
||
expect(sanitizeFilename('Cafe\u0301.txt')).toBe('Café.txt');
|
||
});
|
||
|
||
test('preserves dots and hyphens', () => {
|
||
expect(sanitizeFilename('file-name.with.dots.txt')).toBe('file-name.with.dots.txt');
|
||
});
|
||
|
||
test('prepends underscore to filenames starting with a dot', () => {
|
||
expect(sanitizeFilename('.hiddenfile')).toBe('_.hiddenfile');
|
||
});
|
||
|
||
test('truncates long filenames', () => {
|
||
const longName = 'a'.repeat(300) + '.txt';
|
||
const result = sanitizeFilename(longName);
|
||
expect(result.length).toBe(255);
|
||
expect(result).toMatch(/^a+-abc123\.txt$/);
|
||
});
|
||
|
||
test('truncates Unicode filenames by UTF-8 bytes, preserving the extension', () => {
|
||
const longName = '界'.repeat(100) + '.txt';
|
||
const result = sanitizeFilename(longName);
|
||
expect(utf8ByteLength(result)).toBeLessThanOrEqual(255);
|
||
expect(result.endsWith('-abc123.txt')).toBe(true);
|
||
});
|
||
|
||
test('handles filenames with no extension', () => {
|
||
const longName = 'a'.repeat(300);
|
||
const result = sanitizeFilename(longName);
|
||
expect(result.length).toBe(255);
|
||
expect(result).toMatch(/^a+-abc123$/);
|
||
});
|
||
|
||
test('handles empty input', () => {
|
||
expect(sanitizeFilename('')).toBe('_');
|
||
});
|
||
|
||
test('handles input with only special characters', () => {
|
||
expect(sanitizeFilename('@#$%^&*')).toBe('_______');
|
||
});
|
||
});
|
||
|
||
describe('sanitizeArtifactPath', () => {
|
||
test('preserves a single nested directory component', () => {
|
||
expect(sanitizeArtifactPath('test_folder/test_file.txt')).toBe('test_folder/test_file.txt');
|
||
});
|
||
|
||
test('preserves multiple nested directory components', () => {
|
||
expect(sanitizeArtifactPath('a/b/c/file.txt')).toBe('a/b/c/file.txt');
|
||
});
|
||
|
||
test('preserves Unicode path segments', () => {
|
||
expect(sanitizeArtifactPath('分析/結果📊.csv')).toBe('分析/結果📊.csv');
|
||
});
|
||
|
||
test('replaces non-alphanumeric characters per segment + adds raw-input disambiguator', () => {
|
||
/* Different raw inputs that sanitize to the same form (`out 1.csv`
|
||
* vs `out_1.csv`, `out@1.csv` vs `out#1.csv`) would otherwise share
|
||
* a `claimCodeFile` compound key and silently overwrite each other.
|
||
* When character-level sanitization changed something, we embed a
|
||
* deterministic SHA-256 prefix of the raw input in the leaf so
|
||
* different raw inputs produce different safe forms. Same raw
|
||
* input is still idempotent. */
|
||
const raw = 'proj name/file@v1.txt';
|
||
expect(sanitizeArtifactPath(raw)).toBe(`proj_name/file_v1-${expectedHexSuffix(raw)}.txt`);
|
||
});
|
||
|
||
test('falls back to basename for parent traversal', () => {
|
||
expect(sanitizeArtifactPath('../escape.txt')).toBe('escape.txt');
|
||
});
|
||
|
||
test('resolves embedded parent traversal via path normalization (with disambiguator)', () => {
|
||
/* `path.posix.normalize('a/../escape.txt')` collapses to `escape.txt`,
|
||
* which then passes the traversal guard and goes through the normal
|
||
* segment-split path. Because normalization mutated the input
|
||
* (raw had `a/../`, safe form doesn't), the disambiguator fires —
|
||
* raw `a/../escape.txt` and raw `escape.txt` would otherwise both
|
||
* resolve to `escape.txt` and collide on `claimCodeFile`. */
|
||
const raw = 'a/../escape.txt';
|
||
expect(sanitizeArtifactPath(raw)).toBe(`escape-${expectedHexSuffix(raw)}.txt`);
|
||
});
|
||
|
||
test('falls back to basename for absolute paths', () => {
|
||
expect(sanitizeArtifactPath('/abs/path.txt')).toBe('path.txt');
|
||
});
|
||
|
||
test('collapses redundant separators (with disambiguator)', () => {
|
||
/* `dir//file.txt` and `dir/file.txt` would both resolve to
|
||
* `dir/file.txt` without disambiguation — collision on
|
||
* `claimCodeFile`. The empty-segment collapse counts as
|
||
* mutation, so the disambiguator fires for the doubled-slash
|
||
* variant and the clean one passes through unchanged. */
|
||
const raw = 'dir//file.txt';
|
||
expect(sanitizeArtifactPath(raw)).toBe(`dir/file-${expectedHexSuffix(raw)}.txt`);
|
||
});
|
||
|
||
test('strips current-directory segments (with disambiguator)', () => {
|
||
const raw = './dir/./file.txt';
|
||
expect(sanitizeArtifactPath(raw)).toBe(`dir/file-${expectedHexSuffix(raw)}.txt`);
|
||
});
|
||
|
||
test('prepends underscore on dotfile leaf and disambiguates against literal _.x', () => {
|
||
/* `.hidden` and `_.hidden` both resolve to `_.hidden` without
|
||
* disambiguation. The dotfile-prefix step counts as mutation, so
|
||
* `.hidden` → `_.hidden-<hash>` and the literal `_.hidden` passes
|
||
* through. The disambiguator appends at the end (rather than
|
||
* splitting `_.hidden` into stem + extension) because `dot <= 1`. */
|
||
const raw = 'dir/.hidden';
|
||
expect(sanitizeArtifactPath(raw)).toBe(`dir/_.hidden-${expectedHexSuffix(raw)}`);
|
||
});
|
||
|
||
test('returns underscore for empty input', () => {
|
||
expect(sanitizeArtifactPath('')).toBe('_');
|
||
});
|
||
|
||
test('caps the leaf segment at 255 chars with extension-preserving truncation', () => {
|
||
/* Regression for the unbounded-leaf path: without the per-segment
|
||
* cap, a 300-char artifact name would flow through to saveBuffer's
|
||
* storage key (`${file_id}__${flatName}`) and trip ENAMETOOLONG on
|
||
* filesystems that enforce NAME_MAX. */
|
||
const longName = 'a'.repeat(300) + '.txt';
|
||
const result = sanitizeArtifactPath(longName);
|
||
expect(result.length).toBe(255);
|
||
expect(result).toMatch(new RegExp(`^a+-${expectedHexSuffix(longName)}\\.txt$`));
|
||
});
|
||
|
||
test('caps Unicode leaf segments at 255 UTF-8 bytes with extension-preserving truncation', () => {
|
||
const longName = '界'.repeat(100) + '.txt';
|
||
const result = sanitizeArtifactPath(longName);
|
||
expect(utf8ByteLength(result)).toBeLessThanOrEqual(255);
|
||
expect(result.endsWith(`-${expectedHexSuffix(longName)}.txt`)).toBe(true);
|
||
});
|
||
|
||
test('caps the leaf when nested under a directory, preserving the directory verbatim', () => {
|
||
const longLeaf = 'b'.repeat(300) + '.csv';
|
||
const result = sanitizeArtifactPath(`reports/${longLeaf}`);
|
||
const [dir, leaf] = result.split('/');
|
||
expect(dir).toBe('reports');
|
||
expect(leaf.length).toBe(255);
|
||
expect(leaf).toMatch(new RegExp(`^b+-${expectedHexSuffix(longLeaf)}\\.csv$`));
|
||
});
|
||
|
||
test('caps non-leaf directory segments at 255 chars', () => {
|
||
/* Directory components hit `NAME_MAX` independently of the leaf —
|
||
* each `mkdir` along the path has to satisfy the per-component limit
|
||
* regardless of the basename truncation. */
|
||
const longDir = 'd'.repeat(300);
|
||
const result = sanitizeArtifactPath(`${longDir}/notes.txt`);
|
||
const [dir, leaf] = result.split('/');
|
||
expect(dir.length).toBe(255);
|
||
expect(dir).toMatch(new RegExp(`^d+-${expectedHexSuffix(longDir)}$`));
|
||
expect(leaf).toBe('notes.txt');
|
||
});
|
||
|
||
test('caps Unicode non-leaf directory segments at 255 UTF-8 bytes', () => {
|
||
const longDir = '界'.repeat(100);
|
||
const result = sanitizeArtifactPath(`${longDir}/notes.txt`);
|
||
const [dir, leaf] = result.split('/');
|
||
expect(utf8ByteLength(dir)).toBeLessThanOrEqual(255);
|
||
expect(dir.endsWith(`-${expectedHexSuffix(longDir)}`)).toBe(true);
|
||
expect(leaf).toBe('notes.txt');
|
||
});
|
||
|
||
test('produces deterministic output across calls (no orphaned uploads on re-truncation)', () => {
|
||
/* Codex review P2: `sanitizeFilename`'s `crypto.randomBytes(3)` made
|
||
* the truncated form non-deterministic — re-uploading the same long
|
||
* name would compute a different storage key, orphaning the previous
|
||
* on-disk file under the reused `file_id`. The new helpers hash the
|
||
* input so the same input always produces the same output. */
|
||
const longName = 'a'.repeat(300) + '.txt';
|
||
const a = sanitizeArtifactPath(longName);
|
||
const b = sanitizeArtifactPath(longName);
|
||
expect(a).toBe(b);
|
||
/* Two *different* long names that share a truncation prefix must
|
||
* still produce different outputs (collision avoidance). */
|
||
const otherName = 'a'.repeat(299) + 'X.txt';
|
||
expect(sanitizeArtifactPath(otherName)).not.toBe(a);
|
||
});
|
||
|
||
test('caps every segment in a nested path with mixed lengths', () => {
|
||
/* Every segment respects the cap — the truncate is per-component,
|
||
* not on the join. Use 2 segments here (rather than 3+) so the
|
||
* joined form stays under `ARTIFACT_PATH_TOTAL_MAX` (512); the
|
||
* total-cap fallback gets its own test below. */
|
||
const segA = 'x'.repeat(260);
|
||
const leaf = 'z'.repeat(260) + '.json';
|
||
const result = sanitizeArtifactPath(`${segA}/${leaf}`);
|
||
const parts = result.split('/');
|
||
expect(parts).toHaveLength(2);
|
||
expect(parts[0].length).toBe(255);
|
||
expect(parts[1].length).toBe(255);
|
||
expect(parts[1]).toMatch(/\.json$/);
|
||
});
|
||
|
||
test('does not truncate filenames that are exactly at the 255-char limit', () => {
|
||
/* Off-by-one guard: 255 itself is allowed (filesystem boundary), 256
|
||
* is not. */
|
||
const exact = 'e'.repeat(251) + '.txt'; // 255 chars total
|
||
expect(sanitizeArtifactPath(exact)).toBe(exact);
|
||
expect(sanitizeArtifactPath(`dir/${exact}`)).toBe(`dir/${exact}`);
|
||
});
|
||
|
||
test('falls back to leaf-only when total path length exceeds the DB-index cap (Codex review P2)', () => {
|
||
/* MongoDB 4.0 rejects indexed values past 1024 bytes, and even on
|
||
* 4.2+ where the limit is configurable, runaway nested paths bloat
|
||
* the unique compound index on (file_id, filename, conversationId,
|
||
* context). At 3+ at-cap (255-char) segments + separators the joined
|
||
* form blows past the safety budget; the helper falls back to leaf-
|
||
* only (already segment-capped to ≤ 255). */
|
||
const segA = 'x'.repeat(255);
|
||
const segB = 'y'.repeat(255);
|
||
const segC = 'z'.repeat(255);
|
||
const result = sanitizeArtifactPath(`${segA}/${segB}/${segC}/file.txt`);
|
||
/* Joined would be ~768 chars + slashes; well past the 512 cap. */
|
||
expect(result).toBe('file.txt');
|
||
});
|
||
|
||
test('falls back to leaf-only when Unicode path bytes exceed the DB-index cap', () => {
|
||
const segA = '界'.repeat(80);
|
||
const segB = '分'.repeat(80);
|
||
const segC = '析'.repeat(80);
|
||
const result = sanitizeArtifactPath(`${segA}/${segB}/${segC}/file.txt`);
|
||
expect(result).toBe('file.txt');
|
||
});
|
||
|
||
test('keeps the nested path when total length is within the DB-index cap', () => {
|
||
/* The cap doesn't fire for realistic outputs — typical artifact
|
||
* depth is ≤ 3 segments × short names. */
|
||
const result = sanitizeArtifactPath('reports/2026/q1.csv');
|
||
expect(result).toBe('reports/2026/q1.csv');
|
||
});
|
||
|
||
describe('collision avoidance (Codex review P2)', () => {
|
||
/* `sanitizeArtifactPath` is not injective — multiple raw inputs can
|
||
* collapse to the same regex-and-normalize output. `claimCodeFile`
|
||
* is keyed on the schema's compound unique
|
||
* `(filename, conversationId, context, tenantId)` index, so a
|
||
* collision would silently overwrite an earlier artifact's bytes
|
||
* via a reused `file_id`. The disambiguator branch embeds a
|
||
* SHA-256 prefix of the raw input in the leaf to keep different
|
||
* raw inputs distinct. */
|
||
test('different raw inputs that sanitize to the same form get distinct safe forms', () => {
|
||
const a = sanitizeArtifactPath('out 1.csv');
|
||
const b = sanitizeArtifactPath('out_1.csv');
|
||
const c = sanitizeArtifactPath('out@1.csv');
|
||
const d = sanitizeArtifactPath('out#1.csv');
|
||
/* Pre-fix all four collapsed to `out_1.csv`. Post-fix only the
|
||
* already-clean `out_1.csv` keeps that form; the others get
|
||
* disambiguators based on their raw inputs. */
|
||
expect(b).toBe('out_1.csv');
|
||
expect(a).not.toBe(b);
|
||
expect(c).not.toBe(b);
|
||
expect(d).not.toBe(b);
|
||
expect(a).not.toBe(c);
|
||
expect(c).not.toBe(d);
|
||
});
|
||
|
||
test('whitespace-vs-underscore collision in directory segment', () => {
|
||
/* Codex's specific example: `reports 2026/out.csv` and
|
||
* `reports_2026/out.csv` would both have safeName
|
||
* `reports_2026/out.csv` without disambiguation. */
|
||
const a = sanitizeArtifactPath('reports 2026/out.csv');
|
||
const b = sanitizeArtifactPath('reports_2026/out.csv');
|
||
expect(b).toBe('reports_2026/out.csv');
|
||
expect(a).not.toBe(b);
|
||
/* The disambiguator is on the LEAF (not the mutated dir
|
||
* segment) so the layout matches normal path-preserving
|
||
* outputs. */
|
||
expect(a.startsWith('reports_2026/out-')).toBe(true);
|
||
expect(a.endsWith('.csv')).toBe(true);
|
||
});
|
||
|
||
test('dotfile prefix collision: `.x` vs `_.x`', () => {
|
||
const a = sanitizeArtifactPath('.hidden');
|
||
const b = sanitizeArtifactPath('_.hidden');
|
||
expect(b).toBe('_.hidden');
|
||
expect(a).not.toBe(b);
|
||
expect(a).toBe(`_.hidden-${expectedHexSuffix('.hidden')}`);
|
||
});
|
||
|
||
test('normalization-only collisions get distinct safe forms', () => {
|
||
const composed = 'reports/Café.csv';
|
||
const decomposed = 'reports/Cafe\u0301.csv';
|
||
const normalizedDecomposed = `reports/Café-${expectedHexSuffix(decomposed)}.csv`;
|
||
|
||
expect(sanitizeArtifactPath(composed)).toBe(composed);
|
||
expect(sanitizeArtifactPath(decomposed)).toBe(normalizedDecomposed);
|
||
expect(normalizedDecomposed).not.toBe(composed);
|
||
});
|
||
|
||
test('idempotent: same raw input always produces the same safe form', () => {
|
||
/* Disambiguator is deterministic (SHA-256 prefix of raw input),
|
||
* so re-uploading the same long-or-mutated name lands at the
|
||
* same storage key on every call. */
|
||
const raw = 'proj name/data file.csv';
|
||
expect(sanitizeArtifactPath(raw)).toBe(sanitizeArtifactPath(raw));
|
||
});
|
||
|
||
test('clean inputs (no mutation) skip the disambiguator', () => {
|
||
/* Cosmetic: don't clutter human-readable filenames with a hash
|
||
* when no collision is possible. The check is on the
|
||
* post-regex form vs the raw input — when they match exactly,
|
||
* the disambiguator branch doesn't fire. */
|
||
expect(sanitizeArtifactPath('reports/2026/q1.csv')).toBe('reports/2026/q1.csv');
|
||
expect(sanitizeArtifactPath('plain.txt')).toBe('plain.txt');
|
||
});
|
||
|
||
test('disambiguator survives leaf truncation (long mutated leaf)', () => {
|
||
/* Long input + mutation: truncateLeafSegment caps at 255 first,
|
||
* then embedDisambiguatorInLeaf re-trims to insert the input
|
||
* hash. The seg-hash from the first truncation is replaced by
|
||
* the input-hash from the second pass — that's intentional:
|
||
* input-hash is the load-bearing collision-avoidance suffix. */
|
||
const raw = 'data file ' + 'a'.repeat(280) + '.csv';
|
||
const result = sanitizeArtifactPath(raw);
|
||
expect(result.length).toBe(255);
|
||
expect(result.endsWith(`-${expectedHexSuffix(raw)}.csv`)).toBe(true);
|
||
});
|
||
|
||
test('disambiguator survives total-cap fallback', () => {
|
||
/* When joined > ARTIFACT_PATH_TOTAL_MAX, we fall back to the
|
||
* leaf-only form. The leaf has already had the disambiguator
|
||
* embedded, so the fallback preserves collision avoidance for
|
||
* the pathological-depth case too. */
|
||
const raw =
|
||
'a b'.repeat(100) + '/' + 'c d'.repeat(100) + '/' + 'e f'.repeat(100) + '/file.csv';
|
||
const result = sanitizeArtifactPath(raw);
|
||
/* Result is leaf-only (no slashes). */
|
||
expect(result.includes('/')).toBe(false);
|
||
/* And carries the disambiguator. */
|
||
expect(result.endsWith(`-${expectedHexSuffix(raw)}.csv`)).toBe(true);
|
||
});
|
||
});
|
||
|
||
test('preserves the dotfile underscore prefix when the leaf also needs truncation', () => {
|
||
/* A long hidden-file leaf (`._very_long_name`) goes through the
|
||
* underscore-prefix branch first; truncation must run AFTER that
|
||
* rewrite or the leaf would still leak past the cap. */
|
||
const longHidden = '.' + 'a'.repeat(300);
|
||
const result = sanitizeArtifactPath(`dir/${longHidden}`);
|
||
const [dir, leaf] = result.split('/');
|
||
expect(dir).toBe('dir');
|
||
expect(leaf.length).toBe(255);
|
||
expect(leaf.startsWith('_.')).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('flattenArtifactPath', () => {
|
||
test('joins path components with __ for storage keys', () => {
|
||
expect(flattenArtifactPath('a/b/c.txt')).toBe('a__b__c.txt');
|
||
});
|
||
|
||
test('passes through paths with no separator', () => {
|
||
expect(flattenArtifactPath('file.txt')).toBe('file.txt');
|
||
});
|
||
|
||
test('handles single-level nesting', () => {
|
||
expect(flattenArtifactPath('test_folder/test_file.txt')).toBe('test_folder__test_file.txt');
|
||
});
|
||
|
||
test('passes through unchanged when no maxLength is supplied', () => {
|
||
/* The cap is opt-in — callers that aren't building filesystem keys
|
||
* (e.g. tests, log messages) shouldn't get truncation. */
|
||
const longPath = 'a'.repeat(200) + '/' + 'b'.repeat(200) + '.txt';
|
||
expect(flattenArtifactPath(longPath)).toBe(longPath.replace(/\//g, '__'));
|
||
});
|
||
|
||
test('passes through unchanged when flat form fits within maxLength', () => {
|
||
expect(flattenArtifactPath('short/file.txt', 100)).toBe('short__file.txt');
|
||
expect(flattenArtifactPath('short/file.txt', 15)).toBe('short__file.txt');
|
||
});
|
||
|
||
test('truncates flat form when it exceeds maxLength, preserving leaf extension', () => {
|
||
/* Regression for the deep-nesting flat-key overflow path: three
|
||
* 100-char segments → 308-char flat form that would blow past
|
||
* NAME_MAX=255 once `${file_id}__` is prepended. The truncation
|
||
* keeps the .ext on the leaf so download MIME inference still works. */
|
||
const a = 'a'.repeat(100);
|
||
const b = 'b'.repeat(100);
|
||
const safePath = `${a}/${b}/c.txt`;
|
||
const result = flattenArtifactPath(safePath, 200);
|
||
expect(result.length).toBe(200);
|
||
expect(result.endsWith('.txt')).toBe(true);
|
||
expect(result).toMatch(new RegExp(`-${expectedHexSuffix(safePath)}\\.txt$`));
|
||
});
|
||
|
||
test('truncates Unicode flat forms by UTF-8 bytes', () => {
|
||
const safePath = `${'界'.repeat(80)}/結果.csv`;
|
||
const result = flattenArtifactPath(safePath, 100);
|
||
expect(utf8ByteLength(result)).toBeLessThanOrEqual(100);
|
||
expect(result.endsWith('.csv')).toBe(true);
|
||
expect(result).toMatch(new RegExp(`-${expectedHexSuffix(safePath)}\\.csv$`));
|
||
});
|
||
|
||
test('preserves the extension even when only the leaf overflows', () => {
|
||
const longLeaf = 'L'.repeat(300);
|
||
const result = flattenArtifactPath(`${longLeaf}.json`, 200);
|
||
expect(result.length).toBe(200);
|
||
expect(result.endsWith('.json')).toBe(true);
|
||
});
|
||
|
||
test('falls back to whole-key truncation (no extension preservation) when ext is pathologically long', () => {
|
||
/* `path.extname`-style logic: a single dot followed by 100 chars is
|
||
* not a real extension. Keep the cap honored even if a contrived
|
||
* input would yield a "stem budget" of zero or negative. */
|
||
const result = flattenArtifactPath('stem.' + 'x'.repeat(100), 50);
|
||
expect(result.length).toBe(50);
|
||
});
|
||
|
||
test('handles paths with no extension by truncating the stem', () => {
|
||
const longName = 'n'.repeat(300);
|
||
const result = flattenArtifactPath(longName, 50);
|
||
expect(result.length).toBe(50);
|
||
expect(result).toMatch(new RegExp(`-${expectedHexSuffix(longName)}$`));
|
||
});
|
||
|
||
test('matches the boundary length exactly when input is right at the cap', () => {
|
||
/* Off-by-one guard: maxLength of N should allow flat forms of N. */
|
||
const flat = 'a'.repeat(50);
|
||
expect(flattenArtifactPath(flat, 50)).toBe(flat);
|
||
expect(flattenArtifactPath(flat, 49).length).toBe(49);
|
||
});
|
||
|
||
test('produces deterministic output across calls (no orphaned uploads on re-flatten)', () => {
|
||
/* Codex review P2: re-flattening the same input must produce the
|
||
* same key so re-uploads land at the same storage location. The
|
||
* hash-based suffix replaces the previous random one. */
|
||
const longPath = 'a'.repeat(100) + '/b.csv';
|
||
const a = flattenArtifactPath(longPath, 50);
|
||
const b = flattenArtifactPath(longPath, 50);
|
||
expect(a).toBe(b);
|
||
/* And different inputs that share a truncation prefix still produce
|
||
* different outputs (collision avoidance). */
|
||
const otherPath = 'a'.repeat(100) + '/c.csv';
|
||
expect(flattenArtifactPath(otherPath, 50)).not.toBe(a);
|
||
});
|
||
|
||
test('clamps the result to maxLength even when ext.length > maxLength - 7 (Codex review P2)', () => {
|
||
/* Pathological maxLength: 5, ext: ".txt" (4 chars). Stem budget is
|
||
* Math.max(0, 5 - 4 - 7) = 0, so the formula yields
|
||
* `'' + '-' + 6-char-hash + '.txt'` = 11 chars — past maxLength.
|
||
* The final clamp guarantees the result is ≤ maxLength regardless. */
|
||
const result = flattenArtifactPath('foo/bar.txt', 5);
|
||
expect(result.length).toBeLessThanOrEqual(5);
|
||
});
|
||
|
||
test('returns empty string when maxLength <= 0', () => {
|
||
/* Edge case: a negative or zero budget can't fit any output. Don't
|
||
* attempt to construct a key; let the caller handle the empty case
|
||
* (in practice this never fires — `process.js` passes
|
||
* `255 - file_id.length - 2`, and file_id is bounded). */
|
||
expect(flattenArtifactPath('a/b.txt', 0)).toBe('');
|
||
expect(flattenArtifactPath('a/b.txt', -5)).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('resolveUploadErrorMessage', () => {
|
||
test('returns default message for null error', () => {
|
||
expect(resolveUploadErrorMessage(null)).toBe('Error processing file');
|
||
});
|
||
|
||
test('returns default message for undefined error', () => {
|
||
expect(resolveUploadErrorMessage(undefined)).toBe('Error processing file');
|
||
});
|
||
|
||
test('returns default message when error has no message property', () => {
|
||
expect(resolveUploadErrorMessage({})).toBe('Error processing file');
|
||
});
|
||
|
||
test('returns default message for unrecognized error', () => {
|
||
expect(resolveUploadErrorMessage({ message: 'ENOENT: no such file or directory' })).toBe(
|
||
'Error processing file',
|
||
);
|
||
});
|
||
|
||
test('prepends default message for file_ids errors', () => {
|
||
expect(resolveUploadErrorMessage({ message: 'max file_ids reached' })).toBe(
|
||
'Error processing file: max file_ids reached',
|
||
);
|
||
});
|
||
|
||
test('surfaces "Invalid file format" errors', () => {
|
||
expect(resolveUploadErrorMessage({ message: 'Invalid file format: .xyz' })).toBe(
|
||
'Invalid file format: .xyz',
|
||
);
|
||
});
|
||
|
||
test('surfaces "exceeds token limit" errors', () => {
|
||
expect(resolveUploadErrorMessage({ message: 'Content exceeds token limit' })).toBe(
|
||
'Content exceeds token limit',
|
||
);
|
||
});
|
||
|
||
test('surfaces "Unable to extract text from" errors', () => {
|
||
const msg = 'Unable to extract text from "doc.pdf". The document may be image-based.';
|
||
expect(resolveUploadErrorMessage({ message: msg })).toBe(msg);
|
||
});
|
||
|
||
test('accepts a custom default message', () => {
|
||
expect(resolveUploadErrorMessage(null, 'Custom default')).toBe('Custom default');
|
||
});
|
||
|
||
test('uses custom default in file_ids prepend', () => {
|
||
expect(resolveUploadErrorMessage({ message: 'file_ids limit' }, 'Upload failed')).toBe(
|
||
'Upload failed: file_ids limit',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('sanitizeFilename with real crypto', () => {
|
||
// Temporarily unmock crypto for these tests
|
||
beforeAll(() => {
|
||
jest.resetModules();
|
||
jest.unmock('node:crypto');
|
||
});
|
||
|
||
afterAll(() => {
|
||
jest.resetModules();
|
||
jest.mock('node:crypto', () => {
|
||
const actualModule = jest.requireActual('node:crypto');
|
||
return {
|
||
...actualModule,
|
||
randomBytes: jest.fn().mockReturnValue(Buffer.from('abc123', 'hex')),
|
||
};
|
||
});
|
||
});
|
||
|
||
test('truncates long filenames with real crypto', async () => {
|
||
const { sanitizeFilename: realSanitizeFilename } = await import('./files');
|
||
const longName = 'b'.repeat(300) + '.pdf';
|
||
const result = realSanitizeFilename(longName);
|
||
|
||
expect(result.length).toBe(255);
|
||
expect(result).toMatch(/^b+-[a-f0-9]{6}\.pdf$/);
|
||
expect(result.endsWith('.pdf')).toBe(true);
|
||
});
|
||
|
||
test('handles filenames with no extension with real crypto', async () => {
|
||
const { sanitizeFilename: realSanitizeFilename } = await import('./files');
|
||
const longName = 'c'.repeat(300);
|
||
const result = realSanitizeFilename(longName);
|
||
|
||
expect(result.length).toBe(255);
|
||
expect(result).toMatch(/^c+-[a-f0-9]{6}$/);
|
||
expect(result).not.toContain('.');
|
||
});
|
||
|
||
test('generates unique suffixes for identical long filenames', async () => {
|
||
const { sanitizeFilename: realSanitizeFilename } = await import('./files');
|
||
const longName = 'd'.repeat(300) + '.doc';
|
||
const result1 = realSanitizeFilename(longName);
|
||
const result2 = realSanitizeFilename(longName);
|
||
|
||
expect(result1.length).toBe(255);
|
||
expect(result2.length).toBe(255);
|
||
expect(result1).not.toBe(result2); // Should be different due to random suffix
|
||
expect(result1.endsWith('.doc')).toBe(true);
|
||
expect(result2.endsWith('.doc')).toBe(true);
|
||
});
|
||
|
||
test('real crypto produces valid hex strings', async () => {
|
||
const { sanitizeFilename: realSanitizeFilename } = await import('./files');
|
||
const longName = 'test'.repeat(100) + '.txt';
|
||
const result = realSanitizeFilename(longName);
|
||
|
||
const hexMatch = result.match(/-([a-f0-9]{6})\.txt$/);
|
||
expect(hexMatch).toBeTruthy();
|
||
expect(hexMatch![1]).toMatch(/^[a-f0-9]{6}$/);
|
||
});
|
||
});
|