Node.js path 与 fs 练习参考答案(14~24)

对应题目:path-fs-exercises.md

答案分册:1~13 题 · 25~34 题

以下示例基于 Node.js 22+、TypeScript 和 CommonJS 编译输出。

共用导入

import * as path from "node:path";
import { randomUUID } from "node:crypto";
import { mkdir, readdir, stat } from "node:fs/promises";

function isErrno(error: unknown): error is NodeJS.ErrnoException {
  return error instanceof Error && "code" in error;
}

14. 列出目录中的图片

const imageExtensions = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);

async function listImages(directory: string): Promise<string[]> {
  const entries = await readdir(directory, { withFileTypes: true });

  return entries
    .filter((entry) => {
      return entry.isFile()
        && imageExtensions.has(path.extname(entry.name).toLowerCase());
    })
    .map((entry) => entry.name)
    .sort((left, right) => left.localeCompare(right));
}

withFileTypes 返回 Dirent,能够在多数情况下避免为每个条目额外 stat。符号链接不会被当作普通文件。

15. 递归列出所有文件

interface WalkFailure {
  directory: string;
  error: unknown;
}

interface WalkResult {
  files: string[];
  failures: WalkFailure[];
}

async function listFilesRecursively(rootInput: string): Promise<WalkResult> {
  const root = path.resolve(rootInput);
  const files: string[] = [];
  const failures: WalkFailure[] = [];

  async function visit(directory: string): Promise<void> {
    let entries;

    try {
      entries = await readdir(directory, { withFileTypes: true });
    } catch (error: unknown) {
      failures.push({ directory, error });
      return;
    }

    for (const entry of entries) {
      const absolutePath = path.join(directory, entry.name);

      if (entry.isDirectory()) {
        await visit(absolutePath);
      } else if (entry.isFile()) {
        files.push(path.relative(root, absolutePath));
      }
    }
  }

  await visit(root);
  files.sort((left, right) => left.localeCompare(right));
  return { files, failures };
}

这里选择跳过无法读取的子目录并明确记录失败。若业务要求结果必须完整,应在出现 failures 时让整个任务失败。默认不跟随符号链接,避免循环和目录越界。

16. 查找最近修改的日志

interface LatestLog {
  filePath: string;
  modifiedAt: Date;
  modifiedAtMs: number;
}

async function findLatestLog(directory: string): Promise<LatestLog | undefined> {
  const entries = await readdir(directory, { withFileTypes: true });
  let latest: LatestLog | undefined;

  for (const entry of entries) {
    if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== ".log") {
      continue;
    }

    const filePath = path.join(directory, entry.name);
    const information = await stat(filePath);

    if (latest === undefined || information.mtimeMs > latest.modifiedAtMs) {
      latest = {
        filePath,
        modifiedAt: information.mtime,
        modifiedAtMs: information.mtimeMs,
      };
    }
  }

  return latest;
}

目录为空或没有 log 文件时自然返回 undefined。

17. 统计目录大小

async function calculateDirectorySize(directory: string): Promise<number> {
  let totalBytes = 0;
  const entries = await readdir(directory, { withFileTypes: true });

  for (const entry of entries) {
    const target = path.join(directory, entry.name);

    if (entry.isDirectory()) {
      totalBytes += await calculateDirectorySize(target);
    } else if (entry.isFile()) {
      totalBytes += (await stat(target)).size;
    }
  }

  return totalBytes;
}

返回单位是字节。答案不跟随符号链接;跟随链接可能重复统计、越出根目录或形成循环。超大目录还应使用第 24 题的有限并发或迭代遍历。

18. 查找超过 30 天的日志

async function findExpiredLogs(
  rootInput: string,
  inactiveDays = 30,
): Promise<string[]> {
  const root = path.resolve(rootInput);
  const threshold = Date.now() - inactiveDays * 24 * 60 * 60 * 1000;
  const { files, failures } = await listFilesRecursively(root);

  if (failures.length > 0) {
    throw new AggregateError(
      failures.map((item) => item.error),
      "部分目录无法读取,不能保证过期日志清单完整",
    );
  }

  const expired: string[] = [];

  for (const relativePath of files) {
    if (path.extname(relativePath).toLowerCase() !== ".log") continue;

    const absolutePath = path.join(root, relativePath);
    if ((await stat(absolutePath)).mtimeMs < threshold) {
      expired.push(absolutePath);
    }
  }

  return expired;
}

查找和删除必须分离。mtime 是内容修改时间,并非可靠的业务创建时间;真正清理前还要排除活跃任务。

19. 防止目录穿越

function isInsideDirectory(root: string, candidate: string): boolean {
  const relativePath = path.relative(root, candidate);

  return relativePath !== ""
    && relativePath !== ".."
    && !relativePath.startsWith(".." + path.sep)
    && !path.isAbsolute(relativePath);
}

function resolveDownloadPath(userInput: string): string {
  const root = path.resolve(process.cwd(), "public", "downloads");

  // 在 POSIX 上也把客户端传来的反斜杠视为分隔符。
  const portableInput = userInput.replaceAll("\\", "/");
  const candidate = path.resolve(root, portableInput);

  if (!isInsideDirectory(root, candidate)) {
    throw new Error("请求路径越出下载目录");
  }

  return candidate;
}

normalize 只折叠语法,不建立允许目录边界。以上仍不能完全防范符号链接和“检查后被替换”的 TOCTOU;生产接口优先使用文件 ID 到服务端路径的映射,并在打开文件时继续控制链接策略。

20. 生成安全上传文件名

const windowsReservedName =
  /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;

interface UploadNames {
  storedName: string;
  displayName: string;
}

function createUploadNames(originalName: string): UploadNames {
  // 连续使用两种 basename,处理两种分隔符。
  const baseName = path.win32.basename(path.posix.basename(originalName));
  const cleaned = baseName
    .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
    .replace(/[. ]+$/g, "")
    .slice(0, 150);

  const displayName =
    cleaned.length === 0 || windowsReservedName.test(cleaned)
      ? "file"
      : cleaned;

  const extension = path.extname(displayName)
    .toLowerCase()
    .replace(/[^.a-z0-9]/g, "");

  return {
    storedName: randomUUID() + extension,
    displayName,
  };
}

磁盘名由服务端生成,可避免冲突、保留名和危险字符。displayName 作为元数据保存,下载时再由 Content-Disposition 的安全编码逻辑使用。扩展名不是内容类型证明。

21. 限制可读取文件类型

const allowedReadableExtensions = new Set([".txt", ".json"]);

function resolveReadableFile(rootInput: string, userInput: string): string {
  const root = path.resolve(rootInput);
  const candidate = path.resolve(
    root,
    userInput.replaceAll("\\", "/"),
  );

  if (!isInsideDirectory(root, candidate)) {
    throw new Error("路径越界");
  }

  const extension = path.extname(candidate).toLowerCase();
  if (!allowedReadableExtensions.has(extension)) {
    throw new Error("文件类型不允许");
  }

  return candidate;
}

report.json.exe 的最终扩展名是 exe;包含 …/ 的输入要先解析最终路径。endsWith 仍不能替代目录边界、普通文件检查、授权和内容校验。

22. 创建用户专属目录

function validateUserId(userId: string): string {
  if (!/^[1-9]\d{0,18}$/.test(userId)) {
    throw new Error("用户 ID 必须是 1~19 位正整数");
  }

  return userId;
}

function getUserDirectory(userId: string): string {
  const root = path.resolve("uploads", "users");
  const target = path.join(root, validateUserId(userId));

  if (!isInsideDirectory(root, target)) {
    throw new Error("用户目录越界");
  }

  return target;
}

async function ensureUserDirectory(userId: string): Promise<string> {
  const directory = getUserDirectory(userId);
  await mkdir(directory, { recursive: true });
  return directory;
}

验证、路径生成和实际创建被拆成独立职责,便于单元测试。

23. 三种并发读取方式

import { readFile } from "node:fs/promises";

const configFiles = [
  "config/app.json",
  "config/database.json",
  "config/logger.json",
];

async function readSerially(): Promise<string[]> {
  const result: string[] = [];

  for (const file of configFiles) {
    result.push(await readFile(file, "utf8"));
  }

  return result;
}

function readConcurrently(): Promise<string[]> {
  return Promise.all(
    configFiles.map((file) => readFile(file, "utf8")),
  );
}

function readAndCollectAll(): Promise<PromiseSettledResult<string>[]> {
  return Promise.allSettled(
    configFiles.map((file) => readFile(file, "utf8")),
  );
}

配置缺一不可时通常使用 Promise.all;诊断或批处理需要完整结果时用 allSettled。

24. 最多同时处理五个文件

interface FileProcessFailure {
  file: string;
  error: unknown;
}

async function processWithLimit(
  files: string[],
  concurrency = 5,
): Promise<FileProcessFailure[]> {
  if (!Number.isInteger(concurrency) || concurrency < 1) {
    throw new Error("concurrency 必须是正整数");
  }

  let cursor = 0;
  const failures: FileProcessFailure[] = [];

  async function worker(): Promise<void> {
    while (true) {
      const index = cursor;
      cursor += 1;

      if (index >= files.length) return;
      const file = files[index]!;

      try {
        await processFile(file);
      } catch (error: unknown) {
        failures.push({ file, error });
      }
    }
  }

  const workerCount = Math.min(concurrency, files.length);
  await Promise.all(
    Array.from({ length: workerCount }, () => worker()),
  );

  return failures;
}

答案选择“记录失败后继续”。JavaScript 同步地领取 cursor,不会在领取过程之间发生 await 交错。无限 Promise.all 可能耗尽文件描述符、内存和磁盘 I/O,并拖慢同机其他服务。