@zenfs/core 2.6.2 → 2.6.4

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 (64) hide show
  1. package/README.md +1 -1
  2. package/dist/backends/single_buffer.js +34 -11
  3. package/dist/context.js +1 -0
  4. package/dist/internal/contexts.d.ts +3 -1
  5. package/dist/internal/contexts.js +11 -1
  6. package/dist/internal/inode.d.ts +2 -0
  7. package/dist/internal/inode.js +2 -0
  8. package/dist/internal/rpc.d.ts +4 -4
  9. package/dist/node/async.d.ts +11 -1
  10. package/dist/node/async.js +14 -35
  11. package/dist/node/index.d.ts +1 -0
  12. package/dist/node/index.js +1 -0
  13. package/dist/node/promises.d.ts +17 -3
  14. package/dist/node/promises.js +32 -16
  15. package/dist/node/stats.d.ts +12 -0
  16. package/dist/node/stats.js +35 -6
  17. package/dist/node/sync.d.ts +39 -11
  18. package/dist/node/sync.js +41 -17
  19. package/dist/node/utf8stream.d.ts +99 -0
  20. package/dist/node/utf8stream.js +690 -0
  21. package/dist/utils.d.ts +10 -0
  22. package/dist/utils.js +17 -0
  23. package/dist/vfs/shared.js +9 -7
  24. package/package.json +14 -10
  25. package/scripts/test.js +15 -8
  26. package/tests/backend/single-buffer-rotate.worker.js +19 -0
  27. package/tests/backend/single-buffer.test.ts +106 -0
  28. package/tests/common/context.test.ts +38 -0
  29. package/tests/common/glob.test.ts +86 -1
  30. package/tests/common/utf8stream.test.ts +164 -0
  31. package/tests/common.ts +4 -2
  32. package/tests/fetch/fetch.ts +1 -1
  33. package/tests/fetch/run.sh +1 -1
  34. package/tests/fs/append.test.ts +1 -1
  35. package/tests/fs/concurrency.test.ts +1 -1
  36. package/tests/fs/dir.test.ts +1 -1
  37. package/tests/fs/directory.test.ts +3 -3
  38. package/tests/fs/errors.test.ts +1 -1
  39. package/tests/fs/exists.test.ts +3 -3
  40. package/tests/fs/links.test.ts +5 -5
  41. package/tests/fs/open.test.ts +5 -5
  42. package/tests/fs/permissions.test.ts +5 -5
  43. package/tests/fs/read.test.ts +3 -3
  44. package/tests/fs/readFile.test.ts +2 -2
  45. package/tests/fs/rename.test.ts +5 -5
  46. package/tests/fs/scaling.test.ts +1 -1
  47. package/tests/fs/stat.test.ts +32 -3
  48. package/tests/fs/streams.test.ts +1 -1
  49. package/tests/fs/tempdir.test.ts +1 -1
  50. package/tests/fs/times.test.ts +2 -2
  51. package/tests/fs/truncate.test.ts +1 -1
  52. package/tests/fs/watch.test.ts +1 -1
  53. package/tests/fs/write.test.ts +2 -2
  54. package/tests/fs/xattr.test.ts +1 -1
  55. package/tests/{native/setup.ts → native-setup.ts} +5 -5
  56. package/tests/setup/context.ts +1 -1
  57. package/tests/setup/cow.ts +1 -1
  58. package/tests/setup/index.ts +1 -1
  59. package/tests/setup/memory.ts +1 -1
  60. package/tests/setup/port.ts +1 -1
  61. package/tests/setup/single-buffer.ts +1 -1
  62. package/tests/setup.ts +1 -1
  63. package/tests/tsconfig.json +2 -1
  64. package/tests/native/run.sh +0 -3
package/dist/utils.js CHANGED
@@ -51,6 +51,14 @@ export function validateFD(fd) {
51
51
  code: 'ERR_OUT_OF_RANGE',
52
52
  });
53
53
  }
54
+ /**
55
+ * Whether an error means the path does not resolve to an entry. Used for `throwIfNoEntry`.
56
+ * @internal @hidden
57
+ */
58
+ export function _isNoEntry(e) {
59
+ const { code } = (e ?? {});
60
+ return code == 'ENOENT' || code == 'ENOTDIR';
61
+ }
54
62
  /**
55
63
  * Normalizes a time to milliseconds since the epoch.
56
64
  * Note numbers and numeric strings are seconds, like Node's `toUnixTimestamp`.
@@ -140,3 +148,12 @@ export function _tempDirName(prefix) {
140
148
  // Like Node, the prefix is a path. A relative one is resolved against the working directory, not `/tmp`.
141
149
  return `${normalizePath(prefix, true)}${Date.now()}-${Math.random().toString(36).slice(2)}`;
142
150
  }
151
+ /**
152
+ * Resolves the global `Temporal`, which is not implemented by all runtimes.
153
+ * @internal @hidden
154
+ */
155
+ export function _temporal() {
156
+ if ('Temporal' in globalThis && typeof globalThis.Temporal !== 'undefined')
157
+ return globalThis.Temporal;
158
+ throw withErrno('ENOTSUP', 'Temporal is not supported by this runtime');
159
+ }
@@ -97,14 +97,16 @@ export function resolveMount(path, ctx, extra) {
97
97
  export function _statfs(fs, bigint) {
98
98
  const md = fs.usage();
99
99
  const bs = md.blockSize || 4096;
100
+ const type = bigint ? BigInt : Number;
100
101
  return {
101
- type: (bigint ? BigInt : Number)(fs.type),
102
- bsize: (bigint ? BigInt : Number)(bs),
103
- ffree: (bigint ? BigInt : Number)(md.freeNodes || size_max),
104
- files: (bigint ? BigInt : Number)(md.totalNodes || size_max),
105
- bavail: (bigint ? BigInt : Number)(md.freeSpace / bs),
106
- bfree: (bigint ? BigInt : Number)(md.freeSpace / bs),
107
- blocks: (bigint ? BigInt : Number)(md.totalSpace / bs),
102
+ type: type(fs.type),
103
+ bsize: type(bs),
104
+ frsize: type(bs),
105
+ ffree: type(md.freeNodes || size_max),
106
+ files: type(md.totalNodes || size_max),
107
+ bavail: type(md.freeSpace / bs),
108
+ bfree: type(md.freeSpace / bs),
109
+ blocks: type(md.totalSpace / bs),
108
110
  };
109
111
  }
110
112
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenfs/core",
3
- "version": "2.6.2",
3
+ "version": "2.6.4",
4
4
  "description": "A filesystem, anywhere",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -11,7 +11,9 @@
11
11
  "keywords": [
12
12
  "filesystem",
13
13
  "node",
14
- "storage"
14
+ "storage",
15
+ "fs",
16
+ "browser"
15
17
  ],
16
18
  "bin": {
17
19
  "make-index": "scripts/make-index.js",
@@ -44,14 +46,15 @@
44
46
  },
45
47
  "exports": {
46
48
  ".": "./dist/index.js",
47
- "./*": "./dist/*",
49
+ "./*.js": "./dist/*.js",
50
+ "./*": "./dist/*.js",
48
51
  "./emulation/*": "./dist/node/*",
49
52
  "./promises": "./dist/node/promises.js",
50
53
  "./readline": "./dist/node/readline.js",
51
- "./constants": "./dist/constants.js",
52
- "./path": "./dist/path.js",
53
54
  "./eslint": "./eslint.shared.js",
54
- "./tests/*": "./tests/*",
55
+ "./tests/*.ts": "./tests/*.ts",
56
+ "./tests/*.js": "./tests/*.js",
57
+ "./tests/*": "./tests/*.js",
55
58
  "./types/*": "./types/*"
56
59
  },
57
60
  "publishConfig": {
@@ -64,25 +67,26 @@
64
67
  "spdx:check": "npx lice -a src tests/*.ts tests/**/*.test.ts",
65
68
  "spdx:fix": "npx lice -aw src tests/*.ts tests/**/*.test.ts",
66
69
  "lint": "eslint src tests",
67
- "test": "npx --silent zenfs-test --clean; npx --silent zenfs-test -abcp; tests/fetch/run.sh; npx --silent zenfs-test --report",
70
+ "test": "npx -s zenfs-test --clean; npx -s zenfs-test -abcpn; tests/fetch/run.sh; npx -s zenfs-test --report-only",
71
+ "test:native": "npx -s zenfs-test tests/native-setup.ts --verbose",
68
72
  "build": "tsc -p tsconfig.json",
69
73
  "build:docs": "typedoc",
70
74
  "dev": "tsc -p tsconfig.json --watch",
71
75
  "prepublishOnly": "npm run build"
72
76
  },
73
77
  "dependencies": {
74
- "@types/node": "^25.2.0",
78
+ "@types/node": "^26.0.0",
75
79
  "buffer": "^6.0.3",
76
80
  "eventemitter3": "^5.0.1",
77
81
  "kerium": "^1.4.2",
78
- "memium": "^0.4.0",
82
+ "memium": "^1.0.0",
79
83
  "readable-stream": "^4.5.2",
80
84
  "utilium": "^3.0.0"
81
85
  },
82
86
  "devDependencies": {
83
87
  "@eslint/js": "^10.0.1",
84
88
  "@octokit/action": "^8.0.4",
85
- "c8": "^11.0.0",
89
+ "c8": "^12.0.0",
86
90
  "eslint": "^10.1.0",
87
91
  "globals": "^17.3.0",
88
92
  "prettier": "^3.2.5",
package/scripts/test.js CHANGED
@@ -26,11 +26,14 @@ const { values: options, positionals } = parseArgs({
26
26
  skip: { short: 's', type: 'string', multiple: true, default: [] },
27
27
  'exit-on-fail': { short: 'e', type: 'boolean' },
28
28
  runs: { short: 'r', type: 'string', default: '1' },
29
+ // @todo [breaking] make using node the default and remove tsx completely
30
+ node: { short: 'n', type: 'boolean', default: false },
29
31
 
30
32
  // Coverage and performance
31
33
  coverage: { type: 'string', default: 'tests/.coverage' },
32
34
  preserve: { short: 'p', type: 'boolean' },
33
35
  report: { type: 'boolean', default: false },
36
+ 'report-only': { type: 'boolean', default: false },
34
37
  clean: { type: 'boolean', default: false },
35
38
  profile: { type: 'boolean', default: false },
36
39
  },
@@ -42,7 +45,8 @@ const { values: options, positionals } = parseArgs({
42
45
  * @param {...any} args
43
46
  */
44
47
  function debug(...args) {
45
- if (options.debug) console.debug(styleText('dim', '[debug]'), ...args.map(a => (typeof a === 'string' ? styleText('dim', a) : a)));
48
+ if (options.debug || process.env.DEBUG)
49
+ console.debug(styleText('dim', '[debug]'), ...args.map(a => (typeof a === 'string' ? styleText('dim', a) : a)));
46
50
  }
47
51
 
48
52
  if (options.help) {
@@ -61,6 +65,7 @@ Behavior:
61
65
  -I, --inspect Use the inspector for debugging
62
66
  -s, --skip <pattern> Skip tests with names matching the given pattern. Can be specified multiple times.
63
67
  -d, --debug Output debug messages from the test runner
68
+ -n, --node Use plain node instead of tsx. Requires use of erasable TS syntax only
64
69
 
65
70
  Output:
66
71
  -h, --help Outputs this help message
@@ -74,9 +79,10 @@ Output:
74
79
  Coverage:
75
80
  --coverage <dir> Override the default coverage data directory
76
81
  -p, --preserve Do not delete or report coverage data
77
- --report ONLY report coverage
82
+ --report Report coverage along with test results
83
+ --report-only ONLY report coverage
78
84
  --clean ONLY clean up coverage directory
79
- --profile Record performance profiles`);
85
+ --profile Record performance profiles`);
80
86
  process.exit();
81
87
  }
82
88
 
@@ -105,12 +111,13 @@ function report() {
105
111
  }
106
112
  }
107
113
 
108
- if (options.report) {
114
+ if (options['report-only']) {
109
115
  report();
110
116
  process.exit();
111
117
  }
112
118
 
113
- const nRuns = Number.isSafeInteger(parseInt(options.runs)) ? parseInt(options.runs) : 1;
119
+ const nRuns = Number.isSafeInteger(parseInt(options.runs)) ? parseInt(options.runs) : 1,
120
+ nodeCommand = options.node ? 'node' : 'tsx';
114
121
 
115
122
  /** @type {typeof import('./ci.js')} */
116
123
  let ci;
@@ -175,7 +182,7 @@ function makeCommand(profileName, ...rest) {
175
182
  ]
176
183
  .filter(v => v)
177
184
  .filter(v => typeof v == 'string');
178
- if (!options.quiet) debug('command: tsx', command.join(' '));
185
+ if (!options.quiet) debug('command: ' + nodeCommand, command.join(' '));
179
186
 
180
187
  return command;
181
188
  }
@@ -235,7 +242,7 @@ async function runTests(config) {
235
242
  }
236
243
 
237
244
  try {
238
- execFileSync('tsx', command, {
245
+ execFileSync(nodeCommand, command, {
239
246
  stdio: ['ignore', options.verbose ? 'inherit' : 'ignore', 'inherit'],
240
247
  });
241
248
  if (!options.quiet) console.log(`${styleText('green', 'passed')}${identText} ${time()}`);
@@ -283,4 +290,4 @@ for (const setupFile of positionals) {
283
290
  });
284
291
  }
285
292
 
286
- if (!options.preserve) report();
293
+ if (!options.preserve && options.report) report();
@@ -0,0 +1,19 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ import { parentPort, workerData } from 'node:worker_threads';
3
+ import { SuperBlock } from '../../dist/backends/single_buffer.js';
4
+ import { setupLogs } from '../logs.js';
5
+
6
+ setupLogs('<worker>');
7
+
8
+ const { buffer, gate, rotations } = workerData;
9
+
10
+ const superblock = new SuperBlock(buffer);
11
+
12
+ parentPort.postMessage('ready');
13
+
14
+ Atomics.wait(gate, 0, 0, 1000);
15
+
16
+ const offsets = [];
17
+ for (let i = 0; i < rotations; i++) offsets.push(superblock.rotateMetadata().byteOffset);
18
+
19
+ parentPort.postMessage(offsets);
@@ -3,7 +3,9 @@ import assert from 'node:assert';
3
3
  import { randomBytes } from 'node:crypto';
4
4
  import { suite, test } from 'node:test';
5
5
  import { Worker } from 'worker_threads';
6
+ import { sizeof } from 'memium';
6
7
  import { fs, mount, resolveMountConfig, SingleBuffer, vfs } from '@zenfs/core';
8
+ import { MetadataBlock, SuperBlock } from '@zenfs/core/backends/single_buffer';
7
9
  import { setupLogs } from '../logs.js';
8
10
 
9
11
  setupLogs();
@@ -53,6 +55,88 @@ await suite('SingleBuffer', () => {
53
55
  assert(fs.existsSync('/shared/worker-file.ts'));
54
56
  });
55
57
 
58
+ test('aligns metadata when used_bytes is unaligned #309', () => {
59
+ // Writers reserve data of any length, so used_bytes can sit at any remainder when the metadata block is rotated.
60
+ for (const used of [8192n, 8193n, 8194n, 8195n]) {
61
+ const superblock = new SuperBlock(new ArrayBuffer(0x100000));
62
+ superblock.used_bytes = used;
63
+
64
+ const metadata = superblock.rotateMetadata();
65
+ const expected = Number((used + 3n) & ~3n);
66
+
67
+ assert.strictEqual(metadata.byteOffset, expected, `metadata offset for used_bytes ${used}`);
68
+ assert.strictEqual(superblock.used_bytes, BigInt(expected + sizeof(MetadataBlock)), `used_bytes after rotating from ${used}`);
69
+ }
70
+ });
71
+
72
+ test('refuses to rotate metadata past the end of the filesystem', () => {
73
+ const exhausted = new SuperBlock(new ArrayBuffer(sizeof(SuperBlock) + sizeof(MetadataBlock) + 4));
74
+ const exhaustedUsed = exhausted.used_bytes;
75
+
76
+ assert.throws(() => exhausted.rotateMetadata(), { code: 'ENOSPC' }, 'rotating without room should fail with ENOSPC');
77
+ assert.strictEqual(exhausted.used_bytes, exhaustedUsed, 'a failed rotation must not consume space');
78
+
79
+ const constrained = new SuperBlock(new ArrayBuffer(0x100000));
80
+ constrained.total_bytes = BigInt(sizeof(SuperBlock) + sizeof(MetadataBlock) + 4);
81
+ const constrainedUsed = constrained.used_bytes;
82
+
83
+ assert.throws(() => constrained.rotateMetadata(), { code: 'ENOSPC' }, 'rotating past total_bytes should fail with ENOSPC');
84
+ assert.strictEqual(constrained.used_bytes, constrainedUsed, 'a failed rotation must not consume space');
85
+ assert(constrained.used_bytes <= constrained.total_bytes, 'used_bytes must never exceed total_bytes');
86
+ });
87
+
88
+ test('concurrent rotation keeps every metadata block reachable', async () => {
89
+ const rotations = 100;
90
+ const buffer = new SharedArrayBuffer(0x200000);
91
+ const gate = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
92
+
93
+ const superblock = new SuperBlock(buffer);
94
+ const original = superblock.metadata_offset;
95
+
96
+ const worker = new Worker(import.meta.dirname + '/single-buffer-rotate.worker.js', { workerData: { buffer, gate, rotations } });
97
+
98
+ const ready = Promise.withResolvers<void>();
99
+ const finished = Promise.withResolvers<number[]>();
100
+ const expired = setTimeout(() => {
101
+ const error = new Error('the worker did not finish rotating');
102
+ ready.reject(error);
103
+ finished.reject(error);
104
+ }, 1000);
105
+
106
+ worker.on('error', error => {
107
+ ready.reject(error);
108
+ finished.reject(error);
109
+ });
110
+ worker.on('message', message => (message === 'ready' ? ready.resolve() : finished.resolve(message as number[])));
111
+
112
+ try {
113
+ await ready.promise;
114
+
115
+ Atomics.store(gate, 0, 1);
116
+ Atomics.notify(gate, 0);
117
+
118
+ const mine: number[] = [];
119
+ for (let i = 0; i < rotations; i++) mine.push(superblock.rotateMetadata().byteOffset);
120
+
121
+ const theirs = await finished.promise;
122
+
123
+ const chain = new Set<number>();
124
+ for (let block: MetadataBlock | undefined = new SuperBlock(buffer).metadata; block; block = block.previous) {
125
+ if (chain.has(block.byteOffset)) break;
126
+ chain.add(block.byteOffset);
127
+ }
128
+
129
+ assert.strictEqual(new Set([...mine, ...theirs]).size, rotations * 2, 'two rotations reserved the same offset');
130
+ assert.strictEqual(chain.size, rotations * 2 + 1, 'blocks are missing from the chain');
131
+
132
+ for (const offset of [original, ...mine, ...theirs]) assert(chain.has(offset), `the block at ${offset} is not in the chain`);
133
+ } finally {
134
+ clearTimeout(expired);
135
+ await worker.terminate();
136
+ worker.unref();
137
+ }
138
+ });
139
+
56
140
  test('reliability across varied file sizes', async () => {
57
141
  const mountPoint = '/sbfs-reliability';
58
142
  const verifyMountPoint = '/sbfs-verify';
@@ -98,4 +182,26 @@ await suite('SingleBuffer', () => {
98
182
  vfs.umount(mountPoint);
99
183
  }
100
184
  });
185
+
186
+ test('keeps metadata aligned when files have uneven sizes #309', async () => {
187
+ const mountPoint = '/sbfs-rotation';
188
+ const buffer = new ArrayBuffer(0x400000);
189
+ const writable = await resolveMountConfig({ backend: SingleBuffer, buffer, label: 'rotation' });
190
+ mount(mountPoint, writable);
191
+
192
+ // Uneven writes leave used_bytes between alignment boundaries when the metadata block fills up.
193
+ const sizes = [1, 17, 257, 3, 5, 13, 1023, 4095, 7, 9];
194
+ try {
195
+ for (let i = 0; i < 400; i++) {
196
+ const content = Buffer.alloc(sizes[i % sizes.length], i & 0xff);
197
+ fs.writeFileSync(`${mountPoint}/f${i}.txt`, content);
198
+ }
199
+ for (let i = 0; i < 400; i += 37) {
200
+ const expected = Buffer.alloc(sizes[i % sizes.length], i & 0xff);
201
+ assert.deepStrictEqual(fs.readFileSync(`${mountPoint}/f${i}.txt`), expected, `content mismatch at f${i}.txt`);
202
+ }
203
+ } finally {
204
+ vfs.umount(mountPoint);
205
+ }
206
+ });
101
207
  });
@@ -79,6 +79,44 @@ suite('Context', () => {
79
79
  assert.equal(ctx.realpathSync('.'), '/test');
80
80
  });
81
81
 
82
+ test('globSync should keep the context', () => {
83
+ ctx.mkdirSync('/globbed');
84
+ ctx.writeFileSync('/globbed/inside.txt', 'in the context');
85
+
86
+ fs.mkdirSync('/globbed');
87
+ fs.writeFileSync('/globbed/outside.txt', 'not in the context');
88
+
89
+ const results = ctx.globSync('**/*.txt', { cwd: '/' });
90
+ assert(results.includes('globbed/inside.txt'), 'should match files in the context root');
91
+ assert(!results.includes('globbed/outside.txt'), 'should not match files outside the context root');
92
+ });
93
+
94
+ test('glob should keep the context', async () => {
95
+ const results = await Array.fromAsync(ctx.promises.glob('**/*.txt', { cwd: '/' }));
96
+
97
+ assert(results.includes('globbed/inside.txt'));
98
+ assert(!results.includes('globbed/outside.txt'), 'should not match files outside the context root');
99
+ });
100
+
101
+ test('glob resolves a relative cwd against the context', () => {
102
+ const { pwd } = context;
103
+ context.pwd = '/';
104
+
105
+ assert.deepEqual(ctx.globSync('*', { cwd: '/globbed' }), ['inside.txt']);
106
+ assert.deepEqual(ctx.globSync('*', { cwd: 'globbed' }), ['inside.txt']);
107
+
108
+ context.pwd = pwd;
109
+ });
110
+
111
+ test('glob defaults cwd to the working directory of the context', () => {
112
+ const { pwd } = context;
113
+ context.pwd = '/globbed';
114
+
115
+ assert.deepEqual(ctx.globSync('*'), ['inside.txt']);
116
+
117
+ context.pwd = pwd;
118
+ });
119
+
82
120
  test('copyFileSync should keep the context #307', () => {
83
121
  ctx.writeFileSync('/source.txt', 'not in real root!');
84
122
  ctx.copyFileSync('/source.txt', '/copy.txt');
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: LGPL-3.0-or-later
2
2
  import assert from 'node:assert/strict';
3
3
  import { suite, test } from 'node:test';
4
- import { fs } from '../common.js';
4
+ import { fs } from '../common.ts';
5
5
 
6
6
  // Set up a directory structure for glob tests
7
7
  fs.mkdirSync('/glob');
@@ -13,6 +13,8 @@ fs.writeFileSync('/glob/c.js', 'c');
13
13
  fs.writeFileSync('/glob/sub/d.txt', 'd');
14
14
  fs.writeFileSync('/glob/sub/e.js', 'e');
15
15
  fs.writeFileSync('/glob/sub/deep/f.txt', 'f');
16
+ fs.symlinkSync('sub', '/glob/link');
17
+ fs.symlinkSync('/glob/nowhere', '/glob/dangling');
16
18
 
17
19
  suite('globSync', () => {
18
20
  test('wildcard in root', () => {
@@ -82,6 +84,66 @@ suite('globSync', () => {
82
84
  assert(!results.includes('glob/c.js'), 'should exclude .js files');
83
85
  assert(results.includes('glob/a.txt'), 'should still include .txt files');
84
86
  });
87
+
88
+ test('cwd option', () => {
89
+ const results = fs.globSync('*', { cwd: '/glob' });
90
+ assert(results.includes('a.txt'), 'results should be relative to cwd');
91
+ assert(results.includes('sub'));
92
+ assert(!results.includes('glob/a.txt'), 'results should not be relative to the root');
93
+ });
94
+
95
+ test('patterns are relative to cwd', () => {
96
+ assert.deepEqual(fs.globSync('*.txt', { cwd: '/glob/sub' }), ['d.txt']);
97
+ assert.deepEqual(fs.globSync('deep/*.txt', { cwd: '/glob/sub' }), ['deep/f.txt']);
98
+ });
99
+
100
+ test('absolute patterns are relative to cwd', () => {
101
+ assert.deepEqual(fs.globSync('/glob/sub/*.txt', { cwd: '/glob' }), ['sub/d.txt']);
102
+ assert.deepEqual(fs.globSync('/glob/sub/*.txt', { cwd: '/glob/sub' }), ['d.txt']);
103
+ });
104
+
105
+ test('exclude receives paths relative to cwd', () => {
106
+ const seen: string[] = [];
107
+ fs.globSync('*', {
108
+ cwd: '/glob',
109
+ exclude: path => {
110
+ seen.push(path);
111
+ return false;
112
+ },
113
+ });
114
+ assert(seen.includes('a.txt'), 'should be given cwd-relative paths');
115
+ assert(!seen.some(p => p.startsWith('/')), 'should not be given absolute paths');
116
+ });
117
+
118
+ test('globstar does not follow symlinks by default', () => {
119
+ const results = fs.globSync('/glob/**/*.txt');
120
+ assert(results.includes('glob/sub/d.txt'), 'should include the real directory');
121
+ assert(!results.includes('glob/link/d.txt'), 'should not descend into a symlinked directory');
122
+ });
123
+
124
+ test('followSymlinks option', () => {
125
+ const results = fs.globSync('/glob/**/*.txt', { followSymlinks: true });
126
+ assert(results.includes('glob/link/d.txt'), 'should descend into a symlinked directory');
127
+ assert(results.includes('glob/link/deep/f.txt'), 'should descend recursively');
128
+ assert(results.includes('glob/sub/d.txt'), 'should still include the real directory');
129
+ });
130
+
131
+ test('a symlink named by the pattern is always descended into', () => {
132
+ const results = fs.globSync('/glob/link/*.txt');
133
+ assert(results.includes('glob/link/d.txt'), 'should not need followSymlinks when the pattern names the link');
134
+ });
135
+
136
+ test('the symlink itself is still matched', () => {
137
+ for (const followSymlinks of [false, true]) {
138
+ const results = fs.globSync('/glob/*', { followSymlinks });
139
+ assert(results.includes('glob/link'), `should match the link itself (followSymlinks: ${followSymlinks})`);
140
+ }
141
+ });
142
+
143
+ test('dangling symlinks do not throw', () => {
144
+ const results = fs.globSync('/glob/**', { followSymlinks: true });
145
+ assert(results.includes('glob/dangling'), 'should match a dangling link without following it');
146
+ });
85
147
  });
86
148
 
87
149
  await suite('promises.glob', () => {
@@ -132,4 +194,27 @@ await suite('promises.glob', () => {
132
194
  const results = await Array.fromAsync(fs.promises.glob('/glob/*.xyz'));
133
195
  assert.equal(results.length, 0);
134
196
  });
197
+
198
+ test('globstar does not follow symlinks by default', async () => {
199
+ const results = await Array.fromAsync(fs.promises.glob('/glob/**/*.txt'));
200
+ assert(results.includes('glob/sub/d.txt'));
201
+ assert(!results.includes('glob/link/d.txt'), 'should not descend into a symlinked directory');
202
+ });
203
+
204
+ test('followSymlinks option', async () => {
205
+ const results = await Array.fromAsync(fs.promises.glob('/glob/**/*.txt', { followSymlinks: true }));
206
+ assert(results.includes('glob/link/d.txt'));
207
+ assert(results.includes('glob/link/deep/f.txt'));
208
+ assert(results.includes('glob/sub/d.txt'));
209
+ });
210
+
211
+ test('a symlink named by the pattern is always descended into', async () => {
212
+ const results = await Array.fromAsync(fs.promises.glob('/glob/link/*.txt'));
213
+ assert(results.includes('glob/link/d.txt'));
214
+ });
215
+
216
+ test('dangling symlinks do not throw', async () => {
217
+ const results = await Array.fromAsync(fs.promises.glob('/glob/**', { followSymlinks: true }));
218
+ assert(results.includes('glob/dangling'));
219
+ });
135
220
  });
@@ -0,0 +1,164 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ import { Buffer } from 'buffer';
3
+ import assert from 'node:assert/strict';
4
+ import { suite, test } from 'node:test';
5
+ import { config, fs } from '../common.ts';
6
+
7
+ suite('Utf8Stream', config('write'), () => {
8
+ test('writes synchronously', config('sync'), () => {
9
+ const stream = new fs.Utf8Stream({ dest: 'u8-sync.log', sync: true });
10
+
11
+ assert.equal(stream.write('hello '), true);
12
+ assert.equal(stream.write('wörld\n'), true);
13
+ assert.equal(fs.readFileSync('u8-sync.log', 'utf8'), 'hello wörld\n');
14
+
15
+ stream.destroy();
16
+ });
17
+
18
+ test('exposes its options', config('sync'), () => {
19
+ const stream = new fs.Utf8Stream({ dest: 'u8-props.log', sync: true, minLength: 16, maxLength: 32 });
20
+
21
+ assert.equal(stream.file, 'u8-props.log');
22
+ assert.equal(stream.contentMode, 'utf8');
23
+ assert.equal(stream.sync, true);
24
+ assert.equal(stream.append, true);
25
+ assert.equal(stream.minLength, 16);
26
+ assert.equal(stream.maxLength, 32);
27
+ assert.equal(stream.periodicFlush, 0);
28
+ assert.equal(stream.mkdir, false);
29
+ assert.ok(stream.fd > 0);
30
+
31
+ stream.destroy();
32
+ });
33
+
34
+ test('buffers until minLength, then flushes', config('sync'), () => {
35
+ const stream = new fs.Utf8Stream({ dest: 'u8-buffered.log', sync: true, minLength: 4096 });
36
+
37
+ stream.write('buffered');
38
+ assert.equal(fs.readFileSync('u8-buffered.log', 'utf8'), '');
39
+
40
+ stream.flushSync();
41
+ assert.equal(fs.readFileSync('u8-buffered.log', 'utf8'), 'buffered');
42
+
43
+ stream.destroy();
44
+ });
45
+
46
+ test('truncates when append is false', config('sync'), () => {
47
+ fs.writeFileSync('u8-append.log', 'existing\n');
48
+
49
+ const stream = new fs.Utf8Stream({ dest: 'u8-append.log', sync: true, append: false });
50
+ stream.write('replaced');
51
+ assert.equal(fs.readFileSync('u8-append.log', 'utf8'), 'replaced');
52
+
53
+ stream.destroy();
54
+ });
55
+
56
+ test('writes buffers when contentMode is buffer', config('sync'), () => {
57
+ const stream = new fs.Utf8Stream({ dest: 'u8-buffer.log', sync: true, contentMode: 'buffer' });
58
+
59
+ assert.equal(stream.contentMode, 'buffer');
60
+ assert.throws(() => stream.write('not a buffer'), { code: 'ERR_INVALID_ARG_TYPE' });
61
+ stream.write(Buffer.from('hi '));
62
+ stream.write(Buffer.from('there'));
63
+ assert.equal(fs.readFileSync('u8-buffer.log', 'utf8'), 'hi there');
64
+
65
+ stream.destroy();
66
+ });
67
+
68
+ test('drops writes over maxLength', config('sync'), () => {
69
+ const stream = new fs.Utf8Stream({ dest: 'u8-drop.log', sync: true, maxLength: 4 });
70
+
71
+ const dropped: string[] = [];
72
+ stream.on('drop', data => dropped.push(data.toString()));
73
+
74
+ stream.write('12345');
75
+ stream.write('12');
76
+
77
+ assert.deepEqual(dropped, ['12345']);
78
+ assert.equal(fs.readFileSync('u8-drop.log', 'utf8'), '12');
79
+
80
+ stream.destroy();
81
+ });
82
+
83
+ test('reopen switches files', config('sync'), () => {
84
+ const stream = new fs.Utf8Stream({ dest: 'u8-rotate.log', sync: true, minLength: 4096 });
85
+
86
+ stream.write('before');
87
+ stream.flushSync();
88
+ stream.reopen('u8-rotated.log');
89
+ assert.equal(stream.file, 'u8-rotated.log');
90
+
91
+ stream.write('after');
92
+ stream.flushSync();
93
+
94
+ assert.equal(fs.readFileSync('u8-rotate.log', 'utf8'), 'before');
95
+ assert.equal(fs.readFileSync('u8-rotated.log', 'utf8'), 'after');
96
+
97
+ stream.destroy();
98
+ });
99
+
100
+ test('creates missing directories when mkdir is set', config('sync', 'directories'), () => {
101
+ const stream = new fs.Utf8Stream({ dest: 'u8-deep/dir/out.log', sync: true, mkdir: true });
102
+
103
+ stream.write('made it');
104
+ assert.equal(fs.readFileSync('u8-deep/dir/out.log', 'utf8'), 'made it');
105
+
106
+ stream.destroy();
107
+ });
108
+
109
+ test('writes to an existing descriptor', config('sync'), () => {
110
+ const fd = fs.openSync('u8-fd.log', 'w');
111
+ const stream = new fs.Utf8Stream({ fd, sync: true });
112
+
113
+ assert.equal(stream.fd, fd);
114
+ stream.write('via fd');
115
+ assert.equal(fs.readFileSync('u8-fd.log', 'utf8'), 'via fd');
116
+
117
+ stream.destroy();
118
+ });
119
+
120
+ test('throws after being destroyed', config('sync'), () => {
121
+ const stream = new fs.Utf8Stream({ dest: 'u8-destroyed.log', sync: true });
122
+ stream.destroy();
123
+
124
+ assert.throws(() => stream.write('x'), { code: 'ERR_INVALID_STATE' });
125
+ assert.throws(() => stream.flushSync(), { code: 'ERR_INVALID_STATE' });
126
+ assert.throws(() => stream.end(), { code: 'ERR_INVALID_STATE' });
127
+ stream.destroy(); // A second destroy is a no-op
128
+ });
129
+
130
+ test('validates its options', () => {
131
+ assert.throws(() => new fs.Utf8Stream(), { code: 'ERR_INVALID_ARG_TYPE' });
132
+ assert.throws(() => new fs.Utf8Stream({ dest: 'u8-invalid.log', contentMode: 'ascii' as 'utf8' }), { code: 'ERR_INVALID_ARG_VALUE' });
133
+ assert.throws(() => new fs.Utf8Stream({ dest: 'u8-invalid.log', minLength: 100, maxWrite: 50 }), { code: 'ERR_INVALID_ARG_VALUE' });
134
+ assert.throws(() => new fs.Utf8Stream({ dest: 'u8-invalid.log', sync: 'yes' as unknown as boolean }), { code: 'ERR_INVALID_ARG_TYPE' });
135
+ assert.throws(() => new fs.Utf8Stream({ dest: 'u8-invalid.log', fs: { write: 5 } }), { code: 'ERR_INVALID_ARG_TYPE' });
136
+ });
137
+
138
+ test('writes asynchronously and emits events', config('async'), async () => {
139
+ const stream = new fs.Utf8Stream({ dest: 'u8-async.log' });
140
+
141
+ const events: string[] = [];
142
+ for (const event of ['ready', 'write', 'finish', 'close'] as const) stream.on(event, () => events.push(event));
143
+
144
+ assert.equal(stream.write('async hello\n'), true);
145
+ stream.end();
146
+ await new Promise<void>(resolve => stream.on('close', () => resolve()));
147
+
148
+ assert.deepEqual(events, ['ready', 'write', 'finish', 'close']);
149
+ assert.equal(fs.readFileSync('u8-async.log', 'utf8'), 'async hello\n');
150
+ });
151
+
152
+ test('flush invokes its callback once written', config('async'), async () => {
153
+ const stream = new fs.Utf8Stream({ dest: 'u8-flush.log', minLength: 4096 });
154
+ await new Promise<void>(resolve => stream.on('ready', () => resolve()));
155
+
156
+ stream.write('flushed');
157
+ assert.equal(fs.readFileSync('u8-flush.log', 'utf8'), '');
158
+
159
+ await new Promise<void>((resolve, reject) => stream.flush(err => (err ? reject(err) : resolve())));
160
+ assert.equal(fs.readFileSync('u8-flush.log', 'utf8'), 'flushed');
161
+
162
+ stream.destroy();
163
+ });
164
+ });