Skip to content

Commit ac67ac7

Browse files
authored
Merge branch 'master' into fsa-observable
2 parents 14aa0b3 + 02ca41c commit ac67ac7

File tree

12 files changed

+294
-89
lines changed

12 files changed

+294
-89
lines changed

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,36 @@
1+
## [4.38.3](https://github.com/streamich/memfs/compare/v4.38.2...v4.38.3) (2025-09-09)
2+
3+
4+
### Bug Fixes
5+
6+
* prevent readFile from updating ctime when only accessing files ([f5f3066](https://github.com/streamich/memfs/commit/f5f3066cd6d2d7e2d8bec7414e9eda064afd7868))
7+
8+
## [4.38.2](https://github.com/streamich/memfs/compare/v4.38.1...v4.38.2) (2025-08-26)
9+
10+
11+
### Bug Fixes
12+
13+
* correct permission check logic for readonly files in copyFile operations ([a06bb4d](https://github.com/streamich/memfs/commit/a06bb4d13b3ed4bad28921f5e06a7a87f2c089b7))
14+
15+
## [4.38.1](https://github.com/streamich/memfs/compare/v4.38.0...v4.38.1) (2025-08-24)
16+
17+
18+
### Bug Fixes
19+
20+
* 🐛 use glob-to-regex library ([8962374](https://github.com/streamich/memfs/commit/89623740b78cbcf58a5b1b32d67b2e4ecd183469))
21+
22+
# [4.38.0](https://github.com/streamich/memfs/compare/v4.37.1...v4.38.0) (2025-08-24)
23+
24+
25+
### Bug Fixes
26+
27+
* handle chmod 0 permissions in existsSync and access methods ([3452bcf](https://github.com/streamich/memfs/commit/3452bcf24cd44f476687b693a1a1b6685d7353c9))
28+
29+
30+
### Features
31+
32+
* revert exists implementation ([bf209cd](https://github.com/streamich/memfs/commit/bf209cd05ed41787be8afb425077fbcdb93fb3fa))
33+
134
## [4.37.1](https://github.com/streamich/memfs/compare/v4.37.0...v4.37.1) (2025-08-22)
235

336

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "memfs",
3-
"version": "4.37.1",
3+
"version": "4.38.3",
44
"description": "In-memory file-system with Node's fs API.",
55
"keywords": [
66
"fs",
@@ -126,6 +126,7 @@
126126
"dependencies": {
127127
"@jsonjoy.com/json-pack": "^1.11.0",
128128
"@jsonjoy.com/util": "^1.9.0",
129+
"glob-to-regex.js": "^1.0.1",
129130
"thingies": "^2.5.0",
130131
"tree-dump": "^1.0.3",
131132
"tslib": "^2.0.0"

src/__tests__/node.test.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,15 @@ describe('node.ts', () => {
6060
node.read(buf, 0, 1, 1);
6161
expect(buf.equals(Buffer.from([2]))).toBe(true);
6262
});
63-
it('updates the atime and ctime', () => {
63+
it('updates the atime but not ctime', () => {
6464
const node = new Node(1);
6565
const oldAtime = node.atime;
6666
const oldCtime = node.ctime;
6767
node.read(Buffer.alloc(0));
6868
const newAtime = node.atime;
6969
const newCtime = node.ctime;
7070
expect(newAtime).not.toBe(oldAtime);
71-
expect(newCtime).not.toBe(oldCtime);
71+
expect(newCtime).toBe(oldCtime);
7272
});
7373
});
7474
describe('.chmod(perm)', () => {
@@ -79,7 +79,7 @@ describe('node.ts', () => {
7979
expect(node.perm).toBe(0o600);
8080
expect(node.isFile()).toBe(true);
8181
});
82-
describe.each(['uid', 'gid', 'atime', 'mtime', 'perm', 'nlink'])('when %s changes', field => {
82+
describe.each(['uid', 'gid', 'mtime', 'perm', 'nlink'])('when %s changes', field => {
8383
it('updates the property and the ctime', () => {
8484
const node = new Node(1);
8585
const oldCtime = node.ctime;
@@ -89,28 +89,38 @@ describe('node.ts', () => {
8989
expect(node[field]).toBe(1);
9090
});
9191
});
92+
describe('when atime changes', () => {
93+
it('updates the property but NOT the ctime', () => {
94+
const node = new Node(1);
95+
const oldCtime = node.ctime;
96+
node.atime = new Date(1);
97+
const newCtime = node.ctime;
98+
expect(newCtime).toBe(oldCtime);
99+
expect(node.atime).toEqual(new Date(1));
100+
});
101+
});
92102
describe('.getString(encoding?)', () => {
93-
it('updates the atime and ctime', () => {
103+
it('updates the atime but not ctime', () => {
94104
const node = new Node(1);
95105
const oldAtime = node.atime;
96106
const oldCtime = node.ctime;
97107
node.getString();
98108
const newAtime = node.atime;
99109
const newCtime = node.ctime;
100110
expect(newAtime).not.toBe(oldAtime);
101-
expect(newCtime).not.toBe(oldCtime);
111+
expect(newCtime).toBe(oldCtime);
102112
});
103113
});
104114
describe('.getBuffer()', () => {
105-
it('updates the atime and ctime', () => {
115+
it('updates the atime but not ctime', () => {
106116
const node = new Node(1);
107117
const oldAtime = node.atime;
108118
const oldCtime = node.ctime;
109119
node.getBuffer();
110120
const newAtime = node.atime;
111121
const newCtime = node.ctime;
112122
expect(newAtime).not.toBe(oldAtime);
113-
expect(newCtime).not.toBe(oldCtime);
123+
expect(newCtime).toBe(oldCtime);
114124
});
115125
});
116126
});

src/core/Node.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ export class Node {
7575

7676
public set atime(atime: Date) {
7777
this._atime = atime;
78-
this.ctime = new Date();
7978
}
8079

8180
public get atime(): Date {
@@ -122,7 +121,7 @@ export class Node {
122121

123122
getBuffer(): Buffer {
124123
this.atime = new Date();
125-
if (!this.buf) this.setBuffer(bufferAllocUnsafe(0));
124+
if (!this.buf) this.buf = bufferAllocUnsafe(0);
126125
return bufferFrom(this.buf); // Return a copy.
127126
}
128127

src/core/Superblock.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,12 +444,14 @@ export class Superblock {
444444
}
445445

446446
// Check node permissions
447-
if (!(flagsNum & O_WRONLY)) {
447+
// For read access: check if flags are O_RDONLY or O_RDWR (i.e., not only O_WRONLY)
448+
if ((flagsNum & (O_RDONLY | O_RDWR | O_WRONLY)) !== O_WRONLY) {
448449
if (!node.canRead()) {
449450
throw createError(ERROR_CODE.EACCES, 'open', link.getPath());
450451
}
451452
}
452-
if (!(flagsNum & O_RDONLY)) {
453+
// For write access: check if flags are O_WRONLY or O_RDWR
454+
if (flagsNum & (O_WRONLY | O_RDWR)) {
453455
if (!node.canWrite()) {
454456
throw createError(ERROR_CODE.EACCES, 'open', link.getPath());
455457
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { create } from '../../../__tests__/util';
2+
import { AMODE } from '../../../consts/AMODE';
3+
4+
describe('chmod 0 permission issue reproduction', () => {
5+
describe('existsSync()', () => {
6+
test('chmod on file and existsSync', () => {
7+
// Arrange
8+
const vol = create({ '/path/to/file.txt': 'some text' });
9+
vol.chmodSync('/path/to/file.txt', 0o0000);
10+
11+
// Act
12+
const exists = vol.existsSync('/path/to/file.txt');
13+
const exists2 = vol.existsSync('/path/to/file2.txt');
14+
const exists3 = vol.existsSync('/path/to3/file3.txt');
15+
16+
// Assert
17+
expect(exists).toBe(true);
18+
expect(exists2).toBe(false);
19+
expect(exists3).toBe(false);
20+
});
21+
22+
test('chmod on directory and existsSync', () => {
23+
// Arrange
24+
const vol = create({ '/path/to/file.txt': 'some text' });
25+
vol.chmodSync('/path/to/', 0o0000);
26+
27+
// Act
28+
const exists = vol.existsSync('/path/to/file.txt');
29+
30+
// Assert
31+
expect(exists).toBe(false);
32+
});
33+
});
34+
35+
test.each([AMODE.R_OK, AMODE.W_OK])('chmod on file and access mode %d', mode => {
36+
// Arrange
37+
const vol = create({ '/path/to/file.txt': 'some text' });
38+
vol.accessSync('/path/to/file.txt', mode);
39+
vol.chmodSync('/path/to/file.txt', 0o0000);
40+
41+
// Act & Assert
42+
expect(() => {
43+
vol.accessSync('/path/to/file.txt', mode);
44+
}).toThrow();
45+
});
46+
47+
test.each([AMODE.R_OK, AMODE.W_OK])('chmod on directory and access mode %d', mode => {
48+
// Arrange
49+
const vol = create({ '/path/to/file.txt': 'some text' });
50+
vol.accessSync('/path/to/file.txt', mode);
51+
vol.chmodSync('/path/to/', 0o0000);
52+
53+
// Act & Assert
54+
expect(() => {
55+
vol.accessSync('/path/to/file.txt', mode);
56+
}).toThrow();
57+
});
58+
59+
test('chmod on file and access F_OK should not throw', () => {
60+
const vol = create({ '/path/to/file.txt': 'some text' });
61+
vol.accessSync('/path/to/file.txt', AMODE.F_OK);
62+
vol.chmodSync('/path/to/file.txt', 0o0000);
63+
vol.accessSync('/path/to/file.txt', AMODE.F_OK);
64+
});
65+
66+
// Test the exact scenario from the issue (https://github.com/streamich/memfs/issues/1172)
67+
test('chmod on file and promises access with fs.constants', async () => {
68+
const vol = create({ '/path/to/file.txt': 'some text' });
69+
70+
await vol.promises.chmod('/path/to/file.txt', 0o0000);
71+
72+
// Should throw for R_OK and W_OK
73+
await expect(vol.promises.access('/path/to/file.txt', AMODE.R_OK)).rejects.toThrow();
74+
await expect(vol.promises.access('/path/to/file.txt', AMODE.R_OK | AMODE.F_OK)).rejects.toThrow();
75+
await expect(vol.promises.access('/path/to/file.txt', AMODE.W_OK)).rejects.toThrow();
76+
await expect(vol.promises.access('/path/to/file.txt', AMODE.W_OK | AMODE.F_OK)).rejects.toThrow();
77+
await expect(vol.promises.access('/path/to/file.txt', AMODE.W_OK | AMODE.R_OK)).rejects.toThrow();
78+
await expect(vol.promises.access('/path/to/file.txt', AMODE.W_OK | AMODE.R_OK | AMODE.F_OK)).rejects.toThrow();
79+
80+
// F_OK should NOT throw - it just checks existence, which it does exist
81+
await expect(vol.promises.access('/path/to/file.txt', AMODE.F_OK)).resolves.toBeUndefined();
82+
});
83+
});

src/node/__tests__/volume/copyFile.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,23 @@ describe('copyFile(src, dest[, flags], callback)', () => {
9999
});
100100
});
101101

102+
it('copying readonly source file should succeed', done => {
103+
const vol = create({ '/foo': 'hello world' });
104+
vol.chmodSync('/foo', 0o400); // read-only for owner
105+
106+
// This should not throw - we can read the file even though it's read-only
107+
vol.copyFile('/foo', '/bar', err => {
108+
try {
109+
expect(err).toBeNull();
110+
expect(vol.readFileSync('/foo', 'utf8')).toBe('hello world');
111+
expect(vol.readFileSync('/bar', 'utf8')).toBe('hello world');
112+
done();
113+
} catch (failure) {
114+
done(failure);
115+
}
116+
});
117+
});
118+
102119
it('copying gives EACCES with insufficient permissions on an intermediate directory', done => {
103120
const vol = create({ '/foo/test': 'test' });
104121
vol.mkdirSync('/bar');

src/node/__tests__/volume/copyFileSync.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,18 @@ describe('copyFileSync(src, dest[, flags])', () => {
125125
}).toThrowError(/EACCES/);
126126
});
127127
});
128+
129+
it('copying readonly source file should succeed', () => {
130+
const vol = create({ '/foo': 'hello world' });
131+
vol.chmodSync('/foo', 0o400); // read-only for owner
132+
133+
// This should not throw - we can read the file even though it's read-only
134+
vol.copyFileSync('/foo', '/bar');
135+
136+
expect(vol.readFileSync('/foo', 'utf8')).toBe('hello world');
137+
expect(vol.readFileSync('/bar', 'utf8')).toBe('hello world');
138+
});
139+
128140
it('copying throws EACCES with insufficient permissions an intermediate directory', () => {
129141
const vol = create({ '/foo/test': 'test' });
130142
vol.mkdirSync('/bar');
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { memfs } from '../../..';
2+
3+
describe('readFile ctime issue', () => {
4+
it('should NOT change ctime when readFile is called', async () => {
5+
const { fs } = memfs();
6+
7+
const file = '/test-readfile-ctime.txt';
8+
9+
// Create the file
10+
await fs.promises.writeFile(file, 'test content');
11+
12+
// Get initial ctime
13+
const initialStat = await fs.promises.stat(file);
14+
const initialCtime = initialStat.ctimeMs;
15+
16+
// Wait a bit to ensure any ctime change would be detectable
17+
await new Promise(resolve => setTimeout(resolve, 10));
18+
19+
// Read the file (this should only update atime, not ctime)
20+
await fs.promises.readFile(file);
21+
22+
// Check ctime again
23+
const afterReadStat = await fs.promises.stat(file);
24+
const afterReadCtime = afterReadStat.ctimeMs;
25+
26+
// ctime should be unchanged after read
27+
expect(afterReadCtime).toBe(initialCtime);
28+
});
29+
30+
it('should change atime when readFile is called', async () => {
31+
const { fs } = memfs();
32+
33+
const file = '/test-readfile-atime.txt';
34+
35+
// Create the file
36+
await fs.promises.writeFile(file, 'test content');
37+
38+
// Get initial atime
39+
const initialStat = await fs.promises.stat(file);
40+
const initialAtime = initialStat.atimeMs;
41+
42+
// Wait a bit to ensure any atime change would be detectable
43+
await new Promise(resolve => setTimeout(resolve, 10));
44+
45+
// Read the file (this should update atime)
46+
await fs.promises.readFile(file);
47+
48+
// Check atime again
49+
const afterReadStat = await fs.promises.stat(file);
50+
const afterReadAtime = afterReadStat.atimeMs;
51+
52+
// atime should be updated after read
53+
expect(afterReadAtime).toBeGreaterThan(initialAtime);
54+
});
55+
56+
it('should NOT change ctime when readFileSync is called', () => {
57+
const { fs } = memfs();
58+
59+
const file = '/test-readfilesync-ctime.txt';
60+
61+
// Create the file
62+
fs.writeFileSync(file, 'test content');
63+
64+
// Get initial ctime
65+
const initialStat = fs.statSync(file);
66+
const initialCtime = initialStat.ctimeMs;
67+
68+
// Read the file (this should only update atime, not ctime)
69+
fs.readFileSync(file);
70+
71+
// Check ctime again
72+
const afterReadStat = fs.statSync(file);
73+
const afterReadCtime = afterReadStat.ctimeMs;
74+
75+
// ctime should be unchanged after read
76+
expect(afterReadCtime).toBe(initialCtime);
77+
});
78+
});

0 commit comments

Comments
 (0)