Packing a folder before encrypting it seemed like a straightforward part of building FerroCrypt. TAR already did the job, and the Rust tar crate let me stream it directly into the encryption layer. The more time I spent on extraction, though, the more code I needed to restrict what those archives could contain.
FerroCrypt is a file and folder encryption tool. It encrypts with a password or a public key and is available as a command-line tool, a desktop app and a Rust library. Its archive layer went from ZIP to streaming TAR, then to a small format called FCA.
Why extraction needs its own security rules
Encryption takes a stream of bytes, so a folder has to become one stream first. Decryption turns that stream back into files and folders, using names, sizes and permissions supplied by whoever made the encrypted file.
Authenticated encryption detects changes to encrypted data. It doesn't establish that the person who encrypted it meant well. With public-key encryption, anyone who has your public key can create a file for you. The decrypted archive still needs to be treated as untrusted input.
From ZIP to streaming TAR
Early versions created a temporary ZIP file and then encrypted it. In April 2026, I switched to streaming TAR with the tar crate. The archive streamed directly into encryption and, during decryption, straight into the extractor. There was no longer an intermediate plaintext archive on disk.
TAR handled the streaming part well. Restricting it to the features FerroCrypt needed took more work.
FerroCrypt only needs regular files and folders. TAR also supports decades of extensions: PAX headers, GNU long names and long links, sparse files, multi-volume records, dump directories, volume headers, Solaris records and a binary encoding for large sizes.
For a general archive tool, these are useful features. FerroCrypt had no use for them, but a hostile archive could still include them, so each needed an explicit rejection rule and tests.
The tar crate added a subtlety: by default, it applies extension records to the following entry. A PAX record can change that entry's path, size or owner, so the entry my code checked could combine information from several headers.
I switched to raw iteration, using Entries::raw(true), so the validation code could inspect the records individually. It rejected:
- headers outside POSIX ustar, including GNU magic or a version other than
00; - PAX
xandgrecords; - GNU
L,K,S,M,D,VandNrecords, and SolarisXrecords; - GNU binary size encoding;
- any entry type other than a regular file or folder;
- archives with more than one top-level entry.
Path validation was another layer: empty paths, NUL bytes, backslashes, repeated slashes, . and .. components, absolute paths, Windows prefixes, non-UTF-8 names and file paths ending in / were all refused.
The restrictions worked, but they formed a growing list of exclusions. Rejecting unknown entry types wasn't enough: binary size fields needed separate checks, and extension records had to be caught before the crate merged them into an entry. Every additional way to encode an unsupported feature meant another rule to maintain.
The limits of the subset
The subset kept ustar's limits while banning the extensions that work around them. Files had to be smaller than 8 GiB. Paths had to fit a 155-byte prefix and a 100-byte name, split at a slash, so some long paths could not be stored.
TAR also places each entry's metadata next to its contents. To validate the entire tree before creating the first file, I would have to buffer the archive or read it twice.
I wanted a format where unsupported objects couldn't be expressed at all. It would describe the whole tree up front, so the reader could check it before extracting any file contents.
A smaller format: FCA
In May 2026, I replaced TAR with FCA: FerroCrypt Archive.
FCA lives inside the encrypted .fcr file. Decryption restores the original file or folder; users never open or exchange a standalone FCA archive. TAR compatibility provided no practical benefit in that workflow.
The encryption stream authenticates each chunk before its plaintext reaches the archive reader, so FCA doesn't need its own checksum or MAC.
The layout has four parts:
- A fixed 27-byte header.
- An archive extension area, currently empty when written by FerroCrypt.
- A manifest listing every entry's type, permissions, size and path, with space for per-entry extensions.
- The file contents, concatenated in manifest order.
Integers have fixed widths and use big-endian encoding. Lengths are explicit, and the archive must end exactly after the final file's contents.
Each entry is either a regular file (1) or a folder (2). All other type values are rejected. There are no fields for symlink targets, hard-link references, device metadata, sparse-file maps, ownership or timestamps.
The reader checks a short list of what's allowed. The unsupported filesystem features that needed explicit exclusions in TAR can't be expressed in FCA.
Future features follow explicit compatibility rules. A new object type would need its own type number. An extension that changes how an archive must be interpreted must be marked critical: a reader that doesn't understand it rejects the archive before creating output.
File sizes use 64-bit integers, removing the ustar file-size limit. Paths are stored as single strings, removing the prefix/name split. Replacing the tar crate also removed dependencies such as filetime, reducing the third-party code involved in the archive layer.
Validating the tree before writing it
Putting the manifest first lets the reader check the complete tree before creating anything on disk. It validates:
-
Paths. Shared rules for Linux, macOS and Windows require relative UTF-8 paths and reject
.., control characters and Windows-reserved names such asCONandaux.txt, among other restrictions. -
Duplicate names. Exact duplicates are rejected, as are names that differ only in ASCII letter case, such as
READMEandreadme. These can collide on the default filesystems used by macOS and Windows. -
Unicode forms. Names such as
éandefollowed by a combining accent can also collide on macOS. The reader uses theunicode-normalizationcrate to detect canonically equivalent paths. - Tree structure. Every parent folder must be listed. All entries must belong to one top-level folder, unless the archive contains a single file.
- Resource limits. Entry count, total file size, path length, path depth and manifest size must stay within configured bounds.
This requires holding the manifest and extension metadata in memory, with limits on both. File contents still stream in fixed-size chunks.
Check limits before allocating
A length field comes from the archive's creator, so the reader must check it before using it to allocate memory.
The reader checks the manifest length before allocating its buffer, the entry count before allocating per-entry state, and each path length before copying the path and decoding it as UTF-8. Length calculations use checked arithmetic, so an overflow produces a format error rather than a panic or a wrapped value.
The default limits are 250,000 entries, 64 GiB of total file data, 4,096 bytes per path, 64 path components and a 64 MiB manifest. Library callers can adjust them through ArchiveLimits.
Apply the same rules when writing
The writer validates the source tree using the same path, duplicate and size rules before it starts writing the encrypted archive. It also refuses symlinks, devices, FIFOs and sockets, along with junctions and other reparse points on Windows.
The source can change after that initial scan. Before streaming each file, the writer checks again that it is a regular file and that its size matches the manifest. If either check fails, encryption stops.
FerroCrypt should never create an archive its own reader would reject under the same limits.
Extracting without following symlinks
A valid manifest doesn't prevent the filesystem from changing during extraction. If another process replaces a folder in the output path with a symlink, a call to File::create with the full path could follow it and write outside the intended output folder.
Using cap-std and cap-fs-ext, FerroCrypt opens the output folder once as a Dir handle. It then walks each extraction path relative to that handle, one component at a time, refusing symlinks along the way. On Windows, it also rejects reparse points such as junctions. Files are created with create_new, which refuses to overwrite an existing entry.
A simplified version of the file-creation step looks like this:
use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt};
use cap_std::fs::{Dir, File, OpenOptions};
fn create_in(root: &Dir, parents: &[&str], name: &str) -> std::io::Result<File> {
let mut dir = root.try_clone()?;
for part in parents {
dir = dir.open_dir_nofollow(part)?;
}
let mut options = OpenOptions::new();
options.write(true).create_new(true);
options.follow(FollowSymlinks::No);
dir.open_with(name, &options)
}
Extraction uses a temporary file or folder with a name ending in .incomplete. On Linux and macOS, only its owner can access it while extraction is in progress. It receives its final name only after every file has been written and the reader has verified the end of the archive.
The final rename refuses to replace an existing destination: renameat2 with RENAME_NOREPLACE on Linux, or renameatx_np with RENAME_EXCL on macOS, both through rustix. Filesystems such as exFAT and some network mounts need a fallback, which also refuses to overwrite.
Stored folder permissions are applied after their contents have been written, and the root folder's permissions are applied after the final rename. This keeps restrictive permissions from blocking extraction and permissive permissions from exposing files still being written.
Authentication happens chunk by chunk, so damage or truncation near the end may be detected after earlier files have been written. By default, FerroCrypt attempts to remove the temporary output and reports any cleanup failure. A crash or power loss can still leave .incomplete output containing plaintext.
The library declares #![forbid(unsafe_code)]. The filesystem operations go through safe APIs provided by crates such as rustix and cap-std.
Testing a format with no independent implementation
FCA's tests cover malformed inputs as well as successful round trips:
- Four fuzz targets covering the header, manifest, path rules and complete extraction pipeline, through to the final rename. These are part of the library's 16 fuzz targets.
- Filesystem tests for symlinks, junctions, existing files and read-only folders.
- A frozen conformance corpus of 394 cases from release
0.3.0. Of those, 70 cover FCA, including 57 that must fail with a specified error class. The corpus gives future releases a regression baseline and can be replayed by another implementation. - A written specification in FORMAT.md. It defines both valid encodings and rejection rules, including the order of checks when an input has several defects.
The trade-offs
FCA preserves regular files, folder structure and Unix permission bits. It refuses folders containing symlinks, doesn't preserve timestamps, extended attributes or ACLs, and stores hard-linked files as separate copies. New entry types and extensions could add support later, but each would need its own validation and extraction rules.
Why not pipe TAR into age?
age is a great tool. It doesn't handle folders itself: it encrypts a file or an input stream, and its README shows how to encrypt a folder by piping TAR into it:
tar cvz ~/data | age -r age1... > data.tar.gz.age
That fits age's Unix-style design, but safe unpacking then depends on which TAR implementation the recipient uses and with which options.
FerroCrypt handles folder restoration itself, with path rules, resource limits and filesystem checks built into extraction. It also uses Argon2id for passwords, where age uses scrypt, though that is separate from the archive design.
Why I made the change
With TAR, I had to keep excluding features FerroCrypt didn't support. FCA makes those features impossible to express and lets the reader validate the whole tree before writing files. That is the reason for maintaining a separate format: its rules match what the extractor is designed to handle.
Project links
FerroCrypt is free and open source under GPL-3.0-only.
- Homepage
- Source code
- Downloads
-
Rust library:
cargo add ferrocrypt -
CLI:
cargo install ferrocrypt-cli
Feedback on the design and extraction code is welcome, particularly archive/platform.rs.
Top comments (0)