Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -4585,6 +4602,30 @@ private static function split_workspace_root_path( string $path, string $workspa
);
}

/** @return array<string,string> */
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.
*
Expand Down
20 changes: 20 additions & 0 deletions inc/Runtime/MountedRuntimeBootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,26 @@ public static function context(): array {
return self::$context;
}

/** @return array<string,string> 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<string,mixed> */
private static function discover_context(): array {
foreach ( array( 'mounted_runtime_context', 'wordpress_runtime_context' ) as $global_key ) {
Expand Down
78 changes: 58 additions & 20 deletions inc/Workspace/WorkspaceReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
*
Expand Down
21 changes: 21 additions & 0 deletions tests/mounted-runtime-context.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Expand Down
38 changes: 38 additions & 0 deletions tests/mounted-workspace-path-alias.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php
/**
* Regression coverage for mounted workspace path aliases.
*/

define('ABSPATH', __DIR__ . '/fixtures/wordpress/');
require_once dirname(__DIR__) . '/inc/Runtime/MountedRuntimeBootstrap.php';
require_once dirname(__DIR__) . '/inc/Abilities/WorkspaceAbilities.php';

use DataMachineCode\Abilities\WorkspaceAbilities;
use DataMachineCode\Runtime\MountedRuntimeBootstrap;

$context_property = new ReflectionProperty(MountedRuntimeBootstrap::class, 'context');
$context_property->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";
9 changes: 9 additions & 0 deletions tests/smoke-workspace-file-policy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
Loading