diff --git a/.gila/todo/intelligent_dino_17y/intelligent_dino_17y.md b/.gila/done/intelligent_dino_17y/intelligent_dino_17y.md similarity index 80% rename from .gila/todo/intelligent_dino_17y/intelligent_dino_17y.md rename to .gila/done/intelligent_dino_17y/intelligent_dino_17y.md index b6bb57d..bd46256 100644 --- a/.gila/todo/intelligent_dino_17y/intelligent_dino_17y.md +++ b/.gila/done/intelligent_dino_17y/intelligent_dino_17y.md @@ -1,9 +1,10 @@ --- title: feat: add keybind to extraction archive -status: todo +status: done priority_value: 50 priority: low owner: brookjeynes created: 2026-01-11T21:52:59Z +completed: 2026-01-21T07:05:03Z --- Allow users to extract archives via a keybind diff --git a/README.md b/README.md index bfafc8f..cc67ebb 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ d :Create directory. Will enter input mode. v :Verbose mode. Provides more information about selected entry. y :Yank selected item. p :Past yanked item. +x :Extract archive to `/`. Input mode: :Cancel input. @@ -70,6 +71,7 @@ Command mode: :trash :Navigate to trash directory if it exists. :empty_trash :Empty trash if it exists. This action cannot be undone. :cd :Change directory via path. Will enter input mode. +:extract :Extract archive under cursor. ``` ## Configuration @@ -87,18 +89,20 @@ Config schema: Config = struct { .show_hidden: bool = true, .sort_dirs: bool = true, - .show_images: bool = true, -- Images are only supported in a terminal - supporting the `kitty image protocol`. + .show_images: bool = true, -- Images are only supported in a terminal + supporting the `kitty image protocol`. .preview_file: bool = true, - .empty_trash_on_exit: bool = false, -- Emptying the trash permanently deletes - all files within the trash. These - files are not recoverable past this - point. - .true_dir_size: bool = false, -- Display size of directory including - all its children. This can and will - cause lag on deeply nested directories. - .archive_traversal_limit: usize = 100, -- How many files to be traversed when reading - an archive (zip, tar, etc.). + .empty_trash_on_exit: bool = false, -- Emptying the trash permanently deletes + all files within the trash. These + files are not recoverable past this + point. + .true_dir_size: bool = false, -- Display size of directory including + all its children. This can and will + cause lag on deeply nested directories. + .archive_traversal_limit: usize = 100, -- How many files to be traversed when reading + an archive (zip, tar, etc.). + .keep_partial_extraction: bool = false, -- If extraction fails, keep the partial + extracted directory instead of cleaning up. .keybinds: Keybinds, .styles: Styles } @@ -119,6 +123,7 @@ Keybinds = struct { not recoverable .yank: ?Char = 'y' .paste: ?Char = 'p' + .extract_archive: ?Char = 'x' } NotificationStyles = struct { diff --git a/src/app.zig b/src/app.zig index 4ccf0c8..345e1cd 100644 --- a/src/app.zig +++ b/src/app.zig @@ -42,6 +42,7 @@ const help_menu_items = [_][]const u8{ "v :Verbose mode. Provides more information about selected entry. ", "y :Yank selected item.", "p :Past yanked item.", + "x :Extract archive to `/`", "", "Input mode:", " :Cancel input.", @@ -55,6 +56,7 @@ const help_menu_items = [_][]const u8{ ":trash :Navigate to trash directory if it exists.", ":empty_trash :Empty trash if it exists. This action cannot be undone.", ":cd :Change directory via path. Will enter input mode.", + ":extract :Extract archive under cursor.", }; pub const State = enum { diff --git a/src/archive.zig b/src/archive.zig index 256269c..6ba9798 100644 --- a/src/archive.zig +++ b/src/archive.zig @@ -1,5 +1,6 @@ const std = @import("std"); const ascii = @import("std").ascii; +const FileLogger = @import("./file_logger.zig"); const archive_buf_size = 8192; @@ -33,6 +34,41 @@ pub const ArchiveContents = struct { } }; +pub const ExtractionResult = struct { + files_extracted: usize, + dirs_created: usize, + files_skipped: usize, +}; + +pub const PathValidationError = error{ + PathContainsTraversal, + PathTooLong, + PathEmpty, +}; + +pub const SkipReason = enum { + path_contains_traversal, + path_too_long, + path_empty, +}; + +const Operation = enum { list, extract }; + +const OperationArgs = union(Operation) { + list: struct { + traversal_limit: usize, + }, + extract: struct { + dest_dir: std.fs.Dir, + file_logger: ?FileLogger, + }, +}; + +const OperationResult = union(Operation) { + list: ArchiveContents, + extract: ExtractionResult, +}; + pub fn listArchiveContents( alloc: std.mem.Allocator, file: std.fs.File, @@ -42,17 +78,102 @@ pub fn listArchiveContents( var buffer: [archive_buf_size]u8 = undefined; var reader = file.reader(&buffer); + const list_args = OperationArgs{ .list = .{ + .traversal_limit = traversal_limit, + } }; + const contents = switch (archive_type) { .tar => try listTar(alloc, &reader.interface, traversal_limit), - .@"tar.gz" => try listTarGz(alloc, &reader.interface, traversal_limit), - .@"tar.xz" => try listTarXz(alloc, &reader.interface, traversal_limit), - .@"tar.zst" => try listTarZst(alloc, &reader.interface, traversal_limit), + .@"tar.gz" => (try processTarGz(alloc, &reader.interface, list_args)).list, + .@"tar.xz" => (try processTarXz(alloc, &reader.interface, list_args)).list, + .@"tar.zst" => (try processTarZst(alloc, &reader.interface, list_args)).list, .zip => try listZip(alloc, file, traversal_limit), }; return contents; } +pub fn extractArchive( + alloc: std.mem.Allocator, + file: std.fs.File, + archive_type: ArchiveType, + dest_dir: std.fs.Dir, + file_logger: ?FileLogger, +) !ExtractionResult { + var buffer: [archive_buf_size]u8 = undefined; + var reader = file.reader(&buffer); + + const extract_args = OperationArgs{ .extract = .{ + .dest_dir = dest_dir, + .file_logger = file_logger, + } }; + + return switch (archive_type) { + .tar => try extractTarImpl(alloc, &reader.interface, dest_dir, file_logger), + .@"tar.gz" => (try processTarGz(alloc, &reader.interface, extract_args)).extract, + .@"tar.xz" => (try processTarXz(alloc, &reader.interface, extract_args)).extract, + .@"tar.zst" => (try processTarZst(alloc, &reader.interface, extract_args)).extract, + .zip => try extractZipImpl(alloc, file, dest_dir, file_logger), + }; +} + +pub fn getExtractDirName(archive_path: []const u8) []const u8 { + const basename = std.fs.path.basename(archive_path); + + return if (ascii.endsWithIgnoreCase(basename, ".tar.gz")) + basename[0 .. basename.len - 7] + else if (ascii.endsWithIgnoreCase(basename, ".tar.xz")) + basename[0 .. basename.len - 7] + else if (ascii.endsWithIgnoreCase(basename, ".tar.zst")) + basename[0 .. basename.len - 8] + else if (ascii.endsWithIgnoreCase(basename, ".tgz")) + basename[0 .. basename.len - 4] + else if (ascii.endsWithIgnoreCase(basename, ".txz")) + basename[0 .. basename.len - 4] + else if (ascii.endsWithIgnoreCase(basename, ".tzst")) + basename[0 .. basename.len - 5] + else if (ascii.endsWithIgnoreCase(basename, ".tar")) + basename[0 .. basename.len - 4] + else if (ascii.endsWithIgnoreCase(basename, ".zip")) + basename[0 .. basename.len - 4] + else if (ascii.endsWithIgnoreCase(basename, ".jar")) + basename[0 .. basename.len - 4] + else + basename; +} + +fn validateAndCleanPath( + alloc: std.mem.Allocator, + path: []const u8, +) (PathValidationError || error{OutOfMemory})![]const u8 { + // Strip leading slashes (handles /, //, ///, etc.) + var clean_path = path; + while (std.mem.startsWith(u8, clean_path, "/")) { + clean_path = clean_path[1..]; + } + + if (clean_path.len == 0) return error.PathEmpty; + if (clean_path.len >= std.fs.max_path_bytes) return error.PathTooLong; + + // Check for directory traversal by tracking depth + var depth: i32 = 0; + var iter = std.mem.splitScalar(u8, clean_path, '/'); + while (iter.next()) |component| { + if (component.len == 0) continue; + + if (std.mem.eql(u8, component, "..")) { + depth -= 1; + if (depth < 0) { + return error.PathContainsTraversal; + } + } else if (!std.mem.eql(u8, component, ".")) { + depth += 1; + } + } + + return try alloc.dupe(u8, clean_path); +} + fn extractTopLevelEntry( alloc: std.mem.Allocator, full_path: []const u8, @@ -121,40 +242,65 @@ fn listTar( }; } -fn listTarGz( +fn processTarGz( alloc: std.mem.Allocator, reader: anytype, - limit: usize, -) !ArchiveContents { + args: OperationArgs, +) !OperationResult { var flate_buffer: [std.compress.flate.max_window_len]u8 = undefined; var decompress = std.compress.flate.Decompress.init(reader, .gzip, &flate_buffer); - return try listTar(alloc, &decompress.reader, limit); + + return switch (args) { + .list => |list_args| .{ + .list = try listTar(alloc, &decompress.reader, list_args.traversal_limit), + }, + .extract => |extract_args| .{ + .extract = try extractTarImpl(alloc, &decompress.reader, extract_args.dest_dir, extract_args.file_logger), + }, + }; } -fn listTarXz( +fn processTarXz( alloc: std.mem.Allocator, reader: anytype, - limit: usize, -) !ArchiveContents { + args: OperationArgs, +) !OperationResult { var dcp = try std.compress.xz.decompress(alloc, reader.adaptToOldInterface()); defer dcp.deinit(); var adapter_buffer: [1024]u8 = undefined; var adapter = dcp.reader().adaptToNewApi(&adapter_buffer); - return try listTar(alloc, &adapter.new_interface, limit); + + return switch (args) { + .list => |list_args| .{ + .list = try listTar(alloc, &adapter.new_interface, list_args.traversal_limit), + }, + .extract => |extract_args| .{ + .extract = try extractTarImpl(alloc, &adapter.new_interface, extract_args.dest_dir, extract_args.file_logger), + }, + }; } -fn listTarZst( +fn processTarZst( alloc: std.mem.Allocator, reader: anytype, - limit: usize, -) !ArchiveContents { + args: OperationArgs, +) !OperationResult { const window_len = std.compress.zstd.default_window_len; const window_buffer = try alloc.alloc(u8, window_len + std.compress.zstd.block_size_max); + defer alloc.free(window_buffer); var decompress: std.compress.zstd.Decompress = .init(reader, window_buffer, .{ .verify_checksum = false, .window_len = window_len, }); - return try listTar(alloc, &decompress.reader, limit); + + return switch (args) { + .list => |list_args| .{ + .list = try listTar(alloc, &decompress.reader, list_args.traversal_limit), + }, + .extract => |extract_args| .{ + .extract = try extractTarImpl(alloc, &decompress.reader, extract_args.dest_dir, extract_args.file_logger), + }, + }; } fn listZip( @@ -204,3 +350,172 @@ fn listZip( .entries = entries, }; } + +fn extractTarImpl( + alloc: std.mem.Allocator, + reader: anytype, + dest_dir: std.fs.Dir, + file_logger: ?FileLogger, +) !ExtractionResult { + var files_extracted: usize = 0; + var dirs_created: usize = 0; + var files_skipped: usize = 0; + + var diagnostics: std.tar.Diagnostics = .{ .allocator = alloc }; + defer diagnostics.deinit(); + + var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined; + var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined; + var iter = std.tar.Iterator.init(reader, .{ + .file_name_buffer = &file_name_buffer, + .link_name_buffer = &link_name_buffer, + }); + iter.diagnostics = &diagnostics; + + while (try iter.next()) |tar_file| { + const safe_path = validateAndCleanPath(alloc, tar_file.name) catch |err| { + if (err == error.OutOfMemory) return err; + + files_skipped += 1; + if (file_logger) |logger| { + const reason: SkipReason = switch (err) { + error.PathContainsTraversal => .path_contains_traversal, + error.PathTooLong => .path_too_long, + error.PathEmpty => .path_empty, + error.OutOfMemory => unreachable, + }; + + const message = try std.fmt.allocPrint(alloc, "Failed to extract file '{s}': {any}", .{ tar_file.name, reason }); + defer alloc.free(message); + logger.write(message, .err) catch {}; + } + continue; + }; + defer alloc.free(safe_path); + + if (tar_file.kind == .directory) { + try dest_dir.makePath(safe_path); + dirs_created += 1; + } else if (tar_file.kind == .file or tar_file.kind == .sym_link) { + if (std.fs.path.dirname(safe_path)) |parent| { + try dest_dir.makePath(parent); + } + + // TODO: Investigate preserving file permissions from archive + const out_file = try dest_dir.createFile(safe_path, .{ .exclusive = true }); + defer out_file.close(); + + var file_writer_buffer: [archive_buf_size]u8 = undefined; + var file_writer = out_file.writer(&file_writer_buffer); + try iter.streamRemaining(tar_file, &file_writer.interface); + + files_extracted += 1; + } + } + + return ExtractionResult{ + .files_extracted = files_extracted, + .dirs_created = dirs_created, + .files_skipped = files_skipped, + }; +} + +fn extractZipImpl( + alloc: std.mem.Allocator, + file: std.fs.File, + dest_dir: std.fs.Dir, + file_logger: ?FileLogger, +) !ExtractionResult { + var files_extracted: usize = 0; + var dirs_created: usize = 0; + var files_skipped: usize = 0; + + var buffer: [archive_buf_size]u8 = undefined; + var file_reader = file.reader(&buffer); + + var iter = try std.zip.Iterator.init(&file_reader); + var file_name_buf: [std.fs.max_path_bytes]u8 = undefined; + + while (try iter.next()) |entry| { + const file_name_len = @min(entry.filename_len, file_name_buf.len); + + try file_reader.seekTo(entry.header_zip_offset + @sizeOf(std.zip.CentralDirectoryFileHeader)); + const file_name = file_name_buf[0..file_name_len]; + try file_reader.interface.readSliceAll(file_name); + + const safe_path = validateAndCleanPath(alloc, file_name) catch |err| { + if (err == error.OutOfMemory) return err; + + files_skipped += 1; + if (file_logger) |logger| { + const reason: SkipReason = switch (err) { + error.PathContainsTraversal => .path_contains_traversal, + error.PathTooLong => .path_too_long, + error.PathEmpty => .path_empty, + error.OutOfMemory => unreachable, + }; + + const message = try std.fmt.allocPrint(alloc, "Failed to extract file '{s}': {any}", .{ file_name, reason }); + defer alloc.free(message); + logger.write(message, .err) catch {}; + } + continue; + }; + defer alloc.free(safe_path); + + if (std.mem.endsWith(u8, file_name, "/")) { + try dest_dir.makePath(safe_path); + dirs_created += 1; + } else { + if (std.fs.path.dirname(safe_path)) |parent| { + try dest_dir.makePath(parent); + } + + // TODO: Investigate preserving file permissions from archive + const out_file = try dest_dir.createFile(safe_path, .{ .exclusive = true }); + defer out_file.close(); + + // Seek to local file header and read it to get to compressed data + try file_reader.seekTo(entry.file_offset); + const local_header = try file_reader.interface.takeStruct(std.zip.LocalFileHeader, .little); + + // Skip filename and extra field to get to compressed data + _ = try file_reader.interface.discard(@enumFromInt(local_header.filename_len)); + _ = try file_reader.interface.discard(@enumFromInt(local_header.extra_len)); + + var copy_buffer: [archive_buf_size]u8 = undefined; + + if (entry.compression_method == .store) { + var total_read: usize = 0; + while (total_read < entry.uncompressed_size) { + const to_read = @min(copy_buffer.len, entry.uncompressed_size - total_read); + const n = try file_reader.interface.readSliceShort(copy_buffer[0..to_read]); + if (n == 0) break; + try out_file.writeAll(copy_buffer[0..n]); + total_read += n; + } + } else if (entry.compression_method == .deflate) { + var limited_buffer: [archive_buf_size]u8 = undefined; + var limited_reader = file_reader.interface.limited(@enumFromInt(entry.compressed_size), &limited_buffer); + var flate_buffer: [std.compress.flate.max_window_len]u8 = undefined; + var decompress = std.compress.flate.Decompress.init(&limited_reader.interface, .raw, &flate_buffer); + + while (true) { + const n = try decompress.reader.readSliceShort(©_buffer); + if (n == 0) break; + try out_file.writeAll(copy_buffer[0..n]); + } + } else { + return error.UnsupportedCompressionMethod; + } + + files_extracted += 1; + } + } + + return ExtractionResult{ + .files_extracted = files_extracted, + .dirs_created = dirs_created, + .files_skipped = files_skipped, + }; +} diff --git a/src/config.zig b/src/config.zig index 246c36b..656b38f 100644 --- a/src/config.zig +++ b/src/config.zig @@ -20,6 +20,7 @@ const Config = struct { true_dir_size: bool = false, entry_dir: ?[]const u8 = null, archive_traversal_limit: usize = 100, + keep_partial_extraction: bool = false, styles: Styles = .{}, keybinds: Keybinds = .{}, @@ -212,6 +213,7 @@ pub const Keybinds = struct { force_delete: ?Char = null, paste: ?Char = @enumFromInt('p'), yank: ?Char = @enumFromInt('y'), + extract_archive: ?Char = @enumFromInt('x'), }; const Styles = struct { diff --git a/src/event_handlers.zig b/src/event_handlers.zig index 634798f..b0f591a 100644 --- a/src/event_handlers.zig +++ b/src/event_handlers.zig @@ -120,6 +120,7 @@ pub fn handleNormalEvent( .force_delete => try events.forceDelete(app), .yank => try events.yank(app), .paste => try events.paste(app), + .extract_archive => try events.extractArchive(app), } } else { switch (key.codepoint) { @@ -210,6 +211,11 @@ pub fn handleInputEvent(app: *App, event: App.Event) !void { break :supported; } + if (std.mem.eql(u8, command, ":extract")) { + try events.extractArchive(app); + break :supported; + } + try app.text_input.insertSliceAtCursor(":UnsupportedCommand"); } diff --git a/src/events.zig b/src/events.zig index 2de29dc..f1e6bc6 100644 --- a/src/events.zig +++ b/src/events.zig @@ -1,9 +1,13 @@ const std = @import("std"); -const App = @import("./app.zig"); -const config = &@import("./config.zig").config; + +const vaxis = @import("vaxis"); const zuid = @import("zuid"); + +const App = @import("./app.zig"); +const Archive = @import("./archive.zig"); const environment = @import("./environment.zig"); -const vaxis = @import("vaxis"); + +const config = &@import("./config.zig").config; pub fn delete(app: *App) error{OutOfMemory}!void { var message: ?[]const u8 = null; @@ -563,3 +567,91 @@ pub fn undo(app: *App) error{OutOfMemory}!void { app.directories.entries.selected = selected; } + +pub fn extractArchive(app: *App) error{OutOfMemory}!void { + var message: ?[]const u8 = null; + defer if (message) |msg| app.alloc.free(msg); + + const entry = (app.directories.getSelected() catch { + app.notification.write("Can not extract - no item selected.", .warn) catch {}; + return; + }) orelse return; + + const archive_type = Archive.ArchiveType.fromPath(entry.name) orelse { + app.notification.write("Not an archive file.", .warn) catch {}; + return; + }; + + const extract_dir_name = Archive.getExtractDirName(entry.name); + + if (environment.fileExists(app.directories.dir, extract_dir_name)) { + message = try std.fmt.allocPrint(app.alloc, "Can not extract file(s) - '{s}' already exists.", .{extract_dir_name}); + app.notification.write(message.?, .warn) catch {}; + return; + } + + var dest_dir = app.directories.dir.makeOpenPath(extract_dir_name, .{}) catch |err| { + message = try std.fmt.allocPrint(app.alloc, "Failed to extract archive '{s}' - {}.", .{ extract_dir_name, err }); + app.notification.write(message.?, .err) catch {}; + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; + return; + }; + defer dest_dir.close(); + + const archive_file = app.directories.dir.openFile(entry.name, .{}) catch |err| { + message = try std.fmt.allocPrint( + app.alloc, + "Failed to open archive '{s}' - {}.", + .{ entry.name, err }, + ); + app.notification.write(message.?, .err) catch {}; + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; + + if (!config.keep_partial_extraction) { + app.directories.dir.deleteTree(extract_dir_name) catch {}; + } + return; + }; + defer archive_file.close(); + + const result = Archive.extractArchive( + app.alloc, + archive_file, + archive_type, + dest_dir, + app.file_logger, + ) catch |err| { + message = try std.fmt.allocPrint( + app.alloc, + "Failed to extract '{s}' - {s}.", + .{ entry.name, @errorName(err) }, + ); + app.notification.write(message.?, .err) catch {}; + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; + + if (!config.keep_partial_extraction) { + app.directories.dir.deleteTree(extract_dir_name) catch {}; + } + return; + }; + + if (result.files_skipped > 0) { + message = try std.fmt.allocPrint( + app.alloc, + "Extracted {d} files, {d} directories to './{s}{s}'. Failed to extract {d} files, check the log file for more details.", + .{ result.files_extracted, result.dirs_created, std.fs.path.sep_str, extract_dir_name, result.files_skipped }, + ); + app.notification.write(message.?, .err) catch {}; + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; + } else { + message = try std.fmt.allocPrint( + app.alloc, + "Extracted {d} files, {d} directories to './{s}{s}'.", + .{ result.files_extracted, result.dirs_created, std.fs.path.sep_str, extract_dir_name }, + ); + app.notification.write(message.?, .info) catch {}; + if (app.file_logger) |file_logger| file_logger.write(message.?, .info) catch {}; + } + + try app.repopulateDirectory(""); +}