diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 13b8729..604c8c3 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -21,12 +21,16 @@ use DataMachineCode\Workspace\WorkspaceReader; use DataMachineCode\Workspace\WorkspaceWriter; use DataMachineCode\Support\GitRunner; +use DataMachineCode\Support\RuntimeCapabilities; defined('ABSPATH') || exit; if ( ! class_exists(AbilityRegistry::class) ) { require_once __DIR__ . '/AbilityRegistry.php'; } +if ( ! class_exists(RuntimeCapabilities::class) ) { + require_once dirname(__DIR__) . '/Support/RuntimeCapabilities.php'; +} class WorkspaceAbilities { @@ -2327,7 +2331,7 @@ public static function getCapabilities( array $input ): array { // phpcs:ignor $workspace = new Workspace(); $diagnostic = GitRunner::diagnose(); $backend = RemoteWorkspaceBackend::should_handle() ? 'github_api' : 'local_git'; - $remediation = 'Run workspace abilities in a host runtime with local git access, or provide a Data Machine Code workspace backend that executes these operations outside the constrained PHP sandbox.'; + $remediation = RuntimeCapabilities::workspace_remediation(); return array_merge( $diagnostic, diff --git a/inc/Environment.php b/inc/Environment.php index 079e971..4e81e33 100644 --- a/inc/Environment.php +++ b/inc/Environment.php @@ -30,21 +30,20 @@ namespace DataMachineCode; +use DataMachineCode\Support\RuntimeCapabilities; + if ( ! defined('ABSPATH') ) { exit; } +if ( ! class_exists(RuntimeCapabilities::class) ) { + require_once __DIR__ . '/Support/RuntimeCapabilities.php'; +} + class Environment { - /** - * Memoized shell capability diagnostic. Null until the first probe. - * - * @var array{ok: bool, reason: string, output?: string, exit_code?: int|null}|null - */ - private static $shell_diagnostic_cache = null; - /** * Is Data Machine Code available on this install? * @@ -85,33 +84,10 @@ public static function has_shell(): bool { * * @since 0.24.0 * - * @return array{ok: bool, reason: string, output?: string, exit_code?: int|null} + * @return array{ok: bool, reason: string, exec_available: bool, shell_exec_available: bool, proc_open_available: bool, output?: string, exit_code?: int|null} */ public static function shell_diagnostic(): array { - if ( null !== self::$shell_diagnostic_cache ) { - return self::$shell_diagnostic_cache; - } - - self::$shell_diagnostic_cache = self::evaluate_shell_capability( - static function ( string $function_name ): bool { - return function_exists($function_name); - }, - (string) ini_get('disable_functions'), - static function ( string $command ): array { - $output = array(); - $exit_code = null; - - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Environment capability probe. - exec($command, $output, $exit_code); - - return array( - 'output' => $output, - 'exit_code' => $exit_code, - ); - } - ); - - return self::$shell_diagnostic_cache; + return RuntimeCapabilities::shell_diagnostic(); } /** @@ -122,54 +98,10 @@ static function ( string $command ): array { * @param callable $function_exists Callback receiving a function name and returning availability. * @param string $disabled_functions Raw `disable_functions` value. * @param callable $command_runner Callback receiving command and returning output plus exit code. - * @return array{ok: bool, reason: string, output?: string, exit_code?: int|null} + * @return array{ok: bool, reason: string, exec_available: bool, shell_exec_available: bool, proc_open_available: bool, output?: string, exit_code?: int|null} */ private static function evaluate_shell_capability( callable $function_exists, string $disabled_functions, callable $command_runner ): array { - $required_functions = array( 'exec', 'shell_exec' ); - $disabled = array_filter(array_map('trim', explode(',', $disabled_functions))); - - foreach ( $required_functions as $function_name ) { - if ( ! $function_exists($function_name) ) { - return array( - 'ok' => false, - 'reason' => $function_name . '_missing', - ); - } - - if ( in_array($function_name, $disabled, true) ) { - return array( - 'ok' => false, - 'reason' => $function_name . '_disabled', - ); - } - } - - $marker = '__datamachine_code_shell_ok__'; - - /** - * @var array{output: array, exit_code: int|null} $result -*/ - $result = $command_runner('printf ' . escapeshellarg($marker) . ' 2>&1'); - - $output = $result['output']; - $exit_code = $result['exit_code']; - - $actual_output = trim(implode("\n", array_map('strval', $output))); - if ( 0 !== $exit_code || $marker !== $actual_output ) { - return array( - 'ok' => false, - 'reason' => 'probe_failed', - 'output' => $actual_output, - 'exit_code' => $exit_code, - ); - } - - return array( - 'ok' => true, - 'reason' => 'ok', - 'output' => $actual_output, - 'exit_code' => $exit_code, - ); + return RuntimeCapabilities::evaluate_shell_capability($function_exists, $disabled_functions, $command_runner); } /** diff --git a/inc/Support/GitRunner.php b/inc/Support/GitRunner.php index f0105eb..4ba4ec7 100644 --- a/inc/Support/GitRunner.php +++ b/inc/Support/GitRunner.php @@ -21,50 +21,21 @@ if ( ! class_exists(ProcessRunner::class) ) { require_once __DIR__ . '/ProcessRunner.php'; } +if ( ! class_exists(RuntimeCapabilities::class) ) { + require_once __DIR__ . '/RuntimeCapabilities.php'; +} final class GitRunner { - /** - * Cached runtime capability probe. - * - * @var array|null - */ - private static ?array $diagnostic = null; - /** * Inspect whether the current PHP runtime can execute local git commands. * * @return array */ public static function diagnose(): array { - if ( null !== self::$diagnostic ) { - return self::$diagnostic; - } - - $exec_available = function_exists('exec'); - $proc_open_available = function_exists('proc_open'); - $output = array(); - $exit_code = 127; - $git_path = ''; - - if ( $exec_available ) { - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - exec('command -v git 2>/dev/null', $output, $exit_code); - $git_path = trim( (string) ( $output[0] ?? '' )); - } - - self::$diagnostic = array( - 'backend' => 'local_git', - 'exec_available' => $exec_available, - 'proc_open_available' => $proc_open_available, - 'git_available' => 0 === $exit_code && '' !== $git_path, - 'git_path' => $git_path, - 'probe_exit_code' => $exit_code, - ); - - return self::$diagnostic; + return RuntimeCapabilities::git_diagnostic(); } /** @@ -100,7 +71,7 @@ public static function unavailable_error( string $operation = 'Git workspace ope $reason = 'PHP proc_open() is unavailable for streaming git operations.'; } - $remediation = 'Run workspace abilities in a host runtime with local git access, or provide a Data Machine Code workspace backend that executes these operations outside the constrained PHP sandbox.'; + $remediation = RuntimeCapabilities::workspace_remediation(); return new \WP_Error( 'datamachine_workspace_git_unavailable', diff --git a/inc/Support/ProcessRunner.php b/inc/Support/ProcessRunner.php index 46a1b6e..e1cc2fe 100644 --- a/inc/Support/ProcessRunner.php +++ b/inc/Support/ProcessRunner.php @@ -13,6 +13,10 @@ defined('ABSPATH') || exit; +if ( ! class_exists(RuntimeCapabilities::class) ) { + require_once __DIR__ . '/RuntimeCapabilities.php'; +} + final class ProcessRunner { @@ -35,13 +39,12 @@ public static function run( string $command, array $options = array() ): array|\ return self::run_via_exec($command, $options, $output_cap); } - if ( ! function_exists('proc_open') ) { + $shell = RuntimeCapabilities::shell_diagnostic(); + if ( empty($shell['proc_open_available']) ) { return self::error( $options, 0 === $timeout_seconds ? 'Process command failed to start.' : sprintf('Process command timed out after %d second(s).', $timeout_seconds), - array( - 'proc_open_available' => false, - ) + $shell ); } @@ -53,8 +56,9 @@ public static function run( string $command, array $options = array() ): array|\ * @return array{success: bool, output: string, exit_code: int}|\WP_Error */ private static function run_via_exec( string $command, array $options, int $output_cap ): array|\WP_Error { - if ( ! function_exists('exec') ) { - return self::error($options, 'Process command failed to start.', array( 'exec_available' => false )); + $shell = RuntimeCapabilities::shell_diagnostic(); + if ( empty($shell['exec_available']) ) { + return self::error($options, 'Process command failed to start.', $shell); } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec diff --git a/inc/Support/RuntimeCapabilities.php b/inc/Support/RuntimeCapabilities.php new file mode 100644 index 0000000..3c3e6aa --- /dev/null +++ b/inc/Support/RuntimeCapabilities.php @@ -0,0 +1,220 @@ +|null + */ + private static ?array $shell_diagnostic = null; + + /** + * @var array> + */ + private static array $binary_diagnostics = array(); + + /** + * Return the shell capability diagnostic for this request. + * + * @return array{ok: bool, reason: string, exec_available: bool, shell_exec_available: bool, proc_open_available: bool, output?: string, exit_code?: int|null} + */ + public static function shell_diagnostic(): array { + if ( null !== self::$shell_diagnostic ) { + return self::$shell_diagnostic; + } + + self::$shell_diagnostic = self::evaluate_shell_capability( + static function ( string $function_name ): bool { + return function_exists($function_name); + }, + (string) ini_get('disable_functions'), + static function ( string $command ): array { + $output = array(); + $exit_code = null; + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Runtime capability probe. + exec($command, $output, $exit_code); + + return array( + 'output' => $output, + 'exit_code' => $exit_code, + ); + } + ); + + return self::$shell_diagnostic; + } + + /** + * Evaluate shell capability with injectable probes for tests. + * + * @param callable $function_exists Callback receiving a function name and returning availability. + * @param string $disabled_functions Raw `disable_functions` value. + * @param callable $command_runner Callback receiving command and returning output plus exit code. + * @return array{ok: bool, reason: string, exec_available: bool, shell_exec_available: bool, proc_open_available: bool, output?: string, exit_code?: int|null} + */ + public static function evaluate_shell_capability( callable $function_exists, string $disabled_functions, callable $command_runner ): array { + $disabled = array_filter(array_map('trim', explode(',', $disabled_functions))); + $exec_available = self::function_available('exec', $function_exists, $disabled); + $shell_exec_available = self::function_available('shell_exec', $function_exists, $disabled); + $proc_open_available = self::function_available('proc_open', $function_exists, $disabled); + + $base = array( + 'exec_available' => $exec_available, + 'shell_exec_available' => $shell_exec_available, + 'proc_open_available' => $proc_open_available, + ); + + if ( ! $exec_available ) { + $reason = $function_exists('exec') ? 'exec_disabled' : 'exec_missing'; + return array_merge( + $base, + array( + 'ok' => false, + 'reason' => $reason, + ) + ); + } + + if ( ! $shell_exec_available ) { + $reason = $function_exists('shell_exec') ? 'shell_exec_disabled' : 'shell_exec_missing'; + return array_merge( + $base, + array( + 'ok' => false, + 'reason' => $reason, + ) + ); + } + + $marker = '__datamachine_code_shell_ok__'; + + /** @var array{output: array, exit_code: int|null} $result */ + $result = $command_runner('printf ' . escapeshellarg($marker) . ' 2>&1'); + + $output = $result['output']; + $exit_code = $result['exit_code']; + $actual_output = trim(implode("\n", array_map('strval', $output))); + if ( 0 !== $exit_code || $marker !== $actual_output ) { + return array_merge( + $base, + array( + 'ok' => false, + 'reason' => 'probe_failed', + 'output' => $actual_output, + 'exit_code' => $exit_code, + ) + ); + } + + return array_merge( + $base, + array( + 'ok' => true, + 'reason' => 'ok', + 'output' => $actual_output, + 'exit_code' => $exit_code, + ) + ); + } + + /** + * Whether a named binary is available on PATH. + */ + public static function binary_available( string $binary, ?string $path = null ): bool { + $diagnostic = self::binary_diagnostic($binary, $path); + return ! empty($diagnostic['available']); + } + + /** + * Return a binary lookup diagnostic. + * + * @return array{binary: string, available: bool, path: string, exec_available: bool, exit_code: int|null} + */ + public static function binary_diagnostic( string $binary, ?string $path = null ): array { + $cache_key = $binary . "\0" . (string) $path; + if ( isset(self::$binary_diagnostics[ $cache_key ]) ) { + return self::$binary_diagnostics[ $cache_key ]; + } + + $diagnostic = array( + 'binary' => $binary, + 'available' => false, + 'path' => '', + 'exec_available' => self::shell_diagnostic()['exec_available'], + 'exit_code' => null, + ); + + if ( '' === $binary || ! preg_match('/^[a-zA-Z0-9_.\-]+$/', $binary) || empty($diagnostic['exec_available']) ) { + self::$binary_diagnostics[ $cache_key ] = $diagnostic; + return $diagnostic; + } + + $prefix = null !== $path && '' !== $path ? sprintf('PATH=%s ', escapeshellarg($path)) : ''; + $output = array(); + $exit = null; + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Runtime capability probe. + exec(sprintf('%scommand -v %s 2>/dev/null', $prefix, escapeshellarg($binary)), $output, $exit); + + $binary_path = trim( (string) ( $output[0] ?? '' ) ); + $diagnostic = array( + 'binary' => $binary, + 'available' => 0 === $exit && '' !== $binary_path, + 'path' => $binary_path, + 'exec_available' => true, + 'exit_code' => $exit, + ); + + self::$binary_diagnostics[ $cache_key ] = $diagnostic; + return $diagnostic; + } + + /** + * Return the local git diagnostic expected by workspace callers. + * + * @return array + */ + public static function git_diagnostic(): array { + $shell = self::shell_diagnostic(); + $git = self::binary_diagnostic('git'); + + return array( + 'backend' => 'local_git', + 'exec_available' => (bool) $shell['exec_available'], + 'shell_exec_available' => (bool) $shell['shell_exec_available'], + 'proc_open_available' => (bool) $shell['proc_open_available'], + 'git_available' => (bool) $git['available'], + 'git_path' => (string) $git['path'], + 'probe_exit_code' => $git['exit_code'], + 'shell_reason' => (string) $shell['reason'], + ); + } + + /** + * Standard remediation for shell-backed workspace operations. + */ + public static function workspace_remediation(): string { + return 'Run workspace abilities in a host runtime with local git access, or provide a Data Machine Code workspace backend that executes these operations outside the constrained PHP sandbox.'; + } + + /** + * @param string[] $disabled + */ + private static function function_available( string $function_name, callable $function_exists, array $disabled ): bool { + return $function_exists($function_name) && ! in_array($function_name, $disabled, true); + } +} diff --git a/inc/Workspace/WorktreeBootstrapper.php b/inc/Workspace/WorktreeBootstrapper.php index b77b60b..6376c26 100644 --- a/inc/Workspace/WorktreeBootstrapper.php +++ b/inc/Workspace/WorktreeBootstrapper.php @@ -44,12 +44,16 @@ namespace DataMachineCode\Workspace; use DataMachineCode\Support\ProcessRunner; +use DataMachineCode\Support\RuntimeCapabilities; defined('ABSPATH') || exit; if ( ! class_exists(ProcessRunner::class) ) { require_once dirname(__DIR__) . '/Support/ProcessRunner.php'; } +if ( ! class_exists(RuntimeCapabilities::class) ) { + require_once dirname(__DIR__) . '/Support/RuntimeCapabilities.php'; +} final class WorktreeBootstrapper { @@ -419,13 +423,7 @@ private static function detect_package_manager( string $worktree_path ): ?string * bash/zsh/dash — `which` is not POSIX. */ private static function binary_available( string $binary ): bool { - if ( '' === $binary || ! preg_match('/^[a-zA-Z0-9_.\-]+$/', $binary) ) { - return false; - } - $env = self::shell_env_prefix(); - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - exec(sprintf('%scommand -v %s 2>/dev/null', $env, escapeshellarg($binary)), $_unused, $exit); - return 0 === $exit; + return RuntimeCapabilities::binary_available($binary, self::augmented_path()); } /**