@zenfs/core 2.6.4 → 2.6.6

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.
@@ -11,7 +11,8 @@ const journalOperations = new Set(['delete']);
11
11
  function isJournalOp(op) {
12
12
  return journalOperations.has(op);
13
13
  }
14
- const maxOpLength = Math.max(...[...journalOperations].map(op => op.length));
14
+ // @todo use `Iterator` helper #296
15
+ const maxOpLength = [...journalOperations].reduce((max, op) => Math.max(max, op.length), 0);
15
16
  const journalMagicString = '#journal@v0\n';
16
17
  /**
17
18
  * Tracks various operations for the CoW backend
@@ -116,11 +117,19 @@ export class CopyOnWriteFS extends FileSystem {
116
117
  _nextIno = 1;
117
118
  /** Moves the public inode numbers for a rename, including a directory's descendants */
118
119
  renameInos(oldPath, newPath) {
119
- for (const [path, ino] of [...this.inos]) {
120
+ const changes = [];
121
+ for (const [path, ino] of this.inos) {
120
122
  if (path != oldPath && !path.startsWith(oldPath + '/'))
121
123
  continue;
122
- this.inos.delete(path);
123
- this.inos.set(newPath + path.slice(oldPath.length), ino);
124
+ changes.push({
125
+ path,
126
+ add: newPath + path.slice(oldPath.length),
127
+ ino,
128
+ });
129
+ }
130
+ for (const change of changes) {
131
+ this.inos.delete(change.path);
132
+ this.inos.set(change.add, change.ino);
124
133
  }
125
134
  }
126
135
  /** Replaces the layer's inode number with the public one for `path` */
@@ -442,7 +451,7 @@ export class CopyOnWriteFS extends FileSystem {
442
451
  if (size)
443
452
  this.readable.readSync(path, data, 0, size);
444
453
  this.writable.createFileSync(path, stats);
445
- this.writable.touchSync(path, stats);
454
+ this.writable.touchSync(path, { ...pick(stats, _inode_fields), attributes: stats.attributes, size });
446
455
  if (size)
447
456
  this.writable.writeSync(path, data, 0);
448
457
  }
@@ -460,7 +469,7 @@ export class CopyOnWriteFS extends FileSystem {
460
469
  if (size)
461
470
  await this.readable.read(path, data, 0, size);
462
471
  await this.writable.createFile(path, stats);
463
- await this.writable.touch(path, stats);
472
+ await this.writable.touch(path, { ...pick(stats, _inode_fields), attributes: stats.attributes, size });
464
473
  if (size)
465
474
  await this.writable.write(path, data, 0);
466
475
  }
@@ -1,6 +1,7 @@
1
1
  import type { IndexData } from '../internal/file_index.js';
2
2
  import { Index } from '../internal/file_index.js';
3
3
  import { IndexFS } from '../internal/index_fs.js';
4
+ import type { Inode, InodeLike } from '../internal/inode.js';
4
5
  import type { SharedConfig } from './backend.js';
5
6
  /**
6
7
  * Configuration options for FetchFS.
@@ -45,6 +46,9 @@ export declare class FetchFS extends IndexFS {
45
46
  */
46
47
  protected _async(p: Promise<unknown>): void;
47
48
  constructor(index: Index, baseUrl: string, requestInit?: RequestInit, remoteWrite?: boolean | undefined);
49
+ protected resize(path: string, inode: Inode, oldSize: number): Promise<void> | void;
50
+ touch(path: string, metadata: InodeLike): Promise<void>;
51
+ touchSync(path: string, metadata: InodeLike): void;
48
52
  protected remove(path: string): Promise<void>;
49
53
  protected removeSync(path: string): void;
50
54
  read(path: string, buffer: Uint8Array, offset: number | undefined, end: number): Promise<void>;
@@ -1,10 +1,12 @@
1
1
  // SPDX-License-Identifier: LGPL-3.0-or-later
2
2
  import { withErrno } from 'kerium';
3
3
  import { debug, err, warn } from 'kerium/log';
4
- import { decodeUTF8 } from 'utilium';
4
+ import { _throw, decodeUTF8 } from 'utilium';
5
+ import { Resource } from 'utilium/cache';
5
6
  import * as requests from 'utilium/requests';
6
7
  import { Index } from '../internal/file_index.js';
7
8
  import { IndexFS } from '../internal/index_fs.js';
9
+ import { isDirectory } from '../internal/inode.js';
8
10
  import { normalizePath } from '../utils.js';
9
11
  import { S_IFREG } from '../constants.js';
10
12
  /** Parse and throw */
@@ -48,6 +50,33 @@ export class FetchFS extends IndexFS {
48
50
  this.requestInit = requestInit;
49
51
  this.remoteWrite = remoteWrite;
50
52
  }
53
+ resize(path, inode, oldSize) {
54
+ if (isDirectory(inode) || inode.size == oldSize)
55
+ return;
56
+ const url = this.baseUrl + path;
57
+ const resource = requests.resourcesCache.get(url) ?? new Resource(url, oldSize, {}, requests.resourcesCache);
58
+ resource.size = inode.size;
59
+ if (inode.size < oldSize)
60
+ return;
61
+ const zeroes = new Uint8Array(inode.size - oldSize);
62
+ resource.add(zeroes, oldSize);
63
+ if (this.remoteWrite)
64
+ return requests.set(url, zeroes, { offset: oldSize, warn }, this.requestInit).catch(parseError);
65
+ }
66
+ async touch(path, metadata) {
67
+ const inode = this.index.get(path) ?? _throw(withErrno('ENOENT'));
68
+ const oldSize = inode.size;
69
+ inode.update(metadata);
70
+ await this.resize(path, inode, oldSize);
71
+ }
72
+ touchSync(path, metadata) {
73
+ const inode = this.index.get(path) ?? _throw(withErrno('ENOENT'));
74
+ const oldSize = inode.size;
75
+ inode.update(metadata);
76
+ const pending = this.resize(path, inode, oldSize);
77
+ if (pending)
78
+ this._async(pending);
79
+ }
51
80
  async remove(path) {
52
81
  await requests.remove(this.baseUrl + path, { warn, cacheOnly: !this.remoteWrite }, this.requestInit);
53
82
  }
@@ -418,6 +418,12 @@ export class StoreFS extends FileSystem {
418
418
  try {
419
419
  const tx = __addDisposableResource(env_9, this.transaction(), true);
420
420
  const inode = await this.findInode(tx, path);
421
+ if (!isDirectory(inode) && metadata.size !== undefined && metadata.size !== inode.size) {
422
+ const data = (await tx.get(inode.data)) ?? new Uint8Array();
423
+ if (metadata.size < data.byteLength)
424
+ data.fill(0, metadata.size);
425
+ await tx.set(inode.data, metadata.size < data.byteLength ? data.subarray(0, metadata.size) : extendBuffer(data, metadata.size));
426
+ }
421
427
  if (inode.update(metadata)) {
422
428
  this._add(inode.ino, path);
423
429
  tx.setSync(inode.ino, inode);
@@ -439,6 +445,12 @@ export class StoreFS extends FileSystem {
439
445
  try {
440
446
  const tx = __addDisposableResource(env_10, this.transaction(), false);
441
447
  const inode = this.findInodeSync(tx, path);
448
+ if (!isDirectory(inode) && metadata.size !== undefined && metadata.size !== inode.size) {
449
+ const data = tx.getSync(inode.data) ?? new Uint8Array();
450
+ if (metadata.size < data.byteLength)
451
+ data.fill(0, metadata.size);
452
+ tx.setSync(inode.data, metadata.size < data.byteLength ? data.subarray(0, metadata.size) : extendBuffer(data, metadata.size));
453
+ }
442
454
  if (inode.update(metadata)) {
443
455
  this._add(inode.ino, path);
444
456
  tx.setSync(inode.ino, inode);
@@ -921,7 +933,8 @@ export class StoreFS extends FileSystem {
921
933
  _lastID;
922
934
  /** Allocates a new ID and adds the ID/path */
923
935
  allocNew(path) {
924
- this._lastID ??= Math.max(...this._paths.keys());
936
+ // @todo use `Iterator` helper #296
937
+ this._lastID ||= Array.from(this._paths.keys()).reduce((max, ino) => Math.max(max, ino), 0);
925
938
  this._lastID += 2;
926
939
  const id = this._lastID;
927
940
  if (id > size_max)
@@ -17,6 +17,7 @@ export declare const version = 1;
17
17
  * @internal
18
18
  */
19
19
  export declare class Index extends Map<string, Inode> {
20
+ #private;
20
21
  maxSize: number;
21
22
  /**
22
23
  * Converts the index to JSON
@@ -26,6 +27,7 @@ export declare class Index extends Map<string, Inode> {
26
27
  * Converts the index to a string
27
28
  */
28
29
  toString(): string;
30
+ clear(): void;
29
31
  /**
30
32
  * Get the size in bytes of the index (including the size reported for each entry)
31
33
  */
@@ -21,7 +21,8 @@ export class Index extends Map {
21
21
  return {
22
22
  version,
23
23
  maxSize: this.maxSize,
24
- entries: Object.fromEntries([...this].map(([k, v]) => [k, v.toJSON()])),
24
+ // @todo use `Iterator.map` #296
25
+ entries: Object.fromEntries(Array.from(this).map(([k, v]) => [k, v.toJSON()])),
25
26
  };
26
27
  }
27
28
  /**
@@ -30,6 +31,10 @@ export class Index extends Map {
30
31
  toString() {
31
32
  return JSON.stringify(this.toJSON());
32
33
  }
34
+ clear() {
35
+ super.clear();
36
+ this.#lastAlloc = 0;
37
+ }
33
38
  /**
34
39
  * Get the size in bytes of the index (including the size reported for each entry)
35
40
  */
@@ -74,12 +79,16 @@ export class Index extends Map {
74
79
  }
75
80
  return entries;
76
81
  }
82
+ #lastAlloc = 0;
77
83
  /**
78
84
  * Get the next available ID in the index
79
85
  * @internal
80
86
  */
81
87
  _alloc() {
82
- return Math.max(...[...this.values()].flatMap(i => [i.ino, i.data])) + 1;
88
+ // @todo use `Iterator.flatMap` #296
89
+ this.#lastAlloc ||= Array.from(this.values()).reduce((max, inode) => Math.max(max, inode.ino, inode.data), 0);
90
+ this.#lastAlloc++;
91
+ return this.#lastAlloc++;
83
92
  }
84
93
  /**
85
94
  * Gets a list of entries for each directory in the index.
package/dist/polyfills.js CHANGED
@@ -43,7 +43,10 @@ Uint8Array.fromBase64 ??=
43
43
  Uint8Array.prototype.toHex ??=
44
44
  (warn('Using a polyfill of Uint8Array.prototype.toHex'),
45
45
  function toHex() {
46
- return [...this].map(b => b.toString(16).padStart(2, '0')).join('');
46
+ // @todo use `Iterator.map` #296
47
+ return Array.from(this)
48
+ .map(b => b.toString(16).padStart(2, '0'))
49
+ .join('');
47
50
  });
48
51
  Uint8Array.fromHex ??=
49
52
  (warn('Using a polyfill of Uint8Array.fromHex'),
package/dist/vfs/file.js CHANGED
@@ -675,7 +675,8 @@ export { Handle };
675
675
  */
676
676
  export function toFD(file) {
677
677
  const map = contextOf(file.context).descriptors;
678
- const fd = Math.max(map.size ? Math.max(...map.keys()) + 1 : 0, 4);
678
+ // @todo use `Iterator` helper #296
679
+ const fd = Array.from(map.keys()).reduce((max, k) => Math.max(max, k), 4);
679
680
  map.set(fd, file);
680
681
  return fd;
681
682
  }
@@ -50,6 +50,7 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
50
50
  var e = new Error(message);
51
51
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
52
  });
53
+ import { err } from 'kerium/log';
53
54
  import { VNode } from './vnode.js';
54
55
  /**
55
56
  * The VFS-level cache of vnodes for a single file system. Modeled after Linux's dcache/icache.
@@ -76,6 +77,8 @@ export class VCache {
76
77
  */
77
78
  ref(path, inode) {
78
79
  let node = this.byIno.get(inode.ino);
80
+ if (node && node.inode.nlink === node.paths.size && !node.paths.has(path))
81
+ err(`vcache.ref: vnode for ${this.fs.label || this.fs.uuid}:${inode.ino} has an nlink of ${node.inode.nlink} but referenced by more paths [#314]`);
79
82
  if (!node) {
80
83
  node = new VNode(this.fs, path, inode);
81
84
  this.byIno.set(inode.ino, node);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenfs/core",
3
- "version": "2.6.4",
3
+ "version": "2.6.6",
4
4
  "description": "A filesystem, anywhere",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -90,12 +90,8 @@
90
90
  "eslint": "^10.1.0",
91
91
  "globals": "^17.3.0",
92
92
  "prettier": "^3.2.5",
93
- "tsx": "^4.19.1",
94
93
  "typedoc": "^0.28.18",
95
94
  "typescript": "^6.0.0",
96
95
  "typescript-eslint": "^8.58.0"
97
- },
98
- "allowScripts": {
99
- "esbuild": true
100
96
  }
101
97
  }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { lstatSync, readdirSync, writeFileSync, constants as c } from 'node:fs';
2
+ import { constants as c, lstatSync, readdirSync, writeFileSync } from 'node:fs';
3
3
  import { join, matchesGlob, relative, resolve } from 'node:path/posix';
4
4
  import { parseArgs, styleText } from 'node:util';
5
5
 
@@ -9,6 +9,7 @@ const { values: options, positionals } = parseArgs({
9
9
  ignore: { short: 'i', type: 'string', multiple: true, default: [] },
10
10
  output: { short: 'o', type: 'string', default: 'index.json' },
11
11
  quiet: { short: 'q', type: 'boolean', default: false },
12
+ xdev: { type: 'boolean', default: false },
12
13
  verbose: { type: 'boolean', default: false },
13
14
  },
14
15
  allowPositionals: true,
@@ -22,11 +23,12 @@ if (options.help) {
22
23
  path: The path to create a listing for
23
24
 
24
25
  options:
25
- --help, -h Outputs this help message
26
- --quiet, -q The command will not generate any output, including error messages.
27
- --verbose Output verbose messages
28
- --output, -o <path> Path to the output file. Defaults to listing.
29
- --ignore, -i <pattern> Ignores files which match the glob <pattern>. Can be passed multiple times.
26
+ -h, --help Outputs this help message
27
+ -q, --quiet The command will not generate any output, including error messages.
28
+ --verbose Output verbose messages
29
+ --xdev Force crossing device/FS boundaries
30
+ -o, --output <path> Path to the output file. Defaults to listing.
31
+ -i, --ignore <pattern> Ignores files which match the glob <pattern>. Can be passed multiple times.
30
32
  `);
31
33
  process.exit();
32
34
  }
@@ -75,6 +77,8 @@ function getTypeText(/** @type {{ mode: number }} */ stats) {
75
77
  }
76
78
  }
77
79
 
80
+ const seenDevs = new Set();
81
+
78
82
  /**
79
83
  * @param {string} path
80
84
  */
@@ -86,6 +90,17 @@ function computeEntries(path) {
86
90
  }
87
91
 
88
92
  const stats = lstatSync(path);
93
+ if (!seenDevs.size) seenDevs.add(stats.dev);
94
+ else if (!seenDevs.has(stats.dev))
95
+ if (options.xdev) {
96
+ console.warn(
97
+ `${styleText('yellowBright', `--xdev: Adding entries from device ${stats.dev} (${path}). You may get duplicate inos which MUST be de-duplicated manually.`)}`
98
+ );
99
+ seenDevs.add(stats.dev);
100
+ } else {
101
+ console.warn(`${styleText('yellowBright', `Ignoring ${path} because it crosses a device boundary`)}`);
102
+ return;
103
+ }
89
104
 
90
105
  const type = getTypeText(stats);
91
106
 
@@ -97,7 +112,7 @@ function computeEntries(path) {
97
112
  }
98
113
  } catch (/** @type {any} */ e) {
99
114
  if (!options.quiet) {
100
- console.log(`${styleText('red', 'fail')} ${path}: ${e.message}`);
115
+ console.log(`${styleText('red', 'fail')} ${path}: ${e instanceof Error ? e.message : String(e)}`);
101
116
  }
102
117
  }
103
118
  }
@@ -0,0 +1,33 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ import { CopyOnWriteFS, InMemoryStore, StoreFS } from '@zenfs/core';
3
+ import assert from 'node:assert/strict';
4
+ import { suite, test } from 'node:test';
5
+ import { setupLogs } from '../logs.js';
6
+ setupLogs();
7
+
8
+ const size = 1 << 20;
9
+
10
+ const options = { mode: 0o644, uid: 0, gid: 0 };
11
+
12
+ suite('CopyOnWrite', () => {
13
+ test('Truncating a base-layer file does not copy up the bytes it discards #313', async () => {
14
+ const readable = new StoreFS(new InMemoryStore(undefined, 'ro'));
15
+ await readable.ready();
16
+ await readable.createFile('/big', options);
17
+ await readable.write('/big', new Uint8Array(size).fill(0x41), 0);
18
+
19
+ const writable = new StoreFS(new InMemoryStore(undefined, 'rw'));
20
+ await writable.ready();
21
+
22
+ const fs = new CopyOnWriteFS(readable, writable);
23
+ await fs.touch('/big', { ...(await fs.stat('/big')), size: 4 });
24
+
25
+ assert.equal((await fs.stat('/big')).size, 4);
26
+
27
+ await using tx = writable.transaction();
28
+ const data = (await tx.get((await writable.stat('/big')).data))!;
29
+
30
+ assert.equal(data.byteLength, 4);
31
+ assert.equal(data.buffer.byteLength, 4);
32
+ });
33
+ });
@@ -68,6 +68,14 @@ suite('CopyOnWrite over uncached Fetch #301', () => {
68
68
  // Writes go through the vnode cache, so this surfaces when the handle is flushed rather than at `write`.
69
69
  assert.throws(() => fs.fsyncSync(fd), { code: 'EAGAIN' });
70
70
  });
71
+
72
+ test('Contents follow the size across the layers #313', async () => {
73
+ await fs.promises.truncate('/cow/a1.js', 4);
74
+ assert.equal(await fs.promises.readFile('/cow/a1.js', 'utf8'), '// C');
75
+
76
+ await fs.promises.truncate('/cow/a1.js', 10);
77
+ assert.equal(await fs.promises.readFile('/cow/a1.js', 'utf8'), '// C\0\0\0\0\0\0');
78
+ });
71
79
  });
72
80
 
73
81
  after(async () => {
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: LGPL-3.0-or-later
2
- import assert from 'node:assert';
2
+ import assert from 'node:assert/strict';
3
3
  import { randomBytes } from 'node:crypto';
4
4
  import { suite, test } from 'node:test';
5
5
  import { Worker } from 'worker_threads';
@@ -1,4 +1,4 @@
1
- import assert from 'node:assert';
1
+ import assert from 'node:assert/strict';
2
2
  import { writeFileSync as _write } from 'node:fs';
3
3
  import { parentPort, workerData } from 'node:worker_threads';
4
4
  import { configureSingle, fs, SingleBuffer } from '../../dist/index.js';
@@ -0,0 +1,51 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ import { Index, Inode } from '@zenfs/core';
3
+ import assert from 'node:assert/strict';
4
+ import { suite, test } from 'node:test';
5
+
6
+ /** Build an index with `n` file entries, using IDs 2..2n+1 */
7
+ function buildIndex(n: number): Index {
8
+ const index = new Index();
9
+ index.set('/', new Inode({ ino: 0, data: 1, mode: 0o40755, nlink: 1 }));
10
+ for (let i = 0; i < n; i++) {
11
+ index.set(`/f${i}`, new Inode({ ino: 2 * i + 2, data: 2 * i + 3, mode: 0o100644, nlink: 1 }));
12
+ }
13
+ return index;
14
+ }
15
+
16
+ /** Calls `fn` with `depth` extra frames on the stack */
17
+ function atDepth<T>(depth: number, fn: () => T): T {
18
+ return depth > 0 ? atDepth(depth - 1, fn) : fn();
19
+ }
20
+
21
+ suite('Index', () => {
22
+ test('_alloc returns an unused ID', () => {
23
+ const index = buildIndex(10);
24
+ assert.equal(index._alloc(), 22);
25
+ });
26
+
27
+ test('_alloc does not re-use IDs', () => {
28
+ const index = buildIndex(10);
29
+ const ids = new Set<number>();
30
+
31
+ for (let i = 0; i < 100; i++) {
32
+ const id = index._alloc();
33
+ assert.equal(index.getByID(id), undefined, `\`_alloc\` returned in-use ID ${id}`);
34
+ assert(!ids.has(id), `\`_alloc\` returned duplicate ID ${id}`);
35
+ ids.add(id);
36
+ }
37
+ });
38
+
39
+ test('_alloc works with a large index #312', () => {
40
+ const index = buildIndex(100_000);
41
+ assert.equal(index._alloc(), 200_002);
42
+ });
43
+
44
+ test('_alloc works with a large index and a deep stack #312', () => {
45
+ const index = buildIndex(60_000);
46
+ assert.equal(
47
+ atDepth(1000, () => index._alloc()),
48
+ 120_002
49
+ );
50
+ });
51
+ });
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: LGPL-3.0-or-later
2
2
  import { Inode } from '@zenfs/core';
3
- import assert from 'node:assert';
3
+ import assert from 'node:assert/strict';
4
4
  import { suite, test } from 'node:test';
5
5
 
6
6
  suite('Inode manipulation', () => {
@@ -34,6 +34,17 @@ suite('Truncating', config('truncate'), () => {
34
34
  fs.closeSync(fd);
35
35
  });
36
36
 
37
+ test('Contents follow the size #313', config('sync'), () => {
38
+ const path = 'truncate-contents.txt';
39
+ fs.writeFileSync(path, 'SECRETDATA');
40
+
41
+ fs.truncateSync(path, 4);
42
+ assert.equal(fs.readFileSync(path, 'utf8'), 'SECR');
43
+
44
+ fs.truncateSync(path, 10);
45
+ assert.equal(fs.readFileSync(path, 'utf8'), 'SECR\0\0\0\0\0\0');
46
+ });
47
+
37
48
  const statSize = async (path: string) => (await fs.promises.stat(path)).size;
38
49
 
39
50
  test('Async path functions', config('async'), async () => {
@@ -64,4 +75,15 @@ suite('Truncating', config('truncate'), () => {
64
75
 
65
76
  await handle.close();
66
77
  });
78
+
79
+ test('Contents follow the size (async) #313', config('async'), async () => {
80
+ const path = 'truncate-contents-async.txt';
81
+ await fs.promises.writeFile(path, 'SECRETDATA');
82
+
83
+ await fs.promises.truncate(path, 4);
84
+ assert.equal(await fs.promises.readFile(path, 'utf8'), 'SECR');
85
+
86
+ await fs.promises.truncate(path, 10);
87
+ assert.equal(await fs.promises.readFile(path, 'utf8'), 'SECR\0\0\0\0\0\0');
88
+ });
67
89
  });