File Coverage

File:blib/lib/App/FileSync/Store.pm
Coverage:91.1%

linestmtbrancondsubtimecode
1package App::FileSync::Store;
2
3# The server's view of one repo: the master tree, its index (master.json), the
4# per-client bases, the trash, the journal and the session lock, all under
5# ROOT/master/REPO and ROOT/.filesync/repos/REPO.
6
7
314
314
314
829
292
4586
use App::FileSync::Platform;
8
9
314
314
314
6052
2758
4454
use strict;
10
314
314
314
1028
768
7113
use warnings;
11
314
314
314
698
248
1075
use autodie qw(:all);
12
13
314
314
314
686276
317
7887
use File::Basename qw(dirname);
14
314
314
314
651
270
4330
use File::Spec;
15
314
314
314
507
256
1189
use Sub::Private { mode => 'enforce' };
16
17
314
314
314
36631
279
1041
use parent -norequire, 'App::FileSync::Base';
18
314
314
314
6732
248
4534
use App::FileSync::Base;
19
20
314
314
314
555
243
9819
use App::FileSync qw(json_encode json_decode read_text write_atomic validate_relpath validate_client_id validate_repo_name);
21
314
314
314
559
265
4850
use App::FileSync::Args qw(named);
22
314
314
314
719
340
3900
use App::FileSync::Config;
23
314
314
314
558
505
15637
use App::FileSync::Constants qw($STALE_TEMP_SECONDS $TRASH_DAYS);
24
314
314
314
703
275
6773
use App::FileSync::I18N qw(strip_location throw);
25
314
314
314
549
4661
10109
use App::FileSync::IO qw(append_line ensure_dir install_file);
26
314
314
314
1733
756
3086
use App::FileSync::Ignore;
27
314
314
314
44829
392
4514
use App::FileSync::Lock;
28
314
314
314
49767
424
4978
use App::FileSync::Scan;
29
314
314
314
51852
429
4987
use App::FileSync::Trash;
30
31
314
314
314
665
238
563
use version; our $VERSION = version->declare('0.001.1');
32
33=encoding utf8
34
35 - 95
=head1 NAME

App::FileSync::Store - the server-side files of one repo

=head1 SYNOPSIS

        my $store = App::FileSync::Store->new(root => '/srv/filesync', repo => 'docs');
        my $lock  = $store->acquire_lock() or die 'busy';
        my $index = $store->load_index();
        my $scan  = $store->walk_master($index);
        $store->save_index($scan->{manifest});

=head1 METHODS

=head2 new

Purpose: open a repo on the server.

Args: C<root>, C<repo>, optional C<config> (the repo config, read from disk
when omitted) and C<language>.

Returns: the store.

Side Effects: reads the repo config.

Usage:

        my $store = App::FileSync::Store->new(root => $root, repo => $repo);

=head3 EXAMPLE

        my $store = App::FileSync::Store->new(root => '/srv/filesync', repo => 'docs');
        print $store->master_dir, "\n";

=head3 API SPECIFICATION

=head4 INPUT

        {
                root     => { type => 'string', min => 1 },
                repo     => { type => 'string', min => 1 },
                config   => { type => 'hashref', optional => 1 },
                language => { type => 'string', optional => 1 },
        }

=head4 OUTPUT

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

=head3 MESSAGES

        Key              Meaning                       Resolution
        args.invalid     root or repo missing          pass both
        store.bad_repo   the repo name is not an id    use letters, digits, '.', '_', '-'
        config.no_repo   the repo does not exist       filesync-admin repo create

=head3 FORMAL SPECIFICATION

        new(r, n, c) = ⟨root ↦ r, repo ↦ n, config ↦ c or load_repo(r, n), ignore ↦ Ignore(config.ignore)⟩

=cut
96
97sub new {
98
384
2891
        my $class = shift;
99
384
3385
        my $args = named(\@_, {
100                root     => { type => 'string', min => 1 },
101                repo     => { type => 'string', min => 1 },
102                config   => { type => 'hashref', optional => 1 },
103                language => { type => 'string', optional => 1 },
104        }, 'Store->new');
105
106        # The name becomes a directory under the root, so it must be a plain id
107        # before any path is built from it.
108
384
1231
        my ($ok, $reason) = validate_repo_name($args->{repo});
109
384
678
        throw('store.bad_repo', { reason => $reason }) unless $ok;
110
111
383
1672
        my $config = $args->{config} || App::FileSync::Config::load_repo($args->{root}, $args->{repo});
112
113        return bless {
114                root     => $args->{root},
115                repo     => $args->{repo},
116                language => $args->{language},
117                config   => $config,
118
383
2695
                ignore   => App::FileSync::Ignore->new(patterns => $config->{ignore}),
119        }, $class;
120}
121
122 - 196
=head2 root

Documented with L</journal_path> below.

=head2 repo

Documented with L</journal_path> below.

=head2 config

Documented with L</journal_path> below.

=head2 master_dir

Documented with L</journal_path> below.

=head2 meta_dir

Documented with L</journal_path> below.

=head2 index_path

Documented with L</journal_path> below.

=head2 trash_dir

Documented with L</journal_path> below.

=head2 lock_path

Documented with L</journal_path> below.

=head2 journal_path

Purpose: where the repo's pieces live.

Args: none.

Returns: the server root, the repo name, the config hashref, and the paths of
the master tree (F<ROOT/master/REPO>), the metadata directory, the index
(F<master.json>), the trash, the lock file and this month's journal
(F<journal/YYYY-MM.jsonl>, UTC).

Side Effects: none.

Usage:

        my $master = $store->master_dir;

=head3 EXAMPLE

        print join("\n", $store->master_dir, $store->index_path, $store->journal_path), "\n";

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

        { type => 'string' }

=head3 MESSAGES

None.

=head3 FORMAL SPECIFICATION

        master_dir = root / "master" / repo
        meta_dir = root / ".filesync" / "repos" / repo
        index_path = meta_dir / "master.json"
        journal_path = meta_dir / "journal" / yyyy-mm(now) ⁀ ".jsonl"

=cut
197
198sub root {
199
0
0
        my ($self) = @_;
200
201
0
0
        return $self->{root};
202}
203
204sub repo {
205
0
0
        my ($self) = @_;
206
207
0
0
        return $self->{repo};
208}
209
210sub config {
211
520
755
        my ($self) = @_;
212
213
520
1939
        return $self->{config};
214}
215
216sub master_dir {
217
1701
1397
        my ($self) = @_;
218
219
1701
13398
        return File::Spec->catdir($self->{root}, 'master', $self->{repo});
220}
221
222sub meta_dir {
223
1736
1587
        my ($self) = @_;
224
225
1736
3169
        return App::FileSync::Config::repo_meta_dir($self->{root}, $self->{repo});
226}
227
228sub index_path {
229
420
603
        my ($self) = @_;
230
231
420
728
        return File::Spec->catfile($self->meta_dir(), 'master.json');
232}
233
234sub trash_dir {
235
192
518
        my ($self) = @_;
236
237
192
310
        return File::Spec->catdir($self->meta_dir(), 'trash');
238}
239
240sub lock_path {
241
181
280
        my ($self) = @_;
242
243
181
494
        return File::Spec->catfile($self->meta_dir(), 'locks', 'repo.lock');
244}
245
246sub journal_path {
247
542
559
        my ($self) = @_;
248
249
542
1285
        my @t = gmtime();
250
542
762
        return File::Spec->catfile($self->meta_dir(), 'journal',
251                sprintf('%04d-%02d.jsonl', $t[5] + 1900, $t[4] + 1));
252}
253
254 - 295
=head2 base_path

Purpose: where a client's base (its state at its last sync) is stored.

Args: C<$client>, a client id.

Returns: F<META/clients/CLIENT.json>.

Side Effects: none.

Usage:

        my $path = $store->base_path('laptop-1');

=head3 EXAMPLE

        unlink $store->base_path($client) if $forget;

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

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

=head3 MESSAGES

        Key              Meaning                              Resolution
        store.bad_client the id is not a valid client id      use letters, digits, . _ -

=head3 FORMAL SPECIFICATION

        pre client ∈ Id
        base_path(client) = meta_dir / "clients" / client ⁀ ".json"

=cut
296
297sub base_path {
298
314
504
        my ($self, $client) = @_;
299
300        # The id becomes a file name, so it must not be able to climb out of the
301        # clients directory.
302
314
693
        my ($ok, $reason) = validate_client_id($client);
303
314
541
        $self->_throw('store.bad_client', { reason => $reason }) unless $ok;
304
305
313
594
        return File::Spec->catfile($self->meta_dir(), 'clients', "$client.json");
306}
307
308 - 345
=head2 acquire_lock

Purpose: take the repo's session lock without waiting.

Args: none.

Returns: the lock handle, or C<undef> when another session holds it.

Side Effects: creates the lock file.

Usage:

        my $lock = $store->acquire_lock() or return refuse('locked');

=head3 EXAMPLE

        if (my $lock = $store->acquire_lock) { ... }

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

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

=head3 MESSAGES

        Key         Meaning                         Resolution
        io.append   the lock file cannot be made    check permissions

=head3 FORMAL SPECIFICATION

        acquire_lock = Lock::acquire(lock_path, nonblock)

=cut
346
347sub acquire_lock {
348
177
244
        my ($self) = @_;
349
350
177
527
        return App::FileSync::Lock::acquire(path => $self->lock_path(), nonblock => 1);
351}
352
353 - 396
=head2 abs_path

Purpose: the absolute path in the master for a relative path.

Args: C<$rel>.

Returns: the path under L</master_dir>.

Side Effects: none.  Raises if C<$rel> is unsafe or if a directory on the way
is a symlink, so nothing can be written outside the master through one.

Usage:

        my $abs = $store->abs_path($path);

=head3 EXAMPLE

        my $abs = $store->abs_path('notes/plan.txt');

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

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

=head3 MESSAGES

        Key              Meaning                                  Resolution
        path.unsafe      the path fails validate_relpath          rename the file
        store.symlink    a parent directory is a symlink          remove the symlink

=head3 FORMAL SPECIFICATION

        pre rel ∈ SafePath ∧ ∀ a : ancestors(master / rel) ∩ under(master) • kind a ≠ symlink
        abs_path(rel) = master / rel

=cut
397
398sub abs_path {
399
661
591
        my ($self, $rel) = @_;
400
401
661
833
        my ($ok, $reason) = validate_relpath($rel);
402
661
670
        $self->_throw('path.unsafe', { path => $rel, reason => $reason }) unless $ok;
403
404
661
1100
        $self->_assert_no_symlinked_parent($rel);
405
406
661
668
        return File::Spec->catfile($self->master_dir(), split m{/}, $rel);
407}
408
409 - 468
=head2 load_index

Documented with L</save_base> below.

=head2 save_index

Documented with L</save_base> below.

=head2 load_base

Documented with L</save_base> below.

=head2 save_base

Purpose: read and write the master's index and a client's base.

Args: C<save_index(\%entries)>; C<load_base($client)>;
C<save_base($client, \%entries)>.

Returns: the entries hashref (empty when the file does not exist); 1 for the
save methods.

Side Effects: the save methods replace the file atomically.

Usage:

        my $index = $store->load_index();
        $store->save_base($client, $base_after);

=head3 EXAMPLE

        my $base = $store->load_base('laptop-1');
        print scalar(keys %{$base}), " paths known to laptop-1\n";

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

        { type => 'hashref' }

=head3 MESSAGES

        Key            Meaning                       Resolution
        config.parse   the file is not valid JSON    run filesync-admin reindex
        io.write       the file cannot be written    check space

=head3 FORMAL SPECIFICATION

        load(p) = json(FS(p)).entries if p ∈ dom FS ∧ is a hash, else ∅
        save(p, e): FS'(p) = json(⟨version ↦ 1, entries ↦ e⟩) ⁀ "\n"

=cut
469
470sub load_index {
471
189
1809729
        my ($self) = @_;
472
473
189
475
        return $self->_load_entries($self->index_path());
474}
475
476sub save_index {
477
225
362
        my ($self, $entries) = @_;
478
479
225
764
        return $self->_save_entries($self->index_path(), $entries);
480}
481
482sub load_base {
483
167
301
        my ($self, $client) = @_;
484
485
167
438
        return $self->_load_entries($self->base_path($client));
486}
487
488sub save_base {
489
143
324
        my ($self, $client, $entries) = @_;
490
491
143
376
        return $self->_save_entries($self->base_path($client), $entries);
492}
493
494 - 536
=head2 walk_master

Purpose: scan the master, reusing indexed hashes where size and mtime match.

Args: optional C<\%cache> (usually the current index; C<{}> forces a rehash).

Returns: the L<App::FileSync::Scan/scan> result: C<{ manifest, stats, skipped }>.

Side Effects: reads the master tree.

Usage:

        my $scan = $store->walk_master($store->load_index);

=head3 EXAMPLE

        my $fresh = $store->walk_master({});
        printf "%d files\n", $fresh->{stats}{files};

=head3 API SPECIFICATION

=head4 INPUT

        [
                { name => 'self',  type => 'object' },
                { name => 'cache', type => 'hashref', optional => 1 },
        ]

=head4 OUTPUT

        { type => 'hashref' }

=head3 MESSAGES

        Key               Meaning                     Resolution
        store.no_master   the master directory is gone   restore it or recreate the repo

=head3 FORMAL SPECIFICATION

        pre isdir(master_dir)
        walk_master(c) = scan(master_dir, ignore, c)

=cut
537
538sub walk_master {
539
174
302
        my ($self, $cache) = @_;
540
541
174
433
        my $master = $self->master_dir();
542
174
1341
        $self->_throw('store.no_master', { path => $master }) unless -d $master;
543
544        return App::FileSync::Scan::scan(
545                root   => $master,
546                ignore => $self->{ignore},
547
174
796
                cache  => $cache || {},
548        );
549}
550
551 - 590
=head2 ensure_parent

Purpose: create the directory a master path will be written into.

Args: C<$rel>.

Returns: the parent directory.

Side Effects: creates missing directories.

Usage:

        $store->ensure_parent($path);

=head3 EXAMPLE

        $store->ensure_parent('new/deep/dir/file.txt');

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

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

=head3 MESSAGES

As for L</abs_path>, plus C<io.mkdir>.

=head3 FORMAL SPECIFICATION

        isdir'(dirname(abs_path(rel)))

=cut
591
592sub ensure_parent {
593
181
192
        my ($self, $rel) = @_;
594
595
181
324
        return ensure_dir(dirname($self->abs_path($rel)));
596}
597
598 - 640
=head2 temp_file

Purpose: an exclusive temporary file beside a master path.

Args: C<$rel>, the path the temporary file will become.

Returns: C<[ $filehandle, $path ]>.

Side Effects: creates the file (and its directory).

Usage:

        my ($fh, $temp) = @{ $store->temp_file($path) };

=head3 EXAMPLE

        my ($fh, $temp) = @{ $store->temp_file('notes/plan.txt') };
        print {$fh} $content;
        close $fh;
        $store->commit_temp('notes/plan.txt', $temp, time, oct(644));

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

        { type => 'arrayref', min => 2, max => 2 }

=head3 MESSAGES

As for L</abs_path>, plus C<io.temp>.

=head3 FORMAL SPECIFICATION

        temp_file(rel) = App::FileSync::IO::temp_file(dirname(abs_path(rel)))

=cut
641
642sub temp_file {
643
176
210
        my ($self, $rel) = @_;
644
645
176
229
        return App::FileSync::IO::temp_file(dirname($self->abs_path($rel)));
646}
647
648 - 690
=head2 commit_temp

Purpose: move a verified temporary file into the master.

Args: C<$rel>, C<$temp>, optional C<$mtime> and C<$mode>.

Returns: the absolute path written.

Side Effects: see L<App::FileSync::IO/install_file>.

Usage:

        $store->commit_temp($path, $temp, $mtime, $mode);

=head3 EXAMPLE

        $store->commit_temp('a.txt', $temp, 1_700_000_000, oct(600));

=head3 API SPECIFICATION

=head4 INPUT

        [
                { name => 'self',  type => 'object' },
                { name => 'rel',   type => 'string', min => 1 },
                { name => 'temp',  type => 'string', min => 1 },
                { name => 'mtime', type => 'number', optional => 1 },
                { name => 'mode',  type => 'integer', optional => 1 },
        ]

=head4 OUTPUT

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

=head3 MESSAGES

As for L<App::FileSync::IO/install_file> and L</abs_path>.

=head3 FORMAL SPECIFICATION

        commit_temp(rel, t, m, mode) = install_file(t, abs_path(rel), m, mode)

=cut
691
692sub commit_temp {
693
174
276
        my ($self, $rel, $temp, $mtime, $mode) = @_;
694
695
174
287
        return install_file($temp, $self->abs_path($rel), $mtime, $mode);
696}
697
698 - 739
=head2 move_to_trash

Purpose: move a master path into the repo's trash.

Args: C<$rel>, optional C<$epoch>.

Returns: where it went, or C<undef> if it was not there.

Side Effects: see L<App::FileSync::Trash/move_to_trash>.

Usage:

        my $trashed = $store->move_to_trash($path, time);

=head3 EXAMPLE

        my $where = $store->move_to_trash('old/report.txt');

=head3 API SPECIFICATION

=head4 INPUT

        [
                { name => 'self',  type => 'object' },
                { name => 'rel',   type => 'string', min => 1 },
                { name => 'epoch', type => 'number', optional => 1 },
        ]

=head4 OUTPUT

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

=head3 MESSAGES

As for L<App::FileSync::Trash/move_to_trash> and L</abs_path>.

=head3 FORMAL SPECIFICATION

        abs_path(rel) ∉ dom FS ⇒ result = ⊥
        abs_path(rel) ∈ dom FS ⇒ result = Trash::move_to_trash(abs_path(rel), rel, trash_dir, epoch)

=cut
740
741sub move_to_trash {
742
17
23
        my ($self, $rel, $epoch) = @_;
743
744
17
28
        my $abs = $self->abs_path($rel);
745
17
126
        return undef unless -e $abs || -l $abs; ## no critic (Subroutines::ProhibitExplicitReturnUndef)
746
747
17
36
        return App::FileSync::Trash::move_to_trash(
748                src   => $abs,
749                rel   => $rel,
750                root  => $self->trash_dir(),
751                epoch => defined $epoch ? $epoch : time,
752        );
753}
754
755 - 804
=head2 clean_stale_temps

Documented with L</prune_trash> below.

=head2 prune_trash

Purpose: housekeeping at the start of a session.

Args: C<clean_stale_temps(max_age =E<gt> SECONDS)>;
C<prune_trash(days =E<gt> N, dry_run =E<gt> BOOL)>.  Defaults: one hour, and
the repo's C<trash_days> (30).

Returns: the number of temporaries removed; the arrayref of trash buckets
removed.

Side Effects: deletes stale temporaries from the master and expired trash.

Usage:

        $store->clean_stale_temps();
        $store->prune_trash();

=head3 EXAMPLE

        my $pruned = $store->prune_trash(dry_run => 1);

=head3 API SPECIFICATION

=head4 INPUT

        {
                max_age => { type => 'integer', min => 0, optional => 1 },
                days    => { type => 'number', optional => 1 },
                dry_run => { type => 'boolean', optional => 1 },
        }

=head4 OUTPUT

        { type => 'scalar' }

=head3 MESSAGES

None.

=head3 FORMAL SPECIFICATION

        clean_stale_temps = Scan::clean_stale_temps(master_dir, age)
        prune_trash = Trash::prune(trash_dir, days)

=cut
805
806sub clean_stale_temps {
807
167
341
        my ($self, %args) = @_;
808
809
167
432
        my $master = $self->master_dir();
810
167
1012
        return 0 unless -d $master;
811
812        return App::FileSync::Scan::clean_stale_temps(
813                root    => $master,
814
167
806
                max_age => defined $args{max_age} ? $args{max_age} : $STALE_TEMP_SECONDS,
815        );
816}
817
818sub prune_trash {
819
161
264
        my ($self, %args) = @_;
820
821        return App::FileSync::Trash::prune(
822                root    => $self->trash_dir(),
823                days    => defined $args{days} ? $args{days} : ($self->{config}{trash_days} || $TRASH_DAYS),
824                dry_run => $args{dry_run},
825
161
382
        );
826}
827
828 - 868
=head2 journal

Purpose: append an event to this month's journal.

Args: C<\%event>; a C<ts> (now) is added.

Returns: 1.

Side Effects: appends one JSON line, creating the journal directory.

Usage:

        $store->journal({ client => $id, action => 'push', path => $path, result => 'ok' });

=head3 EXAMPLE

        $store->journal({ action => 'reindex', files => 10, result => 'ok' });

=head3 API SPECIFICATION

=head4 INPUT

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

=head4 OUTPUT

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

=head3 MESSAGES

        Key         Meaning                        Resolution
        io.append   the journal cannot be written  check space

=head3 FORMAL SPECIFICATION

        FS'(journal_path) = FS(journal_path) ⁀ json({ts ↦ now} ⊕ event) ⁀ "\n"

=cut
869
870sub journal {
871
542
640
        my ($self, $event) = @_;
872
873
542
542
961
1747
        append_line($self->journal_path(), json_encode({ ts => time, %{$event} }));
874
542
1255
        return 1;
875}
876
877# _assert_no_symlinked_parent
878# Purpose:     refuse to write through a symlinked directory in the master.
879# Entry:       $rel has passed validate_relpath.
880# Exit:        returns 1.
881# Side effects: raises store.symlink.  Stops at the first missing directory:
882#              nothing below it can be a symlink yet.
883sub _assert_no_symlinked_parent :Private {
884        my ($self, $rel) = @_;
885
886        my @parts = split m{/}, $rel;
887        pop @parts;
888
889        my $current = $self->master_dir();
890        for my $part (@parts) {
891                $current = File::Spec->catfile($current, $part);
892                last unless -e $current || -l $current;
893                $self->_throw('store.symlink', { path => $current }) if -l $current;
894        }
895
896        return 1;
897
314
314
314
217171
276
3843
}
898
899# _load_entries
900# Purpose:     read an index or base file.
901# Entry:       $path may not exist.
902# Exit:        returns the entries hashref (empty for a missing file or one
903#              without an entries hash).
904# Side effects: raises config.parse for a corrupt file.
905sub _load_entries :Private {
906        my ($self, $path) = @_;
907
908        return {} unless -e $path;
909
910        my $text = read_text($path);
911        my $document = eval { json_decode($text) };
912        $self->_throw('config.parse', { path => $path, error => strip_location($@) }) if $@;
913
914        return (ref($document) eq 'HASH' && ref($document->{entries}) eq 'HASH') ? $document->{entries} : {};
915
314
314
314
115798
263
2619
}
916
917# _save_entries
918# Purpose:     write an index or base file.
919# Entry:       $entries may be undef (written as empty).
920# Exit:        returns 1.
921# Side effects: replaces $path atomically.
922sub _save_entries :Private {
923        my ($self, $path, $entries) = @_;
924
925        write_atomic($path, json_encode({ version => 1, entries => $entries || {} }) . "\n");
926        return 1;
927
314
314
314
96872
262
2353
}
928
9291;
930