@@ -1272,11 +1272,7 @@ private function get_repo_allowed_paths( string $repo_name ): array {
12721272 * @return array<int,string>
12731273 */
12741274 private function get_repo_writable_roots ( string $ repo_name ): array {
1275- $ policies = $ this ->get_workspace_git_policies ();
1276- $ repo = $ policies ['repos ' ][ $ repo_name ] ?? array ();
1277- $ paths = $ repo ['writable_roots ' ] ?? $ repo ['allowed_paths ' ] ?? array ();
1278-
1279- return $ this ->normalize_policy_paths ($ paths );
1275+ return ( new WorkspacePolicy () )->writable_roots_for_repo ($ repo_name );
12801276 }
12811277
12821278 /**
@@ -1286,10 +1282,7 @@ private function get_repo_writable_roots( string $repo_name ): array {
12861282 * @return array<int,string>
12871283 */
12881284 private function get_repo_hidden_paths ( string $ repo_name ): array {
1289- $ policies = $ this ->get_workspace_git_policies ();
1290- $ repo = $ policies ['repos ' ][ $ repo_name ] ?? array ();
1291-
1292- return $ this ->normalize_policy_paths ($ repo ['hidden_paths ' ] ?? array ());
1285+ return ( new WorkspacePolicy () )->hidden_paths_for_repo ($ repo_name );
12931286 }
12941287
12951288 /**
@@ -1299,23 +1292,7 @@ private function get_repo_hidden_paths( string $repo_name ): array {
12991292 * @return array<int,string>
13001293 */
13011294 private function normalize_policy_paths ( mixed $ paths ): array {
1302- if ( ! is_array ($ paths ) ) {
1303- return array ();
1304- }
1305-
1306- $ clean = array ();
1307- foreach ( $ paths as $ path ) {
1308- $ normalized = trim ( (string ) $ path );
1309- if ( '' === $ normalized ) {
1310- continue ;
1311- }
1312-
1313- $ normalized = ltrim (str_replace ('\\' , '/ ' , $ normalized ), '/ ' );
1314- $ normalized = rtrim ($ normalized , '/ ' );
1315- $ clean [] = $ normalized ;
1316- }
1317-
1318- return array_values (array_unique ($ clean ));
1295+ return ( new WorkspacePolicy () )->normalize_paths ($ paths );
13191296 }
13201297
13211298 /**
@@ -1357,10 +1334,8 @@ private function enforce_workspace_policy( string $repo_name, string $repo_path,
13571334 * @return array<string,mixed>|null|\WP_Error
13581335 */
13591336 private function build_workspace_policy_attestation ( string $ repo_name , string $ repo_path , ?array $ paths = null ): array |null |\WP_Error {
1360- $ writable_roots = $ this ->get_repo_writable_roots ($ repo_name );
1361- $ hidden_paths = $ this ->get_repo_hidden_paths ($ repo_name );
1362-
1363- if ( empty ($ writable_roots ) && empty ($ hidden_paths ) ) {
1337+ $ policy = new WorkspacePolicy ();
1338+ if ( empty ($ policy ->writable_roots_for_repo ($ repo_name )) && empty ($ policy ->hidden_paths_for_repo ($ repo_name )) ) {
13641339 return null ;
13651340 }
13661341
@@ -1374,47 +1349,15 @@ private function build_workspace_policy_attestation( string $repo_name, string $
13741349 return $ ignored_files ;
13751350 }
13761351
1377- $ changed_files = array_values (array_unique (array_merge ($ changed_files , $ ignored_files )));
1378- sort ($ changed_files );
1379-
1380- $ policy = array (
1381- 'writable_roots ' => $ writable_roots ,
1382- 'hidden_paths ' => $ hidden_paths ,
1383- );
1384-
1385- $ real_repo = realpath ($ repo_path );
1386- if ( false === $ real_repo ) {
1387- return new \WP_Error ('repo_path_unavailable ' , 'Repository path cannot be resolved for workspace policy attestation. ' , array ( 'status ' => 500 ));
1388- }
1389-
1390- $ violations = array ();
1391- foreach ( $ changed_files as $ path ) {
1392- $ violations = array_merge ($ violations , $ this ->workspace_policy_path_violations ($ repo_path , $ real_repo , $ path , $ writable_roots , $ hidden_paths , $ ignored_files ));
1393- }
1394-
1395- // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- fallback for standalone smoke tests outside WordPress.
1396- $ policy_json = function_exists ('wp_json_encode ' ) ? wp_json_encode ($ policy ) : json_encode ($ policy );
1397-
1398- return array (
1399- 'policy_hash ' => hash ('sha256 ' , (string ) $ policy_json ),
1400- 'checked_roots ' => array (
1401- 'repository ' => $ real_repo ,
1402- 'writable_roots ' => $ writable_roots ,
1403- 'hidden_paths ' => $ hidden_paths ,
1404- ),
1405- 'changed_files ' => $ changed_files ,
1406- 'violations ' => $ violations ,
1407- 'supported_checks ' => array (
1408- 'writable_roots ' ,
1409- 'hidden_paths ' ,
1410- 'symlink ' ,
1411- 'gitlink ' ,
1412- 'nested_git ' ,
1413- 'non_regular ' ,
1414- 'outside_realpath ' ,
1415- 'ignored ' ,
1416- 'hardlink ' ,
1417- ),
1352+ return $ policy ->attest_changed_paths (
1353+ $ repo_name ,
1354+ $ repo_path ,
1355+ $ changed_files ,
1356+ $ ignored_files ,
1357+ function ( string $ path ) use ( $ repo_path ): string {
1358+ $ stage = $ this ->run_git ($ repo_path , 'ls-files --stage -- ' . escapeshellarg ($ path ));
1359+ return is_wp_error ($ stage ) ? '' : (string ) ( $ stage ['output ' ] ?? '' );
1360+ }
14181361 );
14191362 }
14201363
@@ -1498,89 +1441,6 @@ private function get_workspace_policy_publish_files( string $repo_path, string $
14981441 return array_values (array_unique ($ files ));
14991442 }
15001443
1501- /**
1502- * Build all policy violations for a single relative path.
1503- *
1504- * @param string $repo_path Repository path.
1505- * @param string $real_repo Real repository path.
1506- * @param string $path Relative path.
1507- * @param array<int,string> $writable_roots Writable roots.
1508- * @param array<int,string> $hidden_paths Hidden paths.
1509- * @param array<int,string> $ignored_files Ignored files.
1510- * @return array<int,array<string,string>>
1511- */
1512- private function workspace_policy_path_violations ( string $ repo_path , string $ real_repo , string $ path , array $ writable_roots , array $ hidden_paths , array $ ignored_files ): array {
1513- $ violations = array ();
1514- $ path = ltrim (str_replace ('\\' , '/ ' , $ path ), '/ ' );
1515- $ absolute = $ repo_path . '/ ' . $ path ;
1516-
1517- $ add_violation = static function ( string $ reason , string $ detail = '' ) use ( &$ violations , $ path ): void {
1518- $ violation = array (
1519- 'path ' => $ path ,
1520- 'reason ' => $ reason ,
1521- );
1522- if ( '' !== $ detail ) {
1523- $ violation ['detail ' ] = $ detail ;
1524- }
1525- $ violations [] = $ violation ;
1526- };
1527-
1528- if ( ! empty ($ writable_roots ) && ! $ this ->is_path_allowed ($ path , $ writable_roots ) ) {
1529- $ add_violation ('outside_writable_roots ' , 'Changed path is outside configured writable_roots. ' );
1530- }
1531-
1532- if ( ! empty ($ hidden_paths ) && $ this ->is_path_allowed ($ path , $ hidden_paths ) ) {
1533- $ add_violation ('hidden_path ' , 'Changed path is under configured hidden_paths. ' );
1534- }
1535-
1536- if ( '.git ' === $ path || str_starts_with ($ path , '.git/ ' ) || str_contains ($ path , '/.git/ ' ) || str_ends_with ($ path , '/.git ' ) ) {
1537- $ add_violation ('nested_git ' , 'Changed path includes a .git directory or file. ' );
1538- }
1539-
1540- if ( in_array ($ path , $ ignored_files , true ) ) {
1541- $ add_violation ('ignored ' , 'Changed path is ignored by git exclude rules. ' );
1542- }
1543-
1544- if ( is_link ($ absolute ) ) {
1545- $ add_violation ('symlink ' , 'Changed path is a symlink. ' );
1546- $ target = readlink ($ absolute );
1547- if ( false !== $ target ) {
1548- $ target_path = str_starts_with ($ target , '/ ' ) ? $ target : dirname ($ absolute ) . '/ ' . $ target ;
1549- $ real_target = realpath ($ target_path );
1550- if ( false === $ real_target || ( $ real_target !== $ real_repo && ! str_starts_with ($ real_target , $ real_repo . '/ ' ) ) ) {
1551- $ add_violation ('outside_realpath ' , 'Symlink target resolves outside the repository. ' );
1552- }
1553- foreach ( $ hidden_paths as $ hidden_path ) {
1554- $ hidden_real = realpath ($ repo_path . '/ ' . $ hidden_path );
1555- if ( false !== $ hidden_real && false !== $ real_target && ( $ real_target === $ hidden_real || str_starts_with ($ real_target , $ hidden_real . '/ ' ) ) ) {
1556- $ add_violation ('hidden_path_exposure ' , 'Symlink target resolves under hidden_paths. ' );
1557- }
1558- }
1559- }
1560- } elseif ( file_exists ($ absolute ) ) {
1561- $ real_path = realpath ($ absolute );
1562- if ( false === $ real_path || ( $ real_path !== $ real_repo && ! str_starts_with ($ real_path , $ real_repo . '/ ' ) ) ) {
1563- $ add_violation ('outside_realpath ' , 'Changed path resolves outside the repository. ' );
1564- }
1565-
1566- if ( ! is_file ($ absolute ) && ! is_dir ($ absolute ) ) {
1567- $ add_violation ('non_regular ' , 'Changed path is neither a regular file nor a directory. ' );
1568- }
1569-
1570- $ stat = @lstat ($ absolute ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
1571- if ( is_array ($ stat ) && is_file ($ absolute ) && (int ) ( $ stat ['nlink ' ] ?? 1 ) > 1 ) {
1572- $ add_violation ('hardlink ' , 'Changed file has more than one hardlink. ' );
1573- }
1574- }
1575-
1576- $ stage = $ this ->run_git ($ repo_path , 'ls-files --stage -- ' . escapeshellarg ($ path ));
1577- if ( ! is_wp_error ($ stage ) && preg_match ('/^160000\s/ ' , (string ) ( $ stage ['output ' ] ?? '' )) ) {
1578- $ add_violation ('gitlink ' , 'Changed path is a gitlink/submodule entry. ' );
1579- }
1580-
1581- return $ violations ;
1582- }
1583-
15841444 /**
15851445 * Get fixed branch restriction for a repo.
15861446 *
0 commit comments