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/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. */