perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

Align sync blob visibility semantics

alice e6db2f5e 3423427a

+80 -30
+2 -1
docs/TEST_AUDIT.md
··· 13 13 The current baseline for saying "the audited suite is green" is: 14 14 15 15 - `prove -lr t` 16 - - latest full green result in the realigned Meridian worktree: `Files=61, Tests=3128` 16 + - latest full green result in the realigned Meridian worktree: `Files=61, Tests=3129` 17 17 - `prove -lv t/server-auth.t` 18 18 - `perl -c script/differential-validate` 19 19 - `PERLSKY_RUN_REFERENCE_DIFF=1 prove -lv t/reference-differential.t` ··· 77 77 - Remote `did:web` DID docs, conservative `resolveIdentity` handle validation, and external handle adoption all need explicit coverage because small resolver-policy drifts turn into visible interop bugs quickly. 78 78 - Remote `did:plc` DID docs should resolve through the PLC directory defaults even when `plc_url` is not explicitly configured; gating that path on local config silently breaks federated identity lookups. 79 79 - Missing-repo read paths now match the official runtime more closely: `describeRepo`, `sync.getLatestCommit`, `sync.getRecord`, `sync.getRepo`, `sync.getCheckout`, `sync.getHead`, `sync.getRepoStatus`, `sync.getBlocks`, `sync.getBlob`, and `sync.listBlobs` report `400 RepoNotFound`, while `listRecords` reports `400 InvalidRequest` / `Could not find repo: ...`. 80 + - `com.atproto.sync.getBlob` needs two distinct not-found branches to stay reference-compatible: missing repos still report `400 RepoNotFound`, but blobs that are merely unreferenced for that repo report `400 InvalidRequest` / `Blob not found`, while moderation takedowns still hide them behind the moderation-layer `404 BlobNotFound` path. 80 81 - `com.atproto.repo.getRecord` must honor `cid` when present, and `putRecord` / `deleteRecord` must actually enforce `swapRecord`; those negative edges are now covered directly. 81 82 - `com.atproto.repo.createRecord` follows the reference runtime by ignoring a stray `swapRecord` field, and direct reference coverage now pins `putRecord` / `deleteRecord` `swapCommit` and `swapRecord` mismatch semantics explicitly. 82 83 - App-password sessions follow the official runtime more closely than the older local assumptions did: access-token scopes use the `com.atproto.appPass` / `com.atproto.appPassPrivileged` names, standard app-password sessions may list app passwords, privileged-only `getServiceAuth` failures report `InvalidRequest`, and revoked refresh tokens on `refreshSession` fail with `400 ExpiredToken`.
+3 -3
lib/ATProto/PDS/API/Sync.pm
··· 114 114 $registry->register('com.atproto.sync.getBlob', sub ($c, $endpoint) { 115 115 my $account = _repo_by_did_or_error($c, missing_status => 400); 116 116 my $blob = $c->store->get_blob($c->param('cid') // q()); 117 - xrpc_error(404, 'BlobNotFound', 'Blob was not found') 118 - unless $blob && $c->store->blob_owned_by_did($c->param('cid') // q(), $account->{did}); 117 + xrpc_error(400, 'InvalidRequest', 'Blob not found') 118 + unless $blob && $c->store->blob_referenced_by_did($c->param('cid') // q(), $account->{did}); 119 119 assert_blob_readable($c, $account, $blob); 120 - xrpc_error(404, 'BlobNotFound', 'Blob content is not available') 120 + xrpc_error(400, 'InvalidRequest', 'Blob not found') 121 121 unless $blob->{storage_path} && -f $blob->{storage_path}; 122 122 open(my $fh, '<:raw', $blob->{storage_path}) or xrpc_error(500, 'StorageFailure', 'Unable to read blob'); 123 123 local $/ = undef;
+12
lib/ATProto/PDS/Store/SQLite.pm
··· 758 758 ) // 0); 759 759 } 760 760 761 + sub blob_referenced_by_did ($self, $cid, $did) { 762 + return 0 unless defined $cid && length $cid && defined $did && length $did; 763 + return !!($self->dbh->selectrow_array( 764 + q{SELECT 1 FROM blob_owners WHERE cid = ? AND did = ? AND referenced_at IS NOT NULL}, 765 + undef, 766 + $cid, 767 + $did, 768 + ) // 0); 769 + } 770 + 761 771 sub mark_blobs_referenced ($self, $did, @cids) { 762 772 if (!defined($did) || ref($did) || (!length($did) && @cids)) { 763 773 unshift @cids, $did if defined $did; ··· 856 866 did collection rkey cid value_json record_bytes repo_rev created_at updated_at 857 867 )], qw(record_bytes)), 858 868 ); 869 + $self->mark_blobs_referenced($did, _record_blob_cids($args{value})); 859 870 860 871 return $self->get_record($did, $collection, $rkey); 861 872 } ··· 886 897 did collection rkey cid value_json record_bytes repo_rev created_at updated_at 887 898 )], qw(record_bytes)), 888 899 ); 900 + $self->mark_blobs_referenced($did, _record_blob_cids($record->{value})); 889 901 } 890 902 return 1; 891 903 }
+31 -9
script/differential-validate
··· 655 655 $server{$name}{secondary_did} = $json->{did}; 656 656 $server{$name}{secondary_handle} = $json->{handle}; 657 657 $server{$name}{secondary_email} = $name eq 'reference' ? 'bob-ref@test.com' : 'bob-perl@test.com'; 658 + $server{$name}{secondary_access} = $json->{accessJwt}; 659 + $server{$name}{secondary_refresh} = $json->{refreshJwt}; 658 660 } 659 661 660 662 note('Comparing account password boundary semantics'); ··· 2026 2028 did => 'did:web:missing.test', 2027 2029 cids => $requested_cid, 2028 2030 }); 2031 + my $wrong_repo = get_form($server{$name}{origin}, 'com.atproto.sync.getBlocks', { 2032 + did => $server{$name}{secondary_did}, 2033 + cids => $requested_cid, 2034 + }); 2029 2035 2030 2036 $server{$name}{get_blocks} = { 2031 2037 ok => $res->is_success ? 1 : 0, ··· 2035 2041 missing_status => $missing->code, 2036 2042 missing_error => ($missing->json || {})->{error}, 2037 2043 missing_repo => normalize_xrpc_error($missing_repo), 2044 + wrong_repo => normalize_xrpc_error($wrong_repo), 2038 2045 }; 2039 2046 } 2040 2047 ··· 2087 2094 2088 2095 note('Comparing listBlobs since semantics'); 2089 2096 for my $name (sort keys %server) { 2090 - my $session = post_json($server{$name}{origin}, 'com.atproto.server.createSession', { 2091 - identifier => $server{$name}{renamed_handle} || $server{$name}{handle}, 2092 - password => 'hunter22', 2093 - }); 2094 - check($session->is_success, "$name createSession succeeds for listBlobs comparison"); 2095 - next unless $session->is_success; 2096 - 2097 2097 my $upload = post_bytes( 2098 2098 $server{$name}{origin}, 2099 2099 'com.atproto.repo.uploadBlob', 2100 2100 'sync blob bytes', 2101 2101 'text/plain', 2102 - auth_header(($session->json || {})->{accessJwt}), 2102 + auth_header($server{$name}{access}), 2103 2103 ); 2104 2104 check($upload->is_success, "$name uploadBlob succeeds for listBlobs comparison"); 2105 2105 next unless $upload->is_success; ··· 2117 2117 blob => $blob, 2118 2118 }, 2119 2119 }, 2120 - auth_header(($session->json || {})->{accessJwt}), 2120 + auth_header($server{$name}{access}), 2121 2121 ); 2122 2122 check($create->is_success, "$name createRecord with blob succeeds for listBlobs comparison"); 2123 + next unless $create->is_success; 2124 + 2125 + my $secondary_upload = post_bytes( 2126 + $server{$name}{origin}, 2127 + 'com.atproto.repo.uploadBlob', 2128 + 'sync blob bytes', 2129 + 'text/plain', 2130 + auth_header($server{$name}{secondary_access}), 2131 + ); 2132 + check($secondary_upload->is_success, "$name secondary uploadBlob succeeds for listBlobs comparison"); 2133 + next unless $secondary_upload->is_success; 2123 2134 2124 2135 my $res = get_form($server{$name}{origin}, 'com.atproto.sync.listBlobs', { 2125 2136 did => $server{$name}{did}, 2126 2137 since => $server{$name}{latest_commit_raw}{rev}, 2127 2138 }); 2139 + my $secondary_res = get_form($server{$name}{origin}, 'com.atproto.sync.listBlobs', { 2140 + did => $server{$name}{secondary_did}, 2141 + }); 2128 2142 my $missing_repo = get_form($server{$name}{origin}, 'com.atproto.sync.listBlobs', { 2129 2143 did => 'did:web:missing.test', 2130 2144 }); 2131 2145 check($res->is_success, "$name listBlobs with since succeeds"); 2146 + check($secondary_res->is_success, "$name secondary listBlobs succeeds"); 2132 2147 my $json = $res->json || {}; 2148 + my $secondary_json = $secondary_res->json || {}; 2133 2149 my $blob_cid = $blob->{ref}{'$link'}; 2134 2150 $server{$name}{sync_blob} = { 2135 2151 cid => $blob_cid, ··· 2138 2154 ok => $res->is_success ? 1 : 0, 2139 2155 returns_blob => (scalar grep { $_ eq $blob_cid } @{ $json->{cids} || [] }) ? 1 : 0, 2140 2156 cursor_matches_tail => (($json->{cursor} // q()) eq (($json->{cids} || [])->[-1] // q())) ? 1 : 0, 2157 + secondary_empty => @{ $secondary_json->{cids} || [] } ? 0 : 1, 2141 2158 missing_repo => normalize_xrpc_error($missing_repo), 2142 2159 }; 2143 2160 } ··· 2157 2174 did => $server{$name}{did}, 2158 2175 cid => $blob_cid, 2159 2176 }); 2177 + my $secondary = get_form($server{$name}{origin}, 'com.atproto.sync.getBlob', { 2178 + did => $server{$name}{secondary_did}, 2179 + cid => $blob_cid, 2180 + }); 2160 2181 my $missing_repo = get_form($server{$name}{origin}, 'com.atproto.sync.getBlob', { 2161 2182 did => 'did:web:missing.test', 2162 2183 cid => $blob_cid, ··· 2171 2192 nosniff => (($res->headers->header('X-Content-Type-Options') // q()) eq 'nosniff') ? 1 : 0, 2172 2193 csp_sandbox => (($res->headers->header('Content-Security-Policy') // q()) eq q{default-src 'none'; sandbox}) ? 1 : 0, 2173 2194 attachment_name => ($disposition =~ /\Aattachment; filename="/) ? 1 : 0, 2195 + secondary => normalize_xrpc_error($secondary), 2174 2196 missing_repo => normalize_xrpc_error($missing_repo), 2175 2197 }; 2176 2198 }
+16
t/moderation.t
··· 197 197 my $blob = $t->tx->res->json->{blob}; 198 198 my $blob_cid = $blob->{ref}{'$link'}; 199 199 200 + $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { 201 + Authorization => "Bearer $access", 202 + } => json => { 203 + repo => $did, 204 + collection => 'app.bsky.feed.post', 205 + rkey => 'blob-preexisting-ref', 206 + record => { 207 + '$type' => 'app.bsky.feed.post', 208 + text => 'existing blob ref', 209 + createdAt => '2026-03-10T00:00:01Z', 210 + embed => { 211 + image => $blob, 212 + }, 213 + }, 214 + })->status_is(200); 215 + 200 216 $t->post_ok('/xrpc/com.atproto.admin.updateSubjectStatus' => { 201 217 Authorization => $admin_auth, 202 218 } => json => {
+4 -1
t/store-sqlite.t
··· 83 83 storage_path => 'blobs/bafk.png', 84 84 ); 85 85 is($store->get_blob('bafkreigh2akiscaildc')->{byte_size}, 1234, 'blob metadata is stored'); 86 - ok($store->blob_owned_by_did('bafkreigh2akiscaildc', $account->{did}), 'primary owner is tracked'); 86 + ok($store->blob_owned_by_did('bafkreigh2akiscaildc', $account->{did}), 'upload tracks the blob owner'); 87 + ok(!$store->blob_referenced_by_did('bafkreigh2akiscaildc', $account->{did}), 'upload alone does not mark the blob referenced'); 87 88 88 89 $store->put_blob( 89 90 cid => 'bafkreigh2akiscaildc', ··· 124 125 }, 125 126 }, 126 127 ); 128 + ok($store->blob_referenced_by_did('bafkreigh2akiscaildc', $account->{did}), 'primary owner is marked referenced once the blob is attached'); 127 129 ok($store->blob_owned_by_did('bafkreigh2akiscaildc', $second_account->{did}), 'second owner is tracked for shared blob'); 130 + ok($store->blob_referenced_by_did('bafkreigh2akiscaildc', $second_account->{did}), 'second owner is marked referenced for shared blob'); 128 131 is($store->count_blobs_by_did($account->{did}), 1, 'first account still counts shared blob'); 129 132 is($store->count_blobs_by_did($second_account->{did}), 1, 'second account counts shared blob'); 130 133 is($store->list_blobs_by_did($account->{did})->{items}[0]{cid}, 'bafkreigh2akiscaildc', 'shared blob lists for first owner');
+12 -16
t/sync-blob-export-surfaces.t
··· 94 94 my $blob = $t->tx->res->json->{blob}; 95 95 my $blob_cid = $blob->{ref}{'$link'}; 96 96 97 - $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.sync.getBlob')->query( 98 - did => $did, 99 - cid => $blob_cid, 100 - ))->status_is(200) 101 - ->header_is('X-Content-Type-Options' => 'nosniff') 102 - ->header_like('Content-Disposition' => qr/\Aattachment; filename="/) 103 - ->header_is('Content-Security-Policy' => "default-src 'none'; sandbox") 104 - ->content_type_is('text/plain') 105 - ->content_is('blob-bytes'); 106 - 107 97 $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { 108 98 Authorization => "Bearer $access", 109 99 } => json => { ··· 116 106 image => $blob, 117 107 }, 118 108 })->status_is(200); 109 + 110 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.sync.getBlob')->query( 111 + did => $did, 112 + cid => $blob_cid, 113 + ))->status_is(200) 114 + ->header_is('X-Content-Type-Options' => 'nosniff') 115 + ->header_like('Content-Disposition' => qr/\Aattachment; filename="/) 116 + ->header_is('Content-Security-Policy' => "default-src 'none'; sandbox") 117 + ->content_type_is('text/plain') 118 + ->content_is('blob-bytes'); 119 119 120 120 $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 121 121 handle => 'bob.example.test', ··· 193 193 $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.sync.getBlob')->query( 194 194 did => $second_did, 195 195 cid => $blob_cid, 196 - ))->status_is(200) 197 - ->header_is('X-Content-Type-Options' => 'nosniff') 198 - ->header_like('Content-Disposition' => qr/\Aattachment; filename="/) 199 - ->header_is('Content-Security-Policy' => "default-src 'none'; sandbox") 200 - ->content_type_is('text/plain') 201 - ->content_is('blob-bytes'); 196 + ))->status_is(400) 197 + ->json_is('/error' => 'InvalidRequest'); 202 198 203 199 my @since_sorted_blob_cids = sort ($blob_cid, $blob_two_cid); 204 200