Extending an automatically watched tree to a new directory swallowed every error, so a reached watch limit or an unreadable directory left part of the tree unwatched without any sign. No call of the consumer is running at that moment, so the failures now arrive in the event stream as InotifyEvent.watchFailed, after the event that triggered the extension. The library watches what it can first: a reached limit ends the attempt, an unreadable directory is skipped with its subtree, and a directory that vanished in between is not reported. The buffer now carries the library's own events next to the kernel's, which keeps them in order and hides the stream's element type. The test runner drops root's DAC capabilities so that an unreadable directory can be tested.
216 lines
9.5 KiB
Swift
216 lines
9.5 KiB
Swift
import Foundation
|
|
import SystemPackage
|
|
import Testing
|
|
@testable import Inotify
|
|
|
|
@Suite("Recursive Event Detection")
|
|
struct RecursiveEventTests {
|
|
@Test func detectsFileCreationInSubfolder() async throws {
|
|
try await withTempDir { dir in
|
|
let subDirectory = "\(dir)/Subfolder"
|
|
let filepath = "\(subDirectory)/modify-target.txt"
|
|
try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true)
|
|
|
|
let events = try await getEventsForTrigger(
|
|
in: dir,
|
|
mask: [.create],
|
|
recursive: .recursive
|
|
) { _ in try createFile(at: "\(filepath)", contents: "hello") }
|
|
|
|
let createEvent = events.first { $0.mask.contains(.create) && $0.path.string == filepath }
|
|
#expect(createEvent != nil, "Expected CREATE for '\(filepath)', got: \(events)")
|
|
}
|
|
}
|
|
|
|
@Test func ignoresFileCreationInIgnoredSubfolder() async throws {
|
|
try await withTempDir { dir in
|
|
let subDirectory = "\(dir)/Subfolder"
|
|
let filepath = "\(subDirectory)/modify-target.txt"
|
|
try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true)
|
|
|
|
let events = try await getEventsForTrigger(
|
|
in: dir,
|
|
mask: [.create],
|
|
recursive: .recursive,
|
|
exclude: ["Subfolder"]
|
|
) { _ in try createFile(at: "\(filepath)", contents: "hello") }
|
|
|
|
let createEvent = events.first { $0.mask.contains(.create) && $0.path.string == filepath }
|
|
#expect(createEvent == nil, "Did not expect CREATE for '\(filepath)', got: \(events)")
|
|
}
|
|
}
|
|
|
|
@Test func ignoresFileCreationInASubfolderMatchingAnExcludedPattern() async throws {
|
|
try await withTempDir { dir in
|
|
let subDirectory = "\(dir)/@eaDir"
|
|
let filepath = "\(subDirectory)/modify-target.txt"
|
|
try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true)
|
|
|
|
let events = try await getEventsForTrigger(
|
|
in: dir,
|
|
mask: [.create],
|
|
recursive: .recursive,
|
|
excludePatterns: ["@*"]
|
|
) { _ in try createFile(at: "\(filepath)", contents: "hello") }
|
|
|
|
let createEvent = events.first { $0.mask.contains(.create) && $0.path.string == filepath }
|
|
#expect(createEvent == nil, "Did not expect CREATE for '\(filepath)', got: \(events)")
|
|
}
|
|
}
|
|
|
|
@Test func doesNotWatchANewSubfolderMatchingAnExcludedPattern() async throws {
|
|
try await withTempDir { dir in
|
|
let subDirectory = "\(dir)/@eaDir"
|
|
let filepath = "\(subDirectory)/modify-target.txt"
|
|
|
|
let events = try await getEventsForTrigger(
|
|
in: dir,
|
|
mask: [.create],
|
|
recursive: .withAutomaticSubtreeWatching,
|
|
excludePatterns: ["@*"]
|
|
) { _ in
|
|
try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true)
|
|
try await Task.sleep(for: .milliseconds(400))
|
|
try createFile(at: "\(filepath)", contents: "hello")
|
|
}
|
|
|
|
#expect(events.isEmpty, "Did not expect any event, got: \(events)")
|
|
}
|
|
}
|
|
|
|
@Test func newSubfoldersOfRecursiveWatchAreAutomaticallyWatchedToo() async throws {
|
|
try await withTempDir { dir in
|
|
let subDirectory = "\(dir)/Subfolder"
|
|
let filepath = "\(subDirectory)/modify-target.txt"
|
|
|
|
let events = try await getEventsForTrigger(
|
|
in: dir,
|
|
mask: [.create],
|
|
recursive: .withAutomaticSubtreeWatching
|
|
) { _ in
|
|
try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true)
|
|
try await Task.sleep(for: .milliseconds(400))
|
|
try createFile(at: "\(filepath)", contents: "hello")
|
|
}
|
|
|
|
let createEvent = events.first { $0.mask.contains(.create) && $0.path.string == filepath }
|
|
#expect(createEvent != nil, "Expected CREATE for '\(filepath)', got: \(events)")
|
|
}
|
|
}
|
|
|
|
@Test func stopsReportingForDirectoriesMovedOutOfTheWatchedTree() async throws {
|
|
try await withTempDir { dir in
|
|
let root = "\(dir)/Root"
|
|
let outside = "\(dir)/Outside"
|
|
let movedSource = "\(root)/Moved"
|
|
let movedDestination = "\(outside)/Moved"
|
|
let filename = "created-after-move.txt"
|
|
try FileManager.default.createDirectory(atPath: movedSource, withIntermediateDirectories: true)
|
|
try FileManager.default.createDirectory(atPath: outside, withIntermediateDirectories: true)
|
|
|
|
let events = try await getEventsForTrigger(
|
|
in: root,
|
|
mask: [.create, .movedFrom],
|
|
recursive: .withAutomaticSubtreeWatching
|
|
) { _ in
|
|
try FileManager.default.moveItem(atPath: movedSource, toPath: movedDestination)
|
|
try await Task.sleep(for: .milliseconds(400))
|
|
try createFile(at: "\(movedDestination)/\(filename)", contents: "hello")
|
|
}
|
|
|
|
let staleEvent = events.first { $0.mask.contains(.create) && $0.path.lastComponent?.string == filename }
|
|
#expect(staleEvent == nil, "Did not expect CREATE for '\(filename)' after its directory left the tree, got: \(events)")
|
|
}
|
|
}
|
|
|
|
@Test func watchesAndReportsContentOfDirectoriesMovedIntoTheTree() async throws {
|
|
try await withTempDir { dir in
|
|
let root = "\(dir)/Root"
|
|
let treeSource = "\(dir)/Outside/Tree"
|
|
let treeDestination = "\(root)/Tree"
|
|
try FileManager.default.createDirectory(atPath: "\(treeSource)/Sub", withIntermediateDirectories: true)
|
|
try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true)
|
|
try createFile(at: "\(treeSource)/existing.txt", contents: "hello")
|
|
try createFile(at: "\(treeSource)/Sub/nested.txt", contents: "hello")
|
|
|
|
let events = try await getEventsForTrigger(
|
|
in: root,
|
|
mask: [.create, .movedTo],
|
|
recursive: .withAutomaticSubtreeWatching
|
|
) { _ in
|
|
try FileManager.default.moveItem(atPath: treeSource, toPath: treeDestination)
|
|
try await Task.sleep(for: .milliseconds(400))
|
|
try createFile(at: "\(treeDestination)/Sub/created-after-move.txt", contents: "hello")
|
|
}
|
|
|
|
let movedIn = events.first { $0.mask.contains(.movedTo) && $0.mask.contains(.isDir) && $0.path.string == treeDestination }
|
|
#expect(movedIn != nil, "Expected MOVED_TO for '\(treeDestination)', got: \(events)")
|
|
|
|
let existing = events.first { $0.synthesized && $0.mask.contains(.movedTo) && $0.path.string == "\(treeDestination)/existing.txt" }
|
|
#expect(existing != nil, "Expected a synthesized MOVED_TO for the existing file, got: \(events)")
|
|
|
|
let subdirectory = events.first { $0.synthesized && $0.mask.contains(.isDir) && $0.path.string == "\(treeDestination)/Sub" }
|
|
#expect(subdirectory != nil, "Expected a synthesized MOVED_TO for the existing subdirectory, got: \(events)")
|
|
|
|
let nested = events.first { $0.synthesized && $0.path.string == "\(treeDestination)/Sub/nested.txt" }
|
|
#expect(nested != nil, "Expected a synthesized MOVED_TO for the nested file, got: \(events)")
|
|
|
|
let createdAfterMove = events.first { !$0.synthesized && $0.mask.contains(.create) && $0.path.string == "\(treeDestination)/Sub/created-after-move.txt" }
|
|
#expect(createdAfterMove != nil, "Expected CREATE inside the moved-in subdirectory, got: \(events)")
|
|
}
|
|
}
|
|
|
|
/// Needs a process that directory permissions apply to; the test runner
|
|
/// drops root's override capabilities for that.
|
|
@Test func reportsANewDirectoryItCannotReadAndWatchesItsSiblings() async throws {
|
|
try await withTempDir { dir in
|
|
let root = "\(dir)/Root"
|
|
let treeSource = "\(dir)/Outside/Grown"
|
|
let treeDestination = "\(root)/Grown"
|
|
let locked = "\(treeDestination)/Locked"
|
|
let filepath = "\(treeDestination)/Open/created.txt"
|
|
try FileManager.default.createDirectory(atPath: "\(treeSource)/Locked", withIntermediateDirectories: true)
|
|
try FileManager.default.createDirectory(atPath: "\(treeSource)/Open", withIntermediateDirectories: true)
|
|
try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: "\(treeSource)/Locked")
|
|
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: locked) }
|
|
|
|
let events = try await getInotifyEventsForTrigger(
|
|
in: root,
|
|
mask: [.create, .movedTo],
|
|
recursive: .withAutomaticSubtreeWatching
|
|
) { _ in
|
|
try FileManager.default.moveItem(atPath: treeSource, toPath: treeDestination)
|
|
try await Task.sleep(for: .milliseconds(400))
|
|
try createFile(at: filepath, contents: "hello")
|
|
}
|
|
|
|
let failures = events.compactMap(\.watchFailure)
|
|
#expect(failures.count == 1, "Expected exactly the locked directory to be reported, got: \(events)")
|
|
#expect(failures.first?.path == FilePath(locked))
|
|
#expect(failures.first?.error == .listDirectoryFailed(path: locked, errno: EACCES))
|
|
let sibling = events.compactMap(\.fileSystemEvent).first { !$0.synthesized && $0.mask.contains(.create) && $0.path.string == filepath }
|
|
#expect(sibling != nil, "Expected CREATE inside the readable sibling, got: \(events)")
|
|
}
|
|
}
|
|
|
|
/// Events are transformed as they are consumed, so a directory that is
|
|
/// created and removed before consumption starts is gone when the
|
|
/// library tries to watch it.
|
|
@Test func doesNotReportADirectoryThatVanishedBeforeItCouldBeWatched() async throws {
|
|
try await withTempDir { dir in
|
|
let vanished = "\(dir)/Vanished"
|
|
let watcher = try Inotify()
|
|
try await watcher.addWatchWithAutomaticSubtreeWatching(forDirectory: dir, mask: [.create])
|
|
try FileManager.default.createDirectory(atPath: vanished, withIntermediateDirectories: false)
|
|
try FileManager.default.removeItem(atPath: vanished)
|
|
|
|
let events = await collectEvents(of: watcher, for: .milliseconds(500))
|
|
|
|
#expect(events.compactMap(\.watchFailure).isEmpty, "Did not expect a watch failure for a vanished directory, got: \(events)")
|
|
let created = events.compactMap(\.fileSystemEvent).first { $0.mask.contains(.create) && $0.path.string == vanished }
|
|
#expect(created != nil, "Expected CREATE for '\(vanished)', got: \(events)")
|
|
}
|
|
}
|
|
}
|