What do we have? case-preserving case-insensitive filesystems on Windows & macOS.
What do we want? case-sensible semantics on Windows, macOS & Linux.
Also, retry in case renaming/removing files fails because they're locked by an Antivirus.
Throughout this documentation and code, we make the following assumptions:
- that on Linux, we have a case-sensitive filesystem
- that on Windows and macOS, we have a case-preserving, case-insensitive filesystem.
screw makes no effort to support case-insensitive Linux
folders, or case-sensitive Windows folders, or case-sensitive macOS partitions
Now that we're clear on the scope of screw, let's agree on some common vocabulary.
CS filesystems allow multiple files to exist, whose names differ only in casing.
For example, the following files may coexist:
- apricot
- Apricot
- APRICOT
They are three separate files, they can have separate contents, separate permissions, etc. Deleting one of them doesn't touch the others.
Additionally, if only apricot exist, trying to access APRICOT will result in an error, since,
well, it doesn't exist.
Case-sensitive semantics are the easiest to remember.
The idea behind CPCI filesystems is that if a user creates file
named apricot, and later tries to access it as APRICOT, we know what
they mean, so we should just give them apricot.
The following properties follow:
- Only a single casing of a name may exist at a time.
apricotandAPRICOTmay not both exist at the same time
- Opening
APRICOTopensapricot - Stat'ing
APRICOTstatsapricotbut it returnsAPRICOTas the name - Deleting
APRICOTdeletesapricot - Creating
APRICOTtruncatesapricot - Renaming
bananatoAPRICOTwill replaceapricot - Renaming
apricottoAPRICOTis possible- But it may take extra steps, depending on the OS API
screw tries to provide a mostly case-sensitive interface over CPCI filesystems.
If APRICOT exists, screw can pretend apricot doesn't - even though CPCI semantics
would say it does.
However, if APRICOT exists, creating apricot cannot be magically made to work.
Those two can't coexist under CPCI. In that case, apricot throws an error,
screw.ErrCaseConflict.
In short:
screwonly lets youStat,Lstat,OpenorRemovea file if you give its actual casingscrewonly lets youCreatea file if no casing variant existsscrewhasRemoveAlldo nothing if the exact casing you passed doesn't exist
In the rest of the text, CPCI (case-preserving case-insensitive) semantics will be referred to as "undesirable", and CSSB (case-sensible) semantics will be referred to as "desirable".
If you disagree, screw probably isn't for you!
First, note that core screw operations are implemented in OpenFile, so when we mean
Open or Create, we actually mean these:
| Shorthand | Actual call |
|---|---|
| Open(name) | OpenFile(name, O_RDONLY, 0) |
| Create(name) | OpenFile(name, O_RDWR|O_CREATE|O_TRUNC, 0o666) |
The tables in the rest of this document attribute meaning to some emojis:
| Emoji | Meaning |
|---|---|
| β | Desirable return value |
| β | Undesirable return value |
| β | Desirable error |
| β | Undesirable error |
The table assume "apricot" is passed to all those functions, and that the code
runs on a case-preserving, case-insensitive filesystem (Windows, macOS).
| Operation | Existing file name | os package (CPCI) |
screw package (CSBL) |
|---|---|---|---|
| Stat, Lstat | (none) | β os.ErrNotExist | |
| "apricot" | β stat "apricot" | ||
| "APRICOT" | β stat "APRICOT" | β os.ErrNotExist | |
| Open | (none) | β os.ErrNotExist | |
| "apricot" | β open "apricot" | ||
| "APRICOT" | β open "APRICOT" | β os.ErrNotExist | |
| Create | (none) | β create "apricot" | |
| "apricot" | β truncate "apricot" | ||
| "APRICOT" | β truncate "APRICOT" | β screw.ErrCaseConflict | |
| Truncate | (none) | β create "apricot" | |
| "apricot" | β truncate "apricot" | ||
| "APRICOT" | β truncate "APRICOT" | β screw.ErrCaseConflict |
Destructive operations also behave differently:
| Operation | Existing file name | os package (CPCI) |
screw package (CSBL) |
|---|---|---|---|
| Remove | (none) | β os.ErrNotExist | |
| "apricot" | β removes "apricot" | ||
| "APRICOT" | β removes "APRICOT" | β os.ErrNotExist | |
| RemoveAll | (none) | β does nothing | |
| "apricot" | β removes "apricot" | ||
| "APRICOT" | β removes "APRICOT" | β does nothing |
The mkdir family behaves differently:
| Operation | Existing dir name | os package (CPCI) |
screw package (CSBL) |
|---|---|---|---|
| Mkdir | (none) | β mkdir "apricot" | |
| "apricot/" | β os.ErrExist | ||
| "APRICOT/" | β os.ErrExist | β screw.ErrCaseConflict | |
| MkdirAll | (none) | β mkdir "apricot" | |
| "apricot/" | β does nothing | ||
| "APRICOT/" | β does nothing | β screw.ErrCaseConflict |
ioutil.ReadFile, ioutil.WriteFile and ioutil.ReadDir are included in screw
This table passes "apricot" to ReadFile and ReadDir
| Operation | Existing file/dir name | ioutil package (CPCI) |
screw package (CSBL) |
|---|---|---|---|
| ReadFile | (none) | β os.NotExist | |
| "apricot" | β read "apricot" | ||
| "APRICOT" | β read "APRICOT" | β os.NotExist | |
| WriteFile | (none) | β create "apricot" | |
| "apricot" | β overwrites "apricot" | ||
| "APRICOT" | β overwrites "apricot" | β screw.ErrCaseConflict | |
| ReadDir | (none) | β os.NotExist | |
| "apricot/" | β list "apricot/" | ||
| "APRICOT/" | β list "APRICOT/" | β os.NotExist |
One of the undesired behaviors of CPCI is that the following code:
func main() {
// Note: the file `APRICOT` exists
f, _ := os.Open("apricot")
stats, _ := f.Stat()
fmt.Println(stats.Name())
}...will print APRICOT, not apricot.
What about screw? Shouldn't it have its own File type to prevent that?
In screw, that's not a problem, because the above code sample fails at the first line
with os.ErrNotFound, so any os.FileInfo obtained via screw contains the exact casing.
Similarly file.Readdir() and file.Readdirname() can only be called on a file
opened with its exact casing.
screw only checks the last path component, ie. the "base name", for performance
and compatibility reasons.
Checking the entire path has these problems:
- It's expensive to call
FindFirstFilefor every path element, like:C:\Users\bob\AppData\Roaming\somethingC:\Users\bob\AppData\RoamingC:\Users\bob\AppDataC:\Users\bobC:\Users
- On macOS, the "reference path" might be different even if both have the true case, e.g.:
/tmp/foobarhas reference path:/private/tmp/foobar
In addition to wrapping a lot of os functions, screw also provides these functions:
| Operation | Existing file name | Parameter | Result |
|---|---|---|---|
| TrueBaseName | (none) | "apricot" | β "" |
| "apricot" | "apricot" | β "apricot" | |
| "apricot" | "APRICOT" | β "apricot" | |
| "apricot/seed" | "apricot/SEED" | β "apricot/seed" | |
| "apricot/seed" | "APRICOT/seed" | β "aAPRICOT/seed" |
Important note: contrary to the simplified table above, GetActualCase returns absolute paths,
not relative ones.
On Windows, screw.Rename differs from os.Rename in two ways.
After a case-only rename (e.g. apricot => APRICOT), screw makes sure the file now has the expected casing.
If it doesn't, it attempts a two-step rename, ie.:
apricot=>apricot_rename_${pid}apricot_rename_${pid}=>APRICOT
This seems unnecessary on recent versions of Windows 10 (as of October 2019), but it is the author's recollection that this wasn't always the case.
Additionally, screw.Rename contains retry logic on Windows (to sidestep spurious AV file locking),
and logic for older versions of Windows that don't support case-only renames.
UNC paths (like \\?\C:\Windows\, \\SOMEHOST\\Share) are untested and unsupported in screw at the time of this writing.
screw always wraps errors in a *os.PathError, to provide additional information as to which
operation caused the error, and on which file.
Comparing errors with == is a bad idea.
Using errors.Is(e, screw.ErrCaseConflict) works.
Using os.IsNotExist(e) also works with screw-returned errors.
On Windows, screw depends on golang.org/x/sys/windows to make the FindFirstFile syscall, instead of the legacy syscall package.
Performance isn't a goal of screw, correctness is.