From 8ad3498e79cb91175e1e054bb86821f752df400a Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 23:03:26 +0300 Subject: [PATCH 1/2] feat(cli): add local file upload --- README.md | 7 ++ skills/putio-cli/references/guardrails.md | 1 + skills/putio-cli/references/writes.md | 3 + src/cli.test.ts | 8 ++ src/command-paths.test.ts | 136 +++++++++++++++++++++ src/commands/files.test.ts | 16 +++ src/commands/files.ts | 139 ++++++++++++++++++++++ src/i18n/catalog/en.ts | 4 + src/internal/command.ts | 8 ++ src/internal/local-upload.ts | 44 +++++++ src/internal/metadata.test.ts | 1 + src/test-support/command-path-mocks.ts | 14 +++ 12 files changed, 381 insertions(+) create mode 100644 src/internal/local-upload.ts diff --git a/README.md b/README.md index 67b330c..c787b19 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,13 @@ Read a small JSON result: putio files list --per-page 5 --fields files,total --output json ``` +Upload a local file: + +```bash +putio files upload --path ./movie.mp4 --parent-id 42 --dry-run --output json +putio files upload --path ./movie.mp4 --parent-id 42 --output json +``` + Stream larger reads: ```bash diff --git a/skills/putio-cli/references/guardrails.md b/skills/putio-cli/references/guardrails.md index f8c8ea6..5bb10ce 100644 --- a/skills/putio-cli/references/guardrails.md +++ b/skills/putio-cli/references/guardrails.md @@ -24,6 +24,7 @@ Input safety notes: - resource identifiers reject query fragments and traversal-like segments - field selectors reject nested paths and malformed tokens - name-like inputs reject control characters and traversal-like segments +- local upload paths reject control characters and must resolve to readable regular files Output safety notes: diff --git a/skills/putio-cli/references/writes.md b/skills/putio-cli/references/writes.md index 9f5afb2..65aab3c 100644 --- a/skills/putio-cli/references/writes.md +++ b/skills/putio-cli/references/writes.md @@ -7,6 +7,7 @@ Dry-run first: ```bash putio transfers cancel --json '{"ids":[12,18]}' --dry-run --output json putio files rename --json '{"file_id":42,"name":"Projects 2027"}' --dry-run --output json +putio files upload --json '{"path":"./movie.mp4","parent_id":42}' --dry-run --output json ``` Execute for real only after the dry-run request shape looks correct. @@ -16,6 +17,7 @@ Examples: ```bash putio download-links create --json '{"ids":[1,2]}' --output json putio files mkdir --json '{"name":"Projects","parent_id":9}' --output json +putio files upload --json '{"path":"./movie.mp4","parent_id":42}' --output json putio transfers add --json '[{"url":"https://example.com/file.torrent"}]' --output json ``` @@ -24,3 +26,4 @@ Rules: - Prefer `--json` over translating through many bespoke flags. - Prefer `--dry-run` before side effects. - Re-check schema-required keys in `describe` instead of guessing names. +- `files upload` validates that `path` resolves to a readable regular file before dry-run or execution. diff --git a/src/cli.test.ts b/src/cli.test.ts index ca18c4b..a40b9b2 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -186,6 +186,7 @@ describe("cli argv parsing", () => { }>; const mkdir = commands.find((entry) => entry.command === "files mkdir"); const deleteFiles = commands.find((entry) => entry.command === "files delete"); + const upload = commands.find((entry) => entry.command === "files upload"); expect(mkdir?.input.json?.properties).toEqual( expect.arrayContaining([ @@ -199,6 +200,13 @@ describe("cli argv parsing", () => { expect.objectContaining({ name: "skip_trash", required: false }), ]), ); + expect(upload?.input.json?.properties).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "path", required: true }), + expect.objectContaining({ name: "file_name", required: false }), + expect.objectContaining({ name: "parent_id", required: false }), + ]), + ); }); it("accepts an explicit output mode on describe", async () => { diff --git a/src/command-paths.test.ts b/src/command-paths.test.ts index 9f20f27..2351037 100644 --- a/src/command-paths.test.ts +++ b/src/command-paths.test.ts @@ -1,3 +1,7 @@ +import { chmod, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { Effect } from "effect"; @@ -132,6 +136,12 @@ const mocks = vi.hoisted(() => { const continueSearchFilesMock = vi.fn((_cursor?: string) => Effect.succeed(emptyFileListPage)); const listFilesMock = vi.fn(() => Effect.succeed(defaultFileListPage)); const searchFilesMock = vi.fn(() => Effect.succeed(defaultSearchFilesPage)); + const uploadFileMock = vi.fn(() => + Effect.succeed({ + file: { id: 88, name: "movie.mp4" }, + type: "file" as const, + }), + ); const getAccountInfoMock = vi.fn(() => Effect.succeed({ account_status: "ACTIVE", @@ -281,6 +291,7 @@ const mocks = vi.hoisted(() => { move: moveFilesMock, rename: renameFileMock, search: searchFilesMock, + upload: uploadFileMock, }, transfers: { addMany: addTransfersMock, @@ -328,6 +339,7 @@ const mocks = vi.hoisted(() => { savePersistedStateMock, searchFilesMock, useProfileMock, + uploadFileMock, waitForDeviceTokenMock, withAuthedSdkMock, withTerminalLoaderMock, @@ -945,6 +957,130 @@ describe("cli command paths", () => { ); }); + it("uploads a readable local file with the mocked sdk", async () => { + const directory = await mkdtemp(join(tmpdir(), "putio-cli-upload-")); + const path = join(directory, "movie.mp4"); + await writeFile(path, "video fixture"); + + await expect( + runCliInTest([ + "putio", + "files", + "upload", + "--path", + path, + "--parent-id", + "42", + "--output", + "json", + ]), + ).resolves.toBeUndefined(); + + expect(mocks.uploadFileMock).toHaveBeenCalledWith({ + file: expect.any(Blob), + fileName: "movie.mp4", + parentId: 42, + }); + expect(mocks.writeOutputMock).toHaveBeenCalledWith( + { + file: { id: 88, name: "movie.mp4" }, + type: "file", + }, + "json", + expect.any(Function), + ); + }); + + it("previews a local file upload from raw json without hitting the sdk", async () => { + const directory = await mkdtemp(join(tmpdir(), "putio-cli-upload-")); + const path = join(directory, "movie.mp4"); + await writeFile(path, "video fixture"); + + await expect( + runCliInTest([ + "putio", + "files", + "upload", + "--json", + JSON.stringify({ file_name: "fixture.mp4", parent_id: 42, path }), + "--dry-run", + "--output", + "json", + ]), + ).resolves.toBeUndefined(); + + expect(mocks.uploadFileMock).not.toHaveBeenCalled(); + expect(mocks.writeOutputMock).toHaveBeenCalledWith( + { + command: "files upload", + dryRun: true, + request: { + file_name: "fixture.mp4", + parent_id: 42, + path, + size: 13, + }, + }, + "json", + expect.any(Function), + ); + }); + + it("rejects directory paths before file upload", async () => { + const directory = await mkdtemp(join(tmpdir(), "putio-cli-upload-")); + + await expect( + runCliInTest(["putio", "files", "upload", "--path", directory, "--output", "json"]), + ).rejects.toMatchObject({ + message: "Expected the upload path to point to a regular file.", + }); + + expect(mocks.uploadFileMock).not.toHaveBeenCalled(); + }); + + it("rejects unreadable files before previewing an upload", async () => { + const directory = await mkdtemp(join(tmpdir(), "putio-cli-upload-")); + const path = join(directory, "private.mp4"); + await writeFile(path, "video fixture", { mode: 0o000 }); + + try { + await expect( + runCliInTest(["putio", "files", "upload", "--path", path, "--dry-run", "--output", "json"]), + ).rejects.toMatchObject({ + message: + "Unable to read the local upload file. Verify that the path exists and is readable.", + }); + } finally { + await chmod(path, 0o600); + } + + expect(mocks.uploadFileMock).not.toHaveBeenCalled(); + }); + + it("rejects a blank upload filename before hitting the sdk", async () => { + const directory = await mkdtemp(join(tmpdir(), "putio-cli-upload-")); + const path = join(directory, "movie.mp4"); + await writeFile(path, "video fixture"); + + await expect( + runCliInTest([ + "putio", + "files", + "upload", + "--path", + path, + "--file-name", + " ", + "--output", + "json", + ]), + ).rejects.toMatchObject({ + message: "Expected `files upload --file-name` to be a non-empty string.", + }); + + expect(mocks.uploadFileMock).not.toHaveBeenCalled(); + }); + it("executes files delete with repeated ids", async () => { await expect( runCliInTest([ diff --git a/src/commands/files.test.ts b/src/commands/files.test.ts index 92e283f..2f57a18 100644 --- a/src/commands/files.test.ts +++ b/src/commands/files.test.ts @@ -5,6 +5,7 @@ import { renderFilesTerminal } from "../internal/terminal/files-terminal.js"; import { renderFileCreatedTerminal, renderFileRenamedTerminal, + renderFileUploadedTerminal, renderFilesDeletedTerminal, renderFilesMovedTerminal, } from "./files.js"; @@ -56,6 +57,21 @@ describe("file mutation renderers", () => { ).toBe('renamed file 42 to "Projects 2026"'); }); + it("renders direct and queued upload feedback", () => { + expect( + renderFileUploadedTerminal({ + file: { id: 88, name: "movie.mp4" }, + type: "file", + }), + ).toBe('uploaded file "movie.mp4" (id 88)'); + expect( + renderFileUploadedTerminal({ + transfer: { id: 89, name: "archive.zip" }, + type: "transfer", + }), + ).toBe('queued upload transfer "archive.zip" (id 89)'); + }); + it("renders delete feedback", () => { expect( renderFilesDeletedTerminal({ diff --git a/src/commands/files.ts b/src/commands/files.ts index 117e419..4013d3c 100644 --- a/src/commands/files.ts +++ b/src/commands/files.ts @@ -16,6 +16,7 @@ import { pageAllOption, resolveMutationInput, resolveReadOutputControls, + validateLocalPathInput, validateNameLikeInput, withAuthedSdk, writeDryRunPlan, @@ -31,6 +32,7 @@ import { type CommandSpec, } from "../internal/command-specs.js"; import { translate } from "../i18n/index.js"; +import { prepareLocalUpload } from "../internal/local-upload.js"; import { withTerminalLoader } from "../internal/loader-service.js"; import { writeOutput } from "../internal/output-service.js"; import { renderFilesTerminal } from "../internal/terminal/files-terminal.js"; @@ -70,6 +72,8 @@ const fileSortChoices = [ const sortByConfig = defineChoiceOption("sort-by", fileSortChoices, { optional: true }); const optionalFileIdConfig = defineIntegerOption("id", { optional: true }); const optionalFileNameConfig = defineTextOption("name", { optional: true }); +const uploadPathConfig = defineTextOption("path", { optional: true }); +const uploadFileNameConfig = defineTextOption("file-name", { optional: true }); const parentIdOption = parentIdConfig.option; const perPageOption = perPageConfig.option; @@ -82,6 +86,8 @@ const fileTypeOption = fileTypeConfig.option; const sortByOption = sortByConfig.option; const optionalFileIdOption = optionalFileIdConfig.option; const optionalFileNameOption = optionalFileNameConfig.option; +const uploadPathOption = uploadPathConfig.option; +const uploadFileNameOption = uploadFileNameConfig.option; const NonBlankStringSchema = Schema.String.check( Schema.makeFilter((value) => @@ -111,6 +117,12 @@ const FilesMoveInputSchema = Schema.Struct({ parent_id: Schema.Number, }); +const FilesUploadInputSchema = Schema.Struct({ + file_name: Schema.optional(NonBlankStringSchema), + parent_id: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + path: NonBlankStringSchema, +}); + const requiredValue = (value: A | undefined, message: string) => { if (value === undefined) { throw new CliCommandInputError({ message }); @@ -135,6 +147,32 @@ const requiredIds = (value: ReadonlyArray, message: string) => { return value; }; +const optionalNonNegativeInteger = (value: number | undefined, message: string) => { + if (value !== undefined && (!Number.isInteger(value) || value < 0)) { + throw new CliCommandInputError({ message }); + } + + return value; +}; + +export const renderFileUploadedTerminal = ( + value: + | { readonly type: "file"; readonly file: { readonly id: number; readonly name: string } } + | { + readonly type: "transfer"; + readonly transfer: { readonly id: number; readonly name: string }; + }, +) => + value.type === "file" + ? translate("cli.files.terminal.uploadedFile", { + id: value.file.id, + name: value.file.name, + }) + : translate("cli.files.terminal.uploadedTransfer", { + id: value.transfer.id, + name: value.transfer.name, + }); + export const renderFileCreatedTerminal = (value: { readonly id: number; readonly name: string; @@ -288,6 +326,80 @@ const filesMkdir = Command.make( }), ); +const filesUpload = Command.make( + "upload", + { + dryRun: dryRunOption, + fileName: uploadFileNameOption, + json: jsonOption, + output: outputOption, + parentId: parentIdOption, + path: uploadPathOption, + }, + ({ dryRun, fileName, json, output, parentId, path }) => + Effect.gen(function* () { + const input = yield* resolveMutationInput({ + buildFromFlags: () => ({ + file_name: getOption(fileName), + parent_id: getOption(parentId), + path: requiredNonEmptyText( + getOption(path), + "Provide `--path` or `--json` for `files upload`.", + ), + }), + json, + schema: FilesUploadInputSchema, + }).pipe( + Effect.map((value) => ({ + ...value, + file_name: + value.file_name === undefined + ? undefined + : requiredNonEmptyText( + value.file_name, + "Expected `files upload --file-name` to be a non-empty string.", + ), + parent_id: optionalNonNegativeInteger( + value.parent_id, + "Expected `files upload --parent-id` to be a non-negative integer.", + ), + path: validateLocalPathInput("`files upload --path`", value.path), + })), + ); + const prepared = yield* prepareLocalUpload(input.path); + const resolvedFileName = validateNameLikeInput( + "`files upload --file-name`", + input.file_name ?? prepared.fileName, + ); + const plan = { + file_name: resolvedFileName, + parent_id: input.parent_id, + path: input.path, + size: prepared.size, + }; + + if (dryRun) { + return yield* writeDryRunPlan("files upload", plan, getOption(output)); + } + + const result = yield* withTerminalLoader( + { + message: translate("cli.files.command.uploading", { name: resolvedFileName }), + output: getOption(output), + }, + withAuthedSdk(({ sdk }) => + sdk.files.upload({ + file: prepared.file, + fileName: resolvedFileName, + parentId: input.parent_id, + }), + ), + ); + + yield* writeOutput(result, getOption(output), renderFileUploadedTerminal); + }), +); + const filesRename = Command.make( "rename", { @@ -485,6 +597,7 @@ export const filesCommand = Command.make("files", {}, () => Effect.void).pipe( filesList, filesSearchCommand, filesMkdir, + filesUpload, filesRename, filesMove, filesDelete, @@ -563,6 +676,32 @@ export const filesCommandSpecs = [ kind: "write", purpose: translate("cli.metadata.filesMkdir"), }, + { + auth: { required: true }, + capabilities: { + dryRun: true, + fieldSelection: false, + rawJsonInput: true, + streaming: false, + }, + command: "files upload", + input: { + flags: [ + dryRunFlag(), + uploadFileNameConfig.flag, + jsonFlag(), + outputFlag(), + parentIdConfig.flag, + uploadPathConfig.flag, + ], + json: jsonShapeFromSchema(FilesUploadInputSchema, [ + "`path` must resolve to a readable regular file.", + "`file_name` rejects control characters and path traversal segments like `../` or `%2e`.", + ]), + }, + kind: "write", + purpose: translate("cli.metadata.filesUpload"), + }, { auth: { required: true }, capabilities: { diff --git a/src/i18n/catalog/en.ts b/src/i18n/catalog/en.ts index d7c64cc..1d07600 100644 --- a/src/i18n/catalog/en.ts +++ b/src/i18n/catalog/en.ts @@ -219,6 +219,7 @@ export const en = { moving: "Moving {{count}} file(s) to parent {{parentId}}...", renaming: 'Renaming file {{id}} to "{{name}}"...', searching: 'Searching files for "{{query}}"...', + uploading: 'Uploading "{{name}}"...', }, terminal: { created: 'created folder "{{name}}" (id {{id}}, parent {{parentId}})', @@ -234,6 +235,8 @@ export const en = { summary: "Showing {{count}} file(s){{totalSuffix}}.", summaryInParent: "Showing {{count}} file(s) in {{name}}{{totalSuffix}}.", totalSuffix: " ({{total}} total)", + uploadedFile: 'uploaded file "{{name}}" (id {{id}})', + uploadedTransfer: 'queued upload transfer "{{name}}" (id {{id}})', }, }, metadata: { @@ -256,6 +259,7 @@ export const en = { filesMove: "Move one or more files to a parent directory.", filesRename: "Rename a file by id.", filesSearch: "Search files by query and optional file type.", + filesUpload: "Upload a readable local file into put.io.", search: "Top-level alias for file search.", transfersAdd: "Add one or more transfers from URLs or magnet links.", transfersCancel: "Cancel one or more transfers.", diff --git a/src/internal/command.ts b/src/internal/command.ts index c719c9c..13a4664 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -288,6 +288,14 @@ export const validateNameLikeInput = (label: string, value: string) => value, }); +export const validateLocalPathInput = (label: string, value: string) => + validateSafeString({ + allowPathTraversal: true, + allowQueryOrFragment: true, + label, + value, + }); + const parseRequestedFields = (raw: string) => { const parts = raw.split(",").map((part) => part.trim()); diff --git a/src/internal/local-upload.ts b/src/internal/local-upload.ts new file mode 100644 index 0000000..28e639c --- /dev/null +++ b/src/internal/local-upload.ts @@ -0,0 +1,44 @@ +import { openAsBlob } from "node:fs"; + +import { Effect, FileSystem, Path } from "effect"; + +import { CliCommandInputError } from "./command.js"; + +const unreadableUploadFile = () => + new CliCommandInputError({ + message: "Unable to read the local upload file. Verify that the path exists and is readable.", + }); + +export const prepareLocalUpload = (inputPath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolvedPath = yield* fileSystem + .realPath(inputPath) + .pipe(Effect.mapError(unreadableUploadFile)); + const info = yield* fileSystem.stat(resolvedPath).pipe(Effect.mapError(unreadableUploadFile)); + + if (info.type !== "File") { + return yield* Effect.fail( + new CliCommandInputError({ + message: "Expected the upload path to point to a regular file.", + }), + ); + } + + yield* fileSystem + .access(resolvedPath, { readable: true }) + .pipe(Effect.mapError(unreadableUploadFile)); + + const file = yield* Effect.tryPromise({ + try: () => openAsBlob(resolvedPath), + catch: unreadableUploadFile, + }); + + return { + file, + fileName: path.basename(resolvedPath), + path: resolvedPath, + size: file.size, + }; + }); diff --git a/src/internal/metadata.test.ts b/src/internal/metadata.test.ts index 5a0cea3..72f573f 100644 --- a/src/internal/metadata.test.ts +++ b/src/internal/metadata.test.ts @@ -62,6 +62,7 @@ describe("describeCli", () => { "files list", "files search", "files mkdir", + "files upload", "files rename", "files move", "files delete", diff --git a/src/test-support/command-path-mocks.ts b/src/test-support/command-path-mocks.ts index c54d470..b2c51d3 100644 --- a/src/test-support/command-path-mocks.ts +++ b/src/test-support/command-path-mocks.ts @@ -175,6 +175,12 @@ const createCommandPathMocks = () => { const continueSearchFilesMock = vi.fn((_cursor?: string) => Effect.succeed(emptyFileListPage)); const listFilesMock = vi.fn(() => Effect.succeed(defaultFileListPage)); const searchFilesMock = vi.fn(() => Effect.succeed(defaultSearchFilesPage)); + const uploadFileMock = vi.fn(() => + Effect.succeed({ + file: { id: 88, name: "movie.mp4" }, + type: "file" as const, + }), + ); const getAccountInfoMock = vi.fn(() => Effect.succeed(defaultAccountInfo())); const listEventsMock = vi.fn(() => Effect.succeed(defaultEventsResponse())); const createDownloadLinksMock = vi.fn(() => Effect.succeed({ id: 55 })); @@ -278,6 +284,7 @@ const createCommandPathMocks = () => { move: moveFilesMock, rename: renameFileMock, search: searchFilesMock, + upload: uploadFileMock, }, transfers: { addMany: addTransfersMock, @@ -325,6 +332,7 @@ const createCommandPathMocks = () => { savePersistedStateMock, searchFilesMock, useProfileMock, + uploadFileMock, waitForDeviceTokenMock, withAuthedSdkMock, withTerminalLoaderMock, @@ -407,6 +415,12 @@ export const resetCommandPathMocks = (mocks: ReturnType + Effect.succeed({ + file: { id: 88, name: "movie.mp4" }, + type: "file" as const, + }), + ); mocks.getAccountInfoMock.mockImplementation(() => Effect.succeed(defaultAccountInfo())); mocks.listEventsMock.mockImplementation(() => Effect.succeed(defaultEventsResponse())); mocks.createDownloadLinksMock.mockImplementation(() => Effect.succeed({ id: 55 })); From c864fd7b3cf84e24f79efda949dc06f77847284e Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 23:44:33 +0300 Subject: [PATCH 2/2] fix(cli): harden upload validation tests --- src/command-paths.test.ts | 95 ++++++++++++++++++++++++++++++------ src/commands/files.ts | 16 +++--- src/internal/local-upload.ts | 16 +++--- 3 files changed, 97 insertions(+), 30 deletions(-) diff --git a/src/command-paths.test.ts b/src/command-paths.test.ts index 2351037..e9bd676 100644 --- a/src/command-paths.test.ts +++ b/src/command-paths.test.ts @@ -1,13 +1,29 @@ -import { chmod, mkdtemp, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { Effect } from "effect"; import { resetCommandPathMocks } from "./test-support/command-path-mocks.js"; import { runCliInTest } from "./test-support/run-cli.js"; +const uploadFixtureDirectories = new Set(); + +const makeUploadFixtureDirectory = async () => { + const directory = await mkdtemp(join(tmpdir(), "putio-cli-upload-")); + uploadFixtureDirectories.add(directory); + return directory; +}; + +afterEach(async () => { + const directories = [...uploadFixtureDirectories]; + uploadFixtureDirectories.clear(); + await Promise.all( + directories.map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + const mocks = vi.hoisted(() => { type FileListItem = { readonly file_type?: string; @@ -958,7 +974,7 @@ describe("cli command paths", () => { }); it("uploads a readable local file with the mocked sdk", async () => { - const directory = await mkdtemp(join(tmpdir(), "putio-cli-upload-")); + const directory = await makeUploadFixtureDirectory(); const path = join(directory, "movie.mp4"); await writeFile(path, "video fixture"); @@ -992,7 +1008,7 @@ describe("cli command paths", () => { }); it("previews a local file upload from raw json without hitting the sdk", async () => { - const directory = await mkdtemp(join(tmpdir(), "putio-cli-upload-")); + const directory = await makeUploadFixtureDirectory(); const path = join(directory, "movie.mp4"); await writeFile(path, "video fixture"); @@ -1027,7 +1043,7 @@ describe("cli command paths", () => { }); it("rejects directory paths before file upload", async () => { - const directory = await mkdtemp(join(tmpdir(), "putio-cli-upload-")); + const directory = await makeUploadFixtureDirectory(); await expect( runCliInTest(["putio", "files", "upload", "--path", directory, "--output", "json"]), @@ -1038,27 +1054,26 @@ describe("cli command paths", () => { expect(mocks.uploadFileMock).not.toHaveBeenCalled(); }); - it("rejects unreadable files before previewing an upload", async () => { - const directory = await mkdtemp(join(tmpdir(), "putio-cli-upload-")); - const path = join(directory, "private.mp4"); - await writeFile(path, "video fixture", { mode: 0o000 }); + it.skipIf(typeof process.getuid === "function" && process.getuid() === 0)( + "rejects unreadable files before previewing an upload", + async () => { + const directory = await makeUploadFixtureDirectory(); + const path = join(directory, "private.mp4"); + await writeFile(path, "video fixture", { mode: 0o000 }); - try { await expect( runCliInTest(["putio", "files", "upload", "--path", path, "--dry-run", "--output", "json"]), ).rejects.toMatchObject({ message: "Unable to read the local upload file. Verify that the path exists and is readable.", }); - } finally { - await chmod(path, 0o600); - } - expect(mocks.uploadFileMock).not.toHaveBeenCalled(); - }); + expect(mocks.uploadFileMock).not.toHaveBeenCalled(); + }, + ); it("rejects a blank upload filename before hitting the sdk", async () => { - const directory = await mkdtemp(join(tmpdir(), "putio-cli-upload-")); + const directory = await makeUploadFixtureDirectory(); const path = join(directory, "movie.mp4"); await writeFile(path, "video fixture"); @@ -1081,6 +1096,54 @@ describe("cli command paths", () => { expect(mocks.uploadFileMock).not.toHaveBeenCalled(); }); + it("rejects invalid upload options before filesystem work", async () => { + await expect( + runCliInTest([ + "putio", + "files", + "upload", + "--path", + "/missing/upload-fixture", + "--file-name", + "../movie.mp4", + "--output", + "json", + ]), + ).rejects.toMatchObject({ + message: + "`files upload --file-name` cannot contain path traversal segments like `../` or `%2e`.", + }); + + await expect( + runCliInTest([ + "putio", + "files", + "upload", + "--json", + '{"path":"/missing/upload-fixture","parent_id":-1}', + "--output", + "json", + ]), + ).rejects.toMatchObject({ + message: "Expected `--json` to match the command input schema.", + }); + + expect(mocks.uploadFileMock).not.toHaveBeenCalled(); + }); + + it("propagates sdk upload failures", async () => { + const directory = await makeUploadFixtureDirectory(); + const path = join(directory, "private.mp4"); + await writeFile(path, "video fixture"); + mocks.uploadFileMock.mockImplementationOnce(() => Effect.fail(new Error("upload failed"))); + + await expect( + runCliInTest(["putio", "files", "upload", "--path", path, "--output", "json"]), + ).rejects.toThrow("upload failed"); + + expect(mocks.uploadFileMock).toHaveBeenCalledOnce(); + }); + it("executes files delete with repeated ids", async () => { await expect( runCliInTest([ diff --git a/src/commands/files.ts b/src/commands/files.ts index 4013d3c..b2c3f34 100644 --- a/src/commands/files.ts +++ b/src/commands/files.ts @@ -32,7 +32,7 @@ import { type CommandSpec, } from "../internal/command-specs.js"; import { translate } from "../i18n/index.js"; -import { prepareLocalUpload } from "../internal/local-upload.js"; +import { inspectLocalUpload, openLocalUploadBlob } from "../internal/local-upload.js"; import { withTerminalLoader } from "../internal/loader-service.js"; import { writeOutput } from "../internal/output-service.js"; import { renderFilesTerminal } from "../internal/terminal/files-terminal.js"; @@ -355,9 +355,12 @@ const filesUpload = Command.make( file_name: value.file_name === undefined ? undefined - : requiredNonEmptyText( - value.file_name, - "Expected `files upload --file-name` to be a non-empty string.", + : validateNameLikeInput( + "`files upload --file-name`", + requiredNonEmptyText( + value.file_name, + "Expected `files upload --file-name` to be a non-empty string.", + ), ), parent_id: optionalNonNegativeInteger( value.parent_id, @@ -366,7 +369,7 @@ const filesUpload = Command.make( path: validateLocalPathInput("`files upload --path`", value.path), })), ); - const prepared = yield* prepareLocalUpload(input.path); + const prepared = yield* inspectLocalUpload(input.path); const resolvedFileName = validateNameLikeInput( "`files upload --file-name`", input.file_name ?? prepared.fileName, @@ -382,6 +385,7 @@ const filesUpload = Command.make( return yield* writeDryRunPlan("files upload", plan, getOption(output)); } + const file = yield* openLocalUploadBlob(prepared.path); const result = yield* withTerminalLoader( { message: translate("cli.files.command.uploading", { name: resolvedFileName }), @@ -389,7 +393,7 @@ const filesUpload = Command.make( }, withAuthedSdk(({ sdk }) => sdk.files.upload({ - file: prepared.file, + file, fileName: resolvedFileName, parentId: input.parent_id, }), diff --git a/src/internal/local-upload.ts b/src/internal/local-upload.ts index 28e639c..e84ed65 100644 --- a/src/internal/local-upload.ts +++ b/src/internal/local-upload.ts @@ -9,7 +9,7 @@ const unreadableUploadFile = () => message: "Unable to read the local upload file. Verify that the path exists and is readable.", }); -export const prepareLocalUpload = (inputPath: string) => +export const inspectLocalUpload = (inputPath: string) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -30,15 +30,15 @@ export const prepareLocalUpload = (inputPath: string) => .access(resolvedPath, { readable: true }) .pipe(Effect.mapError(unreadableUploadFile)); - const file = yield* Effect.tryPromise({ - try: () => openAsBlob(resolvedPath), - catch: unreadableUploadFile, - }); - return { - file, fileName: path.basename(resolvedPath), path: resolvedPath, - size: file.size, + size: Number(info.size), }; }); + +export const openLocalUploadBlob = (path: string) => + Effect.tryPromise({ + try: () => openAsBlob(path), + catch: unreadableUploadFile, + });