Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ rli blueprint from-dockerfile # Create a blueprint from a Dockerfile
rli object list # List objects
rli object get <id> # Get object details
rli object download <id> <path> # Download object to local file
rli object upload <path> # Upload a file as an object
rli object upload <paths...> # Upload file(s) or directory as an obj...
rli object delete <id> # Delete an object (irreversible)
```

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"ink-link": "5.0.0",
"ink-spinner": "5.0.0",
"ink-text-input": "6.0.0",
"nanotar": "^0.3.0",
"react": "19.2.0",
"yaml": "2.8.3",
"zustand": "5.0.10"
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

222 changes: 206 additions & 16 deletions src/commands/object/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
* Upload object command
*/

import { readFile, stat } from "fs/promises";
import { extname } from "path";
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 {
path: string;
paths: string[];
name: string;
contentType?: string;
public?: boolean;
Expand All @@ -32,24 +34,212 @@ const CONTENT_TYPE_MAP: Record<string, ContentType> = {
".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;
}

// Check if file exists and get stats
const stats = await stat(options.path);
const fileBuffer = await readFile(options.path);
// 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;

// Auto-detect content type if not provided
let detectedContentType: ContentType = options.contentType as ContentType;
if (!detectedContentType) {
const ext = extname(options.path).toLowerCase();
detectedContentType = CONTENT_TYPE_MAP[ext] || "unspecified";
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: options.name,
name,
content_type: detectedContentType,
});

Expand All @@ -71,16 +261,16 @@ export async function uploadObject(options: UploadObjectOptions) {

const result = {
id: createResponse.id,
name: options.name,
name,
contentType: detectedContentType,
size: stats.size,
size: fileSize,
};

// Default: just output the ID for easy scripting
if (!options.output || options.output === "text") {
if (!outputFormat || outputFormat === "text") {
console.log(result.id);
} else {
output(result, { format: options.output, defaultFormat: "json" });
output(result, { format: outputFormat, defaultFormat: "json" });
}
} catch (error) {
outputError("Failed to upload object", error);
Expand Down
12 changes: 7 additions & 5 deletions src/utils/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,8 +648,10 @@ export function createProgram(): Command {
});

object
.command("upload <path>")
.description("Upload a file as an object")
.command("upload <paths...>")
.description(
"Upload file(s) or directory as an object. Multiple paths with --content-type tar|tgz creates an archive.",
)
.option("--name <name>", "Object name (required)")
.option(
"--content-type <type>",
Expand All @@ -660,14 +662,14 @@ export function createProgram(): Command {
"-o, --output [format]",
"Output format: text|json|yaml (default: text)",
)
.action(async (path, options) => {
.action(async (paths, options) => {
const { uploadObject } = await import("../commands/object/upload.js");
if (!options.output) {
const { runInteractiveCommand } =
await import("../utils/interactiveCommand.js");
await runInteractiveCommand(() => uploadObject({ path, ...options }));
await runInteractiveCommand(() => uploadObject({ paths, ...options }));
} else {
await uploadObject({ path, ...options });
await uploadObject({ paths, ...options });
}
});

Expand Down
Loading
Loading