@superfunctions/storage-local 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter.d.ts +5 -0
- package/dist/adapter.js +246 -0
- package/dist/adapter.test.d.ts +1 -0
- package/dist/adapter.test.js +257 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +39 -0
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
2
|
+
import { mkdir, lstat, stat, unlink, realpath } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, normalize, resolve, isAbsolute, relative } from 'node:path';
|
|
4
|
+
import { createNotFoundError } from '@superfunctions/storage/internal/errors';
|
|
5
|
+
export function createLocalStorageAdapter(config) {
|
|
6
|
+
const { rootDir } = config;
|
|
7
|
+
const normalizedRoot = resolve(normalize(rootDir));
|
|
8
|
+
let resolvedRoot = null;
|
|
9
|
+
async function getResolvedRoot() {
|
|
10
|
+
if (resolvedRoot === null) {
|
|
11
|
+
try {
|
|
12
|
+
resolvedRoot = await realpath(normalizedRoot);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
resolvedRoot = normalizedRoot;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return resolvedRoot;
|
|
19
|
+
}
|
|
20
|
+
const capabilities = {
|
|
21
|
+
signedUploadUrls: false,
|
|
22
|
+
signedDownloadUrls: false,
|
|
23
|
+
multipart: false,
|
|
24
|
+
proxyStreamingUpload: true,
|
|
25
|
+
proxyStreamingDownload: true,
|
|
26
|
+
};
|
|
27
|
+
function isWithinRoot(root, candidate) {
|
|
28
|
+
const rel = relative(root, candidate);
|
|
29
|
+
return rel !== '..' && !rel.startsWith(`..${'/'}`) && !rel.startsWith(`..${'\\'}`) && !isAbsolute(rel);
|
|
30
|
+
}
|
|
31
|
+
function validateKey(key) {
|
|
32
|
+
if (!key || key.length === 0) {
|
|
33
|
+
throw new Error('Invalid key: key cannot be empty');
|
|
34
|
+
}
|
|
35
|
+
if (isAbsolute(key)) {
|
|
36
|
+
throw new Error('Invalid key: absolute paths are not allowed');
|
|
37
|
+
}
|
|
38
|
+
const normalizedKey = normalize(key);
|
|
39
|
+
if (normalizedKey.startsWith('..') || normalizedKey.includes('/..') || normalizedKey.includes('\\..')) {
|
|
40
|
+
throw new Error('Invalid key: path traversal detected');
|
|
41
|
+
}
|
|
42
|
+
const forbidden = ['\0', ':', '*', '?', '"', '<', '>', '|'];
|
|
43
|
+
for (const char of forbidden) {
|
|
44
|
+
if (key.includes(char)) {
|
|
45
|
+
throw new Error(`Invalid key: forbidden character '${char}'`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function resolvePath(key) {
|
|
50
|
+
validateKey(key);
|
|
51
|
+
const resolved = join(normalizedRoot, normalize(key));
|
|
52
|
+
if (!isWithinRoot(normalizedRoot, resolved)) {
|
|
53
|
+
throw new Error('Invalid key: path traversal detected');
|
|
54
|
+
}
|
|
55
|
+
return resolved;
|
|
56
|
+
}
|
|
57
|
+
async function validateResolvedPath(filePath) {
|
|
58
|
+
try {
|
|
59
|
+
const real = await realpath(filePath);
|
|
60
|
+
const root = await getResolvedRoot();
|
|
61
|
+
if (!isWithinRoot(root, real)) {
|
|
62
|
+
throw new Error('Invalid key: path traversal detected via symlink');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
name: 'local',
|
|
74
|
+
capabilities,
|
|
75
|
+
async statObject(input) {
|
|
76
|
+
const filePath = resolvePath(input.key);
|
|
77
|
+
try {
|
|
78
|
+
await validateResolvedPath(filePath);
|
|
79
|
+
const stats = await stat(filePath);
|
|
80
|
+
if (!stats.isFile()) {
|
|
81
|
+
throw createNotFoundError();
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
key: input.key,
|
|
85
|
+
size: stats.size,
|
|
86
|
+
lastModifiedAt: stats.mtime.toISOString(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
|
|
91
|
+
throw createNotFoundError();
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
async deleteObject(input) {
|
|
97
|
+
const filePath = resolvePath(input.key);
|
|
98
|
+
try {
|
|
99
|
+
await validateResolvedPath(filePath);
|
|
100
|
+
await unlink(filePath);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
async openUploadStream(input) {
|
|
110
|
+
const filePath = resolvePath(input.key);
|
|
111
|
+
const dir = dirname(filePath);
|
|
112
|
+
await mkdir(dir, { recursive: true });
|
|
113
|
+
const dirRealPath = await realpath(dir);
|
|
114
|
+
const root = await getResolvedRoot();
|
|
115
|
+
if (!isWithinRoot(root, dirRealPath)) {
|
|
116
|
+
throw new Error('Invalid key: path traversal detected via symlink');
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const fileStats = await lstat(filePath);
|
|
120
|
+
if (fileStats.isSymbolicLink()) {
|
|
121
|
+
throw new Error('Invalid key: upload target cannot be a symlink');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
if (!(err instanceof Error && 'code' in err && err.code === 'ENOENT')) {
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const nodeStream = createWriteStream(filePath);
|
|
130
|
+
let streamError = null;
|
|
131
|
+
nodeStream.on('error', (err) => {
|
|
132
|
+
streamError = err;
|
|
133
|
+
});
|
|
134
|
+
return new WritableStream({
|
|
135
|
+
write(chunk) {
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
if (streamError) {
|
|
138
|
+
reject(streamError);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const buffer = chunk instanceof Uint8Array ? Buffer.from(chunk) : chunk;
|
|
142
|
+
const ok = nodeStream.write(buffer);
|
|
143
|
+
if (ok) {
|
|
144
|
+
resolve();
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const cleanup = () => {
|
|
148
|
+
nodeStream.off('drain', onDrain);
|
|
149
|
+
nodeStream.off('error', onError);
|
|
150
|
+
};
|
|
151
|
+
const onDrain = () => {
|
|
152
|
+
cleanup();
|
|
153
|
+
resolve();
|
|
154
|
+
};
|
|
155
|
+
const onError = (err) => {
|
|
156
|
+
cleanup();
|
|
157
|
+
reject(err);
|
|
158
|
+
};
|
|
159
|
+
nodeStream.once('drain', onDrain);
|
|
160
|
+
nodeStream.once('error', onError);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
close() {
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
if (streamError) {
|
|
167
|
+
reject(streamError);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const cleanup = () => {
|
|
171
|
+
nodeStream.off('finish', onFinish);
|
|
172
|
+
nodeStream.off('error', onError);
|
|
173
|
+
};
|
|
174
|
+
const onFinish = () => {
|
|
175
|
+
cleanup();
|
|
176
|
+
resolve(undefined);
|
|
177
|
+
};
|
|
178
|
+
const onError = (err) => {
|
|
179
|
+
cleanup();
|
|
180
|
+
reject(err);
|
|
181
|
+
};
|
|
182
|
+
nodeStream.once('finish', onFinish);
|
|
183
|
+
nodeStream.once('error', onError);
|
|
184
|
+
nodeStream.end();
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
abort(reason) {
|
|
188
|
+
nodeStream.destroy(reason instanceof Error ? reason : new Error(String(reason)));
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
async openDownloadStream(input) {
|
|
193
|
+
const filePath = resolvePath(input.key);
|
|
194
|
+
try {
|
|
195
|
+
await validateResolvedPath(filePath);
|
|
196
|
+
const stats = await stat(filePath);
|
|
197
|
+
if (!stats.isFile()) {
|
|
198
|
+
throw createNotFoundError();
|
|
199
|
+
}
|
|
200
|
+
if (input.range) {
|
|
201
|
+
if (input.range.start < 0) {
|
|
202
|
+
throw new Error('Invalid range: start must be non-negative');
|
|
203
|
+
}
|
|
204
|
+
if (input.range.endInclusive < input.range.start) {
|
|
205
|
+
throw new Error('Invalid range: end must be >= start');
|
|
206
|
+
}
|
|
207
|
+
if (input.range.start >= stats.size) {
|
|
208
|
+
throw new Error('Invalid range: start beyond file size');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
|
|
214
|
+
throw createNotFoundError();
|
|
215
|
+
}
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
const options = input.range
|
|
219
|
+
? { start: input.range.start, end: input.range.endInclusive }
|
|
220
|
+
: undefined;
|
|
221
|
+
const nodeStream = createReadStream(filePath, options);
|
|
222
|
+
nodeStream.pause();
|
|
223
|
+
return new ReadableStream({
|
|
224
|
+
start(controller) {
|
|
225
|
+
nodeStream.on('data', (chunk) => {
|
|
226
|
+
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
227
|
+
controller.enqueue(new Uint8Array(buffer));
|
|
228
|
+
nodeStream.pause();
|
|
229
|
+
});
|
|
230
|
+
nodeStream.on('end', () => {
|
|
231
|
+
controller.close();
|
|
232
|
+
});
|
|
233
|
+
nodeStream.on('error', (err) => {
|
|
234
|
+
controller.error(err);
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
pull() {
|
|
238
|
+
nodeStream.resume();
|
|
239
|
+
},
|
|
240
|
+
cancel() {
|
|
241
|
+
nodeStream.destroy();
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdir, rm, writeFile, readFile, symlink } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { createLocalStorageAdapter } from './adapter.js';
|
|
6
|
+
import { runConformanceTests, validateCapabilities } from '@superfunctions/storage';
|
|
7
|
+
describe('@superfunctions/storage-local', () => {
|
|
8
|
+
let testDir;
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
testDir = join(tmpdir(), `storage-local-test-${Date.now()}`);
|
|
11
|
+
await mkdir(testDir, { recursive: true });
|
|
12
|
+
});
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await rm(testDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
describe('Adapter instantiation (STORAGE-001)', () => {
|
|
17
|
+
it('should create adapter with correct name', () => {
|
|
18
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
19
|
+
expect(adapter.name).toBe('local');
|
|
20
|
+
});
|
|
21
|
+
it('should be stateless - factory returns immutable instance', () => {
|
|
22
|
+
const adapter1 = createLocalStorageAdapter({ rootDir: testDir });
|
|
23
|
+
const adapter2 = createLocalStorageAdapter({ rootDir: testDir });
|
|
24
|
+
expect(adapter1).not.toBe(adapter2);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe('Capabilities (STORAGE-002)', () => {
|
|
28
|
+
it('should declare accurate capabilities for local storage', () => {
|
|
29
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
30
|
+
expect(adapter.capabilities).toEqual({
|
|
31
|
+
signedUploadUrls: false,
|
|
32
|
+
signedDownloadUrls: false,
|
|
33
|
+
multipart: false,
|
|
34
|
+
proxyStreamingUpload: true,
|
|
35
|
+
proxyStreamingDownload: true,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
it('should pass capability validation', () => {
|
|
39
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
40
|
+
const result = validateCapabilities(adapter.capabilities);
|
|
41
|
+
expect(result.ok).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe('Conformance tests', () => {
|
|
45
|
+
it('should pass all conformance tests', async () => {
|
|
46
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
47
|
+
const result = await runConformanceTests(adapter);
|
|
48
|
+
expect(result.ok).toBe(true);
|
|
49
|
+
expect(result.failed).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe('statObject (STORAGE-003)', () => {
|
|
53
|
+
it('should return file stats for existing file', async () => {
|
|
54
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
55
|
+
const testFile = join(testDir, 'test.txt');
|
|
56
|
+
await writeFile(testFile, 'hello world');
|
|
57
|
+
const stat = await adapter.statObject({ key: 'test.txt' });
|
|
58
|
+
expect(stat.key).toBe('test.txt');
|
|
59
|
+
expect(stat.size).toBe(11);
|
|
60
|
+
expect(stat.lastModifiedAt).toBeDefined();
|
|
61
|
+
});
|
|
62
|
+
it('should throw STORAGE_NOT_FOUND for missing file', async () => {
|
|
63
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
64
|
+
await expect(adapter.statObject({ key: 'missing.txt' })).rejects.toMatchObject({
|
|
65
|
+
code: 'STORAGE_NOT_FOUND',
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
it('should throw STORAGE_NOT_FOUND for directories', async () => {
|
|
69
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
70
|
+
await mkdir(join(testDir, 'subdir'));
|
|
71
|
+
await expect(adapter.statObject({ key: 'subdir' })).rejects.toMatchObject({
|
|
72
|
+
code: 'STORAGE_NOT_FOUND',
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe('deleteObject (STORAGE-003)', () => {
|
|
77
|
+
it('should delete existing file', async () => {
|
|
78
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
79
|
+
const testFile = join(testDir, 'test.txt');
|
|
80
|
+
await writeFile(testFile, 'hello');
|
|
81
|
+
await adapter.deleteObject({ key: 'test.txt' });
|
|
82
|
+
await expect(adapter.statObject({ key: 'test.txt' })).rejects.toMatchObject({
|
|
83
|
+
code: 'STORAGE_NOT_FOUND',
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
it('should be idempotent - not throw for missing file', async () => {
|
|
87
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
88
|
+
await expect(adapter.deleteObject({ key: 'missing.txt' })).resolves.toBeUndefined();
|
|
89
|
+
await expect(adapter.deleteObject({ key: 'missing.txt' })).resolves.toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('openUploadStream', () => {
|
|
93
|
+
it('should write data to file', async () => {
|
|
94
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
95
|
+
const stream = await adapter.openUploadStream({ key: 'upload.txt' });
|
|
96
|
+
const writer = stream.getWriter();
|
|
97
|
+
await writer.write(new TextEncoder().encode('hello '));
|
|
98
|
+
await writer.write(new TextEncoder().encode('world'));
|
|
99
|
+
await writer.close();
|
|
100
|
+
const content = await readFile(join(testDir, 'upload.txt'), 'utf-8');
|
|
101
|
+
expect(content).toBe('hello world');
|
|
102
|
+
});
|
|
103
|
+
it('should create nested directories', async () => {
|
|
104
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
105
|
+
const stream = await adapter.openUploadStream({ key: 'a/b/c/file.txt' });
|
|
106
|
+
const writer = stream.getWriter();
|
|
107
|
+
await writer.write(new TextEncoder().encode('nested'));
|
|
108
|
+
await writer.close();
|
|
109
|
+
const content = await readFile(join(testDir, 'a/b/c/file.txt'), 'utf-8');
|
|
110
|
+
expect(content).toBe('nested');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('openDownloadStream', () => {
|
|
114
|
+
it('should read data from file', async () => {
|
|
115
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
116
|
+
await writeFile(join(testDir, 'download.txt'), 'hello world');
|
|
117
|
+
const stream = await adapter.openDownloadStream({ key: 'download.txt' });
|
|
118
|
+
const reader = stream.getReader();
|
|
119
|
+
const chunks = [];
|
|
120
|
+
while (true) {
|
|
121
|
+
const { done, value } = await reader.read();
|
|
122
|
+
if (done)
|
|
123
|
+
break;
|
|
124
|
+
chunks.push(value);
|
|
125
|
+
}
|
|
126
|
+
const content = new TextDecoder().decode(Buffer.concat(chunks));
|
|
127
|
+
expect(content).toBe('hello world');
|
|
128
|
+
});
|
|
129
|
+
it('should support range requests - middle of file', async () => {
|
|
130
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
131
|
+
await writeFile(join(testDir, 'range.txt'), '0123456789');
|
|
132
|
+
const stream = await adapter.openDownloadStream({
|
|
133
|
+
key: 'range.txt',
|
|
134
|
+
range: { start: 2, endInclusive: 5 },
|
|
135
|
+
});
|
|
136
|
+
const reader = stream.getReader();
|
|
137
|
+
const chunks = [];
|
|
138
|
+
while (true) {
|
|
139
|
+
const { done, value } = await reader.read();
|
|
140
|
+
if (done)
|
|
141
|
+
break;
|
|
142
|
+
chunks.push(value);
|
|
143
|
+
}
|
|
144
|
+
const content = new TextDecoder().decode(Buffer.concat(chunks));
|
|
145
|
+
expect(content).toBe('2345');
|
|
146
|
+
});
|
|
147
|
+
it('should support range requests - from start', async () => {
|
|
148
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
149
|
+
await writeFile(join(testDir, 'range.txt'), 'ABCDEFGHIJ');
|
|
150
|
+
const stream = await adapter.openDownloadStream({
|
|
151
|
+
key: 'range.txt',
|
|
152
|
+
range: { start: 0, endInclusive: 4 },
|
|
153
|
+
});
|
|
154
|
+
const reader = stream.getReader();
|
|
155
|
+
const chunks = [];
|
|
156
|
+
while (true) {
|
|
157
|
+
const { done, value } = await reader.read();
|
|
158
|
+
if (done)
|
|
159
|
+
break;
|
|
160
|
+
chunks.push(value);
|
|
161
|
+
}
|
|
162
|
+
const content = new TextDecoder().decode(Buffer.concat(chunks));
|
|
163
|
+
expect(content).toBe('ABCDE');
|
|
164
|
+
});
|
|
165
|
+
it('should support range requests - to end', async () => {
|
|
166
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
167
|
+
await writeFile(join(testDir, 'range.txt'), 'ABCDEFGHIJ');
|
|
168
|
+
const stream = await adapter.openDownloadStream({
|
|
169
|
+
key: 'range.txt',
|
|
170
|
+
range: { start: 5, endInclusive: 9 },
|
|
171
|
+
});
|
|
172
|
+
const reader = stream.getReader();
|
|
173
|
+
const chunks = [];
|
|
174
|
+
while (true) {
|
|
175
|
+
const { done, value } = await reader.read();
|
|
176
|
+
if (done)
|
|
177
|
+
break;
|
|
178
|
+
chunks.push(value);
|
|
179
|
+
}
|
|
180
|
+
const content = new TextDecoder().decode(Buffer.concat(chunks));
|
|
181
|
+
expect(content).toBe('FGHIJ');
|
|
182
|
+
});
|
|
183
|
+
it('should throw STORAGE_NOT_FOUND for missing file', async () => {
|
|
184
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
185
|
+
await expect(adapter.openDownloadStream({ key: 'missing.txt' })).rejects.toMatchObject({
|
|
186
|
+
code: 'STORAGE_NOT_FOUND',
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
it('should reject invalid range - negative start', async () => {
|
|
190
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
191
|
+
await writeFile(join(testDir, 'range.txt'), '0123456789');
|
|
192
|
+
await expect(adapter.openDownloadStream({
|
|
193
|
+
key: 'range.txt',
|
|
194
|
+
range: { start: -1, endInclusive: 5 },
|
|
195
|
+
})).rejects.toThrow('Invalid range');
|
|
196
|
+
});
|
|
197
|
+
it('should reject invalid range - end before start', async () => {
|
|
198
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
199
|
+
await writeFile(join(testDir, 'range.txt'), '0123456789');
|
|
200
|
+
await expect(adapter.openDownloadStream({
|
|
201
|
+
key: 'range.txt',
|
|
202
|
+
range: { start: 5, endInclusive: 2 },
|
|
203
|
+
})).rejects.toThrow('Invalid range');
|
|
204
|
+
});
|
|
205
|
+
it('should reject invalid range - start beyond file size', async () => {
|
|
206
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
207
|
+
await writeFile(join(testDir, 'range.txt'), '0123456789');
|
|
208
|
+
await expect(adapter.openDownloadStream({
|
|
209
|
+
key: 'range.txt',
|
|
210
|
+
range: { start: 100, endInclusive: 200 },
|
|
211
|
+
})).rejects.toThrow('Invalid range');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
describe('Security - Path traversal prevention', () => {
|
|
215
|
+
it('should prevent path traversal with ../', async () => {
|
|
216
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
217
|
+
await expect(adapter.statObject({ key: '../../../etc/passwd' })).rejects.toThrow('path traversal');
|
|
218
|
+
});
|
|
219
|
+
it('should prevent path traversal with ..\\', async () => {
|
|
220
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
221
|
+
await expect(adapter.statObject({ key: '..\\..\\etc\\passwd' })).rejects.toThrow('path traversal');
|
|
222
|
+
});
|
|
223
|
+
it('should prevent absolute paths', async () => {
|
|
224
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
225
|
+
await expect(adapter.statObject({ key: '/etc/passwd' })).rejects.toThrow('absolute paths');
|
|
226
|
+
});
|
|
227
|
+
it('should prevent empty keys', async () => {
|
|
228
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
229
|
+
await expect(adapter.statObject({ key: '' })).rejects.toThrow('cannot be empty');
|
|
230
|
+
});
|
|
231
|
+
it('should prevent null bytes in key', async () => {
|
|
232
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
233
|
+
await expect(adapter.statObject({ key: 'test\0.txt' })).rejects.toThrow('forbidden character');
|
|
234
|
+
});
|
|
235
|
+
it('should prevent traversal via symlink', async () => {
|
|
236
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
237
|
+
const outsideDir = join(tmpdir(), `outside-${Date.now()}`);
|
|
238
|
+
await mkdir(outsideDir, { recursive: true });
|
|
239
|
+
await writeFile(join(outsideDir, 'secret.txt'), 'secret data');
|
|
240
|
+
try {
|
|
241
|
+
await symlink(outsideDir, join(testDir, 'link'));
|
|
242
|
+
await expect(adapter.statObject({ key: 'link/secret.txt' })).rejects.toThrow('path traversal');
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await rm(outsideDir, { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
it('should allow safe nested paths', async () => {
|
|
249
|
+
const adapter = createLocalStorageAdapter({ rootDir: testDir });
|
|
250
|
+
const nestedPath = join(testDir, 'a', 'b', 'c');
|
|
251
|
+
await mkdir(nestedPath, { recursive: true });
|
|
252
|
+
await writeFile(join(nestedPath, 'file.txt'), 'content');
|
|
253
|
+
const stat = await adapter.statObject({ key: 'a/b/c/file.txt' });
|
|
254
|
+
expect(stat.key).toBe('a/b/c/file.txt');
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createLocalStorageAdapter, createLocalStorageAdapter as createLocalStorage } from './adapter.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@superfunctions/storage-local",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local filesystem storage adapter for @superfunctions/storage",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": ["dist"],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"test": "vitest",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"clean": "rm -rf dist"
|
|
20
|
+
},
|
|
21
|
+
"keywords": ["storage", "local", "filesystem", "superfunctions"],
|
|
22
|
+
"author": "21n",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"bugs": { "url": "https://github.com/21nCo/super-functions/issues" },
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/21nCo/super-functions.git",
|
|
28
|
+
"directory": "packages/storage-local"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": { "access": "public" },
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@superfunctions/storage": "0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^22.0.0",
|
|
36
|
+
"typescript": "^5.6.0",
|
|
37
|
+
"vitest": "^3.2.4"
|
|
38
|
+
}
|
|
39
|
+
}
|