diff --git a/Sources/Path+Attributes.swift b/Sources/Path+Attributes.swift index ffe21f0..3223f85 100644 --- a/Sources/Path+Attributes.swift +++ b/Sources/Path+Attributes.swift @@ -1,34 +1,35 @@ import Foundation +//#if os(Linux) +//import func Glibc.chmod +//#endif public extension Path { //MARK: Filesystem Attributes /** Returns the creation-time of the file. - - Note: Returns UNIX-time-zero if there is no creation-time, this should only happen if the file doesn’t exist. + - Note: Returns `nil` if there is no creation-time, this should only happen if the file doesn’t exist. + - Important: On Linux this is filesystem dependendent and may not exist. */ - var ctime: Date { + var ctime: Date? { do { let attrs = try FileManager.default.attributesOfItem(atPath: string) - return attrs[.creationDate] as? Date ?? Date(timeIntervalSince1970: 0) + return attrs[.creationDate] as? Date } catch { - //TODO log error - return Date(timeIntervalSince1970: 0) + return nil } } /** Returns the modification-time of the file. - - Note: Returns the creation time if there is no modification time. - - Note: Returns UNIX-time-zero if neither are available, this should only happen if the file doesn’t exist. + - Note: If this returns `nil` and the file exists, something is very wrong. */ - var mtime: Date { + var mtime: Date? { do { let attrs = try FileManager.default.attributesOfItem(atPath: string) - return attrs[.modificationDate] as? Date ?? ctime + return attrs[.modificationDate] as? Date } catch { - //TODO log error - return Date(timeIntervalSince1970: 0) + return nil } } @@ -39,27 +40,40 @@ public extension Path { */ @discardableResult func chmod(_ octal: Int) throws -> Path { +// #if os(Linux) +// Glibc.chmod(string, __mode_t(octal)) +// #else try FileManager.default.setAttributes([.posixPermissions: octal], ofItemAtPath: string) +// #endif return self } - /// - Note: If file is already locked, does nothing - /// - Note: If file doesn’t exist, throws + /** + - Note: If file is already locked, does nothing. + - Note: If file doesn’t exist, throws. + - Important: On Linux does nothing. + */ @discardableResult func lock() throws -> Path { + #if !os(Linux) var attrs = try FileManager.default.attributesOfItem(atPath: string) let b = attrs[.immutable] as? Bool ?? false if !b { attrs[.immutable] = true try FileManager.default.setAttributes(attrs, ofItemAtPath: string) } + #endif return self } - /// - Note: If file isn‘t locked, does nothing - /// - Note: If file doesn’t exist, does nothing + /** + - Note: If file isn‘t locked, does nothing. + - Note: If file doesn’t exist, does nothing. + - Important: On Linux does nothing. + */ @discardableResult func unlock() throws -> Path { + #if !os(Linux) var attrs: [FileAttributeKey: Any] do { attrs = try FileManager.default.attributesOfItem(atPath: string) @@ -71,6 +85,7 @@ public extension Path { attrs[.immutable] = false try FileManager.default.setAttributes(attrs, ofItemAtPath: string) } + #endif return self } } diff --git a/Sources/Path+FileManager.swift b/Sources/Path+FileManager.swift index b464571..e041497 100644 --- a/Sources/Path+FileManager.swift +++ b/Sources/Path+FileManager.swift @@ -130,6 +130,9 @@ public extension Path { Deletes the path, recursively if a directory. - Note: noop: if the path doesn’t exist ∵ *Path.swift* doesn’t error if desired end result preexists. + - Note: On UNIX will this function will succeed if the parent directory is writable and the current user has permission. + - Note: This function will fail if the file or directory is “locked” + - SeeAlso: `lock()` */ @inlinable func delete() throws { diff --git a/Sources/Path+StringConvertibles.swift b/Sources/Path+StringConvertibles.swift index 637a20a..16c597a 100644 --- a/Sources/Path+StringConvertibles.swift +++ b/Sources/Path+StringConvertibles.swift @@ -10,6 +10,6 @@ extension Path: CustomStringConvertible { extension Path: CustomDebugStringConvertible { /// Returns eg. `Path(string: "/foo")` public var debugDescription: String { - return "Path(string: \(string))" + return "Path(\(string))" } } diff --git a/Sources/Path->Bool.swift b/Sources/Path->Bool.swift index 4b61ca5..4a5100b 100644 --- a/Sources/Path->Bool.swift +++ b/Sources/Path->Bool.swift @@ -1,4 +1,9 @@ import Foundation +#if os(Linux) +import func Glibc.access +#else +import func Darwin.access +#endif public extension Path { //MARK: Filesystem Properties @@ -32,11 +37,22 @@ public extension Path { /// Returns true if the path represents an actual file that is also deletable by the current user. var isDeletable: Bool { - return FileManager.default.isDeletableFile(atPath: string) + #if os(Linux) && !swift(>=5.1) + return exists && access(parent.string, W_OK) == 0 + #else + // FileManager.isDeletableFile returns true if there is *not* a file there + return exists && FileManager.default.isDeletableFile(atPath: string) + #endif } /// Returns true if the path represents an actual file that is also executable by the current user. var isExecutable: Bool { - return FileManager.default.isExecutableFile(atPath: string) + if access(string, X_OK) == 0 { + // FileManager.isExxecutableFile returns true even if there is *not* + // a file there *but* if there was it could be *made* executable + return FileManager.default.isExecutableFile(atPath: string) + } else { + return false + } } } diff --git a/Tests/PathTests/PathTests.swift b/Tests/PathTests/PathTests.swift index e771880..a0ed5dc 100644 --- a/Tests/PathTests/PathTests.swift +++ b/Tests/PathTests/PathTests.swift @@ -204,4 +204,121 @@ class PathTests: XCTestCase { } } } + + func testCommonDirectories() { + XCTAssertEqual(Path.root.string, "/") + XCTAssertEqual(Path.home.string, NSHomeDirectory()) + XCTAssertEqual(Path.documents.string, NSHomeDirectory() + "/Documents") + #if os(macOS) + XCTAssertEqual(Path.caches.string, NSHomeDirectory() + "/Library/Caches") + XCTAssertEqual(Path.cwd.string, FileManager.default.currentDirectoryPath) + XCTAssertEqual(Path.applicationSupport.string, NSHomeDirectory() + "/Library/Application Support") + #endif + } + + func testStringConvertibles() { + XCTAssertEqual(Path.root.description, "/") + XCTAssertEqual(Path.root.debugDescription, "Path(/)") + } + + func testFilesystemAttributes() throws { + XCTAssert(Path(#file)!.isFile) + XCTAssert(Path(#file)!.isReadable) + XCTAssert(Path(#file)!.isWritable) + XCTAssert(Path(#file)!.isDeletable) + XCTAssert(Path(#file)!.parent.isDirectory) + + try Path.mktemp { tmpdir in + XCTAssertTrue(tmpdir.isDirectory) + XCTAssertFalse(tmpdir.isFile) + + let bar = try tmpdir.bar.touch().chmod(0o000) + XCTAssertFalse(bar.isReadable) + XCTAssertFalse(bar.isWritable) + XCTAssertFalse(bar.isDirectory) + XCTAssertFalse(bar.isExecutable) + XCTAssertTrue(bar.isFile) + XCTAssertTrue(bar.isDeletable) // can delete even if no read permissions + + try bar.chmod(0o777) + XCTAssertTrue(bar.isReadable) + XCTAssertTrue(bar.isWritable) + XCTAssertTrue(bar.isDeletable) + XCTAssertTrue(bar.isExecutable) + + try bar.delete() + XCTAssertFalse(bar.exists) + XCTAssertFalse(bar.isReadable) + XCTAssertFalse(bar.isWritable) + XCTAssertFalse(bar.isExecutable) + XCTAssertFalse(bar.isDeletable) + + let nonExistantFile = tmpdir.baz + XCTAssertFalse(nonExistantFile.exists) + XCTAssertFalse(nonExistantFile.isExecutable) + XCTAssertFalse(nonExistantFile.isReadable) + XCTAssertFalse(nonExistantFile.isWritable) + XCTAssertFalse(nonExistantFile.isDeletable) + XCTAssertFalse(nonExistantFile.isDirectory) + XCTAssertFalse(nonExistantFile.isFile) + + let baz = try tmpdir.baz.touch() + XCTAssertTrue(baz.isDeletable) + try tmpdir.chmod(0o500) // remove write permission on directory + XCTAssertFalse(baz.isDeletable) // this is how deletion is prevented on UNIX + } + } + + func testTimes() throws { + try Path.mktemp { tmpdir in + let foo = try tmpdir.foo.touch() + let now1 = Date().timeIntervalSince1970.rounded(.down) + #if !os(Linux) + XCTAssertEqual(foo.ctime?.timeIntervalSince1970.rounded(.down), now1) //FIXME flakey + #endif + XCTAssertEqual(foo.mtime?.timeIntervalSince1970.rounded(.down), now1) //FIXME flakey + sleep(1) + try foo.touch() + let now2 = Date().timeIntervalSince1970.rounded(.down) + XCTAssertNotEqual(now1, now2) + XCTAssertEqual(foo.mtime?.timeIntervalSince1970.rounded(.down), now2) //FIXME flakey + } + } + + func testDelete() throws { + try Path.mktemp { tmpdir in + try tmpdir.bar1.delete() + try tmpdir.bar2.touch().delete() + try tmpdir.bar3.touch().chmod(0o000).delete() + #if !os(Linux) + XCTAssertThrowsError(try tmpdir.bar3.touch().lock().delete()) + #endif + } + } + + func testRelativeCodable() throws { + let path = Path.home.foo + let encoder = JSONEncoder() + encoder.userInfo[.relativePath] = Path.home + let data = try encoder.encode([path]) + let decoder = JSONDecoder() + decoder.userInfo[.relativePath] = Path.home + XCTAssertEqual(try decoder.decode([Path].self, from: data), [path]) + decoder.userInfo[.relativePath] = Path.documents + XCTAssertEqual(try decoder.decode([Path].self, from: data), [Path.documents.foo]) + XCTAssertThrowsError(try JSONDecoder().decode([Path].self, from: data)) + } + + func testBundleExtensions() { + XCTAssertTrue(Bundle.main.path.isDirectory) + XCTAssertTrue(Bundle.main.resources.isDirectory) + + // doesn’t exist in tests + _ = Bundle.main.sharedFrameworks + } + + func testFileHandleExtensions() throws { + _ = try FileHandle(forReadingAt: Path(#file)!) + _ = try FileHandle(forWritingAt: Path(#file)!) + } } diff --git a/Tests/PathTests/TemporaryDirectory.swift b/Tests/PathTests/TemporaryDirectory.swift index 1b9261d..e12e274 100644 --- a/Tests/PathTests/TemporaryDirectory.swift +++ b/Tests/PathTests/TemporaryDirectory.swift @@ -42,7 +42,11 @@ class TemporaryDirectory { } deinit { - _ = try? FileManager.default.removeItem(at: url) + do { + try path.chmod(0o777).delete() + } catch { + //TODO log + } } } diff --git a/Tests/PathTests/XCTestManifests.swift b/Tests/PathTests/XCTestManifests.swift index ccfec17..9471fa3 100644 --- a/Tests/PathTests/XCTestManifests.swift +++ b/Tests/PathTests/XCTestManifests.swift @@ -3,22 +3,30 @@ import XCTest extension PathTests { static let __allTests = [ ("testBasename", testBasename), + ("testBundleExtensions", testBundleExtensions), ("testCodable", testCodable), + ("testCommonDirectories", testCommonDirectories), ("testConcatenation", testConcatenation), ("testCopyInto", testCopyInto), + ("testDelete", testDelete), ("testDynamicMember", testDynamicMember), ("testEnumeration", testEnumeration), ("testEnumerationSkippingHiddenFiles", testEnumerationSkippingHiddenFiles), ("testExists", testExists), ("testExtension", testExtension), + ("testFileHandleExtensions", testFileHandleExtensions), + ("testFilesystemAttributes", testFilesystemAttributes), ("testIsDirectory", testIsDirectory), ("testJoin", testJoin), ("testMkpathIfExists", testMkpathIfExists), ("testMktemp", testMktemp), ("testMoveInto", testMoveInto), + ("testRelativeCodable", testRelativeCodable), ("testRelativePathCodable", testRelativePathCodable), ("testRelativeTo", testRelativeTo), ("testRename", testRename), + ("testStringConvertibles", testStringConvertibles), + ("testTimes", testTimes), ] }