@sasjs/api 0.0.0-stage → 0.2.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/LICENCE.md +21 -0
- package/README.md +87 -2
- package/build/browser.js +2469 -0
- package/build/cjs/corpus/drive.js +288 -0
- package/build/cjs/corpus/runner.js +35 -0
- package/build/cjs/drive/memory-store.js +126 -0
- package/build/cjs/drive/paths.js +82 -0
- package/build/cjs/drive/service.js +143 -0
- package/build/cjs/host/dispatch.js +102 -0
- package/build/cjs/index.js +58 -0
- package/build/cjs/routes/generated.js +1603 -0
- package/build/cjs/routes/index.js +111 -0
- package/build/cjs/routes/overlay.js +75 -0
- package/build/cjs/types.js +35 -0
- package/build/esm/corpus/drive.d.ts +33 -0
- package/build/esm/corpus/drive.d.ts.map +1 -0
- package/build/esm/corpus/drive.js +285 -0
- package/build/esm/corpus/runner.d.ts +27 -0
- package/build/esm/corpus/runner.d.ts.map +1 -0
- package/build/esm/corpus/runner.js +30 -0
- package/build/esm/drive/memory-store.d.ts +37 -0
- package/build/esm/drive/memory-store.d.ts.map +1 -0
- package/build/esm/drive/memory-store.js +122 -0
- package/build/esm/drive/paths.d.ts +49 -0
- package/build/esm/drive/paths.d.ts.map +1 -0
- package/build/esm/drive/paths.js +73 -0
- package/build/esm/drive/service.d.ts +42 -0
- package/build/esm/drive/service.d.ts.map +1 -0
- package/build/esm/drive/service.js +139 -0
- package/build/esm/host/dispatch.d.ts +24 -0
- package/build/esm/host/dispatch.d.ts.map +1 -0
- package/build/esm/host/dispatch.js +98 -0
- package/build/esm/index.d.ts +31 -0
- package/build/esm/index.d.ts.map +1 -0
- package/build/esm/index.js +24 -0
- package/build/esm/package.json +3 -0
- package/build/esm/routes/generated.d.ts +14 -0
- package/build/esm/routes/generated.d.ts.map +1 -0
- package/build/esm/routes/generated.js +1600 -0
- package/build/esm/routes/index.d.ts +18 -0
- package/build/esm/routes/index.d.ts.map +1 -0
- package/build/esm/routes/index.js +106 -0
- package/build/esm/routes/overlay.d.ts +40 -0
- package/build/esm/routes/overlay.d.ts.map +1 -0
- package/build/esm/routes/overlay.js +72 -0
- package/build/esm/types.d.ts +110 -0
- package/build/esm/types.d.ts.map +1 -0
- package/build/esm/types.js +30 -0
- package/package.json +55 -4
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DRIVE_CORPUS = void 0;
|
|
4
|
+
const success = { status: 'success' };
|
|
5
|
+
/**
|
|
6
|
+
* Bytes without an encoding API: the package builds with `lib: ES2020` and no
|
|
7
|
+
* ambient types, so a `TextEncoder` reference would compile here and fail in a
|
|
8
|
+
* host that does not have one. An explicit byte array is both.
|
|
9
|
+
*/
|
|
10
|
+
const bytes = (...values) => Uint8Array.from(values);
|
|
11
|
+
exports.DRIVE_CORPUS = [
|
|
12
|
+
{
|
|
13
|
+
id: 'create-file',
|
|
14
|
+
route: 'POST /SASjsApi/drive/file',
|
|
15
|
+
description: 'creates a file the drive did not have',
|
|
16
|
+
request: {
|
|
17
|
+
query: { _filePath: '/data/users.csv' },
|
|
18
|
+
file: { name: 'users.csv', contents: 'id,name\n1,Ann\n' }
|
|
19
|
+
},
|
|
20
|
+
expect: { status: 200, body: success }
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'create-file-at-form-path',
|
|
24
|
+
route: 'POST /SASjsApi/drive/file',
|
|
25
|
+
description: 'takes the destination from the form field when the query has none',
|
|
26
|
+
request: {
|
|
27
|
+
file: { name: 'x.csv', contents: 'x' },
|
|
28
|
+
body: { filePath: '/data/from-form.csv' }
|
|
29
|
+
},
|
|
30
|
+
expect: { status: 200, body: success }
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'create-file-over-existing',
|
|
34
|
+
route: 'POST /SASjsApi/drive/file',
|
|
35
|
+
description: 'refuses a create over a file that exists, as 409',
|
|
36
|
+
seed: { '/data/exists.csv': 'a' },
|
|
37
|
+
request: {
|
|
38
|
+
query: { _filePath: '/data/exists.csv' },
|
|
39
|
+
file: { name: 'x', contents: 'b' }
|
|
40
|
+
},
|
|
41
|
+
expect: { status: 409, error: 'File already exists.' }
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'create-file-blocked-extension',
|
|
45
|
+
route: 'POST /SASjsApi/drive/file',
|
|
46
|
+
description: 'refuses a refused extension before touching the drive',
|
|
47
|
+
request: {
|
|
48
|
+
query: { _filePath: '/data/run.sh' },
|
|
49
|
+
file: { name: 'run.sh', contents: 'x' }
|
|
50
|
+
},
|
|
51
|
+
expect: { status: 400, error: 'Invalid file extension' }
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'create-file-outside-drive',
|
|
55
|
+
route: 'POST /SASjsApi/drive/file',
|
|
56
|
+
description: 'refuses a path that climbs out of the drive',
|
|
57
|
+
request: {
|
|
58
|
+
query: { _filePath: '/../escape.csv' },
|
|
59
|
+
file: { name: 'x', contents: 'x' }
|
|
60
|
+
},
|
|
61
|
+
expect: { status: 400, error: "Can't put file outside drive." }
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'create-file-without-payload',
|
|
65
|
+
route: 'POST /SASjsApi/drive/file',
|
|
66
|
+
description: 'answers for a missing file with the server wording',
|
|
67
|
+
request: { query: { _filePath: '/data/x.csv' } },
|
|
68
|
+
expect: { status: 400, error: '"file" is not present.' }
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'create-file-without-destination',
|
|
72
|
+
route: 'POST /SASjsApi/drive/file',
|
|
73
|
+
description: 'answers for a missing destination with the validation wording',
|
|
74
|
+
request: { file: { name: 'x', contents: 'x' } },
|
|
75
|
+
expect: { status: 400, error: '"_filePath" is required' }
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'replace-file',
|
|
79
|
+
route: 'PATCH /SASjsApi/drive/file',
|
|
80
|
+
description: 'replaces the contents of a file that exists',
|
|
81
|
+
seed: { '/data/keep.csv': 'old' },
|
|
82
|
+
request: {
|
|
83
|
+
query: { _filePath: '/data/keep.csv' },
|
|
84
|
+
file: { name: 'keep.csv', contents: 'new' }
|
|
85
|
+
},
|
|
86
|
+
expect: { status: 200, body: success }
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'replace-file-missing',
|
|
90
|
+
route: 'PATCH /SASjsApi/drive/file',
|
|
91
|
+
description: 'refuses a replace of a file that is not there, as 404',
|
|
92
|
+
request: {
|
|
93
|
+
query: { _filePath: '/data/missing.csv' },
|
|
94
|
+
file: { name: 'x', contents: 'x' }
|
|
95
|
+
},
|
|
96
|
+
expect: { status: 404, error: `File doesn't exist.` }
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'replace-file-outside-drive',
|
|
100
|
+
route: 'PATCH /SASjsApi/drive/file',
|
|
101
|
+
description: 'refuses a replace of a path that climbs out of the drive',
|
|
102
|
+
request: {
|
|
103
|
+
query: { _filePath: '/../escape.csv' },
|
|
104
|
+
file: { name: 'x', contents: 'x' }
|
|
105
|
+
},
|
|
106
|
+
expect: { status: 400, error: "Can't modify file outside drive." }
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'read-file',
|
|
110
|
+
route: 'GET /SASjsApi/drive/file',
|
|
111
|
+
description: 'answers with the file contents',
|
|
112
|
+
seed: { '/data/keep.csv': 'a,b\n1,2\n' },
|
|
113
|
+
request: { query: { _filePath: '/data/keep.csv' } },
|
|
114
|
+
expect: { status: 200, contents: 'a,b\n1,2\n' }
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: 'read-file-missing',
|
|
118
|
+
route: 'GET /SASjsApi/drive/file',
|
|
119
|
+
description: 'refuses a read of a file that is not there',
|
|
120
|
+
request: { query: { _filePath: '/data/missing.csv' } },
|
|
121
|
+
expect: { status: 404, error: `File doesn't exist.` }
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: 'read-file-outside-drive',
|
|
125
|
+
route: 'GET /SASjsApi/drive/file',
|
|
126
|
+
description: 'refuses a read of a path that climbs out of the drive',
|
|
127
|
+
request: { query: { _filePath: '/../escape.csv' } },
|
|
128
|
+
expect: { status: 400, error: "Can't get file outside drive." }
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: 'delete-file',
|
|
132
|
+
route: 'DELETE /SASjsApi/drive/file',
|
|
133
|
+
description: 'deletes a file that exists',
|
|
134
|
+
seed: { '/data/keep.csv': 'a' },
|
|
135
|
+
request: { query: { _filePath: '/data/keep.csv' } },
|
|
136
|
+
expect: { status: 200, body: success }
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: 'delete-file-missing',
|
|
140
|
+
route: 'DELETE /SASjsApi/drive/file',
|
|
141
|
+
description: 'refuses a delete of a file that is not there',
|
|
142
|
+
request: { query: { _filePath: '/data/missing.csv' } },
|
|
143
|
+
expect: { status: 404, error: `File doesn't exist.` }
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: 'delete-file-outside-drive',
|
|
147
|
+
route: 'DELETE /SASjsApi/drive/file',
|
|
148
|
+
description: 'refuses a delete of a path that climbs out of the drive',
|
|
149
|
+
request: { query: { _filePath: '/../escape.csv' } },
|
|
150
|
+
expect: { status: 400, error: "Can't delete file outside drive." }
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 'list-folder',
|
|
154
|
+
route: 'GET /SASjsApi/drive/folder',
|
|
155
|
+
description: 'lists the files and folders of a folder',
|
|
156
|
+
seed: { '/data/a.csv': 'a', '/data/reports/b.csv': 'b' },
|
|
157
|
+
request: { query: { _folderPath: '/data' } },
|
|
158
|
+
expect: { status: 200, body: { files: ['a.csv'], folders: ['reports'] } }
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: 'list-folder-missing',
|
|
162
|
+
route: 'GET /SASjsApi/drive/folder',
|
|
163
|
+
description: 'refuses a listing of a folder that is not there',
|
|
164
|
+
request: { query: { _folderPath: '/nope' } },
|
|
165
|
+
expect: { status: 404, error: `Folder doesn't exist.` }
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: 'list-folder-outside-drive',
|
|
169
|
+
route: 'GET /SASjsApi/drive/folder',
|
|
170
|
+
description: 'refuses a listing of a path that climbs out of the drive',
|
|
171
|
+
request: { query: { _folderPath: '/../escape' } },
|
|
172
|
+
expect: { status: 400, error: "Can't get folder outside drive." }
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'delete-folder-without-path',
|
|
176
|
+
route: 'DELETE /SASjsApi/drive/folder',
|
|
177
|
+
description: 'answers for a missing folder path, because the table marks it required',
|
|
178
|
+
request: {},
|
|
179
|
+
expect: { status: 400, error: '"_folderPath" is required' }
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: 'create-folder-without-path',
|
|
183
|
+
route: 'POST /SASjsApi/drive/folder',
|
|
184
|
+
description: 'answers for a missing folder path in the body',
|
|
185
|
+
request: { body: {} },
|
|
186
|
+
expect: { status: 400, error: '"folderPath" is required' }
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: 'create-folder',
|
|
190
|
+
route: 'POST /SASjsApi/drive/folder',
|
|
191
|
+
description: 'creates a folder',
|
|
192
|
+
request: { body: { folderPath: '/data/reports' } },
|
|
193
|
+
expect: { status: 200, body: success }
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: 'create-folder-existing',
|
|
197
|
+
route: 'POST /SASjsApi/drive/folder',
|
|
198
|
+
description: 'refuses a folder that already exists, as 409',
|
|
199
|
+
seed: { '/data/reports/a.csv': 'a' },
|
|
200
|
+
request: { body: { folderPath: '/data/reports' } },
|
|
201
|
+
expect: { status: 409, error: 'Folder already exists.' }
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
id: 'delete-folder',
|
|
205
|
+
route: 'DELETE /SASjsApi/drive/folder',
|
|
206
|
+
description: 'deletes a folder with everything under it',
|
|
207
|
+
seed: { '/data/reports/a.csv': 'a', '/data/reports/nested/b.csv': 'b' },
|
|
208
|
+
request: { query: { _folderPath: '/data/reports' } },
|
|
209
|
+
expect: { status: 200, body: success }
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: 'delete-folder-missing',
|
|
213
|
+
route: 'DELETE /SASjsApi/drive/folder',
|
|
214
|
+
description: 'refuses a delete of a folder that is not there',
|
|
215
|
+
request: { query: { _folderPath: '/nope' } },
|
|
216
|
+
expect: { status: 404, error: `Folder doesn't exist.` }
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: 'rename-file',
|
|
220
|
+
route: 'POST /SASjsApi/drive/rename',
|
|
221
|
+
description: 'moves a file to a new path',
|
|
222
|
+
seed: { '/data/old.csv': 'r' },
|
|
223
|
+
request: { body: { oldPath: '/data/old.csv', newPath: '/data/new.csv' } },
|
|
224
|
+
expect: { status: 200, body: success }
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
id: 'rename-onto-existing',
|
|
228
|
+
route: 'POST /SASjsApi/drive/rename',
|
|
229
|
+
description: 'refuses a move onto something that exists',
|
|
230
|
+
seed: { '/data/a.csv': 'a', '/data/b.csv': 'b' },
|
|
231
|
+
request: { body: { oldPath: '/data/a.csv', newPath: '/data/b.csv' } },
|
|
232
|
+
expect: { status: 409, error: 'File with new name already exists.' }
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: 'rename-missing',
|
|
236
|
+
route: 'POST /SASjsApi/drive/rename',
|
|
237
|
+
description: 'refuses a move of something that is not there',
|
|
238
|
+
request: { body: { oldPath: '/data/nope.csv', newPath: '/data/x.csv' } },
|
|
239
|
+
expect: { status: 404, error: 'No file/folder found for provided path.' }
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
id: 'rename-folder-into-itself',
|
|
243
|
+
route: 'POST /SASjsApi/drive/rename',
|
|
244
|
+
description: 'refuses a folder moved inside itself, which the server loses at the filesystem',
|
|
245
|
+
seed: { '/data/reports/a.csv': 'a' },
|
|
246
|
+
request: {
|
|
247
|
+
body: { oldPath: '/data/reports', newPath: '/data/reports/nested' }
|
|
248
|
+
},
|
|
249
|
+
expect: {
|
|
250
|
+
status: 400,
|
|
251
|
+
error: "New path can't be inside the folder being moved."
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: 'tree',
|
|
256
|
+
route: 'GET /SASjsApi/drive/filetree',
|
|
257
|
+
description: 'answers with the drive tree',
|
|
258
|
+
seed: { '/data/a.csv': 'a' },
|
|
259
|
+
request: { query: { _folderPath: '/data' } },
|
|
260
|
+
expect: {
|
|
261
|
+
status: 200,
|
|
262
|
+
body: {
|
|
263
|
+
status: 'success',
|
|
264
|
+
tree: {
|
|
265
|
+
name: 'data',
|
|
266
|
+
relativePath: '/data',
|
|
267
|
+
isFolder: true,
|
|
268
|
+
children: [
|
|
269
|
+
{
|
|
270
|
+
name: 'a.csv',
|
|
271
|
+
relativePath: '/data/a.csv',
|
|
272
|
+
isFolder: false,
|
|
273
|
+
children: []
|
|
274
|
+
}
|
|
275
|
+
]
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
id: 'bytes-round-trip',
|
|
282
|
+
route: 'GET /SASjsApi/drive/file',
|
|
283
|
+
description: 'keeps a byte file as bytes',
|
|
284
|
+
seed: { '/data/pack.zip': bytes(80, 75, 3, 4) },
|
|
285
|
+
request: { query: { _filePath: '/data/pack.zip' } },
|
|
286
|
+
expect: { status: 200, contents: bytes(80, 75, 3, 4) }
|
|
287
|
+
}
|
|
288
|
+
];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runCase = exports.servicesFor = void 0;
|
|
4
|
+
const dispatch_js_1 = require("../host/dispatch.js");
|
|
5
|
+
const service_js_1 = require("../drive/service.js");
|
|
6
|
+
const memory_store_js_1 = require("../drive/memory-store.js");
|
|
7
|
+
const types_js_1 = require("../types.js");
|
|
8
|
+
/** Services over a fresh store, seeded with the case's files. */
|
|
9
|
+
const servicesFor = (testCase) => ({
|
|
10
|
+
drive: (0, service_js_1.createDriveService)(new memory_store_js_1.MemoryDriveStore(testCase.seed || {}))
|
|
11
|
+
});
|
|
12
|
+
exports.servicesFor = servicesFor;
|
|
13
|
+
/**
|
|
14
|
+
* Runs a corpus case against the drive the case seeds itself, or against
|
|
15
|
+
* services a caller supplies - that is how a host (or a failing store) is put
|
|
16
|
+
* behind the same cases.
|
|
17
|
+
*/
|
|
18
|
+
const runCase = async (testCase, services = (0, exports.servicesFor)(testCase)) => {
|
|
19
|
+
try {
|
|
20
|
+
const response = await (0, dispatch_js_1.dispatch)(services, testCase.route, testCase.request);
|
|
21
|
+
return {
|
|
22
|
+
status: response.status,
|
|
23
|
+
body: response.body,
|
|
24
|
+
contents: response.contents
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
// only a refusal the API decided on becomes a status; anything else is a
|
|
29
|
+
// broken host, and reporting it as a 500 would hide it from the suite
|
|
30
|
+
if ((0, types_js_1.isApiError)(error))
|
|
31
|
+
return { status: error.status, error: error.message };
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
exports.runCase = runCase;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MemoryDriveStore = void 0;
|
|
4
|
+
const types_js_1 = require("../types.js");
|
|
5
|
+
const paths_js_1 = require("./paths.js");
|
|
6
|
+
class MemoryDriveStore {
|
|
7
|
+
constructor(seed = {}) {
|
|
8
|
+
this.files = new Map();
|
|
9
|
+
this.folders = new Set(['/']);
|
|
10
|
+
for (const [path, contents] of Object.entries(seed)) {
|
|
11
|
+
this.putSync(path, contents);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Everything on the drive, for a snapshot or an assertion. */
|
|
15
|
+
dump() {
|
|
16
|
+
const out = {};
|
|
17
|
+
for (const [path, contents] of this.files)
|
|
18
|
+
out[path] = contents;
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
putSync(path, contents) {
|
|
22
|
+
this.mkdirpSync((0, paths_js_1.dirname)(path));
|
|
23
|
+
this.files.set(path, typeof contents === 'string' ? contents : contents.slice());
|
|
24
|
+
}
|
|
25
|
+
mkdirpSync(path) {
|
|
26
|
+
const parts = path.split('/').filter(Boolean);
|
|
27
|
+
let acc = '';
|
|
28
|
+
for (const part of parts) {
|
|
29
|
+
acc += '/' + part;
|
|
30
|
+
this.folders.add(acc);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
children(path) {
|
|
34
|
+
const prefix = path === '/' ? '/' : path + '/';
|
|
35
|
+
const files = [];
|
|
36
|
+
const folders = [];
|
|
37
|
+
for (const candidate of this.files.keys()) {
|
|
38
|
+
if (!candidate.startsWith(prefix))
|
|
39
|
+
continue;
|
|
40
|
+
const rest = candidate.slice(prefix.length);
|
|
41
|
+
if (rest && !rest.includes('/'))
|
|
42
|
+
files.push(rest);
|
|
43
|
+
}
|
|
44
|
+
for (const candidate of this.folders) {
|
|
45
|
+
if (!candidate.startsWith(prefix))
|
|
46
|
+
continue;
|
|
47
|
+
const rest = candidate.slice(prefix.length);
|
|
48
|
+
if (rest && !rest.includes('/'))
|
|
49
|
+
folders.push(rest);
|
|
50
|
+
}
|
|
51
|
+
return { files: files.sort(), folders: folders.sort() };
|
|
52
|
+
}
|
|
53
|
+
async read(path) {
|
|
54
|
+
const contents = this.files.get(path);
|
|
55
|
+
if (contents === undefined)
|
|
56
|
+
throw new types_js_1.ApiError(404, `File doesn't exist.`);
|
|
57
|
+
return contents;
|
|
58
|
+
}
|
|
59
|
+
async write(path, contents) {
|
|
60
|
+
this.putSync(path, contents);
|
|
61
|
+
}
|
|
62
|
+
async exists(path) {
|
|
63
|
+
return this.files.has(path) || this.folders.has(path);
|
|
64
|
+
}
|
|
65
|
+
async stat(path) {
|
|
66
|
+
if (this.folders.has(path) && !this.files.has(path))
|
|
67
|
+
return { directory: true, size: 0 };
|
|
68
|
+
const contents = this.files.get(path);
|
|
69
|
+
if (contents === undefined)
|
|
70
|
+
throw new types_js_1.ApiError(404, `File doesn't exist.`);
|
|
71
|
+
return {
|
|
72
|
+
directory: false,
|
|
73
|
+
size: typeof contents === 'string' ? contents.length : contents.length
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
async list(path) {
|
|
77
|
+
return this.children(path);
|
|
78
|
+
}
|
|
79
|
+
async mkdirp(path) {
|
|
80
|
+
this.mkdirpSync(path);
|
|
81
|
+
}
|
|
82
|
+
async remove(path) {
|
|
83
|
+
if (this.files.delete(path))
|
|
84
|
+
return;
|
|
85
|
+
const prefix = path === '/' ? '/' : path + '/';
|
|
86
|
+
for (const candidate of [...this.files.keys()]) {
|
|
87
|
+
if (candidate.startsWith(prefix))
|
|
88
|
+
this.files.delete(candidate);
|
|
89
|
+
}
|
|
90
|
+
for (const candidate of [...this.folders]) {
|
|
91
|
+
if (candidate === path || candidate.startsWith(prefix))
|
|
92
|
+
this.folders.delete(candidate);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async move(from, to) {
|
|
96
|
+
const contents = this.files.get(from);
|
|
97
|
+
if (contents !== undefined) {
|
|
98
|
+
const copy = typeof contents === 'string' ? contents : contents.slice();
|
|
99
|
+
this.files.delete(from);
|
|
100
|
+
this.mkdirpSync((0, paths_js_1.dirname)(to));
|
|
101
|
+
this.files.set(to, copy);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// a folder: every entry under it moves by prefix, then the folder records
|
|
105
|
+
const prefix = from + '/';
|
|
106
|
+
const moves = [];
|
|
107
|
+
for (const [candidate, value] of this.files) {
|
|
108
|
+
if (candidate.startsWith(prefix))
|
|
109
|
+
moves.push([candidate, to + candidate.slice(from.length), value]);
|
|
110
|
+
}
|
|
111
|
+
for (const [source] of moves)
|
|
112
|
+
this.files.delete(source);
|
|
113
|
+
for (const [, destination, value] of moves) {
|
|
114
|
+
this.mkdirpSync((0, paths_js_1.dirname)(destination));
|
|
115
|
+
this.files.set(destination, value);
|
|
116
|
+
}
|
|
117
|
+
for (const candidate of [...this.folders]) {
|
|
118
|
+
if (candidate === from || candidate.startsWith(prefix)) {
|
|
119
|
+
this.folders.delete(candidate);
|
|
120
|
+
this.folders.add(to + candidate.slice(from.length));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
this.mkdirpSync(to);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
exports.MemoryDriveStore = MemoryDriveStore;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Drive path rules, in one place.
|
|
4
|
+
*
|
|
5
|
+
* These are the server's rules, not an invention of this package:
|
|
6
|
+
* `resolveWithinDrive` in sasjs/server resolves a caller-supplied path against
|
|
7
|
+
* the drive root and refuses anything that leaves it, and `filePathSchema`
|
|
8
|
+
* refuses a handful of extensions before a path reaches the drive at all.
|
|
9
|
+
* Because both hosts now import this file, the rule can only be changed in one
|
|
10
|
+
* place - which is the point of the package.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.checkFilePath = exports.isRoot = exports.basename = exports.dirname = exports.normaliseDrivePath = exports.hasBlockedExtension = void 0;
|
|
14
|
+
/**
|
|
15
|
+
* Extensions a drive request may not name, on any route (the server's
|
|
16
|
+
* `blockFileRegex`). Nothing is executable on a browser host, but a client that
|
|
17
|
+
* would be refused by a server should hear the same answer from the mock.
|
|
18
|
+
*/
|
|
19
|
+
const BLOCKED_EXTENSION = /\.(exe|sh|htaccess)$/i;
|
|
20
|
+
/** True when the last segment of the path carries a refused extension. */
|
|
21
|
+
const hasBlockedExtension = (path) => BLOCKED_EXTENSION.test(path);
|
|
22
|
+
exports.hasBlockedExtension = hasBlockedExtension;
|
|
23
|
+
/**
|
|
24
|
+
* A caller-supplied path as a drive-relative path, or `null` when it would land
|
|
25
|
+
* outside the drive.
|
|
26
|
+
*
|
|
27
|
+
* The rules, each of which a test pins:
|
|
28
|
+
*
|
|
29
|
+
* - a leading slash means "under the drive root", never filesystem-absolute -
|
|
30
|
+
* so `/Public/app/x` and `Public/app/x` are the same destination;
|
|
31
|
+
* - a backslash is a separator, because a Windows client sends them;
|
|
32
|
+
* - `.` is ignored and `..` pops a segment, so traversal is resolved rather
|
|
33
|
+
* than string-matched;
|
|
34
|
+
* - a `..` that would pop past the root returns null instead of clamping,
|
|
35
|
+
* because a path that escapes is refused, not rewritten;
|
|
36
|
+
* - a name that merely starts with dots (`..foo`) is a name, not a traversal.
|
|
37
|
+
*
|
|
38
|
+
* The return value starts with '/' (the root is '/'), has no trailing slash.
|
|
39
|
+
*/
|
|
40
|
+
const normaliseDrivePath = (input) => {
|
|
41
|
+
const given = typeof input === 'string' ? input : '';
|
|
42
|
+
const segments = [];
|
|
43
|
+
for (const segment of given.replace(/\\/g, '/').split('/')) {
|
|
44
|
+
if (!segment || segment === '.')
|
|
45
|
+
continue;
|
|
46
|
+
if (segment === '..') {
|
|
47
|
+
if (!segments.length)
|
|
48
|
+
return null;
|
|
49
|
+
segments.pop();
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
segments.push(segment);
|
|
53
|
+
}
|
|
54
|
+
return '/' + segments.join('/');
|
|
55
|
+
};
|
|
56
|
+
exports.normaliseDrivePath = normaliseDrivePath;
|
|
57
|
+
/** The folder a file path sits in ('/' for a path at the root). */
|
|
58
|
+
const dirname = (path) => {
|
|
59
|
+
const cut = path.lastIndexOf('/');
|
|
60
|
+
return cut <= 0 ? '/' : path.slice(0, cut);
|
|
61
|
+
};
|
|
62
|
+
exports.dirname = dirname;
|
|
63
|
+
/** The last segment of a path. */
|
|
64
|
+
const basename = (path) => path.slice(path.lastIndexOf('/') + 1);
|
|
65
|
+
exports.basename = basename;
|
|
66
|
+
/** The drive root itself, which no write may name. */
|
|
67
|
+
const isRoot = (path) => path === '/';
|
|
68
|
+
exports.isRoot = isRoot;
|
|
69
|
+
/**
|
|
70
|
+
* The path a file request names, with the blocked-extension rule applied.
|
|
71
|
+
* Returns the reason rather than a status, so the caller chooses the wording:
|
|
72
|
+
* a traversal and an extension are both 400s, with different messages.
|
|
73
|
+
*/
|
|
74
|
+
const checkFilePath = (input) => {
|
|
75
|
+
const path = (0, exports.normaliseDrivePath)(input);
|
|
76
|
+
if (path === null)
|
|
77
|
+
return { reason: 'outside' };
|
|
78
|
+
if ((0, exports.hasBlockedExtension)(path))
|
|
79
|
+
return { reason: 'extension' };
|
|
80
|
+
return { path };
|
|
81
|
+
};
|
|
82
|
+
exports.checkFilePath = checkFilePath;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createDriveService = void 0;
|
|
4
|
+
const types_js_1 = require("../types.js");
|
|
5
|
+
const paths_js_1 = require("./paths.js");
|
|
6
|
+
/** A path a file operation may act on, or the refusal the operation answers. */
|
|
7
|
+
const filePath = (input, outside) => {
|
|
8
|
+
const checked = (0, paths_js_1.checkFilePath)(input);
|
|
9
|
+
if ('reason' in checked) {
|
|
10
|
+
throw checked.reason === 'extension'
|
|
11
|
+
? new types_js_1.ApiError(400, 'Invalid file extension')
|
|
12
|
+
: new types_js_1.ApiError(400, outside);
|
|
13
|
+
}
|
|
14
|
+
if ((0, paths_js_1.isRoot)(checked.path))
|
|
15
|
+
throw new types_js_1.ApiError(400, outside);
|
|
16
|
+
return checked.path;
|
|
17
|
+
};
|
|
18
|
+
/** A folder path, which may be the drive root but never an extension check. */
|
|
19
|
+
const folderPath = (input, outside) => {
|
|
20
|
+
const path = (0, paths_js_1.normaliseDrivePath)(input);
|
|
21
|
+
if (path === null)
|
|
22
|
+
throw new types_js_1.ApiError(400, outside);
|
|
23
|
+
return path;
|
|
24
|
+
};
|
|
25
|
+
const createDriveService = (store) => {
|
|
26
|
+
const treeOf = async (path) => {
|
|
27
|
+
const listing = await store.list(path);
|
|
28
|
+
const children = [];
|
|
29
|
+
for (const folder of listing.folders) {
|
|
30
|
+
children.push(await treeOf(path === '/' ? '/' + folder : path + '/' + folder));
|
|
31
|
+
}
|
|
32
|
+
for (const file of listing.files) {
|
|
33
|
+
children.push({
|
|
34
|
+
name: file,
|
|
35
|
+
relativePath: path === '/' ? '/' + file : path + '/' + file,
|
|
36
|
+
isFolder: false,
|
|
37
|
+
children: []
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
name: path === '/' ? 'files' : (0, paths_js_1.basename)(path),
|
|
42
|
+
relativePath: path,
|
|
43
|
+
isFolder: true,
|
|
44
|
+
children
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
async list(given) {
|
|
49
|
+
// an empty path is the root, which is what the server's GET /folder does
|
|
50
|
+
// with no `_folderPath`
|
|
51
|
+
const path = folderPath(given === undefined ? '' : given, "Can't get folder outside drive.");
|
|
52
|
+
if (!(await store.exists(path)))
|
|
53
|
+
throw new types_js_1.ApiError(404, `Folder doesn't exist.`);
|
|
54
|
+
const stat = await store.stat(path);
|
|
55
|
+
if (!stat.directory)
|
|
56
|
+
throw new types_js_1.ApiError(400, 'Not a Folder.');
|
|
57
|
+
const listing = await store.list(path);
|
|
58
|
+
return {
|
|
59
|
+
files: [...listing.files].sort(),
|
|
60
|
+
folders: [...listing.folders].sort()
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
async read(given) {
|
|
64
|
+
const path = filePath(given, "Can't get file outside drive.");
|
|
65
|
+
if (!(await store.exists(path)))
|
|
66
|
+
throw new types_js_1.ApiError(404, `File doesn't exist.`);
|
|
67
|
+
const contents = await store.read(path);
|
|
68
|
+
return { contents, binary: typeof contents !== 'string' };
|
|
69
|
+
},
|
|
70
|
+
async save(given, contents) {
|
|
71
|
+
const path = filePath(given, "Can't put file outside drive.");
|
|
72
|
+
if (await store.exists(path))
|
|
73
|
+
throw new types_js_1.ApiError(409, 'File already exists.');
|
|
74
|
+
await store.mkdirp((0, paths_js_1.dirname)(path));
|
|
75
|
+
await store.write(path, contents);
|
|
76
|
+
return { status: 'success' };
|
|
77
|
+
},
|
|
78
|
+
async update(given, contents) {
|
|
79
|
+
const path = filePath(given, "Can't modify file outside drive.");
|
|
80
|
+
if (!(await store.exists(path)))
|
|
81
|
+
throw new types_js_1.ApiError(404, `File doesn't exist.`);
|
|
82
|
+
await store.write(path, contents);
|
|
83
|
+
return { status: 'success' };
|
|
84
|
+
},
|
|
85
|
+
async remove(given) {
|
|
86
|
+
const path = filePath(given, "Can't delete file outside drive.");
|
|
87
|
+
if (!(await store.exists(path)))
|
|
88
|
+
throw new types_js_1.ApiError(404, `File doesn't exist.`);
|
|
89
|
+
await store.remove(path);
|
|
90
|
+
return { status: 'success' };
|
|
91
|
+
},
|
|
92
|
+
async mkdir(given) {
|
|
93
|
+
const path = folderPath(given, "Can't put folder outside drive.");
|
|
94
|
+
if ((0, paths_js_1.isRoot)(path))
|
|
95
|
+
throw new types_js_1.ApiError(400, "Can't put folder outside drive.");
|
|
96
|
+
if (await store.exists(path))
|
|
97
|
+
throw new types_js_1.ApiError(409, 'Folder already exists.');
|
|
98
|
+
await store.mkdirp(path);
|
|
99
|
+
return { status: 'success' };
|
|
100
|
+
},
|
|
101
|
+
async rmdir(given) {
|
|
102
|
+
const path = folderPath(given, "Can't delete folder outside drive.");
|
|
103
|
+
// the root is the whole drive: refused here as well as at validation,
|
|
104
|
+
// because a caller reaching this function directly has no validation
|
|
105
|
+
if ((0, paths_js_1.isRoot)(path))
|
|
106
|
+
throw new types_js_1.ApiError(400, "Can't delete folder outside drive.");
|
|
107
|
+
if (!(await store.exists(path)) || !(await store.stat(path)).directory)
|
|
108
|
+
throw new types_js_1.ApiError(404, `Folder doesn't exist.`);
|
|
109
|
+
await store.remove(path);
|
|
110
|
+
return { status: 'success' };
|
|
111
|
+
},
|
|
112
|
+
async rename(oldPath, newPath) {
|
|
113
|
+
const from = folderPath(oldPath, "Old path can't be outside of drive.");
|
|
114
|
+
const to = folderPath(newPath, "New path can't be outside of drive.");
|
|
115
|
+
if ((0, paths_js_1.isRoot)(from) || (0, paths_js_1.isRoot)(to))
|
|
116
|
+
throw new types_js_1.ApiError(400, "New path can't be outside of drive.");
|
|
117
|
+
if (await store.exists(from)) {
|
|
118
|
+
const stat = await store.stat(from);
|
|
119
|
+
// a folder moved inside itself would be an infinite copy, and the
|
|
120
|
+
// server loses this at the filesystem with an opaque error
|
|
121
|
+
if (stat.directory && (to === from || to.startsWith(from + '/')))
|
|
122
|
+
throw new types_js_1.ApiError(400, "New path can't be inside the folder being moved.");
|
|
123
|
+
if (await store.exists(to))
|
|
124
|
+
throw new types_js_1.ApiError(409, stat.directory
|
|
125
|
+
? 'Folder with new name already exists.'
|
|
126
|
+
: 'File with new name already exists.');
|
|
127
|
+
await store.move(from, to);
|
|
128
|
+
return { status: 'success' };
|
|
129
|
+
}
|
|
130
|
+
throw new types_js_1.ApiError(404, 'No file/folder found for provided path.');
|
|
131
|
+
},
|
|
132
|
+
async tree(given) {
|
|
133
|
+
const path = folderPath(given === undefined ? '' : given, "Can't get folder outside drive.");
|
|
134
|
+
if (!(await store.exists(path)))
|
|
135
|
+
throw new types_js_1.ApiError(404, `Folder doesn't exist.`);
|
|
136
|
+
const stat = await store.stat(path);
|
|
137
|
+
if (!stat.directory)
|
|
138
|
+
throw new types_js_1.ApiError(400, 'Not a Folder.');
|
|
139
|
+
return { status: 'success', tree: await treeOf(path) };
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
exports.createDriveService = createDriveService;
|