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_commandsplits on white space, as sshd'sSSH_ORIGINAL_COMMANDcarries 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.