[Accepted with modifications] SE-0529: FilePath

Hello, Swift community.

The review of SE-0529: FilePath was initially scheduled to run from April 22nd through May 4th, 2026. Over the course of the review, several rounds of amendments were made to the proposal: first here, then here. To accommodate discussion of those changes, and to avoid cutting short some productive debates about other points, the review was informally extended for quite a while past its original scheduled conclusion.

The community's feedback was generally positive on the basic idea of adding a type that represents a file path on the current system. The LSG generally agrees that this is a good direction for the standard library. There were some objections in the thread about specific parts of the design, many of which were settled by the amendments or by discussion in the review. I'd like to share the LSG's thinking about a few issues in particular.

During the review, there was a lot of discussion about the portability of certain APIs, such as driveLetter. Since a FilePath represents a path on the current system, and these APIs are presenting non-portable concepts that only really make sense on specific systems, the LSG asked the authors to make these APIs platform-conditional for now. Programmers writing unportable code will not be bothered by this, since platform conditionality is not enforced except by building for different platforms. Meanwhile, programmers trying to write portable code will have their attention called to places where their logic is assuming a specific target. If we decide that the API really would be useful to portable programmers, we can always make it unconditionally available (or introduce a more generic, portable API) later.

There was also a lot of debate about the proposed resolve method, which we believe can be distilled to three principal objections:

  • resolve would be the first I/O operation in the standard library[1]. The objectors ask that we wait to introduce it until it can part of a more complete I/O library.

    The LSG notes that a more complete I/O library will depend on a lot of new types and idioms that will have to be developed in the library. resolve is different because it really does just involve this FilePath type. We do not feel like this is a strong reason by itself to delay adding resolve.

  • resolve can be misused, and in some situations (generally when the code has more privileges than its clients) this can lead to security bugs through TOCTOU logic errors. The correct fix for these bugs is often to stop trying to resolve the path in the first place. The objectors ask that we not add the API at all given this, or that we only add it in some different form, e.g. one that holds a kernel handle to maintain a stable reference to the resolved file.

    The LSG acknowledges that resolve has these problems. However, there are also appropriate ways to use resolve, e.g. combined with appropriate flags passed to open[2]. The LSG is satisfied that the documentation can adequately inform programmers about how and when to use this API correctly. Not including this functionality is also likely to lead programmers to naively recreate it with manual path manipulation, which can be its own source of bugs, including security bugs.

    The suggested alternate forms of this API, e.g. using O_PATH-like functionality, are far from being drop-in replacements, and they are not yet portably implementable.

  • Finally, resolve is a potentially blocking API. The objectors feel that it should be async.

    The LSG acknowledges this as a tricky problem. It is true that resolve can require waiting for arbitrary I/O to finish. Even the fastest modern SSDs, with latencies measured in the tens of microseconds, are slow enough relative to the CPU that the current thread could usually be usefully employed doing other work in the meantime; and of course filesystems are often backed by much slower technologies, including older NANDs, spinning disks, and network servers near and far. All of that naturally suggests that this API ought to be async on some semantic level.[3]

    However, operating systems do not currently provide ways to asynchronously resolve paths. The only way this operation could be implemented asynchronously at present is with an I/O worker pool that performs the normal, blocking syscall. The LSG is making a pragmatic choice here. We are unwilling to delay offering this API for an unbounded period of time, when it can be used safely (if carefully) in many situations and offers substantial security improvements over more naive path manipulation techniques, until either operating systems introduce async resolve or Swift introduces I/O workers for some other reason.

SE-0529 is therefore accepted with the modifications made over the course of the review.

I'd like to thank the Swift community for its thoughtful and patient participation in this rather drawn-out review process. Your contributions really do help to make Swift a better language.

John McCall
Review Manager


  1. Ignoring print and readLine, which have somewhat special roles in the library; they are specialized for their simple purposes and aren't really models for what general I/O routines in Swift are likely to look like. ↩︎

  2. Such as openat2's RESOLVE_NO_SYMLINKS on Linux, or O_NOFOLLOW_ANY on Apple platforms (although this is unnecessary because resolve will also add /.nofollow to the path on Apple platforms). ↩︎

  3. The LSG acknowledges the argument that blocking can also be semantically problematic in specific applications. For example, when implementing a filesystem in userspace, blocking on another filesystem request can result in deadlock. The LSG does not feel strongly motivated by this argument; there are many other ways to run into this kind of deadlock in such services, and programmers writing them simply have to be aware of the restrictions on them. ↩︎

18 Likes

Congratulations to @Michael_Ilseman on particular for this heroic effort. This was an amazing SE to be a part of, and the end result is so much better for the community input and the resulting meticulously-researched changes.

(I’m personally on the side of, maybe resolve shouldn’t have been a member function of FilePath but I can certainly see that all the angles were considered)

2 Likes

Apologies for not bringing this up during the review, but like unsafe API is prefixed with unsafe/Unsafe, should blocking IO API be prefixed with blocking? i.e. name the method blockingResolve? I mentioned a potential future way to flag this by annotating APIs with @blocking , call sites with blocking and --strict-non-blocking flag, so that callers that care a lot about this have a way to statically enforce that code does not block on IO.

4 Likes

:confused:

We are unwilling to delay offering this API for an unbounded period of time, when it can be used safely (if carefully) in many situations and offers substantial security improvements over more naive path manipulation techniques.

Where is the rush coming from? We do not have APIs to do anything with these FilePaths, how come we are happy to delay opening/reading files for an unbounded amount of time but for resolving file paths we need to act now?

Secondly, the claimed 'substantial security improvements' seem to refer to a Darwin-only feature (introduced in 26.0) which also requires a user to know about the magic /.nofollow/ prefixes.
However, I think we all agree that avoiding resolving a path at all is the only true safe option. Yes, there are narrow cases where resolving a path can be safe but I don't understand why we claim that a solution to a narrow space is hailed as a 'substantial security improvement' if the actual (portable!) fix comes from not doing path resolutions which are inherently TOCTOU-affected.

19 Likes

The security improvements are against the alternative of people just doing manual path manipulations to canonicalize their paths because we’re not giving them anything better. Offering a resolve function that does the best possible thing gives us a place to discuss the inherent limitations that of course it still has, including why it is often better to avoid trying to canonicalize paths in the first paths. But some code does have a legitimate need to do so. It is quite easy to not call a function if you do not; the existence of this API will not compromise your program any more than the existence of the syscall does today.

resolve is implicitly safer on Apple platforms because it will automatically add /.nofollow, but the same thing can be achieved on Linux, it just requires clients to remember to set the right flags with open.

3 Likes

But some code does have a legitimate need to do so.

Could you give a few examples where the now specified resolve() behaviour is what you should actually do?

resolve is implicitly safer on Apple platforms because it will automatically add /.nofollow, but the same thing can be achieved on Linux, it just requires clients to remember to set the right flags with open.

Sorry but I just don't get it. The standard library doesn't contain any functionality to work with files. To work with files, you need to use another library, such as the C standard library or a Swift library. If I can call the open function from another library, why is it so important to have resolve in Swift's standard library? Furthermore, Swift is "Swift is the powerful, flexible,
multiplatform programming language." according to swift.org, why are we changing behaviours on Apple platforms?

To make matters worse, that function now also behaves differently on different platforms. For example, asking for "/tmp".resolve() on a typical Linux / macOS 15 / macOS 26.0 will then give me

  • /tmp, for a typical Linux
  • /private/tmp, for a typical macOS 15
  • /.nofollow/private/tmp, for a typical macOS 26+

What's even weirder is if I did

symlink("/etc/hostname", "/tmp/hostname")

let tempDir = try! "/tmp".resolve()
open(tempDir.appending("hostname") // this will fail because `/.nofollow` won't follow any symlinks

This would now succeed on Linux, succeed on macOS 15 but fail on macOS 26+ because my tempDir suddenly contains a /.nofollow. Now, one could argue that that's "secure"? I don't buy that argument but if we're gonna fly with it, you now need to pass O_NOFOLLOW_ANY to open on Linux to be as secure but on macOS 26+ it's somehow added to the path automatically by the resolve() function.

To make software secure, functions need to be understandable and predictable. This resolve() function is not that.

Respectfully, this is a mistake and now's the time to fix it.

16 Likes

I agree. It feels very dangerous for this API to have such platform- and version-specific security characteristics. On every other platform, this is just going to resolve symlinks, which introduces a new TOCTOU bug when the program tries to do something with the resolved path. (I guess we can call this a TOCTOUTOU bug?) These other platforms cannot deliver a path with the special security properties of one returned by macOS 26.0’s implementation of resolve(). Would the LSG consider marking this API @available(macOS 26.0)?

1 Like

Adding a single @available(macOS 26.0) attribute doesn't limit the availability of the API to macOS. It would just limit availability to version 26.0 and up when targeting macOS specifically. Availability on other platforms would remain unrestricted. There isn't a clean way to express "only available on one platform and no others" with @available attributes.

Not even @available(*, unavailable) @available(macOS 26.0)?

This is the fundamental point of disagreement. The alternative to "not having resolve" isn't "no one ever does resolve", it's "everyone who thinks they need it implements their own resolve, which in the best case scenario requires unsafe code to call the right underlying system API, and in the worst case will be a half-baked lexical resolve that definitely introduces more hazards".

2 Likes

No, @available(*, unavailable) takes precedence.

I’m not talking about the scenario where someone does their own thing instead of calling resolve(). I’m talking about the case where someone writes code that calls resolve() and then does things with the resulting path which are secure on macOS 26.0 and insecure elsewhere, but they have no idea because they learned the pattern by reading existing code, and the compiler lets them do it because they are writing portable code.

1 Like

It is still safer than what they would otherwise have available (roll-your-own), and at least as safe as what any other language I'm aware of provides on those platforms (entirely possible that I've missed some better binding, however!)

This is the fundamental point of disagreement. The alternative to "not having resolve" isn't "no one ever does resolve", it's "everyone who thinks they need it implements their own resolve, which in the best case scenario requires unsafe code to call the right underlying system API, and in the worst case will be a half-baked lexical resolve that definitely introduces more hazards"

Do we actually have data to show that this is such a common thing? And can we prove that the resolve we're proposing here is actually gonna solve these issues? I would really like the use cases clearly enumerated before we add function with an easy sounding name (resolve) that is very platform specific, has unexpected behaviours and I think is really poorly motivated.

It is still safer than what they would otherwise have available (roll-your-own), and at least as safe as what any other language I'm aware of provides on those platforms (entirely possible that I've missed some better binding, however!)

Could you motivate which operations become safer that way?


Separately, I think there is another big issue with resolve: On new Darwins exclusively we'll now have two kinds of FilePaths (same type, different behaviours):

  1. The manually constructed ones (e.g. FilePath("/System/Library/Frameworks/WebKit.framework"))
  2. The ones that came out of resolve at some point (e.g. FilePath("/.nofollow/System/Volumes/Preboot/Cryptexes/OS/System/Library/Frameworks/WebKit.framework" (resolve() for the previous path)

Let's say we have them as variables

let webKitFramework = FilePath("/System/Library/Frameworks/WebKit.framework")
let webKitFrameworkResolve = try! webKitFramework.resolve()

These two kinds of file paths (which share the same type) behave fundamentally differently.

To see how they'll behave differently, let's say we had a hypothetical function readInfoPlist defined similarly to this:

func readFrameworkVersion(frameworkPath: FilePath) async throws -> String {
    // construct <FrameworkPath>/Resources/Info.plist, open, read, parse, close
    return try await withOpenFile(
        frameworkPath
        .appending("Resources")
        .appending("Info.plist")
    ) { infoPlist in
        return // parse Info.plist and return version
    }
}

Sounds reasonable I hope. Now let's see we actually called the above function:

let webKitFramework = FilePath("/System/Library/Frameworks/WebKit.framework")
let webKitFrameworkResolve = try! webKitFramework.resolve()

let va = try await readFrameworkVersion(webKitFramework) // will work
let vb = try await readFrameworkVersion(webKitFrameworkResolve) // will FAIL on new Darwins but succeed on older ones and other platforms!

isn't it weird that the readFrameworkVersion(webKitFrameworkResolve) function will (exclusively on newer Darwins) FAIL with Too many levels of symbolic links where readFrameworkVersion(webKitFramework) which is the same file will work?

Why? Well, because resolve auto-prefixes /.nofollow which has the effect of no symlinks being resolved in the path. But frameworkPath.appending("Resources").appending("Info.plist") appends Resources/Info.plist to a path. And in macOS frameworks, Resources is typically a symlink to Versions/Current/Resources and Current is typically a symlink to A.

$ head -n1 /System/Library/Frameworks/WebKit.framework/Resources/Info.plist 
<?xml version="1.0" encoding="UTF-8"?>

$ head -n1 /System/Volumes/Preboot/Cryptexes/OS/System/Library/Frameworks/WebKit.framework/Resources/Info.plist
<?xml version="1.0" encoding="UTF-8"?>

$ head -n1 /.nofollow/System/Volumes/Preboot/Cryptexes/OS/System/Library/Frameworks/WebKit.framework/Resources/Info.plist
head: /.nofollow/System/Volumes/Preboot/Cryptexes/OS/System/Library/Frameworks/WebKit.framework/Resources/Info.plist: Too many levels of symbolic links

:backhand_index_pointing_right: Fundamentally, resolve'd FilePaths behave differently to other FilePaths on new Darwins, especially when it comes to appending path components. They do behave the same on all other platforms. This is a problem but we don't mind problems, we like solutions. What about instead of resolve being (FilePath) -> FilePath it becomes something like (FilePath) -> ResolvedFilePath? That solves a lot of issues:

  1. ResolveFilePath would allow us to automatically add the NO_FOLLOW_ANY on all platforms in the future withOpenFile function. This fixes the very odd platform discrepancies.
  2. We can remove the appending et al functions that don't make sense on ResolvedFilePath (And no, appending can't just keep it resolved, keeping it resolved correctly requires a live file system)
  3. Any function can declare through the type system what it expects.
  4. Converting between them is easy: FilePath(unresolving: resolvePath) would just strip the /.nofollow prefix on Darwin. try await FileSystem.shared.resolve(filePath) -> ResolveFilePath goes the other way.

That way, we could actually claim a win: No more platform differences, no more confusion, no more 'two kinds of file paths masquerading in one FilePath type. Needless to say, this should be a well motivated and separate evolution proposal.

*) I also noticed that the proposal doesn't link an implementation. Mind adding that?

17 Likes

One thing I'm curious about is whether anyone considered whether the Darwin-specific isResourceFork could be applicable to Windows as well?

It seems like Alternate Data Streams are the spiritual equivalent of resource forks, even following a similar file path mechanism to identify the data stream name, e.g. C:\Windows\file.txt:foo that can be directly read/written.

@compnerd, any thoughts on that?

ADS is definitely spiritually the equivalent to resource forks. We could model them similarly, but would likely want the windows specific nomenclature.

2 Likes

For starters, you’d probably want to be able to obtain the name of the alternate data stream.

Though now that I think about it, this API could return misleading answers if it doesn’t resolve the path to a particular filesystem. Alternate data streams and resource forks are features of specific file systems; the same path syntax could resolve differently on NTFS/ReFS or APFS/ExFAT.

1 Like

Yes, ADS would definitely need to consult the file system - there are hooks for minifilter drivers that are involved.

I was starting to get concerned, so I ran a test. It looks like the /..namedfork/rsrc suffix is always handled as a resource fork, even when the prefix refers to a directory. But nothing prevents you from creating a subdirectory named ..namedfork and a file named rsrc within it.

% mkdir -p /tmp/foo/..namedfork
% touch /tmp/foo/..namedfork/rsrc
touch: /tmp/foo/..namedfork/rsrc: Operation not permitted

% (cd /tmp/foo/..namedfork/ ; touch rsrc)             
% stat /tmp/foo/..namedfork/rsrc
stat: /tmp/foo/..namedfork/rsrc: stat: No such file or directory

% find /tmp/foo/..namedfork
/tmp/foo/..namedfork
/tmp/foo/..namedfork/rsrc

On a non-APFS/HFS filesystem, creating and writing to a resource fork path silently does nothing, while attempting to read from it fails loudly:

% touch /Volumes/ExFATTest/foo
% echo "hello" > /Volumes/ExFATTest/foo/..namedfork/rsrc
% echo $?
0

% cat /Volumes/ExFATTest/foo/..namedfork/rsrc   
cat: /Volumes/ExFATTest/foo/..namedfork/rsrc: No such file or directory

So at least the isResourceFork property seems to be implementable without hitting the filesystem, at least on Darwin. Though it is possible that a path that returns true from isResourceFork could, when iterated component-wise, refer to a normal file. I hope that never makes it into a CVE.

2 Likes