NAME

App::FileSync::Server - serve one sync session against a repo's master

SYNOPSIS

my $server = App::FileSync::Server->new(
	root => '/srv/filesync', repo => 'docs', client => 'laptop', role => 'client',
	connection => App::FileSync::Protocol->new(in => \*STDIN, out => \*STDOUT),
);
exit($server->run ? 0 : 1);

DESCRIPTION

A session is: hello, manifest, plan, then the transfers the plan lists, then done. The server holds the repo lock throughout, so sessions on one repo never interleave. It updates the master index and the client's base only in done, after every planned action has been applied; a session that dies part way leaves both as they were, and the next session replans from the tree.

METHODS

new

Purpose: prepare a session.

Args: root, repo, client, role (client or replica), connection (an App::FileSync::Protocol), optional logger and language.

Returns: the server object.

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

Usage:

my $server = App::FileSync::Server->new(%pinned, connection => $connection);

EXAMPLE

my $server = App::FileSync::Server->new(
	root => $root, repo => 'docs', client => 'laptop', role => 'client', connection => $conn,
);

API SPECIFICATION

INPUT

{
	root       => { type => 'string', min => 1 },
	repo       => { type => 'string', min => 1, max => 64 },
	client     => { type => 'string', min => 1, max => 64 },
	role       => { type => 'string', memberof => [ 'client', 'replica' ] },
	connection => { type => 'object', can => [ 'send', 'recv' ] },
	logger     => { type => 'object', optional => 1 },
	language   => { type => 'string', optional => 1 },
}

OUTPUT

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

MESSAGES

Key                  Meaning                         Resolution
server.missing       a required argument is absent   pass it
server.bad_role      role is not client or replica   fix the forced command
server.bad_id        repo or client is not an id     fix the forced command
server.no_connection no connection was given         pass connection

FORMAL SPECIFICATION

pre root ≠ "" ∧ repo ∈ Id ∧ client ∈ Id ∧ role ∈ {client, replica} ∧ connection ≠ ⊥
post finished = 0 ∧ plan = ∅ ∧ uploads = ∅

run

Purpose: serve the whole session.

Args: none.

Returns: 1 when the session finished, 0 when it failed (the client has been sent the error).

Side Effects: takes the repo lock; reads and writes the master, its index, the client's base, the trash and the journal; prunes the trash and removes stale temporaries. A failed session is journalled (exit 2 for a lock refusal, otherwise 1) unless it never got as far as opening the repo.

Usage:

exit($server->run ? 0 : 1);

EXAMPLE

my $ok = $server->run;
warn "session failed\n" unless $ok;

API SPECIFICATION

INPUT

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

OUTPUT

{ type => 'integer', memberof => [ 0, 1 ] }

MESSAGES

Key                    Meaning                             Resolution
server.locked          another session holds the lock      the client retries later
server.not_allowed     the client is not registered        filesync-admin client allow
server.wrong_role      the client is registered otherwise  fix the key's --role
server.proto           the client speaks another protocol  upgrade one side
server.pinned          the hello disagrees with the pin    fix the client config
server.unknown         an unknown command arrived          upgrade one side

Raised once the session is open. Each ends the session: the open uploads are discarded, the failure is journalled, and the text (without Perl's file and line) reaches the client as { ok => 0, fatal => 1, error => ... }. Most mean a client bug or a hostile client; the resolution is to fix the client, or simply to rerun the sync.

Key                     Meaning
server.manifest_list    the manifest is not a list
server.manifest_entry   a manifest entry is not an object
server.manifest_path    a manifest path is unsafe
server.manifest_field   a manifest t, h, s, m or mode field is malformed
server.refused          the deletion guard stopped the plan (rerun with --force if intended)
server.unplanned        an operation names a path that is not in the plan
server.not_planned      the path's planned action does not allow this operation
server.bad_field        an off, max, len, s, mode or h field is malformed
server.conflict_name    the conflict copy is named other than the plan says
server.conflict_unsafe  the planned conflict name is not a safe path
server.conflict_same    the planned conflict name equals the path
server.conflict_exists  something already exists at the conflict name
server.conflict_move    the master's copy could not be moved aside
server.bad_ack          an ack disagrees with the planned action
server.busy             a second put for a path already being received
server.no_put           chunk or chunk_end without a put
server.too_large        one chunk is larger than the chunk limit
server.overflow         the upload is larger than its announced size
server.hash_mismatch    the received content does not have the announced hash
server.size_mismatch    the received content does not have the announced size
server.no_delta         delta data without a delta start
server.bad_copy         a delta copy reaches outside the base file
server.bad_signature    a pull signature is malformed
server.no_signature     a pull delta operation without a signature
server.cannot_stat      a planned file vanished from the master
server.not_file         a get for a path that is a directory
server.unfinished       done arrived before every planned action completed

FORMAL SPECIFICATION

ΔFS
run = 1 ⇔ hello ⨾ open ⨾ lock ⨾ loop ⨾ done succeed
run = 0 ⇒ index' = index ∧ base' = base

PSEUDOCODE

read and check the hello against the pinned arguments
open the repo; check the client is allowed in its role
take the lock (refuse with code "locked" otherwise)
load the index and the client's base; tidy temporaries and the trash
send the policy, then serve commands until done
on any error: log it, journal the failed session, discard partial
uploads, and send the error to the client

validate_forced_command

Purpose: check the command a client asked sshd to run against the arguments pinned in authorized_keys.

The pinned arguments are the authority for a session. sshd passes the command the client asked for in SSH_ORIGINAL_COMMAND; it must agree with the pinned repo, client, role and protocol and ask for nothing else: in particular it may not choose its own --root.

Args: $original (the requested command; undef or blank is accepted), \%pinned (repo, client, role).

Returns: (1, undef) or (0, $reason). A list, deliberately: callers write my ($ok, $why) = ..., matching "validate_relpath" in App::FileSync.

Side Effects: none.

Usage:

my ($ok, $reason) = App::FileSync::Server::validate_forced_command($ENV{SSH_ORIGINAL_COMMAND}, \%pinned);

EXAMPLE

my ($ok, $why) = App::FileSync::Server::validate_forced_command(
	'filesync-server --repo docs --client laptop --role client --proto 2',
	{ repo => 'docs', client => 'laptop', role => 'client' },
);	# (1, undef)

API SPECIFICATION

INPUT

[
	{ name => 'original', type => 'string', optional => 1 },
	{ name => 'pinned',   type => 'hashref' },
]

OUTPUT

{ type => 'arrayref', min => 2, max => 2 }	# returned as a two-element list

MESSAGES

Key                   Meaning
forced.program        the command runs some other program
forced.argument       a token is not a --flag
forced.no_value       a flag has no value
forced.flag           the flag is not repo, client, role or proto
forced.proto          the protocol differs from this server's
forced.pinned         the value differs from the pinned one

FORMAL SPECIFICATION

let ts = tokens(original)
ok ⇔ (ts = ⟨⟩) ∨ ((head ts ∈ FLAG ∨ basename(head ts) = filesync-server) ∧
      ∀ (f, v) : flags(ts) • f ∈ {repo, client, role, proto} ∧
      (f = proto ⇒ v = PROTO) ∧ (f ≠ proto ⇒ v = pinned(f)))

PSEUDOCODE

accept an empty command
split on white space; allow a leading filesync-server path
for each --flag[=value] (value from the next token if not attached):
	reject an unknown flag, a missing value, a wrong protocol
	reject a value that differs from the pinned one

LIMITATIONS

  • The plan is computed once, from a scan taken while the lock is held. Something other than filesync writing into the master during a session (an admin's cp, say) is not seen until the next session, and a file it replaces may be overwritten.

  • The whole client manifest is held in memory, as is the plan; a repo with millions of paths needs memory in proportion.

  • Uploads are verified by SHA-256 only at the end; a client that sends a wrong file of the announced size wastes the transfer before it is refused.

  • validate_forced_command splits on white space, as sshd's SSH_ORIGINAL_COMMAND carries no quoting; a pinned value containing a space can therefore never match. Ids cannot contain spaces, so this only matters if that rule changes.

AUTHOR

Nigel Horne, <njh at nigelhorne.com>

LICENCE AND COPYRIGHT

Copyright 2026 Nigel Horne.

Usage is subject to the GPL2 licence terms. If you use it, please let me know.