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 }