|
1 | | -import { existsSync, readFileSync, statSync, unlinkSync } from 'fs'; // eslint-disable-line no-restricted-imports -- files being checked |
| 1 | +import { existsSync, readFileSync, statSync, writeFileSync } from 'fs'; // eslint-disable-line no-restricted-imports -- files being checked |
2 | 2 | import { writeFile } from 'fs/promises'; |
3 | 3 | import { join } from 'path'; |
4 | | -import { Mutex } from 'async-mutex'; |
5 | 4 | import { Logger } from 'pino'; |
| 5 | +import { lock, lockSync } from 'proper-lockfile'; |
6 | 6 | import { LoggerFactory } from '../../telemetry/LoggerFactory'; |
7 | 7 | import { ScopedTelemetry } from '../../telemetry/ScopedTelemetry'; |
8 | 8 | import { TelemetryService } from '../../telemetry/TelemetryService'; |
9 | 9 | import { DataStore } from '../DataStore'; |
10 | 10 | import { decrypt, encrypt } from './Encryption'; |
11 | 11 |
|
| 12 | +const LOCK_OPTIONS = { stale: 10_000 }; // 10 seconds |
| 13 | + |
12 | 14 | export class EncryptedFileStore implements DataStore { |
13 | 15 | private readonly log: Logger; |
14 | | - |
15 | 16 | private readonly file: string; |
16 | | - private content?: Record<string, unknown>; |
| 17 | + private content: Record<string, unknown> = {}; |
17 | 18 | private readonly telemetry: ScopedTelemetry; |
18 | | - private readonly lock = new Mutex(); |
19 | 19 |
|
20 | 20 | constructor( |
21 | 21 | private readonly KEY: Buffer, |
22 | | - private readonly name: string, |
| 22 | + name: string, |
23 | 23 | fileDbDir: string, |
24 | 24 | ) { |
25 | 25 | this.log = LoggerFactory.getLogger(`FileStore.${name}`); |
26 | 26 | this.file = join(fileDbDir, `${name}.enc`); |
27 | | - |
28 | 27 | this.telemetry = TelemetryService.instance.get(`FileStore.${name}`); |
29 | | - } |
30 | | - |
31 | | - get<T>(key: string): T | undefined { |
32 | | - return this.telemetry.countExecution('get', () => { |
33 | | - if (this.content) { |
34 | | - return this.content[key] as T | undefined; |
35 | | - } |
36 | 28 |
|
37 | | - if (!existsSync(this.file)) { |
38 | | - return; |
39 | | - } |
40 | | - |
41 | | - if (this.lock.isLocked()) { |
42 | | - return this.content?.[key]; |
| 29 | + if (existsSync(this.file)) { |
| 30 | + try { |
| 31 | + this.content = this.readFile(); |
| 32 | + } catch (error) { |
| 33 | + this.log.error(error, 'Failed to decrypt file store, recreating store'); |
| 34 | + this.telemetry.count('filestore.recreate', 1); |
| 35 | + |
| 36 | + const release = lockSync(this.file, LOCK_OPTIONS); |
| 37 | + try { |
| 38 | + this.saveSync(); |
| 39 | + } finally { |
| 40 | + release(); |
| 41 | + } |
43 | 42 | } |
| 43 | + } else { |
| 44 | + this.saveSync(); |
| 45 | + } |
| 46 | + } |
44 | 47 |
|
45 | | - const decrypted = decrypt(this.KEY, readFileSync(this.file)); |
46 | | - this.content = JSON.parse(decrypted) as Record<string, unknown>; |
47 | | - return this.content[key] as T | undefined; |
48 | | - }); |
| 48 | + get<T>(key: string): T | undefined { |
| 49 | + return this.telemetry.countExecution('get', () => this.content[key] as T | undefined); |
49 | 50 | } |
50 | 51 |
|
51 | 52 | put<T>(key: string, value: T): Promise<boolean> { |
52 | | - return this.lock.runExclusive(() => |
53 | | - this.telemetry.measureAsync('put', async () => { |
54 | | - if (!this.content) { |
55 | | - this.get(key); |
56 | | - } |
57 | | - |
58 | | - this.content = { |
59 | | - ...this.content, |
60 | | - [key]: value, |
61 | | - }; |
62 | | - const encrypted = encrypt(this.KEY, JSON.stringify(this.content)); |
63 | | - await writeFile(this.file, encrypted); |
64 | | - return true; |
65 | | - }), |
66 | | - ); |
| 53 | + return this.withLock('put', async () => { |
| 54 | + this.content[key] = value; |
| 55 | + await this.save(); |
| 56 | + return true; |
| 57 | + }); |
67 | 58 | } |
68 | 59 |
|
69 | 60 | remove(key: string): Promise<boolean> { |
70 | | - return this.lock.runExclusive(() => { |
71 | | - return this.telemetry.measureAsync('remove', async () => { |
72 | | - if (!this.content) { |
73 | | - this.get(key); |
74 | | - } |
75 | | - |
76 | | - if (!this.content || !(key in this.content)) { |
77 | | - return false; |
78 | | - } |
| 61 | + return this.withLock('remove', async () => { |
| 62 | + if (!(key in this.content)) { |
| 63 | + return false; |
| 64 | + } |
79 | 65 |
|
80 | | - delete this.content[key]; |
81 | | - const encrypted = encrypt(this.KEY, JSON.stringify(this.content)); |
82 | | - await writeFile(this.file, encrypted); |
83 | | - return true; |
84 | | - }); |
| 66 | + delete this.content[key]; |
| 67 | + await this.save(); |
| 68 | + return true; |
85 | 69 | }); |
86 | 70 | } |
87 | 71 |
|
88 | 72 | clear(): Promise<void> { |
89 | | - return this.lock.runExclusive(() => { |
90 | | - return this.telemetry.countExecutionAsync('clear', () => { |
91 | | - if (existsSync(this.file)) { |
92 | | - unlinkSync(this.file); |
93 | | - } |
94 | | - this.content = undefined; |
95 | | - return Promise.resolve(); |
96 | | - }); |
| 73 | + return this.withLock('clear', async () => { |
| 74 | + this.content = {}; |
| 75 | + await this.save(); |
97 | 76 | }); |
98 | 77 | } |
99 | 78 |
|
100 | 79 | keys(limit: number): ReadonlyArray<string> { |
101 | | - return this.telemetry.countExecution('keys', () => { |
102 | | - if (!this.content) { |
103 | | - this.get('ANY_KEY'); |
104 | | - } |
105 | | - |
106 | | - return Object.keys(this.content ?? {}).slice(0, limit); |
107 | | - }); |
| 80 | + return this.telemetry.countExecution('keys', () => Object.keys(this.content).slice(0, limit)); |
108 | 81 | } |
109 | 82 |
|
110 | 83 | stats(): FileStoreStats { |
111 | | - if (!this.content) { |
112 | | - this.get('ANY_KEY'); |
113 | | - } |
114 | | - |
115 | 84 | return { |
116 | | - entries: Object.keys(this.content ?? {}).length, |
| 85 | + entries: Object.keys(this.content).length, |
117 | 86 | totalSize: existsSync(this.file) ? statSync(this.file).size : 0, |
118 | 87 | }; |
119 | 88 | } |
| 89 | + |
| 90 | + private async withLock<T>(operation: string, fn: () => Promise<T>): Promise<T> { |
| 91 | + return await this.telemetry.measureAsync(operation, async () => { |
| 92 | + const release = await lock(this.file, LOCK_OPTIONS); |
| 93 | + try { |
| 94 | + this.content = this.readFile(); |
| 95 | + return await fn(); |
| 96 | + } finally { |
| 97 | + await release(); |
| 98 | + } |
| 99 | + }); |
| 100 | + } |
| 101 | + |
| 102 | + private readFile(): Record<string, unknown> { |
| 103 | + return JSON.parse(decrypt(this.KEY, readFileSync(this.file))) as Record<string, unknown>; |
| 104 | + } |
| 105 | + |
| 106 | + private saveSync() { |
| 107 | + writeFileSync(this.file, encrypt(this.KEY, JSON.stringify(this.content))); |
| 108 | + } |
| 109 | + |
| 110 | + private async save() { |
| 111 | + await writeFile(this.file, encrypt(this.KEY, JSON.stringify(this.content))); |
| 112 | + } |
120 | 113 | } |
121 | 114 |
|
122 | 115 | export type FileStoreStats = { |
|
0 commit comments