Skip to content

Commit eca6fe6

Browse files
committed
fix(node-client-sdk): better handling for bad filesystem states
- refuse to load from symlinks - fallsback to inmemory store if filesystem loading fails - additionally, exposing `ConnectionMode` type for downstream reference
1 parent 14dfd8a commit eca6fe6

4 files changed

Lines changed: 132 additions & 41 deletions

File tree

packages/sdk/node-client/__tests__/platform/NodePlatform.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,13 @@ it('round-trips storage values through the file-backed NodeStorage', async () =>
3737
await expect(platform.storage.get('alpha')).resolves.toBeNull();
3838
});
3939

40-
it('forwards the logger to NodeStorage so storage failures surface', async () => {
40+
it('forwards the logger to NodeStorage so storage init failures surface', async () => {
4141
const platform = new NodePlatform(logger, {
4242
localStoragePath: path.join(tmpRoot, 'never-created', '\0bad'),
4343
});
4444
await expect(platform.storage.get('alpha')).resolves.toBeNull();
45-
expect(logger.error).toHaveBeenCalledWith(
46-
expect.stringContaining('Error getting key from storage'),
45+
expect(logger.warn).toHaveBeenCalledWith(
46+
expect.stringContaining('Using in-memory storage as a fallback'),
4747
);
4848
});
4949

packages/sdk/node-client/__tests__/platform/NodeStorage.test.ts

Lines changed: 101 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -129,26 +129,115 @@ it('does not follow a symlink planted at the temp file path', async () => {
129129
await expect(storage.get('alpha')).resolves.toBe('one');
130130
});
131131

132-
it('logs and returns sentinel values when initialization fails', async () => {
133-
const filePath = path.join(tmpRoot, 'not-a-dir');
134-
await fs.writeFile(filePath, 'sentinel', 'utf8');
132+
it('falls back to in-memory storage instead of following a symlinked storage directory', async () => {
133+
const victimDir = path.join(tmpRoot, 'victim-dir');
134+
await fs.mkdir(victimDir);
135+
const storagePath = path.join(tmpRoot, 'ldcache');
136+
await fs.symlink(victimDir, storagePath);
135137

136138
const logger = createMockLogger();
137-
const storage = new NodeStorage(filePath, logger);
139+
const storage = new NodeStorage(storagePath, logger);
140+
141+
await storage.set('alpha', 'one');
142+
await expect(storage.get('alpha')).resolves.toBe('one');
143+
expect(logger.error).not.toHaveBeenCalled();
144+
expect(logger.warn).toHaveBeenCalledWith(
145+
expect.stringContaining('Using in-memory storage as a fallback'),
146+
);
147+
148+
// The victim directory the symlink pointed at was never written to.
149+
await expect(fs.readdir(victimDir)).resolves.toEqual([]);
150+
});
151+
152+
it('discards a symlink planted at the storage file path instead of reading through it', async () => {
153+
const victim = path.join(tmpRoot, 'victim.json');
154+
await fs.writeFile(victim, JSON.stringify({ secret: 'do-not-load' }), 'utf8');
155+
await fs.symlink(victim, path.join(tmpRoot, 'ldcache.json'));
156+
157+
const logger = createMockLogger();
158+
const storage = new NodeStorage(tmpRoot, logger);
159+
160+
// The symlinked "cache" is discarded rather than followed, so its contents never load.
161+
await expect(storage.get('secret')).resolves.toBeNull();
162+
expect(logger.warn).toHaveBeenCalledWith(
163+
expect.stringContaining('Discarding malformed flag cache'),
164+
);
165+
expect(logger.error).not.toHaveBeenCalled();
166+
167+
// The victim file itself is untouched, and the symlink has been replaced by a real file.
168+
await expect(fs.readFile(victim, 'utf8')).resolves.toBe(JSON.stringify({ secret: 'do-not-load' }));
169+
await storage.set('alpha', 'one');
170+
await expect(storage.get('alpha')).resolves.toBe('one');
171+
const onDisk = await fs.readFile(path.join(tmpRoot, 'ldcache.json'), 'utf8');
172+
expect(JSON.parse(onDisk)).toEqual({ alpha: 'one' });
173+
});
174+
175+
it('falls back to in-memory storage when a file occupies the storage directory path', async () => {
176+
// No prerelease (v0) installation has customer data to migrate, so a plain file sitting at
177+
// the storage directory path is just treated as a generic, unrecoverable init failure.
178+
const storagePath = path.join(tmpRoot, 'ldcache');
179+
await fs.writeFile(storagePath, 'not a directory', 'utf8');
180+
181+
const logger = createMockLogger();
182+
const storage = new NodeStorage(storagePath, logger);
183+
184+
await storage.set('alpha', 'one');
185+
await expect(storage.get('alpha')).resolves.toBe('one');
186+
expect(logger.error).not.toHaveBeenCalled();
187+
expect(logger.warn).toHaveBeenCalledTimes(1);
188+
expect(logger.warn).toHaveBeenCalledWith(
189+
expect.stringContaining('Using in-memory storage as a fallback'),
190+
);
191+
expect((await fs.stat(storagePath)).isDirectory()).toBe(false);
192+
});
193+
194+
it('falls back to in-memory storage and warns once when the storage directory cannot be created', async () => {
195+
// An intermediate path segment that is a file (rather than the storage directory itself)
196+
// means mkdir cannot traverse through it, so this must fall back to in-memory storage.
197+
const fileInThePath = path.join(tmpRoot, 'not-a-dir');
198+
await fs.writeFile(fileInThePath, 'sentinel', 'utf8');
199+
const storagePath = path.join(fileInThePath, 'subdir');
200+
201+
const logger = createMockLogger();
202+
const storage = new NodeStorage(storagePath, logger);
138203

139204
await expect(storage.get('alpha')).resolves.toBeNull();
140-
await expect(storage.set('alpha', 'one')).resolves.toBeUndefined();
141-
await expect(storage.clear('alpha')).resolves.toBeUndefined();
205+
await storage.set('alpha', 'one');
206+
await expect(storage.get('alpha')).resolves.toBe('one');
207+
await storage.clear('alpha');
208+
await expect(storage.get('alpha')).resolves.toBeNull();
142209

143-
expect(logger.error).toHaveBeenCalledWith(
144-
expect.stringContaining('Error getting key from storage'),
210+
expect(logger.error).not.toHaveBeenCalled();
211+
expect(logger.warn).toHaveBeenCalledTimes(1);
212+
expect(logger.warn).toHaveBeenCalledWith(
213+
expect.stringContaining('Using in-memory storage as a fallback'),
145214
);
146-
expect(logger.error).toHaveBeenCalledWith(
147-
expect.stringContaining('Error setting key in storage'),
215+
});
216+
217+
it('falls back to in-memory storage when rewriting a discarded malformed cache fails', async () => {
218+
// The storage directory itself is created successfully, but the rewrite that normally follows
219+
// discarding a malformed cache file fails because a directory occupies the temp-file path.
220+
// This exercises the fallback triggering from a site other than storage-directory creation.
221+
await fs.writeFile(path.join(tmpRoot, 'ldcache.json'), 'not json', 'utf8');
222+
await fs.mkdir(path.join(tmpRoot, 'ldcache.json.tmp'));
223+
224+
const logger = createMockLogger();
225+
const storage = new NodeStorage(tmpRoot, logger);
226+
227+
await expect(storage.get('anything')).resolves.toBeNull();
228+
expect(logger.warn).toHaveBeenCalledWith(
229+
expect.stringContaining('Discarding malformed flag cache'),
148230
);
149-
expect(logger.error).toHaveBeenCalledWith(
150-
expect.stringContaining('Error clearing key from storage'),
231+
expect(logger.warn).toHaveBeenCalledWith(
232+
expect.stringContaining('Using in-memory storage as a fallback'),
151233
);
234+
expect(logger.error).not.toHaveBeenCalled();
235+
236+
await storage.set('alpha', 'one');
237+
await expect(storage.get('alpha')).resolves.toBe('one');
238+
239+
// Persistence is disabled, so the untouched malformed file on disk proves no flush was attempted.
240+
await expect(fs.readFile(path.join(tmpRoot, 'ldcache.json'), 'utf8')).resolves.toBe('not json');
152241
});
153242

154243
it('returns the same singleton across getNodeStorage calls', () => {

packages/sdk/node-client/src/LDCommon.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export type {
2+
ConnectionMode,
23
InitializerEntry,
34
ModeDefinition,
45
SynchronizerEntry,

packages/sdk/node-client/src/platform/NodeStorage.ts

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ const DEFAULT_DIR_NAME = 'ldclient-user-cache';
77
const STORAGE_FILE_NAME = 'ldcache.json';
88

99
const ERROR_PREFIX = {
10-
get: 'Error getting key from storage',
1110
set: 'Error setting key in storage',
1211
clear: 'Error clearing key from storage',
1312
} as const;
@@ -18,9 +17,9 @@ export default class NodeStorage implements Storage {
1817
private readonly _storageDir: string;
1918
private readonly _storageFile: string;
2019
private readonly _tempFile: string;
21-
private readonly _initialized: Promise<boolean>;
20+
private readonly _initialized: Promise<void>;
2221
private readonly _logger?: LDLogger;
23-
private _initError?: Error;
22+
private _persistenceDisabled: boolean = false;
2423
private _cache: Map<string, string> = new Map();
2524
private _flushPending: boolean = false;
2625
private _flushQueue: Promise<void> = Promise.resolve();
@@ -33,17 +32,29 @@ export default class NodeStorage implements Storage {
3332
this._initialized = this._initialize();
3433
}
3534

36-
private async _initialize(): Promise<boolean> {
35+
private async _initialize(): Promise<void> {
3736
try {
3837
await fs.mkdir(this._storageDir, { recursive: true });
3938

39+
// fs.mkdir succeeds silently if the path already exists as a symlink to a directory, so a
40+
// pre-planted symlink would otherwise redirect where the cache is read from and written to
41+
// without ever surfacing as an init failure.
42+
if ((await fs.lstat(this._storageDir)).isSymbolicLink()) {
43+
throw new Error(`Storage directory path is a symlink, not a real directory: ${this._storageDir}`);
44+
}
45+
4046
try {
4147
await fs.unlink(this._tempFile);
4248
} catch {
4349
// Ignore if temp file does not exist.
4450
}
4551

4652
try {
53+
const fileStat = await fs.lstat(this._storageFile);
54+
if (!fileStat.isFile()) {
55+
throw new Error(`Storage file exists but is not a regular file: ${this._storageFile}`);
56+
}
57+
4758
const data = await fs.readFile(this._storageFile, 'utf8');
4859
const parsed = JSON.parse(data);
4960
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
@@ -60,11 +71,11 @@ export default class NodeStorage implements Storage {
6071
await this._atomicWriteToFile(this._cache);
6172
}
6273
}
63-
64-
return true;
6574
} catch (error) {
66-
this._initError = error instanceof Error ? error : new Error(String(error));
67-
return false;
75+
this._persistenceDisabled = true;
76+
this._logger?.warn(
77+
`Failed to initialize local flag cache at ${this._storageDir}: ${error instanceof Error ? error.message : error}. Using in-memory storage as a fallback - flags will not persist across restarts.`,
78+
);
6879
}
6980
}
7081

@@ -108,26 +119,17 @@ export default class NodeStorage implements Storage {
108119
this._logger?.error(`${ERROR_PREFIX[op]}: ${key}, reason: ${reason}`);
109120
}
110121

111-
private _initFailureReason(): string {
112-
return `not initialized${this._initError ? `: ${this._initError.message}` : ''}`;
113-
}
114-
115122
async get(key: string): Promise<string | null> {
116-
const initialized = await this._initialized;
117-
if (!initialized) {
118-
this._logError('get', key, this._initFailureReason());
119-
return null;
120-
}
123+
await this._initialized;
121124
return this._cache.get(key) ?? null;
122125
}
123126

124127
async set(key: string, value: string): Promise<void> {
125-
const initialized = await this._initialized;
126-
if (!initialized) {
127-
this._logError('set', key, this._initFailureReason());
128+
await this._initialized;
129+
this._cache.set(key, value);
130+
if (this._persistenceDisabled) {
128131
return;
129132
}
130-
this._cache.set(key, value);
131133
try {
132134
await this._scheduleFlush();
133135
} catch (error) {
@@ -136,13 +138,12 @@ export default class NodeStorage implements Storage {
136138
}
137139

138140
async clear(key: string): Promise<void> {
139-
const initialized = await this._initialized;
140-
if (!initialized) {
141-
this._logError('clear', key, this._initFailureReason());
142-
return;
143-
}
141+
await this._initialized;
144142
if (this._cache.has(key)) {
145143
this._cache.delete(key);
144+
if (this._persistenceDisabled) {
145+
return;
146+
}
146147
try {
147148
await this._scheduleFlush();
148149
} catch (error) {

0 commit comments

Comments
 (0)