jm

Odin for scripts: small packages and a runner, on core: only
Log | Files | Refs | README

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:
Atar/tar.odin | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atar/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) +}