mikser-io-drive 11.3.1 → 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 +25 -6
- package/index.js +32 -13
- package/lib/emulated-locks.js +147 -0
- package/package.json +1 -1
- package/test/protocol.test.js +51 -9
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
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
|
|
|
@@ -93,12 +94,29 @@ export function drive(options = {}) {
|
|
|
93
94
|
// browse and commit should not fill up with page.md.nephelemeta
|
|
94
95
|
// files.
|
|
95
96
|
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
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.
|
|
102
120
|
//
|
|
103
121
|
// Do NOT choose 'disallow' if macOS clients matter: it drops DAV
|
|
104
122
|
// compliance class 2 from the OPTIONS response, and Finder refuses a
|
|
@@ -267,13 +285,14 @@ export function drive(options = {}) {
|
|
|
267
285
|
// badly as a folder label. `displayName` overrides it for
|
|
268
286
|
// a key that is not presentable.
|
|
269
287
|
adapter: withDisplayName(
|
|
270
|
-
(
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
+
})),
|
|
277
296
|
ep.displayName ?? name,
|
|
278
297
|
),
|
|
279
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
|
+
"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",
|
package/test/protocol.test.js
CHANGED
|
@@ -142,19 +142,61 @@ describe('DAV compliance class — what decides whether Finder will mount', () =
|
|
|
142
142
|
}
|
|
143
143
|
})
|
|
144
144
|
|
|
145
|
-
it(
|
|
146
|
-
// RFC 4918 puts the activelock in the body
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|