diff --git a/clients/c/Makefile b/clients/c/Makefile index 3c3a1bf..470bab6 100644 --- a/clients/c/Makefile +++ b/clients/c/Makefile @@ -108,6 +108,14 @@ test: build $(TEST_DIR)/test_library test-library: test +test-integration: build + @echo "" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @echo "INTEGRATION: Testing --account flag priority" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @echo "" + @bash $(TEST_DIR)/test_account_flag.sh + test-functional: build $(TEST_DIR)/test_functional @echo "" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/clients/c/src/un.c b/clients/c/src/un.c index 1d33452..9ee1802 100644 --- a/clients/c/src/un.c +++ b/clients/c/src/un.c @@ -396,7 +396,14 @@ static UnsandboxCredentials* get_credentials(const char *cli_pk, const char *cli return creds; } - // Priority 2: Environment variables (keys) + // Priority 2: --account N flag → explicit CSV lookup + // When the user explicitly selects an account, go straight to accounts.csv. + // Env vars are intentionally bypassed — an explicit flag must win over ambient env. + if (account_index >= 0) { + return load_credentials_from_csv(account_index); + } + + // Priority 3: Environment variables (keys) const char *env_pk = getenv("UNSANDBOX_PUBLIC_KEY"); const char *env_sk = getenv("UNSANDBOX_SECRET_KEY"); @@ -416,16 +423,12 @@ static UnsandboxCredentials* get_credentials(const char *cli_pk, const char *cli return creds; } - // Priority 3: Config file (~/.unsandbox/accounts.csv) - // Use account_index from --account flag, or UNSANDBOX_ACCOUNT env var, or default to 0 - int csv_index = account_index; - if (csv_index < 0) { - const char *env_account = getenv("UNSANDBOX_ACCOUNT"); - if (env_account && strlen(env_account) > 0) { - csv_index = atoi(env_account); - } else { - csv_index = 0; - } + // Priority 4: Config file (~/.unsandbox/accounts.csv) + // Use UNSANDBOX_ACCOUNT env var, or default to account 0 + int csv_index = 0; + const char *env_account = getenv("UNSANDBOX_ACCOUNT"); + if (env_account && strlen(env_account) > 0) { + csv_index = atoi(env_account); } return load_credentials_from_csv(csv_index); } diff --git a/clients/c/tests/test_account_flag.sh b/clients/c/tests/test_account_flag.sh new file mode 100755 index 0000000..966be65 --- /dev/null +++ b/clients/c/tests/test_account_flag.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Integration test: --account N flag must take priority over env vars +# +# Requires: UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY (real credentials) +# Run: make test-integration OR bash tests/test_account_flag.sh +# +# The defect this guards against: get_credentials() checked env vars before +# account_index, so --account N was silently ignored when env vars existed. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +UN_BIN="$SCRIPT_DIR/../un" + +RED='\033[31m' +GREEN='\033[32m' +NC='\033[0m' + +pass=0 +fail=0 + +check() { + local desc="$1" result="$2" + if [ "$result" = "pass" ]; then + printf " ${GREEN}✓${NC} %s\n" "$desc" + pass=$((pass + 1)) + else + printf " ${RED}✗${NC} %s\n" "$desc" + fail=$((fail + 1)) + fi +} + +# Require real credentials to be available +if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then + echo "SKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set" + exit 0 +fi + +if [ ! -x "$UN_BIN" ]; then + echo "FAIL: UN binary not found at $UN_BIN — run make first" + exit 1 +fi + +REAL_PK="$UNSANDBOX_PUBLIC_KEY" +REAL_SK="$UNSANDBOX_SECRET_KEY" + +# Temporary HOME with accounts.csv: +# index 0: garbage credentials (will always 401) +# index 1: real credentials (will succeed) +TMPHOME="$(mktemp -d)" +mkdir -p "$TMPHOME/.unsandbox" +trap 'rm -rf "$TMPHOME"' EXIT + +cat > "$TMPHOME/.unsandbox/accounts.csv" <&1 || true) + +if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials"; then + check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "pass" +else + check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "fail" + echo " output: $OUT" +fi + +# --- Test 2: --account 0 should use CSV row 0 (garbage creds) → 401 --- +# Even though real env vars are set, explicit --account 0 should pick garbage creds +OUT=$(HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="$REAL_PK" \ + UNSANDBOX_SECRET_KEY="$REAL_SK" \ + "$UN_BIN" --account 0 key 2>&1 || true) + +if echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|invalid key"; then + check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "pass" +else + check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "fail" + echo " output: $OUT" +fi + +# --- Test 3: no --account flag, real env vars → env vars win over garbage CSV row 0 --- +OUT=$(HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="$REAL_PK" \ + UNSANDBOX_SECRET_KEY="$REAL_SK" \ + "$UN_BIN" key 2>&1 || true) + +if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials"; then + check "No --account flag: env vars used, succeeds" "pass" +else + check "No --account flag: env vars used, succeeds" "fail" + echo " output: $OUT" +fi + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +printf "Passed: ${GREEN}%d${NC} Failed: ${RED}%d${NC}\n" "$pass" "$fail" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +[ "$fail" -eq 0 ] diff --git a/clients/go/sync/src/client.go b/clients/go/sync/src/client.go index 402f2e1..7252951 100644 --- a/clients/go/sync/src/client.go +++ b/clients/go/sync/src/client.go @@ -20,7 +20,7 @@ func NewClient(publicKey, secretKey string) *Client { // NewClientFromEnv resolves credentials from the 4-tier priority system. func NewClientFromEnv() (*Client, error) { - creds, err := ResolveCredentials("", "") + creds, err := ResolveCredentials("", "", -1) if err != nil { return nil, err } diff --git a/clients/go/sync/src/un.go b/clients/go/sync/src/un.go index 09df8e9..06cfbda 100644 --- a/clients/go/sync/src/un.go +++ b/clients/go/sync/src/un.go @@ -161,10 +161,12 @@ func loadCredentialsFromCsv(csvPath string, accountIndex int) *Credentials { // // Priority: // 1. Function arguments (publicKey, secretKey non-empty) -// 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) -// 3. ~/.unsandbox/accounts.csv -// 4. ./accounts.csv -func ResolveCredentials(publicKey, secretKey string) (*Credentials, error) { +// 2. accountIndex >= 0 → load from accounts.csv row N (before env vars) +// 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) +// 4. Default CSV lookup (account 0 or UNSANDBOX_ACCOUNT env) +// +// Pass accountIndex = -1 to mean "not specified". +func ResolveCredentials(publicKey, secretKey string, accountIndex int) (*Credentials, error) { // Tier 1: Function arguments if publicKey != "" && secretKey != "" { return &Credentials{ @@ -173,7 +175,23 @@ func ResolveCredentials(publicKey, secretKey string) (*Credentials, error) { }, nil } - // Tier 2: Environment variables + // Tier 2: Explicit account index → load from CSV before checking env vars + if accountIndex >= 0 { + unsandboxDir, err := getUnsandboxDir() + if err == nil { + if creds := loadCredentialsFromCsv(filepath.Join(unsandboxDir, "accounts.csv"), accountIndex); creds != nil { + return creds, nil + } + } + if creds := loadCredentialsFromCsv("accounts.csv", accountIndex); creds != nil { + return creds, nil + } + return nil, &CredentialsError{ + Message: fmt.Sprintf("No credentials found at account index %d in accounts.csv", accountIndex), + } + } + + // Tier 3: Environment variables envPk := os.Getenv("UNSANDBOX_PUBLIC_KEY") envSk := os.Getenv("UNSANDBOX_SECRET_KEY") if envPk != "" && envSk != "" { @@ -183,35 +201,36 @@ func ResolveCredentials(publicKey, secretKey string) (*Credentials, error) { }, nil } - // Determine account index - accountIndex := 0 + // Determine default account index from env + defaultIndex := 0 if envAccount := os.Getenv("UNSANDBOX_ACCOUNT"); envAccount != "" { var err error - accountIndex, err = strconv.Atoi(envAccount) + defaultIndex, err = strconv.Atoi(envAccount) if err != nil { - accountIndex = 0 + defaultIndex = 0 } } - // Tier 3: ~/.unsandbox/accounts.csv + // Tier 4: ~/.unsandbox/accounts.csv unsandboxDir, err := getUnsandboxDir() if err == nil { - if creds := loadCredentialsFromCsv(filepath.Join(unsandboxDir, "accounts.csv"), accountIndex); creds != nil { + if creds := loadCredentialsFromCsv(filepath.Join(unsandboxDir, "accounts.csv"), defaultIndex); creds != nil { return creds, nil } } - // Tier 4: ./accounts.csv - if creds := loadCredentialsFromCsv("accounts.csv", accountIndex); creds != nil { + // Tier 5: ./accounts.csv + if creds := loadCredentialsFromCsv("accounts.csv", defaultIndex); creds != nil { return creds, nil } return nil, &CredentialsError{ Message: "No credentials found. Please provide via:\n" + " 1. Function arguments (publicKey, secretKey)\n" + - " 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" + - " 3. ~/.unsandbox/accounts.csv\n" + - " 4. ./accounts.csv", + " 2. --account N flag (CSV row N)\n" + + " 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" + + " 4. ~/.unsandbox/accounts.csv\n" + + " 5. ./accounts.csv", } } @@ -1647,18 +1666,19 @@ const ( // CLIOptions holds parsed CLI arguments type CLIOptions struct { // Global options - Shell string - Env []string - Files []string - FilePaths []string - Artifacts bool - OutputDir string - PublicKey string - SecretKey string - Network string - VCPU int - Yes bool - Help bool + Shell string + Env []string + Files []string + FilePaths []string + Artifacts bool + OutputDir string + PublicKey string + SecretKey string + Network string + VCPU int + Yes bool + Help bool + AccountIndex int // -1 means not specified // Command Command string @@ -3153,7 +3173,7 @@ func runLanguages(creds *Credentials, args []string) int { // parseGlobalFlags parses global CLI options func parseGlobalFlags(args []string) (*CLIOptions, []string) { - opts := &CLIOptions{} + opts := &CLIOptions{AccountIndex: -1} remaining := []string{} for i := 0; i < len(args); i++ { @@ -3196,6 +3216,13 @@ func parseGlobalFlags(args []string) (*CLIOptions, []string) { opts.SecretKey = args[i+1] i++ } + case arg == "--account": + if i+1 < len(args) { + if v, err := strconv.Atoi(args[i+1]); err == nil { + opts.AccountIndex = v + } + i++ + } case arg == "-n" || arg == "--network": if i+1 < len(args) { opts.Network = args[i+1] @@ -3249,7 +3276,7 @@ func CliMain() { } // Resolve credentials - creds, err := ResolveCredentials(opts.PublicKey, opts.SecretKey) + creds, err := ResolveCredentials(opts.PublicKey, opts.SecretKey, opts.AccountIndex) if err != nil { fmt.Fprintf(os.Stderr, "Error: %s\n", err) os.Exit(ExitAuthError) diff --git a/clients/go/sync/src/un_test.go b/clients/go/sync/src/un_test.go index 27db114..d9adcd0 100644 --- a/clients/go/sync/src/un_test.go +++ b/clients/go/sync/src/un_test.go @@ -173,7 +173,7 @@ func TestResolveCredentialsFromEnv(t *testing.T) { os.Setenv("UNSANDBOX_SECRET_KEY", testSK) // Test - creds, err := ResolveCredentials("", "") + creds, err := ResolveCredentials("", "", -1) if err != nil { t.Fatalf("ResolveCredentials failed: %v", err) } @@ -201,7 +201,7 @@ func TestResolveCredentialsFromArgs(t *testing.T) { testPK := "unsb-pk-arg1-arg2-arg3-arg4" testSK := "unsb-sk-arg11-arg22-arg33-arg44" - creds, err := ResolveCredentials(testPK, testSK) + creds, err := ResolveCredentials(testPK, testSK, -1) if err != nil { t.Fatalf("ResolveCredentials failed: %v", err) } @@ -218,7 +218,7 @@ func TestResolveCredentialsFromArgs(t *testing.T) { // ============================================================================ func getTestCredentials(t *testing.T) *Credentials { - creds, err := ResolveCredentials("", "") + creds, err := ResolveCredentials("", "", -1) if err != nil { t.Skip("No credentials available for functional tests") } diff --git a/clients/go/sync/tests/test_account_flag.sh b/clients/go/sync/tests/test_account_flag.sh new file mode 100755 index 0000000..c1d2f71 --- /dev/null +++ b/clients/go/sync/tests/test_account_flag.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Integration test: --account N flag must take priority over env vars +# +# Requires: UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY (real credentials) +# Run: make test-integration OR bash tests/test_account_flag.sh +# +# The defect this guards against: ResolveCredentials() checked env vars before +# account_index, so --account N was silently ignored when env vars existed. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC_DIR="$SCRIPT_DIR/../src" +UN_BIN="$SCRIPT_DIR/../un" + +RED='\033[31m' +GREEN='\033[32m' +NC='\033[0m' + +pass=0 +fail=0 + +check() { + local desc="$1" result="$2" + if [ "$result" = "pass" ]; then + printf " ${GREEN}✓${NC} %s\n" "$desc" + pass=$((pass + 1)) + else + printf " ${RED}✗${NC} %s\n" "$desc" + fail=$((fail + 1)) + fi +} + +# Require real credentials to be available +if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then + echo "SKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set" + exit 0 +fi + +# Build the binary if it doesn't exist or source is newer +if [ ! -x "$UN_BIN" ] || [ "$SRC_DIR/un.go" -nt "$UN_BIN" ]; then + echo "Building Go binary..." + (cd "$SRC_DIR" && go build -o "$UN_BIN" .) || { + echo "FAIL: go build failed" + exit 1 + } +fi + +if [ ! -x "$UN_BIN" ]; then + echo "FAIL: UN binary not found at $UN_BIN — run go build first" + exit 1 +fi + +REAL_PK="$UNSANDBOX_PUBLIC_KEY" +REAL_SK="$UNSANDBOX_SECRET_KEY" + +# Temporary HOME with accounts.csv: +# index 0: garbage credentials (will always 401) +# index 1: real credentials (will succeed) +TMPHOME="$(mktemp -d)" +mkdir -p "$TMPHOME/.unsandbox" +trap 'rm -rf "$TMPHOME"' EXIT + +cat > "$TMPHOME/.unsandbox/accounts.csv" <&1 || true) + +if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials"; then + check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "pass" +else + check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "fail" + echo " output: $OUT" +fi + +# --- Test 2: --account 0 should use CSV row 0 (garbage creds) → 401 --- +# Even though real env vars are set, explicit --account 0 should pick garbage creds +OUT=$(HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="$REAL_PK" \ + UNSANDBOX_SECRET_KEY="$REAL_SK" \ + "$UN_BIN" --account 0 key 2>&1 || true) + +if echo "$OUT" | grep -qi "401\|unauthorized\|invalid\|error"; then + check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "pass" +else + check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "fail" + echo " output: $OUT" +fi + +# --- Test 3: no --account flag, real env vars → env vars win over garbage CSV row 0 --- +OUT=$(HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="$REAL_PK" \ + UNSANDBOX_SECRET_KEY="$REAL_SK" \ + "$UN_BIN" key 2>&1 || true) + +if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials"; then + check "No --account flag: env vars used, succeeds" "pass" +else + check "No --account flag: env vars used, succeeds" "fail" + echo " output: $OUT" +fi + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +printf "Passed: ${GREEN}%d${NC} Failed: ${RED}%d${NC}\n" "$pass" "$fail" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +[ "$fail" -eq 0 ] diff --git a/clients/go/sync/tests/un_test.go b/clients/go/sync/tests/un_test.go index 27db114..d9adcd0 100644 --- a/clients/go/sync/tests/un_test.go +++ b/clients/go/sync/tests/un_test.go @@ -173,7 +173,7 @@ func TestResolveCredentialsFromEnv(t *testing.T) { os.Setenv("UNSANDBOX_SECRET_KEY", testSK) // Test - creds, err := ResolveCredentials("", "") + creds, err := ResolveCredentials("", "", -1) if err != nil { t.Fatalf("ResolveCredentials failed: %v", err) } @@ -201,7 +201,7 @@ func TestResolveCredentialsFromArgs(t *testing.T) { testPK := "unsb-pk-arg1-arg2-arg3-arg4" testSK := "unsb-sk-arg11-arg22-arg33-arg44" - creds, err := ResolveCredentials(testPK, testSK) + creds, err := ResolveCredentials(testPK, testSK, -1) if err != nil { t.Fatalf("ResolveCredentials failed: %v", err) } @@ -218,7 +218,7 @@ func TestResolveCredentialsFromArgs(t *testing.T) { // ============================================================================ func getTestCredentials(t *testing.T) *Credentials { - creds, err := ResolveCredentials("", "") + creds, err := ResolveCredentials("", "", -1) if err != nil { t.Skip("No credentials available for functional tests") } diff --git a/clients/java/sync/src/Un.java b/clients/java/sync/src/Un.java index a6aaeca..19d903e 100644 --- a/clients/java/sync/src/Un.java +++ b/clients/java/sync/src/Un.java @@ -25,11 +25,12 @@ * // Snapshot operations * String snapshotId = Un.sessionSnapshot(sessionId, publicKey, secretKey, "my-snapshot", false); * - * Authentication Priority (4-tier): + * Authentication Priority (5-tier): * 1. Method arguments (publicKey, secretKey) - * 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) - * 3. Config file (~/.unsandbox/accounts.csv, line 0 by default) - * 4. Local directory (./accounts.csv, line 0 by default) + * 2. --account N flag / accountIndex >= 0 (load row N from accounts.csv) + * 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) + * 4. Config file (~/.unsandbox/accounts.csv, line 0 by default) + * 5. Local directory (./accounts.csv, line 0 by default) * * Request Authentication (HMAC-SHA256): * Authorization: Bearer @@ -175,38 +176,58 @@ public class Un { } private static String[] resolveCredentials(String publicKey, String secretKey) { + return resolveCredentials(publicKey, secretKey, -1); + } + + private static String[] resolveCredentials(String publicKey, String secretKey, int accountIndex) { // Tier 1: Method arguments if (publicKey != null && !publicKey.isEmpty() && secretKey != null && !secretKey.isEmpty()) { return new String[]{publicKey, secretKey}; } - // Tier 2: Environment variables + // Tier 2: Explicit account index (e.g. --account N from CLI) + if (accountIndex >= 0) { + Path unsandboxDir = getUnsandboxDir(); + String[] creds = loadCredentialsFromCsv(unsandboxDir.resolve("accounts.csv"), accountIndex); + if (creds != null) { + return creds; + } + creds = loadCredentialsFromCsv(Paths.get("accounts.csv"), accountIndex); + if (creds != null) { + return creds; + } + throw new CredentialsException( + "No credentials found at account index " + accountIndex + " in accounts.csv" + ); + } + + // Tier 3: Environment variables String envPk = System.getenv("UNSANDBOX_PUBLIC_KEY"); String envSk = System.getenv("UNSANDBOX_SECRET_KEY"); if (envPk != null && !envPk.isEmpty() && envSk != null && !envSk.isEmpty()) { return new String[]{envPk, envSk}; } - // Determine account index - int accountIndex = 0; + // Determine account index from env (default 0) + int csvIndex = 0; String accountEnv = System.getenv("UNSANDBOX_ACCOUNT"); if (accountEnv != null && !accountEnv.isEmpty()) { try { - accountIndex = Integer.parseInt(accountEnv); + csvIndex = Integer.parseInt(accountEnv); } catch (NumberFormatException e) { // Use default } } - // Tier 3: ~/.unsandbox/accounts.csv + // Tier 4: ~/.unsandbox/accounts.csv Path unsandboxDir = getUnsandboxDir(); - String[] creds = loadCredentialsFromCsv(unsandboxDir.resolve("accounts.csv"), accountIndex); + String[] creds = loadCredentialsFromCsv(unsandboxDir.resolve("accounts.csv"), csvIndex); if (creds != null) { return creds; } - // Tier 4: ./accounts.csv - creds = loadCredentialsFromCsv(Paths.get("accounts.csv"), accountIndex); + // Tier 5: ./accounts.csv + creds = loadCredentialsFromCsv(Paths.get("accounts.csv"), csvIndex); if (creds != null) { return creds; } @@ -214,9 +235,10 @@ public class Un { throw new CredentialsException( "No credentials found. Please provide via:\n" + " 1. Method arguments (publicKey, secretKey)\n" + - " 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" + - " 3. ~/.unsandbox/accounts.csv\n" + - " 4. ./accounts.csv" + " 2. --account N flag (load row N from accounts.csv)\n" + + " 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" + + " 4. ~/.unsandbox/accounts.csv\n" + + " 5. ./accounts.csv" ); } @@ -2860,6 +2882,7 @@ public class Un { String language = null; String networkMode = "zerotrust"; int vcpu = 1; + int accountIndex = -1; List envVars = new ArrayList<>(); List files = new ArrayList<>(); List positionalArgs = new ArrayList<>(); @@ -2871,6 +2894,18 @@ public class Un { if (arg.equals("-h") || arg.equals("--help")) { showHelp = true; i++; + } else if (arg.equals("--account")) { + if (i + 1 >= args.length) { + System.err.println("Error: --account requires an argument"); + System.exit(2); + } + try { + accountIndex = Integer.parseInt(args[++i]); + } catch (NumberFormatException e) { + System.err.println("Error: --account requires an integer argument"); + System.exit(2); + } + i++; } else if (arg.equals("-s") || arg.equals("--shell")) { if (i + 1 >= args.length) { System.err.println("Error: -s/--shell requires an argument"); @@ -2936,6 +2971,14 @@ public class Un { String command = positionalArgs.get(0); + // Pre-resolve credentials so --account N is honoured by all subcommands. + // Only resolve if explicit keys were not supplied via -p/-k flags. + if (publicKey == null || publicKey.isEmpty() || secretKey == null || secretKey.isEmpty()) { + String[] creds = resolveCredentials(publicKey, secretKey, accountIndex); + publicKey = creds[0]; + secretKey = creds[1]; + } + // Route to subcommand handlers switch (command) { case "session": @@ -2982,6 +3025,7 @@ public class Un { System.out.println(" -f, --file FILE Add input file to /tmp/"); System.out.println(" -p, --public-key KEY API public key"); System.out.println(" -k, --secret-key KEY API secret key"); + System.out.println(" --account N Use row N from accounts.csv (overrides env vars)"); System.out.println(" -n, --network MODE Network: zerotrust or semitrusted"); System.out.println(" -v, --vcpu N vCPU count (1-8)"); System.out.println(" -h, --help Show help"); diff --git a/clients/java/sync/tests/test_account_flag.sh b/clients/java/sync/tests/test_account_flag.sh new file mode 100755 index 0000000..15d8ccf --- /dev/null +++ b/clients/java/sync/tests/test_account_flag.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Integration test: --account N credential selection in Java SDK CLI +# +# Tests that --account N loads row N from accounts.csv and takes priority +# over environment variables UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY. +# +# Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY set in the environment. +# SKIP if not set. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC_DIR="$(cd "$SCRIPT_DIR/../src" && pwd)" + +PASS=0 +FAIL=0 +SKIP=0 + +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } +skip() { echo "SKIP: $1"; SKIP=$((SKIP + 1)); } + +# ---- Prerequisites ---- + +if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then + skip "UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY not set" + echo "" + echo "Results: PASS=$PASS FAIL=$FAIL SKIP=$SKIP" + exit 0 +fi + +# Compile Un.java if needed +CLASS_FILE="$SRC_DIR/Un.class" +if [ ! -f "$CLASS_FILE" ] || [ "$SRC_DIR/Un.java" -nt "$CLASS_FILE" ]; then + echo "Compiling Un.java..." + if ! javac -cp "$SRC_DIR" "$SRC_DIR/Un.java" 2>&1; then + fail "javac compilation failed" + echo "" + echo "Results: PASS=$PASS FAIL=$FAIL SKIP=$SKIP" + exit 1 + fi +fi + +# ---- Temp home setup ---- + +TMPHOME="$(mktemp -d)" +trap 'rm -rf "$TMPHOME"' EXIT + +mkdir -p "$TMPHOME/.unsandbox" +# Row 0: garbage credentials +# Row 1: real credentials from environment +printf 'garbage-pk,garbage-sk\n%s,%s\n' \ + "$UNSANDBOX_PUBLIC_KEY" "$UNSANDBOX_SECRET_KEY" \ + > "$TMPHOME/.unsandbox/accounts.csv" + +# ---- Test 1: --account 1 loads real creds, env vars set to garbage ---- +# With HOME=TMPHOME, env set to garbage, --account 1 should pick real creds +# and the 'key' subcommand should succeed (validateKeys returns 200). + +RESULT=$( + HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="garbage-pk-env" \ + UNSANDBOX_SECRET_KEY="garbage-sk-env" \ + java -cp "$SRC_DIR" Un --account 1 key 2>&1 +) && RC=$? || RC=$? + +if [ $RC -eq 0 ]; then + pass "--account 1 loads row 1 from accounts.csv (real creds), ignores garbage env vars" +else + fail "--account 1 should have succeeded but exited $RC: $RESULT" +fi + +# ---- Test 2: --account 0 loads garbage creds, should get 401/error ---- +# With env vars set to real creds, --account 0 should use garbage row 0 +# and the API call should fail (unauthorized). + +RESULT=$( + HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="$UNSANDBOX_PUBLIC_KEY" \ + UNSANDBOX_SECRET_KEY="$UNSANDBOX_SECRET_KEY" \ + java -cp "$SRC_DIR" Un --account 0 key 2>&1 +) && RC=$? || RC=$? + +if [ $RC -ne 0 ]; then + pass "--account 0 loads garbage creds from row 0, API rejects them (env vars ignored)" +else + fail "--account 0 should have failed (garbage creds) but succeeded: $RESULT" +fi + +# ---- Test 3: no --account flag, env vars set to real creds → success ---- + +RESULT=$( + HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="$UNSANDBOX_PUBLIC_KEY" \ + UNSANDBOX_SECRET_KEY="$UNSANDBOX_SECRET_KEY" \ + java -cp "$SRC_DIR" Un key 2>&1 +) && RC=$? || RC=$? + +if [ $RC -eq 0 ]; then + pass "no --account flag uses env vars (real creds), succeeds" +else + fail "no --account flag should succeed with real env var creds but exited $RC: $RESULT" +fi + +# ---- Summary ---- + +echo "" +echo "Results: PASS=$PASS FAIL=$FAIL SKIP=$SKIP" + +if [ $FAIL -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/clients/javascript/sync/src/un.js b/clients/javascript/sync/src/un.js index f975033..675ff2e 100644 --- a/clients/javascript/sync/src/un.js +++ b/clients/javascript/sync/src/un.js @@ -331,10 +331,11 @@ function loadCredentialsFromStorage() { * * Priority: * 1. Function arguments - * 2. Environment variables (Node.js) - * 3. localStorage (Browser) - * 4. ~/.unsandbox/accounts.csv (Node.js) - * 5. ./accounts.csv (Node.js) + * 2. accountIndex >= 0 → load from accounts.csv row N + * 3. Environment variables (Node.js) + * 4. localStorage (Browser) + * 5. ~/.unsandbox/accounts.csv (default row, Node.js) + * 6. ./accounts.csv (default row, Node.js) */ function resolveCredentials(publicKey, secretKey, accountIndex) { // Tier 1: Function arguments @@ -342,7 +343,26 @@ function resolveCredentials(publicKey, secretKey, accountIndex) { return [publicKey, secretKey]; } - // Tier 2: Environment variables (Node.js only) + // Tier 2: Explicit accountIndex → load from accounts.csv row N (Node.js only) + if (IS_NODE && fs && path && accountIndex !== undefined && accountIndex >= 0) { + // ~/.unsandbox/accounts.csv first + try { + const unsandboxDir = getUnsandboxDir(); + const creds = loadCredentialsFromCsv(path.join(unsandboxDir, 'accounts.csv'), accountIndex); + if (creds) { + return creds; + } + } catch (e) { + // Continue to next location + } + // ./accounts.csv fallback + const creds = loadCredentialsFromCsv('accounts.csv', accountIndex); + if (creds) { + return creds; + } + } + + // Tier 3: Environment variables (Node.js only) if (IS_NODE) { const envPk = process.env.UNSANDBOX_PUBLIC_KEY; const envSk = process.env.UNSANDBOX_SECRET_KEY; @@ -351,7 +371,7 @@ function resolveCredentials(publicKey, secretKey, accountIndex) { } } - // Tier 3: localStorage (Browser only) + // Tier 4: localStorage (Browser only) if (IS_BROWSER) { const storageCreds = loadCredentialsFromStorage(); if (storageCreds) { @@ -359,17 +379,14 @@ function resolveCredentials(publicKey, secretKey, accountIndex) { } } - // Tier 4 & 5: File-based credentials (Node.js only) + // Tier 5 & 6: File-based credentials with default index (Node.js only) if (IS_NODE && fs && path) { - // Determine account index - if (accountIndex === undefined) { - accountIndex = parseInt(process.env.UNSANDBOX_ACCOUNT || '0', 10); - } + const defaultIndex = parseInt(process.env.UNSANDBOX_ACCOUNT || '0', 10); - // Tier 4: ~/.unsandbox/accounts.csv + // Tier 5: ~/.unsandbox/accounts.csv try { const unsandboxDir = getUnsandboxDir(); - const creds = loadCredentialsFromCsv(path.join(unsandboxDir, 'accounts.csv'), accountIndex); + const creds = loadCredentialsFromCsv(path.join(unsandboxDir, 'accounts.csv'), defaultIndex); if (creds) { return creds; } @@ -377,8 +394,8 @@ function resolveCredentials(publicKey, secretKey, accountIndex) { // Continue to next tier } - // Tier 5: ./accounts.csv - const creds = loadCredentialsFromCsv('accounts.csv', accountIndex); + // Tier 6: ./accounts.csv + const creds = loadCredentialsFromCsv('accounts.csv', defaultIndex); if (creds) { return creds; } @@ -2193,6 +2210,7 @@ function parseArgs(args) { output: null, publicKey: null, secretKey: null, + accountIndex: undefined, network: 'zerotrust', vcpu: 1, yes: false, @@ -2322,6 +2340,9 @@ function parseArgs(args) { } else if (arg === '-k' || arg === '--secret-key') { result.secretKey = args[++i]; i++; + } else if (arg === '--account') { + result.accountIndex = parseInt(args[++i], 10); + i++; } else if (arg === '-n' || arg === '--network') { result.network = args[++i]; i++; @@ -2509,8 +2530,7 @@ function formatTable(items, columns) { * Handle session commands. */ async function handleSession(opts) { - const pk = opts.publicKey; - const sk = opts.secretKey; + let [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex); // List sessions if (opts.list) { @@ -2593,8 +2613,7 @@ async function handleSession(opts) { * Handle service commands. */ async function handleService(opts) { - const pk = opts.publicKey; - const sk = opts.secretKey; + let [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex); // Handle env subcommand if (opts.subcommand === 'env') { @@ -2829,8 +2848,7 @@ async function handleService(opts) { * Handle snapshot commands. */ async function handleSnapshot(opts) { - const pk = opts.publicKey; - const sk = opts.secretKey; + let [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex); // List snapshots if (opts.list) { @@ -2898,8 +2916,7 @@ async function handleSnapshot(opts) { * Handle image command. */ async function handleImage(opts) { - const pk = opts.publicKey; - const sk = opts.secretKey; + let [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex); // List images if (opts.list) { @@ -3002,13 +3019,13 @@ async function handleImage(opts) { * Handle key command. */ async function handleKey(opts) { + const [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex); try { - const result = await validateKeys(opts.publicKey, opts.secretKey); + const result = await validateKeys(pk, sk); console.log('API Key Status:'); console.log(JSON.stringify(result, null, 2)); } catch (err) { // If validate endpoint doesn't exist, just show that credentials were resolved - const [pk] = resolveCredentials(opts.publicKey, opts.secretKey); console.log(`Public Key: ${pk}`); console.log('Key validation endpoint returned error - key may still be valid.'); } @@ -3018,7 +3035,8 @@ async function handleKey(opts) { * Handle languages command. */ async function handleLanguages(opts) { - const languages = await getLanguages(opts.publicKey, opts.secretKey); + const [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex); + const languages = await getLanguages(pk, sk); if (opts.json) { // Output as JSON array @@ -3035,8 +3053,7 @@ async function handleLanguages(opts) { * Handle execute command (default). */ async function handleExecute(opts) { - const pk = opts.publicKey; - const sk = opts.secretKey; + let [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex); let code; let language; diff --git a/clients/javascript/sync/tests/test_account_flag.sh b/clients/javascript/sync/tests/test_account_flag.sh new file mode 100755 index 0000000..bd38ec9 --- /dev/null +++ b/clients/javascript/sync/tests/test_account_flag.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Integration test for --account N credential selection in un.js. +# +# Tests that --account N selects the correct row from accounts.csv, +# taking priority over environment variables. +# +# Requires UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY to be set. +# Skips if credentials are not available. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +UN_JS="$SCRIPT_DIR/../src/un.js" + +PASS=0 +FAIL=0 +SKIP=0 + +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } +skip() { echo "SKIP: $1"; SKIP=$((SKIP + 1)); } + +# Require real credentials from environment +if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then + skip "UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY not set" + echo "Results: PASS=$PASS FAIL=$FAIL SKIP=$SKIP" + exit 0 +fi + +REAL_PK="$UNSANDBOX_PUBLIC_KEY" +REAL_SK="$UNSANDBOX_SECRET_KEY" +GARBAGE_PK="unsb-pk-0000-0000-0000-garbage" +GARBAGE_SK="unsb-sk-00000-00000-00000-garbage" + +# Build a temporary HOME with accounts.csv: row 0 = garbage, row 1 = real creds +TMPHOME="$(mktemp -d)" +mkdir -p "$TMPHOME/.unsandbox" +# Header + row 0 (garbage) + row 1 (real) +printf 'public_key,secret_key\n%s,%s\n%s,%s\n' \ + "$GARBAGE_PK" "$GARBAGE_SK" \ + "$REAL_PK" "$REAL_SK" \ + > "$TMPHOME/.unsandbox/accounts.csv" + +cleanup() { + rm -rf "$TMPHOME" +} +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# Test 1: HOME=TMPHOME, env vars = garbage, --account 1 => row 1 = real creds +# Expect: key command succeeds (no auth error) +# --------------------------------------------------------------------------- +if HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="$GARBAGE_PK" \ + UNSANDBOX_SECRET_KEY="$GARBAGE_SK" \ + node "$UN_JS" --account 1 key 2>&1 | grep -qiE 'Public Key|key_id|account|status'; then + pass "Test 1: --account 1 selects row 1 (real creds) over env garbage" +else + # Also accept a successful JSON response (key validates) + output="$(HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="$GARBAGE_PK" \ + UNSANDBOX_SECRET_KEY="$GARBAGE_SK" \ + node "$UN_JS" --account 1 key 2>&1 || true)" + if echo "$output" | grep -qiE '401|invalid|unauthorized|authentication'; then + fail "Test 1: --account 1 selected row 1 but auth failed (real creds may be invalid)" + else + pass "Test 1: --account 1 selects row 1 (real creds) over env garbage" + fi +fi + +# --------------------------------------------------------------------------- +# Test 2: HOME=TMPHOME, env vars = real, --account 0 => row 0 = garbage creds +# Expect: 401 / auth error (garbage creds used despite real env vars) +# --------------------------------------------------------------------------- +output2="$(HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="$REAL_PK" \ + UNSANDBOX_SECRET_KEY="$REAL_SK" \ + node "$UN_JS" --account 0 key 2>&1 || true)" +if echo "$output2" | grep -qiE '401|invalid|unauthorized|authentication|error'; then + pass "Test 2: --account 0 selects row 0 (garbage) over env real creds (expected auth failure)" +else + fail "Test 2: --account 0 should use garbage creds and fail auth, but got: $output2" +fi + +# --------------------------------------------------------------------------- +# Test 3: HOME=TMPHOME, env vars = real, no --account => env vars win (real creds) +# Expect: key command succeeds +# --------------------------------------------------------------------------- +output3="$(HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="$REAL_PK" \ + UNSANDBOX_SECRET_KEY="$REAL_SK" \ + node "$UN_JS" key 2>&1 || true)" +if echo "$output3" | grep -qiE '401|invalid|unauthorized|authentication failed'; then + fail "Test 3: without --account, env real creds should succeed but got auth error: $output3" +else + pass "Test 3: without --account, env vars (real creds) are used" +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "Results: PASS=$PASS FAIL=$FAIL SKIP=$SKIP" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/clients/php/sync/src/un.php b/clients/php/sync/src/un.php index 0ac0920..46f209a 100644 --- a/clients/php/sync/src/un.php +++ b/clients/php/sync/src/un.php @@ -138,6 +138,7 @@ class Unsandbox { private ?string $defaultPublicKey = null; private ?string $defaultSecretKey = null; private int $accountIndex = 0; + private bool $accountIndexExplicit = false; /** * Create a new Unsandbox client. @@ -1693,17 +1694,18 @@ class Unsandbox { * Resolve credentials from 4-tier priority system. * * Priority: - * 1. Method arguments - * 2. Environment variables - * 3. ~/.unsandbox/accounts.csv - * 4. ./accounts.csv + * 1. Method arguments / constructor defaults + * 2. $accountIndex >= 0 → load from accounts.csv row N + * 3. Environment variables (UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY) + * 4. Default CSV lookup (account 0) * * @param string|null $publicKey Public key from method argument * @param string|null $secretKey Secret key from method argument + * @param int|null $accountIndex Explicit account index (overrides env and default) * @return array [publicKey, secretKey] * @throws CredentialsException If no credentials found */ - private function resolveCredentials(?string $publicKey, ?string $secretKey): array { + private function resolveCredentials(?string $publicKey, ?string $secretKey, ?int $accountIndex = null): array { // Tier 1: Method arguments if (!empty($publicKey) && !empty($secretKey)) { return [$publicKey, $secretKey]; @@ -1714,29 +1716,50 @@ class Unsandbox { return [$this->defaultPublicKey, $this->defaultSecretKey]; } - // Tier 2: Environment variables + // Tier 2: Explicit account index (--account N flag or constructor accountIndex != 0) + // Resolve the effective account index: explicit arg > UNSANDBOX_ACCOUNT env > $this->accountIndex + $effectiveIndex = null; + if ($accountIndex !== null && $accountIndex >= 0) { + $effectiveIndex = $accountIndex; + } elseif ($this->accountIndexExplicit) { + // --account flag was used on CLI (may be 0, so can't rely on != 0 check) + $effectiveIndex = $this->accountIndex; + } else { + $envAccount = getenv('UNSANDBOX_ACCOUNT'); + if ($envAccount !== false && $envAccount !== '') { + $effectiveIndex = (int)$envAccount; + } elseif ($this->accountIndex !== 0) { + $effectiveIndex = $this->accountIndex; + } + } + + if ($effectiveIndex !== null) { + $unsandboxDir = $this->getUnsandboxDir(); + $creds = $this->loadCredentialsFromCsv($unsandboxDir . '/accounts.csv', $effectiveIndex); + if ($creds !== null) { + return $creds; + } + $creds = $this->loadCredentialsFromCsv('./accounts.csv', $effectiveIndex); + if ($creds !== null) { + return $creds; + } + } + + // Tier 3: Environment variables $envPk = getenv('UNSANDBOX_PUBLIC_KEY'); $envSk = getenv('UNSANDBOX_SECRET_KEY'); if (!empty($envPk) && !empty($envSk)) { return [$envPk, $envSk]; } - // Determine account index - $accountIndex = $this->accountIndex; - $envAccount = getenv('UNSANDBOX_ACCOUNT'); - if ($envAccount !== false && $envAccount !== '') { - $accountIndex = (int)$envAccount; - } - - // Tier 3: ~/.unsandbox/accounts.csv + // Tier 4: Default CSV lookup (account 0) $unsandboxDir = $this->getUnsandboxDir(); - $creds = $this->loadCredentialsFromCsv($unsandboxDir . '/accounts.csv', $accountIndex); + $creds = $this->loadCredentialsFromCsv($unsandboxDir . '/accounts.csv', 0); if ($creds !== null) { return $creds; } - // Tier 4: ./accounts.csv - $creds = $this->loadCredentialsFromCsv('./accounts.csv', $accountIndex); + $creds = $this->loadCredentialsFromCsv('./accounts.csv', 0); if ($creds !== null) { return $creds; } @@ -1744,9 +1767,9 @@ class Unsandbox { throw new CredentialsException( "No credentials found. Please provide via:\n" . " 1. Method arguments (publicKey, secretKey)\n" . - " 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" . - " 3. ~/.unsandbox/accounts.csv\n" . - " 4. ./accounts.csv" + " 2. --account N flag or UNSANDBOX_ACCOUNT env var (CSV row N)\n" . + " 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" . + " 4. ~/.unsandbox/accounts.csv or ./accounts.csv (row 0)" ); } @@ -2138,6 +2161,10 @@ class Unsandbox { if (!empty($opts['secret_key'])) { $this->defaultSecretKey = $opts['secret_key']; } + if ($opts['account'] !== null) { + $this->accountIndex = $opts['account']; + $this->accountIndexExplicit = true; + } // Determine the command if (empty($args)) { @@ -2209,6 +2236,7 @@ class Unsandbox { 'vcpu' => 1, 'yes' => false, 'help' => false, + 'account' => null, ]; $args = []; @@ -2255,6 +2283,11 @@ class Unsandbox { $opts['yes'] = true; } elseif ($arg === '-h' || $arg === '--help') { $opts['help'] = true; + } elseif ($arg === '--account') { + $i++; + if (isset($argv[$i])) { + $opts['account'] = (int)$argv[$i]; + } } elseif (strpos($arg, '-') !== 0) { $args[] = $arg; } diff --git a/clients/php/sync/tests/test_account_flag.sh b/clients/php/sync/tests/test_account_flag.sh new file mode 100755 index 0000000..f96cb01 --- /dev/null +++ b/clients/php/sync/tests/test_account_flag.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Integration test for --account N credential selection in the PHP SDK CLI. +# +# Tests that --account N selects the correct row from accounts.csv, +# taking priority over UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY env vars. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +UN_PHP="${SCRIPT_DIR}/../src/un.php" + +PASS=0 +FAIL=0 +SKIP=0 + +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } +skip() { echo "SKIP: $1"; SKIP=$((SKIP + 1)); } + +# Require real credentials to run meaningful tests +if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then + skip "UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY not set - cannot run account flag tests" + echo "" + echo "Results: ${PASS} passed, ${FAIL} failed, ${SKIP} skipped" + exit 0 +fi + +REAL_PK="${UNSANDBOX_PUBLIC_KEY}" +REAL_SK="${UNSANDBOX_SECRET_KEY}" +GARBAGE_PK="unsb-pk-0000-0000-0000-garbage" +GARBAGE_SK="unsb-sk-00000-00000-00000-garbage" + +# Create a temporary HOME with accounts.csv: row 0 = garbage, row 1 = real creds +TMPHOME="$(mktemp -d)" +trap 'rm -rf "${TMPHOME}"' EXIT + +mkdir -p "${TMPHOME}/.unsandbox" +printf '%s,%s\n' "${GARBAGE_PK}" "${GARBAGE_SK}" > "${TMPHOME}/.unsandbox/accounts.csv" +printf '%s,%s\n' "${REAL_PK}" "${REAL_SK}" >> "${TMPHOME}/.unsandbox/accounts.csv" + +# Test 1: --account 1 with garbage env vars should load row 1 (real creds) and succeed +echo "Test 1: --account 1 ignores garbage env vars and uses CSV row 1 (real creds)" +output=$(HOME="${TMPHOME}" \ + UNSANDBOX_PUBLIC_KEY="${GARBAGE_PK}" \ + UNSANDBOX_SECRET_KEY="${GARBAGE_SK}" \ + php "${UN_PHP}" --account 1 key 2>&1) && rc=0 || rc=$? + +if [ $rc -eq 0 ]; then + pass "Test 1: --account 1 succeeded with real creds from CSV row 1" +elif echo "${output}" | grep -qi "401\|unauthorized\|forbidden"; then + fail "Test 1: got auth error despite real creds at row 1 (output: ${output})" +else + fail "Test 1: unexpected failure (rc=${rc}, output: ${output})" +fi + +# Test 2: --account 0 with real env vars should load row 0 (garbage creds) and get 401 +echo "Test 2: --account 0 overrides real env vars and uses CSV row 0 (garbage creds)" +output=$(HOME="${TMPHOME}" \ + UNSANDBOX_PUBLIC_KEY="${REAL_PK}" \ + UNSANDBOX_SECRET_KEY="${REAL_SK}" \ + php "${UN_PHP}" --account 0 key 2>&1) && rc=0 || rc=$? + +if echo "${output}" | grep -qi "401\|unauthorized\|forbidden\|authentication\|credentials"; then + pass "Test 2: got expected auth rejection for garbage creds at row 0" +elif [ $rc -eq 3 ]; then + # Exit code 3 = CredentialsException (e.g., empty key) - also acceptable + pass "Test 2: got credentials exception for garbage creds at row 0 (rc=3)" +else + fail "Test 2: expected 401/auth error but got rc=${rc}, output: ${output}" +fi + +# Test 3: No --account flag with real env vars should succeed (env vars take priority over CSV row 0) +echo "Test 3: no --account flag with real env vars should succeed" +output=$(HOME="${TMPHOME}" \ + UNSANDBOX_PUBLIC_KEY="${REAL_PK}" \ + UNSANDBOX_SECRET_KEY="${REAL_SK}" \ + php "${UN_PHP}" key 2>&1) && rc=0 || rc=$? + +if [ $rc -eq 0 ]; then + pass "Test 3: succeeded using env vars when no --account flag set" +elif echo "${output}" | grep -qi "401\|unauthorized\|forbidden"; then + fail "Test 3: got unexpected auth error with real env vars (output: ${output})" +else + fail "Test 3: unexpected failure (rc=${rc}, output: ${output})" +fi + +echo "" +echo "Results: ${PASS} passed, ${FAIL} failed, ${SKIP} skipped" + +if [ $FAIL -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/clients/python/sync/src/un.py b/clients/python/sync/src/un.py index 1bb5fac..b7861b9 100644 --- a/clients/python/sync/src/un.py +++ b/clients/python/sync/src/un.py @@ -95,42 +95,52 @@ def _resolve_credentials( Resolve credentials from 4-tier priority system. Priority: - 1. Function arguments - 2. Environment variables - 3. ~/.unsandbox/accounts.csv - 4. ./accounts.csv + 1. Function arguments (public_key, secret_key) + 2. Explicit account_index (>= 0) -> load from accounts.csv + 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) + 4. Default CSV lookup (account 0 or UNSANDBOX_ACCOUNT env) """ # Tier 1: Function arguments if public_key and secret_key: return (public_key, secret_key) - # Tier 2: Environment variables + # Tier 2: Explicit account_index overrides env vars + if account_index is not None and account_index >= 0: + unsandbox_dir = _get_unsandbox_dir() + creds = _load_credentials_from_csv(unsandbox_dir / "accounts.csv", account_index) + if creds: + return creds + creds = _load_credentials_from_csv(Path("accounts.csv"), account_index) + if creds: + return creds + raise CredentialsError( + f"No credentials found for account index {account_index} in accounts.csv" + ) + + # Tier 3: Environment variables env_pk = os.environ.get("UNSANDBOX_PUBLIC_KEY") env_sk = os.environ.get("UNSANDBOX_SECRET_KEY") if env_pk and env_sk: return (env_pk, env_sk) - # Determine account index - if account_index is None: - account_index = int(os.environ.get("UNSANDBOX_ACCOUNT", "0")) - - # Tier 3: ~/.unsandbox/accounts.csv + # Tier 4: Default CSV lookup (UNSANDBOX_ACCOUNT env or index 0) + default_index = int(os.environ.get("UNSANDBOX_ACCOUNT", "0")) unsandbox_dir = _get_unsandbox_dir() - creds = _load_credentials_from_csv(unsandbox_dir / "accounts.csv", account_index) + creds = _load_credentials_from_csv(unsandbox_dir / "accounts.csv", default_index) if creds: return creds - # Tier 4: ./accounts.csv - creds = _load_credentials_from_csv(Path("accounts.csv"), account_index) + creds = _load_credentials_from_csv(Path("accounts.csv"), default_index) if creds: return creds raise CredentialsError( "No credentials found. Please provide via:\n" " 1. Function arguments (public_key, secret_key)\n" - " 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" - " 3. ~/.unsandbox/accounts.csv\n" - " 4. ./accounts.csv" + " 2. --account N (select row from accounts.csv)\n" + " 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" + " 4. ~/.unsandbox/accounts.csv\n" + " 5. ./accounts.csv" ) @@ -2584,6 +2594,8 @@ Examples: metavar="N", help="vCPU count (1-8, default: 1)") parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompts") + parser.add_argument("--account", type=int, metavar="N", + help="Select account row N from accounts.csv (overrides env vars)") # Subcommands subparsers = parser.add_subparsers(dest="command", help="Commands") @@ -2765,7 +2777,7 @@ def cli_main(): # Resolve credentials try: public_key, secret_key = _resolve_credentials( - args.public_key, args.secret_key + args.public_key, args.secret_key, args.account ) except CredentialsError as e: print(f"Error: {e}", file=sys.stderr) diff --git a/clients/python/sync/tests/test_account_flag.sh b/clients/python/sync/tests/test_account_flag.sh new file mode 100755 index 0000000..4d4cce8 --- /dev/null +++ b/clients/python/sync/tests/test_account_flag.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Integration test: --account N flag must take priority over env vars +# Requires: UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +UN_PY="$SCRIPT_DIR/../src/un.py" + +# Skip if credentials not set +if [[ -z "${UNSANDBOX_PUBLIC_KEY:-}" || -z "${UNSANDBOX_SECRET_KEY:-}" ]]; then + echo "SKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set" + exit 0 +fi + +REAL_PK="$UNSANDBOX_PUBLIC_KEY" +REAL_SK="$UNSANDBOX_SECRET_KEY" +GARBAGE_PK="unsb-pk-0000-0000-0000-0000" +GARBAGE_SK="unsb-sk-00000-00000-00000-00000" + +TMPHOME="$(mktemp -d)" +trap 'rm -rf "$TMPHOME"' EXIT + +UNSANDBOX_DIR="$TMPHOME/.unsandbox" +mkdir -p "$UNSANDBOX_DIR" +chmod 700 "$UNSANDBOX_DIR" + +# accounts.csv: row 0 = garbage, row 1 = real creds +cat > "$UNSANDBOX_DIR/accounts.csv" <&1)" || actual_exit=$? + if [[ "$actual_exit" -eq "$expected_exit" ]]; then + echo "PASS: $name" + PASS=$((PASS + 1)) + else + echo "FAIL: $name (expected exit $expected_exit, got $actual_exit)" + echo " output: $output" + FAIL=$((FAIL + 1)) + fi +} + +# Test 1: --account 1 with garbage env vars -> should succeed (exit 0) +# --account 1 selects the real creds from row 1, ignoring garbage env vars +run_test \ + "--account 1 overrides garbage env vars" \ + 0 \ + env UNSANDBOX_PUBLIC_KEY="$GARBAGE_PK" UNSANDBOX_SECRET_KEY="$GARBAGE_SK" \ + python3 "$UN_PY" --account 1 key + +# Test 2: --account 0 with real env vars -> should fail auth (exit 3) +# --account 0 selects garbage creds from row 0, ignoring real env vars +run_test \ + "--account 0 overrides real env vars (expects 401)" \ + 3 \ + env UNSANDBOX_PUBLIC_KEY="$REAL_PK" UNSANDBOX_SECRET_KEY="$REAL_SK" \ + python3 "$UN_PY" --account 0 key + +# Test 3: no --account with real env vars -> should succeed (exit 0) +run_test \ + "no --account uses env vars" \ + 0 \ + env UNSANDBOX_PUBLIC_KEY="$REAL_PK" UNSANDBOX_SECRET_KEY="$REAL_SK" \ + python3 "$UN_PY" key + +echo "" +echo "Results: $PASS passed, $FAIL failed" +if [[ "$FAIL" -gt 0 ]]; then + exit 1 +fi diff --git a/clients/ruby/sync/src/un.rb b/clients/ruby/sync/src/un.rb index 7dd6ce5..568d4ca 100644 --- a/clients/ruby/sync/src/un.rb +++ b/clients/ruby/sync/src/un.rb @@ -36,9 +36,10 @@ # # Authentication Priority (4-tier): # 1. Method arguments (public_key:, secret_key:) -# 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) -# 3. Config file (~/.unsandbox/accounts.csv, line 0 by default) -# 4. Local directory (./accounts.csv, line 0 by default) +# 2. --account N flag (accounts.csv row N, overrides env vars) +# 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) +# 4. Config file (~/.unsandbox/accounts.csv, line 0 by default) +# 5. Local directory (./accounts.csv, line 0 by default) # # Request Authentication (HMAC-SHA256): # Authorization: Bearer @@ -1415,7 +1416,11 @@ module Un class << self attr_accessor :last_error_value + # CLI-set account index (-1 means not specified). When >= 0, takes + # priority over env vars in resolve_credentials. + attr_accessor :cli_account_index end + @cli_account_index = -1 # Get the SDK version string # @@ -1562,41 +1567,53 @@ module Un # # Priority: # 1. Method arguments - # 2. Environment variables - # 3. ~/.unsandbox/accounts.csv - # 4. ./accounts.csv + # 2. account_index >= 0 (explicit --account N flag) → CSV row N + # 3. Environment variables + # 4. Default CSV lookup (account 0 or UNSANDBOX_ACCOUNT env) # # @param public_key [String, nil] Explicit public key # @param secret_key [String, nil] Explicit secret key - # @param account_index [Integer, nil] Account index for CSV files + # @param account_index [Integer, nil] Account index for CSV files (-1 means not specified) # @return [Array] [public_key, secret_key] # @raise [CredentialsError] If no credentials found def resolve_credentials(public_key = nil, secret_key = nil, account_index = nil) # Tier 1: Method arguments return [public_key, secret_key] if public_key && secret_key - # Tier 2: Environment variables + # Resolve effective account_index: parameter takes precedence, then CLI-set value + effective_index = account_index + effective_index = Un.cli_account_index if effective_index.nil? + + # Tier 2: Explicit account_index (--account N) → load from CSV row N before env vars + if effective_index && effective_index >= 0 + creds = load_credentials_from_csv(File.join(unsandbox_dir, 'accounts.csv'), effective_index) + return creds if creds + + creds = load_credentials_from_csv('accounts.csv', effective_index) + return creds if creds + end + + # Tier 3: Environment variables env_pk = ENV['UNSANDBOX_PUBLIC_KEY'] env_sk = ENV['UNSANDBOX_SECRET_KEY'] return [env_pk, env_sk] if env_pk && env_sk - # Determine account index - account_index ||= ENV.fetch('UNSANDBOX_ACCOUNT', '0').to_i + # Tier 4: Default CSV lookup (UNSANDBOX_ACCOUNT env or row 0) + default_index = ENV.fetch('UNSANDBOX_ACCOUNT', '0').to_i - # Tier 3: ~/.unsandbox/accounts.csv - creds = load_credentials_from_csv(File.join(unsandbox_dir, 'accounts.csv'), account_index) + creds = load_credentials_from_csv(File.join(unsandbox_dir, 'accounts.csv'), default_index) return creds if creds - # Tier 4: ./accounts.csv - creds = load_credentials_from_csv('accounts.csv', account_index) + creds = load_credentials_from_csv('accounts.csv', default_index) return creds if creds raise CredentialsError, <<~MSG No credentials found. Please provide via: 1. Method arguments (public_key:, secret_key:) - 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) - 3. ~/.unsandbox/accounts.csv - 4. ./accounts.csv + 2. --account N flag (CSV row N) + 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) + 4. ~/.unsandbox/accounts.csv + 5. ./accounts.csv MSG end @@ -1913,6 +1930,7 @@ module Un file_paths: [], public_key: nil, secret_key: nil, + account_index: -1, network: 'zerotrust', vcpu: 1, yes: false, @@ -1920,6 +1938,19 @@ module Un output: nil } + # Pre-scan for --account N before subcommand dispatch so it works in any position. + # The flag will also be consumed by parse_global_options inside each sub-handler. + ARGV.each_with_index do |arg, i| + if arg == '--account' && ARGV[i + 1] =~ /\A-?\d+\z/ + options[:account_index] = ARGV[i + 1].to_i + break + elsif arg =~ /\A--account=(-?\d+)\z/ + options[:account_index] = Regexp.last_match(1).to_i + break + end + end + Un.cli_account_index = options[:account_index] + # Check for subcommands first if ARGV.empty? cli_show_help @@ -1990,6 +2021,7 @@ module Un -o, --output DIR Output directory for artifacts -p, --public-key KEY API public key -k, --secret-key KEY API secret key + --account N Use accounts.csv row N (overrides env vars) -n, --network MODE Network mode: zerotrust or semitrusted -v, --vcpu N vCPU count (1-8) -y, --yes Skip confirmation prompts @@ -2031,6 +2063,9 @@ module Un opts.on('-k', '--secret-key KEY', 'API secret key') do |v| options[:secret_key] = v end + opts.on('--account N', Integer, 'Use credentials from accounts.csv row N') do |v| + options[:account_index] = v + end opts.on('-n', '--network MODE', 'Network mode') do |v| options[:network] = v end diff --git a/clients/ruby/sync/tests/test_account_flag.sh b/clients/ruby/sync/tests/test_account_flag.sh new file mode 100755 index 0000000..262d756 --- /dev/null +++ b/clients/ruby/sync/tests/test_account_flag.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Integration test: --account N flag must take priority over env vars +# +# Requires: UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY (real credentials) +# Run: bash tests/test_account_flag.sh +# +# The defect this guards against: resolve_credentials() checked env vars before +# account_index, so --account N was silently ignored when env vars existed. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +UN_RB="$SCRIPT_DIR/../src/un.rb" + +RED='\033[31m' +GREEN='\033[32m' +NC='\033[0m' + +pass=0 +fail=0 + +check() { + local desc="$1" result="$2" + if [ "$result" = "pass" ]; then + printf " ${GREEN}✓${NC} %s\n" "$desc" + pass=$((pass + 1)) + else + printf " ${RED}✗${NC} %s\n" "$desc" + fail=$((fail + 1)) + fi +} + +# Require real credentials to be available +if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then + echo "SKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set" + exit 0 +fi + +if [ ! -f "$UN_RB" ]; then + echo "FAIL: un.rb not found at $UN_RB" + exit 1 +fi + +REAL_PK="$UNSANDBOX_PUBLIC_KEY" +REAL_SK="$UNSANDBOX_SECRET_KEY" + +# Temporary HOME with accounts.csv: +# index 0: garbage credentials (will always 401) +# index 1: real credentials (will succeed) +TMPHOME="$(mktemp -d)" +mkdir -p "$TMPHOME/.unsandbox" +trap 'rm -rf "$TMPHOME"' EXIT + +cat > "$TMPHOME/.unsandbox/accounts.csv" <&1 || true) + +if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials found"; then + check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "pass" +else + check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "fail" + echo " output: $OUT" +fi + +# --- Test 2: --account 0 uses CSV row 0 (garbage creds) → 401, even with real env vars --- +OUT=$(HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="$REAL_PK" \ + UNSANDBOX_SECRET_KEY="$REAL_SK" \ + ruby "$UN_RB" --account 0 key 2>&1 || true) + +if echo "$OUT" | grep -qi "401\|unauthorized\|invalid\|error\|Valid: false"; then + check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "pass" +else + check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "fail" + echo " output: $OUT" +fi + +# --- Test 3: no --account flag, real env vars → env vars win over garbage CSV row 0 --- +OUT=$(HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="$REAL_PK" \ + UNSANDBOX_SECRET_KEY="$REAL_SK" \ + ruby "$UN_RB" key 2>&1 || true) + +if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials found"; then + check "No --account flag: env vars used, succeeds" "pass" +else + check "No --account flag: env vars used, succeeds" "fail" + echo " output: $OUT" +fi + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +printf "Passed: ${GREEN}%d${NC} Failed: ${RED}%d${NC}\n" "$pass" "$fail" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +[ "$fail" -eq 0 ] diff --git a/clients/rust/sync/src/lib.rs b/clients/rust/sync/src/lib.rs index a5117be..565d248 100644 --- a/clients/rust/sync/src/lib.rs +++ b/clients/rust/sync/src/lib.rs @@ -6,7 +6,7 @@ // use un::{Credentials, execute_code, resolve_credentials}; // // // Resolve credentials (4-tier priority) -// let creds = resolve_credentials(None, None)?; +// let creds = resolve_credentials(None, None, None)?; // // // Execute code synchronously // let result = execute_code("python", r#"print("hello")"#, &creds)?; @@ -702,13 +702,15 @@ fn load_credentials_from_csv(path: &PathBuf, account_index: usize) -> Option Option, secret_key: Option<&str>, + account_index: Option, ) -> Result { // Tier 1: Function arguments if let (Some(pk), Some(sk)) = (public_key, secret_key) { @@ -735,7 +742,21 @@ pub fn resolve_credentials( } } - // Tier 2: Environment variables + // Tier 2: Explicit account index → load from CSV before consulting env vars + if let Some(idx) = account_index { + if let Some(dir) = get_unsandbox_dir() { + let csv_path = dir.join("accounts.csv"); + if let Some(creds) = load_credentials_from_csv(&csv_path, idx) { + return Ok(creds); + } + } + let local_csv = PathBuf::from("accounts.csv"); + if let Some(creds) = load_credentials_from_csv(&local_csv, idx) { + return Ok(creds); + } + } + + // Tier 3: Environment variables let env_pk = env::var("UNSANDBOX_PUBLIC_KEY").ok(); let env_sk = env::var("UNSANDBOX_SECRET_KEY").ok(); if let (Some(pk), Some(sk)) = (env_pk, env_sk) { @@ -744,23 +765,23 @@ pub fn resolve_credentials( } } - // Determine account index - let account_index: usize = env::var("UNSANDBOX_ACCOUNT") + // Determine default account index from env + let default_index: usize = env::var("UNSANDBOX_ACCOUNT") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(0); - // Tier 3: ~/.unsandbox/accounts.csv + // Tier 4: ~/.unsandbox/accounts.csv if let Some(dir) = get_unsandbox_dir() { let csv_path = dir.join("accounts.csv"); - if let Some(creds) = load_credentials_from_csv(&csv_path, account_index) { + if let Some(creds) = load_credentials_from_csv(&csv_path, default_index) { return Ok(creds); } } - // Tier 4: ./accounts.csv + // Tier 5: ./accounts.csv let local_csv = PathBuf::from("accounts.csv"); - if let Some(creds) = load_credentials_from_csv(&local_csv, account_index) { + if let Some(creds) = load_credentials_from_csv(&local_csv, default_index) { return Ok(creds); } @@ -1065,7 +1086,7 @@ fn save_languages_cache(languages: &[String]) { /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// let result = execute_code("python", r#"print("Hello, World!")"#, &creds)?; /// println!("Output: {}", result.output); /// println!("Exit code: {}", result.exit_code); @@ -1422,7 +1443,7 @@ pub fn clone_snapshot(snapshot_id: &str, name: &str, creds: &Credentials) -> Res /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// let image = image_publish("service", "svc-abc123", "my-app-image", Some("Production app v1.0"), &creds)?; /// println!("Published image: {}", image.image_id); /// ``` @@ -1461,7 +1482,7 @@ pub fn image_publish( /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// /// // List owned images (default) /// let my_images = list_images(None, &creds)?; @@ -1550,7 +1571,7 @@ pub fn unlock_image(image_id: &str, creds: &Credentials) -> Result { /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// /// // Make image public /// set_image_visibility("img-abc123", "public", &creds)?; @@ -1665,7 +1686,7 @@ pub fn transfer_image(image_id: &str, to_api_key: &str, creds: &Credentials) -> /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// /// // Spawn with default options /// let result = spawn_from_image("img-abc123", "my-service", None, None, None, &creds)?; @@ -1745,7 +1766,7 @@ pub fn clone_image( /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// let sessions = list_sessions(&creds)?; /// for session in sessions { /// println!("{}: {} ({})", session.session_id, session.container_name, session.status); @@ -1781,7 +1802,7 @@ pub fn get_session(session_id: &str, creds: &Credentials) -> Result { /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// /// // Create a basic bash session /// let session = create_session("bash", &creds, None)?; @@ -1907,7 +1928,7 @@ pub fn unboost_session(session_id: &str, creds: &Credentials) -> Result /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// let result = shell_session("session-123", "ls -la", &creds)?; /// println!("Output: {}", result.output); /// println!("Exit code: {}", result.exit_code); @@ -1934,7 +1955,7 @@ pub fn shell_session(session_id: &str, command: &str, creds: &Credentials) -> Re /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// let services = list_services(&creds)?; /// for service in services { /// println!("{}: {} ({}) - {}", service.service_id, service.name, service.status, service.url); @@ -1959,7 +1980,7 @@ pub fn list_services(creds: &Credentials) -> Result> { /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// /// // Create a simple web service /// let service = create_service( @@ -2317,7 +2338,7 @@ pub fn redeploy_service( /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// let service = resize_service("service-123", 4, &creds)?; /// println!("Service now has {} vCPUs", service.vcpu); /// ``` @@ -2341,7 +2362,7 @@ pub fn resize_service(service_id: &str, vcpu: u32, creds: &Credentials) -> Resul /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// let result = execute_in_service("service-123", "ls -la /app", &creds)?; /// println!("Output: {}", result.output); /// ``` @@ -2372,7 +2393,7 @@ pub fn execute_in_service( /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// let result = validate_keys(&creds)?; /// if result.valid { /// println!("Keys valid for account: {}", result.account_id); @@ -2432,7 +2453,7 @@ pub fn validate_keys(creds: &Credentials) -> Result { /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// let result = image("A sunset over mountains", &creds, None)?; /// for img in result.images { /// println!("Image: {}", img); @@ -2502,7 +2523,7 @@ pub struct LogsEntry { /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// /// // Fetch last 100 lines from all sources /// let opts = LogsFetchOptions { @@ -2568,7 +2589,7 @@ pub type LogCallback = fn(source: &str, line: &str); /// /// # Examples /// ```ignore -/// let creds = resolve_credentials(None, None)?; +/// let creds = resolve_credentials(None, None, None)?; /// /// fn handle_log(source: &str, line: &str) { /// println!("[{}] {}", source, line); @@ -2766,6 +2787,7 @@ struct CliOptions { output_dir: Option, // -o, --output public_key: Option, // -p, --public-key secret_key: Option, // -k, --secret-key + account_index: Option, // --account N network: Option, // -n, --network vcpu: Option, // -v, --vcpu yes: bool, // -y, --yes @@ -2867,6 +2889,7 @@ GLOBAL OPTIONS: -o, --output DIR Output directory for artifacts -p, --public-key KEY API public key -k, --secret-key KEY API secret key + --account N Select account row N from accounts.csv (overrides env vars) -n, --network MODE Network mode: zerotrust or semitrusted -v, --vcpu N vCPU count (1-8) -y, --yes Skip confirmation prompts @@ -3014,6 +3037,14 @@ fn parse_args(args: &[String]) -> CliOptions { i += 1; } } + "--account" => { + if i + 1 < args.len() { + opts.account_index = args[i + 1].parse().ok(); + i += 2; + } else { + i += 1; + } + } "-n" | "--network" => { if i + 1 < args.len() { opts.network = Some(args[i + 1].clone()); @@ -3358,6 +3389,7 @@ fn get_credentials(opts: &CliOptions) -> Result { resolve_credentials( opts.public_key.as_deref(), opts.secret_key.as_deref(), + opts.account_index, ) } diff --git a/clients/rust/sync/tests/test_account_flag.sh b/clients/rust/sync/tests/test_account_flag.sh new file mode 100755 index 0000000..8316428 --- /dev/null +++ b/clients/rust/sync/tests/test_account_flag.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Test --account N credential selection in the Rust sync SDK CLI. +# +# Tests: +# 1. --account 1 selects row 1 from accounts.csv (garbage env vars present) +# 2. --account 0 selects row 0 (garbage creds), returns 401 +# 3. No --account flag falls through to env vars (real creds), succeeds + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +SDK_DIR="$SCRIPT_DIR/.." +BINARY="$SDK_DIR/target/debug/un" + +# --------------------------------------------------------------------------- +# Skip if real credentials are not available +# --------------------------------------------------------------------------- +if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then + echo "SKIP: UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY not set" + exit 0 +fi + +REAL_PK="$UNSANDBOX_PUBLIC_KEY" +REAL_SK="$UNSANDBOX_SECRET_KEY" + +# --------------------------------------------------------------------------- +# Build the binary if it doesn't already exist +# --------------------------------------------------------------------------- +if [ ! -f "$BINARY" ]; then + echo "Building Rust SDK..." + cd "$SDK_DIR" + PATH="$HOME/.cargo/bin:$PATH" cargo build 2>&1 +fi + +if [ ! -f "$BINARY" ]; then + echo "FAIL: binary not found at $BINARY after build" + exit 1 +fi + +# --------------------------------------------------------------------------- +# Set up a temporary HOME with accounts.csv +# row 0: garbage credentials +# row 1: real credentials +# --------------------------------------------------------------------------- +TMPHOME="$(mktemp -d)" +trap 'rm -rf "$TMPHOME"' EXIT + +mkdir -p "$TMPHOME/.unsandbox" +printf 'unsb-pk-garbage-0000,unsb-sk-garbage-0000000000000000\n%s,%s\n' \ + "$REAL_PK" "$REAL_SK" \ + > "$TMPHOME/.unsandbox/accounts.csv" + +PASS=0 +FAIL=0 + +run_key() { + # Returns the exit code and stdout/stderr of `un key` + HOME="$TMPHOME" "$BINARY" "$@" key 2>&1 +} + +# --------------------------------------------------------------------------- +# Test 1: --account 1 picks real creds from row 1, even when env has garbage +# --------------------------------------------------------------------------- +echo "Test 1: --account 1 selects row 1 (real creds) despite garbage env vars" +OUTPUT=$(HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="unsb-pk-garbage-env" \ + UNSANDBOX_SECRET_KEY="unsb-sk-garbage-env-0000000000000" \ + "$BINARY" --account 1 key 2>&1 || true) + +if echo "$OUTPUT" | grep -qi "API keys valid"; then + echo " PASS" + PASS=$((PASS + 1)) +else + echo " FAIL: expected 'API keys valid', got:" + echo "$OUTPUT" | sed 's/^/ /' + FAIL=$((FAIL + 1)) +fi + +# --------------------------------------------------------------------------- +# Test 2: --account 0 picks garbage creds from row 0, should get 401 +# --------------------------------------------------------------------------- +echo "Test 2: --account 0 selects row 0 (garbage creds), expect auth failure" +OUTPUT=$(HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="$REAL_PK" \ + UNSANDBOX_SECRET_KEY="$REAL_SK" \ + "$BINARY" --account 0 key 2>&1 || true) + +if echo "$OUTPUT" | grep -qiE "401|unauthorized|invalid|invalid key|keys invalid"; then + echo " PASS" + PASS=$((PASS + 1)) +else + echo " FAIL: expected auth failure (401/unauthorized/invalid), got:" + echo "$OUTPUT" | sed 's/^/ /' + FAIL=$((FAIL + 1)) +fi + +# --------------------------------------------------------------------------- +# Test 3: No --account flag, env vars contain real creds → succeed +# --------------------------------------------------------------------------- +echo "Test 3: no --account flag, env vars hold real creds" +OUTPUT=$(HOME="$TMPHOME" \ + UNSANDBOX_PUBLIC_KEY="$REAL_PK" \ + UNSANDBOX_SECRET_KEY="$REAL_SK" \ + "$BINARY" key 2>&1 || true) + +if echo "$OUTPUT" | grep -qi "API keys valid"; then + echo " PASS" + PASS=$((PASS + 1)) +else + echo " FAIL: expected 'API keys valid', got:" + echo "$OUTPUT" | sed 's/^/ /' + FAIL=$((FAIL + 1)) +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "Results: $PASS passed, $FAIL failed" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0