NAME

App::FileSync::IO - filesystem primitives for App::FileSync

SYNOPSIS

use App::FileSync::IO qw(write_atomic read_text sha256_file);

write_atomic('/srv/filesync/.filesync/repos/docs/master.json', $json);
my $text = read_text($path);

DESCRIPTION

Every function raises a catalogued error (see App::FileSync::I18N) that names the path and carries the operating system's reason.

FUNCTIONS

open_file

Purpose: open a file in binary mode.

Args: $path, $mode (<, > or >>).

Returns: the filehandle.

Side Effects: creates or truncates the file for >, creates it for >>.

Usage:

my $fh = open_file($path, '<');

EXAMPLE

my $out = open_file('/tmp/report.bin', '>');
print {$out} $bytes;
close $out;

API SPECIFICATION

INPUT

[
	{ name => 'path', type => 'string', min => 1 },
	{ name => 'mode', type => 'string', memberof => [ '<', '>', '>>' ] },
]

OUTPUT

{ type => 'handle' }

MESSAGES

Key        Meaning                          Resolution
io.read    the file cannot be opened        check it exists and is readable
io.write   the file cannot be created       check the directory is writable
io.append  the file cannot be appended to   check permissions and free space

FORMAL SPECIFICATION

open_file : PATH × MODE ↛ HANDLE
pre  mode ∈ {<, >, >>} ∧ (mode = < ⇒ path ∈ dom FS)
post mode = > ⇒ FS'(path) = ⟨⟩

read_text

Purpose: read a whole file as bytes.

Args: $path.

Returns: the contents (an empty string for an empty file).

Side Effects: none.

Usage:

my $json = read_text($config_path);

EXAMPLE

my $marker = read_text("$root/.filesync-root");

API SPECIFICATION

INPUT

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

OUTPUT

{ type => 'string' }

MESSAGES

Key       Meaning                     Resolution
io.read   the file cannot be opened   check it exists and is readable

FORMAL SPECIFICATION

read_text : PATH ↛ seq BYTE
pre  path ∈ dom FS
post result = FS(path)

temp_file

Purpose: create a temporary file beside its final location.

Args: $directory; created if missing.

Returns: an arrayref [ $filehandle, $path ].

Side Effects: creates the file exclusively (O_EXCL), so a planted file or symlink of the same name cannot be written through. Its permissions are those an ordinary open would give under the current umask. The name starts with .filesync-tmp-, which the scanner ignores and stale-temp cleanup removes.

Usage:

my ($fh, $temp) = @{ temp_file($directory) };

EXAMPLE

my ($fh, $temp) = @{ temp_file('/home/foo/Documents/notes') };
print {$fh} $bytes;
close $fh;
install_file($temp, '/home/foo/Documents/notes/plan.txt');

API SPECIFICATION

INPUT

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

OUTPUT

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

MESSAGES

Key          Meaning                                  Resolution
io.mkdir     the directory cannot be created          check permissions
io.temp      no temporary file can be made there      check permissions and free space

FORMAL SPECIFICATION

ΔFS
dir? : PATH ; t! : PATH
t! ¬in; dom FS ∧ dirname(t!) = dir? ∧ FS' = FS ∪ {t! ↦ ⟨⟩}

write_atomic

Purpose: replace a file's contents so readers see the old or the new version, never a mixture.

Args: $path, $data (bytes).

Returns: 1.

Side Effects: creates missing parent directories; writes a temporary file in the same directory and renames it over $path. The temporary file is removed if any step fails.

Usage:

write_atomic($path, json_encode($config) . "\n");

EXAMPLE

write_atomic('/tmp/state.json', qq({"ok":1}\n));

API SPECIFICATION

INPUT

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

OUTPUT

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

MESSAGES

Key        Meaning                                Resolution
io.temp    no temporary file can be created       check permissions and space
io.write   writing or closing the file failed     check free space
io.rename  the new file cannot replace the old    check permissions

FORMAL SPECIFICATION

ΔFS
path? : PATH ; data? : seq BYTE
FS'(path?) = data?
∀ t : dom FS' \ dom FS • ¬ prefix(TEMP, basename t)

install_file

Purpose: move a verified temporary file into place.

Args: $temp, $target, optional $mtime (epoch, may be fractional) and $mode (permission bits).

Returns: $target.

Side Effects: sets the mode (not on Windows) and mtime on $temp, removes whatever is at $target (a file, a symlink or an empty directory), then renames. $temp is removed if the rename fails.

Usage:

install_file($temp, $abs, $mtime, $mode);

EXAMPLE

my ($fh, $temp) = @{ temp_file($dir) };
print {$fh} $content;
close $fh;
install_file($temp, "$dir/report.txt", 1_700_000_000, oct(644));

API SPECIFICATION

INPUT

[
	{ name => 'temp',   type => 'string', min => 1 },
	{ name => 'target', type => 'string', min => 1 },
	{ name => 'mtime',  type => 'number', optional => 1 },
	{ name => 'mode',   type => 'integer', optional => 1, min => 0 },
]

OUTPUT

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

MESSAGES

Key                Meaning                                     Resolution
io.remove          the old file cannot be removed              check permissions
io.replace_dir     a non-empty directory is in the way         move it aside
io.rename          the new file cannot be moved into place     check permissions

FORMAL SPECIFICATION

ΔFS
temp?, target? : PATH
pre  temp? ∈ dom FS
post FS'(target?) = FS(temp?) ∧ temp? ¬in; dom FS'

clear_target

Purpose: make room for a file at $path.

Args: $path.

Returns: 1.

Side Effects: removes a symlink or file at $path, or an empty directory. A non-empty directory is never removed.

Usage:

clear_target($abs);

EXAMPLE

clear_target('/tmp/old-copy.txt');

API SPECIFICATION

INPUT

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

OUTPUT

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

MESSAGES

Key              Meaning                              Resolution
io.remove        the file or symlink cannot go        check permissions
io.replace_dir   a non-empty directory is in the way  move it aside

FORMAL SPECIFICATION

ΔFS
path? : PATH
path? ∈ dom FS ∧ (kind path? ≠ dir ∨ children path? = ∅) ⇒ path? ¬in; dom FS'

set_mtime

Purpose: set a file's modification time, keeping sub-second precision where the platform allows.

Args: $path, $mtime (epoch; undef leaves the file alone).

Returns: 1.

Side Effects: changes the file's atime and mtime. Where Time::HiRes::utime is unimplemented (some Windows builds) whole seconds are stored, which the 2-second comparison tolerance already allows for.

Usage:

set_mtime($temp, 1_700_000_123.456);

EXAMPLE

set_mtime('/tmp/a.txt', time - 3600);

API SPECIFICATION

INPUT

[
	{ name => 'path',  type => 'string', min => 1 },
	{ name => 'mtime', type => 'number', optional => 1 },
]

OUTPUT

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

MESSAGES

Key        Meaning                         Resolution
io.utime   the timestamp cannot be set     check ownership of the file

FORMAL SPECIFICATION

ΔFS
path? : PATH ; m? : ℝ ∪ {⊥}
m? = ⊥ ⇒ FS' = FS
m? ≠ ⊥ ⇒ |mtime'(path?) − m?| < 1

sha256_file

Purpose: hash a file's contents.

Args: $path.

Returns: the lower-case hex SHA-256, or undef when $path is not a readable regular file.

Side Effects: none.

Usage:

my $hash = sha256_file($abs);

EXAMPLE

print sha256_file('/etc/hostname'), "\n";

API SPECIFICATION

INPUT

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

OUTPUT

{ type => 'string', optional => 1, matches => qr/\A[0-9a-f]{64}\z/ }

MESSAGES

None; an unreadable file yields undef.

FORMAL SPECIFICATION

sha256_file : PATH ⇸ HEX64
sha256_file(p) = sha256(FS(p))   if p ∈ dom FS ∧ kind p = file ∧ readable p

list_dir

Purpose: the entries of a directory.

Args: $directory.

Returns: an arrayref of names, sorted, without . and ..; undef when the directory cannot be read ($! says why).

Side Effects: none.

Usage:

my $names = list_dir($dir) or return 0;

EXAMPLE

print "$_\n" for @{ list_dir('/tmp') || [] };

API SPECIFICATION

INPUT

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

OUTPUT

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

MESSAGES

None; an unreadable directory yields undef.

FORMAL SPECIFICATION

list_dir : PATH ⇸ seq NAME
list_dir(d) = sort(children d \ {".", ".."})   if readable d

copy_preserving

Purpose: copy one regular file, keeping its mode and mtime.

Args: $source, $destination, optional \@stat of the source (saves a second stat when the caller already has one).

Returns: 1.

Side Effects: creates $destination's parent directories; overwrites $destination.

Usage:

copy_preserving($from, $to, \@stat);

EXAMPLE

copy_preserving('/srv/a.txt', '/backup/a.txt');

API SPECIFICATION

INPUT

[
	{ name => 'source',      type => 'string', min => 1 },
	{ name => 'destination', type => 'string', min => 1 },
	{ name => 'stat',        type => 'arrayref', optional => 1 },
]

OUTPUT

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

MESSAGES

Key       Meaning                      Resolution
io.copy   the copy could not be made   check space and permissions

FORMAL SPECIFICATION

ΔFS
src?, dst? : PATH
FS'(dst?) = FS(src?) ∧ mtime'(dst?) = mtime(src?) ∧ mode'(dst?) = mode(src?)

append_line

Purpose: append one line to a log or journal.

Args: $path, $line (without a newline).

Returns: 1.

Side Effects: creates $path and its directory if needed. If the file ends in a torn line from a killed run, a newline is written first so the new entry is not glued to the fragment.

Usage:

append_line($journal, json_encode($event));

EXAMPLE

append_line('/tmp/run.log', 'started');

API SPECIFICATION

INPUT

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

OUTPUT

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

MESSAGES

Key         Meaning                           Resolution
io.append   the file cannot be appended to    check permissions and space

FORMAL SPECIFICATION

ΔFS
path? : PATH ; line? : seq CHAR
let old = FS(path?) if path? ∈ dom FS, else ⟨⟩
let sep = "\n" if old ≠ ⟨⟩ ∧ last old ≠ "\n", else ⟨⟩
FS'(path?) = old ⁀ sep ⁀ line? ⁀ "\n"

ensure_dir

Purpose: make sure a directory exists.

Args: $directory.

Returns: $directory.

Side Effects: creates it and any missing parents.

Usage:

ensure_dir(dirname($path));

EXAMPLE

ensure_dir('/tmp/a/b/c');

API SPECIFICATION

INPUT

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

OUTPUT

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

MESSAGES

Key        Meaning                          Resolution
io.mkdir   the directory cannot be created  check permissions

FORMAL SPECIFICATION

ΔFS
d? : PATH
kind'(d?) = dir ∧ ∀ a : ancestors d? • kind'(a) = dir

LIMITATIONS

  • Nothing is fsync'ed. A rename is atomic against other processes, but after a power cut the new name can point at a file whose data never reached the disk. Adding IO::Handle::sync before each rename would make every upload pay a disk flush; the trade-off has not been measured yet.

  • File::Copy::copy does not copy extended attributes, ACLs or alternate data streams.

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.