deno_std icon indicating copy to clipboard operation
deno_std copied to clipboard

Fixing racy ensureDirSync broke ensureFileSync for tight permissions

Open egfx-notifications opened this issue 1 year ago • 0 comments

I just updated our used version of deno std and the changes made to ensureDir and ensureDirSync in https://github.com/denoland/deno_std/pull/3242 break our unit tests. While I am in favor of fixing the race condition, I feel like one side effect has not been considered that could be easily solved (so I think, see proposal below).

The changes introduced in https://github.com/denoland/deno_std/pull/3242 make it so that ensureFile and ensureFileSync no longer work with write permissions limited to specific files, they would require write permissions to their parent directories now due to their internal usage of ensureDir and ensureDirSync

This can be easily tested with this example code (first two tests, rest is my proposed fix):

import { ensureFileSync as ensureFileSyncWithOldEnsureDirSync } from "https://deno.land/[email protected]/fs/mod.ts";
import {
  ensureDirSync,
  ensureFileSync as ensureFileSyncWithNewEnsureDirSync,
} from "https://deno.land/[email protected]/fs/mod.ts";
import {
  getFileInfoType,
  toPathString,
} from "https://deno.land/[email protected]/fs/_util.ts";
import * as path from "https://deno.land/[email protected]/path/mod.ts";

const testDirectory = "./tests";
const testFilepath = testDirectory + "/allowed-file";

ensureDirSync(testDirectory);

Deno.test(
  {
    name: "Works with old ensureDirSync",
    permissions: {
      read: [testDirectory],
      write: [testFilepath],
    },
  },
  () => {
    try {
      Deno.removeSync(testFilepath);
    } catch (err) {
      if (!(err instanceof Deno.errors.NotFound)) throw err;
    }
    ensureFileSyncWithOldEnsureDirSync(testFilepath);
  },
);

Deno.test(
  {
    name: "Does not work with new ensureDirSync",
    permissions: {
      read: [testDirectory],
      write: [testFilepath],
    },
  },
  () => {
    try {
      Deno.removeSync(testFilepath);
    } catch (err) {
      if (!(err instanceof Deno.errors.NotFound)) throw err;
    }
    ensureFileSyncWithNewEnsureDirSync(testFilepath);
  },
);

Deno.test(
  {
    name: "Would work with proposed ensureDirSync (but is it racy?)",
    permissions: {
      read: [testDirectory],
      write: [testFilepath],
    },
  },
  () => {
    try {
      Deno.removeSync(testFilepath);
    } catch (err) {
      if (!(err instanceof Deno.errors.NotFound)) throw err;
    }
    ensureFileSyncWithProposedEnsureDirSync(testFilepath);
  },
);

Deno.test(
  {
    name: "Proposed ensureDirSync also still works for directories",
    permissions: {
      read: [testDirectory],
      write: [testDirectory],
    },
  },
  () => {
    try {
      Deno.removeSync(testDirectory, { recursive: true });
    } catch (err) {
      if (!(err instanceof Deno.errors.NotFound)) throw err;
    }
    ProposedEnsureDirSync(testDirectory);
  },
);

Deno.test(
  {
    name:
      "Proposed ensureDirSync also still works for directories - throws on missing permission",
    permissions: {
      read: [testDirectory],
      write: [testDirectory],
    },
  },
  () => {
    try {
      Deno.removeSync(testDirectory, { recursive: true });
    } catch (err) {
      if (!(err instanceof Deno.errors.NotFound)) throw err;
    }
    Deno.permissions.revokeSync({ name: "write", path: testDirectory });
    ProposedEnsureDirSync(testDirectory);
  },
);

function ProposedEnsureDirSync(dir: string | URL) {
  try {
    Deno.mkdirSync(dir, { recursive: true });
  } catch (mkdirErr) {
    if (
      !(mkdirErr instanceof Deno.errors.AlreadyExists ||
        mkdirErr instanceof Deno.errors.PermissionDenied)
    ) {
      throw mkdirErr;
    }

    try {
      const fileInfo = Deno.lstatSync(dir);
      if (!fileInfo.isDirectory) {
        throw new Error(
          `Ensure path exists, expected 'dir', got '${
            getFileInfoType(fileInfo)
          }'`,
        );
      }
    } catch (lstatErr) {
      if (
        mkdirErr instanceof Deno.errors.PermissionDenied &&
        lstatErr instanceof Deno.errors.NotFound
      ) {
        throw mkdirErr;
      } else {
        throw lstatErr;
      }
    }
  }
}

// ensureFileSync from 0.185.0, just using the ensureDirSync proposed here
function ensureFileSyncWithProposedEnsureDirSync(filePath: string | URL) {
  try {
    // if file exists
    const stat = Deno.lstatSync(filePath);
    if (!stat.isFile) {
      throw new Error(
        `Ensure path exists, expected 'file', got '${getFileInfoType(stat)}'`,
      );
    }
  } catch (err) {
    // if file not exists
    if (err instanceof Deno.errors.NotFound) {
      // ensure dir exists
      ProposedEnsureDirSync(path.dirname(toPathString(filePath)));
      // create file
      Deno.writeFileSync(filePath, new Uint8Array());
      return;
    }
    throw err;
  }
}

Output

$ deno test --allow-read --allow-write minimal.test.ts
running 5 tests from ./minimal.test.ts
Works with old ensureDirSync ... ok (13ms)
Does not work with new ensureDirSync ... FAILED (5ms)
Would work with proposed ensureDirSync (but is it racy?) ... ok (5ms)
Proposed ensureDirSync also still works for directories ... ok (5ms)
Proposed ensureDirSync also still works for directories - throws on missing permission ... FAILED (5ms)

 ERRORS 

Does not work with new ensureDirSync => ./minimal.test.ts:35:6
error: PermissionDenied: Requires write access to "./tests", run again with the --allow-write flag
    Deno.mkdirSync(dir, { recursive: true });
         ^
    at Object.mkdirSync (ext:deno_fs/30_fs.js:118:7)
    at ensureDirSync (https://deno.land/[email protected]/fs/ensure_dir.ts:49:10)
    at ensureFileSync (https://deno.land/[email protected]/fs/ensure_file.ts:72:7)
    at file:///home/egf0s02d/projects/minion/minimal.test.ts:49:5

Proposed ensureDirSync also still works for directories - throws on missing permission => ./minimal.test.ts:89:6
error: PermissionDenied: Requires write access to "./tests", run again with the --allow-write flag
    Deno.mkdirSync(dir, { recursive: true });
         ^
    at Object.mkdirSync (ext:deno_fs/30_fs.js:118:7)
    at ProposedEnsureDirSync (file:///home/egf0s02d/projects/minion/minimal.test.ts:111:10)
    at file:///home/egf0s02d/projects/minion/minimal.test.ts:105:5

 FAILURES 

Does not work with new ensureDirSync => ./minimal.test.ts:35:6
Proposed ensureDirSync also still works for directories - throws on missing permission => ./minimal.test.ts:89:6

FAILED | 3 passed | 2 failed (82ms)

error: Test failed

As you can see I already included a proposed fix, that is changing the implementation from this https://github.com/denoland/deno_std/blob/aa57bf337bbf5e061752b92164b003a60e728951/fs/ensure_dir.ts#L47-L64

to this

export function ensureDirSync(dir: string | URL) {
  try {
    Deno.mkdirSync(dir, { recursive: true });
  } catch (mkdirErr) {
    if (
      !(mkdirErr instanceof Deno.errors.AlreadyExists ||
        mkdirErr instanceof Deno.errors.PermissionDenied)
    ) {
      throw mkdirErr;
    }

    try {
      const fileInfo = Deno.lstatSync(dir);
      if (!fileInfo.isDirectory) {
        throw new Error(
          `Ensure path exists, expected 'dir', got '${
            getFileInfoType(fileInfo)
          }'`,
        );
      }
    } catch (lstatErr) {
      if (
        mkdirErr instanceof Deno.errors.PermissionDenied &&
        lstatErr instanceof Deno.errors.NotFound
      ) {
        throw mkdirErr;
      } else {
        throw lstatErr;
      }
    }
  }
}

so if the directory creation fails due to missing write privileges, but it already exists, then we are fine, if it does not exist we just rethrow the original error.

This being a friday evening fix, there may be circumstances I have not considered yet, so I'll just leave that here to get some input.

  • OS: Rocky Linux 8 + 9
  • deno version: 1.32.5
  • std version: 0.179.0+

egfx-notifications avatar Apr 28 '23 16:04 egfx-notifications