diff --git a/common_ops.go b/common_ops.go index 48ab4ff..a2d9f27 100644 --- a/common_ops.go +++ b/common_ops.go @@ -3,7 +3,6 @@ package main import ( "C" "syscall" - "golang.org/x/sys/unix" "libgocryptfs/v2/internal/nametransform" diff --git a/dircache.go b/dircache.go new file mode 100644 index 0000000..508d38d --- /dev/null +++ b/dircache.go @@ -0,0 +1,136 @@ +package main + +import ( + "log" + "syscall" + "time" +) + +const ( + // Number of entries in the dirCache. + // 20 entries work well for "git stat" on a small git repo on sshfs. + // Keep in sync with test_helpers.maxCacheFds ! + // TODO: How to share this constant without causing an import cycle? + dirCacheSize = 20 + // Enable Lookup/Store/Clear debug messages + enableDebugMessages = false + // Enable hit rate statistics printing + enableStats = false +) + +type dirCacheEntry struct { + path string + // fd to the directory (opened with O_PATH!) + fd int + // content of gocryptfs.diriv in this directory + iv []byte +} + +func (e *dirCacheEntry) Clear() { + // An earlier clear may have already closed the fd, or the cache + // has never been filled (fd is 0 in that case). + // Note: package ensurefds012, imported from main, guarantees that dirCache + // can never get fds 0,1,2. + if e.fd > 0 { + syscall.Close(e.fd) + } + e.fd = -1 + e.path = "" + e.iv = nil +} + +type dirCache struct { + // Expected length of the stored IVs. Only used for sanity checks. + // Usually set to 16, but 0 in plaintextnames mode. + ivLen int + // Cache entries + entries [dirCacheSize]dirCacheEntry + // Where to store the next entry (index into entries) + nextIndex int + // On the first Lookup(), the expire thread is started, and this flag is set + // to true. + expireThreadRunning bool + // Hit rate stats. Evaluated and reset by the expire thread. + lookups uint64 + hits uint64 +} + +// Clear clears the cache contents. +func (d *dirCache) Clear() { + for i := range d.entries { + d.entries[i].Clear() + } +} + +// Store the entry in the cache. The passed "fd" will be Dup()ed, and the caller +// can close their copy at will. +func (d *dirCache) Store(path string, fd int, iv []byte) { + // Note: package ensurefds012, imported from main, guarantees that dirCache + // can never get fds 0,1,2. + if fd <= 0 || len(iv) != d.ivLen { + log.Panicf("Store sanity check failed: fd=%d len=%d", fd, len(iv)) + } + e := &d.entries[d.nextIndex] + // Round-robin works well enough + d.nextIndex = (d.nextIndex + 1) % dirCacheSize + // Close the old fd + e.Clear() + fd2, err := syscall.Dup(fd) + if err != nil { + return + } + e.fd = fd2 + e.path = string([]byte(path[:])) + e.iv = iv + // expireThread is started on the first Lookup() + if !d.expireThreadRunning { + d.expireThreadRunning = true + go d.expireThread() + } +} + +// Lookup checks if relPath is in the cache, and returns an (fd, iv) pair. +// It returns (-1, nil) if not found. The fd is internally Dup()ed and the +// caller must close it when done. +func (d *dirCache) Lookup(path string) (fd int, iv []byte) { + if enableStats { + d.lookups++ + } + var e *dirCacheEntry + for i := range d.entries { + e = &d.entries[i] + if e.fd <= 0 { + // Cache slot is empty + continue + } + if path != e.path { + // Not the right path + continue + } + var err error + fd, err = syscall.Dup(e.fd) + if err != nil { + return -1, nil + } + iv = e.iv + break + } + if fd == 0 { + return -1, nil + } + if enableStats { + d.hits++ + } + if fd <= 0 || len(iv) != d.ivLen { + log.Panicf("Lookup sanity check failed: fd=%d len=%d", fd, len(iv)) + } + return fd, iv +} + +// expireThread is started on the first Lookup() +func (d *dirCache) expireThread() { + for { + time.Sleep(60 * time.Second) + d.Clear() + } +} diff --git a/directory.go b/directory.go index 269abe3..6a7145f 100644 --- a/directory.go +++ b/directory.go @@ -41,7 +41,7 @@ func mkdirWithIv(dirfd int, cName string, mode uint32) error { //export gcf_list_dir func gcf_list_dir(sessionID int, dirName string) (*C.char, *C.int, C.int) { volume := OpenedVolumes[sessionID] - parentDirFd, cDirName, err := volume.prepareAtSyscall(dirName) + parentDirFd, cDirName, err := volume.prepareAtSyscallMyself(dirName) if err != nil { return nil, nil, 0 } @@ -58,7 +58,7 @@ func gcf_list_dir(sessionID int, dirName string) (*C.char, *C.int, C.int) { } // Get DirIV (stays nil if PlaintextNames is used) var cachedIV []byte - if !OpenedVolumes[sessionID].plainTextNames { + if !volume.plainTextNames { // Read the DirIV from disk cachedIV, err = volume.nameTransform.ReadDirIVAt(fd) if err != nil { @@ -75,7 +75,7 @@ func gcf_list_dir(sessionID int, dirName string) (*C.char, *C.int, C.int) { // silently ignore "gocryptfs.conf" in the top level dir continue } - if OpenedVolumes[sessionID].plainTextNames { + if volume.plainTextNames { plain.WriteString(cipherEntries[i].Name + "\x00") modes = append(modes, cipherEntries[i].Mode) continue @@ -96,7 +96,7 @@ func gcf_list_dir(sessionID int, dirName string) (*C.char, *C.int, C.int) { // ignore "gocryptfs.longname.*.name" continue } - name, err := OpenedVolumes[sessionID].nameTransform.DecryptName(cName, cachedIV) + name, err := volume.nameTransform.DecryptName(cName, cachedIV) if err != nil { continue } @@ -143,7 +143,7 @@ func gcf_mkdir(sessionID int, path string, mode uint32) bool { // Handle long file name if nametransform.IsLongContent(cName) { // Create ".name" - err = OpenedVolumes[sessionID].nameTransform.WriteLongNameAt(dirfd, cName, path) + err = volume.nameTransform.WriteLongNameAt(dirfd, cName, path) if err != nil { return false } @@ -188,7 +188,7 @@ func gcf_mkdir(sessionID int, path string, mode uint32) bool { //export gcf_rmdir func gcf_rmdir(sessionID int, relPath string) bool { volume := OpenedVolumes[sessionID] - parentDirFd, cName, err := volume.openBackingDir(relPath) + parentDirFd, cName, err := volume.prepareAtSyscall(relPath) if err != nil { return false } diff --git a/file.go b/file.go index c72e07d..a91e354 100644 --- a/file.go +++ b/file.go @@ -370,7 +370,7 @@ func (volume *Volume) truncate(handleID int, newSize uint64) bool { //export gcf_open_read_mode func gcf_open_read_mode(sessionID int, path string) int { volume := OpenedVolumes[sessionID] - dirfd, cName, err := volume.prepareAtSyscall(path) + dirfd, cName, err := volume.prepareAtSyscallMyself(path) if err != nil { return -1 } @@ -455,13 +455,14 @@ func gcf_write_file(sessionID, handleID int, offset uint64, data []byte) uint32 //export gcf_close_file func gcf_close_file(sessionID, handleID int) { - f, ok := OpenedVolumes[sessionID].file_handles[handleID] + volume := OpenedVolumes[sessionID] + f, ok := volume.file_handles[handleID] if ok { f.fd.Close() - delete(OpenedVolumes[sessionID].file_handles, handleID) - _, ok := OpenedVolumes[sessionID].fileIDs[handleID] + delete(volume.file_handles, handleID) + _, ok := volume.fileIDs[handleID] if ok { - delete(OpenedVolumes[sessionID].fileIDs, handleID) + delete(volume.fileIDs, handleID) } } } diff --git a/helpers.go b/helpers.go index 72d9968..1360494 100644 --- a/helpers.go +++ b/helpers.go @@ -2,14 +2,20 @@ package main import ( "path/filepath" - "strings" "syscall" "libgocryptfs/v2/internal/configfile" - "libgocryptfs/v2/internal/nametransform" "libgocryptfs/v2/internal/syscallcompat" ) +func getParentPath(path string) string { + parent := filepath.Dir(path) + if parent == "." { + return "" + } + return parent +} + // isFiltered - check if plaintext "path" should be forbidden // // Prevents name clashes with internal files when file names are not encrypted @@ -26,107 +32,99 @@ func (volume *Volume) isFiltered(path string) bool { return false } -func (volume *Volume) openBackingDir(relPath string) (dirfd int, cName string, err error) { - dirRelPath := nametransform.Dir(relPath) - // With PlaintextNames, we don't need to read DirIVs. Easy. - if volume.plainTextNames { - dirfd, err = syscallcompat.OpenDirNofollow(volume.rootCipherDir, dirRelPath) - if err != nil { - return -1, "", err - } - // If relPath is empty, cName is ".". - cName = filepath.Base(relPath) - return dirfd, cName, nil - } - // Open cipherdir (following symlinks) - dirfd, err = syscallcompat.Open(volume.rootCipherDir, syscall.O_DIRECTORY|syscallcompat.O_PATH, 0) - if err != nil { - return -1, "", err - } - // If relPath is empty, cName is ".". - if relPath == "" { - return dirfd, ".", nil - } - // Walk the directory tree - parts := strings.Split(relPath, "/") - for i, name := range parts { - iv, err := volume.nameTransform.ReadDirIVAt(dirfd) - if err != nil { - syscall.Close(dirfd) - return -1, "", err - } - cName, err = volume.nameTransform.EncryptAndHashName(name, iv) - if err != nil { - syscall.Close(dirfd) - return -1, "", err - } - // Last part? We are done. - if i == len(parts)-1 { - break - } - // Not the last part? Descend into next directory. - dirfd2, err := syscallcompat.Openat(dirfd, cName, syscall.O_NOFOLLOW|syscall.O_DIRECTORY|syscallcompat.O_PATH, 0) - syscall.Close(dirfd) - if err != nil { - return -1, "", err - } - dirfd = dirfd2 - } - return dirfd, cName, nil -} - func (volume *Volume) prepareAtSyscall(path string) (dirfd int, cName string, err error) { - // root node itself is special if path == "" { - return volume.openBackingDir(path) + return volume.prepareAtSyscallMyself(path) } + if volume.isFiltered(path) { + return -1, "", nil + } + + var encryptName func(int, string, []byte) (string, error) + if !volume.plainTextNames { + encryptName = func(dirfd int, child string, iv []byte) (cName string, err error) { + // Badname allowed, try to determine filenames + if volume.nameTransform.HaveBadnamePatterns() { + return volume.nameTransform.EncryptAndHashBadName(child, iv, dirfd) + } + return volume.nameTransform.EncryptAndHashName(child, iv) + } + } + + child := filepath.Base(path) + parentPath := getParentPath(path) + // Cache lookup - // TODO make it work for plaintextnames as well? - if !volume.plainTextNames { - directory, ok := volume.dirCache[path] - if ok { - if directory.fd > 0 { - cName, err := volume.nameTransform.EncryptAndHashName(filepath.Base(path), directory.iv) - if err != nil { - return -1, "", err - } - dirfd, err = syscall.Dup(directory.fd) - if err != nil { - return -1, "", err - } - return dirfd, cName, nil - } + var iv []byte + dirfd, iv = volume.dirCache.Lookup(parentPath) + if dirfd > 0 { + if volume.plainTextNames { + return dirfd, child, nil } + var err error + cName, err = encryptName(dirfd, child, iv) + if err != nil { + syscall.Close(dirfd) + return -1, "", err + } + return dirfd, cName, nil } - // Slowpath - if volume.isFiltered(path) { - return -1, "", syscall.EPERM + // Slowpath: Open ourselves & read diriv + parentDirfd, myCName, err := volume.prepareAtSyscallMyself(parentPath) + if err != nil { + return } - dirfd, cName, err = volume.openBackingDir(path) + defer syscall.Close(parentDirfd) + + dirfd, err = syscallcompat.Openat(parentDirfd, myCName, syscall.O_NOFOLLOW|syscall.O_DIRECTORY|syscallcompat.O_PATH, 0) if err != nil { return -1, "", err } // Cache store if !volume.plainTextNames { - // TODO: openBackingDir already calls ReadDirIVAt(). Avoid duplicate work? - iv, err := volume.nameTransform.ReadDirIVAt(dirfd) + var err error + iv, err = volume.nameTransform.ReadDirIVAt(dirfd) if err != nil { syscall.Close(dirfd) return -1, "", err } - dirfdDup, err := syscall.Dup(dirfd) - if err == nil { - var pathCopy strings.Builder - pathCopy.WriteString(path) - volume.dirCache[pathCopy.String()] = Directory{dirfdDup, iv} - } } + volume.dirCache.Store(parentPath, dirfd, iv) + + if volume.plainTextNames { + return dirfd, child, nil + } + + cName, err = encryptName(dirfd, child, iv) + if err != nil { + syscall.Close(dirfd) + return -1, "", err + } + return } +func (volume *Volume) prepareAtSyscallMyself(path string) (dirfd int, cName string, err error) { + dirfd = -1 + + // Handle root node + if path == "" { + var err error + // Open cipherdir (following symlinks) + dirfd, err = syscallcompat.Open(volume.rootCipherDir, syscall.O_DIRECTORY|syscallcompat.O_PATH, 0) + if err != nil { + return -1, "", err + } + return dirfd, ".", nil + } + + // Otherwise convert to prepareAtSyscall of parent node + return volume.prepareAtSyscall(path) +} + // decryptSymlinkTarget: "cData64" is base64-decoded and decrypted // like file contents (GCM). // The empty string decrypts to the empty string. diff --git a/volume.go b/volume.go index 7a3076a..4e79c81 100644 --- a/volume.go +++ b/volume.go @@ -7,7 +7,6 @@ import ( "C" "os" "path/filepath" - "strings" "syscall" "libgocryptfs/v2/internal/configfile" @@ -18,11 +17,6 @@ import ( "libgocryptfs/v2/internal/syscallcompat" ) -type Directory struct { - fd int - iv []byte -} - type File struct { fd *os.File path string @@ -35,12 +29,12 @@ type Volume struct { nameTransform *nametransform.NameTransform cryptoCore *cryptocore.CryptoCore contentEnc *contentenc.ContentEnc - dirCache map[string]Directory + dirCache dirCache file_handles map[int]File fileIDs map[int][]byte } -var OpenedVolumes map[int]Volume +var OpenedVolumes map[int]*Volume func wipe(d []byte) { for i := range d { @@ -49,12 +43,6 @@ func wipe(d []byte) { d = nil } -func clearDirCache(volumeID int) { - for k := range OpenedVolumes[volumeID].dirCache { - delete(OpenedVolumes[volumeID].dirCache, k) - } -} - func errToBool(err error) bool { return err == nil } @@ -85,12 +73,14 @@ func registerNewVolume(rootCipherDir string, masterkey []byte, cf *configfile.Co ) //copying rootCipherDir - var grcd strings.Builder - grcd.WriteString(rootCipherDir) - newVolume.rootCipherDir = grcd.String() + newVolume.rootCipherDir = string([]byte(rootCipherDir[:])) + ivLen := nametransform.DirIVLen + if newVolume.plainTextNames { + ivLen = 0 + } // New empty caches - newVolume.dirCache = make(map[string]Directory) + newVolume.dirCache = dirCache{ivLen: ivLen} newVolume.file_handles = make(map[int]File) newVolume.fileIDs = make(map[int][]byte) @@ -105,9 +95,9 @@ func registerNewVolume(rootCipherDir string, masterkey []byte, cf *configfile.Co c++ } if OpenedVolumes == nil { - OpenedVolumes = make(map[int]Volume) + OpenedVolumes = make(map[int]*Volume) } - OpenedVolumes[volumeID] = newVolume + OpenedVolumes[volumeID] = &newVolume return volumeID } @@ -127,11 +117,12 @@ func gcf_init(rootCipherDir string, password, givenScryptHash, returnedScryptHas //export gcf_close func gcf_close(volumeID int) { - OpenedVolumes[volumeID].cryptoCore.Wipe() - for handleID := range OpenedVolumes[volumeID].file_handles { + volume := OpenedVolumes[volumeID] + volume.cryptoCore.Wipe() + for handleID := range volume.file_handles { gcf_close_file(volumeID, handleID) } - clearDirCache(volumeID) + volume.dirCache.Clear() delete(OpenedVolumes, volumeID) }