@vrtmrz/livesync-commonlib 0.1.42 → 0.1.43
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 +22 -0
- package/dist/common/pathIdentity.d.ts +10 -1
- package/dist/common/pathIdentity.js +1 -0
- package/dist/diagnostics/couchdb.d.ts +15 -0
- package/dist/diagnostics/couchdb.js +129 -0
- package/dist/diagnostics/index.d.ts +10 -0
- package/dist/diagnostics/index.js +26 -0
- package/dist/diagnostics/integrity.d.ts +5 -0
- package/dist/diagnostics/integrity.js +674 -0
- package/dist/diagnostics/local.d.ts +4 -0
- package/dist/diagnostics/local.js +25 -0
- package/dist/diagnostics/reports.d.ts +108 -0
- package/dist/diagnostics/reports.js +353 -0
- package/dist/diagnostics/types.d.ts +130 -0
- package/dist/diagnostics/types.js +13 -0
- package/dist/diagnostics/usage.d.ts +51 -0
- package/dist/diagnostics/usage.js +241 -0
- package/dist/pouchdb/encryption.d.ts +3 -1
- package/dist/pouchdb/encryption.js +38 -30
- package/dist/serviceFeatures/offlineScanner.d.ts +11 -2
- package/dist/serviceModules/Rebuilder.js +1 -1
- package/docs/database-diagnostics.md +24 -0
- package/docs/development.md +1 -0
- package/docs/proven-in-use.md +5 -1
- package/docs/unicode-path-identity.md +98 -0
- package/package.json +9 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
function asRecord(value) {
|
|
2
|
+
return value;
|
|
3
|
+
}
|
|
4
|
+
function getRevisionNumber(revision) {
|
|
5
|
+
const generation = revision.split("-")[0];
|
|
6
|
+
return `${generation ? Number.parseInt(generation) : 0}`;
|
|
7
|
+
}
|
|
8
|
+
function getRevisionHash(revision) {
|
|
9
|
+
const hash = revision.split("-")[1];
|
|
10
|
+
if (hash === void 0) throw new Error("invalid revision");
|
|
11
|
+
return hash.substring(0, 6);
|
|
12
|
+
}
|
|
13
|
+
function getRevisionInfos(value) {
|
|
14
|
+
if (!Array.isArray(value)) return [];
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function getChildren(value) {
|
|
18
|
+
const children = value ?? [];
|
|
19
|
+
if (!Array.isArray(children) || !children.every((child) => typeof child === "string")) {
|
|
20
|
+
throw new Error("invalid chunk references");
|
|
21
|
+
}
|
|
22
|
+
return new Set(children);
|
|
23
|
+
}
|
|
24
|
+
function getStringField(value) {
|
|
25
|
+
if (typeof value !== "string") throw new Error("invalid document field");
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
async function analyseDatabaseUsage(options) {
|
|
29
|
+
const { source, signal, onProgress } = options;
|
|
30
|
+
const errors = [];
|
|
31
|
+
const documentMap = /* @__PURE__ */ new Map();
|
|
32
|
+
const chunkMap = /* @__PURE__ */ new Map();
|
|
33
|
+
const progress = { idsProcessed: 0, revisionsProcessed: 0 };
|
|
34
|
+
let startUpdateSeq;
|
|
35
|
+
let endUpdateSeq;
|
|
36
|
+
let enumerationFailed = false;
|
|
37
|
+
const reportProgress = () => onProgress?.({ ...progress });
|
|
38
|
+
const isCancelled = () => signal?.aborted === true;
|
|
39
|
+
let cancelled = isCancelled();
|
|
40
|
+
if (!cancelled) {
|
|
41
|
+
try {
|
|
42
|
+
const info = await source.info();
|
|
43
|
+
startUpdateSeq = info.update_seq;
|
|
44
|
+
if (startUpdateSeq === void 0) errors.push({ code: "start_update_seq_unavailable" });
|
|
45
|
+
} catch {
|
|
46
|
+
errors.push({ code: "start_info_read_failed" });
|
|
47
|
+
}
|
|
48
|
+
if (isCancelled()) cancelled = true;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
if (!cancelled && !isCancelled()) {
|
|
52
|
+
for await (const id of source.enumerateIds()) {
|
|
53
|
+
if (isCancelled()) {
|
|
54
|
+
cancelled = true;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
let revisionInfos = [];
|
|
58
|
+
let revisionList;
|
|
59
|
+
let revisionListReadFailed = false;
|
|
60
|
+
try {
|
|
61
|
+
revisionList = await source.get(id, { revs: true, revs_info: true, conflicts: true });
|
|
62
|
+
} catch {
|
|
63
|
+
if (!isCancelled()) {
|
|
64
|
+
errors.push({ code: "revision_list_read_failed", id });
|
|
65
|
+
revisionListReadFailed = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (isCancelled()) {
|
|
69
|
+
cancelled = true;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
if (revisionList) revisionInfos = getRevisionInfos(asRecord(revisionList)._revs_info);
|
|
73
|
+
else if (!revisionListReadFailed) {
|
|
74
|
+
errors.push({ code: "revision_list_read_failed", id });
|
|
75
|
+
}
|
|
76
|
+
progress.idsProcessed++;
|
|
77
|
+
reportProgress();
|
|
78
|
+
for (const revisionInfo of revisionInfos) {
|
|
79
|
+
if (isCancelled()) {
|
|
80
|
+
cancelled = true;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
if (revisionInfo.status !== "available") continue;
|
|
84
|
+
const revision = typeof revisionInfo.rev === "string" ? revisionInfo.rev : void 0;
|
|
85
|
+
if (revision === void 0) {
|
|
86
|
+
errors.push({ code: "revision_document_invalid", id });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
progress.revisionsProcessed++;
|
|
90
|
+
let rawDocument;
|
|
91
|
+
try {
|
|
92
|
+
rawDocument = await source.get(id, { rev: revision });
|
|
93
|
+
} catch {
|
|
94
|
+
if (isCancelled()) {
|
|
95
|
+
cancelled = true;
|
|
96
|
+
reportProgress();
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
errors.push({ code: "revision_read_failed", id, revision });
|
|
100
|
+
reportProgress();
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (isCancelled()) {
|
|
104
|
+
cancelled = true;
|
|
105
|
+
reportProgress();
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
if (!rawDocument) {
|
|
109
|
+
errors.push({ code: "revision_not_found", id, revision });
|
|
110
|
+
reportProgress();
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const document = asRecord(rawDocument);
|
|
115
|
+
if ("children" in document) {
|
|
116
|
+
const documentId = getStringField(document._id);
|
|
117
|
+
const documentRevision = getStringField(document._rev);
|
|
118
|
+
const path = getStringField(document.path);
|
|
119
|
+
const chunks = getChildren(document.children);
|
|
120
|
+
const revisions = documentMap.get(documentId) ?? [];
|
|
121
|
+
revisions.push({
|
|
122
|
+
id: documentId,
|
|
123
|
+
revision: documentRevision,
|
|
124
|
+
path,
|
|
125
|
+
chunks,
|
|
126
|
+
uniqueChunks: /* @__PURE__ */ new Set(),
|
|
127
|
+
sharedChunks: /* @__PURE__ */ new Set()
|
|
128
|
+
});
|
|
129
|
+
documentMap.set(documentId, revisions);
|
|
130
|
+
} else if (document.type === "leaf") {
|
|
131
|
+
const chunkId = getStringField(document._id);
|
|
132
|
+
if (!chunkMap.has(chunkId) && !document._deleted) {
|
|
133
|
+
if (typeof document.data !== "string") throw new Error("invalid chunk data");
|
|
134
|
+
chunkMap.set(chunkId, { length: document.data.length, refCount: 0 });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
errors.push({ code: "revision_document_invalid", id, revision });
|
|
139
|
+
}
|
|
140
|
+
reportProgress();
|
|
141
|
+
}
|
|
142
|
+
if (isCancelled()) cancelled = true;
|
|
143
|
+
if (cancelled) break;
|
|
144
|
+
}
|
|
145
|
+
} else if (isCancelled()) {
|
|
146
|
+
cancelled = true;
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
if (isCancelled()) cancelled = true;
|
|
150
|
+
else {
|
|
151
|
+
enumerationFailed = true;
|
|
152
|
+
errors.push({ code: "id_enumeration_failed" });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const revisions of documentMap.values()) {
|
|
156
|
+
for (const document of revisions) {
|
|
157
|
+
for (const chunkId of document.chunks) {
|
|
158
|
+
const chunk = chunkMap.get(chunkId);
|
|
159
|
+
if (!chunk) continue;
|
|
160
|
+
if (chunk.refCount === 0) document.uniqueChunks.add(chunkId);
|
|
161
|
+
else document.sharedChunks.add(chunkId);
|
|
162
|
+
chunk.refCount++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const totalSize = (ids) => {
|
|
167
|
+
let size = 0;
|
|
168
|
+
for (const id of ids) size += chunkMap.get(id)?.length ?? 0;
|
|
169
|
+
return size;
|
|
170
|
+
};
|
|
171
|
+
const rows = [];
|
|
172
|
+
for (const revisions of documentMap.values()) {
|
|
173
|
+
for (const document of revisions) {
|
|
174
|
+
const revisionNumber = getRevisionNumber(document.revision);
|
|
175
|
+
let revisionHash;
|
|
176
|
+
try {
|
|
177
|
+
revisionHash = getRevisionHash(document.revision);
|
|
178
|
+
} catch {
|
|
179
|
+
errors.push({ code: "revision_document_invalid", id: document.id, revision: document.revision });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
rows.push({
|
|
183
|
+
title: `${document.path} (${document.revision})`,
|
|
184
|
+
id: document.id,
|
|
185
|
+
path: document.path,
|
|
186
|
+
revision: document.revision,
|
|
187
|
+
rev: revisionNumber,
|
|
188
|
+
revHash: revisionHash,
|
|
189
|
+
uniqueChunkCount: document.uniqueChunks.size,
|
|
190
|
+
sharedChunkCount: document.sharedChunks.size,
|
|
191
|
+
uniqueChunkSize: totalSize(document.uniqueChunks),
|
|
192
|
+
sharedChunkSize: totalSize(document.sharedChunks)
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const orphanChunks = [...chunkMap.entries()].filter(([, chunk]) => chunk.refCount === 0);
|
|
197
|
+
rows.push({
|
|
198
|
+
title: "__orphan",
|
|
199
|
+
id: "__orphan",
|
|
200
|
+
path: "__orphan",
|
|
201
|
+
revision: "1-xxxxx",
|
|
202
|
+
rev: "1",
|
|
203
|
+
revHash: "xxxxx",
|
|
204
|
+
uniqueChunkCount: orphanChunks.length,
|
|
205
|
+
sharedChunkCount: 0,
|
|
206
|
+
uniqueChunkSize: orphanChunks.reduce((size, [, chunk]) => size + chunk.length, 0),
|
|
207
|
+
sharedChunkSize: 0
|
|
208
|
+
});
|
|
209
|
+
if (!cancelled && !isCancelled()) {
|
|
210
|
+
try {
|
|
211
|
+
const info = await source.info();
|
|
212
|
+
endUpdateSeq = info.update_seq;
|
|
213
|
+
if (endUpdateSeq === void 0) errors.push({ code: "end_update_seq_unavailable" });
|
|
214
|
+
} catch {
|
|
215
|
+
errors.push({ code: "end_info_read_failed" });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (isCancelled()) cancelled = true;
|
|
219
|
+
const changedDuringRun = startUpdateSeq === void 0 || endUpdateSeq === void 0 ? void 0 : startUpdateSeq !== endUpdateSeq;
|
|
220
|
+
const snapshotUncertain = startUpdateSeq === void 0 || endUpdateSeq === void 0;
|
|
221
|
+
const hasDocumentRows = rows.length > 1;
|
|
222
|
+
let status;
|
|
223
|
+
if (cancelled) status = "cancelled";
|
|
224
|
+
else if (changedDuringRun === true || snapshotUncertain) status = "partial";
|
|
225
|
+
else if (enumerationFailed && !hasDocumentRows) status = "failed";
|
|
226
|
+
else if (errors.length > 0) status = "partial";
|
|
227
|
+
else status = "complete";
|
|
228
|
+
return {
|
|
229
|
+
version: 1,
|
|
230
|
+
kind: "usage",
|
|
231
|
+
status,
|
|
232
|
+
...startUpdateSeq === void 0 ? {} : { startUpdateSeq },
|
|
233
|
+
...endUpdateSeq === void 0 ? {} : { endUpdateSeq },
|
|
234
|
+
...changedDuringRun === void 0 ? {} : { changedDuringRun },
|
|
235
|
+
rows,
|
|
236
|
+
errors
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
export {
|
|
240
|
+
analyseDatabaseUsage
|
|
241
|
+
};
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import { Logger } from "../common/logger.js";
|
|
1
2
|
import { type EntryDoc, type AnyEntry, type EntryLeaf, type DocumentID, type E2EEAlgorithm } from "../common/types.js";
|
|
2
3
|
import { encryptWorker, decryptWorker, encryptHKDFWorker, decryptHKDFWorker } from "#worker";
|
|
3
4
|
export declare const encrypt: typeof encryptWorker;
|
|
4
5
|
export declare const decrypt: typeof decryptWorker;
|
|
5
6
|
export declare const encryptHKDF: typeof encryptHKDFWorker;
|
|
6
7
|
export declare const decryptHKDF: typeof decryptHKDFWorker;
|
|
8
|
+
export declare function isEncryptedMeta<T extends AnyEntry>(doc: T): boolean;
|
|
7
9
|
export declare let preprocessOutgoing: (doc: AnyEntry | EntryLeaf) => Promise<AnyEntry | EntryLeaf>;
|
|
8
10
|
export declare let preprocessIncoming: (doc: EntryDoc) => Promise<EntryDoc>;
|
|
9
|
-
export declare function getConfiguredFunctionsForEncryption(passphrase: string, useDynamicIterationCount: boolean, migrationDecrypt: boolean, getPBKDF2Salt: () => Promise<Uint8Array<ArrayBuffer>>, algorithm: E2EEAlgorithm, encryptInternalMetadata?: boolean): {
|
|
11
|
+
export declare function getConfiguredFunctionsForEncryption(passphrase: string, useDynamicIterationCount: boolean, migrationDecrypt: boolean, getPBKDF2Salt: () => Promise<Uint8Array<ArrayBuffer>>, algorithm: E2EEAlgorithm, encryptInternalMetadata?: boolean, log?: typeof Logger): {
|
|
10
12
|
incoming: (doc: AnyEntry | EntryLeaf) => Promise<AnyEntry | EntryLeaf>;
|
|
11
13
|
outgoing: (doc: EntryDoc) => Promise<AnyEntry | EntryLeaf>;
|
|
12
14
|
};
|
|
@@ -32,20 +32,20 @@ function getEncryptionVersion(data) {
|
|
|
32
32
|
}
|
|
33
33
|
return EncryptionVersions.UNENCRYPTED;
|
|
34
34
|
}
|
|
35
|
-
async function tryDecryptV1AsFallback(encryptedData, passphrase, useDynamicIterationCount) {
|
|
35
|
+
async function tryDecryptV1AsFallback(encryptedData, passphrase, useDynamicIterationCount, log = Logger) {
|
|
36
36
|
try {
|
|
37
37
|
return await decrypt(encryptedData, passphrase, useDynamicIterationCount);
|
|
38
38
|
} catch (ex) {
|
|
39
39
|
try {
|
|
40
|
-
|
|
40
|
+
log(
|
|
41
41
|
"Failed to decrypt with V1 method. Fallback to disable useDynamicIterationCount.",
|
|
42
42
|
LOG_LEVEL_VERBOSE
|
|
43
43
|
);
|
|
44
|
-
|
|
44
|
+
log(ex, LOG_LEVEL_VERBOSE);
|
|
45
45
|
return await decrypt(encryptedData, passphrase, false);
|
|
46
46
|
} catch (ex2) {
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
log("Completely failed to decrypt with V1 method.", LOG_LEVEL_VERBOSE);
|
|
48
|
+
log(ex2, LOG_LEVEL_VERBOSE);
|
|
49
49
|
return false;
|
|
50
50
|
}
|
|
51
51
|
}
|
|
@@ -153,7 +153,7 @@ async function incomingEncryptHKDF(doc, passphrase, useDynamicIterationCount, ge
|
|
|
153
153
|
}
|
|
154
154
|
return saveDoc;
|
|
155
155
|
}
|
|
156
|
-
async function outgoingDecryptHKDF(doc, migrationDecrypt, decrypted, passphrase, useDynamicIterationCount, getPBKDF2Salt) {
|
|
156
|
+
async function outgoingDecryptHKDF(doc, migrationDecrypt, decrypted, passphrase, useDynamicIterationCount, getPBKDF2Salt, log) {
|
|
157
157
|
const loadDoc = {
|
|
158
158
|
...doc
|
|
159
159
|
};
|
|
@@ -165,21 +165,26 @@ async function outgoingDecryptHKDF(doc, migrationDecrypt, decrypted, passphrase,
|
|
|
165
165
|
loadDoc.data = await decryptHKDF(loadDoc.data, passphrase, pbkdf2salt);
|
|
166
166
|
delete loadDoc.e_;
|
|
167
167
|
} else if (encryptionVersion === EncryptionVersions.ENCRYPTED) {
|
|
168
|
-
const decryptedData = await tryDecryptV1AsFallback(
|
|
168
|
+
const decryptedData = await tryDecryptV1AsFallback(
|
|
169
|
+
loadDoc.data,
|
|
170
|
+
passphrase,
|
|
171
|
+
useDynamicIterationCount,
|
|
172
|
+
log
|
|
173
|
+
);
|
|
169
174
|
if (decryptedData === false) {
|
|
170
|
-
|
|
175
|
+
log(MESSAGE_FALLBACK_DECRYPT_FAILED, LOG_LEVEL_NOTICE);
|
|
171
176
|
throw new Error(MESSAGE_FALLBACK_DECRYPT_FAILED);
|
|
172
177
|
}
|
|
173
178
|
loadDoc.data = decryptedData;
|
|
174
179
|
delete loadDoc.e_;
|
|
175
180
|
} else if (encryptionVersion === EncryptionVersions.UNENCRYPTED) {
|
|
176
181
|
} else {
|
|
177
|
-
|
|
182
|
+
log("Unknown encryption version. Cannot decrypt.", LOG_LEVEL_NOTICE);
|
|
178
183
|
throw new Error("Unknown encryption version. Cannot decrypt.");
|
|
179
184
|
}
|
|
180
185
|
} catch (ex) {
|
|
181
|
-
|
|
182
|
-
|
|
186
|
+
log(DECRYPTION_HKDF_FAILED, LOG_LEVEL_NOTICE);
|
|
187
|
+
log(ex);
|
|
183
188
|
throw ex;
|
|
184
189
|
}
|
|
185
190
|
}
|
|
@@ -192,16 +197,16 @@ async function outgoingDecryptHKDF(doc, migrationDecrypt, decrypted, passphrase,
|
|
|
192
197
|
loadDoc[key] = metadata[key];
|
|
193
198
|
}
|
|
194
199
|
} catch (ex) {
|
|
195
|
-
|
|
196
|
-
|
|
200
|
+
log(`${DECRYPTION_HKDF_FAILED} on Path`, LOG_LEVEL_NOTICE);
|
|
201
|
+
log(ex);
|
|
197
202
|
throw ex;
|
|
198
203
|
}
|
|
199
204
|
} else {
|
|
200
205
|
const path = getPath(loadDoc);
|
|
201
206
|
if (isPathProbablyObfuscated(path)) {
|
|
202
|
-
const decryptedPath = await tryDecryptV1AsFallback(path, passphrase, useDynamicIterationCount);
|
|
207
|
+
const decryptedPath = await tryDecryptV1AsFallback(path, passphrase, useDynamicIterationCount, log);
|
|
203
208
|
if (decryptedPath === false) {
|
|
204
|
-
|
|
209
|
+
log(`${MESSAGE_FALLBACK_DECRYPT_FAILED} on Path`, LOG_LEVEL_NOTICE);
|
|
205
210
|
throw new Error(MESSAGE_FALLBACK_DECRYPT_FAILED);
|
|
206
211
|
}
|
|
207
212
|
loadDoc.path = decryptedPath;
|
|
@@ -215,7 +220,8 @@ async function outgoingDecryptHKDF(doc, migrationDecrypt, decrypted, passphrase,
|
|
|
215
220
|
const decryptedEden = await tryDecryptV1AsFallback(
|
|
216
221
|
loadDoc.eden[EDEN_ENCRYPTED_KEY].data,
|
|
217
222
|
passphrase,
|
|
218
|
-
useDynamicIterationCount
|
|
223
|
+
useDynamicIterationCount,
|
|
224
|
+
log
|
|
219
225
|
);
|
|
220
226
|
if (decryptedEden === false) throw new Error(MESSAGE_FALLBACK_DECRYPT_FAILED);
|
|
221
227
|
readEden = {
|
|
@@ -224,8 +230,8 @@ async function outgoingDecryptHKDF(doc, migrationDecrypt, decrypted, passphrase,
|
|
|
224
230
|
};
|
|
225
231
|
edenDecrypted = true;
|
|
226
232
|
} catch (ex) {
|
|
227
|
-
|
|
228
|
-
|
|
233
|
+
log(`${DECRYPTION_FALLBACK_FAILED} on Eden`, LOG_LEVEL_NOTICE);
|
|
234
|
+
log(ex);
|
|
229
235
|
throw ex;
|
|
230
236
|
}
|
|
231
237
|
}
|
|
@@ -243,8 +249,8 @@ async function outgoingDecryptHKDF(doc, migrationDecrypt, decrypted, passphrase,
|
|
|
243
249
|
};
|
|
244
250
|
edenDecrypted = true;
|
|
245
251
|
} catch (ex) {
|
|
246
|
-
|
|
247
|
-
|
|
252
|
+
log(`${DECRYPTION_HKDF_FAILED} on Eden`, LOG_LEVEL_NOTICE);
|
|
253
|
+
log(ex);
|
|
248
254
|
throw ex;
|
|
249
255
|
}
|
|
250
256
|
}
|
|
@@ -296,7 +302,7 @@ async function incomingEncryptV1(doc, passphrase, useDynamicIterationCount) {
|
|
|
296
302
|
}
|
|
297
303
|
return saveDoc;
|
|
298
304
|
}
|
|
299
|
-
async function outgoingDecryptV1(doc, migrationDecrypt, decrypted, passphrase, useDynamicIterationCount) {
|
|
305
|
+
async function outgoingDecryptV1(doc, migrationDecrypt, decrypted, passphrase, useDynamicIterationCount, log) {
|
|
300
306
|
const loadDoc = {
|
|
301
307
|
...doc
|
|
302
308
|
};
|
|
@@ -361,15 +367,15 @@ async function outgoingDecryptV1(doc, migrationDecrypt, decrypted, passphrase, u
|
|
|
361
367
|
if (migrationDecrypt && ex2 instanceof SyntaxError) {
|
|
362
368
|
return loadDoc;
|
|
363
369
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
370
|
+
log("Decryption failed.", LOG_LEVEL_NOTICE);
|
|
371
|
+
log(ex2, LOG_LEVEL_VERBOSE);
|
|
372
|
+
log(`id:${loadDoc._id}-${loadDoc._rev?.substring(0, 10)}`, LOG_LEVEL_VERBOSE);
|
|
367
373
|
throw ex2;
|
|
368
374
|
}
|
|
369
375
|
} else {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
376
|
+
log("Decryption failed.", LOG_LEVEL_NOTICE);
|
|
377
|
+
log(ex, LOG_LEVEL_VERBOSE);
|
|
378
|
+
log(`id:${loadDoc._id}-${loadDoc._rev?.substring(0, 10)}`, LOG_LEVEL_VERBOSE);
|
|
373
379
|
throw ex;
|
|
374
380
|
}
|
|
375
381
|
}
|
|
@@ -382,7 +388,7 @@ let preprocessOutgoing = async (doc) => {
|
|
|
382
388
|
let preprocessIncoming = async (doc) => {
|
|
383
389
|
return await Promise.resolve(doc);
|
|
384
390
|
};
|
|
385
|
-
function getConfiguredFunctionsForEncryption(passphrase, useDynamicIterationCount, migrationDecrypt, getPBKDF2Salt, algorithm, encryptInternalMetadata = false) {
|
|
391
|
+
function getConfiguredFunctionsForEncryption(passphrase, useDynamicIterationCount, migrationDecrypt, getPBKDF2Salt, algorithm, encryptInternalMetadata = false, log = Logger) {
|
|
386
392
|
const decryptedCache = /* @__PURE__ */ new Map();
|
|
387
393
|
const incoming = (doc) => algorithm === E2EEAlgorithms.V2 ? incomingEncryptHKDF(doc, passphrase, useDynamicIterationCount, getPBKDF2Salt, encryptInternalMetadata) : incomingEncryptV1(doc, passphrase, useDynamicIterationCount);
|
|
388
394
|
const outgoing = (doc) => algorithm !== E2EEAlgorithms.ForceV1 ? outgoingDecryptHKDF(
|
|
@@ -391,8 +397,9 @@ function getConfiguredFunctionsForEncryption(passphrase, useDynamicIterationCoun
|
|
|
391
397
|
decryptedCache,
|
|
392
398
|
passphrase,
|
|
393
399
|
useDynamicIterationCount,
|
|
394
|
-
getPBKDF2Salt
|
|
395
|
-
|
|
400
|
+
getPBKDF2Salt,
|
|
401
|
+
log
|
|
402
|
+
) : outgoingDecryptV1(doc, migrationDecrypt, decryptedCache, passphrase, useDynamicIterationCount, log);
|
|
396
403
|
return {
|
|
397
404
|
incoming,
|
|
398
405
|
outgoing
|
|
@@ -459,6 +466,7 @@ export {
|
|
|
459
466
|
encrypt,
|
|
460
467
|
encryptHKDF,
|
|
461
468
|
getConfiguredFunctionsForEncryption,
|
|
469
|
+
isEncryptedMeta,
|
|
462
470
|
preprocessIncoming,
|
|
463
471
|
preprocessOutgoing,
|
|
464
472
|
shouldDecryptEden,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type DocumentID, type FilePathWithPrefix, type FilePathWithPrefixLC, type MetaEntry, type UXFileInfoStub, type ObsidianLiveSyncSettings, type LOG_LEVEL } from "../common/types.js";
|
|
2
2
|
import { type LogFunction } from "../services/lib/logUtils.js";
|
|
3
3
|
import type { NecessaryServices } from "../interfaces/ServiceModule.js";
|
|
4
|
+
import type { IPathService } from "../services/base/IService.js";
|
|
4
5
|
import { UnresolvedErrorManager } from "../services/base/UnresolvedErrorManager.js";
|
|
5
6
|
import { VaultScanResults, type VaultScanResult } from "../services/base/VaultScanResult.js";
|
|
6
7
|
export { VaultScanResults };
|
|
@@ -105,7 +106,11 @@ export declare function getMetadataDocumentNamespace(value: string): MetadataDoc
|
|
|
105
106
|
* separate consistent document for the same logical path may still proceed
|
|
106
107
|
* through ordinary path-based handling.
|
|
107
108
|
*/
|
|
108
|
-
export declare function inspectMetadataDocumentIdentity(host:
|
|
109
|
+
export declare function inspectMetadataDocumentIdentity(host: {
|
|
110
|
+
services: {
|
|
111
|
+
path: Pick<IPathService, "getPath" | "path2id">;
|
|
112
|
+
};
|
|
113
|
+
}, doc: MetaEntry): Promise<MetadataDocumentIdentityInspection>;
|
|
109
114
|
/**
|
|
110
115
|
* Enumerate Metadata from its actual local document IDs and report only the
|
|
111
116
|
* entries which ordinary path-based inspection cannot address.
|
|
@@ -139,7 +144,11 @@ export declare function collectDeletedFiles(host: NecessaryServices<"setting" |
|
|
|
139
144
|
* @param doc Meta entry document
|
|
140
145
|
* @returns Path string
|
|
141
146
|
*/
|
|
142
|
-
export declare function getPathFromEntry(host:
|
|
147
|
+
export declare function getPathFromEntry(host: {
|
|
148
|
+
services: {
|
|
149
|
+
path: Pick<IPathService, "getPath">;
|
|
150
|
+
};
|
|
151
|
+
}, doc: MetaEntry): FilePathWithPrefix;
|
|
143
152
|
/**
|
|
144
153
|
* Synchronise a single file between database and storage based on freshness comparison.
|
|
145
154
|
* @param host Services container
|
|
@@ -183,7 +183,7 @@ Please enable them from the settings screen after setup is complete.`,
|
|
|
183
183
|
await assertUniqueStoragePathIdentities(selected, this.path);
|
|
184
184
|
if (databaseReady) {
|
|
185
185
|
await assertMetadataPathIdentities(
|
|
186
|
-
this.database.localDatabase.findAllDocs(),
|
|
186
|
+
this.database.localDatabase.findAllDocs({ conflicts: true }),
|
|
187
187
|
this.path,
|
|
188
188
|
requireMatchingIds
|
|
189
189
|
);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
date: 2026-10-19
|
|
3
|
+
commonlib_version: '0.1.43'
|
|
4
|
+
livesync_version: '1.0.38'
|
|
5
|
+
status: unreleased
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Database diagnostics
|
|
9
|
+
|
|
10
|
+
The focused `/diagnostics` entry supplies read-only database usage analysis and integrity inspection. Hosts choose original names or temporary pseudonyms before each run, provide progress and cancellation controls, and present the resulting reports. Analysis uses the original identifiers; name projection happens afterwards.
|
|
11
|
+
|
|
12
|
+
Usage retains the existing available-revision selection and incremental chunk accounting. The first processed reference counts a chunk as unique; later references count it as shared. Sizes count JavaScript string units, as in the existing report. These columns do not measure physical database size or establish whether a chunk can be deleted.
|
|
13
|
+
|
|
14
|
+
Integrity inspection enumerates actual stored document IDs, including logically deleted normal-file Metadata and current conflict branches. It uses the same identity classifier as the existing Metadata inspector. It reads content only for groups with identity discrepancies, conflicting branches, duplicate path claims, or differences between the inspected local and remote Metadata. Content comparisons use reconstructed bytes, including the Vault file where available. Missing, unreadable, and uninspected content remain distinct outcomes. Equal content within this scope does not establish that an entry is safe to delete.
|
|
15
|
+
|
|
16
|
+
The first implementation supports the local database and CouchDB. Internal-file, customisation, and plug-in storage Metadata, older revision history, and other remote backends are explicitly outside integrity inspection. Usage continues to cover the revisions available to its local source. A remote failure retains the local findings and marks the integrity report partial.
|
|
17
|
+
|
|
18
|
+
The CouchDB reader only sends GET requests. It reads existing synchronisation parameters when decryption needs a Security Seed and never creates missing parameters, initialises a database, or invokes replication admission. Local content reads do not fetch or cache remote Chunks. Owned requests are cancelled and released when the run ends; host-native request adapters may finish an already dispatched request after cancellation, but its result is discarded.
|
|
19
|
+
|
|
20
|
+
Database update sequences and the host's configuration identity are checked again before completion. Storage readers must detect changes while reading a file. These observations can detect concurrent changes; they do not provide an atomic snapshot or prevent a file from changing later.
|
|
21
|
+
|
|
22
|
+
Reports project a whitelist of facts. Pseudonyms preserve exact path equality across sources and keep document, revision, and Chunk identifiers separate. Case and Unicode normalisation facts are computed before projection. Maps are local to one run and are not persisted. Original-name mode still excludes credentials, connection addresses, encryption keys, raw errors, and file contents. JSON and Markdown use the same projected integrity result; TSV uses the same name mapper for Usage.
|
|
23
|
+
|
|
24
|
+
Hosts may pass Commonlib, plug-in, and application versions to either report projector through its optional third argument. Only those three version fields are retained. Integrity reports also retain the five identity-related settings used for the inspection; credentials and the active connection configuration remain outside the report.
|
package/docs/development.md
CHANGED
|
@@ -8,6 +8,7 @@ The source tree is larger than the supported package surface. Consumers may impo
|
|
|
8
8
|
|
|
9
9
|
- `src/index.ts`, `src/context.ts`, `src/settings.ts`, `src/remoteConfigurations.ts`, `src/p2p/index.ts`, `src/platform/browser/index.ts`, and `src/platform/node/index.ts` define the focused entries;
|
|
10
10
|
- `src/replication/index.ts` defines the focused provider-capability and interaction contract for hosts which compose replication;
|
|
11
|
+
- `src/diagnostics/index.ts` defines read-only Usage and integrity inspection, report projection, and formatting; hosts own the run dialogue and output destination;
|
|
11
12
|
- `src/rpc/index.ts` defines a transitional entry for the existing LiveSync P2P composition, not a stable Commonlib 1.0 contract;
|
|
12
13
|
- `_tools/build-package.mjs` compiles those entries and creates the publishable manifest under `.package`;
|
|
13
14
|
- `docs/migration/downstream-imports.json` is the reviewed inventory from which explicit `compat/*` exports are generated;
|
package/docs/proven-in-use.md
CHANGED
|
@@ -8,6 +8,8 @@ The code paths below identify the maintained Self-hosted LiveSync composition po
|
|
|
8
8
|
|
|
9
9
|
## Obsidian plug-in
|
|
10
10
|
|
|
11
|
+
Unreleased [database diagnostics](database-diagnostics.md) tests cover preserved Usage accounting, normal-file identity classification, current conflict branches, byte comparisons, embedded Chunks, missing and unreadable content, cancellation, concurrent changes, and consistent pseudonyms. Real in-memory PouchDB tests verify pagination and conflict revision reads while retaining the original database handle. A CouchDB 3.5.0 integration test verifies GET-only access, encrypted Metadata and Chunk reads with an existing Security Seed, missing-seed failure without initialisation, and unchanged database update sequences and Seed documents. Focused tests also reject ciphertext which remains undecoded after an ID mismatch. Self-hosted LiveSync owns the serviceFeature, run-time name choice, dialogue lifecycle, and report copy and download controls. Its real Obsidian 1.12.7 test on Linux verifies cancellation before a run, both name modes, the nine-column Usage report, copy and download payloads, local integrity findings when CouchDB is unavailable, and unchanged settings, database update sequences, and Vault file names. Mobile emulation verifies the choice dialogue's viewport, safe-area, and touch-target layout; native iOS and Android behaviour remains unverified.
|
|
12
|
+
|
|
11
13
|
Unreleased content-evidence tests replicate 99, 100, and 599 updates between real in-memory PouchDB databases, including a receiver with `revs_limit: 100`. They verify unchanged reflection after the recorded revision is pruned, including a recreated handler, and preserve same-size edits, intentional reverts, and records without valid content evidence. Separate tests verify legacy upgrades and changes during hashing. These are package-owned tests; they do not establish Windows, mobile, or remote CouchDB behaviour.
|
|
12
14
|
|
|
13
15
|
The exact local packed candidate also passes Self-hosted LiveSync 1.0.37's checks, unit tests, application builds, and CLI E2E. Its focused real-Obsidian `stale-file-restart` scenario prunes the recorded revision before restarting, verifies unchanged reflection and legacy-record upgrades, and preserves an unknown-origin file. The Review Harness exercises the exported hash helper in desktop Obsidian. Maintainer-supplied Android and iOS reports additionally verify the helper and its benchmark on those devices; these timing measurements do not establish complete mobile synchronisation behaviour or peak memory use.
|
|
@@ -92,7 +94,9 @@ The CLI extends the same neutral context with its database path and injected `St
|
|
|
92
94
|
|
|
93
95
|
The CLI filesystem composition receives a configured base directory and constructs the rooted Node storage adapter in `src/apps/cli/adapters/NodeFileSystemAdapter.ts`. Commonlib's shared storage result table checks the focused root contract; the consumer adds its file cache, Vault-shaped operations, case handling, and command policy.
|
|
94
96
|
|
|
95
|
-
The
|
|
97
|
+
The [Unicode path identity contract](unicode-path-identity.md), introduced in Commonlib `0.1.42`, provides opt-in NFC document IDs for CLI and Obsidian use with CouchDB. Commonlib tests exercise real in-memory Metadata lookup across different host normalisers. CLI temporary-filesystem tests cover resolution, updates, deletions, unchanged physical spellings, and ambiguous NFC paths. File-reflection tests preserve NFC-equivalent physical names while retaining case-only rename behaviour. With the packed candidate installed, the Linux CLI-to-real-Obsidian scenario verifies creation, remote updates, and daemon-applied deletions with and without E2EE and Path Obfuscation. A separate run migrates two legacy CLI databases with Rebuild and Fetch before the Obsidian round trip, preserving both NFD physical names and replacing the remote document ID. These checks do not establish native macOS or iOS behaviour.
|
|
98
|
+
|
|
99
|
+
The unreleased reconstruction refinement excludes unconflicted logical deletions only when replacing the local database. Rebuilder tests exercise real PouchDB enumeration before the reset boundary across Rebuild, Standard Fetch, and Fast Fetch. Real CouchDB tests cover deletion propagation, fully tombstoned documents, unresolved conflict leaves, and a stale local copy after a remote deletion. Non-deleted duplicates and unresolved conflicts still stop reconstruction, and remote-only Rebuild retains its ID check. These package-owned tests establish preflight decisions; the maintained downstream migration scenario separately owns complete CLI reconstruction and real-Obsidian Fast Fetch with the exact packed candidate.
|
|
96
100
|
|
|
97
101
|
Unreleased Node timestamp tests verify that `stat` and `trystat` retain an available birth time when change time advances, fall back when birth time is zero, and return integer milliseconds. Packed-package checks exercise the shared `getNodeFileCreationTime` export. With the exact locally packed candidate installed, downstream CLI tests verify that an in-place edit retains creation time in local Metadata. The CLI-to-real-Obsidian scenario additionally verifies the edited content and creation time in received Metadata. These checks do not establish the reporter's ZFS behaviour or physical-file creation times in Obsidian.
|
|
98
102
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
date: 2026-10-19
|
|
3
|
+
commonlib-version: "0.1.42"
|
|
4
|
+
self-hosted-livesync-version: "1.0.38"
|
|
5
|
+
status: unreleased
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Unicode path identity
|
|
9
|
+
|
|
10
|
+
`pathNormalizationVersion` controls the logical path used to derive a normal-file
|
|
11
|
+
Metadata document ID. An absent value or `0` preserves legacy behaviour. Value
|
|
12
|
+
`1` applies Unicode NFC before the existing case and ID derivation rules. It
|
|
13
|
+
does not alter the Metadata path or require physical files to be renamed.
|
|
14
|
+
Unsupported values are rejected.
|
|
15
|
+
|
|
16
|
+
NFC identity shipped in Commonlib `0.1.42` and Self-hosted LiveSync `1.0.38`.
|
|
17
|
+
The deletion handling below is an unreleased refinement of reconstruction.
|
|
18
|
+
|
|
19
|
+
## Package contract
|
|
20
|
+
|
|
21
|
+
The Commonlib settings entry exposes `PathNormalizationVersion`,
|
|
22
|
+
`PathIdentitySettings`, `getPathNormalizationVersion`, and
|
|
23
|
+
`normalisePathForIdentity`. Consumers must use the shared path identity
|
|
24
|
+
implementation for Metadata IDs rather than add a host-specific normalisation
|
|
25
|
+
rule. Host storage adapters remain responsible for mapping a logical path back
|
|
26
|
+
to its existing physical entry.
|
|
27
|
+
|
|
28
|
+
## Database compatibility
|
|
29
|
+
|
|
30
|
+
Version `1` uses `nfc-path-identity-v1` in the existing generation-13
|
|
31
|
+
`used_features` list. This is separate from `idDerivationVersion`, which selects
|
|
32
|
+
the cryptographic ID key. The NFC setting is a required Tweak even when
|
|
33
|
+
encryption is disabled.
|
|
34
|
+
|
|
35
|
+
A fresh database declares the feature before its first Metadata mutation. An
|
|
36
|
+
existing database without the feature cannot acquire it through an ordinary
|
|
37
|
+
Metadata write or replication while it already contains Metadata. A client
|
|
38
|
+
configured for version `0` refuses an NFC database. These checks also apply
|
|
39
|
+
when ordinary configuration-mismatch warnings are disabled. The existing
|
|
40
|
+
version-document reception hook stops an active replication session when it
|
|
41
|
+
detects incompatible path identity. It does not revoke server credentials or
|
|
42
|
+
atomically cancel work already admitted by another device.
|
|
43
|
+
|
|
44
|
+
Fast Fetch checkpoints include the path identity version, so a checkpoint
|
|
45
|
+
created under version `0` cannot resume under version `1`, or vice versa.
|
|
46
|
+
|
|
47
|
+
## Migration contract
|
|
48
|
+
|
|
49
|
+
1. Update every participating client, stop synchronisation, and retain a
|
|
50
|
+
complete backup.
|
|
51
|
+
2. Select a complete, authoritative Vault. Resolve distinct files or Metadata
|
|
52
|
+
documents which would derive the same NFC document ID. Case-insensitive
|
|
53
|
+
handling also applies its existing case rule.
|
|
54
|
+
3. Set version `1` on the source and Rebuild both databases from that Vault.
|
|
55
|
+
4. Set version `1` on the remaining clients and Fetch the rebuilt remote before
|
|
56
|
+
resuming synchronisation.
|
|
57
|
+
|
|
58
|
+
Reconstruction checks the available storage listing and local Metadata before
|
|
59
|
+
resetting either database. When the local database is not open, the storage
|
|
60
|
+
check conservatively includes every listed file. A collision stops the
|
|
61
|
+
operation; it never chooses a file to keep or merges separate histories.
|
|
62
|
+
Ordinary synchronisation does not rename legacy document IDs in place.
|
|
63
|
+
|
|
64
|
+
Full Rebuild, Standard Fetch, and Fast Fetch replace the local database. Their
|
|
65
|
+
Metadata check requests conflict information and excludes a logically deleted
|
|
66
|
+
document (`deleted: true`) only when it has no unresolved conflict leaves.
|
|
67
|
+
A deleted winner with any unresolved conflict remains a collision candidate.
|
|
68
|
+
Remote-only Rebuild retains logically deleted Metadata and still requires its
|
|
69
|
+
stored document IDs to match the selected identity.
|
|
70
|
+
|
|
71
|
+
A fully tombstoned document is already absent from ordinary Metadata
|
|
72
|
+
enumeration. A deletion made only on the remote does not exclude a still
|
|
73
|
+
non-deleted local document until that deletion has been replicated.
|
|
74
|
+
|
|
75
|
+
The Setup URI shares `pathNormalizationVersion`; an absent value decodes as
|
|
76
|
+
legacy mode, and unsupported versions are rejected. Importing a shared value
|
|
77
|
+
does not itself perform the required reconstruction.
|
|
78
|
+
|
|
79
|
+
## Supported hosts
|
|
80
|
+
|
|
81
|
+
The initial support boundary is the maintained CLI and Obsidian with CouchDB.
|
|
82
|
+
`PathService` owns logical ID derivation. The CLI resolves NFC-equivalent names
|
|
83
|
+
against directory entries while preserving the actual entry spelling; its
|
|
84
|
+
generic Node storage contract remains exact-path access. Obsidian supplies its
|
|
85
|
+
normalised storage paths and preserves an existing physical spelling when it
|
|
86
|
+
is NFC-equivalent to the received path.
|
|
87
|
+
|
|
88
|
+
Journal and P2P refuse version `1`. Direct file access refuses a remote which
|
|
89
|
+
declares the NFC feature. Other host adapters must establish physical path
|
|
90
|
+
resolution before offering this setting. The regression tests cover ASCII,
|
|
91
|
+
NFC, NFD, and mixed paths through in-memory PouchDB Metadata lookup, with and
|
|
92
|
+
without path obfuscation, plus database admission, reconstruction ordering,
|
|
93
|
+
collisions, and physical path handling. These tests do not establish native
|
|
94
|
+
iOS or macOS filesystem behaviour.
|
|
95
|
+
|
|
96
|
+
The full host setting, Rebuild/Fetch, CLI, and Obsidian migration contract is
|
|
97
|
+
in the [Self-hosted LiveSync Unicode path identity design](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/design_docs/unicode_path_identity.md)
|
|
98
|
+
and its [decision record](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/adr/2026_10_unicode_path_identity.md).
|