feat: Complete 6 additional SDK implementations with fixes and examples

Go Async SDK (clients/go/async/):
- Fixed case-sensitive language detection bug (.R for R language)
- Created go.mod for module management
- Added comprehensive test suite
- Created 3 examples with expected output comments
- Added README with full documentation

Java Sync SDK (clients/java/sync/):
- RENAMED: Unsandbox.java -> Un.java (matches naming convention)
- Updated class name from Unsandbox to Un
- Created pom.xml for Maven build
- Added 6 examples (simple + SDK client versions)
- Created test suite with JUnit 5
- Added README with API documentation

JavaScript Async SDK (clients/javascript/async/):
- Fixed unused import
- Created package.json with ES module support
- Added 5 examples covering all async patterns
- Created 71 tests (all passing)
- Added comprehensive README

PHP Sync SDK (clients/php/sync/):
- RENAMED: Unsandbox.php -> un.php (matches naming convention)
- Created composer.json with PSR-4 autoloading
- Created phpunit.xml for testing
- Added 4 examples with expected output comments
- Created 54 tests across 4 test files
- Added README with full documentation

Ruby Sync SDK (clients/ruby/sync/):
- Created Gemfile and un.gemspec
- Created Rakefile with test task
- Updated examples to actually use the SDK
- Added 4 examples (hello_world, async_job, language_detection, snapshots)
- Created comprehensive test suite with 30+ tests
- Added README with documentation

Rust Sync SDK (clients/rust/sync/):
- Updated Cargo.toml with example declarations
- Created 4 examples (hello_world, fibonacci, multi_language, async_polling)
- Added comprehensive README with API reference
- All dependencies verified correct

All SDKs verified:
- HMAC-SHA256 authentication implemented
- 4-tier credential system (args > env > ~/.unsandbox > ./accounts.csv)
- Expected output comments for pipeline validation
- Proper error handling
- Language detection support
This commit is contained in:
russell@unturf.com 2026-01-15 17:32:24 -05:00
parent 1e4fe2ef96
commit 331cba42aa
66 changed files with 18743 additions and 4 deletions

View file

@ -0,0 +1,715 @@
<?php
/**
* PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
*
* unsandbox.com PHP SDK (Asynchronous)
*
* Library Usage:
* require_once 'UnsandboxAsync.php';
* use Unsandbox\UnsandboxAsync;
*
* $client = new UnsandboxAsync();
*
* // Execute code (returns a promise)
* $promise = $client->executeCode('python', 'print("hello")');
* $result = $promise->wait();
*
* // Execute asynchronously
* $promise = $client->executeAsync('javascript', 'console.log("hello")');
* $jobId = $promise->wait();
*
* // Wait for job completion with exponential backoff
* $promise = $client->waitForJob($jobId);
* $result = $promise->wait();
*
* // Run multiple requests concurrently
* $promises = [
* $client->executeCode('python', 'print(1)'),
* $client->executeCode('python', 'print(2)'),
* $client->executeCode('python', 'print(3)'),
* ];
* $results = \GuzzleHttp\Promise\Utils::all($promises)->wait();
*
* Authentication Priority (4-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)
*
* Request Authentication (HMAC-SHA256):
* Authorization: Bearer <public_key>
* X-Timestamp: <unix_seconds>
* X-Signature: HMAC-SHA256(secret_key, "timestamp:METHOD:path:body")
*
* Languages Cache:
* - Cached in ~/.unsandbox/languages.json
* - TTL: 1 hour
* - Updated on successful API calls
*
* Requirements:
* - guzzlehttp/guzzle: ^7.0
* - guzzlehttp/promises: ^2.0
*/
namespace Unsandbox;
use GuzzleHttp\Client;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Promise\Utils;
use GuzzleHttp\Promise\Create;
use GuzzleHttp\Exception\RequestException;
/**
* Exception thrown when credentials cannot be found or are invalid.
*/
class AsyncCredentialsException extends \Exception {}
/**
* Exception thrown when an API request fails.
*/
class AsyncApiException extends \Exception {
private ?array $response;
public function __construct(string $message, int $code = 0, ?array $response = null, ?\Throwable $previous = null) {
parent::__construct($message, $code, $previous);
$this->response = $response;
}
public function getResponse(): ?array {
return $this->response;
}
}
/**
* Unsandbox PHP SDK - Asynchronous Client
*
* Provides asynchronous methods to execute code, manage jobs, and handle snapshots
* using the unsandbox.com API. All methods return Guzzle promises for non-blocking I/O.
*/
class UnsandboxAsync {
private const API_BASE = 'https://api.unsandbox.com';
private const LANGUAGES_CACHE_TTL = 3600; // 1 hour
private const POLL_DELAYS_MS = [300, 450, 700, 900, 650, 1600, 2000];
/**
* Language detection mapping (file extension -> language)
*/
private const LANGUAGE_MAP = [
'py' => 'python',
'js' => 'javascript',
'ts' => 'typescript',
'rb' => 'ruby',
'php' => 'php',
'pl' => 'perl',
'sh' => 'bash',
'r' => 'r',
'R' => 'r',
'lua' => 'lua',
'go' => 'go',
'rs' => 'rust',
'c' => 'c',
'cpp' => 'cpp',
'cc' => 'cpp',
'cxx' => 'cpp',
'java' => 'java',
'kt' => 'kotlin',
'm' => 'objc',
'cs' => 'csharp',
'fs' => 'fsharp',
'hs' => 'haskell',
'ml' => 'ocaml',
'clj' => 'clojure',
'scm' => 'scheme',
'ss' => 'scheme',
'erl' => 'erlang',
'ex' => 'elixir',
'exs' => 'elixir',
'jl' => 'julia',
'd' => 'd',
'nim' => 'nim',
'zig' => 'zig',
'v' => 'v',
'cr' => 'crystal',
'dart' => 'dart',
'groovy' => 'groovy',
'f90' => 'fortran',
'f95' => 'fortran',
'lisp' => 'commonlisp',
'lsp' => 'commonlisp',
'cob' => 'cobol',
'tcl' => 'tcl',
'raku' => 'raku',
'pro' => 'prolog',
'p' => 'prolog',
'4th' => 'forth',
'forth' => 'forth',
'fth' => 'forth',
];
private Client $httpClient;
private ?string $defaultPublicKey = null;
private ?string $defaultSecretKey = null;
private int $accountIndex = 0;
/**
* Create a new UnsandboxAsync client.
*
* @param string|null $publicKey Default public key (optional)
* @param string|null $secretKey Default secret key (optional)
* @param int $accountIndex Account index for CSV files (default: 0)
* @param Client|null $httpClient Custom Guzzle client (optional)
*/
public function __construct(
?string $publicKey = null,
?string $secretKey = null,
int $accountIndex = 0,
?Client $httpClient = null
) {
$this->defaultPublicKey = $publicKey;
$this->defaultSecretKey = $secretKey;
$this->accountIndex = $accountIndex;
$this->httpClient = $httpClient ?? new Client([
'base_uri' => self::API_BASE,
'timeout' => 120,
]);
}
/**
* Execute code asynchronously and wait for completion.
*
* Returns a promise that resolves to the execution result.
*
* @param string $language Programming language (e.g., "python", "javascript", "go")
* @param string $code Source code to execute
* @param string|null $publicKey Optional API key (uses credentials resolution if not provided)
* @param string|null $secretKey Optional API secret (uses credentials resolution if not provided)
* @return PromiseInterface Resolves to response array containing stdout, stderr, exit code, etc.
*/
public function executeCode(string $language, string $code, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest(
'POST',
'/execute',
$publicKey,
$secretKey,
['language' => $language, 'code' => $code]
)->then(function (array $response) use ($publicKey, $secretKey) {
// If we got a job_id, poll until completion
$jobId = $response['job_id'] ?? null;
$status = $response['status'] ?? null;
if ($jobId && in_array($status, ['pending', 'running'], true)) {
return $this->waitForJob($jobId, $publicKey, $secretKey);
}
return $response;
});
}
/**
* Execute code asynchronously and return job ID immediately.
*
* Returns a promise that resolves to the job ID.
*
* @param string $language Programming language (e.g., "python", "javascript")
* @param string $code Source code to execute
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return PromiseInterface Resolves to job ID string
*/
public function executeAsync(string $language, string $code, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest(
'POST',
'/execute',
$publicKey,
$secretKey,
['language' => $language, 'code' => $code]
)->then(function (array $response) {
return $response['job_id'] ?? '';
});
}
/**
* Get current status/result of a job (single poll, no waiting).
*
* @param string $jobId Job ID from executeAsync()
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return PromiseInterface Resolves to job response array
*/
public function getJob(string $jobId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('GET', "/jobs/{$jobId}", $publicKey, $secretKey);
}
/**
* Wait for job completion with exponential backoff polling.
*
* Polling delays (ms): [300, 450, 700, 900, 650, 1600, 2000, ...]
* Cumulative: 300, 750, 1450, 2350, 3000, 4600, 6600ms+
*
* @param string $jobId Job ID from executeAsync()
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @param int $timeout Maximum wait time in seconds (default: 3600)
* @return PromiseInterface Resolves to final job result when status is terminal
*/
public function waitForJob(string $jobId, ?string $publicKey = null, ?string $secretKey = null, int $timeout = 3600): PromiseInterface {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->pollJob($jobId, $publicKey, $secretKey, 0, time(), $timeout);
}
/**
* Internal polling helper for waitForJob.
*
* @param string $jobId Job ID
* @param string $publicKey API public key
* @param string $secretKey API secret key
* @param int $pollCount Current poll iteration
* @param int $startTime Timestamp when polling started
* @param int $timeout Maximum wait time
* @return PromiseInterface Resolves to final job result
*/
private function pollJob(string $jobId, string $publicKey, string $secretKey, int $pollCount, int $startTime, int $timeout): PromiseInterface {
// Check timeout
if ((time() - $startTime) >= $timeout) {
return Create::rejectionFor(new AsyncApiException("Timeout waiting for job {$jobId}"));
}
// Calculate delay
$delayIdx = min($pollCount, count(self::POLL_DELAYS_MS) - 1);
$delayMs = self::POLL_DELAYS_MS[$delayIdx];
// Sleep synchronously (PHP doesn't have native async sleep)
usleep($delayMs * 1000);
return $this->getJob($jobId, $publicKey, $secretKey)->then(
function (array $response) use ($jobId, $publicKey, $secretKey, $pollCount, $startTime, $timeout) {
$status = $response['status'] ?? null;
if (in_array($status, ['completed', 'failed', 'timeout', 'cancelled'], true)) {
return $response;
}
// Still running, continue polling
return $this->pollJob($jobId, $publicKey, $secretKey, $pollCount + 1, $startTime, $timeout);
}
);
}
/**
* Cancel a running job.
*
* @param string $jobId Job ID to cancel
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return PromiseInterface Resolves to response array with cancellation confirmation
*/
public function cancelJob(string $jobId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('DELETE', "/jobs/{$jobId}", $publicKey, $secretKey);
}
/**
* List all jobs for the authenticated account.
*
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return PromiseInterface Resolves to list of job arrays
*/
public function listJobs(?string $publicKey = null, ?string $secretKey = null): PromiseInterface {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('GET', '/jobs', $publicKey, $secretKey)->then(function (array $response) {
return $response['jobs'] ?? [];
});
}
/**
* Get list of supported programming languages.
*
* Results are cached for 1 hour in ~/.unsandbox/languages.json
*
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return PromiseInterface Resolves to list of language identifiers
*/
public function getLanguages(?string $publicKey = null, ?string $secretKey = null): PromiseInterface {
// Try cache first (synchronously, cache is local)
$cached = $this->loadLanguagesCache();
if ($cached !== null) {
return Create::promiseFor($cached);
}
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('GET', '/languages', $publicKey, $secretKey)->then(function (array $response) {
$languages = $response['languages'] ?? [];
// Cache the result
$this->saveLanguagesCache($languages);
return $languages;
});
}
/**
* Detect programming language from filename extension.
*
* @param string $filename Filename to detect language from (e.g., "script.py")
* @return string|null Language identifier (e.g., "python") or null if unknown
*/
public static function detectLanguage(string $filename): ?string {
if (empty($filename) || strpos($filename, '.') === false) {
return null;
}
$parts = explode('.', $filename);
$ext = end($parts);
return self::LANGUAGE_MAP[$ext] ?? null;
}
/**
* Create a snapshot of a session.
*
* @param string $sessionId Session ID to snapshot
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @param string|null $name Optional snapshot name
* @param bool $ephemeral If true, snapshot is ephemeral (hot snapshot)
* @return PromiseInterface Resolves to snapshot ID
*/
public function sessionSnapshot(
string $sessionId,
?string $publicKey = null,
?string $secretKey = null,
?string $name = null,
bool $ephemeral = false
): PromiseInterface {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$data = ['session_id' => $sessionId, 'hot' => $ephemeral];
if ($name !== null) {
$data['name'] = $name;
}
return $this->makeRequest('POST', '/snapshots', $publicKey, $secretKey, $data)->then(function (array $response) {
return $response['snapshot_id'] ?? '';
});
}
/**
* Create a snapshot of a service.
*
* @param string $serviceId Service ID to snapshot
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @param string|null $name Optional snapshot name
* @return PromiseInterface Resolves to snapshot ID
*/
public function serviceSnapshot(
string $serviceId,
?string $publicKey = null,
?string $secretKey = null,
?string $name = null
): PromiseInterface {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$data = ['service_id' => $serviceId];
if ($name !== null) {
$data['name'] = $name;
}
return $this->makeRequest('POST', '/snapshots', $publicKey, $secretKey, $data)->then(function (array $response) {
return $response['snapshot_id'] ?? '';
});
}
/**
* List all snapshots.
*
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return PromiseInterface Resolves to list of snapshot arrays
*/
public function listSnapshots(?string $publicKey = null, ?string $secretKey = null): PromiseInterface {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('GET', '/snapshots', $publicKey, $secretKey)->then(function (array $response) {
return $response['snapshots'] ?? [];
});
}
/**
* Restore a snapshot.
*
* @param string $snapshotId Snapshot ID to restore
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return PromiseInterface Resolves to response array with restored resource info
*/
public function restoreSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('POST', "/snapshots/{$snapshotId}/restore", $publicKey, $secretKey, []);
}
/**
* Delete a snapshot.
*
* @param string $snapshotId Snapshot ID to delete
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return PromiseInterface Resolves to response array with deletion confirmation
*/
public function deleteSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('DELETE', "/snapshots/{$snapshotId}", $publicKey, $secretKey);
}
/**
* Get path to ~/.unsandbox directory, creating if necessary.
*
* @return string Path to unsandbox directory
*/
private function getUnsandboxDir(): string {
$home = getenv('HOME') ?: (getenv('USERPROFILE') ?: '');
if (empty($home)) {
$info = posix_getpwuid(posix_getuid());
$home = $info['dir'] ?? '/tmp';
}
$dir = $home . '/.unsandbox';
if (!is_dir($dir)) {
mkdir($dir, 0700, true);
}
return $dir;
}
/**
* Load credentials from a CSV file.
*
* @param string $csvPath Path to CSV file
* @param int $accountIndex Account index (0-based)
* @return array|null [publicKey, secretKey] or null if not found
*/
private function loadCredentialsFromCsv(string $csvPath, int $accountIndex = 0): ?array {
if (!file_exists($csvPath)) {
return null;
}
$handle = fopen($csvPath, 'r');
if ($handle === false) {
return null;
}
$currentIndex = 0;
while (($line = fgets($handle)) !== false) {
$line = trim($line);
if (empty($line) || $line[0] === '#') {
continue;
}
if ($currentIndex === $accountIndex) {
$parts = explode(',', $line);
if (count($parts) >= 2) {
$publicKey = trim($parts[0]);
$secretKey = trim($parts[1]);
fclose($handle);
return [$publicKey, $secretKey];
}
}
$currentIndex++;
}
fclose($handle);
return null;
}
/**
* Resolve credentials from 4-tier priority system.
*
* Priority:
* 1. Method arguments
* 2. Environment variables
* 3. ~/.unsandbox/accounts.csv
* 4. ./accounts.csv
*
* @param string|null $publicKey Public key from method argument
* @param string|null $secretKey Secret key from method argument
* @return array [publicKey, secretKey]
* @throws AsyncCredentialsException If no credentials found
*/
private function resolveCredentials(?string $publicKey, ?string $secretKey): array {
// Tier 1: Method arguments
if (!empty($publicKey) && !empty($secretKey)) {
return [$publicKey, $secretKey];
}
// Use default keys if provided to constructor
if (!empty($this->defaultPublicKey) && !empty($this->defaultSecretKey)) {
return [$this->defaultPublicKey, $this->defaultSecretKey];
}
// Tier 2: 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
$unsandboxDir = $this->getUnsandboxDir();
$creds = $this->loadCredentialsFromCsv($unsandboxDir . '/accounts.csv', $accountIndex);
if ($creds !== null) {
return $creds;
}
// Tier 4: ./accounts.csv
$creds = $this->loadCredentialsFromCsv('./accounts.csv', $accountIndex);
if ($creds !== null) {
return $creds;
}
throw new AsyncCredentialsException(
"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"
);
}
/**
* Sign a request using HMAC-SHA256.
*
* Message format: "timestamp:METHOD:path:body"
*
* @param string $secretKey Secret key for signing
* @param int $timestamp Unix timestamp
* @param string $method HTTP method
* @param string $path API endpoint path
* @param string|null $body Request body (optional)
* @return string 64-character hex signature
*/
private function signRequest(string $secretKey, int $timestamp, string $method, string $path, ?string $body = null): string {
$bodyStr = $body ?? '';
$message = "{$timestamp}:{$method}:{$path}:{$bodyStr}";
return hash_hmac('sha256', $message, $secretKey);
}
/**
* Make an authenticated HTTP request to the API asynchronously.
*
* @param string $method HTTP method (GET, POST, DELETE)
* @param string $path API endpoint path
* @param string $publicKey API public key
* @param string $secretKey API secret key
* @param array|null $data Request data (optional)
* @return PromiseInterface Resolves to decoded JSON response array
*/
private function makeRequest(string $method, string $path, string $publicKey, string $secretKey, ?array $data = null): PromiseInterface {
$timestamp = time();
$body = $data !== null ? json_encode($data) : '';
$signature = $this->signRequest($secretKey, $timestamp, $method, $path, $data !== null ? $body : null);
$headers = [
'Authorization' => 'Bearer ' . $publicKey,
'X-Timestamp' => (string)$timestamp,
'X-Signature' => $signature,
'Content-Type' => 'application/json',
];
$options = [
'headers' => $headers,
];
if ($data !== null) {
$options['body'] = $body;
}
return $this->httpClient->requestAsync($method, $path, $options)->then(
function ($response) {
$body = (string)$response->getBody();
$decoded = json_decode($body, true);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
throw new AsyncApiException("Invalid JSON response: " . json_last_error_msg());
}
return $decoded;
},
function ($exception) {
if ($exception instanceof RequestException) {
$response = $exception->getResponse();
if ($response !== null) {
$body = (string)$response->getBody();
$decoded = json_decode($body, true);
$errorMessage = $decoded['error'] ?? $decoded['message'] ?? "HTTP " . $response->getStatusCode();
throw new AsyncApiException($errorMessage, $response->getStatusCode(), $decoded, $exception);
}
}
throw new AsyncApiException($exception->getMessage(), 0, null, $exception);
}
);
}
/**
* Get path to languages cache file.
*
* @return string Path to cache file
*/
private function getLanguagesCachePath(): string {
return $this->getUnsandboxDir() . '/languages.json';
}
/**
* Load languages from cache if valid (< 1 hour old).
*
* @return array|null List of languages or null if cache invalid
*/
private function loadLanguagesCache(): ?array {
$cachePath = $this->getLanguagesCachePath();
if (!file_exists($cachePath)) {
return null;
}
$mtime = filemtime($cachePath);
$ageSeconds = time() - $mtime;
if ($ageSeconds >= self::LANGUAGES_CACHE_TTL) {
return null;
}
$content = file_get_contents($cachePath);
if ($content === false) {
return null;
}
$data = json_decode($content, true);
if ($data === null) {
return null;
}
return $data['languages'] ?? null;
}
/**
* Save languages to cache.
*
* @param array $languages List of languages
*/
private function saveLanguagesCache(array $languages): void {
$cachePath = $this->getLanguagesCachePath();
$data = [
'languages' => $languages,
'timestamp' => time(),
];
file_put_contents($cachePath, json_encode($data));
}
}

247
clients/php/sync/README.md Normal file
View file

@ -0,0 +1,247 @@
# Unsandbox PHP SDK (Synchronous)
A synchronous PHP client library for [unsandbox.com](https://unsandbox.com) - secure, multi-language code execution.
## Installation
Using Composer:
```bash
composer require unsandbox/un
```
Or include directly:
```php
require_once 'path/to/src/un.php';
use Unsandbox\Unsandbox;
```
## Quick Start
```php
<?php
require_once 'vendor/autoload.php';
use Unsandbox\Unsandbox;
$client = new Unsandbox();
// Execute Python code
$result = $client->executeCode('python', 'print("Hello from unsandbox!")');
print_r($result);
```
## Authentication
The SDK supports 4-tier credential resolution:
1. **Method arguments** - Pass directly to methods
2. **Constructor arguments** - Set default credentials
3. **Environment variables** - `UNSANDBOX_PUBLIC_KEY` and `UNSANDBOX_SECRET_KEY`
4. **Config files** - `~/.unsandbox/accounts.csv` or `./accounts.csv`
### Setting up credentials
Create `~/.unsandbox/accounts.csv`:
```csv
your_public_key,your_secret_key
another_public_key,another_secret_key
```
Or use environment variables:
```bash
export UNSANDBOX_PUBLIC_KEY="pk_xxxxx"
export UNSANDBOX_SECRET_KEY="sk_xxxxx"
```
Or pass to constructor:
```php
$client = new Unsandbox('pk_xxxxx', 'sk_xxxxx');
```
## API Reference
### Synchronous Execution
Execute code and wait for completion:
```php
$result = $client->executeCode(
'python', // language
'print("hello")', // code
null, // publicKey (optional)
null // secretKey (optional)
);
// Result:
// [
// 'status' => 'completed',
// 'stdout' => "hello\n",
// 'stderr' => '',
// 'exit_code' => 0,
// 'runtime_ms' => 342
// ]
```
### Asynchronous Execution
Start execution and get a job ID:
```php
// Start execution
$jobId = $client->executeAsync('python', 'print("hello")');
// Check status later
$result = $client->waitForJob($jobId);
```
### Job Management
```php
// Get single job status
$job = $client->getJob('job_123');
// List all active jobs
$jobs = $client->listJobs();
// Cancel a job
$client->cancelJob('job_123');
```
### Languages
```php
// Get list of supported languages (cached for 1 hour)
$languages = $client->getLanguages();
// Returns: ['python', 'javascript', 'go', 'rust', ...]
// Detect language from filename
$lang = Unsandbox::detectLanguage('script.py'); // Returns 'python'
```
### Snapshots
```php
// Create a session snapshot
$snapshotId = $client->sessionSnapshot('session_123', null, null, 'checkpoint');
// Create a service snapshot
$snapshotId = $client->serviceSnapshot('service_123', null, null, 'backup');
// List snapshots
$snapshots = $client->listSnapshots();
// Restore a snapshot
$result = $client->restoreSnapshot($snapshotId);
// Delete a snapshot
$client->deleteSnapshot($snapshotId);
```
## Language Support
The SDK supports 50+ programming languages including:
- **Interpreted**: Python, JavaScript, Ruby, PHP, Perl, Bash, Lua, etc.
- **Compiled**: C, C++, Go, Rust, Java, Kotlin, etc.
- **Functional**: Haskell, OCaml, F#, Scheme, Clojure, etc.
- **Other**: WASM, Prolog, Forth, etc.
See `getLanguages()` for the complete list.
## Caching
The languages list is cached locally for 1 hour in `~/.unsandbox/languages.json`. This reduces API calls and improves performance.
To force a refresh, delete the cache file:
```bash
rm ~/.unsandbox/languages.json
```
## Error Handling
```php
use Unsandbox\Unsandbox;
use Unsandbox\CredentialsException;
use Unsandbox\ApiException;
try {
$client = new Unsandbox();
$result = $client->executeCode('python', 'print("hello")');
} catch (CredentialsException $e) {
echo "No credentials found: " . $e->getMessage();
} catch (ApiException $e) {
echo "API error: " . $e->getMessage();
echo "HTTP code: " . $e->getCode();
$response = $e->getResponse(); // Full response array
}
```
## Examples
See the `examples/` directory for complete working examples:
- `hello_world.php` - Simple print example
- `fibonacci.php` - Recursive function example
- `hello_world_client.php` - Execute Python via SDK
- `fibonacci_client.php` - Execute JavaScript via SDK
Run an example:
```bash
php examples/hello_world_client.php
```
## Testing
Install dev dependencies and run tests:
```bash
composer install
composer test
```
Or run PHPUnit directly:
```bash
./vendor/bin/phpunit tests/
```
## Requirements
- PHP 7.4+
- ext-curl
- ext-json
## Request Authentication
All API requests are authenticated using HMAC-SHA256:
```
Authorization: Bearer <public_key>
X-Timestamp: <unix_seconds>
X-Signature: HMAC-SHA256(secret_key, "timestamp:METHOD:path:body")
```
The signature is computed over the message format: `timestamp:METHOD:path:body`
## Public Domain License
This code is released into the PUBLIC DOMAIN with NO WARRANTY and NO LICENSE.
You are free to:
- Use for any purpose
- Modify and distribute
- Use commercially
- Use privately
## Support
For issues or questions:
- GitHub Issues: https://github.com/unsandbox/un-inception/issues
- Website: https://unsandbox.com

View file

@ -0,0 +1,43 @@
{
"name": "unsandbox/un",
"description": "PHP SDK for unsandbox.com code execution API",
"type": "library",
"license": "Unlicense",
"keywords": ["unsandbox", "code-execution", "sandbox", "api-client"],
"homepage": "https://unsandbox.com",
"authors": [
{
"name": "Unsandbox",
"email": "support@unsandbox.com"
}
],
"require": {
"php": ">=7.4",
"ext-curl": "*",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^9.0 || ^10.0"
},
"autoload": {
"psr-4": {
"Unsandbox\\": "src/"
},
"files": ["src/un.php"]
},
"autoload-dev": {
"psr-4": {
"Unsandbox\\Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit --testdox tests/"
},
"config": {
"sort-packages": true
},
"support": {
"issues": "https://github.com/unsandbox/un-inception/issues",
"source": "https://github.com/unsandbox/un-inception"
}
}

View file

@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
/**
* Fibonacci example for unsandbox PHP SDK
* Expected output:
* fib(10) = 55
* fib(20) = 6765
*/
function fib(int $n): int {
if ($n <= 1) {
return $n;
}
return fib($n - 1) + fib($n - 2);
}
echo "fib(10) = " . fib(10) . "\n";
echo "fib(20) = " . fib(20) . "\n";

View file

@ -0,0 +1,52 @@
#!/usr/bin/env php
<?php
/**
* Example: Execute JavaScript Fibonacci code using the unsandbox PHP SDK
*
* Prerequisites:
* - Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables
* - Or create ~/.unsandbox/accounts.csv with credentials
*
* Expected output (approximate):
* Executing JavaScript Fibonacci...
* Result:
* array(5) {
* ["status"]=> string(9) "completed"
* ["stdout"]=> string(...) "fib(10) = 55\nfib(20) = 6765\n"
* ["stderr"]=> string(0) ""
* ["exit_code"]=> int(0)
* ["runtime_ms"]=> int(...)
* }
*/
require_once __DIR__ . '/../src/un.php';
use Unsandbox\Unsandbox;
use Unsandbox\CredentialsException;
use Unsandbox\ApiException;
$jsCode = <<<'JS'
function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
console.log("fib(10) = " + fib(10));
console.log("fib(20) = " + fib(20));
JS;
echo "Executing JavaScript Fibonacci...\n";
try {
$client = new Unsandbox();
$result = $client->executeCode('javascript', $jsCode);
echo "Result:\n";
var_dump($result);
} catch (CredentialsException $e) {
echo "Credentials error: " . $e->getMessage() . "\n";
exit(1);
} catch (ApiException $e) {
echo "API error: " . $e->getMessage() . " (code: " . $e->getCode() . ")\n";
exit(1);
}

View file

@ -0,0 +1,8 @@
#!/usr/bin/env php
<?php
/**
* Hello World example for unsandbox PHP SDK
* Expected output: Hello from unsandbox!
*/
echo "Hello from unsandbox!\n";

View file

@ -0,0 +1,42 @@
#!/usr/bin/env php
<?php
/**
* Example: Execute Python code using the unsandbox PHP SDK
*
* Prerequisites:
* - Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables
* - Or create ~/.unsandbox/accounts.csv with credentials
*
* Expected output (approximate):
* Executing Python code...
* Result:
* array(5) {
* ["status"]=> string(9) "completed"
* ["stdout"]=> string(20) "Hello from Python!\n"
* ["stderr"]=> string(0) ""
* ["exit_code"]=> int(0)
* ["runtime_ms"]=> int(...)
* }
*/
require_once __DIR__ . '/../src/un.php';
use Unsandbox\Unsandbox;
use Unsandbox\CredentialsException;
use Unsandbox\ApiException;
echo "Executing Python code...\n";
try {
$client = new Unsandbox();
$result = $client->executeCode('python', 'print("Hello from Python!")');
echo "Result:\n";
var_dump($result);
} catch (CredentialsException $e) {
echo "Credentials error: " . $e->getMessage() . "\n";
exit(1);
} catch (ApiException $e) {
echo "API error: " . $e->getMessage() . " (code: " . $e->getCode() . ")\n";
exit(1);
}

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
testdox="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Unsandbox PHP SDK Tests">
<directory suffix="Test.php">tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>

702
clients/php/sync/src/un.php Normal file
View file

@ -0,0 +1,702 @@
<?php
/**
* PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
*
* unsandbox.com PHP SDK (Synchronous)
*
* Library Usage:
* require_once 'un.php';
* use Unsandbox\Unsandbox;
*
* $client = new Unsandbox();
*
* // Execute code synchronously
* $result = $client->executeCode('python', 'print("hello")');
*
* // Execute asynchronously
* $jobId = $client->executeAsync('javascript', 'console.log("hello")');
*
* // Wait for job completion with exponential backoff
* $result = $client->waitForJob($jobId);
*
* // List all jobs
* $jobs = $client->listJobs();
*
* // Get supported languages (cached for 1 hour)
* $languages = $client->getLanguages();
*
* // Snapshot operations
* $snapshotId = $client->sessionSnapshot($sessionId);
*
* Authentication Priority (4-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)
*
* Request Authentication (HMAC-SHA256):
* Authorization: Bearer <public_key>
* X-Timestamp: <unix_seconds>
* X-Signature: HMAC-SHA256(secret_key, "timestamp:METHOD:path:body")
*
* Languages Cache:
* - Cached in ~/.unsandbox/languages.json
* - TTL: 1 hour
* - Updated on successful API calls
*/
namespace Unsandbox;
/**
* Exception thrown when credentials cannot be found or are invalid.
*/
class CredentialsException extends \Exception {}
/**
* Exception thrown when an API request fails.
*/
class ApiException extends \Exception {
private ?array $response;
public function __construct(string $message, int $code = 0, ?array $response = null, ?\Throwable $previous = null) {
parent::__construct($message, $code, $previous);
$this->response = $response;
}
public function getResponse(): ?array {
return $this->response;
}
}
/**
* Unsandbox PHP SDK - Synchronous Client
*
* Provides methods to execute code, manage jobs, and handle snapshots
* using the unsandbox.com API.
*/
class Unsandbox {
private const API_BASE = 'https://api.unsandbox.com';
private const LANGUAGES_CACHE_TTL = 3600; // 1 hour
private const POLL_DELAYS_MS = [300, 450, 700, 900, 650, 1600, 2000];
/**
* Language detection mapping (file extension -> language)
*/
private const LANGUAGE_MAP = [
'py' => 'python',
'js' => 'javascript',
'ts' => 'typescript',
'rb' => 'ruby',
'php' => 'php',
'pl' => 'perl',
'sh' => 'bash',
'r' => 'r',
'R' => 'r',
'lua' => 'lua',
'go' => 'go',
'rs' => 'rust',
'c' => 'c',
'cpp' => 'cpp',
'cc' => 'cpp',
'cxx' => 'cpp',
'java' => 'java',
'kt' => 'kotlin',
'm' => 'objc',
'cs' => 'csharp',
'fs' => 'fsharp',
'hs' => 'haskell',
'ml' => 'ocaml',
'clj' => 'clojure',
'scm' => 'scheme',
'ss' => 'scheme',
'erl' => 'erlang',
'ex' => 'elixir',
'exs' => 'elixir',
'jl' => 'julia',
'd' => 'd',
'nim' => 'nim',
'zig' => 'zig',
'v' => 'v',
'cr' => 'crystal',
'dart' => 'dart',
'groovy' => 'groovy',
'f90' => 'fortran',
'f95' => 'fortran',
'lisp' => 'commonlisp',
'lsp' => 'commonlisp',
'cob' => 'cobol',
'tcl' => 'tcl',
'raku' => 'raku',
'pro' => 'prolog',
'p' => 'prolog',
'4th' => 'forth',
'forth' => 'forth',
'fth' => 'forth',
];
private ?string $defaultPublicKey = null;
private ?string $defaultSecretKey = null;
private int $accountIndex = 0;
/**
* Create a new Unsandbox client.
*
* @param string|null $publicKey Default public key (optional)
* @param string|null $secretKey Default secret key (optional)
* @param int $accountIndex Account index for CSV files (default: 0)
*/
public function __construct(?string $publicKey = null, ?string $secretKey = null, int $accountIndex = 0) {
$this->defaultPublicKey = $publicKey;
$this->defaultSecretKey = $secretKey;
$this->accountIndex = $accountIndex;
}
/**
* Execute code synchronously (awaits until completion).
*
* @param string $language Programming language (e.g., "python", "javascript", "go")
* @param string $code Source code to execute
* @param string|null $publicKey Optional API key (uses credentials resolution if not provided)
* @param string|null $secretKey Optional API secret (uses credentials resolution if not provided)
* @return array Response array containing stdout, stderr, exit code, etc.
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function executeCode(string $language, string $code, ?string $publicKey = null, ?string $secretKey = null): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$response = $this->makeRequest(
'POST',
'/execute',
$publicKey,
$secretKey,
['language' => $language, 'code' => $code]
);
// If we got a job_id, poll until completion
$jobId = $response['job_id'] ?? null;
$status = $response['status'] ?? null;
if ($jobId && in_array($status, ['pending', 'running'], true)) {
return $this->waitForJob($jobId, $publicKey, $secretKey);
}
return $response;
}
/**
* Execute code asynchronously (returns immediately with job_id).
*
* @param string $language Programming language (e.g., "python", "javascript")
* @param string $code Source code to execute
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return string Job ID string
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function executeAsync(string $language, string $code, ?string $publicKey = null, ?string $secretKey = null): string {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$response = $this->makeRequest(
'POST',
'/execute',
$publicKey,
$secretKey,
['language' => $language, 'code' => $code]
);
return $response['job_id'] ?? '';
}
/**
* Get current status/result of a job (single poll, no waiting).
*
* @param string $jobId Job ID from executeAsync()
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Job response array
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function getJob(string $jobId, ?string $publicKey = null, ?string $secretKey = null): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('GET', "/jobs/{$jobId}", $publicKey, $secretKey);
}
/**
* Wait for job completion with exponential backoff polling.
*
* Polling delays (ms): [300, 450, 700, 900, 650, 1600, 2000, ...]
* Cumulative: 300, 750, 1450, 2350, 3000, 4600, 6600ms+
*
* @param string $jobId Job ID from executeAsync()
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @param int $timeout Maximum wait time in seconds (default: 3600)
* @return array Final job result when status is terminal (completed, failed, timeout, cancelled)
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed or timeout exceeded
*/
public function waitForJob(string $jobId, ?string $publicKey = null, ?string $secretKey = null, int $timeout = 3600): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$pollCount = 0;
$startTime = time();
while (true) {
// Check timeout
if ((time() - $startTime) >= $timeout) {
throw new ApiException("Timeout waiting for job {$jobId}");
}
// Sleep before polling
$delayIdx = min($pollCount, count(self::POLL_DELAYS_MS) - 1);
usleep(self::POLL_DELAYS_MS[$delayIdx] * 1000);
$pollCount++;
$response = $this->getJob($jobId, $publicKey, $secretKey);
$status = $response['status'] ?? null;
if (in_array($status, ['completed', 'failed', 'timeout', 'cancelled'], true)) {
return $response;
}
// Still running, continue polling
}
}
/**
* Cancel a running job.
*
* @param string $jobId Job ID to cancel
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Response array with cancellation confirmation
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function cancelJob(string $jobId, ?string $publicKey = null, ?string $secretKey = null): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('DELETE', "/jobs/{$jobId}", $publicKey, $secretKey);
}
/**
* List all jobs for the authenticated account.
*
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array List of job arrays
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function listJobs(?string $publicKey = null, ?string $secretKey = null): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$response = $this->makeRequest('GET', '/jobs', $publicKey, $secretKey);
return $response['jobs'] ?? [];
}
/**
* Get list of supported programming languages.
*
* Results are cached for 1 hour in ~/.unsandbox/languages.json
*
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array List of language identifiers (e.g., ["python", "javascript", "go", ...])
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function getLanguages(?string $publicKey = null, ?string $secretKey = null): array {
// Try cache first
$cached = $this->loadLanguagesCache();
if ($cached !== null) {
return $cached;
}
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$response = $this->makeRequest('GET', '/languages', $publicKey, $secretKey);
$languages = $response['languages'] ?? [];
// Cache the result
$this->saveLanguagesCache($languages);
return $languages;
}
/**
* Detect programming language from filename extension.
*
* @param string $filename Filename to detect language from (e.g., "script.py")
* @return string|null Language identifier (e.g., "python") or null if unknown
*/
public static function detectLanguage(string $filename): ?string {
if (empty($filename) || strpos($filename, '.') === false) {
return null;
}
$parts = explode('.', $filename);
$ext = end($parts);
return self::LANGUAGE_MAP[$ext] ?? null;
}
/**
* Create a snapshot of a session.
*
* @param string $sessionId Session ID to snapshot
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @param string|null $name Optional snapshot name
* @param bool $ephemeral If true, snapshot is ephemeral (hot snapshot)
* @return string Snapshot ID
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function sessionSnapshot(
string $sessionId,
?string $publicKey = null,
?string $secretKey = null,
?string $name = null,
bool $ephemeral = false
): string {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$data = ['session_id' => $sessionId, 'hot' => $ephemeral];
if ($name !== null) {
$data['name'] = $name;
}
$response = $this->makeRequest('POST', '/snapshots', $publicKey, $secretKey, $data);
return $response['snapshot_id'] ?? '';
}
/**
* Create a snapshot of a service.
*
* @param string $serviceId Service ID to snapshot
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @param string|null $name Optional snapshot name
* @return string Snapshot ID
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function serviceSnapshot(
string $serviceId,
?string $publicKey = null,
?string $secretKey = null,
?string $name = null
): string {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$data = ['service_id' => $serviceId];
if ($name !== null) {
$data['name'] = $name;
}
$response = $this->makeRequest('POST', '/snapshots', $publicKey, $secretKey, $data);
return $response['snapshot_id'] ?? '';
}
/**
* List all snapshots.
*
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array List of snapshot arrays
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function listSnapshots(?string $publicKey = null, ?string $secretKey = null): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$response = $this->makeRequest('GET', '/snapshots', $publicKey, $secretKey);
return $response['snapshots'] ?? [];
}
/**
* Restore a snapshot.
*
* @param string $snapshotId Snapshot ID to restore
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Response array with restored resource info
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function restoreSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('POST', "/snapshots/{$snapshotId}/restore", $publicKey, $secretKey, []);
}
/**
* Delete a snapshot.
*
* @param string $snapshotId Snapshot ID to delete
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Response array with deletion confirmation
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function deleteSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('DELETE', "/snapshots/{$snapshotId}", $publicKey, $secretKey);
}
/**
* Get path to ~/.unsandbox directory, creating if necessary.
*
* @return string Path to unsandbox directory
*/
private function getUnsandboxDir(): string {
$home = getenv('HOME') ?: (getenv('USERPROFILE') ?: '');
if (empty($home)) {
$home = posix_getpwuid(posix_getuid())['dir'] ?? '/tmp';
}
$dir = $home . '/.unsandbox';
if (!is_dir($dir)) {
mkdir($dir, 0700, true);
}
return $dir;
}
/**
* Load credentials from a CSV file.
*
* @param string $csvPath Path to CSV file
* @param int $accountIndex Account index (0-based)
* @return array|null [publicKey, secretKey] or null if not found
*/
private function loadCredentialsFromCsv(string $csvPath, int $accountIndex = 0): ?array {
if (!file_exists($csvPath)) {
return null;
}
$handle = fopen($csvPath, 'r');
if ($handle === false) {
return null;
}
$currentIndex = 0;
while (($line = fgets($handle)) !== false) {
$line = trim($line);
if (empty($line) || $line[0] === '#') {
continue;
}
if ($currentIndex === $accountIndex) {
$parts = explode(',', $line);
if (count($parts) >= 2) {
$publicKey = trim($parts[0]);
$secretKey = trim($parts[1]);
fclose($handle);
return [$publicKey, $secretKey];
}
}
$currentIndex++;
}
fclose($handle);
return null;
}
/**
* Resolve credentials from 4-tier priority system.
*
* Priority:
* 1. Method arguments
* 2. Environment variables
* 3. ~/.unsandbox/accounts.csv
* 4. ./accounts.csv
*
* @param string|null $publicKey Public key from method argument
* @param string|null $secretKey Secret key from method argument
* @return array [publicKey, secretKey]
* @throws CredentialsException If no credentials found
*/
private function resolveCredentials(?string $publicKey, ?string $secretKey): array {
// Tier 1: Method arguments
if (!empty($publicKey) && !empty($secretKey)) {
return [$publicKey, $secretKey];
}
// Use default keys if provided to constructor
if (!empty($this->defaultPublicKey) && !empty($this->defaultSecretKey)) {
return [$this->defaultPublicKey, $this->defaultSecretKey];
}
// Tier 2: 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
$unsandboxDir = $this->getUnsandboxDir();
$creds = $this->loadCredentialsFromCsv($unsandboxDir . '/accounts.csv', $accountIndex);
if ($creds !== null) {
return $creds;
}
// Tier 4: ./accounts.csv
$creds = $this->loadCredentialsFromCsv('./accounts.csv', $accountIndex);
if ($creds !== null) {
return $creds;
}
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"
);
}
/**
* Sign a request using HMAC-SHA256.
*
* Message format: "timestamp:METHOD:path:body"
*
* @param string $secretKey Secret key for signing
* @param int $timestamp Unix timestamp
* @param string $method HTTP method
* @param string $path API endpoint path
* @param string|null $body Request body (optional)
* @return string 64-character hex signature
*/
private function signRequest(string $secretKey, int $timestamp, string $method, string $path, ?string $body = null): string {
$bodyStr = $body ?? '';
$message = "{$timestamp}:{$method}:{$path}:{$bodyStr}";
return hash_hmac('sha256', $message, $secretKey);
}
/**
* Make an authenticated HTTP request to the API.
*
* @param string $method HTTP method (GET, POST, DELETE)
* @param string $path API endpoint path
* @param string $publicKey API public key
* @param string $secretKey API secret key
* @param array|null $data Request data (optional)
* @return array Decoded JSON response
* @throws ApiException On network errors or non-2xx response
*/
private function makeRequest(string $method, string $path, string $publicKey, string $secretKey, ?array $data = null): array {
$url = self::API_BASE . $path;
$timestamp = time();
$body = $data !== null ? json_encode($data) : '';
$signature = $this->signRequest($secretKey, $timestamp, $method, $path, $data !== null ? $body : null);
$headers = [
'Authorization: Bearer ' . $publicKey,
'X-Timestamp: ' . $timestamp,
'X-Signature: ' . $signature,
'Content-Type: application/json',
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
switch ($method) {
case 'POST':
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
break;
case 'DELETE':
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
break;
case 'GET':
default:
// GET is the default
break;
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($response === false) {
throw new ApiException("cURL error: {$error}");
}
$decoded = json_decode($response, true);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
throw new ApiException("Invalid JSON response: " . json_last_error_msg());
}
if ($httpCode >= 400) {
$errorMessage = $decoded['error'] ?? $decoded['message'] ?? "HTTP {$httpCode}";
throw new ApiException($errorMessage, $httpCode, $decoded);
}
return $decoded;
}
/**
* Get path to languages cache file.
*
* @return string Path to cache file
*/
private function getLanguagesCachePath(): string {
return $this->getUnsandboxDir() . '/languages.json';
}
/**
* Load languages from cache if valid (< 1 hour old).
*
* @return array|null List of languages or null if cache invalid
*/
private function loadLanguagesCache(): ?array {
$cachePath = $this->getLanguagesCachePath();
if (!file_exists($cachePath)) {
return null;
}
$mtime = filemtime($cachePath);
$ageSeconds = time() - $mtime;
if ($ageSeconds >= self::LANGUAGES_CACHE_TTL) {
return null;
}
$content = file_get_contents($cachePath);
if ($content === false) {
return null;
}
$data = json_decode($content, true);
if ($data === null) {
return null;
}
return $data['languages'] ?? null;
}
/**
* Save languages to cache.
*
* @param array $languages List of languages
*/
private function saveLanguagesCache(array $languages): void {
$cachePath = $this->getLanguagesCachePath();
$data = [
'languages' => $languages,
'timestamp' => time(),
];
file_put_contents($cachePath, json_encode($data));
}
}

View file

@ -0,0 +1,178 @@
<?php
/**
* Tests for language caching functionality
*/
declare(strict_types=1);
namespace Unsandbox\Tests;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
require_once __DIR__ . '/../src/un.php';
use Unsandbox\Unsandbox;
class CachingTest extends TestCase
{
private string $tempDir;
private string $originalHome;
protected function setUp(): void
{
// Create a temp directory for test cache
$this->tempDir = sys_get_temp_dir() . '/unsandbox_cache_test_' . uniqid();
mkdir($this->tempDir);
// Save and set HOME to temp directory
$this->originalHome = getenv('HOME') ?: '';
putenv("HOME={$this->tempDir}");
}
protected function tearDown(): void
{
// Restore HOME
putenv("HOME={$this->originalHome}");
// Clean up temp directory
$this->removeDir($this->tempDir);
}
private function removeDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
is_dir($path) ? $this->removeDir($path) : unlink($path);
}
rmdir($dir);
}
private function invokePrivateMethod(object $object, string $methodName, array $parameters = [])
{
$reflection = new ReflectionClass(get_class($object));
$method = $reflection->getMethod($methodName);
$method->setAccessible(true);
return $method->invokeArgs($object, $parameters);
}
public function testCacheDirectoryCreated(): void
{
$client = new Unsandbox();
// Trigger directory creation
$this->invokePrivateMethod($client, 'getUnsandboxDir');
$this->assertDirectoryExists($this->tempDir . '/.unsandbox');
}
public function testCachePathCorrect(): void
{
$client = new Unsandbox();
$path = $this->invokePrivateMethod($client, 'getLanguagesCachePath');
$this->assertEquals($this->tempDir . '/.unsandbox/languages.json', $path);
}
public function testLoadLanguagesCacheReturnsNullWhenMissing(): void
{
$client = new Unsandbox();
$result = $this->invokePrivateMethod($client, 'loadLanguagesCache');
$this->assertNull($result);
}
public function testSaveAndLoadLanguagesCache(): void
{
$client = new Unsandbox();
$languages = ['python', 'javascript', 'go'];
$this->invokePrivateMethod($client, 'saveLanguagesCache', [$languages]);
$loaded = $this->invokePrivateMethod($client, 'loadLanguagesCache');
$this->assertEquals($languages, $loaded);
}
public function testCacheExpiry(): void
{
$client = new Unsandbox();
$languages = ['python', 'javascript'];
$this->invokePrivateMethod($client, 'saveLanguagesCache', [$languages]);
// Modify file time to be older than TTL (1 hour)
$cachePath = $this->invokePrivateMethod($client, 'getLanguagesCachePath');
touch($cachePath, time() - 3700); // 1 hour + 100 seconds ago
$loaded = $this->invokePrivateMethod($client, 'loadLanguagesCache');
$this->assertNull($loaded);
}
public function testCacheFreshWithinTtl(): void
{
$client = new Unsandbox();
$languages = ['python', 'javascript', 'ruby'];
$this->invokePrivateMethod($client, 'saveLanguagesCache', [$languages]);
// Modify file time to be just within TTL
$cachePath = $this->invokePrivateMethod($client, 'getLanguagesCachePath');
touch($cachePath, time() - 3500); // 1 hour - 100 seconds ago
$loaded = $this->invokePrivateMethod($client, 'loadLanguagesCache');
$this->assertEquals($languages, $loaded);
}
public function testCacheJsonFormat(): void
{
$client = new Unsandbox();
$languages = ['python', 'javascript'];
$this->invokePrivateMethod($client, 'saveLanguagesCache', [$languages]);
$cachePath = $this->invokePrivateMethod($client, 'getLanguagesCachePath');
$content = file_get_contents($cachePath);
$data = json_decode($content, true);
$this->assertIsArray($data);
$this->assertArrayHasKey('languages', $data);
$this->assertArrayHasKey('timestamp', $data);
$this->assertEquals($languages, $data['languages']);
$this->assertIsInt($data['timestamp']);
}
public function testLoadInvalidJsonReturnsNull(): void
{
$client = new Unsandbox();
// Create directory and invalid cache file
$this->invokePrivateMethod($client, 'getUnsandboxDir');
$cachePath = $this->invokePrivateMethod($client, 'getLanguagesCachePath');
file_put_contents($cachePath, 'not valid json');
$loaded = $this->invokePrivateMethod($client, 'loadLanguagesCache');
$this->assertNull($loaded);
}
public function testLoadMissingLanguagesKeyReturnsNull(): void
{
$client = new Unsandbox();
// Create directory and cache file without 'languages' key
$this->invokePrivateMethod($client, 'getUnsandboxDir');
$cachePath = $this->invokePrivateMethod($client, 'getLanguagesCachePath');
file_put_contents($cachePath, json_encode(['timestamp' => time()]));
$loaded = $this->invokePrivateMethod($client, 'loadLanguagesCache');
$this->assertNull($loaded);
}
}

View file

@ -0,0 +1,186 @@
<?php
/**
* Tests for 4-tier credential resolution
*/
declare(strict_types=1);
namespace Unsandbox\Tests;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
require_once __DIR__ . '/../src/un.php';
use Unsandbox\Unsandbox;
use Unsandbox\CredentialsException;
class CredentialsTest extends TestCase
{
private string $originalHome;
private ?string $originalPublicKey;
private ?string $originalSecretKey;
private ?string $originalAccount;
protected function setUp(): void
{
// Save original environment
$this->originalHome = getenv('HOME') ?: '';
$this->originalPublicKey = getenv('UNSANDBOX_PUBLIC_KEY') ?: null;
$this->originalSecretKey = getenv('UNSANDBOX_SECRET_KEY') ?: null;
$this->originalAccount = getenv('UNSANDBOX_ACCOUNT') ?: null;
// Clear environment variables
putenv('UNSANDBOX_PUBLIC_KEY');
putenv('UNSANDBOX_SECRET_KEY');
putenv('UNSANDBOX_ACCOUNT');
}
protected function tearDown(): void
{
// Restore original environment
if ($this->originalHome) {
putenv("HOME={$this->originalHome}");
}
if ($this->originalPublicKey !== null) {
putenv("UNSANDBOX_PUBLIC_KEY={$this->originalPublicKey}");
} else {
putenv('UNSANDBOX_PUBLIC_KEY');
}
if ($this->originalSecretKey !== null) {
putenv("UNSANDBOX_SECRET_KEY={$this->originalSecretKey}");
} else {
putenv('UNSANDBOX_SECRET_KEY');
}
if ($this->originalAccount !== null) {
putenv("UNSANDBOX_ACCOUNT={$this->originalAccount}");
} else {
putenv('UNSANDBOX_ACCOUNT');
}
}
private function invokePrivateMethod(object $object, string $methodName, array $parameters = [])
{
$reflection = new ReflectionClass(get_class($object));
$method = $reflection->getMethod($methodName);
$method->setAccessible(true);
return $method->invokeArgs($object, $parameters);
}
public function testTier1MethodArguments(): void
{
$client = new Unsandbox();
$creds = $this->invokePrivateMethod($client, 'resolveCredentials', [
'pk_method',
'sk_method'
]);
$this->assertEquals(['pk_method', 'sk_method'], $creds);
}
public function testTier1ConstructorArguments(): void
{
$client = new Unsandbox('pk_constructor', 'sk_constructor');
$creds = $this->invokePrivateMethod($client, 'resolveCredentials', [null, null]);
$this->assertEquals(['pk_constructor', 'sk_constructor'], $creds);
}
public function testTier1MethodOverridesConstructor(): void
{
$client = new Unsandbox('pk_constructor', 'sk_constructor');
$creds = $this->invokePrivateMethod($client, 'resolveCredentials', [
'pk_method',
'sk_method'
]);
$this->assertEquals(['pk_method', 'sk_method'], $creds);
}
public function testTier2EnvironmentVariables(): void
{
putenv('UNSANDBOX_PUBLIC_KEY=pk_env');
putenv('UNSANDBOX_SECRET_KEY=sk_env');
$client = new Unsandbox();
$creds = $this->invokePrivateMethod($client, 'resolveCredentials', [null, null]);
$this->assertEquals(['pk_env', 'sk_env'], $creds);
}
public function testTier1OverridesTier2(): void
{
putenv('UNSANDBOX_PUBLIC_KEY=pk_env');
putenv('UNSANDBOX_SECRET_KEY=sk_env');
$client = new Unsandbox();
$creds = $this->invokePrivateMethod($client, 'resolveCredentials', [
'pk_method',
'sk_method'
]);
$this->assertEquals(['pk_method', 'sk_method'], $creds);
}
public function testNoCredentialsThrowsException(): void
{
// Set HOME to a temp directory without accounts.csv
$tempDir = sys_get_temp_dir() . '/unsandbox_test_' . uniqid();
mkdir($tempDir);
putenv("HOME={$tempDir}");
// Change to a directory without accounts.csv
$cwd = getcwd();
chdir($tempDir);
try {
$client = new Unsandbox();
$this->expectException(CredentialsException::class);
$this->invokePrivateMethod($client, 'resolveCredentials', [null, null]);
} finally {
chdir($cwd);
rmdir($tempDir . '/.unsandbox');
rmdir($tempDir);
}
}
public function testCredentialsExceptionMessage(): void
{
$tempDir = sys_get_temp_dir() . '/unsandbox_test_' . uniqid();
mkdir($tempDir);
putenv("HOME={$tempDir}");
$cwd = getcwd();
chdir($tempDir);
try {
$client = new Unsandbox();
$this->invokePrivateMethod($client, 'resolveCredentials', [null, null]);
$this->fail('Expected CredentialsException');
} catch (CredentialsException $e) {
$this->assertStringContainsString('No credentials found', $e->getMessage());
$this->assertStringContainsString('UNSANDBOX_PUBLIC_KEY', $e->getMessage());
$this->assertStringContainsString('accounts.csv', $e->getMessage());
} finally {
chdir($cwd);
rmdir($tempDir . '/.unsandbox');
rmdir($tempDir);
}
}
public function testPartialCredentialsMethodArguments(): void
{
// Only public key provided, should fall through to other tiers
putenv('UNSANDBOX_PUBLIC_KEY=pk_env');
putenv('UNSANDBOX_SECRET_KEY=sk_env');
$client = new Unsandbox();
$creds = $this->invokePrivateMethod($client, 'resolveCredentials', [
'pk_method',
null // Missing secret key
]);
// Should fall back to environment variables
$this->assertEquals(['pk_env', 'sk_env'], $creds);
}
}

View file

@ -0,0 +1,210 @@
<?php
/**
* Tests for language detection from filename extension
*/
declare(strict_types=1);
namespace Unsandbox\Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../src/un.php';
use Unsandbox\Unsandbox;
class LanguageDetectionTest extends TestCase
{
public function testDetectPython(): void
{
$this->assertEquals('python', Unsandbox::detectLanguage('script.py'));
$this->assertEquals('python', Unsandbox::detectLanguage('test.py'));
}
public function testDetectJavaScript(): void
{
$this->assertEquals('javascript', Unsandbox::detectLanguage('app.js'));
$this->assertEquals('javascript', Unsandbox::detectLanguage('index.js'));
}
public function testDetectTypeScript(): void
{
$this->assertEquals('typescript', Unsandbox::detectLanguage('main.ts'));
}
public function testDetectRuby(): void
{
$this->assertEquals('ruby', Unsandbox::detectLanguage('script.rb'));
}
public function testDetectPhp(): void
{
$this->assertEquals('php', Unsandbox::detectLanguage('index.php'));
}
public function testDetectGo(): void
{
$this->assertEquals('go', Unsandbox::detectLanguage('main.go'));
}
public function testDetectRust(): void
{
$this->assertEquals('rust', Unsandbox::detectLanguage('main.rs'));
}
public function testDetectC(): void
{
$this->assertEquals('c', Unsandbox::detectLanguage('program.c'));
}
public function testDetectCpp(): void
{
$this->assertEquals('cpp', Unsandbox::detectLanguage('program.cpp'));
$this->assertEquals('cpp', Unsandbox::detectLanguage('code.cc'));
$this->assertEquals('cpp', Unsandbox::detectLanguage('main.cxx'));
}
public function testDetectJava(): void
{
$this->assertEquals('java', Unsandbox::detectLanguage('Main.java'));
}
public function testDetectKotlin(): void
{
$this->assertEquals('kotlin', Unsandbox::detectLanguage('main.kt'));
}
public function testDetectCSharp(): void
{
$this->assertEquals('csharp', Unsandbox::detectLanguage('Program.cs'));
}
public function testDetectHaskell(): void
{
$this->assertEquals('haskell', Unsandbox::detectLanguage('Main.hs'));
}
public function testDetectElixir(): void
{
$this->assertEquals('elixir', Unsandbox::detectLanguage('script.ex'));
$this->assertEquals('elixir', Unsandbox::detectLanguage('script.exs'));
}
public function testDetectBash(): void
{
$this->assertEquals('bash', Unsandbox::detectLanguage('script.sh'));
}
public function testDetectLua(): void
{
$this->assertEquals('lua', Unsandbox::detectLanguage('script.lua'));
}
public function testDetectPerl(): void
{
$this->assertEquals('perl', Unsandbox::detectLanguage('script.pl'));
}
public function testDetectR(): void
{
$this->assertEquals('r', Unsandbox::detectLanguage('analysis.r'));
$this->assertEquals('r', Unsandbox::detectLanguage('analysis.R'));
}
public function testDetectScheme(): void
{
$this->assertEquals('scheme', Unsandbox::detectLanguage('code.scm'));
$this->assertEquals('scheme', Unsandbox::detectLanguage('code.ss'));
}
public function testDetectNullForEmptyFilename(): void
{
$this->assertNull(Unsandbox::detectLanguage(''));
}
public function testDetectNullForNoExtension(): void
{
$this->assertNull(Unsandbox::detectLanguage('Makefile'));
$this->assertNull(Unsandbox::detectLanguage('README'));
}
public function testDetectNullForUnknownExtension(): void
{
$this->assertNull(Unsandbox::detectLanguage('file.xyz'));
$this->assertNull(Unsandbox::detectLanguage('file.unknown'));
}
public function testDetectWithPath(): void
{
$this->assertEquals('python', Unsandbox::detectLanguage('/path/to/script.py'));
$this->assertEquals('javascript', Unsandbox::detectLanguage('./src/app.js'));
}
public function testDetectWithMultipleDots(): void
{
$this->assertEquals('python', Unsandbox::detectLanguage('my.script.test.py'));
$this->assertEquals('javascript', Unsandbox::detectLanguage('app.min.js'));
}
public function testAllSupportedLanguages(): void
{
$expected = [
'py' => 'python',
'js' => 'javascript',
'ts' => 'typescript',
'rb' => 'ruby',
'php' => 'php',
'pl' => 'perl',
'sh' => 'bash',
'r' => 'r',
'lua' => 'lua',
'go' => 'go',
'rs' => 'rust',
'c' => 'c',
'cpp' => 'cpp',
'cc' => 'cpp',
'cxx' => 'cpp',
'java' => 'java',
'kt' => 'kotlin',
'm' => 'objc',
'cs' => 'csharp',
'fs' => 'fsharp',
'hs' => 'haskell',
'ml' => 'ocaml',
'clj' => 'clojure',
'scm' => 'scheme',
'ss' => 'scheme',
'erl' => 'erlang',
'ex' => 'elixir',
'exs' => 'elixir',
'jl' => 'julia',
'd' => 'd',
'nim' => 'nim',
'zig' => 'zig',
'v' => 'v',
'cr' => 'crystal',
'dart' => 'dart',
'groovy' => 'groovy',
'f90' => 'fortran',
'f95' => 'fortran',
'lisp' => 'commonlisp',
'lsp' => 'commonlisp',
'cob' => 'cobol',
'tcl' => 'tcl',
'raku' => 'raku',
'pro' => 'prolog',
'p' => 'prolog',
'4th' => 'forth',
'forth' => 'forth',
'fth' => 'forth',
];
foreach ($expected as $ext => $language) {
$this->assertEquals(
$language,
Unsandbox::detectLanguage("test.{$ext}"),
"Extension .{$ext} should map to {$language}"
);
}
}
}

View file

@ -0,0 +1,251 @@
<?php
/**
* Tests for HMAC-SHA256 request signing
*/
declare(strict_types=1);
namespace Unsandbox\Tests;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
require_once __DIR__ . '/../src/un.php';
use Unsandbox\Unsandbox;
class SignaturesTest extends TestCase
{
private function invokePrivateMethod(object $object, string $methodName, array $parameters = [])
{
$reflection = new ReflectionClass(get_class($object));
$method = $reflection->getMethod($methodName);
$method->setAccessible(true);
return $method->invokeArgs($object, $parameters);
}
public function testSignRequestBasic(): void
{
$client = new Unsandbox();
$signature = $this->invokePrivateMethod($client, 'signRequest', [
'my_secret',
1234567890,
'POST',
'/execute',
'{"language":"python"}'
]);
// Signature should be 64 hex characters
$this->assertEquals(64, strlen($signature));
$this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $signature);
}
public function testSignRequestGet(): void
{
$client = new Unsandbox();
$signature = $this->invokePrivateMethod($client, 'signRequest', [
'my_secret',
1234567890,
'GET',
'/languages',
null
]);
$this->assertEquals(64, strlen($signature));
}
public function testSignRequestDelete(): void
{
$client = new Unsandbox();
$signature = $this->invokePrivateMethod($client, 'signRequest', [
'my_secret',
1234567890,
'DELETE',
'/jobs/job_123',
null
]);
$this->assertEquals(64, strlen($signature));
}
public function testSignRequestDeterministic(): void
{
$client = new Unsandbox();
$signature1 = $this->invokePrivateMethod($client, 'signRequest', [
'test_secret',
9999,
'POST',
'/test',
'{"test":"data"}'
]);
$signature2 = $this->invokePrivateMethod($client, 'signRequest', [
'test_secret',
9999,
'POST',
'/test',
'{"test":"data"}'
]);
$this->assertEquals($signature1, $signature2);
}
public function testSignRequestDifferentSecrets(): void
{
$client = new Unsandbox();
$signature1 = $this->invokePrivateMethod($client, 'signRequest', [
'secret1',
1234567890,
'POST',
'/execute',
'code'
]);
$signature2 = $this->invokePrivateMethod($client, 'signRequest', [
'secret2',
1234567890,
'POST',
'/execute',
'code'
]);
$this->assertNotEquals($signature1, $signature2);
}
public function testSignRequestDifferentTimestamps(): void
{
$client = new Unsandbox();
$signature1 = $this->invokePrivateMethod($client, 'signRequest', [
'secret',
1000,
'POST',
'/execute',
'code'
]);
$signature2 = $this->invokePrivateMethod($client, 'signRequest', [
'secret',
2000,
'POST',
'/execute',
'code'
]);
$this->assertNotEquals($signature1, $signature2);
}
public function testSignRequestDifferentPaths(): void
{
$client = new Unsandbox();
$signature1 = $this->invokePrivateMethod($client, 'signRequest', [
'secret',
1234567890,
'GET',
'/jobs',
null
]);
$signature2 = $this->invokePrivateMethod($client, 'signRequest', [
'secret',
1234567890,
'GET',
'/languages',
null
]);
$this->assertNotEquals($signature1, $signature2);
}
public function testSignRequestDifferentMethods(): void
{
$client = new Unsandbox();
$signature1 = $this->invokePrivateMethod($client, 'signRequest', [
'secret',
1234567890,
'GET',
'/jobs/123',
null
]);
$signature2 = $this->invokePrivateMethod($client, 'signRequest', [
'secret',
1234567890,
'DELETE',
'/jobs/123',
null
]);
$this->assertNotEquals($signature1, $signature2);
}
public function testSignRequestEmptyBody(): void
{
$client = new Unsandbox();
$sig1 = $this->invokePrivateMethod($client, 'signRequest', [
'secret',
1234567890,
'GET',
'/test',
''
]);
$sig2 = $this->invokePrivateMethod($client, 'signRequest', [
'secret',
1234567890,
'GET',
'/test',
null
]);
// Both should be the same (null and "" are both empty)
$this->assertEquals($sig1, $sig2);
}
public function testSignRequestSpecialCharacters(): void
{
$client = new Unsandbox();
$signature = $this->invokePrivateMethod($client, 'signRequest', [
'my_secret',
1234567890,
'POST',
'/execute',
'{"code":"print(\\"hello\\")"}'
]);
$this->assertEquals(64, strlen($signature));
$this->assertIsString($signature);
}
public function testSignRequestMatchesPythonSdk(): void
{
// Verify the exact algorithm matches other SDKs
// Message format: "timestamp:METHOD:path:body"
$client = new Unsandbox();
$secret = 'test_secret';
$timestamp = 1609459200;
$method = 'POST';
$path = '/execute';
$body = '{"language":"python"}';
$signature = $this->invokePrivateMethod($client, 'signRequest', [
$secret,
$timestamp,
$method,
$path,
$body
]);
// Manually compute expected signature
$message = "{$timestamp}:{$method}:{$path}:{$body}";
$expected = hash_hmac('sha256', $message, $secret);
$this->assertEquals($expected, $signature);
}
}