地圖 (Jido) is a lightweight Unix TUI file explorer designed for speed and simplicity.
at main 522 lines 18 kB view raw
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(&copy_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}