|
| 1 | +/* |
| 2 | + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one |
| 3 | + * or more contributor license agreements. Licensed under the Elastic License |
| 4 | + * 2.0; you may not use this file except in compliance with the Elastic License |
| 5 | + * 2.0. |
| 6 | + */ |
| 7 | + |
| 8 | +import type { ByteString, IFileSystem, FsStat } from 'just-bash'; |
| 9 | + |
| 10 | +const encoder = new TextEncoder(); |
| 11 | + |
| 12 | +const byteLengthOf = (content: string | Uint8Array): number => |
| 13 | + typeof content === 'string' ? encoder.encode(content).length : content.byteLength; |
| 14 | + |
| 15 | +const enospc = (op: string, path: string, limit: number) => |
| 16 | + new Error( |
| 17 | + `ENOSPC: workspace capacity exceeded — ${op} '${path}' would exceed the ${limit}-byte limit` |
| 18 | + ); |
| 19 | + |
| 20 | +/** |
| 21 | + * Caps the total content size of an underlying writable `IFileSystem`. Writes |
| 22 | + * that would push the aggregate above `maxBytes` are rejected with `ENOSPC`. |
| 23 | + * Reads and non-growing operations (rm, mkdir, chmod, ...) pass through. |
| 24 | + * |
| 25 | + * The current size is computed by walking the fs on each mutation. That's O(n) |
| 26 | + * per write; acceptable for the workspace's tens-to-hundreds of small files at |
| 27 | + * worst. Revisit (cache + invalidate) if walk cost becomes measurable. |
| 28 | + */ |
| 29 | +export class CapacityLimitedFs implements IFileSystem { |
| 30 | + private readonly inner: IFileSystem; |
| 31 | + private readonly maxBytes: number; |
| 32 | + |
| 33 | + constructor(inner: IFileSystem, maxBytes: number) { |
| 34 | + this.inner = inner; |
| 35 | + this.maxBytes = maxBytes; |
| 36 | + } |
| 37 | + |
| 38 | + // ---- mutation paths with capacity enforcement ---- |
| 39 | + |
| 40 | + async writeFile( |
| 41 | + path: string, |
| 42 | + content: string | Uint8Array, |
| 43 | + options?: Parameters<IFileSystem['writeFile']>[2] |
| 44 | + ): Promise<void> { |
| 45 | + const newBytes = byteLengthOf(content); |
| 46 | + const existingBytes = await this.tryGetFileSize(path); |
| 47 | + const projected = (await this.currentSize()) - existingBytes + newBytes; |
| 48 | + if (projected > this.maxBytes) throw enospc('writeFile', path, this.maxBytes); |
| 49 | + return this.inner.writeFile(path, content, options); |
| 50 | + } |
| 51 | + |
| 52 | + async appendFile( |
| 53 | + path: string, |
| 54 | + content: string | Uint8Array, |
| 55 | + options?: Parameters<IFileSystem['appendFile']>[2] |
| 56 | + ): Promise<void> { |
| 57 | + const addedBytes = byteLengthOf(content); |
| 58 | + const projected = (await this.currentSize()) + addedBytes; |
| 59 | + if (projected > this.maxBytes) throw enospc('appendFile', path, this.maxBytes); |
| 60 | + return this.inner.appendFile(path, content, options); |
| 61 | + } |
| 62 | + |
| 63 | + async cp(src: string, dest: string, options?: Parameters<IFileSystem['cp']>[2]): Promise<void> { |
| 64 | + const srcBytes = await this.subtreeSize(src); |
| 65 | + const existingDestBytes = await this.subtreeSize(dest); |
| 66 | + const projected = (await this.currentSize()) - existingDestBytes + srcBytes; |
| 67 | + if (projected > this.maxBytes) throw enospc('cp', dest, this.maxBytes); |
| 68 | + return this.inner.cp(src, dest, options); |
| 69 | + } |
| 70 | + |
| 71 | + // ---- pass-through (no growth or freeing) ---- |
| 72 | + |
| 73 | + async readFile(path: string, options?: Parameters<IFileSystem['readFile']>[1]): Promise<string> { |
| 74 | + return this.inner.readFile(path, options); |
| 75 | + } |
| 76 | + async readFileBuffer(path: string): Promise<Uint8Array> { |
| 77 | + return this.inner.readFileBuffer(path); |
| 78 | + } |
| 79 | + async readFileBytes(path: string): Promise<ByteString> { |
| 80 | + return this.inner.readFileBytes!(path); |
| 81 | + } |
| 82 | + async exists(path: string): Promise<boolean> { |
| 83 | + return this.inner.exists(path); |
| 84 | + } |
| 85 | + async stat(path: string): Promise<FsStat> { |
| 86 | + return this.inner.stat(path); |
| 87 | + } |
| 88 | + async lstat(path: string): Promise<FsStat> { |
| 89 | + return this.inner.lstat(path); |
| 90 | + } |
| 91 | + async readdir(path: string): Promise<string[]> { |
| 92 | + return this.inner.readdir(path); |
| 93 | + } |
| 94 | + async readdirWithFileTypes( |
| 95 | + path: string |
| 96 | + ): Promise< |
| 97 | + Array<{ name: string; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean }> |
| 98 | + > { |
| 99 | + return this.inner.readdirWithFileTypes!(path); |
| 100 | + } |
| 101 | + getAllPaths(): string[] { |
| 102 | + return this.inner.getAllPaths(); |
| 103 | + } |
| 104 | + resolvePath(base: string, path: string): string { |
| 105 | + return this.inner.resolvePath(base, path); |
| 106 | + } |
| 107 | + async realpath(path: string): Promise<string> { |
| 108 | + return this.inner.realpath(path); |
| 109 | + } |
| 110 | + async readlink(path: string): Promise<string> { |
| 111 | + return this.inner.readlink(path); |
| 112 | + } |
| 113 | + async mkdir(path: string, options?: Parameters<IFileSystem['mkdir']>[1]): Promise<void> { |
| 114 | + return this.inner.mkdir(path, options); |
| 115 | + } |
| 116 | + async rm(path: string, options?: Parameters<IFileSystem['rm']>[1]): Promise<void> { |
| 117 | + return this.inner.rm(path, options); |
| 118 | + } |
| 119 | + async mv(src: string, dest: string): Promise<void> { |
| 120 | + // Net-neutral within the same fs: bytes move from src to dest, total unchanged. |
| 121 | + return this.inner.mv(src, dest); |
| 122 | + } |
| 123 | + async chmod(path: string, mode: number): Promise<void> { |
| 124 | + return this.inner.chmod(path, mode); |
| 125 | + } |
| 126 | + async symlink(target: string, linkPath: string): Promise<void> { |
| 127 | + return this.inner.symlink(target, linkPath); |
| 128 | + } |
| 129 | + async link(existingPath: string, newPath: string): Promise<void> { |
| 130 | + return this.inner.link(existingPath, newPath); |
| 131 | + } |
| 132 | + async utimes(path: string, atime: Date, mtime: Date): Promise<void> { |
| 133 | + return this.inner.utimes(path, atime, mtime); |
| 134 | + } |
| 135 | + |
| 136 | + // ---- size helpers ---- |
| 137 | + |
| 138 | + /** Total bytes used by all files in the fs. Directories don't count. */ |
| 139 | + private async currentSize(): Promise<number> { |
| 140 | + return this.subtreeSize('/'); |
| 141 | + } |
| 142 | + |
| 143 | + /** Recursively sums file sizes under `path`. Returns 0 if `path` doesn't exist. */ |
| 144 | + private async subtreeSize(path: string): Promise<number> { |
| 145 | + if (!(await this.inner.exists(path))) return 0; |
| 146 | + try { |
| 147 | + const stat = await this.inner.stat(path); |
| 148 | + if (stat.isFile) return stat.size; |
| 149 | + } catch { |
| 150 | + return 0; |
| 151 | + } |
| 152 | + let total = 0; |
| 153 | + const walk = async (dir: string): Promise<void> => { |
| 154 | + const entries = await this.inner.readdirWithFileTypes!(dir); |
| 155 | + for (const entry of entries) { |
| 156 | + const child = dir === '/' ? `/${entry.name}` : `${dir}/${entry.name}`; |
| 157 | + if (entry.isFile) { |
| 158 | + const s = await this.inner.stat(child); |
| 159 | + total += s.size; |
| 160 | + } else if (entry.isDirectory) { |
| 161 | + await walk(child); |
| 162 | + } |
| 163 | + } |
| 164 | + }; |
| 165 | + await walk(path); |
| 166 | + return total; |
| 167 | + } |
| 168 | + |
| 169 | + private async tryGetFileSize(path: string): Promise<number> { |
| 170 | + if (!(await this.inner.exists(path))) return 0; |
| 171 | + try { |
| 172 | + const stat = await this.inner.stat(path); |
| 173 | + return stat.isFile ? stat.size : 0; |
| 174 | + } catch { |
| 175 | + return 0; |
| 176 | + } |
| 177 | + } |
| 178 | +} |
0 commit comments