File Coverage

lib/Net/BitTorrent/Storage.pm
Criterion Covered Total %
statement 26 26 100.0
branch n/a
condition n/a
subroutine 9 9 100.0
pod n/a
total 35 35 100.0


line stmt bran cond sub pod time code
1 21     21   237301 use v5.40;
  21         72  
2 21     21   144 use feature 'class';
  21         41  
  21         3022  
3 21     21   160 no warnings 'experimental::class';
  21         36  
  21         1153  
4 21     21   619 use Net::BitTorrent::Emitter;
  21         34  
  21         2060  
5             class Net::BitTorrent::Storage v2.0.1 : isa(Net::BitTorrent::Emitter) {
6 21     21   11073 use Net::BitTorrent::Storage::File;
  21         70  
  21         1005  
7 21     21   149 use Digest::Merkle::SHA256; # Standalone spin-off
  21         36  
  21         609  
8 21     21   94 use Path::Tiny qw();
  21         35  
  21         459  
9 21     21   81 use Digest::SHA qw[sha1];
  21         33  
  21         1287  
10 21     21   14198 use builtin qw[refaddr];
  21         2809  
  21         120872  
11             field $base_path : param : reader;
12             field $file_tree : param : reader = undef;
13             field $piece_size : param : reader = 0;
14             field $pieces_v1 : param : reader = undef;
15             field %files; # pieces_root => File object
16             field @files_ordered; # For v1 mapping
17             method files_ordered () { \@files_ordered }
18             field %piece_layers; # pieces_root => piece layer data
19              
20             # Async Disk Cache (LRU)
21             field %cache; # file_id => { offset => data }
22             field %cache_dirty; # file_id => { offset => 1 }
23             field @lru_list; # [[file_id, offset], ...]
24             field $max_cache_size = 1024 * 1024 * 128; # 128MiB default cache limit
25             field $current_cache_size = 0;
26             ADJUST {
27             $base_path = Path::Tiny::path($base_path);
28             if ($file_tree) {
29             $self->load_file_tree($file_tree);
30             }
31             }
32              
33             method add_file ( $rel_path, $size, $pieces_root = undef ) {
34             my $file = Net::BitTorrent::Storage::File->new(
35             path => $base_path->child($rel_path),
36             size => $size,
37             pieces_root => $pieces_root,
38             piece_size => $piece_size,
39             );
40             push @files_ordered, $file;
41             $files{$pieces_root} = $file if $pieces_root;
42             return $file;
43             }
44              
45             method load_file_tree ($tree) {
46             $self->_parse_file_tree( $tree, [] );
47             }
48              
49             method _parse_file_tree ( $tree, $path_stack ) {
50             for my $name ( sort keys %$tree ) {
51             my $node = $tree->{$name};
52             if ( exists $node->{''} ) {
53             my $file_info = $node->{''};
54             $self->add_file( Path::Tiny::path( @$path_stack, $name ), $file_info->{length}, $file_info->{'pieces root'} );
55             }
56             else {
57             $self->_parse_file_tree( $node, [ @$path_stack, $name ] );
58             }
59             }
60             }
61              
62             method get_file_by_root ($root) {
63             return $files{$root};
64             }
65              
66             method set_piece_layer ( $root, $layer_data ) {
67             $piece_layers{$root} = $layer_data;
68             }
69              
70             method get_hashes ( $root, $base_layer, $index, $length ) {
71             my $file = $files{$root} or return undef;
72             return $file->merkle->get_hashes( $base_layer, $index, $length );
73             }
74              
75             method verify_block ( $root, $index, $data ) {
76             my $file = $files{$root};
77             if ( !$file ) {
78             $self->_emit( log => 'Unknown file root', level => 'fatal' );
79             return;
80             }
81             return $file->verify_block( $index, $data );
82             }
83              
84             method verify_block_audit ( $root, $index, $data, $audit_path ) {
85             my $file = $files{$root};
86             if ( !$file ) {
87             $self->_emit( log => 'Unknown file root', level => 'fatal' );
88             return;
89             }
90             return $file->verify_block_audit( $index, $data, $audit_path );
91             }
92              
93             method verify_piece_v2 ( $root, $index, $data ) {
94             my $file = $files{$root};
95             if ( !$file ) {
96             $self->_emit( log => 'Unknown file root', level => 'fatal' );
97             return;
98             }
99             my $layer = $piece_layers{$root} or return undef;
100             my $expected = substr( $layer, $index * 32, 32 );
101             return $file->verify_piece_v2( $index, $data, $expected );
102             }
103              
104             # Writes a block to the cache
105             method write_block ( $root, $offset, $data ) {
106             my $file = $files{$root};
107             if ( !$file ) {
108             $self->_emit( log => 'Unknown file root', level => 'fatal' );
109             return;
110             }
111             $self->_write_to_cache( $file, $offset, $data );
112             }
113              
114             # Reads a block, checking cache first
115             method read_block ( $root, $offset, $length ) {
116             my $file = $files{$root};
117             if ( !$file ) {
118             $self->_emit( log => 'Unknown file root', level => 'fatal' );
119             return;
120             }
121             return $self->_read_from_cache( $file, $offset, $length );
122             }
123              
124             method read_global ( $offset, $length ) {
125             my $segments = $self->map_abs_offset( undef, $offset, $length );
126             my $full_data = '';
127             for my $seg (@$segments) {
128             $full_data .= $self->_read_from_cache( $seg->{file}, $seg->{offset}, $seg->{length} );
129             }
130             return $full_data;
131             }
132              
133             method write_global ( $offset, $data ) {
134             my $segments = $self->map_abs_offset( undef, $offset, length($data) );
135             my $data_offset = 0;
136             for my $seg (@$segments) {
137             $self->_write_to_cache( $seg->{file}, $seg->{offset}, substr( $data, $data_offset, $seg->{length} ) );
138             $data_offset += $seg->{length};
139             }
140             }
141              
142             method _write_to_cache ( $file, $offset, $data ) {
143             my $id = refaddr($file);
144             $self->_emit( log => " [DEBUG] Adding " . length($data) . " bytes to cache for file $id at offset $offset (dirty)\n", level => 'debug' );
145             if ( exists $cache{$id}{$offset} ) {
146             $current_cache_size -= length( $cache{$id}{$offset} );
147             $self->_lru_bump( $id, $offset );
148             }
149             else {
150             push @lru_list, [ $id, $offset ];
151             }
152             $cache{$id}{$offset} = $data;
153             $cache_dirty{$id}{$offset} = 1;
154             $current_cache_size += length($data);
155             while ( $current_cache_size > $max_cache_size ) {
156             last unless $self->_evict_one();
157             }
158             }
159              
160             method _read_from_cache ( $file, $offset, $length ) {
161             my $id = refaddr($file);
162             if ( exists $cache{$id} ) {
163             for my $cached_offset ( keys %{ $cache{$id} } ) {
164             my $cached_data = $cache{$id}{$cached_offset};
165             my $cached_len = length($cached_data);
166             if ( $offset >= $cached_offset && ( $offset + $length ) <= ( $cached_offset + $cached_len ) ) {
167             $self->_lru_bump( $id, $cached_offset );
168             return substr( $cached_data, $offset - $cached_offset, $length );
169             }
170             }
171             }
172              
173             # Cache miss - read from disk and add to clean cache
174             my $data = $file->read( $offset, $length );
175             if ( defined $data && length($data) > 0 ) {
176             $self->_emit( log => " [DEBUG] Cache miss for file $id at offset $offset, caching read\n", level => 'debug' );
177             push @lru_list, [ $id, $offset ];
178             $cache{$id}{$offset} = $data;
179             $current_cache_size += length($data);
180             while ( $current_cache_size > $max_cache_size ) {
181             last unless $self->_evict_one();
182             }
183             }
184             return $data;
185             }
186              
187             method _lru_bump ( $id, $offset ) {
188              
189             # Move [id, offset] to end of @lru_list
190             for my $i ( 0 .. $#lru_list ) {
191             if ( $lru_list[$i][0] == $id && $lru_list[$i][1] == $offset ) {
192             push @lru_list, splice( @lru_list, $i, 1 );
193             last;
194             }
195             }
196             }
197              
198             method _evict_one () {
199             return 0 unless @lru_list;
200             my $entry = shift @lru_list;
201             my ( $id, $offset ) = @$entry;
202             if ( $cache_dirty{$id} && $cache_dirty{$id}{$offset} ) {
203              
204             # Must flush before evicting
205             $self->_flush_one( $id, $offset );
206             }
207             if ( exists $cache{$id} && exists $cache{$id}{$offset} ) {
208             $current_cache_size -= length( delete $cache{$id}{$offset} );
209             delete $cache{$id} unless keys %{ $cache{$id} }; # Clean up empty file entry
210             }
211             return 1;
212             }
213              
214             method _flush_one ( $id, $offset ) {
215             my $file;
216              
217             # Find file object by refaddr - inefficient but safe without reverse mapping
218             # In a real system we'd store file objects in a registry.
219             # Actually, let's look in %files and @files_ordered.
220             for my $f (@files_ordered) {
221             if ( refaddr($f) == $id ) {
222             $file = $f;
223             last;
224             }
225             }
226             return unless $file;
227             if ( exists $cache{$id}{$offset} && delete $cache_dirty{$id}{$offset} ) {
228             my $data = $cache{$id}{$offset};
229             $file->write( $offset, $data );
230             $current_cache_size -= length( delete $cache{$id}{$offset} ); # Remove from cache after flush
231             delete $cache{$id} unless keys %{ $cache{$id} }; # Clean up empty file entry
232             delete $cache_dirty{$id} unless keys %{ $cache_dirty{$id} }; # Clean up empty dirty entry
233             return 1;
234             }
235             return 0;
236             }
237              
238             method flush ( $count = undef ) {
239             my $flushed = 0;
240             for my $id ( keys %cache_dirty ) {
241             for my $offset ( keys %{ $cache_dirty{$id} } ) {
242             $self->_flush_one( $id, $offset );
243             $flushed++;
244             return $flushed if defined $count && $flushed >= $count;
245             }
246             }
247             return $flushed;
248             }
249              
250             method explicit_flush () {
251             $self->flush();
252             }
253              
254             method tick ( $delta = 0.1 ) {
255              
256             # Throttled flush: flush up to 16 items per tick
257             $self->flush(16);
258             }
259              
260             method map_abs_offset ( $root, $offset, $length ) {
261             my @segments;
262             if ( defined $root ) {
263             my $file = $files{$root};
264             if ( !$file ) {
265             $self->_emit( log => 'Unknown file root', level => 'fatal' );
266             return [];
267             }
268             push @segments, { file => $file, offset => $offset, length => $length };
269             }
270             else {
271             my $current_file_start = 0;
272             my $end = $offset + $length;
273             for my $file (@files_ordered) {
274             my $file_size = $file->size;
275             my $current_file_end = $current_file_start + $file_size;
276             if ( $offset < $current_file_end && $end > $current_file_start ) {
277             my $overlap_start = $offset > $current_file_start ? $offset : $current_file_start;
278             my $overlap_end = $end < $current_file_end ? $end : $current_file_end;
279             push @segments, { file => $file, offset => $overlap_start - $current_file_start, length => $overlap_end - $overlap_start, };
280             }
281             $current_file_start = $current_file_end;
282             last if $current_file_start >= $end;
283             }
284             }
285             return \@segments;
286             }
287              
288             method map_v1_piece ($index) {
289             if ( !$piece_size ) {
290             $self->_emit( log => 'piece_size not set', level => 'fatal' );
291             return [];
292             }
293             my $piece_start = $index * $piece_size;
294             my $piece_end = $piece_start + $piece_size;
295             my @segments;
296             my $current_v1_offset = 0;
297             for my $file (@files_ordered) {
298             my $file_size = $file->size;
299             my $padded_size = $file_size;
300             if ( $file->merkle && ( $file_size % $piece_size != 0 ) ) {
301             $padded_size += ( $piece_size - ( $file_size % $piece_size ) );
302             }
303             my $current_v1_end = $current_v1_offset + $padded_size;
304             if ( $piece_start < $current_v1_end && $piece_end > $current_v1_offset ) {
305             my $overlap_start = $piece_start > $current_v1_offset ? $piece_start : $current_v1_offset;
306             my $overlap_end = $piece_end < $current_v1_end ? $piece_end : $current_v1_end;
307             my $file_offset = $overlap_start - $current_v1_offset;
308             my $length = $overlap_end - $overlap_start;
309             if ( $file_offset < $file_size ) {
310             my $actual_len = ( $file_offset + $length > $file_size ) ? ( $file_size - $file_offset ) : $length;
311             push @segments, { file => $file, offset => $file_offset, length => $actual_len, } if $actual_len > 0;
312             }
313             }
314             $current_v1_offset = $current_v1_end;
315             last if $current_v1_offset >= $piece_end;
316             }
317             return \@segments;
318             }
319              
320             method write_piece_v1 ( $index, $data ) {
321             my $segments = $self->map_v1_piece($index);
322             $self->_emit( log => " [DEBUG] write_piece_v1: Piece $index mapped to " . scalar(@$segments) . " segments\n", level => 'debug' );
323             my $data_offset = 0;
324             for my $seg (@$segments) {
325             $self->_write_to_cache( $seg->{file}, $seg->{offset}, substr( $data, $data_offset, $seg->{length} ) );
326             $data_offset += $seg->{length};
327             }
328             }
329              
330             method read_piece_v1 ($index) {
331             my $segments = $self->map_v1_piece($index);
332             my $full_data = '';
333             for my $seg (@$segments) {
334             $full_data .= $self->_read_from_cache( $seg->{file}, $seg->{offset}, $seg->{length} );
335             }
336             return $full_data;
337             }
338              
339             method verify_piece_v1 ( $index, $data ) {
340             return undef unless $pieces_v1;
341             my $expected = substr( $pieces_v1, $index * 20, 20 );
342             return sha1($data) eq $expected;
343             }
344              
345             method map_v2_piece ($index) {
346             if ( !$piece_size ) {
347             $self->_emit( log => 'piece_size not set', level => 'fatal' );
348             return ( undef, undef );
349             }
350             my $offset = 0;
351             for my $file (@files_ordered) {
352             my $file_size = $file->size;
353             my $padded_size = $file_size;
354             if ( $file_size % $piece_size != 0 ) {
355             $padded_size += ( $piece_size - ( $file_size % $piece_size ) );
356             }
357             my $num_pieces = int( $padded_size / $piece_size );
358             my $rel_index = $index - ( $offset / $piece_size );
359             if ( $rel_index >= 0 && $rel_index < $num_pieces ) {
360             return ( $file->pieces_root, $rel_index ) if $file->pieces_root;
361             return ( undef, undef );
362             }
363             $offset += $padded_size;
364             }
365             return ( undef, undef );
366             }
367              
368             method dump_state () {
369             my %file_states;
370             for my $file (@files_ordered) {
371             my $rel = $file->path->relative($base_path)->stringify;
372             $file_states{$rel} = $file->dump_state();
373             }
374             return \%file_states;
375             }
376              
377             method load_state ($state) {
378             for my $file (@files_ordered) {
379             my $rel = $file->path->relative($base_path)->stringify;
380             if ( exists $state->{$rel} ) {
381             $file->load_state( $state->{$rel} );
382             }
383             }
384             }
385             } 1;