#    This file is part of Metasm, the Ruby assembly manipulation suite
#    Copyright (C) 2006-2009 Yoann GUILLOT
#
#    Licence is LGPL, see LICENCE in the top-level directory

require 'metasm/exe_format/main'
require 'metasm/encode'
require 'metasm/decode'
begin
	require 'zlib'
rescue LoadError
end

# generic ZIP file, may be an APK or JAR
# supports only a trivial subset of the whole ZIP specification
#  single file archive
#  deflate or no compression
#  no encryption
#  32bit offsets/sizes

module Metasm
class ZIP < ExeFormat
	MAGIC_LOCALHEADER = 0x04034b50
	COMPRESSION_METHOD = { 0 => 'NONE', 1 => 'SHRUNK', 2 => 'REDUCE1', 3 => 'REDUCE2',
		4 => 'REDUCE3', 5 => 'REDUCE4', 6 => 'IMPLODE', 7 => 'TOKENIZED',
		8 => 'DEFLATE', 9 => 'DEFLATE64', 10 => 'OLDTERSE', 12 => 'BZIP2', 14 => 'LZMA',
		18 => 'TERSE', 19 => 'LZ77', 97 => 'WAVPACK', 98 => 'PPMD' }

	# zip file format:
	#
	# [local header 1]
	# compressed data 1
	#
	# [local header 2]
	# compressed data 2
	#
	# [central header 1]
	# [central header 2]
	#
	# [end of central directory]

	class LocalHeader < SerialStruct
		word :signature, MAGIC_LOCALHEADER
		half :verneed, 10
		half :flags	# bit 3 => has data descriptor following the compressed data
		half :compress_method, 0, COMPRESSION_METHOD
		halfs :mtime, :mdate
		word :crc32
		words :compressed_sz, :uncompressed_sz
		halfs :fname_len, :extra_len
		attr_accessor :fname, :extra
		attr_accessor :compressed_off

		def decode(zip)
			super(zip)
			raise "Invalid ZIP signature #{@signature.to_s(16)}" if @signature != MAGIC_LOCALHEADER
			@fname = zip.encoded.read(@fname_len) if @fname_len > 0
			@extra = zip.encoded.read(@extra_len) if @extra_len > 0
			@compressed_off = zip.encoded.ptr
		end

		def set_default_values(zip)
			@fname_len = fname ? @fname.length : 0
			@extra_len = extra ? @extra.length : 0
			super(zip)
		end

		def encode(zip)
			ed = super(zip)
			ed << fname << extra
		end

		# return a new LocalHeader with all fields copied from a CentralHeader
		def self.from_central(f)
			l = new
			l.verneed = f.verneed
			l.flags = f.flags
			l.compress_method = f.compress_method
			l.mtime = f.mtime
			l.mdate = f.mdate
			l.crc32 = f.crc32
			l.compressed_sz = f.compressed_sz
			l.uncompressed_sz = f.uncompressed_sz
			l.fname = f.fname
			l.extra = f.extra
			l
		end
	end

	MAGIC_CENTRALHEADER = 0x02014b50
	class CentralHeader < SerialStruct
		word :signature, MAGIC_CENTRALHEADER
		half :vermade, 10
		half :verneed, 10
		half :flags
		half :compress_method, 0, COMPRESSION_METHOD
		halfs :mtime, :mdate
		word :crc32
		words :compressed_sz, :uncompressed_sz
		halfs :fname_len, :extra_len, :comment_len
		half :disk_nr
		half :file_attr_intern
		word :file_attr_extern
		word :localhdr_off
		attr_accessor :fname, :extra, :comment
		attr_accessor :data

		def decode(zip)
			super(zip)
			raise "Invalid ZIP signature #{@signature.to_s(16)}" if @signature != MAGIC_CENTRALHEADER
			@fname = zip.encoded.read(@fname_len) if @fname_len > 0
			@extra = zip.encoded.read(@extra_len) if @extra_len > 0
			@comment = zip.encoded.read(@comment_len) if @comment_len > 0
		end

		def set_default_values(zip)
			@fname_len = fname ? @fname.length : 0
			@extra_len = extra ? @extra.length : 0
			@comment_len = comment ? @comment.length : 0
			super(zip)
		end

		def encode(zip)
			ed = super(zip)
			ed << fname << extra << comment
		end

		# reads the raw file data from the archive
		def file_data(zip)
			return @data if data

			zip.encoded.ptr = @localhdr_off
			LocalHeader.decode(zip)
			raw = zip.encoded.read(@compressed_sz)
			@data = case @compress_method
			when 'NONE'
				raw
			when 'DEFLATE'
				z = Zlib::Inflate.new(-Zlib::MAX_WBITS)
				z.inflate(raw)
			else
				raise "Unsupported zip compress method #@compress_method"
			end
		end

		def zlib_deflate(data, level=Zlib::DEFAULT_COMPRESSION)
			z = Zlib::Deflate.new(level, -Zlib::MAX_WBITS)
			z.deflate(data) + z.finish
		end

		# encode the data, fixup related fields
		def encode_data(zip)
			data = file_data(zip)
			@compress_method = 'NONE' if data == ''

			@crc32 = Zlib.crc32(data)
			@uncompressed_sz = data.length

			case compress_method
			when 'NONE'
			when 'DEFLATE'
				data = zlib_deflate(data)
			when nil
				# autodetect compression method
				# compress if we win more than 10% space
				cdata = zlib_deflate(data)
				ratio = cdata.length * 100 / data.length
				if ratio < 90
					@compress_method = 'DEFLATE'
					data = cdata
				else
					@compress_method = 'NONE'
				end
			end

			@compressed_sz = data.length

			data
		end
	end

	MAGIC_ENDCENTRALDIRECTORY = 0x06054b50
	class EndCentralDirectory < SerialStruct
		word :signature, MAGIC_ENDCENTRALDIRECTORY
		halfs :disk_nr, :disk_centraldir, :entries_nr_thisdisk, :entries_nr
		word :directory_sz
		word :directory_off
		half :comment_len
		attr_accessor :comment

		def decode(zip)
			super(zip)
			raise "Invalid ZIP end signature #{@signature.to_s(16)}" if @signature != MAGIC_ENDCENTRALDIRECTORY
			@comment = zip.encoded.read(@comment_len) if @comment_len > 0
		end

		def set_default_values(zip)
			@entries_nr_thisdisk = zip.files.length
			@entries_nr = zip.files.length
			@comment_len = comment ? @comment.length : 0
			super(zip)
		end

		def encode(zip)
			ed = super(zip)
			ed << comment
		end
	end

	def decode_half(edata=@encoded) edata.decode_imm(:u16, @endianness) end
	def decode_word(edata=@encoded) edata.decode_imm(:u32, @endianness) end
	def encode_half(w) Expression[w].encode(:u16, @endianness) end
	def encode_word(w) Expression[w].encode(:u32, @endianness) end
	def sizeof_half ; 2 ; end
	def sizeof_word ; 4 ; end

	attr_accessor :files, :header

	def initialize(cpu = nil)
		@endianness = :little
		@header = EndCentralDirectory.new
		@files = []
		super(cpu)
	end

	# scan and decode the 'end of central directory' header
	def decode_header
		if not @encoded.ptr = @encoded.data.rindex([MAGIC_ENDCENTRALDIRECTORY].pack('V'))
			raise "ZIP: no end of central directory record"
		end
		@header = EndCentralDirectory.decode(self)
	end

	# read the whole central directory file descriptors
	def decode
		decode_header
		@encoded.ptr = @header.directory_off
		while @encoded.ptr < @header.directory_off + @header.directory_sz
			@files << CentralHeader.decode(self)
		end
	end

	# checks if a given file name exists in the archive
	# returns the CentralHeader or nil
	# case-insensitive if lcase is false
	def has_file(fname, lcase=true)
		decode if @files.empty?
		if lcase
			@files.find { |f| f.fname == fname }
		else
			fname = fname.downcase
			@files.find { |f| f.fname.downcase == fname }
		end
	end

	# returns the uncompressed raw file content from a given name
	# nil if name not found
	# case-insensitive if lcase is false
	def file_data(fname, lcase=true)
		if f = has_file(fname, lcase)
			f.file_data(self)
		end
	end

	# add a new file to the zip archive
	def add_file(fname, data, compress=:auto)
		f = CentralHeader.new

		case compress
		when 'NONE', false; f.compress_method = 'NONE'
		when 'DEFLATE', true; f.compress_method = 'DEFLATE'
		end

		f.fname = fname
		f.data = data

		@files << f
		f
	end

	# create a new zip file
	def encode
		edata = EncodedData.new
		central_dir = EncodedData.new

		@files.each { |f|
			encode_entry(f, edata, central_dir)
		}

		@header.directory_off = edata.length
		@header.directory_sz  = central_dir.length

		edata << central_dir << @header.encode(self)

		@encoded = edata
	end

	# add one file to the zip stream
	def encode_entry(f, edata, central_dir)
		f.localhdr_off = edata.length

		# may autodetect compression method
		raw = f.encode_data(self)

		zipalign(f, edata)

		central_dir << f.encode(self)	# calls f.set_default_values

		l = LocalHeader.from_central(f)
		edata << l.encode(self)

		edata << raw
	end

	# zipalign: ensure uncompressed data starts on a 4-aligned offset
	def zipalign(f, edata)
		if f.compress_method == 'NONE' and not f.extra
			o = (edata.length + f.fname.length + 2) & 3
			f.extra = " "*(4-o) if o > 0
		end

	end

	# when called as AutoExe, try to find a meaningful exefmt
	def self.autoexe_load(bin)
		z = decode(bin)
		if dex = z.file_data('classes.dex')
			puts "ZIP: APK file, loading 'classes.dex'" if $VERBOSE
			AutoExe.load(dex)
		else
			z
		end
	end
end
end
