dsh-webdav-sync 1.0.0 → 1.0.2
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 +119 -25
- package/lib/client.js +563 -65
- package/lib/dav.d.ts +27 -7
- package/lib/dav.js +134 -28
- package/lib/dirs.d.ts +114 -0
- package/lib/dirs.js +259 -0
- package/lib/engine.d.ts +5 -1
- package/lib/engine.js +207 -44
- package/lib/index.d.ts +7 -18
- package/lib/index.js +98 -40
- package/lib/relay.d.ts +119 -0
- package/lib/relay.js +683 -0
- package/lib/state.d.ts +14 -2
- package/lib/state.js +47 -2
- package/lib/store.d.ts +1 -1
- package/lib/store.js +8 -10
- package/lib/wire.d.ts +228 -17
- package/lib/wire.js +65 -10
- package/package.json +1 -1
package/lib/dirs.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The extra directories: reading them into the `dirs` part, and writing that
|
|
3
|
+
* part back out.
|
|
4
|
+
*
|
|
5
|
+
* The four profile parts are read from two files whose shape this plugin already
|
|
6
|
+
* knows. This one is different in three ways, and each of them shapes the code
|
|
7
|
+
* below:
|
|
8
|
+
*
|
|
9
|
+
* - **It is a whole tree, not a file.** Reading means walking, so it is capped:
|
|
10
|
+
* a directory over the cap is refused as a whole rather than half-carried,
|
|
11
|
+
* because half a directory that then gets pushed would delete the rest for
|
|
12
|
+
* everybody else.
|
|
13
|
+
* - **Its path is part of the data.** The user names a directory; the path
|
|
14
|
+
* travels so a restore writes back into the same directory instead of inventing
|
|
15
|
+
* one. A path this computer cannot make sense of is therefore not an error to
|
|
16
|
+
* shout about — it is a directory to leave exactly as the server has it.
|
|
17
|
+
* - **Writing is additive.** A sync writes and overwrites files here; it never
|
|
18
|
+
* removes one. Mirrored deletion inside a person's own directory is the one
|
|
19
|
+
* accident this feature could cause that no notice could undo, and a union is
|
|
20
|
+
* the safe half of the trade: a file deleted on one computer comes back from the
|
|
21
|
+
* others, and deleting it everywhere is done by hand — the same bargain the
|
|
22
|
+
* plugin already makes for the plugin list (see
|
|
23
|
+
* `docs/decisions/0005-four-parts-and-plugin-exception.md`).
|
|
24
|
+
*/
|
|
25
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
26
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
27
|
+
import { isMissing, message, writeIfChanged } from './store.js';
|
|
28
|
+
/**
|
|
29
|
+
* Names never descended into. `node_modules` because a plugin's own dependencies
|
|
30
|
+
* are installed on each computer rather than carried, `.git` because a repository
|
|
31
|
+
* there has an owner of its own. Both are skipped in silence: a skipped name is
|
|
32
|
+
* not a directory that failed to read.
|
|
33
|
+
*/
|
|
34
|
+
export const DIRS_SKIP = new Set(['node_modules', '.git']);
|
|
35
|
+
/** Most files one configured directory may carry. */
|
|
36
|
+
export const DIRS_MAX_FILES = 2000;
|
|
37
|
+
/** Most bytes one configured directory may carry, before base64 and sealing. */
|
|
38
|
+
export const DIRS_MAX_BYTES = 8 * 1024 * 1024;
|
|
39
|
+
/**
|
|
40
|
+
* The stored list of directories: strings only, trimmed, no blanks, no repeats,
|
|
41
|
+
* in a fixed order.
|
|
42
|
+
*
|
|
43
|
+
* The order is by path rather than by when each was added, so the same set of
|
|
44
|
+
* directories hashes the same on every computer — the part is hashed whole, and
|
|
45
|
+
* two computers that name the same directories in a different order would
|
|
46
|
+
* otherwise see a change that is not one.
|
|
47
|
+
*/
|
|
48
|
+
export function normaliseDirs(value) {
|
|
49
|
+
if (!Array.isArray(value))
|
|
50
|
+
return [];
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const one of value) {
|
|
53
|
+
if (typeof one !== 'string')
|
|
54
|
+
continue;
|
|
55
|
+
const path = one.trim();
|
|
56
|
+
if (path === '' || out.includes(path))
|
|
57
|
+
continue;
|
|
58
|
+
out.push(path);
|
|
59
|
+
}
|
|
60
|
+
return out.sort(comparePath);
|
|
61
|
+
}
|
|
62
|
+
/** Path order, used wherever the set of directories is put in canonical order. */
|
|
63
|
+
export function comparePath(a, b) {
|
|
64
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Read every configured directory.
|
|
68
|
+
*
|
|
69
|
+
* Nothing throws: this runs on every status call, and a card that cannot be
|
|
70
|
+
* opened because a directory on a disconnected drive is not there would be worse
|
|
71
|
+
* than a card that says so. What cannot be read is reported in `problems`, and
|
|
72
|
+
* {@link dirsPartOf} decides what to do about it.
|
|
73
|
+
*/
|
|
74
|
+
export async function readDirs(dirs) {
|
|
75
|
+
const entries = [];
|
|
76
|
+
const problems = [];
|
|
77
|
+
let mtimeMs = 0;
|
|
78
|
+
for (const path of normaliseDirs(dirs)) {
|
|
79
|
+
if (!isAbsolute(path)) {
|
|
80
|
+
problems.push({
|
|
81
|
+
path,
|
|
82
|
+
message: '这个路径在这台电脑上不是绝对路径,可能是另一台电脑上的目录',
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const read = await readTree(path);
|
|
88
|
+
entries.push({ path, files: read.files });
|
|
89
|
+
mtimeMs = Math.max(mtimeMs, read.mtimeMs);
|
|
90
|
+
}
|
|
91
|
+
catch (cause) {
|
|
92
|
+
// A directory that is not there is reported like any other problem — this
|
|
93
|
+
// computer must not read it as "empty" and push that as a deletion — but it is
|
|
94
|
+
// marked as merely absent, because a restore has to be able to create it and
|
|
95
|
+
// "could not read" must not stop the one write that is the whole point.
|
|
96
|
+
problems.push(isMissing(cause)
|
|
97
|
+
? { path, message: '这台电脑上还没有这个目录', missing: true }
|
|
98
|
+
: { path, message: message(cause) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { entries, mtimeMs, problems };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The `dirs` part this computer holds, given what the server holds.
|
|
105
|
+
*
|
|
106
|
+
* Without a problem this is simply what is on disk — and `undefined` when there is
|
|
107
|
+
* nothing on disk, which keeps a computer that syncs no directories from putting an
|
|
108
|
+
* empty `dirs` part on the server and quietly emptying it for the computers that do.
|
|
109
|
+
*
|
|
110
|
+
* With a problem it is still what is on disk, except for the directories that could
|
|
111
|
+
* not be read: those take the server's copy verbatim. That is the whole reason this
|
|
112
|
+
* function exists. Reading a directory that is not there as "a directory with no
|
|
113
|
+
* files in it" would make the next push announce that every file in it was deleted,
|
|
114
|
+
* and a disconnected drive would quietly empty everybody else's copy of it. Carrying
|
|
115
|
+
* the server's copy instead means such a directory is neither read nor written this
|
|
116
|
+
* run, and the card says which one it was.
|
|
117
|
+
*/
|
|
118
|
+
export function dirsPartOf(read, carried) {
|
|
119
|
+
if (read.problems.length === 0)
|
|
120
|
+
return read.entries.length === 0 ? undefined : { dirs: read.entries };
|
|
121
|
+
const unreadable = new Set(read.problems.map((problem) => problem.path));
|
|
122
|
+
const fromDisk = new Map(read.entries.map((dir) => [dir.path, dir]));
|
|
123
|
+
const fromServer = new Map((carried?.dirs ?? []).map((dir) => [dir.path, dir]));
|
|
124
|
+
const dirs = [];
|
|
125
|
+
for (const path of normaliseDirs([...fromDisk.keys(), ...unreadable])) {
|
|
126
|
+
const entry = fromDisk.get(path) ?? fromServer.get(path);
|
|
127
|
+
if (entry !== undefined)
|
|
128
|
+
dirs.push(entry);
|
|
129
|
+
}
|
|
130
|
+
return dirs.length === 0 ? undefined : { dirs };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Put one `dirs` part on disk.
|
|
134
|
+
*
|
|
135
|
+
* Only directories that are absolute *on this computer* are touched: the list
|
|
136
|
+
* travels, so a path another computer named may not be a path here, and writing
|
|
137
|
+
* into a made-up relative one would scatter files around the working directory.
|
|
138
|
+
* Those paths stay in the list — the part is carried whole, and dropping one here
|
|
139
|
+
* would delete it for the computers that can use it — and are simply not read or
|
|
140
|
+
* written on this machine.
|
|
141
|
+
*
|
|
142
|
+
* `skip` holds the directories this run could not read. They are left alone for
|
|
143
|
+
* the same reason {@link dirsPartOf} carries them: whatever is at that path now is
|
|
144
|
+
* not this sync's business to replace, and it may well be nothing at all.
|
|
145
|
+
*/
|
|
146
|
+
export async function writeDirs(part, skip) {
|
|
147
|
+
let changed = false;
|
|
148
|
+
const paths = [];
|
|
149
|
+
const seen = new Set();
|
|
150
|
+
for (const dir of part.dirs) {
|
|
151
|
+
const path = dir.path.trim();
|
|
152
|
+
if (path === '' || seen.has(path))
|
|
153
|
+
continue;
|
|
154
|
+
seen.add(path);
|
|
155
|
+
paths.push(path);
|
|
156
|
+
if (!isAbsolute(path) || skip.has(path))
|
|
157
|
+
continue;
|
|
158
|
+
for (const file of dir.files) {
|
|
159
|
+
const target = under(path, file.path);
|
|
160
|
+
if (target === undefined)
|
|
161
|
+
continue;
|
|
162
|
+
if (await writeIfChanged(target, Buffer.from(file.data, 'base64')))
|
|
163
|
+
changed = true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
paths.sort(comparePath);
|
|
167
|
+
return { changed, paths };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Whether writing the part would change anything on disk.
|
|
171
|
+
*
|
|
172
|
+
* Asked before the copy kept for Undo is taken. That copy carries the directories
|
|
173
|
+
* when they are about to be written to, and a tree can run to megabytes — sealing one
|
|
174
|
+
* on every sync whose server copy of this part merely *existed* would cost a
|
|
175
|
+
* profile-size file per sync for a write that changes no file, which is exactly what
|
|
176
|
+
* happens on a computer that syncs no directories of its own and is sent somebody's.
|
|
177
|
+
*/
|
|
178
|
+
export async function dirsDiffer(part, skip) {
|
|
179
|
+
for (const dir of part.dirs) {
|
|
180
|
+
const path = dir.path.trim();
|
|
181
|
+
if (!isAbsolute(path) || skip.has(path))
|
|
182
|
+
continue;
|
|
183
|
+
for (const file of dir.files) {
|
|
184
|
+
const target = under(path, file.path);
|
|
185
|
+
if (target === undefined)
|
|
186
|
+
continue;
|
|
187
|
+
let current;
|
|
188
|
+
try {
|
|
189
|
+
current = await readFile(target);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// Not there, or not readable: either way a write would try, and trying is
|
|
193
|
+
// what reports the problem properly.
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
if (!current.equals(Buffer.from(file.data, 'base64')))
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
/** The absolute path of a carried file inside its directory, or nothing. */
|
|
203
|
+
function under(root, path) {
|
|
204
|
+
if (typeof path !== 'string' || path === '')
|
|
205
|
+
return undefined;
|
|
206
|
+
// An absolute path names somewhere else by construction, whatever the platform
|
|
207
|
+
// calls absolute.
|
|
208
|
+
if (isAbsolute(path))
|
|
209
|
+
return undefined;
|
|
210
|
+
// Carried paths are `/`-separated because `readTree` writes them that way. On
|
|
211
|
+
// Windows a backslash separates too, so a path spelled `..\..\x` would look like
|
|
212
|
+
// one harmless segment while `join` walked two levels up: both separators are
|
|
213
|
+
// split there. On other platforms a backslash is a legal filename character and
|
|
214
|
+
// splitting on it would rename the file, so only `/` counts.
|
|
215
|
+
const parts = path.split(sep === '\\' ? /[\\/]+/ : '/').filter((part) => part !== '' && part !== '.');
|
|
216
|
+
// A `..` in a carried path is never written outside the directory it belongs to.
|
|
217
|
+
if (parts.length === 0 || parts.some((part) => part === '..'))
|
|
218
|
+
return undefined;
|
|
219
|
+
return join(root, ...parts);
|
|
220
|
+
}
|
|
221
|
+
/** Walk one directory, honouring the skip list and both caps. */
|
|
222
|
+
async function readTree(root) {
|
|
223
|
+
const files = [];
|
|
224
|
+
let mtimeMs = 0;
|
|
225
|
+
let bytes = 0;
|
|
226
|
+
const walk = async (dir) => {
|
|
227
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
const full = join(dir, entry.name);
|
|
230
|
+
// A symlink is not followed: what it points at belongs to whoever put it
|
|
231
|
+
// there, and following one can walk out of the directory or around in a
|
|
232
|
+
// circle.
|
|
233
|
+
if (entry.isSymbolicLink())
|
|
234
|
+
continue;
|
|
235
|
+
if (entry.isDirectory()) {
|
|
236
|
+
if (DIRS_SKIP.has(entry.name))
|
|
237
|
+
continue;
|
|
238
|
+
await walk(full);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (!entry.isFile())
|
|
242
|
+
continue;
|
|
243
|
+
const info = await stat(full);
|
|
244
|
+
if (files.length >= DIRS_MAX_FILES) {
|
|
245
|
+
throw new Error(`文件超过 ${DIRS_MAX_FILES} 个,这一份没有同步`);
|
|
246
|
+
}
|
|
247
|
+
bytes += info.size;
|
|
248
|
+
if (bytes > DIRS_MAX_BYTES) {
|
|
249
|
+
throw new Error(`内容超过 ${Math.round(DIRS_MAX_BYTES / (1024 * 1024))} MiB,这一份没有同步`);
|
|
250
|
+
}
|
|
251
|
+
const data = await readFile(full);
|
|
252
|
+
files.push({ path: relative(root, full).split(sep).join('/'), data: data.toString('base64') });
|
|
253
|
+
mtimeMs = Math.max(mtimeMs, info.mtimeMs);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
await walk(root);
|
|
257
|
+
files.sort((a, b) => comparePath(a.path, b.path));
|
|
258
|
+
return { files, mtimeMs };
|
|
259
|
+
}
|
package/lib/engine.d.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* a relay cut in half.
|
|
15
15
|
*/
|
|
16
16
|
import { type Bundle, type CheckResult, type ConfigView, type Part, type PartHashes, type PreviewResult, type StatusResult, type SyncConfig, type SyncResult } from './wire.js';
|
|
17
|
+
import { type DirsRead } from './dirs.js';
|
|
17
18
|
import { missingPlugins, type Collected, type ManifestFile, type PatchFile } from './store.js';
|
|
18
19
|
import { loadConfig, sameAccount, saveConfig, usable } from './state.js';
|
|
19
20
|
import type { ResolvedPaths } from './paths.js';
|
|
@@ -21,7 +22,10 @@ import type { ResolvedPaths } from './paths.js';
|
|
|
21
22
|
interface Snapshot {
|
|
22
23
|
patch: PatchFile;
|
|
23
24
|
manifest: ManifestFile;
|
|
25
|
+
/** The four parts that come out of the profile's own two files. */
|
|
24
26
|
parts: Collected;
|
|
27
|
+
/** The extra directories, walked from disk, with whatever could not be read. */
|
|
28
|
+
dirs: DirsRead;
|
|
25
29
|
}
|
|
26
30
|
/** Everything a sync needs, resolved once. */
|
|
27
31
|
export interface Engine {
|
|
@@ -85,7 +89,7 @@ export declare function check(engine: Engine): Promise<CheckResult>;
|
|
|
85
89
|
/** What one sync would do, decided the same way but writing nothing anywhere. */
|
|
86
90
|
export declare function preview(engine: Engine): Promise<PreviewResult>;
|
|
87
91
|
/** Read this computer's setup and the files it came from, in one pass. */
|
|
88
|
-
export declare function take(paths: ResolvedPaths): Promise<Snapshot>;
|
|
92
|
+
export declare function take(paths: ResolvedPaths, config: SyncConfig): Promise<Snapshot>;
|
|
89
93
|
/** Per-part hashes of a setup; a part that is not there has none. */
|
|
90
94
|
export declare function hashesOf(parts: Partial<Record<Part, unknown>> | Bundle): PartHashes;
|
|
91
95
|
/** Whether two sets of part hashes describe the same setup. */
|