From f0af5a885f7764525f9fa50503f5fdeb73cb31fc Mon Sep 17 00:00:00 2001 From: Greg Neagle Date: Fri, 16 Aug 2024 09:14:46 -0700 Subject: [PATCH] More implementation of updatecheck/download functions; refactoring of parts of network/fetch; adding support for localizedDescription to MunkiError and FetchError --- .../cli/munki/munki.xcodeproj/project.pbxproj | 12 + code/cli/munki/shared/errors.swift | 7 + code/cli/munki/shared/installer/core.swift | 4 +- code/cli/munki/shared/network/fetch.swift | 96 ++++- code/cli/munki/shared/network/urls.swift | 59 +++ code/cli/munki/shared/reports.swift | 2 +- .../munki/shared/updatecheck/download.swift | 363 ++++++++++++++++++ 7 files changed, 526 insertions(+), 17 deletions(-) create mode 100644 code/cli/munki/shared/network/urls.swift diff --git a/code/cli/munki/munki.xcodeproj/project.pbxproj b/code/cli/munki/munki.xcodeproj/project.pbxproj index ed3c8614..fd858539 100644 --- a/code/cli/munki/munki.xcodeproj/project.pbxproj +++ b/code/cli/munki/munki.xcodeproj/project.pbxproj @@ -24,6 +24,10 @@ C01364592C3265D6008DB215 /* display.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01364572C3265D6008DB215 /* display.swift */; }; C01792D32C6BC81B008CBC22 /* fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01792D22C6BC81B008CBC22 /* fetch.swift */; }; C01792D42C6BC81B008CBC22 /* fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01792D22C6BC81B008CBC22 /* fetch.swift */; }; + C01792D62C6E58D1008CBC22 /* download.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01792D52C6E58D1008CBC22 /* download.swift */; }; + C01792D72C6E58D1008CBC22 /* download.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01792D52C6E58D1008CBC22 /* download.swift */; }; + C01792D92C6EC09C008CBC22 /* urls.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01792D82C6EC09C008CBC22 /* urls.swift */; }; + C01792DA2C6EC09C008CBC22 /* urls.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01792D82C6EC09C008CBC22 /* urls.swift */; }; C0209B862C63C9FD007664A0 /* stoprequested.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0209B852C63C9FD007664A0 /* stoprequested.swift */; }; C0209B872C63C9FD007664A0 /* stoprequested.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0209B852C63C9FD007664A0 /* stoprequested.swift */; }; C0209B892C63DDF1007664A0 /* info.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0209B882C63DDF1007664A0 /* info.swift */; }; @@ -292,6 +296,8 @@ C01364532C321FE7008DB215 /* dmgutils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = dmgutils.swift; sourceTree = ""; }; C01364572C3265D6008DB215 /* display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = display.swift; sourceTree = ""; }; C01792D22C6BC81B008CBC22 /* fetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = fetch.swift; sourceTree = ""; }; + C01792D52C6E58D1008CBC22 /* download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = download.swift; sourceTree = ""; }; + C01792D82C6EC09C008CBC22 /* urls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = urls.swift; sourceTree = ""; }; C0209B852C63C9FD007664A0 /* stoprequested.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = stoprequested.swift; sourceTree = ""; }; C0209B882C63DDF1007664A0 /* info.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = info.swift; sourceTree = ""; }; C0209B8F2C63E3B7007664A0 /* launchapp */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = launchapp; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -618,6 +624,7 @@ isa = PBXGroup; children = ( C07AED6A2C66F56C00DE6119 /* manifests.swift */, + C01792D52C6E58D1008CBC22 /* download.swift */, ); path = updatecheck; sourceTree = ""; @@ -628,6 +635,7 @@ C07AED6E2C67DF6B00DE6119 /* gurl.swift */, C07AED712C67F77000DE6119 /* sslerrors.swift */, C01792D22C6BC81B008CBC22 /* fetch.swift */, + C01792D82C6EC09C008CBC22 /* urls.swift */, ); path = network; sourceTree = ""; @@ -1001,6 +1009,7 @@ C07AED662C66D0A000DE6119 /* facts.swift in Sources */, C030A98F2C39C135007F0B34 /* munkihash.swift in Sources */, C07AED722C67F77000DE6119 /* sslerrors.swift in Sources */, + C01792D62C6E58D1008CBC22 /* download.swift in Sources */, C0D00FB62C45BCB90021DA9C /* errors.swift in Sources */, C07A6FB02C2B22A400090743 /* prefs.swift in Sources */, C0D9C2972C6012C80019A067 /* dmg.swift in Sources */, @@ -1025,6 +1034,7 @@ C07AED6F2C67DF6B00DE6119 /* gurl.swift in Sources */, C07AED632C66CFBD00DE6119 /* appinventory.swift in Sources */, C043ED232C483EEE0047C025 /* rmpkgs.swift in Sources */, + C01792D92C6EC09C008CBC22 /* urls.swift in Sources */, C0209B862C63C9FD007664A0 /* stoprequested.swift in Sources */, C07074DF2C33B9A000B86310 /* reports.swift in Sources */, ); @@ -1049,6 +1059,7 @@ C0D00FB12C458EAA0021DA9C /* version.swift in Sources */, C030A9C32C41B581007F0B34 /* pkginfoOptions.swift in Sources */, C01364552C321FE7008DB215 /* dmgutils.swift in Sources */, + C01792D72C6E58D1008CBC22 /* download.swift in Sources */, C0209B872C63C9FD007664A0 /* stoprequested.swift in Sources */, C0D9C29B2C607D390019A067 /* xattr.swift in Sources */, C030A9BC2C3F4CF4007F0B34 /* munkiimportlib.swift in Sources */, @@ -1060,6 +1071,7 @@ C013644E2C30F5D8008DB215 /* RepoFactory.swift in Sources */, C07A6FBE2C2B5AF000090743 /* prefs.swift in Sources */, C07A6FBA2C2B5ADE00090743 /* main.swift in Sources */, + C01792DA2C6EC09C008CBC22 /* urls.swift in Sources */, C030A9B72C3DF6D0007F0B34 /* fileutils.swift in Sources */, C07074DD2C33AE5F00B86310 /* munkilog.swift in Sources */, C07A6FBF2C2B5AF400090743 /* constants.swift in Sources */, diff --git a/code/cli/munki/shared/errors.swift b/code/cli/munki/shared/errors.swift index 162e5a0e..268af4ae 100644 --- a/code/cli/munki/shared/errors.swift +++ b/code/cli/munki/shared/errors.swift @@ -34,6 +34,13 @@ class MunkiError: Error, CustomStringConvertible { } } +extension MunkiError: LocalizedError { + // cheap hack + var errorDescription: String? { + return message + } +} + struct UserCancelled: Error { // an exception to throw when user cancels } diff --git a/code/cli/munki/shared/installer/core.swift b/code/cli/munki/shared/installer/core.swift index 8691f1dc..4ddbdf03 100644 --- a/code/cli/munki/shared/installer/core.swift +++ b/code/cli/munki/shared/installer/core.swift @@ -660,7 +660,7 @@ func doInstallsAndRemovals(onlyUnattended: Bool = false) async -> Int { // this is janky because it relies on stuff being recorded to the report var optionalInstalls = installInfo["optional_installs"] as? [PlistDict] ?? [PlistDict]() if !optionalInstalls.isEmpty { - if let removalResults = Report.shared.retreive(key: "RemovalResults") as? [PlistDict] { + if let removalResults = Report.shared.retrieve(key: "RemovalResults") as? [PlistDict] { for (index, optionalInstall) in optionalInstalls.enumerated() { let optionalInstallName = optionalInstall["name"] as? String ?? "" for removal in removalResults { @@ -680,7 +680,7 @@ func doInstallsAndRemovals(onlyUnattended: Bool = false) async -> Int { } } } - if let installResults = Report.shared.retreive(key: "InstallResults") as? [PlistDict] { + if let installResults = Report.shared.retrieve(key: "InstallResults") as? [PlistDict] { for (index, optionalInstall) in optionalInstalls.enumerated() { let optionalInstallName = optionalInstall["name"] as? String ?? "" let optionalInstallVersion = optionalInstall["version_to_install"] as? String ?? "" diff --git a/code/cli/munki/shared/network/fetch.swift b/code/cli/munki/shared/network/fetch.swift index 22e66884..0cdba422 100644 --- a/code/cli/munki/shared/network/fetch.swift +++ b/code/cli/munki/shared/network/fetch.swift @@ -23,6 +23,23 @@ enum FetchError: Error { case verification } +extension FetchError: LocalizedError { + var errorDescription: String? { + switch self { + case let .connection(errorCode, description): + return "Connection error \(errorCode): \(description)" + case let .http(errorCode, description): + return "HTTP error \(errorCode): \(description)" + case let .download(errorCode, description): + return "Download error \(errorCode): \(description)" + case let .fileSystem(description): + return "File system error: \(description)" + case .verification: + return "Checksum verification error" + } + } +} + func storeCachedChecksum(toPath path: String, hash: String? = nil) -> String? { let fhash: String = if let hash { hash @@ -122,6 +139,11 @@ func headerDictFromList(_ strList: [String]?) -> [String: String] { return headerDict } +func runMiddleware(options: GurlOptions, pkginfo _: PlistDict?) -> GurlOptions { + // placeholder function + return options +} + func getURL( _ url: String, destinationPath: String, @@ -159,7 +181,7 @@ func getURL( let ignoreSystemProxy = pref("IgnoreSystemProxies") as? Bool ?? false - let options = GurlOptions( + var options = GurlOptions( url: url, destinationPath: tempDownloadPath, additionalHeaders: headerDictFromList(customHeaders), @@ -172,7 +194,7 @@ func getURL( ) // TODO: middleware support - // (which will use pkginfo) + options = runMiddleware(options: options, pkginfo: pkginfo) let session = Gurl(options: options) var displayMessage = message @@ -526,7 +548,7 @@ func getResourceIfChangedAtomically( return changed } -func munkiResource( +func fetchMunkiResourceByURL( _ url: String, destinationPath: String, message: String = "", @@ -535,7 +557,7 @@ func munkiResource( verify: Bool = false, pkginfo: PlistDict? = nil ) throws -> Bool { - // The high-level function for getting resources from the Munki repo. + // A high-level function for getting resources from the Munki repo. // Gets a given URL from the Munki server. // Adds any additional headers to the request if present // Throws a FetchError if there's an error @@ -549,6 +571,7 @@ func munkiResource( // another-custom-header: bar value // let customHeaders = pref(ADDITIONAL_HTTP_HEADERS_KEY) as? [String] + return try getResourceIfChangedAtomically( url, destinationPath: destinationPath, @@ -561,9 +584,45 @@ func munkiResource( ) } -func getDataFromURL(_ url: String) throws -> Data? { - // Returns data from url as string. We use the existing - // munkiResource function so any custom +enum MunkiResourceType: String { + case catalog = "catalogs" + case clientResource = "client_resources" + case icon = "icons" + case manifest = "manifests" + case package = "pkgs" +} + +func fetchMunkiResource( + kind: MunkiResourceType, + name: String, + destinationPath: String, + message: String = "", + resume: Bool = false, + expectedHash: String? = nil, + verify: Bool = false, + pkginfo: PlistDict? = nil +) throws -> Bool { + // An even higher-level function for getting resources from the Munki repo. + guard let url = munkiRepoURL(kind.rawValue, resource: name) else { + throw FetchError.connection( + errorCode: -1, + description: "Could not encode all characters in URL" + ) + } + return try fetchMunkiResourceByURL( + url, + destinationPath: destinationPath, + message: message, + resume: resume, + expectedHash: expectedHash, + verify: verify, + pkginfo: pkginfo + ) +} + +func getDataFromServer(_ url: String) throws -> Data? { + // Returns data from server as string. + // We use the existing fetchMunkiResource function so any custom // authentication/authorization headers are used // (including, eventually, middleware-generated headers) // May throw a FetchError @@ -573,20 +632,29 @@ func getDataFromURL(_ url: String) throws -> Data? { return nil } defer { try? FileManager.default.removeItem(atPath: tmpDir) } - let urlDataPath = (tmpDir as NSString).appendingPathComponent("urldata") - _ = try munkiResource(url, destinationPath: urlDataPath) - return FileManager.default.contents(atPath: urlDataPath) + let tempDataPath = (tmpDir as NSString).appendingPathComponent("urldata") + _ = try fetchMunkiResourceByURL( + url, destinationPath: tempDataPath + ) + return FileManager.default.contents(atPath: tempDataPath) } -func checkServer(_ urlString: String) -> (Int, String) { +func checkServer(_ urlString: String = "") -> (Int, String) { // A function we can call to check to see if the server is // available before we kick off a full run. This can be fooled by // ISPs that return results for non-existent web servers... // Returns a tuple (exitCode, exitDescription) - guard let url = URL(string: urlString) else { - return (-1, "Invalid url string") + let serverURL: String = if !urlString.isEmpty { + urlString + } else { + munkiRepoURL() ?? "" } + + guard let url = URL(string: serverURL) else { + return (-1, "Invalid URL") + } + if ["http", "https"].contains(url.scheme) { // pass } else if url.scheme == "file" { @@ -601,7 +669,7 @@ func checkServer(_ urlString: String) -> (Int, String) { return (-1, "Unsupported URL scheme: \(url.scheme ?? "")") } do { - _ = try getDataFromURL(urlString) + _ = try getDataFromServer(url.absoluteString) } catch let err as FetchError { switch err { case let .connection(errorCode, description): diff --git a/code/cli/munki/shared/network/urls.swift b/code/cli/munki/shared/network/urls.swift new file mode 100644 index 00000000..44d9adb6 --- /dev/null +++ b/code/cli/munki/shared/network/urls.swift @@ -0,0 +1,59 @@ +// +// urls.swift +// munki +// +// Created by Greg Neagle on 8/15/24. +// + +import Foundation + +func composedURLWithBase(_ baseURLString: String, adding path: String) -> String { + let baseURL = URL(string: baseURLString) + let composedURL = URL(string: path, relativeTo: baseURL) + return composedURL?.absoluteString ?? "" +} + +func munkiRepoURL(_ type: String = "", resource: String = "") -> String? { + // we could use composedURLWithBase, but that doesn't handle + // URLs in the format of CGI invocations correctly, and would not + // be consistent with the behavior of the Python version of Munki + // So instead we'll do simple string concatenation + // (with percent-encoding of the resource path) + + let munkiBaseURL = pref("SoftwareRepoURL") as? String ?? "" + if type.isEmpty { + return munkiBaseURL + } + let map = [ + "catalogs": "CatalogURL", + "client_resources": "ClientResourceURL", + "icons": "IconURL", + "manifests": "ManifestURL", + "pkgs": "PackageURL", + ] + guard let encodedType = (type as NSString).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { + // encoding failed + return nil + } + // we're not actually handling errors in percent-encoding + var typeURL = munkiBaseURL + "/" + encodedType + "/" + // if a more specific URL has been defined in preferences, use that + if let key = map[type] { + if let testURL = pref(key) as? String { + // add a trailing slash if needed + if testURL.hasSuffix("/") || testURL.hasSuffix("?") { + typeURL = testURL + } else { + typeURL = testURL + "/" + } + } + } + if resource.isEmpty { + return typeURL + } + if let encodedResource = (resource as NSString).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) { + return typeURL + encodedResource + } + // encoding failed + return nil +} diff --git a/code/cli/munki/shared/reports.swift b/code/cli/munki/shared/reports.swift index 99ca8b59..57e3e43f 100644 --- a/code/cli/munki/shared/reports.swift +++ b/code/cli/munki/shared/reports.swift @@ -34,7 +34,7 @@ class Report { report[key] = value } - func retreive(key: String) -> Any? { + func retrieve(key: String) -> Any? { return report[key] } diff --git a/code/cli/munki/shared/updatecheck/download.swift b/code/cli/munki/shared/updatecheck/download.swift index 43a3d50c..50791a58 100644 --- a/code/cli/munki/shared/updatecheck/download.swift +++ b/code/cli/munki/shared/updatecheck/download.swift @@ -6,3 +6,366 @@ // import Foundation + +func baseName(_ str: String) -> String { + // Return a basename string. + // Examples: + // "http://foo/bar/path/foo.dmg" => "foo.dmg" + // "/path/foo.dmg" => "foo.dmg" + + if let url = URL(string: str) { + return url.lastPathComponent + } else { + return (str as NSString).lastPathComponent + } +} + +func getDownloadCachePath(_ urlString: String) -> String { + // For a URL, return the path that the download should cache to. + // + // Returns a string. + return managedInstallsDir(subpath: "Cache/" + baseName(urlString)) +} + +func enoughDiskSpaceFor( + _ item: PlistDict, + installList: [PlistDict] = [], + uninstalling: Bool = false, + warn: Bool = true, + precaching: Bool = false +) -> Bool { + // Determine if there is enough disk space to download and install + // the installer item. + + // fudgefactor is 100MB + let fudgefactor = 102_400 // KBytes + var alreadyDownloadedSize = 0 + if let installerItemLocation = item["installer_item_location"] as? String { + let downloadedPath = getDownloadCachePath(installerItemLocation) + if pathExists(downloadedPath) { + alreadyDownloadedSize = getSize(downloadedPath) / 1024 // KBytes + } + } + var installerItemSize = item["installer_item_size"] as? Int ?? 0 // KBytes + var installedSize = item["installed_size"] as? Int ?? installerItemSize // KBytes + if uninstalling { + installedSize = 0 + installerItemSize = 0 + if let uninstallerItemSize = item["uninstaller_item_size"] as? Int { + installerItemSize = uninstallerItemSize // KBytes + } + } + let diskSpaceNeeded = installerItemSize - alreadyDownloadedSize + installedSize + fudgefactor + + var diskSpace = availableDiskSpace() // KBytes + for additionalItem in installList { + // subtract space needed for other items that are to be installed + diskSpace -= (additionalItem["installed_size"] as? Int ?? 0) + } + + if diskSpaceNeeded > diskSpace, !precaching { + // try to clear space by deleting some precached items + // TODO: uncache(diskSpaceNeeded - diskSpace) + // now re-calc + diskSpace = availableDiskSpace() + for additionalItem in installList { + // subtract space needed for other items that are to be installed + diskSpace -= (additionalItem["installed_size"] as? Int ?? 0) + } + } + + if diskSpace >= diskSpaceNeeded { + return true + } + + // we don't have enough space + if warn { + let itemName = item["name"] as? String ?? "" + if uninstalling { + displayWarning("There is insufficient disk space to download the uninstaller for \(itemName)") + } else { + displayWarning("There is insufficient disk space to download and install \(itemName)") + } + displayWarning("\(Int(diskSpaceNeeded / 1024))MB needed, \(Int(diskSpace / 1024))MB available") + } + return false +} + +func downloadInstallerItem( + _ item: PlistDict, + installInfo: PlistDict, + uninstalling: Bool = false, + precaching: Bool = false +) throws -> Bool { + // Downloads an (un)installer item. + // Returns true if the item was downloaded, + // false if it was already cached. + // Thows an error if there are issues + + let downloadItemKey = if uninstalling, item.keys.contains("uninstaller_item_location") { + "uninstaller_item_location" + } else { + "installer_item_location" + } + let itemHashKey = if uninstalling, item.keys.contains("uninstaller_item_location") { + "uninstaller_item_hash" + } else { + "installer_item_hash" + } + guard let location = item[downloadItemKey] as? String else { + throw FetchError.download( + errorCode: -1, + description: "No \(downloadItemKey) in pkginfo" + ) + } + + // pkginfos support two keys that can essentially override the + // normal URL generation for Munki repo items. But they are not + // commonly used. + let alternatePkgURL: String = if let packageCompleteURL = item["PackageCompleteURL"] as? String { + packageCompleteURL + } else if let packageURL = item["PackageURL"] as? String { + composedURLWithBase(packageURL, adding: location) + } else { + "" + } + + let pkgName = baseName(location) + displayDebug2("Package name is: \(pkgName)") + if !alternatePkgURL.isEmpty { + displayDebug2("Download URL is: \(alternatePkgURL)") + } + + let destinationPath = getDownloadCachePath(location) + displayDebug2("Downloading to: \(destinationPath)") + + if !pathExists(destinationPath) { + // check to see if there is enough free space to download and install + let installList = installInfo["managed_installs"] as? [PlistDict] ?? [] + if !enoughDiskSpaceFor( + item, + installList: installList, + uninstalling: uninstalling, + precaching: precaching + ) { + throw FetchError.download( + errorCode: -1, + description: "Insufficient disk space to download and install \(pkgName)" + ) + } + } + displayDetail("Downloading \(pkgName) from \(location)") + let downloadMessage = "Downloading \(pkgName)..." + let expectedHash = item[itemHashKey] as? String + if alternatePkgURL.isEmpty { + return try fetchMunkiResource( + kind: .package, + name: pkgName, + destinationPath: destinationPath, + message: downloadMessage, + resume: true, + expectedHash: expectedHash, + verify: true, + pkginfo: item + ) + } else { + // use alternatePkgURL + return try fetchMunkiResourceByURL( + alternatePkgURL, + destinationPath: destinationPath, + message: downloadMessage, + resume: true, + expectedHash: expectedHash, + verify: true, + pkginfo: item + ) + } +} + +let ICON_HASHES_PLIST_NAME = "_icon_hashes.plist" + +func cleanUpIconsDir(keepList: [String] = []) { + // Remove any cached/downloaded icons that aren't in the list of ones + // to keep + let itemsToKeep = keepList + [ICON_HASHES_PLIST_NAME] + let iconsDir = managedInstallsDir(subpath: "icons") + let filemanager = FileManager.default + let dirEnum = filemanager.enumerator(atPath: iconsDir) + while let file = dirEnum?.nextObject() as? String { + let fullPath = (iconsDir as NSString).appendingPathComponent(file) + if !pathIsDirectory(fullPath) { + if !itemsToKeep.contains(file) { + try? filemanager.removeItem(atPath: fullPath) + } + } else if let dirContents = try? filemanager.contentsOfDirectory(atPath: fullPath), + dirContents.isEmpty + { + // remove any empty directories as well + try? filemanager.removeItem(atPath: fullPath) + } + } +} + +func getIconHashes() -> [String: String] { + // Attempts to download the dictionary of compiled icon hashes + let iconsHashesPlist = managedInstallsDir(subpath: "icons/\(ICON_HASHES_PLIST_NAME)") + do { + _ = try fetchMunkiResource( + kind: .icon, + name: ICON_HASHES_PLIST_NAME, + destinationPath: iconsHashesPlist, + message: "Getting list of available icons" + ) + return try readPlist(fromFile: iconsHashesPlist) as? [String: String] ?? [:] + } catch { + displayDebug1("Error while retreiving icon hashes: \(error.localizedDescription)") + return [String: String]() + } +} + +func downloadIcons(_ itemList: [PlistDict]) { + // Attempts to download icons (actually image files) for items in + // itemList + var iconsToKeep = [String]() + let iconsDir = managedInstallsDir(subpath: "icons") + let iconHashes = getIconHashes() + + for item in itemList { + var iconName = item["icon_name"] as? String ?? item["name"] as? String ?? "" + if (iconName as NSString).pathExtension.isEmpty { + iconName += ".png" + } + iconsToKeep.append(iconName) + let serverHash: String = if let iconHash = item["icon_hash"] as? String { + iconHash + } else { + iconHashes[iconName] ?? "" + } + let iconPath = (iconsDir as NSString).appendingPathComponent(iconName) + var localHash = "" + if pathIsRegularFile(iconPath) { + // have we already downloaded it? If so get the local hash + if let data = try? getXattr(named: XATTR_SHA, atPath: iconPath) { + localHash = String(data: data, encoding: .utf8) ?? "" + } else { + // get hash and also store for future use + localHash = storeCachedChecksum(toPath: iconPath) ?? "" + } + } + let iconSubDir = (iconPath as NSString).deletingLastPathComponent + if !pathIsDirectory(iconSubDir) { + let success = createMissingDirs(iconSubDir) + if !success { + displayError("Could not create \(iconSubDir)") + continue + } + } + if serverHash != localHash { + // hashes don't match, so download the icon + if !iconHashes.isEmpty, !iconHashes.keys.contains(iconName) { + // if we have a dict of icon hashes, and the icon name is not + // in that dict, then there's no point in attempting to + // download this icon + continue + } + let itemName = item["display_name"] as? String ?? item["name"] as? String ?? "" + do { + _ = try fetchMunkiResource( + kind: .icon, + name: iconName, + destinationPath: iconPath, + message: "Getting icon \(iconName) for \(itemName)..." + ) + _ = storeCachedChecksum(toPath: iconPath) + } catch { + displayDebug1("Error when retrieving icon \(iconName) from the server: \(error.localizedDescription)") + } + } + } + cleanUpIconsDir(keepList: iconsToKeep) +} + +func downloadClientResources() { + // Download client customization resources (if any). + + // Munki's preferences can specify an explicit name + // under ClientResourcesFilename + // if that doesn't exist, use the primary manifest name as the + // filename. If that fails, try site_default.zip + + // build a list of resource names to request from the server + var filenames = [String]() + if let resourcesName = pref("ClientResourcesFilename") as? String { + if (resourcesName as NSString).pathExtension.isEmpty { + filenames.append(resourcesName + ".zip") + } else { + filenames.append(resourcesName) + } + } else { + // TODO: make a better way to retrieve the current manifest name + if let manifestName = Report.shared.retrieve(key: "ManifestName") as? String { + filenames.append(manifestName + ".zip") + } + } + filenames.append("site_default.zip") + + let resourceDir = managedInstallsDir(subpath: "client_resources") + // make sure local resource directory exists + if !pathIsDirectory(resourceDir) { + let success = createMissingDirs(resourceDir) + if !success { + displayError("Could not create \(resourceDir)") + return + } + } + let resourceArchivePath = (resourceDir as NSString).appendingPathComponent("custom.zip") + let message = "Getting client resources..." + var downloadedResourcePath = "" + for filename in filenames { + let resourceURL = munkiRepoURL("client_resources", resource: filename) + do { + _ = try fetchMunkiResource( + kind: .clientResource, + name: filename, + destinationPath: resourceArchivePath, + message: message + ) + downloadedResourcePath = resourceArchivePath + break + } catch { + displayDebug1("Could not retrieve client resources with name \(filename): \(error.localizedDescription)") + } + } + if downloadedResourcePath.isEmpty { + // make sure we don't have an old custom.zip hanging around + if pathExists(resourceArchivePath) { + do { + try FileManager.default.removeItem(atPath: resourceArchivePath) + } catch { + displayError("Could not remove stale \(resourceArchivePath): \(error.localizedDescription)") + } + } + } +} + +func downloadCatalog(_ catalogName: String) -> String { + // Attempt to download a catalog from the Munki server, Returns the path to + // the downloaded catalog file + let catalogPath = managedInstallsDir(subpath: "catalogs/\(catalogName)") + displayDetail("Getting catalog \(catalogName)...") + let message = "Retrieving catalog \(catalogName)..." + do { + _ = try fetchMunkiResource( + kind: .catalog, + name: catalogName, + destinationPath: catalogPath, + message: message + ) + return catalogPath + } catch { + displayError("Could not retrieve catalog \(catalogName) from server: \(error.localizedDescription)") + } + return "" +} + +// TODO: precaching support