Skip to content

Commit d6c0591

Browse files
committed
refactor(@angular/build): prevent key collisions in cache namespaces
Previously, Cache formatted keys by joining the namespace and key with a single colon delimiter (${namespace}:${key}). This allowed potential key collisions if a namespace contained colons (such as 'a' with key 'b:c' versus 'a:b' with key 'c'). While this was not an issue in existing usages because all current namespaces are fixed, colon-free identifiers and keys are hash digests, it presented an architectural risk as caching usages expand. Namespacing is now encapsulated in a dedicated NamespacedCacheStore wrapper that frames keys using length-prefix encoding (<length>:<namespace>:<key>). The length prefix eliminates delimiter ambiguity regardless of what characters appear in the namespace or key. Additionally, Cache has been decoupled from namespace management, simplifying MemoryCache and internal request tracking.
1 parent 0a137f9 commit d6c0591

4 files changed

Lines changed: 175 additions & 55 deletions

File tree

packages/angular/build/src/tools/esbuild/cache.ts

Lines changed: 64 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,40 @@ export interface PersistentCacheStore<V = any> extends CacheStore<V> {
5151
close(): void | Promise<void>;
5252
}
5353

54+
/**
55+
* A backing data store wrapper that namespaces all keys using length-prefix framing.
56+
* Prevents key collisions between namespaces regardless of characters (such as colons)
57+
* in the namespace or key.
58+
*/
59+
export class NamespacedCacheStore<V> implements CacheStore<V> {
60+
readonly #prefix: string;
61+
62+
constructor(
63+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
64+
private readonly store: CacheStore<any>,
65+
readonly namespace: string,
66+
) {
67+
this.#prefix = `${namespace.length}:${namespace}:`;
68+
}
69+
70+
get(key: string): V | undefined | Promise<V | undefined> {
71+
return this.store.get(this.#prefix + key);
72+
}
73+
74+
has(key: string): boolean | Promise<boolean> {
75+
return this.store.has(this.#prefix + key);
76+
}
77+
78+
set(key: string, value: V): this | Promise<this> {
79+
const result = this.store.set(this.#prefix + key, value);
80+
if (result instanceof Promise) {
81+
return result.then(() => this);
82+
}
83+
84+
return this;
85+
}
86+
}
87+
5488
/**
5589
* A cache object that allows accessing and storing key/value pairs in
5690
* an underlying CacheStore. This class is the primary method for consumers
@@ -64,10 +98,7 @@ export class Cache<V, S extends CacheStore<V> = CacheStore<V>> {
6498
// Count the number of active, pending getOrCreate operations per key to avoid memory leaks.
6599
readonly #pendingGets = new Map<string, number>();
66100

67-
constructor(
68-
protected readonly store: S,
69-
readonly namespace?: string,
70-
) {}
101+
constructor(protected readonly store: S) {}
71102

72103
#incrementWrite(key: string) {
73104
// Only track write counts if there is a pending getOrCreate operation active for the key.
@@ -77,19 +108,6 @@ export class Cache<V, S extends CacheStore<V> = CacheStore<V>> {
77108
}
78109
}
79110

80-
/**
81-
* Prefixes a key with the cache namespace if present.
82-
* @param key A key string to prefix.
83-
* @returns A prefixed key if a namespace is present. Otherwise the provided key.
84-
*/
85-
protected withNamespace(key: string): string {
86-
if (this.namespace) {
87-
return `${this.namespace}:${key}`;
88-
}
89-
90-
return key;
91-
}
92-
93111
/**
94112
* Gets the value associated with a provided key if available.
95113
* Otherwise, creates a value using the factory creator function, puts the value
@@ -99,27 +117,25 @@ export class Cache<V, S extends CacheStore<V> = CacheStore<V>> {
99117
* @returns A value associated with the provided key.
100118
*/
101119
async getOrCreate(key: string, creator: () => V | Promise<V>): Promise<V> {
102-
const namespacedKey = this.withNamespace(key);
103-
104120
// 1. If another call is already running the creator for this key, share its promise.
105-
let activeRequest = this.#requests.get(namespacedKey);
121+
let activeRequest = this.#requests.get(key);
106122
if (activeRequest !== undefined) {
107123
return activeRequest;
108124
}
109125

110126
// Increment pending gets count to enable write-tracking for this key.
111-
const currentPending = this.#pendingGets.get(namespacedKey) || 0;
112-
this.#pendingGets.set(namespacedKey, currentPending + 1);
127+
const currentPending = this.#pendingGets.get(key) || 0;
128+
this.#pendingGets.set(key, currentPending + 1);
113129

114130
try {
115-
const startWriteCount = this.#writeCounts.get(namespacedKey) || 0;
131+
const startWriteCount = this.#writeCounts.get(key) || 0;
116132

117133
// 2. Query the backing store. Since store.get can be async, we yield to the event loop.
118-
const value = await this.store.get(namespacedKey);
134+
const value = await this.store.get(key);
119135

120136
// If a write (e.g. put) occurred during the store.get await gap, we must abort
121137
// the current execution and restart to ensure we return the newly written value.
122-
if ((this.#writeCounts.get(namespacedKey) || 0) !== startWriteCount) {
138+
if ((this.#writeCounts.get(key) || 0) !== startWriteCount) {
123139
return this.getOrCreate(key, creator);
124140
}
125141

@@ -129,7 +145,7 @@ export class Cache<V, S extends CacheStore<V> = CacheStore<V>> {
129145

130146
// 3. Recheck active request after the await gap in case another concurrent call
131147
// initiated a creator during the store.get wait.
132-
activeRequest = this.#requests.get(namespacedKey);
148+
activeRequest = this.#requests.get(key);
133149
if (activeRequest !== undefined) {
134150
return activeRequest;
135151
}
@@ -139,34 +155,34 @@ export class Cache<V, S extends CacheStore<V> = CacheStore<V>> {
139155
async (newValue) => {
140156
// Ensure this request is still the active one before writing back to the store
141157
// (prevents overwriting newer data if put() was called before resolution).
142-
if (this.#requests.get(namespacedKey) === activeRequest) {
143-
this.#incrementWrite(namespacedKey);
144-
await this.store.set(namespacedKey, newValue);
145-
this.#requests.delete(namespacedKey);
158+
if (this.#requests.get(key) === activeRequest) {
159+
this.#incrementWrite(key);
160+
await this.store.set(key, newValue);
161+
this.#requests.delete(key);
146162
}
147163

148164
return newValue;
149165
},
150166
(error) => {
151167
// Clean up the active request if the creator fails.
152-
if (this.#requests.get(namespacedKey) === activeRequest) {
153-
this.#requests.delete(namespacedKey);
168+
if (this.#requests.get(key) === activeRequest) {
169+
this.#requests.delete(key);
154170
}
155171
throw error;
156172
},
157173
);
158174

159-
this.#requests.set(namespacedKey, activeRequest);
175+
this.#requests.set(key, activeRequest);
160176

161177
return activeRequest;
162178
} finally {
163179
// Clean up write counts and pending gets once all concurrent gets for this key finish.
164-
const current = this.#pendingGets.get(namespacedKey) || 0;
180+
const current = this.#pendingGets.get(key) || 0;
165181
if (current <= 1) {
166-
this.#pendingGets.delete(namespacedKey);
167-
this.#writeCounts.delete(namespacedKey);
182+
this.#pendingGets.delete(key);
183+
this.#writeCounts.delete(key);
168184
} else {
169-
this.#pendingGets.set(namespacedKey, current - 1);
185+
this.#pendingGets.set(key, current - 1);
170186
}
171187
}
172188
}
@@ -177,7 +193,7 @@ export class Cache<V, S extends CacheStore<V> = CacheStore<V>> {
177193
* @returns A value associated with the provided key if present. Otherwise, `undefined`.
178194
*/
179195
async get(key: string): Promise<V | undefined> {
180-
const value = await this.store.get(this.withNamespace(key));
196+
const value = await this.store.get(key);
181197

182198
return value;
183199
}
@@ -189,19 +205,18 @@ export class Cache<V, S extends CacheStore<V> = CacheStore<V>> {
189205
* @param value A value to put in the cache.
190206
*/
191207
async put(key: string, value: V): Promise<void> {
192-
const namespacedKey = this.withNamespace(key);
193-
this.#requests.delete(namespacedKey);
194-
this.#incrementWrite(namespacedKey);
195-
await this.store.set(namespacedKey, value);
208+
this.#requests.delete(key);
209+
this.#incrementWrite(key);
210+
await this.store.set(key, value);
196211
}
197212

198213
/**
199-
* Clears internal state for a specific namespaced key (requests, write counts, and pending gets).
214+
* Clears internal state for a specific key (requests, write counts, and pending gets).
200215
*/
201-
protected deleteInternal(namespacedKey: string): void {
202-
this.#requests.delete(namespacedKey);
203-
this.#writeCounts.delete(namespacedKey);
204-
this.#pendingGets.delete(namespacedKey);
216+
protected deleteInternal(key: string): void {
217+
this.#requests.delete(key);
218+
this.#writeCounts.delete(key);
219+
this.#pendingGets.delete(key);
205220
}
206221

207222
/**
@@ -228,10 +243,9 @@ export class MemoryCache<V> extends Cache<V, Map<string, V>> {
228243
* @returns True if an element in the Map existed and has been removed, or false if the element does not exist.
229244
*/
230245
delete(key: string): boolean {
231-
const namespacedKey = this.withNamespace(key);
232-
this.deleteInternal(namespacedKey);
246+
this.deleteInternal(key);
233247

234-
return this.store.delete(namespacedKey);
248+
return this.store.delete(key);
235249
}
236250

237251
/**

packages/angular/build/src/tools/esbuild/cache_spec.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { MemoryCache } from './cache';
9+
import { Cache, CacheStore, MemoryCache, NamespacedCacheStore } from './cache';
1010

1111
describe('MemoryCache', () => {
1212
let cache: MemoryCache<string>;
@@ -158,4 +158,110 @@ describe('MemoryCache', () => {
158158
it('should return false when deleting a non-existent key', () => {
159159
expect(cache.delete('non-existent')).toBeFalse();
160160
});
161+
162+
it('should return unencoded keys in entries() and allow deletion', async () => {
163+
await cache.put('component/style.scss', 'content');
164+
165+
const entries = Array.from(cache.entries());
166+
expect(entries).toEqual([['component/style.scss', 'content']]);
167+
168+
expect(cache.delete(entries[0][0])).toBeTrue();
169+
expect(await cache.get('component/style.scss')).toBeUndefined();
170+
});
171+
});
172+
173+
describe('NamespacedCacheStore', () => {
174+
class TestStore implements CacheStore<string> {
175+
readonly map = new Map<string, string>();
176+
177+
get(key: string): string | undefined {
178+
return this.map.get(key);
179+
}
180+
181+
has(key: string): boolean {
182+
return this.map.has(key);
183+
}
184+
185+
set(key: string, value: string): this {
186+
this.map.set(key, value);
187+
188+
return this;
189+
}
190+
}
191+
192+
let store: TestStore;
193+
194+
beforeEach(() => {
195+
store = new TestStore();
196+
});
197+
198+
it('should encode namespaced keys with <length>:<namespace>:<key>', async () => {
199+
const namespacedStore = new NamespacedCacheStore(store, 'test-ns');
200+
const cache = new Cache<string>(namespacedStore);
201+
await cache.put('my-key', 'my-val');
202+
203+
expect(store.map.has('7:test-ns:my-key')).toBeTrue();
204+
expect(await cache.get('my-key')).toBe('my-val');
205+
});
206+
207+
it('should prevent collisions between namespaces containing colons', async () => {
208+
const cacheA = new Cache<string>(new NamespacedCacheStore(store, 'a'));
209+
const cacheB = new Cache<string>(new NamespacedCacheStore(store, 'a:b'));
210+
211+
await cacheA.put('b:c', 'val-a');
212+
await cacheB.put('c', 'val-b');
213+
214+
expect(await cacheA.get('b:c')).toBe('val-a');
215+
expect(await cacheB.get('c')).toBe('val-b');
216+
expect(store.map.get('1:a:b:c')).toBe('val-a');
217+
expect(store.map.get('3:a:b:c')).toBe('val-b');
218+
});
219+
220+
it('should encode empty string namespace as 0::<key>', async () => {
221+
const namespacedStore = new NamespacedCacheStore(store, '');
222+
const cache = new Cache<string>(namespacedStore);
223+
await cache.put('key', 'val');
224+
225+
expect(store.map.has('0::key')).toBeTrue();
226+
expect(await cache.get('key')).toBe('val');
227+
});
228+
229+
it('should forward get, has, and set calls with the namespaced prefix', async () => {
230+
const namespacedStore = new NamespacedCacheStore(store, 'custom');
231+
await namespacedStore.set('hello', 'world');
232+
233+
expect(store.map.has('6:custom:hello')).toBeTrue();
234+
expect(await namespacedStore.has('hello')).toBeTrue();
235+
expect(await namespacedStore.get('hello')).toBe('world');
236+
});
237+
238+
it('should return this when the underlying store set is asynchronous', async () => {
239+
class AsyncStore implements CacheStore<string> {
240+
readonly map = new Map<string, string>();
241+
242+
get(key: string): Promise<string | undefined> {
243+
return Promise.resolve(this.map.get(key));
244+
}
245+
246+
has(key: string): Promise<boolean> {
247+
return Promise.resolve(this.map.has(key));
248+
}
249+
250+
async set(key: string, value: string): Promise<this> {
251+
this.map.set(key, value);
252+
253+
return this;
254+
}
255+
}
256+
257+
const asyncStore = new AsyncStore();
258+
const namespacedStore = new NamespacedCacheStore(asyncStore, 'async-ns');
259+
const setPromise = namespacedStore.set('foo', 'bar');
260+
261+
expect(setPromise instanceof Promise).toBeTrue();
262+
expect(await setPromise).toBe(namespacedStore);
263+
expect(asyncStore.map.get('8:async-ns:foo')).toBe('bar');
264+
expect(await namespacedStore.get('foo')).toBe('bar');
265+
expect(await namespacedStore.has('foo')).toBeTrue();
266+
});
161267
});

packages/angular/build/src/tools/esbuild/lmdb-cache-store.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import { RootDatabase, open } from 'lmdb';
10-
import { Cache, PersistentCacheStore } from './cache';
10+
import { Cache, NamespacedCacheStore, PersistentCacheStore } from './cache';
1111

1212
export class LmdbCacheStore implements PersistentCacheStore<unknown> {
1313
readonly #cacheFileUrl;
@@ -46,7 +46,7 @@ export class LmdbCacheStore implements PersistentCacheStore<unknown> {
4646
}
4747

4848
createCache<V = unknown>(namespace: string): Cache<V> {
49-
return new Cache(this, namespace);
49+
return new Cache<V>(new NamespacedCacheStore<V>(this, namespace));
5050
}
5151

5252
async close() {

packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { DatabaseSync, StatementSync } from 'node:sqlite';
1212
import { promisify } from 'node:util';
1313
import { deserialize, serialize } from 'node:v8';
1414
import { deflateRaw, inflateRawSync } from 'node:zlib';
15-
import { Cache, PersistentCacheStore } from './cache';
15+
import { Cache, NamespacedCacheStore, PersistentCacheStore } from './cache';
1616

1717
const deflateRawAsync = promisify(deflateRaw);
1818

@@ -315,7 +315,7 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {
315315
}
316316

317317
createCache<V = unknown>(namespace: string): Cache<V> {
318-
return new Cache(this, namespace);
318+
return new Cache<V>(new NamespacedCacheStore<V>(this, namespace));
319319
}
320320

321321
close(): void {

0 commit comments

Comments
 (0)