diff options
| author | Jakob Unterwurzacher | 2026-01-23 21:32:49 +0100 |
|---|---|---|
| committer | Jakob Unterwurzacher | 2026-01-24 20:00:43 +0100 |
| commit | 041e977dfc5c84ef5b38199c9b91c1586c59df90 (patch) | |
| tree | 74b924a0bf1f5298a403ffb4480cf378453a7c5e /internal/nametransform | |
| parent | c9cf6f1f8a5b90c9cb70ed19f8c8426dc2655c9d (diff) | |
macos: normalize unicode file names in forward modenfc_v2
Summary: Store as NFC, read as NFD.
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
and https://github.com/macfuse/macfuse/wiki/File-Names-(Unicode-Normalization-Forms) .
See https://github.com/rfjakob/gocryptfs/pull/949 for more info.
This commit does nothing for reverse mode as it is not clear if
anything can be done. Reverse mode can not influence how the
file names are stored, hence mapping normalized names back to
what is actually on disk seems difficult.
Diffstat (limited to 'internal/nametransform')
| -rw-r--r-- | internal/nametransform/names.go | 43 | ||||
| -rw-r--r-- | internal/nametransform/nfc_test.go | 29 |
2 files changed, 65 insertions, 7 deletions
diff --git a/internal/nametransform/names.go b/internal/nametransform/names.go index 3313a7c..0389c95 100644 --- a/internal/nametransform/names.go +++ b/internal/nametransform/names.go @@ -7,9 +7,12 @@ import ( "errors" "math" "path/filepath" + "runtime" "strings" "syscall" + "golang.org/x/text/unicode/norm" + "github.com/rfjakob/eme" "github.com/rfjakob/gocryptfs/v2/internal/tlog" @@ -32,6 +35,12 @@ type NameTransform struct { // Patterns to bypass decryption badnamePatterns []string deterministicNames bool + // Convert filenames to NFC before encrypting, + // and to NFD when decrypting. + // For MacOS compatibility. + // Automatically enabled on MacOS, off otherwise, + // except in tests (see nfc_test.go). + nfd2nfc bool } // New returns a new NameTransform instance. @@ -55,34 +64,44 @@ func New(e *eme.EMECipher, longNames bool, longNameMax uint8, raw64 bool, badnam effectiveLongNameMax = int(longNameMax) } } + nfd2nfc := runtime.GOOS == "darwin" + if nfd2nfc { + tlog.Info.Printf("Running on MacOS, enabling Unicode normalization") + } return &NameTransform{ emeCipher: e, longNameMax: effectiveLongNameMax, B64: b64, badnamePatterns: badname, deterministicNames: deterministicNames, + nfd2nfc: nfd2nfc, } } // DecryptName calls decryptName to try and decrypt a base64-encoded encrypted // filename "cipherName", and failing that checks if it can be bypassed -func (n *NameTransform) DecryptName(cipherName string, iv []byte) (string, error) { - res, err := n.decryptName(cipherName, iv) +func (n *NameTransform) DecryptName(cipherName string, iv []byte) (plainName string, err error) { + plainName, err = n.decryptName(cipherName, iv) if err != nil && n.HaveBadnamePatterns() { - res, err = n.decryptBadname(cipherName, iv) + plainName, err = n.decryptBadname(cipherName, iv) } if err != nil { return "", err } - if err := IsValidName(res); err != nil { + if err := IsValidName(plainName); err != nil { tlog.Warn.Printf("DecryptName %q: invalid name after decryption: %v", cipherName, err) return "", syscall.EBADMSG } - return res, err + if n.nfd2nfc { + // MacOS expects file names in NFD form. Present them as NFD. + // They are converted back to NFC in EncryptName. + plainName = norm.NFD.String(plainName) + } + return plainName, err } -// decryptName decrypts a base64-encoded encrypted filename "cipherName" using the -// initialization vector "iv". +// decryptName decrypts a base64-encoded encrypted file- or xattr-name "cipherName" +// using the initialization vector "iv". func (n *NameTransform) decryptName(cipherName string, iv []byte) (string, error) { // From https://pkg.go.dev/encoding/base64#Encoding.Strict : // > Note that the input is still malleable, as new line characters @@ -126,6 +145,16 @@ func (n *NameTransform) EncryptName(plainName string, iv []byte) (cipherName64 s tlog.Warn.Printf("EncryptName %q: invalid plainName: %v", plainName, err) return "", syscall.EBADMSG } + if n.nfd2nfc { + // MacOS GUI apps expect Unicode in NFD form. + // But MacOS CLI apps, Linux and Windows use NFC form. + // We normalize to NFC for two reasons: + // 1) Make sharing gocryptfs filesystems from MacOS to other systems + // less painful + // 2) Enable DecryptName to normalize to NFD, which works for both + // GUI and CLI on MacOS. + plainName = norm.NFC.String(plainName) + } return n.encryptName(plainName, iv), nil } diff --git a/internal/nametransform/nfc_test.go b/internal/nametransform/nfc_test.go new file mode 100644 index 0000000..aad1d7d --- /dev/null +++ b/internal/nametransform/nfc_test.go @@ -0,0 +1,29 @@ +package nametransform + +import ( + "strconv" + "testing" + + "golang.org/x/text/unicode/norm" +) + +func TestNFD2NFC(t *testing.T) { + n := newLognamesTestInstance(NameMax) + n.nfd2nfc = true + iv := make([]byte, DirIVLen) + srcNFC := "Österreich Café" + srcNFD := norm.NFD.String(srcNFC) + + // cipherName should get normalized to NFC + cipherName, _ := n.EncryptName(srcNFD, iv) + // Decrypt without changing normalization + decryptedRaw, _ := n.decryptName(cipherName, iv) + if srcNFC != decryptedRaw { + t.Errorf("want %s have %s", strconv.QuoteToASCII(srcNFC), strconv.QuoteToASCII(decryptedRaw)) + } + // Decrypt with normalizing to NFD + decrypted, _ := n.DecryptName(cipherName, iv) + if srcNFD != decrypted { + t.Errorf("want %s have %s", strconv.QuoteToASCII(srcNFD), strconv.QuoteToASCII(decrypted)) + } +} |
