#!/usr/bin/python

######
# git-lso: list and verify loose objects
#
# version: 0.1 created on 05/08/2012 (DD/MM/YYYY)
###
#
# Copyright (C) 2012 Jimmy Scott #jimmy#inet-solutions#be#. Belgium.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
#   1. Redistributions of source code must retain the above copyright notice,
#      this list of conditions and the following disclaimer.
#
#   2. Redistributions in binary form must reproduce the above copyright
#      notice, this list of conditions and the following disclaimer in the
#      documentation and/or other materials provided with the distribution.
#
#   3. The names of the authors may not be used to endorse or promote products
#      derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS "AS IS" AND ANY
# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
# THE POSSIBILITY OF SUCH DAMAGE.
#
######

import os
import sys
import re
import sha
import zlib
import getopt

version = "git-lso version 0.1";

class GitLooseObject(object):
	"""Holds git object data"""
	
	id = None
	path = None
	
	# Compressed object
	z_data_size = None
	
	# Decompressed object
	x_data_size = None
	
	# Header data
	o_data_type = None
	o_data_size = None
	
	# Verification error
	v_error = None
	
	# Verification data
	v_fmt1 = v_fmt2 = v_fmt3 = v_fmt4 = False
	v_read = v_zlib = v_size = v_sha1 = False
	
	# Optimize non-verbose mode
	v_all = False
	
	def __init__(self, id=None, path=None):
		self.id = id
		self.path = path


class GitRepoValidator(object):
	"""Holds git repository data"""
	
	_max_verbose = 3
	
	def __init__(self, repo, bare=False):
		"""Init variables"""
		
		self.verbose = 0
		self.show_compression = False
		self.error = None
		self.repo = repo
		self.bare = bare
		self.objects = []
		self.object_dirs = []
		
		if bare:
			self.git_dir = repo
		else:
			self.git_dir = os.path.join(repo, ".git")
		
		self.objects_root = os.path.join(self.git_dir, "objects")
	
	def _vprint(self, verbose_min, verbose_max, vrb_msg):
		"""Verbose aware print"""
		
		if self.verbose >= verbose_min and self.verbose <= verbose_max:
			sys.stdout.write(vrb_msg)
	
	def is_bare(self):
		"""Return self.bare"""
		
		return self.bare
	
	def set_verbose(self, verbose_level):
		"""Set verbose level"""
		
		if verbose_level < self._max_verbose:
			self.verbose = verbose_level
		else:
			self.verbose = self._max_verbose
	
	def set_show_compression(self, show_compression):
		"""Set to show compression"""
		
		self.show_compression = show_compression
	
	def get_error(self):
		"""Return self.error"""
		
		return self.error
	
	def reset_error(self):
		"""Reset the error status"""
		
		self.error = None
	
	def verify(self):
		"""Verify if self.objects_root exists and is a directory"""
		
		if not os.path.exists(self.objects_root):
			self.error = "no such directory: %s" % \
				self.objects_root
			return False
		
		if not os.path.isdir(self.objects_root):
			self.error = "not a directory: %s" % \
				self.objects_root
			return False
		
		return True
	
	def populate_objects(self):
		"""Gather a list of objects"""
		
		# Don't populate if already done
		if self.objects:
			return
		
		self.object_dirs = []
		dir_regex = re.compile("^[0-9a-f]{2}$")
		file_regex = re.compile("^[0-9a-f]{38}$")
		
		# Get list of object dirs
		for o_dir in os.listdir(self.objects_root):
			o_dir_path = os.path.join(self.objects_root, o_dir)
			if re.match(dir_regex, o_dir) and os.path.isdir(o_dir_path):
				# Looks like an object dir so far
				self.object_dirs.append((o_dir, o_dir_path))
		
		# Get list of object files
		for o_dir, o_dir_path in self.object_dirs:
			for o_file in os.listdir(o_dir_path):
				o_file_path = os.path.join(o_dir_path, o_file)
				if re.match(file_regex, o_file) and os.path.isfile(o_file_path):
					# Looks like an object file so far
					self.objects.append(
						GitLooseObject(
							id = o_dir + o_file,
							path = o_file_path
						)
					)
	
	def list_objects(self):
		"""List gathered objects"""
		
		_max_verbose = self._max_verbose
		
		if not self.objects:
			sys.stderr.write("No objects.\n")
			return
		
		for obj in self.objects:
			self._vprint(0, 0, "%s\n" % obj.id)
			self._vprint(1, _max_verbose, "id: %s\n" % obj.id)
			self._vprint(2, _max_verbose, "  path: %s\n" % obj.path)
	
	def print_summary(self):
		"""Print summary of verified objects"""
		
		if not self.objects:
			sys.stderr.write("No objects.\n")
			return
		
		# Summary header data
		header = ("ok", "error", "zdata", "xdata", "odata", "ratio")
		
		# Summary header format
		field = " %11s"
		fmt = field * len(header)
		width = len(field % "") * len(header)
		s_line = "-" * width
		d_line = "=" * width
		
		# Verbose header data
		vheader = ("ok?", "type", "id", "zdata", "xdata", "odata", "ratio")
		
		# Verbose header format
		vfmt = " %3s %7s" + field * 5
		
		# Summary data
		c_ratio = None
		o_ok = o_error = 0
		z_data_size = x_data_size = o_data_size = 0
		
		if self.verbose:
			print vfmt % vheader
			print s_line
		
		# Gather data from objects
		for obj in self.objects:
			if obj.v_all:
				o_ok += 1
				if obj.z_data_size: z_data_size += obj.z_data_size
				if obj.x_data_size: x_data_size += obj.x_data_size
				if obj.o_data_size: o_data_size += obj.o_data_size
			else:
				o_error += 1
			
			if self.verbose:
				v_c_ratio = None
				
				# Calculate compression if possible
				if obj.z_data_size and obj.x_data_size:
					v_c_ratio = str(100 * obj.z_data_size / obj.x_data_size) + "%"
				
				# Build verbose data
				v_data = (
					"[Y]" if obj.v_all else "[N]",
					obj.o_data_type or "N/A",
					obj.id[:10],
					obj.z_data_size or "N/A",
					obj.x_data_size or "N/A",
					obj.o_data_size or "N/A",
					v_c_ratio or "N/A"
				)
				
				# Print verbose data
				print vfmt % v_data
		
		if self.verbose:
			print d_line
		
		# Calculate compression ratio
		if z_data_size and x_data_size:
			c_ratio = str(100 * z_data_size / x_data_size) + "%"
		
		# Print summary
		print fmt % header
		print s_line
		print fmt % (o_ok, o_error, z_data_size, x_data_size, o_data_size, c_ratio)
	
	def print_objects(self):
		"""Print verified objects"""
		
		_max_verbose = self._max_verbose
		
		# Output formatting
		sfmt = "  %-15s %11s\n"		# Aligned 'show'
		vfmt = "  verify %-8s %11s\n"	# Aligned 'verify'
		ffmt = "  %s %s\n"		# Full/Maximized 'show'
		
		if not self.objects:
			sys.stderr.write("No objects.\n")
			return
		
		for obj in self.objects:
			# Stuff that needs to be calculated
			c_ratio = None
			
			# Calculate compression if possible
			if self.show_compression and obj.z_data_size and obj.x_data_size:
				c_ratio = str(100 * obj.z_data_size / obj.x_data_size) + "%"
			
			# Shortcut for non-verbose mode
			if self.verbose == 0:
				# Print id
				print "%s " % obj.id,
				
				# Print compression
				if c_ratio is not None:
					print "%4s " % c_ratio,
				
				# Print status
				if obj.v_all:
					print "OK"
				else:
					print "ERROR"
				continue
			
			### Only verbose mode passes the section below
			
			# Print object id
			self._vprint(1, _max_verbose, "id: %s\n" % obj.id)
			
			# Show steps
			s_steps = (
				(obj.path, "path", 3, _max_verbose, ffmt),
				(obj.z_data_size, "zdata size", 3, _max_verbose, sfmt),
				(obj.x_data_size, "xdata size", 3, _max_verbose, sfmt),
				(c_ratio, "compression", 1, _max_verbose, sfmt),
				(obj.o_data_type, "odata type", 3, _max_verbose, sfmt),
				(obj.o_data_size, "odata size", 3, _max_verbose, sfmt),
			)
			
			# Print step data if needed
			for s_opt, s_name, s_min, s_max, s_fmt in s_steps:
				if s_opt is not None:
					self._vprint(s_min, s_max, s_fmt %
						(s_name + ":", s_opt))
			
			# Verify steps
			v_steps = (
				(obj.v_read, "read", 1, _max_verbose),
				(obj.v_zlib, "zlib", 1, _max_verbose),
				(obj.v_fmt1, "fmt1", 2, _max_verbose),
				(obj.v_fmt2, "fmt2", 2, _max_verbose),
				(obj.v_fmt3, "fmt3", 2, _max_verbose),
				(obj.v_fmt4, "fmt4", 2, _max_verbose),
				(obj.v_fmt4, "fmtX", 1, 1),
				(obj.v_size, "size", 1, _max_verbose),
				(obj.v_sha1, "sha1", 1, _max_verbose)
			)
			
			# Print step status if needed
			for v_opt, v_name, v_min, v_max in v_steps:
				if v_opt:
					self._vprint(v_min, v_max, vfmt % (v_name + ":", "[OK]"))
					continue
				else:
					self._vprint(1, _max_verbose, vfmt % (v_name + ":", "[EE]"))
					self._vprint(1, _max_verbose, ffmt % ("ERROR:", obj.v_error))
					break
	
	
	def verify_objects(self):
		"""Verify gathered objects"""
		
		if not self.objects:
			return
		
		for obj in self.objects:
			
			# Verify read
			try:
				obj_zdata = read_file_x(obj.path, blob=True)
			except IOError as e:
				obj.v_error = "IOError: [Errno %s] %s" % (e.errno, e.strerror)
				continue
			obj.v_read = True
			obj.z_data_size = len(obj_zdata)
			
			# Verify zlib
			try:
				obj_xdata = zlib.decompress(obj_zdata)
			except zlib.error:
				obj.v_error = "zlib decompress failed"
				continue
			obj.v_zlib = True
			obj.x_data_size = len(obj_xdata)
			
			# Verify format check 1
			try:
				obj_hdr, obj_data = obj_xdata.split("\x00", 1)
			except ValueError:
				obj.v_error = "failed to extract header"
				continue
			obj.v_fmt1 = True
			
			# Verify format check 2
			try:
				obj_data_type, obj_data_size = obj_hdr.split(" ")
			except ValueError:
				obj.v_error = "failed to split header"
				continue
			obj.v_fmt2 = True
			
			# Verify format check 3
			if not obj_data_type in ('blob', 'tree', 'commit', 'tag'):
				obj.v_error = "invalid data type: %s" % obj_data_type
				continue
			obj.v_fmt3 = True
			obj.o_data_type = obj_data_type
			
			# Verify format check 4
			if not obj_data_size.isdigit():
				obj.v_error = "invalid data size: %s" % obj_data_len
				continue
			obj.v_fmt4 = True
			obj.o_data_size = int(obj_data_size)
			
			# Verify size
			if int(obj_data_size) != len(obj_data):
				obj.v_error = "data size mismatch: %s vs %s" % \
					(int(obj_data_size), len(obj_data))
				continue
			obj.v_size = True
			
			# Calculate checksum
			vrf_id = sha.sha(obj_data_type + " " + obj_data_size + 
				"\x00" + obj_data).hexdigest()
			
			# Verify checksum
			if vrf_id != obj.id:
				obj.v_error = "invalid checksum: %s" % vrf_id
				continue
			obj.v_sha1 = True
			
			# All is OK
			obj.v_all = True


def read_file_x(filename, blob=False):
	"""Open a file, read it and return its content.
	
	Returns the content as a list or as a blob.
	Raises IOError on open or read error.
	"""
	
	fp = open(filename, "r")
	try:
		if blob:
			data = fp.read()
		else:
			data = fp.readlines()
	finally:
		fp.close()
	
	return data


def sysexit(msg, excode=70):
	sys.stderr.write("%s: %s\n" % (sys.argv[0], msg))
	sys.exit(excode)


def process_repo(repo, mode):
	"""Run the requested mode"""
	
	# Verify it looks like a valid repo
	if not repo.verify():
		errmsg = "%s" % repo.get_error()
		if not repo.is_bare():
			errmsg += "\n\nMaybe you forgot --bare?"
		sysexit(errmsg, 65)
	
	# Populate objects
	repo.populate_objects()
	
	# Execute requested mode
	if mode == 'verify':
		repo.verify_objects()
		repo.print_objects()
	elif mode == 'summary':
		repo.verify_objects()
		repo.print_summary()
	elif mode == 'list':
		repo.list_objects()
	else:
		assert False, "unhandled mode"


def usage(errmsg=None):
	if errmsg is not None:
		sys.stderr.write("%s: %s\n" % (sys.argv[0], errmsg))
	sys.stderr.write(
		"usage: %s [-vcs] [--bare] [<git-repo-root>]\n"
		"       %s [-vl] [--bare] [<git-repo-root>]\n\n"
	 	"   --bare             Operate on a bare repository\n" 
		"   -v, --verbose      Verbose, more -v flags increase the verbosity\n"
		"   -c, --compression  Show compression ratio as a percentage\n"
		"   -s, --summary      Show a summary after verification, implies -c\n"
		"   -l, --list         Show a listing only, do not verify\n"
		% (sys.argv[0], sys.argv[0]))
	sys.exit(64)


def show_version():
	print version
	sys.exit(0)


def main():
	opts = "lvchsV"
	long_opts = ['help', 'bare', 'verbose', 'compression', 'list'
		'summary', 'version']
	bare = False
	list_only = False
	show_compression = False
	show_summary = False
	verbose = 0
	mode = 'verify'
	
	# Get options and arguments
	try:
		opts, args = getopt.getopt(sys.argv[1:], opts, long_opts)
	except getopt.GetoptError, error:
		usage(error)
	
	# Process options
	for opt, arg in opts:
		if opt == "--bare":
			bare = True
		elif opt in ('-v', '--verbose'):
			verbose += 1
		elif opt in ('-c', '--compression'):
			show_compression = True
		elif opt in ('-s', '--summary'):
			show_summary = True
			mode = 'summary'
		elif opt in ('-l', '--list'):
			list_only = True
			mode = 'list'
		elif opt in ('-V', '--version'):
			show_version()
		elif opt in ('-h', '--help'):
			usage()
		else:
			assert False, "unhandled option"
	
	# Check incompatible options
	if list_only and show_compression:
		usage("option -c and -l cannot be used together")
	if list_only and show_summary:
		usage("option -l and -s cannot be used together")
	
	# Process arguments
	if args:
		repo = args.pop(0)
	else:
		repo = os.curdir
	
	# No args should be left
	if args:
		usage("too many arguments: %s" % str.join(', ', args))
	
	# Create object and process repo
	repo = GitRepoValidator(repo, bare)
	repo.set_verbose(verbose)
	repo.set_show_compression(show_compression)
	process_repo(repo, mode)


if __name__ == "__main__":
	main()

