地圖 (Jido) is a lightweight Unix TUI file explorer designed for speed and simplicity.
1const ascii = @import("std").ascii;
2const std = @import("std");
3
4const FileLogger = @import("./file_logger.zig");
5
6const archive_buf_size = 8192;
7
8pub const ArchiveType = enum {
9 tar,
10 @"tar.gz",
11 @"tar.xz",
12 @"tar.zst",
13 zip,
14
15 pub fn fromPath(file_path: []const u8) ?ArchiveType {
16 if (ascii.endsWithIgnoreCase(file_path, ".tar")) return .tar;
17 if (ascii.endsWithIgnoreCase(file_path, ".tgz")) return .@"tar.gz";
18 if (ascii.endsWithIgnoreCase(file_path, ".tar.gz")) return .@"tar.gz";
19 if (ascii.endsWithIgnoreCase(file_path, ".txz")) return .@"tar.xz";
20 if (ascii.endsWithIgnoreCase(file_path, ".tar.xz")) return .@"tar.xz";
21 if (ascii.endsWithIgnoreCase(file_path, ".tzst")) return .@"tar.zst";
22 if (ascii.endsWithIgnoreCase(file_path, ".tar.zst")) return .@"tar.zst";
23 if (ascii.endsWithIgnoreCase(file_path, ".zip")) return .zip;
24 if (ascii.endsWithIgnoreCase(file_path, ".jar")) return .zip;
25 return null;
26 }
27};
28
29pub const ArchiveContents = struct {
30 entries: std.ArrayList([]const u8),
31
32 pub fn deinit(self: *ArchiveContents, alloc: std.mem.Allocator) void {
33 for (self.entries.items) |entry| alloc.free(entry);
34 self.entries.deinit(alloc);
35 }
36};
37
38pub const ExtractionResult = struct {
39 files_extracted: usize,
40 dirs_created: usize,
41 files_skipped: usize,
42};
43
44pub const PathValidationError = error{
45 PathContainsTraversal,
46 PathTooLong,
47 PathEmpty,
48};
49
50pub const SkipReason = enum {
51 path_contains_traversal,
52 path_too_long,
53 path_empty,
54};
55
56const Operation = enum { list, extract };
57
58const OperationArgs = union(Operation) {
59 list: struct {
60 traversal_limit: usize,
61 },
62 extract: struct {
63 dest_dir: std.fs.Dir,
64 file_logger: ?FileLogger,
65 },
66};
67
68const OperationResult = union(Operation) {
69 list: ArchiveContents,
70 extract: ExtractionResult,
71};
72
73pub fn listArchiveContents(
74 alloc: std.mem.Allocator,
75 file: std.fs.File,
76 archive_type: ArchiveType,
77 traversal_limit: usize,
78) !ArchiveContents {
79 var buffer: [archive_buf_size]u8 = undefined;
80 var reader = file.reader(&buffer);
81
82 const list_args = OperationArgs{ .list = .{
83 .traversal_limit = traversal_limit,
84 } };
85
86 const contents = switch (archive_type) {
87 .tar => try listTar(alloc, &reader.interface, traversal_limit),
88 .@"tar.gz" => (try processTarGz(alloc, &reader.interface, list_args)).list,
89 .@"tar.xz" => (try processTarXz(alloc, &reader.interface, list_args)).list,
90 .@"tar.zst" => (try processTarZst(alloc, &reader.interface, list_args)).list,
91 .zip => try listZip(alloc, file, traversal_limit),
92 };
93
94 return contents;
95}
96
97pub fn extractArchive(
98 alloc: std.mem.Allocator,
99 file: std.fs.File,
100 archive_type: ArchiveType,
101 dest_dir: std.fs.Dir,
102 file_logger: ?FileLogger,
103) !ExtractionResult {
104 var buffer: [archive_buf_size]u8 = undefined;
105 var reader = file.reader(&buffer);
106
107 const extract_args = OperationArgs{ .extract = .{
108 .dest_dir = dest_dir,
109 .file_logger = file_logger,
110 } };
111
112 return switch (archive_type) {
113 .tar => try extractTarImpl(alloc, &reader.interface, dest_dir, file_logger),
114 .@"tar.gz" => (try processTarGz(alloc, &reader.interface, extract_args)).extract,
115 .@"tar.xz" => (try processTarXz(alloc, &reader.interface, extract_args)).extract,
116 .@"tar.zst" => (try processTarZst(alloc, &reader.interface, extract_args)).extract,
117 .zip => try extractZipImpl(alloc, file, dest_dir, file_logger),
118 };
119}
120
121pub fn getExtractDirName(archive_path: []const u8) []const u8 {
122 const basename = std.fs.path.basename(archive_path);
123
124 return if (ascii.endsWithIgnoreCase(basename, ".tar.gz"))
125 basename[0 .. basename.len - 7]
126 else if (ascii.endsWithIgnoreCase(basename, ".tar.xz"))
127 basename[0 .. basename.len - 7]
128 else if (ascii.endsWithIgnoreCase(basename, ".tar.zst"))
129 basename[0 .. basename.len - 8]
130 else if (ascii.endsWithIgnoreCase(basename, ".tgz"))
131 basename[0 .. basename.len - 4]
132 else if (ascii.endsWithIgnoreCase(basename, ".txz"))
133 basename[0 .. basename.len - 4]
134 else if (ascii.endsWithIgnoreCase(basename, ".tzst"))
135 basename[0 .. basename.len - 5]
136 else if (ascii.endsWithIgnoreCase(basename, ".tar"))
137 basename[0 .. basename.len - 4]
138 else if (ascii.endsWithIgnoreCase(basename, ".zip"))
139 basename[0 .. basename.len - 4]
140 else if (ascii.endsWithIgnoreCase(basename, ".jar"))
141 basename[0 .. basename.len - 4]
142 else
143 basename;
144}
145
146fn validateAndCleanPath(
147 alloc: std.mem.Allocator,
148 path: []const u8,
149) (PathValidationError || error{OutOfMemory})![]const u8 {
150 // Strip leading slashes (handles /, //, ///, etc.)
151 var clean_path = path;
152 while (std.mem.startsWith(u8, clean_path, "/")) {
153 clean_path = clean_path[1..];
154 }
155
156 if (clean_path.len == 0) return error.PathEmpty;
157 if (clean_path.len >= std.fs.max_path_bytes) return error.PathTooLong;
158
159 // Check for directory traversal by tracking depth
160 var depth: i32 = 0;
161 var iter = std.mem.splitScalar(u8, clean_path, '/');
162 while (iter.next()) |component| {
163 if (component.len == 0) continue;
164
165 if (std.mem.eql(u8, component, "..")) {
166 depth -= 1;
167 if (depth < 0) {
168 return error.PathContainsTraversal;
169 }
170 } else if (!std.mem.eql(u8, component, ".")) {
171 depth += 1;
172 }
173 }
174
175 return try alloc.dupe(u8, clean_path);
176}
177
178fn extractTopLevelEntry(
179 alloc: std.mem.Allocator,
180 full_path: []const u8,
181 is_directory: bool,
182 truncated: bool,
183) ![]const u8 {
184 var is_directory_internal = is_directory;
185 var path = full_path;
186
187 if (std.mem.indexOfScalar(u8, full_path, '/')) |idx| {
188 path = full_path[0..idx];
189 is_directory_internal = true;
190 }
191
192 return try std.fmt.allocPrint(
193 alloc,
194 "{s}{s}{s}",
195 .{ path, if (truncated) "..." else "", if (is_directory_internal) "/" else "" },
196 );
197}
198
199fn listTar(
200 alloc: std.mem.Allocator,
201 reader: anytype,
202 traversal_limit: usize,
203) !ArchiveContents {
204 var entries: std.ArrayList([]const u8) = .empty;
205 errdefer {
206 for (entries.items) |e| alloc.free(e);
207 entries.deinit(alloc);
208 }
209
210 var seen = std.StringHashMap(void).init(alloc);
211 defer seen.deinit();
212
213 var diagnostics: std.tar.Diagnostics = .{ .allocator = alloc };
214 defer diagnostics.deinit();
215
216 var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined;
217 var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined;
218 var iter = std.tar.Iterator.init(reader, .{
219 .file_name_buffer = &file_name_buffer,
220 .link_name_buffer = &link_name_buffer,
221 });
222 iter.diagnostics = &diagnostics;
223
224 for (0..traversal_limit) |_| {
225 const tar_file = try iter.next();
226 if (tar_file == null) break;
227
228 const is_dir = tar_file.?.kind == .directory;
229 const truncated = tar_file.?.name.len >= std.fs.max_path_bytes;
230 const entry = try extractTopLevelEntry(alloc, tar_file.?.name, is_dir, truncated);
231
232 const gop = try seen.getOrPut(entry);
233 if (gop.found_existing) {
234 alloc.free(entry);
235 continue;
236 }
237
238 try entries.append(alloc, entry);
239 }
240
241 return ArchiveContents{
242 .entries = entries,
243 };
244}
245
246fn processTarGz(
247 alloc: std.mem.Allocator,
248 reader: anytype,
249 args: OperationArgs,
250) !OperationResult {
251 var flate_buffer: [std.compress.flate.max_window_len]u8 = undefined;
252 var decompress = std.compress.flate.Decompress.init(reader, .gzip, &flate_buffer);
253
254 return switch (args) {
255 .list => |list_args| .{
256 .list = try listTar(alloc, &decompress.reader, list_args.traversal_limit),
257 },
258 .extract => |extract_args| .{
259 .extract = try extractTarImpl(alloc, &decompress.reader, extract_args.dest_dir, extract_args.file_logger),
260 },
261 };
262}
263
264fn processTarXz(
265 alloc: std.mem.Allocator,
266 reader: anytype,
267 args: OperationArgs,
268) !OperationResult {
269 var dcp = try std.compress.xz.decompress(alloc, reader.adaptToOldInterface());
270 defer dcp.deinit();
271 var adapter_buffer: [1024]u8 = undefined;
272 var adapter = dcp.reader().adaptToNewApi(&adapter_buffer);
273
274 return switch (args) {
275 .list => |list_args| .{
276 .list = try listTar(alloc, &adapter.new_interface, list_args.traversal_limit),
277 },
278 .extract => |extract_args| .{
279 .extract = try extractTarImpl(alloc, &adapter.new_interface, extract_args.dest_dir, extract_args.file_logger),
280 },
281 };
282}
283
284fn processTarZst(
285 alloc: std.mem.Allocator,
286 reader: anytype,
287 args: OperationArgs,
288) !OperationResult {
289 const window_len = std.compress.zstd.default_window_len;
290 const window_buffer = try alloc.alloc(u8, window_len + std.compress.zstd.block_size_max);
291 defer alloc.free(window_buffer);
292 var decompress: std.compress.zstd.Decompress = .init(reader, window_buffer, .{
293 .verify_checksum = false,
294 .window_len = window_len,
295 });
296
297 return switch (args) {
298 .list => |list_args| .{
299 .list = try listTar(alloc, &decompress.reader, list_args.traversal_limit),
300 },
301 .extract => |extract_args| .{
302 .extract = try extractTarImpl(alloc, &decompress.reader, extract_args.dest_dir, extract_args.file_logger),
303 },
304 };
305}
306
307fn listZip(
308 alloc: std.mem.Allocator,
309 file: std.fs.File,
310 traversal_limit: usize,
311) !ArchiveContents {
312 var entries: std.ArrayList([]const u8) = .empty;
313 errdefer {
314 for (entries.items) |e| alloc.free(e);
315 entries.deinit(alloc);
316 }
317
318 var seen = std.StringHashMap(void).init(alloc);
319 defer seen.deinit();
320
321 var buffer: [archive_buf_size]u8 = undefined;
322 var file_reader = file.reader(&buffer);
323
324 var iter = try std.zip.Iterator.init(&file_reader);
325 var file_name_buf: [std.fs.max_path_bytes]u8 = undefined;
326
327 for (0..traversal_limit) |_| {
328 const zip_file = try iter.next();
329 if (zip_file == null) break;
330
331 const file_name_len = @min(zip_file.?.filename_len, file_name_buf.len);
332 const truncated = zip_file.?.filename_len > file_name_buf.len;
333
334 try file_reader.seekTo(zip_file.?.header_zip_offset + @sizeOf(std.zip.CentralDirectoryFileHeader));
335 const file_name = file_name_buf[0..file_name_len];
336 try file_reader.interface.readSliceAll(file_name);
337
338 const is_dir = std.mem.endsWith(u8, file_name, "/");
339 const entry = try extractTopLevelEntry(alloc, file_name, is_dir, truncated);
340
341 const gop = try seen.getOrPut(entry);
342 if (gop.found_existing) {
343 alloc.free(entry);
344 continue;
345 }
346
347 try entries.append(alloc, entry);
348 }
349
350 return ArchiveContents{
351 .entries = entries,
352 };
353}
354
355fn extractTarImpl(
356 alloc: std.mem.Allocator,
357 reader: anytype,
358 dest_dir: std.fs.Dir,
359 file_logger: ?FileLogger,
360) !ExtractionResult {
361 var files_extracted: usize = 0;
362 var dirs_created: usize = 0;
363 var files_skipped: usize = 0;
364
365 var diagnostics: std.tar.Diagnostics = .{ .allocator = alloc };
366 defer diagnostics.deinit();
367
368 var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined;
369 var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined;
370 var iter = std.tar.Iterator.init(reader, .{
371 .file_name_buffer = &file_name_buffer,
372 .link_name_buffer = &link_name_buffer,
373 });
374 iter.diagnostics = &diagnostics;
375
376 while (try iter.next()) |tar_file| {
377 const safe_path = validateAndCleanPath(alloc, tar_file.name) catch |err| {
378 if (err == error.OutOfMemory) return err;
379
380 files_skipped += 1;
381 if (file_logger) |logger| {
382 const reason: SkipReason = switch (err) {
383 error.PathContainsTraversal => .path_contains_traversal,
384 error.PathTooLong => .path_too_long,
385 error.PathEmpty => .path_empty,
386 error.OutOfMemory => unreachable,
387 };
388
389 const message = try std.fmt.allocPrint(alloc, "Failed to extract file '{s}': {any}", .{ tar_file.name, reason });
390 defer alloc.free(message);
391 logger.write(message, .err) catch {};
392 }
393 continue;
394 };
395 defer alloc.free(safe_path);
396
397 if (tar_file.kind == .directory) {
398 try dest_dir.makePath(safe_path);
399 dirs_created += 1;
400 } else if (tar_file.kind == .file or tar_file.kind == .sym_link) {
401 if (std.fs.path.dirname(safe_path)) |parent| {
402 try dest_dir.makePath(parent);
403 }
404
405 // TODO: Investigate preserving file permissions from archive
406 const out_file = try dest_dir.createFile(safe_path, .{ .exclusive = true });
407 defer out_file.close();
408
409 var file_writer_buffer: [archive_buf_size]u8 = undefined;
410 var file_writer = out_file.writer(&file_writer_buffer);
411 try iter.streamRemaining(tar_file, &file_writer.interface);
412
413 files_extracted += 1;
414 }
415 }
416
417 return ExtractionResult{
418 .files_extracted = files_extracted,
419 .dirs_created = dirs_created,
420 .files_skipped = files_skipped,
421 };
422}
423
424fn extractZipImpl(
425 alloc: std.mem.Allocator,
426 file: std.fs.File,
427 dest_dir: std.fs.Dir,
428 file_logger: ?FileLogger,
429) !ExtractionResult {
430 var files_extracted: usize = 0;
431 var dirs_created: usize = 0;
432 var files_skipped: usize = 0;
433
434 var buffer: [archive_buf_size]u8 = undefined;
435 var file_reader = file.reader(&buffer);
436
437 var iter = try std.zip.Iterator.init(&file_reader);
438 var file_name_buf: [std.fs.max_path_bytes]u8 = undefined;
439
440 while (try iter.next()) |entry| {
441 const file_name_len = @min(entry.filename_len, file_name_buf.len);
442
443 try file_reader.seekTo(entry.header_zip_offset + @sizeOf(std.zip.CentralDirectoryFileHeader));
444 const file_name = file_name_buf[0..file_name_len];
445 try file_reader.interface.readSliceAll(file_name);
446
447 const safe_path = validateAndCleanPath(alloc, file_name) catch |err| {
448 if (err == error.OutOfMemory) return err;
449
450 files_skipped += 1;
451 if (file_logger) |logger| {
452 const reason: SkipReason = switch (err) {
453 error.PathContainsTraversal => .path_contains_traversal,
454 error.PathTooLong => .path_too_long,
455 error.PathEmpty => .path_empty,
456 error.OutOfMemory => unreachable,
457 };
458
459 const message = try std.fmt.allocPrint(alloc, "Failed to extract file '{s}': {any}", .{ file_name, reason });
460 defer alloc.free(message);
461 logger.write(message, .err) catch {};
462 }
463 continue;
464 };
465 defer alloc.free(safe_path);
466
467 if (std.mem.endsWith(u8, file_name, "/")) {
468 try dest_dir.makePath(safe_path);
469 dirs_created += 1;
470 } else {
471 if (std.fs.path.dirname(safe_path)) |parent| {
472 try dest_dir.makePath(parent);
473 }
474
475 // TODO: Investigate preserving file permissions from archive
476 const out_file = try dest_dir.createFile(safe_path, .{ .exclusive = true });
477 defer out_file.close();
478
479 // Seek to local file header and read it to get to compressed data
480 try file_reader.seekTo(entry.file_offset);
481 const local_header = try file_reader.interface.takeStruct(std.zip.LocalFileHeader, .little);
482
483 // Skip filename and extra field to get to compressed data
484 _ = try file_reader.interface.discard(@enumFromInt(local_header.filename_len));
485 _ = try file_reader.interface.discard(@enumFromInt(local_header.extra_len));
486
487 var copy_buffer: [archive_buf_size]u8 = undefined;
488
489 if (entry.compression_method == .store) {
490 var total_read: usize = 0;
491 while (total_read < entry.uncompressed_size) {
492 const to_read = @min(copy_buffer.len, entry.uncompressed_size - total_read);
493 const n = try file_reader.interface.readSliceShort(copy_buffer[0..to_read]);
494 if (n == 0) break;
495 try out_file.writeAll(copy_buffer[0..n]);
496 total_read += n;
497 }
498 } else if (entry.compression_method == .deflate) {
499 var limited_buffer: [archive_buf_size]u8 = undefined;
500 var limited_reader = file_reader.interface.limited(@enumFromInt(entry.compressed_size), &limited_buffer);
501 var flate_buffer: [std.compress.flate.max_window_len]u8 = undefined;
502 var decompress = std.compress.flate.Decompress.init(&limited_reader.interface, .raw, &flate_buffer);
503
504 while (true) {
505 const n = try decompress.reader.readSliceShort(©_buffer);
506 if (n == 0) break;
507 try out_file.writeAll(copy_buffer[0..n]);
508 }
509 } else {
510 return error.UnsupportedCompressionMethod;
511 }
512
513 files_extracted += 1;
514 }
515 }
516
517 return ExtractionResult{
518 .files_extracted = files_extracted,
519 .dirs_created = dirs_created,
520 .files_skipped = files_skipped,
521 };
522}