-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathEncryptedFileStore.ts
More file actions
302 lines (273 loc) · 9.31 KB
/
EncryptedFileStore.ts
File metadata and controls
302 lines (273 loc) · 9.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
import {
closeSync,
existsSync,
fsyncSync,
openSync,
readdirSync,
readFileSync, // eslint-disable-line no-restricted-imports -- atomic file primitives required here
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from 'fs';
import { open, rename, unlink, writeFile } from 'fs/promises';
import { basename, join } from 'path';
import { Logger } from 'pino';
import { lock, LockOptions, lockSync } from 'proper-lockfile';
import { LoggerFactory } from '../../telemetry/LoggerFactory';
import { ScopedTelemetry } from '../../telemetry/ScopedTelemetry';
import { TelemetryService } from '../../telemetry/TelemetryService';
import { processId } from '../../utils/Environment';
import { sleep } from '../../utils/Retry';
import { DataStore } from '../DataStore';
import { decrypt, encrypt } from './Encryption';
const STALE_MS = 30_000;
const LOCK_OPTIONS_SYNC: LockOptions = { stale: STALE_MS, realpath: false };
const LOCK_OPTIONS: LockOptions = {
...LOCK_OPTIONS_SYNC,
retries: { retries: 100, minTimeout: 25, maxTimeout: 200, factor: 1.5, randomize: true },
};
const RENAME_MAX_RETRIES = 10;
const RENAME_RETRY_DELAY_MS = 50;
const RETRIABLE_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'ENOENT']);
const SYNC_LOCK_MAX_ATTEMPTS = 200;
const SYNC_LOCK_RETRY_DELAY_MS = 50;
export class EncryptedFileStore implements DataStore {
private readonly log: Logger;
private readonly file: string;
private readonly dir: string;
private readonly lockfilePath: string;
private content: Record<string, unknown> = {};
private readonly telemetry: ScopedTelemetry;
private writeQueue: Promise<void> = Promise.resolve();
private tempFileCounter = 0;
constructor(
private readonly encryptionKey: Buffer,
name: string,
fileDbDir: string,
) {
this.log = LoggerFactory.getLogger(`FileStore.${name}`);
this.file = join(fileDbDir, `${name}.enc`);
this.dir = fileDbDir;
this.lockfilePath = join(fileDbDir, `${name}.enc.lock`);
this.telemetry = TelemetryService.instance.get(`FileStore.${name}`);
this.cleanupStaleTmpFiles();
const release = lockSyncWithRetry(fileDbDir, { ...LOCK_OPTIONS_SYNC, lockfilePath: this.lockfilePath });
try {
if (existsSync(this.file)) {
try {
this.content = this.readFile();
} catch (error) {
this.log.error(error, 'Failed to decrypt file store, recreating store');
this.telemetry.count('filestore.recreate', 1);
this.saveSync();
}
} else {
this.saveSync();
}
} finally {
release();
}
}
get<T>(key: string): T | undefined {
return this.telemetry.countExecution('get', () => this.content[key] as T | undefined, {
captureErrorAttributes: true,
});
}
put<T>(key: string, value: T): Promise<boolean> {
return this.withLock('put', async () => {
this.content[key] = value;
await this.save();
return true;
});
}
remove(key: string): Promise<boolean> {
return this.withLock('remove', async () => {
if (!(key in this.content)) {
return false;
}
delete this.content[key];
await this.save();
return true;
});
}
clear(): Promise<void> {
return this.withLock('clear', async () => {
this.content = {};
await this.save();
});
}
keys(limit: number): ReadonlyArray<string> {
return this.telemetry.countExecution('keys', () => Object.keys(this.content).slice(0, limit), {
captureErrorAttributes: true,
});
}
stats(): FileStoreStats {
return {
entries: Object.keys(this.content).length,
totalSize: existsSync(this.file) ? statSync(this.file).size : 0,
};
}
private async withLock<T>(operation: string, fn: () => Promise<T>): Promise<T> {
const previous = this.writeQueue;
let releaseNext!: () => void;
this.writeQueue = new Promise<void>((resolve) => {
releaseNext = resolve;
});
try {
await previous;
return await this.telemetry.measureAsync(
operation,
async () => {
const release = await lock(this.dir, { ...LOCK_OPTIONS, lockfilePath: this.lockfilePath });
try {
this.content = existsSync(this.file) ? this.readFile() : {};
return await fn();
} finally {
await release();
}
},
{ captureErrorAttributes: true },
);
} finally {
releaseNext();
}
}
private readFile(): Record<string, unknown> {
return JSON.parse(decrypt(this.encryptionKey, readFileSync(this.file))) as Record<string, unknown>;
}
private saveSync() {
const tmp = this.tmpPath();
writeFileSync(tmp, encrypt(this.encryptionKey, JSON.stringify(this.content)));
fsyncFileSync(tmp);
renameSyncWithRetry(tmp, this.file);
fsyncDirSync(this.dir);
}
private async save() {
const tmp = this.tmpPath();
await writeFile(tmp, encrypt(this.encryptionKey, JSON.stringify(this.content)));
await fsyncFile(tmp);
await renameWithRetry(tmp, this.file);
await fsyncDir(this.dir);
}
private tmpPath(): string {
this.tempFileCounter = (this.tempFileCounter + 1) % Number.MAX_SAFE_INTEGER;
return `${this.file}.${processId()}.${this.tempFileCounter}.tmp`;
}
private cleanupStaleTmpFiles(): void {
const prefix = `${basename(this.file)}.`;
try {
for (const entry of readdirSync(this.dir)) {
if (entry.startsWith(prefix) && entry.endsWith('.tmp')) {
try {
unlinkSync(join(this.dir, entry));
} catch {
// ignore - another process may own it
}
}
}
} catch {
// ignore - dir may not exist yet
}
}
}
function fsyncFileSync(filePath: string): void {
const fd = openSync(filePath, 'r+');
try {
fsyncSync(fd);
} finally {
closeSync(fd);
}
}
function fsyncDirSync(dirPath: string): void {
try {
const fd = openSync(dirPath, 'r');
try {
fsyncSync(fd);
} finally {
closeSync(fd);
}
} catch {
// Windows cannot fsync directories - writes are still durable via NTFS journaling
}
}
async function fsyncFile(filePath: string): Promise<void> {
const handle = await open(filePath, 'r+');
try {
await handle.sync();
} finally {
await handle.close();
}
}
async function fsyncDir(dirPath: string): Promise<void> {
try {
const handle = await open(dirPath, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
} catch {
// Windows cannot fsync directories
}
}
function renameSyncWithRetry(sourcePath: string, destinationPath: string): void {
for (let attempt = 0; attempt < RENAME_MAX_RETRIES; attempt++) {
try {
renameSync(sourcePath, destinationPath);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (!code || !RETRIABLE_RENAME_CODES.has(code) || attempt === RENAME_MAX_RETRIES - 1) {
try {
unlinkSync(sourcePath);
} catch {
// best-effort tmp cleanup
}
throw error;
}
sleepSyncMs(RENAME_RETRY_DELAY_MS);
}
}
}
function lockSyncWithRetry(target: string, options: LockOptions): () => void {
let lastError: unknown;
for (let attempt = 0; attempt < SYNC_LOCK_MAX_ATTEMPTS; attempt++) {
try {
return lockSync(target, options);
} catch (error) {
lastError = error;
if ((error as NodeJS.ErrnoException).code !== 'ELOCKED') {
throw error;
}
sleepSyncMs(SYNC_LOCK_RETRY_DELAY_MS);
}
}
throw lastError;
}
function sleepSyncMs(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
async function renameWithRetry(sourcePath: string, destinationPath: string): Promise<void> {
for (let attempt = 0; attempt < RENAME_MAX_RETRIES; attempt++) {
try {
await rename(sourcePath, destinationPath);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (!code || !RETRIABLE_RENAME_CODES.has(code) || attempt === RENAME_MAX_RETRIES - 1) {
try {
await unlink(sourcePath);
} catch {
// best-effort tmp cleanup
}
throw error;
}
await sleep(RENAME_RETRY_DELAY_MS);
}
}
}
export type FileStoreStats = {
entries: number;
totalSize: number;
};