@vrtmrz/livesync-commonlib 0.1.0-rc.6 → 0.1.0-rc.8

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.
@@ -2,7 +2,12 @@ import type { UXDataWriteOptions, UXStat } from "../../common/types.js";
2
2
  import type { IStorageAdapter } from "../../serviceModules/adapters/IStorageAdapter.js";
3
3
  export type NodeStorageStat = UXStat;
4
4
  export interface CreateNodeStorageOptions {
5
- /** Absolute or process-relative directory which bounds every adapter operation. */
5
+ /**
6
+ * Host-selected root directory. Adapter paths are resolved relative to this root.
7
+ *
8
+ * The host owns this trust boundary. The adapter is not a filesystem sandbox against
9
+ * an untrusted local process which can replace path components concurrently.
10
+ */
6
11
  rootPath: string;
7
12
  }
8
13
  /** Rooted storage adapter backed by Node's file-system APIs. */
@@ -10,6 +15,7 @@ export declare class NodeStorageAdapter implements IStorageAdapter<NodeStorageSt
10
15
  private readonly rootPath;
11
16
  constructor(rootPath: string);
12
17
  private resolvePath;
18
+ private assertNoSymbolicLinks;
13
19
  exists(path: string): Promise<boolean>;
14
20
  trystat(path: string): Promise<NodeStorageStat | null>;
15
21
  stat(path: string): Promise<NodeStorageStat | null>;
@@ -20,10 +26,13 @@ export declare class NodeStorageAdapter implements IStorageAdapter<NodeStorageSt
20
26
  write(path: string, data: string, options?: UXDataWriteOptions): Promise<void>;
21
27
  writeBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void>;
22
28
  append(path: string, data: string, options?: UXDataWriteOptions): Promise<void>;
29
+ /** Atomically rename or move an entry without leaving the injected root. */
30
+ rename(sourcePath: string, targetPath: string): Promise<void>;
23
31
  list(basePath: string): Promise<{
24
32
  files: string[];
25
33
  folders: string[];
26
34
  }>;
35
+ private writeFile;
27
36
  private applyTimes;
28
37
  }
29
38
  /** Bind a Node storage adapter to an explicitly selected root directory. */
@@ -1,16 +1,40 @@
1
+ import { constants as nodeFsConstants } from "node:fs";
1
2
  import * as nodeFsPromises from "node:fs/promises";
2
3
  import * as nodePath from "node:path";
3
4
  import { validateStoragePath } from "../storagePath.js";
5
+ const NO_FOLLOW = nodeFsConstants.O_NOFOLLOW ?? 0;
6
+ class SymbolicLinkStoragePathError extends Error {
7
+ constructor(path) {
8
+ super(`Storage paths must not contain symbolic links: ${path}`);
9
+ this.name = "SymbolicLinkStoragePathError";
10
+ }
11
+ }
4
12
  class NodeStorageAdapter {
13
+ rootPath;
5
14
  constructor(rootPath) {
6
- this.rootPath = rootPath;
15
+ this.rootPath = nodePath.resolve(rootPath);
7
16
  }
8
- rootPath;
9
- resolvePath(path, allowRoot = true) {
10
- return nodePath.join(this.rootPath, validateStoragePath(path, allowRoot));
17
+ async resolvePath(path, allowRoot = true) {
18
+ const fullPath = nodePath.join(this.rootPath, validateStoragePath(path, allowRoot));
19
+ await this.assertNoSymbolicLinks(fullPath);
20
+ return fullPath;
21
+ }
22
+ async assertNoSymbolicLinks(fullPath) {
23
+ const relativePath = nodePath.relative(this.rootPath, fullPath);
24
+ let currentPath = this.rootPath;
25
+ for (const segment of relativePath.split(nodePath.sep).filter(Boolean)) {
26
+ currentPath = nodePath.join(currentPath, segment);
27
+ try {
28
+ const stat = await nodeFsPromises.lstat(currentPath);
29
+ if (stat.isSymbolicLink()) throw new SymbolicLinkStoragePathError(currentPath);
30
+ } catch (error) {
31
+ if (error.code === "ENOENT") return;
32
+ throw error;
33
+ }
34
+ }
11
35
  }
12
36
  async exists(path) {
13
- const fullPath = this.resolvePath(path);
37
+ const fullPath = await this.resolvePath(path);
14
38
  try {
15
39
  await nodeFsPromises.access(fullPath);
16
40
  return true;
@@ -20,14 +44,15 @@ class NodeStorageAdapter {
20
44
  }
21
45
  async trystat(path) {
22
46
  try {
23
- const stat = await nodeFsPromises.stat(this.resolvePath(path));
47
+ const stat = await nodeFsPromises.stat(await this.resolvePath(path));
24
48
  return {
25
49
  size: stat.size,
26
50
  mtime: Math.floor(stat.mtimeMs),
27
51
  ctime: Math.floor(stat.ctimeMs),
28
52
  type: stat.isDirectory() ? "folder" : "file"
29
53
  };
30
- } catch {
54
+ } catch (error) {
55
+ if (error instanceof SymbolicLinkStoragePathError) throw error;
31
56
  return null;
32
57
  }
33
58
  }
@@ -35,10 +60,12 @@ class NodeStorageAdapter {
35
60
  return await this.trystat(path);
36
61
  }
37
62
  async mkdir(path) {
38
- await nodeFsPromises.mkdir(this.resolvePath(path), { recursive: true });
63
+ const fullPath = await this.resolvePath(path);
64
+ await nodeFsPromises.mkdir(fullPath, { recursive: true });
65
+ await this.assertNoSymbolicLinks(fullPath);
39
66
  }
40
67
  async remove(path) {
41
- const fullPath = this.resolvePath(path, false);
68
+ const fullPath = await this.resolvePath(path, false);
42
69
  const stat = await nodeFsPromises.stat(fullPath);
43
70
  if (stat.isDirectory()) {
44
71
  await nodeFsPromises.rm(fullPath, { recursive: true, force: true });
@@ -47,33 +74,55 @@ class NodeStorageAdapter {
47
74
  }
48
75
  }
49
76
  async read(path) {
50
- return await nodeFsPromises.readFile(this.resolvePath(path, false), "utf-8");
77
+ const handle = await nodeFsPromises.open(
78
+ await this.resolvePath(path, false),
79
+ nodeFsConstants.O_RDONLY | NO_FOLLOW
80
+ );
81
+ try {
82
+ return await handle.readFile("utf-8");
83
+ } finally {
84
+ await handle.close();
85
+ }
51
86
  }
52
87
  async readBinary(path) {
53
- const buffer = await nodeFsPromises.readFile(this.resolvePath(path, false));
54
- return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
88
+ const handle = await nodeFsPromises.open(
89
+ await this.resolvePath(path, false),
90
+ nodeFsConstants.O_RDONLY | NO_FOLLOW
91
+ );
92
+ try {
93
+ const buffer = await handle.readFile();
94
+ return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
95
+ } finally {
96
+ await handle.close();
97
+ }
55
98
  }
56
99
  async write(path, data, options) {
57
- const fullPath = this.resolvePath(path, false);
100
+ const fullPath = await this.resolvePath(path, false);
58
101
  await nodeFsPromises.mkdir(nodePath.dirname(fullPath), { recursive: true });
59
- await nodeFsPromises.writeFile(fullPath, data, "utf-8");
60
- await this.applyTimes(fullPath, options);
102
+ await this.writeFile(fullPath, data, options, false);
61
103
  }
62
104
  async writeBinary(path, data, options) {
63
- const fullPath = this.resolvePath(path, false);
105
+ const fullPath = await this.resolvePath(path, false);
64
106
  await nodeFsPromises.mkdir(nodePath.dirname(fullPath), { recursive: true });
65
- await nodeFsPromises.writeFile(fullPath, new Uint8Array(data));
66
- await this.applyTimes(fullPath, options);
107
+ await this.writeFile(fullPath, new Uint8Array(data), options, false);
67
108
  }
68
109
  async append(path, data, options) {
69
- const fullPath = this.resolvePath(path, false);
110
+ const fullPath = await this.resolvePath(path, false);
70
111
  await nodeFsPromises.mkdir(nodePath.dirname(fullPath), { recursive: true });
71
- await nodeFsPromises.appendFile(fullPath, data, "utf-8");
72
- await this.applyTimes(fullPath, options);
112
+ await this.writeFile(fullPath, data, options, true);
113
+ }
114
+ /** Atomically rename or move an entry without leaving the injected root. */
115
+ async rename(sourcePath, targetPath) {
116
+ const fullSourcePath = await this.resolvePath(sourcePath, false);
117
+ const fullTargetPath = await this.resolvePath(targetPath, false);
118
+ await nodeFsPromises.mkdir(nodePath.dirname(fullTargetPath), { recursive: true });
119
+ await this.assertNoSymbolicLinks(fullSourcePath);
120
+ await this.assertNoSymbolicLinks(fullTargetPath);
121
+ await nodeFsPromises.rename(fullSourcePath, fullTargetPath);
73
122
  }
74
123
  async list(basePath) {
75
124
  try {
76
- const entries = await nodeFsPromises.readdir(this.resolvePath(basePath), { withFileTypes: true });
125
+ const entries = await nodeFsPromises.readdir(await this.resolvePath(basePath), { withFileTypes: true });
77
126
  const files = [];
78
127
  const folders = [];
79
128
  for (const entry of entries) {
@@ -82,15 +131,27 @@ class NodeStorageAdapter {
82
131
  else if (entry.isFile()) files.push(entryPath);
83
132
  }
84
133
  return { files, folders };
85
- } catch {
134
+ } catch (error) {
135
+ if (error instanceof SymbolicLinkStoragePathError) throw error;
86
136
  return { files: [], folders: [] };
87
137
  }
88
138
  }
89
- async applyTimes(fullPath, options) {
139
+ async writeFile(fullPath, data, options, append) {
140
+ await this.assertNoSymbolicLinks(fullPath);
141
+ const flags = nodeFsConstants.O_WRONLY | nodeFsConstants.O_CREAT | NO_FOLLOW | (append ? nodeFsConstants.O_APPEND : nodeFsConstants.O_TRUNC);
142
+ const handle = await nodeFsPromises.open(fullPath, flags, 438);
143
+ try {
144
+ await handle.writeFile(data, typeof data === "string" ? "utf-8" : void 0);
145
+ await this.applyTimes(handle, options);
146
+ } finally {
147
+ await handle.close();
148
+ }
149
+ }
150
+ async applyTimes(handle, options) {
90
151
  if (!options?.mtime && !options?.ctime) return;
91
152
  const accessTime = options.mtime ? new Date(options.mtime) : /* @__PURE__ */ new Date();
92
153
  const modificationTime = options.mtime ? new Date(options.mtime) : /* @__PURE__ */ new Date();
93
- await nodeFsPromises.utimes(fullPath, accessTime, modificationTime);
154
+ await handle.utimes(accessTime, modificationTime);
94
155
  }
95
156
  }
96
157
  function createNodeStorage({ rootPath }) {
@@ -19,14 +19,11 @@ function addP2PEventHandlers(source, events) {
19
19
  });
20
20
  events.onEvent(EVENT_SETTING_SAVED, async (settings) => {
21
21
  const instance = current();
22
- const isOpen = instance.isServing ?? instance.server?.isServing ?? false;
23
22
  if (settings.P2P_Enabled && settings.P2P_AutoStart) {
24
23
  await instance.open();
25
24
  return;
26
25
  }
27
- if (isOpen) {
28
- await instance.close();
29
- }
26
+ await instance.close();
30
27
  });
31
28
  }
32
29
  async function openP2PReplicator(instance) {
@@ -8,6 +8,7 @@ function useP2PReplicatorFeature(host, openReplicationUIFactory, openRebuildUIFa
8
8
  let replicator = new LiveSyncTrysteroReplicator({
9
9
  services: host.services
10
10
  });
11
+ let replacementPromise;
11
12
  if (openReplicationUIFactory) {
12
13
  replicator.env.openReplicationUI = openReplicationUIFactory(replicator);
13
14
  }
@@ -24,22 +25,31 @@ function useP2PReplicatorFeature(host, openReplicationUIFactory, openRebuildUIFa
24
25
  async (settingOverride = {}) => {
25
26
  const settings = { ...host.services.setting.currentSettings(), ...settingOverride };
26
27
  if (settings.remoteType == REMOTE_P2P) {
27
- const existingReplicator = replicator;
28
+ if (replacementPromise) return await replacementPromise;
29
+ const operation = (async () => {
30
+ const existingReplicator = replicator;
31
+ try {
32
+ await existingReplicator?.close();
33
+ } catch (e) {
34
+ Logger(`Error closing existing p2p replicator`);
35
+ Logger(e, LOG_LEVEL_VERBOSE);
36
+ }
37
+ const newReplicator = new LiveSyncTrysteroReplicator({ services: host.services });
38
+ if (openReplicationUIFactory) {
39
+ newReplicator.env.openReplicationUI = openReplicationUIFactory(newReplicator);
40
+ }
41
+ if (openRebuildUIFactory) {
42
+ newReplicator.env.openRebuildUI = openRebuildUIFactory(newReplicator);
43
+ }
44
+ replicator = newReplicator;
45
+ return replicator;
46
+ })();
47
+ replacementPromise = operation;
28
48
  try {
29
- await existingReplicator?.close();
30
- } catch (e) {
31
- Logger(`Error closing existing p2p replicator`);
32
- Logger(e, LOG_LEVEL_VERBOSE);
49
+ return await operation;
50
+ } finally {
51
+ if (replacementPromise === operation) replacementPromise = void 0;
33
52
  }
34
- const newReplicator = new LiveSyncTrysteroReplicator({ services: host.services });
35
- if (openReplicationUIFactory) {
36
- newReplicator.env.openReplicationUI = openReplicationUIFactory(newReplicator);
37
- }
38
- if (openRebuildUIFactory) {
39
- newReplicator.env.openRebuildUI = openRebuildUIFactory(newReplicator);
40
- }
41
- replicator = newReplicator;
42
- return replicator;
43
53
  }
44
54
  return void 0;
45
55
  }
@@ -59,7 +59,7 @@ The multiple-remote profile contract has focused unit coverage in `src/serviceFe
59
59
 
60
60
  ### P2P composition ownership
61
61
 
62
- `useP2PReplicatorFeature` is the sole owner of the active `LiveSyncTrysteroReplicator` and its lifecycle bindings. It creates or replaces the outer replicator when `ReplicatorService` requests one, closes the previous instance before replacement, and returns a stable result object whose `replicator` property resolves the current instance.
62
+ `useP2PReplicatorFeature` is the sole owner of the active `LiveSyncTrysteroReplicator` and its lifecycle bindings. It creates or replaces the outer replicator when `ReplicatorService` requests one, closes the previous instance before replacement, shares an in-flight replacement between concurrent callers, and returns a stable result object whose `replicator` property resolves the current instance.
63
63
 
64
64
  Consumers must preserve that result object and read `result.replicator` at the point of use. Do not destructure the property into a command, event handler, view, or other long-lived closure: a database reinitialisation or remote-type transition can close and replace the captured instance, and calling `open()` on that obsolete outer object can create a second connection outside the active service state.
65
65
 
@@ -31,7 +31,9 @@ It is therefore a logical LiveSync disconnection and a physical signalling-serve
31
31
 
32
32
  ## Reconnection and replacement
33
33
 
34
- An explicit open resumes Trystero relay reconnection before joining the configured room. Settings application may change the relay URLs, room ID, passphrase, or TURN configuration, so the owning service closes and discards the former LiveSync instance before constructing the replacement. No fixed close-to-open delay is required: lifecycle operations are serialised, and peer readiness is observed through discovery.
34
+ An explicit open resumes Trystero relay reconnection before joining the configured room. Settings application may change the relay URLs, room ID, passphrase, or TURN configuration, so the owning service closes and discards the former LiveSync instance before constructing the replacement. Concurrent replacement requests share the same in-flight replacement and receive the same new owner. No fixed close-to-open delay is required: lifecycle operations are serialised, and peer readiness is observed through discovery.
35
+
36
+ Saving settings with P2P or automatic start disabled always requests `close()`, even when the room has not started serving. This cancels an in-flight open through the same lifecycle queue, so a connection cannot appear after the disabling setting has been applied.
35
37
 
36
38
  Do not replace a relay socket's `onclose` handler. Trystero 0.25 shares relay clients by URL and uses its own handler to retire and recreate them. Use the exported pause and resume functions around explicit disconnection instead.
37
39
 
@@ -51,7 +53,7 @@ Commonlib consequently does not expose a forced physical-disconnection command.
51
53
 
52
54
  Maintain all five boundaries when this lifecycle changes:
53
55
 
54
- - Commonlib unit tests must prove that normal close leaves the room without directly closing Trystero-owned peers, and that overlapping open and close requests leave one current owner;
56
+ - Commonlib unit tests must prove that normal close leaves the room without directly closing Trystero-owned peers, that disabling settings cancel a pending open, and that concurrent replacement requests leave one current owner;
55
57
  - Commonlib rebuild tests must prove that first-device P2P initialisation does not reset a remote database and that an additional-device P2P Fetch performs one explicit peer-selection pass before resuming reflection;
56
58
  - the Self-hosted LiveSync Compose P2P lifecycle test must replace a current replicator, rediscover the same real peer, perform bidirectional RPC, and verify transferred content from a separate process;
57
59
  - the Self-hosted LiveSync real-Obsidian P2P Setup URI workflow must generate the second-device URI on the first device, accept both peer directions visibly, and verify a two-way note round-trip; and
@@ -39,6 +39,10 @@ await storage.write('notes/example.md', '# Example');
39
39
 
40
40
  `rootPath` may be absolute or process-relative. The host should normally resolve and validate configuration before constructing the adapter.
41
41
 
42
+ The Node adapter rejects symbolic-link components which it observes in an adapter path before an operation. File opens also use `O_NOFOLLOW` for the final component where the platform exposes that flag. These checks protect against invalid paths and symbolic links which already exist when an operation begins. Its concrete `rename(sourcePath, targetPath)` operation validates both paths before providing the atomic rename expected by Node hosts.
43
+
44
+ The host-selected `rootPath` is a trust boundary, not an operating-system filesystem sandbox. The adapter assumes that an untrusted local process cannot replace that root or its descendants concurrently with an operation. A host which grants another local actor write access to the tree must not rely on the adapter as a privilege boundary.
45
+
42
46
  ## Shared path contract
43
47
 
44
48
  Adapter paths are relative to the injected root and use forward slashes. The empty path refers to the root for root-safe operations such as `exists`, `stat`, `list`, and `mkdir`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrtmrz/livesync-commonlib",
3
- "version": "0.1.0-rc.6",
3
+ "version": "0.1.0-rc.8",
4
4
  "description": "Shared data, storage, and replication primitives for Self-hosted LiveSync clients.",
5
5
  "type": "module",
6
6
  "license": "MIT",