Files
munki/code/cli/munki/shared/updatecheck/analyze.swift
T

1109 lines
46 KiB
Swift

//
// analyze.swift
// munki
//
// Created by Greg Neagle on 8/19/24.
//
// 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.
// TODO: this file has very long, very complex functions that are hard to follow
// and hard to understand.
// (Notably processInstall, processOptionalInstall, and processRemoval)
// Need to refactor these
import Foundation
func itemInInstallInfo(_ thisItem: PlistDict, theList: [PlistDict], version: String = "") -> Bool {
// Determines if an item is in a list of processed items.
//
// Returns true if the item has already been processed (it's in the list)
// and, optionally, the version is the same or greater.
for listItem in theList {
if let listItemName = listItem["name"] as? String,
let thisItemName = thisItem["name"] as? String
{
if listItemName == thisItemName {
if version.isEmpty {
return true
}
// if the version already installed or processed to be
// installed is the same or greater, then we're good.
if let installed = listItem["installed"] as? Bool,
installed == true,
let installedVersion = listItem["installed_version"] as? String,
compareVersions(installedVersion, version).rawValue > 0
{
return true
}
if let versionToInstall = listItem["version_to_install"] as? String,
compareVersions(versionToInstall, version).rawValue > 0
{
return true
}
}
}
}
return false
}
func isAppleItem(_ pkginfo: PlistDict) -> Bool {
// Returns True if the item to be installed or removed appears to be from
// Apple. If we are installing or removing any Apple items in a check/install
// cycle, we skip checking/installing Apple updates from an Apple Software
// Update server so we don't stomp on each other
// is this a startosinstall item?
if let installerType = pkginfo["installer_type"] as? String,
installerType == "startosinstall"
{
return true
}
// check receipts
if let receipts = pkginfo["receipts"] as? [PlistDict] {
for receipt in receipts {
if let pkgid = receipt["packageid"] as? String,
pkgid.hasPrefix("com.apple.")
{
return true
}
}
}
// check installs items
if let installs = pkginfo["installs"] as? [PlistDict] {
for install in installs {
if let bundleID = install["CFBundleIdentifier"] as? String,
bundleID.hasPrefix("com.apple.")
{
return true
}
}
}
// if we get here, no receipts or installs items have Apple
// identifiers
return false
}
func alreadyProcessed(_ itemName: String, installInfo: PlistDict, sections: [String]) -> Bool {
// Returns True if itemname has already been added to installinfo in one
// of the given sections
let description = [
"processed_installs": "install",
"processed_uninstalls": "uninstall",
"managed_updates": "update",
"optional_installs": "optional install",
]
for section in sections {
if let listOfNames = installInfo[section] as? [String],
listOfNames.contains(itemName)
{
displayDebug1("\(itemName) has already been processed for \(description[section] ?? "")")
return true
} else if let listOfPkgInfos = installInfo[section] as? [PlistDict] {
for pkginfo in listOfPkgInfos {
if let pkginfoName = pkginfo["name"] as? String {
if itemName == pkginfoName {
displayDebug1("\(itemName) has already been processed for \(description[section] ?? "")")
return true
}
}
}
}
}
return false
}
func processInstall(
_ manifestItem: String,
catalogList: [String],
installInfo: inout PlistDict,
isManagedUpdate: Bool = false,
isOptionalInstall: Bool = false
) async -> Bool {
// Processes a manifest item for install. Determines if it needs to be
// installed, and if so, if any items it is dependent on need to
// be installed first. Installation detail is added to
// installinfo['managed_installs']
// Calls itself recursively as it processes dependencies.
// Returns a boolean; when processing dependencies, a false return
// will stop the installation of a dependent item
func appendToProcessedManagedInstalls(_ item: PlistDict) {
// helper function
var managedInstalls = installInfo["managed_installs"] as? [PlistDict] ?? []
managedInstalls.append(item)
installInfo["managed_installs"] = managedInstalls
}
let manifestItemName = (manifestItem as NSString).lastPathComponent
displayDebug1("* Processing manifest item \(manifestItemName) for install")
let (manifestItemNameWithoutVersion, includedVersion) = nameAndVersion(manifestItemName, onlySplitOnHyphens: true)
// have we processed this already?
if let processedInstalls = installInfo["processed_installs"] as? [String],
processedInstalls.contains(manifestItemName)
{
displayDebug1("\(manifestItemName) has already been processed for install.")
return true
}
if let processedUninstalls = installInfo["processed_uninstalls"] as? [String],
processedUninstalls.contains(manifestItemNameWithoutVersion)
{
displayDebug1("Will not process \(manifestItemName) for install because it has already been processed for uninstall!")
return false
}
guard let pkginfo = await getItemDetail(manifestItemName, catalogList: catalogList) else {
displayWarning("Could not process item \(manifestItemName) for install. No pkginfo found in catalogs: \(catalogList)")
return false
}
if let managedInstalls = installInfo["managed_installs"] as? [PlistDict],
let itemName = pkginfo["name"] as? String,
let itemVersion = pkginfo["version"] as? String,
itemInInstallInfo(pkginfo, theList: managedInstalls, version: itemVersion)
{
// item has been processed for install; now check to see if there
// was a problem when it was processed
let problemItemNames: [String]
problemItemNames = managedInstalls.filter {
$0["installed"] is Bool &&
($0["installed"] as? Bool ?? true) == false &&
$0["installer_item"] == nil
}.map {
$0["name"] as? String ?? ""
}.filter {
!$0.isEmpty
}
if problemItemNames.contains(itemName) {
// item was processed, but not successfully downloaded
displayDebug1("\(manifestItemName) was processed earlier, but download failed.")
return false
}
// item was processed successfully
displayDebug1("\(manifestItem) is or will be installed.")
return true
}
// check dependencies
var dependenciesMet = true
// there are two kinds of dependencies/relationships.
//
// 'requires' are prerequisites:
// package A requires package B be installed first.
// if package A is removed, package B is unaffected.
// requires can be a one to many relationship.
//
// The second type of relationship is 'update_for'.
// This signifies that that current package should be considered an update
// for the packages listed in the 'update_for' array. When processing a
// package, we look through the catalogs for other packages that declare
// they are updates for the current package and install them if needed.
// This can be a one-to-many relationship - one package can be an update
// for several other packages; for example, 'PhotoshopCS4update-11.0.1'
// could be an update for PhotoshopCS4 and for AdobeCS4DesignSuite.
//
// When removing an item, any updates for that item are removed as well.
let name = pkginfo["name"] as? String ?? "<unknown>"
let version = pkginfo["version"] as? String ?? "<unknown>"
// get list of dependencies. 'requires' should be a list of strings, but
// sometimes admins define it as just a single string. Account for both
// possibilities
var dependencies = [String]()
if let requires = pkginfo["requires"] as? [String] {
dependencies = requires
} else if let requires = pkginfo["requires"] as? String {
dependencies = [requires]
}
for item in dependencies {
displayDetail("\(name)-\(version) requires \(item). Getting info on \(item)...")
let success = await processInstall(
item,
catalogList: catalogList,
installInfo: &installInfo,
isManagedUpdate: isManagedUpdate,
isOptionalInstall: isOptionalInstall
)
if !success {
dependenciesMet = false
}
}
var processedItem = PlistDict()
processedItem["name"] = name
let displayName = pkginfo["display_name"] as? String ?? name
processedItem["display_name"] = displayName
processedItem["description"] = pkginfo["description"] as? String ?? ""
processedItem["localized_strings"] = pkginfo["localized_strings"]
processedItem["developer"] = pkginfo["developer"]
processedItem["icon_name"] = pkginfo["icon_name"]
let installedState = await installedState(pkginfo)
if installedState == .thisVersionNotInstalled {
if !dependenciesMet {
// we should not attempt to install
displayWarning("Didn't attempt ro install \(manifestItemName) because could not resolve all dependencies.")
// add information to managed_installs so we have some feedback
// to display in MSC.app
processedItem["installed"] = false
processedItem["note"] = "Can't install \(displayName) because could not verify all other items it requires are or will be installed."
appendToProcessedManagedInstalls(processedItem)
return false
}
displayDetail("Need to install \(manifestItemName)")
processedItem["installed"] = false
processedItem["version_to_install"] = version
let installerItemSize = pkginfo["installer_item_size"] as? Int ?? 0
processedItem["installer_item_size"] = installerItemSize
processedItem["installed_size"] = pkginfo["installer_item_size"] as? Int ?? installerItemSize
var downloadSeconds = 0.0
var filename = ""
if let installerType = pkginfo["installer_type"] as? String,
installerType == "nopkg"
{
// "install" that has no actual download
filename = "packageless_install"
} else {
do {
// record starttime
let startTime = Date()
if try downloadInstallerItem(pkginfo, installInfo: installInfo) {
// Record the download speed for the InstallResults output.
downloadSeconds = Date().timeIntervalSince(startTime)
} else {
// item was in cache and unchanged
}
} catch let FetchError.fileSystem(description) {
displayWarning("Can't install \(manifestItemName) because \(description).")
processedItem["installed"] = false
processedItem["note"] = description
if let installerItemLocation = pkginfo["installer_item_location"] as? String {
processedItem["partial_installer_item"] = baseName(installerItemLocation)
}
processedItem["version_to_install"] = version
appendToProcessedManagedInstalls(processedItem)
return false
} catch {
// download unsuccessful
if let installerItemLocation = pkginfo["installer_item_location"] as? String {
processedItem["partial_installer_item"] = baseName(installerItemLocation)
}
if let err = error as? FetchError {
switch err {
case .verification:
displayWarning("Can't install \(manifestItemName) because the integrity check failed.")
processedItem["note"] = "Integrity check failed"
processedItem["partial_installer_item"] = nil
case let .fileSystem(description):
displayWarning("Can't install \(manifestItemName) because \(description).")
processedItem["note"] = description
case let .connection(errorCode, description),
let .download(errorCode, description),
let .http(errorCode, description):
displayWarning("Download of \(manifestItemName) failed: error \(errorCode): \(description)")
processedItem["note"] = "Download failed: \(description)"
}
} else {
displayWarning("Can't install \(manifestItemName) because \(error.localizedDescription)")
processedItem["note"] = error.localizedDescription
}
processedItem["version_to_install"] = version
appendToProcessedManagedInstalls(processedItem)
return false
}
// download succeeded or cached item is current
if let installerItemLocation = pkginfo["installer_item_location"] as? String {
filename = baseName(installerItemLocation)
}
if installerItemSize >= 1024, downloadSeconds > 0 {
let downloadSpeed = Int(Double(installerItemSize) / downloadSeconds)
processedItem["download_kbytes_per_sec"] = downloadSpeed
displayDetail("\(filename) downloaded at \(downloadSpeed) KB/sec")
}
}
processedItem["installer_item"] = filename
// we will ignore the unattended_install key if the item needs a
// restart or logout...
if (pkginfo["unattended_install"] as? Bool ?? false) || (pkginfo["forced_install"] as? Bool ?? false) {
let restartAction = pkginfo["RestartAction"] as? String ?? "None"
if restartAction != "None" {
displayWarning("Ignoring unattended_install key for \(name) because RestartAction is \(restartAction).")
} else {
processedItem["unattended_install"] = true
}
}
// optional keys to copy if they exist
var optionalKeys = [
"force_install_after_date",
"additional_startosinstall_options",
"allow_untrusted",
"installer_choices_xml",
"installer_environment",
"adobe_install_info",
"RestartAction",
"installer_type",
"adobe_package_name",
"package_path",
"blocking_applications",
"installs",
"requires",
"update_for",
"payloads",
"preinstall_script",
"postinstall_script",
"items_to_copy", // used w/ copy_from_dmg
"apple_item",
"category",
"developer",
"icon_name",
"PayloadIdentifier",
"icon_hash",
"OnDemand",
"precache",
"display_name_staged", // used w/ stage_os_installer
"description_staged",
"installed_size_staged",
]
if isOptionalInstall {
let someVersionInstalled = await someVersionInstalled(pkginfo)
if !someVersionInstalled {
// For optional installs where no version is installed yet
// we do not enforce force_install_after_date
optionalKeys = optionalKeys.filter {
$0 != "force_install_after_date"
}
}
}
for key in optionalKeys {
processedItem[key] = pkginfo[key]
}
if pkginfo["apple_item"] == nil {
// admin did not explicitly mark this item; let's determine if
// it's from Apple
if isAppleItem(pkginfo) {
munkiLog("Marking \(name) as an apple_item - this will block Apple SUS updates")
processedItem["apple_item"] = true
}
}
appendToProcessedManagedInstalls(processedItem)
// now look for update_for items
var updateList = [String]()
if !includedVersion.isEmpty {
// a specific version was specified in the manifest
// so look only for updates for this specific version
updateList = lookForUpdatesForName(
manifestItemNameWithoutVersion,
version: includedVersion,
catalogList: catalogList
)
} else {
// didn't specify a specific version, so
// now look for all updates for this item
updateList = lookForUpdatesFor(
manifestItemNameWithoutVersion,
catalogList: catalogList
)
// now append any updates specifically
// for the version to be installed
updateList += lookForUpdatesForName(
manifestItemNameWithoutVersion,
version: version,
catalogList: catalogList
)
}
// if we have any update items, process them
for updateItem in updateList {
_ = await processInstall(
updateItem,
catalogList: catalogList,
installInfo: &installInfo
)
}
} else {
// same or higher version installed
processedItem["installed"] = true
if !dependenciesMet {
displayWarning("Could not resolve all dependencies for \(manifestItemName), but no install or update needed.")
}
if let installerType = pkginfo["installer_type"] as? String,
installerType == "stage_os_installer"
{
// installer appears to be staged; make sure the info is recorded
// so we know we can launch the installer later
// TODO: maybe filter the actual info recorded
displayInfo("Recording staged macOS installer...")
// TODO: recordStagedOSInstaller(pkginfo)
}
// record installed size and version
let installedSize = pkginfo["installed_size"] as? Int ?? pkginfo["installer_item_size"] as? Int ?? 0
processedItem["installed_size"] = installedSize
if installedState == .thisVersionInstalled {
// just use the version from the pkginfo
processedItem["installed_version"] = version
} else {
// might be newer; attempt to figure out the version
var installedVersion = getInstalledVersion(pkginfo)
if installedVersion == "UNKNOWN" {
installedVersion = "(newer than \(version))"
}
processedItem["installed_version"] = installedVersion
}
appendToProcessedManagedInstalls(processedItem)
displayDetail("\(manifestItemNameWithoutVersion) version \(version) (or newer) is already installed.")
// now look for update_for items
var updateList = [String]()
let installedVersion = processedItem["installed_version"] as? String ?? ""
if includedVersion.isEmpty {
// no specific version is specified;
// the item is already installed;
// now look for updates for this item
updateList = lookForUpdatesFor(name, catalogList: catalogList)
// and also any for this specific version
if !installedVersion.hasPrefix("(newer than") {
updateList += lookForUpdatesForName(
name,
version: installedVersion,
catalogList: catalogList
)
}
} else if compareVersions(includedVersion, installedVersion) == .same {
// manifest specifies a specific version.
// if that's what's installed, look for any updates
// specific to this version
updateList = lookForUpdatesForName(
name,
version: includedVersion,
catalogList: catalogList
)
}
// if we have any update items, process them
for updateItem in updateList {
_ = await processInstall(
updateItem,
catalogList: catalogList,
installInfo: &installInfo,
isManagedUpdate: isManagedUpdate,
isOptionalInstall: isOptionalInstall
)
}
}
// done successfully processing this install; add it to our list
// of processed installs so we don't process it again in the future
// (unless it is a managed_update)
if !isManagedUpdate {
displayDebug2("Adding \(manifestItemName) to the list of processed installs")
var processedInstalls = installInfo["processed_installs"] as? [String] ?? []
processedInstalls.append(manifestItemName)
installInfo["processed_installs"] = processedInstalls
}
return true
}
func processManagedUpdate(
_ manifestItem: String,
catalogList: [String],
installInfo: inout PlistDict
) async {
// Process a managed_updates item to see if it is installed, and if so,
// if it needs an update.
let manifestItemName = (manifestItem as NSString).lastPathComponent
displayDebug1("* Processing manifest item \(manifestItemName) for update")
if alreadyProcessed(
manifestItemName,
installInfo: installInfo,
sections: ["managed_updates", "processed_installs", "processed_uninstalls"]
) { return }
guard let pkginfo = await getItemDetail(manifestItemName, catalogList: catalogList) else {
displayWarning("Could not process item \(manifestItemName) for update. No pkginfo found in catalogs: \(catalogList) ")
return
}
// we only offer to update if some version of the item is already
// installed, so let's check
if await someVersionInstalled(pkginfo) {
// add to the list of processed managed_updates
var managedUpdates = installInfo["managed_updates"] as? [String] ?? []
managedUpdates.append(manifestItemName)
installInfo["managed_updates"] = managedUpdates
_ = await processInstall(
manifestItemName,
catalogList: catalogList,
installInfo: &installInfo,
isManagedUpdate: true
)
} else {
displayDebug1("\(manifestItemName) does not appear to be installed, so no managed updates.")
}
}
func processOptionalInstall(
_ manifestItem: String,
catalogList: [String],
installInfo: inout PlistDict
) async {
// Process an optional install item to see if it should be added to
// the list of optional installs
let manifestItemName = (manifestItem as NSString).lastPathComponent
displayDebug1("* Processing manifest item \(manifestItemName) for optional install")
if alreadyProcessed(
manifestItemName,
installInfo: installInfo,
sections: ["optional_installs", "processed_installs", "processed_uninstalls"]
) {
return
}
var processedItem = PlistDict()
var pkginfo = PlistDict()
if let testPkginfo = await getItemDetail(
manifestItemName,
catalogList: catalogList,
suppressWarnings: true
) {
pkginfo = testPkginfo
}
if pkginfo.isEmpty,
let show = boolPref("ShowOptionalInstallsForHigherOSVersions"),
show == true
{
// could not find an item valid for the current OS and hardware
// try again to see if there is an item for a higher OS
if let testPkginfo = await getItemDetail(
manifestItemName,
catalogList: catalogList,
skipMinimumOSCheck: true,
suppressWarnings: true
) {
pkginfo = testPkginfo
}
if !pkginfo.isEmpty {
// found an item that requires a higher OS version
let pkginfoName = pkginfo["name"] as? String ?? "<unknown>"
let pkginfoVersion = pkginfo["version"] as? String ?? "<unknown>"
displayDebug1("Found \(pkginfoName), version \(pkginfoVersion) that requires a higher os version")
// insert a note about the OS version requirement
if let minimumOSVersion = pkginfo["minimum_os_version"] {
processedItem["note"] = "Requires macOS version \(minimumOSVersion)."
}
processedItem["update_available"] = true
}
}
if pkginfo.isEmpty {
// could not find anything that matches and is applicable
displayWarning("Could not process item \(manifestItemName) for optional install. No pkginfo found in catalogs: \(catalogList)")
return
}
let isCurrentlyInstalled = await someVersionInstalled(pkginfo)
var needsUpdate = false
if isCurrentlyInstalled {
// TODO: if shouldBeRemovedIfUnused(pkginfo) {
// processRemoval(manifestItemName, catalogList: catalogList, installInfo: &installInfo)
// removeFromSelfServeInstalls(manifestItemName)
// return
// }
if pkginfo["installcheck_script"] == nil {
// installcheck_scripts can be expensive and only tell us if
// an item is installed or not. So if iteminfo['installed'] is
// True, and we're using an installcheck_script,
// installedState() is going to return 1
// (which does not equal 0), so we can avoid running it again.
// We should really revisit all of this in the future to avoid
// repeated checks of the same data.
// (installcheck_script isn't called if OnDemand is True, but if
// OnDemand is true, is_currently_installed would be False, and
// therefore we would not be here!)
//
// TL;DR: only check installed_state if no installcheck_script
let installationState = await installedState(pkginfo)
if let installerType = pkginfo["installer_type"] as? String,
installerType == "stage_os_installer"
{
// .thisVersionNotInstalled means installer is staged, but not _installed_
needsUpdate = installationState != .newerVersionInstalled
} else {
needsUpdate = installationState == .thisVersionNotInstalled
}
}
if !needsUpdate,
let show = boolPref("ShowOptionalInstallsForHigherOSVersions"),
show == true
{
// the version we have installed is the newest for the current OS.
// check again to see if there is a newer version for a higher OS
displayDebug1("Checking for versions of \(manifestItemName) that require a higher OS version")
if let anotherPkgInfo = await getItemDetail(
manifestItemName,
catalogList: catalogList,
skipMinimumOSCheck: true,
suppressWarnings: true
) {
if !NSDictionary(dictionary: anotherPkgInfo).isEqual(to: NSDictionary(dictionary: pkginfo)) {
// we found a different item. Replace the one we found
// previously with this one.
pkginfo = anotherPkgInfo
let pkginfoName = pkginfo["name"] as? String ?? "<unknown>"
let pkginfoVersion = pkginfo["version"] as? String ?? "<unknown>"
displayDebug1("Found \(pkginfoName), version \(pkginfoVersion) that requires a higher os version")
// insert a note about the OS version requirement
if let minimumOSVersion = pkginfo["minimum_os_version"] {
processedItem["note"] = "Requires macOS version \(minimumOSVersion)."
}
processedItem["update_available"] = true
}
}
}
}
// if we get to this point we can add this item
// to the list of optional installs
processedItem["name"] = pkginfo["name"] as? String ?? manifestItemName
processedItem["display_name"] = pkginfo["display_name"] ?? ""
processedItem["description"] = pkginfo["description"] ?? ""
processedItem["version_to_install"] = pkginfo["version"] ?? "UNKNOWN"
processedItem["needs_update"] = needsUpdate
for key in [
"category",
"developer",
"featured",
"icon_name",
"icon_hash",
"requires",
"RestartAction",
] {
processedItem[key] = pkginfo[key]
}
processedItem["installed"] = isCurrentlyInstalled
processedItem["licensed_seat_info_available"] = pkginfo["licensed_seat_info_available"] as? Bool ?? false
processedItem["uninstallable"] = (pkginfo["uninstallable"] as? Bool ?? false) && !(pkginfo["uninstall_method"] as? String ?? "").isEmpty
// If the item is a precache item, record the precache flag
// and also the installer item location (as long as item doesn't have a note
// explaining why it's not available and as long as available seats is not 0)
if let installerItemLocation = pkginfo["installer_item_location"] as? String,
!installerItemLocation.isEmpty,
pkginfo["precache"] as? Bool ?? false,
pkginfo["note"] == nil,
pkginfo["licensed_seats_available"] as? Bool ?? true
{
processedItem["precache"] = true
for key in [
"installer_item_location",
"installer_item_hash",
"PackageCompleteURL",
"PackageURL",
] {
processedItem[key] = pkginfo[key]
}
}
let installerSize = pkginfo["installer_item_size"] as? Int ?? 0
processedItem["installer_item_size"] = installerSize
processedItem["installed_size"] = pkginfo["installed_size"] as? Int ?? installerSize
if let pkgInfoNote = pkginfo["note"] as? String,
processedItem["note"] == nil
{
processedItem["note"] = pkgInfoNote
} else if needsUpdate || !isCurrentlyInstalled {
if !enoughDiskSpaceFor(
pkginfo,
installList: installInfo["managed_installs"] as? [PlistDict] ?? [],
warn: true
) {
processedItem["note"] = "Insufficient disk space to download and install."
if needsUpdate {
processedItem["needs_update"] = false
processedItem["update_available"] = true
}
}
}
let optionalKeys = [
"preinstall_alert",
"preuninstall_alert",
"preupgrade_alert",
"OnDemand",
"minimum_os_version",
"update_available",
"localized_strings",
]
for key in optionalKeys {
processedItem[key] = pkginfo[key]
}
let itemName = processedItem["name"] as? String ?? "<unknown>"
displayDebug1("Adding \(itemName) to the optional install list")
var optionalInstalls = installInfo["optional_installs"] as? [PlistDict] ?? []
optionalInstalls.append(processedItem)
installInfo["optional_installs"] = optionalInstalls
}
func processRemoval(
_ manifestItem: String,
catalogList: [String],
installInfo: inout PlistDict
) async -> Bool {
// Processes a manifest item; attempts to determine if it
// needs to be removed, and if it can be removed.
// Unlike installs, removals aren't really version-specific -
// If we can figure out how to remove the currently installed
// version, we do, unless the admin specifies a specific version
// number in the manifest. In that case, we only attempt a
// removal if the version installed matches the specific version
// in the manifest.
// Any items dependent on the given item need to be removed first.
// Items to be removed are added to installinfo['removals'].
// Calls itself recursively as it processes dependencies.
// Returns a boolean; when processing dependencies, a false return
// will stop the removal of a dependent item.
func getReceiptsToRemove(_ item: PlistDict) async -> [String] {
// Returns a list of (installed/present) receipts to remove for item
if let name = item["name"] as? String {
let pkgdata = await analyzeInstalledPkgs()
if let receiptsForName = pkgdata["receipts_for_name"] as? [String: [String]] {
return receiptsForName[name] ?? [String]()
}
}
return [String]()
}
func appendToProcessedRemovals(_ item: PlistDict) {
// helper function
var removals = installInfo["removals"] as? [PlistDict] ?? []
removals.append(item)
installInfo["removals"] = removals
}
let manifestItemNameWithVersion = (manifestItem as NSString).lastPathComponent
displayDebug1("* Processing manifest item \(manifestItemNameWithVersion) for removal")
let (manifestItemName, manifestItemVersion) = nameAndVersion(manifestItemNameWithVersion, onlySplitOnHyphens: true)
// have we processed this item already?
let processedInstalls = installInfo["processed_installs"] as? [String] ?? []
let processedInstallsNames = processedInstalls.map {
nameAndVersion($0, onlySplitOnHyphens: true).0
}
if processedInstallsNames.contains(manifestItemName) {
displayWarning("Will not attempt to remove \(manifestItemName) because some version of it is in the list of managed installs, or it is required by another managed install.")
return false
}
var processedUninstallsNames = installInfo["processed_uninstalls"] as? [String] ?? []
if processedUninstallsNames.contains(manifestItemName) {
displayDebug1("\(manifestItemName) has already been processed for removal.")
return true
}
processedUninstallsNames.append(manifestItemName)
installInfo["processed_uninstalls"] = processedUninstallsNames
var pkginfos = [PlistDict]()
if !manifestItemVersion.isEmpty {
// a specific version was specified
if let pkginfo = await getItemDetail(manifestItemName, catalogList: catalogList, version: manifestItemVersion) {
pkginfos.append(pkginfo)
}
} else {
// get all items matching the name provided
pkginfos = getAllItemsWithName(manifestItemName, catalogList: catalogList)
}
if pkginfos.isEmpty {
displayWarning("Could not process item \(manifestItemName) for removal. No pkginfo found in catalogs: \(catalogList)")
return false
}
var installEvidence = false
var foundItem = PlistDict()
for pkginfo in pkginfos {
let name = pkginfo["name"] as? String ?? "<unknown>"
let version = pkginfo["version"] as? String ?? "<unknown>"
displayDebug2("Considering item \(name)-\(version) for removal info")
if await evidenceThisIsInstalled(pkginfo) {
installEvidence = true
foundItem = pkginfo
break
}
displayDebug2("\(name)-\(version) is not installed")
}
if !installEvidence {
displayDetail("\(manifestItemNameWithVersion) doesn\'t appear to be installed.")
var processedItem = PlistDict()
processedItem["name"] = manifestItemName
processedItem["installed"] = false
appendToProcessedRemovals(processedItem)
return true
}
// if we get here, installEvidence is true, and foundItem
// holds the item we found install evidence for, so we
// should use that item to do the removal
var uninstallItem = PlistDict()
var packagesToRemove = [String]()
let foundItemName = foundItem["name"] as? String ?? "<unknown>"
let foundItemVersion = foundItem["version"] as? String ?? "<unknown>"
let uninstallable = foundItem["uninstallable"] as? Bool ?? false
let uninstallMethod = foundItem["uninstall_method"] as? String ?? ""
if !uninstallable {
displayWarning("Item \(foundItemName)-\(foundItemVersion) is not marked as uninstallable.")
} else if uninstallMethod.isEmpty {
displayWarning("No uninstall_method in \(foundItemName)-\(foundItemVersion).")
} else if uninstallMethod.hasPrefix("Adobe") {
displayWarning("Adobe-specific uninstall methods are no longer supported.")
} else if ["remove_app", "remove_profile"].contains(uninstallMethod) {
displayWarning("Uninstall method \(uninstallMethod) is no longer supported.")
} else if uninstallMethod == "removepackages" {
packagesToRemove = await getReceiptsToRemove(foundItem)
if !packagesToRemove.isEmpty {
uninstallItem = foundItem
} else {
displayWarning("uninstall_method for \(manifestItemNameWithVersion) is removepackages, but no packages found to remove")
}
} else if ["remove_copied_items", "uninstall_script", "uninstall_package"].contains(uninstallMethod) {
uninstallItem = foundItem
} else {
// might be a path to a locally-installed script/executable
if pathIsExecutableFile(uninstallMethod) {
uninstallItem = foundItem
} else {
displayWarning("Uninstall method \(uninstallMethod) is not a valid method.")
}
}
if uninstallItem.isEmpty {
// could not find usable uninstall_method
return false
}
// if we got this far, we have enough info to attempt an uninstall.
// the pkginfo is in uninstall_item
// Now check for dependent items
//
// First, look through catalogs for items that are required by this item;
// if any are installed, we need to remove them as well
//
// still not sure how to handle references to specific versions --
// if another package says it requires SomePackage--1.0.0.0.0
// and we're supposed to remove SomePackage--1.0.1.0.0... what do we do?
var dependentItemsRemoved = true
let uninstallItemName = uninstallItem["name"] as? String ?? "<unknown>"
let uninstallItemVersion = uninstallItem["version"] as? String ?? "<unknown>"
let uninstallItemNameWVersion = "\(uninstallItemName)-\(uninstallItemVersion)"
let altUninstallItemNameWVersion = "\(uninstallItemName)--\(uninstallItemVersion)"
var processedNames = [String]()
for catalogName in catalogList {
guard let catalogDB = Catalogs.shared.get(catalogName) else {
// in case the list refers to a non-existent catalog
continue
}
let catalogItems = catalogDB["items"] as? [PlistDict] ?? []
for catalogItem in catalogItems {
let name = catalogItem["name"] as? String ?? "<unknown>"
if processedNames.contains(name) {
// already added
continue
}
guard let requires = catalogItem["requires"] as? [String] else {
// no requires, so skip to next
processedNames.append(name)
continue
}
if requires.contains(uninstallItemName) ||
requires.contains(uninstallItemNameWVersion) ||
requires.contains(altUninstallItemNameWVersion)
{
displayDebug1("\(name) requires \(manifestItemName), checking to see if it's installed...")
if await evidenceThisIsInstalled(catalogItem) {
displayDetail("\(name) requires \(manifestItemName). \(name) must be removed as well.")
let success = await processRemoval(
name, catalogList: catalogList, installInfo: &installInfo
)
if !success {
dependentItemsRemoved = false
break
}
}
}
processedNames.append(name)
}
}
if !dependentItemsRemoved {
displayWarning("Will not attempt to remove %s because could not remove all items dependent on it.")
return false
}
// Finally! We can record the removal information!
var processedItem = PlistDict()
let name = uninstallItem["name"] as? String ?? "<unknown>"
let version = uninstallItem["version"] as? String ?? "<unknown>"
processedItem["name"] = uninstallItem["name"]
processedItem["display_name"] = uninstallItem["display_name"]
processedItem["description"] = "Will be removed."
// we will ignore the unattended_uninstall key if the item needs a restart
// or logout...
if (uninstallItem["unattended_install"] as? Bool ?? false) || (uninstallItem["forced_install"] as? Bool ?? false) {
let restartAction = uninstallItem["RestartAction"] as? String ?? "None"
if restartAction != "None" {
displayWarning("Ignoring unattended_install key for \(name) because RestartAction is \(restartAction).")
} else {
processedItem["unattended_install"] = true
}
}
// some keys we'll copy if they exist
let optionalKeys = [
"RestartAction",
"blocking_applications",
"installs",
"requires",
"update_for",
"payloads",
"preuninstall_script",
"postuninstall_script",
"apple_item",
"category",
"developer",
"icon_name",
"PayloadIdentifier",
]
for key in optionalKeys {
processedItem[key] = uninstallItem[key]
}
if processedItem["apple_item"] == nil {
// admin did not explicitly mark this item; let's determine if
// it's from Apple
if isAppleItem(uninstallItem) {
munkiLog("Marking \(name) as an apple_item - this will block Apple SUS updates")
processedItem["apple_item"] = true
}
}
if !packagesToRemove.isEmpty {
// remove references for each package
var packagesToReallyRemove = [String]()
let pkgdata = await analyzeInstalledPkgs()
let pkgReferences = pkgdata["pkg_references"] as? [String: [String]] ?? [:]
var pkgReferencesMessages = [String]()
for pkg in packagesToRemove {
displayDebug1("Considering \(pkg) for removal...")
// find pkg in pkgdata["pkg_references"] and remove the reference
// so we only remove packages if we're the only reference to it
// pkgdata["pkg_references"] is [String:[String]]
guard let references = pkgReferences[pkg] else {
// This shouldn't happen
displayWarning("pkg id \(pkg) missing from pkgdata")
continue
}
let msg = "Package \(pkg) references are: \(references)"
displayDebug1(msg)
pkgReferencesMessages.append(msg)
if references.contains(name) {
let filteredReferences = references.filter {
$0 != name
}
if filteredReferences.isEmpty {
// no other references than this item
displayDebug1("Adding \(pkg) to removal list.")
packagesToReallyRemove.append(pkg)
} else {
displayDebug1("Will not attempt to remove \(pkg)")
}
}
}
if !packagesToReallyRemove.isEmpty {
processedItem["packages"] = packagesToReallyRemove
} else {
// no packages that belong to this item only
displayWarning("could not find unique packages to remove for \(name)")
for msg in pkgReferencesMessages {
displayWarning(msg)
}
return false
}
}
processedItem["uninstall_method"] = uninstallMethod
if uninstallMethod == "remove_copied_items" {
if let itemsToCopy = uninstallItem["items_to_copy"] as? [PlistDict] {
processedItem["items_to_remove"] = itemsToCopy
} else {
displayWarning("Can't uninstall \(name) because there is no info on installed items.")
return false
}
} else if uninstallMethod == "uninstall_script" {
if let uninstallScript = uninstallItem["uninstall_script"] as? String {
processedItem["uninstall_script"] = uninstallScript
} else {
displayWarning("Can't uninstall \(name) because uninstall_script is undefined or invalid.")
return false
}
} else if uninstallMethod == "uninstall_package" {
if let location = uninstallItem["uninstaller_item_location"] as? String {
do {
_ = try downloadInstallerItem(
uninstallItem, installInfo: installInfo, uninstalling: true
)
processedItem["uninstaller_item"] = baseName(location)
} catch FetchError.verification {
displayWarning("Can't uninstall \(name) because the integrity check for the uninstall package failed.")
return false
} catch {
displayWarning("Failed to download the uninstaller for \(name) because \(error.localizedDescription)")
return false
}
} else {
displayWarning("Can't uninstall \(name) because there is no URL for the uninstall package.")
return false
}
}
// before we add this removal to the list,
// check for installed updates and add them to the
// removal list as well
var updateList = lookForUpdatesFor(uninstallItemName, catalogList: catalogList)
updateList += lookForUpdatesFor(uninstallItemNameWVersion, catalogList: catalogList)
updateList += lookForUpdatesFor(altUninstallItemNameWVersion, catalogList: catalogList)
updateList = Array(Set(updateList))
for updateItem in updateList {
// call us recursively
_ = await processRemoval(
updateItem, catalogList: catalogList, installInfo: &installInfo
)
}
// finish recording info for this removal
processedItem["installed"] = true
processedItem["installed_version"] = version
appendToProcessedRemovals(processedItem)
displayDetail("Removal of \(manifestItemNameWithVersion) added to managedsoftwareupdate tasks.")
return true
}