import Foundation import NetFS /// SMB mounting via NetFS. Chosen over shelling out to `mount_smbfs` because /// NetFS reads credentials from the login Keychain on its own, and — when UI is /// allowed — presents the standard system auth sheet with the "remember /// password" checkbox. That sheet is the only supported way to create the /// Keychain entry, and this app must never handle the password itself. enum Mounter { // Option keys from NetFS.h. private static let uiOptionKey = "UIOption" as CFString private static let uiOptionNoUI = "NoUI" as CFString private static let softMountKey = "SoftMount" as CFString struct Result: Sendable { let share: String let status: Int32 /// Already-mounted counts as success. The share can be mounted between /// the UI reading its state and the click landing — most often by Finder /// — and showing a red error for "it is already how you wanted it" is /// just wrong. var ok: Bool { status == 0 || status == EEXIST || status == EBUSY } /// True when the failure looks like missing or rejected credentials, /// i.e. retrying with the auth sheet is worth offering. var needsCredentials: Bool { status == EAUTH || status == EACCES || status == EPERM } /// Short human explanation. ENOENT gets a specific one: a share the /// account is not listed in (`valid users` on the NAS) comes back as /// "no such file or directory", not as a permission error, which is /// otherwise a very misleading thing to show. var reason: String { switch status { case ENOENT: return "\(share): нет доступа или шара не существует" case EAUTH, EACCES, EPERM: return "\(share): нужен пароль" case ETIMEDOUT: return "\(share): таймаут" default: return "\(share): ошибка \(status)" } } } static func url(host: String, user: String, share: String) -> URL? { var components = URLComponents() components.scheme = "smb" components.host = host components.user = user components.path = "/" + share return components.url } /// Blocking — call off the main thread. /// /// `allowUI: false` is the silent path: it succeeds only if the Keychain /// already holds the credentials, and fails fast otherwise. Automatic /// mounts always use it, so reconnecting never throws an unexpected /// password sheet at the user. static func mount( host: String, user: String, share: String, allowUI: Bool ) -> Result { guard let url = url(host: host, user: user, share: share) else { return Result(share: share, status: EINVAL) } let openOptions = NSMutableDictionary() if !allowUI { openOptions[uiOptionKey] = uiOptionNoUI } // Soft mount: a NAS that disappears mid-session must return an error // rather than wedge every Finder window that touches the volume. let mountOptions = NSMutableDictionary() mountOptions[softMountKey] = true var mountpoints: Unmanaged? let status = NetFSMountURLSync( url as CFURL, nil, user as CFString, nil, openOptions, mountOptions, &mountpoints) mountpoints?.release() if status != 0 { NSLog("campusnas-monitor: mount %@ failed, status %d", share, status) } return Result(share: share, status: status) } /// Asks the server which shares it exports, via `smbutil view`. /// /// NetFS does have a share-enumeration API, but it is not declared in the /// public headers — reaching it would mean dlsym against private symbols, /// which breaks on an OS update. `smbutil` is a documented system tool and /// uses the same Keychain credentials. /// /// Filters to disk shares and drops administrative ones (`IPC$` and /// friends), which are never mountable as volumes. static func listShares( host: String, user: String, timeout: TimeInterval ) async -> [String] { let output = await Probe.commandOutput( "/usr/bin/smbutil", args: ["view", "-N", "//\(user)@\(host)"], timeout: timeout) guard let output else { Log.write("share enumeration failed for \(host)") return [] } var shares: [String] = [] for line in output.split(separator: "\n", omittingEmptySubsequences: true) { // Columns are separated by runs of spaces: "name Disk comment" let columns = line .components(separatedBy: " ") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } guard columns.count >= 2 else { continue } let name = columns[0] guard columns[1] == "Disk", !name.hasSuffix("$") else { continue } shares.append(name) } return shares.sorted() } static func unmount(mountPoint: String) -> Bool { let status = mountPoint.withCString { Darwin.unmount($0, 0) } if status != 0 { NSLog("campusnas-monitor: unmount %@ failed: %d", mountPoint, errno) } return status == 0 } /// Every spelling that means "this server": the configured name plus each /// address it resolves to. /// /// Needed because a mount can be created by something other than this app — /// Finder mounts from the server browser, an older mount made by IP — and /// those record a different host string. A plain case-sensitive `contains` /// missed them and reported live mounts as "not mounted". private static var aliasCache: (host: String, aliases: Set)? static func aliases(for host: String) -> Set { if let cached = aliasCache, cached.host == host { return cached.aliases } var result: Set = [host.lowercased()] var hints = addrinfo() hints.ai_family = AF_UNSPEC hints.ai_socktype = SOCK_STREAM var info: UnsafeMutablePointer? if getaddrinfo(host, nil, &hints, &info) == 0 { var node = info while let current = node { var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) if getnameinfo( current.pointee.ai_addr, current.pointee.ai_addrlen, &buffer, socklen_t(buffer.count), nil, 0, NI_NUMERICHOST) == 0 { result.insert(String(cString: buffer).lowercased()) } node = current.pointee.ai_next } freeaddrinfo(info) } aliasCache = (host, result) return result } /// Splits `//user@host/share` into its host and share parts. private static func parse(mountFrom: String) -> (host: String, share: String)? { guard mountFrom.hasPrefix("//") else { return nil } let body = mountFrom.dropFirst(2) guard let slash = body.firstIndex(of: "/") else { return nil } var host = String(body[body.startIndex.. mount point. /// Read from the kernel mount table rather than by listing /Volumes, so a /// leftover empty directory is never mistaken for a live mount. static func mountedShares(host: String) -> [String: String] { var buffer: UnsafeMutablePointer? let count = getmntinfo(&buffer, MNT_NOWAIT) guard count > 0, let buffer else { return [:] } let known = aliases(for: host) var result: [String: String] = [:] for index in 0..