dsh-webdav-sync 1.0.4 → 1.0.5

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/engine.js CHANGED
@@ -164,6 +164,11 @@ async function syncOnce(engine, state, force) {
164
164
  state.blind = undefined;
165
165
  }
166
166
  const snapshot = await take(paths, config, engine.credentials);
167
+ if (snapshot.keysProblems.length === 0 && state.local?.keys !== undefined) {
168
+ // Older state files predate the explicit marker; an existing keys hash is
169
+ // already a trustworthy baseline, even before this run measures it again.
170
+ state.keysBaseline = true;
171
+ }
167
172
  let remote = await openRemote(engine, state, read);
168
173
  const first = state.local === undefined;
169
174
  // A directory could not be read this run: its part stands in for the server's
@@ -175,7 +180,11 @@ async function syncOnce(engine, state, force) {
175
180
  && sameHashes(hashesOf(partsOf(snapshot, remote.part('dirs'))), state.local)
176
181
  && !force) {
177
182
  // Neither side moved: the last sync is still the state of the world.
178
- return { here: [], there: [], pushed: false, reloadNeeded: false, ...(await pendings(engine, snapshot.parts.plugins)) };
183
+ return {
184
+ here: [], there: [], pushed: false, reloadNeeded: false,
185
+ ...keyProblems(snapshot.keysProblems),
186
+ ...(await pendings(engine, snapshot.parts.plugins)),
187
+ };
179
188
  }
180
189
  if (remote.notModified) {
181
190
  // The server said the file is the one last read, which was worth knowing for
@@ -185,6 +194,10 @@ async function syncOnce(engine, state, force) {
185
194
  }
186
195
  const carried = remote.part('dirs');
187
196
  const localParts = partsOf(snapshot, carried);
197
+ // A failed credential read is unknown, not a deletion or a partial replacement.
198
+ // Defer this part while the other parts sync, carrying the server's copy below.
199
+ if (snapshot.keysProblems.length > 0)
200
+ delete localParts.keys;
188
201
  const local = hashesOf(localParts);
189
202
  // The server holds no file of ours. This computer's setup becomes the first
190
203
  // version, which is what joining an empty folder means.
@@ -206,10 +219,10 @@ async function syncOnce(engine, state, force) {
206
219
  for (const part of PARTS) {
207
220
  const remoteHas = remote.hashes[part] !== undefined;
208
221
  const remotePart = () => remote.part(part);
209
- if (!syncsPart(config, part)) {
210
- // Not carried with this configuration. The server's copy is carried through
211
- // the upload untouched — turning a part off must never delete it for the
212
- // computers that still sync it — and nothing is written here from it.
222
+ if (!syncsPart(config, part) || (part === 'keys' && snapshot.keysProblems.length > 0)) {
223
+ // Disabled or unreadable: carry the server's copy untouched. In particular,
224
+ // a missing/locked credential service must not look like local key deletion
225
+ // and erase the keys already uploaded by a successful manual sync.
213
226
  if (remoteHas)
214
227
  merged[part] = remotePart();
215
228
  continue;
@@ -218,7 +231,8 @@ async function syncOnce(engine, state, force) {
218
231
  // the last sync — not merely when its presence changed. Comparing presence
219
232
  // would read an edit made only on the other computer as "unchanged" and
220
233
  // quietly keep this computer's older copy.
221
- const changedHere = !first && local[part] !== state.local?.[part];
234
+ const missingKeyBaseline = !first && state.keysBaseline !== true && part === 'keys';
235
+ const changedHere = !first && !missingKeyBaseline && local[part] !== state.local?.[part];
222
236
  const changedThere = first || remote.hashes[part] !== state.remote?.[part];
223
237
  // A part the other computer took away is taken away here too — but never the
224
238
  // plugin list, which is what loads the shell, and never on a first sync,
@@ -327,11 +341,12 @@ async function syncOnce(engine, state, force) {
327
341
  const pushNeeded = force || remote.rebuilt === true || !sameHashes(mergedHashes, remote.hashes);
328
342
  if (!pushNeeded) {
329
343
  const written = await applyMerged(engine, snapshot, applied);
330
- state.remote = remote.hashes;
331
344
  // Read back rather than assume: a directory whose file was just written — or
332
345
  // removed — has to be hashed as it now is, or the next sync would see an edit
333
- // that this one made itself and call it a conflict.
334
- state.local = hashesOf(partsOf(await take(paths, config, engine.credentials), undefined));
346
+ // that this one made itself and call it a conflict. Keep the key baselines if
347
+ // this read could not measure them, so recovery still sees pending edits.
348
+ const after = await take(paths, config, engine.credentials);
349
+ recordHashes(state, hashesOf(partsOf(after, undefined)), remote.hashes, snapshot.keysProblems.length === 0 && after.keysProblems.length === 0);
335
350
  // A part can come in without the file needing to be written: when only the
336
351
  // server moved, the merge agrees with it. The person still has to be told
337
352
  // what changed here, so the notice is recorded on this path too — clearing
@@ -351,7 +366,7 @@ async function syncOnce(engine, state, force) {
351
366
  ...(saved === undefined ? {} : { saved }),
352
367
  pushed: false,
353
368
  reloadNeeded: written.reloadNeeded,
354
- ...keyProblems(written.keysProblems),
369
+ ...keyProblems([...snapshot.keysProblems, ...written.keysProblems]),
355
370
  ...(await pendings(engine, applied.plugins)),
356
371
  };
357
372
  }
@@ -556,11 +571,11 @@ async function push(engine, state, snapshot, merged) {
556
571
  // failed upload never leaves this computer holding another computer's setup
557
572
  // with nothing to say where its own went.
558
573
  const applied = await applyMerged(engine, snapshot, merged.applied);
559
- const now = hashesOf(partsOf(await take(paths, config, engine.credentials), undefined));
574
+ const after = await take(paths, config, engine.credentials);
575
+ const now = hashesOf(partsOf(after, undefined));
560
576
  state.server = written;
561
577
  state.sum = sum(sealed);
562
- state.remote = hashesOf(bundle);
563
- state.local = now;
578
+ recordHashes(state, now, hashesOf(bundle), snapshot.keysProblems.length === 0 && after.keysProblems.length === 0);
564
579
  state.blind = written.etag === undefined || written.etag === '' ? { ...before } : undefined;
565
580
  state.undo = undefined;
566
581
  const notice = merged.here.length > 0 || merged.there.length > 0
@@ -579,7 +594,7 @@ async function push(engine, state, snapshot, merged) {
579
594
  ...(merged.saved === undefined ? {} : { saved: merged.saved }),
580
595
  pushed: true,
581
596
  reloadNeeded: applied.reloadNeeded,
582
- ...keyProblems(applied.keysProblems),
597
+ ...keyProblems([...snapshot.keysProblems, ...applied.keysProblems]),
583
598
  ...(await pendings(engine, merged.merged.plugins)),
584
599
  };
585
600
  }
@@ -664,6 +679,29 @@ async function applyMerged(engine, snapshot, applied) {
664
679
  }
665
680
  return { reloadNeeded, keysProblems };
666
681
  }
682
+ /** Record measured parts without turning an unreadable key store into a baseline. */
683
+ function recordHashes(state, local, remote, keysReadable) {
684
+ if (keysReadable)
685
+ state.keysBaseline = true;
686
+ if (!keysReadable) {
687
+ // Keep both comparison points: when the service returns, local and remote
688
+ // key edits still need to be merged rather than accepted as already applied.
689
+ if (state.local?.keys === undefined)
690
+ delete local.keys;
691
+ else
692
+ local.keys = state.local.keys;
693
+ remote = { ...remote };
694
+ if (state.remote?.keys === undefined)
695
+ delete remote.keys;
696
+ else
697
+ remote.keys = state.remote.keys;
698
+ // The server may have changed keys during this attempt. A conditional 304
699
+ // would hide its body on recovery even though those keys are still pending.
700
+ state.server = undefined;
701
+ }
702
+ state.local = local;
703
+ state.remote = remote;
704
+ }
667
705
  /**
668
706
  * The `keysProblems` field of a result, present only when there is one.
669
707
  *
@@ -838,7 +876,7 @@ export async function restore(engine) {
838
876
  ...(kept === undefined ? {} : { saved: keptDir(paths) }),
839
877
  pushed: false,
840
878
  reloadNeeded: written.reloadNeeded,
841
- ...keyProblems(written.keysProblems),
879
+ ...keyProblems([...snapshot.keysProblems, ...written.keysProblems]),
842
880
  ...(await pendings(engine, applied.plugins)),
843
881
  };
844
882
  });
@@ -939,7 +977,7 @@ export async function undo(engine) {
939
977
  there: [],
940
978
  pushed: false,
941
979
  reloadNeeded: written.reloadNeeded,
942
- ...keyProblems(written.keysProblems),
980
+ ...keyProblems([...snapshot.keysProblems, ...written.keysProblems]),
943
981
  ...(await pendings(engine, merged.plugins)),
944
982
  };
945
983
  });
package/lib/index.js CHANGED
@@ -348,7 +348,7 @@ export function apply(ctx, config = {}) {
348
348
  send(res, 200, await installPlugins(current.paths));
349
349
  });
350
350
  });
351
- startTimer(ctx, currentConfig);
351
+ startTimer(ctx, engine);
352
352
  }
353
353
  /** Make sure the state directory exists, so the first sync has somewhere to write. */
354
354
  async function saveStateIfUsable(engine) {
@@ -368,7 +368,7 @@ async function saveStateIfUsable(engine) {
368
368
  * A failure is recorded in the state by `sync` itself and changes nothing here:
369
369
  * the next tick tries again, which is the whole point of a timer.
370
370
  */
371
- function startTimer(ctx, currentConfig) {
371
+ function startTimer(ctx, currentEngine) {
372
372
  let timer;
373
373
  let stopped = false;
374
374
  const tick = async () => {
@@ -376,11 +376,12 @@ function startTimer(ctx, currentConfig) {
376
376
  return;
377
377
  let wait = 0;
378
378
  try {
379
- const paths = resolveFromEnv(readVolatile(currentConfig().profileDir));
380
- const { config: stored } = await loadConfig(paths);
381
- const minutes = stored.autoEvery;
382
- if (usable(stored) && minutes > 0) {
383
- await sync(engineFor(paths, stored)).catch(() => undefined);
379
+ // Use the same fresh engine as the routes: reading only sync.json would
380
+ // drop both the host credential service and a session-only WebDAV password.
381
+ const current = await currentEngine();
382
+ const minutes = current.config.autoEvery;
383
+ if (usable(current.config) && minutes > 0) {
384
+ await sync(current).catch(() => undefined);
384
385
  wait = minutes * 60_000;
385
386
  }
386
387
  }
package/lib/wire.d.ts CHANGED
@@ -292,6 +292,8 @@ export interface StoredState extends SyncRecord {
292
292
  key?: string | undefined;
293
293
  /** Last sync attempt, ISO-8601. */
294
294
  last?: string | undefined;
295
+ /** True once keys have a trustworthy local comparison baseline. */
296
+ keysBaseline?: boolean | undefined;
295
297
  /**
296
298
  * Last attempt that actually moved something, ISO-8601: a push, a part taken
297
299
  * from the server, or a restore. Absent until one has. This is the difference
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-webdav-sync",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "用 WebDAV 在多台电脑之间同步 DSH 的配置:模型供应商与模型、已安装插件、其余 profile 配置、附加目录与 API 密钥。整个备份在离开本机前用口令加密(AES-256-GCM + PBKDF2),以分片三方合并的方式在服务端保留一个加密文件。",
5
5
  "keywords": [
6
6
  "dsh",