xar unarchiver (.xar, .pkg, .xip)
1#!/usr/bin/env crystal
2
3require "binary_parser"
4require "compress/zlib"
5require "xml"
6
7def perror(msg : String)
8 STDERR.write Slice.new (msg + "\n").to_unsafe, (msg + "\n").size
9 exit 1
10end
11
12def xml_select(xml : XML::Node, node : String)
13 perror "error in xml_select" if xml.nil?
14 xml.children.select { |e| e.name == node }
15end
16
17def xml_value(xml : XML::Node, name : String)
18 perror "error in xml_value" if xml.nil?
19 xml.children.select { |e| e.name == name }.map { |e| e.content }
20end
21
22enum XARChecksumAlgo
23 NONE
24 SHA1
25 MD5
26end
27
28enum XARFileType
29 FILE
30 DIRECTORY
31end
32
33enum XARFileEncoding
34 NONE
35 GZIP
36 BZIP2
37end
38
39class XARHeader < BinaryParser
40 endian :big
41 string :magic, {count: 4}
42 uint16 :header_size
43 uint16 :version
44 uint64 :length_compressed
45 uint64 :length_uncompressed
46 uint32 :checksum_algo
47end
48
49class XARFileData
50 property offset : UInt64 = 0
51 property size : UInt64 = 0
52 property length : UInt64 = 0
53 property checksum_extracted : String = ""
54 property checksum_extracted_style : XARChecksumAlgo = XARChecksumAlgo::NONE
55 property checksum_archived : String = ""
56 property checksum_archived_style : XARChecksumAlgo = XARChecksumAlgo::NONE
57 property encoding : XARFileEncoding = XARFileEncoding::NONE
58end
59
60class XARFileEAttrs < XARFileData
61 property name : String = ""
62end
63
64class XARChecksum
65 property style : XARChecksumAlgo = XARChecksumAlgo::NONE
66 property size : UInt64 = 0
67 property offset : UInt64 = 0
68end
69
70class XARFile
71 property path : String = ""
72 property name : String = ""
73 property type : XARFileType = XARFileType::FILE
74 property mode : Array(UInt8) = [0_u8, 0_u8, 0_u8, 0_u8]
75 property uid : UInt64 = 0
76 property gid : UInt64 = 0
77 property user : String = ""
78 property group : String = ""
79 property size : UInt64 = 0
80 property data : XARFileData = XARFileData.new
81 property ea : XARFileEAttrs = XARFileEAttrs.new
82end
83
84class XAR
85 property checksum : XARChecksum = XARChecksum.new
86 property files : Array(XARFile) = [] of XARFile
87end
88
89def xar_decode_data(entity : XML::Node, data : XARFileData = XARFileData.new)
90 data.offset = (xml_value(entity, "offset").first rescue 0).to_u64
91 data.size = (xml_value(entity, "size").first rescue 0).to_u64
92 data.length = (xml_value(entity, "length").first rescue 0).to_u64
93 data.checksum_extracted = xml_value(entity, "extracted-checksum").first rescue ""
94 data.checksum_extracted_style = XARChecksumAlgo.parse(xml_select(entity, "extracted-checksum").first["style"]) rescue XARChecksumAlgo::NONE
95 data.checksum_archived = xml_value(entity, "archived-checksum").first rescue ""
96 data.checksum_archived_style = XARChecksumAlgo.parse(xml_select(entity, "archived-checksum").first["style"]) rescue XARChecksumAlgo::NONE
97 data.encoding = XARFileEncoding.parse(xml_select(entity, "encoding").first["style"].split("/x-").last) rescue XARFileEncoding::NONE
98 data
99end
100
101def xar_decode_ea(entity : XML::Node, ea : XARFileEAttrs = XARFileEAttrs.new)
102 xar_decode_data entity, ea
103 ea.name = xml_value(entity, "name").first rescue ""
104 ea
105end
106
107def xar_decode_file(entity : XML::Node, path : String = "./")
108 file = XARFile.new
109 file.path = path
110 file.name = xml_value(entity, "name").first rescue ""
111 file.type = XARFileType.parse(xml_value(entity, "type").first) rescue XARFileType::FILE
112 file.mode = (xml_value(entity, "mode").first rescue "0000").split("").map { |p| p.to_u8 }
113 file.uid = (xml_value(entity, "uid").first rescue 0).to_u64
114 file.gid = (xml_value(entity, "gid").first rescue 0).to_u64
115 file.user = xml_value(entity, "user").first rescue ""
116 file.group = xml_value(entity, "group").first rescue ""
117 file.size = (xml_value(entity, "size").first rescue 0).to_u64
118
119 data = xml_select(entity, "data")
120 unless data.size < 1
121 xar_decode_data data.first, file.data
122 end
123
124 ea = xml_select(entity, "ea")
125 unless ea.size < 1
126 xar_decode_ea ea.first, file.ea
127 end
128
129 files = [file]
130 children = xml_select(entity, "file")
131 if children.size > 0
132 if file.type != XARFileType::DIRECTORY
133 puts "warn: found a #{file.type} with #{children.size} children"
134 end
135 children.each do |child|
136 files += xar_decode_file child, "#{path}#{file.name}/"
137 end
138 end
139 files
140end
141
142perror "no filename given" if ARGV.size == 0
143
144File.open ARGV.first, "r" do |file|
145 header = XARHeader.new
146 header.load file
147
148 perror "not a xar file" if header.magic != "xar!"
149
150 puts "#{header.magic}"
151 puts "header size #{header.header_size}"
152 puts "format version #{header.version}"
153 puts "TOC length (compressed) #{header.length_compressed}"
154 puts "TOC length (uncompressed) #{header.length_uncompressed}"
155 puts "checksum algo #{XARChecksumAlgo.new(header.checksum_algo.to_i32).to_s}"
156
157 toc_data = Bytes.new header.length_uncompressed
158 file.seek header.header_size
159 Compress::Zlib::Reader.open file do |zfile|
160 zfile.read toc_data
161 end
162
163 xar_xml = XML.parse String.new(toc_data)
164 xar_obj = xml_select(xar_xml, "xar")
165 perror "empty xar object" if xar_obj.empty?
166
167 tocs = xml_select(xar_obj.first, "toc")
168 perror "empty TOC" if tocs.empty?
169 toc = tocs.first
170
171 puts "reading TOC"
172 xar = XAR.new
173
174 elem = xml_select(toc, "checksum").first
175 xar.checksum.style = XARChecksumAlgo.parse elem["style"]
176 xar.checksum.size = xml_value(elem, "size").first.to_u64
177 xar.checksum.offset = xml_value(elem, "offset").first.to_u64
178
179 puts "TOC is checksummed as #{xar.checksum.style}, #{xar.checksum.size} bytes at offset #{xar.checksum.offset}"
180
181 xml_select(toc, "file").each do |entity|
182 xar.files += xar_decode_file entity
183 end
184
185 puts "contains #{xar.files.select { |e| e.type == XARFileType::FILE }.size} files across #{xar.files.select { |e| e.type == XARFileType::DIRECTORY }.size} directories"
186
187 puts xar.files.map{ |e| "#{e.path}#{e.name}" }.join " "
188end