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:
parent
1e4fe2ef96
commit
331cba42aa
66 changed files with 18743 additions and 4 deletions
715
clients/php/async/src/UnsandboxAsync.php
Normal file
715
clients/php/async/src/UnsandboxAsync.php
Normal 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
247
clients/php/sync/README.md
Normal 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
|
||||
43
clients/php/sync/composer.json
Normal file
43
clients/php/sync/composer.json
Normal 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"
|
||||
}
|
||||
}
|
||||
18
clients/php/sync/examples/fibonacci.php
Normal file
18
clients/php/sync/examples/fibonacci.php
Normal 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";
|
||||
52
clients/php/sync/examples/fibonacci_client.php
Normal file
52
clients/php/sync/examples/fibonacci_client.php
Normal 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);
|
||||
}
|
||||
8
clients/php/sync/examples/hello_world.php
Normal file
8
clients/php/sync/examples/hello_world.php
Normal 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";
|
||||
42
clients/php/sync/examples/hello_world_client.php
Normal file
42
clients/php/sync/examples/hello_world_client.php
Normal 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);
|
||||
}
|
||||
18
clients/php/sync/phpunit.xml
Normal file
18
clients/php/sync/phpunit.xml
Normal 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
702
clients/php/sync/src/un.php
Normal 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));
|
||||
}
|
||||
}
|
||||
178
clients/php/sync/tests/CachingTest.php
Normal file
178
clients/php/sync/tests/CachingTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
186
clients/php/sync/tests/CredentialsTest.php
Normal file
186
clients/php/sync/tests/CredentialsTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
210
clients/php/sync/tests/LanguageDetectionTest.php
Normal file
210
clients/php/sync/tests/LanguageDetectionTest.php
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
251
clients/php/sync/tests/SignaturesTest.php
Normal file
251
clients/php/sync/tests/SignaturesTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue