mikser-io-drive 11.3.0 → 11.4.0

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.
package/README.md CHANGED
@@ -137,12 +137,31 @@ Three protocol facts worth knowing, all asserted in `test/protocol.test.js`:
137
137
  | --- | --- |
138
138
  | default (`emulate`) | `DAV: 1, 3, 2` — **class 2**, which macOS requires for a read-write mount |
139
139
  | `locks: 'disallow'` | drops class 2 and `LOCK` from `Allow`. **Finder will refuse a read-write mount.** A trap, because it is invisible until someone tries |
140
- | `emulate` vs `meta-files` | both return a valid `Lock-Token` header; `emulate` returns an empty `<lockdiscovery/>` where `meta-files` returns the full `<activelock>` |
141
-
142
- The last is the real cost of the default: clients read the header, so this is
143
- survivable, but a client that parses the body for the token finds nothing.
144
- Choose `meta-files` if you need real dead properties or real locking — with
145
- persisted locks a second `LOCK` on a held resource correctly answers `423`.
140
+ | `emulate` vs `meta-files` | both are real locking now (11.4.0): the `<activelock>` is in the LOCK body and in a later PROPFIND, and a held resource answers `423`. They differ in where the lock lives — memory, or a sidecar |
141
+
142
+ **Until 11.4.0 `emulate` stored no lock at all** — a token in the header and an
143
+ empty `<lockdiscovery/>`, which this table used to describe as survivable
144
+ because "clients read the header". The Windows WebDAV redirector does not: it
145
+ needs the `<activelock>` RFC 4918 §9.10.1 requires, and without it a mapped
146
+ drive lists and reads perfectly and **cannot be written to**. Every save fails
147
+ with "The parameter is incorrect" and leaves a zero-byte file, because Windows
148
+ PUTs an empty file, LOCKs it, PROPFINDs it, finds no lock, and loops until it
149
+ gives up. It reads like a permissions problem and is not.
150
+
151
+ `emulate` now keeps locks in memory, so the folder stays free of sidecars and
152
+ the locks are real. They do not survive a restart — the same observable as
153
+ every lock timing out at once, which is why locks carry timeouts — and there is
154
+ no second process to share them with, since one mikser instance owns a working
155
+ folder.
156
+
157
+ Choose `meta-files` if you need **dead properties** to persist. That is the one
158
+ thing `emulate` still does not do, deliberately: a PROPPATCH answers `207` and
159
+ the property is gone by the next PROPFIND (measured). Properties are meant to
160
+ last, and an in-memory store would lose them on restart while a same-session
161
+ read made them look durable — not storing is the honest answer. In practice
162
+ what Windows PROPPATCHes is its Win32 timestamps, so on `emulate` a file keeps
163
+ the server's mtime rather than the client's. It does not affect the write: the
164
+ PUT has already succeeded by then.
146
165
 
147
166
  One more, because it surprises people: **`LOCK` on a path that does not exist
148
167
  creates an empty file** (RFC 4918 §9.10.4). A client that locks before writing
@@ -353,6 +372,45 @@ class of problem from Nephele's four known gaps, and it carries 65 open issues
353
372
  against Nephele's zero. Nephele is also Apache-2.0 and load-bearing for its
354
373
  author's own product, which is the maintenance signal that matters most.
355
374
 
375
+ **Writing it here was considered and is not close.** RFC 4918 is about 4,650
376
+ lines in Nephele — 2,994 of method handlers and 1,662 of machinery — and the
377
+ weight is not where it looks:
378
+
379
+ | | lines | |
380
+ | --- | --- | --- |
381
+ | `Method.js` | 930 | the base class every method inherits |
382
+ | — `checkIfHeader` | 234 | the `If:` header alone |
383
+ | — `checkConditionalHeaders` | 77 | |
384
+ | `LOCK` + `UNLOCK` | 442 | plus 81 lock references threaded through `Method.js` |
385
+ | `MOVE` + `COPY` | 438 | `Depth` and `Overwrite` semantics |
386
+ | `PROPFIND` | 276 | |
387
+ | `GET`/`HEAD` | 286 | ranges, etags |
388
+
389
+ Listing files is the cheap part. `If:` is a grammar rather than a value —
390
+ tagged and untagged lists, `Not`, lock tokens and etags mixed, evaluated
391
+ against resources other than the request target — and getting it wrong means
392
+ Finder's Save As either walks over somebody's lock or refuses forever. Locking
393
+ is not isolated in `LOCK.js` either: provisional locks, timeouts, per-user lock
394
+ permission and token extraction run on every write method.
395
+
396
+ And the litmus column above is what would be given up: 95 of 104 on
397
+ `meta-files`, with four known gaps named and pinned. A hand-written server
398
+ starts at zero and earns each point by a client breaking in someone's hands.
399
+
400
+ The read-only case is genuinely different — `OPTIONS` + `PROPFIND` + `GET` with
401
+ no locking and no conditionals is a few hundred lines, and the drive root in
402
+ `lib/root-listing.js` is already that shape. It is not the case anyone is
403
+ asking for.
404
+
405
+ **The sharper version of the question is the ADAPTER, not the engine.** Three
406
+ things have been worked around here, and all three are in
407
+ `@nephele/adapter-file-system` rather than in the protocol: `displayname`
408
+ commented out of its live-property list with a TODO, a flat `max-age=604800` on
409
+ the OPTIONS response, and a read-only mount advertising `PUT` in `Allow`. The
410
+ adapter answers "what is at this path and what are its properties", which is
411
+ the thin half. If it keeps costing, replace it and keep the 4,650 lines that
412
+ matter.
413
+
356
414
  Staying on Nephele. If its gaps ever become the binding constraint, the
357
415
  serious alternative is not another npm package — it is **Apache `mod_dav`**,
358
416
  the implementation litmus was written to test, which reads `htpasswd` and
package/index.js CHANGED
@@ -6,6 +6,7 @@ import { registerFileTools } from './lib/files.js'
6
6
  import { withStagedWrites, stageWrites } from './lib/staged-writes.js'
7
7
  import { withDisplayName } from './lib/display-name.js'
8
8
  import { rootListing } from './lib/root-listing.js'
9
+ import { withEmulatedLockStore } from './lib/emulated-locks.js'
9
10
 
10
11
  export { MikserAuthenticator, withStagedWrites, stageWrites }
11
12
 
@@ -47,9 +48,12 @@ export const writeCapability = (name) => `drive:${name}:write`
47
48
  * data: { folder: 'data', readOnly: true },
48
49
  * Reports: { folder: 'reports' }, // shows as "Reports"
49
50
  * q3: { folder: 'q3', displayName: 'Q3 reports' },
50
- * share: { folder: 'share', host: 'drive.example.com' },
51
51
  * },
52
52
  * auth: identity,
53
+ * // A domain of its own, where `/` lists the endpoints the caller
54
+ * // may read. Belongs to the DRIVE, not to one endpoint: the whole
55
+ * // point is that several endpoints share it.
56
+ * host: 'drive.example.com',
53
57
  * })
54
58
  *
55
59
  * One Nephele server per endpoint, mounted at `<base>/<name>` — the same
@@ -90,12 +94,29 @@ export function drive(options = {}) {
90
94
  // browse and commit should not fill up with page.md.nephelemeta
91
95
  // files.
92
96
  //
93
- // The cost is measurable and small: 'emulate' returns a valid
94
- // Lock-Token header but an EMPTY <lockdiscovery/> body, where
95
- // 'meta-files' returns the full <activelock>. Clients read the
96
- // header; one that parses the body for the token would not find it.
97
- // Choose 'meta-files' if you need real dead properties or real
98
- // locks, and accept the sidecars.
97
+ // `locks: 'emulate'` is a real locking implementation now — see
98
+ // lib/emulated-locks.js. It stored nothing until 11.4.0, answering an
99
+ // empty <lockdiscovery/>, and the comment here called that cost
100
+ // "measurable and small", affecting "a client that parses the body for
101
+ // the token". That client is the Windows WebDAV redirector, and the
102
+ // cost was that the drive could not be WRITTEN to from Explorer at
103
+ // all: every save failed with "The parameter is incorrect" and left a
104
+ // zero-byte file. Listing and reading worked, which is what made it
105
+ // look like a permissions problem for weeks.
106
+ //
107
+ // `properties: 'emulate'` still reports success for a PROPPATCH and
108
+ // stores nothing — measured: 207 back, and the property is gone on the
109
+ // next PROPFIND. That is deliberate where the lock case was not.
110
+ // Locks carry timeouts, so keeping them in memory loses nothing a
111
+ // client does not already handle; dead properties are meant to
112
+ // persist, and an in-memory store would lose them on restart while a
113
+ // same-session read made them look durable. Not storing is the honest
114
+ // answer; 'meta-files' is the one that stores them.
115
+ //
116
+ // What Windows PROPPATCHes after a write is its Win32 timestamps, so
117
+ // on 'emulate' a file keeps the server's mtime rather than the
118
+ // client's. It does not block the write — the PUT is already done by
119
+ // then.
99
120
  //
100
121
  // Do NOT choose 'disallow' if macOS clients matter: it drops DAV
101
122
  // compliance class 2 from the OPTIONS response, and Finder refuses a
@@ -145,6 +166,18 @@ export function drive(options = {}) {
145
166
  if (!ep.folder) {
146
167
  throw new Error(`drive: endpoint ${JSON.stringify(name)} declares no folder`)
147
168
  }
169
+ // `host` belongs to the drive, not to an endpoint — several
170
+ // endpoints share one domain, and `/` on it lists them. Said
171
+ // out loud because the alternative is a config that reads as
172
+ // though it works and does nothing: this option was
173
+ // per-endpoint while it was being designed, and the example in
174
+ // this file's own JSDoc showed it that way after it moved.
175
+ if (ep.host) {
176
+ logger.warn(
177
+ 'drive: endpoint %j sets `host`, which is a plugin-level option and is ignored here. '
178
+ + 'Move it beside `endpoints`: drive({ host: %j, endpoints: { … } }).',
179
+ name, ep.host)
180
+ }
148
181
  const root = resolve(ep.folder)
149
182
  const mountPath = `${base}/${name}`
150
183
 
@@ -252,13 +285,14 @@ export function drive(options = {}) {
252
285
  // badly as a folder label. `displayName` overrides it for
253
286
  // a key that is not presentable.
254
287
  adapter: withDisplayName(
255
- (ep.atomicWrites === false)
256
- ? fsAdapter
257
- : withStagedWrites(fsAdapter, {
258
- onFailure: (err, file) => logger.warn(
259
- 'drive: upload of %s failed, original left intact — %s',
260
- path.basename(file), err.message),
261
- }),
288
+ withEmulatedLockStore(
289
+ (ep.atomicWrites === false)
290
+ ? fsAdapter
291
+ : withStagedWrites(fsAdapter, {
292
+ onFailure: (err, file) => logger.warn(
293
+ 'drive: upload of %s failed, original left intact — %s',
294
+ path.basename(file), err.message),
295
+ })),
262
296
  ep.displayName ?? name,
263
297
  ),
264
298
  authenticator,
@@ -0,0 +1,147 @@
1
+ // Real locks, without sidecar files.
2
+ //
3
+ // `locks: 'emulate'` kept the content folder clean by not storing locks at
4
+ // all: the adapter's readMetadataFile returns `{}` unless locks are
5
+ // 'meta-files', so `Lock.save()` writes nowhere and `getLocks()` answers `[]`
6
+ // the moment the response is sent. The token in the `Lock-Token` header is the
7
+ // only trace the lock ever existed.
8
+ //
9
+ // That is not a cosmetic gap. The Microsoft WebDAV redirector needs the
10
+ // `<activelock>` that RFC 4918 §9.10.1 requires in the LOCK body, and without
11
+ // it Windows cannot WRITE to the drive at all — it PUTs an empty file, LOCKs
12
+ // it, PROPFINDs it, sees no lock, and loops until it gives up with
13
+ // ERROR_INVALID_PARAMETER. Listing and reading work, so the share looks
14
+ // mounted and healthy while every save fails with "The parameter is
15
+ // incorrect". Captured on the wire against
16
+ // Microsoft-WebDAV-MiniRedir/10.0.22631.
17
+ //
18
+ // So locks are kept in memory instead. The whole lock lifecycle — save,
19
+ // delete, getLocks, and the lockdiscovery PROPFIND reads — goes through those
20
+ // two methods on the RESOURCE, which makes them the seam: route the `locks`
21
+ // half of the metadata through a Map and leave the `props` half exactly as it
22
+ // was.
23
+ //
24
+ // In memory is the right scope here rather than a compromise. One mikser
25
+ // instance owns a working folder — the engine enforces it, forwarding any
26
+ // second invocation over a unix socket rather than opening the folder twice —
27
+ // so there is no other process to share a lock with. What it does mean is that
28
+ // locks do not survive a restart, which is the same observable as every lock
29
+ // timing out at once, and is why they carry timeouts.
30
+
31
+ // path -> { token -> entry }, shared by every resource from one adapter.
32
+ // Keyed by absolute path because that is what identifies a resource across the
33
+ // several Resource objects a single request can produce.
34
+ const storeFor = new WeakMap()
35
+
36
+ function locksFor(adapter) {
37
+ if (!storeFor.has(adapter)) storeFor.set(adapter, new Map())
38
+ return storeFor.get(adapter)
39
+ }
40
+
41
+ // The lock methods themselves, not the metadata underneath them.
42
+ //
43
+ // Intercepting readMetadataFile looks like the smaller seam and does nothing:
44
+ // a Proxy binds methods to the TARGET, so when `getLocks()` runs it is the raw
45
+ // resource's `this.readMetadataFile()` that answers, and the interception is
46
+ // never reached. The same shape cost an afternoon on `displayname`, where
47
+ // PROPFIND reads through the by-user variants and those delegate to the plain
48
+ // ones on `this`.
49
+ //
50
+ // So the three methods that make up the lock lifecycle are replaced outright,
51
+ // and the metadata file is left alone.
52
+ function withEmulatedLocks(resource, locks) {
53
+ const key = resource.absolutePath
54
+ const held = () => locks.get(key) ?? {}
55
+
56
+ // A Lock that saves to the store instead of to a sidecar. Built from the
57
+ // adapter's own, so it is the adapter's Lock class with the adapter's
58
+ // defaults — only the two persistence methods differ.
59
+ const stored = (lock) => new Proxy(lock, {
60
+ get(target, prop, receiver) {
61
+ if (prop === 'save') {
62
+ return async () => {
63
+ locks.set(key, {
64
+ ...held(),
65
+ [target.token]: {
66
+ username: target.username,
67
+ date: target.date.getTime(),
68
+ timeout: target.timeout,
69
+ scope: target.scope,
70
+ depth: target.depth,
71
+ provisional: target.provisional,
72
+ owner: target.owner,
73
+ },
74
+ })
75
+ }
76
+ }
77
+ if (prop === 'delete') {
78
+ return async () => {
79
+ const rest = { ...held() }
80
+ delete rest[target.token]
81
+ if (Object.keys(rest).length) locks.set(key, rest)
82
+ else locks.delete(key)
83
+ }
84
+ }
85
+ const value = Reflect.get(target, prop, receiver)
86
+ return typeof value === 'function' ? value.bind(target) : value
87
+ },
88
+ })
89
+
90
+ const revive = async (token, entry) => {
91
+ const lock = await resource.createLockForUser({ username: entry.username })
92
+ lock.token = token
93
+ lock.date = new Date(entry.date)
94
+ lock.timeout = entry.timeout
95
+ lock.scope = entry.scope
96
+ lock.depth = entry.depth
97
+ lock.provisional = entry.provisional
98
+ lock.owner = entry.owner
99
+ return stored(lock)
100
+ }
101
+
102
+ return new Proxy(resource, {
103
+ get(target, prop, receiver) {
104
+ if (prop === 'getLocks') {
105
+ return async () => Promise.all(
106
+ Object.entries(held()).map(([token, entry]) => revive(token, entry)))
107
+ }
108
+ if (prop === 'getLocksByUser') {
109
+ return async (user) => Promise.all(
110
+ Object.entries(held())
111
+ .filter(([, entry]) => entry.username === user?.username)
112
+ .map(([token, entry]) => revive(token, entry)))
113
+ }
114
+ if (prop === 'createLockForUser') {
115
+ return async (user) => stored(await target.createLockForUser(user))
116
+ }
117
+ const value = Reflect.get(target, prop, receiver)
118
+ return typeof value === 'function' ? value.bind(target) : value
119
+ },
120
+ })
121
+ }
122
+
123
+ /**
124
+ * Give an adapter's resources a lock store that outlives the response.
125
+ *
126
+ * Same shape as withStagedWrites and withDisplayName, and composes with both:
127
+ * each wraps the three methods that hand back a resource and passes the rest
128
+ * through.
129
+ *
130
+ * @param {object} adapter a nephele adapter
131
+ */
132
+ export function withEmulatedLockStore(adapter) {
133
+ const locks = locksFor(adapter)
134
+ const wrap = (method) => async (...args) => {
135
+ const resource = await adapter[method](...args)
136
+ return resource ? withEmulatedLocks(resource, locks) : resource
137
+ }
138
+ return new Proxy(adapter, {
139
+ get(target, prop, receiver) {
140
+ if (prop === 'getResource' || prop === 'newResource' || prop === 'newCollection') {
141
+ return wrap(prop)
142
+ }
143
+ const value = Reflect.get(target, prop, receiver)
144
+ return typeof value === 'function' ? value.bind(target) : value
145
+ },
146
+ })
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-drive",
3
- "version": "11.3.0",
3
+ "version": "11.4.0",
4
4
  "description": "WebDAV for mikser-io. Exposes working-folder directories as WebDAV endpoints via Nephele, authenticated through mikser-io-auth's htpasswd identity — so content can be edited from Finder, Explorer or any DAV client and the build picks the change up.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -142,19 +142,61 @@ describe('DAV compliance class — what decides whether Finder will mount', () =
142
142
  }
143
143
  })
144
144
 
145
- it("but 'emulate' returns an EMPTY lockdiscovery body where 'meta-files' returns the lock", async () => {
146
- // RFC 4918 puts the activelock in the body. Clients read the header,
147
- // so this is survivable — but a client that parses the body for the
148
- // token finds nothing, and that is the cost of a clean folder.
149
- const emulated = await (await lock('emulated', 'body-emulated.md')).text()
150
- assert.match(emulated, /<lockdiscovery\s*\/>/)
151
-
152
- const meta = await (await lock('meta', 'body-meta.md')).text()
153
- assert.match(meta, /<activelock>/)
145
+ it('every mode that advertises class 2 returns a real activelock, and reports it again', async () => {
146
+ // RFC 4918 §9.10.1 puts the activelock in the LOCK body, and the
147
+ // Microsoft WebDAV redirector needs it. Without it Windows cannot
148
+ // WRITE to the drive at all: it PUTs an empty file, LOCKs it,
149
+ // PROPFINDs it, finds no lock, and loops until it gives up with
150
+ // ERROR_INVALID_PARAMETER — so the share lists and reads perfectly
151
+ // while every save fails with "The parameter is incorrect" and leaves
152
+ // a zero-byte file behind.
153
+ //
154
+ // `emulate` used to answer an empty <lockdiscovery/>, because it
155
+ // stored no lock at all — the token in the header was the only trace
156
+ // the lock had ever existed. It keeps them in memory now.
157
+ //
158
+ // Asserted for EVERY mode that claims class 2 in OPTIONS, because
159
+ // claiming it is the promise this checks. A status-only assertion is
160
+ // what let the gap ship.
161
+ for (const ep of ['emulated', 'meta']) {
162
+ const res = await lock(ep, `activelock-${ep}.md`)
163
+ const body = await res.text()
164
+ const token = res.headers.get('lock-token')
165
+
166
+ assert.match(body, /<activelock>/, `${ep}: no activelock in the LOCK body\n${body}`)
167
+ assert.match(body, /<locktoken>/, `${ep}: no locktoken\n${body}`)
168
+ // The token in the body must BE the token in the header. A body
169
+ // carrying a different one is worse than an empty body.
170
+ const inBody = body.match(/<locktoken>\s*<href>([^<]+)<\/href>/)?.[1]
171
+ assert.equal(`<${inBody}>`, token, `${ep}: body token and header disagree\n${body}`)
172
+
173
+ // And it has to be discoverable afterwards, which is the half a
174
+ // client uses to find out whether its own lock still holds.
175
+ const found = await fetch(`http://127.0.0.1:${port}/drive/${ep}/activelock-${ep}.md`, {
176
+ method: 'PROPFIND',
177
+ headers: { authorization: AUTH, 'content-type': 'text/xml', depth: '0' },
178
+ body: '<?xml version="1.0"?><propfind xmlns="DAV:"><prop><lockdiscovery/></prop></propfind>',
179
+ })
180
+ const discovered = await found.text()
181
+ assert.match(discovered, /<activelock>/,
182
+ `${ep}: PROPFIND does not report the lock\n${discovered}`)
183
+ assert.ok(discovered.includes(inBody),
184
+ `${ep}: PROPFIND reports a different token\n${discovered}`)
185
+ }
154
186
  })
155
187
  })
156
188
 
157
189
  describe('a persisted lock is honoured', () => {
190
+ it("'emulate' refuses a second LOCK too, which is what makes it real", async () => {
191
+ // The other half of storing locks. A mode that hands out a token to
192
+ // anyone who asks is a polite fiction; refusing the second caller is
193
+ // what a client is relying on when it takes one.
194
+ const first = await lock('emulated', 'contended-emulated.md')
195
+ assert.ok([200, 201].includes(first.status), `first lock: ${first.status}`)
196
+ const second = await lock('emulated', 'contended-emulated.md')
197
+ assert.equal(second.status, 423, `a held resource must answer 423 Locked, got ${second.status}`)
198
+ })
199
+
158
200
  it('a second LOCK on an already-locked resource is refused', async () => {
159
201
  // Only meaningful where locks are persisted. Worth asserting because
160
202
  // it is what makes 'meta-files' a real locking implementation rather
@@ -113,6 +113,32 @@ after(async () => {
113
113
  await rm(dir, { recursive: true, force: true })
114
114
  })
115
115
 
116
+ describe('host in the wrong place', () => {
117
+ // It was per-endpoint while this was being designed, and moved to the
118
+ // plugin when the root listing arrived — several endpoints share one
119
+ // domain, and `/` on it lists them. This file's own JSDoc went on showing
120
+ // the old shape, which is a config that reads as though it works and does
121
+ // nothing at all. So it says so.
122
+ it('warns instead of silently ignoring it', async () => {
123
+ const said = []
124
+ const core = {
125
+ runtime,
126
+ onLoad: () => {},
127
+ onLoaded: (cb) => said.push(cb),
128
+ useLogger: () => ({
129
+ info(){}, error(){}, debug(){}, trace(){},
130
+ warn: (...args) => said.push(args.join(' ')),
131
+ }),
132
+ }
133
+ drive({ endpoints: { Share: { folder: 'documents', host: 'drive.example.test' } } })(core)
134
+ for (const entry of [...said]) if (typeof entry === 'function') await entry()
135
+
136
+ const warning = said.find(entry =>
137
+ typeof entry === 'string' && entry.includes('plugin-level option'))
138
+ assert.ok(warning, `no warning about a misplaced host:\n${said.filter(e => typeof e === 'string').join('\n')}`)
139
+ })
140
+ })
141
+
116
142
  describe('the drive root on its own host', () => {
117
143
  it('announces itself as WebDAV, which is what the redirector asks first', async () => {
118
144
  // Answered without credentials on purpose: this is discovery, and it