From 3ba0f758168a1e60c2d6aca8f5928753623611b5 Mon Sep 17 00:00:00 2001 From: Paige Julianne Sullivan Date: Fri, 10 Jul 2026 11:09:54 -0400 Subject: [PATCH] Add directory support to the CLI via SQAR archive streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend `squish c`/`d`/`s` to accept a directory as well as a single file. A directory is serialized into a single SQAR archive stream (files, subdirectories, empty directories, and unix permission bits) and compressed as one unit; decompression detects the archive and recreates the tree under the output path. The compression engine and libsquish ABI are untouched — the archive format is a CLI-level wrapper compressed as an ordinary SQ02/SQ01 stream. Single-file compression is unchanged and byte-for-byte compatible with earlier releases; auto-detection on decompress keys off the SQAR magic. Extraction rejects absolute paths and `..` traversal, and entries are stored sorted so archives depend only on the tree, not on filesystem iteration order. Documented as docs/FORMAT.md §12; CI gains directory round-trip steps (including empty-dir and SFX-directory checks) on the Linux and Windows-MSVC jobs. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 24 ++ CHANGELOG.md | 11 + README.md | 21 +- SQUISH.md | 7 +- docs/FORMAT.md | 52 ++++ squish_cli.c | 544 +++++++++++++++++++++++++++++++++++++-- 6 files changed, 630 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e4d7ee..c0ee762 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,19 @@ jobs: ( mkdir sfxout && cd sfxout && ../readme.run -q ) cmp README.md sfxout/README.md + - name: Directory archive round-trip + run: | + mkdir -p tree/sub tree/empty + cp README.md tree/a.txt + printf 'nested\n' > tree/sub/b.txt + ./squish c tree tree.sq + ./squish d tree.sq tree-out + diff -r tree tree-out + test -d tree-out/empty + ./squish s tree tree.run + ( mkdir sfxdir && cd sfxdir && ../tree.run -q ) + diff -r tree sfxdir/tree + windows-cross: name: cross-compile Windows DLL + CLI runs-on: ubuntu-latest @@ -77,3 +90,14 @@ jobs: ..\readme_sfx.exe -q cd .. fc /b README.md sfxout\README.md + + - name: Directory archive round-trip + shell: cmd + run: | + mkdir tree\sub + copy README.md tree\a.txt + echo nested> tree\sub\b.txt + squish.exe c tree tree.sq + squish.exe d tree.sq tree-out + fc /b tree\a.txt tree-out\a.txt + fc /b tree\sub\b.txt tree-out\sub\b.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c8122..dc9ac0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ Per [CONTRIBUTING.md](CONTRIBUTING.md), any change to the model constants in ### Added +- CLI: directory support for `c`, `d`, and `s`. When `input` is a directory, + `squish c` serializes the whole tree (files, subdirectories, empty + directories, and unix permission bits) into a single `SQAR` archive stream + and compresses that; `squish d` detects the archive on decompression and + recreates the tree under `output`. `squish s` packs a directory the same + way into a self-extracting executable. Single-file compression is + unchanged and byte-for-byte compatible with earlier releases (the archive + wrapper is used only for directories). Extraction rejects absolute paths + and `..` traversal, so an archive can never write outside its target + directory; entries are stored in sorted order, so an archive depends only + on the tree, not on filesystem iteration order. See docs/FORMAT.md §12. - CLI: `s` command — build a self-extracting archive. `squish s input output` compresses `input` and appends it (plus the original name and a 32-byte trailer) to a copy of the `squish` CLI used as a stub; the resulting diff --git a/README.md b/README.md index 375674e..a36f451 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ No dependencies beyond libc/libm. ```sh ./squish c bigfile bigfile.sq # compress ./squish d bigfile.sq restored # decompress (checksum-verified) +./squish c project project.sq # compress a whole directory tree +./squish d project.sq restored-dir # ... recreate it under restored-dir ./squish -t 0 c bigfile bigfile.sq # compress on all cores (multi-block) ./squish -t 0 -b 4 c big big.sq # ... with 4 MiB blocks (more parallel, # slightly worse ratio) @@ -55,6 +57,16 @@ No dependencies beyond libc/libm. ./report.run # ... run it to restore report.pdf ``` +`input` may be a file or a **directory**. A directory is packed into a single +archive stream (files, subdirectories, empty directories, and permission bits +preserved) and compressed as one unit, so cross-file redundancy is modeled +too; `d` detects the archive and recreates the tree under `output`. +Extraction refuses absolute paths and `..`, so an archive never writes +outside its target directory, and entries are stored sorted, so the output +depends only on the tree. Single files are compressed exactly as before — +the archive wrapper is used only for directories. See +[docs/FORMAT.md §12](docs/FORMAT.md) for the layout. + `-t N` compresses with N threads (`0` = all cores) by splitting the input into independently modeled blocks — near-linear speedup, at a small ratio cost because each block's model starts cold (about 1–2% at the default @@ -65,10 +77,11 @@ it) and reads both formats transparently. Budget ~150 MB of model memory per thread. The `s` command writes a **self-extracting archive**: `squish s input output` -compresses `input` and produces an executable `output` that carries the -compressed data. Running it — with no `squish` and no `libsquish` installed — -decompresses (and checksum-verifies) the original back to its stored name in -the current directory, or to a path you name: +compresses `input` (a file or a directory tree) and produces an executable +`output` that carries the compressed data. Running it — with no `squish` and +no `libsquish` installed — decompresses (and checksum-verifies) the original +back to its stored name in the current directory, or to a path you name; +a directory archive unpacks the whole tree: ```sh ./squish s report.pdf report.run # build (Linux: chmod +x is applied for you) diff --git a/SQUISH.md b/SQUISH.md index 8415755..153db7d 100644 --- a/SQUISH.md +++ b/SQUISH.md @@ -83,10 +83,15 @@ ratio for near-linear speedup. ``` make # libsquish.so + squish CLI -./squish c input output.sq # compress +./squish c input output.sq # compress a file (or a directory tree) ./squish d output.sq restored # decompress (checksum-verified) ``` +`input` may be a directory: it is serialized into a single `SQAR` archive +stream and compressed as one unit, and `d` recreates the tree under +`restored`. Single files are compressed exactly as before. See +[docs/FORMAT.md](docs/FORMAT.md) §12. + SQUISH is also a library — `squish.h` + `libsquish.so` (or `make dll` for Windows); see [docs/API.md](docs/API.md) and `examples/`. diff --git a/docs/FORMAT.md b/docs/FORMAT.md index 36cc975..d194e3a 100644 --- a/docs/FORMAT.md +++ b/docs/FORMAT.md @@ -243,3 +243,55 @@ offset size field - The trailer is independent of the stream format: changing it does not affect stream compatibility, and it carries its own version byte (the `1` in the magic) for future revisions. + +## 12. Directory archive "SQAR" (CLI packaging) + +Like §11, this section is **not** part of the compressed format. It describes +how the CLI packs a directory tree into a single byte stream so the ordinary +compressor can handle directories without knowing anything about files. The +CLI serializes the tree into an `SQAR` buffer, compresses that buffer as an +§1 stream, and on decompression checks whether the decompressed bytes begin +with the `SQAR` magic: if so it unpacks a tree, otherwise the bytes are a +single file, written verbatim. Single-file compression never uses this +wrapper, so it stays byte-for-byte compatible with readers that predate it. + +The buffer is a fixed header followed by a flat, pre-order list of entries; +all integers are little-endian: + +``` +offset size field +0 8 magic: 'S' 'Q' 'A' 'R' '0' '1' 0x0A 0x1A +8 4 version u32 (currently 1) +12 4 flags u32 (0) +16 8 entry count u64 +24 ... entries +``` + +Each entry: + +``` +offset size field +0 1 type: 0 = regular file, 1 = directory +1 4 mode u32: unix permission bits (low 9); informational, + synthesized as 0644/0755 by producers without them +5 4 path length P u32 (1 .. 65535) +9 8 data length D u64 (0 for a directory) +17 P path: relative, '/'-separated, UTF-8, no terminator +17 + P D file contents (regular files only; absent for a directory) +``` + +- Paths are relative to the archived directory and use `/` separators. + Directories are emitted before their contents so empty directories are + preserved. Sibling entries are stored sorted by name, so the archive + depends only on the tree, not on directory iteration order. +- A reader must reject any entry whose path is absolute, contains an empty, + `.`, or `..` component, or contains `\` or `:` — this is what stops an + archive from writing outside the extraction root. It must also verify that + every length field stays within the buffer and that the entries tile it + exactly; a violation is a format error. +- There is no archive-level checksum: the enclosing §1 stream already + carries and verifies one over these exact bytes. +- Because the archive is just the payload of an ordinary stream, `squish s` + (§11) packs a directory with no change to the SFX wrapper — the stub + decompresses the payload and, finding the `SQAR` magic, unpacks a tree + instead of writing one file. diff --git a/squish_cli.c b/squish_cli.c index 4b0084e..adafc62 100644 --- a/squish_cli.c +++ b/squish_cli.c @@ -21,16 +21,20 @@ #include #include #include +#include #if defined(_WIN32) # define WIN32_LEAN_AND_MEAN # include # include +# include # include # define SQ_ISATTY(f) _isatty(_fileno(f)) #else # include +# include # include +# include # define SQ_ISATTY(f) isatty(fileno(f)) #endif @@ -71,8 +75,11 @@ static int usage(void) { " -b, --block N with -t: split input into N MiB blocks (default 16;\n" " smaller = more parallelism, slightly worse ratio)\n" "\n" + "'input' may be a file or a directory. A directory is packed into a\n" + "single archive stream; 'd' recreates the tree under 'output'.\n" "'s' writes a self-extracting executable: running `output` (no squish\n" - "needed) decompresses the embedded file. On Windows, name it `*.exe`.\n", + "needed) unpacks the embedded file or directory. On Windows, name it\n" + "`*.exe`.\n", squish_version()); return 2; } @@ -250,6 +257,374 @@ static const char *sfx_basename(const char *name) { return b; } +/* ============================ directory archive ========================== * + * squish compresses a directory by first serializing its whole tree into a + * single "SQAR" byte stream (built here), then handing that stream to the + * ordinary compressor — so the compression engine never has to know about + * files or directories. On decompression the CLI decompresses to a buffer + * and, if it begins with the SQAR magic, unpacks the tree; otherwise the + * buffer is a single file and is written out verbatim, exactly as before. + * + * Layout (all integers little-endian). Header: + * magic[8]="SQAR01\n\x1a" | version u32 (1) | flags u32 (0) | count u64 + * then `count` entries, each: + * type u8 (0=file,1=dir) | mode u32 | path_len u32 | data_len u64 | + * path[path_len] (relative, '/'-separated, UTF-8) | data[data_len] + * Directories carry data_len 0. Full spec: docs/FORMAT.md §12. */ + +#define SQAR_VERSION 1u +#define SQAR_HDR_LEN 24u /* magic8 + version4 + flags4 + count8 */ +#define SQAR_ENT_LEN 17u /* type1 + mode4 + plen4 + dlen8 */ +#define SQAR_MAX_PATH 65535u +static const unsigned char SQAR_MAGIC[8] = + { 'S','Q','A','R','0','1','\n','\x1a' }; + +/* --- growable byte buffer (used to accumulate the archive) --------------- */ +typedef struct { unsigned char *p; size_t len, cap; } gbuf; + +static int gbuf_put(gbuf *b, const void *d, size_t n) { + if (n) { + if (b->len + n < b->len) return -1; /* size_t overflow */ + if (b->len + n > b->cap) { + size_t nc = b->cap ? b->cap : 4096; + while (nc < b->len + n) { + if (nc > (size_t)-1 / 2) { nc = b->len + n; break; } + nc *= 2; + } + unsigned char *np = (unsigned char *)realloc(b->p, nc); + if (!np) return -1; + b->p = np; b->cap = nc; + } + memcpy(b->p + b->len, d, n); + b->len += n; + } + return 0; +} +static int gbuf_u8 (gbuf *b, unsigned char v) { return gbuf_put(b, &v, 1); } +static int gbuf_u32(gbuf *b, uint32_t v) { + unsigned char t[4]; put_u32le(t, v); return gbuf_put(b, t, 4); +} +static int gbuf_u64(gbuf *b, uint64_t v) { + unsigned char t[8]; put_u64le(t, v); return gbuf_put(b, t, 8); +} + +/* --- small path helpers -------------------------------------------------- */ + +/* malloc "/", or a plain copy of b when a is empty/NULL. NULL on OOM. */ +static char *path_join(const char *a, const char *b) { + size_t la = a ? strlen(a) : 0, lb = strlen(b); + char *r = (char *)malloc(la + 1 + lb + 1); + if (!r) return NULL; + if (la) { memcpy(r, a, la); r[la] = '/'; memcpy(r + la + 1, b, lb + 1); } + else { memcpy(r, b, lb + 1); } + return r; +} + +/* malloc'd copy of `path` with trailing '/' and '\\' trimmed (never to + * empty). NULL on OOM. */ +static char *strip_trailing_sep(const char *path) { + size_t n = strlen(path); + while (n > 1 && (path[n-1] == '/' || path[n-1] == '\\')) n--; + char *r = (char *)malloc(n + 1); + if (!r) return NULL; + memcpy(r, path, n); r[n] = '\0'; + return r; +} + +/* Filesystem probes. On Windows mode bits are synthesized (there are no unix + * permissions); stat() on POSIX follows symlinks, so a link is archived as + * whatever it points at and a dangling link is silently skipped. */ +static int path_is_dir(const char *p) { +#if defined(_WIN32) + DWORD a = GetFileAttributesA(p); + return a != INVALID_FILE_ATTRIBUTES && (a & FILE_ATTRIBUTE_DIRECTORY) != 0; +#else + struct stat s; + return stat(p, &s) == 0 && S_ISDIR(s.st_mode); +#endif +} +static int path_is_regular(const char *p) { +#if defined(_WIN32) + DWORD a = GetFileAttributesA(p); + return a != INVALID_FILE_ATTRIBUTES && (a & FILE_ATTRIBUTE_DIRECTORY) == 0; +#else + struct stat s; + return stat(p, &s) == 0 && S_ISREG(s.st_mode); +#endif +} +static unsigned path_mode(const char *p, unsigned dflt) { +#if defined(_WIN32) + (void)p; return dflt; +#else + struct stat s; + return stat(p, &s) == 0 ? (unsigned)(s.st_mode & 0777) : dflt; +#endif +} + +/* Create one directory; success if it already exists. */ +static int make_dir(const char *p) { +#if defined(_WIN32) + if (_mkdir(p) == 0) return 0; +#else + if (mkdir(p, 0777) == 0) return 0; +#endif + return errno == EEXIST ? 0 : -1; +} + +/* Create out_root and every directory along the relative path `rel`. When + * include_last is 0, stop before rel's final component (create parents only, + * for a file); when 1, create rel itself too (for a directory entry). */ +static int make_dirs(const char *out_root, const char *rel, int include_last) { + if (make_dir(out_root) != 0) return -1; + size_t rl = strlen(out_root); + char *w = (char *)malloc(rl + 1 + strlen(rel) + 1); + if (!w) return -1; + memcpy(w, out_root, rl); w[rl] = '\0'; + size_t wl = rl; + int rc = 0; + for (const char *c = rel; *c; ) { + const char *slash = strchr(c, '/'); + size_t clen = slash ? (size_t)(slash - c) : strlen(c); + if (!slash && !include_last) break; /* final component */ + w[wl++] = '/'; + memcpy(w + wl, c, clen); wl += clen; w[wl] = '\0'; + if (make_dir(w) != 0) { rc = -1; break; } + if (!slash) break; + c = slash + 1; + } + free(w); + return rc; +} + +/* A stored path is safe iff it is relative and every component is non-empty, + * not "." or "..", and free of ':' and '\\' — so an archive can never write + * outside out_root (no absolute paths, drive letters, or traversal). */ +static int arc_path_safe(const char *p, size_t n) { + if (n == 0 || p[0] == '/') return 0; + for (size_t i = 0; i < n; ) { + size_t j = i; + while (j < n && p[j] != '/') j++; + size_t clen = j - i; + if (clen == 0) return 0; /* //, or trailing / */ + if (clen == 1 && p[i] == '.') return 0; + if (clen == 2 && p[i] == '.' && p[i+1] == '.') return 0; + for (size_t k = i; k < j; k++) + if (p[k] == '\\' || p[k] == ':' || p[k] == '\0') return 0; + i = (j < n) ? j + 1 : j; + } + return 1; +} + +/* --- listing a directory (sorted, so archives are reproducible) ---------- */ +static int name_cmp(const void *a, const void *b) { + return strcmp(*(const char *const *)a, *(const char *const *)b); +} +static void free_names(char **names, size_t n) { + for (size_t i = 0; i < n; i++) free(names[i]); + free(names); +} +static int add_name(char ***a, size_t *n, size_t *cap, const char *nm) { + if (*n == *cap) { + size_t nc = *cap ? *cap * 2 : 16; + char **np = (char **)realloc(*a, nc * sizeof *np); + if (!np) return -1; + *a = np; *cap = nc; + } + size_t l = strlen(nm); + char *copy = (char *)malloc(l + 1); + if (!copy) return -1; + memcpy(copy, nm, l + 1); + (*a)[(*n)++] = copy; + return 0; +} + +/* Names in `dir` (excluding "." and ".."), sorted. Caller frees with + * free_names. Returns 0 on success (including an empty directory), -1 on I/O + * or allocation failure. */ +static int list_dir(const char *dir, char ***out, size_t *out_n) { + char **names = NULL; size_t n = 0, cap = 0; + *out = NULL; *out_n = 0; +#if defined(_WIN32) + char *pat = path_join(dir, "*"); + if (!pat) return -1; + WIN32_FIND_DATAA fd; + HANDLE h = FindFirstFileA(pat, &fd); + free(pat); + if (h == INVALID_HANDLE_VALUE) + return GetLastError() == ERROR_FILE_NOT_FOUND ? 0 : -1; /* empty dir */ + do { + const char *nm = fd.cFileName; + if (!strcmp(nm, ".") || !strcmp(nm, "..")) continue; + if (add_name(&names, &n, &cap, nm) != 0) { + FindClose(h); free_names(names, n); return -1; + } + } while (FindNextFileA(h, &fd)); + FindClose(h); +#else + DIR *d = opendir(dir); + if (!d) return -1; + struct dirent *e; + while ((e = readdir(d)) != NULL) { + const char *nm = e->d_name; + if (!strcmp(nm, ".") || !strcmp(nm, "..")) continue; + if (add_name(&names, &n, &cap, nm) != 0) { + closedir(d); free_names(names, n); return -1; + } + } + closedir(d); +#endif + if (n > 1) qsort(names, n, sizeof *names, name_cmp); + *out = names; *out_n = n; + return 0; +} + +/* Append the subtree rooted at fs_dir to the archive, giving each entry the + * archive-relative path arc_pre/ ("" at the top level). Directories are + * emitted before their contents so empty ones survive. Returns 0 on success. */ +static int arc_add_dir(gbuf *a, const char *fs_dir, const char *arc_pre, + uint64_t *count, int quiet) { + char **names; size_t n; + if (list_dir(fs_dir, &names, &n) != 0) { + fprintf(stderr, "squish: %s: cannot read directory\n", fs_dir); + return -1; + } + int rc = 0; + for (size_t i = 0; i < n && rc == 0; i++) { + char *fs = path_join(fs_dir, names[i]); + char *arc = path_join(arc_pre, names[i]); /* arc_pre="" -> name */ + if (!fs || !arc) { free(fs); free(arc); rc = -1; break; } + size_t arclen = strlen(arc); + if (arclen > SQAR_MAX_PATH) { + fprintf(stderr, "squish: %s: path too long for archive\n", arc); + free(fs); free(arc); rc = -1; break; + } + if (path_is_dir(fs)) { + if (gbuf_u8(a, 1) || gbuf_u32(a, path_mode(fs, 0755)) || + gbuf_u32(a, (uint32_t)arclen) || gbuf_u64(a, 0) || + gbuf_put(a, arc, arclen)) + rc = -1; + else { (*count)++; rc = arc_add_dir(a, fs, arc, count, quiet); } + } else if (path_is_regular(fs)) { + unsigned char *data; size_t dl; + if (read_file_all(fopen(fs, "rb"), &data, &dl) != 0) { + fprintf(stderr, "squish: %s: %s\n", fs, + squish_strerror(SQUISH_E_IO)); + rc = -1; + } else { + if (gbuf_u8(a, 0) || gbuf_u32(a, path_mode(fs, 0644)) || + gbuf_u32(a, (uint32_t)arclen) || gbuf_u64(a, (uint64_t)dl) || + gbuf_put(a, arc, arclen) || gbuf_put(a, data, dl)) + rc = -1; + else (*count)++; + free(data); + } + } else if (!quiet) { + fprintf(stderr, "squish: %s: skipping (not a regular file)\n", fs); + } + free(fs); free(arc); + } + free_names(names, n); + return rc; +} + +/* Serialize directory `dir` into a fresh SQAR buffer (caller frees *out with + * free). *entries, if non-NULL, receives the entry count. */ +static int build_archive(const char *dir, unsigned char **out, size_t *out_len, + uint64_t *entries, int quiet) { + gbuf a = { NULL, 0, 0 }; + if (gbuf_put(&a, SQAR_MAGIC, 8) || gbuf_u32(&a, SQAR_VERSION) || + gbuf_u32(&a, 0) || gbuf_u64(&a, 0)) { free(a.p); return -1; } + uint64_t count = 0; + if (arc_add_dir(&a, dir, "", &count, quiet) != 0) { free(a.p); return -1; } + put_u64le(a.p + 16, count); /* patch entry_count */ + *out = a.p; *out_len = a.len; + if (entries) *entries = count; + return 0; +} + +/* True if `buf` is (at least on its face) a serialized archive. */ +static int is_archive(const unsigned char *buf, size_t len) { + return len >= SQAR_HDR_LEN && memcmp(buf, SQAR_MAGIC, 8) == 0 && + get_u32le(buf + 8) == SQAR_VERSION; +} + +/* Write `n` bytes to `path`, applying unix `mode` where supported. 0 on ok. */ +static int write_file_bytes(const char *path, const unsigned char *d, size_t n, + unsigned mode) { + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = !(n && fwrite(d, 1, n, f) != n); + if (fclose(f) != 0) ok = 0; + if (!ok) { remove(path); return -1; } +#if !defined(_WIN32) + if (mode) chmod(path, (mode_t)(mode & 0777)); +#else + (void)mode; +#endif + return 0; +} + +/* Unpack a validated SQAR buffer into directory out_root (created if needed). + * Every length and path is bounds-checked and traversal-guarded; a malformed + * archive returns SQUISH_E_FORMAT, an I/O failure SQUISH_E_IO. */ +static int unpack_archive(const unsigned char *b, size_t len, + const char *out_root, uint64_t *nfiles, + uint64_t *nbytes) { + if (!is_archive(b, len)) return SQUISH_E_FORMAT; + uint64_t count = get_u64le(b + 16); + size_t off = SQAR_HDR_LEN; + uint64_t files = 0, bytes = 0; + int rc = SQUISH_OK; + if (make_dir(out_root) != 0) { + fprintf(stderr, "squish: %s: cannot create directory\n", out_root); + return SQUISH_E_IO; + } + for (uint64_t i = 0; i < count && rc == SQUISH_OK; i++) { + if (len - off < SQAR_ENT_LEN) return SQUISH_E_FORMAT; + unsigned char type = b[off]; + unsigned mode = get_u32le(b + off + 1); + uint32_t plen = get_u32le(b + off + 5); + uint64_t dlen = get_u64le(b + off + 9); + off += SQAR_ENT_LEN; + if (plen == 0 || plen > SQAR_MAX_PATH || plen > len - off) + return SQUISH_E_FORMAT; + const char *path = (const char *)(b + off); + if (!arc_path_safe(path, plen)) return SQUISH_E_FORMAT; + char *rel = (char *)malloc((size_t)plen + 1); + if (!rel) return SQUISH_E_NOMEM; + memcpy(rel, path, plen); rel[plen] = '\0'; + off += plen; + + if (type == 1) { /* directory */ + if (dlen != 0) { free(rel); return SQUISH_E_FORMAT; } + if (make_dirs(out_root, rel, 1) != 0) { + fprintf(stderr, "squish: %s: cannot create directory\n", rel); + rc = SQUISH_E_IO; + } + } else if (type == 0) { /* regular file */ + if (dlen > len - off) { free(rel); return SQUISH_E_FORMAT; } + char *full = path_join(out_root, rel); + if (!full) { free(rel); return SQUISH_E_NOMEM; } + if (make_dirs(out_root, rel, 0) != 0 || + write_file_bytes(full, b + off, (size_t)dlen, mode) != 0) { + fprintf(stderr, "squish: %s: %s\n", full, + squish_strerror(SQUISH_E_IO)); + rc = SQUISH_E_IO; + } + free(full); + off += (size_t)dlen; + files++; bytes += dlen; + } else { + rc = SQUISH_E_FORMAT; + } + free(rel); + } + if (rc == SQUISH_OK && off != len) return SQUISH_E_FORMAT; /* must tile */ + if (nfiles) *nfiles = files; + if (nbytes) *nbytes = bytes; + return rc; +} + /* Build a self-extracting archive: stub || payload || name || trailer. */ static int sfx_create(const char *in_path, const char *out_path, int threads, size_t block, int quiet, @@ -259,9 +634,17 @@ static int sfx_create(const char *in_path, const char *out_path, fprintf(stderr, "squish: cannot read own executable to use as SFX stub\n"); return 1; } + /* A directory input is serialized into an archive stream first (§ archive + * above); a plain file is embedded as-is. Either way the payload is an + * ordinary compressed stream the SFX stub already knows how to decode. */ + int is_dir = path_is_dir(in_path); + char *in_disp = strip_trailing_sep(in_path); /* tidy name + summary */ + if (!in_disp) { free(stub); fprintf(stderr, "squish: out of memory\n"); return 1; } unsigned char *in; size_t in_len; - if (read_file_all(fopen(in_path, "rb"), &in, &in_len) != 0) { - free(stub); + int rd = is_dir ? build_archive(in_disp, &in, &in_len, NULL, quiet) + : read_file_all(fopen(in_disp, "rb"), &in, &in_len); + if (rd != 0) { + free(stub); free(in_disp); fprintf(stderr, "squish: %s: %s\n", in_path, squish_strerror(SQUISH_E_IO)); return 1; } @@ -274,12 +657,12 @@ static int sfx_create(const char *in_path, const char *out_path, clear_status(st); free(in); if (rc != SQUISH_OK) { - free(stub); + free(stub); free(in_disp); fprintf(stderr, "squish: %s: %s\n", in_path, squish_strerror(rc)); return 1; } - const char *name = sfx_basename(in_path); + const char *name = sfx_basename(in_disp); size_t name_len = strlen(name); if (name_len > SFX_MAX_NAME) name_len = SFX_MAX_NAME; @@ -298,6 +681,7 @@ static int sfx_create(const char *in_path, const char *out_path, if (ok && fwrite(tr, 1, SFX_TRAILER_LEN, o) != SFX_TRAILER_LEN) ok = 0; if (o && fclose(o) != 0) ok = 0; free(stub); + free(in_disp); squish_free(payload); if (!ok) { fprintf(stderr, "squish: %s: %s\n", out_path, squish_strerror(SQUISH_E_IO)); @@ -312,9 +696,9 @@ static int sfx_create(const char *in_path, const char *out_path, if (!quiet) { long long outsz = file_size(out_path); fprintf(stderr, - "%s -> %s: %lld -> %lld bytes self-extracting" + "%s%s -> %s: %lld -> %lld bytes self-extracting" " (payload %llu, %.3f bpb, %.2f MB/s)\n", - in_path, out_path, (long long)in_len, outsz, + in_path, is_dir ? "/" : "", out_path, (long long)in_len, outsz, (unsigned long long)payload_len, in_len ? 8.0 * (double)payload_len / (double)in_len : 0.0, dt > 0 && in_len ? (double)in_len / 1e6 / dt : 0.0); @@ -326,8 +710,8 @@ static int sfx_usage(void) { fprintf(stderr, "self-extracting SQUISH archive\n" "usage: %s [-f] [-q] [-t N] [output]\n" - " Extracts the embedded file to the current directory (its original\n" - " name), or to if given.\n" + " Extracts the embedded file (or directory tree) to the current\n" + " directory under its original name, or to if given.\n" " -f, --force overwrite an existing output file\n" " -q, --quiet errors only\n" " -t, --threads N worker threads, 0 = all cores (default)\n", @@ -371,13 +755,11 @@ static int sfx_run(int argc, char **argv, const sfx_info *info) { free(namebuf); const char *out = target ? target : sfx_basename(stored); - if (!force) { - FILE *chk = fopen(out, "rb"); - if (chk) { - fclose(chk); - fprintf(stderr, "squish: %s already exists (use -f to overwrite)\n", out); - return 1; - } + /* Guard single-file overwrite (an archive payload merges into the target + * directory instead, so only a colliding *file* is a conflict here). */ + if (!force && path_is_regular(out)) { + fprintf(stderr, "squish: %s already exists (use -f to overwrite)\n", out); + return 1; } unsigned char *payload; @@ -400,6 +782,22 @@ static int sfx_run(int argc, char **argv, const sfx_info *info) { return 1; } + /* A directory archive unpacks into `out`; a plain payload is one file. */ + if (is_archive(outbuf, outn)) { + uint64_t nf = 0, nb = 0; + int urc = unpack_archive(outbuf, outn, out, &nf, &nb); + squish_free(outbuf); + if (urc != SQUISH_OK) { + fprintf(stderr, "squish: %s: %s\n", out, squish_strerror(urc)); + return 1; + } + if (!quiet) + fprintf(stderr, "extracted %s/: %llu files, %llu bytes (%.2f MB/s)\n", + out, (unsigned long long)nf, (unsigned long long)nb, + dt > 0 && nb ? (double)nb / 1e6 / dt : 0.0); + return 0; + } + FILE *o = fopen(out, "wb"); int ok = o != NULL; if (ok && outn && fwrite(outbuf, 1, outn, o) != outn) ok = 0; @@ -418,6 +816,99 @@ static int sfx_run(int argc, char **argv, const sfx_info *info) { return 0; } +/* ============================ top-level commands ========================= */ + +/* Compress directory `dir` into `out` as a single archive stream. */ +static int compress_dir_cmd(const char *dir, const char *out, int threads, + size_t block, int quiet, squish_progress_fn cb, + status *st) { + char *d = strip_trailing_sep(dir); + if (!d) { fprintf(stderr, "squish: out of memory\n"); return 1; } + unsigned char *blob; size_t blob_len; uint64_t entries = 0; + if (build_archive(d, &blob, &blob_len, &entries, quiet) != 0) { + fprintf(stderr, "squish: %s: failed to read directory tree\n", d); + free(d); return 1; + } + double t0 = now_sec(); + void *comp; size_t comp_len; + int rc = squish_compress_alloc_mt(blob, blob_len, &comp, &comp_len, + threads, block, cb, st); + double dt = now_sec() - t0; + clear_status(st); + free(blob); + if (rc != SQUISH_OK) { + fprintf(stderr, "squish: %s: %s\n", d, squish_strerror(rc)); + free(d); return 1; + } + int wr = write_file_bytes(out, (const unsigned char *)comp, comp_len, 0); + squish_free(comp); + if (wr != 0) { + fprintf(stderr, "squish: %s: %s\n", out, squish_strerror(SQUISH_E_IO)); + free(d); return 1; + } + if (!quiet) { + long long outsz = file_size(out); + fprintf(stderr, + "%s/ -> %s: %llu entries, %llu -> %lld bytes (%.3f bpb, %.2f MB/s)\n", + d, out, (unsigned long long)entries, + (unsigned long long)blob_len, outsz, + blob_len ? 8.0 * (double)outsz / (double)blob_len : 0.0, + dt > 0 && blob_len ? (double)blob_len / 1e6 / dt : 0.0); + } + free(d); + return 0; +} + +/* Decompress `in` into `out`: a single file, or — when the stream carries an + * archive — a directory tree recreated under `out`. */ +static int decompress_cmd(const char *in, const char *out, int threads, + int quiet, squish_progress_fn cb, status *st) { + unsigned char *comp; size_t comp_len; + if (read_file_all(fopen(in, "rb"), &comp, &comp_len) != 0) { + fprintf(stderr, "squish: %s: %s\n", in, squish_strerror(SQUISH_E_IO)); + return 1; + } + double t0 = now_sec(); + void *buf; size_t n; + int rc = squish_decompress_alloc_mt(comp, comp_len, &buf, &n, + threads, cb, st); + double dt = now_sec() - t0; + clear_status(st); + free(comp); + if (rc != SQUISH_OK) { + fprintf(stderr, "squish: %s: %s\n", in, squish_strerror(rc)); + return 1; + } + if (is_archive((unsigned char *)buf, n)) { + uint64_t nf = 0, nb = 0; + int urc = unpack_archive((unsigned char *)buf, n, out, &nf, &nb); + squish_free(buf); + if (urc != SQUISH_OK) { + fprintf(stderr, "squish: %s: %s\n", out, squish_strerror(urc)); + return 1; + } + if (!quiet) + fprintf(stderr, "%s -> %s/: %llu files, %llu bytes (%.2f MB/s)\n", + in, out, (unsigned long long)nf, (unsigned long long)nb, + dt > 0 && nb ? (double)nb / 1e6 / dt : 0.0); + return 0; + } + int wr = write_file_bytes(out, (const unsigned char *)buf, n, 0); + squish_free(buf); + if (wr != 0) { + fprintf(stderr, "squish: %s: %s\n", out, squish_strerror(SQUISH_E_IO)); + return 1; + } + if (!quiet) { + long long insz = file_size(in); + fprintf(stderr, "%s -> %s: %lld -> %llu bytes (%.3f bpb, %.2f MB/s)\n", + in, out, insz, (unsigned long long)n, + n ? 8.0 * (double)insz / (double)n : 0.0, + dt > 0 && n ? (double)n / 1e6 / dt : 0.0); + } + return 0; +} + int main(int argc, char **argv) { g_argv0 = argv[0]; { sfx_info sfxi; if (sfx_probe(&sfxi)) return sfx_run(argc, argv, &sfxi); } @@ -461,12 +952,18 @@ int main(int argc, char **argv) { if (sfx) return sfx_create(pos[1], pos[2], threads, block, quiet, cb, &st); + if (!compress) /* decompress auto-detects a file vs. an archive stream */ + return decompress_cmd(pos[1], pos[2], threads, quiet, cb, &st); + + /* A directory input is packed into an archive stream; a file is unchanged + * (a plain SQ02/SQ01 stream, byte-for-byte as before). */ + if (path_is_dir(pos[1])) + return compress_dir_cmd(pos[1], pos[2], threads, block, quiet, cb, &st); + double t0 = now_sec(); - int rc = compress - ? (threads == 1 && !block - ? squish_compress_file2(pos[1], pos[2], cb, &st) - : squish_compress_file_mt(pos[1], pos[2], threads, block, cb, &st)) - : squish_decompress_file_mt(pos[1], pos[2], threads, cb, &st); + int rc = (threads == 1 && !block) + ? squish_compress_file2(pos[1], pos[2], cb, &st) + : squish_compress_file_mt(pos[1], pos[2], threads, block, cb, &st); double dt = now_sec() - t0; clear_status(&st); if (rc != SQUISH_OK) { @@ -475,10 +972,9 @@ int main(int argc, char **argv) { } if (quiet) return 0; long long in = file_size(pos[1]), out = file_size(pos[2]); - long long data = compress ? in : out; fprintf(stderr, "%s -> %s: %lld -> %lld bytes (%.3f bpb, %.2f MB/s)\n", pos[1], pos[2], in, out, - data > 0 ? 8.0 * (double)(compress ? out : in) / (double)data : 0.0, - dt > 0 && data > 0 ? (double)data / 1e6 / dt : 0.0); + in > 0 ? 8.0 * (double)out / (double)in : 0.0, + dt > 0 && in > 0 ? (double)in / 1e6 / dt : 0.0); return 0; }