| File: | blib/lib/App/FileSync/Config.pm |
| Coverage: | 89.9% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::FileSync::Config; | |||||
| 2 | ||||||
| 3 | # Where configuration lives and what it holds: the client's repo list in | |||||
| 4 | # ~/.filesync/config.json, and each repo's policy under the server root. | |||||
| 5 | ||||||
| 6 | 314 314 314 | 765 261 4232 | use App::FileSync::Platform; | |||
| 7 | ||||||
| 8 | 314 314 314 | 538 237 2490 | use strict; | |||
| 9 | 314 314 314 | 423 226 5818 | use warnings; | |||
| 10 | 314 314 314 | 496 235 624 | use autodie qw(:all); | |||
| 11 | ||||||
| 12 | 314 314 314 | 662252 287 3645 | use File::Spec; | |||
| 13 | 314 314 314 | 510 263 3771 | use JSON::PP (); | |||
| 14 | 314 314 314 | 461 246 960 | use Sub::Private { mode => 'enforce' }; | |||
| 15 | ||||||
| 16 | 314 314 314 | 30438 265 7673 | use App::FileSync qw(json_encode json_decode read_text write_atomic); | |||
| 17 | 314 314 314 | 576 252 5634 | use App::FileSync::Args qw(positional); | |||
| 18 | 314 | 20254 | use App::FileSync::Constants qw( | |||
| 19 | $ROOT_MARKER $TEMP_PREFIX $SNAPSHOT_DIR | |||||
| 20 | $TRASH_DAYS $MAX_DELETES $MAX_DELETE_PERCENT | |||||
| 21 | 314 314 | 507 224 | ); | |||
| 22 | 314 314 314 | 633 271 6822 | use App::FileSync::I18N qw(throw strip_location); | |||
| 23 | 314 314 314 | 562 247 4931 | use App::FileSync::IO qw(ensure_dir); | |||
| 24 | ||||||
| 25 | 314 314 314 | 467 243 862 | use version; our $VERSION = version->declare('0.001.1'); | |||
| 26 | ||||||
| 27 | # Patterns that must never enter a manifest; they are excluded on both sides. | |||||
| 28 | our @DEFAULT_IGNORE = ( | |||||
| 29 | $ROOT_MARKER, | |||||
| 30 | "$TEMP_PREFIX*", | |||||
| 31 | $SNAPSHOT_DIR, | |||||
| 32 | '*.swp', | |||||
| 33 | '*~', | |||||
| 34 | '.#*', | |||||
| 35 | '#*#', | |||||
| 36 | '.DS_Store', | |||||
| 37 | '._*', | |||||
| 38 | 'Thumbs.db', | |||||
| 39 | 'desktop.ini', | |||||
| 40 | '*.crdownload', | |||||
| 41 | '*.part', | |||||
| 42 | ); | |||||
| 43 | ||||||
| 44 | # Schemas for the positional entry points. | |||||
| 45 | my %PATH_RULE = (type => 'string', min => 1); | |||||
| 46 | my %ROOT_REPO = (root => {%PATH_RULE}, repo => {%PATH_RULE}); | |||||
| 47 | ||||||
| 48 | =encoding utf8 | |||||
| 49 | ||||||
| 50 - 112 | =head1 NAME
App::FileSync::Config - client and repo configuration files
=head1 SYNOPSIS
my $client = App::FileSync::Config::load_client();
$client->{repos}{docs}{root} = '/home/foo/Documents';
App::FileSync::Config::save_client($client);
my $repo = App::FileSync::Config::load_repo('/srv/filesync', 'docs');
=head1 DESCRIPTION
The client keeps one JSON file, F<~/.filesync/config.json> (or
C<FILESYNC_CONFIG>), listing its repos. The server keeps one JSON file per
repo, F<ROOT/.filesync/repos/NAME/config.json>, holding its policy (trash
retention, deletion limits, ignore patterns) and the allowed clients. Both are
written atomically.
=head1 FUNCTIONS
=head2 home_dir
Purpose: the client's state directory.
Args: none.
Returns: C<FILESYNC_HOME>, else F<~/.filesync> (using C<HOME>, then
C<USERPROFILE>).
Side Effects: none.
Usage:
my $home = App::FileSync::Config::home_dir();
=head3 EXAMPLE
print App::FileSync::Config::home_dir(), "\n"; # /home/foo/.filesync
=head3 API SPECIFICATION
=head4 INPUT
[]
=head4 OUTPUT
{ type => 'string', min => 1 }
=head3 MESSAGES
Key Meaning Resolution
config.no_home no home directory is known set FILESYNC_HOME
=head3 FORMAL SPECIFICATION
home_dir = ENV(FILESYNC_HOME) if non-empty
= (ENV(HOME) or ENV(USERPROFILE)) / ".filesync" if one is non-empty
â config.no_home otherwise
=cut | |||||
| 113 | ||||||
| 114 | sub home_dir { | |||||
| 115 | 1066 | 2183 | my $explicit = _env('FILESYNC_HOME'); | |||
| 116 | 1066 | 8780 | return $explicit if defined $explicit; | |||
| 117 | ||||||
| 118 | 0 | 0 | my $home = _env('HOME'); | |||
| 119 | 0 | 0 | $home = _env('USERPROFILE') unless defined $home; | |||
| 120 | 0 | 0 | throw('config.no_home') unless defined $home; | |||
| 121 | ||||||
| 122 | 0 | 0 | return File::Spec->catdir($home, '.filesync'); | |||
| 123 | } | |||||
| 124 | ||||||
| 125 - 163 | =head2 client_config_path
Purpose: where the client's repo list is stored.
Args: none.
Returns: C<FILESYNC_CONFIG>, else F<HOME_DIR/config.json>.
Side Effects: none.
Usage:
my $path = App::FileSync::Config::client_config_path();
=head3 EXAMPLE
local $ENV{FILESYNC_CONFIG} = '/tmp/test.json';
print App::FileSync::Config::client_config_path(), "\n"; # /tmp/test.json
=head3 API SPECIFICATION
=head4 INPUT
[]
=head4 OUTPUT
{ type => 'string', min => 1 }
=head3 MESSAGES
Key Meaning Resolution
config.no_home no home directory is known set FILESYNC_HOME
=head3 FORMAL SPECIFICATION
client_config_path = ENV(FILESYNC_CONFIG) if non-empty, else home_dir / "config.json"
=cut | |||||
| 164 | ||||||
| 165 | sub client_config_path { | |||||
| 166 | 75 | 351 | my $explicit = _env('FILESYNC_CONFIG'); | |||
| 167 | ||||||
| 168 | 75 | 179 | return defined $explicit ? $explicit : File::Spec->catfile(home_dir(), 'config.json'); | |||
| 169 | } | |||||
| 170 | ||||||
| 171 - 215 | =head2 default_client_config
Purpose: the configuration of a client that has no repos yet.
Args: none.
Returns: a new hashref C<{ version, ssh =E<gt> { command, extra_args }, repos =E<gt> {} }>.
Side Effects: none.
Usage:
my $config = App::FileSync::Config::default_client_config();
=head3 EXAMPLE
my $config = App::FileSync::Config::default_client_config();
$config->{client_id} = 'laptop-1';
=head3 API SPECIFICATION
=head4 INPUT
[]
=head4 OUTPUT
{
type => 'hashref',
schema => {
version => { type => 'integer' },
ssh => { type => 'hashref' },
repos => { type => 'hashref' },
},
}
=head3 MESSAGES
None.
=head3 FORMAL SPECIFICATION
default_client_config = â¨version ⦠1, ssh ⦠default_ssh, repos ⦠â
â©
=cut | |||||
| 216 | ||||||
| 217 | sub default_client_config { | |||||
| 218 | return { | |||||
| 219 | 75 | 395 | version => 1, | |||
| 220 | ssh => _default_ssh(), | |||||
| 221 | repos => {}, | |||||
| 222 | }; | |||||
| 223 | } | |||||
| 224 | ||||||
| 225 - 267 | =head2 load_client
Purpose: read the client's repo list.
Args: optional C<$path> (default L</client_config_path>).
Returns: the configuration hashref; a missing file gives
L</default_client_config>. C<repos> and C<ssh> are always hashes.
Side Effects: none.
Usage:
my $config = App::FileSync::Config::load_client($opts{config});
=head3 EXAMPLE
my $config = App::FileSync::Config::load_client();
print join(', ', sort keys %{ $config->{repos} }), "\n";
=head3 API SPECIFICATION
=head4 INPUT
[ { name => 'path', type => 'string', optional => 1 } ]
=head4 OUTPUT
{ type => 'hashref' }
=head3 MESSAGES
Key Meaning Resolution
config.parse the file is not valid JSON fix or remove it
config.not_object the JSON is not an object fix or remove it
io.read the file cannot be read check permissions
=head3 FORMAL SPECIFICATION
load_client(p) = normalise(json(FS(p))) if p â dom FS
= default_client_config otherwise
=cut | |||||
| 268 | ||||||
| 269 | sub load_client { | |||||
| 270 | 281 | 9493456 | my $args = positional(\@_, ['path'], { path => { type => 'string', optional => 1 } }, 'load_client'); | |||
| 271 | 281 | 1339 | my $path = _or_default($args->{path}, \&client_config_path); | |||
| 272 | ||||||
| 273 | 281 | 2487 | my $config = -e $path ? _load_object($path) : default_client_config(); | |||
| 274 | ||||||
| 275 | # Older or hand-edited files may lack either section. | |||||
| 276 | 281 | 606 | $config->{repos} = {} unless ref($config->{repos}) eq 'HASH'; | |||
| 277 | 281 | 580 | $config->{ssh} = _default_ssh() unless ref($config->{ssh}) eq 'HASH'; | |||
| 278 | ||||||
| 279 | 281 | 550 | return $config; | |||
| 280 | } | |||||
| 281 | ||||||
| 282 - 323 | =head2 save_client
Purpose: write the client's repo list.
Args: C<\%config>, optional C<$path>.
Returns: the path written.
Side Effects: replaces the file atomically, creating its directory.
Usage:
App::FileSync::Config::save_client($config, $path);
=head3 EXAMPLE
my $written = App::FileSync::Config::save_client(App::FileSync::Config::default_client_config());
=head3 API SPECIFICATION
=head4 INPUT
[
{ name => 'config', type => 'hashref' },
{ name => 'path', type => 'string', optional => 1 },
]
=head4 OUTPUT
{ type => 'string', min => 1 }
=head3 MESSAGES
Key Meaning Resolution
io.write the file cannot be written check permissions and space
=head3 FORMAL SPECIFICATION
ÎFS
FS'(path) = json(config) â "\n"
=cut | |||||
| 324 | ||||||
| 325 | sub save_client { | |||||
| 326 | 82 | 2259 | my $args = positional(\@_, [qw(config path)], { | |||
| 327 | config => { type => 'hashref' }, | |||||
| 328 | path => { type => 'string', optional => 1 }, | |||||
| 329 | }, 'save_client'); | |||||
| 330 | ||||||
| 331 | 82 | 355 | my $path = _or_default($args->{path}, \&client_config_path); | |||
| 332 | 82 | 203 | write_atomic($path, json_encode($args->{config}) . "\n"); | |||
| 333 | ||||||
| 334 | 82 | 206 | return $path; | |||
| 335 | } | |||||
| 336 | ||||||
| 337 - 381 | =head2 repo_meta_dir
Documented with L</repo_config_path> below.
=head2 repo_config_path
Purpose: where a repo's metadata (and its config file) lives on the server.
Args: C<$root>, C<$repo>.
Returns: F<ROOT/.filesync/repos/REPO> and F<.../config.json>.
Side Effects: none.
Usage:
my $meta = App::FileSync::Config::repo_meta_dir($root, $repo);
=head3 EXAMPLE
print App::FileSync::Config::repo_config_path('/srv/filesync', 'docs'), "\n";
=head3 API SPECIFICATION
=head4 INPUT
[
{ name => 'root', type => 'string', min => 1 },
{ name => 'repo', type => 'string', min => 1 },
]
=head4 OUTPUT
{ type => 'string', min => 1 }
=head3 MESSAGES
None.
=head3 FORMAL SPECIFICATION
repo_meta_dir(r, n) = r / ".filesync" / "repos" / n
repo_config_path(r, n) = repo_meta_dir(r, n) / "config.json"
=cut | |||||
| 382 | ||||||
| 383 | sub repo_meta_dir { | |||||
| 384 | 2612 | 2630 | my ($root, $repo) = @_; | |||
| 385 | ||||||
| 386 | 2612 | 23122 | return File::Spec->catdir($root, '.filesync', 'repos', $repo); | |||
| 387 | } | |||||
| 388 | ||||||
| 389 | sub repo_config_path { | |||||
| 390 | 645 | 1848 | my ($root, $repo) = @_; | |||
| 391 | ||||||
| 392 | 645 | 1075 | return File::Spec->catfile(repo_meta_dir($root, $repo), 'config.json'); | |||
| 393 | } | |||||
| 394 | ||||||
| 395 - 436 | =head2 default_repo_config
Purpose: the policy of a newly created repo.
Args: C<$name>.
Returns: a new hashref: 30 days of trash, at most 200 or 10% of known files
deleted per session, inline conflict copies, modes preserved, symlinks not
followed, no clients, the default ignore list.
Side Effects: none.
Usage:
my $config = App::FileSync::Config::default_repo_config('docs');
=head3 EXAMPLE
my $config = App::FileSync::Config::default_repo_config('photos');
$config->{trash_days} = 90;
=head3 API SPECIFICATION
=head4 INPUT
[ { name => 'name', type => 'string', min => 1 } ]
=head4 OUTPUT
{ type => 'hashref' }
=head3 MESSAGES
Key Meaning Resolution
args.invalid no name was given pass the repo name
=head3 FORMAL SPECIFICATION
default_repo_config(n) = â¨name ⦠n, trash_days ⦠30, max_deletes ⦠200,
max_delete_percent ⦠10, clients ⦠â
, ignore ⦠DEFAULT_IGNORE, ...â©
=cut | |||||
| 437 | ||||||
| 438 | sub default_repo_config { | |||||
| 439 | 77 | 426 | my $args = positional(\@_, ['name'], { name => {%PATH_RULE} }, 'default_repo_config'); | |||
| 440 | ||||||
| 441 | return { | |||||
| 442 | version => 1, | |||||
| 443 | name => $args->{name}, | |||||
| 444 | 77 | 751 | created => scalar(gmtime()) . ' UTC', | |||
| 445 | trash_days => $TRASH_DAYS, | |||||
| 446 | max_deletes => $MAX_DELETES, | |||||
| 447 | max_delete_percent => $MAX_DELETE_PERCENT, | |||||
| 448 | conflicts => 'inline', | |||||
| 449 | preserve_mode => JSON::PP::true, | |||||
| 450 | allow_symlinks => JSON::PP::false, | |||||
| 451 | clients => {}, | |||||
| 452 | ignore => [ @DEFAULT_IGNORE ], | |||||
| 453 | }; | |||||
| 454 | } | |||||
| 455 | ||||||
| 456 - 501 | =head2 load_repo
Purpose: read a repo's policy from the server root.
Args: C<$root>, C<$repo>.
Returns: the configuration hashref; C<ignore> is always an array and
C<clients> always a hash.
Side Effects: none.
Usage:
my $config = App::FileSync::Config::load_repo($root, 'docs');
=head3 EXAMPLE
my $config = App::FileSync::Config::load_repo('/srv/filesync', 'docs');
print "$config->{trash_days} days of trash\n";
=head3 API SPECIFICATION
=head4 INPUT
[
{ name => 'root', type => 'string', min => 1 },
{ name => 'repo', type => 'string', min => 1 },
]
=head4 OUTPUT
{ type => 'hashref' }
=head3 MESSAGES
Key Meaning Resolution
config.no_repo no such repo under the root filesync-admin repo create
config.parse the file is not valid JSON fix it by hand
config.not_object the JSON is not an object fix it by hand
=head3 FORMAL SPECIFICATION
load_repo(r, n) = normalise(json(FS(repo_config_path(r, n))))
pre repo_config_path(r, n) â dom FS
=cut | |||||
| 502 | ||||||
| 503 | sub load_repo { | |||||
| 504 | 413 | 1740 | my $args = positional(\@_, [qw(root repo)], {%ROOT_REPO}, 'load_repo'); | |||
| 505 | 413 413 | 817 707 | my ($root, $repo) = @{$args}{qw(root repo)}; | |||
| 506 | ||||||
| 507 | 413 | 894 | my $path = repo_config_path($root, $repo); | |||
| 508 | 413 | 3474 | throw('config.no_repo', { repo => $repo, root => $root }) unless -e $path; | |||
| 509 | ||||||
| 510 | 411 | 949 | my $config = _load_object($path); | |||
| 511 | 411 | 796 | $config->{ignore} = [ @DEFAULT_IGNORE ] unless ref($config->{ignore}) eq 'ARRAY'; | |||
| 512 | 411 | 779 | $config->{clients} = {} unless ref($config->{clients}) eq 'HASH'; | |||
| 513 | ||||||
| 514 | 411 | 1236 | return $config; | |||
| 515 | } | |||||
| 516 | ||||||
| 517 - 563 | =head2 save_repo
Purpose: write a repo's policy.
Args: C<$root>, C<$repo>, C<\%config>.
Returns: the path written.
Side Effects: creates the repo's metadata directory; replaces the file
atomically.
Usage:
App::FileSync::Config::save_repo($root, 'docs', $config);
=head3 EXAMPLE
my $config = App::FileSync::Config::load_repo($root, 'docs');
$config->{trash_days} = 60;
App::FileSync::Config::save_repo($root, 'docs', $config);
=head3 API SPECIFICATION
=head4 INPUT
[
{ name => 'root', type => 'string', min => 1 },
{ name => 'repo', type => 'string', min => 1 },
{ name => 'config', type => 'hashref' },
]
=head4 OUTPUT
{ type => 'string', min => 1 }
=head3 MESSAGES
Key Meaning Resolution
io.mkdir the directory cannot be made check permissions
io.write the file cannot be written check permissions and space
=head3 FORMAL SPECIFICATION
ÎFS
FS'(repo_config_path(r, n)) = json(config) â "\n"
=cut | |||||
| 564 | ||||||
| 565 | sub save_repo { | |||||
| 566 | 154 | 904 | my $args = positional(\@_, [qw(root repo config)], { | |||
| 567 | %ROOT_REPO, | |||||
| 568 | config => { type => 'hashref' }, | |||||
| 569 | }, 'save_repo'); | |||||
| 570 | 154 154 | 313 235 | my ($root, $repo) = @{$args}{qw(root repo)}; | |||
| 571 | ||||||
| 572 | 154 | 183 | ensure_dir(repo_meta_dir($root, $repo)); | |||
| 573 | 154 | 201 | my $path = repo_config_path($root, $repo); | |||
| 574 | 154 | 327 | write_atomic($path, json_encode($args->{config}) . "\n"); | |||
| 575 | ||||||
| 576 | 154 | 382 | return $path; | |||
| 577 | } | |||||
| 578 | ||||||
| 579 | # _load_object | |||||
| 580 | # Purpose: read a JSON file that must hold an object. | |||||
| 581 | # Entry: $path exists. | |||||
| 582 | # Exit: returns the decoded hashref. | |||||
| 583 | # Side effects: raises config.parse or config.not_object. | |||||
| 584 | sub _load_object :Private { | |||||
| 585 | my ($path) = @_; | |||||
| 586 | ||||||
| 587 | my $text = read_text($path); | |||||
| 588 | my $data = eval { json_decode($text) }; | |||||
| 589 | throw('config.parse', { path => $path, error => strip_location($@) }) if $@; | |||||
| 590 | throw('config.not_object', { path => $path }) unless ref($data) eq 'HASH'; | |||||
| 591 | ||||||
| 592 | return $data; | |||||
| 593 | 314 314 314 | 158543 337 4742 | } | |||
| 594 | ||||||
| 595 | # _default_ssh | |||||
| 596 | # Purpose: the ssh section of a client config that has none. | |||||
| 597 | # Entry: none. | |||||
| 598 | # Exit: returns a new hashref. | |||||
| 599 | # Side effects: none. | |||||
| 600 | sub _default_ssh :Private { | |||||
| 601 | return { command => 'ssh', extra_args => [] }; | |||||
| 602 | 314 314 314 | 84606 301 2518 | } | |||
| 603 | ||||||
| 604 | # _env | |||||
| 605 | # Purpose: an environment variable, treating empty as unset. | |||||
| 606 | # Entry: $name is the variable's name. | |||||
| 607 | # Exit: returns the value, or undef when unset or empty. | |||||
| 608 | # Side effects: none. | |||||
| 609 | sub _env :Private { | |||||
| 610 | my ($name) = @_; | |||||
| 611 | ||||||
| 612 | my $value = $ENV{$name}; | |||||
| 613 | return (defined $value && length $value) ? $value : undef; | |||||
| 614 | 314 314 314 | 82435 276 2321 | } | |||
| 615 | ||||||
| 616 | # _or_default | |||||
| 617 | # Purpose: use a given path, or compute the default one. | |||||
| 618 | # Entry: $given may be undef or empty; $default is a coderef. | |||||
| 619 | # Exit: returns the path to use. | |||||
| 620 | # Side effects: whatever $default does (reads %ENV). | |||||
| 621 | sub _or_default :Private { | |||||
| 622 | my ($given, $default) = @_; | |||||
| 623 | ||||||
| 624 | return (defined $given && length $given) ? $given : $default->(); | |||||
| 625 | 314 314 314 | 85344 280 2256 | } | |||
| 626 | ||||||
| 627 | 1; | |||||
| 628 | ||||||