# frozen_string_literal: true
#
# Copyright (c) 2006-2024 Hal Brodigan (postmodern.mod3 at gmail.com)
#
# ronin-support is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ronin-support is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with ronin-support. If not, see .
#
require 'ronin/support/archive/zip/reader/entry'
require 'ronin/support/archive/zip/reader/statistics'
require 'time'
module Ronin
module Support
module Archive
module Zip
#
# Handles reading zip archives.
#
# @note
# This provides a simple interface for reading zip archives using
# the `unzip` command. If you need something more powerful, use the
# [archive-zip] gem instead.
#
# [archive-zip]: https://github.com/javanthropus/archive-zip
#
# @api public
#
# @since 1.0.0
#
class Reader
include Enumerable
# The path to the zip archive that will be read.
#
# @return [String]
attr_reader :path
# The optional password to use when reading the zip archive.
#
# @return [String, nil]
attr_reader :password
#
# Initializes the zip archive reader.
#
# @param [String] path
# The path to the zip archive file.
#
# @param [String, nil] password
# Optional password to use when reading the zip archive.
#
# @yield [zip]
# If a block is given, it will be yielded the new zip reader.
#
# @yieldparam [Reader] zip
# The new zip reacher.
#
def initialize(path, password: nil)
@path = File.expand_path(path)
@password = password
yield self if block_given?
end
#
# Alias to {#initialize new}.
#
# @param [String] path
# The path to the zip archive file.
#
# @param [Hash{Symbol => Object}] kwargs
# Additional keyword arguments for {#initialize new}.
#
# @option kwargs [String, nil] :password
# Optional password to use when reading the zip archive.
#
# @yield [zip]
# If a block is given, it will be yielded the new zip reader.
#
# @yieldparam [Reader] zip
# The new zip reacher.
#
# @see #initialize
#
def self.open(path,**kwargs,&block)
new(path,**kwargs,&block)
end
#
# Lists the contents of the zip archive.
#
# @yield [entry]
# If a block is given it will be passed each parsed entry in the
# zip archive.
#
# @yieldparam [Entry] entry
# An entry in the zip archive.
#
# @return [Enumerator, Statistics]
# If no block is given an enumerator will be returned.
# If a block was given, then a statistics object will be returned.
#
# @note
# This method actually executes the `unzip -v -l ZIP` command
# and parses it's output.
#
def each
return enum_for(__method__) unless block_given?
io = IO.popen(command_argv('-v','-l',@path))
# skip lines until the "--------- ---------- ----- ----" line
until io.eof?
break if io.readline.start_with?('-')
end
until io.eof?
line = io.readline(chomp: true)
unless line.start_with?('-')
yield parse_entry_line(line)
else
# reached the "--------- -------" line
break
end
end
last_line = io.readline(chomp: true)
return parse_statistics_line(last_line)
end
alias list each
alias entries to_a
#
# Finds the entry with the given name.
#
# @param [String] name
# The file name to search for.
#
# @return [Entry, nil]
# The matching entry or `nil` if no entry could be found.
#
def [](name)
find { |entry| entry.name == name }
end
#
# Reads the contents of an entry from the zip archive.
#
# @param [String] name
# The name of the entry to read.
#
# @param [Integer, nil] length
# Optional number of bytes to read.
#
# @return [String]
# The read data.
#
# @note
# This method actually executes the `unzip -p ZIP FILE` command
# and reads it's output.
#
def read(name, length: nil)
io = IO.popen(command_argv('-p', @path, name))
if length then io.read(length)
else io.read
end
end
private
#
# Creates an `unzip` command with the additional arguments.
#
# @param [Array] arguments
# Additional arguments for the `unzip` command.
#
# @return [Array]
# The `unzip` command argv.
#
def command_argv(*arguments)
argv = ['unzip']
if @password
argv << '-P' << @password
end
argv.concat(arguments)
end
# Translates the Method column in `unzip -v -l` output to Symbols.
METHODS = {
'Stored' => :stored,
'Defl:N' => :deflate
}
#
# Parses a entry line from the output of `unzip -v -l ZIP`.
#
# @param [String] line
# The line to parse.
#
# @return [Entry]
# The parsed entry.
#
def parse_entry_line(line)
length, method, size, compression, date, time, crc32, name =
line.lstrip.split(/\s+/,8)
time_fmt = case date
when /\A\d{2}-\d{2}-\d{4}\z/ then "%m-%d-%Y %H:%M"
when /\A\d{4}-\d{2}-\d{2}\z/ then "%Y-%m-%d %H:%M"
else
raise(NotImplementedError,"unrecognized date format: #{date.inspect}")
end
length = length.to_i
method = METHODS.fetch(method)
size = size.to_i
compression = compression.chomp('%').to_i
time = Time.strptime("#{date} #{time}",time_fmt)
date = time.to_date
return Entry.new(self, length: length,
method: method,
size: size,
compression: compression,
date: date,
time: time,
crc32: crc32,
name: name)
end
#
# Parses the last line from the output of `unzip -v -l ZIP`.
#
# @param [String] line
# The line to parse.
#
# @return [Statistics]
# The parsed statistics.
#
def parse_statistics_line(line)
length, size, compression, files, _rest = line.lstrip.split(/\s+/,5)
return Statistics.new(
length: length.to_i,
size: size.to_i,
compression: compression.chomp('%').to_i,
files: files.to_i
)
end
end
end
end
end
end