assafvayner HF Staff commited on
Commit
0d57d79
·
1 Parent(s): ee86043

feat(storage): add BucketStore interface, LocalFsStore and PrefixedStore

Browse files
src/lib/server/storage/local-fs-store.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { appendFile, copyFile, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, relative, sep } from 'node:path';
3
+ import { assertValidPath, type BucketStore, type StoreEntry } from './store';
4
+
5
+ function isEnoent(e: unknown): boolean {
6
+ return typeof e === 'object' && e !== null && (e as { code?: string }).code === 'ENOENT';
7
+ }
8
+
9
+ /** Directory-backed store for local development and tests. Writes are atomic (temp file + rename). */
10
+ export class LocalFsStore implements BucketStore {
11
+ constructor(private readonly root: string) {}
12
+
13
+ private abs(path: string): string {
14
+ assertValidPath(path);
15
+ return join(this.root, ...path.split('/'));
16
+ }
17
+
18
+ async get(path: string): Promise<Uint8Array | null> {
19
+ try {
20
+ return new Uint8Array(await readFile(this.abs(path)));
21
+ } catch (e) {
22
+ if (isEnoent(e)) return null;
23
+ throw e;
24
+ }
25
+ }
26
+
27
+ async put(path: string, bytes: Uint8Array, contentType?: string): Promise<void> {
28
+ const target = this.abs(path);
29
+ await mkdir(dirname(target), { recursive: true });
30
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
31
+ await writeFile(tmp, bytes);
32
+ await rename(tmp, target);
33
+ }
34
+
35
+ async append(path: string, bytes: Uint8Array): Promise<void> {
36
+ const target = this.abs(path);
37
+ await mkdir(dirname(target), { recursive: true });
38
+ await appendFile(target, bytes);
39
+ }
40
+
41
+ async list(prefix: string): Promise<StoreEntry[]> {
42
+ const out: StoreEntry[] = [];
43
+ const walk = async (dir: string): Promise<void> => {
44
+ let entries;
45
+ try {
46
+ entries = await readdir(dir, { withFileTypes: true });
47
+ } catch (e) {
48
+ if (isEnoent(e)) return;
49
+ throw e;
50
+ }
51
+ for (const ent of entries) {
52
+ const full = join(dir, ent.name);
53
+ if (ent.isDirectory()) {
54
+ await walk(full);
55
+ } else if (ent.isFile() && !ent.name.endsWith('.tmp')) {
56
+ const rel = relative(this.root, full).split(sep).join('/');
57
+ if (rel.startsWith(prefix)) {
58
+ const s = await stat(full);
59
+ out.push({ path: rel, size: s.size, uploadedAt: s.mtime });
60
+ }
61
+ }
62
+ }
63
+ };
64
+ await walk(this.root);
65
+ return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
66
+ }
67
+
68
+ async delete(paths: string[]): Promise<void> {
69
+ for (const p of paths) await rm(this.abs(p), { force: true });
70
+ }
71
+
72
+ async copy(src: string, dst: string): Promise<void> {
73
+ const target = this.abs(dst);
74
+ await mkdir(dirname(target), { recursive: true });
75
+ await copyFile(this.abs(src), target);
76
+ }
77
+ }
src/lib/server/storage/prefixed-store.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { assertValidPath, type BucketStore, type StoreEntry } from './store';
2
+
3
+ /**
4
+ * Confines another store to `<prefix>/`, so several apps (or test runs) can share one
5
+ * bucket under different directories. Paths seen by callers never include the prefix.
6
+ */
7
+ export class PrefixedStore implements BucketStore {
8
+ private readonly prefix: string;
9
+
10
+ constructor(
11
+ private readonly inner: BucketStore,
12
+ prefix: string
13
+ ) {
14
+ const trimmed = prefix.replace(/^\/+|\/+$/g, '');
15
+ assertValidPath(trimmed);
16
+ this.prefix = `${trimmed}/`;
17
+ }
18
+
19
+ private full(path: string): string {
20
+ assertValidPath(path);
21
+ return this.prefix + path;
22
+ }
23
+
24
+ get(path: string): Promise<Uint8Array | null> {
25
+ return this.inner.get(this.full(path));
26
+ }
27
+ put(path: string, bytes: Uint8Array, contentType?: string): Promise<void> {
28
+ return this.inner.put(this.full(path), bytes, contentType);
29
+ }
30
+ append(path: string, bytes: Uint8Array): Promise<void> {
31
+ return this.inner.append(this.full(path), bytes);
32
+ }
33
+ async list(prefix: string): Promise<StoreEntry[]> {
34
+ const entries = await this.inner.list(this.prefix + prefix);
35
+ return entries.map((e) => ({ ...e, path: e.path.slice(this.prefix.length) }));
36
+ }
37
+ delete(paths: string[]): Promise<void> {
38
+ return this.inner.delete(paths.map((p) => this.full(p)));
39
+ }
40
+ copy(src: string, dst: string): Promise<void> {
41
+ return this.inner.copy(this.full(src), this.full(dst));
42
+ }
43
+
44
+ /** Deletes every object under this store's prefix. Returns how many were deleted. */
45
+ async wipe(): Promise<number> {
46
+ const entries = await this.inner.list(this.prefix);
47
+ await this.inner.delete(entries.map((e) => e.path));
48
+ return entries.length;
49
+ }
50
+ }
src/lib/server/storage/store.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface StoreEntry {
2
+ path: string;
3
+ size: number;
4
+ uploadedAt?: Date;
5
+ }
6
+
7
+ /**
8
+ * Minimal object-store abstraction. Paths are bucket-relative, use `/` separators,
9
+ * and never start with `/`.
10
+ */
11
+ export interface BucketStore {
12
+ /** Resolves null when the object does not exist. */
13
+ get(path: string): Promise<Uint8Array | null>;
14
+ /** Creates or overwrites. */
15
+ put(path: string, bytes: Uint8Array, contentType?: string): Promise<void>;
16
+ /** Appends bytes to an existing object, creating it if missing. */
17
+ append(path: string, bytes: Uint8Array): Promise<void>;
18
+ /** Lists files whose path starts with `prefix`, sorted by path. */
19
+ list(prefix: string): Promise<StoreEntry[]>;
20
+ /** Deletes objects; missing ones are ignored. */
21
+ delete(paths: string[]): Promise<void>;
22
+ /** Server-side copy. Rejects if `src` does not exist. */
23
+ copy(src: string, dst: string): Promise<void>;
24
+ }
25
+
26
+ const SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
27
+
28
+ export function assertValidPath(path: string): void {
29
+ if (path.length === 0 || path.length > 512) throw new Error(`Invalid path: ${path}`);
30
+ if (path.startsWith('/') || path.endsWith('/')) throw new Error(`Invalid path: ${path}`);
31
+ for (const seg of path.split('/')) {
32
+ if (seg === '' || seg === '.' || seg === '..' || !SEGMENT_RE.test(seg)) throw new Error(`Invalid path: ${path}`);
33
+ }
34
+ }
35
+
36
+ export const encodeText = (s: string): Uint8Array => new TextEncoder().encode(s);
37
+ export const decodeText = (b: Uint8Array): string => new TextDecoder().decode(b);
tests/unit/storage/local-fs-store.test.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { LocalFsStore } from '../../../src/lib/server/storage/local-fs-store';
6
+ import { assertValidPath } from '../../../src/lib/server/storage/store';
7
+
8
+ const enc = (s: string) => new TextEncoder().encode(s);
9
+ const dec = (b: Uint8Array | null) => (b ? new TextDecoder().decode(b) : null);
10
+
11
+ describe('LocalFsStore', () => {
12
+ let dir: string;
13
+ let store: LocalFsStore;
14
+ beforeEach(async () => {
15
+ dir = await mkdtemp(join(tmpdir(), 'splitwise-store-'));
16
+ store = new LocalFsStore(dir);
17
+ });
18
+ afterEach(() => rm(dir, { recursive: true, force: true }));
19
+
20
+ it('returns null for missing files', async () => {
21
+ expect(await store.get('nope.json')).toBeNull();
22
+ });
23
+
24
+ it('puts, gets and overwrites', async () => {
25
+ await store.put('a/b.json', enc('one'), 'application/json');
26
+ expect(dec(await store.get('a/b.json'))).toBe('one');
27
+ await store.put('a/b.json', enc('two'));
28
+ expect(dec(await store.get('a/b.json'))).toBe('two');
29
+ });
30
+
31
+ it('appends, creating the file when missing', async () => {
32
+ await store.append('log.jsonl', enc('1\n'));
33
+ await store.append('log.jsonl', enc('2\n'));
34
+ expect(dec(await store.get('log.jsonl'))).toBe('1\n2\n');
35
+ });
36
+
37
+ it('lists files under a prefix, sorted by path', async () => {
38
+ await store.put('events/b.json', enc('b'));
39
+ await store.put('events/a.json', enc('a'));
40
+ await store.put('other.json', enc('o'));
41
+ const listed = await store.list('events/');
42
+ expect(listed.map((e) => e.path)).toEqual(['events/a.json', 'events/b.json']);
43
+ expect(listed[0].size).toBe(1);
44
+ expect(await store.list('missing/')).toEqual([]);
45
+ });
46
+
47
+ it('deletes (ignoring missing) and copies', async () => {
48
+ await store.put('x.txt', enc('x'));
49
+ await store.copy('x.txt', 'backups/x.txt');
50
+ expect(dec(await store.get('backups/x.txt'))).toBe('x');
51
+ await store.delete(['x.txt', 'not-there.txt']);
52
+ expect(await store.get('x.txt')).toBeNull();
53
+ await expect(store.copy('gone.txt', 'y.txt')).rejects.toThrow();
54
+ });
55
+
56
+ it('rejects unsafe paths', () => {
57
+ expect(() => assertValidPath('../etc/passwd')).toThrow();
58
+ expect(() => assertValidPath('/abs')).toThrow();
59
+ expect(() => assertValidPath('a//b')).toThrow();
60
+ expect(() => assertValidPath('ok/path.json')).not.toThrow();
61
+ });
62
+ });
tests/unit/storage/prefixed-store.test.ts ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { LocalFsStore } from '../../../src/lib/server/storage/local-fs-store';
6
+ import { PrefixedStore } from '../../../src/lib/server/storage/prefixed-store';
7
+ import { decodeText, encodeText } from '../../../src/lib/server/storage/store';
8
+
9
+ describe('PrefixedStore', () => {
10
+ let dir: string;
11
+ let inner: LocalFsStore;
12
+ let a: PrefixedStore;
13
+ let b: PrefixedStore;
14
+ beforeEach(async () => {
15
+ dir = await mkdtemp(join(tmpdir(), 'splitwise-prefixed-'));
16
+ inner = new LocalFsStore(dir);
17
+ a = new PrefixedStore(inner, 'tenants/a');
18
+ b = new PrefixedStore(inner, '/tenants/b/');
19
+ });
20
+ afterEach(() => rm(dir, { recursive: true, force: true }));
21
+
22
+ it('stores objects under the prefix and reads them back', async () => {
23
+ await a.put('users.json', encodeText('A'), 'application/json');
24
+ expect(decodeText((await a.get('users.json'))!)).toBe('A');
25
+ expect(decodeText((await inner.get('tenants/a/users.json'))!)).toBe('A');
26
+ expect(await b.get('users.json')).toBeNull();
27
+ });
28
+
29
+ it('lists with the prefix stripped and never leaks other prefixes', async () => {
30
+ await a.put('events/1.json', encodeText('1'));
31
+ await a.put('events/2.json', encodeText('2'));
32
+ await b.put('events/9.json', encodeText('9'));
33
+ expect((await a.list('events/')).map((e) => e.path)).toEqual(['events/1.json', 'events/2.json']);
34
+ expect((await a.list('')).map((e) => e.path)).toEqual(['events/1.json', 'events/2.json']);
35
+ expect((await b.list('events/')).map((e) => e.path)).toEqual(['events/9.json']);
36
+ });
37
+
38
+ it('appends, copies and deletes inside the prefix', async () => {
39
+ await a.append('ledger.jsonl', encodeText('x\n'));
40
+ await a.append('ledger.jsonl', encodeText('y\n'));
41
+ await a.copy('ledger.jsonl', 'backups/l.jsonl');
42
+ expect(decodeText((await inner.get('tenants/a/backups/l.jsonl'))!)).toBe('x\ny\n');
43
+ await a.delete(['ledger.jsonl', 'missing.txt']);
44
+ expect(await a.get('ledger.jsonl')).toBeNull();
45
+ });
46
+
47
+ it('wipe removes only its own subtree', async () => {
48
+ await a.put('x.txt', encodeText('x'));
49
+ await a.put('deep/y.txt', encodeText('y'));
50
+ await b.put('z.txt', encodeText('z'));
51
+ expect(await a.wipe()).toBe(2);
52
+ expect(await a.list('')).toEqual([]);
53
+ expect(await b.get('z.txt')).not.toBeNull();
54
+ });
55
+
56
+ it('rejects invalid prefixes', () => {
57
+ expect(() => new PrefixedStore(inner, '')).toThrow();
58
+ expect(() => new PrefixedStore(inner, '../x')).toThrow();
59
+ });
60
+ });