commit 710fb57514be825119014419392beb08cad5e351
parent 08b3ecbe7ebc37c4ecfbbcaab7d11b7a0e240490
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Wed, 23 Sep 2026 21:34:01 -0300
tar: read git archive output without a tar program
A script that wants a clean tree of a commit runs git archive and has to
hand the bytes to a tar binary, which Windows may not have. extract
reads the archive in memory and writes its files under a destination:
ustar and GNU headers, the pax extended headers git writes for long
paths and the commit id, regular files and directories. Links, devices
and anything else are skipped, and a path that climbs out of the
destination is not written anywhere. The tests build a real archive with
git archive through sh and extract it whole.
Diffstat:
| A | tar/tar.odin | | | 181 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | tar/tar_test.odin | | | 127 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
2 files changed, 308 insertions(+), 0 deletions(-)
diff --git a/tar/tar.odin b/tar/tar.odin
@@ -0,0 +1,181 @@
+/*
+Package tar reads a tar archive in memory and writes its files out, which
+is what a repository's `git archive` output needs and nothing more: ustar
+and GNU headers, the pax extended headers git writes for long paths and
+the commit id, regular files and directories. Links, devices and anything
+else are skipped, and a path that climbs out of the destination is not
+written anywhere.
+
+ archive := must(sh.exec({"git", "archive", "--format=tar", "HEAD"}))
+ count := must(tar.extract(transmute([]byte)archive.stdout, "build/tree"))
+*/
+package tar
+
+import "core:os"
+import "core:path/filepath"
+import "core:strconv"
+import "core:strings"
+
+// Error is why an archive could not be read or written.
+Error :: enum {
+ None,
+ // A header is not a tar header: the archive is truncated or not tar.
+ Bad_Header,
+ // An entry's size does not fit in what follows its header.
+ Truncated,
+ // A file or directory could not be written.
+ Write_Failed,
+}
+
+// Entry is one file the archive holds, as extract hands it to a visitor.
+Entry :: struct {
+ name: string,
+ data: []byte,
+ dir: bool,
+}
+
+// block is the header and record size the format is built on.
+block :: 512
+
+// extract writes the archive's regular files and directories under dir,
+// creating directories as needed, and returns how many files it wrote.
+extract :: proc(archive: []byte, dir: string) -> (count: int, err: Error) {
+ entries := read(archive, context.temp_allocator) or_return
+ for e in entries {
+ clean, _ := filepath.clean(e.name, context.temp_allocator)
+ if clean == "." ||
+ clean == "" ||
+ strings.has_prefix(clean, "..") ||
+ strings.has_prefix(clean, "/") ||
+ strings.contains(clean, "/../") {
+ continue
+ }
+ full := filepath.join({dir, clean}, context.temp_allocator) or_else clean
+ if e.dir {
+ if !os.is_dir(full) && os.make_directory_all(full) != nil {
+ return count, .Write_Failed
+ }
+ continue
+ }
+ parent := filepath.dir(full)
+ if !os.is_dir(parent) && os.make_directory_all(parent) != nil {
+ return count, .Write_Failed
+ }
+ if os.write_entire_file(full, e.data) != nil {
+ return count, .Write_Failed
+ }
+ count += 1
+ }
+ return count, .None
+}
+
+// read lists the archive's regular files and directories, with their data
+// pointing into the archive. A pax extended header's path, or a GNU long
+// name, names the entry that follows it.
+read :: proc(archive: []byte, allocator := context.allocator) -> (entries: []Entry, err: Error) {
+ out := make([dynamic]Entry, allocator)
+ offset := 0
+ pending_name := ""
+ for offset + block <= len(archive) {
+ header := archive[offset:offset + block]
+ if is_zero(header) {
+ break // Two zero blocks end the archive; one is enough to stop.
+ }
+ size, ok := octal(header[124:136])
+ if !ok {
+ return nil, .Bad_Header
+ }
+ start := offset + block
+ end := start + size
+ if end > len(archive) {
+ return nil, .Truncated
+ }
+ flag := header[156]
+ name := field(header[0:100])
+ if prefix := field(header[345:500]); prefix != "" && string(header[257:262]) == "ustar" {
+ name = strings.concatenate({prefix, "/", name}, allocator)
+ }
+ if pending_name != "" {
+ name = pending_name
+ pending_name = ""
+ }
+ data := archive[start:end]
+ switch flag {
+ case 'x':
+ if path, found := pax_path(data); found {
+ pending_name = strings.clone(path, allocator)
+ }
+ case 'L':
+ pending_name = strings.clone(strings.trim_right(string(data), "\x00"), allocator)
+ case '0', 0, '7':
+ append(&out, Entry{name = strings.clone(name, allocator), data = data})
+ case '5':
+ append(&out, Entry{name = strings.clone(name, allocator), dir = true})
+ case 'g':
+ // A global header carries the commit id, which names no file.
+ }
+ offset = end + ((block - end % block) % block)
+ }
+ return out[:], .None
+}
+
+// field is a fixed-width header field, up to its first NUL.
+field :: proc(raw: []byte) -> string {
+ s := string(raw)
+ if i := strings.index_byte(s, 0); i >= 0 {
+ s = s[:i]
+ }
+ return s
+}
+
+// octal reads a size field: octal digits, or the base-256 form a large
+// entry takes.
+octal :: proc(raw: []byte) -> (int, bool) {
+ if len(raw) > 0 && raw[0] & 0x80 != 0 {
+ n := 0
+ for b, i in raw {
+ v := int(b)
+ if i == 0 {
+ v &= 0x7f
+ }
+ n = n << 8 | v
+ }
+ return n, true
+ }
+ s := strings.trim(field(raw), " ")
+ if s == "" {
+ return 0, true
+ }
+ return strconv.parse_int(s, 8)
+}
+
+// pax_path reads the path record out of a pax extended header, whose
+// records are "length key=value\n".
+pax_path :: proc(data: []byte) -> (string, bool) {
+ rest := string(data)
+ for len(rest) > 0 {
+ space := strings.index_byte(rest, ' ')
+ if space < 0 {
+ break
+ }
+ length, ok := strconv.parse_int(rest[:space])
+ if !ok || length <= 0 || length > len(rest) {
+ break
+ }
+ record := rest[space + 1:length]
+ if strings.has_prefix(record, "path=") {
+ return strings.trim_suffix(record[5:], "\n"), true
+ }
+ rest = rest[length:]
+ }
+ return "", false
+}
+
+is_zero :: proc(b: []byte) -> bool {
+ for c in b {
+ if c != 0 {
+ return false
+ }
+ }
+ return true
+}
diff --git a/tar/tar_test.odin b/tar/tar_test.odin
@@ -0,0 +1,127 @@
+package tar
+
+import "core:os"
+import "core:path/filepath"
+import "core:strings"
+import "core:testing"
+import "jfm:sh"
+
+@(test)
+git_archive_extracts_whole :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ temp := os.temp_directory(context.temp_allocator) or_else ""
+ root, err := os.make_directory_temp(temp, "jfm-tar-*", context.temp_allocator)
+ testing.expect(t, err == nil)
+ defer os.remove_all(root)
+ git := proc(root: string, args: ..string) -> sh.Result {
+ argv := make([dynamic]string, context.temp_allocator)
+ append(
+ &argv,
+ "git",
+ "-c",
+ "user.email=t@t",
+ "-c",
+ "user.name=t",
+ "-c",
+ "commit.gpgsign=false",
+ )
+ append(&argv, ..args)
+ return sh.exec(argv[:], {dir = root}, context.temp_allocator)
+ }
+ long := strings.repeat("deep/", 30)
+ testing.expect(t, git(root, "init", "-q").ok)
+ testing.expect(
+ t,
+ os.make_directory_all(filepath.join({root, long}, context.temp_allocator) or_else "") ==
+ nil,
+ )
+ testing.expect(
+ t,
+ os.write_entire_file(
+ filepath.join({root, "a.txt"}, context.temp_allocator) or_else "",
+ transmute([]byte)string("alpha\n"),
+ ) ==
+ nil,
+ )
+ testing.expect(
+ t,
+ os.make_directory_all(filepath.join({root, "sub"}, context.temp_allocator) or_else "") ==
+ nil,
+ )
+ testing.expect(
+ t,
+ os.write_entire_file(
+ filepath.join({root, "sub", "b.bin"}, context.temp_allocator) or_else "",
+ transmute([]byte)string("\x00\x01\x02"),
+ ) ==
+ nil,
+ )
+ testing.expect(
+ t,
+ os.write_entire_file(
+ filepath.join({root, long, "c.txt"}, context.temp_allocator) or_else "",
+ transmute([]byte)strings.repeat("x", 1000),
+ ) ==
+ nil,
+ )
+ testing.expect(t, git(root, "add", "-A").ok)
+ testing.expect(t, git(root, "commit", "-q", "-m", "seed").ok)
+
+ archive := git(root, "archive", "--format=tar", "HEAD")
+ testing.expect(t, archive.ok)
+ entries, read_err := read(transmute([]byte)archive.stdout)
+ testing.expect_value(t, read_err, Error.None)
+ names := make([dynamic]string, context.temp_allocator)
+ for e in entries {
+ if !e.dir {
+ append(&names, e.name)
+ }
+ }
+ testing.expect_value(t, len(names), 3)
+
+ dest, derr := os.make_directory_temp(temp, "jfm-tar-out-*", context.temp_allocator)
+ testing.expect(t, derr == nil)
+ defer os.remove_all(dest)
+ count, xerr := extract(transmute([]byte)archive.stdout, dest)
+ testing.expect_value(t, xerr, Error.None)
+ testing.expect_value(t, count, 3)
+ a, _ := os.read_entire_file_from_path(
+ filepath.join({dest, "a.txt"}, context.temp_allocator) or_else "",
+ context.temp_allocator,
+ )
+ testing.expect_value(t, string(a), "alpha\n")
+ b, _ := os.read_entire_file_from_path(
+ filepath.join({dest, "sub", "b.bin"}, context.temp_allocator) or_else "",
+ context.temp_allocator,
+ )
+ testing.expect_value(t, string(b), "\x00\x01\x02")
+ c, cerr := os.read_entire_file_from_path(
+ filepath.join({dest, long, "c.txt"}, context.temp_allocator) or_else "",
+ context.temp_allocator,
+ )
+ testing.expect(t, cerr == nil, "the long path was written")
+ testing.expect_value(t, string(c), strings.repeat("x", 1000))
+}
+
+@(test)
+headers_are_read_and_bad_ones_refused :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ n, ok := octal(transmute([]byte)string("00000000144\x00"))
+ testing.expect(t, ok)
+ testing.expect_value(t, n, 100)
+ n, ok = octal(transmute([]byte)string(" "))
+ testing.expect(t, ok)
+ testing.expect_value(t, n, 0)
+ _, ok = octal(transmute([]byte)string("not octal!!!"))
+ testing.expect(t, !ok)
+ path, found := pax_path(
+ transmute([]byte)string("27 mtime=1700000000.123456\n16 path=x/y.txt\n"),
+ )
+ testing.expect(t, found)
+ testing.expect_value(t, path, "x/y.txt")
+ _, err := read(transmute([]byte)strings.repeat("z", 512))
+ testing.expect_value(t, err, Error.Bad_Header)
+ empty, eerr := read(transmute([]byte)strings.repeat("\x00", 1024))
+ testing.expect_value(t, eerr, Error.None)
+ testing.expect_value(t, len(empty), 0)
+}