Preserve independent FNA image copies after source withdrawal

This commit is contained in:
Codex 2026-10-31 13:27:38 +08:00
parent 26cb1ed724
commit 21bb77c176
2 changed files with 31 additions and 7 deletions

View File

@ -160,17 +160,20 @@ public function synchronize(array $snapshots, ?array $baseline = null): array
$state['assets'][$id] = $this->copy($image, $state['assets']);
$available[$id] = true;
}
$selections = [];
// Published FNA copies are independent of the source lifecycle.
// A deleted source reference must not silently remove a local assignment.
$selections = $state['selections'][$pool] ?? [];
foreach ($snapshot['selections'] as $slug => $image) $selections[$slug] = $image['asset_id'];
foreach ($snapshot['unavailable'] ?? [] as $slug => $reason) {
$id = $state['selections'][$pool][$slug] ?? null;
$old = $id ? ($state['assets'][$id] ?? null) : null;
$local = $old ? config('illustrations.local_directory').'/'.basename($old['url']) : null;
// A healthy independent display copy survives physical loss at the source.
if (in_array($reason, ['file_missing', 'file_corrupt', 'file_symlink']) && $local && is_file($local) && ! is_link($local) && hash_equals($old['sha256'], hash_file('sha256', $local))) {
// Source deletion, disabling or withdrawal never revokes an FNA copy.
if ($local && is_file($local) && ! is_link($local) && hash_equals($old['sha256'], hash_file('sha256', $local))) {
$selections[$slug] = $id;
} else {
$selections[$slug] = null;
// Report missing/corrupt local bytes without destroying recovery metadata.
$selections[$slug] = $id;
$pending[$pool.':'.$slug] = $reason;
}
}

View File

@ -111,14 +111,35 @@ public function test_new_article_assignment_downloads_before_returning_local_url
Http::assertSentCount(4); // Two assignment requests; content downloaded once.
}
public function test_sync_stops_showing_withdrawn_mapping_without_automatically_deleting_shared_file(): void
public function test_source_withdrawal_disabling_and_deletion_preserve_independent_local_copies(): void
{
$this->fakeImages();$local = app(LocalIllustrations::class);$image = $this->image();
$local->storeSelection('freenode', 'fn1', $image);$local->storeSelection('freenode', 'fn2', $image);
$local->synchronize(['freenode' => ['items' => [$image], 'selections' => ['fn2' => $image], 'unavailable' => ['fn1' => 'publication_revoked']]]);
$this->assertNull($local->thumbnail('freenode', 'fn1'));
Http::fake();
foreach (['publication_revoked', 'asset_disabled', 'asset_inactive', 'access_revoked', 'file_missing', 'file_corrupt', 'file_symlink'] as $reason) {
$result = $local->synchronize(['freenode' => ['items' => [], 'selections' => [], 'unavailable' => ['fn1' => $reason]]]);
$this->assertSame([], $result['pending']);
$this->assertSame('/images/illustrations/'.$this->sha.'.png', $local->thumbnail('freenode', 'fn1'));
}
// A deleted upstream selection is absent even from the unavailable map.
$local->synchronize(['freenode' => ['items' => [], 'selections' => [], 'unavailable' => []]]);
$this->assertNotNull($local->thumbnail('freenode', 'fn1'));
$this->assertNotNull($local->thumbnail('freenode', 'fn2'));
$this->assertFileExists($this->dir.'/public/'.$this->sha.'.png');
Http::assertNothingSent();
}
public function test_sync_reports_missing_local_file_without_erasing_its_assignment(): void
{
$this->fakeImages();$local = app(LocalIllustrations::class);
$local->storeSelection('freenode', 'fn1', $this->image());
unlink($this->dir.'/public/'.$this->sha.'.png');
Http::fake();
$result = $local->synchronize(['freenode' => ['items' => [], 'selections' => [], 'unavailable' => ['fn1' => 'asset_disabled']]]);
$this->assertSame(['freenode:fn1' => 'asset_disabled'], $result['pending']);
$state = json_decode(file_get_contents($this->dir.'/private/manifest.json'), true);
$this->assertSame($this->id, $state['selections']['freenode']['fn1']);
Http::assertNothingSent();
}
public function test_missing_manifest_never_fetches_resource_or_returns_remote_fallback(): void