import { createWriteStream, promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import * as tar from 'tar';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chmodWorkspaceEntry, createWorkspaceEntry, deleteWorkspaceEntry, extractWorkspaceArchive, getWorkspaceEntryProperties, listWorkspaceDirectory, moveWorkspaceEntry, previewWorkspaceDocument, readWorkspaceFile, renameWorkspaceEntry, resolveWorkspaceDownloadTarget, streamWorkspaceDirectoryZip, uploadWorkspaceFiles, writeWorkspaceFile, } from './workspace-files.js';
const { ZipArchive } = await import('archiver');
function crc32(input) {
    let crc = 0xffffffff;
    for (const byte of input) {
        crc ^= byte;
        for (let bit = 0; bit < 8; bit += 1) {
            crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
        }
    }
    return (crc ^ 0xffffffff) >>> 0;
}
async function writeStoredZip(filePath, entryName, content) {
    const nameBytes = Buffer.from(entryName, 'utf-8');
    await writeStoredZipWithNameBytes(filePath, nameBytes, content);
}
async function writeStoredZipWithNameBytes(filePath, nameBytes, content) {
    const contentBytes = Buffer.from(content, 'utf-8');
    const checksum = crc32(contentBytes);
    const localHeader = Buffer.alloc(30);
    localHeader.writeUInt32LE(0x04034b50, 0);
    localHeader.writeUInt16LE(20, 4);
    localHeader.writeUInt16LE(0, 6);
    localHeader.writeUInt16LE(0, 8);
    localHeader.writeUInt32LE(0, 10);
    localHeader.writeUInt32LE(checksum, 14);
    localHeader.writeUInt32LE(contentBytes.length, 18);
    localHeader.writeUInt32LE(contentBytes.length, 22);
    localHeader.writeUInt16LE(nameBytes.length, 26);
    localHeader.writeUInt16LE(0, 28);
    const centralHeader = Buffer.alloc(46);
    centralHeader.writeUInt32LE(0x02014b50, 0);
    centralHeader.writeUInt16LE(20, 4);
    centralHeader.writeUInt16LE(20, 6);
    centralHeader.writeUInt16LE(0, 8);
    centralHeader.writeUInt16LE(0, 10);
    centralHeader.writeUInt32LE(0, 12);
    centralHeader.writeUInt32LE(checksum, 16);
    centralHeader.writeUInt32LE(contentBytes.length, 20);
    centralHeader.writeUInt32LE(contentBytes.length, 24);
    centralHeader.writeUInt16LE(nameBytes.length, 28);
    centralHeader.writeUInt16LE(0, 30);
    centralHeader.writeUInt16LE(0, 32);
    centralHeader.writeUInt16LE(0, 34);
    centralHeader.writeUInt16LE(0, 36);
    centralHeader.writeUInt32LE(0, 38);
    centralHeader.writeUInt32LE(0, 42);
    const centralDirectoryOffset = localHeader.length + nameBytes.length + contentBytes.length;
    const centralDirectorySize = centralHeader.length + nameBytes.length;
    const endRecord = Buffer.alloc(22);
    endRecord.writeUInt32LE(0x06054b50, 0);
    endRecord.writeUInt16LE(0, 4);
    endRecord.writeUInt16LE(0, 6);
    endRecord.writeUInt16LE(1, 8);
    endRecord.writeUInt16LE(1, 10);
    endRecord.writeUInt32LE(centralDirectorySize, 12);
    endRecord.writeUInt32LE(centralDirectoryOffset, 16);
    endRecord.writeUInt16LE(0, 20);
    await fs.writeFile(filePath, Buffer.concat([
        localHeader,
        nameBytes,
        contentBytes,
        centralHeader,
        nameBytes,
        endRecord
    ]));
}
async function writeTarHeaderOnlyEntry(filePath, entryName, typeFlag) {
    const header = Buffer.alloc(512, 0);
    header.write(entryName, 0, Math.min(Buffer.byteLength(entryName), 100), 'utf-8');
    header.write('0000644\0', 100, 'ascii');
    header.write('0000000\0', 108, 'ascii');
    header.write('0000000\0', 116, 'ascii');
    header.write('00000000000\0', 124, 'ascii');
    header.write('00000000000\0', 136, 'ascii');
    header.fill(0x20, 148, 156);
    header.write(typeFlag, 156, 'ascii');
    header.write('ustar\0', 257, 'ascii');
    header.write('00', 263, 'ascii');
    let checksum = 0;
    for (const byte of header) {
        checksum += byte;
    }
    header.write(checksum.toString(8).padStart(6, '0'), 148, 'ascii');
    header[154] = 0;
    header[155] = 0x20;
    await fs.writeFile(filePath, Buffer.concat([header, Buffer.alloc(1024, 0)]));
}
describe('workspace file service', () => {
    const tempRoot = path.join(os.tmpdir(), `aws-workspace-files-${Date.now()}`);
    const workspacePath = path.join(tempRoot, 'workspace');
    const nestedDir = path.join(workspacePath, 'src');
    const nestedFile = path.join(nestedDir, 'index.ts');
    let realWorkspacePath = '';
    let realNestedFile = '';
    beforeAll(async () => {
        await fs.mkdir(nestedDir, { recursive: true });
        await fs.writeFile(nestedFile, 'console.log("hello")\n', 'utf-8');
        await fs.writeFile(path.join(workspacePath, 'README.md'), '# test\n', 'utf-8');
        realWorkspacePath = (await fs.realpath(workspacePath)).replace(/\\/g, '/');
        realNestedFile = (await fs.realpath(nestedFile)).replace(/\\/g, '/');
    });
    afterAll(async () => {
        await Promise.all([
            path.join(workspacePath, 'outside-secret-link.txt'),
            path.join(workspacePath, 'outside-link'),
            path.join(tempRoot, 'archive-link')
        ].map(async (linkPath) => {
            try {
                const stat = await fs.lstat(linkPath);
                if (stat.isSymbolicLink()) {
                    await fs.unlink(linkPath);
                }
            }
            catch {
                // Best-effort cleanup for platform-dependent symlink tests.
            }
        }));
        await fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
    }, 60_000);
    it('lists workspace root when request path is empty', async () => {
        const result = await listWorkspaceDirectory({
            workspacePath,
            targetPath: ''
        });
        expect(result.currentPath).toBe(realWorkspacePath);
        expect(result.workspacePath).toBe(realWorkspacePath);
        expect(result.items.some((item) => item.name === 'src' && item.isDirectory)).toBe(true);
        expect(result.items.some((item) => item.name === 'README.md' && !item.isDirectory)).toBe(true);
        expect(result.items.find((item) => item.name === 'README.md')?.size).toBe(Buffer.byteLength('# test\n'));
        expect(result.items.every((item) => !item.path.includes('\\'))).toBe(true);
    });
    it('rejects reading files outside workspace', async () => {
        await expect(readWorkspaceFile({
            workspacePath,
            filePath: path.join(tempRoot, 'outside.txt')
        })).rejects.toThrow(/outside workspace/);
    });
    it('reads and writes text files within workspace', async () => {
        const beforeRead = await readWorkspaceFile({
            workspacePath,
            filePath: nestedFile
        });
        expect(beforeRead.content).toBe('console.log("hello")\n');
        expect(beforeRead.workspacePath).toBe(realWorkspacePath);
        expect(beforeRead.filePath).toBe(realNestedFile);
        await writeWorkspaceFile({
            workspacePath,
            filePath: nestedFile,
            content: 'console.log("updated")\n'
        });
        const afterRead = await readWorkspaceFile({
            workspacePath,
            filePath: nestedFile
        });
        expect(afterRead.content).toBe('console.log("updated")\n');
    });
    it('creates, renames, and deletes files and directories within workspace', async () => {
        const featureDir = path.join(workspacePath, 'feature');
        const createdFile = path.join(featureDir, 'demo.txt');
        const renamedFile = path.join(featureDir, 'demo-renamed.txt');
        const renamedDir = path.join(workspacePath, 'feature-renamed');
        await createWorkspaceEntry({
            workspacePath,
            targetPath: workspacePath,
            entryName: 'feature',
            isDirectory: true
        });
        await createWorkspaceEntry({
            workspacePath,
            targetPath: featureDir,
            entryName: 'demo.txt',
            isDirectory: false
        });
        const createdContent = await readWorkspaceFile({
            workspacePath,
            filePath: createdFile
        });
        expect(createdContent.content).toBe('');
        const renameFileResult = await renameWorkspaceEntry({
            workspacePath,
            targetPath: createdFile,
            newName: 'demo-renamed.txt'
        });
        expect(renameFileResult.targetPath).toBe((await fs.realpath(renamedFile)).replace(/\\/g, '/'));
        const renameDirResult = await renameWorkspaceEntry({
            workspacePath,
            targetPath: featureDir,
            newName: 'feature-renamed'
        });
        expect(renameDirResult.targetPath).toBe((await fs.realpath(renamedDir)).replace(/\\/g, '/'));
        const listRenamedDirectory = await listWorkspaceDirectory({
            workspacePath,
            targetPath: renamedDir
        });
        expect(listRenamedDirectory.items.some((item) => item.name === 'demo-renamed.txt')).toBe(true);
        await deleteWorkspaceEntry({
            workspacePath,
            targetPath: renamedDir
        });
        await expect(fs.access(renamedDir)).rejects.toThrow();
    });
    it('returns workspace entry properties with image dimensions', async () => {
        const pngPath = path.join(workspacePath, 'tiny.png');
        const pngBytes = Buffer.from('89504e470d0a1a0a0000000d4948445200000002000000030802000000947ddc6d0000000049454e44ae426082', 'hex');
        await fs.writeFile(pngPath, pngBytes);
        const result = await getWorkspaceEntryProperties({
            workspacePath,
            targetPath: pngPath
        });
        expect(result.workspacePath).toBe(realWorkspacePath);
        expect(result.targetPath).toBe((await fs.realpath(pngPath)).replace(/\\/g, '/'));
        expect(result.name).toBe('tiny.png');
        expect(result.isDirectory).toBe(false);
        expect(result.size).toBe(pngBytes.length);
        expect(result.modeOctal).toMatch(/^[0-7]{4}$/);
        expect(result.permissions).toMatch(/^[r-][w-][xsS-][r-][w-][xsS-][r-][w-][xtT-]$/);
        expect(result.image).toEqual({ format: 'png', width: 2, height: 3 });
    });
    it('returns read-only properties for workspace root', async () => {
        const result = await getWorkspaceEntryProperties({
            workspacePath,
            targetPath: workspacePath
        });
        expect(result.workspacePath).toBe(realWorkspacePath);
        expect(result.targetPath).toBe(realWorkspacePath);
        expect(result.isDirectory).toBe(true);
        expect(result.name).toBe(path.basename(realWorkspacePath));
    });
    it('changes workspace entry permissions non-recursively and recursively', async () => {
        if (process.platform === 'win32') {
            return;
        }
        const permissionDir = path.join(workspacePath, 'permission-dir');
        const nestedPermissionFile = path.join(permissionDir, 'nested.txt');
        await fs.mkdir(permissionDir, { recursive: true });
        await fs.writeFile(nestedPermissionFile, 'nested', 'utf-8');
        const nonRecursiveResult = await chmodWorkspaceEntry({
            workspacePath,
            targetPath: permissionDir,
            mode: '700',
            recursive: false
        });
        expect(nonRecursiveResult.changedCount).toBe(1);
        expect((await fs.stat(permissionDir)).mode & 0o777).toBe(0o700);
        expect((await fs.stat(nestedPermissionFile)).mode & 0o777).not.toBe(0o700);
        const recursiveResult = await chmodWorkspaceEntry({
            workspacePath,
            targetPath: permissionDir,
            mode: '755',
            recursive: true
        });
        expect(recursiveResult.changedCount).toBe(2);
        expect((await fs.stat(permissionDir)).mode & 0o777).toBe(0o755);
        expect((await fs.stat(nestedPermissionFile)).mode & 0o777).toBe(0o755);
    });
    it('rejects recursive permission changes through symlink children', async () => {
        if (process.platform === 'win32') {
            return;
        }
        const outsideSecret = path.join(tempRoot, 'chmod-outside.txt');
        const symlinkDirectory = path.join(workspacePath, 'symlink-permission-dir');
        const symlinkPath = path.join(symlinkDirectory, 'outside-link.txt');
        await fs.writeFile(outsideSecret, 'secret', 'utf-8');
        await fs.mkdir(symlinkDirectory, { recursive: true });
        await fs.symlink(outsideSecret, symlinkPath);
        await expect(chmodWorkspaceEntry({
            workspacePath,
            targetPath: symlinkDirectory,
            mode: '755',
            recursive: true
        })).rejects.toThrow(/symbolic link/i);
    });
    it('rejects invalid workspace mutations', async () => {
        await expect(createWorkspaceEntry({
            workspacePath,
            targetPath: workspacePath,
            entryName: '../escape.txt',
            isDirectory: false
        })).rejects.toThrow(/plain file or directory name/);
        await expect(renameWorkspaceEntry({
            workspacePath,
            targetPath: workspacePath,
            newName: 'renamed-root'
        })).rejects.toThrow(/Workspace root cannot be modified/);
        await expect(deleteWorkspaceEntry({
            workspacePath,
            targetPath: workspacePath
        })).rejects.toThrow(/Workspace root cannot be modified/);
    });
    it('moves files and rejects moving directories into themselves', async () => {
        const moveSourceDir = path.join(workspacePath, 'move-source');
        const moveDestinationDir = path.join(workspacePath, 'move-destination');
        const fileToMove = path.join(moveSourceDir, 'move-me.txt');
        await fs.mkdir(moveSourceDir, { recursive: true });
        await fs.mkdir(moveDestinationDir, { recursive: true });
        await fs.writeFile(fileToMove, 'move me', 'utf-8');
        const result = await moveWorkspaceEntry({
            workspacePath,
            targetPath: fileToMove,
            destinationPath: moveDestinationDir,
        });
        const movedPath = path.join(moveDestinationDir, 'move-me.txt');
        expect(result.sourcePath).toBe(fileToMove.replace(/\\/g, '/'));
        expect(result.targetPath).toBe((await fs.realpath(movedPath)).replace(/\\/g, '/'));
        await expect(fs.access(fileToMove)).rejects.toThrow();
        await expect(fs.readFile(movedPath, 'utf-8')).resolves.toBe('move me');
        const childDestination = path.join(moveSourceDir, 'child');
        await fs.mkdir(childDestination, { recursive: true });
        await expect(moveWorkspaceEntry({
            workspacePath,
            targetPath: moveSourceDir,
            destinationPath: childDestination,
        })).rejects.toThrow(/itself or its descendant/);
    });
    it('rejects unsafe workspace move targets', async () => {
        const moveSafetyDir = path.join(workspacePath, 'move-safety');
        const moveSafetyFile = path.join(moveSafetyDir, 'safe.txt');
        const moveSafetyDestination = path.join(workspacePath, 'move-safety-dest');
        await fs.mkdir(moveSafetyDir, { recursive: true });
        await fs.mkdir(moveSafetyDestination, { recursive: true });
        await fs.writeFile(moveSafetyFile, 'safe', 'utf-8');
        await fs.writeFile(path.join(moveSafetyDestination, 'safe.txt'), 'exists', 'utf-8');
        await expect(moveWorkspaceEntry({
            workspacePath,
            targetPath: workspacePath,
            destinationPath: moveSafetyDestination,
        })).rejects.toThrow(/Workspace root cannot be modified/);
        await expect(moveWorkspaceEntry({
            workspacePath,
            targetPath: path.join(tempRoot, 'outside.txt'),
            destinationPath: moveSafetyDestination,
        })).rejects.toThrow(/outside workspace/);
        await expect(moveWorkspaceEntry({
            workspacePath,
            targetPath: moveSafetyFile,
            destinationPath: tempRoot,
        })).rejects.toThrow(/outside workspace/);
        await expect(moveWorkspaceEntry({
            workspacePath,
            targetPath: moveSafetyFile,
            destinationPath: moveSafetyDestination,
        })).rejects.toThrow(/Path already exists/);
    });
    it('uploads files to a selected workspace directory', async () => {
        const uploadSource = path.join(tempRoot, 'upload-source.txt');
        const uploadTargetDir = path.join(workspacePath, 'uploads');
        await fs.mkdir(uploadTargetDir, { recursive: true });
        await fs.writeFile(uploadSource, 'uploaded content', 'utf-8');
        const result = await uploadWorkspaceFiles({
            workspacePath,
            targetPath: uploadTargetDir,
            files: [{ originalname: '../safe-name.txt', path: uploadSource, size: 16 }]
        });
        const uploadedPath = path.join(uploadTargetDir, 'safe-name.txt');
        expect(result.files[0].targetPath).toBe((await fs.realpath(uploadedPath)).replace(/\\/g, '/'));
        await expect(fs.readFile(uploadedPath, 'utf-8')).resolves.toBe('uploaded content');
    });
    it('preserves safe relative paths when uploading folders', async () => {
        const firstSource = path.join(tempRoot, 'folder-upload-first.txt');
        const secondSource = path.join(tempRoot, 'folder-upload-second.txt');
        const uploadTargetDir = path.join(workspacePath, 'folder-uploads');
        await fs.mkdir(uploadTargetDir, { recursive: true });
        await fs.writeFile(firstSource, 'first nested file', 'utf-8');
        await fs.writeFile(secondSource, 'second nested file', 'utf-8');
        const result = await uploadWorkspaceFiles({
            workspacePath,
            targetPath: uploadTargetDir,
            files: [
                { originalname: 'first.txt', relativePath: 'project/src/first.txt', path: firstSource, size: 17 },
                { originalname: 'second.txt', relativePath: 'project/docs/second.txt', path: secondSource, size: 18 }
            ]
        });
        const firstUploadedPath = path.join(uploadTargetDir, 'project', 'src', 'first.txt');
        const secondUploadedPath = path.join(uploadTargetDir, 'project', 'docs', 'second.txt');
        expect(result.files.map((file) => file.targetPath)).toEqual([
            (await fs.realpath(firstUploadedPath)).replace(/\\/g, '/'),
            (await fs.realpath(secondUploadedPath)).replace(/\\/g, '/')
        ]);
        await expect(fs.readFile(firstUploadedPath, 'utf-8')).resolves.toBe('first nested file');
        await expect(fs.readFile(secondUploadedPath, 'utf-8')).resolves.toBe('second nested file');
    });
    it('rejects unsafe relative upload paths', async () => {
        const uploadSource = path.join(tempRoot, 'unsafe-folder-upload.txt');
        const uploadTargetDir = path.join(workspacePath, 'unsafe-folder-uploads');
        await fs.mkdir(uploadTargetDir, { recursive: true });
        await fs.writeFile(uploadSource, 'unsafe content', 'utf-8');
        await expect(uploadWorkspaceFiles({
            workspacePath,
            targetPath: uploadTargetDir,
            files: [{ originalname: 'safe.txt', relativePath: 'project/../escape.txt', path: uploadSource, size: 14 }]
        })).rejects.toThrow(/entryName is invalid/);
    });
    it('uploads binary exe files and lists them after upload', async () => {
        const uploadSource = path.join(tempRoot, 'demo-app-source.exe');
        const uploadTargetDir = path.join(workspacePath, 'bin');
        const exeBytes = Buffer.from([0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00, 0xff, 0x00]);
        await fs.mkdir(uploadTargetDir, { recursive: true });
        await fs.writeFile(uploadSource, exeBytes);
        const uploadResult = await uploadWorkspaceFiles({
            workspacePath,
            targetPath: uploadTargetDir,
            files: [{ originalname: 'demo-app.exe', path: uploadSource, size: exeBytes.length }]
        });
        const uploadedPath = path.join(uploadTargetDir, 'demo-app.exe');
        expect(uploadResult.files[0]).toMatchObject({
            fileName: 'demo-app.exe',
            size: exeBytes.length
        });
        await expect(fs.readFile(uploadedPath)).resolves.toEqual(exeBytes);
        const listedDirectory = await listWorkspaceDirectory({
            workspacePath,
            targetPath: uploadTargetDir
        });
        expect(listedDirectory.items.some((item) => item.name === 'demo-app.exe' && !item.isDirectory)).toBe(true);
    });
    it('decodes latin1-mojibake UTF-8 upload file names before saving', async () => {
        const uploadSource = path.join(tempRoot, 'springboot-source.zip');
        const uploadTargetDir = path.join(workspacePath, 'encoded-uploads');
        const originalName = 'springboot测试压缩包2.zip';
        const mojibakeName = Buffer.from(originalName, 'utf8').toString('latin1');
        await fs.mkdir(uploadTargetDir, { recursive: true });
        await fs.writeFile(uploadSource, 'zip bytes', 'utf-8');
        const uploadResult = await uploadWorkspaceFiles({
            workspacePath,
            targetPath: uploadTargetDir,
            files: [{ originalname: mojibakeName, path: uploadSource, size: 9 }]
        });
        const uploadedPath = path.join(uploadTargetDir, originalName);
        expect(uploadResult.files[0].fileName).toBe(originalName);
        await expect(fs.readFile(uploadedPath, 'utf-8')).resolves.toBe('zip bytes');
        const listedDirectory = await listWorkspaceDirectory({
            workspacePath,
            targetPath: uploadTargetDir
        });
        expect(listedDirectory.items.some((item) => item.name === originalName)).toBe(true);
    });
    it('extracts uploaded zip archives into a directory named after the archive', async () => {
        const uploadTargetDir = path.join(workspacePath, 'zip-auto-extract');
        const uploadSource = path.join(tempRoot, 'project-package-source.zip');
        await fs.mkdir(uploadTargetDir, { recursive: true });
        await new Promise((resolve, reject) => {
            const archive = new ZipArchive();
            const output = createWriteStream(uploadSource);
            output.on('close', resolve);
            archive.on('error', reject);
            archive.pipe(output);
            archive.append('first file', { name: 'a.txt' });
            archive.append('second file', { name: 'nested/b.txt' });
            archive.finalize().catch(reject);
        });
        const uploadResult = await uploadWorkspaceFiles({
            workspacePath,
            targetPath: uploadTargetDir,
            extractArchives: true,
            files: [{ originalname: 'project-package.zip', path: uploadSource, size: (await fs.stat(uploadSource)).size }]
        });
        const extractedRoot = path.join(uploadTargetDir, 'project-package');
        expect(uploadResult.files[0].extracted?.targetPath).toBe((await fs.realpath(extractedRoot)).replace(/\\/g, '/'));
        expect(uploadResult.files[0].extracted?.extractedEntries).toBe(2);
        await expect(fs.readFile(path.join(extractedRoot, 'a.txt'), 'utf-8')).resolves.toBe('first file');
        await expect(fs.readFile(path.join(extractedRoot, 'nested', 'b.txt'), 'utf-8')).resolves.toBe('second file');
        await expect(fs.access(path.join(uploadTargetDir, 'a.txt'))).rejects.toThrow();
    });
    it('extracts uploaded tar archives into a directory named after the archive', async () => {
        const uploadTargetDir = path.join(workspacePath, 'tar-auto-extract');
        const tarSourceDir = path.join(tempRoot, 'tar-source');
        const uploadSource = path.join(tempRoot, 'bundle-source.tar');
        await fs.mkdir(path.join(tarSourceDir, 'nested'), { recursive: true });
        await fs.mkdir(uploadTargetDir, { recursive: true });
        await fs.writeFile(path.join(tarSourceDir, 'root.txt'), 'root tar file', 'utf-8');
        await fs.writeFile(path.join(tarSourceDir, 'nested', 'child.txt'), 'child tar file', 'utf-8');
        await tar.c({ file: uploadSource, cwd: tarSourceDir }, ['root.txt', 'nested/child.txt']);
        const uploadResult = await uploadWorkspaceFiles({
            workspacePath,
            targetPath: uploadTargetDir,
            extractArchives: true,
            files: [{ originalname: 'bundle.tar', path: uploadSource, size: (await fs.stat(uploadSource)).size }]
        });
        const extractedRoot = path.join(uploadTargetDir, 'bundle');
        expect(uploadResult.files[0].extracted?.targetPath).toBe((await fs.realpath(extractedRoot)).replace(/\\/g, '/'));
        expect(uploadResult.files[0].extracted?.extractedEntries).toBe(2);
        await expect(fs.readFile(path.join(extractedRoot, 'root.txt'), 'utf-8')).resolves.toBe('root tar file');
        await expect(fs.readFile(path.join(extractedRoot, 'nested', 'child.txt'), 'utf-8')).resolves.toBe('child tar file');
        await expect(fs.access(path.join(uploadTargetDir, 'root.txt'))).rejects.toThrow();
    });
    it('does not expose outside target size for file symlinks in directory listings', async () => {
        const outsideFile = path.join(tempRoot, 'outside-secret.txt');
        const symlinkPath = path.join(workspacePath, 'outside-secret-link.txt');
        await fs.writeFile(outsideFile, 'outside-secret-content', 'utf-8');
        try {
            await fs.symlink(outsideFile, symlinkPath, 'file');
        }
        catch (error) {
            const errorCode = typeof error === 'object' && error !== null ? Reflect.get(error, 'code') : undefined;
            if (errorCode === 'EPERM' || errorCode === 'EACCES') {
                return;
            }
            throw error;
        }
        const result = await listWorkspaceDirectory({
            workspacePath,
            targetPath: workspacePath
        });
        const symlinkItem = result.items.find((item) => item.name === 'outside-secret-link.txt');
        expect(symlinkItem).toBeDefined();
        expect(symlinkItem?.size).toBeUndefined();
    });
    it('resolves file and directory download targets safely', async () => {
        const fileTarget = await resolveWorkspaceDownloadTarget({
            workspacePath,
            targetPath: nestedFile
        });
        expect(fileTarget.isDirectory).toBe(false);
        expect(fileTarget.fileName).toBe('index.ts');
        const directoryTarget = await resolveWorkspaceDownloadTarget({
            workspacePath,
            targetPath: nestedDir
        });
        expect(directoryTarget.isDirectory).toBe(true);
        expect(directoryTarget.fileName).toBe('src.zip');
    });
    it('streams a workspace directory as a zip archive', async () => {
        const zipPath = path.join(tempRoot, 'src.zip');
        await streamWorkspaceDirectoryZip(nestedDir, createWriteStream(zipPath));
        const stat = await fs.stat(zipPath);
        expect(stat.size).toBeGreaterThan(0);
    });
    it('extracts zip archives and rejects path traversal entries', async () => {
        const goodZip = path.join(workspacePath, 'archive.zip');
        const badZip = path.join(workspacePath, 'bad.zip');
        const extractDir = path.join(workspacePath, 'extracted');
        await new Promise((resolve, reject) => {
            const archive = new ZipArchive();
            const output = createWriteStream(goodZip);
            output.on('close', resolve);
            archive.on('error', reject);
            archive.pipe(output);
            archive.append('zip content', { name: 'nested/file.txt' });
            archive.finalize().catch(reject);
        });
        const extracted = await extractWorkspaceArchive({
            workspacePath,
            archivePath: goodZip,
            outputPath: extractDir
        });
        expect(extracted.extractedEntries).toBe(1);
        await expect(fs.readFile(path.join(extractDir, 'nested', 'file.txt'), 'utf-8')).resolves.toBe('zip content');
        await writeStoredZip(badZip, '../escape.txt', 'escape');
        await expect(extractWorkspaceArchive({
            workspacePath,
            archivePath: badZip,
            outputPath: extractDir
        })).rejects.toThrow(/Archive entry path is invalid|outside workspace/);
    });
    it('extracts GBK encoded zip entry names as Chinese paths', async () => {
        const gbkZip = path.join(workspacePath, 'gbk-archive.zip');
        const extractDir = path.join(workspacePath, 'gbk-extracted');
        const chineseDirectoryName = 'springboot067摄影师社区2';
        const gbkEntryNameBytes = Buffer.from([
            115, 112, 114, 105, 110, 103, 98, 111, 111, 116, 48, 54, 55,
            201, 227, 211, 176, 202, 166, 201, 231, 199, 248, 50,
            47, 114, 101, 97, 100, 109, 101, 46, 116, 120, 116
        ]);
        await writeStoredZipWithNameBytes(gbkZip, gbkEntryNameBytes, '中文目录内容');
        const extracted = await extractWorkspaceArchive({
            workspacePath,
            archivePath: gbkZip,
            outputPath: extractDir
        });
        expect(extracted.extractedEntries).toBe(1);
        await expect(fs.readFile(path.join(extractDir, chineseDirectoryName, 'readme.txt'), 'utf-8'))
            .resolves.toBe('中文目录内容');
    });
    it('returns docx preview metadata without parsing document contents', async () => {
        const docxPath = path.join(workspacePath, 'large-preview.docx');
        const largeImageLikePayload = Buffer.alloc(21 * 1024 * 1024, 1);
        await fs.writeFile(docxPath, largeImageLikePayload);
        const preview = await previewWorkspaceDocument({ workspacePath, filePath: docxPath });
        expect(preview.kind).toBe('html');
        expect(preview.content).toBe('');
        expect(preview.size).toBe(largeImageLikePayload.length);
        expect(preview.mtimeMs).toBeGreaterThan(0);
    });
    it('returns unsupported preview metadata for legacy doc files', async () => {
        const docPath = path.join(workspacePath, 'legacy.doc');
        await fs.writeFile(docPath, 'legacy word bytes', 'utf-8');
        const preview = await previewWorkspaceDocument({ workspacePath, filePath: docPath });
        expect(preview.kind).toBe('unsupported');
        expect(preview.message).toMatch(/暂不支持旧版 \.doc/);
    });
    it('rejects workspace access through symlink escapes', async () => {
        const outsideDir = path.join(tempRoot, 'outside');
        const symlinkPath = path.join(workspacePath, 'outside-link');
        await fs.mkdir(outsideDir, { recursive: true });
        await fs.writeFile(path.join(outsideDir, 'secret.txt'), 'secret', 'utf-8');
        try {
            await fs.symlink(outsideDir, symlinkPath, 'dir');
        }
        catch (error) {
            const errorCode = typeof error === 'object' && error !== null ? Reflect.get(error, 'code') : undefined;
            if (errorCode === 'EPERM') {
                return;
            }
            throw error;
        }
        await expect(readWorkspaceFile({
            workspacePath,
            filePath: path.join(symlinkPath, 'secret.txt')
        })).rejects.toThrow(/outside workspace/);
    });
    it('rejects tar archives with symbolic links', async () => {
        const archivePath = path.join(workspacePath, 'link.tar');
        const linkTarget = path.join(tempRoot, 'linked-target');
        const sourceLink = path.join(tempRoot, 'archive-link');
        await fs.mkdir(linkTarget, { recursive: true });
        try {
            await fs.symlink(linkTarget, sourceLink, 'dir');
        }
        catch (error) {
            const errorCode = typeof error === 'object' && error !== null ? Reflect.get(error, 'code') : undefined;
            if (errorCode === 'EPERM') {
                return;
            }
            throw error;
        }
        await tar.c({ file: archivePath, cwd: tempRoot }, ['archive-link']);
        await expect(extractWorkspaceArchive({
            workspacePath,
            archivePath,
            outputPath: workspacePath
        })).rejects.toThrow(/links are not supported/);
    });
    it('rejects tar archives with unsupported special entries', async () => {
        const archivePath = path.join(workspacePath, 'special-entry.tar');
        await writeTarHeaderOnlyEntry(archivePath, 'special-device', '3');
        await expect(extractWorkspaceArchive({
            workspacePath,
            archivePath,
            outputPath: workspacePath
        })).rejects.toThrow(/Unsupported archive entry type/);
    });
});
