@animalabs/agent-framework 0.22.4 → 0.23.1

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 (234) hide show
  1. package/README.md +105 -3
  2. package/dist/src/agent.d.ts +36 -10
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +76 -32
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/index.d.ts +1 -1
  7. package/dist/src/code-execution/index.d.ts.map +1 -1
  8. package/dist/src/code-execution/index.js.map +1 -1
  9. package/dist/src/code-execution/py-runner.d.ts +34 -2
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  11. package/dist/src/code-execution/py-runner.js +72 -16
  12. package/dist/src/code-execution/py-runner.js.map +1 -1
  13. package/dist/src/code-execution/runtime-py.d.ts +3 -2
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  15. package/dist/src/code-execution/runtime-py.js +47 -7
  16. package/dist/src/code-execution/runtime-py.js.map +1 -1
  17. package/dist/src/code-execution/tool-definition.d.ts +12 -7
  18. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  19. package/dist/src/code-execution/tool-definition.js +59 -11
  20. package/dist/src/code-execution/tool-definition.js.map +1 -1
  21. package/dist/src/config-checks.d.ts +2 -2
  22. package/dist/src/config-checks.d.ts.map +1 -1
  23. package/dist/src/config-checks.js +6 -2
  24. package/dist/src/config-checks.js.map +1 -1
  25. package/dist/src/framework.d.ts +744 -120
  26. package/dist/src/framework.d.ts.map +1 -1
  27. package/dist/src/framework.js +3546 -906
  28. package/dist/src/framework.js.map +1 -1
  29. package/dist/src/gate/event-gate.d.ts.map +1 -1
  30. package/dist/src/gate/event-gate.js +13 -6
  31. package/dist/src/gate/event-gate.js.map +1 -1
  32. package/dist/src/held-writes.d.ts +16 -0
  33. package/dist/src/held-writes.d.ts.map +1 -0
  34. package/dist/src/held-writes.js +56 -0
  35. package/dist/src/held-writes.js.map +1 -0
  36. package/dist/src/index.d.ts +7 -3
  37. package/dist/src/index.d.ts.map +1 -1
  38. package/dist/src/index.js +8 -4
  39. package/dist/src/index.js.map +1 -1
  40. package/dist/src/mcpl/capability-grant.d.ts +4 -0
  41. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  42. package/dist/src/mcpl/capability-grant.js +1 -1
  43. package/dist/src/mcpl/capability-grant.js.map +1 -1
  44. package/dist/src/mcpl/channel-registry.d.ts +95 -18
  45. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  46. package/dist/src/mcpl/channel-registry.js +578 -150
  47. package/dist/src/mcpl/channel-registry.js.map +1 -1
  48. package/dist/src/mcpl/convert-block.d.ts +2 -2
  49. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  50. package/dist/src/mcpl/convert-block.js +6 -7
  51. package/dist/src/mcpl/convert-block.js.map +1 -1
  52. package/dist/src/mcpl/index.d.ts +5 -1
  53. package/dist/src/mcpl/index.d.ts.map +1 -1
  54. package/dist/src/mcpl/index.js +3 -1
  55. package/dist/src/mcpl/index.js.map +1 -1
  56. package/dist/src/mcpl/inference-router.d.ts +4 -2
  57. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  58. package/dist/src/mcpl/inference-router.js +6 -7
  59. package/dist/src/mcpl/inference-router.js.map +1 -1
  60. package/dist/src/mcpl/log-value.d.ts +7 -0
  61. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  62. package/dist/src/mcpl/log-value.js +9 -0
  63. package/dist/src/mcpl/log-value.js.map +1 -1
  64. package/dist/src/mcpl/modern-connection.d.ts +227 -0
  65. package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
  66. package/dist/src/mcpl/modern-connection.js +1088 -0
  67. package/dist/src/mcpl/modern-connection.js.map +1 -0
  68. package/dist/src/mcpl/prefix-rule.d.ts +18 -0
  69. package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
  70. package/dist/src/mcpl/prefix-rule.js +25 -0
  71. package/dist/src/mcpl/prefix-rule.js.map +1 -0
  72. package/dist/src/mcpl/protocol-family.d.ts +179 -0
  73. package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
  74. package/dist/src/mcpl/protocol-family.js +697 -0
  75. package/dist/src/mcpl/protocol-family.js.map +1 -0
  76. package/dist/src/mcpl/push-coalescer.d.ts +38 -19
  77. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  78. package/dist/src/mcpl/push-coalescer.js +52 -23
  79. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  80. package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
  81. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  82. package/dist/src/mcpl/reference-fetcher.js +243 -76
  83. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  84. package/dist/src/mcpl/references.d.ts +63 -18
  85. package/dist/src/mcpl/references.d.ts.map +1 -1
  86. package/dist/src/mcpl/references.js +117 -32
  87. package/dist/src/mcpl/references.js.map +1 -1
  88. package/dist/src/mcpl/server-connection.d.ts +326 -30
  89. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  90. package/dist/src/mcpl/server-connection.js +995 -212
  91. package/dist/src/mcpl/server-connection.js.map +1 -1
  92. package/dist/src/mcpl/server-registry.d.ts +25 -5
  93. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  94. package/dist/src/mcpl/server-registry.js +51 -12
  95. package/dist/src/mcpl/server-registry.js.map +1 -1
  96. package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
  97. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
  98. package/dist/src/mcpl/tool-result-normalize.js +232 -0
  99. package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
  100. package/dist/src/mcpl/transport.d.ts +56 -13
  101. package/dist/src/mcpl/transport.d.ts.map +1 -1
  102. package/dist/src/mcpl/transport.js +206 -70
  103. package/dist/src/mcpl/transport.js.map +1 -1
  104. package/dist/src/mcpl/types.d.ts +66 -20
  105. package/dist/src/mcpl/types.d.ts.map +1 -1
  106. package/dist/src/mcpl/types.js.map +1 -1
  107. package/dist/src/mcpl/visible-content.d.ts +2 -1
  108. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  109. package/dist/src/mcpl/visible-content.js +2 -1
  110. package/dist/src/mcpl/visible-content.js.map +1 -1
  111. package/dist/src/module-registry.d.ts +16 -6
  112. package/dist/src/module-registry.d.ts.map +1 -1
  113. package/dist/src/module-registry.js +65 -6
  114. package/dist/src/module-registry.js.map +1 -1
  115. package/dist/src/modules/api/index.d.ts +1 -1
  116. package/dist/src/modules/api/index.d.ts.map +1 -1
  117. package/dist/src/modules/api/index.js +3 -2
  118. package/dist/src/modules/api/index.js.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
  120. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  121. package/dist/src/modules/discord/discord-js-client.js +268 -65
  122. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  123. package/dist/src/modules/discord/index.d.ts +27 -5
  124. package/dist/src/modules/discord/index.d.ts.map +1 -1
  125. package/dist/src/modules/discord/index.js +366 -76
  126. package/dist/src/modules/discord/index.js.map +1 -1
  127. package/dist/src/modules/discord/refusal.d.ts +14 -0
  128. package/dist/src/modules/discord/refusal.d.ts.map +1 -0
  129. package/dist/src/modules/discord/refusal.js +14 -0
  130. package/dist/src/modules/discord/refusal.js.map +1 -0
  131. package/dist/src/modules/discord/types.d.ts +17 -7
  132. package/dist/src/modules/discord/types.d.ts.map +1 -1
  133. package/dist/src/modules/health/index.d.ts.map +1 -1
  134. package/dist/src/modules/health/index.js +6 -2
  135. package/dist/src/modules/health/index.js.map +1 -1
  136. package/dist/src/modules/index.d.ts +1 -1
  137. package/dist/src/modules/index.d.ts.map +1 -1
  138. package/dist/src/modules/index.js +1 -1
  139. package/dist/src/modules/index.js.map +1 -1
  140. package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
  141. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/disk-agreement.js +45 -5
  143. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  144. package/dist/src/modules/workspace/effects.d.ts +2 -1
  145. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/effects.js +19 -9
  147. package/dist/src/modules/workspace/effects.js.map +1 -1
  148. package/dist/src/modules/workspace/index.d.ts +214 -42
  149. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/index.js +667 -113
  151. package/dist/src/modules/workspace/index.js.map +1 -1
  152. package/dist/src/modules/workspace/observe.d.ts +240 -8
  153. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  154. package/dist/src/modules/workspace/observe.js +554 -24
  155. package/dist/src/modules/workspace/observe.js.map +1 -1
  156. package/dist/src/modules/workspace/types.d.ts +10 -1
  157. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  158. package/dist/src/modules/workspace/types.js.map +1 -1
  159. package/dist/src/modules/workspace/watcher.d.ts +42 -0
  160. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  161. package/dist/src/modules/workspace/watcher.js +130 -25
  162. package/dist/src/modules/workspace/watcher.js.map +1 -1
  163. package/dist/src/silent-heartbeat.d.ts +112 -39
  164. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  165. package/dist/src/silent-heartbeat.js +100 -29
  166. package/dist/src/silent-heartbeat.js.map +1 -1
  167. package/dist/src/tool-args.d.ts +40 -0
  168. package/dist/src/tool-args.d.ts.map +1 -0
  169. package/dist/src/tool-args.js +207 -0
  170. package/dist/src/tool-args.js.map +1 -0
  171. package/dist/src/tool-presentation.d.ts.map +1 -1
  172. package/dist/src/tool-presentation.js +9 -2
  173. package/dist/src/tool-presentation.js.map +1 -1
  174. package/dist/src/types/agent.d.ts +27 -17
  175. package/dist/src/types/agent.d.ts.map +1 -1
  176. package/dist/src/types/events.d.ts +8 -0
  177. package/dist/src/types/events.d.ts.map +1 -1
  178. package/dist/src/types/framework.d.ts +17 -2
  179. package/dist/src/types/framework.d.ts.map +1 -1
  180. package/dist/src/types/module.d.ts +54 -10
  181. package/dist/src/types/module.d.ts.map +1 -1
  182. package/dist/src/types/trace.d.ts +2 -2
  183. package/dist/src/types/trace.d.ts.map +1 -1
  184. package/package.json +3 -4
  185. package/src/agent.ts +99 -36
  186. package/src/code-execution/index.ts +1 -0
  187. package/src/code-execution/py-runner.ts +104 -17
  188. package/src/code-execution/runtime-py.ts +47 -7
  189. package/src/code-execution/tool-definition.ts +65 -11
  190. package/src/config-checks.ts +4 -2
  191. package/src/framework.ts +3633 -956
  192. package/src/gate/event-gate.ts +14 -6
  193. package/src/held-writes.ts +59 -0
  194. package/src/index.ts +19 -4
  195. package/src/mcpl/capability-grant.ts +1 -1
  196. package/src/mcpl/channel-registry.ts +613 -160
  197. package/src/mcpl/convert-block.ts +15 -7
  198. package/src/mcpl/index.ts +14 -1
  199. package/src/mcpl/inference-router.ts +8 -7
  200. package/src/mcpl/log-value.ts +10 -0
  201. package/src/mcpl/modern-connection.ts +1182 -0
  202. package/src/mcpl/prefix-rule.ts +32 -0
  203. package/src/mcpl/protocol-family.ts +691 -0
  204. package/src/mcpl/push-coalescer.ts +67 -29
  205. package/src/mcpl/reference-fetcher.ts +288 -82
  206. package/src/mcpl/references.ts +123 -33
  207. package/src/mcpl/server-connection.ts +1050 -221
  208. package/src/mcpl/server-registry.ts +50 -12
  209. package/src/mcpl/tool-result-normalize.ts +294 -0
  210. package/src/mcpl/transport.ts +204 -62
  211. package/src/mcpl/types.ts +67 -20
  212. package/src/mcpl/visible-content.ts +2 -1
  213. package/src/module-registry.ts +68 -13
  214. package/src/modules/api/index.ts +3 -2
  215. package/src/modules/discord/discord-js-client.ts +288 -72
  216. package/src/modules/discord/index.ts +380 -77
  217. package/src/modules/discord/refusal.ts +13 -0
  218. package/src/modules/discord/types.ts +17 -9
  219. package/src/modules/health/index.ts +6 -2
  220. package/src/modules/index.ts +1 -1
  221. package/src/modules/workspace/disk-agreement.ts +46 -5
  222. package/src/modules/workspace/effects.ts +18 -7
  223. package/src/modules/workspace/index.ts +722 -124
  224. package/src/modules/workspace/observe.ts +591 -22
  225. package/src/modules/workspace/types.ts +10 -1
  226. package/src/modules/workspace/watcher.ts +126 -25
  227. package/src/silent-heartbeat.ts +184 -59
  228. package/src/tool-args.ts +213 -0
  229. package/src/tool-presentation.ts +9 -2
  230. package/src/types/agent.ts +27 -17
  231. package/src/types/events.ts +8 -0
  232. package/src/types/framework.ts +17 -2
  233. package/src/types/module.ts +55 -10
  234. package/src/types/trace.ts +2 -2
@@ -39,14 +39,16 @@
39
39
  import { createHash } from 'node:crypto';
40
40
  import { constants as fsConstants } from 'node:fs';
41
41
  import type { BigIntStats } from 'node:fs';
42
- import { lstat, mkdir, open, readdir, realpath, stat, statfs } from 'node:fs/promises';
42
+ import { lstat, mkdir, open, readdir, readlink, realpath, stat, statfs } from 'node:fs/promises';
43
43
  import type { FileHandle } from 'node:fs/promises';
44
- import { dirname, join, posix, sep } from 'node:path';
44
+ import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
45
45
  import { sameRootIdentity, type Fingerprint, type RootIdentity } from './disk-agreement.js';
46
46
 
47
47
  export interface MountView {
48
48
  /** Mount root as configured. */
49
49
  root: string;
50
+ /** The mount's name, which a tool puts before a path in the mount. */
51
+ name: string;
50
52
  /**
51
53
  * The root directory disk last agreed under, once the mount has evidence.
52
54
  * A root that resolves to any other directory is unavailable.
@@ -61,15 +63,38 @@ export interface MountView {
61
63
  * pass passes over them, named or not, as another mount's directory.
62
64
  */
63
65
  others: readonly string[];
66
+ /**
67
+ * The same mounts, each by its directory, its name and, where it has one,
68
+ * its real root (NestedMount), for a refusal that names the mount a path
69
+ * leads into: through a symlink this mount doesn't follow (linkWords), or
70
+ * through one it follows, or a root configured through one (nestedAtReal).
71
+ * Without it, no path is refused for where its links lead, and a link's
72
+ * refusal names no mount.
73
+ */
74
+ nested?: readonly NestedMount[];
64
75
  }
65
76
 
77
+ /**
78
+ * A mount nested in another: its directory in one of its spellings,
79
+ * mount-relative and `/`-separated, its name, and its real root, which
80
+ * every spelling shares. The real root is the root as realpath resolves it
81
+ * (the nearest existing directory's, where the root isn't made yet) once
82
+ * the module's start has read it, and the root as configured until then.
83
+ * There's none where it's this mount's own or holds it: the mount is nested
84
+ * by its configured path alone, since through its real root it would claim
85
+ * this mount's every path.
86
+ */
87
+ export type NestedMount = { dir: string; name: string; real?: string };
88
+
66
89
  export type StatFingerprint = Omit<Fingerprint, 'hashedAt'>;
67
90
 
68
91
  /**
69
- * Opened with every read or write of a workspace path, where the platform has
70
- * it: for a regular file it changes nothing, and a FIFO or other special file
71
- * swapped in at the path neither blocks the open nor passes the regular-file
72
- * check that follows.
92
+ * Opened with every open of a workspace path that may already be there, a
93
+ * read, a write in place or a directory's sync, where the platform has it:
94
+ * for a regular file or a directory it changes nothing, and a FIFO or other
95
+ * special file swapped in at the path neither blocks the open nor passes the
96
+ * check of its kind that follows. A create needs none: O_CREAT | O_EXCL fails
97
+ * at once on anything already at the path.
73
98
  */
74
99
  export const NONBLOCK = typeof fsConstants.O_NONBLOCK === 'number' ? fsConstants.O_NONBLOCK : 0;
75
100
 
@@ -141,6 +166,494 @@ function lexicalOf(view: MountView, rel: string): string {
141
166
  return rel === '' ? view.root : join(view.root, rel);
142
167
  }
143
168
 
169
+ /**
170
+ * What linkOnTheWay found on a path's way beneath the mount's root: a
171
+ * symlink (`link`, mount-relative), or a directory there the walk couldn't
172
+ * ask (`unsure`, with the errno it got).
173
+ */
174
+ export type LinkFound = { link: string } | { unsure: string; code: string };
175
+
176
+ const UNSURE_PREFIX = 'cannot tell whether "';
177
+
178
+ /**
179
+ * On a mount that doesn't follow symlinks, what is on the way to a
180
+ * mount-relative path: the first directory, from beneath the mount's root
181
+ * down to the path itself (or, `through` 'parents', down to its parent), that
182
+ * is a symlink, or one the walk couldn't ask. Null when there is none, or
183
+ * when the mount follows symlinks. The path is
184
+ * walked as the lookups that follow resolve it, with `.` and `..` taken out,
185
+ * so a `..` after a name that isn't there hides nothing beyond it, and the
186
+ * root, which is the configured path whatever it resolves to, is never on the
187
+ * way, even where the path names it on its way back into the mount. Each
188
+ * directory is asked only whether it is a link, by readlink, from the root
189
+ * down. EINVAL means it isn't, and the walk goes on. Nothing there, a file in
190
+ * the way, or a directory this user can't search (ENOENT, ENOTDIR, EACCES)
191
+ * ends it with nothing found, since nothing beneath is reached through a link
192
+ * and the caller's own lookup meets the same answer. Any other answer, EPERM
193
+ * included, leaves it unsure of what's beneath, and the path is refused for
194
+ * that.
195
+ */
196
+ export async function linkOnTheWay(
197
+ view: Pick<MountView, 'root' | 'followSymlinks'>,
198
+ rel: string,
199
+ through: 'path' | 'parents' = 'path',
200
+ ): Promise<LinkFound | null> {
201
+ if (view.followSymlinks) return null;
202
+ const resolved = relative(resolve(view.root), resolve(view.root, rel));
203
+ if (resolved === '' || resolved === '..' || resolved.startsWith(`..${sep}`) || isAbsolute(resolved)) return null;
204
+ const parts = resolved.split(sep);
205
+ const last = through === 'path' ? parts.length : parts.length - 1;
206
+ for (let i = 1; i <= last; i++) {
207
+ const at = parts.slice(0, i).join('/');
208
+ try {
209
+ await readlink(join(view.root, at));
210
+ return { link: at };
211
+ } catch (err) {
212
+ const code = errno(err);
213
+ if (code === 'EINVAL') continue; // not a link: on to the next directory
214
+ if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'EACCES') return null;
215
+ return { unsure: at, code: code ?? String(err) };
216
+ }
217
+ }
218
+ return null;
219
+ }
220
+
221
+ /**
222
+ * On a mount that doesn't follow symlinks, what refuses the mount-relative
223
+ * `rel`, which realpath resolved to `real` (or, `through` 'parents', whose
224
+ * parent it resolved to `real`): the first symlink on its way, or a
225
+ * directory there the walk couldn't ask. Null when the mount follows
226
+ * symlinks, or there's none. Where `real` is the root's canonical path joined
227
+ * with what was resolved (`rel`, or with `through` 'parents', its parent),
228
+ * nothing beneath the root resolved through a link, and nothing more is
229
+ * asked: that is every path through ordinary directories, at no cost
230
+ * per path. Otherwise each directory is asked in turn (linkOnTheWay), since a
231
+ * name spelled otherwise than disk spells it, on a file system that ignores
232
+ * case or Unicode normalization, also resolves to another string, and isn't
233
+ * a link.
234
+ */
235
+ export async function linkRefusal(
236
+ view: Pick<MountView, 'root' | 'followSymlinks'>,
237
+ rootReal: string,
238
+ rel: string,
239
+ real: string,
240
+ through: 'path' | 'parents' = 'path',
241
+ ): Promise<LinkFound | null> {
242
+ if (view.followSymlinks) return null;
243
+ const resolvedAt = through === 'path' ? rel : parentOf(rel);
244
+ if (resolvedAt === '' || real === join(rootReal, resolvedAt)) return null;
245
+ return linkOnTheWay(view, rel, through);
246
+ }
247
+
248
+ /**
249
+ * Where a symlink the walk found leads, for its refusal: to a path inside
250
+ * the mount (mount-relative, and whether it's a directory), outside the
251
+ * mount, or nowhere it can be resolved to. One realpath and one stat, made
252
+ * only when refusing.
253
+ */
254
+ export type LinkLeads = { kind: 'inside'; target: string; directory: boolean } | { kind: 'outside' } | { kind: 'nowhere' };
255
+
256
+ export async function linkLeads(root: string, rootReal: string, link: string): Promise<LinkLeads> {
257
+ const leads = await realpath(join(root, link)).catch(() => null);
258
+ if (leads === null) return { kind: 'nowhere' };
259
+ if (!contained(rootReal, leads)) return { kind: 'outside' };
260
+ const directory = await stat(leads).then((info) => info.isDirectory(), () => false);
261
+ return { kind: 'inside', target: relative(rootReal, leads).split(sep).join('/'), directory };
262
+ }
263
+
264
+ /**
265
+ * The nested mount whose directory holds `target` (mount-relative), the
266
+ * innermost where nested mounts nest, as #460 picks one; undefined when none
267
+ * does.
268
+ */
269
+ export function nestedHolder(target: string, nested: readonly { dir: string; name: string }[]): { dir: string; name: string } | undefined {
270
+ return nested
271
+ .filter((n) => target === n.dir || target.startsWith(`${n.dir}/`))
272
+ .sort((a, b) => b.dir.length - a.dir.length)[0];
273
+ }
274
+
275
+ /**
276
+ * Why a path is refused for what linkOnTheWay found, given where a link
277
+ * leads (linkLeads). For a link, the link is the subject, with where it
278
+ * leads: for a link inside the mount, its target, and for a link to a
279
+ * directory there, the setting the mount's host can change (the link stands
280
+ * among the path's directories, so following a link to a file can't make the
281
+ * path beneath it exist); for one leading outside the mount, that it does;
282
+ * for one that can't be resolved, nothing more. A target in a nested mount's
283
+ * directory (`nested`) is that mount's: the refusal names the mount and the
284
+ * path to use there in place of the link, and offers no setting, since
285
+ * following the link would only reach into another mount's directory. For a
286
+ * directory the walk couldn't ask, which one, and the answer it got. Paths
287
+ * are spelled as the tools take them, from the mount's name.
288
+ */
289
+ export function linkWords(name: string, found: LinkFound, leads: LinkLeads | null, nested: readonly { dir: string; name: string }[] = []): string {
290
+ const spell = (rel: string) => `"${rel === '' ? name : `${name}/${rel}`}"`;
291
+ if (!('link' in found)) return `${UNSURE_PREFIX}${name}/${found.unsure}" is a symlink (${found.code})`;
292
+ const link = `${spell(found.link)} is a symlink`;
293
+ if (leads === null || leads.kind === 'nowhere') return `${link}, which this mount does not follow`;
294
+ if (leads.kind === 'outside') return `${link} leading outside the mount, which this mount does not follow`;
295
+ const holder = nestedHolder(leads.target, nested);
296
+ if (holder) {
297
+ const rest = leads.target.slice(holder.dir.length + 1);
298
+ return `${link} to ${spell(leads.target)}, which this mount does not follow; it lies in mount "${holder.name}", ` +
299
+ `nested in mount "${name}": use "${rest ? `${holder.name}/${rest}` : holder.name}" in place of ${spell(found.link)}`;
300
+ }
301
+ const setting = leads.directory ? "; the mount's host can set followSymlinks: true" : '';
302
+ return `${link} to ${spell(leads.target)}, which this mount does not follow${setting}`;
303
+ }
304
+
305
+ /** Why a path is refused for what linkOnTheWay found (linkWords, with where the link leads). */
306
+ export async function linkReason(view: Pick<MountView, 'root' | 'name' | 'nested'>, rootReal: string, found: LinkFound): Promise<string> {
307
+ return linkWords(view.name, found, 'link' in found ? await linkLeads(view.root, rootReal, found.link) : null, view.nested);
308
+ }
309
+
310
+ /**
311
+ * The nested mount whose root holds `real`, a canonical path, the innermost,
312
+ * with the path to use there; undefined when none does. A path the lexical
313
+ * checks pass (inOtherMount, the tools' holderOf) reaches another mount's
314
+ * directory this way through a symlink the mount follows, so each check that
315
+ * resolves a path canonically asks this too.
316
+ */
317
+ export function nestedAtReal(real: string, nested: readonly NestedMount[] = []): { name: string; use: string } | undefined {
318
+ const holder = nested
319
+ .flatMap((n) => (n.real !== undefined && contained(n.real, real) ? [{ name: n.name, real: n.real }] : []))
320
+ .sort((a, b) => b.real.length - a.real.length)[0];
321
+ if (!holder) return undefined;
322
+ const rest = relative(holder.real, real).split(sep).join('/');
323
+ return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name };
324
+ }
325
+
326
+ /**
327
+ * Why a mount-relative path that resolves into a nested mount's directory is
328
+ * refused (nestedAtReal): what it leads into, and the path to use in its
329
+ * place. Spelled as the tools take paths, from the mount's name.
330
+ */
331
+ export function leadsInto(name: string, rel: string, holder: { name: string; use: string }): string {
332
+ return `"${rel === '' ? name : `${name}/${rel}`}" leads into mount "${holder.name}", nested in mount "${name}": use "${holder.use}" in its place`;
333
+ }
334
+
335
+ // ===========================================================================
336
+ // Disk's spelling of a path
337
+ // ===========================================================================
338
+
339
+ const sameFile = (a: BigIntStats, b: BigIntStats): boolean => a.dev === b.dev && a.ino === b.ino;
340
+
341
+ /** A letter's other case: its upper case if it's in lower case, else its lower case. */
342
+ const otherCase = (c: string): string => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase());
343
+
344
+ /**
345
+ * The name with each letter's case flipped, where its other case is one
346
+ * letter whose own other case is the letter again, so the flip changes only
347
+ * pairs that any folding disk folds together. `ß`, whose upper case is `SS`,
348
+ * and dotless `ı`, whose upper case `I` lowers to `i`, stay as they are.
349
+ */
350
+ const flippedCase = (name: string): string =>
351
+ [...name].map((c) => {
352
+ const other = otherCase(c);
353
+ return [...other].length === 1 && otherCase(other) === c ? other : c;
354
+ }).join('');
355
+
356
+ /** Names compared as NFC in lower case. */
357
+ const folded = (name: string): string => name.normalize('NFC').toLowerCase();
358
+
359
+ /**
360
+ * Names compared more loosely, through upper case and back, which also folds
361
+ * `ß` with `ss`, `ſ` with `s` and `ς` with `σ`, as a disk that folds fully
362
+ * does. It also folds dotless `ı` with `i`, which a disk may keep apart;
363
+ * the same-file check keeps that from naming another file.
364
+ */
365
+ export const foldedLoosely = (name: string): string => name.normalize('NFC').toLowerCase().toUpperCase().toLowerCase();
366
+
367
+ /** What the workspace holds of a mount, as the spelling step reads it (diskSpelling). */
368
+ export interface WorkspaceEntries {
369
+ /**
370
+ * Whether the workspace holds an entry at exactly this path, or records an
371
+ * intent there, such as a deletion disk hasn't been given yet.
372
+ */
373
+ has(path: string): boolean;
374
+ /** The paths of the entries under `prefix`, a directory's path and `/`, or `''` for every entry. */
375
+ under(prefix: string): string[];
376
+ /**
377
+ * The nested mount whose directory holds this path, by the nested-mount
378
+ * checks' own reading of it, or undefined where the path is the mount's
379
+ * own.
380
+ */
381
+ holder(path: string): string | undefined;
382
+ /** Whether the mount's root folds case (foldsCase). */
383
+ rootFoldsCase: boolean;
384
+ }
385
+
386
+ /**
387
+ * Whether `dir` folds case, told without writing: by looking up flipped a
388
+ * name it lists whose flipped spelling it doesn't list too (a name with no
389
+ * letter to flip is its own flipped spelling, and two hard links that differ
390
+ * only in case are one file on any disk). Where no name it lists tells, or
391
+ * it can't be listed, the directory holding it is asked instead, where both
392
+ * are on one device, each as a link to it leads: its own name looked up
393
+ * flipped there, or, for a name with no letter to flip, that directory's own
394
+ * folding. A directory on a device of its own is taken to tell case apart. A
395
+ * directory not there yet folds as the one it would be made in. Where
396
+ * nothing tells, it's taken to tell case apart.
397
+ */
398
+ export async function foldsCase(dir: string): Promise<boolean> {
399
+ const holder = dirname(dir);
400
+ let listed: Set<string>;
401
+ try {
402
+ listed = new Set(await readdir(dir));
403
+ } catch (error) {
404
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT' && holder !== dir) return foldsCase(holder);
405
+ listed = new Set();
406
+ }
407
+ const look = (path: string) => lstat(path, { bigint: true }).catch(() => null);
408
+ const name = [...listed].find((n) => !listed.has(flippedCase(n)));
409
+ if (name !== undefined) {
410
+ const [named, flipped] = await Promise.all([look(join(dir, name)), look(join(dir, flippedCase(name)))]);
411
+ return named != null && flipped != null && sameFile(named, flipped);
412
+ }
413
+ if (holder === dir) return false;
414
+ // The directory and its holder are looked up through links, so a root
415
+ // that is one, or is reached through one, is compared by the device it
416
+ // lies on. Whether the holder folds is told by its own entries, the root's
417
+ // name and the flipped name, each looked up without following a link
418
+ // there: a link at the flipped name to the root is another file, and a
419
+ // root that is a link is found as itself.
420
+ const see = (path: string) => stat(path, { bigint: true }).catch(() => null);
421
+ const own = basename(dir);
422
+ const [self, held, entry, flipped] = await Promise.all([see(dir), see(holder), look(dir), look(join(holder, flippedCase(own)))]);
423
+ if (self == null || held == null || self.dev !== held.dev) return false;
424
+ if (flippedCase(own) === own) return foldsCase(holder);
425
+ return entry != null && flipped != null && sameFile(entry, flipped);
426
+ }
427
+
428
+ /**
429
+ * A name in simple case folding, letter by letter: each letter as the lower
430
+ * case of its upper case, so `Σ` and a word's last `ς` are both `σ`, `ſ` is
431
+ * `s` and `µ` is `μ`. A letter whose upper case is more than one letter
432
+ * takes its own lower case instead, as the Greek `ᾈ` takes `ᾀ` and `ß` stays
433
+ * `ß`. A letter whose lower case is more than one letter too, as `İ`'s is,
434
+ * stays as it is, and so does dotless `ı`, whose upper case `I` lowers to
435
+ * `i`, which case folding keeps apart from it. Unicode normalization is left
436
+ * alone, and so are letters only a fuller folding joins, such as `ß` with
437
+ * `ss`: no listing shows two such names to be one file, and a disk that
438
+ * folds case needn't join them.
439
+ */
440
+ export const lowered = (name: string): string =>
441
+ [...name].map((c) => {
442
+ if (c === '\u0131') return c;
443
+ const upper = c.toUpperCase();
444
+ const lower = [...upper].length === 1 ? upper.toLowerCase() : c.toLowerCase();
445
+ return [...lower].length === 1 ? lower : c;
446
+ }).join('');
447
+
448
+ /**
449
+ * The name `segment` takes among the workspace's entries directly under the
450
+ * directory `prefix` (its path and `/`, or `''` for the mount's root): the
451
+ * segment itself where an entry is there under it, else the first name, in
452
+ * the order given, equal to it in simple case folding (lowered), else null.
453
+ * A name is compared again only where another name came between its entries,
454
+ * as `N.txt` comes between `N` and `N/x` in path order, so the step doesn't
455
+ * fold a name once for every entry beneath it.
456
+ */
457
+ function entryName(paths: readonly string[], prefix: string, segment: string): string | null {
458
+ const want = lowered(segment);
459
+ let found: string | null = null;
460
+ let previous: string | null = null;
461
+ for (const path of paths) {
462
+ if (!path.startsWith(prefix)) continue;
463
+ const rest = path.slice(prefix.length);
464
+ const slash = rest.indexOf('/');
465
+ const name = slash === -1 ? rest : rest.slice(0, slash);
466
+ if (name === previous) continue;
467
+ previous = name;
468
+ if (name === segment) return segment;
469
+ if (found === null && lowered(name) === want) found = name;
470
+ }
471
+ return found;
472
+ }
473
+
474
+ /**
475
+ * The name `dir` lists for `segment`: `segment` itself, unless disk folds it
476
+ * there and lists it otherwise. `dir` is listed only where a variant of
477
+ * `segment`, its case flipped or its other Unicode normalization form, finds
478
+ * the same file (device and inode) as `given`, the segment's own entry, as
479
+ * on a disk that folds names. The last segment isn't looked up as given, so
480
+ * for it any variant found leads to the listing: on a disk that tells
481
+ * spellings apart, that's only where `dir` holds a variant as a name of its
482
+ * own. The name as given wins if it's listed; otherwise the segment takes
483
+ * the listed name that folds to it and is the same file, so the name
484
+ * returned reaches the file the segment does, or is the segment as given.
485
+ * Names are compared as NFC in lower case first, and only where none of
486
+ * those is the file, more loosely (foldedLoosely). A directory that can't be
487
+ * listed, or a name that can't be looked up, leaves the segment as given. A
488
+ * segment with no variant at all, no letter to flip and one normalization
489
+ * form, is looked up as given and keeps its spelling; for a last segment
490
+ * with a letter that case maps, such as `ς`, the listing then says whether
491
+ * disk lists it as given. `found` says whether the lookups found disk's
492
+ * file: for a directory, its own lookup; for a last segment, whether a
493
+ * variant was found, which on a disk that tells spellings apart may be
494
+ * another file, or for one with no variant, whether disk has it as given, by
495
+ * the listing where it was read. `caseFolds` says what looking the segment
496
+ * up with its case flipped showed of `dir`: that it folds case, that it
497
+ * tells case apart, or nothing (null), for a segment with no letter to flip.
498
+ * For a segment not looked up as given (`given` null), `caseFolds` is set
499
+ * from whatever file the flipped spelling finds, which tells nothing of
500
+ * `dir`; diskSpelling reads it only for directories, which are looked up as
501
+ * given.
502
+ */
503
+ async function listedName(dir: string, segment: string, given: BigIntStats | null): Promise<{ name: string; found: boolean; caseFolds: boolean | null }> {
504
+ const flipped = flippedCase(segment);
505
+ const variants = new Set([flipped, segment.normalize('NFC'), segment.normalize('NFD')]);
506
+ variants.delete(segment);
507
+ // A segment with no variant to try, such as `2024` or `ς`, is looked up
508
+ // as given, the only way to learn that disk has it. A last segment with a
509
+ // letter that case maps, as `ς` does, can reach a file disk lists in
510
+ // another spelling, such as `σ`, on a disk that folds more than case
511
+ // pairs, so there the listing settles whether disk lists it as given.
512
+ if (variants.size === 0) {
513
+ const own = given ?? await lstat(join(dir, segment), { bigint: true }).catch(() => null);
514
+ if (given !== null || own === null || (segment.toLowerCase() === segment && segment.toUpperCase() === segment)) {
515
+ return { name: segment, found: own !== null, caseFolds: null };
516
+ }
517
+ const listed = await readdir(dir).catch(() => null);
518
+ return { name: segment, found: listed?.includes(segment) ?? true, caseFolds: null };
519
+ }
520
+ let folds = false;
521
+ let caseFolds: boolean | null = flipped === segment ? null : false;
522
+ for (const variant of variants) {
523
+ const other = await lstat(join(dir, variant), { bigint: true }).catch(() => null);
524
+ if (other !== null && (given === null || sameFile(other, given))) {
525
+ folds = true;
526
+ if (variant === flipped) caseFolds = true;
527
+ break;
528
+ }
529
+ }
530
+ const as = (name: string) => ({ name, found: folds, caseFolds });
531
+ if (!folds) return as(segment);
532
+ const listed = await readdir(dir).catch(() => null);
533
+ if (listed === null || listed.includes(segment)) return as(segment);
534
+ const target = given ?? await lstat(join(dir, segment), { bigint: true }).catch(() => null);
535
+ if (target === null) return as(segment);
536
+ for (const fold of [folded, foldedLoosely]) {
537
+ for (const name of listed) {
538
+ if (fold(name) !== fold(segment)) continue;
539
+ const info = await lstat(join(dir, name), { bigint: true }).catch(() => null);
540
+ if (info !== null && sameFile(info, target)) return as(name);
541
+ }
542
+ }
543
+ return as(segment);
544
+ }
545
+
546
+ /**
547
+ * `relativePath` as disk spells it. On a disk that folds names, as macOS's
548
+ * does by default (case and Unicode normalization), `SRC/a.ts` reaches
549
+ * `src/a.ts`, and whatever names what it finds from the spelling it was
550
+ * given takes the file in a second time, under a name disk doesn't list. So
551
+ * each segment, from the mount's root down, takes the name its directory
552
+ * lists for it (listedName). The walk goes on beneath a segment only if it's
553
+ * a directory, a symlink not included, so nothing past a link is read. It
554
+ * passes over an empty or `.` segment, which names the directory reached,
555
+ * and goes back up at `..`, to the directory it came from. At the mount's
556
+ * root a `..` stops it, keeping the rest as given: a path can climb above
557
+ * the root and come back in, as `../proj/x` does for a root named `proj`, and
558
+ * walking that would list the directory above the root. An absolute path
559
+ * keeps its spelling too: it names a place from the filesystem's root, not
560
+ * the mount's, so walking it from the mount's root would respell it against
561
+ * other directories, and the path returned could lead outside the mount.
562
+ *
563
+ * Given the workspace's `entries`, an entry not yet on disk spells its path
564
+ * as its file will. A path the workspace holds an entry at exactly keeps its
565
+ * spelling whole, and so does a last segment where the workspace holds an
566
+ * entry at exactly that path once its directories have taken disk's
567
+ * spelling, so an entry that disk lists in another spelling, or in a
568
+ * directory disk lists in another, stays reachable by its own path. `has`
569
+ * counts a path the workspace records an intent at, such as a deletion disk
570
+ * hasn't been given yet, so that's reached by its own path too. And where
571
+ * the directory reached folds case, each segment disk has nothing for takes
572
+ * the name of an entry it equals in simple case folding (entryName), as the
573
+ * disk will once that entry is written: a second draft spelled in another
574
+ * case is the first one. None of this applies where disk's spelling puts the
575
+ * path in a nested mount's directory, by the nested-mount checks' own
576
+ * reading (`holder`): there the path keeps disk's spelling, so they refuse
577
+ * at least what they refuse without the entries. The root's folding is read
578
+ * at start (`rootFoldsCase`), and a directory beneath it is taken to fold as
579
+ * the directory holding it did when its name was looked up with its case
580
+ * flipped, or, for a name with no letter to flip, as the nearest directory
581
+ * above that told, so the step reads nothing more of disk for this.
582
+ */
583
+ export async function diskSpelling(root: string, relativePath: string, entries?: WorkspaceEntries): Promise<string> {
584
+ if (relativePath === '' || isAbsolute(relativePath)) return relativePath;
585
+ // A path that names an entry exactly keeps its spelling, read before the
586
+ // walk awaits disk, so the entries' walk needn't run for it.
587
+ const exact = entries?.has(relativePath) ?? false;
588
+ // The path as disk spells it, and as the workspace's entries spell it.
589
+ const segments = relativePath.split('/');
590
+ const spelled = [...segments];
591
+ // The directories walked into, the mount's root first; their names below
592
+ // the root, as returned; and whether each folds case: the root's as read at
593
+ // start, and beneath it, whether looking its name up with its case flipped
594
+ // found it in the directory holding it (null for a name with no letter to
595
+ // flip).
596
+ const dirs = [root];
597
+ const names: string[] = [];
598
+ const folding: (boolean | null)[] = [entries?.rootFoldsCase ?? null];
599
+ // From segment `from` on, disk has nothing: each segment takes an entry's
600
+ // name, where the directory reached folds case, passing over an empty or
601
+ // `.` segment, and the rest keeps its spelling from the first segment no
602
+ // entry has a name for, a `..` included.
603
+ const fromEntries = (from: number): void => {
604
+ // The nearest directory, from the one reached up, that told.
605
+ let folds: boolean | null = null;
606
+ for (let k = folding.length - 1; folds === null && k >= 0; k--) folds = folding[k]!;
607
+ if (!entries || exact || !folds) return;
608
+ let prefix = names.length === 0 ? '' : `${names.join('/')}/`;
609
+ const paths = entries.under(prefix);
610
+ for (let j = from; j < spelled.length; j++) {
611
+ const segment = spelled[j]!;
612
+ if (segment === '' || segment === '.') continue;
613
+ const name = entryName(paths, prefix, segment);
614
+ if (name === null) return;
615
+ spelled[j] = name;
616
+ prefix = `${prefix}${name}/`;
617
+ }
618
+ };
619
+ for (let i = 0; i < segments.length; i++) {
620
+ const segment = segments[i]!;
621
+ if (segment === '' || segment === '.') continue;
622
+ if (segment === '..') {
623
+ if (dirs.length === 1) break;
624
+ dirs.pop();
625
+ names.pop();
626
+ folding.pop();
627
+ continue;
628
+ }
629
+ const dir = dirs[dirs.length - 1]!;
630
+ if (i === segments.length - 1) {
631
+ const { name, found } = await listedName(dir, segment, null);
632
+ segments[i] = name;
633
+ if (!exact && !entries?.has([...names, segment].join('/'))) {
634
+ spelled[i] = name;
635
+ if (!found) fromEntries(i);
636
+ }
637
+ break;
638
+ }
639
+ const given = await lstat(join(dir, segment), { bigint: true }).catch(() => null);
640
+ if (given === null) {
641
+ fromEntries(i);
642
+ break;
643
+ }
644
+ const { name, caseFolds } = await listedName(dir, segment, given);
645
+ segments[i] = name;
646
+ spelled[i] = name;
647
+ if (!given.isDirectory()) break;
648
+ dirs.push(join(dir, name));
649
+ names.push(name);
650
+ folding.push(caseFolds);
651
+ }
652
+ const asDisk = segments.join('/');
653
+ if (!entries || entries.holder(asDisk) !== undefined) return asDisk;
654
+ return exact ? relativePath : spelled.join('/');
655
+ }
656
+
144
657
  // ===========================================================================
145
658
  // Checked directories
146
659
  // ===========================================================================
@@ -201,7 +714,11 @@ export class CheckedDir {
201
714
  */
202
715
  async sync(): Promise<void> {
203
716
  if (process.platform === 'win32') return;
204
- const handle = await open(this.real, 'r');
717
+ // NONBLOCK: a FIFO put at its path since it was checked opens at once,
718
+ // and is refused below as not this directory, instead of holding the
719
+ // open, and with it the mount's turn, until something opens it for
720
+ // writing.
721
+ const handle = await open(this.real, fsConstants.O_RDONLY | NONBLOCK);
205
722
  try {
206
723
  const info = await handle.stat({ bigint: true });
207
724
  if (!info.isDirectory() || info.dev !== this.dev || info.ino !== this.ino) throw new Error(CHANGED);
@@ -235,14 +752,27 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis
235
752
  } catch (err) {
236
753
  const code = errno(err);
237
754
  if (code === 'ENOENT' || code === 'ENOTDIR') return rel === '' ? { kind: 'unobserved', reason: ROOT_UNAVAILABLE } : { kind: 'missing' };
755
+ // A link on the way that can't be resolved, such as a loop, is still a
756
+ // link this mount doesn't follow.
757
+ const found = rel === '' ? null : await linkOnTheWay(view, rel);
758
+ if (found !== null) return { kind: 'unobserved', reason: await linkReason(view, rootReal, found) };
238
759
  return { kind: 'unobserved', reason: `cannot resolve the directory (${code ?? String(err)})` };
239
760
  }
761
+ // A mount that doesn't follow symlinks goes through none on the way to a
762
+ // directory, inside the mount or out of it.
763
+ const found = await linkRefusal(view, rootReal, rel, real);
764
+ if (found !== null) return { kind: 'unobserved', reason: await linkReason(view, rootReal, found) };
240
765
  if (rel === '') {
241
766
  // The root is whatever the configured path names, and it must still be
242
767
  // the root the pass was given.
243
768
  if (real !== rootReal) return { kind: 'unobserved', reason: ROOT_UNAVAILABLE };
244
769
  } else if (!contained(rootReal, real)) {
245
770
  return { kind: 'unobserved', reason: OUTSIDE_PARENT, outside: true };
771
+ } else {
772
+ // Through a link this mount follows, a directory can be a nested
773
+ // mount's, which the mount neither reads nor writes.
774
+ const holder = nestedAtReal(real, view.nested);
775
+ if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, rel, holder) };
246
776
  }
247
777
  const checked = await checkedAt(view, rel, real);
248
778
  if (checked) {
@@ -303,6 +833,10 @@ async function createDir(view: MountView, rootReal: string, rel: string): Promis
303
833
  if (base === null) return { kind: 'unobserved', reason: ROOT_UNAVAILABLE };
304
834
  for (let i = depth; i < parts.length; i++) {
305
835
  const childRel = parts.slice(0, i + 1).join('/');
836
+ // Nothing is made in a nested mount's directory, its root included where
837
+ // it isn't made yet, by a path that reaches it through a link.
838
+ const holder = nestedAtReal(base.at(parts[i]!), view.nested);
839
+ if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, childRel, holder) };
306
840
  try {
307
841
  await mkdir(base.at(parts[i]!));
308
842
  } catch (err) {
@@ -312,7 +846,13 @@ async function createDir(view: MountView, rootReal: string, rel: string): Promis
312
846
  // Made, or there already (made meanwhile, or not a directory): it must
313
847
  // be a real directory now, never a symlink or file in its place.
314
848
  const checked = await checkedAt(view, childRel, base.at(parts[i]!));
315
- if (!checked) return { kind: 'unobserved', reason: 'a parent path is not a directory' };
849
+ if (!checked) {
850
+ // A symlink in the way, such as one that leads nowhere, on a mount that
851
+ // doesn't follow symlinks, is named as one.
852
+ const there = view.followSymlinks ? null : await lstat(base.at(parts[i]!)).catch(() => null);
853
+ if (there?.isSymbolicLink()) return { kind: 'unobserved', reason: await linkReason(view, rootReal, { link: childRel }) };
854
+ return { kind: 'unobserved', reason: 'a parent path is not a directory' };
855
+ }
316
856
  base = checked;
317
857
  }
318
858
  return { kind: 'checked', dir: base };
@@ -326,10 +866,12 @@ async function createDir(view: MountView, rootReal: string, rel: string): Promis
326
866
  * which nothing can exist. Either proves it only while every directory the
327
867
  * walk went through, the root included, is still the one its path names
328
868
  * afterwards: a root or parent replaced during the walk would otherwise lend
329
- * its replacement's absence to the original's path. Symlinked directories
330
- * inside the mount are followed as parents; any other symlink proves
331
- * nothing. Returns the directory whose entries prove it, or why absence
332
- * couldn't be shown.
869
+ * its replacement's absence to the original's path. On a mount that follows
870
+ * symlinks, symlinked directories inside the mount are followed as parents;
871
+ * on one that doesn't, a symlinked directory on the way proves nothing, and
872
+ * the reason says so (linkReason). Any other symlink proves nothing.
873
+ * Returns the directory whose entries prove it, or why absence couldn't be
874
+ * shown.
333
875
  */
334
876
  export async function confirmAbsent(view: MountView, rootReal: string, rel: string, target: 'entry' | 'beneath' = 'entry'): Promise<{ by: string } | string> {
335
877
  const root = await locateDir(view, rootReal, '');
@@ -364,6 +906,8 @@ export async function confirmAbsent(view: MountView, rootReal: string, rel: stri
364
906
  }
365
907
  if (info.isSymbolicLink()) {
366
908
  if (last && target === 'entry') return CHANGED; // present: the link itself
909
+ // A mount that doesn't follow symlinks goes through none on the way.
910
+ if (!view.followSymlinks) return linkReason(view, rootReal, { link: childRel });
367
911
  const located = await locateDir(view, rootReal, childRel);
368
912
  if (located.kind !== 'checked') return BENEATH_SYMLINK;
369
913
  if (last) return CHANGED; // a directory there after all
@@ -455,15 +999,24 @@ export async function filesystemTrustsCtime(root: string): Promise<boolean> {
455
999
  /**
456
1000
  * Where a path's parent directory canonically lies: inside the mount, gone
457
1001
  * (absence is then proven separately), or somewhere a no-follow open would
458
- * still reach through — O_NOFOLLOW guards only the final component.
1002
+ * still reach through — O_NOFOLLOW guards only the final component — which
1003
+ * on a mount that doesn't follow symlinks is any symlink on the way.
459
1004
  */
460
- async function parentPlacement(rootReal: string, lexical: string): Promise<'inside' | 'gone' | { reason: string }> {
1005
+ async function parentPlacement(view: MountView, rootReal: string, relativePath: string): Promise<'inside' | 'gone' | { reason: string }> {
461
1006
  try {
462
- const parent = await realpath(dirname(lexical));
463
- return contained(rootReal, parent) ? 'inside' : { reason: OUTSIDE_PARENT };
1007
+ const parent = await realpath(dirname(join(view.root, relativePath)));
1008
+ const found = await linkRefusal(view, rootReal, relativePath, parent, 'parents');
1009
+ if (found !== null) return { reason: await linkReason(view, rootReal, found) };
1010
+ if (!contained(rootReal, parent)) return { reason: OUTSIDE_PARENT };
1011
+ const holder = nestedAtReal(parent, view.nested);
1012
+ return holder ? { reason: leadsInto(view.name, parentOf(relativePath), holder) } : 'inside';
464
1013
  } catch (err) {
465
1014
  const code = errno(err);
466
1015
  if (code === 'ENOENT' || code === 'ENOTDIR') return 'gone';
1016
+ // A link on the way that can't be resolved, such as a loop, is still a
1017
+ // link this mount doesn't follow.
1018
+ const found = await linkOnTheWay(view, relativePath, 'parents');
1019
+ if (found !== null) return { reason: await linkReason(view, rootReal, found) };
467
1020
  return { reason: `cannot resolve the parent directory (${code ?? String(err)})` };
468
1021
  }
469
1022
  }
@@ -476,7 +1029,7 @@ async function parentPlacement(rootReal: string, lexical: string): Promise<'insi
476
1029
  */
477
1030
  export async function observePath(view: MountView, rootReal: string, relativePath: string): Promise<Observation> {
478
1031
  const lexical = join(view.root, relativePath);
479
- const placement = await parentPlacement(rootReal, lexical);
1032
+ const placement = await parentPlacement(view, rootReal, relativePath);
480
1033
  if (placement === 'gone') return absence(view, rootReal, relativePath);
481
1034
  if (placement !== 'inside') return { kind: 'unobserved', reason: placement.reason };
482
1035
  let info;
@@ -502,6 +1055,8 @@ export async function observePath(view: MountView, rootReal: string, relativePat
502
1055
  return { kind: 'unobserved', reason: `a symlink that cannot be resolved (${errno(err) ?? String(err)})` };
503
1056
  }
504
1057
  if (!contained(rootReal, real)) return { kind: 'unobserved', reason: 'a symlink that leaves the mount' };
1058
+ const holder = nestedAtReal(real, view.nested);
1059
+ if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, relativePath, holder) };
505
1060
  if (!target.isFile()) {
506
1061
  return { kind: 'unobserved', reason: target.isDirectory() ? 'a symlink to a directory, not walked' : 'not a regular file' };
507
1062
  }
@@ -516,9 +1071,10 @@ export async function observePath(view: MountView, rootReal: string, relativePat
516
1071
 
517
1072
  /**
518
1073
  * Open a mount-relative file for reading, located: a regular file that
519
- * canonically lies inside the mount — whatever the symlink policy, since a
520
- * symlinked parent escapes O_NOFOLLOW — and is the file the canonical path
521
- * names now. The handle is the caller's to close.
1074
+ * canonically lies inside the mount, whatever the symlink policy, since
1075
+ * O_NOFOLLOW guards only the final component; on a mount that doesn't follow
1076
+ * symlinks, reached through no symlinked directory either (linkRefusal); and
1077
+ * the file the canonical path names now. The handle is the caller's to close.
522
1078
  */
523
1079
  async function openContained(
524
1080
  view: MountView,
@@ -540,19 +1096,32 @@ async function openContained(
540
1096
  } catch (err) {
541
1097
  const code = errno(err);
542
1098
  if (code === 'ENOENT' || code === 'ENOTDIR') return absence(view, rootReal, relativePath);
543
- if (code === 'ELOOP' && !view.followSymlinks) return { kind: 'unobserved', reason: NOT_FOLLOWED };
1099
+ if (code === 'ELOOP' && !view.followSymlinks) {
1100
+ // The link at the path, or a looping one on its way.
1101
+ const found = await linkOnTheWay(view, relativePath, 'parents');
1102
+ return { kind: 'unobserved', reason: found === null ? NOT_FOLLOWED : await linkReason(view, rootReal, found) };
1103
+ }
544
1104
  if (code === 'EISDIR') return { kind: 'unobserved', reason: 'a directory' };
545
1105
  return { kind: 'unobserved', reason: `cannot open (${code ?? String(err)})` };
546
1106
  }
547
1107
  let keep = false;
548
1108
  try {
549
1109
  const info = await handle.stat({ bigint: true });
550
- if (!info.isFile()) return { kind: 'unobserved', reason: info.isDirectory() ? 'a directory' : 'not a regular file' };
551
1110
  const real = await realpath(lexical).catch(() => null);
552
1111
  if (real === null) return { kind: 'unobserved', reason: 'the file changed while it was read' };
1112
+ // O_NOFOLLOW guards only the final component: a mount that doesn't
1113
+ // follow symlinks goes through none among the directories on the way
1114
+ // either. Asked of what the path resolved to after the open, which the
1115
+ // check below holds to the descriptor's file, and before what the
1116
+ // descriptor holds, so a link gets its own answer whatever it leads to.
1117
+ const found = await linkRefusal(view, rootReal, relativePath, real);
1118
+ if (found !== null) return { kind: 'unobserved', reason: await linkReason(view, rootReal, found) };
1119
+ if (!info.isFile()) return { kind: 'unobserved', reason: info.isDirectory() ? 'a directory' : 'not a regular file' };
553
1120
  if (!contained(rootReal, real)) {
554
1121
  return { kind: 'unobserved', reason: view.followSymlinks ? 'a symlink that leaves the mount' : OUTSIDE_PARENT };
555
1122
  }
1123
+ const holder = nestedAtReal(real, view.nested);
1124
+ if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, relativePath, holder) };
556
1125
  const named = await stat(real, { bigint: true }).catch(() => null);
557
1126
  if (named === null || named.dev !== info.dev || named.ino !== info.ino) {
558
1127
  return { kind: 'unobserved', reason: 'the file changed while it was read' };