diff --git a/Package.swift b/Package.swift index cd0cfa93..be25a5df 100644 --- a/Package.swift +++ b/Package.swift @@ -396,6 +396,7 @@ let package = Package( .product(name: "DNSClient", package: "DNSClient"), .product(name: "DNS", package: "DNS"), .product(name: "Logging", package: "swift-log"), + .product(name: "ContainerizationOS", package: "containerization"), ] ), .testTarget( diff --git a/Sources/Helpers/APIServer/DirectoryWatcher.swift b/Sources/DNSServer/DirectoryWatcher.swift similarity index 53% rename from Sources/Helpers/APIServer/DirectoryWatcher.swift rename to Sources/DNSServer/DirectoryWatcher.swift index 31693221..1f003e88 100644 --- a/Sources/Helpers/APIServer/DirectoryWatcher.swift +++ b/Sources/DNSServer/DirectoryWatcher.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerizationError +import ContainerizationOS import Foundation import Logging @@ -22,31 +23,33 @@ public class DirectoryWatcher { public let directoryURL: URL private let monitorQueue: DispatchQueue + private var parentSource: DispatchSourceFileSystemObject? private var source: DispatchSourceFileSystemObject? - private let log: Logger + private let log: Logger? - init(directoryURL: URL, log: Logger) { + public init(directoryURL: URL, log: Logger?) { self.directoryURL = directoryURL self.monitorQueue = DispatchQueue(label: "monitor:\(directoryURL.path)") self.log = log } - public func startWatching(handler: @escaping ([URL]) throws -> Void) throws { - guard source == nil else { - throw ContainerizationError(.invalidState, message: "already watching on \(directoryURL.path)") + private func _startWatching( + handler: @escaping ([URL]) throws -> Void + ) throws { + let descriptor = open(directoryURL.path, O_EVTONLY) + guard descriptor > 0 else { + throw ContainerizationError(.internalError, message: "cannot open \(directoryURL.path), descriptor=\(descriptor)") } do { let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path) try handler(files.map { directoryURL.appending(path: $0) }) } catch { - throw ContainerizationError(.invalidState, message: "failed to start watching on \(directoryURL.path)") + throw ContainerizationError(.internalError, message: "failed to run handler for \(directoryURL.path)") } - log.info("starting directory watcher for \(directoryURL.path)") - - let descriptor = open(directoryURL.path, O_EVTONLY) + log?.info("starting directory watcher for \(directoryURL.path)") let dispatchSource = DispatchSource.makeFileSystemObjectSource( fileDescriptor: descriptor, @@ -54,7 +57,6 @@ public class DirectoryWatcher { queue: monitorQueue ) - // Close the file descriptor when the source is cancelled dispatchSource.setCancelHandler { close(descriptor) } @@ -66,7 +68,7 @@ public class DirectoryWatcher { let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path) try handler(files.map { directoryURL.appending(path: $0) }) } catch { - self.log.error("failed to run DirectoryWatcher handler", metadata: ["error": "\(error)", "path": "\(directoryURL.path)"]) + self.log?.error("failed to run DirectoryWatcher handler", metadata: ["error": "\(error)", "path": "\(directoryURL.path)"]) } } @@ -74,11 +76,56 @@ public class DirectoryWatcher { dispatchSource.resume() } - deinit { - guard let source else { + public func startWatching(handler: @escaping ([URL]) throws -> Void) throws { + guard source == nil else { + throw ContainerizationError(.invalidState, message: "already watching on \(directoryURL.path)") + } + + let parent = directoryURL.deletingLastPathComponent().resolvingSymlinksInPathWithPrivate() + guard parent.isDirectory else { + throw ContainerizationError(.invalidState, message: "expected \(parent.path) to be an existing directory") + } + + guard !directoryURL.isSymlink else { + throw ContainerizationError(.invalidState, message: "expected \(directoryURL.path) not a symlink") + } + + guard directoryURL.isDirectory else { + log?.info("no \(directoryURL.path), start watching \(parent.path)") + + let descriptor = open(parent.path, O_EVTONLY) + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, + eventMask: .write, + queue: monitorQueue) + + source.setCancelHandler { + close(descriptor) + } + + source.setEventHandler { [weak self] in + guard let self else { return } + + if directoryURL.isDirectory { + do { + try _startWatching(handler: handler) + } catch { + log?.error("failed to start watching: \(error)") + } + source.cancel() + } + } + + parentSource = source + source.resume() return } - source.cancel() + try _startWatching(handler: handler) + } + + deinit { + parentSource?.cancel() + source?.cancel() } } diff --git a/Sources/Helpers/APIServer/APIServer+Start.swift b/Sources/Helpers/APIServer/APIServer+Start.swift index d9d38972..c9b73417 100644 --- a/Sources/Helpers/APIServer/APIServer+Start.swift +++ b/Sources/Helpers/APIServer/APIServer+Start.swift @@ -85,10 +85,15 @@ extension APIServer { $0[$1.key.rawValue] = $1.value }), log: log) - await withThrowingTaskGroup(of: Void.self) { group in + await withTaskGroup(of: Result.self) { group in group.addTask { log.info("starting XPC server") - try await server.listen() + do { + try await server.listen() + return .success(()) + } catch { + return .failure(error) + } } // start up host table DNS group.addTask { @@ -104,30 +109,47 @@ extension APIServer { "port": "\(Self.dnsPort)", ] ) - try await dnsServer.run(host: Self.listenAddress, port: Self.dnsPort) + do { + try await dnsServer.run(host: Self.listenAddress, port: Self.dnsPort) + return .success(()) + } catch { + return .failure(error) + } } // start up realhost DNS - /* group.addTask { - let localhostResolver = LocalhostDNSHandler(log: log) - try localhostResolver.monitorResolvers() - - let nxDomainResolver = NxDomainResolver() - let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver]) - let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver) - let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log) - log.info( - "starting DNS resolver for localhost", - metadata: [ - "host": "\(Self.listenAddress)", - "port": "\(Self.localhostDNSPort)", - ] - ) - try await dnsServer.run(host: Self.listenAddress, port: Self.localhostDNSPort) + do { + let localhostResolver = LocalhostDNSHandler(log: log) + try localhostResolver.monitorResolvers() + + let nxDomainResolver = NxDomainResolver() + let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver]) + let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver) + let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log) + log.info( + "starting DNS resolver for localhost", + metadata: [ + "host": "\(Self.listenAddress)", + "port": "\(Self.localhostDNSPort)", + ] + ) + try await dnsServer.run(host: Self.listenAddress, port: Self.localhostDNSPort) + return .success(()) + } catch { + return .failure(error) + } + } + + for await result in group { + switch result { + case .success(): + continue + case .failure(let error): + log.error("API server task failed: \(error)") + } } - */ } } catch { log.error("\(commandName) failed", metadata: ["error": "\(error)"]) diff --git a/Tests/DNSServerTests/DirectoryWatcherTest.swift b/Tests/DNSServerTests/DirectoryWatcherTest.swift new file mode 100644 index 00000000..89293337 --- /dev/null +++ b/Tests/DNSServerTests/DirectoryWatcherTest.swift @@ -0,0 +1,161 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// 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. +//===----------------------------------------------------------------------===// + +import ContainerizationError +import DNSServer +import Foundation +import Testing + +struct DirectoryWatcherTest { + let testUUID = UUID().uuidString + + private var testDir: URL! { + let tempDir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent(".clitests") + .appendingPathComponent(testUUID) + try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + return tempDir + } + + private func withTempDir(_ body: (URL) async throws -> T) async throws -> T { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + return try await body(tempDir) + } + + private class CreatedURLs { + public var urls: [URL] + + public init() { + self.urls = [] + } + + public func append(url: URL) { + urls.append(url) + } + } + + @Test func testWatchingExistingDirectory() async throws { + try await withTempDir { tempDir in + + let watcher = DirectoryWatcher(directoryURL: tempDir, log: nil) + let createdURLs = CreatedURLs() + let name = "newFile" + + #expect(throws: Never.self) { + try watcher.startWatching { [createdURLs] urls in + for url in urls where url.lastPathComponent == name { + createdURLs.append(url: url) + } + } + } + + try await Task.sleep(for: .milliseconds(500)) + let newFile = tempDir.appendingPathComponent(name) + FileManager.default.createFile(atPath: newFile.path, contents: nil) + try await Task.sleep(for: .milliseconds(500)) + + #expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect new file") + #expect(createdURLs.urls.first!.lastPathComponent == name) + } + } + + @Test func testWatchingNonExistingDirectory() async throws { + try await withTempDir { tempDir in + let uuid = UUID().uuidString + let childDir = tempDir.appendingPathComponent(uuid) + + let watcher = DirectoryWatcher(directoryURL: childDir, log: nil) + let createdURLs = CreatedURLs() + let name = "newFile" + + #expect(throws: Never.self) { + try watcher.startWatching { [createdURLs] urls in + for url in urls where url.lastPathComponent == name { + createdURLs.append(url: url) + } + } + } + + try await Task.sleep(for: .milliseconds(300)) + try FileManager.default.createDirectory(at: childDir, withIntermediateDirectories: true) + + try await Task.sleep(for: .milliseconds(300)) + let newFile = childDir.appendingPathComponent(name) + FileManager.default.createFile(atPath: newFile.path, contents: nil) + try await Task.sleep(for: .milliseconds(300)) + + #expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect parent directory") + #expect(createdURLs.urls.first!.lastPathComponent == name) + + } + + try await withTempDir { tempDir in + let parent = UUID().uuidString + let symlink = UUID().uuidString + let child = UUID().uuidString + + let parentDir = tempDir.appendingPathComponent(parent) + let symlinkDir = tempDir.appendingPathComponent(symlink) + let childDir = symlinkDir.appendingPathComponent(child) + + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: symlinkDir, withDestinationURL: parentDir) + + let watcher = DirectoryWatcher(directoryURL: childDir, log: nil) + let createdURLs = CreatedURLs() + let name = "newFile" + + #expect(throws: Never.self) { + try watcher.startWatching { [createdURLs] urls in + for url in urls where url.lastPathComponent == name { + createdURLs.append(url: url) + } + } + } + + try await Task.sleep(for: .milliseconds(300)) + try FileManager.default.createDirectory(at: childDir, withIntermediateDirectories: true) + + try await Task.sleep(for: .milliseconds(300)) + let newFile = childDir.appendingPathComponent(name) + FileManager.default.createFile(atPath: newFile.path, contents: nil) + try await Task.sleep(for: .milliseconds(300)) + + #expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect symbolic parent directory") + #expect(createdURLs.urls.first!.lastPathComponent == name) + } + } + + @Test func testWatchingNonExistingParent() async throws { + try await withTempDir { tempDir in + let parent = UUID().uuidString + let child = UUID().uuidString + let childDir = tempDir.appendingPathComponent(parent).appendingPathComponent(child) + + let watcher = DirectoryWatcher(directoryURL: childDir, log: nil) + #expect(throws: ContainerizationError.self, "directory watcher should fail if no parent") { + try watcher.startWatching { urls in } + } + } + } + +}