import { lstat, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; let mockDataRoot = ""; vi.mock("@/lib/server/modules/files/stars-repository", () => ({ isPathStarredInDb: vi.fn(async () => false), listStarredPathsFromDb: vi.fn(async () => []), setPathStarredInDb: vi.fn(async () => undefined), })); vi.mock("@/lib/server/modules/files/trash-repository", () => ({ listTrashEntriesFromDb: vi.fn(async () => []), })); vi.mock("@/lib/server/modules/files/path-resolver", () => { class FilesPathError extends Error { code: string; statusCode: number; constructor( message: string, options?: { code?: string; statusCode?: number; }, ) { super(message); this.code = options?.code ?? "internal_error"; this.statusCode = options?.statusCode ?? 500; } } function ensureWithinRoot(rootPath: string, absolutePath: string) { const relative = path.relative(rootPath, absolutePath); const within = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); if (!within) { throw new FilesPathError("Path escapes root", { code: "path_outside_root", statusCode: 400, }); } } return { FilesPathError, resolvePathWithinFilesRoot: vi.fn(async (input: { inputPath?: string; allowEmpty?: boolean; allowHiddenSegments?: boolean; allowMissingLeaf?: boolean; requiredPrefix?: string; }) => { const cleaned = (input.inputPath ?? "").trim().replaceAll("\\", "/"); if (!cleaned) { if (!input.allowEmpty) { throw new FilesPathError("Invalid path", { code: "invalid_path", statusCode: 400, }); } } const normalized = cleaned ? path.posix.normalize(cleaned) : ""; if (cleaned.startsWith("/") || cleaned.includes("\0")) { throw new FilesPathError("Invalid path", { code: "invalid_path", statusCode: 400, }); } if (normalized === ".." || normalized.startsWith("../")) { throw new FilesPathError("Path escapes root", { code: "path_outside_root", statusCode: 400, }); } const relativePath = normalized === "." ? "" : normalized; const segments = relativePath ? relativePath.split("/") : []; if ( input.requiredPrefix && relativePath !== input.requiredPrefix && !relativePath.startsWith(`${input.requiredPrefix}/`) ) { throw new FilesPathError("Invalid path", { code: "invalid_path", statusCode: 400, }); } if (!input.allowHiddenSegments && segments.some((segment) => segment.startsWith("."))) { throw new FilesPathError("Hidden files are not allowed", { code: "hidden_blocked", statusCode: 403, }); } let current = mockDataRoot; for (const segment of segments) { current = path.join(current, segment); try { const info = await lstat(current); if (info.isSymbolicLink()) { throw new FilesPathError("Symlinks are not allowed", { code: "symlink_blocked", statusCode: 403, }); } } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError?.code === "ENOENT") { if (input.allowMissingLeaf) break; throw new FilesPathError("File or directory not found", { code: "not_found", statusCode: 404, }); } throw error; } } const absolutePath = path.resolve(mockDataRoot, relativePath); ensureWithinRoot(mockDataRoot, absolutePath); try { await lstat(absolutePath); return { rootPath: mockDataRoot, relativePath, absolutePath, segments, exists: true, }; } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError?.code !== "ENOENT") throw error; } if (!input.allowMissingLeaf) { throw new FilesPathError("File or directory not found", { code: "not_found", statusCode: 404, }); } return { rootPath: mockDataRoot, relativePath, absolutePath, segments, exists: false, }; }), }; }); import { createDirectoryEntry, createFileEntry, MAX_TEXT_READ_BYTES, listDirectory, pasteEntry, readFileForViewer, searchFiles, writeTextFile, } from "@/lib/server/modules/files/service"; describe("files service", () => { beforeEach(async () => { mockDataRoot = await mkdtemp(path.join(os.tmpdir(), "home-server-files-")); }); afterEach(async () => { if (mockDataRoot) { await rm(mockDataRoot, { recursive: true, force: true }); } }); it("lists folders first and hides hidden and symlink entries by default", async () => { await mkdir(path.join(mockDataRoot, "ZetaFolder"), { recursive: true }); await writeFile(path.join(mockDataRoot, "alpha.txt"), "alpha", "utf8"); await writeFile(path.join(mockDataRoot, ".hidden.txt"), "hidden", "utf8"); await symlink(path.join(mockDataRoot, "alpha.txt"), path.join(mockDataRoot, "alpha-link")); const result = await listDirectory({ path: "", }); expect(result.root).toBe(mockDataRoot); expect(result.cwd).toBe(""); expect(result.entries.map((entry) => entry.name)).toEqual([ "ZetaFolder", "alpha.txt", ]); expect(result.entries[0]?.type).toBe("folder"); expect(result.entries[1]?.type).toBe("file"); }); it("blocks traversal paths outside root", async () => { await expect( listDirectory({ path: "../etc", }), ).rejects.toMatchObject({ code: "path_outside_root", }); }); it("blocks symlink traversal", async () => { await mkdir(path.join(mockDataRoot, "safe"), { recursive: true }); await symlink(path.join(mockDataRoot, "safe"), path.join(mockDataRoot, "safe-link")); await expect( listDirectory({ path: "safe-link", }), ).rejects.toMatchObject({ code: "symlink_blocked", }); }); it("classifies read mode for text, image, and pdf files", async () => { await writeFile(path.join(mockDataRoot, "config.yaml"), "name: home", "utf8"); await writeFile(path.join(mockDataRoot, "photo.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); await writeFile(path.join(mockDataRoot, "manual.pdf"), Buffer.from("%PDF-1.7", "utf8")); const textResult = await readFileForViewer({ path: "config.yaml", }); const imageResult = await readFileForViewer({ path: "photo.png", }); const pdfResult = await readFileForViewer({ path: "manual.pdf", }); expect(textResult.mode).toBe("text"); expect(textResult.content).toBe("name: home"); expect(imageResult.mode).toBe("image"); expect(imageResult.content).toBeNull(); expect(pdfResult.mode).toBe("pdf"); expect(pdfResult.content).toBeNull(); }); it("returns too_large mode for oversized text files", async () => { const largeText = "a".repeat(MAX_TEXT_READ_BYTES + 1); await writeFile(path.join(mockDataRoot, "large.txt"), largeText, "utf8"); const result = await readFileForViewer({ path: "large.txt", }); expect(result.mode).toBe("too_large"); expect(result.content).toBeNull(); expect(result.sizeBytes).toBe(MAX_TEXT_READ_BYTES + 1); }); it("writes text files and enforces optimistic mtime conflict", async () => { const filePath = path.join(mockDataRoot, "notes.txt"); await writeFile(filePath, "before", "utf8"); const opened = await readFileForViewer({ path: "notes.txt", }); const saved = await writeTextFile({ path: "notes.txt", content: "after", expectedMtimeMs: opened.mtimeMs, }); expect(saved.path).toBe("notes.txt"); expect(await readFile(filePath, "utf8")).toBe("after"); await writeFile(filePath, "changed-outside", "utf8"); await expect( writeTextFile({ path: "notes.txt", content: "conflict-write", expectedMtimeMs: opened.mtimeMs, }), ).rejects.toMatchObject({ code: "write_conflict", }); }); it("shows hidden entries when listing Trash", async () => { await mkdir(path.join(mockDataRoot, "Trash"), { recursive: true }); await writeFile(path.join(mockDataRoot, "Trash", ".env"), "x=1", "utf8"); await writeFile(path.join(mockDataRoot, "Trash", "notes.txt"), "hello", "utf8"); const result = await listDirectory({ path: "Trash", }); expect(result.entries.map((entry) => entry.name)).toEqual([ ".env", "notes.txt", ]); }); it("shows hidden entries when includeHidden is true", async () => { await writeFile(path.join(mockDataRoot, ".env"), "x=1", "utf8"); await writeFile(path.join(mockDataRoot, "notes.txt"), "hello", "utf8"); const result = await listDirectory({ path: "", includeHidden: true, }); expect(result.entries.map((entry) => entry.name)).toEqual([ ".env", "notes.txt", ]); }); it("creates folder and file entries in the requested directory", async () => { await mkdir(path.join(mockDataRoot, "Documents"), { recursive: true }); const createdFolder = await createDirectoryEntry({ parentPath: "Documents", name: "Projects", }); const createdFile = await createFileEntry({ parentPath: "Documents", name: "todo.txt", }); expect(createdFolder.path).toBe("Documents/Projects"); expect(createdFolder.type).toBe("folder"); expect(createdFile.path).toBe("Documents/todo.txt"); expect(createdFile.type).toBe("file"); }); it("copies entries into destination directory", async () => { await mkdir(path.join(mockDataRoot, "Documents"), { recursive: true }); await mkdir(path.join(mockDataRoot, "Media"), { recursive: true }); await writeFile(path.join(mockDataRoot, "Media", "photo.jpg"), "binary", "utf8"); const copied = await pasteEntry({ sourcePath: "Media/photo.jpg", destinationPath: "Documents", operation: "copy", }); expect(copied.path).toBe("Documents/photo.jpg"); expect(await readFile(path.join(mockDataRoot, "Media", "photo.jpg"), "utf8")).toBe("binary"); expect(await readFile(path.join(mockDataRoot, "Documents", "photo.jpg"), "utf8")).toBe("binary"); }); it("moves entries into destination directory", async () => { await mkdir(path.join(mockDataRoot, "Documents"), { recursive: true }); await mkdir(path.join(mockDataRoot, "Media"), { recursive: true }); await writeFile(path.join(mockDataRoot, "Media", "move-me.txt"), "m", "utf8"); const moved = await pasteEntry({ sourcePath: "Media/move-me.txt", destinationPath: "Documents", operation: "move", }); expect(moved.path).toBe("Documents/move-me.txt"); await expect( readFile(path.join(mockDataRoot, "Media", "move-me.txt"), "utf8"), ).rejects.toMatchObject({ code: "ENOENT", }); expect(await readFile(path.join(mockDataRoot, "Documents", "move-me.txt"), "utf8")).toBe("m"); }); it("rejects paste when destination exists", async () => { await mkdir(path.join(mockDataRoot, "Documents"), { recursive: true }); await mkdir(path.join(mockDataRoot, "Media"), { recursive: true }); await writeFile(path.join(mockDataRoot, "Media", "photo.jpg"), "src", "utf8"); await writeFile(path.join(mockDataRoot, "Documents", "photo.jpg"), "dst", "utf8"); await expect( pasteEntry({ sourcePath: "Media/photo.jpg", destinationPath: "Documents", operation: "copy", }), ).rejects.toMatchObject({ code: "destination_exists", }); }); describe("searchFiles", () => { it("returns matching entries by name", async () => { await mkdir(path.join(mockDataRoot, "Documents"), { recursive: true }); await writeFile(path.join(mockDataRoot, "Documents", "notes.md"), "x"); await writeFile(path.join(mockDataRoot, "Documents", "ignore.txt"), "x"); const result = await searchFiles({ query: "notes" }); expect(result.entries.map((e) => e.name)).toEqual(["notes.md"]); expect(result.truncated).toBeUndefined(); }); it("flags truncated when the deadline expires before walk completes", async () => { await mkdir(path.join(mockDataRoot, "Deep"), { recursive: true }); // A few nested directories with files; the per-subdir setImmediate // yield means a 0-ms deadline trips before we finish the walk. for (let i = 0; i < 5; i += 1) { const dir = path.join(mockDataRoot, "Deep", `level-${i}`); await mkdir(dir, { recursive: true }); await writeFile(path.join(dir, "match-target.txt"), "x"); } const result = await searchFiles({ query: "match-target", timeoutMs: 1_000, }); expect(result.entries.length).toBeGreaterThan(0); expect(result.truncated).toBeUndefined(); const truncatedResult = await searchFiles({ query: "match-target", timeoutMs: -1_000, // deadline already in the past — trips on first walk }); expect(truncatedResult.truncated).toBe(true); }); }); });