-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathupload.ts
More file actions
279 lines (248 loc) · 8.2 KB
/
Copy pathupload.ts
File metadata and controls
279 lines (248 loc) · 8.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
/**
* Upload object command
*/
import { lstat, readFile, readdir } from "fs/promises";
import { dirname, extname, relative, resolve, sep } from "path";
import { createTar, createTarGzip } from "nanotar";
import type { TarFileInput } from "nanotar";
import { getClient } from "../../utils/client.js";
import { output, outputError } from "../../utils/output.js";
interface UploadObjectOptions {
paths: string[];
name: string;
contentType?: string;
public?: boolean;
output?: string;
}
type ContentType = "binary" | "text" | "unspecified" | "gzip" | "tar" | "tgz";
const CONTENT_TYPE_MAP: Record<string, ContentType> = {
".txt": "text",
".html": "text",
".css": "text",
".js": "text",
".json": "text",
".yaml": "text",
".yml": "text",
".md": "text",
".gz": "gzip",
".tar": "tar",
".tgz": "tgz",
".tar.gz": "tgz",
};
/**
* Recursively collect all files and directories under the given paths into
* nanotar entries. Normalizes permissions: uid/gid 1000, directories and
* executable files get mode 755, everything else gets 644. Preserves mtime.
*
* Entry names are always relative to `archiveRoot` and never contain leading
* `../` segments, preventing path traversal in the generated archive.
*/
async function collectEntries(
paths: string[],
archiveRoot: string,
precomputedStats?: Map<string, Awaited<ReturnType<typeof lstat>>>,
): Promise<TarFileInput[]> {
const entries: TarFileInput[] = [];
for (const p of paths) {
const absPath = resolve(p);
let relPath = relative(archiveRoot, absPath);
// Guard against path traversal: entry names must not escape the archive root
if (relPath.startsWith("..")) {
throw new Error(
`Path "${absPath}" is outside the archive root "${archiveRoot}". All paths must share a common ancestor directory.`,
);
}
let stats = precomputedStats?.get(absPath);
if (!stats) {
try {
stats = await lstat(absPath);
} catch (err) {
throw new Error(`Cannot read path: ${relPath}`, { cause: err });
}
}
if (stats.isSymbolicLink()) {
throw new Error(
`Path is a symlink: ${relPath}. Resolve the symlink or pass the target path directly.`,
);
}
if (stats.isDirectory()) {
entries.push({
name: relPath.endsWith("/") ? relPath : relPath + "/",
attrs: {
mode: "755",
uid: 1000,
gid: 1000,
// nanotar expects mtime in milliseconds and converts to seconds internally
mtime: Number(stats.mtimeMs),
},
});
const children = (await readdir(absPath)).sort();
const childPaths = children.map((c) => resolve(absPath, c));
if (childPaths.length > 0) {
entries.push(...(await collectEntries(childPaths, archiveRoot)));
}
} else {
const isExecutable = (Number(stats.mode) & 0o111) !== 0;
let data;
try {
data = await readFile(absPath);
} catch (err) {
throw new Error(`Cannot read file: ${relPath}`, { cause: err });
}
entries.push({
name: relPath,
data,
attrs: {
mode: isExecutable ? "755" : "644",
uid: 1000,
gid: 1000,
// nanotar expects mtime in milliseconds and converts to seconds internally
mtime: Number(stats.mtimeMs),
},
});
}
}
return entries;
}
/**
* Compute the deepest common directory for a list of absolute paths.
* Used as the archive root so entry names are always relative and safe.
*/
function commonAncestor(absPaths: string[]): string {
if (absPaths.length === 0) return process.cwd();
if (absPaths.length === 1) return dirname(absPaths[0]);
const parts = absPaths.map((p) => p.split(sep));
const common: string[] = [];
for (let i = 0; i < parts[0].length; i++) {
const segment = parts[0][i];
if (parts.every((p) => p[i] === segment)) {
common.push(segment);
} else {
break;
}
}
return common.join(sep) || sep;
}
/**
* Create a tar (or tgz) archive as a Buffer from the given filesystem paths.
*
* Walks directories recursively and normalizes all entries to uid/gid 1000,
* mode 644 (non-executable files) or 755 (executable files and directories).
* Entry names are relative to the common ancestor of the provided paths.
*/
export async function createTarBuffer(
paths: string[],
gzip: boolean,
precomputedStats?: Map<string, Awaited<ReturnType<typeof lstat>>>,
): Promise<Buffer> {
const absPaths = paths.map((p) => resolve(p));
const archiveRoot = commonAncestor(absPaths);
const entries = await collectEntries(paths, archiveRoot, precomputedStats);
if (gzip) {
const data = await createTarGzip(entries);
return Buffer.from(data);
}
const data = createTar(entries);
return Buffer.from(data);
}
export async function uploadObject(options: UploadObjectOptions) {
try {
const client = getClient();
const { paths, name, contentType, output: outputFormat } = options;
if (paths.length === 0) {
outputError("At least one path is required");
return;
}
// Validate all paths exist (use lstat to match collectEntries and detect symlinks)
// Key by resolved absolute path so collectEntries can reuse stats
const statsMap = new Map<string, Awaited<ReturnType<typeof lstat>>>();
for (const p of paths) {
try {
const s = await lstat(p);
if (s.isSymbolicLink()) {
outputError(
`Path is a symlink: ${p}. Resolve the symlink or pass the target path directly.`,
);
return;
}
statsMap.set(resolve(p), s);
} catch {
outputError(`Path does not exist: ${p}`);
return;
}
}
const isTarType = contentType === "tar" || contentType === "tgz";
const isSinglePath = paths.length === 1;
const firstStats = isSinglePath
? statsMap.get(resolve(paths[0]))!
: undefined;
const singleIsDir = isSinglePath && firstStats!.isDirectory();
// Multi-path requires tar/tgz content type
if (paths.length > 1 && !isTarType) {
outputError(
"Multiple paths require --content-type tar or --content-type tgz",
);
return;
}
// Directory without tar/tgz type
if (singleIsDir && !isTarType) {
outputError(
"Cannot upload a directory directly. Use --content-type tar or --content-type tgz to create an archive.",
);
return;
}
let fileBuffer: Buffer;
let detectedContentType: ContentType;
let fileSize: number;
const shouldCreateArchive = isTarType && (paths.length > 1 || singleIsDir);
if (shouldCreateArchive) {
const gzip = contentType === "tgz";
fileBuffer = await createTarBuffer(paths, gzip, statsMap);
detectedContentType = contentType as ContentType;
fileSize = fileBuffer.length;
} else {
// Single file upload (existing behavior)
const filePath = paths[0];
fileBuffer = await readFile(filePath);
fileSize = fileBuffer.length;
detectedContentType = contentType as ContentType;
if (!detectedContentType) {
const ext = extname(filePath).toLowerCase();
detectedContentType = CONTENT_TYPE_MAP[ext] || "unspecified";
}
}
// Step 1: Create the object
const createResponse = await client.objects.create({
name,
content_type: detectedContentType,
...(options.public ? { is_public: true } : {}),
} as any);
// Step 2: Upload the file
const uploadResponse = await fetch(createResponse.upload_url!, {
method: "PUT",
body: fileBuffer,
headers: {
"Content-Length": fileBuffer.length.toString(),
},
});
if (!uploadResponse.ok) {
outputError(`Upload failed: HTTP ${uploadResponse.status}`);
}
// Step 3: Complete the upload
await client.objects.complete(createResponse.id);
const result = {
id: createResponse.id,
name,
contentType: detectedContentType,
size: fileSize,
};
// Default: just output the ID for easy scripting
if (!outputFormat || outputFormat === "text") {
console.log(result.id);
} else {
output(result, { format: outputFormat, defaultFormat: "json" });
}
} catch (error) {
outputError("Failed to upload object", error);
}
}