From 564c409c156c14af984d6675d12c367eaa6b61e8 Mon Sep 17 00:00:00 2001 From: "T. R. Bernstein" Date: Wed, 11 Mar 2026 17:58:06 +0100 Subject: [PATCH] Implement watching a path Each inotify instance produces events for paths in its watch list. Each item in the watch list is identified by its watch descriptor. Different paths can be watched for different events. --- Sources/Inotify/Inotify.swift | 11 +++++ Sources/Inotify/InotifyError.swift | 3 ++ Sources/Inotify/InotifyEventMask.swift | 47 +++++++++++++++++++ Tests/InotifyIntegrationTests/Utilities.swift | 10 ++++ .../InotifyIntegrationTests/WatchTests.swift | 21 +++++++++ 5 files changed, 92 insertions(+) create mode 100644 Sources/Inotify/InotifyEventMask.swift create mode 100644 Tests/InotifyIntegrationTests/Utilities.swift create mode 100644 Tests/InotifyIntegrationTests/WatchTests.swift diff --git a/Sources/Inotify/Inotify.swift b/Sources/Inotify/Inotify.swift index 298c899..7e078cd 100644 --- a/Sources/Inotify/Inotify.swift +++ b/Sources/Inotify/Inotify.swift @@ -2,6 +2,7 @@ import CInotify public actor Inotify { private let fd: Int32 + private var watches: [Int32: String] = [:] public init() throws { self.fd = inotify_init1(Int32(IN_NONBLOCK | IN_CLOEXEC)) @@ -10,6 +11,16 @@ public actor Inotify { } } + @discardableResult + public func addWatch(path: String, mask: InotifyEventMask) throws -> Int32 { + let wd = inotify_add_watch(self.fd, path, mask.rawValue) + guard wd >= 0 else { + throw InotifyError.addWatchFailed(path: path, errno: cinotify_get_errno()) + } + watches[wd] = path + return wd + } + deinit { cinotify_deinit(self.fd) } diff --git a/Sources/Inotify/InotifyError.swift b/Sources/Inotify/InotifyError.swift index 3859856..824a336 100644 --- a/Sources/Inotify/InotifyError.swift +++ b/Sources/Inotify/InotifyError.swift @@ -2,11 +2,14 @@ import CInotify public enum InotifyError: Error, Sendable, CustomStringConvertible { case initFailed(errno: Int32) + case addWatchFailed(path: String, errno: Int32) public var description: String { switch self { case .initFailed(let code): "inotify_init1 failed: \(readableErrno(code))" + case .addWatchFailed(let path, let code): + "inotify_add_watch failed for '\(path)': \(readableErrno(code))" } } diff --git a/Sources/Inotify/InotifyEventMask.swift b/Sources/Inotify/InotifyEventMask.swift new file mode 100644 index 0000000..6cbd439 --- /dev/null +++ b/Sources/Inotify/InotifyEventMask.swift @@ -0,0 +1,47 @@ +import CInotify + +public struct InotifyEventMask: OptionSet, Sendable, Hashable { + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + // MARK: - Watchable Events + + public static let access = InotifyEventMask(rawValue: UInt32(IN_ACCESS)) + public static let attrib = InotifyEventMask(rawValue: UInt32(IN_ATTRIB)) + public static let closeWrite = InotifyEventMask(rawValue: UInt32(IN_CLOSE_WRITE)) + public static let closeNoWrite = InotifyEventMask(rawValue: UInt32(IN_CLOSE_NOWRITE)) + public static let create = InotifyEventMask(rawValue: UInt32(IN_CREATE)) + public static let delete = InotifyEventMask(rawValue: UInt32(IN_DELETE)) + public static let deleteSelf = InotifyEventMask(rawValue: UInt32(IN_DELETE_SELF)) + public static let modify = InotifyEventMask(rawValue: UInt32(IN_MODIFY)) + public static let moveSelf = InotifyEventMask(rawValue: UInt32(IN_MOVE_SELF)) + public static let movedFrom = InotifyEventMask(rawValue: UInt32(IN_MOVED_FROM)) + public static let movedTo = InotifyEventMask(rawValue: UInt32(IN_MOVED_TO)) + public static let open = InotifyEventMask(rawValue: UInt32(IN_OPEN)) + + // MARK: - Combinations + + public static let move: InotifyEventMask = [.movedFrom, .movedTo] + public static let close: InotifyEventMask = [.closeWrite, .closeNoWrite] + public static let allEvents: InotifyEventMask = [ + .access, .attrib, .closeWrite, .closeNoWrite, + .create, .delete, .deleteSelf, .modify, + .moveSelf, .movedFrom, .movedTo, .open + ] + + // MARK: - Watch Flags + + public static let dontFollow = InotifyEventMask(rawValue: UInt32(IN_DONT_FOLLOW)) + public static let onlyDir = InotifyEventMask(rawValue: UInt32(IN_ONLYDIR)) + public static let oneShot = InotifyEventMask(rawValue: UInt32(IN_ONESHOT)) + + // MARK: - Kernel-Only Flags + + public static let isDir = InotifyEventMask(rawValue: UInt32(IN_ISDIR)) + public static let ignored = InotifyEventMask(rawValue: UInt32(IN_IGNORED)) + public static let queueOverflow = InotifyEventMask(rawValue: UInt32(IN_Q_OVERFLOW)) + public static let unmount = InotifyEventMask(rawValue: UInt32(IN_UNMOUNT)) +} diff --git a/Tests/InotifyIntegrationTests/Utilities.swift b/Tests/InotifyIntegrationTests/Utilities.swift new file mode 100644 index 0000000..1c08b65 --- /dev/null +++ b/Tests/InotifyIntegrationTests/Utilities.swift @@ -0,0 +1,10 @@ +import Foundation + +func withTempDir(_ body: (String) async throws -> Void) async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("InotifyIntegrationTests-\(UUID().uuidString)") + .path + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: dir) } + try await body(dir) +} diff --git a/Tests/InotifyIntegrationTests/WatchTests.swift b/Tests/InotifyIntegrationTests/WatchTests.swift new file mode 100644 index 0000000..178e13d --- /dev/null +++ b/Tests/InotifyIntegrationTests/WatchTests.swift @@ -0,0 +1,21 @@ +import Testing +import Foundation +@testable import Inotify + +@Suite("Watch Management") +struct WatchTests { + @Test func addWatchReturnsValidDescriptor() async throws { + try await withTempDir { dir in + let watcher = try Inotify() + let wd = try await watcher.addWatch(path: dir, mask: .allEvents) + #expect(wd >= 0) + } + } + + @Test func addWatchOnInvalidPathThrows() async throws { + let watcher = try Inotify() + await #expect(throws: InotifyError.self) { + try await watcher.addWatch(path: "/nonexistent-\(UUID())", mask: .allEvents) + } + } +}