| File: | blib/lib/App/FileSync/Server.pm |
| Coverage: | 96.5% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::FileSync::Server; | |||||
| 2 | ||||||
| 3 | # The server end of one sync session. It is started by sshd (or the local | |||||
| 4 | # transport) with the repo, client and role pinned on its command line, speaks | |||||
| 5 | # the lock-step protocol on its connection, and only ever does what its own | |||||
| 6 | # plan allows: a client cannot write, read, delete or acknowledge a path the | |||||
| 7 | # server did not plan for it. | |||||
| 8 | ||||||
| 9 | 300 300 300 | 1709 243 4263 | use App::FileSync::Platform; | |||
| 10 | ||||||
| 11 | 300 300 300 | 574 247 2745 | use strict; | |||
| 12 | 300 300 300 | 436 226 7376 | use warnings; | |||
| 13 | 300 300 300 | 546 221 864 | use autodie qw(:all); | |||
| 14 | ||||||
| 15 | 300 300 300 | 649467 341 2347 | use Digest::SHA (); | |||
| 16 | 300 300 300 | 526 278 2635 | use File::Temp (); | |||
| 17 | 300 300 300 | 502 230 7905 | use List::Util qw(any); | |||
| 18 | 300 300 300 | 598 242 6336 | use Readonly; | |||
| 19 | 300 300 300 | 574 264 1146 | use Sub::Private { mode => 'enforce' }; | |||
| 20 | 300 300 300 | 36852 270 2640 | use Time::HiRes (); | |||
| 21 | ||||||
| 22 | 300 300 300 | 489 222 935 | use parent 'App::FileSync::Base'; | |||
| 23 | ||||||
| 24 | 300 300 300 | 8826 254 6941 | use App::FileSync qw(validate_relpath validate_client_id validate_repo_name name_with_suffix); | |||
| 25 | 300 300 300 | 524 237 6643 | use App::FileSync::Args qw(named); | |||
| 26 | 300 | 20851 | use App::FileSync::Constants qw( | |||
| 27 | $PROTOCOL_VERSION $ROLE_CLIENT @ROLES $TYPE_FILE $TYPE_DIR | |||||
| 28 | $MODE_MASK $DEFAULT_FILE_MODE $MAX_CHUNK_BYTES $SHA256_HEX | |||||
| 29 | $EXIT_OK $EXIT_FATAL $EXIT_LOCKED $EXIT_WARNINGS $SERVER_PROGRAM | |||||
| 30 | 300 300 | 518 308 | ); | |||
| 31 | 300 300 300 | 648 270 3737 | use App::FileSync::Delta; | |||
| 32 | 300 300 300 | 481 235 5378 | use App::FileSync::I18N qw(message os_error strip_location); | |||
| 33 | 300 300 300 | 50686 388 4134 | use App::FileSync::Merge; | |||
| 34 | 300 300 300 | 662 243 3017 | use App::FileSync::Protocol; | |||
| 35 | 300 300 300 | 555 259 2427 | use App::FileSync::Settings; | |||
| 36 | 300 300 300 | 489 225 2281 | use App::FileSync::Store; | |||
| 37 | ||||||
| 38 | 300 300 300 | 427 245 540 | use version; our $VERSION = version->declare('0.001.1'); | |||
| 39 | ||||||
| 40 | # Package variables for compatibility: the CLI checks $PROTO, and tests may | |||||
| 41 | # localise $CHUNK. | |||||
| 42 | our $CHUNK = 262_144; | |||||
| 43 | our $PROTO = $PROTOCOL_VERSION; | |||||
| 44 | ||||||
| 45 | # Bounds on a pull signature the client uploads, so a hostile client cannot | |||||
| 46 | # make the server allocate or hash without limit. | |||||
| 47 | Readonly my $MAX_SIGNATURE_BLOCKS => 16_384; | |||||
| 48 | Readonly my $MIN_SIGNATURE_BLOCK => 1024; | |||||
| 49 | Readonly my $MAX_SIGNATURE_BLOCK => 1024 * 1024; | |||||
| 50 | ||||||
| 51 | # Wire cost of one delta record, for deciding whether a delta is worth it; | |||||
| 52 | # the constant covers the reply headers. | |||||
| 53 | Readonly my $DELTA_OP_BYTES => 12; | |||||
| 54 | Readonly my $DELTA_OVERHEAD => 64; | |||||
| 55 | ||||||
| 56 | # Unsigned decimal integers, and mtimes (which may be fractional or, for a | |||||
| 57 | # file dated before 1970, negative). | |||||
| 58 | Readonly my $UINT => qr/\A\d+\z/; | |||||
| 59 | Readonly my $MTIME => qr/\A-?\d+(?:\.\d+)?\z/; | |||||
| 60 | ||||||
| 61 | # The protocol's commands and the method that serves each. | |||||
| 62 | Readonly my %COMMANDS => ( | |||||
| 63 | manifest => '_manifest', | |||||
| 64 | plan => '_plan', | |||||
| 65 | get => '_get', | |||||
| 66 | put => '_put', | |||||
| 67 | chunk => '_chunk', | |||||
| 68 | chunk_end => '_chunk_end', | |||||
| 69 | sig => '_signature', | |||||
| 70 | delta => '_delta_start', | |||||
| 71 | dcopy => '_delta_copy', | |||||
| 72 | dlit => '_delta_literal', | |||||
| 73 | delta_end => '_delta_end', | |||||
| 74 | dsig => '_pull_signature', | |||||
| 75 | dop => '_pull_op', | |||||
| 76 | mkdir => '_mkdir', | |||||
| 77 | del => '_delete', | |||||
| 78 | ack => '_ack', | |||||
| 79 | done => '_finish', | |||||
| 80 | ); | |||||
| 81 | ||||||
| 82 | # Which planned actions each kind of request may serve. "conflict:client" | |||||
| 83 | # is a conflict the client's version won; "conflict:remote" one the master's won. | |||||
| 84 | Readonly my %MAY => ( | |||||
| 85 | read => [ 'pull', 'conflict:remote' ], | |||||
| 86 | write => [ 'push', 'conflict:client' ], | |||||
| 87 | delta => [ 'push' ], | |||||
| 88 | pull => [ 'pull' ], | |||||
| 89 | mkdir => [ 'push', 'conflict:client' ], | |||||
| 90 | delete => [ 'delete_remote' ], | |||||
| 91 | ack => [ 'pull', 'delete_local', 'conflict:remote' ], | |||||
| 92 | ); | |||||
| 93 | ||||||
| 94 | # The flags a forced command may carry, and the pinned field each must match. | |||||
| 95 | Readonly my %FORCED_FLAGS => (repo => 'repo', client => 'client', role => 'role', proto => 'proto'); | |||||
| 96 | ||||||
| 97 | =encoding utf8 | |||||
| 98 | ||||||
| 99 - 174 | =head1 NAME
App::FileSync::Server - serve one sync session against a repo's master
=head1 SYNOPSIS
my $server = App::FileSync::Server->new(
root => '/srv/filesync', repo => 'docs', client => 'laptop', role => 'client',
connection => App::FileSync::Protocol->new(in => \*STDIN, out => \*STDOUT),
);
exit($server->run ? 0 : 1);
=head1 DESCRIPTION
A session is: hello, manifest, plan, then the transfers the plan lists, then
C<done>. The server holds the repo lock throughout, so sessions on one repo
never interleave. It updates the master index and the client's base only in
C<done>, after every planned action has been applied; a session that dies part
way leaves both as they were, and the next session replans from the tree.
=head1 METHODS
=head2 new
Purpose: prepare a session.
Args: C<root>, C<repo>, C<client>, C<role> (C<client> or C<replica>),
C<connection> (an L<App::FileSync::Protocol>), optional C<logger> and
C<language>.
Returns: the server object.
Side Effects: builds a logger from the settings when none is given.
Usage:
my $server = App::FileSync::Server->new(%pinned, connection => $connection);
=head3 EXAMPLE
my $server = App::FileSync::Server->new(
root => $root, repo => 'docs', client => 'laptop', role => 'client', connection => $conn,
);
=head3 API SPECIFICATION
=head4 INPUT
{
root => { type => 'string', min => 1 },
repo => { type => 'string', min => 1, max => 64 },
client => { type => 'string', min => 1, max => 64 },
role => { type => 'string', memberof => [ 'client', 'replica' ] },
connection => { type => 'object', can => [ 'send', 'recv' ] },
logger => { type => 'object', optional => 1 },
language => { type => 'string', optional => 1 },
}
=head4 OUTPUT
{ type => 'object', isa => 'App::FileSync::Server' }
=head3 MESSAGES
Key Meaning Resolution
server.missing a required argument is absent pass it
server.bad_role role is not client or replica fix the forced command
server.bad_id repo or client is not an id fix the forced command
server.no_connection no connection was given pass connection
=head3 FORMAL SPECIFICATION
pre root â "" â§ repo â Id â§ client â Id â§ role â {client, replica} â§ connection â â¥
post finished = 0 â§ plan = â
â§ uploads = â
=cut | |||||
| 175 | ||||||
| 176 | sub new { | |||||
| 177 | 172 | 218 | my $class = shift; | |||
| 178 | 172 | 1394 | my $args = named(\@_, { | |||
| 179 | root => { type => 'string', optional => 1 }, | |||||
| 180 | repo => { type => 'string', optional => 1 }, | |||||
| 181 | client => { type => 'string', optional => 1 }, | |||||
| 182 | role => { type => 'string', optional => 1 }, | |||||
| 183 | connection => { type => 'object', optional => 1 }, | |||||
| 184 | logger => { type => 'object', optional => 1 }, | |||||
| 185 | language => { type => 'string', optional => 1 }, | |||||
| 186 | }, 'Server->new'); | |||||
| 187 | ||||||
| 188 | # Checked here rather than by the schema so the messages read as a | |||||
| 189 | # server refusal rather than an argument error. | |||||
| 190 | 172 | 571 | for my $required (qw(root repo client role)) { | |||
| 191 | App::FileSync::I18N::throw('server.missing', { what => $required }) | |||||
| 192 | 688 | 1253 | unless defined $args->{$required} && length $args->{$required}; | |||
| 193 | } | |||||
| 194 | App::FileSync::I18N::throw('server.bad_role', { role => $args->{role} }) | |||||
| 195 | 172 200 | 624 766 | unless any { $_ eq $args->{role} } @ROLES; | |||
| 196 | ||||||
| 197 | 172 | 1229 | for my $check ([ repo => \&validate_repo_name ], [ client => \&validate_client_id ]) { | |||
| 198 | 344 344 | 307 419 | my ($field, $validate) = @{$check}; | |||
| 199 | 344 | 810 | my ($ok, $reason) = $validate->($args->{$field}); | |||
| 200 | 344 | 514 | App::FileSync::I18N::throw('server.bad_id', { reason => $reason }) unless $ok; | |||
| 201 | } | |||||
| 202 | 172 | 1008 | App::FileSync::I18N::throw('server.no_connection') unless $args->{connection}; | |||
| 203 | ||||||
| 204 | return bless { | |||||
| 205 | 860 | 2482 | (map { $_ => $args->{$_} } qw(root repo client role language)), | |||
| 206 | conn => $args->{connection}, | |||||
| 207 | 172 | 290 | logger => $args->{logger} || App::FileSync::Settings::logger(), | |||
| 208 | dry_run => 0, | |||||
| 209 | force => 0, | |||||
| 210 | finished => 0, | |||||
| 211 | uploads => {}, | |||||
| 212 | pull_deltas => {}, | |||||
| 213 | client_manifest => {}, | |||||
| 214 | plan => {}, | |||||
| 215 | index => {}, | |||||
| 216 | base => {}, | |||||
| 217 | base_after => {}, | |||||
| 218 | applied => {}, | |||||
| 219 | }, $class; | |||||
| 220 | } | |||||
| 221 | ||||||
| 222 - 316 | =head2 run
Purpose: serve the whole session.
Args: none.
Returns: 1 when the session finished, 0 when it failed (the client has been
sent the error).
Side Effects: takes the repo lock; reads and writes the master, its index, the
client's base, the trash and the journal; prunes the trash and removes stale
temporaries. A failed session is journalled (exit 2 for a lock refusal,
otherwise 1) unless it never got as far as opening the repo.
Usage:
exit($server->run ? 0 : 1);
=head3 EXAMPLE
my $ok = $server->run;
warn "session failed\n" unless $ok;
=head3 API SPECIFICATION
=head4 INPUT
[ { name => 'self', type => 'object' } ]
=head4 OUTPUT
{ type => 'integer', memberof => [ 0, 1 ] }
=head3 MESSAGES
Key Meaning Resolution
server.locked another session holds the lock the client retries later
server.not_allowed the client is not registered filesync-admin client allow
server.wrong_role the client is registered otherwise fix the key's --role
server.proto the client speaks another protocol upgrade one side
server.pinned the hello disagrees with the pin fix the client config
server.unknown an unknown command arrived upgrade one side
Raised once the session is open. Each ends the session: the open uploads
are discarded, the failure is journalled, and the text (without Perl's
file and line) reaches the client as C<< { ok => 0, fatal => 1, error => ... } >>.
Most mean a client bug or a hostile client; the resolution is to fix the
client, or simply to rerun the sync.
Key Meaning
server.manifest_list the manifest is not a list
server.manifest_entry a manifest entry is not an object
server.manifest_path a manifest path is unsafe
server.manifest_field a manifest t, h, s, m or mode field is malformed
server.refused the deletion guard stopped the plan (rerun with --force if intended)
server.unplanned an operation names a path that is not in the plan
server.not_planned the path's planned action does not allow this operation
server.bad_field an off, max, len, s, mode or h field is malformed
server.conflict_name the conflict copy is named other than the plan says
server.conflict_unsafe the planned conflict name is not a safe path
server.conflict_same the planned conflict name equals the path
server.conflict_exists something already exists at the conflict name
server.conflict_move the master's copy could not be moved aside
server.bad_ack an ack disagrees with the planned action
server.busy a second put for a path already being received
server.no_put chunk or chunk_end without a put
server.too_large one chunk is larger than the chunk limit
server.overflow the upload is larger than its announced size
server.hash_mismatch the received content does not have the announced hash
server.size_mismatch the received content does not have the announced size
server.no_delta delta data without a delta start
server.bad_copy a delta copy reaches outside the base file
server.bad_signature a pull signature is malformed
server.no_signature a pull delta operation without a signature
server.cannot_stat a planned file vanished from the master
server.not_file a get for a path that is a directory
server.unfinished done arrived before every planned action completed
=head3 FORMAL SPECIFICATION
ÎFS
run = 1 â hello ⨾ open ⨾ lock ⨾ loop ⨾ done succeed
run = 0 â index' = index â§ base' = base
=head3 PSEUDOCODE
read and check the hello against the pinned arguments
open the repo; check the client is allowed in its role
take the lock (refuse with code "locked" otherwise)
load the index and the client's base; tidy temporaries and the trash
send the policy, then serve commands until done
on any error: log it, journal the failed session, discard partial
uploads, and send the error to the client
=cut | |||||
| 317 | ||||||
| 318 | sub run { | |||||
| 319 | 172 | 259 | my ($self) = @_; | |||
| 320 | ||||||
| 321 | 172 | 457 | my $connection = $self->{conn}; | |||
| 322 | ||||||
| 323 | 172 | 215 | my $ok = eval { | |||
| 324 | 172 | 491 | $self->_validate_hello($connection->recv()); | |||
| 325 | ||||||
| 326 | my $store = $self->{store} = App::FileSync::Store->new( | |||||
| 327 | root => $self->{root}, repo => $self->{repo}, language => $self->{language}, | |||||
| 328 | 171 | 1278 | ); | |||
| 329 | 171 | 490 | $self->_check_client_allowed(); | |||
| 330 | ||||||
| 331 | 170 | 489 | $self->{lock} = $store->acquire_lock(); | |||
| 332 | 170 | 351 | unless ($self->{lock}) { | |||
| 333 | 3 | 6 | $self->{refusal} = 'locked'; | |||
| 334 | 3 | 24 | $self->_throw('server.locked', { repo => $self->{repo} }); | |||
| 335 | } | |||||
| 336 | ||||||
| 337 | 167 | 472 | $self->{index} = $store->load_index(); | |||
| 338 | 167 | 712 | $self->{base} = $store->load_base($self->{client}); | |||
| 339 | 167 | 556 | $store->clean_stale_temps(); | |||
| 340 | 167 | 600 | $store->prune_trash() unless $self->{dry_run}; | |||
| 341 | ||||||
| 342 | 167 501 | 414 1329 | $self->{logger}->info('session opened', { map { $_ => $self->{$_} } qw(repo client role) }); | |||
| 343 | ||||||
| 344 | 167 | 6379 | my $policy = $store->config(); | |||
| 345 | $connection->send({ | |||||
| 346 | ok => 1, | |||||
| 347 | proto => $PROTO, | |||||
| 348 | server => { os => $^O }, | |||||
| 349 | repo => { name => $self->{repo} }, | |||||
| 350 | 167 668 | 694 1475 | policy => { map { $_ => $policy->{$_} } qw(ignore trash_days max_deletes max_delete_percent) }, | |||
| 351 | }); | |||||
| 352 | ||||||
| 353 | 167 | 715 | $self->_loop(); | |||
| 354 | 149 | 268 | 1; | |||
| 355 | }; | |||||
| 356 | ||||||
| 357 | 172 | 1045 | return 1 if $ok; | |||
| 358 | ||||||
| 359 | # The client shows this text to a person, so no Perl file and line. | |||||
| 360 | 23 | 59 | my $error = strip_location($@) || $self->i18n('server.unknown_error'); | |||
| 361 | 23 | 97 | $self->{logger}->error($error); | |||
| 362 | 23 | 6318 | $self->_discard_uploads(); | |||
| 363 | 23 | 64 | $self->_journal_failed_session($error); | |||
| 364 | ||||||
| 365 | 23 | 25 | eval { | |||
| 366 | 23 | 122 | $connection->send({ ok => 0, error => $error, fatal => 1, ($self->{refusal} ? (code => $self->{refusal}) : ()) }); | |||
| 367 | }; | |||||
| 368 | 23 | 86 | return 0; | |||
| 369 | } | |||||
| 370 | ||||||
| 371 - 438 | =head2 validate_forced_command
Purpose: check the command a client asked sshd to run against the arguments
pinned in F<authorized_keys>.
The pinned arguments are the authority for a session. sshd passes the command
the client asked for in C<SSH_ORIGINAL_COMMAND>; it must agree with the pinned
repo, client, role and protocol and ask for nothing else: in particular it may
not choose its own C<--root>.
Args: C<$original> (the requested command; undef or blank is accepted),
C<\%pinned> (C<repo>, C<client>, C<role>).
Returns: C<(1, undef)> or C<(0, $reason)>. A list, deliberately: callers
write C<my ($ok, $why) = ...>, matching L<App::FileSync/validate_relpath>.
Side Effects: none.
Usage:
my ($ok, $reason) = App::FileSync::Server::validate_forced_command($ENV{SSH_ORIGINAL_COMMAND}, \%pinned);
=head3 EXAMPLE
my ($ok, $why) = App::FileSync::Server::validate_forced_command(
'filesync-server --repo docs --client laptop --role client --proto 2',
{ repo => 'docs', client => 'laptop', role => 'client' },
); # (1, undef)
=head3 API SPECIFICATION
=head4 INPUT
[
{ name => 'original', type => 'string', optional => 1 },
{ name => 'pinned', type => 'hashref' },
]
=head4 OUTPUT
{ type => 'arrayref', min => 2, max => 2 } # returned as a two-element list
=head3 MESSAGES
Key Meaning
forced.program the command runs some other program
forced.argument a token is not a --flag
forced.no_value a flag has no value
forced.flag the flag is not repo, client, role or proto
forced.proto the protocol differs from this server's
forced.pinned the value differs from the pinned one
=head3 FORMAL SPECIFICATION
let ts = tokens(original)
ok â (ts = â¨â©) ⨠((head ts â FLAG ⨠basename(head ts) = filesync-server) â§
â (f, v) : flags(ts) ⢠f â {repo, client, role, proto} â§
(f = proto â v = PROTO) â§ (f â proto â v = pinned(f)))
=head3 PSEUDOCODE
accept an empty command
split on white space; allow a leading filesync-server path
for each --flag[=value] (value from the next token if not attached):
reject an unknown flag, a missing value, a wrong protocol
reject a value that differs from the pinned one
=cut | |||||
| 439 | ||||||
| 440 | sub validate_forced_command { | |||||
| 441 | 28 | 95848 | my ($original, $pinned) = @_; | |||
| 442 | ||||||
| 443 | 28 | 67 | my @tokens = defined $original ? split(' ', $original) : (); | |||
| 444 | 28 | 35 | return (1, undef) unless @tokens; | |||
| 445 | ||||||
| 446 | 25 | 41 | if ($tokens[0] !~ /\A--/) { | |||
| 447 | 24 | 23 | my $program = shift @tokens; | |||
| 448 | 24 | 49 | return (0, message('forced.program', { program => $program })) | |||
| 449 | unless $program =~ m{(?:\A|/)\Q$SERVER_PROGRAM\E\z}; | |||||
| 450 | } | |||||
| 451 | ||||||
| 452 | 24 | 217 | while (@tokens) { | |||
| 453 | 75 | 46 | my $token = shift @tokens; | |||
| 454 | 75 | 122 | return (0, message('forced.argument', { token => $token })) | |||
| 455 | unless $token =~ /\A--([a-z][a-z-]*)(?:=(.*))?\z/s; | |||||
| 456 | ||||||
| 457 | 74 | 83 | my ($flag, $value) = ($1, $2); | |||
| 458 | 74 | 63 | unless (defined $value) { | |||
| 459 | 69 | 56 | return (0, message('forced.no_value', { flag => $flag })) unless @tokens; | |||
| 460 | 68 | 48 | $value = shift @tokens; | |||
| 461 | } | |||||
| 462 | ||||||
| 463 | 73 | 106 | my $field = $FORCED_FLAGS{$flag} or return (0, message('forced.flag', { flag => $flag })); | |||
| 464 | ||||||
| 465 | 70 | 257 | if ($field eq 'proto') { | |||
| 466 | 13 | 23 | return (0, message('forced.proto', { proto => $value })) unless $value eq $PROTO; | |||
| 467 | 12 | 17 | next; | |||
| 468 | } | |||||
| 469 | ||||||
| 470 | 57 | 41 | my $expected = $pinned->{$field}; | |||
| 471 | 57 | 111 | return (0, message('forced.pinned', { field => $field, value => $value, expected => $expected // '' })) | |||
| 472 | unless defined $expected && $value eq $expected; | |||||
| 473 | } | |||||
| 474 | ||||||
| 475 | 13 | 19 | return (1, undef); | |||
| 476 | } | |||||
| 477 | ||||||
| 478 | # _journal_failed_session | |||||
| 479 | # Purpose: record a session that never reached done, so the admin sees | |||||
| 480 | # the attempt with the same exit code the client records. | |||||
| 481 | # Entry: $error is the message sent to the client. | |||||
| 482 | # Exit: returns 1 if journalled, else 0. | |||||
| 483 | # Side effects: appends to the journal. Nothing is journalled without a store | |||||
| 484 | # (a bad hello, say) or on a dry run, and a journal write that | |||||
| 485 | # fails is logged, never allowed to mask the original error. | |||||
| 486 | sub _journal_failed_session :Private { | |||||
| 487 | my ($self, $error) = @_; | |||||
| 488 | ||||||
| 489 | return 0 if $self->{finished} || $self->{session_journalled} || $self->{dry_run} || !$self->{store}; | |||||
| 490 | ||||||
| 491 | my $locked = ($self->{refusal} || '') eq 'locked'; | |||||
| 492 | my $ok = eval { | |||||
| 493 | $self->_journal({ | |||||
| 494 | action => 'session', | |||||
| 495 | result => 'error', | |||||
| 496 | exit => $locked ? $EXIT_LOCKED : $EXIT_FATAL, | |||||
| 497 | error => $error, | |||||
| 498 | ($locked ? (locked => 1) : ()), | |||||
| 499 | }); | |||||
| 500 | 1; | |||||
| 501 | }; | |||||
| 502 | $self->{logger}->warn('cannot journal the failed session', { error => strip_location($@) }) unless $ok; | |||||
| 503 | ||||||
| 504 | return $ok ? 1 : 0; | |||||
| 505 | 300 300 300 | 221875 306 3428 | } | |||
| 506 | ||||||
| 507 | # _validate_hello | |||||
| 508 | # Purpose: check the client's hello against the pinned session. | |||||
| 509 | # Entry: $hello is the first message. | |||||
| 510 | # Exit: returns 1. | |||||
| 511 | # Side effects: records dry_run and force; raises server.proto or | |||||
| 512 | # server.pinned. | |||||
| 513 | sub _validate_hello :Private { | |||||
| 514 | my ($self, $hello) = @_; | |||||
| 515 | ||||||
| 516 | $self->_throw('server.proto') unless defined $hello->{proto} && $hello->{proto} eq $PROTO; | |||||
| 517 | ||||||
| 518 | for my $field (qw(client repo role)) { | |||||
| 519 | my $given = $hello->{$field} // ''; | |||||
| 520 | $self->_throw('server.pinned', { field => $field, expected => $self->{$field}, given => $given }) | |||||
| 521 | unless $given eq $self->{$field}; | |||||
| 522 | } | |||||
| 523 | ||||||
| 524 | # case_insensitive is part of the hello but only the client acts on it. | |||||
| 525 | $self->{dry_run} = $hello->{dry_run} ? 1 : 0; | |||||
| 526 | $self->{force} = $hello->{force} ? 1 : 0; | |||||
| 527 | return 1; | |||||
| 528 | 300 300 300 | 104054 265 2458 | } | |||
| 529 | ||||||
| 530 | # _check_client_allowed | |||||
| 531 | # Purpose: enforce the repo's client list. | |||||
| 532 | # Entry: the store is open. | |||||
| 533 | # Exit: returns 1. | |||||
| 534 | # Side effects: raises server.not_allowed or server.wrong_role. An empty | |||||
| 535 | # list allows everyone (a repo nobody has restricted yet). | |||||
| 536 | sub _check_client_allowed :Private { | |||||
| 537 | my ($self) = @_; | |||||
| 538 | ||||||
| 539 | my $clients = $self->{store}->config()->{clients} || {}; | |||||
| 540 | return 1 unless %{$clients}; | |||||
| 541 | ||||||
| 542 | my $entry = $clients->{ $self->{client} } | |||||
| 543 | or $self->_throw('server.not_allowed', { client => $self->{client}, repo => $self->{repo} }); | |||||
| 544 | ||||||
| 545 | my $role = (ref($entry) eq 'HASH' && $entry->{role}) || $ROLE_CLIENT; | |||||
| 546 | $self->_throw('server.wrong_role', { client => $self->{client}, registered => $role, asked => $self->{role} }) | |||||
| 547 | unless $role eq $self->{role}; | |||||
| 548 | ||||||
| 549 | return 1; | |||||
| 550 | 300 300 300 | 97508 284 2384 | } | |||
| 551 | ||||||
| 552 | # _loop | |||||
| 553 | # Purpose: serve commands until done. | |||||
| 554 | # Entry: the hello has been answered. | |||||
| 555 | # Exit: returns 1 once _finish has run. | |||||
| 556 | # Side effects: whatever the commands do; raises server.unknown. | |||||
| 557 | sub _loop :Private { | |||||
| 558 | my ($self) = @_; | |||||
| 559 | ||||||
| 560 | until ($self->{finished}) { | |||||
| 561 | my $message = $self->{conn}->recv(); | |||||
| 562 | my $command = $message->{cmd} // ''; | |||||
| 563 | my $method = $COMMANDS{$command} or $self->_throw('server.unknown', { command => $command }); | |||||
| 564 | $self->$method($message); | |||||
| 565 | } | |||||
| 566 | ||||||
| 567 | return 1; | |||||
| 568 | 300 300 300 | 91707 256 2352 | } | |||
| 569 | ||||||
| 570 | # _manifest | |||||
| 571 | # Purpose: accept (part of) the client's manifest. | |||||
| 572 | # Entry: $message->{entries} is an arrayref of { p, t, s, m, h, mode }. | |||||
| 573 | # Exit: returns 1 after replying ok. | |||||
| 574 | # Side effects: adds normalised entries to client_manifest; raises | |||||
| 575 | # server.manifest_* for anything malformed, so the plan never | |||||
| 576 | # sees an unchecked path or field. | |||||
| 577 | sub _manifest :Private { | |||||
| 578 | my ($self, $message) = @_; | |||||
| 579 | ||||||
| 580 | my $entries = $message->{entries}; | |||||
| 581 | $self->_throw('server.manifest_list') unless ref($entries) eq 'ARRAY'; | |||||
| 582 | ||||||
| 583 | for my $entry (@{$entries}) { | |||||
| 584 | $self->_throw('server.manifest_entry') unless ref($entry) eq 'HASH'; | |||||
| 585 | ||||||
| 586 | my $path = $entry->{p}; | |||||
| 587 | my ($safe, $reason) = validate_relpath($path); | |||||
| 588 | $self->_throw('server.manifest_path', { reason => $reason }) unless $safe; | |||||
| 589 | ||||||
| 590 | my $type = $entry->{t} // $TYPE_FILE; | |||||
| 591 | $self->_throw('server.manifest_field', { field => 't', path => $path }) | |||||
| 592 | unless $type eq $TYPE_FILE || $type eq $TYPE_DIR; | |||||
| 593 | ||||||
| 594 | if ($type eq $TYPE_DIR) { | |||||
| 595 | $self->{client_manifest}{$path} = { t => $TYPE_DIR }; | |||||
| 596 | next; | |||||
| 597 | } | |||||
| 598 | ||||||
| 599 | my %field = (h => $SHA256_HEX, s => $UINT, m => $MTIME, mode => $UINT); | |||||
| 600 | my %entry = (m => 0, mode => $DEFAULT_FILE_MODE, %{$entry}); | |||||
| 601 | for my $name (sort keys %field) { | |||||
| 602 | $self->_throw('server.manifest_field', { field => $name, path => $path }) | |||||
| 603 | unless defined $entry{$name} && $entry{$name} =~ $field{$name}; | |||||
| 604 | } | |||||
| 605 | ||||||
| 606 | $self->{client_manifest}{$path} = { | |||||
| 607 | t => $TYPE_FILE, | |||||
| 608 | h => $entry{h}, | |||||
| 609 | (map { $_ => $entry{$_} + 0 } qw(s m mode)), | |||||
| 610 | }; | |||||
| 611 | } | |||||
| 612 | ||||||
| 613 | $self->{conn}->send({ ok => 1 }); | |||||
| 614 | return 1; | |||||
| 615 | 300 300 300 | 125243 289 2651 | } | |||
| 616 | ||||||
| 617 | # _plan | |||||
| 618 | # Purpose: plan the session and send it to the client. | |||||
| 619 | # Entry: the manifest has arrived. | |||||
| 620 | # Exit: returns 1 after sending the actions. | |||||
| 621 | # Side effects: rescans the master (reusing the index's hashes); stores the | |||||
| 622 | # plan; journals it unless dry run; raises the delete guard's | |||||
| 623 | # refusal when the plan deletes too much. | |||||
| 624 | sub _plan :Private { | |||||
| 625 | my ($self) = @_; | |||||
| 626 | ||||||
| 627 | my $store = $self->{store}; | |||||
| 628 | my $master = $self->{index} = $store->walk_master($self->{index})->{manifest}; | |||||
| 629 | my $config = $store->config(); | |||||
| 630 | ||||||
| 631 | my $plan = App::FileSync::Merge::plan( | |||||
| 632 | base => $self->{base}, | |||||
| 633 | client => $self->{client_manifest}, | |||||
| 634 | master => $master, | |||||
| 635 | client_id => $self->{client}, | |||||
| 636 | role => $self->{role}, | |||||
| 637 | now => time, | |||||
| 638 | ); | |||||
| 639 | ||||||
| 640 | my $refusal = App::FileSync::Merge::check_delete_limit( | |||||
| 641 | deletions => $plan->{deletions}, | |||||
| 642 | base_size => scalar(keys %{ $self->{base} }), | |||||
| 643 | max_deletes => $config->{max_deletes}, | |||||
| 644 | max_delete_percent => $config->{max_delete_percent}, | |||||
| 645 | force => $self->{force}, | |||||
| 646 | ); | |||||
| 647 | $self->_throw('server.refused', { reason => $refusal }) if defined $refusal; | |||||
| 648 | ||||||
| 649 | $self->_unique_loser_names($plan->{actions}); | |||||
| 650 | ||||||
| 651 | $self->{plan} = { map { $_->{p} => $_ } @{ $plan->{actions} } }; | |||||
| 652 | $self->{base_after} = { %{ $self->{client_manifest} } }; | |||||
| 653 | ||||||
| 654 | my %counts = ( | |||||
| 655 | actions => scalar @{ $plan->{actions} }, | |||||
| 656 | deletions => $plan->{deletions}, | |||||
| 657 | conflicts => $plan->{conflicts}, | |||||
| 658 | dry_run => $self->{dry_run}, | |||||
| 659 | ); | |||||
| 660 | $self->{logger}->debug('plan', { repo => $self->{repo}, client => $self->{client}, %counts }); | |||||
| 661 | $self->_journal({ action => 'plan', %counts }) unless $self->{dry_run}; | |||||
| 662 | ||||||
| 663 | $self->{conn}->send({ ok => 1, actions => $plan->{actions}, warnings => $plan->{warnings} }); | |||||
| 664 | return 1; | |||||
| 665 | 300 300 300 | 125693 261 2493 | } | |||
| 666 | ||||||
| 667 | # _unique_loser_names | |||||
| 668 | # Purpose: make every conflict-copy name unique against the master and | |||||
| 669 | # the rest of the plan. | |||||
| 670 | # Entry: $actions from Merge::plan. | |||||
| 671 | # Exit: returns 1. | |||||
| 672 | # Side effects: rewrites loser_name in place. Two conflicts resolved in the | |||||
| 673 | # same second would otherwise pick the same name. | |||||
| 674 | sub _unique_loser_names :Private { | |||||
| 675 | my ($self, $actions) = @_; | |||||
| 676 | ||||||
| 677 | my %used; | |||||
| 678 | for my $action (grep { $_->{a} eq 'conflict' && defined $_->{loser_name} } @{$actions}) { | |||||
| 679 | my ($name, $suffix) = ($action->{loser_name}, 1); | |||||
| 680 | $name = name_with_suffix($action->{loser_name}, ++$suffix) | |||||
| 681 | while $used{$name} || exists $self->{index}{$name}; | |||||
| 682 | $used{$name} = 1; | |||||
| 683 | $action->{loser_name} = $name; | |||||
| 684 | } | |||||
| 685 | ||||||
| 686 | return 1; | |||||
| 687 | 300 300 300 | 100050 252 2414 | } | |||
| 688 | ||||||
| 689 | # _expect | |||||
| 690 | # Purpose: the one gate every path-bearing request passes. | |||||
| 691 | # Entry: $kind is a key of %MAY; $path is from the client. | |||||
| 692 | # Exit: returns the planned action for $path. | |||||
| 693 | # Side effects: raises server.unplanned when the plan has no such path and | |||||
| 694 | # server.not_planned when it does not allow this request. | |||||
| 695 | sub _expect :Private { | |||||
| 696 | my ($self, $kind, $path) = @_; | |||||
| 697 | ||||||
| 698 | my $what = $self->i18n("server.op.$kind"); | |||||
| 699 | my $action = defined $path ? $self->{plan}{$path} : undef; | |||||
| 700 | $self->_throw('server.unplanned', { what => $what, path => $path // '' }) unless $action; | |||||
| 701 | ||||||
| 702 | my $label = $action->{a} eq 'conflict' ? "conflict:" . ($action->{winner} // '') : $action->{a}; | |||||
| 703 | $self->_throw('server.not_planned', { what => $what, path => $path }) | |||||
| 704 | unless any { $_ eq $label } @{ $MAY{$kind} }; | |||||
| 705 | ||||||
| 706 | return $action; | |||||
| 707 | 300 300 300 | 103828 256 2425 | } | |||
| 708 | ||||||
| 709 | # _get | |||||
| 710 | # Purpose: send one chunk of a master file the client is pulling. | |||||
| 711 | # Entry: $message has p, optional off and max. | |||||
| 712 | # Exit: returns 1 after sending the chunk. | |||||
| 713 | # Side effects: reads the master; raises server.bad_field for a malformed | |||||
| 714 | # offset or size, server.not_file and io.* errors. max is | |||||
| 715 | # clamped to MAX_CHUNK_BYTES so a client cannot ask the server to | |||||
| 716 | # buffer an entire large file. | |||||
| 717 | sub _get :Private { | |||||
| 718 | my ($self, $message) = @_; | |||||
| 719 | ||||||
| 720 | my $path = $message->{p}; | |||||
| 721 | $self->_expect('read', $path); | |||||
| 722 | ||||||
| 723 | my $offset = $self->_uint($message, 'off', 0); | |||||
| 724 | my $max = $self->_uint($message, 'max', $CHUNK); | |||||
| 725 | $max = $MAX_CHUNK_BYTES if $max > $MAX_CHUNK_BYTES; | |||||
| 726 | ||||||
| 727 | my $abs = $self->{store}->abs_path($path); | |||||
| 728 | my $file = $self->_file_stat($abs, $path); | |||||
| 729 | $self->_throw('server.bad_field', { field => 'off', path => $path }) if $offset > $file->{s}; | |||||
| 730 | ||||||
| 731 | my $length = $file->{s} - $offset; | |||||
| 732 | $length = $max if $length > $max; | |||||
| 733 | ||||||
| 734 | my $bytes = ''; | |||||
| 735 | if ($length > 0) { | |||||
| 736 | my $fh = App::FileSync::IO::open_file($abs, '<:raw'); | |||||
| 737 | seek($fh, $offset, 0); | |||||
| 738 | $bytes = App::FileSync::Delta::read_exact($fh, $length, $abs); | |||||
| 739 | close $fh; | |||||
| 740 | } | |||||
| 741 | ||||||
| 742 | $self->{conn}->send_data({ ok => 1, eof => ($offset + $length >= $file->{s}) ? 1 : 0, %{$file} }, $bytes); | |||||
| 743 | return 1; | |||||
| 744 | 300 300 300 | 113578 256 2518 | } | |||
| 745 | ||||||
| 746 | # _put | |||||
| 747 | # Purpose: start receiving a whole file the client is pushing. | |||||
| 748 | # Entry: $message has p, s, h, m, mode; conflict_name is optional and, | |||||
| 749 | # when sent, must equal the plan's loser_name. | |||||
| 750 | # Exit: returns 1 after replying ok with off 0. | |||||
| 751 | # Side effects: for a conflict the client won, moves the master's version | |||||
| 752 | # aside under the plan's name first - whatever the client | |||||
| 753 | # sends, so a client that omits the name cannot overwrite the | |||||
| 754 | # master's losing version. Opens a temporary file. | |||||
| 755 | sub _put :Private { | |||||
| 756 | my ($self, $message) = @_; | |||||
| 757 | ||||||
| 758 | my $path = $message->{p}; | |||||
| 759 | my $action = $self->_expect('write', $path); | |||||
| 760 | ||||||
| 761 | if ($action->{a} eq 'conflict' && defined $action->{loser_name}) { | |||||
| 762 | $self->_throw('server.conflict_name', { path => $path, expected => $action->{loser_name} }) | |||||
| 763 | if defined $message->{conflict_name} && $message->{conflict_name} ne $action->{loser_name}; | |||||
| 764 | $self->_preserve_loser($path, $action->{loser_name}, { sha256 => $message->{h}, mtime => $message->{m} }); | |||||
| 765 | } | |||||
| 766 | ||||||
| 767 | $self->_open_upload($path, $message); | |||||
| 768 | $self->{conn}->send({ ok => 1, off => 0 }); | |||||
| 769 | return 1; | |||||
| 770 | 300 300 300 | 102048 288 2371 | } | |||
| 771 | ||||||
| 772 | # _chunk | |||||
| 773 | # Purpose: receive one chunk of a whole-file upload. | |||||
| 774 | # Entry: a put for $message->{p} is open; len gives the payload size. | |||||
| 775 | # Exit: returns 1 after replying with the new offset. | |||||
| 776 | # Side effects: appends to the temporary file and the running hash. | |||||
| 777 | sub _chunk :Private { | |||||
| 778 | my ($self, $message) = @_; | |||||
| 779 | ||||||
| 780 | my $path = $message->{p} // ''; | |||||
| 781 | my $upload = $self->{uploads}{$path} or $self->_throw('server.no_put', { path => $path }); | |||||
| 782 | ||||||
| 783 | my $length = $self->_uint($message, 'len', 0); | |||||
| 784 | $self->_throw('server.too_large', { path => $path }) if $length > $MAX_CHUNK_BYTES; | |||||
| 785 | ||||||
| 786 | $self->_append_upload($path, $upload, $length ? $self->{conn}->read_data($message) : ''); | |||||
| 787 | $self->{conn}->send({ ok => 1, off => $upload->{got} }); | |||||
| 788 | return 1; | |||||
| 789 | 300 300 300 | 96779 295 2342 | } | |||
| 790 | ||||||
| 791 | # _chunk_end | |||||
| 792 | # Purpose: finish a whole-file upload. | |||||
| 793 | # Entry: a put for $message->{p} is open. | |||||
| 794 | # Exit: returns 1 after replying ok. | |||||
| 795 | # Side effects: verifies and installs the file (see _commit_upload). | |||||
| 796 | sub _chunk_end :Private { | |||||
| 797 | my ($self, $message) = @_; | |||||
| 798 | ||||||
| 799 | my $path = $message->{p} // ''; | |||||
| 800 | my $upload = delete $self->{uploads}{$path} or $self->_throw('server.no_put', { path => $path }); | |||||
| 801 | ||||||
| 802 | $self->_commit_upload($path, $upload); | |||||
| 803 | $self->{conn}->send({ ok => 1 }); | |||||
| 804 | return 1; | |||||
| 805 | 300 300 300 | 90948 270 2229 | } | |||
| 806 | ||||||
| 807 | # _signature | |||||
| 808 | # Purpose: describe the master's current version so the client can push | |||||
| 809 | # only the blocks that differ. | |||||
| 810 | # Entry: $message->{p} is a planned push. | |||||
| 811 | # Exit: returns 1 after replying; delta 0 when the file is too small | |||||
| 812 | # or absent. | |||||
| 813 | # Side effects: reads the master file. | |||||
| 814 | sub _signature :Private { | |||||
| 815 | my ($self, $message) = @_; | |||||
| 816 | ||||||
| 817 | my $path = $message->{p}; | |||||
| 818 | $self->_expect('delta', $path); | |||||
| 819 | ||||||
| 820 | my $abs = $self->{store}->abs_path($path); | |||||
| 821 | my $signature = (-f $abs && (-s _) >= $App::FileSync::Delta::MIN_SIZE) | |||||
| 822 | ? App::FileSync::Delta::signature($abs) | |||||
| 823 | : undef; | |||||
| 824 | ||||||
| 825 | return $self->_reply({ ok => 1, delta => 0 }) unless $signature; | |||||
| 826 | ||||||
| 827 | $self->{conn}->send_data({ | |||||
| 828 | ok => 1, | |||||
| 829 | delta => 1, | |||||
| 830 | s => $signature->{size}, | |||||
| 831 | b => $signature->{blocklen}, | |||||
| 832 | n => scalar @{ $signature->{blocks} }, | |||||
| 833 | }, App::FileSync::Delta::encode_signature($signature)); | |||||
| 834 | return 1; | |||||
| 835 | 300 300 300 | 103491 249 2371 | } | |||
| 836 | ||||||
| 837 | # _delta_start | |||||
| 838 | # Purpose: start receiving a pushed file as a delta against the master. | |||||
| 839 | # Entry: $message has p, h, s, m, mode. | |||||
| 840 | # Exit: returns 1 after replying ok. | |||||
| 841 | # Side effects: opens a temporary file and, when the master has the file, | |||||
| 842 | # a read handle on it for copy records. | |||||
| 843 | sub _delta_start :Private { | |||||
| 844 | my ($self, $message) = @_; | |||||
| 845 | ||||||
| 846 | my $path = $message->{p}; | |||||
| 847 | $self->_expect('delta', $path); | |||||
| 848 | $self->_throw('server.bad_field', { field => 'h', path => $path }) | |||||
| 849 | unless defined $message->{h} && $message->{h} =~ $SHA256_HEX; | |||||
| 850 | $self->_uint($message, 's'); | |||||
| 851 | ||||||
| 852 | my $upload = $self->_open_upload($path, $message); | |||||
| 853 | $upload->{delta} = 1; | |||||
| 854 | ||||||
| 855 | my $old = $self->{store}->abs_path($path); | |||||
| 856 | if (-f $old) { | |||||
| 857 | @{$upload}{qw(old old_fh old_size)} = ($old, App::FileSync::IO::open_file($old, '<:raw'), -s _); | |||||
| 858 | } | |||||
| 859 | ||||||
| 860 | $self->{conn}->send({ ok => 1 }); | |||||
| 861 | return 1; | |||||
| 862 | 300 300 300 | 108612 301 2361 | } | |||
| 863 | ||||||
| 864 | # _delta_copy | |||||
| 865 | # Purpose: apply a copy record: bytes from the master's old version. | |||||
| 866 | # Entry: a delta for p is open; off and len are integers, len > 0. | |||||
| 867 | # Exit: returns 1 after replying with the new offset. | |||||
| 868 | # Side effects: appends to the upload; raises server.bad_copy for a range | |||||
| 869 | # outside the old file. | |||||
| 870 | sub _delta_copy :Private { | |||||
| 871 | my ($self, $message) = @_; | |||||
| 872 | ||||||
| 873 | my ($path, $upload) = $self->_open_delta($message); | |||||
| 874 | my $offset = $self->_uint($message, 'off'); | |||||
| 875 | my $length = $self->_uint($message, 'len'); | |||||
| 876 | ||||||
| 877 | $self->_throw('server.bad_copy', { path => $path }) | |||||
| 878 | unless $length > 0 && $upload->{old_fh} && $offset + $length <= $upload->{old_size}; | |||||
| 879 | ||||||
| 880 | seek($upload->{old_fh}, $offset, 0); | |||||
| 881 | $self->_append_upload($path, $upload, App::FileSync::Delta::read_exact($upload->{old_fh}, $length, $upload->{old})); | |||||
| 882 | $self->{conn}->send({ ok => 1, off => $upload->{got} }); | |||||
| 883 | return 1; | |||||
| 884 | 300 300 300 | 106039 275 2351 | } | |||
| 885 | ||||||
| 886 | # _delta_literal | |||||
| 887 | # Purpose: apply a literal record: bytes sent by the client. | |||||
| 888 | # Entry: a delta for p is open. | |||||
| 889 | # Exit: returns 1 after replying with the new offset. | |||||
| 890 | # Side effects: appends to the upload. | |||||
| 891 | sub _delta_literal :Private { | |||||
| 892 | my ($self, $message) = @_; | |||||
| 893 | ||||||
| 894 | my ($path, $upload) = $self->_open_delta($message); | |||||
| 895 | $self->_append_upload($path, $upload, $self->{conn}->read_data($message)); | |||||
| 896 | $self->{conn}->send({ ok => 1, off => $upload->{got} }); | |||||
| 897 | return 1; | |||||
| 898 | 300 300 300 | 94161 277 2271 | } | |||
| 899 | ||||||
| 900 | # _delta_end | |||||
| 901 | # Purpose: finish a delta upload. | |||||
| 902 | # Entry: a delta for p is open. | |||||
| 903 | # Exit: returns 1 after replying ok. | |||||
| 904 | # Side effects: verifies and installs the file; journals it as a delta push. | |||||
| 905 | sub _delta_end :Private { | |||||
| 906 | my ($self, $message) = @_; | |||||
| 907 | ||||||
| 908 | my ($path, $upload) = $self->_open_delta($message); | |||||
| 909 | delete $self->{uploads}{$path}; | |||||
| 910 | ||||||
| 911 | $self->_commit_upload($path, $upload, delta => 1); | |||||
| 912 | $self->{conn}->send({ ok => 1, delta => 1 }); | |||||
| 913 | return 1; | |||||
| 914 | 300 300 300 | 91515 271 2205 | } | |||
| 915 | ||||||
| 916 | # _pull_signature | |||||
| 917 | # Purpose: take the client's description of its old version and spool a | |||||
| 918 | # delta from the master's current one. | |||||
| 919 | # Entry: $message->{p} is a planned pull; n and b describe the blocks. | |||||
| 920 | # Exit: returns 1 after replying; delta 0 when a delta is not worth it. | |||||
| 921 | # Side effects: reads the master file; keeps the spool for _pull_op. | |||||
| 922 | sub _pull_signature :Private { | |||||
| 923 | my ($self, $message) = @_; | |||||
| 924 | ||||||
| 925 | my $path = $message->{p}; | |||||
| 926 | $self->_expect('pull', $path); | |||||
| 927 | ||||||
| 928 | my $count = $self->_uint($message, 'n'); | |||||
| 929 | my $block = $self->_uint($message, 'b'); | |||||
| 930 | $self->_throw('server.bad_signature', { path => $path }) | |||||
| 931 | unless $count <= $MAX_SIGNATURE_BLOCKS && $block >= $MIN_SIGNATURE_BLOCK && $block <= $MAX_SIGNATURE_BLOCK; | |||||
| 932 | ||||||
| 933 | my $blocks = App::FileSync::Delta::decode_signature($self->{conn}->read_data($message), $count); | |||||
| 934 | ||||||
| 935 | my $abs = $self->{store}->abs_path($path); | |||||
| 936 | my $file = $self->_file_stat($abs, $path); | |||||
| 937 | return $self->_reply({ ok => 1, delta => 0 }) if $file->{s} < $App::FileSync::Delta::MIN_SIZE; | |||||
| 938 | ||||||
| 939 | my $spool = File::Temp->new(UNLINK => 1); | |||||
| 940 | binmode $spool; | |||||
| 941 | my $stats = App::FileSync::Delta::diff($abs, { blocklen => $block, blocks => $blocks }, $spool); | |||||
| 942 | $spool->flush(); | |||||
| 943 | ||||||
| 944 | # Only the literal bytes and record headers go on the wire; compression can | |||||
| 945 | # shrink them further, so this is a conservative estimate. | |||||
| 946 | my $estimated = $stats->{literal} + $DELTA_OP_BYTES * $stats->{ops} + $DELTA_OVERHEAD; | |||||
| 947 | return $self->_reply({ ok => 1, delta => 0 }) if $estimated >= $file->{s}; | |||||
| 948 | ||||||
| 949 | seek($spool, 0, 0); | |||||
| 950 | $self->{pull_deltas}{$path} = { spool => $spool, %{$file} }; | |||||
| 951 | $self->{conn}->send({ ok => 1, delta => 1, %{$file}, b => $block }); | |||||
| 952 | return 1; | |||||
| 953 | 300 300 300 | 127765 271 2461 | } | |||
| 954 | ||||||
| 955 | # _pull_op | |||||
| 956 | # Purpose: serve the next record of a spooled pull delta. | |||||
| 957 | # Entry: _pull_signature spooled a delta for p. | |||||
| 958 | # Exit: returns 1 after sending a copy, a literal, or done. | |||||
| 959 | # Side effects: advances the spool; forgets it at the end. | |||||
| 960 | sub _pull_op :Private { | |||||
| 961 | my ($self, $message) = @_; | |||||
| 962 | ||||||
| 963 | my $path = $message->{p} // ''; | |||||
| 964 | my $state = $self->{pull_deltas}{$path} or $self->_throw('server.no_signature', { path => $path }); | |||||
| 965 | ||||||
| 966 | my ($type, @rest) = App::FileSync::Delta::read_op($state->{spool}); | |||||
| 967 | unless ($type) { | |||||
| 968 | delete $self->{pull_deltas}{$path}; | |||||
| 969 | return $self->_reply({ ok => 1, done => 1 }); | |||||
| 970 | } | |||||
| 971 | ||||||
| 972 | return $self->_reply({ ok => 1, op => 'copy', off => $rest[0], len => $rest[1] }) if $type eq 'copy'; | |||||
| 973 | ||||||
| 974 | $self->{conn}->send_data({ ok => 1, op => 'lit' }, $rest[0]); | |||||
| 975 | return 1; | |||||
| 976 | 300 300 300 | 108831 298 2382 | } | |||
| 977 | ||||||
| 978 | # _mkdir | |||||
| 979 | # Purpose: create a directory the client pushed. | |||||
| 980 | # Entry: $message->{p} is a planned push of a directory. | |||||
| 981 | # Exit: returns 1 after replying ok. | |||||
| 982 | # Side effects: creates the directory in the master; records it. | |||||
| 983 | sub _mkdir :Private { | |||||
| 984 | my ($self, $message) = @_; | |||||
| 985 | ||||||
| 986 | my $path = $message->{p}; | |||||
| 987 | $self->_expect('mkdir', $path); | |||||
| 988 | ||||||
| 989 | App::FileSync::IO::ensure_dir($self->{store}->abs_path($path)); | |||||
| 990 | $self->{index}{$path} = { t => $TYPE_DIR }; | |||||
| 991 | $self->{base_after}{$path} = { t => $TYPE_DIR }; | |||||
| 992 | $self->_journal({ action => 'mkdir', path => $path, result => 'ok' }); | |||||
| 993 | ||||||
| 994 | $self->{applied}{$path} = 1; | |||||
| 995 | return $self->_reply({ ok => 1 }); | |||||
| 996 | 300 300 300 | 102338 261 2303 | } | |||
| 997 | ||||||
| 998 | # _delete | |||||
| 999 | # Purpose: move a master path the client deleted into the trash. | |||||
| 1000 | # Entry: $message->{p} is a planned delete_remote. | |||||
| 1001 | # Exit: returns 1 after replying with the trash path. | |||||
| 1002 | # Side effects: moves the path to the trash; records it. | |||||
| 1003 | sub _delete :Private { | |||||
| 1004 | my ($self, $message) = @_; | |||||
| 1005 | ||||||
| 1006 | my $path = $message->{p}; | |||||
| 1007 | $self->_expect('delete', $path); | |||||
| 1008 | ||||||
| 1009 | my $trashed = $self->{store}->move_to_trash($path, time); | |||||
| 1010 | delete $self->{index}{$path}; | |||||
| 1011 | delete $self->{base_after}{$path}; | |||||
| 1012 | $self->_journal({ action => 'delete', path => $path, trash => $trashed, result => 'ok' }); | |||||
| 1013 | ||||||
| 1014 | $self->{applied}{$path} = 1; | |||||
| 1015 | return $self->_reply({ ok => 1, trash => $trashed }); | |||||
| 1016 | 300 300 300 | 100757 280 2266 | } | |||
| 1017 | ||||||
| 1018 | # _ack | |||||
| 1019 | # Purpose: record what the client now holds after a change on its side. | |||||
| 1020 | # Entry: $message->{p} is a planned pull, delete_local or conflict the | |||||
| 1021 | # master won. deleted is only valid for delete_local, and only | |||||
| 1022 | # a delete_local may omit the file description. | |||||
| 1023 | # Exit: returns 1 after replying ok. | |||||
| 1024 | # Side effects: updates base_after; journals a conflict the master won. | |||||
| 1025 | # Restricting acks to these actions stops a client from writing | |||||
| 1026 | # arbitrary entries into its own base, which would make the | |||||
| 1027 | # next plan delete or skip master files. | |||||
| 1028 | sub _ack :Private { | |||||
| 1029 | my ($self, $message) = @_; | |||||
| 1030 | ||||||
| 1031 | my $path = $message->{p}; | |||||
| 1032 | my $action = $self->_expect('ack', $path); | |||||
| 1033 | my $deleting = $action->{a} eq 'delete_local'; | |||||
| 1034 | ||||||
| 1035 | $self->_throw('server.bad_ack', { path => $path }) unless !$message->{deleted} == !$deleting; | |||||
| 1036 | ||||||
| 1037 | if ($deleting) { | |||||
| 1038 | delete $self->{base_after}{$path}; | |||||
| 1039 | } elsif (($message->{t} // '') eq $TYPE_DIR) { | |||||
| 1040 | $self->{base_after}{$path} = { t => $TYPE_DIR }; | |||||
| 1041 | } else { | |||||
| 1042 | $self->_throw('server.bad_field', { field => 'h', path => $path }) | |||||
| 1043 | unless defined $message->{h} && $message->{h} =~ $SHA256_HEX; | |||||
| 1044 | $self->{base_after}{$path} = { | |||||
| 1045 | t => $TYPE_FILE, | |||||
| 1046 | h => $message->{h}, | |||||
| 1047 | s => $self->_uint($message, 's', 0), | |||||
| 1048 | m => ($message->{m} // 0) + 0, | |||||
| 1049 | mode => $self->_uint($message, 'mode', $DEFAULT_FILE_MODE), | |||||
| 1050 | }; | |||||
| 1051 | } | |||||
| 1052 | ||||||
| 1053 | if ($action->{a} eq 'conflict') { | |||||
| 1054 | $self->_journal_conflict($path, 'remote', $action->{loser_name}, | |||||
| 1055 | $self->{index}{$path}, $self->{client_manifest}{$path}); | |||||
| 1056 | } | |||||
| 1057 | ||||||
| 1058 | $self->{applied}{$path} = 1; | |||||
| 1059 | delete $self->{pull_deltas}{$path}; | |||||
| 1060 | return $self->_reply({ ok => 1 }); | |||||
| 1061 | 300 300 300 | 123186 275 2419 | } | |||
| 1062 | ||||||
| 1063 | # _preserve_loser | |||||
| 1064 | # Purpose: move the master's losing version aside before the client's | |||||
| 1065 | # version is written. | |||||
| 1066 | # Entry: $conflict_rel is the plan's loser_name for $path. | |||||
| 1067 | # Exit: returns 1. | |||||
| 1068 | # Side effects: renames the master file; moves its index entry; journals the | |||||
| 1069 | # copy and the conflict. Does nothing if the master no longer | |||||
| 1070 | # has the file. | |||||
| 1071 | sub _preserve_loser :Private { | |||||
| 1072 | my ($self, $path, $conflict_rel, $winner) = @_; | |||||
| 1073 | ||||||
| 1074 | my ($safe, $reason) = validate_relpath($conflict_rel); | |||||
| 1075 | $self->_throw('server.conflict_unsafe', { reason => $reason }) unless $safe; | |||||
| 1076 | $self->_throw('server.conflict_same', { path => $path }) if $conflict_rel eq $path; | |||||
| 1077 | ||||||
| 1078 | my $store = $self->{store}; | |||||
| 1079 | my $abs = $store->abs_path($path); | |||||
| 1080 | return 1 unless -e $abs || -l $abs; | |||||
| 1081 | ||||||
| 1082 | my $destination = $store->abs_path($conflict_rel); | |||||
| 1083 | $self->_throw('server.conflict_exists', { path => $conflict_rel }) if -e $destination || -l $destination; | |||||
| 1084 | ||||||
| 1085 | $store->ensure_parent($conflict_rel); | |||||
| 1086 | eval { rename $abs, $destination; 1 } | |||||
| 1087 | or $self->_throw('server.conflict_move', { path => $path, to => $conflict_rel, error => os_error($@) }); | |||||
| 1088 | ||||||
| 1089 | my $old = delete $self->{index}{$path}; | |||||
| 1090 | $self->{index}{$conflict_rel} = $old if $old; | |||||
| 1091 | ||||||
| 1092 | $self->_journal({ action => 'conflict_copy', path => $conflict_rel, from => $path, sha256 => $old ? $old->{h} : undef, result => 'ok' }); | |||||
| 1093 | $self->_journal_conflict($path, 'client', $conflict_rel, { h => $winner->{sha256}, m => $winner->{mtime} }, $old); | |||||
| 1094 | ||||||
| 1095 | return 1; | |||||
| 1096 | 300 300 300 | 118593 289 2496 | } | |||
| 1097 | ||||||
| 1098 | # _journal_conflict | |||||
| 1099 | # Purpose: record how a conflict was resolved. | |||||
| 1100 | # Entry: $side is client or remote; entries may be undef. | |||||
| 1101 | # Exit: returns 1. | |||||
| 1102 | # Side effects: appends a conflict event. | |||||
| 1103 | sub _journal_conflict :Private { | |||||
| 1104 | my ($self, $path, $side, $loser_name, $winner, $loser) = @_; | |||||
| 1105 | ||||||
| 1106 | ($winner, $loser) = ($winner || {}, $loser || {}); | |||||
| 1107 | return $self->_journal({ | |||||
| 1108 | action => 'conflict', | |||||
| 1109 | path => $path, | |||||
| 1110 | winner => $side, | |||||
| 1111 | loser_name => $loser_name, | |||||
| 1112 | winner_sha256 => $winner->{h}, | |||||
| 1113 | winner_mtime => $winner->{m}, | |||||
| 1114 | loser_sha256 => $loser->{h}, | |||||
| 1115 | loser_mtime => $loser->{m}, | |||||
| 1116 | result => 'ok', | |||||
| 1117 | }); | |||||
| 1118 | 300 300 300 | 102826 282 2323 | } | |||
| 1119 | ||||||
| 1120 | # _finish | |||||
| 1121 | # Purpose: end the session: save the index and the client's base. | |||||
| 1122 | # Entry: every planned action has been applied (unless dry run). | |||||
| 1123 | # Exit: returns 1 after sending the summary. | |||||
| 1124 | # Side effects: writes the index, the base and a session event; sets | |||||
| 1125 | # finished. Raises server.unfinished, naming the missing paths | |||||
| 1126 | # in order, when the client stopped early. | |||||
| 1127 | sub _finish :Private { | |||||
| 1128 | my ($self) = @_; | |||||
| 1129 | ||||||
| 1130 | my @actions = values %{ $self->{plan} }; | |||||
| 1131 | unless ($self->{dry_run}) { | |||||
| 1132 | my @unfinished = sort map { $_->{p} } grep { !$self->{applied}{ $_->{p} } } @actions; | |||||
| 1133 | $self->_throw('server.unfinished', { paths => join(', ', @unfinished) }) if @unfinished; | |||||
| 1134 | ||||||
| 1135 | $self->{store}->save_index($self->{index}); | |||||
| 1136 | $self->{store}->save_base($self->{client}, $self->{base_after}); | |||||
| 1137 | } | |||||
| 1138 | ||||||
| 1139 | my %summary = map { my $kind = $_; $kind => scalar(grep { $_->{a} =~ /\A$kind/ } @actions) } | |||||
| 1140 | qw(pull push delete conflict); | |||||
| 1141 | %summary = ( | |||||
| 1142 | pulled => $summary{pull}, | |||||
| 1143 | pushed => $summary{push}, | |||||
| 1144 | deleted => $summary{delete}, | |||||
| 1145 | conflicts => $summary{conflict}, | |||||
| 1146 | ); | |||||
| 1147 | ||||||
| 1148 | $self->{logger}->info('session finished', { repo => $self->{repo}, client => $self->{client}, %summary, dry_run => $self->{dry_run} }); | |||||
| 1149 | ||||||
| 1150 | # The client exits 3 when it had to keep conflict copies; record that here | |||||
| 1151 | # too, so --exit means the same thing on both sides. | |||||
| 1152 | $self->_journal({ | |||||
| 1153 | action => 'session', | |||||
| 1154 | result => 'ok', | |||||
| 1155 | exit => $summary{conflicts} ? $EXIT_WARNINGS : $EXIT_OK, | |||||
| 1156 | dry_run => $self->{dry_run}, | |||||
| 1157 | %summary, | |||||
| 1158 | }) unless $self->{dry_run}; | |||||
| 1159 | $self->{session_journalled} = 1; | |||||
| 1160 | ||||||
| 1161 | $self->{conn}->send({ ok => 1, summary => \%summary }); | |||||
| 1162 | $self->{finished} = 1; | |||||
| 1163 | return 1; | |||||
| 1164 | 300 300 300 | 134487 294 2554 | } | |||
| 1165 | ||||||
| 1166 | # _open_upload | |||||
| 1167 | # Purpose: start an upload for a planned path. | |||||
| 1168 | # Entry: $message has s, h, m, mode (s and h may be absent for a put). | |||||
| 1169 | # Exit: returns the upload record. | |||||
| 1170 | # Side effects: creates the parent directory and a temporary file; raises | |||||
| 1171 | # server.busy if the path is already being received. | |||||
| 1172 | sub _open_upload :Private { | |||||
| 1173 | my ($self, $path, $message) = @_; | |||||
| 1174 | ||||||
| 1175 | $self->_throw('server.busy', { path => $path }) if $self->{uploads}{$path}; | |||||
| 1176 | ||||||
| 1177 | my $size = defined $message->{s} ? $self->_uint($message, 's') : undef; | |||||
| 1178 | $self->{store}->ensure_parent($path); | |||||
| 1179 | my ($fh, $temp) = @{ $self->{store}->temp_file($path) }; | |||||
| 1180 | ||||||
| 1181 | return $self->{uploads}{$path} = { | |||||
| 1182 | fh => $fh, | |||||
| 1183 | temp => $temp, | |||||
| 1184 | sha => Digest::SHA->new(256), | |||||
| 1185 | got => 0, | |||||
| 1186 | size => $size, | |||||
| 1187 | hash => $message->{h}, | |||||
| 1188 | mtime => $message->{m}, | |||||
| 1189 | mode => $message->{mode}, | |||||
| 1190 | }; | |||||
| 1191 | 300 300 300 | 112483 306 2444 | } | |||
| 1192 | ||||||
| 1193 | # _open_delta | |||||
| 1194 | # Purpose: find the delta upload a record belongs to. | |||||
| 1195 | # Entry: $message->{p} names it. | |||||
| 1196 | # Exit: returns ($path, $upload). | |||||
| 1197 | # Side effects: raises server.no_delta when there is none, or the open | |||||
| 1198 | # upload for that path is a whole-file put. | |||||
| 1199 | sub _open_delta :Private { | |||||
| 1200 | my ($self, $message) = @_; | |||||
| 1201 | ||||||
| 1202 | my $path = $message->{p} // ''; | |||||
| 1203 | my $upload = $self->{uploads}{$path}; | |||||
| 1204 | $self->_throw('server.no_delta', { path => $path }) unless $upload && $upload->{delta}; | |||||
| 1205 | return ($path, $upload); | |||||
| 1206 | 300 300 300 | 99274 283 2337 | } | |||
| 1207 | ||||||
| 1208 | # _append_upload | |||||
| 1209 | # Purpose: add received bytes to an upload. | |||||
| 1210 | # Entry: $upload is open. | |||||
| 1211 | # Exit: returns 1. | |||||
| 1212 | # Side effects: writes to the temporary file and the hash. Refuses more | |||||
| 1213 | # bytes than were announced, so a client cannot fill the disk | |||||
| 1214 | # behind a small size; on any failure the upload is discarded. | |||||
| 1215 | sub _append_upload :Private { | |||||
| 1216 | my ($self, $path, $upload, $bytes) = @_; | |||||
| 1217 | ||||||
| 1218 | my $length = length $bytes; | |||||
| 1219 | if (defined $upload->{size} && $upload->{got} + $length > $upload->{size}) { | |||||
| 1220 | $self->_discard_upload($path); | |||||
| 1221 | $self->_throw('server.overflow', { path => $path }); | |||||
| 1222 | } | |||||
| 1223 | ||||||
| 1224 | unless (print { $upload->{fh} } $bytes) { | |||||
| 1225 | my $error = os_error(); | |||||
| 1226 | $self->_discard_upload($path); | |||||
| 1227 | $self->_throw('io.write', { path => $upload->{temp}, error => $error }); | |||||
| 1228 | } | |||||
| 1229 | ||||||
| 1230 | $upload->{sha}->add($bytes); | |||||
| 1231 | $upload->{got} += $length; | |||||
| 1232 | return 1; | |||||
| 1233 | 300 300 300 | 109971 311 2293 | } | |||
| 1234 | ||||||
| 1235 | # _commit_upload | |||||
| 1236 | # Purpose: close, verify and install an upload, whole or delta. | |||||
| 1237 | # Entry: $upload is no longer in uploads. | |||||
| 1238 | # Exit: returns the new index entry. | |||||
| 1239 | # Side effects: installs the file in the master; updates the index and | |||||
| 1240 | # base_after; journals the push. A hash or size mismatch | |||||
| 1241 | # discards the temporary file and raises. | |||||
| 1242 | sub _commit_upload :Private { | |||||
| 1243 | my ($self, $path, $upload, %extra) = @_; | |||||
| 1244 | ||||||
| 1245 | my $closed = eval { close $upload->{fh}; 1 }; | |||||
| 1246 | my $error = $closed ? undef : os_error($@); | |||||
| 1247 | eval { close $upload->{old_fh} } if $upload->{old_fh}; | |||||
| 1248 | ||||||
| 1249 | my $hash = $upload->{sha}->hexdigest(); | |||||
| 1250 | my ($key, %args) = | |||||
| 1251 | !$closed ? ('io.write', path => $upload->{temp}, error => $error) | |||||
| 1252 | : (defined $upload->{hash} && $hash ne $upload->{hash}) ? ('server.hash_mismatch', path => $path, expected => $upload->{hash}, got => $hash) | |||||
| 1253 | : (defined $upload->{size} && $upload->{got} != $upload->{size}) ? ('server.size_mismatch', path => $path, expected => $upload->{size}, got => $upload->{got}) | |||||
| 1254 | : (); | |||||
| 1255 | if ($key) { | |||||
| 1256 | eval { unlink $upload->{temp} }; | |||||
| 1257 | $self->_throw($key, \%args); | |||||
| 1258 | } | |||||
| 1259 | ||||||
| 1260 | $self->{store}->commit_temp($path, $upload->{temp}, $upload->{mtime}, $upload->{mode}); | |||||
| 1261 | ||||||
| 1262 | my $entry = { | |||||
| 1263 | t => $TYPE_FILE, | |||||
| 1264 | s => $upload->{got}, | |||||
| 1265 | m => $upload->{mtime} // time, | |||||
| 1266 | h => $hash, | |||||
| 1267 | mode => $upload->{mode} // $DEFAULT_FILE_MODE, | |||||
| 1268 | }; | |||||
| 1269 | $self->{index}{$path} = $entry; | |||||
| 1270 | $self->{base_after}{$path} = { %{$entry} }; | |||||
| 1271 | ||||||
| 1272 | $self->_journal({ action => 'push', path => $path, size => $upload->{got}, sha256 => $hash, result => 'ok', %extra }); | |||||
| 1273 | $self->{applied}{$path} = 1; | |||||
| 1274 | return $entry; | |||||
| 1275 | 300 300 300 | 137159 305 2472 | } | |||
| 1276 | ||||||
| 1277 | # _discard_upload / _discard_uploads | |||||
| 1278 | # Purpose: abandon one, or every, partial upload. | |||||
| 1279 | # Entry: none. | |||||
| 1280 | # Exit: returns 1. | |||||
| 1281 | # Side effects: closes handles and removes temporary files, so a failed | |||||
| 1282 | # session leaves nothing for clean_stale_temps to find an hour | |||||
| 1283 | # later. | |||||
| 1284 | sub _discard_upload :Private { | |||||
| 1285 | my ($self, $path) = @_; | |||||
| 1286 | ||||||
| 1287 | my $upload = delete $self->{uploads}{$path} or return 1; | |||||
| 1288 | eval { close $_ } for grep { defined } @{$upload}{qw(fh old_fh)}; | |||||
| 1289 | eval { unlink $upload->{temp} }; | |||||
| 1290 | return 1; | |||||
| 1291 | 300 300 300 | 113257 284 2469 | } | |||
| 1292 | ||||||
| 1293 | sub _discard_uploads :Private { | |||||
| 1294 | my ($self) = @_; | |||||
| 1295 | ||||||
| 1296 | $self->_discard_upload($_) for keys %{ $self->{uploads} }; | |||||
| 1297 | return 1; | |||||
| 1298 | 300 300 300 | 104528 277 2299 | } | |||
| 1299 | ||||||
| 1300 | # _file_stat | |||||
| 1301 | # Purpose: size, mtime and mode of a master file the client is reading. | |||||
| 1302 | # Entry: $abs is inside the master. | |||||
| 1303 | # Exit: returns { s, m, mode }. | |||||
| 1304 | # Side effects: raises server.cannot_stat or server.not_file. | |||||
| 1305 | sub _file_stat :Private { | |||||
| 1306 | my ($self, $abs, $path) = @_; | |||||
| 1307 | ||||||
| 1308 | my @stat = Time::HiRes::stat($abs); | |||||
| 1309 | $self->_throw('server.cannot_stat', { path => $path, error => os_error() }) unless @stat; | |||||
| 1310 | $self->_throw('server.not_file', { path => $path }) unless -f _; | |||||
| 1311 | return { s => $stat[7], m => $stat[9], mode => $stat[2] & $MODE_MASK }; | |||||
| 1312 | 300 300 300 | 105080 291 2397 | } | |||
| 1313 | ||||||
| 1314 | # _uint | |||||
| 1315 | # Purpose: read an unsigned integer field from a client message. | |||||
| 1316 | # Entry: $default is used when the field is absent; without one the | |||||
| 1317 | # field is required. | |||||
| 1318 | # Exit: returns the number. | |||||
| 1319 | # Side effects: raises server.bad_field for anything else (a negative or | |||||
| 1320 | # fractional offset, a string, a missing required field). | |||||
| 1321 | sub _uint :Private { | |||||
| 1322 | my ($self, $message, $field, $default) = @_; | |||||
| 1323 | ||||||
| 1324 | my $value = $message->{$field} // $default; | |||||
| 1325 | $self->_throw('server.bad_field', { field => $field, path => $message->{p} // '' }) | |||||
| 1326 | unless defined $value && $value =~ $UINT; | |||||
| 1327 | return $value + 0; | |||||
| 1328 | 300 300 300 | 99881 259 2300 | } | |||
| 1329 | ||||||
| 1330 | # _journal | |||||
| 1331 | # Purpose: append an event for this client. | |||||
| 1332 | # Entry: $event is a hashref without client. | |||||
| 1333 | # Exit: returns 1. | |||||
| 1334 | # Side effects: appends to the journal. | |||||
| 1335 | sub _journal :Private { | |||||
| 1336 | my ($self, $event) = @_; | |||||
| 1337 | ||||||
| 1338 | $self->{store}->journal({ client => $self->{client}, %{$event} }); | |||||
| 1339 | return 1; | |||||
| 1340 | 300 300 300 | 101686 298 2288 | } | |||
| 1341 | ||||||
| 1342 | # _reply | |||||
| 1343 | # Purpose: send a reply and end the handler. | |||||
| 1344 | # Entry: $reply is a hashref. | |||||
| 1345 | # Exit: returns 1. | |||||
| 1346 | # Side effects: writes to the connection. | |||||
| 1347 | sub _reply :Private { | |||||
| 1348 | my ($self, $reply) = @_; | |||||
| 1349 | ||||||
| 1350 | $self->{conn}->send($reply); | |||||
| 1351 | return 1; | |||||
| 1352 | 300 300 300 | 99305 286 2223 | } | |||
| 1353 | ||||||
| 1354 | 1; | |||||
| 1355 | ||||||