From 709c3fb99d00cdb1fee55414d31bc90eee187450 Mon Sep 17 00:00:00 2001 From: Max Howell Date: Mon, 11 Feb 2019 12:42:50 -0500 Subject: [PATCH 1/2] Symlink funcs & support `NSURL` file-refs * Also removes most `NSString` usage * Also does more thorough testing in some places * Also adds * Fixes `Path?(_:)` resolving symlinks in some cases --- README.md | 78 +++++++- Sources/Path+FileManager.swift | 26 +++ Sources/Path+StringConvertibles.swift | 2 - Sources/Path->Bool.swift | 9 +- Sources/Path.swift | 244 +++++++++++++++++++++++--- Tests/PathTests/PathTests.swift | 195 ++++++++++++++++++-- Tests/PathTests/XCTestManifests.swift | 10 ++ 7 files changed, 515 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 76c9f1f..135fad8 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ let ls = Path.root.usr.bin.ls // => /usr/bin/ls ``` This is less commonly useful than you would think, hence our documentation -does not use it. Usually you are joining variables or other `String` arguments +does not use it. Usually you are joining variables or other `String` arguments or trying to describe files (and files usually have extensions). However when you need it, it’s *lovely*. @@ -171,13 +171,18 @@ let swiftFiles = Path.home.ls().files(withExtension: "swift") # `Path.swift` is robust -Some parts of `FileManager` are not exactly idiomatic. For example +Some parts of `FileManager` are not exactly idiomatic. For example `isExecutableFile` returns `true` even if there is no file there, it is instead telling you that *if* you made a file there it *could* be executable. Thus we check the POSIX permissions of the file first, before returning the result of `isExecutableFile`. `Path.swift` has done the leg-work for you so you can get on with your work without worries. +There is also some magic going on in Foundation’s filesystem APIs, which we look +for and ensure our API is deterministic, eg. [this test]. + +[this test]: TODO + # `Path.swift` is properly cross-platform `FileManager` on Linux is full of holes. We have found the holes and worked @@ -199,6 +204,10 @@ Path.home/"b/c" // => /Users/mxcl/b/c // joining with absolute paths omits prefixed slash Path.home/"/b" // => /Users/mxcl/b +// joining with .. or . works as expected +Path.home.foo.bar.join("..") // => /Users/mxcl/foo +Path.home.foo.bar.join(".") // => /Users/mxcl/foo/bar + // of course, feel free to join variables: let b = "b" let c = "c" @@ -211,8 +220,24 @@ Path.root/"~/b" // => /~/b // but is here Path("~/foo")! // => /Users/mxcl/foo -// this does not work though +// this works provided the user `Guest` exists +Path("~Guest") // => /Users/Guest + +// but if the user does not exist Path("~foo") // => nil + +// paths with .. or . are resolved +Path("/foo/bar/../baz") // => /foo/baz + +// symlinks are not resolved +Path.root.bar.symlink(as: "foo") +Path("foo") // => /foo +Path.foo // => /foo + +// unless you do it explicitly +try Path.foo.readlink() // => /bar + // `readlink` only resolves the *final* path component, + // thus use `realpath` if there are multiple symlinks ``` *Path.swift* has the general policy that if the desired end result preexists, @@ -220,30 +245,71 @@ then it’s a noop: * If you try to delete a file, but the file doesn't exist, we do nothing. * If you try to make a directory and it already exists, we do nothing. +* If you call `readlink` on a non-symlink, we return `self` However notably if you try to copy or move a file with specifying `overwrite` and the file already exists at the destination and is identical, we don’t check for that as the check was deemed too expensive to be worthwhile. +## Symbolic links + +* Two paths may represent the same *resolved* path yet not be equal due to + symlinks in such cases you should use `realpath` on both first if an + equality check is required. +* There are several symlink paths on Mac that are typically automatically + resolved by Foundation, eg. `/private`, we attempt to do the same for + functions that you would expect it (notably `realpath`), but we do *not* for + `Path.init`, *nor* if you are joining a path that ends up being one of these + paths, (eg. `Path.root.join("var/private')`). + +## We do not provide change directory functionality + +Changing directory is dangerous, you should *always* try to avoid it and thus +we don’t even provide the method. If you are executing a sub-process then +use `Process.currentDirectoryURL`. + +If you must then use `FileManager.changeCurrentDirectory`. + +# I thought I should only use `URL`s? + +Apple recommend this because they provide a magic translation for +[file-references embodied by URLs][file-refs], which gives you URLs like so: + + file:///.file/id=6571367.15106761 + +Therefore, if you are not using this feature you are fine. If you have URLs the correct +way to get a `Path` is: + +```swift +if let path = Path(url) { + /*…*/ +} +``` + +Our initializer calls `path` on the URL which resolves any reference to an +actual filesystem path, however we also check the URL has a `file` scheme first. + +[file-refs]: https://developer.apple.com/documentation/foundation/nsurl/1408631-filereferenceurl + # Installation SwiftPM: ```swift -package.append(.package(url: "https://github.com/mxcl/Path.swift.git", from: "0.5.0")) +package.append(.package(url: "https://github.com/mxcl/Path.swift.git", from: "0.13.0")) ``` CocoaPods: ```ruby -pod 'Path.swift', '~> 0.5' +pod 'Path.swift', '~> 0.13' ``` Carthage: > Waiting on: [@Carthage#1945](https://github.com/Carthage/Carthage/pull/1945). -## Please note +## Pre‐1.0 status We are pre 1.0, thus we can change the API as we like, and we will (to the pursuit of getting it *right*)! We will tag 1.0 as soon as possible. diff --git a/Sources/Path+FileManager.swift b/Sources/Path+FileManager.swift index 4893864..63ffed5 100644 --- a/Sources/Path+FileManager.swift +++ b/Sources/Path+FileManager.swift @@ -211,6 +211,32 @@ public extension Path { try FileManager.default.moveItem(atPath: string, toPath: newpath.string) return newpath } + + /** + Creates a symlink of this file at `as`. + - Note: If `self` does not exist, is **not** an error. + */ + @discardableResult + func symlink(as: Path) throws -> Path { + try FileManager.default.createSymbolicLink(atPath: `as`.string, withDestinationPath: string) + return `as` + } + + /** + Creates a symlink of this file with the same filename in the `into` directory. + - Note: If into does not exist, creates the directory with intermediate directories if necessary. + */ + @discardableResult + func symlink(into dir: Path) throws -> Path { + if !dir.exists { + try dir.mkdir(.p) + } else if !dir.isDirectory { + throw CocoaError.error(.fileWriteFileExists) + } + let dst = dir/basename() + try FileManager.default.createSymbolicLink(atPath: dst.string, withDestinationPath: string) + return dst + } } /// Options for `Path.mkdir(_:)` diff --git a/Sources/Path+StringConvertibles.swift b/Sources/Path+StringConvertibles.swift index 16c597a..ab2c120 100644 --- a/Sources/Path+StringConvertibles.swift +++ b/Sources/Path+StringConvertibles.swift @@ -1,5 +1,3 @@ -import class Foundation.NSString - extension Path: CustomStringConvertible { /// Returns `Path.string` public var description: String { diff --git a/Sources/Path->Bool.swift b/Sources/Path->Bool.swift index 4a5100b..1baf65c 100644 --- a/Sources/Path->Bool.swift +++ b/Sources/Path->Bool.swift @@ -2,7 +2,7 @@ import Foundation #if os(Linux) import func Glibc.access #else -import func Darwin.access +import Darwin #endif public extension Path { @@ -55,4 +55,11 @@ public extension Path { return false } } + + /// Returns `true` if the file is a symbolic-link (symlink). + var isSymlink: Bool { + var sbuf = stat() + lstat(string, &sbuf) + return (sbuf.st_mode & S_IFMT) == S_IFLNK + } } diff --git a/Sources/Path.swift b/Sources/Path.swift index 2c993a2..c85171f 100644 --- a/Sources/Path.swift +++ b/Sources/Path.swift @@ -1,4 +1,11 @@ import Foundation +#if !os(Linux) +import func Darwin.realpath +let _realpath = Darwin.realpath +#else +import func Glibc.realpath +let _realpath = Glibc.realpath +#endif /** A `Path` represents an absolute path on a filesystem. @@ -32,19 +39,85 @@ import Foundation public struct Path: Equatable, Hashable, Comparable { init(string: String) { + assert(string.first == "/") + assert(string.last != "/" || string == "/") + assert(string.split(separator: "/").contains("..") == false) self.string = string } - /// Returns `nil` unless fed an absolute path. + /** + Creates a new absolute, standardized path. + - Note: Resolves any .. or . components. + - Note: Removes multiple subsequent and trailing occurences of `/`. + - Note: Does *not* resolve any symlinks. + - Note: On macOS, removes an initial component of “/private/var/automount”, “/var/automount”, or “/private” from the path, if the result still indicates an existing file or directory (checked by consulting the file system). + - Returns: The path or `nil` if fed a relative path or a `~foo` string where there is no user `foo`. + */ public init?(_ description: String) { - guard description.starts(with: "/") || description.starts(with: "~/") else { return nil } - self.init(string: (description as NSString).standardizingPath) + var pathComponents = description.split(separator: "/") + switch description.first { + case "/": + break + case "~": + if description == "~" { + self = Path.home + return + } + let tilded: String + if description.hasPrefix("~/") { + tilded = Path.home.string + } else { + let username = String(pathComponents[0].dropFirst()) + + if #available(OSX 10.12, *) { + guard let url = FileManager.default.homeDirectory(forUser: username) else { return nil } + assert(url.scheme == "file") + tilded = url.path + } else { + guard let dir = NSHomeDirectoryForUser(username) else { return nil } + tilded = dir + } + } + pathComponents.remove(at: 0) + pathComponents.insert(contentsOf: tilded.split(separator: "/"), at: 0) + default: + return nil + } + + #if os(macOS) + func ifExists(withPrefix prefix: String, removeFirst n: Int) { + assert(prefix.split(separator: "/").count == n) + + if description.hasPrefix(prefix), FileManager.default.fileExists(atPath: description) { + pathComponents.removeFirst(n) + } + } + + ifExists(withPrefix: "/private/var/automount", removeFirst: 3) + ifExists(withPrefix: "/var/automount", removeFirst: 2) + ifExists(withPrefix: "/private", removeFirst: 1) + #endif + + self.string = join_(prefix: "/", pathComponents: pathComponents) + } + + public init?(_ url: URL) { + guard url.scheme == "file" else { return nil } + self.init(string: url.path) + //NOTE: URL cannot be a file-reference url, unlike NSURL, so this always works + } + + public init?(_ url: NSURL) { + guard url.scheme == "file", let path = url.path else { return nil } + self.init(string: path) + // ^^ works even if the url is a file-reference url } /// :nodoc: - public subscript(dynamicMember pathComponent: String) -> Path { - let str = (string as NSString).appendingPathComponent(pathComponent) - return Path(string: str) + public subscript(dynamicMember addendum: String) -> Path { + //NOTE it’s possible for the string to be anything if we are invoked via + // explicit subscript thus we use our fully sanitized `join` function + return Path(string: join_(prefix: string, appending: addendum)) } //MARK: Properties @@ -57,6 +130,21 @@ public struct Path: Equatable, Hashable, Comparable { return URL(fileURLWithPath: string) } + /** + Returns a file-reference URL. + - Note: Only NSURL can be a file-reference-URL, hence we return NSURL. + - SeeAlso: https://developer.apple.com/documentation/foundation/nsurl/1408631-filereferenceurl + - Important: On Linux returns an file scheme NSURL for this path string. + */ + public var fileReferenceURL: NSURL? { + #if !os(Linux) + // https://bugs.swift.org/browse/SR-2728 + return (url as NSURL).perform(#selector(NSURL.fileReferenceURL))?.takeUnretainedValue() as? NSURL + #else + return NSURL(fileURLWithPath: string) + #endif + } + /** Returns the parent directory for this path. @@ -66,20 +154,37 @@ public struct Path: Equatable, Hashable, Comparable { - Note: always returns a valid path, `Path.root.parent` *is* `Path.root`. */ public var parent: Path { - return Path(string: (string as NSString).deletingLastPathComponent) + let index = string.lastIndex(of: "/")! + let substr = string[string.indices.startIndex.. /a/b Path.root.join("a").join("/b") // => /a/b + - Note: `..` and `.` components are interpreted. + - Note: pathComponent *may* be multiple components. + - Note: symlinks are *not* resolved. - Parameter pathComponent: The string to join with this path. - Returns: A new joined path. - SeeAlso: `Path./(_:_:)` */ - public func join(_ pathComponent: S) -> Path where S: StringProtocol { - //TODO standardizingPath does more than we want really (eg tilde expansion) - let str = (string as NSString).appendingPathComponent(String(pathComponent)) - return Path(string: (str as NSString).standardizingPath) + public func join(_ addendum: S) -> Path where S: StringProtocol { + return Path(string: join_(prefix: string, appending: addendum)) } /** @@ -111,6 +217,9 @@ public struct Path: Equatable, Hashable, Comparable { Path.root/"a"/"b" // => /a/b Path.root/"a"/"/b" // => /a/b + - Note: `..` and `.` components are interpreted. + - Note: pathComponent *may* be multiple components. + - Note: symlinks are *not* resolved. - Parameter lhs: The base path to join with `rhs`. - Parameter rhs: The string to join with this `lhs`. - Returns: A new joined path. @@ -168,17 +277,71 @@ public struct Path: Equatable, Hashable, Comparable { - Parameter dropExtension: If `true` returns the basename without its file extension. */ public func basename(dropExtension: Bool = false) -> String { - let str = string as NSString - if !dropExtension { - return str.lastPathComponent - } else { - let ext = str.pathExtension - if !ext.isEmpty { - return String(str.lastPathComponent.dropLast(ext.count + 1)) + var lastPathComponent: Substring { + let slash = string.lastIndex(of: "/")! + let index = string.index(after: slash) + return string[index...] + } + var go: Substring { + if !dropExtension { + return lastPathComponent } else { - return str.lastPathComponent + let ext = self.extension + if !ext.isEmpty { + return lastPathComponent.dropLast(ext.count + 1) + } else { + return lastPathComponent + } } } + return String(go) + } + + /** + If the path represents an actual entry that is a symlink, returns the symlink’s + absolute destination. + + - Important: This is not exhaustive, the resulting path may still contain + symlink. + - Important: The path will only be different if the last path component is a + symlink, any symlinks in prior components are not resolved. + - Note: If file exists but isn’t a symlink, returns `self`. + - Note: If symlink destination does not exist, is **not** an error. + */ + public func readlink() throws -> Path { + do { + let rv = try FileManager.default.destinationOfSymbolicLink(atPath: string) + return Path(rv) ?? parent/rv + } catch CocoaError.fileReadUnknown { + // file is not symlink, return `self` + assert(exists) + return self + } catch { + #if os(Linux) + // ugh: Swift on Linux + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain, nsError.code == CocoaError.fileReadUnknown.rawValue, exists { + return self + } + #endif + throw error + } + } + + /// Recursively resolves symlinks in this path. + public func realpath() throws -> Path { + guard let rv = _realpath(string, nil) else { throw CocoaError.error(.fileNoSuchFile) } + defer { free(rv) } + guard let rvv = String(validatingUTF8: rv) else { throw CocoaError.error(.fileReadUnknownStringEncoding) } + + // “Removing an initial component of “/private/var/automount”, “/var/automount”, + // or “/private” from the path, if the result still indicates an existing file or + // directory (checked by consulting the file system).” + // ^^ we do this to not conflict with the results that other Apple APIs give + // which is necessary if we are to have equality checks work reliably + let rvvv = (rvv as NSString).standardizingPath + + return Path(string: rvvv) } /// Returns the locale-aware sort order for the two paths. @@ -188,3 +351,38 @@ public struct Path: Equatable, Hashable, Comparable { return lhs.string.compare(rhs.string, locale: .current) == .orderedAscending } } + +@inline(__always) +private func join_(prefix: String, appending: S) -> String where S: StringProtocol { + return join_(prefix: prefix, pathComponents: appending.split(separator: "/")) +} + +private func join_(prefix: String, pathComponents: S) -> String where S: Sequence, S.Element: StringProtocol { + assert(prefix.first == "/") + + var rv = prefix + for component in pathComponents { + + assert(!component.contains("/")) + + switch component { + case "..": + let start = rv.indices.startIndex + let index = rv.lastIndex(of: "/")! + if start == index { + rv = "/" + } else { + rv = String(rv[start.. Date: Mon, 11 Feb 2019 14:19:27 -0500 Subject: [PATCH 2/2] Fix iOS, etc. --- Sources/Path.swift | 9 ++++++++- Tests/PathTests/PathTests.swift | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/Path.swift b/Sources/Path.swift index c85171f..5d95a45 100644 --- a/Sources/Path.swift +++ b/Sources/Path.swift @@ -68,7 +68,7 @@ public struct Path: Equatable, Hashable, Comparable { tilded = Path.home.string } else { let username = String(pathComponents[0].dropFirst()) - + #if os(macOS) || os(Linux) if #available(OSX 10.12, *) { guard let url = FileManager.default.homeDirectory(forUser: username) else { return nil } assert(url.scheme == "file") @@ -77,6 +77,13 @@ public struct Path: Equatable, Hashable, Comparable { guard let dir = NSHomeDirectoryForUser(username) else { return nil } tilded = dir } + #else + if username != NSUserName() { + return nil + } else { + tilded = NSHomeDirectory() + } + #endif } pathComponents.remove(at: 0) pathComponents.insert(contentsOf: tilded.split(separator: "/"), at: 0) diff --git a/Tests/PathTests/PathTests.swift b/Tests/PathTests/PathTests.swift index 8e2bcb9..4f7933b 100644 --- a/Tests/PathTests/PathTests.swift +++ b/Tests/PathTests/PathTests.swift @@ -493,6 +493,8 @@ class PathTests: XCTestCase { } func testReadlinkOnRelativeSymlink() throws { + //TODO how to test on iOS etc.? + #if os(macOS) || os(Linux) try Path.mktemp { tmpdir in let foo = try tmpdir.foo.mkdir() let bar = try tmpdir.bar.touch() @@ -511,6 +513,7 @@ class PathTests: XCTestCase { XCTAssertEqual(try tmpdir.foo.baz.readlink(), bar) } + #endif } func testReadlinkOnFileReturnsSelf() throws {