diff --git a/code/client/munkilib/installer/core.py b/code/client/munkilib/installer/core.py old mode 100644 new mode 100755 index be25eda6..d668ac06 --- a/code/client/munkilib/installer/core.py +++ b/code/client/munkilib/installer/core.py @@ -50,7 +50,7 @@ from .. import scriptutils from .. import FoundationPlist from ..updatecheck import catalogs -from ..removepackages import removepackages +from ..removepkgslib import removepackages # initialize our report fields diff --git a/code/client/munkilib/removepackages.py b/code/client/munkilib/removepackages.py deleted file mode 100755 index 9937ed15..00000000 --- a/code/client/munkilib/removepackages.py +++ /dev/null @@ -1,1011 +0,0 @@ -#!/usr/bin/python -# encoding: utf-8 -# -# Copyright 2009-2016 Greg Neagle. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -""" -removepackages.py - -a tool to analyze installed packages and remove -files unique to the packages given at the command line. No attempt -is made to revert to older versions of a file when uninstalling; -only file removals are done. - -Callable directly from the command-line and as a python module. -""" - -import os -import optparse -import subprocess -import sqlite3 - -from . import display -from . import munkistatus -from . import osutils -from . import pkgutils -from . import prefs -from . import processes -from . import FoundationPlist - -# pylint: disable=invalid-name - -################################################################# -# our package db schema -- a subset of Apple's schema in Leopard -# -# CREATE TABLE paths (path_key INTEGER PRIMARY KEY AUTOINCREMENT, -# path VARCHAR NOT NULL UNIQUE ) -# CREATE TABLE pkgs (pkg_key INTEGER PRIMARY KEY AUTOINCREMENT, -# timestamp INTEGER NOT NULL, -# owner INTEGER NOT NULL, -# pkgid VARCHAR NOT NULL, -# vers VARCHAR NOT NULL, -# ppath VARCHAR NOT NULL, -# pkgname VARCHAR NOT NULL, -# replaces INTEGER ) -# CREATE TABLE pkgs_paths (pkg_key INTEGER NOT NULL, -# path_key INTEGER NOT NULL, -# uid INTEGER, -# gid INTEGER, -# perms INTEGER ) -################################################################# - - -def shouldRebuildDB(pkgdbpath): - """ - Checks to see if our internal package DB should be rebuilt. - If anything in /Library/Receipts, /Library/Receipts/boms, or - /Library/Receipts/db/a.receiptdb has a newer modtime than our - database, we should rebuild. - """ - receiptsdir = "/Library/Receipts" - bomsdir = "/Library/Receipts/boms" - sl_receiptsdir = "/private/var/db/receipts" - installhistory = "/Library/Receipts/InstallHistory.plist" - - if not os.path.exists(pkgdbpath): - return True - - packagedb_modtime = os.stat(pkgdbpath).st_mtime - - if os.path.exists(receiptsdir): - receiptsdir_modtime = os.stat(receiptsdir).st_mtime - if packagedb_modtime < receiptsdir_modtime: - return True - receiptlist = osutils.listdir(receiptsdir) - for item in receiptlist: - if item.endswith(".pkg"): - pkgpath = os.path.join(receiptsdir, item) - pkg_modtime = os.stat(pkgpath).st_mtime - if packagedb_modtime < pkg_modtime: - return True - - if os.path.exists(bomsdir): - bomsdir_modtime = os.stat(bomsdir).st_mtime - if packagedb_modtime < bomsdir_modtime: - return True - bomlist = osutils.listdir(bomsdir) - for item in bomlist: - if item.endswith(".bom"): - bompath = os.path.join(bomsdir, item) - bom_modtime = os.stat(bompath).st_mtime - if packagedb_modtime < bom_modtime: - return True - - if os.path.exists(sl_receiptsdir): - receiptsdir_modtime = os.stat(sl_receiptsdir).st_mtime - if packagedb_modtime < receiptsdir_modtime: - return True - receiptlist = osutils.listdir(sl_receiptsdir) - for item in receiptlist: - if item.endswith(".bom") or item.endswith(".plist"): - pkgpath = os.path.join(sl_receiptsdir, item) - pkg_modtime = os.stat(pkgpath).st_mtime - if packagedb_modtime < pkg_modtime: - return True - - if os.path.exists(installhistory): - installhistory_modtime = os.stat(installhistory).st_mtime - if packagedb_modtime < installhistory_modtime: - return True - - # if we got this far, we don't need to update the db - return False - - -def CreateTables(curs): - """ - Creates the tables needed for our internal package database. - """ - curs.execute('''CREATE TABLE paths - (path_key INTEGER PRIMARY KEY AUTOINCREMENT, - path VARCHAR NOT NULL UNIQUE )''') - curs.execute('''CREATE TABLE pkgs - (pkg_key INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp INTEGER NOT NULL, - owner INTEGER NOT NULL, - pkgid VARCHAR NOT NULL, - vers VARCHAR NOT NULL, - ppath VARCHAR NOT NULL, - pkgname VARCHAR NOT NULL, - replaces INTEGER )''') - curs.execute('''CREATE TABLE pkgs_paths - (pkg_key INTEGER NOT NULL, - path_key INTEGER NOT NULL, - uid INTEGER, - gid INTEGER, - perms INTEGER )''') - - -def findBundleReceiptFromID(pkgid): - '''Finds a bundle receipt in /Library/Receipts based on packageid. - Some packages write bundle receipts under /Library/Receipts even on - Snow Leopard; we need to be able to find them so we can remove them. - Returns a path.''' - if not pkgid: - return '' - receiptsdir = "/Library/Receipts" - if os.path.isdir(receiptsdir): - for item in osutils.listdir(receiptsdir): - itempath = os.path.join(receiptsdir, item) - if item.endswith('.pkg') and os.path.isdir(itempath): - info = pkgutils.getOnePackageInfo(itempath) - if info.get('packageid') == pkgid: - return itempath - - #if we get here, not found - return '' - - -def ImportPackage(packagepath, curs): - """ - Imports package data from the receipt at packagepath into - our internal package database. - """ - - bompath = os.path.join(packagepath, 'Contents/Archive.bom') - infopath = os.path.join(packagepath, 'Contents/Info.plist') - pkgname = os.path.basename(packagepath) - - if not os.path.exists(packagepath): - display.display_error("%s not found.", packagepath) - return - - if not os.path.isdir(packagepath): - # Every machine I've seen has a bogus BSD.pkg, - # so we won't print a warning for that specific one. - if pkgname != "BSD.pkg": - display.display_warning( - "%s is not a valid receipt. Skipping.", packagepath) - return - - if not os.path.exists(bompath): - # look in receipt's Resources directory - bomname = os.path.splitext(pkgname)[0] + '.bom' - bompath = os.path.join( - packagepath, "Contents/Resources", bomname) - if not os.path.exists(bompath): - display.display_warning( - "%s has no BOM file. Skipping.", packagepath) - return - - if not os.path.exists(infopath): - display.display_warning( - "%s has no Info.plist. Skipping.", packagepath) - return - - timestamp = os.stat(packagepath).st_mtime - owner = 0 - plist = FoundationPlist.readPlist(infopath) - if "CFBundleIdentifier" in plist: - pkgid = plist["CFBundleIdentifier"] - elif "Bundle identifier" in plist: - # special case for JAMF Composer generated packages. WTF? - pkgid = plist["Bundle identifier"] - else: - pkgid = pkgname - if "CFBundleShortVersionString" in plist: - vers = plist["CFBundleShortVersionString"] - elif "Bundle versions string, short" in plist: - # another special case for JAMF Composer-generated packages. Wow. - vers = plist["Bundle versions string, short"] - else: - vers = "1.0" - if "IFPkgRelocatedPath" in plist: - ppath = plist["IFPkgRelocatedPath"] - ppath = ppath.lstrip('./').rstrip('/') - else: - ppath = "" - - values_t = (timestamp, owner, pkgid, vers, ppath, pkgname) - curs.execute( - '''INSERT INTO pkgs (timestamp, owner, pkgid, vers, ppath, pkgname) - values (?, ?, ?, ?, ?, ?)''', values_t) - pkgkey = curs.lastrowid - - cmd = ["/usr/bin/lsbom", bompath] - proc = subprocess.Popen(cmd, shell=False, bufsize=-1, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - - while True: - line = proc.stdout.readline().decode('UTF-8') - if not line and (proc.poll() != None): - break - - try: - item = line.rstrip("\n").split("\t") - path = item[0] - perms = item[1] - uidgid = item[2].split("/") - uid = uidgid[0] - gid = uidgid[1] - except IndexError: - # we really only care about the path - perms = "0000" - uid = "0" - gid = "0" - - try: - if path != ".": - # special case for MS Office 2008 installers - if ppath == "tmp/com.microsoft.updater/office_location": - ppath = "Applications" - - # prepend the ppath so the paths match the actual install - # locations - path = path.lstrip("./") - if ppath: - path = ppath + "/" + path - - values_t = (path, ) - row = curs.execute( - 'SELECT path_key from paths where path = ?', - values_t).fetchone() - if not row: - curs.execute( - 'INSERT INTO paths (path) values (?)', values_t) - pathkey = curs.lastrowid - else: - pathkey = row[0] - - values_t = (pkgkey, pathkey, uid, gid, perms) - curs.execute( - 'INSERT INTO pkgs_paths (pkg_key, path_key, uid, gid, ' - 'perms) values (?, ?, ?, ?, ?)', values_t) - except sqlite3.DatabaseError: - pass - - -def ImportBom(bompath, curs): - """ - Imports package data into our internal package database - using a combination of the bom file and data in Apple's - package database into our internal package database. - """ - # If we completely trusted the accuracy of Apple's database, we wouldn't - # need the bom files, but in my environment at least, the bom files are - # a better indicator of what flat packages have actually been installed - # on the current machine. - # We still need to consult Apple's package database - # because the bom files are missing metadata about the package. - - pkgname = os.path.basename(bompath) - - timestamp = os.stat(bompath).st_mtime - owner = 0 - pkgid = os.path.splitext(pkgname)[0] - vers = "1.0" - ppath = "" - - # try to get metadata from pkginfo db - proc = subprocess.Popen(["/usr/sbin/pkgutil", "--pkg-info-plist", pkgid], - bufsize=-1, stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - (pliststr, dummy_err) = proc.communicate() - if pliststr: - plist = FoundationPlist.readPlistFromString(pliststr) - if "install-location" in plist: - ppath = plist["install-location"] - ppath = ppath.lstrip('./').rstrip('/') - if "pkg-version" in plist: - vers = plist["pkg-version"] - if "install-time" in plist: - timestamp = plist["install-time"] - - values_t = (timestamp, owner, pkgid, vers, ppath, pkgname) - curs.execute( - '''INSERT INTO pkgs (timestamp, owner, pkgid, vers, ppath, pkgname) - values (?, ?, ?, ?, ?, ?)''', values_t) - pkgkey = curs.lastrowid - - cmd = ["/usr/bin/lsbom", bompath] - proc = subprocess.Popen(cmd, shell=False, bufsize=-1, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - - while True: - line = proc.stdout.readline().decode('UTF-8') - if not line and (proc.poll() != None): - break - try: - item = line.rstrip("\n").split("\t") - path = item[0] - perms = item[1] - uidgid = item[2].split("/") - uid = uidgid[0] - gid = uidgid[1] - except IndexError: - # we really only care about the path - perms = "0000" - uid = "0" - gid = "0" - - if path != ".": - # special case for MS Office 2008 installers - if ppath == "tmp/com.microsoft.updater/office_location": - ppath = "Applications" - - #prepend the ppath so the paths match the actual install locations - path = path.lstrip("./") - if ppath: - path = ppath + "/" + path - - values_t = (path, ) - row = curs.execute( - 'SELECT path_key from paths where path = ?', - values_t).fetchone() - if not row: - curs.execute( - 'INSERT INTO paths (path) values (?)', values_t) - pathkey = curs.lastrowid - else: - pathkey = row[0] - - values_t = (pkgkey, pathkey, uid, gid, perms) - curs.execute( - 'INSERT INTO pkgs_paths (pkg_key, path_key, uid, gid, perms) ' - 'values (?, ?, ?, ?, ?)', values_t) - - -def ImportFromPkgutil(pkgname, curs): - """ - Imports package data from pkgutil into our internal package database. - """ - - timestamp = 0 - owner = 0 - pkgid = pkgname - vers = "1.0" - ppath = "" - - #get metadata from pkginfo db - proc = subprocess.Popen(["/usr/sbin/pkgutil", "--pkg-info-plist", pkgid], - bufsize=-1, stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - (pliststr, dummy_err) = proc.communicate() - if pliststr: - plist = FoundationPlist.readPlistFromString(pliststr) - if "pkg-version" in plist: - vers = plist["pkg-version"] - if "install-time" in plist: - timestamp = plist["install-time"] - if "install-location" in plist: - ppath = plist["install-location"] - ppath = ppath.lstrip('./').rstrip('/') - else: - # there _should_ be an install-location. If there's not, let's - # check the old /Library/Receipts. - # (Workaround for QuarkXPress 8.1 packages) - receiptpath = findBundleReceiptFromID(pkgid) - if receiptpath: - infopath = os.path.join(receiptpath, 'Contents/Info.plist') - if os.path.exists(infopath): - infopl = FoundationPlist.readPlist(infopath) - if "IFPkgRelocatedPath" in infopl: - ppath = infopl["IFPkgRelocatedPath"] - ppath = ppath.lstrip('./').rstrip('/') - - values_t = (timestamp, owner, pkgid, vers, ppath, pkgname) - curs.execute( - '''INSERT INTO pkgs (timestamp, owner, pkgid, vers, ppath, pkgname) - values (?, ?, ?, ?, ?, ?)''', values_t) - pkgkey = curs.lastrowid - - cmd = ["/usr/sbin/pkgutil", "--files", pkgid] - proc = subprocess.Popen(cmd, shell=False, bufsize=-1, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - - while True: - line = proc.stdout.readline().decode('UTF-8') - if not line and (proc.poll() != None): - break - path = line.rstrip("\n") - - # pkgutil --files pkgid only gives us path info. We don't - # really need perms, uid and gid, so we'll just fake them. - # if we needed them, we'd have to call - # pkgutil --export-plist pkgid and iterate through the - # plist. That would be slower, so we'll do things this way... - perms = "0000" - uid = "0" - gid = "0" - if path != ".": - # special case for MS Office 2008 installers - # /tmp/com.microsoft.updater/office_location - if ppath == "tmp/com.microsoft.updater/office_location": - ppath = "Applications" - # another special case for Office 2011 updaters - if ppath.startswith( - 'tmp/com.microsoft.office.updater/com.microsoft.office.'): - ppath = "" - #prepend the ppath so the paths match the actual install locations - path = path.lstrip("./") - if ppath: - path = ppath + "/" + path - - values_t = (path, ) - row = curs.execute( - 'SELECT path_key from paths where path = ?', - values_t).fetchone() - if not row: - curs.execute( - 'INSERT INTO paths (path) values (?)', values_t) - pathkey = curs.lastrowid - else: - pathkey = row[0] - - values_t = (pkgkey, pathkey, uid, gid, perms) - curs.execute( - 'INSERT INTO pkgs_paths (pkg_key, path_key, uid, gid, perms) ' - 'values (?, ?, ?, ?, ?)', values_t) - - -def initDatabase(forcerebuild=False): - """ - Builds or rebuilds our internal package database. - """ - if not shouldRebuildDB(packagedb) and not forcerebuild: - return True - - display.display_status_minor( - 'Gathering information on installed packages') - - if os.path.exists(packagedb): - try: - os.remove(packagedb) - except (OSError, IOError): - display.display_error( - "Could not remove out-of-date receipt database.") - return False - - pkgcount = 0 - receiptsdir = "/Library/Receipts" - bomsdir = "/Library/Receipts/boms" - if os.path.exists(receiptsdir): - receiptlist = osutils.listdir(receiptsdir) - for item in receiptlist: - if item.endswith(".pkg"): - pkgcount += 1 - if os.path.exists(bomsdir): - bomslist = osutils.listdir(bomsdir) - for item in bomslist: - if item.endswith(".bom"): - pkgcount += 1 - - pkglist = [] - cmd = ['/usr/sbin/pkgutil', '--pkgs'] - proc = subprocess.Popen(cmd, shell=False, bufsize=-1, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - while True: - line = proc.stdout.readline() - if not line and (proc.poll() != None): - break - - pkglist.append(line.rstrip('\n')) - pkgcount += 1 - - conn = sqlite3.connect(packagedb) - conn.text_factory = str - curs = conn.cursor() - CreateTables(curs) - - currentpkgindex = 0 - display.display_percent_done(0, pkgcount) - - if os.path.exists(receiptsdir): - receiptlist = osutils.listdir(receiptsdir) - for item in receiptlist: - if processes.stop_requested(): - curs.close() - conn.close() - #our package db isn't valid, so we should delete it - os.remove(packagedb) - return False - - if item.endswith(".pkg"): - receiptpath = os.path.join(receiptsdir, item) - display.display_detail("Importing %s...", receiptpath) - ImportPackage(receiptpath, curs) - currentpkgindex += 1 - display.display_percent_done(currentpkgindex, pkgcount) - - if os.path.exists(bomsdir): - bomslist = osutils.listdir(bomsdir) - for item in bomslist: - if processes.stop_requested(): - curs.close() - conn.close() - #our package db isn't valid, so we should delete it - os.remove(packagedb) - return False - - if item.endswith(".bom"): - bompath = os.path.join(bomsdir, item) - display.display_detail("Importing %s...", bompath) - ImportBom(bompath, curs) - currentpkgindex += 1 - display.display_percent_done(currentpkgindex, pkgcount) - - for pkg in pkglist: - if processes.stop_requested(): - curs.close() - conn.close() - #our package db isn't valid, so we should delete it - os.remove(packagedb) - return False - - display.display_detail("Importing %s...", pkg) - ImportFromPkgutil(pkg, curs) - currentpkgindex += 1 - display.display_percent_done(currentpkgindex, pkgcount) - - # in case we didn't quite get to 100% for some reason - if currentpkgindex < pkgcount: - display.display_percent_done(pkgcount, pkgcount) - - # commit and close the db when we're done. - conn.commit() - curs.close() - conn.close() - return True - - -def getpkgkeys(pkgnames): - """ - Given a list of receipt names, bom file names, or package ids, - gets a list of pkg_keys from the pkgs table in our database. - """ - # open connection and cursor to our database - conn = sqlite3.connect(packagedb) - curs = conn.cursor() - - # check package names to make sure they're all in the database, - # build our list of pkg_keys - pkgerror = False - pkgkeyslist = [] - for pkg in pkgnames: - values_t = (pkg, ) - display.display_debug1( - "select pkg_key from pkgs where pkgid = %s", pkg) - pkg_keys = curs.execute( - 'select pkg_key from pkgs where pkgid = ?', values_t).fetchall() - if not pkg_keys: - # try pkgname - display.display_debug1( - "select pkg_key from pkgs where pkgname = %s", pkg) - pkg_keys = curs.execute( - 'select pkg_key from pkgs where pkgname = ?', - values_t).fetchall() - if not pkg_keys: - display.display_error("%s not found in database.", pkg) - pkgerror = True - else: - for row in pkg_keys: - # only want first column - pkgkeyslist.append(row[0]) - if pkgerror: - pkgkeyslist = [] - - curs.close() - conn.close() - display.display_debug1("pkgkeys: %s", pkgkeyslist) - return pkgkeyslist - - -def getpathstoremove(pkgkeylist): - """ - Queries our database for paths to remove. - """ - pkgkeys = tuple(pkgkeylist) - - # open connection and cursor to our database - conn = sqlite3.connect(packagedb) - curs = conn.cursor() - - # set up some subqueries: - # all the paths that are referred to by the selected packages: - if len(pkgkeys) > 1: - in_selected_packages = \ - "select distinct path_key from pkgs_paths where pkg_key in %s" % \ - str(pkgkeys) - else: - in_selected_packages = \ - "select distinct path_key from pkgs_paths where pkg_key = %s" % \ - str(pkgkeys[0]) - - # all the paths that are referred to by every package - # except the selected packages: - if len(pkgkeys) > 1: - not_in_other_packages = \ - "select distinct path_key from pkgs_paths where pkg_key not in %s" % \ - str(pkgkeys) - else: - not_in_other_packages = \ - "select distinct path_key from pkgs_paths where pkg_key != %s" % \ - str(pkgkeys[0]) - - # every path that is used by the selected packages and no other packages: - combined_query = \ - "select path from paths where " + \ - "(path_key in (%s) and path_key not in (%s))" % \ - (in_selected_packages, not_in_other_packages) - - display.display_status_minor( - 'Determining which filesystem items to remove') - munkistatus.percent(-1) - - curs.execute(combined_query) - results = curs.fetchall() - curs.close() - conn.close() - - removalpaths = [] - for item in results: - removalpaths.append(item[0]) - - return removalpaths - - -def removeReceipts(pkgkeylist, noupdateapplepkgdb): - """ - Removes receipt data from /Library/Receipts, - /Library/Receipts/boms, our internal package database, - and optionally Apple's package database. - """ - display.display_status_minor('Removing receipt info') - display.display_percent_done(0, 4) - - os_version = osutils.getOsVersion(as_tuple=True) - conn = sqlite3.connect(packagedb) - curs = conn.cursor() - - display.display_percent_done(1, 4) - - for pkgkey in pkgkeylist: - pkgid = '' - pkgkey_t = (pkgkey, ) - row = curs.execute( - 'SELECT pkgname, pkgid from pkgs where pkg_key = ?', - pkgkey_t).fetchone() - if row: - pkgname = row[0] - pkgid = row[1] - receiptpath = None - if os_version <= (10, 5): - if pkgname.endswith('.pkg'): - receiptpath = os.path.join('/Library/Receipts', pkgname) - if pkgname.endswith('.bom'): - receiptpath = os.path.join( - '/Library/Receipts/boms', pkgname) - else: - # clean up /Library/Receipts in case there's stuff left there - receiptpath = findBundleReceiptFromID(pkgid) - - if receiptpath and os.path.exists(receiptpath): - display.display_detail("Removing %s...", receiptpath) - dummy_retcode = subprocess.call( - ["/bin/rm", "-rf", receiptpath]) - - # remove pkg info from our database - display.display_detail( - "Removing package data from internal database...") - curs.execute('DELETE FROM pkgs_paths where pkg_key = ?', pkgkey_t) - curs.execute('DELETE FROM pkgs where pkg_key = ?', pkgkey_t) - - # then remove pkg info from Apple's database unless option is passed - if not noupdateapplepkgdb and pkgid: - cmd = ['/usr/sbin/pkgutil', '--forget', pkgid] - proc = subprocess.Popen(cmd, bufsize=-1, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - (output, dummy_err) = proc.communicate() - if output: - display.display_detail( - str(output).decode('UTF-8').rstrip('\n')) - - display.display_percent_done(2, 4) - - # now remove orphaned paths from paths table - display.display_percent_done(3, 4) - - # we do our database last so its modtime is later than the modtime for the - # Apple DB... - display.display_detail( - "Removing unused paths from internal package database...") - curs.execute( - '''DELETE FROM paths where path_key not in - (select distinct path_key from pkgs_paths)''') - conn.commit() - curs.close() - conn.close() - - display.display_percent_done(4, 4) - - -def isBundle(pathname): - """ - Returns true if pathname is a bundle-style directory. - """ - bundle_extensions = [".action", - ".app", - ".bundle", - ".clr", - ".colorPicker", - ".component", - ".dictionary", - ".docset", - ".framework", - ".fs", - ".kext", - ".loginPlugin", - ".mdiimporter", - ".monitorPanel", - ".osax", - ".pkg", - ".plugin", - ".prefPane", - ".qlgenerator", - ".saver", - ".service", - ".slideSaver", - ".SpeechRecognizer", - ".SpeechSynthesizer", - ".SpeechVoice", - ".spreporter", - ".wdgt"] - if os.path.isdir(pathname): - basename = os.path.basename(pathname) - extension = os.path.splitext(basename)[1] - if extension in bundle_extensions: - return True - else: - return False - else: - return False - - -def insideBundle(pathname): - '''Check the path to see if it's inside a bundle.''' - while len(pathname) > 1: - if isBundle(pathname): - return True - else: - # chop off last item in path - pathname = os.path.dirname(pathname) - #if we get here, we didn't find a bundle path - return False - - -def removeFilesystemItems(removalpaths, forcedeletebundles): - """ - Attempts to remove all the paths in the array removalpaths - """ - # we sort in reverse because we can delete from the bottom up, - # clearing a directory before we try to remove the directory itself - removalpaths.sort(reverse=True) - removalerrors = "" - removalcount = len(removalpaths) - display.display_status_minor( - 'Removing %s filesystem items' % removalcount) - - itemcount = len(removalpaths) - itemindex = 0 - display.display_percent_done(itemindex, itemcount) - - for item in removalpaths: - itemindex += 1 - pathtoremove = "/" + item - # use os.path.lexists so broken links return true - # so we can remove them - if os.path.lexists(pathtoremove): - display.display_detail("Removing: " + pathtoremove) - if (os.path.isdir(pathtoremove) and - not os.path.islink(pathtoremove)): - diritems = osutils.listdir(pathtoremove) - if diritems == ['.DS_Store']: - # If there's only a .DS_Store file - # we'll consider it empty - ds_storepath = pathtoremove + "/.DS_Store" - try: - os.remove(ds_storepath) - except (OSError, IOError): - pass - diritems = osutils.listdir(pathtoremove) - if diritems == []: - # directory is empty - try: - os.rmdir(pathtoremove) - except (OSError, IOError), err: - msg = "Couldn't remove directory %s - %s" % ( - pathtoremove, err) - display.display_error(msg) - removalerrors = removalerrors + "\n" + msg - else: - # the directory is marked for deletion but isn't empty. - # if so directed, if it's a bundle (like .app), we should - # remove it anyway - no use having a broken bundle hanging - # around - if forcedeletebundles and isBundle(pathtoremove): - display.display_warning( - "Removing non-empty bundle: %s", pathtoremove) - retcode = subprocess.call(['/bin/rm', '-r', - pathtoremove]) - if retcode: - msg = "Couldn't remove bundle %s" % pathtoremove - display.display_error(msg) - removalerrors = removalerrors + "\n" + msg - else: - # if this path is inside a bundle, and we've been - # directed to force remove bundles, - # we don't need to warn because it's going to be - # removed with the bundle. - # Otherwise, we should warn about non-empty - # directories. - if not insideBundle(pathtoremove) or \ - not forcedeletebundles: - msg = \ - "Did not remove %s because it is not empty." % \ - pathtoremove - display.display_error(msg) - removalerrors = removalerrors + "\n" + msg - - else: - # not a directory, just unlink it - try: - os.remove(pathtoremove) - except (OSError, IOError), err: - msg = "Couldn't remove item %s: %s" % (pathtoremove, err) - display.display_error(msg) - removalerrors = removalerrors + "\n" + msg - - display.display_percent_done(itemindex, itemcount) - - if removalerrors: - display.display_info( - "---------------------------------------------------") - display.display_info( - "There were problems removing some filesystem items.") - display.display_info( - "---------------------------------------------------") - display.display_info(removalerrors) - - -def removepackages(pkgnames, forcedeletebundles=False, listfiles=False, - rebuildpkgdb=False, noremovereceipts=False, - noupdateapplepkgdb=False): - """ - Our main function, called by installer.py to remove items based on - receipt info. - """ - if pkgnames == []: - display.display_error( - "You must specify at least one package to remove!") - return -2 - - if not initDatabase(forcerebuild=rebuildpkgdb): - display.display_error("Could not initialize receipt database.") - return -3 - - pkgkeyslist = getpkgkeys(pkgnames) - if len(pkgkeyslist) == 0: - return -4 - - if processes.stop_requested(): - return -128 - removalpaths = getpathstoremove(pkgkeyslist) - if processes.stop_requested(): - return -128 - - if removalpaths: - if listfiles: - removalpaths.sort() - for item in removalpaths: - print "/" + item.encode('UTF-8') - else: - munkistatus.disableStopButton() - removeFilesystemItems(removalpaths, forcedeletebundles) - else: - display.display_status_minor('Nothing to remove.') - - if not listfiles: - if not noremovereceipts: - removeReceipts(pkgkeyslist, noupdateapplepkgdb) - munkistatus.enableStopButton() - display.display_status_minor('Package removal complete.') - - return 0 - - -# some globals -packagedb = os.path.join(prefs.pref('ManagedInstallDir'), "b.receiptdb") - -def main(): - '''Used when calling removepackages.py directly from the command line.''' - # command-line options - parser = optparse.OptionParser() - parser.set_usage('''Usage: %prog [options] package_id ...''') - parser.add_option('--forcedeletebundles', '-f', action='store_true', - help='Delete bundles even if they aren\'t empty.') - parser.add_option('--listfiles', '-l', action='store_true', - help='List the filesystem objects to be removed, ' - 'but do not actually remove them.') - parser.add_option('--rebuildpkgdb', action='store_true', - help='Force a rebuild of the internal package database.') - parser.add_option('--noremovereceipts', action='store_true', - help='''Do not remove receipts and boms from - /Library/Receipts and update internal package - database.''') - parser.add_option('--noupdateapplepkgdb', action='store_true', - help='Do not update Apple\'s package database. ' - 'If --noremovereceipts is also given, this is implied') - parser.add_option('--munkistatusoutput', '-m', action='store_true', - help='Output is formatted for use with MunkiStatus.') - parser.add_option('--verbose', '-v', action='count', default=1, - help='More verbose output. May be specified multiple ' - 'times.') - - # Get our options and our package names - options, pkgnames = parser.parse_args() - - # check to see if we're root - if os.geteuid() != 0: - display.display_error("You must run this as root!") - exit(-1) - - # set the display globals - display.munkistatusoutput = options.munkistatusoutput - display.verbose = options.verbose - - if options.munkistatusoutput: - pkgcount = len(pkgnames) - munkistatus.message("Removing %s packages..." % pkgcount) - munkistatus.detail("") - - retcode = removepackages(pkgnames, - forcedeletebundles=options.forcedeletebundles, - listfiles=options.listfiles, - rebuildpkgdb=options.rebuildpkgdb, - noremovereceipts=options.noremovereceipts, - noupdateapplepkgdb=options.noupdateapplepkgdb) - if options.munkistatusoutput: - munkistatus.quit() - exit(retcode) - - -if __name__ == '__main__': - main() diff --git a/code/client/munkilib/removepackages.py b/code/client/munkilib/removepackages.py new file mode 120000 index 00000000..2f4a2d34 --- /dev/null +++ b/code/client/munkilib/removepackages.py @@ -0,0 +1 @@ +../removepackages \ No newline at end of file diff --git a/code/client/munkilib/removepkgslib.py b/code/client/munkilib/removepkgslib.py new file mode 100755 index 00000000..35f3e895 --- /dev/null +++ b/code/client/munkilib/removepkgslib.py @@ -0,0 +1,842 @@ +#!/usr/bin/python +# encoding: utf-8 +# +# Copyright 2009-2016 Greg Neagle. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +removepkgslib + +Code to analyze installed packages and remove +files unique to the packages given. No attempt +is made to revert to older versions of a file when uninstalling; +only file removals are done. + +""" + +import os +import subprocess +import sqlite3 + +from . import display +from . import munkistatus +from . import osutils +from . import pkgutils +from . import prefs +from . import processes +from . import FoundationPlist + + +################################################################# +# our package db schema -- a subset of Apple's schema in Leopard +# +# CREATE TABLE paths (path_key INTEGER PRIMARY KEY AUTOINCREMENT, +# path VARCHAR NOT NULL UNIQUE ) +# CREATE TABLE pkgs (pkg_key INTEGER PRIMARY KEY AUTOINCREMENT, +# timestamp INTEGER NOT NULL, +# owner INTEGER NOT NULL, +# pkgid VARCHAR NOT NULL, +# vers VARCHAR NOT NULL, +# ppath VARCHAR NOT NULL, +# pkgname VARCHAR NOT NULL, +# replaces INTEGER ) +# CREATE TABLE pkgs_paths (pkg_key INTEGER NOT NULL, +# path_key INTEGER NOT NULL, +# uid INTEGER, +# gid INTEGER, +# perms INTEGER ) +################################################################# + + +def should_rebuild_db(pkgdbpath): + """ + Checks to see if our internal package DB should be rebuilt. + If anything in /Library/Receipts, /Library/Receipts/boms, or + /Library/Receipts/db/a.receiptdb has a newer modtime than our + database, we should rebuild. + """ + def items_newer_than_pkgdb(directory, file_extensions): + '''Return true if the directory or files inside the directory + with the given file extensions are newer than the pkgdb''' + if os.path.exists(directory): + dir_modtime = os.stat(directory).st_mtime + # was directory modified after packagedb? + if dir_modtime > packagedb_modtime: + return True + for item in osutils.listdir(directory): + if item.endswith(file_extensions): + filepath = os.path.join(directory, item) + file_modtime = os.stat(filepath).st_mtime + # was file modified after packagedb? + if file_modtime > packagedb_modtime: + return True + return False + + if not os.path.exists(pkgdbpath): + return True + + packagedb_modtime = os.stat(pkgdbpath).st_mtime + + if items_newer_than_pkgdb('/Library/Receipts', '.pkg'): + return True + if items_newer_than_pkgdb('/Library/Receipts/boms', '.bom'): + return True + if items_newer_than_pkgdb('/private/var/db/receipts', ('.bom', '.plist')): + return True + + installhistory = '/Library/Receipts/InstallHistory.plist' + if os.path.exists(installhistory): + installhistory_modtime = os.stat(installhistory).st_mtime + if packagedb_modtime < installhistory_modtime: + return True + + # if we got this far, we don't need to update the db + return False + + +def create_tables(curs): + """ + Creates the tables needed for our internal package database. + """ + curs.execute('''CREATE TABLE paths + (path_key INTEGER PRIMARY KEY AUTOINCREMENT, + path VARCHAR NOT NULL UNIQUE )''') + curs.execute('''CREATE TABLE pkgs + (pkg_key INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + owner INTEGER NOT NULL, + pkgid VARCHAR NOT NULL, + vers VARCHAR NOT NULL, + ppath VARCHAR NOT NULL, + pkgname VARCHAR NOT NULL, + replaces INTEGER )''') + curs.execute('''CREATE TABLE pkgs_paths + (pkg_key INTEGER NOT NULL, + path_key INTEGER NOT NULL, + uid INTEGER, + gid INTEGER, + perms INTEGER )''') + + +def find_bundle_receipt(pkgid): + '''Finds a bundle receipt in /Library/Receipts based on packageid. + Some packages write bundle receipts under /Library/Receipts even on + Snow Leopard; we need to be able to find them so we can remove them. + Returns a path.''' + if not pkgid: + return '' + receiptsdir = "/Library/Receipts" + if os.path.isdir(receiptsdir): + for item in osutils.listdir(receiptsdir): + itempath = os.path.join(receiptsdir, item) + if item.endswith('.pkg') and os.path.isdir(itempath): + info = pkgutils.getOnePackageInfo(itempath) + if info.get('packageid') == pkgid: + return itempath + + #if we get here, not found + return '' + + +def insert_bomvalues_into_pkgdb(bom_line, pkgkey, ppath, curs): + '''Parses line from lsbom or pkgutil --files and inserts the paths into + our pkgdb''' + try: + item = bom_line.rstrip("\n").split("\t") + path = item[0] + perms = item[1] + uidgid = item[2].split("/") + uid = uidgid[0] + gid = uidgid[1] + except IndexError: + # we really only care about the path + perms = "0000" + uid = "0" + gid = "0" + + try: + if path != ".": + # special case for MS Office 2008 installers + if ppath == "tmp/com.microsoft.updater/office_location": + ppath = "Applications" + + # prepend the ppath so the paths match the actual install locations + path = path.lstrip("./") + if ppath: + path = ppath + "/" + path + + values_t = (path, ) + row = curs.execute( + 'SELECT path_key from paths where path = ?', + values_t).fetchone() + if not row: + curs.execute( + 'INSERT INTO paths (path) values (?)', values_t) + pathkey = curs.lastrowid + else: + pathkey = row[0] + + values_t = (pkgkey, pathkey, uid, gid, perms) + curs.execute( + 'INSERT INTO pkgs_paths (pkg_key, path_key, uid, gid, ' + 'perms) values (?, ?, ?, ?, ?)', values_t) + except sqlite3.DatabaseError: + pass + + +def import_package(packagepath, curs): + """ + Imports package data from the receipt at packagepath into + our internal package database. + """ + + bompath = os.path.join(packagepath, 'Contents/Archive.bom') + infopath = os.path.join(packagepath, 'Contents/Info.plist') + pkgname = os.path.basename(packagepath) + + if not os.path.exists(packagepath): + display.display_error("%s not found.", packagepath) + return + + if not os.path.isdir(packagepath): + # Every machine I've seen has a bogus BSD.pkg, + # so we won't print a warning for that specific one. + if pkgname != "BSD.pkg": + display.display_warning( + "%s is not a valid receipt. Skipping.", packagepath) + return + + if not os.path.exists(bompath): + # look in receipt's Resources directory + bomname = os.path.splitext(pkgname)[0] + '.bom' + bompath = os.path.join( + packagepath, "Contents/Resources", bomname) + if not os.path.exists(bompath): + display.display_warning( + "%s has no BOM file. Skipping.", packagepath) + return + + if not os.path.exists(infopath): + display.display_warning( + "%s has no Info.plist. Skipping.", packagepath) + return + + timestamp = os.stat(packagepath).st_mtime + owner = 0 + plist = FoundationPlist.readPlist(infopath) + # 'Bundle identifier' is a weird Casper Composer thing + pkgid = plist.get('CFBundleIdentifier', + plist.get('Bundle identifier', pkgname)) + # 'Bundle versions string, short' is a weird Casper Composer thing + vers = plist.get('CFBundleShortVersionString', + plist.get('Bundle versions string, short'), '1.0') + ppath = plist.get('IFPkgRelocatedPath', '').lstrip('./').rstrip('/') + + values_t = (timestamp, owner, pkgid, vers, ppath, pkgname) + curs.execute( + '''INSERT INTO pkgs (timestamp, owner, pkgid, vers, ppath, pkgname) + values (?, ?, ?, ?, ?, ?)''', values_t) + pkgkey = curs.lastrowid + + proc = subprocess.Popen( + ['/usr/bin/lsbom', bompath], shell=False, bufsize=-1, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + while True: + line = proc.stdout.readline().decode('UTF-8') + if not line and (proc.poll() != None): + break + insert_bomvalues_into_pkgdb(line, pkgkey, ppath, curs) + + +def import_bom(bompath, curs): + """ + Imports package data into our internal package database + using a combination of the bom file and data in Apple's + package database into our internal package database. + """ + # If we completely trusted the accuracy of Apple's database, we wouldn't + # need the bom files, but in my environment at least, the bom files are + # a better indicator of what flat packages have actually been installed + # on the current machine. + # We still need to consult Apple's package database + # because the bom files are missing metadata about the package. + + pkgname = os.path.basename(bompath) + + timestamp = os.stat(bompath).st_mtime + owner = 0 + pkgid = os.path.splitext(pkgname)[0] + vers = "1.0" + ppath = "" + + # try to get metadata from pkginfo db + proc = subprocess.Popen(["/usr/sbin/pkgutil", "--pkg-info-plist", pkgid], + bufsize=-1, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + (pliststr, dummy_err) = proc.communicate() + if pliststr: + plist = FoundationPlist.readPlistFromString(pliststr) + if "install-location" in plist: + ppath = plist["install-location"] + ppath = ppath.lstrip('./').rstrip('/') + if "pkg-version" in plist: + vers = plist["pkg-version"] + if "install-time" in plist: + timestamp = plist["install-time"] + + values_t = (timestamp, owner, pkgid, vers, ppath, pkgname) + curs.execute( + '''INSERT INTO pkgs (timestamp, owner, pkgid, vers, ppath, pkgname) + values (?, ?, ?, ?, ?, ?)''', values_t) + pkgkey = curs.lastrowid + + cmd = ["/usr/bin/lsbom", bompath] + proc = subprocess.Popen(cmd, shell=False, bufsize=-1, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + while True: + line = proc.stdout.readline().decode('UTF-8') + if not line and (proc.poll() != None): + break + insert_bomvalues_into_pkgdb(line, pkgkey, ppath, curs) + +def import_from_pkgutil(pkgname, curs): + """ + Imports package data from pkgutil into our internal package database. + """ + + timestamp = 0 + owner = 0 + pkgid = pkgname + vers = "1.0" + ppath = "" + + #get metadata from pkginfo db + proc = subprocess.Popen(["/usr/sbin/pkgutil", "--pkg-info-plist", pkgid], + bufsize=-1, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + (pliststr, dummy_err) = proc.communicate() + if pliststr: + plist = FoundationPlist.readPlistFromString(pliststr) + if "pkg-version" in plist: + vers = plist["pkg-version"] + if "install-time" in plist: + timestamp = plist["install-time"] + if "install-location" in plist: + ppath = plist["install-location"] + ppath = ppath.lstrip('./').rstrip('/') + else: + # there _should_ be an install-location. If there's not, let's + # check the old /Library/Receipts. + # (Workaround for QuarkXPress 8.1 packages) + receiptpath = find_bundle_receipt(pkgid) + if receiptpath: + infopath = os.path.join(receiptpath, 'Contents/Info.plist') + if os.path.exists(infopath): + infopl = FoundationPlist.readPlist(infopath) + if "IFPkgRelocatedPath" in infopl: + ppath = infopl["IFPkgRelocatedPath"] + ppath = ppath.lstrip('./').rstrip('/') + + values_t = (timestamp, owner, pkgid, vers, ppath, pkgname) + curs.execute( + '''INSERT INTO pkgs (timestamp, owner, pkgid, vers, ppath, pkgname) + values (?, ?, ?, ?, ?, ?)''', values_t) + pkgkey = curs.lastrowid + + cmd = ["/usr/sbin/pkgutil", "--files", pkgid] + proc = subprocess.Popen(cmd, shell=False, bufsize=-1, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + while True: + line = proc.stdout.readline().decode('UTF-8') + if not line and (proc.poll() != None): + break + insert_bomvalues_into_pkgdb(line, pkgkey, ppath, curs) + + +def init_database(forcerebuild=False): + """ + Builds or rebuilds our internal package database. + """ + def abort_init_database(): + '''What to do if user requests we stop''' + curs.close() + conn.close() + #our package db isn't valid, so we should delete it + os.remove(PACKAGEDB) + return False + + if not should_rebuild_db(PACKAGEDB) and not forcerebuild: + return True + + display.display_status_minor( + 'Gathering information on installed packages') + + if os.path.exists(PACKAGEDB): + try: + os.remove(PACKAGEDB) + except (OSError, IOError): + display.display_error( + "Could not remove out-of-date receipt database.") + return False + + receiptsdir = '/Library/Receipts' + receiptlist = [] + if os.path.exists(receiptsdir): + receiptlist = [item for item in osutils.listdir(receiptsdir) + if item.endswith('.pkg')] + + bomsdir = '/Library/Receipts/boms' + bomslist = [] + if os.path.exists(bomsdir): + bomslist = [item for item in osutils.listdir(bomsdir) + if item.endswith('.bom')] + + pkglist = [] + cmd = ['/usr/sbin/pkgutil', '--pkgs'] + proc = subprocess.Popen(cmd, shell=False, bufsize=-1, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + while True: + line = proc.stdout.readline() + if not line and (proc.poll() != None): + break + + pkglist.append(line.rstrip('\n')) + + pkgcount = len(receiptlist) + len(bomslist) + len(pkglist) + conn = sqlite3.connect(PACKAGEDB) + conn.text_factory = str + curs = conn.cursor() + create_tables(curs) + + currentpkgindex = 0 + display.display_percent_done(0, pkgcount) + + for item in receiptlist: + if processes.stop_requested(): + return abort_init_database() + + receiptpath = os.path.join(receiptsdir, item) + display.display_detail("Importing %s...", receiptpath) + import_package(receiptpath, curs) + currentpkgindex += 1 + display.display_percent_done(currentpkgindex, pkgcount) + + for item in bomslist: + if processes.stop_requested(): + return abort_init_database() + + bompath = os.path.join(bomsdir, item) + display.display_detail("Importing %s...", bompath) + import_bom(bompath, curs) + currentpkgindex += 1 + display.display_percent_done(currentpkgindex, pkgcount) + + for pkg in pkglist: + if processes.stop_requested(): + return abort_init_database() + + display.display_detail("Importing %s...", pkg) + import_from_pkgutil(pkg, curs) + currentpkgindex += 1 + display.display_percent_done(currentpkgindex, pkgcount) + + # in case we didn't quite get to 100% for some reason + display.display_percent_done(pkgcount, pkgcount) + + # commit and close the db when we're done. + conn.commit() + curs.close() + conn.close() + return True + + +def getpkgkeys(pkgnames): + """ + Given a list of receipt names, bom file names, or package ids, + gets a list of pkg_keys from the pkgs table in our database. + """ + # open connection and cursor to our database + conn = sqlite3.connect(PACKAGEDB) + curs = conn.cursor() + + # check package names to make sure they're all in the database, + # build our list of pkg_keys + pkgerror = False + pkgkeyslist = [] + for pkg in pkgnames: + values_t = (pkg, ) + display.display_debug1( + "select pkg_key from pkgs where pkgid = %s", pkg) + pkg_keys = curs.execute( + 'select pkg_key from pkgs where pkgid = ?', values_t).fetchall() + if not pkg_keys: + # try pkgname + display.display_debug1( + "select pkg_key from pkgs where pkgname = %s", pkg) + pkg_keys = curs.execute( + 'select pkg_key from pkgs where pkgname = ?', + values_t).fetchall() + if not pkg_keys: + display.display_error("%s not found in database.", pkg) + pkgerror = True + else: + for row in pkg_keys: + # only want first column + pkgkeyslist.append(row[0]) + if pkgerror: + pkgkeyslist = [] + + curs.close() + conn.close() + display.display_debug1("pkgkeys: %s", pkgkeyslist) + return pkgkeyslist + + +def getpathstoremove(pkgkeylist): + """ + Queries our database for paths to remove. + """ + pkgkeys = tuple(pkgkeylist) + + # open connection and cursor to our database + conn = sqlite3.connect(PACKAGEDB) + curs = conn.cursor() + + # set up some subqueries: + # all the paths that are referred to by the selected packages: + if len(pkgkeys) > 1: + in_selected_packages = \ + "select distinct path_key from pkgs_paths where pkg_key in %s" % \ + str(pkgkeys) + else: + in_selected_packages = \ + "select distinct path_key from pkgs_paths where pkg_key = %s" % \ + str(pkgkeys[0]) + + # all the paths that are referred to by every package + # except the selected packages: + if len(pkgkeys) > 1: + not_in_other_packages = \ + "select distinct path_key from pkgs_paths where pkg_key not in %s" % \ + str(pkgkeys) + else: + not_in_other_packages = \ + "select distinct path_key from pkgs_paths where pkg_key != %s" % \ + str(pkgkeys[0]) + + # every path that is used by the selected packages and no other packages: + combined_query = \ + "select path from paths where " + \ + "(path_key in (%s) and path_key not in (%s))" % \ + (in_selected_packages, not_in_other_packages) + + display.display_status_minor( + 'Determining which filesystem items to remove') + munkistatus.percent(-1) + + curs.execute(combined_query) + results = curs.fetchall() + curs.close() + conn.close() + + removalpaths = [] + for item in results: + removalpaths.append(item[0]) + + return removalpaths + + +def remove_receipts(pkgkeylist, noupdateapplepkgdb): + """ + Removes receipt data from /Library/Receipts, + /Library/Receipts/boms, our internal package database, + and optionally Apple's package database. + """ + display.display_status_minor('Removing receipt info') + display.display_percent_done(0, 4) + + os_version = osutils.getOsVersion(as_tuple=True) + conn = sqlite3.connect(PACKAGEDB) + curs = conn.cursor() + + display.display_percent_done(1, 4) + + for pkgkey in pkgkeylist: + pkgid = '' + pkgkey_t = (pkgkey, ) + row = curs.execute( + 'SELECT pkgname, pkgid from pkgs where pkg_key = ?', + pkgkey_t).fetchone() + if row: + pkgname = row[0] + pkgid = row[1] + receiptpath = None + if os_version <= (10, 5): + if pkgname.endswith('.pkg'): + receiptpath = os.path.join('/Library/Receipts', pkgname) + if pkgname.endswith('.bom'): + receiptpath = os.path.join( + '/Library/Receipts/boms', pkgname) + else: + # clean up /Library/Receipts in case there's stuff left there + receiptpath = find_bundle_receipt(pkgid) + + if receiptpath and os.path.exists(receiptpath): + display.display_detail("Removing %s...", receiptpath) + dummy_retcode = subprocess.call( + ["/bin/rm", "-rf", receiptpath]) + + # remove pkg info from our database + display.display_detail( + "Removing package data from internal database...") + curs.execute('DELETE FROM pkgs_paths where pkg_key = ?', pkgkey_t) + curs.execute('DELETE FROM pkgs where pkg_key = ?', pkgkey_t) + + # then remove pkg info from Apple's database unless option is passed + if not noupdateapplepkgdb and pkgid: + cmd = ['/usr/sbin/pkgutil', '--forget', pkgid] + proc = subprocess.Popen(cmd, bufsize=-1, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + (output, dummy_err) = proc.communicate() + if output: + display.display_detail( + str(output).decode('UTF-8').rstrip('\n')) + + display.display_percent_done(2, 4) + + # now remove orphaned paths from paths table + display.display_percent_done(3, 4) + + # we do our database last so its modtime is later than the modtime for the + # Apple DB... + display.display_detail( + "Removing unused paths from internal package database...") + curs.execute( + '''DELETE FROM paths where path_key not in + (select distinct path_key from pkgs_paths)''') + conn.commit() + curs.close() + conn.close() + + display.display_percent_done(4, 4) + + +def is_bundle(pathname): + """ + Returns true if pathname is a bundle-style directory. + """ + bundle_extensions = [".action", + ".app", + ".bundle", + ".clr", + ".colorPicker", + ".component", + ".dictionary", + ".docset", + ".framework", + ".fs", + ".kext", + ".loginPlugin", + ".mdiimporter", + ".monitorPanel", + ".osax", + ".pkg", + ".plugin", + ".prefPane", + ".qlgenerator", + ".saver", + ".service", + ".slideSaver", + ".SpeechRecognizer", + ".SpeechSynthesizer", + ".SpeechVoice", + ".spreporter", + ".wdgt"] + if os.path.isdir(pathname): + basename = os.path.basename(pathname) + extension = os.path.splitext(basename)[1] + if extension in bundle_extensions: + return True + else: + return False + else: + return False + + +def inside_bundle(pathname): + '''Check the path to see if it's inside a bundle.''' + while len(pathname) > 1: + if is_bundle(pathname): + return True + else: + # chop off last item in path + pathname = os.path.dirname(pathname) + #if we get here, we didn't find a bundle path + return False + + +def remove_filesystem_items(removalpaths, forcedeletebundles): + """ + Attempts to remove all the paths in the array removalpaths + """ + # we sort in reverse because we can delete from the bottom up, + # clearing a directory before we try to remove the directory itself + removalpaths.sort(reverse=True) + removalerrors = "" + removalcount = len(removalpaths) + display.display_status_minor( + 'Removing %s filesystem items' % removalcount) + + itemcount = len(removalpaths) + itemindex = 0 + display.display_percent_done(itemindex, itemcount) + + for item in removalpaths: + itemindex += 1 + pathtoremove = "/" + item + # use os.path.lexists so broken links return true + # so we can remove them + if os.path.lexists(pathtoremove): + display.display_detail("Removing: " + pathtoremove) + if (os.path.isdir(pathtoremove) and + not os.path.islink(pathtoremove)): + diritems = osutils.listdir(pathtoremove) + if diritems == ['.DS_Store']: + # If there's only a .DS_Store file + # we'll consider it empty + ds_storepath = pathtoremove + "/.DS_Store" + try: + os.remove(ds_storepath) + except (OSError, IOError): + pass + diritems = osutils.listdir(pathtoremove) + if diritems == []: + # directory is empty + try: + os.rmdir(pathtoremove) + except (OSError, IOError), err: + msg = "Couldn't remove directory %s - %s" % ( + pathtoremove, err) + display.display_error(msg) + removalerrors = removalerrors + "\n" + msg + else: + # the directory is marked for deletion but isn't empty. + # if so directed, if it's a bundle (like .app), we should + # remove it anyway - no use having a broken bundle hanging + # around + if forcedeletebundles and is_bundle(pathtoremove): + display.display_warning( + "Removing non-empty bundle: %s", pathtoremove) + retcode = subprocess.call(['/bin/rm', '-r', + pathtoremove]) + if retcode: + msg = "Couldn't remove bundle %s" % pathtoremove + display.display_error(msg) + removalerrors = removalerrors + "\n" + msg + else: + # if this path is inside a bundle, and we've been + # directed to force remove bundles, + # we don't need to warn because it's going to be + # removed with the bundle. + # Otherwise, we should warn about non-empty + # directories. + if not inside_bundle(pathtoremove) or \ + not forcedeletebundles: + msg = \ + "Did not remove %s because it is not empty." % \ + pathtoremove + display.display_error(msg) + removalerrors = removalerrors + "\n" + msg + + else: + # not a directory, just unlink it + try: + os.remove(pathtoremove) + except (OSError, IOError), err: + msg = "Couldn't remove item %s: %s" % (pathtoremove, err) + display.display_error(msg) + removalerrors = removalerrors + "\n" + msg + + display.display_percent_done(itemindex, itemcount) + + if removalerrors: + display.display_info( + "---------------------------------------------------") + display.display_info( + "There were problems removing some filesystem items.") + display.display_info( + "---------------------------------------------------") + display.display_info(removalerrors) + + +def removepackages(pkgnames, forcedeletebundles=False, listfiles=False, + rebuildpkgdb=False, noremovereceipts=False, + noupdateapplepkgdb=False): + """ + Our main function, called by installer.py to remove items based on + receipt info. + """ + if pkgnames == []: + display.display_error( + "You must specify at least one package to remove!") + return -2 + + if not init_database(forcerebuild=rebuildpkgdb): + display.display_error("Could not initialize receipt database.") + return -3 + + pkgkeyslist = getpkgkeys(pkgnames) + if len(pkgkeyslist) == 0: + return -4 + + if processes.stop_requested(): + return -128 + removalpaths = getpathstoremove(pkgkeyslist) + if processes.stop_requested(): + return -128 + + if removalpaths: + if listfiles: + removalpaths.sort() + for item in removalpaths: + print "/" + item.encode('UTF-8') + else: + munkistatus.disableStopButton() + remove_filesystem_items(removalpaths, forcedeletebundles) + else: + display.display_status_minor('Nothing to remove.') + + if not listfiles: + if not noremovereceipts: + remove_receipts(pkgkeyslist, noupdateapplepkgdb) + munkistatus.enableStopButton() + display.display_status_minor('Package removal complete.') + + return 0 + + +# some globals +PACKAGEDB = os.path.join(prefs.pref('ManagedInstallDir'), "b.receiptdb") + + +if __name__ == '__main__': + print 'This is a library of support tools for the Munki Suite.' diff --git a/code/client/removepackages b/code/client/removepackages new file mode 100755 index 00000000..33b23cfe --- /dev/null +++ b/code/client/removepackages @@ -0,0 +1,94 @@ +#!/usr/bin/python +# encoding: utf-8 +# +# Copyright 2009-2016 Greg Neagle. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +removepackages +munkilib/removepackages.py should be a symlink to this file for backwards +compatibility + +Command-line tool to analyze installed packages and remove +files unique to the packages given. No attempt +is made to revert to older versions of a file when uninstalling; +only file removals are done. + +""" + +import optparse +import os +import sys + +from munkilib import display +from munkilib import munkistatus +from munkilib import removepkgslib + + +def main(): + '''Used when calling removepackages.py directly from the command line.''' + # command-line options + parser = optparse.OptionParser() + parser.set_usage('''Usage: %prog [options] package_id ...''') + parser.add_option('--forcedeletebundles', '-f', action='store_true', + help='Delete bundles even if they aren\'t empty.') + parser.add_option('--listfiles', '-l', action='store_true', + help='List the filesystem objects to be removed, ' + 'but do not actually remove them.') + parser.add_option('--rebuildpkgdb', action='store_true', + help='Force a rebuild of the internal package database.') + parser.add_option('--noremovereceipts', action='store_true', + help='''Do not remove receipts and boms from + /Library/Receipts and update internal package + database.''') + parser.add_option('--noupdateapplepkgdb', action='store_true', + help='Do not update Apple\'s package database. ' + 'If --noremovereceipts is also given, this is implied') + parser.add_option('--munkistatusoutput', '-m', action='store_true', + help='Output is formatted for use with MunkiStatus.') + parser.add_option('--verbose', '-v', action='count', default=1, + help='More verbose output. May be specified multiple ' + 'times.') + + # Get our options and our package names + options, pkgnames = parser.parse_args() + + # check to see if we're root + if os.geteuid() != 0: + print sys.stderr, "You must run this as root!" + exit(-1) + + # set the display globals + display.munkistatusoutput = options.munkistatusoutput + display.verbose = options.verbose + + if options.munkistatusoutput: + pkgcount = len(pkgnames) + munkistatus.message("Removing %s packages..." % pkgcount) + munkistatus.detail("") + + retcode = removepkgslib.removepackages( + pkgnames, + forcedeletebundles=options.forcedeletebundles, + listfiles=options.listfiles, + rebuildpkgdb=options.rebuildpkgdb, + noremovereceipts=options.noremovereceipts, + noupdateapplepkgdb=options.noupdateapplepkgdb) + if options.munkistatusoutput: + munkistatus.quit() + exit(retcode) + + +if __name__ == '__main__': + main()