File Coverage

File:blib/lib/App/FileSync/Client.pm
Coverage:94.3%

linestmtbrancondsubtimecode
1package App::FileSync::Client;
2
3# The client end of a sync, plus the client-side commands that work on one
4# repo: log, verify, restore from the local trash, and replica snapshots.
5# Everything the client keeps for a repo lives under ~/.filesync: its cache
6# (the base of the last sync), its run log, its lock and its trash.
7
8
313
313
313
4710
290
4612
use App::FileSync::Platform;
9
10
313
313
313
566
242
2705
use strict;
11
313
313
313
459
240
6697
use warnings;
12
313
313
313
546
267
2908
use autodie qw(:all);
13
14
313
313
313
662485
279
3343
use Digest::SHA ();
15
313
313
313
549
289
9007
use File::Basename qw(dirname);
16
313
313
313
590
267
3386
use File::Spec;
17
313
313
313
595
248
2266
use File::Temp ();
18
313
313
313
511
227
7001
use List::Util qw(any);
19
313
313
313
560
236
6947
use Readonly;
20
313
313
313
524
223
1115
use Sub::Private { mode => 'enforce' };
21
313
313
313
34141
8345
2070
use Sys::Hostname ();
22
313
313
313
481
256
2527
use Time::HiRes ();
23
24
313
313
313
533
242
1139
use parent 'App::FileSync::Base';
25
26
313
313
313
9814
234
8448
use App::FileSync qw(json_encode json_decode validate_relpath validate_repo_name parse_since parse_until);
27
313
313
313
574
250
4849
use App::FileSync::Args qw(named);
28
313
313
313
507
248
4967
use App::FileSync::Config;
29
313
23274
use App::FileSync::Constants qw(
30        $PROTOCOL_VERSION $ROLE_CLIENT $ROLE_REPLICA $TYPE_FILE $TYPE_DIR $MODE_MASK
31        $EXIT_OK $EXIT_FATAL $EXIT_LOCKED $EXIT_WARNINGS
32        $ROOT_MARKER $SNAPSHOT_DIR $TEMP_PREFIX $TRASH_DAYS $SNAPSHOT_KEEP
33
313
313
474
227
);
34
313
313
313
56625
431
5723
use App::FileSync::Delta;
35
313
313
313
691
269
7855
use App::FileSync::I18N qw(os_error strip_location);
36
313
313
313
582
248
7565
use App::FileSync::IO qw(append_line ensure_dir install_file open_file read_text temp_file write_atomic);
37
313
313
313
564
233
2670
use App::FileSync::Ignore;
38
313
313
313
496
235
2248
use App::FileSync::Lock;
39
313
313
313
55572
426
4717
use App::FileSync::Protocol;
40
313
313
313
670
269
2827
use App::FileSync::Scan;
41
313
313
313
563
281
2376
use App::FileSync::Settings;
42
313
313
313
53919
446
5730
use App::FileSync::Snapshot;
43
313
313
313
51452
395
4684
use App::FileSync::Transport::Local;
44
313
313
313
50663
448
5160
use App::FileSync::Transport::SSH;
45
313
313
313
653
234
2889
use App::FileSync::Trash;
46
313
313
313
508
258
2599
use App::FileSync::Verify;
47
48
313
313
313
479
8882
4336
use version; our $VERSION = version->declare('0.001.1');
49
50# Tunables, kept as package variables so tests can localise them.
51our $CHUNK          = 262_144;
52our $MANIFEST_CHUNK = 2_000;
53
54# How many run summaries the local log keeps before trimming the oldest.
55our $LOG_LIMIT = 500;
56
57# How many runs "filesync log" shows when no limit is given.
58Readonly my $LOG_DEFAULT_LIMIT => 20;
59
60# Wire cost of one delta record (see the server's identical estimate).
61Readonly my $DELTA_OP_BYTES => 12;
62Readonly my $DELTA_OVERHEAD => 64;
63
64# Per-repo trash lives under trash/repos/NAME; "repos" can never be mistaken
65# for a year directory of the trash layout used before 0.001.1, which put
66# every repo's trash straight under trash/.
67Readonly my $TRASH_REPOS => 'repos';
68
69# Names probed to discover whether the sync root's filesystem folds case.
70Readonly my $CASE_PROBE => "${TEMP_PREFIX}caseprobe";
71
72# Summary counters, in the order the run log records them.
73Readonly my @SUMMARY_KEYS => qw(pulled pushed deleted conflicts skipped delta_pulled delta_pushed bytes_sent bytes_received);
74
75=encoding utf8
76
77 - 156
=head1 NAME

App::FileSync::Client - sync one repo, and the client-side repo commands

=head1 SYNOPSIS

        my $config = App::FileSync::Config::load_client();
        my $client = App::FileSync::Client->new(config => $config, name => 'docs', opts => { verbose => 1 });

        my $result = $client->sync;
        exit $result->{exit};

=head1 DESCRIPTION

A sync is one session with the server: hello, manifest, plan, then the
transfers the plan lists, then done.  The client keeps under
F<~/.filesync> (or C<FILESYNC_HOME>):

        cache/NAME.json          the base: what the last successful sync left
        logs/NAME.jsonl          one line per attempt, for "filesync log"
        locks/NAME.lock          held for the whole run
        trash/repos/NAME/Y/M/D/  files the sync deleted or replaced locally

Exit codes: 0 ok, 1 error, 2 another run holds the lock, 3 finished with
warnings (conflicts or skipped paths).

=head1 METHODS

=head2 new

Purpose: a client for one repo of a client configuration.

Args: C<config> (from L<App::FileSync::Config/load_client>), C<name> (the
repo's key in it), optional C<opts> (C<dry_run>, C<force>, C<no_prune>,
C<no_delta>, C<verbose>, C<quiet>), C<logger>, C<settings> (process settings,
read lazily otherwise), C<language>.

Returns: the client.

Side Effects: builds a logger from the settings when none is given.

Usage:

        my $client = App::FileSync::Client->new(config => $config, name => 'docs');

=head3 EXAMPLE

        my $client = App::FileSync::Client->new(
                config => $config, name => 'docs', opts => { dry_run => 1 },
        );

=head3 API SPECIFICATION

=head4 INPUT

        {
                config   => { type => 'hashref' },
                name     => { type => 'string', min => 1, max => 64 },
                opts     => { type => 'hashref', optional => 1 },
                logger   => { type => 'object', optional => 1 },
                settings => { type => 'hashref', optional => 1 },
                language => { type => 'string', optional => 1 },
        }

=head4 OUTPUT

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

=head3 MESSAGES

        Key              Meaning                              Resolution
        client.no_config no configuration was given           pass config
        client.bad_name  the repo name is not a plain id      rename the repo in config.json

=head3 FORMAL SPECIFICATION

        pre config ≠ ⊥ ∧ name ∈ Id
        post self.name = name ∧ self.opts = opts ∨ ∅

=cut
157
158sub new {
159
274
2690
        my $class = shift;
160
274
2718
        my $args = named(\@_, {
161                config   => { type => 'hashref', optional => 1 },
162                name     => { type => 'string', optional => 1 },
163                opts     => { type => 'hashref', optional => 1 },
164                logger   => { type => 'object', optional => 1 },
165                settings => { type => 'hashref', optional => 1 },
166                language => { type => 'string', optional => 1 },
167        }, 'Client->new');
168
169
274
836
        App::FileSync::I18N::throw('client.no_config') unless $args->{config};
170
171        # The name becomes part of the cache, log, lock and trash paths, so it
172        # must be a plain id, not something like "../x".
173
274
698
        my ($ok, $reason) = validate_repo_name($args->{name});
174
274
453
        App::FileSync::I18N::throw('client.bad_name', { reason => $reason }) unless $ok;
175
176
271
621
        my $opts = $args->{opts} || {};
177        my $self = bless {
178                config   => $args->{config},
179                name     => $args->{name},
180                opts     => $opts,
181                language => $args->{language},
182
271
2055
                logger   => $args->{logger} || App::FileSync::Settings::logger(verbose => $opts->{verbose}, quiet => $opts->{quiet}),
183        }, $class;
184
185        # Read lazily unless the caller (a test, or a command that already loaded
186        # them) supplies the process settings.
187
271
748
        $self->{settings} = $args->{settings} if exists $args->{settings};
188
271
1244
        return $self;
189}
190
191 - 260
=head2 sync

Purpose: run one sync session for the repo.

Args: none (see C<opts> in L</new>).

Returns: C<{ repo, ok, exit, summary, warnings, stats }>; with C<dry_run>,
also C<dry_run =E<gt> 1> and the planned C<actions>.  A lock refusal (here or
on the server) returns C<ok =E<gt> 0, exit =E<gt> 2, locked =E<gt> 1> rather than
raising: cron starting a run while the last one is finishing is normal.

Side Effects: changes the local tree and the master; updates the cache;
prunes this repo's local trash (unless C<no_prune>); appends to the run log,
whatever the outcome.

Usage:

        my $result = App::FileSync::Client->new(%args)->sync;

=head3 EXAMPLE

        my $result = $client->sync;
        printf "pulled %d, pushed %d\n", @{ $result->{summary} }{qw(pulled pushed)};
        print "warning: $_\n" for @{ $result->{warnings} };

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

        {
                type => 'hashref',
                schema => {
                        repo     => { type => 'string' },
                        ok       => { type => 'integer', memberof => [ 0, 1 ] },
                        exit     => { type => 'integer', memberof => [ 0, 2, 3 ] },
                        summary  => { type => 'hashref', optional => 1 },
                        warnings => { type => 'arrayref', optional => 1 },
                },
        }

=head3 MESSAGES

        Key                  Meaning                                    Resolution
        client.no_repo       the repo is not in the configuration       filesync init
        client.bad_root      the sync root is not a directory           fix root in config.json
        client.no_marker     the root has no .filesync-root marker      mount the drive, or create the marker
        client.no_client_id  the configuration has no client_id         filesync init
        client.refused       the server refused the session             read the reason
        client.req.*         the server rejected a step                 read the reason
        client.hash_mismatch a transfer arrived corrupted               rerun the sync

=head3 FORMAL SPECIFICATION

        Î”FS
        sync = ⟨exit ↦ 2⟩                              if lock held elsewhere
        sync = ⟨exit ↦ 0 | 3⟩ ∧ local' ≡ master'       if the session completes
        sync raises ∧ base' = base                     otherwise

=head3 PSEUDOCODE

        look up the repo
        run the session (lock, transport, protocol)
        record the attempt in the run log, success or failure
        log a summary; return the result, or re-raise the error

=cut
261
262sub sync {
263
169
239
        my ($self) = @_;
264
265
169
500
        my $repo = $self->_repo_config();
266
169
169
223
446
        my $result = eval { $self->_run_session($repo) };
267
268
169
3489
        unless ($result) {
269                # A run that started and then failed is still something cron attempted,
270                # so the audit trail records it before the error escapes.
271
12
35
                my $error = $@;
272
12
65
                $self->_record_run({ exit => $EXIT_FATAL, error => strip_location($error) });
273
12
231
                die $error;     ## no critic (ErrorHandling::RequireCarping) -- rethrow unchanged
274        }
275
276
157
1105
        $self->_record_run($result);
277
278
157
421
        if ($result->{ok}) {
279                $self->{logger}->info('sync finished', {
280                        repo    => $self->{name},
281
1043
2711
                        (map { $_ => ($result->{summary}{$_} || 0) } qw(pulled pushed deleted conflicts skipped bytes_sent bytes_received)),
282                        exit    => $result->{exit} || $EXIT_OK,
283
149
491
                        dry_run => $result->{dry_run} ? 1 : 0,
284                });
285        }
286
287
157
8682
        return $result;
288}
289
290 - 346
=head2 log

Purpose: recent run attempts from the local run log.

Args: optional C<limit> (default 20), C<since> and C<until> (C<YYYY-MM-DD>
or epoch, inclusive), C<exits> (an exit code or arrayref of them),
C<warnings> (only runs that recorded a warning, conflict or skipped path).
All filters apply before the limit.

Returns: C<{ repo, path, runs }> (runs oldest first), plus the filters used.

Side Effects: reads the log.  A torn final line from a killed run is skipped.

Usage:

        my $log = $client->log(limit => 5, exits => [ 1, 2 ]);

=head3 EXAMPLE

        for my $run (@{ $client->log(since => '2026-10-01')->{runs} }) {
                print "$run->{ts} exit $run->{exit}\n";
        }

=head3 API SPECIFICATION

=head4 INPUT

        {
                limit    => { type => 'integer', optional => 1 },
                since    => { type => 'string', optional => 1 },
                until    => { type => 'string', optional => 1 },
                exits    => { type => 'arrayref', optional => 1 },     # a lone code is wrapped
                warnings => { type => 'boolean', optional => 1 },
        }

=head4 OUTPUT

        { type => 'hashref', schema => { repo => { type => 'string' }, path => { type => 'string' }, runs => { type => 'arrayref' } } }

=head3 MESSAGES

        Key        Meaning                     Resolution
        when.bad   since/until is malformed    use YYYY-MM-DD or an epoch
        io.read    the log cannot be read      check permissions

=head3 FORMAL SPECIFICATION

        log = last limit ⟨ r ∈ runs | since ≤ r.ts ≤ until ∧ (exits = ∅ ∨ r.exit ∈ exits) ∧ (¬warnings ∨ has_warnings(r)) ⟩

=head3 PSEUDOCODE

        parse the filters
        read each JSON line of the log, skipping torn ones
        keep the runs that pass every filter
        keep the newest limit; report the filters used

=cut
347
348sub log {       ## no critic (Subroutines::ProhibitBuiltinHomonyms) -- public API name
349
69
158
        my ($self, %raw) = @_;
350
351        # One exit code is accepted as shorthand for a list of one.
352
69
259
        $raw{exits} = [ $raw{exits} ] if defined $raw{exits} && !ref $raw{exits};
353
69
503
        my $args = named([ %raw ], {
354                limit    => { type => 'integer', optional => 1 },
355                since    => { type => 'string', optional => 1 },
356                until    => { type => 'string', optional => 1 },
357                exits    => { type => 'arrayref', optional => 1 },
358                warnings => { type => 'scalar', optional => 1 },
359        }, 'Client::log');
360
361
69
361
        my $limit = ($args->{limit} && $args->{limit} > 0) ? $args->{limit} : $LOG_DEFAULT_LIMIT;
362
69
278
        my $since = defined $args->{since} ? parse_since($args->{since}) : undef;
363
69
133
        my $until = defined $args->{until} ? parse_until($args->{until}) : undef;
364
69
129
        my $warnings = $args->{warnings} ? 1 : 0;
365
366
69
11
69
77
17
191
        my @exits = map { $_ + 0 } @{ $args->{exits} || [] };
367
368
69
178
        my $path = $self->log_path();
369        my @runs = grep {
370
203
249
                my $ts = $_->{ts} || 0;
371
203
643
                (!defined $since || $ts >= $since)
372                        && (!defined $until || $ts <= $until)
373                        && (!$warnings || _has_warnings($_));
374
69
69
76
143
        } @{ _read_jsonl($path) };
375
376        # any's block sees its own $_, so the exit test reads the run explicitly.
377
69
150
150
42
151
114
233
121
        @runs = grep { my $run = $_; !@exits || any { ($run->{exit} || 0) == $_ } @exits } @runs;
378
69
126
        splice(@runs, 0, @runs - $limit) if @runs > $limit;
379
380        return {
381                repo => $self->{name},
382
69
515
                path => $path,
383                runs => \@runs,
384                (defined $since ? (since => $since) : ()),
385                (defined $until ? (until => $until) : ()),
386                (@exits ? (exits => \@exits) : ()),
387                ($warnings ? (warnings => 1) : ()),
388        };
389}
390
391 - 432
=head2 verify

Purpose: compare the local tree with the base the last successful sync
recorded.  Drift means the tree changed without being synced.

Args: optional C<deep> (hash every file rather than trusting size and mtime).

Returns: the L<App::FileSync::Verify> report, plus C<repo>.

Side Effects: reads the tree; logs a summary.  Works offline: the ignore list
is the one cached by the last sync.

Usage:

        my $report = $client->verify(deep => 1);

=head3 EXAMPLE

        my $report = $client->verify;
        print $report->{ok} ? "clean\n" : scalar(@{ $report->{modified} }) . " modified\n";

=head3 API SPECIFICATION

=head4 INPUT

        { deep => { type => 'boolean', optional => 1 } }

=head4 OUTPUT

        { type => 'hashref' }

=head3 MESSAGES

        Key               Meaning                                Resolution
        client.bad_root   the sync root is not a directory       fix root in config.json
        client.no_marker  the root has no .filesync-root marker  mount the drive

=head3 FORMAL SPECIFICATION

        verify = Verify(root, cache.entries, Ignore(cache.ignore ∪ {snapshots}))

=cut
433
434sub verify {
435
10
13
        my $self = shift;
436
10
37
        my $args = named(\@_, { deep => { type => 'scalar', optional => 1 } }, 'Client::verify');
437
438
10
30
        my $root = $self->_checked_root($self->_repo_config());
439
10
23
        my $cache = $self->cache();
440
10
24
        my $patterns = ref($cache->{ignore}) eq 'ARRAY' ? $cache->{ignore} : \@App::FileSync::Config::DEFAULT_IGNORE;
441
442        my $report = App::FileSync::Verify::verify(
443                root    => $root,
444                entries => $cache->{entries},
445                ignore  => App::FileSync::Ignore->new(patterns => _with_reserved($patterns)),
446                deep    => $args->{deep},
447
10
27
        );
448
10
101
        $report->{repo} = $self->{name};
449
450        $self->{logger}->info('verify finished', {
451                repo  => $self->{name},
452                files => $report->{files},
453
30
30
22
63
                (map { $_ => scalar @{ $report->{$_} } } qw(modified missing extra)),
454
10
20
                ok    => $report->{ok} ? 1 : 0,
455        });
456
457
10
317
        return $report;
458}
459
460 - 520
=head2 restore

Purpose: put a path back from this repo's local trash.  The newest stored
version wins; C<list> shows them all instead.

Args: C<path>, optional C<to> (a directory; default the sync root), C<list>.

Versions are searched in this repo's trash first, then in the shared trash
that releases before 0.001.1 used, so files trashed before an upgrade can
still be restored.

Returns: with C<list>, C<{ repo, path, versions }>; otherwise
C<{ repo, path, from, to }>.

Side Effects: copies the version out; the trash entry is kept.

Usage:

        $client->restore(path => 'notes/a.txt', to => '/tmp/out');

=head3 EXAMPLE

        my $versions = $client->restore(path => 'a.txt', list => 1)->{versions};
        print "$_->{date} $_->{size}\n" for @{$versions};

=head3 API SPECIFICATION

=head4 INPUT

        {
                path => { type => 'string', min => 1 },
                to   => { type => 'string', optional => 1 },
                list => { type => 'boolean', optional => 1 },
        }

=head4 OUTPUT

        { type => 'hashref' }

=head3 MESSAGES

        Key                 Meaning                                 Resolution
        client.unsafe       the path fails validate_relpath         fix the path
        client.no_versions  nothing of that path is in the trash    check --list
        client.not_dir      the target is not a directory           choose another
        client.exists       the destination exists                  move it aside or use --to

=head3 FORMAL SPECIFICATION

        Î”FS
        pre path ∈ SafePath ∧ versions(path) ≠ ⟨⟩ ∧ dest ∉ dom FS
        post tree'(dest) = tree(head versions(path))

=head3 PSEUDOCODE

        validate the path; list versions from the repo trash then the legacy trash
        if listing, return them
        destination := to (created) or the sync root, plus the path
        refuse to overwrite; copy the newest version out

=cut
521
522sub restore {
523
12
16
        my $self = shift;
524
12
55
        my $args = named(\@_, {
525                path => { type => 'string', optional => 1 },
526                to   => { type => 'string', optional => 1 },
527                list => { type => 'scalar', optional => 1 },
528        }, 'Client::restore');
529
530
12
35
        my $repo = $self->_repo_config();
531
12
14
        my $path = $args->{path};
532
12
26
        my ($safe, $reason) = validate_relpath($path);
533
12
18
        $self->_throw('client.unsafe', { path => $path // '', reason => $reason }) unless $safe;
534
535
12
24
24
28
20
38
        my @versions = map { @{ App::FileSync::Trash::versions(root => $_, rel => $path) } }
536                $self->_trash_root(), $self->_legacy_trash_root();
537
538
12
26
        if ($args->{list}) {
539
3
2
28
5
                return { repo => $self->{name}, path => $path, versions => [ map { App::FileSync::Trash::version_info($_) } @versions ] };
540        }
541
542
9
21
        $self->_throw('client.no_versions', { path => $path, repo => $self->{name}, trash => $self->_trash_root() })
543                unless @versions;
544
545
7
22
        my $to_root = (defined $args->{to} && length $args->{to}) ? $args->{to} : $repo->{root};
546
7
15
        ensure_dir($to_root);
547
7
19
        $self->_throw('client.not_dir', { path => $to_root }) unless -d $to_root;
548
549
7
36
        my $destination = File::Spec->catfile($to_root, split m{/}, $path);
550
7
81
        $self->_throw('client.exists', { path => $destination }) if -e $destination || -l $destination;
551
552
5
15
        App::FileSync::Trash::restore(src => $versions[0], to => $destination);
553
554
5
18
        my %done = (path => $path, from => $versions[0], to => $destination);
555
5
23
        $self->{logger}->info('restored path', { repo => $self->{name}, %done });
556
5
144
        return { repo => $self->{name}, %done };
557}
558
559 - 610
=head2 snapshot_create

Purpose: take today's snapshot of a replica.

Args: optional C<keep> (how many snapshots to keep; default the repo's
C<snapshot_keep>, else 14; 0 keeps all).

Returns: C<{ repo, ok, exit, keep, pruned, older, ... }> with the
L<App::FileSync::Snapshot/create> result merged in; C<exit =E<gt> 2> when
another run holds the lock.

Side Effects: holds the repo lock; creates a snapshot; prunes old ones when a
new one was made.

Usage:

        my $made = $client->snapshot_create(keep => 7);

=head3 EXAMPLE

        my $made = $client->snapshot_create;
        print $made->{created} ? "snapshot $made->{date}\n" : "already have $made->{date}\n";

=head3 API SPECIFICATION

=head4 INPUT

        { keep => { type => 'integer', min => 0, optional => 1 } }

=head4 OUTPUT

        { type => 'hashref' }

=head3 MESSAGES

        Key                 Meaning                           Resolution
        client.not_replica  the repo is a client              snapshots are for replicas
        snapshot.*          see App::FileSync::Snapshot

=head3 FORMAL SPECIFICATION

        pre role = replica
        created ∧ keep > 0 ⇒ #snapshots' ≤ keep

=head3 PSEUDOCODE

        refuse unless the repo is a replica
        take the lock, or report that another run holds it
        choose the mode and the number to keep
        create today's snapshot; prune if one was made

=cut
611
612sub snapshot_create {
613
4
5
        my $self = shift;
614
4
17
        my $args = named(\@_, { keep => { type => 'integer', min => 0, optional => 1 } }, 'Client::snapshot_create');
615
616
4
14
        my $repo = $self->_repo_config();
617        $self->_throw('client.not_replica', { repo => $self->{name} })
618
4
19
                unless ($repo->{role} || $ROLE_CLIENT) eq $ROLE_REPLICA;
619
620
3
17
        my $lock = $self->_lock() or return $self->_locked_result();
621
622        my $mode = (defined $repo->{snapshot_mode} && length $repo->{snapshot_mode})
623                ? $repo->{snapshot_mode}
624
3
17
                : App::FileSync::Snapshot::default_mode();
625
3
18
        my $keep = $args->{keep} // $repo->{snapshot_keep} // $SNAPSHOT_KEEP;
626
627
3
11
        my $created = App::FileSync::Snapshot::create(root => $repo->{root}, mode => $mode);
628        my $pruned = ($created->{created} && $keep > 0)
629
3
23
                ? App::FileSync::Snapshot::prune(root => $repo->{root}, keep => $keep)
630                : { removed => [], kept => [] };
631
632        return {
633                repo   => $self->{name},
634                ok     => 1,
635                exit   => $EXIT_OK,
636
3
10
                %{$created},
637                keep   => $keep,
638                pruned => $pruned->{removed},
639                older  => $pruned->{kept},
640
3
6
        };
641}
642
643 - 680
=head2 snapshot_list

Purpose: a replica's snapshots, newest first.

Args: none.

Returns: C<{ repo, snapshots }>.

Side Effects: walks every snapshot to count it.

Usage:

        my $list = $client->snapshot_list;

=head3 EXAMPLE

        print "$_->{date}\n" for @{ $client->snapshot_list->{snapshots} };

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

        { type => 'hashref', schema => { repo => { type => 'string' }, snapshots => { type => 'arrayref' } } }

=head3 MESSAGES

        Key              Meaning                            Resolution
        client.no_repo   the repo is not in the config      filesync init

=head3 FORMAL SPECIFICATION

        snapshot_list = ⟨repo ↦ name, snapshots ↦ Snapshot::list(root)⟩

=cut
681
682sub snapshot_list {
683
2
4
        my ($self) = @_;
684
685
2
12
        return { repo => $self->{name}, snapshots => App::FileSync::Snapshot::list(root => $self->_repo_config()->{root}) };
686}
687
688 - 733
=head2 snapshot_restore

Purpose: copy a snapshot (or one path of it) back.

Args: C<date>, optional C<path> (one relative path; default everything) and
C<to> (default the sync root, which rolls the replica back).

Returns: the L<App::FileSync::Snapshot/restore> result plus C<repo>, C<ok>,
C<exit> and C<into_root>; C<exit =E<gt> 2> when another run holds the lock.

Side Effects: holds the repo lock; replaces files under C<to>.

Usage:

        $client->snapshot_restore(date => '2026-10-20', path => 'notes');

=head3 EXAMPLE

        my $done = $client->snapshot_restore(date => '2026-10-20', to => '/tmp/export');
        print "$done->{files} files\n";

=head3 API SPECIFICATION

=head4 INPUT

        {
                date => { type => 'string', optional => 1 },
                path => { type => 'string', optional => 1 },
                to   => { type => 'string', optional => 1 },
        }

=head4 OUTPUT

        { type => 'hashref' }

=head3 MESSAGES

See L<App::FileSync::Snapshot/restore>.

=head3 FORMAL SPECIFICATION

        Î”FS
        into_root ⇔ to = root
        tree'(to / p) = tree(snapshot(date) / p) for each restored p

=cut
734
735sub snapshot_restore {
736
2
4
        my $self = shift;
737
2
12
        my $args = named(\@_, {
738                date => { type => 'string', optional => 1 },
739                path => { type => 'string', optional => 1 },
740                to   => { type => 'string', optional => 1 },
741        }, 'Client::snapshot_restore');
742
743
2
9
        my $repo = $self->_repo_config();
744
2
6
        my $lock = $self->_lock() or return $self->_locked_result();
745
746
2
8
        my $to = (defined $args->{to} && length $args->{to}) ? $args->{to} : $repo->{root};
747        my $result = App::FileSync::Snapshot::restore(
748                root  => $repo->{root},
749                date  => $args->{date},
750
2
47
                paths => (defined $args->{path} && length $args->{path}) ? [ $args->{path} ] : [],
751                to    => $to,
752        );
753
754
2
2
7
9
        return { repo => $self->{name}, ok => 1, exit => $EXIT_OK, into_root => ($to eq $repo->{root} ? 1 : 0), %{$result} };
755}
756
757 - 799
=head2 cache_path

Documented with L</log_path> below.

=head2 log_path

Purpose: where this repo's cache and run log live.

Args: none.

Returns: F<HOME/cache/NAME.json>, F<HOME/logs/NAME.jsonl>.

Side Effects: none.

Usage:

        my $path = $client->cache_path;

=head3 EXAMPLE

        print $client->log_path, "\n";

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

        { type => 'string', min => 1 }

=head3 MESSAGES

        Key              Meaning                        Resolution
        config.no_home   no home directory is known     set HOME or FILESYNC_HOME

=head3 FORMAL SPECIFICATION

        cache_path = home / "cache" / name ⁀ ".json"
        log_path   = home / "logs" / name ⁀ ".jsonl"

=cut
800
801sub cache_path {
802
311
476
        my ($self) = @_;
803
804
311
1408
        return $self->_home_file('cache', "$self->{name}.json");
805}
806
807sub log_path {
808
238
364
        my ($self) = @_;
809
810
238
1151
        return $self->_home_file('logs', "$self->{name}.jsonl");
811}
812
813 - 851
=head2 cache

Purpose: the cached base and last-run summary.

Args: none.

Returns: the cache hashref, always with an C<entries> hashref; an empty cache
when there is none or it is unreadable (the next sync rebuilds it).

Side Effects: reads the cache file.

Usage:

        my $last = $client->cache->{last_run};

=head3 EXAMPLE

        my $cache = $client->cache;
        printf "%d files at the last sync\n", scalar keys %{ $cache->{entries} };

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

        { type => 'hashref', schema => { entries => { type => 'hashref' } } }

=head3 MESSAGES

None; a damaged cache is treated as empty.

=head3 FORMAL SPECIFICATION

        cache = json(FS(cache_path)) if that parses to an object, else ⟨entries ↦ ∅⟩

=cut
852
853sub cache {
854
168
258
        my ($self) = @_;
855
856
168
580
        my $path = $self->cache_path();
857
168
102
1141
365
        my $cache = -e $path ? eval { json_decode(read_text($path)) } : undef;
858
168
438
        $cache = {} unless ref($cache) eq 'HASH';
859
168
532
        $cache->{entries} = {} unless ref($cache->{entries}) eq 'HASH';
860
861
168
494
        return $cache;
862}
863
864# _run_session
865# Purpose:     one attempt: check the root, lock, connect, run the session.
866# Entry:       $repo is this repo's client config.
867# Exit:        returns a result hashref; a lock refusal is a result, not an
868#              exception.
869# Side effects: holds the lock for the session; starts and closes the
870#              transport.  When the transport failed, its own error text
871#              ("Permission denied (publickey)", a host key mismatch) is
872#              raised instead of the protocol error it caused.
873sub _run_session :Private {
874        my ($self, $repo) = @_;
875
876        my $root = $self->_checked_root($repo);
877        my $lock = $self->_lock() or return $self->_locked_result();
878
879        my $transport = $self->_transport($repo);
880        my $connection = App::FileSync::Protocol->new(in => $transport->reader(), out => $transport->writer());
881
882        my $result = eval { $self->_session($connection, $repo, $root) };
883        my $error = $@;
884        $transport->close();
885        return $result if $result;
886
887        if ($connection->transport_failed) {
888                my $text = eval { $transport->error_text() };
889                $error = "$text\n" if defined $text && length $text;
890        }
891        die $error;     ## no critic (ErrorHandling::RequireCarping) -- rethrow unchanged
892
313
313
313
352074
1836
4396
}
893
894# _session
895# Purpose:     the protocol conversation, from hello to done.
896# Entry:       $connection is open; $root has been checked.
897# Exit:        returns the result hashref.
898# Side effects: scans the tree; applies the plan; saves the cache and prunes
899#              the trash after a real (not dry) run.
900sub _session :Private {
901        my ($self, $connection, $repo, $root) = @_;
902
903        my $opts = $self->{opts};
904        my $client_id = $self->{config}{client_id};
905        $self->_throw('client.no_client_id') unless defined $client_id && length $client_id;
906
907        $connection->send({
908                cmd              => 'hello',
909                proto            => $PROTOCOL_VERSION,
910                client           => $client_id,
911                host             => Sys::Hostname::hostname(),
912                os               => $^O,
913                role             => $repo->{role} || $ROLE_CLIENT,
914                repo             => $repo->{remote_repo} || $self->{name},
915                case_insensitive => _case_insensitive($root),
916                now              => time,
917                dry_run          => $opts->{dry_run} ? 1 : 0,
918                force            => $opts->{force} ? 1 : 0,
919        });
920
921        my $hello = $connection->recv();
922        unless ($hello->{ok}) {
923                my $reason = $hello->{error} || $self->i18n('client.unknown_reason');
924                # Lock contention on the server is as normal as it is here.
925                return { %{ $self->_locked_result($reason) }, summary => { map { $_ => 0 } qw(pulled pushed deleted conflicts) } }
926                        if ($hello->{code} // '') eq 'locked';
927                $self->_throw('client.refused', { reason => $reason });
928        }
929
930        my $policy = $hello->{policy} || {};
931        my $patterns = _with_reserved($policy->{ignore} || []);
932        my $ignore = App::FileSync::Ignore->new(patterns => $patterns, case_insensitive => $hello->{case_insensitive} ? 1 : 0);
933
934        App::FileSync::Scan::clean_stale_temps(root => $root);
935        my ($manifest, $stats, $skipped) = @{ App::FileSync::Scan::scan(
936                root   => $root,
937                ignore => $ignore,
938                cache  => $self->cache()->{entries},
939        ) }{qw(manifest stats skipped)};
940
941        $self->_send_manifest($connection, $manifest);
942
943        my $plan = $self->_request($connection, { cmd => 'plan' }, 'client.req.plan', {});
944        my @actions = @{ $plan->{actions} || [] };
945        my @warnings = (@{ $plan->{warnings} || [] },
946                map { $self->i18n('client.skipped', { path => $_, reason => $skipped->{$_} }) } sort keys %{$skipped});
947
948        my %planned = (
949                pulled    => scalar(grep { $_->{a} eq 'pull' } @actions),
950                pushed    => scalar(grep { $_->{a} eq 'push' } @actions),
951                deleted   => scalar(grep { $_->{a} =~ /\Adelete_/ } @actions),
952                conflicts => scalar(grep { $_->{a} eq 'conflict' } @actions),
953        );
954        $self->{logger}->debug('plan received', {
955                repo => $self->{name}, actions => scalar @actions, deletions => $planned{deleted},
956                conflicts => $planned{conflicts}, dry_run => $opts->{dry_run} ? 1 : 0,
957        });
958
959        my $summary = { (map { $_ => 0 } qw(pulled pushed deleted conflicts delta_pulled delta_pushed)), skipped => scalar(keys %{$skipped}) };
960        my %result = (repo => $self->{name}, ok => 1, summary => $summary, warnings => \@warnings, stats => $stats);
961
962        if ($opts->{dry_run}) {
963                $self->_request($connection, { cmd => 'done' }, 'client.req.end', {});
964                # Nothing but headers and control messages has crossed the connection.
965                %{$summary} = (%{$summary}, %planned, bytes_sent => $connection->bytes_sent, bytes_received => $connection->bytes_received);
966                return { %result, exit => @warnings ? $EXIT_WARNINGS : $EXIT_OK, dry_run => 1, actions => \@actions };
967        }
968
969        if ($opts->{verbose}) {
970                for my $action (@actions) {
971                        (my $verb = $action->{a}) =~ tr/_/ /;
972                        print $self->i18n('client.verbose_action', { verb => $verb, path => $action->{p} }), "\n";
973                }
974        }
975
976        my %after = %{$manifest};
977        $self->_apply($connection, $_, \%after, $summary) for @actions;
978
979        $self->_request($connection, { cmd => 'done' }, 'client.req.end', {});
980        @{$summary}{qw(delta_pulled delta_pushed)} = map { $self->{$_} || 0 } qw(delta_pulled delta_pushed);
981        @{$summary}{qw(bytes_sent bytes_received)} = ($connection->bytes_sent, $connection->bytes_received);
982
983        $self->_save_cache(\%after, $summary, $patterns);
984        $self->_prune_local_trash($repo) unless $opts->{no_prune};
985
986        return { %result, exit => @warnings ? $EXIT_WARNINGS : $EXIT_OK };
987
313
313
313
231148
458
3024
}
988
989# _apply
990# Purpose:     carry out one planned action.
991# Entry:       $after is the manifest being turned into the new base.
992# Exit:        returns 1.
993# Side effects: transfers, deletes or resolves; counts it in $summary.
994sub _apply :Private {
995        my ($self, $connection, $action, $after, $summary) = @_;
996
997        my ($type, $path) = @{$action}{qw(a p)};
998        if ($type eq 'pull') {
999                $self->_pull($connection, $action, $after);
1000                $summary->{pulled}++;
1001        } elsif ($type eq 'push') {
1002                $self->_push($connection, $action);
1003                $summary->{pushed}++;
1004        } elsif ($type eq 'delete_remote') {
1005                $self->_request($connection, { cmd => 'del', p => $path }, 'client.req.delete', { path => $path });
1006                delete $after->{$path};
1007                $summary->{deleted}++;
1008        } elsif ($type eq 'delete_local') {
1009                $self->_delete_local($path);
1010                $self->_request($connection, { cmd => 'ack', p => $path, deleted => 1 }, 'client.req.ack_delete', { path => $path });
1011                delete $after->{$path};
1012                $summary->{deleted}++;
1013        } elsif ($type eq 'conflict') {
1014                $self->_resolve_conflict($connection, $action, $after);
1015                $summary->{conflicts}++;
1016        } else {
1017                $self->_throw('client.unknown_action', { action => $type, path => $path });
1018        }
1019
1020        return 1;
1021
313
313
313
129422
235
2562
}
1022
1023# _send_manifest
1024# Purpose:     send the scanned manifest in parts.
1025# Entry:       $manifest maps paths to entries.
1026# Exit:        returns 1.
1027# Side effects: one request per $MANIFEST_CHUNK entries (at least one, so an
1028#              empty tree still sends an empty manifest).
1029sub _send_manifest :Private {
1030        my ($self, $connection, $manifest) = @_;
1031
1032        my @entries = map { { p => $_, %{ $manifest->{$_} } } } sort keys %{$manifest};
1033        my $parts = @entries ? int((@entries - 1) / $MANIFEST_CHUNK) + 1 : 1;
1034
1035        for my $part (0 .. $parts - 1) {
1036                my @slice = splice(@entries, 0, $MANIFEST_CHUNK);
1037                $self->_request($connection, { cmd => 'manifest', part => $part, of => $parts, entries => \@slice },
1038                        'client.req.manifest', {});
1039        }
1040
1041        return 1;
1042
313
313
313
117240
263
2675
}
1043
1044# _pull
1045# Purpose:     fetch a master file (or directory) the plan says to pull.
1046# Entry:       $action has p, t and maybe h (the expected hash).
1047# Exit:        returns 1.
1048# Side effects: replaces the local path; acknowledges it; records it in
1049#              $after.  Tries a delta first when there is a local version.
1050sub _pull :Private {
1051        my ($self, $connection, $action, $after) = @_;
1052
1053        my $path = $action->{p};
1054        my $abs  = $self->_abs($path);
1055
1056        if (($action->{t} || $TYPE_FILE) eq $TYPE_DIR) {
1057                ensure_dir($abs);
1058                $self->_request($connection, { cmd => 'ack', p => $path, t => $TYPE_DIR }, 'client.req.ack_dir', { path => $path });
1059                $after->{$path} = { t => $TYPE_DIR };
1060                return 1;
1061        }
1062
1063        return 1 if $self->_pull_delta($connection, $action, $after, $abs);
1064
1065        my $receipt = $self->_receive($abs, sub {
1066                my ($write) = @_;
1067                my ($offset, %server) = (0);
1068                while (1) {
1069                        my $header = $self->_request($connection, { cmd => 'get', p => $path, off => $offset, max => $CHUNK },
1070                                'client.req.get', { path => $path });
1071                        my $bytes = $connection->read_data($header);
1072                        $write->($bytes);
1073                        $offset += length $bytes;
1074                        $server{$_} = $header->{$_} for grep { defined $header->{$_} } qw(s m mode h);
1075                        last if $header->{eof};
1076                }
1077                return \%server;
1078        });
1079
1080        return $self->_finish_pull($connection, $action, $after, $abs, $receipt);
1081
313
313
313
137095
265
2705
}
1082
1083# _pull_delta
1084# Purpose:     receive only the blocks that differ from the local copy.
1085# Entry:       $abs is the local path.
1086# Exit:        returns 1 if the file was pulled as a delta, 0 (without
1087#              touching the connection beyond the signature) when a
1088#              whole-file pull is the right answer.
1089# Side effects: as _pull.
1090sub _pull_delta :Private {
1091        my ($self, $connection, $action, $after, $abs) = @_;
1092
1093        return 0 if $self->{opts}{no_delta} || !-f $abs || -l $abs;
1094        my $local_size = -s $abs;
1095        return 0 if $local_size < $App::FileSync::Delta::MIN_SIZE;
1096
1097        my $path = $action->{p};
1098        my $block = App::FileSync::Delta::block_size($local_size);
1099        my $signature = App::FileSync::Delta::signature($abs, $block) or return 0;
1100
1101        my $reply = $self->_request($connection, {
1102                cmd => 'dsig',
1103                p   => $path,
1104                s   => $local_size,
1105                b   => $block,
1106                n   => scalar @{ $signature->{blocks} },
1107                (defined $action->{h} ? (h => $action->{h}) : ()),
1108        }, 'client.req.signature', { path => $path }, App::FileSync::Delta::encode_signature($signature));
1109        return 0 unless $reply->{delta};
1110
1111        my $old = open_file($abs, '<:raw');
1112        my $receipt = $self->_receive($abs, sub {
1113                my ($write) = @_;
1114                while (1) {
1115                        my $op = $self->_request($connection, { cmd => 'dop', p => $path }, 'client.req.get', { path => $path });
1116                        last if $op->{done};
1117                        $write->($self->_delta_bytes($connection, $op, $old, $abs, $path, $local_size));
1118                }
1119                return { map { $_ => $reply->{$_} } grep { defined $reply->{$_} } qw(s m mode h) };
1120        }, $old);
1121
1122        $self->_finish_pull($connection, $action, $after, $abs, $receipt);
1123        $self->{delta_pulled}++;
1124        return 1;
1125
313
313
313
147811
252
3357
}
1126
1127# _delta_bytes
1128# Purpose:     the bytes one pull-delta record stands for.
1129# Entry:       $op is a reply with op copy (off, len) or lit (data follows).
1130# Exit:        returns the bytes.
1131# Side effects: reads the local old version or the connection; raises
1132#              client.bad_copy or client.bad_op for anything malformed.
1133sub _delta_bytes :Private {
1134        my ($self, $connection, $op, $old, $abs, $path, $local_size) = @_;
1135
1136        my $kind = $op->{op} // '';
1137        return $connection->read_data($op) if $kind eq 'lit';
1138        $self->_throw('client.bad_op', { path => $path }) unless $kind eq 'copy';
1139
1140        my ($offset, $length) = @{$op}{qw(off len)};
1141        $self->_throw('client.bad_copy', { path => $path })
1142                unless defined $offset && $offset =~ /\A\d+\z/ && defined $length && $length =~ /\A\d+\z/
1143                        && $offset + $length <= $local_size;
1144
1145        return '' unless $length;
1146        seek($old, $offset, 0);
1147        return App::FileSync::Delta::read_exact($old, $length, $abs);
1148
313
313
313
129464
340
2938
}
1149
1150# _receive
1151# Purpose:     the shared body of every pull: a temporary file beside the
1152#              target, a running hash, and cleanup on failure.
1153# Entry:       $fill->($write) reads from the server, calling $write->($bytes)
1154#              for each piece, and returns what the server said about the
1155#              file ({ s, m, mode, h }); @close are extra handles to close.
1156# Exit:        returns { temp, hash, written, server }.
1157# Side effects: creates the temporary file; on any error removes it, closes
1158#              @close and re-raises.
1159sub _receive :Private {
1160        my ($self, $abs, $fill, @close) = @_;
1161
1162        my ($out, $temp) = @{ temp_file(dirname($abs)) };
1163        my $sha = Digest::SHA->new(256);
1164        my $written = 0;
1165
1166        my $server = eval {
1167                my $info = $fill->(sub {
1168                        my ($bytes) = @_;
1169                        print {$out} $bytes or $self->_throw('io.write', { path => $temp, error => os_error() });
1170                        $sha->add($bytes);
1171                        $written += length $bytes;
1172                });
1173                close $out;
1174                $info;
1175        };
1176        my $error = $@;
1177        eval { close $_ } for grep { defined } @close;
1178
1179        unless ($server) {
1180                eval { close $out };
1181                eval { unlink $temp };
1182                die $error;     ## no critic (ErrorHandling::RequireCarping) -- rethrow unchanged
1183        }
1184
1185        return { temp => $temp, hash => $sha->hexdigest(), written => $written, server => $server };
1186
313
313
313
136307
263
2670
}
1187
1188# _finish_pull
1189# Purpose:     verify a received file, install it, and acknowledge it.
1190# Entry:       $receipt is from _receive.
1191# Exit:        returns 1.
1192# Side effects: replaces the local path (whatever was there: a file, a
1193#              symlink or an empty directory); records the new entry in
1194#              $after.  A mismatch removes the temporary file and raises.
1195sub _finish_pull :Private {
1196        my ($self, $connection, $action, $after, $abs, $receipt) = @_;
1197
1198        my ($path, $server, $got) = ($action->{p}, $receipt->{server}, $receipt->{hash});
1199        my $expected = $action->{h} // $server->{h};
1200
1201        my ($key, %args) =
1202                  (defined $expected && $got ne $expected) ? ('client.hash_mismatch', expected => $expected, got => $got)
1203                : (defined $server->{s} && $receipt->{written} != $server->{s}) ? ('client.size_mismatch', expected => $server->{s}, got => $receipt->{written})
1204                : ();
1205        if ($key) {
1206                eval { unlink $receipt->{temp} };
1207                $self->_throw($key, { path => $path, %args });
1208        }
1209
1210        install_file($receipt->{temp}, $abs, $server->{m}, $server->{mode});
1211
1212        my %entry = (h => $got, s => $receipt->{written}, m => $server->{m}, mode => $server->{mode});
1213        $self->_request($connection, { cmd => 'ack', p => $path, %entry }, 'client.req.ack', { path => $path });
1214        $after->{$path} = { t => $TYPE_FILE, %entry };
1215        return 1;
1216
313
313
313
126855
258
2541
}
1217
1218# _push
1219# Purpose:     send a local file (or directory) the plan says to push.
1220# Entry:       $action has p, t, h and, for a conflict this side won,
1221#              loser_name.
1222# Exit:        returns 1.
1223# Side effects: transfers the file, as a delta when that is cheaper (never
1224#              for a conflict, where the server must first move its version
1225#              aside).
1226sub _push :Private {
1227        my ($self, $connection, $action) = @_;
1228
1229        my $path = $action->{p};
1230        my $abs  = $self->_abs($path);
1231
1232        if (($action->{t} || $TYPE_FILE) eq $TYPE_DIR) {
1233                $self->_request($connection, { cmd => 'mkdir', p => $path }, 'client.req.mkdir', { path => $path });
1234                return 1;
1235        }
1236
1237        my @stat = Time::HiRes::stat($abs);
1238        $self->_throw('client.cannot_stat', { path => $abs, error => os_error() }) unless @stat;
1239        my %file = (s => $stat[7], m => $stat[9], mode => $stat[2] & $MODE_MASK);
1240
1241        return 1 if !$action->{loser_name} && $self->_push_delta($connection, $path, $abs, \%file, $action->{h});
1242
1243        my $in = open_file($abs, '<:raw');
1244
1245        # The server acknowledges the header before any chunk is sent; reading it
1246        # here keeps the lock-step protocol aligned.
1247        $self->_request($connection, {
1248                cmd => 'put', p => $path, t => $TYPE_FILE, %file, h => $action->{h},
1249                ($action->{loser_name} ? (conflict_name => $action->{loser_name}) : ()),
1250        }, 'client.req.put', { path => $path });
1251
1252        while (read($in, my $buffer, $CHUNK)) {
1253                $self->_request($connection, { cmd => 'chunk', p => $path }, 'client.req.chunk', { path => $path }, $buffer);
1254        }
1255        close $in;
1256
1257        $self->_request($connection, { cmd => 'chunk_end', p => $path }, 'client.req.store', { path => $path });
1258        return 1;
1259
313
313
313
140466
313
2595
}
1260
1261# _push_delta
1262# Purpose:     send only the blocks that changed since the master's version.
1263# Entry:       %{$file} has s, m, mode; $hash is the planned hash.
1264# Exit:        returns 1 if pushed as a delta, 0 when a whole-file put is
1265#              cheaper or there is nothing to reuse.
1266# Side effects: spools the delta to a temporary file beside $abs.
1267sub _push_delta :Private {
1268        my ($self, $connection, $path, $abs, $file, $hash) = @_;
1269
1270        return 0 if $self->{opts}{no_delta} || !defined $hash || $file->{s} < $App::FileSync::Delta::MIN_SIZE;
1271
1272        my $reply = $self->_request($connection, { cmd => 'sig', p => $path }, 'client.req.signature', { path => $path });
1273        return 0 unless $reply->{delta};
1274
1275        my $blocks = App::FileSync::Delta::decode_signature($connection->read_data($reply), $reply->{n});
1276
1277        my $spool = File::Temp->new(TEMPLATE => "${TEMP_PREFIX}delta-XXXXXXXX", DIR => dirname($abs), UNLINK => 1);
1278        binmode $spool;
1279        my $stats = App::FileSync::Delta::diff($abs, { size => $reply->{s}, blocklen => $reply->{b}, blocks => $blocks }, $spool);
1280
1281        my $estimated = $stats->{literal} + $DELTA_OP_BYTES * $stats->{ops} + $DELTA_OVERHEAD;
1282        return 0 if $estimated >= $file->{s};
1283
1284        $self->_request($connection, { cmd => 'delta', p => $path, %{$file}, h => $hash }, 'client.req.delta', { path => $path });
1285
1286        seek($spool, 0, 0);
1287        while (my ($type, @rest) = App::FileSync::Delta::read_op($spool)) {
1288                my ($message, $bytes) = $type eq 'copy'
1289                        ? ({ cmd => 'dcopy', p => $path, off => $rest[0], len => $rest[1] })
1290                        : ({ cmd => 'dlit', p => $path }, $rest[0]);
1291                $self->_request($connection, $message, 'client.req.delta_data', { path => $path }, $bytes);
1292        }
1293
1294        $self->_request($connection, { cmd => 'delta_end', p => $path }, 'client.req.store', { path => $path });
1295        $self->{delta_pushed}++;
1296        return 1;
1297
313
313
313
150226
319
2674
}
1298
1299# _resolve_conflict
1300# Purpose:     carry out a conflict resolution.
1301# Entry:       $action has p, winner (client or remote), winner_type, h,
1302#              loser_name (when the losing side is a file).
1303# Exit:        returns 1.
1304# Side effects: when the master won, moves the local version aside under
1305#              loser_name and pulls; when this side won, pushes (the server
1306#              moves its version aside).
1307sub _resolve_conflict :Private {
1308        my ($self, $connection, $action, $after) = @_;
1309
1310        my ($path, $loser) = @{$action}{qw(p loser_name)};
1311        my %winner = (p => $path, t => $action->{winner_type} || $TYPE_FILE, (defined $action->{h} ? (h => $action->{h}) : ()));
1312
1313        return $self->_push($connection, { %winner, loser_name => $loser }) if $action->{winner} ne 'remote';
1314
1315        my $abs = $self->_abs($path);
1316        if ((-e $abs || -l $abs) && defined $loser) {
1317                my $conflict = $self->_abs($loser);
1318                $self->_throw('client.conflict_exists', { path => $conflict }) if -e $conflict || -l $conflict;
1319                ensure_dir(dirname($conflict));
1320                eval { rename $abs, $conflict; 1 }
1321                        or $self->_throw('client.conflict_move', { path => $path, to => $loser, error => os_error($@) });
1322                $after->{$loser} = delete $after->{$path};
1323        }
1324
1325        return $self->_pull($connection, \%winner, $after);
1326
313
313
313
136611
261
2626
}
1327
1328# _delete_local
1329# Purpose:     remove a local path the master deleted.
1330# Entry:       $path is relative.
1331# Exit:        returns 1.
1332# Side effects: moves the path into this repo's local trash.
1333sub _delete_local :Private {
1334        my ($self, $path) = @_;
1335
1336        my $abs = $self->_abs($path);
1337        return 1 unless -e $abs || -l $abs;
1338
1339        App::FileSync::Trash::move_to_trash(src => $abs, rel => $path, root => $self->_trash_root(), epoch => time);
1340        return 1;
1341
313
313
313
110759
311
2397
}
1342
1343# _request
1344# Purpose:     one lock-step exchange: send, receive, insist on ok.
1345# Entry:       $key is the catalogue key for a refusal, $args its arguments;
1346#              $bytes, when defined, is sent as a data payload.
1347# Exit:        returns the reply.
1348# Side effects: raises $key with the server's reason.
1349sub _request :Private {
1350        my ($self, $connection, $message, $key, $args, $bytes) = @_;
1351
1352        defined $bytes ? $connection->send_data($message, $bytes) : $connection->send($message);
1353        my $reply = $connection->recv();
1354        $self->_throw($key, { %{$args}, reason => $reply->{error} || $self->i18n('client.unknown_reason') })
1355                unless $reply->{ok};
1356
1357        return $reply;
1358
313
313
313
112462
257
2352
}
1359
1360# _abs
1361# Purpose:     the local path for a relative path from the server.
1362# Entry:       $path came over the wire.
1363# Exit:        returns ROOT/path.
1364# Side effects: raises client.unsafe for a path that could escape the root.
1365sub _abs :Private {
1366        my ($self, $path) = @_;
1367
1368        my ($ok, $reason) = validate_relpath($path);
1369        $self->_throw('client.unsafe', { path => $path // '', reason => $reason }) unless $ok;
1370        return File::Spec->catfile($self->_repo_config()->{root}, split m{/}, $path);
1371
313
313
313
113914
268
2524
}
1372
1373# _transport
1374# Purpose:     build the transport the repo's config asks for.
1375# Entry:       $repo is this repo's client config.
1376# Exit:        returns a started transport.
1377# Side effects: starts the server (locally or over ssh).  Per-repo SSH
1378#              settings fall back to the process settings.
1379sub _transport :Private {
1380        my ($self, $repo) = @_;
1381
1382        my $type = $repo->{transport} || ($repo->{server_root} ? 'local' : 'ssh');
1383        my %common = (
1384                repo   => $repo->{remote_repo} || $self->{name},
1385                client => $self->{config}{client_id},
1386                role   => $repo->{role} || $ROLE_CLIENT,
1387        );
1388
1389        if ($type eq 'local') {
1390                $self->_throw('client.need_server_root', { repo => $self->{name} })
1391                        unless defined $repo->{server_root} && length $repo->{server_root};
1392                return App::FileSync::Transport::Local->new(%common, root => $repo->{server_root}, server_bin => $repo->{server_bin});
1393        }
1394
1395        $self->_throw('client.bad_transport', { transport => $type, repo => $self->{name} }) unless $type eq 'ssh';
1396        $self->_throw('client.need_server', { repo => $self->{name} }) unless defined $repo->{server} && length $repo->{server};
1397
1398        my $ssh = ref($self->{config}{ssh}) eq 'HASH' ? $self->{config}{ssh} : {};
1399        my $settings = $self->_settings();
1400        my $either = sub { my ($own, $fallback) = @_; (defined $own && length $own) ? $own : (defined $fallback && length $fallback) ? $fallback : undef };
1401
1402        return App::FileSync::Transport::SSH->new(
1403                %common,
1404                server            => $repo->{server},
1405                port              => $repo->{port},
1406                identity          => $either->($repo->{identity},       $settings->{ssh_identity}),
1407                known_hosts       => $either->($repo->{known_hosts},    $settings->{ssh_known_hosts}),
1408                server_command    => $either->($repo->{server_command}, $settings->{server_command}),
1409                host_key_checking => $repo->{host_key_checking},
1410                command           => $ssh->{command},
1411                extra_args        => $ssh->{extra_args},
1412        );
1413
313
313
313
145265
367
2680
}
1414
1415# _settings
1416# Purpose:     the process settings, read once per client.
1417# Entry:       none.
1418# Exit:        returns a hashref.
1419# Side effects: reads the settings file the first time.  A bad settings
1420#              file is reported here rather than ignored: it decides which
1421#              key and program a sync uses.
1422sub _settings :Private {
1423        my ($self) = @_;
1424
1425        $self->{settings} = App::FileSync::Settings::load() unless exists $self->{settings};
1426        return $self->{settings} || {};
1427
313
313
313
110011
259
2305
}
1428
1429# _record_run
1430# Purpose:     append one line per attempt to the run log, so "filesync log"
1431#              can audit what cron tried even when it failed or never got
1432#              the lock.
1433# Entry:       $result is a sync result or { exit, error }.
1434# Exit:        returns 1 if recorded, 0 if not.
1435# Side effects: appends (on a fresh line, if a killed run left a torn one)
1436#              and trims the log.  A failure is logged as a warning: losing
1437#              the audit line must not fail the sync.
1438sub _record_run :Private {
1439        my ($self, $result) = @_;
1440
1441        my $summary = ref($result->{summary}) eq 'HASH' ? $result->{summary} : {};
1442        my $entry = {
1443                ts       => time,
1444                exit     => ($result->{exit} // $EXIT_OK) + 0,
1445                dry_run  => $result->{dry_run} ? 1 : 0,
1446                summary  => { map { $_ => ($summary->{$_} || 0) } @SUMMARY_KEYS },
1447                warnings => [ map { strip_location($_) } @{ $result->{warnings} || [] } ],
1448                ($result->{locked} ? (locked => 1) : ()),
1449        };
1450        my $error = strip_location($result->{error});
1451        $entry->{error} = $error if length $error;
1452
1453        my $path = $self->log_path();
1454        my $ok = eval { append_line($path, json_encode($entry)); $self->_trim_log($path); 1 };
1455        $self->{logger}->warn($self->i18n('client.record_failed', { path => $path, error => strip_location($@) })) unless $ok;
1456
1457        return $ok ? 1 : 0;
1458
313
313
313
132895
267
2517
}
1459
1460# _trim_log
1461# Purpose:     keep only the newest $LOG_LIMIT lines.
1462# Entry:       $path exists.
1463# Exit:        returns 1 if trimmed, else 0.
1464# Side effects: rewrites the log atomically when it is too long.
1465sub _trim_log :Private {
1466        my ($self, $path) = @_;
1467
1468        my @lines = split /^/m, read_text($path);
1469        return 0 if @lines <= $LOG_LIMIT;
1470
1471        write_atomic($path, join('', @lines[ -$LOG_LIMIT .. -1 ]));
1472        return 1;
1473
313
313
313
120384
247
2522
}
1474
1475# _save_cache
1476# Purpose:     record the new base after a successful sync.
1477# Entry:       $entries is the post-sync manifest; $patterns the ignore list
1478#              used.
1479# Exit:        returns 1.
1480# Side effects: rewrites the cache atomically.  The ignore list is kept so
1481#              verify can scan with the same exclusions when it runs offline.
1482sub _save_cache :Private {
1483        my ($self, $entries, $summary, $patterns) = @_;
1484
1485        write_atomic($self->cache_path(), json_encode({
1486                version  => 1,
1487                entries  => $entries,
1488                ignore   => $patterns,
1489                last_run => { ts => time, summary => $summary },
1490        }) . "\n");
1491
1492        return 1;
1493
313
313
313
114175
554
2603
}
1494
1495# _prune_local_trash
1496# Purpose:     drop this repo's trashed files older than local_trash_days.
1497# Entry:       $repo is this repo's client config.
1498# Exit:        returns 1.
1499# Side effects: removes old day buckets from this repo's trash, and from
1500#              the legacy shared trash with the default retention (it can
1501#              no longer be attributed to one repo's setting).
1502sub _prune_local_trash :Private {
1503        my ($self, $repo) = @_;
1504
1505        App::FileSync::Trash::prune(root => $self->_trash_root(), days => $repo->{local_trash_days} // $TRASH_DAYS, now => time);
1506        App::FileSync::Trash::prune(root => $self->_legacy_trash_root(), days => $TRASH_DAYS, now => time);
1507        return 1;
1508
313
313
313
118282
277
2334
}
1509
1510# _repo_config
1511# Purpose:     this repo's entry in the client config.
1512# Entry:       none.
1513# Exit:        returns the hashref.
1514# Side effects: raises client.no_repo.
1515sub _repo_config :Private {
1516        my ($self) = @_;
1517
1518        my $repo = $self->{config}{repos}{ $self->{name} };
1519        $self->_throw('client.no_repo', { repo => $self->{name}, path => App::FileSync::Config::client_config_path() })
1520                unless ref($repo) eq 'HASH';
1521        return $repo;
1522
313
313
313
115673
277
2378
}
1523
1524# _checked_root
1525# Purpose:     the sync root, after checking it is safe to sync.
1526# Entry:       $repo is this repo's client config.
1527# Exit:        returns the root.
1528# Side effects: raises client.bad_root or client.no_marker.  The marker
1529#              guards against syncing an unmounted drive's empty mount
1530#              point, which would look like every file was deleted.
1531sub _checked_root :Private {
1532        my ($self, $repo) = @_;
1533
1534        my $root = $repo->{root};
1535        $self->_throw('client.bad_root', { repo => $self->{name}, root => $root // '(unset)' })
1536                unless defined $root && length $root && -d $root;
1537        $self->_throw('client.no_marker', { root => $root }) unless -e File::Spec->catfile($root, $ROOT_MARKER);
1538        return $root;
1539
313
313
313
117458
271
2382
}
1540
1541# _lock / _locked_result
1542# Purpose:     take this repo's run lock; describe a refusal.
1543# Entry:       $reason optionally overrides the refusal text.
1544# Exit:        _lock returns the held handle or undef; _locked_result a
1545#              result with exit 2.
1546# Side effects: _lock creates the lock file.
1547sub _lock :Private {
1548        my ($self) = @_;
1549
1550        return App::FileSync::Lock::acquire(path => $self->_home_file('locks', "$self->{name}.lock"), nonblock => 1);
1551
313
313
313
111223
317
2259
}
1552
1553sub _locked_result :Private {
1554        my ($self, $reason) = @_;
1555
1556        return {
1557                repo   => $self->{name},
1558                ok     => 0,
1559                exit   => $EXIT_LOCKED,
1560                locked => 1,
1561                error  => $reason // $self->i18n('client.locked', { repo => $self->{name} }),
1562        };
1563
313
313
313
113025
277
2231
}
1564
1565# _home_file
1566# Purpose:     a file under the client's home directory.
1567# Entry:       directory and file names.
1568# Exit:        returns the path.
1569# Side effects: raises config.no_home when there is no home.
1570sub _home_file :Private {
1571        my ($self, @parts) = @_;
1572
1573        return File::Spec->catfile(App::FileSync::Config::home_dir(), @parts);
1574
313
313
313
113897
261
2243
}
1575
1576# _trash_root / _legacy_trash_root
1577# Purpose:     this repo's local trash, and the shared one older releases used.
1578# Entry:       none.
1579# Exit:        returns the directory.
1580# Side effects: none.
1581sub _trash_root :Private {
1582        my ($self) = @_;
1583
1584        return File::Spec->catdir(App::FileSync::Config::home_dir(), 'trash', $TRASH_REPOS, $self->{name});
1585
313
313
313
115656
266
2224
}
1586
1587sub _legacy_trash_root :Private {
1588        return File::Spec->catdir(App::FileSync::Config::home_dir(), 'trash');
1589
313
313
313
110132
264
2111
}
1590
1591# _with_reserved
1592# Purpose:     an ignore list that always covers the snapshot directory.
1593# Entry:       $patterns is an arrayref (not changed).
1594# Exit:        returns a new arrayref.
1595# Side effects: none.  A replica's snapshots live inside its root; the
1596#              pattern is reserved so it applies even to a repo created
1597#              before snapshots existed, whose stored list lacks it.
1598sub _with_reserved :Private {
1599        my ($patterns) = @_;
1600
1601        return [ @{$patterns}, (any { $_ eq $SNAPSHOT_DIR } @{$patterns}) ? () : $SNAPSHOT_DIR ];
1602
313
313
313
116261
275
2313
}
1603
1604# _has_warnings
1605# Purpose:     whether a logged run carried a warning.
1606# Entry:       $entry is a run-log entry.
1607# Exit:        returns 1 or 0.
1608# Side effects: none.  Older entries may predate one of the fields, so a
1609#              warning, a conflict or a skipped path each count.
1610sub _has_warnings :Private {
1611        my ($entry) = @_;
1612
1613        my $summary = $entry->{summary} || {};
1614        return ((ref($entry->{warnings}) eq 'ARRAY' && @{ $entry->{warnings} })
1615                || ($summary->{conflicts} || 0) > 0
1616                || ($summary->{skipped} || 0) > 0) ? 1 : 0;
1617
313
313
313
116578
311
2328
}
1618
1619# _read_jsonl
1620# Purpose:     the JSON objects in a line-per-record file.
1621# Entry:       $path may not exist.
1622# Exit:        returns an arrayref of hashrefs.
1623# Side effects: reads the file; lines that are not objects (a torn last
1624#              line) are skipped.
1625sub _read_jsonl :Private {
1626        my ($path) = @_;
1627
1628        return [] unless -e $path;
1629        return [ grep { ref($_) eq 'HASH' } map { length ? eval { json_decode($_) } : () } split /\n/, read_text($path) ];
1630
313
313
313
126826
289
2454
}
1631
1632# _case_insensitive
1633# Purpose:     whether the sync root's filesystem folds case.
1634# Entry:       $root is a directory.
1635# Exit:        returns 1 or 0; when the probe cannot be written, guesses
1636#              from the OS (Windows folds case).
1637# Side effects: creates and removes a probe file.
1638sub _case_insensitive :Private {
1639        my ($root) = @_;
1640
1641        my $probe = File::Spec->catfile($root, $CASE_PROBE);
1642        my $created = eval { close(open_file($probe, '>')); 1 };
1643        return $^O eq 'MSWin32' ? 1 : 0 unless $created;
1644
1645        my $insensitive = -e File::Spec->catfile($root, uc $CASE_PROBE) ? 1 : 0;
1646        eval { unlink $probe };
1647        return $insensitive;
1648
313
313
313
130947
289
2507
}
1649
16501;
1651