feat(cli): add image commands for cloud storage management#728
feat(cli): add image commands for cloud storage management#728ddupont808 merged 4 commits intomainfrom
Conversation
Adds `cua image` command group to manage VM images in cloud storage: - `cua image list` - List all images in workspace - `cua image upload <file>` - Upload VM image with multipart upload - `cua image download <name>` - Download VM image with checksum verification - `cua image delete <name>` - Delete image version Integrates with the golden image cloud storage API from trycua/cloud#758. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR adds a new cua image command group to manage VM images in cloud storage, enabling users to upload, download, list, and delete VM images with features like multipart upload for large files and checksum verification.
Key changes:
- Implements multipart upload functionality for handling large image files (up to several GB)
- Adds checksum verification (SHA256) for both uploads and downloads
- Provides four commands: list, upload, download, and delete with appropriate options
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| libs/typescript/cua-cli/src/commands/image.ts | New file implementing all image management commands with multipart upload, checksum verification, and error handling |
| libs/typescript/cua-cli/src/cli.ts | Integrates image commands into the CLI and updates help text to include image command documentation |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Default part size: 100MB | ||
| const DEFAULT_PART_SIZE = 100 * 1024 * 1024; | ||
|
|
There was a problem hiding this comment.
The DEFAULT_PART_SIZE constant is defined but never used in the code. The actual part size is determined by the server in the UploadSession response. Consider removing this unused constant or documenting its purpose if it's meant for reference.
| // Default part size: 100MB | |
| const DEFAULT_PART_SIZE = 100 * 1024 * 1024; |
| // Get ETag from response | ||
| const etag = uploadRes.headers.get('ETag') || ''; |
There was a problem hiding this comment.
If the ETag header is missing from the upload response, an empty string is used, which could cause the multipart upload completion to fail. Consider adding validation to ensure the ETag is present, and abort the upload if it's missing.
| // Get ETag from response | |
| const etag = uploadRes.headers.get('ETag') || ''; | |
| // Get ETag from response and validate it | |
| const etag = uploadRes.headers.get('ETag'); | |
| if (!etag) { | |
| console.error(`\nMissing ETag header for part ${partNum}`); | |
| // Abort upload | |
| await http(`/v1/images/${encodeURIComponent(name)}/upload/${session.upload_id}`, { | |
| token, | |
| method: 'DELETE', | |
| }); | |
| process.exit(1); | |
| } |
| const fileBuffer = await file.arrayBuffer(); | ||
|
|
||
| for (let partNum = 1; partNum <= session.total_parts; partNum++) { | ||
| const start = (partNum - 1) * session.part_size; | ||
| const end = Math.min(start + session.part_size, sizeBytes); | ||
| const partData = fileBuffer.slice(start, end); |
There was a problem hiding this comment.
Loading the entire file into memory with await file.arrayBuffer() is inefficient and could cause memory issues for large files (several GB as mentioned in the PR description). Consider using Bun's streaming API to read the file in chunks instead. You can use file.slice(start, end) for each part without loading the entire file into memory first.
| const fileBuffer = await file.arrayBuffer(); | |
| for (let partNum = 1; partNum <= session.total_parts; partNum++) { | |
| const start = (partNum - 1) * session.part_size; | |
| const end = Math.min(start + session.part_size, sizeBytes); | |
| const partData = fileBuffer.slice(start, end); | |
| for (let partNum = 1; partNum <= session.total_parts; partNum++) { | |
| const start = (partNum - 1) * session.part_size; | |
| const end = Math.min(start + session.part_size, sizeBytes); | |
| const partData = file.slice(start, end); |
| // Helper to calculate SHA256 hash of a file | ||
| async function calculateFileHash(filePath: string): Promise<string> { | ||
| const file = Bun.file(filePath); | ||
| const buffer = await file.arrayBuffer(); |
There was a problem hiding this comment.
Loading the entire file into memory to calculate the hash is inefficient for large files. Consider using a streaming approach where you read the file in chunks and update the hash incrementally. This would prevent memory issues with multi-GB files.
| const buffer = await downloadRes.arrayBuffer(); | ||
| await Bun.write(outputPath, buffer); |
There was a problem hiding this comment.
Loading the entire download into memory with await downloadRes.arrayBuffer() is inefficient for large files. Consider streaming the response directly to disk using Bun's streaming capabilities or writing in chunks to avoid memory issues with multi-GB files.
| const buffer = await downloadRes.arrayBuffer(); | |
| await Bun.write(outputPath, buffer); | |
| await Bun.write(outputPath, downloadRes); |
| if (downloadedChecksum !== urlData.checksum_sha256) { | ||
| console.error('Checksum mismatch! Download may be corrupted.'); | ||
| console.error(`Expected: ${urlData.checksum_sha256}`); | ||
| console.error(`Got: ${downloadedChecksum}`); | ||
| process.exit(1); |
There was a problem hiding this comment.
When checksum verification fails after downloading a file, the corrupted file is left on disk and the process exits without cleanup.
View Details
📝 Patch Details
diff --git a/libs/typescript/cua-cli/src/commands/image.ts b/libs/typescript/cua-cli/src/commands/image.ts
index b746d973..91b1d2d8 100644
--- a/libs/typescript/cua-cli/src/commands/image.ts
+++ b/libs/typescript/cua-cli/src/commands/image.ts
@@ -304,6 +304,7 @@ const downloadHandler = async (argv: Record<string, unknown>) => {
console.error('Checksum mismatch! Download may be corrupted.');
console.error(`Expected: ${urlData.checksum_sha256}`);
console.error(`Got: ${downloadedChecksum}`);
+ await Bun.file(outputPath).delete();
process.exit(1);
}
Analysis
Corrupted file not cleaned up when checksum verification fails in downloadHandler
What fails: In downloadHandler() at libs/typescript/cua-cli/src/commands/image.ts, when checksum verification fails after downloading a file, the corrupted file is left on disk and the process exits without cleanup.
How to reproduce:
- Call the download command with an image that will have a corrupted checksum
- The file is written to disk via
await Bun.write(outputPath, buffer)at line 297 - Checksum verification happens at line 301
- If the checksum doesn't match (line 303-307), the process exits via
process.exit(1)without deleting the corrupted file
What happens: The corrupted file remains on disk after the download fails due to checksum mismatch, potentially confusing users who may later attempt to use the corrupted file without realizing it failed verification.
Expected: When checksum verification fails, the corrupted file should be deleted before the process exits, per Bun file I/O documentation.
Fix: Added await Bun.file(outputPath).delete(); before process.exit(1) in the checksum verification failure block.
| const token = await ensureApiKeyInteractive(); | ||
| const name = String(argv.name); | ||
| const tag = String(argv.tag || 'latest'); | ||
| const outputPath = String(argv.output || `${name}-${tag}.qcow2`); |
There was a problem hiding this comment.
The download command's --output parameter accepts arbitrary file paths without validation, allowing path traversal attacks (e.g., ../../sensitive/file).
View Details
📝 Patch Details
diff --git a/libs/typescript/cua-cli/src/commands/image.ts b/libs/typescript/cua-cli/src/commands/image.ts
index b746d973..8be1c368 100644
--- a/libs/typescript/cua-cli/src/commands/image.ts
+++ b/libs/typescript/cua-cli/src/commands/image.ts
@@ -1,5 +1,6 @@
import type { Argv } from 'yargs';
import { createHash } from 'crypto';
+import { resolve, relative } from 'path';
import { ensureApiKeyInteractive } from '../auth';
import { http } from '../http';
import { clearApiKey } from '../storage';
@@ -70,6 +71,24 @@ function printTable(headers: string[], rows: string[][]) {
console.log(r.map((c, i) => (c ?? '').padEnd(widths[i] ?? 0)).join(' '));
}
+// Helper to validate output path against directory traversal attacks
+function validateOutputPath(outputPath: string): void {
+ const cwd = process.cwd();
+ const resolvedPath = resolve(outputPath);
+ const relativePath = relative(cwd, resolvedPath);
+
+ // Check if the resolved path escapes the current working directory
+ if (relativePath.startsWith('..')) {
+ console.error(`Error: Output path "${outputPath}" escapes the current working directory`);
+ process.exit(1);
+ }
+
+ // Check if it's an absolute path
+ if (resolve(outputPath) !== resolvedPath.replace(cwd, cwd)) {
+ // This is already checked above, but kept for clarity
+ }
+}
+
// Command handlers
const listHandler = async (argv: Record<string, unknown>) => {
const token = await ensureApiKeyInteractive();
@@ -258,6 +277,7 @@ const downloadHandler = async (argv: Record<string, unknown>) => {
const name = String(argv.name);
const tag = String(argv.tag || 'latest');
const outputPath = String(argv.output || `${name}-${tag}.qcow2`);
+ validateOutputPath(outputPath);
console.log(`Downloading ${name}:${tag}...`);
Analysis
Path Traversal Vulnerability in image download command allows arbitrary file writes
What fails: The cua image download command accepts user-provided output paths without validation, allowing attackers to write files outside the current working directory using path traversal sequences like ../../.
How to reproduce:
# In a safe directory, run:
cua image download myimage --output "../../etc/malicious.txt"The file would be written to /etc/malicious.txt instead of the intended location in the current working directory.
What happens: Without validation, Bun.write(outputPath, buffer) allows paths containing ../ sequences to traverse directories and write to arbitrary locations within filesystem permissions.
Expected behavior: The --output path should be validated to ensure it resolves within the current working directory, preventing directory traversal attacks. This follows OWASP guidance on path traversal prevention and matches fixes in similar tools like curl (CVE-2016-0754).
References:
- Change `cua image upload <file> --name` to `cua image upload <name>` - Auto-detect image path from cua-bench local storage - Add optional --file flag to specify custom path Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
@copilot pull and push instead of upload and download |
|
@r33drichards I've opened a new pull request, #731, to work on those changes. Once the pull request is ready, I'll request review from you. |
* Initial plan * refactor: rename upload/download to push/pull for image commands Co-authored-by: r33drichards <57335981+r33drichards@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: r33drichards <57335981+r33drichards@users.noreply.github.com>
Summary
cua imagecommand group to manage VM images in cloud storageCommands Added
cua image listcua image upload <name>cua image download <name>cua image delete <name> --forceUsage Examples
Integration with cua-bench
The upload command automatically looks for images in the cua-bench local storage:
This enables a seamless workflow:
Test plan
cua image --helpto verify command structurecua image listwith valid credentials🤖 Generated with Claude Code