File Coverage

File:blib/lib/App/FileSync/Protocol.pm
Coverage:89.3%

linestmtbrancondsubtimecode
1package App::FileSync::Protocol;
2
3# One connection, strictly one request in flight at a time.  Control messages
4# are JSON on one line; binary payloads are announced by "len" in a header and
5# follow it immediately.
6
7
313
313
313
747
276
4224
use App::FileSync::Platform;
8
9
313
313
313
530
237
2477
use strict;
10
313
313
313
449
240
7012
use warnings;
11# Not autodie qw(:all): this class has methods named send, recv and close, and
12# autodie installs wrappers under those names in the package, then deletes
13# them (taking the methods with them) when the file scope ends.  Only the
14# builtins this file calls are covered.
15
313
313
313
568
259
750
use autodie qw(binmode sysread);
16
17
313
313
313
143689
635155
4524
use Compress::Raw::Zlib ();
18
313
313
313
797
272
2389
use IO::Handle ();
19
313
313
313
486
261
7613
use Readonly;
20
313
313
313
589
285
1061
use Sub::Private { mode => 'enforce' };
21
22
313
313
313
34796
276
1011
use parent -norequire, 'App::FileSync::Base';
23
313
313
313
6270
246
3092
use App::FileSync::Base;
24
25
313
313
313
527
288
6845
use App::FileSync qw(json_encode json_decode);
26
313
313
313
598
319
5234
use App::FileSync::Args qw(named);
27
313
313
313
521
286
5021
use App::FileSync::I18N qw(os_error strip_location);
28
29
313
313
313
590
327
712
use version; our $VERSION = version->declare('0.001.1');
30
31# Payloads are raw-deflated when that makes them smaller.  Small payloads and
32# already-compressed data are left alone, so the flag costs more than it saves.
33our $COMPRESS_MIN = 256;
34our $MAX_WIRE     = 64 * 1024 * 1024;
35our $MAX_INFLATED = 64 * 1024 * 1024;
36
37# zlib level: the default trade-off between speed and size.
38Readonly my $DEFLATE_LEVEL => 6;
39
40# Bytes asked of the pipe per sysread.
41Readonly my $READ_SIZE => 65_536;
42
43=encoding utf8
44
45 - 110
=head1 NAME

App::FileSync::Protocol - the line-delimited JSON wire protocol

=head1 SYNOPSIS

        my $connection = App::FileSync::Protocol->new(in => $reader, out => $writer);
        $connection->send({ cmd => 'hello', proto => 2 });
        my $reply = $connection->recv();
        $connection->send_data({ cmd => 'chunk', p => $path }, $bytes);

=head1 DESCRIPTION

Each control message is one JSON object on one line.  A message announcing a
payload carries C<len> (the bytes on the wire) and, when the payload was
deflated, C<z>; the payload follows the line directly.  The connection counts
payload bytes in each direction for the transfer report.

When reading or writing fails because the pipe itself failed (the peer exited,
ssh was refused), L</transport_failed> becomes true, so a caller can show the
transport's own diagnostics instead of a protocol error.

=head1 METHODS

=head2 new

Purpose: wrap a pair of handles as a protocol connection.

Args: C<in> and C<out> (handles), optional C<language>.

Returns: the connection.

Side Effects: puts both handles in binary mode and makes C<out> autoflush.

Usage:

        my $connection = App::FileSync::Protocol->new(in => \*STDIN, out => \*STDOUT);

=head3 EXAMPLE

        my $connection = App::FileSync::Protocol->new(in => $transport->reader, out => $transport->writer);

=head3 API SPECIFICATION

=head4 INPUT

        {
                in       => { type => 'handle' },
                out      => { type => 'handle' },
                language => { type => 'string', optional => 1 },
        }

=head4 OUTPUT

        { type => 'object', isa => 'App::FileSync::Protocol' }

=head3 MESSAGES

        Key            Meaning                       Resolution
        args.invalid   a handle is missing           pass in and out

=head3 FORMAL SPECIFICATION

        new(in, out) = ⟨in, out, buffer ↦ ⟨⟩, closed ↦ false, sent ↦ 0, received ↦ 0⟩

=cut
111
112sub new {
113
347
1101
        my $class = shift;
114
347
3565
        my $args = named(\@_, {
115                in       => { type => 'handle' },
116                out      => { type => 'handle' },
117                language => { type => 'string', optional => 1 },
118        }, 'Protocol->new');
119
120
347
4806
        binmode $args->{in};
121
347
99234
        binmode $args->{out};
122
347
9881
        $args->{out}->autoflush(1);
123
124        return bless {
125                in               => $args->{in},
126                out              => $args->{out},
127                language         => $args->{language},
128
347
16390
                buffer           => '',
129                closed           => 0,
130                transport_failed => 0,
131                # Payload bytes as they crossed the wire, headers and control
132                # messages excluded: this is the number a transfer report means.
133                sent_bytes       => 0,
134                received_bytes   => 0,
135        }, $class;
136}
137
138 - 180
=head2 send

Purpose: send one control message.

Args: C<\%message>.

Returns: 1.

Side Effects: writes one line.

Usage:

        $connection->send({ cmd => 'plan' });

=head3 EXAMPLE

        $connection->send({ ok => 1, off => $offset });

=head3 API SPECIFICATION

=head4 INPUT

        [
                { name => 'self',    type => 'object' },
                { name => 'message', type => 'hashref' },
        ]

=head4 OUTPUT

        { type => 'integer', min => 1, max => 1 }

=head3 MESSAGES

        Key                       Meaning                          Resolution
        protocol.closed           the connection was closed        a caller bug
        protocol.write_failed     the pipe would not take it       see the transport's stderr

=head3 FORMAL SPECIFICATION

        pre ¬closed
        out' = out ⁀ json(message) ⁀ "\n"

=cut
181
182sub send {      ## no critic (Subroutines::ProhibitBuiltinHomonyms)
183
2988
9134
        my ($self, $message) = @_;
184
185
2988
4635
        $self->_write(json_encode($message) . "\n");
186
2985
3403
        return 1;
187}
188
189 - 231
=head2 recv

Purpose: read one control message.

Args: none.

Returns: the decoded message hashref.

Side Effects: reads from C<in>; blocks until a whole line arrives.

Usage:

        my $reply = $connection->recv();

=head3 EXAMPLE

        my $hello = $connection->recv();
        die unless $hello->{ok};

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'self', type => 'object' } ]

=head4 OUTPUT

        { type => 'hashref' }

=head3 MESSAGES

        Key                   Meaning                        Resolution
        protocol.malformed    the line is not JSON           version mismatch or corrupt stream
        protocol.not_object   the JSON is not an object      version mismatch
        protocol.eof          the peer closed the connection see the peer's log
        protocol.read_failed  the pipe failed                see the transport's stderr

=head3 FORMAL SPECIFICATION

        in = line ⁀ "\n" ⁀ rest ⇒ recv = json⁻¹(line) ∧ in' = rest
        pre json⁻¹(line) ∈ OBJECT

=cut
232
233sub recv {      ## no critic (Subroutines::ProhibitBuiltinHomonyms)
234
2988
3001
        my ($self) = @_;
235
236
2988
3965
        my $line = $self->read_line();
237
2983
2983
2627
5059
        my $message = eval { json_decode($line) };
238
2983
3459
        $self->_throw('protocol.malformed', { error => strip_location($@) }) if $@;
239
2983
3339
        $self->_throw('protocol.not_object') unless ref($message) eq 'HASH';
240
241
2983
4054
        return $message;
242}
243
244 - 287
=head2 send_bytes

Purpose: write raw bytes (the payload after a header).

Args: C<$bytes>; empty or undef writes nothing.

Returns: 1.

Side Effects: writes to C<out>.  Not counted in L</bytes_sent>; use
L</send_data> for counted payloads.

Usage:

        $connection->send_bytes($payload);

=head3 EXAMPLE

        $connection->send({ len => length $bytes });
        $connection->send_bytes($bytes);

=head3 API SPECIFICATION

=head4 INPUT

        [
                { name => 'self',  type => 'object' },
                { name => 'bytes', type => 'string', optional => 1 },
        ]

=head4 OUTPUT

        { type => 'integer', min => 1, max => 1 }

=head3 MESSAGES

        Key                       Meaning                          Resolution
        protocol.closed           the connection was closed        a caller bug
        protocol.write_failed     the pipe would not take it       see the transport's stderr

=head3 FORMAL SPECIFICATION

        out' = out ⁀ bytes

=cut
288
289sub send_bytes {
290
280
329
        my ($self, $bytes) = @_;
291
292
280
983
        $self->_write($bytes) if defined $bytes && length $bytes;
293
280
216
        return 1;
294}
295
296 - 339
=head2 send_data

Purpose: send a header and its payload, compressing when worthwhile.

Args: C<\%header>, C<$bytes>.

Returns: 1.

Side Effects: writes the header (with C<len> and, if compressed, C<z>) and the
payload; adds the wire length to L</bytes_sent>.

Usage:

        $connection->send_data({ cmd => 'chunk', p => $path }, $bytes);

=head3 EXAMPLE

        $connection->send_data({ ok => 1, eof => 1 }, $last_chunk);

=head3 API SPECIFICATION

=head4 INPUT

        [
                { name => 'self',    type => 'object' },
                { name => 'message', type => 'hashref', optional => 1 },
                { name => 'bytes',   type => 'string', optional => 1 },
        ]

=head4 OUTPUT

        { type => 'integer', min => 1, max => 1 }

=head3 MESSAGES

As for L</send>.

=head3 FORMAL SPECIFICATION

        let (w, z) = deflate(bytes)
        out' = out ⁀ json(header ⊕ {len ↦ #w} ⊕ (z ⇒ {z ↦ 1})) ⁀ "\n" ⁀ w
        sent' = sent + #w

=cut
340
341sub send_data {
342
280
2377
        my ($self, $message, $bytes) = @_;
343
344
280
280
249
765
        my ($wire, $compressed) = @{ _deflate(defined $bytes ? $bytes : '') };
345
346        # The header carries the wire length and a "z" flag, so a peer that does
347        # not know the flag rejects the frame rather than misreading it.
348
280
280
290
651
        my %header = %{ $message || {} };
349
280
442
        $header{len} = length $wire;
350
280
359
        $header{z}   = 1 if $compressed;
351
352
280
465
        $self->send(\%header);
353
280
523
        $self->send_bytes($wire);
354
280
330
        $self->{sent_bytes} += length $wire;
355
356
280
498
        return 1;
357}
358
359 - 406
=head2 read_data

Purpose: read the payload a header announced.

Args: C<\%header> as returned by L</recv>.

Returns: the payload, decompressed if it was sent compressed.

Side Effects: reads C<len> bytes; adds them to L</bytes_received>.

Usage:

        my $bytes = $connection->read_data($header);

=head3 EXAMPLE

        my $reply = $connection->recv();
        my $bytes = $connection->read_data($reply);

=head3 API SPECIFICATION

=head4 INPUT

        [
                { name => 'self',    type => 'object' },
                { name => 'message', type => 'hashref' },
        ]

=head4 OUTPUT

        { type => 'string' }

=head3 MESSAGES

        Key                      Meaning                                 Resolution
        protocol.bad_length      len is not a number or exceeds 64 MiB   a peer bug
        protocol.inflate         the payload cannot be decompressed      a corrupt stream
        protocol.corrupt         the compressed payload is corrupt       a corrupt stream
        protocol.too_large       it would inflate beyond 64 MiB          a hostile or buggy peer

=head3 FORMAL SPECIFICATION

        pre header.len ∈ 0 .. MAX_WIRE
        read_data(h) = inflate(take(h.len, in))   if h.z
                     = take(h.len, in)            otherwise
        received' = received + h.len

=cut
407
408sub read_data {
409
280
1010
        my ($self, $message) = @_;
410
411
280
457
        my $length = defined $message->{len} ? $message->{len} : 0;
412
280
989
        $self->_throw('protocol.bad_length')
413                unless $length =~ /\A\d+\z/ && $length <= $MAX_WIRE;
414
415
280
540
        my $wire = $length ? $self->read_bytes($length) : '';
416
280
351
        $self->{received_bytes} += length $wire;
417
418
280
704
        return $message->{z} ? $self->_inflate($wire) : $wire;
419}
420
421 - 469
=head2 read_line

Documented with L</read_bytes> below.

=head2 read_bytes

Purpose: read up to the next newline, or exactly C<$length> bytes.

Args: C<read_bytes($length)>.

Returns: the line (without its newline) or the bytes.

Side Effects: consumes them from the buffer, reading from C<in> as needed.

Usage:

        my $line = $connection->read_line();
        my $bytes = $connection->read_bytes(1024);

=head3 EXAMPLE

        my $payload = $connection->read_bytes($header->{len});

=head3 API SPECIFICATION

=head4 INPUT

        [
                { name => 'self',   type => 'object' },
                { name => 'length', type => 'integer', min => 0 },
        ]

=head4 OUTPUT

        { type => 'string' }

=head3 MESSAGES

        Key                       Meaning                          Resolution
        protocol.negative         a negative length was asked for  a caller bug
        protocol.eof              the peer closed the connection   see the peer's log
        protocol.read_failed      the pipe failed                  see the transport's stderr

=head3 FORMAL SPECIFICATION

        read_line: in = l ⁀ "\n" ⁀ r ∧ "\n" ∉ ran l ⇒ result = l ∧ in' = r
        read_bytes(n): in = b ⁀ r ∧ #b = n ⇒ result = b ∧ in' = r

=cut
470
471sub read_line {
472
2988
2412
        my ($self) = @_;
473
474
2988
2069
        my $position;
475
2988
6713
        $self->_fill() while ($position = index($self->{buffer}, "\n")) < 0;
476
477
2983
4548
        my $line = substr($self->{buffer}, 0, $position);
478
2983
3849
        substr($self->{buffer}, 0, $position + 1, '');
479
480
2983
3705
        return $line;
481}
482
483sub read_bytes {
484
279
375
        my ($self, $length) = @_;
485
486
279
315
        $self->_throw('protocol.negative') if $length < 0;
487
279
600
        $self->_fill() while length($self->{buffer}) < $length;
488
489
279
524
        return substr($self->{buffer}, 0, $length, '');
490}
491
492 - 532
=head2 bytes_sent

Documented with L</bytes_received> below.

=head2 bytes_received

Purpose: payload bytes that crossed the wire in each direction.

Args: none.

Returns: a byte count (headers and control messages excluded).

Side Effects: none.

Usage:

        printf "sent %d bytes\n", $connection->bytes_sent;

=head3 EXAMPLE

        my %summary = (bytes_sent => $connection->bytes_sent, bytes_received => $connection->bytes_received);

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'self', type => 'object' } ]

=head4 OUTPUT

        { type => 'integer', min => 0 }

=head3 MESSAGES

None.

=head3 FORMAL SPECIFICATION

        bytes_sent = sent ; bytes_received = received

=cut
533
534sub bytes_sent {
535
150
236
        my ($self) = @_;
536
537
150
636
        return $self->{sent_bytes} || 0;
538}
539
540sub bytes_received {
541
150
230
        my ($self) = @_;
542
543
150
445
        return $self->{received_bytes} || 0;
544}
545
546 - 583
=head2 transport_failed

Purpose: whether the last failure was the pipe rather than the protocol.

Args: none.

Returns: 1 after a read or write on the underlying handles failed, or the peer
closed the connection; otherwise 0.

Side Effects: none.

Usage:

        my $detail = $connection->transport_failed ? $transport->error_text : undef;

=head3 EXAMPLE

        eval { $connection->recv() } or print "pipe failed\n" if $connection->transport_failed;

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'self', type => 'object' } ]

=head4 OUTPUT

        { type => 'integer', min => 0, max => 1 }

=head3 MESSAGES

None.

=head3 FORMAL SPECIFICATION

        transport_failed ⇔ ∃ an I/O failure or end of file on in or out

=cut
584
585sub transport_failed {
586
9
31
        my ($self) = @_;
587
588
9
71
        return $self->{transport_failed} ? 1 : 0;
589}
590
591 - 628
=head2 close

Purpose: close both handles.

Args: none.

Returns: 1, or nothing if already closed.

Side Effects: closes C<in> and C<out>; later sends raise C<protocol.closed>.

Usage:

        $connection->close;

=head3 EXAMPLE

        $connection->send({ cmd => 'done' });
        $connection->close;

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'self', type => 'object' } ]

=head4 OUTPUT

        { type => 'integer', optional => 1 }

=head3 MESSAGES

None; close errors are ignored, as the session is over.

=head3 FORMAL SPECIFICATION

        closed' = true

=cut
629
630sub close {     ## no critic (Subroutines::ProhibitBuiltinHomonyms, Subroutines::ProhibitAmbiguousNames)
631
0
        my ($self) = @_;
632
633
0
        return if $self->{closed};
634
0
        $self->{closed} = 1;
635
636        # The session is over; a pipe that reports an error on close has nothing
637        # left to say that a caller could act on, so the result is not checked.
638
0
        CORE::close($self->{in});
639
0
        CORE::close($self->{out});
640
641
0
        return 1;
642}
643
644# _write
645# Purpose:     put bytes on the wire.
646# Entry:       the connection is open.
647# Exit:        returns 1.
648# Side effects: writes to out; on failure marks the transport as failed and
649#              raises protocol.write_failed.  print is outside autodie.
650sub _write :Private {
651        my ($self, $bytes) = @_;
652
653        $self->_throw('protocol.closed') if $self->{closed};
654        unless (print { $self->{out} } $bytes) {
655                my $error = "$!";
656                $self->{transport_failed} = 1;
657                $self->_throw('protocol.write_failed', { error => $error });
658        }
659
660        return 1;
661
313
313
313
173333
294
4198
}
662
663# _fill
664# Purpose:     read more bytes from the peer into the buffer.
665# Entry:       the caller needs more than the buffer holds.
666# Exit:        returns 1 after appending at least one byte.
667# Side effects: on end of file or a read error marks the transport as failed
668#              (and closed, for end of file) and raises.
669sub _fill :Private {
670        my ($self) = @_;
671
672        my $chunk;
673        my $read = eval { sysread($self->{in}, $chunk, $READ_SIZE) };
674        unless (defined $read) {
675                my $error = os_error($@);
676                $self->{transport_failed} = 1;
677                $self->_throw('protocol.read_failed', { error => $error });
678        }
679        if ($read == 0) {
680                @{$self}{qw(closed transport_failed)} = (1, 1);
681                $self->_throw('protocol.eof');
682        }
683
684        $self->{buffer} .= $chunk;
685        return 1;
686
313
313
313
75260
299
2755
}
687
688# _deflate
689# Purpose:     compress a payload when that makes it smaller.
690# Entry:       $bytes may be empty.
691# Exit:        returns [ $wire_bytes, $compressed_flag ].
692# Side effects: none.  Any zlib failure just sends the bytes as they are.
693sub _deflate :Private {
694        my ($bytes) = @_;
695
696        return [ $bytes, 0 ] if length($bytes) < $COMPRESS_MIN;
697
698        my ($deflater, $status) = Compress::Raw::Zlib::Deflate->new(
699                -WindowBits => -Compress::Raw::Zlib::MAX_WBITS(),
700                -Level      => $DEFLATE_LEVEL,
701        );
702        my $ok = $deflater && $status == Compress::Raw::Zlib::Z_OK();
703
704        my ($out, $tail) = ('', '');
705        $ok &&= $deflater->deflate($bytes, $out) == Compress::Raw::Zlib::Z_OK();
706        $ok &&= $deflater->flush($tail) == Compress::Raw::Zlib::Z_OK();
707        $out .= $tail;
708
709        return ($ok && length($out) < length($bytes)) ? [ $out, 1 ] : [ $bytes, 0 ];
710
313
313
313
71223
269
2593
}
711
712# _inflate
713# Purpose:     decompress a payload sent with "z".
714# Entry:       $wire is raw deflate data.
715# Exit:        returns the original bytes.
716# Side effects: raises protocol.inflate, protocol.corrupt or protocol.too_large.
717sub _inflate :Private {
718        my ($self, $wire) = @_;
719
720        my ($inflater, $status) = Compress::Raw::Zlib::Inflate->new(
721                -WindowBits => -Compress::Raw::Zlib::MAX_WBITS(),
722        );
723        $self->_throw('protocol.inflate')
724                unless $inflater && $status == Compress::Raw::Zlib::Z_OK();
725
726        my $out = '';
727        $status = $inflater->inflate($wire, $out);
728        $self->_throw('protocol.corrupt')
729                unless $status == Compress::Raw::Zlib::Z_OK() || $status == Compress::Raw::Zlib::Z_STREAM_END();
730        $self->_throw('protocol.too_large') if length($out) > $MAX_INFLATED;
731
732        return $out;
733
313
313
313
67113
310
2528
}
734
7351;
736