jm

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

tar.odin (5006B)


      1 /*
      2 Package tar reads a tar archive in memory and writes its files out, which
      3 is what a repository's `git archive` output needs and nothing more: ustar
      4 and GNU headers, the pax extended headers git writes for long paths and
      5 the commit id, regular files and directories. Links, devices and anything
      6 else are skipped, and a path that climbs out of the destination is not
      7 written anywhere.
      8 
      9 	archive := must(sh.exec({"git", "archive", "--format=tar", "HEAD"}))
     10 	count := must(tar.extract(transmute([]byte)archive.stdout, "build/tree"))
     11 */
     12 package tar
     13 
     14 import "core:os"
     15 import "core:path/filepath"
     16 import "core:strconv"
     17 import "core:strings"
     18 
     19 // Error is why an archive could not be read or written.
     20 Error :: enum {
     21 	None,
     22 	// A header is not a tar header: the archive is truncated or not tar.
     23 	Bad_Header,
     24 	// An entry's size does not fit in what follows its header.
     25 	Truncated,
     26 	// A file or directory could not be written.
     27 	Write_Failed,
     28 }
     29 
     30 // Entry is one file the archive holds, as extract hands it to a visitor.
     31 Entry :: struct {
     32 	name: string,
     33 	data: []byte,
     34 	dir:  bool,
     35 }
     36 
     37 // block is the header and record size the format is built on.
     38 block :: 512
     39 
     40 // extract writes the archive's regular files and directories under dir,
     41 // creating directories as needed, and returns how many files it wrote.
     42 extract :: proc(archive: []byte, dir: string) -> (count: int, err: Error) {
     43 	entries := read(archive, context.temp_allocator) or_return
     44 	for e in entries {
     45 		clean, _ := filepath.clean(e.name, context.temp_allocator)
     46 		if clean == "." ||
     47 		   clean == "" ||
     48 		   strings.has_prefix(clean, "..") ||
     49 		   strings.has_prefix(clean, "/") ||
     50 		   strings.contains(clean, "/../") {
     51 			continue
     52 		}
     53 		full := filepath.join({dir, clean}, context.temp_allocator) or_else clean
     54 		if e.dir {
     55 			if !os.is_dir(full) && os.make_directory_all(full) != nil {
     56 				return count, .Write_Failed
     57 			}
     58 			continue
     59 		}
     60 		parent := filepath.dir(full)
     61 		if !os.is_dir(parent) && os.make_directory_all(parent) != nil {
     62 			return count, .Write_Failed
     63 		}
     64 		if os.write_entire_file(full, e.data) != nil {
     65 			return count, .Write_Failed
     66 		}
     67 		count += 1
     68 	}
     69 	return count, .None
     70 }
     71 
     72 // read lists the archive's regular files and directories, with their data
     73 // pointing into the archive. A pax extended header's path, or a GNU long
     74 // name, names the entry that follows it.
     75 read :: proc(archive: []byte, allocator := context.allocator) -> (entries: []Entry, err: Error) {
     76 	out := make([dynamic]Entry, allocator)
     77 	offset := 0
     78 	pending_name := ""
     79 	for offset + block <= len(archive) {
     80 		header := archive[offset:offset + block]
     81 		if is_zero(header) {
     82 			break // Two zero blocks end the archive; one is enough to stop.
     83 		}
     84 		size, ok := octal(header[124:136])
     85 		if !ok {
     86 			return nil, .Bad_Header
     87 		}
     88 		start := offset + block
     89 		end := start + size
     90 		if end > len(archive) {
     91 			return nil, .Truncated
     92 		}
     93 		flag := header[156]
     94 		name := field(header[0:100])
     95 		if prefix := field(header[345:500]); prefix != "" && string(header[257:262]) == "ustar" {
     96 			name = strings.concatenate({prefix, "/", name}, allocator)
     97 		}
     98 		if pending_name != "" {
     99 			name = pending_name
    100 			pending_name = ""
    101 		}
    102 		data := archive[start:end]
    103 		switch flag {
    104 		case 'x':
    105 			if path, found := pax_path(data); found {
    106 				pending_name = strings.clone(path, allocator)
    107 			}
    108 		case 'L':
    109 			pending_name = strings.clone(strings.trim_right(string(data), "\x00"), allocator)
    110 		case '0', 0, '7':
    111 			append(&out, Entry{name = strings.clone(name, allocator), data = data})
    112 		case '5':
    113 			append(&out, Entry{name = strings.clone(name, allocator), dir = true})
    114 		case 'g':
    115 		// A global header carries the commit id, which names no file.
    116 		}
    117 		offset = end + ((block - end % block) % block)
    118 	}
    119 	return out[:], .None
    120 }
    121 
    122 // field is a fixed-width header field, up to its first NUL.
    123 field :: proc(raw: []byte) -> string {
    124 	s := string(raw)
    125 	if i := strings.index_byte(s, 0); i >= 0 {
    126 		s = s[:i]
    127 	}
    128 	return s
    129 }
    130 
    131 // octal reads a size field: octal digits, or the base-256 form a large
    132 // entry takes.
    133 octal :: proc(raw: []byte) -> (int, bool) {
    134 	if len(raw) > 0 && raw[0] & 0x80 != 0 {
    135 		n := 0
    136 		for b, i in raw {
    137 			v := int(b)
    138 			if i == 0 {
    139 				v &= 0x7f
    140 			}
    141 			n = n << 8 | v
    142 		}
    143 		return n, true
    144 	}
    145 	s := strings.trim(field(raw), " ")
    146 	if s == "" {
    147 		return 0, true
    148 	}
    149 	return strconv.parse_int(s, 8)
    150 }
    151 
    152 // pax_path reads the path record out of a pax extended header, whose
    153 // records are "length key=value\n".
    154 pax_path :: proc(data: []byte) -> (string, bool) {
    155 	rest := string(data)
    156 	for len(rest) > 0 {
    157 		space := strings.index_byte(rest, ' ')
    158 		if space < 0 {
    159 			break
    160 		}
    161 		length, ok := strconv.parse_int(rest[:space])
    162 		if !ok || length <= 0 || length > len(rest) {
    163 			break
    164 		}
    165 		record := rest[space + 1:length]
    166 		if strings.has_prefix(record, "path=") {
    167 			return strings.trim_suffix(record[5:], "\n"), true
    168 		}
    169 		rest = rest[length:]
    170 	}
    171 	return "", false
    172 }
    173 
    174 is_zero :: proc(b: []byte) -> bool {
    175 	for c in b {
    176 		if c != 0 {
    177 			return false
    178 		}
    179 	}
    180 	return true
    181 }