#!/usr/bin/python3
SVER = '2.0.8'
##############################################################################
# pat - SCA Pattern Checker Tool
# Copyright (C) 2022 SUSE LLC
#
# Description:  Runs a pattern or patterns against supportconfig directories to
#               check the pattern's output based on the supportconfig directory.
# Modified:     2022 Nov 19
#
##############################################################################
#
#  This program is free software; you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation; version 2 of the License.
#
#  This program 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 General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program; if not, see <http://www.gnu.org/licenses/>.
#
#  Authors/Contributors:
#     Jason Record <jason.record@suse.com>
#
##############################################################################

import sys
import os
import re
import getopt
import signal
import subprocess
from datetime import timedelta
from timeit import default_timer as timer

##############################################################################
# Global Options
##############################################################################

recurse_archives = False
recurse_patterns = False
pattern_list = []
archive_list = []
usepath = {'archives': '', 'patterns': '', 'scalib': ''}
overall_dict = {"-2": "Temporary", "-1": "Partial", "0": "Success", "1": "Recommend", "2": "Promotion", "3": "Warning", "4": "Critical", "5": "Error", "6": "Ignore"}
c_ = {'current': 0, 'total': 0, 'total_pat': 0, 'total_arch': 0, "active_pattern": '', "active_archive": '', 'skipped': 0, 'Fatal': 0, 'Temporary': 0, 'Partial': 0, 'Success': 0, 'Recommend': 0, 'Promotion': 0, 'Warning': 0, 'Critical': 0, 'Error': 0, 'Ignore': 0}
meta_error = {'any': False, 'mode': False, 'bin': False, 'out': False, 'hpl': False, 'dos': False, 'notsca': False}
progress_bar_width = 65
invalid_patterns = []
invalid_pattern_shortlist = {}
log_level = {'quiet': 0, 'normal': 1, 'verbose': 2}
logging = log_level['normal']
elapsed = -1
hbar_length = 90
conf_file = "/etc/opt/patdevel/patdev.conf"
conf_file_dict = {}


##############################################################################
# Functions
##############################################################################

def hbar(_type):
	print("{}".format(_type*hbar_length))

def title():
	"Display the program title"
	hbar("#")
	print("# SCA Pattern Checker v" + str(SVER))
	hbar("#")

def usage():
	"Displays usage information"
	print("Usage: pat [options] <path_to_pattern|directory>")
	print()
	print("Description:")
	print("  Runs the selected pattern(s) against the selected supportconfig archive(s).")
	print()
	print("  Documentation: /usr/share/doc/packages/sca-patterns-devel/index.html")
	print()
	print("Options:")
	print("  -h, --help     Display this help")
	print("  -a, --archives Root directory for supportconfig archives to be used for testing")
	print("  -r, --recurse  Validate patterns and archives recursively found in the directory structures")
	print("  -q, --quiet    Not output is displayed. The number of fatal patterns is returned as the exit status")
	print("  -v, --verbose  Display verbose logging messages")
	print()

def signal_handler(sig, frame):
	print("\n\nAborting...\n")
	show_summary()
	sys.exit(1)

class ProgressBar():
	"Initialize and update progress bar class"
	def __init__(self, prefix, bar_width, total):
		self.prefix = prefix
		self.bar_width = bar_width
		self.total = total
		self.out = sys.stdout

	def __str__(self):
		return 'class %s(\n  prefix=%r \n  bar_width=%r \n  total=%r\n)' % (self.__class__.__name__, self.prefix, self.bar_width, self.total)

	def update(self, count):
		percent_complete = int(100*count/self.total)
		current_progress = int(self.bar_width*count/self.total)
		print("{}[{}{}] {:3g}% {:3g}/{}".format(self.prefix, "#"*current_progress, "."*(self.bar_width-current_progress), percent_complete, count, self.total), end='\r', file=self.out, flush=True)

	def finish(self):
		print("\n", flush=True, file=self.out)

def show_summary():
	display = "{0:26} {1}"
	print("Summary")
	hbar("-")
	print(display.format("Elsapsed Runtime", str(elapsed).split('.')[0]))
	print(display.format("Pattern Directory", usepath['patterns']))
	print(display.format("Archive Directory", usepath['archives']))
	print(display.format("SCA Library Directory", usepath['scalib']))
	print(display.format("Patterns Checked", c_['total_pat']))
	print(display.format("Archives Tested", c_['total_arch']))
	print(display.format("Total Checks", c_['total']))
	print(display.format("Files Skipped", c_['skipped']))
	print(display.format("Fatal", c_['Fatal']))
	print(display.format("Errors", c_['Error']))
	print(display.format("Ignored", c_['Ignore']))
	print(display.format("Critical", c_['Critical']))
	print(display.format("Warning", c_['Warning']))
	print(display.format("Proactive", c_['Promotion']))
	print(display.format("Recommended", c_['Recommend']))
	print(display.format("Success", c_['Success']))
	if len(c_['active_pattern']) > 0:
		print(display.format("Active Pattern", c_['active_pattern']))
	if len(c_['active_archive']) > 0:
		print(display.format("Active Archive", c_['active_archive']))
	print()
	print("Fatal Pattern Short List")
	hbar("-")
	for pattern in invalid_pattern_shortlist.keys():
		print(pattern)
	print()
	print("Fatal Pattern Details")
	hbar("-")
	ldisplay = "  {0:25} {1}"
	if len(invalid_patterns) > 0:
		for invalid_pattern in invalid_patterns:
			print("clear; pat -va " + invalid_pattern[1] + " " + invalid_pattern[0])
	else:
		print("None")
	print()

def set_environment():
	os.environ['PERL5LIB'] = usepath['scalib'] + '/perl'
	os.environ['PYTHONPATH'] = usepath['scalib'] + '/python'

def get_archive_list(this_path):
	scadir = re.compile("^scc_|^nts_", re.IGNORECASE)
	subfolders = []
	supportconfig_dir = this_path + "/basic-environment.txt"

	if os.path.exists(supportconfig_dir):
		subfolders.append(this_path)
	else:
		these_folders = [f.path for f in os.scandir(this_path) if f.is_dir()]

		if recurse_archives:
			for dirname in list(these_folders):
				these_folders.extend(get_archive_list(dirname))

		for folder in these_folders:
			if scadir.search(os.path.basename(folder)):
				subfolders.append(folder)
				
	return subfolders

def prepare_archives():
	these_archives = []
	if os.path.isdir(usepath['archives']):
		usepath['archives'] = os.path.abspath(usepath['archives'])
		if logging > log_level['quiet']:
			if recurse_archives:
				print("Recursively Loading Archives from " + usepath['archives'])
			else:
				print("Loading Archives from " + usepath['archives'])
		these_archives = get_archive_list(usepath['archives'])
	else:
		print("Error: Invalid archive path - " + usepath['archives'] + "\n")
		usage()
		sys.exit(5)

	return these_archives

def get_pattern_list():
	this_list = []
	include_file = re.compile(".py$|.pl$")
	for dirpath, subdirs, files in os.walk(usepath['patterns'], topdown = True):
		for file in files:
			if include_file.search(file):
				this_list.append(os.path.join(dirpath, file))
		if not recurse_patterns:
			break
	return this_list

def prepare_patterns():
	these_patterns = []

	if os.path.isdir(usepath['patterns']):
		usepath['patterns'] = os.path.abspath(usepath['patterns'])
		if logging > log_level['quiet']:
			if recurse_patterns:
				print("Recursively Processing " + usepath['patterns'])
			else:
				print("Processing " + usepath['patterns'])
		these_patterns = get_pattern_list()
	elif os.path.isfile(usepath['patterns']):
		usepath['patterns'] = os.path.abspath(usepath['patterns'])
		if logging > log_level['quiet']:
			print("Processing file " + usepath['patterns'])
		these_patterns.append(usepath['patterns'])
	else:
		print("Error: Invalid pattern file or path - " + usepath['patterns'] + "\n")
		usage()
		sys.exit(5)

	return these_patterns

def validate_pattern(this_pattern):
	# Validate pattern mode
	if not os.access(this_pattern, os.X_OK):
		meta_error['any'] = True
		meta_error['mode'] = True

	# Validate UNIX text files
	with open(this_pattern, 'rb') as f:
		bindata = f.read()
	f.close()

	if b'\x0d\x0a' in bindata: # Look for \r\n
		meta_error['any'] = True
		meta_error['dos'] = True

	# Validate pattern hash pling and SCA pattern
	data = bindata.splitlines()
	hashpling = re.compile('^#!/')
	validhpls = re.compile('python3$|perl$')
	scapattern = re.compile('^Core.init\(META_CLASS|^\@PATTERN_RESULTS = \(', re.IGNORECASE)
	hpl = ''
	hplmissing = True
	scafile = False
	for binline in data:
		line = binline.decode('ascii')
		if hashpling.search(line):
			hpl = line[2:].split()[0] #drop the #!, keep the path
		if scapattern.search(line):
			scafile = True
			break

	if not scafile:
		meta_error['notsca'] = True
		return

	if len(hpl) > 0:
		if os.path.exists(hpl):
			if validhpls.search(hpl):
				hplmissing = False
	if hplmissing:
		meta_error['any'] = True
		meta_error['hpl'] = True

def show_meta_errors():
	edisplay = "- {0}"
	if meta_error['any']:
		if logging > log_level['normal']:
			if meta_error['dos']:
				print(edisplay.format("Detected DOS file format, use dos2unix to convert"))
			if meta_error['mode']:
				print(edisplay.format("Missing execute permission"))
			if meta_error['hpl']:
				print(edisplay.format("Missing or invalid hash pling"))
			if meta_error['out']:
				print(edisplay.format("Invalid pattern output string, review Pattern Requirements"))
			if meta_error['bin']:
				print(edisplay.format("Pattern execution error, pattern returned non-zero"))

def run_pattern(this_pattern, this_archive):
	global meta_error
	run_display = "Running: {0} -p {1}\n"
	IDX_OVERALL = 5
	IDX_LAST = -1
	output = []
	status = ''
	status_display = "Status: {0}"
	valid_output = re.compile("^META_CLASS=.*|META_CATEGORY=.*|META_COMPONENT=.*|PATTERN_ID=.*|PRIMARY_LINK=META_LINK_.*|OVERALL=.*|OVERALL_INFO=.*|META_LINK_")
	meta_error = {'any': False, 'mode': False, 'bin': False, 'out': False, 'hpl': False, 'dos': False, 'notsca': False}

	validate_pattern(this_pattern)

	if logging > log_level['normal']:
		print(run_display.format(this_pattern, this_archive))

	if meta_error['notsca']:
		c_['skipped'] += 1
		status = 'Skipped, Not an SCA Pattern'
		if logging > log_level['normal']:
			print(status_display.format(status))
			hbar("-")
			print()
		return


	if meta_error['any']:
		status = 'Fatal'
		c_['Fatal'] += 1

	try:
		p = subprocess.run([this_pattern, '-p', this_archive], universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
	except Exception as e:
#		print('Exception') ### DEBUG
		meta_error['bin'] = True # not working
		if not meta_error['any']:
			c_['Fatal'] += 1
		status = 'Fatal'
		meta_error['any'] = True

		if logging > log_level['normal']:
			print(str(e) + "\n")
			show_meta_errors()
			print(status_display.format(status))
			hbar("-")
			print()
		if meta_error['any']:
			invalid_patterns.append([this_pattern, this_archive])
			invalid_pattern_shortlist[this_pattern] = True
		return

	if p.returncode > 0:
#		print('None-Zero Return Code') ### DEBUG
		if not meta_error['any']:
			c_['Fatal'] += 1
		status = 'Fatal'
		meta_error['any'] = True
		meta_error['bin'] = True

		if logging > log_level['normal']:
			print(p.stdout)
			print(p.stderr)
	else:
		if valid_output.search(p.stdout):
#			print('Valid Execution, Output Found') ### DEBUG
			lines = p.stdout.splitlines()
			if len(lines) > 1:
				meta_error['any'] = True
				meta_error['out'] = True
				status = 'Fatal'
				c_['Fatal'] += 1
			else:
				output = p.stdout.split('|')
				overall = output[IDX_OVERALL].split("=")[IDX_LAST]
				c_[overall_dict[overall]] += 1
				status = overall_dict[overall]
			if logging > log_level['normal']:
				print(p.stdout)
		else:
#			print('Valid Execution, Output MISSING') ### DEBUG
			if not meta_error['any']:
				c_['Fatal'] += 1
			status = 'Fatal'
			meta_error['any'] = True
			meta_error['out'] = True

	if meta_error['any']:
		invalid_patterns.append([this_pattern, this_archive])
		invalid_pattern_shortlist[this_pattern] = True
	if logging > log_level['normal']:
		show_meta_errors()
		print(status_display.format(status))
		hbar("-")
		print()

##############################################################################
# Main
##############################################################################

def main(argv):
	"main entry point"
	global SVER, pattern_list, archive_list, recurse_archives, recurse_patterns, c, conf_file, conf_file_dict
	global invalid_patterns, invalid_pattern_shortlist, logging, log_level, elapsed, defpath, usepath, hbar_length, meta_error
	start = timer()
	
	try:
		(optlist, args) = getopt.gnu_getopt(argv[1:], "ha:qrv", ["help", "archives=", "quiet", "recurse", "verbose"])
	except getopt.GetoptError as exc:
		title()
		usage()
		print("Error:", exc, file=sys.stderr)
		sys.exit(2)
	for opt in optlist:
		if opt[0] in {"-h", "--help"}:
			title()
			usage()
			sys.exit(0)
		elif opt[0] in {"-a", "--archives"}:
			usepath['archives'] = opt[1]
		elif opt[0] in {"-r", "--recurse"}:
			recurse_archives = True
			recurse_patterns = True
		elif opt[0] in {"-v", "--verbose"}:
			logging = log_level['verbose']
		elif opt[0] in {"-q", "--quiet"}:
			logging = log_level['quiet']

	if logging > log_level['quiet']:
		title()
	set_environment()
	archive_list = prepare_archives()
	c_['total_arch'] = len(archive_list)

	if len(args) > 0:
		usepath['patterns'] = args[0]
	else:
		print("Error: Missing path to pattern file or directory, use . for current directory\n")
		usage()
		sys.exit(1)


	pattern_list = prepare_patterns()
	c_['total_pat'] = len(pattern_list)
	c_['total'] = c_['total_pat'] * c_['total_arch']
	if logging > log_level['quiet']:
		print()

	if logging == log_level['normal']:
		bar = ProgressBar(" Checking: ", progress_bar_width, c_['total'])

	for pattern in pattern_list:
		for archive in archive_list:
			c_['current'] += 1
			c_['active_pattern'] = pattern
			c_['active_archive'] = archive
			run_pattern(pattern, archive)
			if logging == log_level['normal']:
				bar.update(c_['current'])
	c_['active_pattern'] = ''
	c_['active_archive'] = ''

	if logging == log_level['normal']:
		bar.finish()

	end = timer()
	elapsed = str(timedelta(seconds=end-start))
	if logging == log_level['verbose']:
		print()
	if logging > log_level['quiet']:
		if c_['total_pat'] > 0:
			show_summary()
		else:
			print("No patterns found\n")
		
# Entry point
if __name__ == "__main__":
	signal.signal(signal.SIGINT, signal_handler)
	try:
		f = open(conf_file, "rt")
	except Exception as error:
		title()
		print("{0} {1}, {2}".format("ERROR: Missing configuration file: ", conf_file, str(error)))
		print()
		sys.exit(3)

	skipped_line = re.compile("^#|^$", re.IGNORECASE)
	for line in f.readlines():
		line = line.strip("\n")
		if skipped_line.search(line):
			continue
		key, value = line.split('=')
		value = value.strip('"')
		conf_file_dict[key] = value.strip('"\'')
	f.close()
	usepath['archives'] = conf_file_dict['PATDEV_ARCH_DIR']
	usepath['scalib'] = conf_file_dict['SCALIB_DIR']

	main(sys.argv)
	sys.exit(c_['Fatal'])


