diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a6b1f3f..fb0b12af7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +# [4.12.0](https://github.com/streamich/memfs/compare/v4.11.2...v4.12.0) (2024-09-19) + + +### Features + +* improve permissions support ([#1059](https://github.com/streamich/memfs/issues/1059)) ([575a76b](https://github.com/streamich/memfs/commit/575a76b5f538f65ca5b8813073bc783f3d408a1b)) + +## [4.11.2](https://github.com/streamich/memfs/compare/v4.11.1...v4.11.2) (2024-09-17) + + +### Bug Fixes + +* add `parentPath` to `Dirent` ([#1058](https://github.com/streamich/memfs/issues/1058)) ([9156c84](https://github.com/streamich/memfs/commit/9156c8466b530ad985b462890c7164dfeeaf472f)), closes [#735](https://github.com/streamich/memfs/issues/735) [#735](https://github.com/streamich/memfs/issues/735) + ## [4.11.1](https://github.com/streamich/memfs/compare/v4.11.0...v4.11.1) (2024-08-01) diff --git a/docs/snapshot/index.md b/docs/snapshot/index.md index e860d9858..d9541686e 100644 --- a/docs/snapshot/index.md +++ b/docs/snapshot/index.md @@ -30,15 +30,15 @@ import * as snapshot from 'memfs/lib/snapshot'; You can convert any folder of an `fs`-like file system into a POJO snapshot. ```ts -const snap = snapshot.toSnapshotSync({ fs, dir }); -const snap = await snapshot.toSnapshot({ fs: fs.promises, dir }); +const snap = snapshot.toSnapshotSync({ fs, path }); +const snap = await snapshot.toSnapshot({ fs: fs.promises, path }); ``` Then import it back from snapshot. ```ts -snapshot.fromSnapshotSync(snap, { fs, dir }); -await snapshot.fromSnapshot(snap, { fs: fs.promises, dir }); +snapshot.fromSnapshotSync(snap, { fs, path }); +await snapshot.fromSnapshot(snap, { fs: fs.promises, path }); ``` ## Binary snapshot @@ -47,15 +47,15 @@ Binary snapshots are encoded as CBOR `Uint8Array` buffers. You can convert any folder of an `fs`-like file system into a `Uint8Array` snapshot. ```ts -const uint8 = snapshot.toBinarySnapshotSync({ fs, dir }); -const uint8 = await snapshot.toBinarySnapshot({ fs: fs.promises, dir }); +const uint8 = snapshot.toBinarySnapshotSync({ fs, path }); +const uint8 = await snapshot.toBinarySnapshot({ fs: fs.promises, path }); ``` Then import it back from `Uint8Array` snapshot. ```ts -snapshot.fromBinarySnapshotSync(uint8, { fs, dir }); -await snapshot.fromBinarySnapshot(uint8, { fs: fs.promises, dir }); +snapshot.fromBinarySnapshotSync(uint8, { fs, path }); +await snapshot.fromBinarySnapshot(uint8, { fs: fs.promises, path }); ``` ## JSON snapshot @@ -67,15 +67,15 @@ data is encoded as Base64 data URL strings. The resulting JSON is returned as You can convert any folder of an `fs`-like file system into a `Uint8Array` snapshot. ```ts -const uint8 = snapshot.toJsonSnapshotSync({ fs, dir }); -const uint8 = await snapshot.toJsonSnapshot({ fs: fs.promises, dir }); +const uint8 = snapshot.toJsonSnapshotSync({ fs, path }); +const uint8 = await snapshot.toJsonSnapshot({ fs: fs.promises, path }); ``` Then import it back from `Uint8Array` snapshot. ```ts -snapshot.fromJsonSnapshotSync(uint8, { fs, dir }); -await snapshot.fromJsonSnapshot(uint8, { fs: fs.promises, dir }); +snapshot.fromJsonSnapshotSync(uint8, { fs, path }); +await snapshot.fromJsonSnapshot(uint8, { fs: fs.promises, path }); ``` ## Encoding format diff --git a/package.json b/package.json index 310b64cbe..72cd255b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "memfs", - "version": "4.11.1", + "version": "4.12.0", "description": "In-memory file-system with Node's fs API.", "keywords": [ "fs", diff --git a/src/Dirent.ts b/src/Dirent.ts index 32cafd514..237598f11 100644 --- a/src/Dirent.ts +++ b/src/Dirent.ts @@ -16,12 +16,14 @@ export class Dirent implements IDirent { dirent.name = strToEncoding(link.getName(), encoding); dirent.mode = mode; dirent.path = link.getParentPath(); + dirent.parentPath = dirent.path; return dirent; } name: TDataOut = ''; path = ''; + parentPath = ''; private mode: number = 0; private _checkModeProperty(property: number): boolean { diff --git a/src/__tests__/promises.test.ts b/src/__tests__/promises.test.ts index ffe82ccb2..6a768b677 100644 --- a/src/__tests__/promises.test.ts +++ b/src/__tests__/promises.test.ts @@ -38,18 +38,22 @@ describe('Promises API', () => { }); }); describe('chmod(mode)', () => { - const vol = new Volume(); - const { promises } = vol; - vol.fromJSON({ - '/foo': 'bar', + let vol; + beforeEach(() => { + vol = new Volume(); + vol.fromJSON({ + '/foo': 'bar', + }); }); it('Change mode of existing file', async () => { + const { promises } = vol; const fileHandle = await promises.open('/foo', 'a'); await fileHandle.chmod(0o444); expect(vol.statSync('/foo').mode & 0o777).toEqual(0o444); await fileHandle.close(); }); it('Reject when the file handle was closed', async () => { + const { promises } = vol; const fileHandle = await promises.open('/foo', 'a'); await fileHandle.close(); return expect(fileHandle.chmod(0o666)).rejects.toBeInstanceOf(Error); diff --git a/src/__tests__/util.ts b/src/__tests__/util.ts index 1107578fb..6a8851d21 100644 --- a/src/__tests__/util.ts +++ b/src/__tests__/util.ts @@ -1,6 +1,18 @@ import { createFsFromVolume, Volume } from '..'; import { Link, Node } from '../node'; +// Turn the done callback into an incremental one that will only fire after being called +// `times` times, failing with the first reported error if such exists. +// Useful for testing callback-style functions with several different fixtures without +// having to clutter the test suite with a multitude of individual tests (like it.each would). +export const multitest = (_done: (err?: Error) => void, times: number) => { + let err; + return function done(_err?: Error) { + err ??= _err; + if (!--times) _done(_err); + }; +}; + export const create = (json: { [s: string]: string } = { '/foo': 'bar' }) => { const vol = Volume.fromJSON(json); return vol; diff --git a/src/__tests__/volume/ReadStream.test.ts b/src/__tests__/volume/ReadStream.test.ts index 3ca588aef..5d40a7406 100644 --- a/src/__tests__/volume/ReadStream.test.ts +++ b/src/__tests__/volume/ReadStream.test.ts @@ -18,4 +18,46 @@ describe('ReadStream', () => { done(); }); }); + + it('should emit EACCES error when file has insufficient permissions', done => { + const fs = createFs({ '/test': 'test' }); + fs.chmodSync('/test', 0o333); // wx + new fs.ReadStream('/test') + .on('error', err => { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + }) + .on('open', () => { + done(new Error("Expected ReadStream to emit EACCES but it didn't")); + }); + }); + + it('should emit EACCES error when containing directory has insufficient permissions', done => { + const fs = createFs({ '/foo/test': 'test' }); + fs.chmodSync('/foo', 0o666); // rw + new fs.ReadStream('/foo/test') + .on('error', err => { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + }) + .on('open', () => { + done(new Error("Expected ReadStream to emit EACCES but it didn't")); + }); + }); + + it('should emit EACCES error when intermediate directory has insufficient permissions', done => { + const fs = createFs({ '/foo/test': 'test' }); + fs.chmodSync('/', 0o666); // rw + new fs.ReadStream('/foo/test') + .on('error', err => { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + }) + .on('open', () => { + done(new Error("Expected ReadStream to emit EACCES but it didn't")); + }); + }); }); diff --git a/src/__tests__/volume/WriteStream.test.ts b/src/__tests__/volume/WriteStream.test.ts index 9e85a0ba4..02ca7196d 100644 --- a/src/__tests__/volume/WriteStream.test.ts +++ b/src/__tests__/volume/WriteStream.test.ts @@ -19,4 +19,60 @@ describe('WriteStream', () => { done(); }); }); + + it('should emit EACCES error when file has insufficient permissions', done => { + const fs = createFs({ '/test': 'test' }); + fs.chmodSync('/test', 0o555); // rx + new fs.WriteStream('/test') + .on('error', err => { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + }) + .on('open', () => { + done(new Error("Expected WriteStream to emit EACCES but it didn't")); + }); + }); + + it('should emit EACCES error for an existing file when containing directory has insufficient permissions', done => { + const fs = createFs({ '/foo/test': 'test' }); + fs.chmodSync('/foo', 0o666); // rw + new fs.WriteStream('/foo/test') + .on('error', err => { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + }) + .on('open', () => { + done(new Error("Expected WriteStream to emit EACCES but it didn't")); + }); + }); + + it('should emit EACCES error for when intermediate directory has insufficient permissions', done => { + const fs = createFs({ '/foo/test': 'test' }); + fs.chmodSync('/', 0o666); // rw + new fs.WriteStream('/foo/test') + .on('error', err => { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + }) + .on('open', () => { + done(new Error("Expected WriteStream to emit EACCES but it didn't")); + }); + }); + + it('should emit EACCES error for a non-existent file when containing directory has insufficient permissions', done => { + const fs = createFs({}); + fs.mkdirSync('/foo', { mode: 0o555 }); // rx + new fs.WriteStream('/foo/test') + .on('error', err => { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + }) + .on('open', () => { + done(new Error("Expected WriteStream to emit EACCES but it didn't")); + }); + }); }); diff --git a/src/__tests__/volume/appendFile.test.ts b/src/__tests__/volume/appendFile.test.ts index bd8ee4c05..2c322c87e 100644 --- a/src/__tests__/volume/appendFile.test.ts +++ b/src/__tests__/volume/appendFile.test.ts @@ -1,4 +1,4 @@ -import { create } from '../util'; +import { create, multitest } from '../util'; describe('appendFile(file, data[, options], callback)', () => { it('Simple write to non-existing file', done => { @@ -15,4 +15,55 @@ describe('appendFile(file, data[, options], callback)', () => { done(); }); }); + + it('Appending gives EACCES without sufficient permissions on the file', done => { + const vol = create({ '/foo': 'foo' }); + vol.chmodSync('/foo', 0o555); // rx across the board + vol.appendFile('/foo', 'bar', err => { + try { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + } catch (failure) { + done(failure); + } + }); + }); + + it('Appending gives EACCES if file does not exist and containing directory has insufficient permissions', _done => { + const perms = [ + 0o555, // rx across the board + 0o666, // rw across the board + ]; + const done = multitest(_done, perms.length); + + perms.forEach(perm => { + const vol = create({}); + vol.mkdirSync('/foo', { mode: perm }); + vol.appendFile('/foo/test', 'bar', err => { + try { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + } catch (failure) { + done(failure); + } + }); + }); + }); + + it('Appending gives EACCES if intermediate directory has insufficient permissions', done => { + const vol = create({}); + vol.mkdirSync('/foo'); + vol.chmodSync('/', 0o666); // rw + vol.appendFile('/foo/test', 'bar', err => { + try { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + } catch (failure) { + done(failure); + } + }); + }); }); diff --git a/src/__tests__/volume/appendFileSync.test.ts b/src/__tests__/volume/appendFileSync.test.ts index 8fbdaad8b..af834e057 100644 --- a/src/__tests__/volume/appendFileSync.test.ts +++ b/src/__tests__/volume/appendFileSync.test.ts @@ -11,4 +11,33 @@ describe('appendFileSync(file, data, options)', () => { vol.appendFileSync('/a', 'c'); expect(vol.readFileSync('/a', 'utf8')).toEqual('bc'); }); + it('Appending throws EACCES without sufficient permissions on the file', () => { + const vol = create({ '/foo': 'foo' }); + vol.chmodSync('/foo', 0o555); // rx across the board + expect(() => { + vol.appendFileSync('/foo', 'bar'); + }).toThrowError(/EACCES/); + }); + it('Appending throws EACCES if file does not exist and containing directory has insufficient permissions', () => { + const perms = [ + 0o555, // rx across the board + // 0o666, // rw across the board + // 0o111, // x + // 0o222 // w + ]; + perms.forEach(perm => { + const vol = create({}); + vol.mkdirSync('/foo', perm); + expect(() => { + vol.appendFileSync('/foo/test', 'bar'); + }).toThrowError(/EACCES/); + }); + }); + it('Appending throws EACCES if intermediate directory has insufficient permissions', () => { + const vol = create({ '/foo/test': 'test' }); + vol.chmodSync('/', 0o666); // rw + expect(() => { + vol.appendFileSync('/foo/test', 'bar'); + }).toThrowError(/EACCES/); + }); }); diff --git a/src/__tests__/volume/chmodSync.test.ts b/src/__tests__/volume/chmodSync.test.ts new file mode 100644 index 000000000..7a36b9d1d --- /dev/null +++ b/src/__tests__/volume/chmodSync.test.ts @@ -0,0 +1,76 @@ +import { create } from '../util'; + +describe('chmodSync', () => { + it('should be able to chmod files and directories owned by the UID regardless of their permissions', () => { + const perms = [ + 0o777, // rwx + 0o666, // rw + 0o555, // rx + 0o444, // r + 0o333, // wx + 0o222, // w + 0o111, // x + 0o000, // none + ]; + // Check for directories + perms.forEach(perm => { + const vol = create({}); + vol.mkdirSync('/foo', { mode: perm }); + expect(() => { + vol.chmodSync('/foo', 0o777); + }).not.toThrow(); + }); + // Check for files + perms.forEach(perm => { + const vol = create({ '/foo': 'foo' }); + expect(() => { + vol.chmodSync('/foo', 0o777); + }).not.toThrow(); + }); + }); + + it('should chmod the target of a symlink, not the symlink itself', () => { + const vol = create({ '/target': 'contents' }); + vol.symlinkSync('/target', '/link'); + const expectedLink = vol.lstatSync('/link').mode; + const expectedTarget = vol.statSync('/target').mode & ~0o777; + vol.chmodSync('/link', 0); + + expect(vol.lstatSync('/link').mode).toEqual(expectedLink); + expect(vol.statSync('/target').mode).toEqual(expectedTarget); + }); + + it.skip('should throw EPERM when trying to chmod targets not owned by the uid', () => { + const uid = process.getuid() + 1; + // Check for directories + const vol = create({}); + vol.mkdirSync('/foo'); + vol.chownSync('/foo', uid, process.getgid()); + expect(() => { + vol.chmodSync('/foo', 0o777); + }).toThrow(/PERM/); + }); + + it("should throw ENOENT when target doesn't exist", () => { + const vol = create({}); + expect(() => { + vol.chmodSync('/foo', 0o777); + }).toThrow(/ENOENT/); + }); + + it('should throw EACCES when containing directory has insufficient permissions', () => { + const vol = create({ '/foo/test': 'test' }); + vol.chmodSync('/foo', 0o666); // rw + expect(() => { + vol.chmodSync('/foo/test', 0o777); + }).toThrow(/EACCES/); + }); + + it('should throw EACCES when intermediate directory has insufficient permissions', () => { + const vol = create({ '/foo/test': 'test' }); + vol.chmodSync('/', 0o666); // rw + expect(() => { + vol.chmodSync('/foo/test', 0o777); + }).toThrow(/EACCES/); + }); +}); diff --git a/src/__tests__/volume/copyFile.test.ts b/src/__tests__/volume/copyFile.test.ts index 2210f90fa..b7d701554 100644 --- a/src/__tests__/volume/copyFile.test.ts +++ b/src/__tests__/volume/copyFile.test.ts @@ -1,4 +1,4 @@ -import { create } from '../util'; +import { create, multitest } from '../util'; import { constants } from '../../constants'; describe('copyFile(src, dest[, flags], callback)', () => { @@ -44,4 +44,73 @@ describe('copyFile(src, dest[, flags], callback)', () => { done(); }); }); + + describe('permissions', () => { + it('copying gives EACCES with insufficient permissions on the source file', done => { + const vol = create({ '/foo': 'foo' }); + vol.chmodSync('/foo', 0o333); // wx across the board + vol.copyFile('/foo', '/bar', err => { + try { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + } finally { + done(); + } + }); + }); + + it('copying gives EACCES with insufficient permissions on the source directory', done => { + const vol = create({ '/foo/bar': 'foo' }); + vol.chmodSync('/foo', 0o666); // rw across the board + vol.copyFile('/foo/bar', '/bar', err => { + try { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + } catch (failure) { + done(failure); + } + }); + }); + + it('copying gives EACCES with insufficient permissions on the destination directory', _done => { + const perms = [ + 0o555, // rx + 0o666, // rw + 0o111, // x + 0o222, // w + ]; + const done = multitest(_done, perms.length); + + perms.forEach(perm => { + const vol = create({ '/foo': 'foo' }); + vol.mkdirSync('/bar'); + vol.chmodSync('/bar', perm); + vol.copyFile('/foo', '/bar/foo', err => { + try { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + } catch (failure) { + done(failure); + } + }); + }); + }); + }); + + it('copying gives EACCES with insufficient permissions on an intermediate directory', done => { + const vol = create({ '/foo/test': 'test' }); + vol.mkdirSync('/bar'); + vol.chmodSync('/', 0o666); // rw + vol.copyFile('/foo/test', '/bar/test', err => { + try { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + } catch (failure) { + done(failure); + } + }); + }); }); diff --git a/src/__tests__/volume/copyFileSync.test.ts b/src/__tests__/volume/copyFileSync.test.ts index cba2aecdc..e9de89e1e 100644 --- a/src/__tests__/volume/copyFileSync.test.ts +++ b/src/__tests__/volume/copyFileSync.test.ts @@ -91,4 +91,47 @@ describe('copyFileSync(src, dest[, flags])', () => { expect(vol.readFileSync('/foo', 'utf8')).toBe('hello world'); }); }); + + describe('permissions', () => { + it('copying throws EACCES with insufficient permissions on the source file', () => { + const vol = create({ '/foo': 'foo' }); + vol.chmodSync('/foo', 0o333); // wx across the board + expect(() => { + vol.copyFileSync('/foo', '/bar'); + }).toThrowError(/EACCES/); + }); + + it('copying throws EACCES with insufficient permissions on the source directory', () => { + const vol = create({ '/foo/bar': 'foo' }); + vol.chmodSync('/foo', 0o666); // rw across the board + expect(() => { + vol.copyFileSync('/foo/bar', '/bar'); + }).toThrowError(/EACCES/); + }); + + it('copying throws EACCES with insufficient permissions on the destination directory', () => { + const perms = [ + 0o555, // rx + 0o666, // rw + 0o111, // x + 0o222, // w + ]; + perms.forEach(perm => { + const vol = create({ '/foo': 'foo' }); + vol.mkdirSync('/bar'); + vol.chmodSync('/bar', perm); + expect(() => { + vol.copyFileSync('/foo', '/bar/foo'); + }).toThrowError(/EACCES/); + }); + }); + it('copying throws EACCES with insufficient permissions an intermediate directory', () => { + const vol = create({ '/foo/test': 'test' }); + vol.mkdirSync('/bar'); + vol.chmodSync('/', 0o666); // rw across the board + expect(() => { + vol.copyFileSync('/foo/test', '/bar/test'); + }).toThrowError(/EACCES/); + }); + }); }); diff --git a/src/__tests__/volume/exists.test.ts b/src/__tests__/volume/exists.test.ts index b8004b13f..042d54e25 100644 --- a/src/__tests__/volume/exists.test.ts +++ b/src/__tests__/volume/exists.test.ts @@ -31,4 +31,19 @@ describe('exists(path, callback)', () => { expect(err.message !== 'not_this').toEqual(true); } }); + it('gives false if permissions on containing directory are insufficient', done => { + // Experimentally determined: fs.exists treats missing permissions as "file does not exist", + // presumably because due to the non-standard callback signature there is no way to signal + // that permissions were insufficient + const vol = create({ '/foo/bar': 'test' }); + vol.chmodSync('/foo', 0o666); // rw across the board + vol.exists('/foo/bar', exists => { + try { + expect(exists).toEqual(false); + done(); + } catch (failure) { + done(failure); + } + }); + }); }); diff --git a/src/__tests__/volume/existsSync.test.ts b/src/__tests__/volume/existsSync.test.ts index 3d8fa94ad..cc65da4bc 100644 --- a/src/__tests__/volume/existsSync.test.ts +++ b/src/__tests__/volume/existsSync.test.ts @@ -13,4 +13,12 @@ describe('existsSync(path)', () => { it('invalid path type should not throw', () => { expect(vol.existsSync(123 as any)).toEqual(false); }); + it('returns false if permissions are insufficient on containing directory', () => { + // Experimentally determined: fs.existsSync treats missing permissions as "file does not exist", + // even though it could throw EACCES instead. + // This is presumably to achieve unity of behavior with fs.exists. + const vol = create({ '/foo/bar': 'test' }); + vol.chmodSync('/foo', 0o666); // rw across the board + expect(vol.existsSync('/foo/bar')).toEqual(false); + }); }); diff --git a/src/__tests__/volume/lutimesSync.test.ts b/src/__tests__/volume/lutimesSync.test.ts new file mode 100644 index 000000000..11735a6fe --- /dev/null +++ b/src/__tests__/volume/lutimesSync.test.ts @@ -0,0 +1,66 @@ +import { create } from '../util'; + +describe('lutimesSync', () => { + it('should be able to lutimes symlinks regardless of their permissions', () => { + const perms = [ + 0o777, // rwx + 0o666, // rw + 0o555, // rx + 0o444, // r + 0o333, // wx + 0o222, // w + 0o111, // x + 0o000, // none + ]; + // Check for directories + perms.forEach(perm => { + const vol = create({ '/target': 'test' }); + vol.symlinkSync('/target', '/test'); + expect(() => { + vol.lutimesSync('/test', 0, 0); + }).not.toThrow(); + }); + }); + + it('should set atime and mtime on the link itself, not the target', () => { + const vol = create({ '/target': 'test' }); + vol.symlinkSync('/target', '/test'); + vol.lutimesSync('/test', new Date(1), new Date(2)); + const linkStats = vol.lstatSync('/test'); + const targetStats = vol.statSync('/target'); + + expect(linkStats.atime).toEqual(new Date(1)); + expect(linkStats.mtime).toEqual(new Date(2)); + + expect(targetStats.atime).not.toEqual(new Date(1)); + expect(targetStats.mtime).not.toEqual(new Date(2)); + }); + + it("should throw ENOENT when target doesn't exist", () => { + const vol = create({ '/target': 'test' }); + // Don't create symlink this time + expect(() => { + vol.lutimesSync('/test', 0, 0); + }).toThrow(/ENOENT/); + }); + + it('should throw EACCES when containing directory has insufficient permissions', () => { + const vol = create({ '/target': 'test' }); + vol.mkdirSync('/foo'); + vol.symlinkSync('/target', '/foo/test'); + vol.chmodSync('/foo', 0o666); // rw + expect(() => { + vol.lutimesSync('/foo/test', 0, 0); + }).toThrow(/EACCES/); + }); + + it('should throw EACCES when intermediate directory has insufficient permissions', () => { + const vol = create({ '/target': 'test' }); + vol.mkdirSync('/foo'); + vol.symlinkSync('/target', '/foo/test'); + vol.chmodSync('/', 0o666); // rw + expect(() => { + vol.lutimesSync('/foo/test', 0, 0); + }).toThrow(/EACCES/); + }); +}); diff --git a/src/__tests__/volume/mkdirSync.test.ts b/src/__tests__/volume/mkdirSync.test.ts index 8230a0572..46d4bf7ca 100644 --- a/src/__tests__/volume/mkdirSync.test.ts +++ b/src/__tests__/volume/mkdirSync.test.ts @@ -63,4 +63,92 @@ describe('mkdirSync', () => { expect(vol.statSync('/__proto__').isDirectory()).toBe(true); }); + + it('throws EACCES with insufficient permissions on containing directory', () => { + const perms = [ + 0o666, // rw across the board + 0o555, // rx across the bord + ]; + perms.forEach(perm => { + const vol = create({}); + vol.mkdirSync('/foo'); + vol.chmodSync('/foo', perm); + expect(() => { + vol.mkdirSync(`/foo/bar`); + }).toThrowError(/EACCES/); + }); + }); + + describe('recursive', () => { + it('can create nested directories when none exist', () => { + const vol = create({}); + vol.mkdirSync('/a/b/c', { recursive: true }); + expect(() => { + vol.statSync('/a/b/c'); + }).not.toThrow(); + }); + + it('can create nested directories when some exist', () => { + const vol = create({}); + vol.mkdirSync('/a'); + vol.mkdirSync('/a/b/c', { recursive: true }); + expect(() => { + vol.statSync('/a/b/c'); + }).not.toThrow(); + }); + + it('can create nested directories when all exist', () => { + const vol = create({}); + vol.mkdirSync('/a'); + vol.mkdirSync('/a/b'); + vol.mkdirSync('/a/b/c'); + vol.mkdirSync('/a/b/c', { recursive: true }); + expect(() => { + vol.statSync('/a/b/c'); + }).not.toThrow(); + }); + + it('can create directories under symlinks', () => { + const vol = create({}); + vol.mkdirSync('/target'); + vol.symlinkSync('/target', '/a'); + vol.mkdirSync('/a/b/c', { recursive: true }); + expect(() => { + vol.statSync('/a/b/c'); + }).not.toThrow(); + }); + + it('throws ENOTDIR when trying to create under something that is not a directory', () => { + const vol = create({ '/a': 'I am a file' }); + expect(() => { + debugger; + vol.mkdirSync('/a/b/c', { recursive: true }); + }).toThrow(/ENOTDIR/); + }); + + it('throws EACCES with insufficient permissions on containing directory', () => { + const perms = [ + 0o666, // rw + 0o555, // rx + 0o111, // x + 0o222, // w + ]; + perms.forEach(perm => { + const vol = create({}); + vol.mkdirSync('/a', { mode: perm }); + expect(() => { + vol.mkdirSync('/a/b/c', { recursive: true }); + }).toThrow(/EACCES/); + }); + }); + + it('throws EACCES with insufficient permissions on intermediate directory', () => { + const vol = create({}); + vol.mkdirSync('/a'); + vol.chmodSync('/', 0o666); // rw + expect(() => { + vol.mkdirSync('/a/b/c', { recursive: true }); + }).toThrow(/EACCES/); + }); + }); }); diff --git a/src/__tests__/volume/openSync.test.ts b/src/__tests__/volume/openSync.test.ts index 94f8675c9..73d89b34e 100644 --- a/src/__tests__/volume/openSync.test.ts +++ b/src/__tests__/volume/openSync.test.ts @@ -1,8 +1,74 @@ -import { fs } from '../..'; +import { createFs } from '../util'; describe('openSync(path, mode[, flags])', () => { it('should return a file descriptor', () => { + const fs = createFs(); const fd = fs.openSync('/foo', 'w'); expect(typeof fd).toEqual('number'); }); + + it('throws ENOTDIR when trying to create a non-existent file inside another file', () => { + const fs = createFs(); + + expect(() => { + fs.openSync('/foo/baz', 'a'); + }).toThrow(/ENOTDIR/); + }); + + describe('permissions', () => { + it('opening for writing throws EACCES without sufficient permissions on the file', () => { + const flags = ['a', 'w', 'r+']; // append, write, read+write + flags.forEach(intent => { + const fs = createFs(); + fs.chmodSync('/foo', 0o555); // rx across the board + expect(() => { + fs.openSync('/foo', intent); + }).toThrowError(/EACCES/); + }); + }); + + it('opening for reading throws EACCES without sufficient permissions on the file', () => { + const flags = ['a+', 'r', 'w+']; // append+read, read, write+read + flags.forEach(intent => { + const fs = createFs(); + fs.chmodSync('/foo', 0o333); // wx across the board + expect(() => { + fs.openSync('/foo', intent); + }).toThrowError(/EACCES/); + }); + }); + + it('opening for anything throws EACCES without sufficient permissions on the containing directory of an existing file', () => { + const flags = ['a+', 'r', 'w']; // append+read, read, write + flags.forEach(intent => { + const fs = createFs({ '/foo/bar': 'test' }); + fs.chmodSync('/foo', 0o666); // wr across the board + expect(() => { + fs.openSync('/foo/bar', intent); + }).toThrowError(/EACCES/); + }); + }); + + it('opening for anything throws EACCES without sufficient permissions on an intermediate directory', () => { + const flags = ['a+', 'r', 'w']; // append+read, read, write + flags.forEach(intent => { + const fs = createFs({ '/foo/bar': 'test' }); + fs.chmodSync('/', 0o666); // wr across the board + expect(() => { + fs.openSync('/foo/bar', intent); + }).toThrowError(/EACCES/); + }); + }); + + it('opening for anything throws EACCES without sufficient permissions on the containing directory of an non-existent file', () => { + const flags = ['a+', 'r', 'w']; // append+read, read, write + flags.forEach(intent => { + const fs = createFs({}); + fs.mkdirSync('/foo', { mode: 0o666 }); // wr + expect(() => { + fs.openSync('/foo/bar', intent); + }).toThrowError(/EACCES/); + }); + }); + }); }); diff --git a/src/__tests__/volume/readFile.test.ts b/src/__tests__/volume/readFile.test.ts index 45d4a3902..8721aea53 100644 --- a/src/__tests__/volume/readFile.test.ts +++ b/src/__tests__/volume/readFile.test.ts @@ -14,4 +14,22 @@ describe('.readFile()', () => { expect(err).toBeInstanceOf(Error); expect((err).code).toBe('ENOENT'); }); + + it('throws EACCES if file has insufficient permissions', async () => { + const { fs } = memfs({ '/foo': 'test' }); + fs.chmodSync('/foo', 0o333); // wx + return expect(fs.promises.readFile('/foo')).rejects.toThrow(/EACCES/); + }); + + it('throws EACCES if containing directory has insufficient permissions', async () => { + const { fs } = memfs({ '/foo/bar': 'test' }); + fs.chmodSync('/foo', 0o666); // rw + return expect(fs.promises.readFile('/foo/bar')).rejects.toThrow(/EACCES/); + }); + + it('throws EACCES if intermediate directory has insufficient permissions', async () => { + const { fs } = memfs({ '/foo/bar': 'test' }); + fs.chmodSync('/', 0o666); // rw + return expect(fs.promises.readFile('/foo/bar')).rejects.toThrow(/EACCES/); + }); }); diff --git a/src/__tests__/volume/readSync.test.ts b/src/__tests__/volume/readSync.test.ts index 2cacc5b4b..daacef584 100644 --- a/src/__tests__/volume/readSync.test.ts +++ b/src/__tests__/volume/readSync.test.ts @@ -39,4 +39,9 @@ describe('.readSync(fd, buffer, offset, length, position)', () => { expect(buf.equals(Buffer.from('675'))).toBe(true); }); xit('Negative tests', () => {}); + + /* + * No need for permissions tests, because readSync requires a file descriptor, which can only be + * obtained from open or openSync. + */ }); diff --git a/src/__tests__/volume/readdirSync.test.ts b/src/__tests__/volume/readdirSync.test.ts index adf03fd29..80d8f67a6 100644 --- a/src/__tests__/volume/readdirSync.test.ts +++ b/src/__tests__/volume/readdirSync.test.ts @@ -36,10 +36,9 @@ describe('readdirSync()', () => { '/a/aa': 'aa', '/b/b': 'b', }); + vol.symlinkSync('/a', '/lnk'); - vol.symlinkSync('/a', '/b/b/b'); - - const dirs = vol.readdirSync('/b/b/b'); + const dirs = vol.readdirSync('/lnk'); (dirs as any).sort(); @@ -67,9 +66,9 @@ describe('readdirSync()', () => { return { ...dirent }; }); expect(mapped).toEqual([ - { mode: 33206, name: 'af', path: '/x' }, - { mode: 16895, name: 'b', path: '/x' }, - { mode: 16895, name: 'c', path: '/x' }, + { mode: 33206, name: 'af', path: '/x', parentPath: '/x' }, + { mode: 16895, name: 'b', path: '/x', parentPath: '/x' }, + { mode: 16895, name: 'c', path: '/x', parentPath: '/x' }, ]); }); @@ -105,16 +104,28 @@ describe('readdirSync()', () => { }) .sort((a, b) => a.path.localeCompare(b.path)); expect(mapped).toEqual([ - { mode: 33206, name: 'af1', path: '/z' }, - { mode: 33206, name: 'af2', path: '/z' }, - { mode: 16895, name: 'b', path: '/z' }, - { mode: 16895, name: 'c', path: '/z' }, - { mode: 33206, name: 'bf1', path: '/z/b' }, - { mode: 33206, name: 'bf2', path: '/z/b' }, - { mode: 16895, name: 'c', path: '/z/c' }, - { mode: 33206, name: '.cf0', path: '/z/c/c' }, - { mode: 33206, name: 'cf1', path: '/z/c/c' }, - { mode: 33206, name: 'cf2', path: '/z/c/c' }, + { mode: 33206, name: 'af1', path: '/z', parentPath: '/z' }, + { mode: 33206, name: 'af2', path: '/z', parentPath: '/z' }, + { mode: 16895, name: 'b', path: '/z', parentPath: '/z' }, + { mode: 16895, name: 'c', path: '/z', parentPath: '/z' }, + { mode: 33206, name: 'bf1', path: '/z/b', parentPath: '/z/b' }, + { mode: 33206, name: 'bf2', path: '/z/b', parentPath: '/z/b' }, + { mode: 16895, name: 'c', path: '/z/c', parentPath: '/z/c' }, + { mode: 33206, name: '.cf0', path: '/z/c/c', parentPath: '/z/c/c' }, + { mode: 33206, name: 'cf1', path: '/z/c/c', parentPath: '/z/c/c' }, + { mode: 33206, name: 'cf2', path: '/z/c/c', parentPath: '/z/c/c' }, ]); }); + + it('throws EACCES when trying to readdirSync a directory with insufficient permissions', () => { + const vol = create({}); + vol.mkdirSync('/foo', { mode: 0o333 }); // wx across the board + expect(() => { + vol.readdirSync('/foo'); + }).toThrowError(/EACCES/); + // Check that it also throws with one of the subdirs of a recursive scan + expect(() => { + vol.readdirSync('/', { recursive: true }); + }).toThrowError(/EACCES/); + }); }); diff --git a/src/__tests__/volume/realpathSync.test.ts b/src/__tests__/volume/realpathSync.test.ts index 95465c15f..db9126b7d 100644 --- a/src/__tests__/volume/realpathSync.test.ts +++ b/src/__tests__/volume/realpathSync.test.ts @@ -15,4 +15,19 @@ describe('.realpathSync(...)', () => { const vol = create({ './a': 'a' }); expect(vol.realpathSync('/')).toBe('/'); }); + it('throws EACCES when the containing directory does not have sufficient permissions', () => { + const vol = create({ '/foo/bar': 'bar' }); + vol.chmodSync('/foo', 0o666); // rw + expect(() => { + vol.realpathSync('/foo/bar'); + }).toThrow(/EACCES/); + }); + + it('throws EACCES when an intermediate directory does not have sufficient permissions', () => { + const vol = create({ '/foo/bar': 'bar' }); + vol.chmodSync('/', 0o666); // rw + expect(() => { + vol.realpathSync('/foo/bar'); + }).toThrow(/EACCES/); + }); }); diff --git a/src/__tests__/volume/rename.test.ts b/src/__tests__/volume/rename.test.ts index 305927c4e..f5be74d0a 100644 --- a/src/__tests__/volume/rename.test.ts +++ b/src/__tests__/volume/rename.test.ts @@ -1,4 +1,4 @@ -import { create } from '../util'; +import { create, multitest } from '../util'; describe('renameSync(fromPath, toPath)', () => { it('Renames a simple case', done => { @@ -8,4 +8,62 @@ describe('renameSync(fromPath, toPath)', () => { done(); }); }); + + it('gives EACCES when source directory has insufficient permissions', _done => { + const perms = [ + 0o666, // rw + 0o555, // rx - insufficient because the file will be removed from this directory during renaming + ]; + const done = multitest(_done, perms.length); + perms.forEach(perm => { + const vol = create({ '/src/test': 'test' }); + vol.mkdirSync('/dest'); + vol.chmodSync('/src', perm); + vol.rename('/src/test', '/dest/fail', err => { + try { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + } catch (failure) { + done(failure); + } + }); + }); + }); + + it('gives EACCES when destination directory has insufficient permissions', _done => { + const perms = [ + 0o666, // rw + 0o555, // rx + ]; + const done = multitest(_done, perms.length); + perms.forEach(perm => { + const vol = create({ '/src/test': 'test' }); + vol.mkdirSync('/dest', { mode: perm }); + vol.rename('/src/test', '/dest/fail', err => { + try { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + } catch (failure) { + done(failure); + } + }); + }); + }); + + it('gives EACCES when intermediate directory has insufficient permissions', done => { + const vol = create({ '/src/test': 'test' }); + vol.mkdirSync('/dest'); + vol.chmodSync('/', 0o666); // rw + vol.rename('/src/test', '/dest/fail', err => { + try { + expect(err).toBeInstanceOf(Error); + expect(err).toHaveProperty('code', 'EACCES'); + done(); + } catch (failure) { + done(failure); + } + }); + }); }); diff --git a/src/__tests__/volume/renameSync.test.ts b/src/__tests__/volume/renameSync.test.ts index 2354a03ea..90cd37985 100644 --- a/src/__tests__/volume/renameSync.test.ts +++ b/src/__tests__/volume/renameSync.test.ts @@ -71,4 +71,42 @@ describe('renameSync(fromPath, toPath)', () => { (vol as any).renameSync('/foo', 123); }).toThrowErrorMatchingSnapshot(); }); + + it('throws EACCES when source directory has insufficient permissions', () => { + const perms = [ + 0o666, // rw + 0o555, // rx - insufficient because the file will be removed from this directory during renaming + ]; + perms.forEach(perm => { + const vol = create({ '/src/test': 'test' }); + vol.mkdirSync('/dest'); + vol.chmodSync('/src', perm); + expect(() => { + vol.renameSync('/src/test', '/dest/fail'); + }).toThrowError(/EACCES/); + }); + }); + + it('throws EACCES when destination directory has insufficient permissions', () => { + const perms = [ + 0o666, // rw + 0o555, // rx + ]; + perms.forEach(perm => { + const vol = create({ '/src/test': 'test' }); + vol.mkdirSync('/dest', { mode: perm }); + expect(() => { + vol.renameSync('/src/test', '/dest/fail'); + }).toThrowError(/EACCES/); + }); + }); + + it('throws EACCES when intermediate directory has insufficient permissions', () => { + const vol = create({ '/src/test': 'test' }); + vol.mkdirSync('/dest'); + vol.chmodSync('/', 0o666); // rw + expect(() => { + vol.renameSync('/src/test', '/dest/fail'); + }).toThrow(/EACCES/); + }); }); diff --git a/src/__tests__/volume/rmPromise.test.ts b/src/__tests__/volume/rmPromise.test.ts index d9ba5c842..365fa4d2c 100644 --- a/src/__tests__/volume/rmPromise.test.ts +++ b/src/__tests__/volume/rmPromise.test.ts @@ -136,4 +136,25 @@ describe('rmSync', () => { expect(vol.toJSON()).toEqual({}); }); }); + + it('throws EACCES when containing directory has insufficient permissions', async () => { + const perms = [ + 0o666, // rw + 0o555, // rx + 0o111, // x + ]; + return Promise.all( + perms.map(perm => { + const vol = create({ '/foo/test': 'test' }); + vol.chmodSync('/foo', perm); + return expect(vol.promises.rm('/foo/test')).rejects.toThrow(/EACCES/); + }), + ); + }); + + it('throws EACCES when intermediate directory has insufficient permissions', async () => { + const vol = create({ '/foo/test': 'test' }); + vol.chmodSync('/foo', 0o666); // rw + return expect(vol.promises.rm('/foo/test')).rejects.toThrow(/EACCES/); + }); }); diff --git a/src/__tests__/volume/rmSync.test.ts b/src/__tests__/volume/rmSync.test.ts index 49b1c811f..c5c2b9404 100644 --- a/src/__tests__/volume/rmSync.test.ts +++ b/src/__tests__/volume/rmSync.test.ts @@ -114,4 +114,27 @@ describe('rmSync', () => { expect(vol.toJSON()).toEqual({}); }); }); + + it('throws EACCES when containing directory has insufficient permissions', () => { + const perms = [ + 0o666, // rw + 0o555, // rx + 0o111, // x + ]; + perms.forEach(perm => { + const vol = create({ '/foo/test': 'test' }); + vol.chmodSync('/foo', perm); + expect(() => { + vol.rmSync('/foo/test'); + }).toThrowError(/EACCES/); + }); + }); + + it('throws EACCES when intermediate directory has insufficient permissions', async () => { + const vol = create({ '/foo/test': 'test' }); + vol.chmodSync('/foo', 0o666); // rw + expect(() => { + vol.rmSync('/foo/test'); + }).toThrow(/EACCES/); + }); }); diff --git a/src/__tests__/volume/statSync.test.ts b/src/__tests__/volume/statSync.test.ts index 50e4d9568..c78819985 100644 --- a/src/__tests__/volume/statSync.test.ts +++ b/src/__tests__/volume/statSync.test.ts @@ -11,4 +11,42 @@ describe('.statSync(...)', () => { const stats = vol.statSync('/a/b/index.js'); expect(stats.size).toBe(11); }); + + it('returns undefined for non-existent targets with the throwIfNoEntry option set to false', () => { + const vol = create({}); + + const stats = vol.statSync('/non-existent', { throwIfNoEntry: false }); + expect(stats).toBeUndefined(); + }); + + it('throws EACCES when for a non-existent file when containing directory does not have sufficient permissions even if throwIfNoEntry option is false', () => { + const vol = create({}); + vol.mkdirSync('/foo', { mode: 0o666 }); // rw + expect(() => { + vol.statSync('/foo/non-existent', { throwIfNoEntry: false }); + }).toThrowError(/EACCES/); + }); + + it('throws EACCES when containing directory does not have sufficient permissions', () => { + const vol = create({ '/foo/test': 'test' }); + vol.chmodSync('/foo', 0o666); // rw + + expect(() => { + vol.statSync('/foo/test'); + }).toThrowError(/EACCES/); + + // Make sure permissions win out against throwIfNoEntry option: + expect(() => { + vol.statSync('/foo/test', { throwIfNoEntry: false }); + }).toThrowError(/EACCES/); + }); + + it('throws EACCES when intermediate directory does not have sufficient permissions', () => { + const vol = create({ '/foo/test': 'test' }); + vol.chmodSync('/', 0o666); // rw + + expect(() => { + vol.statSync('/foo/test'); + }).toThrowError(/EACCES/); + }); }); diff --git a/src/__tests__/volume/utimesSync.test.ts b/src/__tests__/volume/utimesSync.test.ts new file mode 100644 index 000000000..e76f438ec --- /dev/null +++ b/src/__tests__/volume/utimesSync.test.ts @@ -0,0 +1,70 @@ +import { create } from '../util'; + +describe('utimesSync', () => { + it('should be able to utimes files and directories regardless of their permissions', () => { + const perms = [ + 0o777, // rwx + 0o666, // rw + 0o555, // rx + 0o444, // r + 0o333, // wx + 0o222, // w + 0o111, // x + 0o000, // none + ]; + // Check for directories + perms.forEach(perm => { + const vol = create({}); + vol.mkdirSync('/foo', { mode: perm }); + expect(() => { + vol.utimesSync('/foo', 0, 0); + }).not.toThrow(); + }); + // Check for files + perms.forEach(perm => { + const vol = create({ '/foo': 'foo' }); + expect(() => { + vol.utimesSync('/foo', 0, 0); + }).not.toThrow(); + }); + }); + + it('should set atime and mtime on a file', () => { + const vol = create({ '/foo/test': 'test' }); + vol.utimesSync('/foo/test', new Date(1), new Date(2)); + const { atime, mtime } = vol.statSync('/foo/test'); + expect(atime).toEqual(new Date(1)); + expect(mtime).toEqual(new Date(2)); + }); + + it('should set atime and mtime on a directory', () => { + const vol = create({ '/foo/test': 'test' }); + vol.utimesSync('/foo', new Date(1), new Date(2)); + const { atime, mtime } = vol.statSync('/foo'); + expect(atime).toEqual(new Date(1)); + expect(mtime).toEqual(new Date(2)); + }); + + it("should throw ENOENT when target doesn't exist", () => { + const vol = create({}); + expect(() => { + vol.utimesSync('/foo', 0, 0); + }).toThrow(/ENOENT/); + }); + + it('should throw EACCES when containing directory has insufficient permissions', () => { + const vol = create({ '/foo/test': 'test' }); + vol.chmodSync('/foo', 0o666); // rw + expect(() => { + vol.utimesSync('/foo/test', 0, 0); + }).toThrow(/EACCES/); + }); + + it('should throw EACCES when intermediate directory has insufficient permissions', () => { + const vol = create({ '/foo/test': 'test' }); + vol.chmodSync('/', 0o666); // rw + expect(() => { + vol.utimesSync('/foo/test', 0, 0); + }).toThrow(/EACCES/); + }); +}); diff --git a/src/__tests__/volume/write.test.ts b/src/__tests__/volume/write.test.ts index 86a7434b7..21829ec49 100644 --- a/src/__tests__/volume/write.test.ts +++ b/src/__tests__/volume/write.test.ts @@ -17,4 +17,9 @@ describe('write(fs, str, position, encoding, callback)', () => { done(); }); }); + + /* + * No need for permissions tests, because write requires a file descriptor, which can only be + * obtained from open or openSync. + */ }); diff --git a/src/__tests__/volume/writeFileSync.test.ts b/src/__tests__/volume/writeFileSync.test.ts index fc089668d..3615a8a29 100644 --- a/src/__tests__/volume/writeFileSync.test.ts +++ b/src/__tests__/volume/writeFileSync.test.ts @@ -44,4 +44,43 @@ describe('writeFileSync(path, data[, options])', () => { expect(err.code).toBe('ENOENT'); } }); + + it('Write throws EACCES if file exists but has insufficient permissions', () => { + const vol = create({ '/foo/test': 'test' }); + vol.chmodSync('/foo/test', 0o555); // rx + expect(() => { + vol.writeFileSync('/foo/test', 'test'); + }).toThrowError(/EACCES/); + }); + + it('Write throws EACCES without sufficient permissions on containing directory', () => { + const perms = [ + 0o666, // rw + 0o555, // rx, only when target file does not exist yet + ]; + perms.forEach(perm => { + const vol = create({}); + vol.mkdirSync('/foo'); + vol.chmodSync('/foo', perm); + expect(() => { + vol.writeFileSync('/foo/test', 'test'); + }).toThrowError(/EACCES/); + }); + + // If the target file exists, it should not care about the write permission on containing dir + const vol = create({ '/foo/test': 'test' }); + vol.chmodSync('/foo', 0o555); // rx, should be enough + expect(() => { + vol.writeFileSync('/foo/test', 'test'); + }).not.toThrowError(); + }); + + it('Write throws EACCES without sufficient permissions on intermediate directory', () => { + const vol = create({}); + vol.mkdirSync('/foo'); + vol.chmodSync('/', 0o666); // rw + expect(() => { + vol.writeFileSync('/foo/test', 'test'); + }).toThrowError(/EACCES/); + }); }); diff --git a/src/__tests__/volume/writeSync.test.ts b/src/__tests__/volume/writeSync.test.ts index 371597edf..3ed63822e 100644 --- a/src/__tests__/volume/writeSync.test.ts +++ b/src/__tests__/volume/writeSync.test.ts @@ -25,4 +25,9 @@ describe('.writeSync(fd, buffer, offset, length, position)', () => { fs.writeSync(fd, 'x', 1); expect(fs.readFileSync('/foo', 'utf8')).toBe('1x3'); }); + + /* + * No need for permissions tests, because write requires a file descriptor, which can only be + * obtained from open or openSync. + */ }); diff --git a/src/node.ts b/src/node.ts index 9df2ead26..8128e364c 100644 --- a/src/node.ts +++ b/src/node.ts @@ -279,6 +279,26 @@ export class Node extends EventEmitter { return false; } + canExecute(uid: number = getuid(), gid: number = getgid()): boolean { + if (this.perm & S.IXOTH) { + return true; + } + + if (gid === this.gid) { + if (this.perm & S.IXGRP) { + return true; + } + } + + if (uid === this.uid) { + if (this.perm & S.IXUSR) { + return true; + } + } + + return false; + } + del() { this.emit('delete', this); } @@ -426,24 +446,6 @@ export class Link extends EventEmitter { // this.vol = null; // } - /** - * Walk the tree path and return the `Link` at that location, if any. - * @param steps {string[]} Desired location. - * @param stop {number} Max steps to go into. - * @param i {number} Current step in the `steps` array. - * - * @return {Link|null} - */ - walk(steps: string[], stop: number = steps.length, i: number = 0): Link | null { - if (i >= steps.length) return this; - if (i >= stop) return this; - - const step = steps[i]; - const link = this.getChild(step); - if (!link) return null; - return link.walk(steps, stop, i + 1); - } - toJSON() { return { steps: this.steps, diff --git a/src/volume.ts b/src/volume.ts index 43f31406f..1baaa0442 100644 --- a/src/volume.ts +++ b/src/volume.ts @@ -388,6 +388,7 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { } createNode(isDirectory: boolean = false, perm?: number): Node { + perm ??= isDirectory ? 0o777 : 0o666; const node = new this.props.Node(this.newInoNumber(), perm); if (isDirectory) node.setIsDirectory(); this.inodes[node.ino] = node; @@ -400,34 +401,79 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { this.releasedInos.push(node.ino); } - // Returns a `Link` (hard link) referenced by path "split" into steps. - getLink(steps: string[]): Link | null { - return this.root.walk(steps); - } - - // Just link `getLink`, but throws a correct user error, if link to found. - getLinkOrThrow(filename: string, funcName?: string): Link { - const steps = filenameToSteps(filename); - const link = this.getLink(steps); - if (!link) throw createError(ENOENT, funcName, filename); - return link; - } - - // Just like `getLink`, but also dereference/resolves symbolic links. - getResolvedLink(filenameOrSteps: string | string[]): Link | null { - let steps: string[] = typeof filenameOrSteps === 'string' ? filenameToSteps(filenameOrSteps) : filenameOrSteps; + private walk( + steps: string[], + resolveSymlinks: boolean, + checkExistence: boolean, + checkAccess: boolean, + funcName?: string, + ): Link | null; + private walk( + filename: string, + resolveSymlinks: boolean, + checkExistence: boolean, + checkAccess: boolean, + funcName?: string, + ): Link | null; + private walk( + link: Link, + resolveSymlinks: boolean, + checkExistence: boolean, + checkAccess: boolean, + funcName?: string, + ): Link | null; + private walk( + stepsOrFilenameOrLink: string[] | string | Link, + resolveSymlinks: boolean, + checkExistence: boolean, + checkAccess: boolean, + funcName?: string, + ): Link | null; + private walk( + stepsOrFilenameOrLink: string[] | string | Link, + resolveSymlinks: boolean = false, + checkExistence: boolean = false, + checkAccess: boolean = false, + funcName?: string, + ): Link | null { + let steps: string[]; + let filename: string; + if (stepsOrFilenameOrLink instanceof Link) { + steps = stepsOrFilenameOrLink.steps; + filename = sep + steps.join(sep); + } else if (typeof stepsOrFilenameOrLink === 'string') { + steps = filenameToSteps(stepsOrFilenameOrLink); + filename = stepsOrFilenameOrLink; + } else { + steps = stepsOrFilenameOrLink; + filename = sep + steps.join(sep); + } - let link: Link | undefined = this.root; + let curr: Link | null = this.root; let i = 0; while (i < steps.length) { - const step = steps[i]; - link = link.getChild(step); - if (!link) return null; + let node: Node = curr.getNode(); + // Check access permissions if current link is a directory + if (node.isDirectory()) { + if (checkAccess && !node.canExecute()) { + throw createError(EACCES, funcName, filename); + } + } else { + if (i < steps.length - 1) throw createError(ENOTDIR, funcName, filename); + } - const node = link.getNode(); - if (node.isSymlink()) { + curr = curr.getChild(steps[i]) ?? null; + + // Check existence of current link + if (!curr) + if (checkExistence) throw createError(ENOENT, funcName, filename); + else return null; + + node = curr?.getNode(); + // Resolve symlink + if (resolveSymlinks && node.isSymlink()) { steps = node.symlink.concat(steps.slice(i + 1)); - link = this.root; + curr = this.root; i = 0; continue; } @@ -435,44 +481,52 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { i++; } - return link; + return curr; + } + + // Returns a `Link` (hard link) referenced by path "split" into steps. + getLink(steps: string[]): Link | null { + return this.walk(steps, false, false, false); + } + + // Just link `getLink`, but throws a correct user error, if link to found. + getLinkOrThrow(filename: string, funcName?: string): Link { + return this.walk(filename, false, true, true, funcName)!; + } + + // Just like `getLink`, but also dereference/resolves symbolic links. + getResolvedLink(filenameOrSteps: string | string[]): Link | null { + return this.walk(filenameOrSteps, true, false, false); } // Just like `getLinkOrThrow`, but also dereference/resolves symbolic links. getResolvedLinkOrThrow(filename: string, funcName?: string): Link { - const link = this.getResolvedLink(filename); - if (!link) throw createError(ENOENT, funcName, filename); - return link; + return this.walk(filename, true, true, true, funcName)!; } resolveSymlinks(link: Link): Link | null { - // let node: Node = link.getNode(); - // while(link && node.isSymlink()) { - // link = this.getLink(node.symlink); - // if(!link) return null; - // node = link.getNode(); - // } - // return link; return this.getResolvedLink(link.steps.slice(1)); } // Just like `getLinkOrThrow`, but also verifies that the link is a directory. private getLinkAsDirOrThrow(filename: string, funcName?: string): Link { - const link = this.getLinkOrThrow(filename, funcName); + const link = this.getLinkOrThrow(filename, funcName)!; if (!link.getNode().isDirectory()) throw createError(ENOTDIR, funcName, filename); return link; } // Get the immediate parent directory of the link. private getLinkParent(steps: string[]): Link | null { - return this.root.walk(steps, steps.length - 1); + return this.getLink(steps.slice(0, -1)); } private getLinkParentAsDirOrThrow(filenameOrSteps: string | string[], funcName?: string): Link { - const steps = filenameOrSteps instanceof Array ? filenameOrSteps : filenameToSteps(filenameOrSteps); - const link = this.getLinkParent(steps); - if (!link) throw createError(ENOENT, funcName, sep + steps.join(sep)); - if (!link.getNode().isDirectory()) throw createError(ENOTDIR, funcName, sep + steps.join(sep)); + const steps: string[] = ( + filenameOrSteps instanceof Array ? filenameOrSteps : filenameToSteps(filenameOrSteps) + ).slice(0, -1); + const filename: string = sep + steps.join(sep); + const link = this.getLinkOrThrow(filename, funcName); + if (!link.getNode().isDirectory()) throw createError(ENOTDIR, funcName, filename); return link; } @@ -642,9 +696,11 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { } // Resolve symlinks. + // + // @TODO: This should be superfluous. This method is only ever called by openFile(), which does its own symlink resolution + // prior to calling. let realLink: Link | null = link; - if (resolveSymlinks) realLink = this.resolveSymlinks(link); - if (!realLink) throw createError(ENOENT, 'open', link.getPath()); + if (resolveSymlinks) realLink = this.getResolvedLinkOrThrow(link.getPath(), 'open'); const node = realLink.getNode(); @@ -661,7 +717,10 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { throw createError(EACCES, 'open', link.getPath()); } } - if (flagsNum & O_RDWR) { + if (!(flagsNum & O_RDONLY)) { + if (!node.canWrite()) { + throw createError(EACCES, 'open', link.getPath()); + } } const file = new this.props.File(link, node, flagsNum, this.newFdNumber()); @@ -680,20 +739,33 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { resolveSymlinks: boolean = true, ): File { const steps = filenameToSteps(filename); - let link: Link | null = resolveSymlinks ? this.getResolvedLink(steps) : this.getLink(steps); - - if (link && flagsNum & O_EXCL) throw createError(EEXIST, 'open', filename); + let link: Link | null; + try { + link = resolveSymlinks ? this.getResolvedLinkOrThrow(filename, 'open') : this.getLinkOrThrow(filename, 'open'); - // Try creating a new file, if it does not exist. - if (!link && flagsNum & O_CREAT) { - // const dirLink: Link = this.getLinkParent(steps); - const dirLink: Link | null = this.getResolvedLink(steps.slice(0, steps.length - 1)); - // if(!dirLink) throw createError(ENOENT, 'open', filename); - if (!dirLink) throw createError(ENOENT, 'open', sep + steps.join(sep)); + // Check if file already existed when trying to create it exclusively (O_CREAT and O_EXCL flags are set). + // This is an error, see https://pubs.opengroup.org/onlinepubs/009695399/functions/open.html: + // "If O_CREAT and O_EXCL are set, open() shall fail if the file exists." + if (link && flagsNum & O_CREAT && flagsNum & O_EXCL) throw createError(EEXIST, 'open', filename); + } catch (err) { + // Try creating a new file, if it does not exist and O_CREAT flag is set. + // Note that this will still throw if the ENOENT came from one of the + // intermediate directories instead of the file itself. + if (err.code === ENOENT && flagsNum & O_CREAT) { + const dirname: string = pathModule.dirname(filename); + const dirLink: Link = this.getResolvedLinkOrThrow(dirname); + const dirNode = dirLink.getNode(); + + // Check that the place we create the new file is actually a directory and that we are allowed to do so: + if (!dirNode.isDirectory()) throw createError(ENOTDIR, 'open', filename); + if (!dirNode.canExecute() || !dirNode.canWrite()) throw createError(EACCES, 'open', filename); + + // This is a difference to the original implementation, which would simply not create a file unless modeNum was specified. + // However, current Node versions will default to 0o666. + modeNum ??= 0o666; - if (flagsNum & O_CREAT && typeof modeNum === 'number') { link = this.createLink(dirLink, steps[steps.length - 1], false, modeNum); - } + } else throw err; } if (link) return this.openLink(link, flagsNum, resolveSymlinks); @@ -886,13 +958,10 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { if (userOwnsFd) fd = id as number; else { const filename = pathToFilename(id as PathLike); - const steps = filenameToSteps(filename); - const link: Link | null = this.getResolvedLink(steps); + const link: Link = this.getResolvedLinkOrThrow(filename, 'open'); - if (link) { - const node = link.getNode(); - if (node.isDirectory()) throw createError(EISDIR, 'open', link.getPath()); - } + const node = link.getNode(); + if (node.isDirectory()) throw createError(EISDIR, 'open', link.getPath()); fd = this.openSync(id as PathLike, flagsNum); } @@ -1085,17 +1154,26 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { } private linkBase(filename1: string, filename2: string) { - const steps1 = filenameToSteps(filename1); - const link1 = this.getLink(steps1); - if (!link1) throw createError(ENOENT, 'link', filename1, filename2); - - const steps2 = filenameToSteps(filename2); + let link1: Link; + try { + link1 = this.getLinkOrThrow(filename1, 'link'); + } catch (err) { + // Augment error with filename2 + if (err.code) err = createError(err.code, 'link', filename1, filename2); + throw err; + } - // Check new link directory exists. - const dir2 = this.getLinkParent(steps2); - if (!dir2) throw createError(ENOENT, 'link', filename1, filename2); + const dirname2 = pathModule.dirname(filename2); + let dir2: Link; + try { + dir2 = this.getLinkOrThrow(dirname2, 'link'); + } catch (err) { + // Augment error with filename1 + if (err.code) err = createError(err.code, 'link', filename1, filename2); + throw err; + } - const name = steps2[steps2.length - 1]; + const name = pathModule.basename(filename2); // Check if new file already exists. if (dir2.getChild(name)) throw createError(EEXIST, 'link', filename1, filename2); @@ -1163,9 +1241,7 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { } private unlinkBase(filename: string) { - const steps = filenameToSteps(filename); - const link = this.getLink(steps); - if (!link) throw createError(ENOENT, 'unlink', filename); + const link: Link = this.getLinkOrThrow(filename, 'unlink'); // TODO: Check if it is file, dir, other... @@ -1196,14 +1272,26 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { const pathSteps = filenameToSteps(pathFilename); // Check if directory exists, where we about to create a symlink. - const dirLink = this.getLinkParent(pathSteps); - if (!dirLink) throw createError(ENOENT, 'symlink', targetFilename, pathFilename); + let dirLink; + try { + dirLink = this.getLinkParentAsDirOrThrow(pathSteps); + } catch (err) { + // Catch error to populate with the correct fields - getLinkParentAsDirOrThrow won't be aware of the second path + if (err.code) err = createError(err.code, 'symlink', targetFilename, pathFilename); + throw err; + } const name = pathSteps[pathSteps.length - 1]; // Check if new file already exists. if (dirLink.getChild(name)) throw createError(EEXIST, 'symlink', targetFilename, pathFilename); + // Check permissions on the path where we are creating the symlink. + // Note we're not checking permissions on the target path: It is not an error to create a symlink to a + // non-existent or inaccessible target + const node = dirLink.getNode(); + if (!node.canExecute() || !node.canWrite()) throw createError(EACCES, 'symlink', targetFilename, pathFilename); + // Create symlink. const symlink: Link = dirLink.createChild(name); symlink.getNode().makeSymlink(filenameToSteps(targetFilename)); @@ -1227,9 +1315,8 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { } private realpathBase(filename: string, encoding: TEncodingExtended | undefined): TDataOut { - const steps = filenameToSteps(filename); - const realLink = this.getResolvedLink(steps); - if (!realLink) throw createError(ENOENT, 'realpath', filename); + debugger; + const realLink = this.getResolvedLinkOrThrow(filename, 'realpath'); return strToEncoding(realLink.getPath() || '/', encoding); } @@ -1251,15 +1338,15 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { private lstatBase(filename: string, bigint: true, throwIfNoEntry: false): Stats | undefined; private lstatBase(filename: string, bigint: false, throwIfNoEntry: false): Stats | undefined; private lstatBase(filename: string, bigint = false, throwIfNoEntry = false): Stats | undefined { - const link = this.getLink(filenameToSteps(filename)); - - if (link) { - return Stats.build(link.getNode(), bigint); - } else if (!throwIfNoEntry) { - return undefined; - } else { - throw createError(ENOENT, 'lstat', filename); + let link: Link; + try { + link = this.getLinkOrThrow(filename, 'lstat'); + } catch (err) { + if (err.code === ENOENT && !throwIfNoEntry) return undefined; + else throw err; } + + return Stats.build(link.getNode(), bigint); } lstatSync(path: PathLike): Stats; @@ -1287,14 +1374,14 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { private statBase(filename: string, bigint: true, throwIfNoEntry: false): Stats | undefined; private statBase(filename: string, bigint: false, throwIfNoEntry: false): Stats | undefined; private statBase(filename: string, bigint = false, throwIfNoEntry = true): Stats | undefined { - const link = this.getResolvedLink(filenameToSteps(filename)); - if (link) { - return Stats.build(link.getNode(), bigint); - } else if (!throwIfNoEntry) { - return undefined; - } else { - throw createError(ENOENT, 'stat', filename); + let link: Link; + try { + link = this.getResolvedLinkOrThrow(filename, 'stat'); + } catch (err) { + if (err.code === ENOENT && !throwIfNoEntry) return undefined; + else throw err; } + return Stats.build(link.getNode(), bigint); } statSync(path: PathLike): Stats; @@ -1342,28 +1429,49 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { } private renameBase(oldPathFilename: string, newPathFilename: string) { - const link = this.getLink(filenameToSteps(oldPathFilename)); - if (!link) throw createError(ENOENT, 'rename', oldPathFilename, newPathFilename); + let link: Link; + try { + link = this.getResolvedLinkOrThrow(oldPathFilename); + } catch (err) { + // Augment err with newPathFilename + if (err.code) err = createError(err.code, 'rename', oldPathFilename, newPathFilename); + throw err; + } // TODO: Check if it is directory, if non-empty, we cannot move it, right? - const newPathSteps = filenameToSteps(newPathFilename); - // Check directory exists for the new location. - const newPathDirLink = this.getLinkParent(newPathSteps); - if (!newPathDirLink) throw createError(ENOENT, 'rename', oldPathFilename, newPathFilename); + let newPathDirLink: Link; + try { + newPathDirLink = this.getLinkParentAsDirOrThrow(newPathFilename); + } catch (err) { + // Augment error with oldPathFilename + if (err.code) err = createError(err.code, 'rename', oldPathFilename, newPathFilename); + throw err; + } // TODO: Also treat cases with directories and symbolic links. // TODO: See: http://man7.org/linux/man-pages/man2/rename.2.html // Remove hard link from old folder. const oldLinkParent = link.parent; - if (oldLinkParent) { - oldLinkParent.deleteChild(link); + + // Check we have access and write permissions in both places + const oldParentNode: Node = oldLinkParent.getNode(); + const newPathDirNode: Node = newPathDirLink.getNode(); + if ( + !oldParentNode.canExecute() || + !oldParentNode.canWrite() || + !newPathDirNode.canExecute() || + !newPathDirNode.canWrite() + ) { + throw createError(EACCES, 'rename', oldPathFilename, newPathFilename); } + oldLinkParent.deleteChild(link); + // Rename should overwrite the new path, if that exists. - const name = newPathSteps[newPathSteps.length - 1]; + const name = pathModule.basename(newPathFilename); link.name = name; link.steps = [...newPathDirLink.steps, name]; newPathDirLink.setChild(link.getName(), link); @@ -1409,8 +1517,6 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { private accessBase(filename: string, mode: number) { const link = this.getLinkOrThrow(filename, 'access'); - - // TODO: Verify permissions } accessSync(path: PathLike, mode: number = F_OK) { @@ -1459,12 +1565,14 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { private readdirBase(filename: string, options: opts.IReaddirOptions): TDataOut[] | Dirent[] { const steps = filenameToSteps(filename); - const link: Link | null = this.getResolvedLink(steps); - if (!link) throw createError(ENOENT, 'readdir', filename); + const link: Link = this.getResolvedLinkOrThrow(filename, 'scandir'); const node = link.getNode(); if (!node.isDirectory()) throw createError(ENOTDIR, 'scandir', filename); + // Check we have permissions + if (!node.canRead()) throw createError(EACCES, 'scandir', filename); + const list: Dirent[] = []; // output list for (const name of link.children.keys()) { @@ -1499,7 +1607,7 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { return list.map(dirent => { if (options.recursive) { - let fullPath = pathModule.join(dirent.path, dirent.name.toString()); + let fullPath = pathModule.join(dirent.parentPath, dirent.name.toString()); if (isWin) { fullPath = fullPath.replace(/\\/g, '/'); } @@ -1634,21 +1742,37 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { this.wrapAsync(this.futimesBase, [fd, toUnixTimestamp(atime), toUnixTimestamp(mtime)], callback); } - private utimesBase(filename: string, atime: number, mtime: number) { - const fd = this.openSync(filename, 'r'); - try { - this.futimesBase(fd, atime, mtime); - } finally { - this.closeSync(fd); - } + private utimesBase(filename: string, atime: number, mtime: number, followSymlinks: boolean = true) { + const link = followSymlinks + ? this.getResolvedLinkOrThrow(filename, 'utimes') + : this.getLinkOrThrow(filename, 'lutimes'); + const node = link.getNode(); + node.atime = new Date(atime * 1000); + node.mtime = new Date(mtime * 1000); } utimesSync(path: PathLike, atime: TTime, mtime: TTime) { - this.utimesBase(pathToFilename(path), toUnixTimestamp(atime), toUnixTimestamp(mtime)); + this.utimesBase(pathToFilename(path), toUnixTimestamp(atime), toUnixTimestamp(mtime), true); } utimes(path: PathLike, atime: TTime, mtime: TTime, callback: TCallback) { - this.wrapAsync(this.utimesBase, [pathToFilename(path), toUnixTimestamp(atime), toUnixTimestamp(mtime)], callback); + this.wrapAsync( + this.utimesBase, + [pathToFilename(path), toUnixTimestamp(atime), toUnixTimestamp(mtime), true], + callback, + ); + } + + lutimesSync(path: PathLike, atime: TTime, mtime: TTime): void { + this.utimesBase(pathToFilename(path), toUnixTimestamp(atime), toUnixTimestamp(mtime), false); + } + + lutimes(path: PathLike, atime: TTime, mtime: TTime, callback: TCallback): void { + this.wrapAsync( + this.utimesBase, + [pathToFilename(path), toUnixTimestamp(atime), toUnixTimestamp(mtime), false], + callback, + ); } private mkdirBase(filename: string, modeNum: number) { @@ -1665,35 +1789,50 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { const name = steps[steps.length - 1]; if (dir.getChild(name)) throw createError(EEXIST, 'mkdir', filename); + const node = dir.getNode(); + if (!node.canWrite() || !node.canExecute()) throw createError(EACCES, 'mkdir', filename); + dir.createChild(name, this.createNode(true, modeNum)); } /** * Creates directory tree recursively. - * @param filename - * @param modeNum */ private mkdirpBase(filename: string, modeNum: number) { - const fullPath = resolve(filename); - const fullPathSansSlash = fullPath.substring(1); - const steps = !fullPathSansSlash ? [] : fullPathSansSlash.split(sep); - let link = this.root; let created = false; - for (let i = 0; i < steps.length; i++) { - const step = steps[i]; - - if (!link.getNode().isDirectory()) throw createError(ENOTDIR, 'mkdir', link.getPath()); + const steps = filenameToSteps(filename); - const child = link.getChild(step); - if (child) { - if (child.getNode().isDirectory()) link = child; - else throw createError(ENOTDIR, 'mkdir', child.getPath()); + let curr: Link | null = null; + let i = steps.length; + // Find the longest subpath of filename that still exists: + for (i = steps.length; i >= 0; i--) { + curr = this.getResolvedLink(steps.slice(0, i)); + if (curr) break; + } + if (!curr) { + curr = this.root; + i = 0; + } + // curr is now the last directory that still exists. + // (If none of them existed, curr is the root.) + // Check access the lazy way: + curr = this.getResolvedLinkOrThrow(sep + steps.slice(0, i).join(sep), 'mkdir'); + + // Start creating directories: + for (i; i < steps.length; i++) { + const node = curr.getNode(); + + if (node.isDirectory()) { + // Check we have permissions + if (!node.canExecute() || !node.canWrite()) throw createError(EACCES, 'mkdir', filename); } else { - link = link.createChild(step, this.createNode(true, modeNum)); - created = true; + throw createError(ENOTDIR, 'mkdir', filename); } + + created = true; + curr = curr.createChild(steps[i], this.createNode(true, modeNum)); } - return created ? fullPath : undefined; + return created ? filename : undefined; } mkdirSync(path: PathLike, options: opts.IMkdirOptions & { recursive: true }): string | undefined; @@ -1778,17 +1917,21 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { } private rmBase(filename: string, options: opts.IRmOptions = {}): void { - const link = this.getResolvedLink(filename); - if (!link) { - // "stat" is used to match Node's native error message. - if (!options.force) throw createError(ENOENT, 'stat', filename); - return; - } - if (link.getNode().isDirectory()) { - if (!options.recursive) { - throw createError(ERR_FS_EISDIR, 'rm', filename); - } + // "stat" is used to match Node's native error message. + let link: Link; + try { + link = this.getResolvedLinkOrThrow(filename, 'stat'); + } catch (err) { + // Silently ignore missing paths if force option is true + if (err.code === ENOENT && options.force) return; + else throw err; } + + if (link.getNode().isDirectory() && !options.recursive) throw createError(ERR_FS_EISDIR, 'rm', filename); + + // Check permissions + if (!link.parent.getNode().canWrite()) throw createError(EACCES, 'rm', filename); + this.deleteLink(link); } @@ -1816,19 +1959,18 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { this.wrapAsync(this.fchmodBase, [fd, modeToNumber(mode)], callback); } - private chmodBase(filename: string, modeNum: number) { - const fd = this.openSync(filename, 'r'); - try { - this.fchmodBase(fd, modeNum); - } finally { - this.closeSync(fd); - } + private chmodBase(filename: string, modeNum: number, followSymlinks: boolean = true) { + const link = followSymlinks + ? this.getResolvedLinkOrThrow(filename, 'chmod') + : this.getLinkOrThrow(filename, 'chmod'); + const node = link.getNode(); + node.chmod(modeNum); } chmodSync(path: PathLike, mode: TMode) { const modeNum = modeToNumber(mode); const filename = pathToFilename(path); - this.chmodBase(filename, modeNum); + this.chmodBase(filename, modeNum, true); } chmod(path: PathLike, mode: TMode, callback: TCallback) { @@ -1838,12 +1980,7 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { } private lchmodBase(filename: string, modeNum: number) { - const fd = this.openBase(filename, O_RDWR, 0, false); - try { - this.fchmodBase(fd, modeNum); - } finally { - this.closeSync(fd); - } + this.chmodBase(filename, modeNum, false); } lchmodSync(path: PathLike, mode: TMode) { @@ -2011,18 +2148,14 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { } public cpSync: FsSynchronousApi['cpSync'] = notImplemented; - public lutimesSync: FsSynchronousApi['lutimesSync'] = notImplemented; public statfsSync: FsSynchronousApi['statfsSync'] = notImplemented; public cp: FsCallbackApi['cp'] = notImplemented; - public lutimes: FsCallbackApi['lutimes'] = notImplemented; public statfs: FsCallbackApi['statfs'] = notImplemented; public openAsBlob: FsCallbackApi['openAsBlob'] = notImplemented; private opendirBase(filename: string, options: opts.IOpendirOptions): Dir { - const steps = filenameToSteps(filename); - const link: Link | null = this.getResolvedLink(steps); - if (!link) throw createError(ENOENT, 'opendir', filename); + const link: Link = this.getResolvedLinkOrThrow(filename, 'scandir'); const node = link.getNode(); if (!node.isDirectory()) throw createError(ENOTDIR, 'scandir', filename); diff --git a/yarn.lock b/yarn.lock index e96813a7e..62445a642 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1856,10 +1856,10 @@ binary-extensions@^2.0.0, binary-extensions@^2.2.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== -body-parser@1.20.2: - version "1.20.2" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" - integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== +body-parser@1.20.3: + version "1.20.3" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" + integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== dependencies: bytes "3.1.2" content-type "~1.0.5" @@ -1869,7 +1869,7 @@ body-parser@1.20.2: http-errors "2.0.0" iconv-lite "0.4.24" on-finished "2.4.1" - qs "6.11.0" + qs "6.13.0" raw-body "2.5.2" type-is "~1.6.18" unpipe "1.0.0" @@ -2750,6 +2750,11 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== +encodeurl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + encoding@^0.1.13: version "0.1.13" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" @@ -2925,36 +2930,36 @@ exponential-backoff@^3.1.1: integrity sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw== express@^4.17.3: - version "4.19.2" - resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" - integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== + version "4.21.0" + resolved "https://registry.yarnpkg.com/express/-/express-4.21.0.tgz#d57cb706d49623d4ac27833f1cbc466b668eb915" + integrity sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng== dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "1.20.2" + body-parser "1.20.3" content-disposition "0.5.4" content-type "~1.0.4" cookie "0.6.0" cookie-signature "1.0.6" debug "2.6.9" depd "2.0.0" - encodeurl "~1.0.2" + encodeurl "~2.0.0" escape-html "~1.0.3" etag "~1.8.1" - finalhandler "1.2.0" + finalhandler "1.3.1" fresh "0.5.2" http-errors "2.0.0" - merge-descriptors "1.0.1" + merge-descriptors "1.0.3" methods "~1.1.2" on-finished "2.4.1" parseurl "~1.3.3" - path-to-regexp "0.1.7" + path-to-regexp "0.1.10" proxy-addr "~2.0.7" - qs "6.11.0" + qs "6.13.0" range-parser "~1.2.1" safe-buffer "5.2.1" - send "0.18.0" - serve-static "1.15.0" + send "0.19.0" + serve-static "1.16.2" setprototypeof "1.2.0" statuses "2.0.1" type-is "~1.6.18" @@ -3046,13 +3051,13 @@ fill-range@^7.1.1: dependencies: to-regex-range "^5.0.1" -finalhandler@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" - integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== +finalhandler@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.1.tgz#0c575f1d1d324ddd1da35ad7ece3df7d19088019" + integrity sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ== dependencies: debug "2.6.9" - encodeurl "~1.0.2" + encodeurl "~2.0.0" escape-html "~1.0.3" on-finished "2.4.1" parseurl "~1.3.3" @@ -4771,10 +4776,10 @@ meow@^8.0.0: type-fest "^0.18.0" yargs-parser "^20.2.3" -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== +merge-descriptors@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== merge-stream@^2.0.0: version "2.0.0" @@ -5622,10 +5627,10 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" -path-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== +path-to-regexp@0.1.10: + version "0.1.10" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.10.tgz#67e9108c5c0551b9e5326064387de4763c4d5f8b" + integrity sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w== path-type@^4.0.0: version "4.0.0" @@ -5798,14 +5803,7 @@ qrcode-terminal@^0.12.0: resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819" integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== -qs@6.11.0: - version "6.11.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" - integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== - dependencies: - side-channel "^1.0.4" - -qs@^6.12.3: +qs@6.13.0, qs@^6.12.3: version "6.13.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== @@ -6198,10 +6196,10 @@ semver@^7.0.0, semver@^7.1.1, semver@^7.1.2, semver@^7.3.2, semver@^7.3.4, semve resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== -send@0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" - integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== +send@0.19.0: + version "0.19.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" + integrity sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw== dependencies: debug "2.6.9" depd "2.0.0" @@ -6237,15 +6235,15 @@ serve-index@^1.9.1: mime-types "~2.1.17" parseurl "~1.3.2" -serve-static@1.15.0: - version "1.15.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" - integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== +serve-static@1.16.2: + version "1.16.2" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.2.tgz#b6a5343da47f6bdd2673848bf45754941e803296" + integrity sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw== dependencies: - encodeurl "~1.0.2" + encodeurl "~2.0.0" escape-html "~1.0.3" parseurl "~1.3.3" - send "0.18.0" + send "0.19.0" set-blocking@^2.0.0: version "2.0.0" @@ -6306,7 +6304,7 @@ shell-quote@^1.8.1: resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680" integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA== -side-channel@^1.0.4, side-channel@^1.0.6: +side-channel@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==