@zenfs/core 2.7.6 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +5 -0
  2. package/dist/backends/index.d.ts +3 -1
  3. package/dist/backends/index.js +3 -1
  4. package/dist/backends/single_buffer/backend.d.ts +42 -0
  5. package/dist/backends/single_buffer/backend.js +204 -0
  6. package/dist/backends/{single_buffer.d.ts → single_buffer/v1.d.ts} +24 -34
  7. package/dist/backends/{single_buffer.js → single_buffer/v1.js} +10 -26
  8. package/dist/backends/single_buffer/v2.d.ts +257 -0
  9. package/dist/backends/single_buffer/v2.js +1283 -0
  10. package/dist/backends/store/fs.d.ts +1 -1
  11. package/dist/backends/store/fs.js +35 -18
  12. package/dist/backends/store/map.d.ts +6 -6
  13. package/dist/backends/store/map.js +6 -6
  14. package/dist/backends/store/store.d.ts +18 -14
  15. package/dist/backends/store/store.js +12 -14
  16. package/dist/config.d.ts +7 -0
  17. package/dist/config.js +9 -3
  18. package/dist/internal/contexts.d.ts +5 -0
  19. package/dist/internal/contexts.js +7 -1
  20. package/dist/internal/error.d.ts +5 -0
  21. package/dist/internal/error.js +10 -0
  22. package/dist/internal/inode.d.ts +25 -0
  23. package/dist/internal/inode.js +75 -10
  24. package/dist/mixins/async.js +47 -30
  25. package/dist/node/promises.js +23 -124
  26. package/dist/node/stats.d.ts +1 -1
  27. package/dist/node/stats.js +4 -1
  28. package/dist/node/sync.js +20 -51
  29. package/dist/polyfills.js +16 -0
  30. package/dist/vfs/async.d.ts +4 -1
  31. package/dist/vfs/async.js +149 -69
  32. package/dist/vfs/config.d.ts +10 -1
  33. package/dist/vfs/config.js +5 -2
  34. package/dist/vfs/file.js +10 -4
  35. package/dist/vfs/ioctl.js +2 -2
  36. package/dist/vfs/shared.d.ts +2 -0
  37. package/dist/vfs/shared.js +3 -4
  38. package/dist/vfs/sync.d.ts +4 -1
  39. package/dist/vfs/sync.js +142 -64
  40. package/dist/vfs/vcache.d.ts +14 -14
  41. package/dist/vfs/vcache.js +65 -57
  42. package/dist/vfs/watchers.d.ts +1 -0
  43. package/dist/vfs/watchers.js +2 -4
  44. package/dist/vfs/xattr.js +18 -17
  45. package/package.json +3 -4
  46. package/scripts/make-index.js +1 -1
  47. package/tests/backend/single-buffer-rotate.worker.js +1 -1
  48. package/tests/backend/single-buffer-v2.test.ts +342 -0
  49. package/tests/backend/single-buffer-v2.worker.js +51 -0
  50. package/tests/backend/single-buffer.test.ts +6 -6
  51. package/tests/backend/single-buffer.worker.js +1 -0
  52. package/tests/common/async.test.ts +46 -0
  53. package/tests/common/config.test.ts +1 -1
  54. package/tests/common/context.test.ts +30 -0
  55. package/tests/common/file_index.test.ts +1 -1
  56. package/tests/common/mounts.test.ts +12 -0
  57. package/tests/common/vcache.test.ts +13 -0
  58. package/tests/fs/concurrency.test.ts +3 -3
  59. package/tests/fs/links.test.ts +76 -0
  60. package/tests/fs/permissions.test.ts +253 -0
  61. package/tests/fs/stat.test.ts +1 -1
  62. package/tests/fs/streams.test.ts +1 -1
  63. package/tests/fs/watch.test.ts +14 -0
  64. package/tests/fs/xattr.test.ts +10 -0
  65. package/tests/perf/config.json +31 -1
  66. package/tests/perf/single-buffer-random.ts +38 -0
  67. package/tests/perf/single-buffer-read.ts +28 -0
  68. package/tests/perf/single-buffer-write.ts +41 -0
  69. package/tests/setup/single-buffer-v2.ts +10 -0
  70. package/tests/setup/single-buffer.ts +1 -0
@@ -134,4 +134,34 @@ suite('Context', () => {
134
134
 
135
135
  assert.doesNotThrow(() => ctx.bind({ credentials: { uid: 500, gid: 500 } }));
136
136
  });
137
+
138
+ test('umask is inherited by children', () => {
139
+ const parent = bindContext({ umask: 0o027 });
140
+ assert.equal(parent.bind({}).umask, 0o027);
141
+ assert.equal(parent.bind({ umask: 0o077 }).umask, 0o077);
142
+ });
143
+
144
+ test('umask applies to created files and directories', async () => {
145
+ fs.mkdirSync('/masked');
146
+ const masked = bindContext({ root: '/masked', umask: 0o027 });
147
+
148
+ masked.fs.writeFileSync('/sync.txt', '', { mode: 0o666 });
149
+ assert.equal(masked.fs.statSync('/sync.txt').mode & 0o7777, 0o640);
150
+
151
+ await masked.fs.promises.writeFile('/async.txt', '', { mode: 0o666 });
152
+ assert.equal(masked.fs.statSync('/async.txt').mode & 0o7777, 0o640);
153
+
154
+ masked.fs.mkdirSync('/a/b', { recursive: true, mode: 0o777 });
155
+ assert.equal(masked.fs.statSync('/a').mode & 0o7777, 0o750);
156
+ assert.equal(masked.fs.statSync('/a/b').mode & 0o7777, 0o750);
157
+
158
+ await masked.fs.promises.mkdir('/c', 0o1777);
159
+ assert.equal(masked.fs.statSync('/c').mode & 0o7777, 0o1750);
160
+
161
+ masked.fs.symlinkSync('/sync.txt', '/link');
162
+ assert.equal(masked.fs.lstatSync('/link').mode & 0o7777, 0o777);
163
+
164
+ masked.fs.chmodSync('/sync.txt', 0o666);
165
+ assert.equal(masked.fs.statSync('/sync.txt').mode & 0o7777, 0o666);
166
+ });
137
167
  });
@@ -45,7 +45,7 @@ suite('Index', () => {
45
45
  const index = buildIndex(60_000);
46
46
  assert.equal(
47
47
  atDepth(1000, () => index._alloc()),
48
- 120_002
48
+ 120_002,
49
49
  );
50
50
  });
51
51
  });
@@ -19,6 +19,18 @@ suite('Mounts', () => {
19
19
  fs.rmSync('/nested', { recursive: true, force: true });
20
20
  });
21
21
 
22
+ test('lstat of a mount point is the mounted root #326', async () => {
23
+ await configure({ mounts: { '/mounted': InMemory } });
24
+
25
+ await fs.promises.utimes('/mounted', 1, 1);
26
+ assert.equal(fs.statSync('/mounted').mtimeMs, 1000);
27
+ assert.equal(fs.lstatSync('/mounted').mtimeMs, 1000);
28
+ assert.equal((await fs.promises.lstat('/mounted')).mtimeMs, 1000);
29
+
30
+ fs.umount('/mounted');
31
+ fs.rmdirSync('/mounted');
32
+ });
33
+
22
34
  test('Race conditions', async () => {
23
35
  await configure({
24
36
  mounts: {
@@ -0,0 +1,13 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ import { InMemory } from '@zenfs/core';
3
+ import { withExceptionContext } from '@zenfs/core/internal/error';
4
+ import { cacheOf } from '@zenfs/core/vfs/vcache';
5
+ import assert from 'node:assert/strict';
6
+ import { suite, test } from 'node:test';
7
+
8
+ suite('VCache', () => {
9
+ test('does not keep the exception context of the first caller', () => {
10
+ const fs = InMemory.create({ label: 'vcache' });
11
+ assert.equal(cacheOf(withExceptionContext(fs, { path: '/first' })).fs, fs);
12
+ });
13
+ });
@@ -67,7 +67,7 @@ suite('Concurrency', config('write', 'async'), () => {
67
67
  const rejected = results.filter(r => r.status === 'rejected');
68
68
  assert.deepEqual(
69
69
  rejected.map(r => r.reason.code ?? r.reason.message),
70
- []
70
+ [],
71
71
  );
72
72
 
73
73
  assert.equal(await fs.promises.readFile('/issue-303.txt', 'utf8'), contents);
@@ -96,7 +96,7 @@ suite('Concurrency', config('write', 'async'), () => {
96
96
  const rejected = results.filter(r => r.status == 'rejected');
97
97
  assert.deepEqual(
98
98
  rejected.map(r => r.reason.code ?? r.reason.message),
99
- []
99
+ [],
100
100
  );
101
101
 
102
102
  for (const path of paths) assert((await fs.promises.stat(path)).isDirectory());
@@ -107,7 +107,7 @@ suite('Concurrency', config('write', 'async'), () => {
107
107
 
108
108
  assert.deepEqual(
109
109
  results.filter(result => result !== undefined),
110
- ['/308-same']
110
+ ['/308-same'],
111
111
  );
112
112
  });
113
113
  });
@@ -18,6 +18,16 @@ suite('Links', config('symlinks'), () => {
18
18
  assert(stats.isSymbolicLink());
19
19
  });
20
20
 
21
+ test('symlinks have mode 0o777 #326', async () => {
22
+ assert.equal((await fs.promises.lstat(symlink)).mode & 0o7777, 0o777);
23
+
24
+ fs.symlinkSync(target, 'symlink-sync.js');
25
+ const stats = fs.lstatSync('symlink-sync.js');
26
+ assert(stats.isSymbolicLink());
27
+ assert.equal(stats.mode & 0o7777, 0o777);
28
+ fs.unlinkSync('symlink-sync.js');
29
+ });
30
+
21
31
  test('lstat file inside symlinked directory #241', async () => {
22
32
  await fs.promises.mkdir('/a');
23
33
  await fs.promises.writeFile('/a/hello.txt', 'hello world');
@@ -187,6 +197,72 @@ suite('Links', config('symlinks'), () => {
187
197
  assert(fs.lstatSync('/wfS-dangling').isSymbolicLink());
188
198
  });
189
199
 
200
+ test('operations follow symlinks in parent directories', config('async'), async () => {
201
+ await fs.promises.mkdir('/parent-real');
202
+ await fs.promises.symlink('/parent-real', '/parent-link');
203
+ await fs.promises.writeFile('/parent-real/unlink', 'x');
204
+ await fs.promises.writeFile('/parent-real/rename', 'x');
205
+ await fs.promises.mkdir('/parent-real/rmdir');
206
+ await fs.promises.symlink('/parent-real/renamed', '/parent-real/symlink');
207
+ await fs.promises.symlink('/parent-real/rmdir', '/parent-real/dir-link');
208
+
209
+ await fs.promises.unlink('/parent-link/unlink');
210
+ await fs.promises.rename('/parent-link/rename', '/parent-link/renamed');
211
+ assert.equal(await fs.promises.readlink('/parent-link/symlink'), '/parent-real/renamed');
212
+ await fs.promises.lutimes('/parent-link/symlink', 1, 1);
213
+ assert.equal((await fs.promises.lstat('/parent-real/symlink')).mtimeMs, 1000);
214
+
215
+ await assert.rejects(fs.promises.rmdir('/parent-link/dir-link'), { code: 'ENOTDIR' });
216
+ await fs.promises.unlink('/parent-link/dir-link');
217
+ await fs.promises.rmdir('/parent-link/rmdir');
218
+
219
+ assert.deepEqual((await fs.promises.readdir('/parent-real')).sort(), ['renamed', 'symlink']);
220
+ });
221
+
222
+ test('operations follow symlinks in parent directories (sync)', config('sync'), () => {
223
+ fs.mkdirSync('/parentS-real');
224
+ fs.symlinkSync('/parentS-real', '/parentS-link');
225
+ fs.writeFileSync('/parentS-real/unlink', 'x');
226
+ fs.writeFileSync('/parentS-real/rename', 'x');
227
+ fs.mkdirSync('/parentS-real/rmdir');
228
+ fs.symlinkSync('/parentS-real/renamed', '/parentS-real/symlink');
229
+ fs.symlinkSync('/parentS-real/rmdir', '/parentS-real/dir-link');
230
+
231
+ fs.unlinkSync('/parentS-link/unlink');
232
+ fs.renameSync('/parentS-link/rename', '/parentS-link/renamed');
233
+ assert.equal(fs.readlinkSync('/parentS-link/symlink'), '/parentS-real/renamed');
234
+ fs.lutimesSync('/parentS-link/symlink', 1, 1);
235
+ assert.equal(fs.lstatSync('/parentS-real/symlink').mtimeMs, 1000);
236
+
237
+ assert.throws(() => fs.rmdirSync('/parentS-link/dir-link'), { code: 'ENOTDIR' });
238
+ fs.unlinkSync('/parentS-link/dir-link');
239
+ fs.rmdirSync('/parentS-link/rmdir');
240
+
241
+ assert.deepEqual(fs.readdirSync('/parentS-real').sort(), ['renamed', 'symlink']);
242
+ });
243
+
244
+ test('link follows symlinks in parent directories', config('links'), async () => {
245
+ await fs.promises.link('/parent-link/renamed', '/parent-link/hard');
246
+ assert.equal(await fs.promises.readFile('/parent-real/hard', 'utf8'), 'x');
247
+ fs.linkSync('/parentS-link/renamed', '/parentS-link/hard');
248
+ assert.equal(fs.readFileSync('/parentS-real/hard', 'utf8'), 'x');
249
+ });
250
+
251
+ test('xattr follows symlinks unless noFollow is set', config('xattr'), async () => {
252
+ await fs.xattr.set('/parent-link/renamed', 'user.parent', 'async');
253
+ assert.equal(await fs.xattr.get('/parent-real/renamed', 'user.parent', { encoding: 'utf8' }), 'async');
254
+ fs.xattr.setSync('/parentS-link/renamed', 'user.parent', 'sync');
255
+ assert.equal(fs.xattr.getSync('/parentS-real/renamed', 'user.parent', { encoding: 'utf8' }), 'sync');
256
+
257
+ await fs.xattr.set('/parent-link/symlink', 'user.link', 'on link', { noFollow: true });
258
+ assert.equal(await fs.xattr.get('/parent-real/symlink', 'user.link', { encoding: 'utf8', noFollow: true }), 'on link');
259
+ await assert.rejects(fs.xattr.get('/parent-real/renamed', 'user.link'), { code: 'ENODATA' });
260
+ assert.deepEqual(await fs.xattr.list('/parent-link/symlink'), ['user.parent']);
261
+
262
+ await fs.xattr.remove('/parent-link/renamed', 'user.parent');
263
+ assert.deepEqual(fs.xattr.listSync('/parent-real/renamed'), []);
264
+ });
265
+
190
266
  test('cp preserves a symlink by default #304', async () => {
191
267
  await fs.promises.mkdir('/cp-src');
192
268
  await fs.promises.writeFile('/cp-src/real.txt', 'contents');
@@ -1,8 +1,10 @@
1
1
  // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ import { bindContext, fs as rootFS } from '@zenfs/core';
2
3
  import { R_OK, W_OK, X_OK } from '@zenfs/core/constants';
3
4
  import { defaultContext } from '@zenfs/core/internal/contexts';
4
5
  import { hasAccess } from '@zenfs/core/internal/inode';
5
6
  import { join } from '@zenfs/core/path';
7
+ import * as vfsConfig from '@zenfs/core/vfs/config';
6
8
  import type { Exception } from 'kerium';
7
9
  import assert from 'node:assert/strict';
8
10
  import { suite, test } from 'node:test';
@@ -84,6 +86,257 @@ suite('Permissions', config('permissions'), () => {
84
86
  assert(hasAccess(defaultContext, stats, W_OK));
85
87
  }
86
88
 
89
+ test('unprivileged users can read files they do not own #326', async () => {
90
+ await rootFS.promises.writeFile('/root-owned.txt', 'shared', { mode: 0o644 });
91
+ const user = bindContext({ credentials: { uid: 1000, gid: 1000 } });
92
+
93
+ assert.equal(user.fs.readFileSync('/root-owned.txt', 'utf8'), 'shared');
94
+ assert.equal(await user.fs.promises.readFile('/root-owned.txt', 'utf8'), 'shared');
95
+
96
+ assert.throws(() => user.fs.writeFileSync('/root-owned.txt', 'nope'), { code: 'EACCES' });
97
+ await assert.rejects(user.fs.promises.writeFile('/root-owned.txt', 'nope'), { code: 'EACCES' });
98
+ });
99
+
100
+ test('access checks follow Linux #326', () => {
101
+ const as = (uid: number, gid: number, euid = uid, egid = gid, groups: number[] = []) =>
102
+ bindContext({ credentials: { uid, gid, euid, egid, groups } });
103
+ const file = (mode: number, uid = 1000, gid = 1000) => ({ mode: 0o100000 | mode, uid, gid });
104
+
105
+ assert(!hasAccess(as(1000, 0), file(0o600, 0, 0), R_OK));
106
+ assert(hasAccess(as(0, 1000), file(0o000, 5, 5), R_OK | W_OK));
107
+
108
+ assert(hasAccess(as(1000, 1000, 2000, 1000), file(0o600, 2000, 5), R_OK | W_OK));
109
+ assert(!hasAccess(as(2000, 1000, 1000, 1000), file(0o600, 2000, 5), R_OK));
110
+ assert(!hasAccess(as(1000, 0, 1000, 1000), file(0o060, 2000, 0), R_OK));
111
+
112
+ assert(!hasAccess(as(1000, 1000), file(0o077), R_OK));
113
+ assert(!hasAccess(as(1000, 1000), file(0o407, 2000), R_OK));
114
+ assert(hasAccess(as(1000, 3000), file(0o004, 2000, 2000), R_OK));
115
+ assert(hasAccess(as(1000, 3000, 1000, 3000, [2000]), file(0o040, 2000, 2000), R_OK));
116
+ assert(hasAccess(as(1000, 2000), file(0o021, 0, 2000), W_OK));
117
+ assert(!hasAccess(as(1000, 2000), file(0o021, 0, 2000), X_OK));
118
+ });
119
+
120
+ test('stat needs no permission on the file itself #326', async () => {
121
+ await rootFS.promises.writeFile('/secret-stat.txt', 'x', { mode: 0o000 });
122
+ const user = bindContext({ credentials: { uid: 1000, gid: 1000 } });
123
+
124
+ assert.equal(user.fs.statSync('/secret-stat.txt').size, 1);
125
+ assert.equal((await user.fs.promises.lstat('/secret-stat.txt')).size, 1);
126
+ assert.throws(() => user.fs.readFileSync('/secret-stat.txt'), { code: 'EACCES' });
127
+ });
128
+
129
+ test('removing an entry needs write and search permission on its parent #326', async () => {
130
+ const alice = bindContext({ credentials: { uid: 1000, gid: 1000 } });
131
+
132
+ await rootFS.promises.mkdir('/removal-root', { mode: 0o755 });
133
+ await rootFS.promises.writeFile('/removal-root/file', 'x', { mode: 0o666 });
134
+ await rootFS.promises.mkdir('/removal-root/dir', { mode: 0o777 });
135
+ await rootFS.promises.mkdir('/removal-own', { mode: 0o755 });
136
+ await rootFS.promises.chown('/removal-own', 1000, 1000);
137
+ await rootFS.promises.writeFile('/removal-own/readonly', 'x', { mode: 0o444 });
138
+ await rootFS.promises.mkdir('/removal-own/readonly-dir', { mode: 0o555 });
139
+ await rootFS.promises.mkdir('/removal-unreadable', { mode: 0o300 });
140
+ await rootFS.promises.chown('/removal-unreadable', 1000, 1000);
141
+ await rootFS.promises.writeFile('/removal-unreadable/file', 'x');
142
+
143
+ await assert.rejects(alice.fs.promises.unlink('/removal-root/file'), { code: 'EACCES' });
144
+ assert.throws(() => alice.fs.unlinkSync('/removal-root/file'), { code: 'EACCES' });
145
+ await assert.rejects(alice.fs.promises.rmdir('/removal-root/dir'), { code: 'EACCES' });
146
+ assert.throws(() => alice.fs.rmdirSync('/removal-root/dir'), { code: 'EACCES' });
147
+ assert.throws(() => alice.fs.renameSync('/removal-root/file', '/removal-own/file'), { code: 'EACCES' });
148
+ await assert.rejects(alice.fs.promises.rename('/removal-own/readonly', '/removal-root/file'), { code: 'EACCES' });
149
+
150
+ await alice.fs.promises.unlink('/removal-own/readonly');
151
+ alice.fs.rmdirSync('/removal-own/readonly-dir');
152
+ assert.deepEqual(rootFS.readdirSync('/removal-own'), []);
153
+
154
+ await alice.fs.promises.rename('/removal-unreadable/file', '/removal-unreadable/renamed');
155
+ alice.fs.renameSync('/removal-unreadable/renamed', '/removal-unreadable/file');
156
+ });
157
+
158
+ test('sticky directories only let owners remove entries #326', async () => {
159
+ const alice = bindContext({ credentials: { uid: 1000, gid: 1000 } });
160
+ const bob = bindContext({ credentials: { uid: 2000, gid: 2000 } });
161
+
162
+ await rootFS.promises.mkdir('/sticky', { mode: 0o1777 });
163
+ await alice.fs.promises.writeFile('/sticky/alice', 'a', { mode: 0o666 });
164
+ await alice.fs.promises.mkdir('/sticky/alice-dir', { mode: 0o777 });
165
+ await bob.fs.promises.writeFile('/sticky/bob', 'b');
166
+
167
+ await assert.rejects(bob.fs.promises.unlink('/sticky/alice'), { code: 'EPERM' });
168
+ assert.throws(() => bob.fs.unlinkSync('/sticky/alice'), { code: 'EPERM' });
169
+ await assert.rejects(bob.fs.promises.rmdir('/sticky/alice-dir'), { code: 'EPERM' });
170
+ assert.throws(() => bob.fs.rmdirSync('/sticky/alice-dir'), { code: 'EPERM' });
171
+ assert.throws(() => bob.fs.renameSync('/sticky/alice', '/sticky/stolen'), { code: 'EPERM' });
172
+ await assert.rejects(alice.fs.promises.rename('/sticky/alice', '/sticky/bob'), { code: 'EPERM' });
173
+
174
+ await alice.fs.promises.unlink('/sticky/alice');
175
+ alice.fs.rmdirSync('/sticky/alice-dir');
176
+ await rootFS.promises.unlink('/sticky/bob');
177
+ assert.deepEqual(rootFS.readdirSync('/sticky'), []);
178
+
179
+ await rootFS.promises.mkdir('/sticky-alice', { mode: 0o1777 });
180
+ await rootFS.promises.chown('/sticky-alice', 1000, 1000);
181
+ await bob.fs.promises.writeFile('/sticky-alice/bob', 'b');
182
+ await alice.fs.promises.unlink('/sticky-alice/bob');
183
+ });
184
+
185
+ test('only owners may change the mode, owner, or times of a file #326', async () => {
186
+ const alice = bindContext({ credentials: { uid: 1000, gid: 1000, groups: [3000] } });
187
+ const bob = bindContext({ credentials: { uid: 2000, gid: 2000 } });
188
+
189
+ await rootFS.promises.writeFile('/owned', 'x', { mode: 0o666 });
190
+ rootFS.chownSync('/owned', 1000, 1000);
191
+
192
+ assert.throws(() => bob.fs.chmodSync('/owned', 0o600), { code: 'EPERM' });
193
+ await assert.rejects(bob.fs.promises.chmod('/owned', 0o600), { code: 'EPERM' });
194
+ assert.throws(() => bob.fs.utimesSync('/owned', 1, 2), { code: 'EPERM' });
195
+ await assert.rejects(bob.fs.promises.utimes('/owned', 1, 2), { code: 'EPERM' });
196
+ assert.throws(() => bob.fs.chownSync('/owned', -1, 2000), { code: 'EPERM' });
197
+ assert.throws(() => bob.fs.chownSync('/owned', 1000, -1), { code: 'EPERM' });
198
+ bob.fs.chownSync('/owned', -1, -1);
199
+
200
+ alice.fs.chmodSync('/owned', 0o644);
201
+ await alice.fs.promises.utimes('/owned', 1, 2);
202
+ alice.fs.chownSync('/owned', -1, 3000);
203
+ await alice.fs.promises.chown('/owned', 1000, 1000);
204
+ assert.throws(() => alice.fs.chownSync('/owned', -1, 4000), { code: 'EPERM' });
205
+ assert.throws(() => alice.fs.chownSync('/owned', 2000, -1), { code: 'EPERM' });
206
+
207
+ rootFS.chownSync('/owned', 2000, 2000);
208
+ const stats = rootFS.statSync('/owned');
209
+ assert.equal(stats.mode & 0o777, 0o644);
210
+ assert.equal(stats.mtimeMs, 2000);
211
+ assert.equal(stats.uid, 2000);
212
+ });
213
+
214
+ test('changing metadata needs no read or write access, even on directories #327', async () => {
215
+ const alice = bindContext({ credentials: { uid: 1000, gid: 1000 } });
216
+
217
+ await rootFS.promises.writeFile('/metadata-file', 'x', { mode: 0o000 });
218
+ await rootFS.promises.mkdir('/metadata-dir', { mode: 0o000 });
219
+
220
+ for (const path of ['/metadata-file', '/metadata-dir']) {
221
+ rootFS.chownSync(path, 1000, 1000);
222
+ alice.fs.chmodSync(path, 0o500);
223
+ await alice.fs.promises.chmod(path, 0o400);
224
+ alice.fs.utimesSync(path, 1, 2);
225
+ await alice.fs.promises.utimes(path, 3, 4);
226
+ alice.fs.chownSync(path, -1, -1);
227
+ await alice.fs.promises.chown(path, -1, -1);
228
+
229
+ const stats = rootFS.statSync(path);
230
+ assert.equal(stats.mode & 0o777, 0o400);
231
+ assert.equal(stats.mtimeMs, 4000);
232
+ }
233
+ });
234
+
235
+ test('moving a directory to another parent needs write permission on it', async () => {
236
+ const alice = bindContext({ credentials: { uid: 1000, gid: 1000 } });
237
+
238
+ await rootFS.promises.mkdir('/move-a', { mode: 0o777 });
239
+ await rootFS.promises.mkdir('/move-b', { mode: 0o777 });
240
+ await rootFS.promises.mkdir('/move-a/dir', { mode: 0o755 });
241
+ await rootFS.promises.writeFile('/move-a/file', 'x', { mode: 0o644 });
242
+
243
+ assert.throws(() => alice.fs.renameSync('/move-a/dir', '/move-b/dir'), { code: 'EACCES' });
244
+ await assert.rejects(alice.fs.promises.rename('/move-a/dir', '/move-b/dir'), { code: 'EACCES' });
245
+ await alice.fs.promises.rename('/move-a/dir', '/move-a/renamed');
246
+ await alice.fs.promises.rename('/move-a/file', '/move-b/file');
247
+ });
248
+
249
+ test('chmod and chown clear setuid and setgid like Linux', async () => {
250
+ const alice = bindContext({ credentials: { uid: 1000, gid: 1000, groups: [3000] } });
251
+ const modeOf = (path: string) => rootFS.statSync(path).mode & 0o7777;
252
+
253
+ await rootFS.promises.mkdir('/sid', { mode: 0o777 });
254
+ await alice.fs.promises.writeFile('/sid/file', 'x');
255
+ await alice.fs.promises.mkdir('/sid/dir');
256
+
257
+ alice.fs.chmodSync('/sid/file', 0o6755);
258
+ assert.equal(modeOf('/sid/file'), 0o6755);
259
+ alice.fs.chownSync('/sid/file', -1, -1);
260
+ assert.equal(modeOf('/sid/file'), 0o755);
261
+
262
+ alice.fs.chmodSync('/sid/file', 0o2745);
263
+ alice.fs.chownSync('/sid/file', -1, 3000);
264
+ assert.equal(modeOf('/sid/file'), 0o2745);
265
+
266
+ rootFS.chmodSync('/sid/file', 0o4755);
267
+ rootFS.chownSync('/sid/file', 1000, 0);
268
+ assert.equal(modeOf('/sid/file'), 0o755);
269
+
270
+ alice.fs.chmodSync('/sid/file', 0o2755);
271
+ assert.equal(modeOf('/sid/file'), 0o755);
272
+
273
+ await alice.fs.promises.chmod('/sid/dir', 0o6755);
274
+ await alice.fs.promises.chown('/sid/dir', -1, -1);
275
+ assert.equal(modeOf('/sid/dir'), 0o6755);
276
+ });
277
+
278
+ test('access checks see unsynced changes to an open file', async () => {
279
+ const alice = bindContext({ credentials: { uid: 1000, gid: 1000 } });
280
+
281
+ await rootFS.promises.writeFile('/unsynced', 'x', { mode: 0o644 });
282
+ const fd = rootFS.openSync('/unsynced', 'r');
283
+ rootFS.fchmodSync(fd, 0o600);
284
+
285
+ assert.throws(() => alice.fs.readFileSync('/unsynced'), { code: 'EACCES' });
286
+ await assert.rejects(alice.fs.promises.readFile('/unsynced'), { code: 'EACCES' });
287
+ rootFS.closeSync(fd);
288
+ });
289
+
290
+ test('reaching a path needs search permission on every directory leading to it #326', async () => {
291
+ const alice = bindContext({ credentials: { uid: 1000, gid: 1000 } });
292
+ const bob = bindContext({ credentials: { uid: 2000, gid: 2000 } });
293
+
294
+ await rootFS.promises.mkdir('/private', { mode: 0o700 });
295
+ await rootFS.promises.chown('/private', 1000, 1000);
296
+ await rootFS.promises.mkdir('/private/inner', { mode: 0o755 });
297
+ await rootFS.promises.writeFile('/private/inner/readable.txt', 'secret', { mode: 0o644 });
298
+ await rootFS.promises.symlink('/private', '/private-link');
299
+ await rootFS.promises.mkdir('/search-only', { mode: 0o711 });
300
+ await rootFS.promises.writeFile('/search-only/known.txt', 'known', { mode: 0o644 });
301
+ await rootFS.promises.mkdir('/no-search', { mode: 0o722 });
302
+
303
+ assert.throws(() => bob.fs.readFileSync('/private-link/inner/readable.txt'), { code: 'EACCES' });
304
+ await assert.rejects(bob.fs.promises.readFile('/private-link/inner/readable.txt'), { code: 'EACCES' });
305
+ assert.throws(() => bob.fs.lstatSync('/private/inner'), { code: 'EACCES' });
306
+ assert.throws(() => bob.fs.mkdirSync('/no-search/dir'), { code: 'EACCES' });
307
+ assert.throws(() => bob.fs.writeFileSync('/no-search/file', 'x'), { code: 'EACCES' });
308
+
309
+ const { checkAccess, resolveFullWalk } = vfsConfig;
310
+ vfsConfig._setVFSConfig({ checkAccess, resolveFullWalk: true });
311
+ try {
312
+ assert.equal(alice.fs.readFileSync('/private/inner/readable.txt', 'utf8'), 'secret');
313
+ assert.equal(rootFS.readFileSync('/private/inner/readable.txt', 'utf8'), 'secret');
314
+
315
+ assert.throws(() => bob.fs.readFileSync('/private/inner/readable.txt'), { code: 'EACCES' });
316
+ await assert.rejects(bob.fs.promises.readFile('/private/inner/readable.txt'), { code: 'EACCES' });
317
+ assert.throws(() => bob.fs.statSync('/private/inner/readable.txt'), { code: 'EACCES' });
318
+ await assert.rejects(bob.fs.promises.stat('/private/inner/readable.txt'), { code: 'EACCES' });
319
+ assert.throws(() => bob.fs.readdirSync('/private/inner'), { code: 'EACCES' });
320
+ assert.throws(() => bob.fs.writeFileSync('/private/inner/new.txt', 'x'), { code: 'EACCES' });
321
+ assert.throws(() => bob.fs.unlinkSync('/private/inner/readable.txt'), { code: 'EACCES' });
322
+ assert.throws(() => bob.fs.mkdirSync('/private/inner/dir'), { code: 'EACCES' });
323
+ assert.throws(() => bob.fs.renameSync('/private/inner/readable.txt', '/stolen.txt'), { code: 'EACCES' });
324
+ assert(rootFS.existsSync('/private/inner/readable.txt'));
325
+
326
+ assert.equal(bob.fs.statSync('/private').mode & 0o777, 0o700);
327
+ assert.throws(() => bob.fs.readdirSync('/private'), { code: 'EACCES' });
328
+
329
+ assert.equal(bob.fs.readFileSync('/search-only/known.txt', 'utf8'), 'known');
330
+ assert.throws(() => bob.fs.readdirSync('/search-only'), { code: 'EACCES' });
331
+
332
+ assert.equal(bob.fs.existsSync('/private/inner/readable.txt'), false);
333
+ assert.equal(await bob.fs.promises.exists('/private/inner/readable.txt'), false);
334
+ assert.equal(bob.fs.existsSync('/search-only/known.txt'), true);
335
+ } finally {
336
+ vfsConfig._setVFSConfig({ checkAccess, resolveFullWalk });
337
+ }
338
+ });
339
+
87
340
  const copy = { ...defaultContext.credentials };
88
341
  Object.assign(defaultContext.credentials, { uid: 1000, gid: 1000, euid: 1000, egid: 1000 });
89
342
  test('Access controls: /', () => test_item('/'));
@@ -63,7 +63,7 @@ suite('Stats', () => {
63
63
  assert.equal(hasAccess(defaultContext, stat, fs.constants.X_OK), false);
64
64
  // changing group
65
65
 
66
- Object.assign(defaultContext.credentials, { ...nonRootCredentials, gid: 44 });
66
+ Object.assign(defaultContext.credentials, { ...nonRootCredentials, gid: 44, egid: 44 });
67
67
 
68
68
  assert.equal(hasAccess(defaultContext, stat, fs.constants.R_OK), false);
69
69
  assert.equal(hasAccess(defaultContext, stat, fs.constants.W_OK), false);
@@ -167,7 +167,7 @@ suite('Streams', config('streams'), () => {
167
167
  assert.deepEqual(sizes.slice(0, -1), Array(sizes.length - 1).fill(highWaterMark));
168
168
  assert.equal(
169
169
  sizes.reduce((a, b) => a + b, 0),
170
- large.length
170
+ large.length,
171
171
  );
172
172
  });
173
173
 
@@ -41,6 +41,20 @@ suite('Watch', config('watch'), () => {
41
41
  assert.equal(filename, 'test.txt');
42
42
  });
43
43
 
44
+ test('Events emitted on metadata change', async () => {
45
+ const { promise, resolve } = Promise.withResolvers<[string, string]>();
46
+
47
+ using watcher = fs.watch(testFile, (eventType, filename) => {
48
+ resolve([eventType, filename]);
49
+ });
50
+
51
+ fs.chmodSync(testFile, 0o600);
52
+
53
+ const [eventType, filename] = await withTimeout(promise);
54
+ assert.equal(eventType, 'change');
55
+ assert.equal(filename, 'test.txt');
56
+ });
57
+
44
58
  test('Events are emitted on delete', async () => {
45
59
  const { promise, resolve } = Promise.withResolvers<string>();
46
60
 
@@ -22,6 +22,16 @@ suite('Extended Attributes', config('xattr'), () => {
22
22
  assert.equal(value, testValue);
23
23
  });
24
24
 
25
+ test('set is kept when an open handle with unsynced changes is synced', async () => {
26
+ await using handle = await fs.promises.open(testFile, 'r');
27
+ await handle.chmod(0o640);
28
+ await fs.xattr.set(testFile, 'user.unsynced', 'value');
29
+ await handle.sync();
30
+
31
+ assert.equal(await fs.xattr.get(testFile, 'user.unsynced', { encoding: 'utf8' }), 'value');
32
+ assert.equal((await fs.promises.stat(testFile)).mode & 0o777, 0o640);
33
+ });
34
+
25
35
  test('get attributes with buffer encoding', async () => {
26
36
  await fs.xattr.set(testFile, 'user.buffer', 'buffer value');
27
37
  const buffer = await fs.xattr.get(testFile, 'user.buffer', { encoding: 'buffer' });
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "flags": {
3
- "prep": ["writeFile", "stream"]
3
+ "prep": ["writeFile", "stream"],
4
+ "sbfs_v2": [true, false]
4
5
  },
5
6
  "tests": [
6
7
  {
@@ -79,6 +80,35 @@
79
80
  { "name": "2000 appends", "value": { "writes": 2000 } },
80
81
  { "name": "8000 appends", "cpu": 1, "value": { "writes": 8000 } }
81
82
  ]
83
+ },
84
+ {
85
+ "path": "single-buffer-read.ts",
86
+ "name": "Random 4 KB reads from one file in a SingleBuffer",
87
+ "flags": ["sbfs_v2"],
88
+ "measure": {
89
+ "bytes": { "throughput": "s", "unit": "MB" },
90
+ "reads": { "cost": true, "unit": "read" }
91
+ },
92
+ "configurations": [
93
+ { "name": "1 MiB", "value": { "size": 1048576, "count": 256 } },
94
+ { "name": "8 MiB", "value": { "size": 8388608, "count": 1024 } },
95
+ { "name": "32 MiB", "cpu": 1, "value": { "size": 33554432, "count": 1024 } }
96
+ ]
97
+ },
98
+ {
99
+ "path": "single-buffer-write.ts",
100
+ "name": "Random 4 KB writes to one file in a SingleBuffer",
101
+ "flags": ["sbfs_v2"],
102
+ "measure": {
103
+ "bytes": { "throughput": "s", "unit": "MB" },
104
+ "writes": { "cost": true, "unit": "write" }
105
+ },
106
+ "configurations": [
107
+ { "name": "1 MiB, flushed", "value": { "size": 1048576, "count": 256, "flush": 1, "sync": 0 } },
108
+ { "name": "1 MiB, O_SYNC", "value": { "size": 1048576, "count": 256, "flush": 0, "sync": 1 } },
109
+ { "name": "8 MiB, flushed", "cpu": 1, "value": { "size": 8388608, "count": 1024, "flush": 1, "sync": 0 } },
110
+ { "name": "8 MiB, O_SYNC", "cpu": 1, "value": { "size": 8388608, "count": 1024, "flush": 0, "sync": 1 } }
111
+ ]
82
112
  }
83
113
  ]
84
114
  }
@@ -0,0 +1,38 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ // Helpers for random access to one file in a SingleBuffer.
3
+ import { configureSingle, SingleBuffer } from '@zenfs/core';
4
+ import { fs, KB, MB } from './common.ts';
5
+
6
+ export { fs };
7
+
8
+ export const block = 4 * KB;
9
+
10
+ export interface Config {
11
+ /** Size of the file */
12
+ size: number;
13
+
14
+ /** Number of blocks accessed, each once */
15
+ count: number;
16
+
17
+ sbfs_v2: boolean;
18
+ }
19
+
20
+ /** Mounts a SingleBuffer with room for the file twice over, then creates the file */
21
+ export async function mountWithFile(config: Config): Promise<void> {
22
+ await configureSingle({ backend: SingleBuffer, buffer: new ArrayBuffer(config.size * 2 + 4 * MB), v2: config.sbfs_v2 });
23
+ fs.writeFileSync('/f', new Uint8Array(config.size).fill(0x5a));
24
+ }
25
+
26
+ /** Offsets of `count` distinct blocks of the file, in a shuffled order that is the same on every run */
27
+ export function offsets(config: Config): number[] {
28
+ const blocks = Array.from({ length: config.size / block }, (_, i) => i);
29
+
30
+ let seed = 0x2545f491;
31
+ for (let i = blocks.length - 1; i > 0; i--) {
32
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
33
+ const j = seed % (i + 1);
34
+ [blocks[i], blocks[j]] = [blocks[j], blocks[i]];
35
+ }
36
+
37
+ return blocks.slice(0, config.count).map(i => i * block);
38
+ }
@@ -0,0 +1,28 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ // 4 KB reads at random offsets in one file in a SingleBuffer.
3
+ // The file is opened fresh for each iteration, so every read misses the vnode cache and reaches the store.
4
+ import { block, fs, mountWithFile, offsets, type Config } from './single-buffer-random.ts';
5
+
6
+ interface State {
7
+ offsets: number[];
8
+ target: Uint8Array;
9
+ fd?: number;
10
+ }
11
+
12
+ export async function setup(config: Config): Promise<State> {
13
+ await mountWithFile(config);
14
+ return { offsets: offsets(config), target: new Uint8Array(block) };
15
+ }
16
+
17
+ export function before(config: Config, state: State): void {
18
+ state.fd = fs.openSync('/f', 'r');
19
+ }
20
+
21
+ export function test(config: Config, state: State) {
22
+ for (const offset of state.offsets) fs.readSync(state.fd!, state.target, 0, block, offset);
23
+ return { reads: state.offsets.length, bytes: state.offsets.length * block };
24
+ }
25
+
26
+ export function after(config: Config, state: State): void {
27
+ fs.closeSync(state.fd!);
28
+ }