Repository navigation
syc archive format
Format 2, frozen at tag v0.4.0. syc writes it from v0.4.0 on; any later change is a new format number, and format 2 stays as described here. Test vectors and a reference reader written from this page: syc-vectors.zip in the v0.4.0 release and tools/syc_ref.py. Format 1 (syc 0.3.1–0.3.4) is the same except for a 16-byte vhead without the codec; syc reads it, and appends to it in format 1.
Little-endian throughout; varint is unsigned LEB128 (7 bits a byte, low first, bit 7 = more follows, at most 10 bytes, value below 2^64).
An archive is append-only: syc a on an existing archive adds a version at the end and never rewrites what is before it. A version holds only what changed since the previous one: new and modified files, their new data, and deletions. The state at version N is versions 1..N applied in order. Data is deduplicated: files are cut into content-defined fragments, and a fragment whose hash is already in the archive (in any version, or earlier in the same one) is not stored again.
archive := header version*
header := "SYCA" fmt:u8 (=2) cdcMinLog:u8 cdcAvgLog:u8 cdcMaxLog:u8 8 bytes
version := vhead block* end index commit
vhead := "SYCV" vnum:u32 time:i64 codec:u8 reserved:u8[3] 20 bytes
block := rawLen:u32 packed:u32 crc32:u32 payload as a .sylz stream's block
end := rawLen = 0 4 zero bytes
index := "SYCI" rawLen:u32 packed:u32 crc32:u32 payload the index, one block
commit := "SYCE" vstart:u64 istart:u64 crc32:u32 24 bytes
-
header:
6 ≤ cdcMinLog < cdcAvgLog < cdcMaxLog ≤ 24(syc writes 14, 16, 19). -
vhead:
vnumis the version's number, 1 for the first and one more for each;timeis when it was written, in seconds since 1970 UTC (information only);codecsays what the version's blocks and index are: 1 = sylz (the only one so far; a reader refuses another);reservedis zero.
Exactly the block of a .sylz stream: packed bit 31 set = payload stored raw (then the payload length equals rawLen), otherwise one block of the version's codec (sylz: format v2, or v3 when written with -ec, see sylz format); the low 31 bits are the payload length; crc32 (IEEE, zlib's) of the raw data. 1 ≤ rawLen ≤ 2^29 (512 MiB). A block's raw data is the concatenation of the version's new fragments, in order, and its length is the sum of their sizes; a fragment never spans two blocks. syc closes a block when the next fragment would take it past the block size (-b, default 16 MiB).
Each file is cut on its own (a fragment never spans two files) with a gear rolling hash, FastCDC-style with normalization, parameters from the header (defaults: min 2^14 = 16 KiB, average 2^16 = 64 KiB, max 2^19 = 512 KiB):
gear[0..255]: splitmix64 from state 0x5359434143444331 ("SYCACDC1"):
state += 0x9E3779B97F4A7C15; z = state
z = (z xor (z shr 30)) * 0xBF58476D1CE4E5B9
z = (z xor (z shr 27)) * 0x94D049BB133111EB
gear[i] = z xor (z shr 31)
from the start s of a fragment, with n bytes left in the file:
if n <= min: the fragment is the rest of the file
h = 0; i = min
while i < min(n, max):
h = (h shl 1) + gear[byte[s + i]] (mod 2^64)
i += 1
bits = avgLog + 2 if i < avg else avgLog - 2
if h shr (64 - bits) = 0: cut here (the fragment is i bytes)
no cut: the fragment is min(n, max) bytes
A reference implementation of this section, in Python: tools/syc_cdc_ref.py (prints each fragment's offset, length and hash).
A fragment's identity is its BLAKE2s-256 hash (RFC 7693, unkeyed, 32 bytes). Fragments are numbered from 1 across the whole archive in the order versions store them; fragment 0 does not exist.
The index record is a block with a magic in front: stored or compressed with the version's codec, rawLen ≤ 2^29, its crc32 checked like a block's. Its raw data is:
indexdata := nblocks:varint blockinfo[nblocks]
nfrags:varint fraginfo[nfrags]
nentries:varint entry[nentries]
blockinfo := bytes:varint (the block's whole size, header included) frags:varint
fraginfo := size:varint hash[32]
entry := path type:u8 [fields by type]
path := len:varint utf8[len] relative, "/" separators, no "", ".", ".." components
type := 0 deleted (nothing follows)
1 file meta size:varint nrefs:varint first:varint delta:varint[nrefs-1]
2 directory meta
3 symlink meta target:len:varint utf8[len]
meta := mtime:i64 (ns since 1970 UTC) attrKind:u8 attrs:varint [uid:varint gid:varint]
attrKind := 0 none 1 Unix st_mode (permission bits, 12 bits) + uid/gid follow
2 Windows file attributes (readonly, hidden, system, archive; and
for a symlink, directory: whether it points to one)
- The version's new fragments are numbered from (fragments in earlier versions) + 1, in the order of
fraginfo; the blocks hold them in that order,blockinfo.fragsof them each (nfrags= the sum). Block k starts atvstart + 20 + Σ bytesof the blocks before it, so a reader finds any fragment without scanning the data; after the last block come the 4 zero bytes ofend, then the index atistart. - Ranges:
12 ≤ blockinfo.bytes ≤ 12 + 2^29,1 ≤ blockinfo.frags,1 ≤ fraginfo.size ≤ 2^cdcMaxLog; a path is 1 to 65535 bytes, a symlink's target up to 65535;attrKind≤ 2,attrs,uid,gid< 2^32; a file's references are fragments 1 .. the last one of this version (a version may refer to fragments of earlier versions, never of later ones). Nothing follows the last entry. -
attrs: with attrKind 1, the low 12 bits ofst_mode(permissions, setuid, setgid, sticky); with attrKind 2, Windows attributes: 0x1 read-only, 0x2 hidden, 0x4 system, 0x20 archive, and 0x10 (directory) only on a symlink that points to a directory. - A file's references:
firstis a fragment number; eachdeltais the next one as zigzag(next − (previous + 1)), so a run of consecutive fragments codes as zeros.sizemust equal the sum of the referenced fragments' sizes. An empty file hasnrefs = 0(thenfirstis absent). - Entries are sorted by path (bytewise); a path appears at most once per version. An entry replaces the path's previous state;
deletedremoves it (and in a later version the path may come back). - Hard links are stored as separate files: dedup makes them cost a few bytes.
vstart is the offset of the version's vhead, istart that of its index; crc32 covers the 20 bytes before it. A version counts only once its commit is complete and valid: the first version's vstart is 8 and each next one starts right after the commit before it; the index record ends where the commit starts.
- Paths are UTF-8 on every system (on Windows syc converts from and to UTF-16).
-
Opening a file (
a,x,l,t,i): read the commit at the end; itsvstart− 24 is the previous version's commit, down tovstart = 8. Every index is read throughistart: no data is read to list or plan an extraction. -
If the end is not a valid commit (power cut, full disk during
a): scan forward from offset 8, version by version (block headers carry their lengths), to the last valid commit.l/x/twarn and use the versions up to it; the nextatruncates the archive there and appends. -
A non-seekable input (
l -,i -,t -,x -): syc copies it to a temporary file and reads that. The index of each version comes after its data, and only the index says where each fragment is in a block, so a single forward pass could check the blocks' CRCs but neither the fragments' hashes nor which file a fragment belongs to. -
syc a -writes a new archive to stdout (one version, written front to back); appending needs a file.
Not part of the format either: an entry whose type, size, mtime, attributes, owner and link target match the last version is left out (its file is not read); a path that was under one of the PATHs given and is gone gets a deleted entry, unless it is excluded, could not be read this time, or --no-delete is given. With no change, no version is written. Before the commit, the version's bytes are flushed to disk; after it, the commit.
Not part of the format, but what a reader should do with it: never write through a symlink (an archive's symlinks are made after every file, and an existing directory on the way that is a symlink skips the entry); files go to a temporary name and are renamed when complete; directories get their times last, deepest first.
Three levels, all checked by syc t and by syc x on what it extracts: each block's crc32 (and the sylz decoder's own bounds checks), the index's crc32 and structure (every number in range, sizes adding up), and each fragment's BLAKE2s-256 against the data it decodes to. Any failure exits with status 3.
-
Fragment hashes: 32 bytes in the archive, 16 in memory. The index stores the full BLAKE2s-256 (~0.05% of the data at 64 KiB fragments) and every extraction checks it; the dedup table that
syc akeeps in RAM is keyed by the first 16 bytes (~24 bytes a fragment: 1 TB of unique data ≈ 384 MB, 100 GB ≈ 38 MB). An accidental 128-bit match is out of reach (~2^64 fragments). - Average fragment 64 KiB (zpaq's): it finds duplicates inside large files that change little (VM images, databases, regenerated tars). The block size, not the fragment size, sets the compression context, so the ratio does not depend on it. The parameters live in the header, so a later option can choose others per archive.
- Not stored: on Windows, uid/gid, ACLs and alternate streams; anywhere, extended attributes and hard links as links (they are stored as separate files, and dedup makes the copies cost a few bytes).