@zenfs/core 2.6.1 → 2.6.2

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.
@@ -46,6 +46,7 @@ export declare class Journal extends EventEmitter<{
46
46
  fromString(value: string): this;
47
47
  add(op: JournalOperation, path: string): void;
48
48
  has(op: JournalOperation, path: string): boolean;
49
+ delete(op: JournalOperation, path: string): void;
49
50
  isDeleted(path: string): boolean;
50
51
  }
51
52
  /**
@@ -53,6 +53,9 @@ export class Journal extends EventEmitter {
53
53
  return true;
54
54
  return false;
55
55
  }
56
+ delete(op, path) {
57
+ this.entries = this.entries.filter(entry => entry.op != op || entry.path != path);
58
+ }
56
59
  isDeleted(path) {
57
60
  let deleted = false;
58
61
  for (const entry of this.entries) {
@@ -168,6 +171,9 @@ export class CopyOnWriteFS extends FileSystem {
168
171
  if (this.isDeleted(oldPath))
169
172
  throw withErrno('ENOENT');
170
173
  }
174
+ if (await this.readable.exists(oldPath))
175
+ this.journal.add('delete', oldPath);
176
+ this.journal.delete('delete', newPath);
171
177
  this.renameInos(oldPath, newPath);
172
178
  }
173
179
  renameSync(oldPath, newPath) {
@@ -179,6 +185,9 @@ export class CopyOnWriteFS extends FileSystem {
179
185
  if (this.isDeleted(oldPath))
180
186
  throw withErrno('ENOENT');
181
187
  }
188
+ if (this.readable.existsSync(oldPath))
189
+ this.journal.add('delete', oldPath);
190
+ this.journal.delete('delete', newPath);
182
191
  this.renameInos(oldPath, newPath);
183
192
  }
184
193
  async stat(path) {
@@ -211,19 +220,25 @@ export class CopyOnWriteFS extends FileSystem {
211
220
  }
212
221
  async createFile(path, options) {
213
222
  await this.createParentDirectories(path);
214
- return this.remapIno(path, await this.writable.createFile(path, options));
223
+ const inode = await this.writable.createFile(path, options);
224
+ this.journal.delete('delete', path);
225
+ return this.remapIno(path, inode);
215
226
  }
216
227
  createFileSync(path, options) {
217
228
  this.createParentDirectoriesSync(path);
218
- return this.remapIno(path, this.writable.createFileSync(path, options));
229
+ const inode = this.writable.createFileSync(path, options);
230
+ this.journal.delete('delete', path);
231
+ return this.remapIno(path, inode);
219
232
  }
220
233
  async link(srcpath, dstpath) {
221
234
  await this.copyForWrite(srcpath);
222
235
  await this.writable.link(srcpath, dstpath);
236
+ this.journal.delete('delete', dstpath);
223
237
  }
224
238
  linkSync(srcpath, dstpath) {
225
239
  this.copyForWriteSync(srcpath);
226
240
  this.writable.linkSync(srcpath, dstpath);
241
+ this.journal.delete('delete', dstpath);
227
242
  }
228
243
  async unlink(path) {
229
244
  if (!(await this.exists(path)))
@@ -285,19 +300,27 @@ export class CopyOnWriteFS extends FileSystem {
285
300
  if (await this.exists(path))
286
301
  throw withErrno('EEXIST');
287
302
  await this.createParentDirectories(path);
288
- return this.remapIno(path, await this.writable.mkdir(path, options));
303
+ const inode = await this.writable.mkdir(path, options);
304
+ this.journal.delete('delete', path);
305
+ return this.remapIno(path, inode);
289
306
  }
290
307
  mkdirSync(path, options) {
291
308
  if (this.existsSync(path))
292
309
  throw withErrno('EEXIST');
293
310
  this.createParentDirectoriesSync(path);
294
- return this.remapIno(path, this.writable.mkdirSync(path, options));
311
+ const inode = this.writable.mkdirSync(path, options);
312
+ this.journal.delete('delete', path);
313
+ return this.remapIno(path, inode);
295
314
  }
296
315
  async readdir(path) {
297
- if (this.isDeleted(path) || !(await this.exists(path)))
316
+ if (this.isDeleted(path))
317
+ throw withErrno('ENOENT');
318
+ const readable = await this.readable.stat(path).catch(() => null);
319
+ const writable = await this.writable.stat(path).catch(() => null);
320
+ if (!readable && !writable)
298
321
  throw withErrno('ENOENT');
299
- const entries = (await this.readable.exists(path)) ? await this.readable.readdir(path) : [];
300
- if (await this.writable.exists(path))
322
+ const entries = readable && isDirectory(readable) ? await this.readable.readdir(path) : [];
323
+ if (writable && isDirectory(writable))
301
324
  for (const entry of await this.writable.readdir(path)) {
302
325
  if (!entries.includes(entry))
303
326
  entries.push(entry);
@@ -305,10 +328,26 @@ export class CopyOnWriteFS extends FileSystem {
305
328
  return entries.filter(entry => !this.isDeleted(join(path, entry)));
306
329
  }
307
330
  readdirSync(path) {
308
- if (this.isDeleted(path) || !this.existsSync(path))
331
+ if (this.isDeleted(path))
332
+ throw withErrno('ENOENT');
333
+ let readable = null;
334
+ let writable = null;
335
+ try {
336
+ readable = this.readable.statSync(path);
337
+ }
338
+ catch {
339
+ // Not on the readable layer
340
+ }
341
+ try {
342
+ writable = this.writable.statSync(path);
343
+ }
344
+ catch {
345
+ // Not on the writable layer
346
+ }
347
+ if (!readable && !writable)
309
348
  throw withErrno('ENOENT');
310
- const entries = this.readable.existsSync(path) ? this.readable.readdirSync(path) : [];
311
- if (this.writable.existsSync(path))
349
+ const entries = readable && isDirectory(readable) ? this.readable.readdirSync(path) : [];
350
+ if (writable && isDirectory(writable))
312
351
  for (const entry of this.writable.readdirSync(path)) {
313
352
  if (!entries.includes(entry))
314
353
  entries.push(entry);
@@ -55,7 +55,7 @@ import { Exception, rethrow, UV } from 'kerium';
55
55
  import { encodeUTF8 } from 'utilium';
56
56
  import * as constants from '../constants.js';
57
57
  import { hasAccess, InodeFlags, isDirectory } from '../internal/inode.js';
58
- import { dirname, join, matchesGlob } from '../path.js';
58
+ import { dirname, join, matchesGlob, resolve } from '../path.js';
59
59
  import '../polyfills.js';
60
60
  import { _tempDirName, globToRegex, normalizeMode, normalizeOptions, normalizePath, normalizeTime, validateFD } from '../utils.js';
61
61
  import * as _async from '../vfs/async.js';
@@ -1081,8 +1081,16 @@ export async function cp(source, destination, opts) {
1081
1081
  await Promise.all(entries.map(_cp));
1082
1082
  break;
1083
1083
  }
1084
- case constants.S_IFREG:
1085
1084
  case constants.S_IFLNK:
1085
+ if (opts?.dereference) {
1086
+ await copyFile.call(this, source, destination);
1087
+ }
1088
+ else {
1089
+ const link = await readlink.call(this, source);
1090
+ await symlink.call(this, opts?.verbatimSymlinks ? link : resolve.call(this, dirname(source), link), destination);
1091
+ }
1092
+ break;
1093
+ case constants.S_IFREG:
1086
1094
  await copyFile.call(this, source, destination);
1087
1095
  break;
1088
1096
  case constants.S_IFBLK:
package/dist/node/sync.js CHANGED
@@ -56,7 +56,7 @@ import { encodeUTF8 } from 'utilium';
56
56
  import * as constants from '../constants.js';
57
57
  import { wrap } from '../internal/error.js';
58
58
  import { hasAccess, isDirectory } from '../internal/inode.js';
59
- import { dirname, join, matchesGlob } from '../path.js';
59
+ import { dirname, join, matchesGlob, resolve } from '../path.js';
60
60
  import { _tempDirName, globToRegex, normalizeMode, normalizeOptions, normalizePath, normalizeTime } from '../utils.js';
61
61
  import { checkAccess } from '../vfs/config.js';
62
62
  import { deleteFD, fromFD, toFD } from '../vfs/file.js';
@@ -202,7 +202,6 @@ export function writeFileSync(path, data, _options = {}) {
202
202
  : _sync.open.call(this, path.toString(), {
203
203
  flag,
204
204
  mode: options.mode,
205
- preserveSymlinks: true,
206
205
  }), false);
207
206
  file.writeSync(encodedData, 0, encodedData.byteLength, 0);
208
207
  emitChange(this, 'change', path.toString());
@@ -237,7 +236,6 @@ export function appendFileSync(filename, data, _options = {}) {
237
236
  : _sync.open.call(this, normalizePath(filename), {
238
237
  flag,
239
238
  mode: options.mode,
240
- preserveSymlinks: true,
241
239
  });
242
240
  file.writeSync(encodedData, 0, encodedData.byteLength);
243
241
  if (typeof file != 'number')
@@ -567,9 +565,9 @@ export function mkdtempDisposableSync(prefix, options) {
567
565
  export function copyFileSync(source, destination, flags) {
568
566
  source = normalizePath(source);
569
567
  destination = normalizePath(destination);
570
- if (flags && flags & constants.COPYFILE_EXCL && existsSync(destination))
568
+ if (flags && flags & constants.COPYFILE_EXCL && existsSync.call(this, destination))
571
569
  throw UV('EEXIST', 'copyFile', destination);
572
- writeFileSync.call(this, destination, readFileSync(source));
570
+ writeFileSync.call(this, destination, readFileSync.call(this, source));
573
571
  emitChange(this, 'rename', destination.toString());
574
572
  }
575
573
  copyFileSync;
@@ -647,8 +645,16 @@ export function cpSync(source, destination, opts) {
647
645
  cpSync.call(this, join(source, dirent.name), join(destination, dirent.name), opts);
648
646
  }
649
647
  break;
650
- case constants.S_IFREG:
651
648
  case constants.S_IFLNK:
649
+ if (opts?.dereference) {
650
+ copyFileSync.call(this, source, destination);
651
+ }
652
+ else {
653
+ const link = readlinkSync.call(this, source, 'utf8');
654
+ symlinkSync.call(this, opts?.verbatimSymlinks ? link : resolve.call(this, dirname(source), link), destination);
655
+ }
656
+ break;
657
+ case constants.S_IFREG:
652
658
  copyFileSync.call(this, source, destination);
653
659
  break;
654
660
  case constants.S_IFBLK:
package/dist/vfs/async.js CHANGED
@@ -102,7 +102,7 @@ export async function resolve($, path, preserveSymlinks, extra) {
102
102
  throw setUVMessage(Object.assign(e, { syscall: 'stat', path: maybePath, ...extra }));
103
103
  }));
104
104
  if (!stats)
105
- return { ...resolved, fullPath: path };
105
+ return { ...resolved, fullPath: maybePath };
106
106
  if (!isSymbolicLink(stats)) {
107
107
  return { ...resolved, fullPath: maybePath, stats };
108
108
  }
@@ -184,16 +184,36 @@ export async function readlink(path) {
184
184
  return decodeUTF8(data);
185
185
  }
186
186
  export async function mkdir(path, options = {}) {
187
- path = normalizePath(path);
187
+ const original = normalizePath(path);
188
188
  const { euid: uid, egid: gid } = contextOf(this).credentials;
189
189
  const { mode = 0o777, recursive } = options;
190
- const { fs, path: resolved } = resolveMount(path, this, { syscall: 'mkdir' });
191
- const __create = async (path, resolved, parent) => {
190
+ const $ex = { syscall: 'mkdir', path: original };
191
+ const { fullPath: realParent } = await resolve(this, dirname(original), false, $ex);
192
+ const { fs, path: target } = resolveMount(join(realParent, basename(original)), this, $ex);
193
+ const followed = realParent != dirname(original);
194
+ let firstCreated;
195
+ const __create = async (path, resolved) => {
192
196
  const env_2 = { stack: [], error: void 0, hasError: false };
193
197
  try {
198
+ const parentPath = dirname(resolved);
199
+ const parent = recursive && parentPath != '/' ? await __create(dirname(path), parentPath) : await fs.stat(parentPath);
200
+ const _ = __addDisposableResource(env_2, await lockPath(fs, parentPath, 'rw', parent), false);
201
+ if (recursive) {
202
+ const existing = cacheOf(fs).get(resolved)?.inode ?? (await fs.stat(resolved).catch(() => null));
203
+ if (existing) {
204
+ const stats = isSymbolicLink(existing) ? (await resolve(this, path, false, $ex)).stats : existing;
205
+ if (!stats)
206
+ throw UV('ENOENT', $ex);
207
+ if (!isDirectory(stats))
208
+ throw UV(resolved == target ? 'EEXIST' : 'ENOTDIR', $ex);
209
+ return existing;
210
+ }
211
+ // Node reports the link itself with ENOTDIR here, while `mkdirSync` reports the requested path with ENOENT
212
+ if (followed && (await fs.exists(join(parentPath, basename(path)))))
213
+ throw UV('ENOTDIR', 'mkdir', path);
214
+ }
194
215
  if (checkAccess && !hasAccess(this, parent, constants.W_OK))
195
216
  throw UV('EACCES', 'mkdir', path);
196
- const _ = __addDisposableResource(env_2, await lockPath(fs, dirname(resolved), 'rw', parent), false);
197
217
  const inode = await fs
198
218
  .mkdir(resolved, {
199
219
  mode,
@@ -201,6 +221,8 @@ export async function mkdir(path, options = {}) {
201
221
  gid: parent.mode & constants.S_ISGID ? parent.gid : gid,
202
222
  })
203
223
  .catch(rethrow({ syscall: 'mkdir', path }));
224
+ if (recursive)
225
+ firstCreated ??= path;
204
226
  emitChange(this, 'rename', path);
205
227
  return inode;
206
228
  }
@@ -212,22 +234,8 @@ export async function mkdir(path, options = {}) {
212
234
  __disposeResources(env_2);
213
235
  }
214
236
  };
215
- if (!recursive) {
216
- await __create(path, resolved, await fs.stat(dirname(resolved)));
217
- return;
218
- }
219
- const dirs = [];
220
- let origDir = path;
221
- for (let dir = resolved; !(await fs.exists(dir)); dir = dirname(dir), origDir = dirname(origDir)) {
222
- dirs.unshift([origDir, dir]);
223
- }
224
- if (!dirs.length)
225
- return;
226
- const stats = [await fs.stat(dirname(dirs[0][1]))];
227
- for (const [i, [path, resolved]] of dirs.entries()) {
228
- stats.push(await __create(path, resolved, stats[i]));
229
- }
230
- return dirs[0][0];
237
+ await __create(original, target);
238
+ return firstCreated;
231
239
  }
232
240
  export async function readdir(path, options = {}) {
233
241
  path = normalizePath(path);
package/dist/vfs/sync.js CHANGED
@@ -100,7 +100,7 @@ export function resolve($, path, preserveSymlinks, extra) {
100
100
  }
101
101
  catch (e) {
102
102
  if (e.code === 'ENOENT')
103
- return { ...resolved, fullPath: path };
103
+ return { ...resolved, fullPath: maybePath };
104
104
  throw setUVMessage(Object.assign(e, { syscall: 'stat', path: maybePath, ...extra }));
105
105
  }
106
106
  if (!isSymbolicLink(stats)) {
@@ -189,21 +189,48 @@ export function readlink(path) {
189
189
  return decodeUTF8(data);
190
190
  }
191
191
  export function mkdir(path, options = {}) {
192
- path = normalizePath(path);
193
- const { fs, path: resolved } = resolve(this, path);
192
+ const original = normalizePath(path);
193
+ const $ex = { syscall: 'mkdir', path: original };
194
+ const { fullPath: realParent } = resolve(this, dirname(original), false, $ex);
195
+ const { fs, path: target } = resolveMount(join(realParent, basename(original)), this, $ex);
196
+ const followed = realParent != dirname(original);
194
197
  const { euid: uid, egid: gid } = contextOf(this).credentials;
195
198
  const { mode = 0o777, recursive } = options;
196
- const __create = (path, resolved, parent) => {
199
+ let firstCreated;
200
+ const __create = (path, resolved) => {
197
201
  const env_2 = { stack: [], error: void 0, hasError: false };
198
202
  try {
203
+ const parentPath = dirname(resolved);
204
+ const parent = recursive && parentPath != '/' ? __create(dirname(path), parentPath) : fs.statSync(parentPath);
205
+ if (recursive) {
206
+ let existing;
207
+ try {
208
+ existing = cacheOf(fs).get(resolved)?.inode ?? fs.statSync(resolved);
209
+ }
210
+ catch {
211
+ // It doesn't exist yet
212
+ }
213
+ if (existing) {
214
+ const stats = isSymbolicLink(existing) ? resolve(this, path, false, $ex).stats : existing;
215
+ if (!stats)
216
+ throw UV('ENOENT', $ex);
217
+ if (!isDirectory(stats))
218
+ throw UV(resolved == target ? 'EEXIST' : 'ENOTDIR', $ex);
219
+ return existing;
220
+ }
221
+ if (followed && fs.existsSync(join(parentPath, basename(path))))
222
+ throw UV('ENOENT', $ex);
223
+ }
199
224
  if (checkAccess && !hasAccess(this, parent, constants.W_OK))
200
- throw UV('EACCES', 'mkdir', dirname(path));
201
- const _ = __addDisposableResource(env_2, lockPathSync(fs, dirname(resolved), 'rw', parent), false);
225
+ throw UV('EACCES', 'mkdir', path);
226
+ const _ = __addDisposableResource(env_2, lockPathSync(fs, parentPath, 'rw', parent), false);
202
227
  const inode = wrap(fs, 'mkdirSync', { path, syscall: 'mkdir' })(resolved, {
203
228
  mode,
204
229
  uid: parent.mode & constants.S_ISUID ? parent.uid : uid,
205
230
  gid: parent.mode & constants.S_ISGID ? parent.gid : gid,
206
231
  });
232
+ if (recursive)
233
+ firstCreated ??= path;
207
234
  emitChange(this, 'rename', path);
208
235
  return inode;
209
236
  }
@@ -215,21 +242,8 @@ export function mkdir(path, options = {}) {
215
242
  __disposeResources(env_2);
216
243
  }
217
244
  };
218
- if (!recursive) {
219
- __create(path, resolved, fs.statSync(dirname(resolved)));
220
- return;
221
- }
222
- const dirs = [];
223
- for (let dir = resolved, original = path; !fs.existsSync(dir); dir = dirname(dir), original = dirname(original)) {
224
- dirs.unshift({ resolved: dir, original });
225
- }
226
- if (!dirs.length)
227
- return;
228
- const stats = [fs.statSync(dirname(dirs[0].resolved))];
229
- for (const [i, dir] of dirs.entries()) {
230
- stats.push(__create(dir.original, dir.resolved, stats[i]));
231
- }
232
- return dirs[0].original;
245
+ __create(original, target);
246
+ return firstCreated;
233
247
  }
234
248
  export function readdir(path, options = {}) {
235
249
  path = normalizePath(path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenfs/core",
3
- "version": "2.6.1",
3
+ "version": "2.6.2",
4
4
  "description": "A filesystem, anywhere",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -78,4 +78,12 @@ suite('Context', () => {
78
78
 
79
79
  assert.equal(ctx.realpathSync('.'), '/test');
80
80
  });
81
+
82
+ test('copyFileSync should keep the context #307', () => {
83
+ ctx.writeFileSync('/source.txt', 'not in real root!');
84
+ ctx.copyFileSync('/source.txt', '/copy.txt');
85
+
86
+ assert.equal(ctx.readFileSync('/copy.txt', 'utf-8'), 'not in real root!');
87
+ assert(fs.readdirSync('/ctx').includes('copy.txt'));
88
+ });
81
89
  });
@@ -87,4 +87,27 @@ suite('Concurrency', config('write', 'async'), () => {
87
87
  await writer.close();
88
88
  assert.equal(await fs.promises.readFile('/issue-303-close.txt', 'utf8'), 'DIRTYnal');
89
89
  });
90
+
91
+ test('concurrent recursive mkdir of overlapping trees does not fail #308', async () => {
92
+ const paths = ['/308/a', '/308/a', '/308/a/b', '/308/a/b', '/308/c', '/308/c', '/308-2', '/308-2'];
93
+
94
+ const results = await Promise.allSettled(paths.map(path => fs.promises.mkdir(path, { recursive: true })));
95
+
96
+ const rejected = results.filter(r => r.status == 'rejected');
97
+ assert.deepEqual(
98
+ rejected.map(r => r.reason.code ?? r.reason.message),
99
+ []
100
+ );
101
+
102
+ for (const path of paths) assert((await fs.promises.stat(path)).isDirectory());
103
+ });
104
+
105
+ test('parallel recursive mkdir of the same path creates it once #308', async () => {
106
+ const results = await Promise.all(Array.from({ length: 8 }, () => fs.promises.mkdir('/308-same/nested', { recursive: true })));
107
+
108
+ assert.deepEqual(
109
+ results.filter(result => result !== undefined),
110
+ ['/308-same']
111
+ );
112
+ });
90
113
  });
@@ -45,6 +45,13 @@ suite('Directories', config('directories'), () => {
45
45
  assert.equal((await fs.promises.stat('/recursiveP/A/B/C/D')).mode, fs.constants.S_IFDIR | 0o777);
46
46
  });
47
47
 
48
+ test('mkdir, recursive with a file in the way', config('async'), async () => {
49
+ await fs.promises.writeFile('/recursiveF', 'Sample content');
50
+
51
+ await assert.rejects(fs.promises.mkdir('/recursiveF', { recursive: true }), { code: 'EEXIST', path: '/recursiveF' });
52
+ await assert.rejects(fs.promises.mkdir('/recursiveF/A/B', { recursive: true }), { code: 'ENOTDIR', path: '/recursiveF/A/B' });
53
+ });
54
+
48
55
  test('mkdirSync, recursive', config('sync'), () => {
49
56
  assert.equal(fs.mkdirSync('/recursiveS/A/B', { recursive: true, mode: 0o755 }), '/recursiveS');
50
57
  assert.equal(fs.mkdirSync('/recursiveS/A/B/C/D', { recursive: true, mode: 0o777 }), '/recursiveS/A/B/C');
@@ -57,6 +64,13 @@ suite('Directories', config('directories'), () => {
57
64
  assert.equal(fs.statSync('/recursiveS/A/B/C/D').mode, fs.constants.S_IFDIR | 0o777);
58
65
  });
59
66
 
67
+ test('mkdirSync, recursive with a file in the way', config('sync'), () => {
68
+ fs.writeFileSync('/recursiveSF', 'Sample content');
69
+
70
+ assert.throws(() => fs.mkdirSync('/recursiveSF', { recursive: true }), { code: 'EEXIST', path: '/recursiveSF' });
71
+ assert.throws(() => fs.mkdirSync('/recursiveSF/A/B', { recursive: true }), { code: 'ENOTDIR', path: '/recursiveSF/A/B' });
72
+ });
73
+
60
74
  test('rmdir (non-empty)', config('async'), async () => {
61
75
  await fs.promises.mkdir('/rmdirTest');
62
76
  await fs.promises.mkdir('/rmdirTest/rmdirTest2');
@@ -165,4 +179,11 @@ suite('Directories', config('directories'), () => {
165
179
  fs.writeFileSync('/мой-файл.txt', 'HELLO!', 'utf-8');
166
180
  assert(fs.readdirSync('/').includes('мой-файл.txt'));
167
181
  });
182
+
183
+ test('re-creating a deleted path still shows up in readdir #305', config('sync'), () => {
184
+ fs.unlinkSync('/elipses.txt');
185
+ fs.writeFileSync('/elipses.txt', '...');
186
+
187
+ assert(fs.readdirSync('/').includes('elipses.txt'));
188
+ });
168
189
  });
@@ -83,4 +83,150 @@ suite('Links', config('symlinks'), () => {
83
83
  const linkContent = await fs.promises.readFile(link, 'utf8');
84
84
  assert.equal(targetContent, linkContent);
85
85
  });
86
+
87
+ test('mkdir does not follow a symlink at the final component', config('async'), async () => {
88
+ await fs.promises.mkdir('/mkdir-real');
89
+ await fs.promises.symlink('/mkdir-real', '/mkdir-link');
90
+ await fs.promises.writeFile('/mkdir-file', 'contents');
91
+ await fs.promises.symlink('/mkdir-file', '/mkdir-flink');
92
+ await fs.promises.symlink('/mkdir-missing', '/mkdir-dangling');
93
+
94
+ await assert.rejects(fs.promises.mkdir('/mkdir-link'), { code: 'EEXIST' });
95
+ await assert.rejects(fs.promises.mkdir('/mkdir-dangling'), { code: 'EEXIST' });
96
+ await assert.rejects(fs.promises.mkdir('/mkdir-flink', { recursive: true }), { code: 'EEXIST' });
97
+ await assert.rejects(fs.promises.mkdir('/mkdir-dangling', { recursive: true }), { code: 'ENOENT' });
98
+
99
+ // A link pointing at a directory satisfies a recursive mkdir and is left as a link
100
+ assert.equal(await fs.promises.mkdir('/mkdir-link', { recursive: true }), undefined);
101
+ assert((await fs.promises.lstat('/mkdir-link')).isSymbolicLink());
102
+ assert(!(await fs.promises.exists('/mkdir-missing')));
103
+ });
104
+
105
+ test('mkdir follows symlinks for intermediate components', config('async'), async () => {
106
+ assert.equal(await fs.promises.mkdir('/mkdir-link/direct'), undefined);
107
+ assert.equal(await fs.promises.mkdir('/mkdir-link/nested/deep', { recursive: true }), '/mkdir-link/nested');
108
+
109
+ assert.deepEqual((await fs.promises.readdir('/mkdir-real')).sort(), ['direct', 'nested']);
110
+ assert((await fs.promises.stat('/mkdir-real/nested/deep')).isDirectory());
111
+
112
+ await assert.rejects(fs.promises.mkdir('/mkdir-flink/x', { recursive: true }), { code: 'ENOTDIR' });
113
+
114
+ // Node reports the link itself here, unlike `mkdirSync` which reports the requested path with ENOENT
115
+ await assert.rejects(fs.promises.mkdir('/mkdir-dangling/x', { recursive: true }), { code: 'ENOTDIR', path: '/mkdir-dangling' });
116
+ assert(!(await fs.promises.exists('/mkdir-missing')));
117
+ });
118
+
119
+ test('mkdirSync does not follow a symlink at the final component', config('sync'), () => {
120
+ fs.mkdirSync('/mkdirS-real');
121
+ fs.symlinkSync('/mkdirS-real', '/mkdirS-link');
122
+ fs.writeFileSync('/mkdirS-file', 'contents');
123
+ fs.symlinkSync('/mkdirS-file', '/mkdirS-flink');
124
+ fs.symlinkSync('/mkdirS-missing', '/mkdirS-dangling');
125
+
126
+ assert.throws(() => fs.mkdirSync('/mkdirS-link'), { code: 'EEXIST' });
127
+ assert.throws(() => fs.mkdirSync('/mkdirS-dangling'), { code: 'EEXIST' });
128
+ assert.throws(() => fs.mkdirSync('/mkdirS-flink', { recursive: true }), { code: 'EEXIST' });
129
+ assert.throws(() => fs.mkdirSync('/mkdirS-dangling', { recursive: true }), { code: 'ENOENT' });
130
+
131
+ assert.equal(fs.mkdirSync('/mkdirS-link', { recursive: true }), undefined);
132
+ assert(fs.lstatSync('/mkdirS-link').isSymbolicLink());
133
+ assert(!fs.existsSync('/mkdirS-missing'));
134
+ });
135
+
136
+ test('mkdirSync follows symlinks for intermediate components', config('sync'), () => {
137
+ assert.equal(fs.mkdirSync('/mkdirS-link/direct'), undefined);
138
+ assert.equal(fs.mkdirSync('/mkdirS-link/nested/deep', { recursive: true }), '/mkdirS-link/nested');
139
+
140
+ assert.deepEqual(fs.readdirSync('/mkdirS-real').sort(), ['direct', 'nested']);
141
+ assert(fs.statSync('/mkdirS-real/nested/deep').isDirectory());
142
+
143
+ assert.throws(() => fs.mkdirSync('/mkdirS-flink/x', { recursive: true }), { code: 'ENOTDIR' });
144
+ assert.throws(() => fs.mkdirSync('/mkdirS-dangling/x', { recursive: true }), { code: 'ENOENT', path: '/mkdirS-dangling/x' });
145
+ assert(!fs.existsSync('/mkdirS-missing'));
146
+ });
147
+
148
+ test('writeFile and appendFile follow symlinks', config('async'), async () => {
149
+ await fs.promises.mkdir('/wf-real');
150
+ await fs.promises.symlink('/wf-real', '/wf-link');
151
+ await fs.promises.writeFile('/wf-real/f.txt', 'original');
152
+ await fs.promises.symlink('/wf-real/f.txt', '/wf-flink');
153
+ await fs.promises.symlink('/wf-missing.txt', '/wf-dangling');
154
+
155
+ await fs.promises.writeFile('/wf-link/new.txt', 'hi');
156
+ assert.equal(await fs.promises.readFile('/wf-real/new.txt', 'utf8'), 'hi');
157
+
158
+ // A link to a file is written through, not replaced
159
+ await fs.promises.writeFile('/wf-flink', 'replaced');
160
+ await fs.promises.appendFile('/wf-flink', '+');
161
+ assert.equal(await fs.promises.readFile('/wf-real/f.txt', 'utf8'), 'replaced+');
162
+ assert((await fs.promises.lstat('/wf-flink')).isSymbolicLink());
163
+
164
+ // Writing through a dangling link creates its target
165
+ await fs.promises.writeFile('/wf-dangling', 'made');
166
+ assert.equal(await fs.promises.readFile('/wf-missing.txt', 'utf8'), 'made');
167
+ assert((await fs.promises.lstat('/wf-dangling')).isSymbolicLink());
168
+ });
169
+
170
+ test('writeFileSync and appendFileSync follow symlinks', config('sync'), () => {
171
+ fs.mkdirSync('/wfS-real');
172
+ fs.symlinkSync('/wfS-real', '/wfS-link');
173
+ fs.writeFileSync('/wfS-real/f.txt', 'original');
174
+ fs.symlinkSync('/wfS-real/f.txt', '/wfS-flink');
175
+ fs.symlinkSync('/wfS-missing.txt', '/wfS-dangling');
176
+
177
+ fs.writeFileSync('/wfS-link/new.txt', 'hi');
178
+ assert.equal(fs.readFileSync('/wfS-real/new.txt', 'utf8'), 'hi');
179
+
180
+ fs.writeFileSync('/wfS-flink', 'replaced');
181
+ fs.appendFileSync('/wfS-flink', '+');
182
+ assert.equal(fs.readFileSync('/wfS-real/f.txt', 'utf8'), 'replaced+');
183
+ assert(fs.lstatSync('/wfS-flink').isSymbolicLink());
184
+
185
+ fs.writeFileSync('/wfS-dangling', 'made');
186
+ assert.equal(fs.readFileSync('/wfS-missing.txt', 'utf8'), 'made');
187
+ assert(fs.lstatSync('/wfS-dangling').isSymbolicLink());
188
+ });
189
+
190
+ test('cp preserves a symlink by default #304', async () => {
191
+ await fs.promises.mkdir('/cp-src');
192
+ await fs.promises.writeFile('/cp-src/real.txt', 'contents');
193
+ await fs.promises.symlink('real.txt', '/cp-src/link.txt');
194
+
195
+ await fs.promises.cp('/cp-src', '/cp-dst', { recursive: true });
196
+
197
+ const stats = await fs.promises.lstat('/cp-dst/link.txt');
198
+ assert(stats.isSymbolicLink());
199
+
200
+ // A relative target is resolved against the link's directory, so the copy points at the original file
201
+ assert.equal(await fs.promises.readlink('/cp-dst/link.txt'), '/cp-src/real.txt');
202
+ });
203
+
204
+ test('cp with verbatimSymlinks keeps the target unresolved', async () => {
205
+ await fs.promises.cp('/cp-src', '/cp-verbatim', { recursive: true, verbatimSymlinks: true });
206
+
207
+ assert((await fs.promises.lstat('/cp-verbatim/link.txt')).isSymbolicLink());
208
+ assert.equal(await fs.promises.readlink('/cp-verbatim/link.txt'), 'real.txt');
209
+ });
210
+
211
+ test('cp with dereference copies the symlink target contents #304', async () => {
212
+ await fs.promises.cp('/cp-src', '/cp-deref', { recursive: true, dereference: true });
213
+
214
+ const stats = await fs.promises.lstat('/cp-deref/link.txt');
215
+ assert(!stats.isSymbolicLink());
216
+ assert.equal(await fs.promises.readFile('/cp-deref/link.txt', 'utf8'), 'contents');
217
+ });
218
+
219
+ test('cpSync preserves a symlink #304', () => {
220
+ fs.writeFileSync('/cp-sync-target.txt', 'contents');
221
+ fs.symlinkSync('cp-sync-target.txt', '/cp-sync-link');
222
+
223
+ fs.cpSync('/cp-sync-link', '/cp-sync-link-copy');
224
+
225
+ const stats = fs.lstatSync('/cp-sync-link-copy');
226
+ assert(stats.isSymbolicLink());
227
+ assert.equal(fs.readlinkSync('/cp-sync-link-copy'), '/cp-sync-target.txt');
228
+
229
+ fs.cpSync('/cp-sync-link', '/cp-sync-link-verbatim', { verbatimSymlinks: true });
230
+ assert.equal(fs.readlinkSync('/cp-sync-link-verbatim'), 'cp-sync-target.txt');
231
+ });
86
232
  });
@@ -47,6 +47,7 @@ function toNative(path: PathLike): string {
47
47
 
48
48
  /** Translate a native path back into a test path */
49
49
  function fromNative(path: string): string {
50
+ if (!isAbsolute(path)) return path;
50
51
  return resolve('/', relative(root, path));
51
52
  }
52
53