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::syncbefore each rename would make every upload pay a disk flush; the trade-off has not been measured yet.File::Copy::copydoes 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.