aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorrafjaf2025-07-20 17:04:41 +0200
committerJakob Unterwurzacher2026-01-14 21:05:20 +0100
commita753e0075e854f57689b2b6efa00a447149df8b9 (patch)
treecb9ba4cbca88eb4d81c13198d11e6489cb6cea4c
parentc9cf6f1f8a5b90c9cb70ed19f8c8426dc2655c9d (diff)
macOS: Fix Unicode normalization issues in forward & reverse moderafjaf-rebase-onto-2.6.0
This commit resolves https://github.com/rfjakob/gocryptfs/issues/850 by addressing Unicode normalization mismatches on macOS between NFC (used by CLI tools) and NFD (used by GUI apps). The solution is inspired by Cryptomator's approach ( https://github.com/cryptomator/cryptomator/issues/264 ). Forward mode on MacOS now enforces NFC for storage but presents NFD as recommended by https://developer.apple.com/library/archive/qa/qa1173/_index.html . See https://github.com/rfjakob/gocryptfs/pull/949 for more info.
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--internal/fusefrontend/file_dir_ops.go4
-rw-r--r--internal/fusefrontend/node.go7
-rw-r--r--internal/fusefrontend/node_dir_ops.go19
-rw-r--r--internal/fusefrontend/node_open_create.go1
-rw-r--r--tests/macos_filename_encoding/nfc_nfd_test.go457
7 files changed, 490 insertions, 1 deletions
diff --git a/go.mod b/go.mod
index c608e6f..986f329 100644
--- a/go.mod
+++ b/go.mod
@@ -13,4 +13,5 @@ require (
golang.org/x/crypto v0.45.0
golang.org/x/sys v0.38.0
golang.org/x/term v0.37.0
+ golang.org/x/text v0.31.0
)
diff --git a/go.sum b/go.sum
index f0e7ea7..29d991f 100644
--- a/go.sum
+++ b/go.sum
@@ -38,6 +38,8 @@ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/fusefrontend/file_dir_ops.go b/internal/fusefrontend/file_dir_ops.go
index b69e7bc..df14410 100644
--- a/internal/fusefrontend/file_dir_ops.go
+++ b/internal/fusefrontend/file_dir_ops.go
@@ -138,6 +138,8 @@ func (f *File) Readdirent(ctx context.Context) (entry *fuse.DirEntry, errno sysc
continue
}
if f.rootNode.args.PlaintextNames {
+ // Even in plaintext mode, normalize for macOS display
+ entry.Name = normalizeFilenameForDisplay(cName)
return
}
if !f.rootNode.args.DeterministicNames && cName == nametransform.DirIVFilename {
@@ -171,7 +173,7 @@ func (f *File) Readdirent(ctx context.Context) (entry *fuse.DirEntry, errno sysc
}
// Override the ciphertext name with the plaintext name but reuse the rest
// of the structure
- entry.Name = name
+ entry.Name = normalizeFilenameForDisplay(name)
return
}
}
diff --git a/internal/fusefrontend/node.go b/internal/fusefrontend/node.go
index 95be48d..56f9452 100644
--- a/internal/fusefrontend/node.go
+++ b/internal/fusefrontend/node.go
@@ -140,6 +140,7 @@ func (n *Node) Access(ctx context.Context, mode uint32) syscall.Errno {
//
// Symlink-safe through use of Unlinkat().
func (n *Node) Unlink(ctx context.Context, name string) (errno syscall.Errno) {
+ name = normalizeFilename(name) // Always store as NFC
dirfd, cName, errno := n.prepareAtSyscall(name)
if errno != 0 {
return
@@ -274,6 +275,7 @@ func (n *Node) Statfs(ctx context.Context, out *fuse.StatfsOut) syscall.Errno {
//
// Symlink-safe through use of Mknodat().
func (n *Node) Mknod(ctx context.Context, name string, mode, rdev uint32, out *fuse.EntryOut) (inode *fs.Inode, errno syscall.Errno) {
+ name = normalizeFilename(name) // Always store as NFC
dirfd, cName, errno := n.prepareAtSyscall(name)
if errno != 0 {
return
@@ -329,6 +331,7 @@ func (n *Node) Mknod(ctx context.Context, name string, mode, rdev uint32, out *f
//
// Symlink-safe through use of Linkat().
func (n *Node) Link(ctx context.Context, target fs.InodeEmbedder, name string, out *fuse.EntryOut) (inode *fs.Inode, errno syscall.Errno) {
+ name = normalizeFilename(name) // Always store as NFC
dirfd, cName, errno := n.prepareAtSyscall(name)
if errno != 0 {
return
@@ -379,6 +382,7 @@ func (n *Node) Link(ctx context.Context, target fs.InodeEmbedder, name string, o
//
// Symlink-safe through use of Symlinkat.
func (n *Node) Symlink(ctx context.Context, target, name string, out *fuse.EntryOut) (inode *fs.Inode, errno syscall.Errno) {
+ name = normalizeFilename(name) // Always store as NFC
dirfd, cName, errno := n.prepareAtSyscall(name)
if errno != 0 {
return
@@ -451,6 +455,9 @@ func (n *Node) Rename(ctx context.Context, name string, newParent fs.InodeEmbedd
return errno
}
+ name = normalizeFilename(name) // Always store as NFC
+ newName = normalizeFilename(newName) // Always store as NFC
+
dirfd, cName, errno := n.prepareAtSyscall(name)
if errno != 0 {
return
diff --git a/internal/fusefrontend/node_dir_ops.go b/internal/fusefrontend/node_dir_ops.go
index 97327ce..ba78ac0 100644
--- a/internal/fusefrontend/node_dir_ops.go
+++ b/internal/fusefrontend/node_dir_ops.go
@@ -6,8 +6,10 @@ import (
"io"
"runtime"
"syscall"
+ "unicode/utf8"
"golang.org/x/sys/unix"
+ "golang.org/x/text/unicode/norm"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
@@ -20,6 +22,22 @@ import (
const dsStoreName = ".DS_Store"
+// normalizeFilename converts filenames to NFC for consistent internal storage
+func normalizeFilename(name string) string {
+ if runtime.GOOS == "darwin" && utf8.ValidString(name) {
+ return norm.NFC.String(name)
+ }
+ return name
+}
+
+// normalizeFilenameForDisplay converts NFC to NFD for macOS GUI compatibility
+func normalizeFilenameForDisplay(name string) string {
+ if runtime.GOOS == "darwin" && utf8.ValidString(name) {
+ return norm.NFD.String(name)
+ }
+ return name
+}
+
// haveDsstore return true if one of the entries in "names" is ".DS_Store".
func haveDsstore(entries []fuse.DirEntry) bool {
for _, e := range entries {
@@ -70,6 +88,7 @@ func (n *Node) mkdirWithIv(dirfd int, cName string, mode uint32, context *fuse.C
//
// Symlink-safe through use of Mkdirat().
func (n *Node) Mkdir(ctx context.Context, name string, mode uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
+ name = normalizeFilename(name) // Always store as NFC
dirfd, cName, errno := n.prepareAtSyscall(name)
if errno != 0 {
return nil, errno
diff --git a/internal/fusefrontend/node_open_create.go b/internal/fusefrontend/node_open_create.go
index 9598559..24f3e21 100644
--- a/internal/fusefrontend/node_open_create.go
+++ b/internal/fusefrontend/node_open_create.go
@@ -58,6 +58,7 @@ func (n *Node) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFl
//
// Symlink-safe through the use of Openat().
func (n *Node) Create(ctx context.Context, name string, flags uint32, mode uint32, out *fuse.EntryOut) (inode *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
+ name = normalizeFilename(name) // Always store as NFC
dirfd, cName, errno := n.prepareAtSyscall(name)
if errno != 0 {
return
diff --git a/tests/macos_filename_encoding/nfc_nfd_test.go b/tests/macos_filename_encoding/nfc_nfd_test.go
new file mode 100644
index 0000000..c308fed
--- /dev/null
+++ b/tests/macos_filename_encoding/nfc_nfd_test.go
@@ -0,0 +1,457 @@
+package macos_filename_encoding
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "golang.org/x/text/unicode/norm"
+
+ "github.com/rfjakob/gocryptfs/v2/tests/test_helpers"
+)
+
+var nfcName = norm.NFC.String("e\u0301") // é
+var nfdName = norm.NFD.String("e\u0301") // e + combining acute accent
+
+// Additional test cases with various Unicode characters
+var unicodeTestCases = []struct {
+ name string
+ nfc string
+ nfd string
+ comment string
+}{
+ {"acute_e", norm.NFC.String("e\u0301"), norm.NFD.String("e\u0301"), "é (e + combining acute)"},
+ {"circumflex_a", norm.NFC.String("a\u0302"), norm.NFD.String("a\u0302"), "â (a + combining circumflex)"},
+ {"tilde_n", norm.NFC.String("n\u0303"), norm.NFD.String("n\u0303"), "ñ (n + combining tilde)"},
+ {"umlaut_u", norm.NFC.String("u\u0308"), norm.NFD.String("u\u0308"), "ü (u + combining diaeresis)"},
+ {"multiple_combining", norm.NFC.String("o\u0302\u0308"), norm.NFD.String("o\u0302\u0308"), "ô̈ (o + circumflex + diaeresis)"},
+}
+
+func TestCreateNFC_AccessNFD(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("macOS only test")
+ }
+ if nfcName == nfdName {
+ t.Fatal("NFC and NFD names should be different")
+ }
+ test_helpers.ResetTmpDir(false)
+ cipherDir := test_helpers.InitFS(t)
+ mntDir := filepath.Join(test_helpers.TmpDir, "mnt")
+ test_helpers.MountOrFatal(t, cipherDir, mntDir, "-extpass", "echo test")
+ defer test_helpers.UnmountPanic(mntDir)
+
+ // Create a file with an NFC name
+ nfcPath := filepath.Join(mntDir, nfcName)
+ err := os.WriteFile(nfcPath, []byte("content"), 0600)
+ if err != nil {
+ t.Fatalf("Failed to create file with NFC name: %v", err)
+ }
+
+ // Try to read it with an NFD name
+ nfdPath := filepath.Join(mntDir, nfdName)
+ content, err := os.ReadFile(nfdPath)
+ if err != nil {
+ t.Fatalf("Failed to read file with NFD name: %v", err)
+ }
+ if string(content) != "content" {
+ t.Errorf("Wrong content: %q", string(content))
+ }
+}
+
+func TestCreateNFD_AccessNFC(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("macOS only test")
+ }
+ if nfcName == nfdName {
+ t.Fatal("NFC and NFD names should be different")
+ }
+ test_helpers.ResetTmpDir(false)
+ cipherDir := test_helpers.InitFS(t)
+ mntDir := filepath.Join(test_helpers.TmpDir, "mnt")
+ test_helpers.MountOrFatal(t, cipherDir, mntDir, "-extpass", "echo test")
+ defer test_helpers.UnmountPanic(mntDir)
+
+ // Create a file with an NFD name
+ nfdPath := filepath.Join(mntDir, nfdName)
+ err := os.WriteFile(nfdPath, []byte("content"), 0600)
+ if err != nil {
+ t.Fatalf("Failed to create file with NFD name: %v", err)
+ }
+
+ // Try to read it with an NFC name
+ nfcPath := filepath.Join(mntDir, nfcName)
+ content, err := os.ReadFile(nfcPath)
+ if err != nil {
+ t.Fatalf("Failed to read file with NFC name: %v", err)
+ }
+ if string(content) != "content" {
+ t.Errorf("Wrong content: %q", string(content))
+ }
+}
+
+// TestMultipleUnicodeCharacters tests various Unicode characters with different NFC/NFD forms
+func TestMultipleUnicodeCharacters(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("macOS only test")
+ }
+
+ test_helpers.ResetTmpDir(false)
+ cipherDir := test_helpers.InitFS(t)
+ mntDir := filepath.Join(test_helpers.TmpDir, "mnt")
+ test_helpers.MountOrFatal(t, cipherDir, mntDir, "-extpass", "echo test")
+ defer test_helpers.UnmountPanic(mntDir)
+
+ for _, tc := range unicodeTestCases {
+ t.Run(tc.name, func(t *testing.T) {
+ if tc.nfc == tc.nfd {
+ t.Skipf("NFC and NFD are identical for %s", tc.comment)
+ }
+
+ // Create with NFC, access with NFD
+ nfcPath := filepath.Join(mntDir, "nfc_"+tc.nfc+".txt")
+ err := os.WriteFile(nfcPath, []byte("nfc_content"), 0600)
+ if err != nil {
+ t.Fatalf("Failed to create file with NFC name %s: %v", tc.comment, err)
+ }
+
+ nfdPath := filepath.Join(mntDir, "nfc_"+tc.nfd+".txt")
+ content, err := os.ReadFile(nfdPath)
+ if err != nil {
+ t.Fatalf("Failed to read file with NFD name %s: %v", tc.comment, err)
+ }
+ if string(content) != "nfc_content" {
+ t.Errorf("Wrong content for %s: got %q, want %q", tc.comment, string(content), "nfc_content")
+ }
+
+ // Create with NFD, access with NFC
+ nfdPath2 := filepath.Join(mntDir, "nfd_"+tc.nfd+".txt")
+ err = os.WriteFile(nfdPath2, []byte("nfd_content"), 0600)
+ if err != nil {
+ t.Fatalf("Failed to create file with NFD name %s: %v", tc.comment, err)
+ }
+
+ nfcPath2 := filepath.Join(mntDir, "nfd_"+tc.nfc+".txt")
+ content, err = os.ReadFile(nfcPath2)
+ if err != nil {
+ t.Fatalf("Failed to read file with NFC name %s: %v", tc.comment, err)
+ }
+ if string(content) != "nfd_content" {
+ t.Errorf("Wrong content for %s: got %q, want %q", tc.comment, string(content), "nfd_content")
+ }
+ })
+ }
+}
+
+// TestDirectoryOperations tests directory creation and listing with NFC/NFD names
+func TestDirectoryOperations(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("macOS only test")
+ }
+ if nfcName == nfdName {
+ t.Fatal("NFC and NFD names should be different")
+ }
+
+ test_helpers.ResetTmpDir(false)
+ cipherDir := test_helpers.InitFS(t)
+ mntDir := filepath.Join(test_helpers.TmpDir, "mnt")
+ test_helpers.MountOrFatal(t, cipherDir, mntDir, "-extpass", "echo test")
+ defer test_helpers.UnmountPanic(mntDir)
+
+ // Create directory with NFC name
+ nfcDirPath := filepath.Join(mntDir, "dir_"+nfcName)
+ err := os.Mkdir(nfcDirPath, 0755)
+ if err != nil {
+ t.Fatalf("Failed to create directory with NFC name: %v", err)
+ }
+
+ // Access with NFD name
+ nfdDirPath := filepath.Join(mntDir, "dir_"+nfdName)
+ stat, err := os.Stat(nfdDirPath)
+ if err != nil {
+ t.Fatalf("Failed to stat directory with NFD name: %v", err)
+ }
+ if !stat.IsDir() {
+ t.Error("Expected directory, got file")
+ }
+
+ // Create file inside directory using NFD path
+ filePath := filepath.Join(nfdDirPath, "test.txt")
+ err = os.WriteFile(filePath, []byte("dir_content"), 0600)
+ if err != nil {
+ t.Fatalf("Failed to create file in directory: %v", err)
+ }
+
+ // Read file using NFC directory path
+ filePath2 := filepath.Join(nfcDirPath, "test.txt")
+ content, err := os.ReadFile(filePath2)
+ if err != nil {
+ t.Fatalf("Failed to read file from directory: %v", err)
+ }
+ if string(content) != "dir_content" {
+ t.Errorf("Wrong content: got %q, want %q", string(content), "dir_content")
+ }
+
+ // Test directory listing
+ entries, err := os.ReadDir(nfdDirPath)
+ if err != nil {
+ t.Fatalf("Failed to list directory: %v", err)
+ }
+ if len(entries) != 1 || entries[0].Name() != "test.txt" {
+ t.Errorf("Unexpected directory contents: %v", entries)
+ }
+}
+
+// TestFileOperations tests various file operations with NFC/NFD names
+func TestFileOperations(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("macOS only test")
+ }
+ if nfcName == nfdName {
+ t.Fatal("NFC and NFD names should be different")
+ }
+
+ test_helpers.ResetTmpDir(false)
+ cipherDir := test_helpers.InitFS(t)
+ mntDir := filepath.Join(test_helpers.TmpDir, "mnt")
+ test_helpers.MountOrFatal(t, cipherDir, mntDir, "-extpass", "echo test")
+ defer test_helpers.UnmountPanic(mntDir)
+
+ // Create file with NFC name
+ nfcPath := filepath.Join(mntDir, "ops_"+nfcName+".txt")
+ err := os.WriteFile(nfcPath, []byte("original"), 0600)
+ if err != nil {
+ t.Fatalf("Failed to create file: %v", err)
+ }
+
+ // Test stat with NFD name
+ nfdPath := filepath.Join(mntDir, "ops_"+nfdName+".txt")
+ stat, err := os.Stat(nfdPath)
+ if err != nil {
+ t.Fatalf("Failed to stat file with NFD name: %v", err)
+ }
+ if stat.Size() != 8 {
+ t.Errorf("Wrong file size: got %d, want 8", stat.Size())
+ }
+
+ // Test truncate with NFD name
+ err = os.Truncate(nfdPath, 4)
+ if err != nil {
+ t.Fatalf("Failed to truncate file: %v", err)
+ }
+
+ // Verify truncation with NFC name
+ content, err := os.ReadFile(nfcPath)
+ if err != nil {
+ t.Fatalf("Failed to read truncated file: %v", err)
+ }
+ if string(content) != "orig" {
+ t.Errorf("Wrong content after truncate: got %q, want %q", string(content), "orig")
+ }
+
+ // Test chmod with NFD name
+ err = os.Chmod(nfdPath, 0644)
+ if err != nil {
+ t.Fatalf("Failed to chmod file: %v", err)
+ }
+
+ // Verify chmod with NFC name
+ stat, err = os.Stat(nfcPath)
+ if err != nil {
+ t.Fatalf("Failed to stat file after chmod: %v", err)
+ }
+ if stat.Mode().Perm() != 0644 {
+ t.Errorf("Wrong permissions: got %o, want %o", stat.Mode().Perm(), 0644)
+ }
+
+ // Test removal with NFD name
+ err = os.Remove(nfdPath)
+ if err != nil {
+ t.Fatalf("Failed to remove file with NFD name: %v", err)
+ }
+
+ // Verify removal with NFC name
+ _, err = os.Stat(nfcPath)
+ if !os.IsNotExist(err) {
+ t.Error("File should not exist after removal")
+ }
+}
+
+// TestEdgeCases tests edge cases and error conditions
+func TestEdgeCases(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("macOS only test")
+ }
+
+ test_helpers.ResetTmpDir(false)
+ cipherDir := test_helpers.InitFS(t)
+ mntDir := filepath.Join(test_helpers.TmpDir, "mnt")
+ test_helpers.MountOrFatal(t, cipherDir, mntDir, "-extpass", "echo test")
+ defer test_helpers.UnmountPanic(mntDir)
+
+ // Test filename that is identical in NFC and NFD
+ identicalName := "regular_ascii.txt"
+ if norm.NFC.String(identicalName) != norm.NFD.String(identicalName) {
+ t.Fatal("Test setup error: filename should be identical in NFC and NFD")
+ }
+
+ identicalPath := filepath.Join(mntDir, identicalName)
+ err := os.WriteFile(identicalPath, []byte("identical"), 0600)
+ if err != nil {
+ t.Fatalf("Failed to create file with identical NFC/NFD name: %v", err)
+ }
+
+ content, err := os.ReadFile(identicalPath)
+ if err != nil {
+ t.Fatalf("Failed to read file with identical name: %v", err)
+ }
+ if string(content) != "identical" {
+ t.Errorf("Wrong content: got %q, want %q", string(content), "identical")
+ }
+
+ // Test invalid UTF-8 bytes (should fall back to direct access)
+ invalidUTF8 := string([]byte{0xff, 0xfe, 0xfd})
+ invalidPath := filepath.Join(mntDir, invalidUTF8)
+ err = os.WriteFile(invalidPath, []byte("invalid_utf8"), 0600)
+ if err != nil {
+ t.Fatalf("Failed to create file with invalid UTF-8 name: %v", err)
+ }
+
+ content, err = os.ReadFile(invalidPath)
+ if err != nil {
+ t.Fatalf("Failed to read file with invalid UTF-8 name: %v", err)
+ }
+ if string(content) != "invalid_utf8" {
+ t.Errorf("Wrong content: got %q, want %q", string(content), "invalid_utf8")
+ }
+
+ // Test long filename with Unicode characters
+ longUnicode := strings.Repeat(nfcName, 50) // 50 repetitions of é
+ longPath := filepath.Join(mntDir, longUnicode+".txt")
+ err = os.WriteFile(longPath, []byte("long_unicode"), 0600)
+ if err != nil {
+ t.Fatalf("Failed to create file with long Unicode name: %v", err)
+ }
+
+ // Access with NFD version
+ longNFD := strings.Repeat(nfdName, 50)
+ longNFDPath := filepath.Join(mntDir, longNFD+".txt")
+ content, err = os.ReadFile(longNFDPath)
+ if err != nil {
+ t.Fatalf("Failed to read file with long NFD name: %v", err)
+ }
+ if string(content) != "long_unicode" {
+ t.Errorf("Wrong content: got %q, want %q", string(content), "long_unicode")
+ }
+}
+
+// TestNonExistentFiles tests behavior when files don't exist in either normalization form
+func TestNonExistentFiles(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("macOS only test")
+ }
+ if nfcName == nfdName {
+ t.Fatal("NFC and NFD names should be different")
+ }
+
+ test_helpers.ResetTmpDir(false)
+ cipherDir := test_helpers.InitFS(t)
+ mntDir := filepath.Join(test_helpers.TmpDir, "mnt")
+ test_helpers.MountOrFatal(t, cipherDir, mntDir, "-extpass", "echo test")
+ defer test_helpers.UnmountPanic(mntDir)
+
+ // Try to access non-existent file with NFC name
+ nonExistentNFC := filepath.Join(mntDir, "nonexistent_"+nfcName+".txt")
+ _, err := os.ReadFile(nonExistentNFC)
+ if !os.IsNotExist(err) {
+ t.Errorf("Expected ENOENT for non-existent NFC file, got: %v", err)
+ }
+
+ // Try to access non-existent file with NFD name
+ nonExistentNFD := filepath.Join(mntDir, "nonexistent_"+nfdName+".txt")
+ _, err = os.ReadFile(nonExistentNFD)
+ if !os.IsNotExist(err) {
+ t.Errorf("Expected ENOENT for non-existent NFD file, got: %v", err)
+ }
+
+ // Create file with specific normalization and ensure only that form exists initially
+ specificNFC := filepath.Join(mntDir, "specific_"+nfcName+".txt")
+ err = os.WriteFile(specificNFC, []byte("content"), 0600)
+ if err != nil {
+ t.Fatalf("Failed to create file: %v", err)
+ }
+
+ // Now both forms should work due to Unicode normalization fallback
+ specificNFD := filepath.Join(mntDir, "specific_"+nfdName+".txt")
+ _, err = os.ReadFile(specificNFD)
+ if err != nil {
+ t.Errorf("NFD access should work after NFC creation: %v", err)
+ }
+}
+
+// TestNestedDirectories tests Unicode normalization in nested directory structures
+func TestNestedDirectories(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("macOS only test")
+ }
+ if nfcName == nfdName {
+ t.Fatal("NFC and NFD names should be different")
+ }
+
+ test_helpers.ResetTmpDir(false)
+ cipherDir := test_helpers.InitFS(t)
+ mntDir := filepath.Join(test_helpers.TmpDir, "mnt")
+ test_helpers.MountOrFatal(t, cipherDir, mntDir, "-extpass", "echo test")
+ defer test_helpers.UnmountPanic(mntDir)
+
+ // Create nested directory structure with mixed NFC/NFD
+ dir1NFC := filepath.Join(mntDir, "level1_"+nfcName)
+ err := os.Mkdir(dir1NFC, 0755)
+ if err != nil {
+ t.Fatalf("Failed to create level1 directory: %v", err)
+ }
+
+ // Create subdirectory using NFD path to parent
+ dir1NFD := filepath.Join(mntDir, "level1_"+nfdName)
+ dir2Path := filepath.Join(dir1NFD, "level2_"+nfdName)
+ err = os.Mkdir(dir2Path, 0755)
+ if err != nil {
+ t.Fatalf("Failed to create level2 directory: %v", err)
+ }
+
+ // Create file in nested structure using NFC path
+ dir2NFC := filepath.Join(dir1NFC, "level2_"+nfcName)
+ filePath := filepath.Join(dir2NFC, "nested_file.txt")
+ err = os.WriteFile(filePath, []byte("nested_content"), 0600)
+ if err != nil {
+ t.Fatalf("Failed to create nested file: %v", err)
+ }
+
+ // Access file using different path combinations
+ filePath2 := filepath.Join(dir2Path, "nested_file.txt") // NFD/NFD path
+ content, err := os.ReadFile(filePath2)
+ if err != nil {
+ t.Fatalf("Failed to read nested file with NFD path: %v", err)
+ }
+ if string(content) != "nested_content" {
+ t.Errorf("Wrong content: got %q, want %q", string(content), "nested_content")
+ }
+
+ // Test directory traversal with mixed normalization
+ entries, err := os.ReadDir(dir1NFD)
+ if err != nil {
+ t.Fatalf("Failed to list level1 directory: %v", err)
+ }
+ if len(entries) != 1 {
+ t.Errorf("Expected 1 entry in level1, got %d", len(entries))
+ }
+
+ entries, err = os.ReadDir(dir2NFC)
+ if err != nil {
+ t.Fatalf("Failed to list level2 directory: %v", err)
+ }
+ if len(entries) != 1 || entries[0].Name() != "nested_file.txt" {
+ t.Errorf("Unexpected level2 contents: %v", entries)
+ }
+}