diff --git a/packages/core/src/contracts/format-cli-args.ts b/packages/core/src/contracts/format-cli-args.ts index a587fb6..46a2f77 100644 --- a/packages/core/src/contracts/format-cli-args.ts +++ b/packages/core/src/contracts/format-cli-args.ts @@ -1,5 +1,12 @@ +import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; import type { DeployArgValue } from "./resolve-deploy-args.js"; +const CLI_ARG_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function throwUnsafeCliArg(message: string, hint: string): never { + throw new CaatingaError(message, CaatingaErrorCode.INVALID_CONFIG, hint); +} + export function toSnakeCaseFlag(key: string): string { return key .replace(/([A-Z])/g, "_$1") @@ -19,7 +26,56 @@ export function formatNamedCliArgs(resolved: Record): st const tail: string[] = []; for (const [key, value] of entries) { - tail.push(`--${toSnakeCaseFlag(key)}`, String(value)); + if (!CLI_ARG_KEY_PATTERN.test(key)) { + throwUnsafeCliArg( + `Invalid contract argument name "${key}".`, + "Argument names may only contain letters, digits, and underscores, and must not start with a digit." + ); + } + + const stringValue = String(value); + if (stringValue.startsWith("-")) { + throwUnsafeCliArg( + `Refusing to pass flag-shaped contract argument value for "${key}".`, + "Contract argument values must not start with '-' because Stellar CLI would parse them as flags." + ); + } + + tail.push(`--${toSnakeCaseFlag(key)}`, stringValue); } return tail; } + +export function assertSafeCliArgs(args: readonly string[]): void { + for (let index = 0; index < args.length; index++) { + const token = args[index]; + if (!token.startsWith("-")) { + continue; + } + + if (!token.startsWith("--")) { + throwUnsafeCliArg( + `Refusing to pass short flag-shaped contract argument "${token}".`, + "Pass contract arguments as --name value pairs." + ); + } + + const key = token.slice(2); + if (!CLI_ARG_KEY_PATTERN.test(key)) { + throwUnsafeCliArg( + `Invalid contract argument name "${key}".`, + "Argument names may only contain letters, digits, and underscores, and must not start with a digit." + ); + } + + const value = args[index + 1]; + if (value === undefined || value.startsWith("-")) { + throwUnsafeCliArg( + `Refusing to pass flag-shaped or missing value for contract argument "${key}".`, + "Pass each contract argument as --name value, with a value that does not start with '-'." + ); + } + + index += 1; + } +} diff --git a/packages/core/src/contracts/invoke-contract.test.ts b/packages/core/src/contracts/invoke-contract.test.ts index 7fb3d23..405f880 100644 --- a/packages/core/src/contracts/invoke-contract.test.ts +++ b/packages/core/src/contracts/invoke-contract.test.ts @@ -53,6 +53,14 @@ describe("parseInvokeTarget", () => { expect.objectContaining({ code: CaatingaErrorCode.INVOKE_TARGET_INVALID }) ); }); + it("rejects flag-shaped method names", () => { + expect(() => parseInvokeTarget("counter.--help")).toThrow( + expect.objectContaining({ code: CaatingaErrorCode.INVOKE_FAILED }) + ); + expect(() => parseInvokeTarget("counter.bad-name")).toThrow( + expect.objectContaining({ code: CaatingaErrorCode.INVOKE_FAILED }) + ); + }); }); describe("invokeContract", () => { diff --git a/packages/core/src/contracts/invoke-target.ts b/packages/core/src/contracts/invoke-target.ts index ff12ca4..cc42a1b 100644 --- a/packages/core/src/contracts/invoke-target.ts +++ b/packages/core/src/contracts/invoke-target.ts @@ -1,4 +1,5 @@ import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; +import { assertSorobanSymbol } from "../soroban/assert-soroban-symbol.js"; export type InvokeTarget = { contractName: string; @@ -18,6 +19,8 @@ export function parseInvokeTarget(target: string): InvokeTarget { ); } + assertSorobanSymbol(method, "method"); + return { contractName, method }; } diff --git a/packages/core/src/contracts/resolve-method-args.test.ts b/packages/core/src/contracts/resolve-method-args.test.ts index 1ba88c8..015d8bc 100644 --- a/packages/core/src/contracts/resolve-method-args.test.ts +++ b/packages/core/src/contracts/resolve-method-args.test.ts @@ -75,4 +75,22 @@ describe("resolveMethodArgs", () => { const resolved = await resolveCliMethodArgs(["--caller", "alice"], { cwd: "/tmp" }); expect(resolved).toEqual(["--caller", VALID_G_ADDRESS]); }); + + it("should_reject_flag_shaped_named_argument_values", async () => { + await expect(resolveCliMethodArgs(["--caller", "--help"])).rejects.toMatchObject({ + code: CaatingaErrorCode.INVALID_CONFIG, + }); + }); + + it("should_reject_invalid_named_argument_keys", async () => { + await expect(resolveCliMethodArgs(["--bad-key", "value"])).rejects.toMatchObject({ + code: CaatingaErrorCode.INVALID_CONFIG, + }); + }); + + it("should_reject_standalone_short_flags", async () => { + await expect(resolveCliMethodArgs(["-h"])).rejects.toMatchObject({ + code: CaatingaErrorCode.INVALID_CONFIG, + }); + }); }); diff --git a/packages/core/src/contracts/resolve-method-args.ts b/packages/core/src/contracts/resolve-method-args.ts index c6d1d61..1b3f0fe 100644 --- a/packages/core/src/contracts/resolve-method-args.ts +++ b/packages/core/src/contracts/resolve-method-args.ts @@ -1,5 +1,5 @@ import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; -import { formatNamedCliArgs } from "./format-cli-args.js"; +import { assertSafeCliArgs, formatNamedCliArgs } from "./format-cli-args.js"; import { resolveSourceAddress } from "./resolve-source-address.js"; import type { DeployArgValue } from "./resolve-deploy-args.js"; @@ -96,6 +96,8 @@ export async function resolveCliMethodArgs( return []; } + assertSafeCliArgs(args); + const named = parseNamedCliArgs(args); if (Object.keys(named).length === 0) { return [...args]; diff --git a/packages/core/src/contracts/run-post-deploy.test.ts b/packages/core/src/contracts/run-post-deploy.test.ts index 0f6d041..1f27ae1 100644 --- a/packages/core/src/contracts/run-post-deploy.test.ts +++ b/packages/core/src/contracts/run-post-deploy.test.ts @@ -166,7 +166,7 @@ describe("runPostDeployHooks", () => { contract: "coin", method: "set_minter", args: {}, - source: "SABC123DEF456GHI789JKL012MNO345PQR678STU901VWX234YZA567BCD890EFG123", + source: "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", kind: "invoke", }, ], @@ -340,4 +340,20 @@ describe("runPostDeployHooks", () => { { contract: "coin", method: "list_items", result: '[{"id":1}]', kind: "invoke" }, ]); }); + + it("should_reject_flag_shaped_hook_method", async () => { + const configWithBadMethod: CaatingaConfig = { + ...config, + postDeploy: [{ contract: "coin", method: "--help", args: {}, kind: "invoke" }], + }; + + await expect( + runPostDeployHooks({ + config: configWithBadMethod, + source: "alice", + cwd: tmpDir, + hookRetryDelaysMs: [0], + }) + ).rejects.toMatchObject({ code: CaatingaErrorCode.INVOKE_FAILED }); + }); }); diff --git a/packages/core/src/contracts/run-post-deploy.ts b/packages/core/src/contracts/run-post-deploy.ts index 8d96fae..941f88c 100644 --- a/packages/core/src/contracts/run-post-deploy.ts +++ b/packages/core/src/contracts/run-post-deploy.ts @@ -15,6 +15,7 @@ import { assertSafeSourceAccount } from "./source-account.js"; import { assertExpect } from "./verify-expect.js"; import { resolvePlaceholders } from "./placeholder-engine.js"; import { resolveSourceAddress } from "./resolve-source-address.js"; +import { assertSorobanSymbol } from "../soroban/assert-soroban-symbol.js"; import { TRANSACTION_TIMEOUT_MS } from "../shell/command-timeouts.js"; export type RunPostDeployHooksOptions = { @@ -128,6 +129,8 @@ export async function runPostDeployHooks( await checkBinary("stellar", "Install Stellar CLI before running ctg wire."); for (const hook of hooks) { + assertSorobanSymbol(hook.method, "postDeploy method"); + if (!options.config.contracts[hook.contract]) { throw new CaatingaError( `Post-deploy hook references unknown contract "${hook.contract}".`, diff --git a/packages/core/src/contracts/source-account.test.ts b/packages/core/src/contracts/source-account.test.ts index af42f46..6a85c53 100644 --- a/packages/core/src/contracts/source-account.test.ts +++ b/packages/core/src/contracts/source-account.test.ts @@ -42,6 +42,18 @@ describe("assertSafeSourceAccount", () => { }); }); +it("should_reject_flag_shaped_source_alias", () => { + expect(() => assertSafeSourceAccount("--config-dir")).toThrowError( + expect.objectContaining({ code: CaatingaErrorCode.UNSAFE_SOURCE_ACCOUNT }) + ); + expect(() => assertSafeSourceAccount("-h")).toThrowError( + expect.objectContaining({ code: CaatingaErrorCode.UNSAFE_SOURCE_ACCOUNT }) + ); +}); + +it("should_allow_non_secret_alias_starting_with_S", () => { + expect(assertSafeSourceAccount("Staging")).toBe("Staging"); +}); describe("resolveCliSource", () => { const previous = process.env.CAATINGA_SOURCE; diff --git a/packages/core/src/contracts/upgrade-contract.test.ts b/packages/core/src/contracts/upgrade-contract.test.ts index 23192e1..06603a6 100644 --- a/packages/core/src/contracts/upgrade-contract.test.ts +++ b/packages/core/src/contracts/upgrade-contract.test.ts @@ -163,4 +163,20 @@ describe("upgradeContractInPlace", () => { }) ).rejects.toMatchObject({ code: CaatingaErrorCode.ARTIFACT_NOT_FOUND }); }); + + it("should_reject_flag_shaped_upgrade_symbols", async () => { + await seedProject(NEW_WASM, OLD_HASH); + + await expect( + upgradeContractInPlace({ + config: baseConfig, + contractName: "sticker", + networkName: "testnet", + source: "deployer", + cwd: tmpDir, + build: false, + upgradeMethod: "--help", + }) + ).rejects.toMatchObject({ code: CaatingaErrorCode.INVOKE_FAILED }); + }); }); diff --git a/packages/core/src/contracts/upgrade-contract.ts b/packages/core/src/contracts/upgrade-contract.ts index 9b97973..dae6097 100644 --- a/packages/core/src/contracts/upgrade-contract.ts +++ b/packages/core/src/contracts/upgrade-contract.ts @@ -16,6 +16,7 @@ import { assertSafeSourceAccount } from "./source-account.js"; import { resolveContract } from "./resolve-contract.js"; import { uploadWasm } from "./upload-wasm.js"; import { hashWasm, resolveWasmArtifactPath } from "./wasm.js"; +import { assertSorobanSymbol } from "../soroban/assert-soroban-symbol.js"; import { TRANSACTION_TIMEOUT_MS } from "../shell/command-timeouts.js"; export type UpgradeContractOptions = { @@ -70,6 +71,8 @@ export async function upgradeContractInPlace( const source = assertSafeSourceAccount(options.source); const upgradeMethod = options.upgradeMethod ?? DEFAULT_UPGRADE_METHOD; const wasmArg = options.wasmArg ?? DEFAULT_WASM_ARG; + assertSorobanSymbol(upgradeMethod, "upgradeMethod"); + assertSorobanSymbol(wasmArg, "wasmArg"); await checkBinary("stellar", "Install Stellar CLI before running ctg upgrade."); diff --git a/packages/core/src/contracts/validate-source-shape.ts b/packages/core/src/contracts/validate-source-shape.ts index abaa0dd..76daa4d 100644 --- a/packages/core/src/contracts/validate-source-shape.ts +++ b/packages/core/src/contracts/validate-source-shape.ts @@ -1,8 +1,18 @@ import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; import { isLikelyPublicKeySource } from "../stellar-cli/recover-deploy-contract-id.js"; +const STELLAR_SECRET_KEY_PATTERN = /^S[A-Z2-7]{55}$/; + export function validateSourceShape(source: string): CaatingaError | undefined { - if (source.startsWith("S")) { + if (source.startsWith("-")) { + return new CaatingaError( + "Refusing to accept a flag-shaped value as --source.", + CaatingaErrorCode.UNSAFE_SOURCE_ACCOUNT, + "Use a Stellar CLI identity alias instead, for example: --source alice" + ); + } + + if (STELLAR_SECRET_KEY_PATTERN.test(source)) { return new CaatingaError( "Refusing to accept a Stellar secret key as --source.", CaatingaErrorCode.SOURCE_IS_SECRET_KEY,