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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion packages/core/src/contracts/format-cli-args.ts
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -19,7 +26,56 @@ export function formatNamedCliArgs(resolved: Record<string, DeployArgValue>): 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;
}
}
8 changes: 8 additions & 0 deletions packages/core/src/contracts/invoke-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,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", () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/contracts/invoke-target.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js";
import { assertSorobanSymbol } from "../soroban/assert-soroban-symbol.js";

export type InvokeTarget = {
contractName: string;
Expand All @@ -18,6 +19,8 @@ export function parseInvokeTarget(target: string): InvokeTarget {
);
}

assertSorobanSymbol(method, "method");

return { contractName, method };
}

Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/contracts/resolve-method-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
4 changes: 3 additions & 1 deletion packages/core/src/contracts/resolve-method-args.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -96,6 +96,8 @@ export async function resolveCliMethodArgs(
return [];
}

assertSafeCliArgs(args);

const named = parseNamedCliArgs(args);
if (Object.keys(named).length === 0) {
return [...args];
Expand Down
18 changes: 17 additions & 1 deletion packages/core/src/contracts/run-post-deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ describe("runPostDeployHooks", () => {
contract: "coin",
method: "set_minter",
args: {},
source: "SABC123DEF456GHI789JKL012MNO345PQR678STU901VWX234YZA567BCD890EFG123",
source: "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
kind: "invoke",
},
],
Expand Down Expand Up @@ -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 });
});
});
3 changes: 3 additions & 0 deletions packages/core/src/contracts/run-post-deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,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";

export type RunPostDeployHooksOptions = {
config: CaatingaConfig;
Expand Down Expand Up @@ -126,6 +127,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}".`,
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/contracts/source-account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/contracts/upgrade-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
3 changes: 3 additions & 0 deletions packages/core/src/contracts/upgrade-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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";

export type UpgradeContractOptions = {
config: CaatingaConfig;
Expand Down Expand Up @@ -68,6 +69,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.");

Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/contracts/validate-source-shape.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down