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
27 changes: 26 additions & 1 deletion inc/Core/JobArtifacts.php
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ private function write_artifact_file( int $job_id, string $artifact_key, array $
}

$json .= "\n";
if ( false === file_put_contents( $file_path, $json ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
if ( ! $this->write_atomic_file( $file_path, $json ) ) {
return array(
'success' => false,
'error' => sprintf( 'Failed to write %s artifact for job %d.', $artifact_key, $job_id ),
Expand All @@ -537,6 +537,31 @@ private function write_artifact_file( int $job_id, string $artifact_key, array $
);
}

private function write_atomic_file( string $file_path, string $contents ): bool {
$directory = dirname( $file_path );
if ( ! is_dir( $directory ) || ! is_writable( $directory ) ) {
return false;
}

$temp_path = tempnam( $directory, '.tmp-artifact-' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_tempnam
if ( false === $temp_path ) {
return false;
}

$written = file_put_contents( $temp_path, $contents, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
if ( strlen( $contents ) !== $written ) {
@unlink( $temp_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
return false;
}

if ( ! @rename( $temp_path, $file_path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
@unlink( $temp_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
return false;
}

return true;
}

/** @return array<string,mixed> */
private function with_payload_hash( array $payload ): array {
$payload['sha256'] = $this->hash_payload( $payload );
Expand Down
17 changes: 17 additions & 0 deletions tests/job-artifact-hashable-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -237,12 +237,29 @@ function wp_strip_all_tags( $value ): string {
$assert_true( $tool_trace_artifact['sha256'] === $refs['tool_trace']['sha256'], 'artifact refs expose tool trace hash' );
$assert_true( $tool_trace_artifact['artifact_ref'] === $refs['tool_trace']['artifact_ref'], 'artifact refs expose stable tool trace ref' );

$upload_dir = wp_upload_dir();
$artifact_job_dir = trailingslashit( $upload_dir['basedir'] ) . 'datamachine-artifacts/jobs/123';
$tool_trace_path = trailingslashit( $artifact_job_dir ) . 'tool-trace.json';
$leftover_temp_glob = trailingslashit( $artifact_job_dir ) . '.tmp-artifact-*';
wp_mkdir_p( $artifact_job_dir );
foreach ( glob( $leftover_temp_glob ) ?: array() as $leftover_temp_path ) {
@unlink( $leftover_temp_path );
}
file_put_contents( $tool_trace_path, "stale\n" );

$file_result = $invoke( $artifacts, 'write_artifact_file', array( 123, 'tool_trace', $tool_trace_artifact ) );
$assert_true( true === ( $file_result['success'] ?? false ), 'tool trace artifact file write succeeds' );
$assert_true( is_file( $file_result['file']['path'] ?? '' ), 'tool trace artifact file exists on disk' );
$assert_true( 'datamachine-artifacts/jobs/123/tool-trace.json' === ( $file_result['file']['relative_path'] ?? '' ), 'artifact file has stable relative path' );
$assert_true( $tool_trace_artifact['sha256'] === ( $file_result['file']['payload_sha256'] ?? '' ), 'artifact file references payload hash' );
$assert_true( hash_file( 'sha256', $file_result['file']['path'] ) === ( $file_result['file']['sha256'] ?? '' ), 'artifact file hash matches written bytes' );
$assert_true( "stale\n" !== file_get_contents( $tool_trace_path ), 'artifact file atomically replaces stale final contents' );

$second_file_result = $invoke( $artifacts, 'write_artifact_file', array( 123, 'tool_trace', $tool_trace_artifact ) );
$assert_true( true === ( $second_file_result['success'] ?? false ), 'repeat tool trace artifact write succeeds' );
$assert_true( $file_result['file']['path'] === ( $second_file_result['file']['path'] ?? '' ), 'repeat artifact write targets same stable path' );
$assert_true( $file_result['file']['sha256'] === ( $second_file_result['file']['sha256'] ?? '' ), 'repeat artifact write is byte-idempotent' );
$assert_true( array() === ( glob( $leftover_temp_glob ) ?: array() ), 'atomic artifact write leaves no temp files behind' );

$transcript_file_result = $invoke( $artifacts, 'write_artifact_file', array( 123, 'transcript', $transcript_artifact ) );
$assert_true( true === ( $transcript_file_result['success'] ?? false ), 'transcript artifact file write succeeds' );
Expand Down
Loading