#!/usr/bin/env python3
# Copyright (C) 2012  Michał Masłowski  <mtjm@mtjm.eu>
#
# 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, either version 3 of the License, or
# (at your option) any later version.
#
# 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 Affero 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/>.


"""
Check which libraries are provided or required by a package, store
this in a database, update and list broken packages.

Dependencies:

- Python 3.2 or later with SQLite 3 support

- ``bsdtar``

- ``readelf``
"""


import os.path
import re
import sqlite3
import subprocess
import tempfile


#: Regexp matching an interesting dynamic entry.
_DYNAMIC = re.compile(r"^\s*[0-9a-fx]+"
                      "\s*\((NEEDED|SONAME)\)[^:]*:\s*\[(.+)\]$")


def make_db(path):
	"""Make a new, empty, library database at *path*."""
	con = sqlite3.connect(path)
	con.executescript("""
create table provided(
  library varchar not null,
  package varchar not null
);
create table used(
  library varchar not null,
  package varchar not null
);
""")
	con.close()


def begin(database):
	"""Connect to *database* and start a transaction."""
	con = sqlite3.connect(database)
	con.execute("begin exclusive")
	return con


def add_provided(con, package, libraries):
	"""Write that *package* provides *libraries*."""
	for library in libraries:
		con.execute("insert into provided (package, library) values (?,?)",
		            (package, library))


def add_used(con, package, libraries):
	"""Write that *package* uses *libraries*."""
	for library in libraries:
		con.execute("insert into used (package, library) values (?,?)",
		            (package, library))


def remove_package(con, package):
	"""Remove all entries for a package."""
	con.execute("delete from provided where package=?", (package,))
	con.execute("delete from used where package=?", (package,))


def add_package(con, package):
	"""Add entries from a named *package*."""
	# Extract to a temporary directory.  This could be done more
	# efficiently, since there is no need to store more than one file
	# at once.
	print("adding package:", package)
	with tempfile.TemporaryDirectory(None, "db-check-package-libraries."+os.path.basename(package)+".") as temp:
		subprocess.Popen(("bsdtar", "xf", package, "-C", temp)).communicate()
		subprocess.Popen(('find', temp,
		                  '-type', 'd',
		                  '(', '-not', '-readable', '-o', '-not', '-executable', ')',
		                  '-exec', 'chmod', '755', '--', '{}', ';')).communicate()
		subprocess.Popen(('find', temp,
		                  '-type', 'f',
		                  '-not', '-readable',
		                  '-exec', 'chmod', '644', '--', '{}', ';')).communicate()
		with open(os.path.join(temp, ".PKGINFO")) as pkginfo:
			for line in pkginfo:
				if line.startswith("pkgname ="):
					pkgname = line[len("pkgname ="):].strip()
					break
		# Don't list previously removed libraries.
		remove_package(con, pkgname)
		provided = set()
		used = set()
		# Search for ELFs.
		for dirname, dirnames, filenames in os.walk(temp):
			assert dirnames is not None  # unused, avoid pylint warning
			for file_name in filenames:
				path = os.path.join(dirname, file_name)
				if os.path.islink(path) or not os.path.isfile(path):
					continue
				with open(path, "rb") as file_object:
					if file_object.read(4) != b"\177ELF":
						continue
				readelf = subprocess.Popen(("readelf", "-d", path),
				                           stdout=subprocess.PIPE)
				for line in readelf.communicate()[0].split(b"\n"):
					match = _DYNAMIC.match(line.decode("ascii"))
					if match:
						if match.group(1) == "SONAME":
							provided.add(match.group(2))
						elif match.group(1) == "NEEDED":
							used.add(match.group(2))
						else:
							raise AssertionError("unknown entry type "
							                     + match.group(1))
		add_provided(con, pkgname, provided)
		add_used(con, pkgname, used)


def init(arguments):
	"""Initialize."""
	make_db(arguments.database)


def add(arguments):
	"""Add packages."""
	con = begin(arguments.database)
	for package in arguments.packages:
		add_package(con, package)
	con.commit()
	con.close()


def remove(arguments):
	"""Remove packages."""
	con = begin(arguments.database)
	for package in arguments.packages:
		remove_package(con, package)
	con.commit()
	con.close()


def check(arguments):
	"""List broken packages."""
	con = begin(arguments.database)
	available = set(row[0] for row
	                in con.execute("select library from provided"))
	for package, library in con.execute("select package, library from used"):
		if library not in available:
			print(package, "needs", library)
	con.close()


def main():
	"""Get arguments and run the command."""
	from argparse import ArgumentParser
	parser = ArgumentParser(prog="db-check-package-libraries",
	                        description="Check packages for "
	                        "provided/needed libraries")
	parser.add_argument("-d", "--database", type=str,
	                    help="Database file to use",
	                    default="package-libraries.sqlite")
	subparsers = parser.add_subparsers()
	subparser = subparsers.add_parser(name="init",
	                                  help="initialize the database")
	subparser.set_defaults(command=init)
	subparser = subparsers.add_parser(name="add",
	                                  help="add packages to database")
	subparser.add_argument("packages", nargs="+", type=str,
	                       help="package files to add")
	subparser.set_defaults(command=add)
	subparser = subparsers.add_parser(name="remove",
	                                  help="remove packages from database")
	subparser.add_argument("packages", nargs="+", type=str,
	                       help="package names to remove")
	subparser.set_defaults(command=remove)
	subparser = subparsers.add_parser(name="check",
	                                  help="list broken packages")
	subparser.set_defaults(command=check)
	arguments = parser.parse_args()
	arguments.command(arguments)


if __name__ == "__main__":
	main()
