File Coverage

File:blib/lib/App/FileSync/Admin.pm
Coverage:86.2%

linestmtbrancondsubtimecode
1package App::FileSync::Admin;
2
3# Server-side administration: the operations behind filesync-admin.  Every
4# method works directly on the server root; none of them talks to a client.
5
6
314
314
314
1159889
277
4500
use App::FileSync::Platform;
7
8
314
314
314
519
240
2608
use strict;
9
314
314
314
470
1845
8004
use warnings;
10
314
314
314
3618
87769
904
use autodie qw(:all);
11
12
314
314
314
761587
318
4349
use File::Spec;
13
314
314
314
588
219
8670
use List::Util qw(any);
14
314
314
314
3665
25494
6271
use Readonly;
15
314
314
314
3802
413322
1101
use Sub::Private { mode => 'enforce' };
16
17
314
314
314
35629
249
802
use parent 'App::FileSync::Base';
18
19
314
314
314
10469
288
8420
use App::FileSync qw(json_decode validate_relpath validate_repo_name validate_client_id parse_since parse_until);
20
314
314
314
44863
426
8745
use App::FileSync::Args qw(named positional);
21
314
314
314
53500
399
5136
use App::FileSync::Config;
22
314
314
314
691
246
14233
use App::FileSync::Constants qw($DEFAULT_SERVER_ROOT $ROLE_CLIENT @ROLES);
23
314
314
314
635
230
5066
use App::FileSync::I18N qw(os_error);
24
314
314
314
47640
451
6178
use App::FileSync::Ignore;
25
314
314
314
694
248
7644
use App::FileSync::IO qw(ensure_dir list_dir open_file sha256_file);
26
314
314
314
56651
675
5936
use App::FileSync::Settings;
27
314
314
314
59706
517
5194
use App::FileSync::Store;
28
314
314
314
716
249
2175
use App::FileSync::Trash;
29
314
314
314
49485
426
4377
use App::FileSync::Verify;
30
31
314
314
314
644
246
525
use version; our $VERSION = version->declare('0.001.1');
32
33# The per-repo metadata directories a new repo starts with.
34Readonly my @META_DIRS => qw(clients trash journal locks);
35
36# Journal files are one per month, named YYYY-MM.jsonl.
37Readonly my $JOURNAL_FILE => qr/\.jsonl\z/;
38
39# A trash date argument.
40Readonly my $DATE => qr/\A\d{4}-\d{2}-\d{2}\z/;
41
42# Arguments most methods take.
43my %REPO = (repo => { type => 'string', min => 1 });
44
45=encoding utf8
46
47 - 109
=head1 NAME

App::FileSync::Admin - server-side repo, client and recovery administration

=head1 SYNOPSIS

        my $admin = App::FileSync::Admin->new(root => '/srv/filesync');
        $admin->repo_create(name => 'docs');
        $admin->client_allow(repo => 'docs', client => 'laptop');
        my $report = $admin->verify(repo => 'docs', deep => 1);

=head1 DESCRIPTION

The library behind C<filesync-admin>.  Methods return hashrefs or arrayrefs
for the command line to print (or encode as JSON) and raise catalogued
errors (see L<App::FileSync::I18N>) on failure.

=head1 METHODS

=head2 new

Purpose: an administrator for one server root.

Args: optional C<root> (default C<FILESYNC_ROOT>, then F</srv/filesync>),
C<logger> (default from L<App::FileSync::Settings>), C<language>.

Returns: the object.

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

Usage:

        my $admin = App::FileSync::Admin->new(root => $root);

=head3 EXAMPLE

        my $admin = App::FileSync::Admin->new;
        print $admin->root, "\n";  # /srv/filesync unless FILESYNC_ROOT says otherwise

=head3 API SPECIFICATION

=head4 INPUT

        {
                root     => { type => 'string', optional => 1 },
                logger   => { type => 'object', optional => 1 },
                language => { type => 'string', optional => 1 },
        }

=head4 OUTPUT

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

=head3 MESSAGES

        Key            Meaning                  Resolution
        args.invalid   an argument is malformed fix the call

=head3 FORMAL SPECIFICATION

        root' = first non-empty ⟨root, FILESYNC_ROOT, "/srv/filesync"⟩

=cut
110
111sub new {
112
133
49551840
        my $class = shift;
113
133
1188
        my $args = named(\@_, {
114                root     => { type => 'string', optional => 1 },
115                logger   => { type => 'object', optional => 1 },
116                language => { type => 'string', optional => 1 },
117        }, 'Admin->new');
118
119
133
399
505
1267
        my ($root) = grep { defined && length } $args->{root}, $ENV{FILESYNC_ROOT}, $DEFAULT_SERVER_ROOT;
120
121        return bless {
122                root     => $root,
123                language => $args->{language},
124
133
1576
                logger   => $args->{logger} || App::FileSync::Settings::logger(),
125        }, $class;
126}
127
128 - 164
=head2 root

Purpose: the server root this object administers.

Args: none.

Returns: the path.

Side Effects: none.

Usage:

        my $root = $admin->root;

=head3 EXAMPLE

        print $admin->root, "\n";

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

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

=head3 MESSAGES

None.

=head3 FORMAL SPECIFICATION

        root = self.root

=cut
165
166sub root {
167
30
41
        my ($self) = @_;
168
169
30
200
        return $self->{root};
170}
171
172 - 237
=head2 repo_create

Purpose: create a repo: its master tree, metadata directories, policy and an
empty index.

Args: C<name> (a repo id), optional C<trash_days>, C<max_deletes>,
C<max_delete_percent> (non-negative integers) and C<ignore> (patterns; the
default ignore list when absent or empty).

Returns: C<{ name, master, config }> (the master and config paths).

Side Effects: creates F<ROOT/master/NAME> and F<ROOT/.filesync/repos/NAME>.

Usage:

        $admin->repo_create(name => 'docs', trash_days => 60);

=head3 EXAMPLE

        my $made = $admin->repo_create(name => 'photos', ignore => [ '*.tmp', '/cache/' ]);
        print "master at $made->{master}\n";

=head3 API SPECIFICATION

=head4 INPUT

        {
                name               => { type => 'string', min => 1, max => 64 },
                trash_days         => { type => 'integer', min => 0, optional => 1 },
                max_deletes        => { type => 'integer', min => 0, optional => 1 },
                max_delete_percent => { type => 'integer', min => 0, optional => 1 },
                ignore             => { type => 'arrayref', optional => 1 },
        }

=head4 OUTPUT

        {
                type => 'hashref',
                schema => {
                        name   => { type => 'string' },
                        master => { type => 'string' },
                        config => { type => 'string' },
                },
        }

=head3 MESSAGES

        Key                  Meaning                      Resolution
        admin.bad_repo       the name is not a valid id   letters, digits, . _ - ; at most 64
        admin.repo_exists    the repo already exists      choose another name
        io.mkdir / io.write  the files cannot be written  check permissions

=head3 FORMAL SPECIFICATION

        Î”FS
        pre name ∈ Id ∧ repo_config_path(root, name) ∉ dom FS
        post repo_config_path(root, name) ∈ dom FS' ∧ index(name) = ∅

=head3 PSEUDOCODE

        validate the name; refuse if the repo exists
        start from the default policy; apply the given limits and ignore list
        create the master and metadata directories
        save the policy and an empty index

=cut
238
239sub repo_create {
240
77
426
        my $self = shift;
241
77
827
        my $args = named(\@_, {
242                name               => { type => 'string', optional => 1 },
243                trash_days         => { type => 'integer', min => 0, optional => 1 },
244                max_deletes        => { type => 'integer', min => 0, optional => 1 },
245                max_delete_percent => { type => 'integer', min => 0, optional => 1 },
246                ignore             => { type => 'arrayref', optional => 1 },
247        }, 'Admin::repo_create');
248
249
77
373
        my $name = $self->_valid_repo($args->{name});
250
251
77
354
        my $config_path = App::FileSync::Config::repo_config_path($self->{root}, $name);
252
77
759
        $self->_throw('admin.repo_exists', { repo => $name, path => $config_path }) if -e $config_path;
253
254
77
155
        my $config = App::FileSync::Config::default_repo_config($name);
255
77
1731
        for my $limit (qw(trash_days max_deletes max_delete_percent)) {
256
231
298
                $config->{$limit} = $args->{$limit} + 0 if defined $args->{$limit};
257        }
258        # A copy: the caller's list must not become the stored policy's storage.
259
77
0
0
158
0
0
        $config->{ignore} = [ @{ $args->{ignore} } ] if $args->{ignore} && @{ $args->{ignore} };
260
261
77
269
        my $master = File::Spec->catdir($self->{root}, 'master', $name);
262
77
139
        my $meta = App::FileSync::Config::repo_meta_dir($self->{root}, $name);
263
77
308
284
1382
        ensure_dir($_) for $master, map { File::Spec->catdir($meta, $_) } @META_DIRS;
264
265
77
313
        App::FileSync::Config::save_repo($self->{root}, $name, $config);
266
77
248
        $self->_store($name, $config)->save_index({});
267
268
77
1110
        return { name => $name, master => $master, config => $config_path };
269}
270
271 - 308
=head2 repo_list

Purpose: the repos under this root.

Args: none.

Returns: an arrayref of names, sorted.

Side Effects: reads the metadata directory.

Usage:

        print "$_\n" for @{ $admin->repo_list };

=head3 EXAMPLE

        my $repos = $admin->repo_list;
        print @{$repos} ? "@{$repos}\n" : "none\n";

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

        { type => 'arrayref' }

=head3 MESSAGES

None; a root with no repos gives an empty list.

=head3 FORMAL SPECIFICATION

        repo_list = sort { n | n ∈ children(root/.filesync/repos) ∧ isdir(n) }

=cut
309
310sub repo_list {
311
1
1
        my ($self) = @_;
312
313
1
9
        my $root = File::Spec->catdir($self->{root}, '.filesync', 'repos');
314
1
1
1
1
11
3
        return [ grep { -d File::Spec->catdir($root, $_) } @{ list_dir($root) || [] } ];
315}
316
317 - 356
=head2 repo_show

Purpose: a repo's policy and where it lives.

Args: C<$name>.

Returns: C<{ config, master, meta, clients }>; C<clients> is a sorted arrayref.

Side Effects: reads the repo config.

Usage:

        my $info = $admin->repo_show('docs');

=head3 EXAMPLE

        my $info = $admin->repo_show('docs');
        print "trash kept $info->{config}{trash_days} days\n";

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'name', type => 'string', min => 1 } ]

=head4 OUTPUT

        { type => 'hashref' }

=head3 MESSAGES

        Key              Meaning                  Resolution
        admin.bad_repo   the name is not an id    check the spelling
        config.no_repo   the repo does not exist  filesync-admin repo list

=head3 FORMAL SPECIFICATION

        repo_show(n) = ⟨config ↦ load_repo(n), master ↦ master_dir(n), meta ↦ meta_dir(n), clients ↦ sort dom config.clients⟩

=cut
357
358sub repo_show {
359
0
0
        my ($self, $name) = @_;
360
361
0
0
        my $store = $self->_store($name);
362        return {
363                config  => $store->config,
364                master  => $store->master_dir(),
365                meta    => $store->meta_dir(),
366
0
0
0
0
                clients => [ sort keys %{ $store->config->{clients} } ],
367        };
368}
369
370 - 418
=head2 client_allow

Purpose: let a client (or replica) sync a repo.

Args: C<repo>, C<client> (a client id), optional C<role> (C<client>, the
default, or C<replica>).

Returns: C<{ client, role }>.

Side Effects: rewrites the repo config.  Allowing an existing client again
changes its role.

Usage:

        $admin->client_allow(repo => 'docs', client => 'backup', role => 'replica');

=head3 EXAMPLE

        $admin->client_allow(repo => 'docs', client => 'laptop');

=head3 API SPECIFICATION

=head4 INPUT

        {
                repo   => { type => 'string', min => 1 },
                client => { type => 'string', optional => 1 },
                role   => { type => 'string', optional => 1, memberof => [ 'client', 'replica' ] },
        }

=head4 OUTPUT

        { type => 'hashref', schema => { client => { type => 'string' }, role => { type => 'string' } } }

=head3 MESSAGES

        Key               Meaning                         Resolution
        admin.bad_client  the client id is not valid      letters, digits, . _ - ; at most 64
        admin.bad_role    the role is not client/replica  choose one of those
        admin.bad_repo    the repo name is not an id      check the spelling
        config.no_repo    the repo does not exist         create it first

=head3 FORMAL SPECIFICATION

        Î”RepoConfig
        pre client ∈ Id ∧ role ∈ {client, replica}
        clients' = clients ⊕ {client ↦ role}

=cut
419
420sub client_allow {
421
78
1934
        my $self = shift;
422
78
555
        my $args = named(\@_, {
423                %REPO,
424                client => { type => 'string', optional => 1 },
425                role   => { type => 'string', optional => 1 },
426        }, 'Admin::client_allow');
427
428
78
260
        my $client = $self->_valid_client($args->{client});
429
77
143
        my $role = $args->{role} || $ROLE_CLIENT;
430        $self->_throw('admin.bad_role', { role => $role, roles => join(', ', @ROLES) })
431
77
86
448
413
                unless any { $_ eq $role } @ROLES;
432
433
77
448
        my $repo = $self->_valid_repo($args->{repo});
434
77
169
        my $config = App::FileSync::Config::load_repo($self->{root}, $repo);
435
76
183
        $config->{clients}{$client} = { role => $role };
436
76
149
        App::FileSync::Config::save_repo($self->{root}, $repo, $config);
437
438
76
432
        return { client => $client, role => $role };
439}
440
441 - 479
=head2 client_list

Purpose: the clients allowed on a repo.

Args: C<$repo>.

Returns: an arrayref, sorted by id, of C<{ client, role, base }>; C<base> is 1
when the server holds that client's base (it has synced at least once).

Side Effects: reads the repo config.

Usage:

        for my $c (@{ $admin->client_list('docs') }) { ... }

=head3 EXAMPLE

        printf "%-12s %s\n", $_->{client}, $_->{role} for @{ $admin->client_list('docs') };

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'repo', type => 'string', min => 1 } ]

=head4 OUTPUT

        { type => 'arrayref' }

=head3 MESSAGES

        Key              Meaning                  Resolution
        config.no_repo   the repo does not exist  check the name

=head3 FORMAL SPECIFICATION

        client_list(r) = sort ⟨ (c, role(c), base(c) ∈ dom FS) | c ∈ dom clients(r) ⟩

=cut
480
481sub client_list {
482
0
0
        my ($self, $repo) = @_;
483
484
0
0
        my $store = $self->_store($repo);
485
0
0
        my $clients = $store->config->{clients};
486
487        # A hand-edited config may hold an id that is not one; list it without a base.
488        return [
489                map {
490
0
0
                        my $entry = $clients->{$_};
491
0
0
0
0
                        my $base = eval { $store->base_path($_) };
492                        {
493                                client => $_,
494
0
0
                                role   => (ref($entry) eq 'HASH' && $entry->{role}) || $ROLE_CLIENT,
495                                base   => (defined $base && -e $base) ? 1 : 0,
496                        }
497
0
0
0
0
                } sort keys %{$clients}
498        ];
499}
500
501 - 545
=head2 client_forget

Purpose: drop the server's base for a client, so its next sync starts afresh.

Args: C<repo>, C<client>.

Returns: C<{ removed =E<gt> 0|1, path }>.

Side Effects: removes the client's base file.  The client stays allowed.

Usage:

        $admin->client_forget(repo => 'docs', client => 'laptop');

=head3 EXAMPLE

        my $done = $admin->client_forget(repo => 'docs', client => 'old-laptop');
        print $done->{removed} ? "forgot\n" : "nothing to forget\n";

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

        { type => 'hashref', schema => { removed => { type => 'integer' }, path => { type => 'string' } } }

=head3 MESSAGES

        Key               Meaning                        Resolution
        store.bad_client  the client id is not valid     check the spelling
        io.remove         the base cannot be removed     check permissions

=head3 FORMAL SPECIFICATION

        Î”FS
        FS' = {base_path(r, c)} ⩤ FS
        removed = 1 ⇔ base_path(r, c) ∈ dom FS

=cut
546
547sub client_forget {
548
4
28
        my $self = shift;
549
4
21
        my $args = named(\@_, { %REPO, client => { type => 'string', min => 1 } }, 'Admin::client_forget');
550
551        # base_path validates the id, so "../x" cannot reach outside the repo.
552
4
13
        my $path = $self->_store($args->{repo})->base_path($args->{client});
553
3
54
        return { removed => 0, path => $path } unless -e $path;
554
555
3
3
3
5
7
624
        eval { unlink $path; 1 } or $self->_throw('io.remove', { path => $path, error => os_error($@) });
556
3
11
        return { removed => 1, path => $path };
557}
558
559 - 596
=head2 journal_dir

Purpose: where a repo's journal lives.

Args: C<$repo>.

Returns: F<ROOT/.filesync/repos/REPO/journal>.

Side Effects: reads the repo config.

Usage:

        my $dir = $admin->journal_dir('docs');

=head3 EXAMPLE

        print $admin->journal_dir('docs'), "\n";

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'repo', type => 'string', min => 1 } ]

=head4 OUTPUT

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

=head3 MESSAGES

        Key              Meaning                  Resolution
        config.no_repo   the repo does not exist  check the name

=head3 FORMAL SPECIFICATION

        journal_dir(r) = meta_dir(r) / "journal"

=cut
597
598sub journal_dir {
599
88
130
        my ($self, $repo) = @_;
600
601
88
257
        return File::Spec->catdir($self->_store($repo)->meta_dir(), 'journal');
602}
603
604 - 665
=head2 journal_read

Purpose: the journal's events, filtered.

Args: C<repo>; optional filters C<client> (id), C<since> and C<until>
(C<YYYY-MM-DD> or epoch, inclusive), C<exits> (arrayref of exit codes; selects
session outcomes), C<warnings> (sessions that kept conflict copies) and
C<limit> (the newest N).

Returns: an arrayref of event hashrefs, oldest first.

Side Effects: reads every monthly journal file.  A torn final line from a
killed run is skipped.

Usage:

        my $events = $admin->journal_read(repo => 'docs', since => '2026-10-01', limit => 50);

=head3 EXAMPLE

        for my $event (@{ $admin->journal_read(repo => 'docs', exits => [ 1, 2 ]) }) {
                print "$event->{ts} $event->{client} exit $event->{exit}\n";
        }

=head3 API SPECIFICATION

=head4 INPUT

        {
                repo     => { type => 'string', min => 1 },
                client   => { type => 'string', optional => 1 },
                since    => { type => 'string', optional => 1 },
                until    => { type => 'string', optional => 1 },
                exits    => { type => 'arrayref', optional => 1 },
                warnings => { type => 'boolean', optional => 1 },
                limit    => { type => 'integer', optional => 1 },
        }

=head4 OUTPUT

        { type => 'arrayref' }

=head3 MESSAGES

        Key            Meaning                         Resolution
        when.bad       since/until is not a date       use YYYY-MM-DD or an epoch
        io.read        a journal file cannot be read   check permissions

=head3 FORMAL SPECIFICATION

        let E = ⟨ e | f ∈ sort(journal files), line ∈ f, e = json(line) ∈ HASH ⟩
        journal_read = last limit (sort_by ts (filter(E)))

=head3 PSEUDOCODE

        read every journal file in name (month) order, skipping torn lines
        keep events for the client, inside since..until
        keep session outcomes with a wanted exit code, if asked
        keep sessions that kept conflict copies, if asked
        sort by time; keep the newest limit

=cut
666
667sub journal_read {
668
60
14031
        my $self = shift;
669
60
606
        my $args = named(\@_, {
670                %REPO,
671                client   => { type => 'string', optional => 1 },
672                since    => { type => 'string', optional => 1 },
673                until    => { type => 'string', optional => 1 },
674                exits    => { type => 'arrayref', optional => 1 },
675                warnings => { type => 'scalar', optional => 1 },
676                limit    => { type => 'integer', optional => 1 },
677        }, 'Admin::journal_read');
678
679        # list_dir, not glob: glob splits a root containing spaces into words.
680
60
258
        my $dir = $self->journal_dir($args->{repo});
681
60
59
59
60
621
652
198
151
        my @files = map { File::Spec->catfile($dir, $_) } grep { $_ =~ $JOURNAL_FILE } @{ list_dir($dir) || [] };
682
683
60
176
        my $keep = $self->_event_filter($args);
684
59
691
727
58
58
83
611
466
60
121
        my @events = sort { ($a->{ts} || 0) <=> ($b->{ts} || 0) } grep { $keep->($_) } map { @{ _read_events($_) } } @files;
685
686
59
239
        my $limit = $args->{limit};
687
59
220
        splice(@events, 0, @events - $limit) if defined $limit && $limit > 0 && @events > $limit;
688
59
493
        return \@events;
689}
690
691 - 736
=head2 verify

Purpose: rehash the master against its index and report what no longer
agrees.

Args: C<repo>, optional C<deep> (hash every file) and C<sample> (hash N
random files).

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

Side Effects: reads the master tree; logs a debug summary.

Usage:

        my $report = $admin->verify(repo => 'docs', deep => 1);

=head3 EXAMPLE

        my $report = $admin->verify(repo => 'docs', sample => 20);
        print $report->{ok} ? "clean\n" : "drift\n";

=head3 API SPECIFICATION

=head4 INPUT

        {
                repo   => { type => 'string', min => 1 },
                deep   => { type => 'boolean', optional => 1 },
                sample => { type => 'integer', min => 0, optional => 1 },
        }

=head4 OUTPUT

        { type => 'hashref' }

=head3 MESSAGES

        Key              Meaning                  Resolution
        config.no_repo   the repo does not exist  check the name
        verify.*         see App::FileSync::Verify

=head3 FORMAL SPECIFICATION

        verify(r) = Verify(master_dir(r), index(r), ignore(r)) ⊕ ⟨repo ↦ r, master ↦ master_dir(r)⟩

=cut
737
738sub verify {
739
15
5141
        my $self = shift;
740
15
141
        my $args = named(\@_, {
741                %REPO,
742                deep   => { type => 'scalar', optional => 1 },
743                sample => { type => 'integer', min => 0, optional => 1 },
744        }, 'Admin::verify');
745
746
15
34
        my $repo = $args->{repo};
747
15
38
        my $store = $self->_store($repo);
748
749        my $report = App::FileSync::Verify::verify(
750                root    => $store->master_dir(),
751                entries => $store->load_index(),
752                ignore  => App::FileSync::Ignore->new(patterns => $store->config->{ignore}),
753                deep    => $args->{deep},
754                sample  => $args->{sample},
755
15
44
        );
756
15
15
148
38
        @{$report}{qw(repo master)} = ($repo, $store->master_dir());
757
758        $self->{logger}->debug('verify finished', {
759                repo => $repo,
760                files => $report->{files},
761
45
45
29
118
                (map { $_ => scalar @{ $report->{$_} } } qw(modified missing extra)),
762
15
35
                ok => $report->{ok} ? 1 : 0,
763        });
764
765
15
556
        return $report;
766}
767
768 - 826
=head2 reindex

Purpose: rebuild F<master.json> from the tree, hashing every file.

Used after a manual repair (a rollback from a backup, say) so the index
describes the restored master without waiting for the next client session.
The old index is deliberately not reused: a restored file can keep its size
and mtime while its content differs, which is exactly the case reindex exists
for.

Args: C<repo>, optional C<dry_run>.

Returns: C<{ repo, master, index, files, dirs, bytes, skipped, dry_run, written }>.

Side Effects: holds the repo lock (the same one the server takes), so it never
races a session; writes the index and a journal event unless C<dry_run>.

Usage:

        my $result = $admin->reindex(repo => 'docs');

=head3 EXAMPLE

        my $plan = $admin->reindex(repo => 'docs', dry_run => 1);
        print "would index $plan->{files} files\n";

=head3 API SPECIFICATION

=head4 INPUT

        {
                repo    => { type => 'string', min => 1 },
                dry_run => { type => 'boolean', optional => 1 },
        }

=head4 OUTPUT

        { type => 'hashref' }

=head3 MESSAGES

        Key              Meaning                              Resolution
        admin.locked     a sync session holds the repo lock   retry when it has finished
        config.no_repo   the repo does not exist              check the name

=head3 FORMAL SPECIFICATION

        Î”FS
        pre lock(r) is free
        Â¬dry_run ⇒ index'(r) = scan(master_dir(r))

=head3 PSEUDOCODE

        open the repo and take its lock
        walk the master, hashing everything
        unless dry run: save the index and journal the reindex
        log and report the counts

=cut
827
828sub reindex {
829
7
16
        my $self = shift;
830
7
39
        my $args = named(\@_, { %REPO, dry_run => { type => 'scalar', optional => 1 } }, 'Admin::reindex');
831
832
7
17
        my $repo = $args->{repo};
833
7
25
        my $store = $self->_store($repo);
834
7
34
        my $dry_run = $args->{dry_run} ? 1 : 0;
835
836        # Held until $lock goes out of scope at the end of this method.
837
7
25
        my $lock = $store->acquire_lock() or $self->_throw('admin.locked', { repo => $repo });
838
839
6
6
8
23
        my ($manifest, $stats, $skipped) = @{ $store->walk_master({}) }{qw(manifest stats skipped)};
840
6
18
12
26
        my %counts = (map { $_ => $stats->{$_} } qw(files dirs bytes));
841
842
6
14
        unless ($dry_run) {
843
4
11
                $store->save_index($manifest);
844
4
4
20
32
                $store->journal({ action => 'reindex', %counts, skipped => scalar(keys %{$skipped}), result => 'ok' });
845        }
846
847        $self->{logger}->info('reindexed master', {
848
6
6
18
31
                repo => $repo, %counts, skipped => scalar(keys %{$skipped}), dry_run => $dry_run,
849        });
850
851        return {
852
6
218
                repo    => $repo,
853                master  => $store->master_dir(),
854                index   => $store->index_path(),
855                %counts,
856                skipped => $skipped,
857                dry_run => $dry_run,
858                written => $dry_run ? 0 : 1,
859        };
860}
861
862 - 930
=head2 restore

Purpose: put a path back from the server trash: into the master by default,
or into C<to> as an export.

Args: C<repo>, C<path> (the relative path as synced), optional C<to> (a
directory), C<date> (C<YYYY-MM-DD>; the newest version from that day), C<list>
(only list the versions).

Returns: with C<list>, C<{ repo, path, versions }> (rows from
L<App::FileSync::Trash/version_info>); otherwise
C<{ repo, path, from, to, date, sha256, into_master }>.

Side Effects: copies the version out (the trash entry is kept, so this is
repeatable); when restoring into the master, rebuilds the index so verify and
the next sync agree; journals the restore.

Usage:

        $admin->restore(repo => 'docs', path => 'notes/a.txt');

=head3 EXAMPLE

        my $done = $admin->restore(repo => 'docs', path => 'a.txt', to => '/tmp/out', date => '2026-10-20');
        print "restored to $done->{to}\n";

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

        { type => 'hashref' }

=head3 MESSAGES

        Key                  Meaning                                 Resolution
        admin.unsafe_path    the path fails validate_relpath         fix the path
        admin.no_versions    nothing of that path is in the trash    check "restore --list"
        admin.bad_date       the date is not YYYY-MM-DD              fix it
        admin.no_version_on  no version from that day                pick a listed date
        admin.not_dir        the export target is not a directory    choose another
        admin.exists         the destination exists                  move it aside first

=head3 FORMAL SPECIFICATION

        Î”FS
        pre path ∈ SafePath ∧ versions(path) ≠ ⟨⟩ ∧ dest ∉ dom FS
        post tree'(dest) = tree(chosen) ∧ (into_master ⇒ index' = scan(master))

=head3 PSEUDOCODE

        validate the path and list its trashed versions
        if listing, return them
        choose the newest version, or the newest from the given day
        destination: the master, or the export directory (created if needed)
        refuse to overwrite; copy the version out
        if into the master, rebuild the index
        journal, log and report

=cut
931
932sub restore {
933
12
5257
        my $self = shift;
934
12
90
        my $args = named(\@_, {
935                %REPO,
936                path => { type => 'string', optional => 1 },
937                to   => { type => 'string', optional => 1 },
938                date => { type => 'string', optional => 1 },
939                list => { type => 'scalar', optional => 1 },
940        }, 'Admin::restore');
941
942
12
12
27
22
        my ($repo, $path) = @{$args}{qw(repo path)};
943
12
29
        my ($safe, $reason) = validate_relpath($path);
944
12
19
        $self->_throw('admin.unsafe_path', { path => $path // '', reason => $reason }) unless $safe;
945
946
12
27
        my $store = $self->_store($repo);
947
12
24
        my $versions = App::FileSync::Trash::versions(root => $store->trash_dir(), rel => $path);
948
949
12
20
        if ($args->{list}) {
950
3
4
3
5
221
4
                return { repo => $repo, path => $path, versions => [ map { App::FileSync::Trash::version_info($_) } @{$versions} ] };
951        }
952
953
9
23
        my $chosen = $self->_choose_version($versions, $args);
954
955
5
18
        my $into_master = !(defined $args->{to} && length $args->{to});
956
5
12
        my $base = $into_master ? $store->master_dir() : $args->{to};
957
5
12
        unless ($into_master) {
958
3
6
                ensure_dir($base);
959
3
14
                $self->_throw('admin.not_dir', { path => $base }) unless -d $base;
960        }
961
962
5
24
        my $destination = File::Spec->catfile($base, split m{/}, $path);
963
5
54
        $self->_throw('admin.exists', { path => $destination }) if -e $destination || -l $destination;
964
965
4
8
        App::FileSync::Trash::restore(src => $chosen, to => $destination);
966
4
12
        my $sha = sha256_file($destination);
967
968        # The index is a cache; rebuild it so verify and the next sync agree that
969        # the restored tree is what the master now holds.
970
4
9
        $store->save_index($store->walk_master($store->load_index())->{manifest}) if $into_master;
971
972
4
19
        my %moved = (path => $path, to => $destination, (defined $sha ? (sha256 => $sha) : ()));
973
4
28
        $store->journal({ action => 'restore', %moved, trash => $chosen, result => 'ok' });
974
4
30
        $self->{logger}->info('restored path', { repo => $repo, path => $path, from => $chosen, to => $destination });
975
976        return {
977
4
178
                repo => $repo,
978                %moved,
979                from => $chosen,
980                date => App::FileSync::Trash::date_of($chosen),
981                into_master => $into_master ? 1 : 0,
982        };
983}
984
985# _store
986# Purpose:     open a repo after checking its name.
987# Entry:       $repo is the caller's name; $config optional (saves a read).
988# Exit:        returns an App::FileSync::Store.
989# Side effects: reads the repo config unless given; raises admin.bad_repo or
990#              config.no_repo.
991sub _store :Private {
992        my ($self, $repo, $config) = @_;
993
994        return App::FileSync::Store->new(
995                root     => $self->{root},
996                repo     => $self->_valid_repo($repo),
997                language => $self->{language},
998                ($config ? (config => $config) : ()),
999        );
1000
314
314
314
372511
288
3796
}
1001
1002# _valid_repo / _valid_client
1003# Purpose:     check an id with the shared validators (which also cap the
1004#              length), so admin, server and transport agree on what an id is.
1005# Entry:       the candidate, possibly undef.
1006# Exit:        returns the id.
1007# Side effects: raises admin.bad_repo / admin.bad_client.
1008sub _valid_repo :Private {
1009        my ($self, $name) = @_;
1010
1011        my ($ok, $reason) = validate_repo_name($name);
1012        $self->_throw('admin.bad_repo', { reason => $reason }) unless $ok;
1013        return $name;
1014
314
314
314
100848
318
2576
}
1015
1016sub _valid_client :Private {
1017        my ($self, $id) = @_;
1018
1019        my ($ok, $reason) = validate_client_id($id);
1020        $self->_throw('admin.bad_client', { reason => $reason }) unless $ok;
1021        return $id;
1022
314
314
314
93305
276
2397
}
1023
1024# _event_filter
1025# Purpose:     turn journal_read's options into one predicate.
1026# Entry:       $args validated by journal_read.
1027# Exit:        returns a coderef taking an event, true to keep it.
1028# Side effects: raises when.bad for a malformed since/until (before any
1029#              file is read, since the bounds are parsed here).
1030sub _event_filter :Private {
1031        my ($self, $args) = @_;
1032
1033        my $client = (defined $args->{client} && length $args->{client}) ? $args->{client} : undef;
1034        my $since = defined $args->{since} ? parse_since($args->{since}) : undef;
1035        my $until = defined $args->{until} ? parse_until($args->{until}) : undef;
1036
1037        # Only session outcomes record an exit code, so this selects those.
1038        my %exits = map { ($_ + 0) => 1 } @{ $args->{exits} || [] };
1039
1040        # The server journals planned conflicts on the plan event too, but only a
1041        # session outcome shows a run that kept the conflict copies, which is the
1042        # client's --warnings case.
1043        my $warnings = $args->{warnings};
1044
1045        return sub {
1046                my ($event) = @_;
1047                my $ts = $event->{ts} || 0;
1048                return 0 if defined $client && !(defined $event->{client} && $event->{client} eq $client);
1049                return 0 if (defined $since && $ts < $since) || (defined $until && $ts > $until);
1050                return 0 if %exits && !(defined $event->{exit} && $exits{ $event->{exit} + 0 });
1051                return 0 if $warnings && !(($event->{action} || '') eq 'session' && ($event->{conflicts} || 0) > 0);
1052                return 1;
1053        };
1054
314
314
314
119398
272
2491
}
1055
1056# _read_events
1057# Purpose:     parse one journal file.
1058# Entry:       $file exists.
1059# Exit:        returns an arrayref of event hashrefs.
1060# Side effects: reads the file; raises io.read if it cannot be opened.
1061#              Lines that are not JSON objects (a torn final line from a
1062#              killed run) are skipped, not fatal.
1063sub _read_events :Private {
1064        my ($file) = @_;
1065
1066        my $fh = open_file($file, '<');
1067        my @events = grep { ref($_) eq 'HASH' } map { chomp; length ? eval { json_decode($_) } : () } <$fh>;
1068        close $fh;
1069
1070        return \@events;
1071
314
314
314
106059
285
2557
}
1072
1073# _choose_version
1074# Purpose:     pick the trashed version restore should use.
1075# Entry:       $versions newest first; $args has path, repo and maybe date.
1076# Exit:        returns a version path.
1077# Side effects: raises admin.no_versions, admin.bad_date or
1078#              admin.no_version_on.
1079sub _choose_version :Private {
1080        my ($self, $versions, $args) = @_;
1081
1082        my ($repo, $path, $date) = @{$args}{qw(repo path date)};
1083        $self->_throw('admin.no_versions', { path => $path, repo => $repo }) unless @{$versions};
1084        return $versions->[0] unless defined $date && length $date;
1085
1086        $self->_throw('admin.bad_date', { date => $date }) unless $date =~ $DATE;
1087
1088        # date_of is undef for a path outside the dated layout; such a version
1089        # never matches a date.
1090        my ($match) = grep { (App::FileSync::Trash::date_of($_) // '') eq $date } @{$versions};
1091        $self->_throw('admin.no_version_on', { path => $path, date => $date }) unless defined $match;
1092        return $match;
1093
314
314
314
109719
301
2607
}
1094
10951;
1096