From 79ae009fb4e071b8559823826f6ce4a37c0ba60d Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Thu, 23 Apr 2026 13:44:00 -0700 Subject: [PATCH 01/13] feat: add tar as direct dependency The tar package was previously only referenced in pnpm overrides. Adding it as a direct dependency to support tar/tgz archive creation during object uploads. Co-Authored-By: Claude Opus 4.6 --- package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/package.json b/package.json index 78d0cc83..b6bbdab3 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@runloop/api-client": "1.16.0", "@types/express": "5.0.6", "adm-zip": "0.5.16", + "tar": "^7.5.13", "chalk": "5.6.2", "commander": "14.0.2", "conf": "15.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ada46644..e19d279e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: react: specifier: 19.2.0 version: 19.2.0 + tar: + specifier: ^7.5.13 + version: 7.5.13 yaml: specifier: 2.8.3 version: 2.8.3 From e04d694eac7ab7331abacd178893e33a7db504b2 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Thu, 23 Apr 2026 13:44:38 -0700 Subject: [PATCH 02/13] feat: support multi-path tar/tgz archive creation in obj upload Changes the upload command from single to variadic . When --content-type tar or tgz is specified with multiple paths or a single directory, creates a tar/tgz archive automatically before upload. Single file uploads preserve existing behavior. Decision matrix: - tar/tgz + multiple paths: create archive - tar/tgz + single directory: create archive - tar/tgz + single file: upload as-is - non-tar + multiple paths: error with guidance - non-tar + single directory: error with guidance - non-tar + single file: existing behavior Co-Authored-By: Claude Opus 4.6 --- src/commands/object/upload.ts | 114 +++++++++++++++++++++++++++++----- src/utils/commands.ts | 14 +++-- 2 files changed, 107 insertions(+), 21 deletions(-) diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index 4ce9dbcb..3bd52ee5 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -2,13 +2,17 @@ * Upload object command */ -import { readFile, stat } from "fs/promises"; -import { extname } from "path"; +import { readFile, stat, unlink } from "fs/promises"; +import { tmpdir } from "os"; +import { basename, extname, relative, resolve } from "path"; +import { randomUUID } from "crypto"; +import { join } from "path"; +import * as tar from "tar"; 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; @@ -32,24 +36,102 @@ const CONTENT_TYPE_MAP: Record = { ".tar.gz": "tgz", }; +export async function createTarBuffer( + paths: string[], + gzip: boolean, +): Promise { + const cwd = process.cwd(); + const relativePaths = paths.map((p) => relative(cwd, resolve(p))); + + const tmpFile = join( + tmpdir(), + `rl-upload-${randomUUID()}.${gzip ? "tgz" : "tar"}`, + ); + + await tar.create( + { + file: tmpFile, + gzip, + cwd, + }, + relativePaths, + ); + + const buffer = await readFile(tmpFile); + await unlink(tmpFile); + return buffer; +} + 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 + const statsMap = new Map>>(); + for (const p of paths) { + try { + statsMap.set(p, await stat(p)); + } catch { + outputError(`Path does not exist: ${p}`); + return; + } + } - // Check if file exists and get stats - const stats = await stat(options.path); - const fileBuffer = await readFile(options.path); + const isTarType = contentType === "tar" || contentType === "tgz"; + const singlePath = paths.length === 1; + const singleIsDir = singlePath && statsMap.get(paths[0])!.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); + detectedContentType = contentType as ContentType; + fileSize = fileBuffer.length; + } else { + // Single file upload (existing behavior) + const singlePath = paths[0]; + const stats = statsMap.get(singlePath)!; + fileBuffer = await readFile(singlePath); + fileSize = Number(stats.size); - // 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(singlePath).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, }); @@ -71,16 +153,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); diff --git a/src/utils/commands.ts b/src/utils/commands.ts index 70f920d8..d5d46e40 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -648,8 +648,10 @@ export function createProgram(): Command { }); object - .command("upload ") - .description("Upload a file as an object") + .command("upload ") + .description( + "Upload file(s) or directory as an object. Multiple paths with --content-type tar|tgz creates an archive.", + ) .option("--name ", "Object name (required)") .option( "--content-type ", @@ -660,14 +662,16 @@ 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 }); } }); From 0008edf6bdeebb555c21ecbef919ab76d2c3b690 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Thu, 23 Apr 2026 13:53:22 -0700 Subject: [PATCH 03/13] test: add tests for multi-path tar/tgz upload Tests cover createTarBuffer helper and uploadObject decision matrix: - Single file upload (existing behavior) - Multi-file tar creation - Directory tar/tgz creation - Single tar file passthrough (no re-archiving) - Error cases: multi-path without tar type, directory without tar type, nonexistent paths Co-Authored-By: Claude Opus 4.6 --- .../__tests__/commands/object/upload.test.ts | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 tests/__tests__/commands/object/upload.test.ts diff --git a/tests/__tests__/commands/object/upload.test.ts b/tests/__tests__/commands/object/upload.test.ts new file mode 100644 index 00000000..686b8588 --- /dev/null +++ b/tests/__tests__/commands/object/upload.test.ts @@ -0,0 +1,262 @@ +/** + * Tests for object upload command - tar/tgz archive creation and validation + */ + +import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"; +import { mkdtemp, writeFile, mkdir, rm } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import * as tar from "tar"; + +// Mock client and output +const mockCreate = jest.fn(); +const mockComplete = jest.fn(); +jest.unstable_mockModule("@/utils/client.js", () => ({ + getClient: () => ({ + objects: { + create: mockCreate, + complete: mockComplete, + }, + }), +})); + +const mockOutput = jest.fn(); +const mockOutputError = jest.fn(); +jest.unstable_mockModule("@/utils/output.js", () => ({ + output: mockOutput, + outputError: mockOutputError, +})); + +// Mock fetch for upload +const mockFetch = jest.fn(); +globalThis.fetch = mockFetch; + +describe("createTarBuffer", () => { + let testDir: string; + + beforeEach(async () => { + jest.clearAllMocks(); + testDir = await mkdtemp(join(tmpdir(), "rl-upload-test-")); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it("creates a valid tar buffer from multiple files", async () => { + await writeFile(join(testDir, "a.txt"), "hello"); + await writeFile(join(testDir, "b.txt"), "world"); + + const { createTarBuffer } = await import("@/commands/object/upload.js"); + const buffer = await createTarBuffer( + [join(testDir, "a.txt"), join(testDir, "b.txt")], + false, + ); + + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.length).toBeGreaterThan(0); + + // Verify the tar contains the expected files + const entries: string[] = []; + const extractDir = await mkdtemp(join(tmpdir(), "rl-extract-test-")); + await tar.extract({ file: undefined, cwd: extractDir, sync: false }, undefined); + + // Write buffer to temp file and list contents + const tmpFile = join(testDir, "test.tar"); + await writeFile(tmpFile, buffer); + await tar.list({ + file: tmpFile, + onReadEntry: (entry) => { + entries.push(entry.path); + }, + }); + + expect(entries.length).toBe(2); + await rm(extractDir, { recursive: true, force: true }); + }); + + it("creates a valid tgz buffer (gzipped)", async () => { + await writeFile(join(testDir, "file.txt"), "compressed content"); + + const { createTarBuffer } = await import("@/commands/object/upload.js"); + const buffer = await createTarBuffer( + [join(testDir, "file.txt")], + true, + ); + + expect(buffer).toBeInstanceOf(Buffer); + // Gzip magic bytes: 0x1f 0x8b + expect(buffer[0]).toBe(0x1f); + expect(buffer[1]).toBe(0x8b); + }); + + it("creates a tar from a directory", async () => { + const subDir = join(testDir, "mydir"); + await mkdir(subDir); + await writeFile(join(subDir, "nested.txt"), "nested content"); + + const { createTarBuffer } = await import("@/commands/object/upload.js"); + const buffer = await createTarBuffer([subDir], false); + + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.length).toBeGreaterThan(0); + }); +}); + +describe("uploadObject", () => { + let testDir: string; + + beforeEach(async () => { + jest.clearAllMocks(); + testDir = await mkdtemp(join(tmpdir(), "rl-upload-test-")); + mockCreate.mockResolvedValue({ + id: "obj_test123", + upload_url: "https://example.com/upload", + }); + mockFetch.mockResolvedValue({ ok: true } as Response); + mockComplete.mockResolvedValue({}); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it("uploads a single file with existing behavior", async () => { + const filePath = join(testDir, "test.txt"); + await writeFile(filePath, "test content"); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: [filePath], + name: "test-object", + }); + + logSpy.mockRestore(); + + expect(mockCreate).toHaveBeenCalledWith({ + name: "test-object", + content_type: "text", + }); + expect(mockFetch).toHaveBeenCalled(); + expect(mockComplete).toHaveBeenCalledWith("obj_test123"); + }); + + it("errors when multiple paths given without tar/tgz content type", async () => { + const file1 = join(testDir, "a.txt"); + const file2 = join(testDir, "b.txt"); + await writeFile(file1, "a"); + await writeFile(file2, "b"); + + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: [file1, file2], + name: "test-object", + contentType: "text", + }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Multiple paths require --content-type tar or --content-type tgz", + ); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("errors when directory given without tar/tgz content type", async () => { + const dir = join(testDir, "mydir"); + await mkdir(dir); + + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: [dir], + name: "test-object", + }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Cannot upload a directory directly. Use --content-type tar or --content-type tgz to create an archive.", + ); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("errors when path does not exist", async () => { + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: [join(testDir, "nonexistent.txt")], + name: "test-object", + }); + + expect(mockOutputError).toHaveBeenCalledWith( + expect.stringContaining("Path does not exist"), + ); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("creates tar archive from multiple files when content type is tar", async () => { + const file1 = join(testDir, "a.txt"); + const file2 = join(testDir, "b.txt"); + await writeFile(file1, "file a"); + await writeFile(file2, "file b"); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: [file1, file2], + name: "multi-file-archive", + contentType: "tar", + }); + + logSpy.mockRestore(); + + expect(mockCreate).toHaveBeenCalledWith({ + name: "multi-file-archive", + content_type: "tar", + }); + expect(mockFetch).toHaveBeenCalled(); + expect(mockComplete).toHaveBeenCalledWith("obj_test123"); + }); + + it("creates tgz archive from a directory when content type is tgz", async () => { + const dir = join(testDir, "mydir"); + await mkdir(dir); + await writeFile(join(dir, "file.txt"), "content"); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: [dir], + name: "dir-archive", + contentType: "tgz", + }); + + logSpy.mockRestore(); + + expect(mockCreate).toHaveBeenCalledWith({ + name: "dir-archive", + content_type: "tgz", + }); + expect(mockFetch).toHaveBeenCalled(); + }); + + it("uploads single tar file as-is without creating archive", async () => { + const filePath = join(testDir, "existing.tar"); + await writeFile(filePath, "fake tar content"); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { uploadObject } = await import("@/commands/object/upload.js"); + await uploadObject({ + paths: [filePath], + name: "existing-archive", + contentType: "tar", + }); + + logSpy.mockRestore(); + + expect(mockCreate).toHaveBeenCalledWith({ + name: "existing-archive", + content_type: "tar", + }); + // Should upload the raw file content, not create a tar of the tar + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1]?.body as Buffer; + expect(body.toString()).toBe("fake tar content"); + }); +}); From 84f0bfd200a83a6facb6463cd4ae19c60dc8c9e9 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Thu, 23 Apr 2026 13:59:36 -0700 Subject: [PATCH 04/13] chore: apply prettier formatting Co-Authored-By: Claude Opus 4.6 --- src/commands/object/upload.ts | 3 +-- src/utils/commands.ts | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index 3bd52ee5..ae8da906 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -107,8 +107,7 @@ export async function uploadObject(options: UploadObjectOptions) { let detectedContentType: ContentType; let fileSize: number; - const shouldCreateArchive = - isTarType && (paths.length > 1 || singleIsDir); + const shouldCreateArchive = isTarType && (paths.length > 1 || singleIsDir); if (shouldCreateArchive) { const gzip = contentType === "tgz"; diff --git a/src/utils/commands.ts b/src/utils/commands.ts index d5d46e40..ff9bd0e6 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -667,9 +667,7 @@ export function createProgram(): Command { if (!options.output) { const { runInteractiveCommand } = await import("../utils/interactiveCommand.js"); - await runInteractiveCommand(() => - uploadObject({ paths, ...options }), - ); + await runInteractiveCommand(() => uploadObject({ paths, ...options })); } else { await uploadObject({ paths, ...options }); } From ba53fa674b8836292f52662c3710a6aef22a5939 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Thu, 23 Apr 2026 14:07:50 -0700 Subject: [PATCH 05/13] fix: remove unused import and update generated docs Remove unused `basename` import from upload.ts. Update README.md with new command signature. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- src/commands/object/upload.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 518ed882..537988c0 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ rli blueprint from-dockerfile # Create a blueprint from a Dockerfile rli object list # List objects rli object get # Get object details rli object download # Download object to local file -rli object upload # Upload a file as an object +rli object upload # Upload file(s) or directory as an obj... rli object delete # Delete an object (irreversible) ``` diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index ae8da906..17544eea 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -4,9 +4,8 @@ import { readFile, stat, unlink } from "fs/promises"; import { tmpdir } from "os"; -import { basename, extname, relative, resolve } from "path"; +import { extname, join, relative, resolve } from "path"; import { randomUUID } from "crypto"; -import { join } from "path"; import * as tar from "tar"; import { getClient } from "../../utils/client.js"; import { output, outputError } from "../../utils/output.js"; From 99a5c5c041a4285a81e1c76cb267fe853f5c9975 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Thu, 23 Apr 2026 16:52:34 -0700 Subject: [PATCH 06/13] docs: add docstring to createTarBuffer explaining temp file approach The tar package's tar.create() only supports writing to a file path or stream, not returning a buffer directly. The docstring explains why we write to a temp file and read it back. Co-Authored-By: Claude Opus 4.6 --- src/commands/object/upload.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index 17544eea..ab004f02 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -35,6 +35,15 @@ const CONTENT_TYPE_MAP: Record = { ".tar.gz": "tgz", }; +/** + * Create a tar (or tgz) archive as a Buffer from the given filesystem paths. + * + * Uses a temp file because the `tar` package's `tar.create()` only supports + * writing to a file path or to a stream — it has no "return a buffer" + * option. We write to a temp file, read it back into memory, then delete + * the temp file. This is the simplest reliable approach for producing a + * single Buffer suitable for an HTTP PUT body. + */ export async function createTarBuffer( paths: string[], gzip: boolean, From cd6eb203645138aec482ca47fd2dc9da6de552d0 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Thu, 23 Apr 2026 17:09:28 -0700 Subject: [PATCH 07/13] refactor: replace tar package with nanotar, normalize permissions Switch from the tar package (which requires a temp file roundtrip) to nanotar (returns Uint8Array directly). Walk directories recursively and normalize all entries: uid/gid 1000, mode 644 for non-executable files, 755 for executable files and directories. Preserve mtime from the filesystem. Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- pnpm-lock.yaml | 11 ++- src/commands/object/upload.ts | 93 ++++++++++++------ .../__tests__/commands/object/upload.test.ts | 94 +++++++++++++++---- 4 files changed, 148 insertions(+), 52 deletions(-) diff --git a/package.json b/package.json index b6bbdab3..0462aef4 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "@runloop/api-client": "1.16.0", "@types/express": "5.0.6", "adm-zip": "0.5.16", - "tar": "^7.5.13", "chalk": "5.6.2", "commander": "14.0.2", "conf": "15.1.0", @@ -89,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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e19d279e..61a666d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,12 +86,12 @@ importers: ink-text-input: specifier: 6.0.0 version: 6.0.0(ink@6.6.0(@types/react@19.2.10)(react@19.2.0))(react@19.2.0) + nanotar: + specifier: 0.3.0 + version: 0.3.0 react: specifier: 19.2.0 version: 19.2.0 - tar: - specifier: ^7.5.13 - version: 7.5.13 yaml: specifier: 2.8.3 version: 2.8.3 @@ -2361,6 +2361,9 @@ packages: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + nanotar@0.3.0: + resolution: {integrity: sha512-Kv2JYYiCzt16Kt5QwAc9BFG89xfPNBx+oQL4GQXD9nLqPkZBiNaqaCWtwnbk/q7UVsTYevvM1b0UF8zmEI4pCg==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -5917,6 +5920,8 @@ snapshots: mute-stream@1.0.0: {} + nanotar@0.3.0: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index ab004f02..22e3075c 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -2,11 +2,10 @@ * Upload object command */ -import { readFile, stat, unlink } from "fs/promises"; -import { tmpdir } from "os"; -import { extname, join, relative, resolve } from "path"; -import { randomUUID } from "crypto"; -import * as tar from "tar"; +import { readFile, readdir, stat } from "fs/promises"; +import { extname, relative, resolve } 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"; @@ -35,39 +34,75 @@ const CONTENT_TYPE_MAP: Record = { ".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. + */ +async function collectEntries( + paths: string[], + cwd: string, +): Promise { + const entries: TarFileInput[] = []; + + for (const p of paths) { + const absPath = resolve(p); + const relPath = relative(cwd, absPath); + const stats = await stat(absPath); + + if (stats.isDirectory()) { + entries.push({ + name: relPath.endsWith("/") ? relPath : relPath + "/", + attrs: { + mode: "755", + uid: 1000, + gid: 1000, + mtime: stats.mtimeMs, + }, + }); + const children = await readdir(absPath); + const childPaths = children.map((c) => resolve(absPath, c)); + if (childPaths.length > 0) { + entries.push(...(await collectEntries(childPaths, cwd))); + } + } else { + const isExecutable = (stats.mode & 0o111) !== 0; + entries.push({ + name: relPath, + data: await readFile(absPath), + attrs: { + mode: isExecutable ? "755" : "644", + uid: 1000, + gid: 1000, + mtime: stats.mtimeMs, + }, + }); + } + } + + return entries; +} + /** * Create a tar (or tgz) archive as a Buffer from the given filesystem paths. * - * Uses a temp file because the `tar` package's `tar.create()` only supports - * writing to a file path or to a stream — it has no "return a buffer" - * option. We write to a temp file, read it back into memory, then delete - * the temp file. This is the simplest reliable approach for producing a - * single Buffer suitable for an HTTP PUT body. + * Walks directories recursively and normalizes all entries to uid/gid 1000, + * mode 644 (non-executable files) or 755 (executable files and directories). */ export async function createTarBuffer( paths: string[], gzip: boolean, ): Promise { const cwd = process.cwd(); - const relativePaths = paths.map((p) => relative(cwd, resolve(p))); - - const tmpFile = join( - tmpdir(), - `rl-upload-${randomUUID()}.${gzip ? "tgz" : "tar"}`, - ); - - await tar.create( - { - file: tmpFile, - gzip, - cwd, - }, - relativePaths, - ); - - const buffer = await readFile(tmpFile); - await unlink(tmpFile); - return buffer; + const entries = await collectEntries(paths, cwd); + + 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) { diff --git a/tests/__tests__/commands/object/upload.test.ts b/tests/__tests__/commands/object/upload.test.ts index 686b8588..a392f827 100644 --- a/tests/__tests__/commands/object/upload.test.ts +++ b/tests/__tests__/commands/object/upload.test.ts @@ -3,10 +3,10 @@ */ import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"; -import { mkdtemp, writeFile, mkdir, rm } from "fs/promises"; +import { mkdtemp, writeFile, mkdir, rm, chmod, utimes } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; -import * as tar from "tar"; +import { parseTar } from "nanotar"; // Mock client and output const mockCreate = jest.fn(); @@ -56,23 +56,11 @@ describe("createTarBuffer", () => { expect(buffer).toBeInstanceOf(Buffer); expect(buffer.length).toBeGreaterThan(0); - // Verify the tar contains the expected files - const entries: string[] = []; - const extractDir = await mkdtemp(join(tmpdir(), "rl-extract-test-")); - await tar.extract({ file: undefined, cwd: extractDir, sync: false }, undefined); - - // Write buffer to temp file and list contents - const tmpFile = join(testDir, "test.tar"); - await writeFile(tmpFile, buffer); - await tar.list({ - file: tmpFile, - onReadEntry: (entry) => { - entries.push(entry.path); - }, - }); - - expect(entries.length).toBe(2); - await rm(extractDir, { recursive: true, force: true }); + const entries = parseTar(buffer); + const names = entries.map((e) => e.name); + expect(names).toHaveLength(2); + expect(names.some((n) => n.endsWith("a.txt"))).toBe(true); + expect(names.some((n) => n.endsWith("b.txt"))).toBe(true); }); it("creates a valid tgz buffer (gzipped)", async () => { @@ -100,6 +88,74 @@ describe("createTarBuffer", () => { expect(buffer).toBeInstanceOf(Buffer); expect(buffer.length).toBeGreaterThan(0); + + const entries = parseTar(buffer); + const dirEntry = entries.find((e) => e.name.endsWith("mydir/")); + const fileEntry = entries.find((e) => e.name.endsWith("nested.txt")); + expect(dirEntry).toBeDefined(); + expect(fileEntry).toBeDefined(); + }); + + it("normalizes uid/gid to 1000 for all entries", async () => { + await writeFile(join(testDir, "file.txt"), "content"); + + const { createTarBuffer } = await import("@/commands/object/upload.js"); + const buffer = await createTarBuffer([join(testDir, "file.txt")], false); + + // Verify uid/gid by reading the raw tar header bytes directly. + // nanotar's parseTar has an octal parsing quirk, so read the raw field. + const uid = buffer.toString("ascii", 108, 115).replace(/\0/g, "").trim(); + const gid = buffer.toString("ascii", 116, 123).replace(/\0/g, "").trim(); + expect(parseInt(uid, 8)).toBe(1000); + expect(parseInt(gid, 8)).toBe(1000); + }); + + it("sets mode 644 for non-executable files and 755 for executable files", async () => { + const normalFile = join(testDir, "normal.txt"); + const execFile = join(testDir, "script.sh"); + await writeFile(normalFile, "data"); + await writeFile(execFile, "#!/bin/sh\necho hi"); + await chmod(execFile, 0o755); + + const { createTarBuffer } = await import("@/commands/object/upload.js"); + const buffer = await createTarBuffer([normalFile, execFile], false); + + const entries = parseTar(buffer); + const normal = entries.find((e) => e.name.endsWith("normal.txt")); + const exec = entries.find((e) => e.name.endsWith("script.sh")); + expect(normal?.attrs?.mode).toContain("644"); + expect(exec?.attrs?.mode).toContain("755"); + }); + + it("sets mode 755 for directories", async () => { + const subDir = join(testDir, "subdir"); + await mkdir(subDir); + await writeFile(join(subDir, "file.txt"), "content"); + + const { createTarBuffer } = await import("@/commands/object/upload.js"); + const buffer = await createTarBuffer([subDir], false); + + const entries = parseTar(buffer); + const dir = entries.find((e) => e.name.endsWith("subdir/")); + expect(dir?.attrs?.mode).toContain("755"); + }); + + it("preserves mtime from the filesystem", async () => { + const filePath = join(testDir, "dated.txt"); + await writeFile(filePath, "content"); + // Set a known mtime: 2024-01-15T00:00:00Z + const knownTime = new Date("2024-01-15T00:00:00Z"); + await utimes(filePath, knownTime, knownTime); + + const { createTarBuffer } = await import("@/commands/object/upload.js"); + const buffer = await createTarBuffer([filePath], false); + + const entries = parseTar(buffer); + const entry = entries.find((e) => e.name.endsWith("dated.txt")); + expect(entry?.attrs?.mtime).toBeDefined(); + // parseTar returns mtime in seconds (raw tar format) + const expectedSec = Math.floor(knownTime.getTime() / 1000); + expect(entry?.attrs?.mtime).toBe(expectedSec); }); }); From e7b91ab27a1a0c7ed18caace383b826070fc69e7 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Thu, 23 Apr 2026 18:40:40 -0700 Subject: [PATCH 08/13] fix: add symlink protection and user-friendly errors in archive creation Skip symlinks with a warning to stderr (prevents infinite recursion from symlink cycles) and wrap lstat/readFile in try-catch with descriptive error messages instead of raw Node errors. Co-Authored-By: Claude Opus 4.6 --- src/commands/object/upload.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index 22e3075c..850e0b61 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -2,7 +2,7 @@ * Upload object command */ -import { readFile, readdir, stat } from "fs/promises"; +import { lstat, readFile, readdir, stat } from "fs/promises"; import { extname, relative, resolve } from "path"; import { createTar, createTarGzip } from "nanotar"; import type { TarFileInput } from "nanotar"; @@ -48,7 +48,18 @@ async function collectEntries( for (const p of paths) { const absPath = resolve(p); const relPath = relative(cwd, absPath); - const stats = await stat(absPath); + + let stats; + try { + stats = await lstat(absPath); + } catch { + throw new Error(`Cannot read path: ${relPath}`); + } + + if (stats.isSymbolicLink()) { + console.error(`Skipping symlink: ${relPath}`); + continue; + } if (stats.isDirectory()) { entries.push({ @@ -67,9 +78,15 @@ async function collectEntries( } } else { const isExecutable = (stats.mode & 0o111) !== 0; + let data; + try { + data = await readFile(absPath); + } catch { + throw new Error(`Cannot read file: ${relPath}`); + } entries.push({ name: relPath, - data: await readFile(absPath), + data, attrs: { mode: isExecutable ? "755" : "644", uid: 1000, From 85177e3c9a96677a11f3ba3215f9840cb2d2db7e Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Thu, 23 Apr 2026 19:03:05 -0700 Subject: [PATCH 09/13] fix: address code review feedback for tar/tgz upload - Guard against path traversal: compute common ancestor as archive root, strip leading ../ from entry names - Use lstat consistently for validation (detect symlinks before archiving) - Reject top-level symlink paths with clear error message - Chain error causes in catch blocks for better diagnostics - Rename singlePath boolean to isSinglePath to avoid variable shadowing - Add mtime documentation comment (nanotar expects ms, converts internally) - Use console.warn for symlink skip warnings during recursive collection Co-Authored-By: Claude Opus 4.6 --- src/commands/object/upload.ts | 83 +++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 22 deletions(-) diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index 850e0b61..6a46cb17 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -2,8 +2,8 @@ * Upload object command */ -import { lstat, readFile, readdir, stat } from "fs/promises"; -import { extname, relative, resolve } from "path"; +import { lstat, readFile, readdir } from "fs/promises"; +import { basename, dirname, extname, relative, resolve } from "path"; import { createTar, createTarGzip } from "nanotar"; import type { TarFileInput } from "nanotar"; import { getClient } from "../../utils/client.js"; @@ -38,26 +38,34 @@ const CONTENT_TYPE_MAP: Record = { * 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[], - cwd: string, + archiveRoot: string, ): Promise { const entries: TarFileInput[] = []; for (const p of paths) { const absPath = resolve(p); - const relPath = relative(cwd, absPath); + let relPath = relative(archiveRoot, absPath); + + // Guard against path traversal: entry names must not escape the archive root + if (relPath.startsWith("..")) { + relPath = basename(absPath); + } let stats; try { stats = await lstat(absPath); - } catch { - throw new Error(`Cannot read path: ${relPath}`); + } catch (err) { + throw new Error(`Cannot read path: ${relPath}`, { cause: err }); } if (stats.isSymbolicLink()) { - console.error(`Skipping symlink: ${relPath}`); + console.warn(`Warning: Skipping symlink: ${relPath}`); continue; } @@ -68,21 +76,22 @@ async function collectEntries( mode: "755", uid: 1000, gid: 1000, + // nanotar expects mtime in milliseconds and converts to seconds internally mtime: stats.mtimeMs, }, }); const children = await readdir(absPath); const childPaths = children.map((c) => resolve(absPath, c)); if (childPaths.length > 0) { - entries.push(...(await collectEntries(childPaths, cwd))); + entries.push(...(await collectEntries(childPaths, archiveRoot))); } } else { const isExecutable = (stats.mode & 0o111) !== 0; let data; try { data = await readFile(absPath); - } catch { - throw new Error(`Cannot read file: ${relPath}`); + } catch (err) { + throw new Error(`Cannot read file: ${relPath}`, { cause: err }); } entries.push({ name: relPath, @@ -91,6 +100,7 @@ async function collectEntries( mode: isExecutable ? "755" : "644", uid: 1000, gid: 1000, + // nanotar expects mtime in milliseconds and converts to seconds internally mtime: stats.mtimeMs, }, }); @@ -100,18 +110,40 @@ async function collectEntries( 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("/")); + 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("/") || "/"; +} + /** * 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, ): Promise { - const cwd = process.cwd(); - const entries = await collectEntries(paths, cwd); + const absPaths = paths.map((p) => resolve(p)); + const archiveRoot = commonAncestor(absPaths); + const entries = await collectEntries(paths, archiveRoot); if (gzip) { const data = await createTarGzip(entries); @@ -132,11 +164,18 @@ export async function uploadObject(options: UploadObjectOptions) { return; } - // Validate all paths exist - const statsMap = new Map>>(); + // Validate all paths exist (use lstat to match collectEntries and detect symlinks) + const statsMap = new Map>>(); for (const p of paths) { try { - statsMap.set(p, await stat(p)); + 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(p, s); } catch { outputError(`Path does not exist: ${p}`); return; @@ -144,8 +183,9 @@ export async function uploadObject(options: UploadObjectOptions) { } const isTarType = contentType === "tar" || contentType === "tgz"; - const singlePath = paths.length === 1; - const singleIsDir = singlePath && statsMap.get(paths[0])!.isDirectory(); + const isSinglePath = paths.length === 1; + const firstStats = isSinglePath ? statsMap.get(paths[0])! : undefined; + const singleIsDir = isSinglePath && firstStats!.isDirectory(); // Multi-path requires tar/tgz content type if (paths.length > 1 && !isTarType) { @@ -176,14 +216,13 @@ export async function uploadObject(options: UploadObjectOptions) { fileSize = fileBuffer.length; } else { // Single file upload (existing behavior) - const singlePath = paths[0]; - const stats = statsMap.get(singlePath)!; - fileBuffer = await readFile(singlePath); - fileSize = Number(stats.size); + const filePath = paths[0]; + fileBuffer = await readFile(filePath); + fileSize = Number(firstStats!.size); detectedContentType = contentType as ContentType; if (!detectedContentType) { - const ext = extname(singlePath).toLowerCase(); + const ext = extname(filePath).toLowerCase(); detectedContentType = CONTENT_TYPE_MAP[ext] || "unspecified"; } } From 5a37c69261bc6d040f2d5508e60188f5ddec93b8 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Thu, 23 Apr 2026 19:13:59 -0700 Subject: [PATCH 10/13] fix: address code review findings for tar/tgz upload - Use path.sep in commonAncestor for cross-platform support - Error consistently on symlinks (collectEntries now throws instead of warn+skip) - Reuse pre-validated stats in createTarBuffer to avoid double lstat - Use caret version for nanotar dependency (^0.3.0) - Add test for symlink error during archive creation Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- pnpm-lock.yaml | 2 +- src/commands/object/upload.ts | 36 +++++++++++-------- .../__tests__/commands/object/upload.test.ts | 14 +++++++- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 0462aef4..5a6a01a6 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "ink-link": "5.0.0", "ink-spinner": "5.0.0", "ink-text-input": "6.0.0", - "nanotar": "0.3.0", + "nanotar": "^0.3.0", "react": "19.2.0", "yaml": "2.8.3", "zustand": "5.0.10" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61a666d7..465c79fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,7 +87,7 @@ importers: specifier: 6.0.0 version: 6.0.0(ink@6.6.0(@types/react@19.2.10)(react@19.2.0))(react@19.2.0) nanotar: - specifier: 0.3.0 + specifier: ^0.3.0 version: 0.3.0 react: specifier: 19.2.0 diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index 6a46cb17..13764a47 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -3,7 +3,7 @@ */ import { lstat, readFile, readdir } from "fs/promises"; -import { basename, dirname, extname, relative, resolve } from "path"; +import { basename, dirname, extname, relative, resolve, sep } from "path"; import { createTar, createTarGzip } from "nanotar"; import type { TarFileInput } from "nanotar"; import { getClient } from "../../utils/client.js"; @@ -45,6 +45,7 @@ const CONTENT_TYPE_MAP: Record = { async function collectEntries( paths: string[], archiveRoot: string, + precomputedStats?: Map>>, ): Promise { const entries: TarFileInput[] = []; @@ -57,16 +58,19 @@ async function collectEntries( relPath = basename(absPath); } - let stats; - try { - stats = await lstat(absPath); - } catch (err) { - throw new Error(`Cannot read path: ${relPath}`, { cause: err }); + 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()) { - console.warn(`Warning: Skipping symlink: ${relPath}`); - continue; + throw new Error( + `Path is a symlink: ${relPath}. Resolve the symlink or pass the target path directly.`, + ); } if (stats.isDirectory()) { @@ -117,7 +121,7 @@ async function collectEntries( 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("/")); + 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]; @@ -127,7 +131,7 @@ function commonAncestor(absPaths: string[]): string { break; } } - return common.join("/") || "/"; + return common.join(sep) || sep; } /** @@ -140,10 +144,11 @@ function commonAncestor(absPaths: string[]): string { export async function createTarBuffer( paths: string[], gzip: boolean, + precomputedStats?: Map>>, ): Promise { const absPaths = paths.map((p) => resolve(p)); const archiveRoot = commonAncestor(absPaths); - const entries = await collectEntries(paths, archiveRoot); + const entries = await collectEntries(paths, archiveRoot, precomputedStats); if (gzip) { const data = await createTarGzip(entries); @@ -165,6 +170,7 @@ export async function uploadObject(options: UploadObjectOptions) { } // 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>>(); for (const p of paths) { try { @@ -175,7 +181,7 @@ export async function uploadObject(options: UploadObjectOptions) { ); return; } - statsMap.set(p, s); + statsMap.set(resolve(p), s); } catch { outputError(`Path does not exist: ${p}`); return; @@ -184,7 +190,9 @@ export async function uploadObject(options: UploadObjectOptions) { const isTarType = contentType === "tar" || contentType === "tgz"; const isSinglePath = paths.length === 1; - const firstStats = isSinglePath ? statsMap.get(paths[0])! : undefined; + const firstStats = isSinglePath + ? statsMap.get(resolve(paths[0]))! + : undefined; const singleIsDir = isSinglePath && firstStats!.isDirectory(); // Multi-path requires tar/tgz content type @@ -211,7 +219,7 @@ export async function uploadObject(options: UploadObjectOptions) { if (shouldCreateArchive) { const gzip = contentType === "tgz"; - fileBuffer = await createTarBuffer(paths, gzip); + fileBuffer = await createTarBuffer(paths, gzip, statsMap); detectedContentType = contentType as ContentType; fileSize = fileBuffer.length; } else { diff --git a/tests/__tests__/commands/object/upload.test.ts b/tests/__tests__/commands/object/upload.test.ts index a392f827..0e00c9e9 100644 --- a/tests/__tests__/commands/object/upload.test.ts +++ b/tests/__tests__/commands/object/upload.test.ts @@ -3,7 +3,7 @@ */ import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"; -import { mkdtemp, writeFile, mkdir, rm, chmod, utimes } from "fs/promises"; +import { mkdtemp, writeFile, mkdir, rm, chmod, utimes, symlink } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; import { parseTar } from "nanotar"; @@ -140,6 +140,18 @@ describe("createTarBuffer", () => { expect(dir?.attrs?.mode).toContain("755"); }); + it("errors on symlinks inside a directory tree", async () => { + const subDir = join(testDir, "with-symlink"); + await mkdir(subDir); + await writeFile(join(subDir, "real.txt"), "real content"); + await symlink(join(subDir, "real.txt"), join(subDir, "link.txt")); + + const { createTarBuffer } = await import("@/commands/object/upload.js"); + await expect(createTarBuffer([subDir], false)).rejects.toThrow( + /symlink/i, + ); + }); + it("preserves mtime from the filesystem", async () => { const filePath = join(testDir, "dated.txt"); await writeFile(filePath, "content"); From 1f18c606f9d43085ff1083884f0ef624d2feb991 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Thu, 23 Apr 2026 19:16:53 -0700 Subject: [PATCH 11/13] fix: cast Stats bigint fields to number for nanotar compatibility Co-Authored-By: Claude Opus 4.6 --- src/commands/object/upload.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index 13764a47..863a0474 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -81,7 +81,7 @@ async function collectEntries( uid: 1000, gid: 1000, // nanotar expects mtime in milliseconds and converts to seconds internally - mtime: stats.mtimeMs, + mtime: Number(stats.mtimeMs), }, }); const children = await readdir(absPath); @@ -90,7 +90,7 @@ async function collectEntries( entries.push(...(await collectEntries(childPaths, archiveRoot))); } } else { - const isExecutable = (stats.mode & 0o111) !== 0; + const isExecutable = (Number(stats.mode) & 0o111) !== 0; let data; try { data = await readFile(absPath); @@ -105,7 +105,7 @@ async function collectEntries( uid: 1000, gid: 1000, // nanotar expects mtime in milliseconds and converts to seconds internally - mtime: stats.mtimeMs, + mtime: Number(stats.mtimeMs), }, }); } From 5877ea457bca76ca1d25acdeaab9e03e3a488659 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Thu, 23 Apr 2026 19:34:42 -0700 Subject: [PATCH 12/13] fix: use fileBuffer.length for fileSize and sort readdir results Use fileBuffer.length instead of lstat size for consistency with the archive branch and to avoid potential bugs if fileSize is later used for Content-Length. Sort readdir results for deterministic tar archive creation across different OSes and filesystems. Co-Authored-By: Claude Opus 4.6 --- src/commands/object/upload.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index 863a0474..f31fb64c 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -84,7 +84,7 @@ async function collectEntries( mtime: Number(stats.mtimeMs), }, }); - const children = await readdir(absPath); + const children = (await readdir(absPath)).sort(); const childPaths = children.map((c) => resolve(absPath, c)); if (childPaths.length > 0) { entries.push(...(await collectEntries(childPaths, archiveRoot))); @@ -226,7 +226,7 @@ export async function uploadObject(options: UploadObjectOptions) { // Single file upload (existing behavior) const filePath = paths[0]; fileBuffer = await readFile(filePath); - fileSize = Number(firstStats!.size); + fileSize = fileBuffer.length; detectedContentType = contentType as ContentType; if (!detectedContentType) { From e40583bc6ae755a232f0329c949d5f615a712037 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Fri, 24 Apr 2026 09:15:32 -0700 Subject: [PATCH 13/13] fix: throw error on path traversal instead of silent basename fallback The previous behavior silently fell back to basename(absPath) when a relative path started with "..", which could cause name collisions if two files from different directories shared the same basename. Now throws an explicit error so the user knows all paths must share a common ancestor directory. Co-Authored-By: Claude Opus 4.6 --- src/commands/object/upload.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index f31fb64c..b5f4fde1 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -3,7 +3,7 @@ */ import { lstat, readFile, readdir } from "fs/promises"; -import { basename, dirname, extname, relative, resolve, sep } from "path"; +import { dirname, extname, relative, resolve, sep } from "path"; import { createTar, createTarGzip } from "nanotar"; import type { TarFileInput } from "nanotar"; import { getClient } from "../../utils/client.js"; @@ -55,7 +55,9 @@ async function collectEntries( // Guard against path traversal: entry names must not escape the archive root if (relPath.startsWith("..")) { - relPath = basename(absPath); + throw new Error( + `Path "${absPath}" is outside the archive root "${archiveRoot}". All paths must share a common ancestor directory.`, + ); } let stats = precomputedStats?.get(absPath);