From 67b184d6e720f522a15a844057e9fdc814a29cad Mon Sep 17 00:00:00 2001 From: TowyTowy Date: Sun, 12 Jul 2026 11:26:47 +0200 Subject: [PATCH] crypt: fix directory names which look like versioned file names The --b2-versions support added in 3fe2aaf96 strips a version string from the last segment of a path before encrypting it, so that the plain text version suffixes which the underlying backend appends to encrypted file leaf names can be handled. EncryptDirName and DecryptDirName share that code, so the last segment of a *directory* name was version stripped too. Only file leaf names are ever given a version string by the backend - a directory gets a version-string-like name from the user, and such a name is encrypted verbatim when it appears as the parent of a file name, so the same directory ended up with two different encryptions. Before this change, with a directory whose name matches rclone's version format, eg dir-v2001-02-03-040506-123: rclone copy file.txt crypt:dir-v2001-02-03-040506-123/ rclone ls crypt:dir-v2001-02-03-040506-123 # => "directory not found" - the file is invisible to listings rclone mkdir crypt:dir-v2001-02-03-040506-123 # => creates a second directory with the same decrypted name After this change EncryptDirName and DecryptDirName encrypt directory names verbatim, so a directory encrypts the same way whether it is named on its own or as the parent of a file. Version strings are only added to file names by the underlying backend, so --b2-versions is unaffected and the existing version tests are untouched. A directory which was created by the old EncryptDirName will no longer decrypt and will be reported as undecryptable in listings. Such directories were already unusable - anything copied into one was written to a different encrypted directory - so nothing which worked before is broken by this. Co-Authored-By: Claude Fable 5 --- backend/crypt/cipher.go | 25 +++++++++++++++++-------- backend/crypt/cipher_test.go | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/backend/crypt/cipher.go b/backend/crypt/cipher.go index 6f4c987f6..d05a93924 100644 --- a/backend/crypt/cipher.go +++ b/backend/crypt/cipher.go @@ -486,7 +486,14 @@ func (c *Cipher) deobfuscateSegment(ciphertext string) (string, error) { } // encryptFileName encrypts a file path -func (c *Cipher) encryptFileName(in string) string { +// +// If stripVersion is set then a version string on the last segment (as +// used by --b2-versions) is removed before encryption and put back in +// plain text afterwards. Only file leaf names are given version strings +// by the underlying backend, so it must not be set for directory names, +// which would otherwise encrypt differently from the same directory +// appearing as the parent of a file name. +func (c *Cipher) encryptFileName(in string, stripVersion bool) string { segments := strings.Split(in, "/") for i := range segments { // Skip directory name encryption if the user chose to @@ -499,7 +506,7 @@ func (c *Cipher) encryptFileName(in string) string { // of the file name gets encrypted/obfuscated hasVersion := false var t time.Time - if i == (len(segments)-1) && version.Match(segments[i]) { + if stripVersion && i == (len(segments)-1) && version.Match(segments[i]) { var s string t, s = version.Remove(segments[i]) // version.Remove can fail, in which case it returns segments[i] @@ -529,7 +536,7 @@ func (c *Cipher) EncryptFileName(in string) string { if c.mode == NameEncryptionOff { return in + c.encryptedSuffix } - return c.encryptFileName(in) + return c.encryptFileName(in, true) } // EncryptDirName encrypts a directory path @@ -537,11 +544,13 @@ func (c *Cipher) EncryptDirName(in string) string { if c.mode == NameEncryptionOff || !c.dirNameEncrypt { return in } - return c.encryptFileName(in) + return c.encryptFileName(in, false) } // decryptFileName decrypts a file path -func (c *Cipher) decryptFileName(in string) (string, error) { +// +// stripVersion is as for encryptFileName. +func (c *Cipher) decryptFileName(in string, stripVersion bool) (string, error) { segments := strings.Split(in, "/") for i := range segments { var err error @@ -555,7 +564,7 @@ func (c *Cipher) decryptFileName(in string) (string, error) { // of the file name gets decrypted/deobfuscated hasVersion := false var t time.Time - if i == (len(segments)-1) && version.Match(segments[i]) { + if stripVersion && i == (len(segments)-1) && version.Match(segments[i]) { var s string t, s = version.Remove(segments[i]) // version.Remove can fail, in which case it returns segments[i] @@ -601,7 +610,7 @@ func (c *Cipher) DecryptFileName(in string) (string, error) { // Leave the version string on, if it was there return decrypted, nil } - return c.decryptFileName(in) + return c.decryptFileName(in, true) } // DecryptDirName decrypts a directory path @@ -609,7 +618,7 @@ func (c *Cipher) DecryptDirName(in string) (string, error) { if c.mode == NameEncryptionOff || !c.dirNameEncrypt { return in, nil } - return c.decryptFileName(in) + return c.decryptFileName(in, false) } // NameEncryptionMode returns the encryption mode in use for names diff --git a/backend/crypt/cipher_test.go b/backend/crypt/cipher_test.go index fe2edc6f9..217b85447 100644 --- a/backend/crypt/cipher_test.go +++ b/backend/crypt/cipher_test.go @@ -682,6 +682,28 @@ func TestNonStandardDecryptDirName(t *testing.T) { } } +// Test directories whose name looks like it has a version string - +// these encrypt verbatim, so that EncryptDirName agrees with the same +// directory encrypted as the parent of a file name +func TestVersionedDirName(t *testing.T) { + const dir = "dir-v2001-02-03-040506-123" + for _, encoding := range []string{"base32", "base64", "base32768"} { + enc, _ := NewNameEncoding(encoding) + for _, mode := range []NameEncryptionMode{NameEncryptionStandard, NameEncryptionObfuscated} { + c, _ := newCipher(mode, "", "", true, enc) + what := fmt.Sprintf("Testing %q (mode=%v)", encoding, mode) + // Check EncryptDirName matches the parent of an encrypted file name + encryptedDir := c.EncryptDirName(dir) + encryptedFile := c.EncryptFileName(dir + "/file.txt") + assert.Equal(t, encryptedDir+"/", encryptedFile[:strings.LastIndex(encryptedFile, "/")+1], what) + // Check the encrypted directory name round trips OK + decryptedDir, err := c.DecryptDirName(encryptedDir) + assert.NoError(t, err, what) + assert.Equal(t, dir, decryptedDir, what) + } + } +} + func TestEncryptedSize(t *testing.T) { c, _ := newCipher(NameEncryptionStandard, "", "", true, nil) for _, test := range []struct {