diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 5627304..525d9a3 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -4568,6 +4568,23 @@ private static function split_workspace_root_path( string $path, string $workspa return null; } + foreach ( self::mounted_workspace_path_aliases() as $mount_path => $workspace_ref ) { + if ( $path !== $mount_path && ! str_starts_with($path, $mount_path . '/') ) { + continue; + } + + $relative = ltrim(substr($path, strlen($mount_path)), '/'); + $segments = '' === $relative ? array() : array_values(array_filter(explode('/', $relative), static fn( string $segment ): bool => '' !== $segment && '.' !== $segment)); + if ( in_array('..', $segments, true) ) { + return null; + } + + return array( + 'repo' => $workspace_ref, + 'path' => implode('/', $segments), + ); + } + $relative = ltrim(substr($path, strlen($root)), '/'); if ( '' === $relative ) { return null; @@ -4585,6 +4602,30 @@ private static function split_workspace_root_path( string $path, string $workspa ); } + /** @return array */ + private static function mounted_workspace_path_aliases(): array { + if ( ! class_exists('\DataMachineCode\Runtime\MountedRuntimeBootstrap') ) { + return array(); + } + + $aliases = \DataMachineCode\Runtime\MountedRuntimeBootstrap::mounted_workspace_path_aliases(); + if ( ! is_array($aliases) ) { + return array(); + } + + $normalized = array(); + foreach ( $aliases as $path => $workspace_ref ) { + $path = rtrim(str_replace('\\', '/', (string) $path), '/'); + $workspace_ref = trim( (string) $workspace_ref ); + if ( '' !== $path && '' !== $workspace_ref ) { + $normalized[ $path ] = $workspace_ref; + } + } + + uksort($normalized, static fn ( string $left, string $right ): int => strlen($right) <=> strlen($left)); + return $normalized; + } + /** * Read git log entries for a workspace repository. * diff --git a/inc/Runtime/MountedRuntimeBootstrap.php b/inc/Runtime/MountedRuntimeBootstrap.php index 6f434bb..b6a7949 100644 --- a/inc/Runtime/MountedRuntimeBootstrap.php +++ b/inc/Runtime/MountedRuntimeBootstrap.php @@ -51,6 +51,26 @@ public static function context(): array { return self::$context; } + /** @return array Mounted target path => workspace handle. */ + public static function mounted_workspace_path_aliases(): array { + $workspace_root = defined('DATAMACHINE_WORKSPACE_PATH') ? rtrim( (string) DATAMACHINE_WORKSPACE_PATH, '/') : self::workspace_root_from_context(self::$context); + if ( '' === $workspace_root ) { + return array(); + } + + $aliases = array(); + foreach ( self::workspace_mounts(self::$context, $workspace_root) as $mount ) { + $path = rtrim( (string) ( $mount['path'] ?? '' ), '/'); + $workspace_ref = trim( (string) ( $mount['workspace_ref'] ?? '' ) ); + if ( '' !== $path && '' !== $workspace_ref ) { + $aliases[ $path ] = $workspace_ref; + } + } + + uksort($aliases, static fn ( string $left, string $right ): int => strlen($right) <=> strlen($left)); + return $aliases; + } + /** @return array */ private static function discover_context(): array { foreach ( array( 'mounted_runtime_context', 'wordpress_runtime_context' ) as $global_key ) { diff --git a/inc/Workspace/WorkspaceReader.php b/inc/Workspace/WorkspaceReader.php index e2ca720..878f2fc 100644 --- a/inc/Workspace/WorkspaceReader.php +++ b/inc/Workspace/WorkspaceReader.php @@ -95,38 +95,41 @@ public function read_file( string $name, string $path, int $max_size = Workspace ); } - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - $content = file_get_contents($real_path); - - if ( false === $content ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fopen + $handle = fopen($real_path, 'rb'); + if ( false === $handle ) { return new \WP_Error('read_failed', sprintf('Failed to read file: %s', $path), array( 'status' => 500 )); } - // Detect binary: check for null bytes in first 8 KB. - $sample = substr($content, 0, 8192); + // Detect binary: check for null bytes in first 8 KB without loading the whole file. + $sample = fread($handle, 8192); + if ( false === $sample ) { + fclose($handle); + return new \WP_Error('read_failed', sprintf('Failed to read file: %s', $path), array( 'status' => 500 )); + } if ( false !== strpos($sample, "\0") ) { + fclose($handle); return new \WP_Error('binary_file', sprintf('Binary file detected: %s. Only text files can be read.', $path), array( 'status' => 400 )); } - // Apply line offset and limit if specified. $lines_read = 0; $start_line = 1; if ( null !== $offset || null !== $limit ) { - $lines = explode("\n", $content); - $total_lines = count($lines); - - if ( null !== $offset ) { - $start_line = max(1, $offset); - $lines = array_slice($lines, $start_line - 1); + rewind($handle); + $slice = $this->read_line_slice($handle, $offset, $limit); + $content = $slice['content']; + $lines_read = $slice['lines_read']; + $start_line = $slice['offset']; + } else { + rewind($handle); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_stream_get_contents + $content = stream_get_contents($handle); + if ( false === $content ) { + fclose($handle); + return new \WP_Error('read_failed', sprintf('Failed to read file: %s', $path), array( 'status' => 500 )); } - - if ( null !== $limit ) { - $lines = array_slice($lines, 0, $limit); - } - - $content = implode("\n", $lines); - $lines_read = count($lines); } + fclose($handle); $result = array( 'success' => true, @@ -147,6 +150,41 @@ public function read_file( string $name, string $path, int $max_size = Workspace return $result; } + /** + * Read a bounded line slice from an already-open text file handle. + * + * @param resource $handle Open readable file handle. + * @return array{content:string,lines_read:int,offset:int} + */ + private function read_line_slice( $handle, ?int $offset, ?int $limit ): array { + $start_line = max(1, (int) ( $offset ?? 1 )); + $max_lines = null === $limit ? null : max(0, $limit); + $line_no = 0; + $lines = array(); + + while ( ! feof($handle) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fgets + $line = fgets($handle); + if ( false === $line ) { + break; + } + ++$line_no; + if ( $line_no < $start_line ) { + continue; + } + if ( null !== $max_lines && count($lines) >= $max_lines ) { + break; + } + $lines[] = rtrim($line, "\n"); + } + + return array( + 'content' => implode("\n", $lines), + 'lines_read' => count($lines), + 'offset' => $start_line, + ); + } + /** * List directory contents within a workspace repo. * diff --git a/tests/mounted-runtime-context.php b/tests/mounted-runtime-context.php index 54b6a7a..68d1296 100644 --- a/tests/mounted-runtime-context.php +++ b/tests/mounted-runtime-context.php @@ -76,6 +76,27 @@ function mounted_runtime_context_assert_same( mixed $expected, mixed $actual, st mounted_runtime_context_assert_same('wp-site-generator@wpsg-lab-proof-20260622-2102', $mounts[0]['workspace_ref'] ?? '', 'Mounted workspace adoption preserves the full worktree handle.'); +$context_property = new ReflectionProperty(MountedRuntimeBootstrap::class, 'context'); +$context_property->setValue( + null, + array( + 'workspace_root' => '/tmp/mounted-workspace', + 'runtime_workspace' => array( + 'root' => '/tmp/mounted-workspace', + 'mounts' => array( + array( + 'target' => '/tmp/mounted-workspace/wp-site-generator', + 'sourceMode' => 'mounted', + 'workspaceRef' => 'wp-site-generator@wpsg-lab-proof-20260622-2102', + ), + ), + ), + ) +); + +$aliases = MountedRuntimeBootstrap::mounted_workspace_path_aliases(); +mounted_runtime_context_assert_same('wp-site-generator@wpsg-lab-proof-20260622-2102', $aliases['/tmp/mounted-workspace/wp-site-generator'] ?? '', 'Mounted runtime exposes target path aliases to full workspace refs.'); + $method = new ReflectionMethod(MountedRuntimeBootstrap::class, 'discover_context'); $GLOBALS['wordpress_runtime_context'] = array( 'workspace_root' => '/tmp/generic-wordpress-workspace' ); diff --git a/tests/mounted-workspace-path-alias.php b/tests/mounted-workspace-path-alias.php new file mode 100644 index 0000000..68dda75 --- /dev/null +++ b/tests/mounted-workspace-path-alias.php @@ -0,0 +1,38 @@ +setValue( + null, + array( + 'workspace_root' => '/workspace', + 'runtime_workspace' => array( + 'root' => '/workspace', + 'mounts' => array( + array( + 'target' => '/workspace/wp-site-generator', + 'sourceMode' => 'repo-backed', + 'workspaceRef' => 'wp-site-generator@proof-worktree', + ), + ), + ), + ) +); + +$method = new ReflectionMethod(WorkspaceAbilities::class, 'split_workspace_root_path'); +$result = $method->invoke(null, '/workspace/wp-site-generator/static-sites/example/index.html', '/workspace'); + +if ( array( 'repo' => 'wp-site-generator@proof-worktree', 'path' => 'static-sites/example/index.html' ) !== $result ) { + throw new RuntimeException(sprintf('Mounted path alias was not resolved to workspaceRef. Got %s.', var_export($result, true))); +} + +echo "Mounted workspace path alias regression passed.\n"; diff --git a/tests/smoke-workspace-file-policy.php b/tests/smoke-workspace-file-policy.php index 14da2b7..c60c724 100644 --- a/tests/smoke-workspace-file-policy.php +++ b/tests/smoke-workspace-file-policy.php @@ -121,6 +121,15 @@ function size_format( int|float $bytes ): string { $assert(is_array($write_worktree) && ! empty($write_worktree['success']), 'write_file allows worktree mutation'); $assert('worktree' === file_get_contents($workspace_root . '/demo@feature-policy/new.txt'), 'worktree write lands in the worktree handle'); + $large_lines = array(); + for ( $i = 1; $i <= 5000; ++$i ) { + $large_lines[] = 'line-' . $i; + } + file_put_contents($workspace_root . '/demo@feature-policy/large.txt', implode("\n", $large_lines)); + $large_slice = $reader->read_file('demo@feature-policy', 'large.txt', 1024 * 1024, 4999, 2); + $assert(is_array($large_slice) && "line-4999\nline-5000" === $large_slice['content'], 'bounded read streams the requested line slice'); + $assert(is_array($large_slice) && 2 === $large_slice['lines_read'] && 4999 === $large_slice['offset'], 'bounded read reports slice metadata'); + $write_primary_allowed = $writer->write_file('demo', 'allowed.txt', 'allowed', true); $assert(is_array($write_primary_allowed) && ! empty($write_primary_allowed['success']), 'write_file honors explicit primary mutation override');