aboutsummaryrefslogtreecommitdiff
path: root/internal/fusefrontend_reverse_v1api
diff options
context:
space:
mode:
authorJakob Unterwurzacher2020-08-01 20:47:59 +0200
committerJakob Unterwurzacher2020-08-01 21:14:10 +0200
commit13dc7657ba0aac4da3f4b80ba231a919fdfae0f5 (patch)
treed13781bacb4a02327f519d399e946f83fd13e5c7 /internal/fusefrontend_reverse_v1api
parentdd3d8c100bc9e84c44b2efb55e536b2fc0ded37e (diff)
v2api/reverse: move old fusefrontend_reverse out of the way
fusefrontend_reverse -> fusefrontend_reverse_v1api
Diffstat (limited to 'internal/fusefrontend_reverse_v1api')
-rw-r--r--internal/fusefrontend_reverse_v1api/ctlsock_interface.go38
-rw-r--r--internal/fusefrontend_reverse_v1api/excluder.go66
-rw-r--r--internal/fusefrontend_reverse_v1api/excluder_test.go84
-rw-r--r--internal/fusefrontend_reverse_v1api/mocks_test.go32
-rw-r--r--internal/fusefrontend_reverse_v1api/reverse_longnames.go126
-rw-r--r--internal/fusefrontend_reverse_v1api/rfile.go209
-rw-r--r--internal/fusefrontend_reverse_v1api/rfs.go472
-rw-r--r--internal/fusefrontend_reverse_v1api/rfs_test.go124
-rw-r--r--internal/fusefrontend_reverse_v1api/rpath.go113
-rw-r--r--internal/fusefrontend_reverse_v1api/rpath_cache.go45
-rw-r--r--internal/fusefrontend_reverse_v1api/virtualfile.go110
11 files changed, 1419 insertions, 0 deletions
diff --git a/internal/fusefrontend_reverse_v1api/ctlsock_interface.go b/internal/fusefrontend_reverse_v1api/ctlsock_interface.go
new file mode 100644
index 0000000..f7b8afd
--- /dev/null
+++ b/internal/fusefrontend_reverse_v1api/ctlsock_interface.go
@@ -0,0 +1,38 @@
+package fusefrontend_reverse
+
+import (
+ "path/filepath"
+ "strings"
+
+ "golang.org/x/sys/unix"
+
+ "github.com/rfjakob/gocryptfs/internal/ctlsocksrv"
+ "github.com/rfjakob/gocryptfs/internal/pathiv"
+)
+
+var _ ctlsocksrv.Interface = &ReverseFS{} // Verify that interface is implemented.
+
+// EncryptPath implements ctlsock.Backend.
+// This is used for the control socket and for the "-exclude" logic.
+func (rfs *ReverseFS) EncryptPath(plainPath string) (string, error) {
+ if rfs.args.PlaintextNames || plainPath == "" {
+ return plainPath, nil
+ }
+ cipherPath := ""
+ parts := strings.Split(plainPath, "/")
+ for _, part := range parts {
+ dirIV := pathiv.Derive(cipherPath, pathiv.PurposeDirIV)
+ encryptedPart := rfs.nameTransform.EncryptName(part, dirIV)
+ if rfs.args.LongNames && len(encryptedPart) > unix.NAME_MAX {
+ encryptedPart = rfs.nameTransform.HashLongName(encryptedPart)
+ }
+ cipherPath = filepath.Join(cipherPath, encryptedPart)
+ }
+ return cipherPath, nil
+}
+
+// DecryptPath implements ctlsock.Backend
+func (rfs *ReverseFS) DecryptPath(cipherPath string) (string, error) {
+ p, err := rfs.decryptPath(cipherPath)
+ return p, err
+}
diff --git a/internal/fusefrontend_reverse_v1api/excluder.go b/internal/fusefrontend_reverse_v1api/excluder.go
new file mode 100644
index 0000000..c44816c
--- /dev/null
+++ b/internal/fusefrontend_reverse_v1api/excluder.go
@@ -0,0 +1,66 @@
+package fusefrontend_reverse
+
+import (
+ "io/ioutil"
+ "os"
+ "strings"
+
+ "github.com/rfjakob/gocryptfs/internal/exitcodes"
+ "github.com/rfjakob/gocryptfs/internal/fusefrontend"
+ "github.com/rfjakob/gocryptfs/internal/tlog"
+
+ "github.com/sabhiram/go-gitignore"
+)
+
+// prepareExcluder creates an object to check if paths are excluded
+// based on the patterns specified in the command line.
+func (rfs *ReverseFS) prepareExcluder(args fusefrontend.Args) {
+ if len(args.Exclude) > 0 || len(args.ExcludeWildcard) > 0 || len(args.ExcludeFrom) > 0 {
+ excluder, err := ignore.CompileIgnoreLines(getExclusionPatterns(args)...)
+ if err != nil {
+ tlog.Fatal.Printf("Error compiling exclusion rules: %q", err)
+ os.Exit(exitcodes.ExcludeError)
+ }
+ rfs.excluder = excluder
+ }
+}
+
+// getExclusionPatters prepares a list of patterns to be excluded.
+// Patterns passed in the -exclude command line option are prefixed
+// with a leading '/' to preserve backwards compatibility (before
+// wildcard matching was implemented, exclusions always were matched
+// against the full path).
+func getExclusionPatterns(args fusefrontend.Args) []string {
+ patterns := make([]string, len(args.Exclude)+len(args.ExcludeWildcard))
+ // add -exclude
+ for i, p := range args.Exclude {
+ patterns[i] = "/" + p
+ }
+ // add -exclude-wildcard
+ copy(patterns[len(args.Exclude):], args.ExcludeWildcard)
+ // add -exclude-from
+ for _, file := range args.ExcludeFrom {
+ lines, err := getLines(file)
+ if err != nil {
+ tlog.Fatal.Printf("Error reading exclusion patterns: %q", err)
+ os.Exit(exitcodes.ExcludeError)
+ }
+ patterns = append(patterns, lines...)
+ }
+ return patterns
+}
+
+// getLines reads a file and splits it into lines
+func getLines(file string) ([]string, error) {
+ buffer, err := ioutil.ReadFile(file)
+ if err != nil {
+ return nil, err
+ }
+ return strings.Split(string(buffer), "\n"), nil
+}
+
+// isExcludedPlain finds out if the plaintext path "pPath" is
+// excluded (used when -exclude is passed by the user).
+func (rfs *ReverseFS) isExcludedPlain(pPath string) bool {
+ return rfs.excluder != nil && rfs.excluder.MatchesPath(pPath)
+}
diff --git a/internal/fusefrontend_reverse_v1api/excluder_test.go b/internal/fusefrontend_reverse_v1api/excluder_test.go
new file mode 100644
index 0000000..e19b99f
--- /dev/null
+++ b/internal/fusefrontend_reverse_v1api/excluder_test.go
@@ -0,0 +1,84 @@
+package fusefrontend_reverse
+
+import (
+ "io/ioutil"
+ "os"
+ "reflect"
+ "testing"
+
+ "github.com/rfjakob/gocryptfs/internal/fusefrontend"
+)
+
+func TestShouldNoCreateExcluderIfNoPattersWereSpecified(t *testing.T) {
+ var rfs ReverseFS
+ var args fusefrontend.Args
+ rfs.prepareExcluder(args)
+ if rfs.excluder != nil {
+ t.Error("Should not have created excluder")
+ }
+}
+
+func TestShouldPrefixExcludeValuesWithSlash(t *testing.T) {
+ var args fusefrontend.Args
+ args.Exclude = []string{"file1", "dir1/file2.txt"}
+ args.ExcludeWildcard = []string{"*~", "build/*.o"}
+
+ expected := []string{"/file1", "/dir1/file2.txt", "*~", "build/*.o"}
+
+ patterns := getExclusionPatterns(args)
+ if !reflect.DeepEqual(patterns, expected) {
+ t.Errorf("expected %q, got %q", expected, patterns)
+ }
+}
+
+func TestShouldReadExcludePatternsFromFiles(t *testing.T) {
+ tmpfile1, err := ioutil.TempFile("", "excludetest")
+ if err != nil {
+ t.Fatal(err)
+ }
+ exclude1 := tmpfile1.Name()
+ defer os.Remove(exclude1)
+ defer tmpfile1.Close()
+
+ tmpfile2, err := ioutil.TempFile("", "excludetest")
+ if err != nil {
+ t.Fatal(err)
+ }
+ exclude2 := tmpfile2.Name()
+ defer os.Remove(exclude2)
+ defer tmpfile2.Close()
+
+ tmpfile1.WriteString("file1.1\n")
+ tmpfile1.WriteString("file1.2\n")
+ tmpfile2.WriteString("file2.1\n")
+ tmpfile2.WriteString("file2.2\n")
+
+ var args fusefrontend.Args
+ args.ExcludeWildcard = []string{"cmdline1"}
+ args.ExcludeFrom = []string{exclude1, exclude2}
+
+ // An empty string is returned for the last empty line
+ // It's ignored when the patterns are actually compiled
+ expected := []string{"cmdline1", "file1.1", "file1.2", "", "file2.1", "file2.2", ""}
+
+ patterns := getExclusionPatterns(args)
+ if !reflect.DeepEqual(patterns, expected) {
+ t.Errorf("expected %q, got %q", expected, patterns)
+ }
+}
+
+func TestShouldReturnFalseIfThereAreNoExclusions(t *testing.T) {
+ var rfs ReverseFS
+ if rfs.isExcludedPlain("any/path") {
+ t.Error("Should not exclude any path if no exclusions were specified")
+ }
+}
+
+func TestShouldCallIgnoreParserToCheckExclusion(t *testing.T) {
+ rfs, ignorerMock := createRFSWithMocks()
+
+ rfs.isExcludedPlain("some/path")
+ if ignorerMock.calledWith != "some/path" {
+ t.Error("Failed to call IgnoreParser")
+ }
+}
diff --git a/internal/fusefrontend_reverse_v1api/mocks_test.go b/internal/fusefrontend_reverse_v1api/mocks_test.go
new file mode 100644
index 0000000..fd2f940
--- /dev/null
+++ b/internal/fusefrontend_reverse_v1api/mocks_test.go
@@ -0,0 +1,32 @@
+package fusefrontend_reverse
+
+import (
+ "github.com/rfjakob/gocryptfs/internal/nametransform"
+)
+
+type IgnoreParserMock struct {
+ toExclude string
+ calledWith string
+}
+
+func (parser *IgnoreParserMock) MatchesPath(f string) bool {
+ parser.calledWith = f
+ return f == parser.toExclude
+}
+
+type NameTransformMock struct {
+ nametransform.NameTransform
+}
+
+func (n *NameTransformMock) DecryptName(cipherName string, iv []byte) (string, error) {
+ return "mockdecrypt_" + cipherName, nil
+}
+
+func createRFSWithMocks() (*ReverseFS, *IgnoreParserMock) {
+ ignorerMock := &IgnoreParserMock{}
+ nameTransformMock := &NameTransformMock{}
+ var rfs ReverseFS
+ rfs.excluder = ignorerMock
+ rfs.nameTransform = nameTransformMock
+ return &rfs, ignorerMock
+}
diff --git a/internal/fusefrontend_reverse_v1api/reverse_longnames.go b/internal/fusefrontend_reverse_v1api/reverse_longnames.go
new file mode 100644
index 0000000..f1576d2
--- /dev/null
+++ b/internal/fusefrontend_reverse_v1api/reverse_longnames.go
@@ -0,0 +1,126 @@
+package fusefrontend_reverse
+
+import (
+ "log"
+ "path/filepath"
+ "sync"
+ "syscall"
+ "time"
+
+ "golang.org/x/sys/unix"
+
+ "github.com/hanwen/go-fuse/v2/fuse"
+ "github.com/hanwen/go-fuse/v2/fuse/nodefs"
+
+ "github.com/rfjakob/gocryptfs/internal/nametransform"
+ "github.com/rfjakob/gocryptfs/internal/pathiv"
+ "github.com/rfjakob/gocryptfs/internal/syscallcompat"
+ "github.com/rfjakob/gocryptfs/internal/tlog"
+)
+
+const (
+ // File names are padded to 16-byte multiples, encrypted and
+ // base64-encoded. We can encode at most 176 bytes to stay below the 255
+ // bytes limit:
+ // * base64(176 bytes) = 235 bytes
+ // * base64(192 bytes) = 256 bytes (over 255!)
+ // But the PKCS#7 padding is at least one byte. This means we can only use
+ // 175 bytes for the file name.
+ shortNameMax = 175
+)
+
+// longnameParentCache maps dir+"/"+longname to plaintextname.
+// Yes, the combination of relative plaintext dir path and encrypted
+// longname is strange, but works fine as a map index.
+var longnameParentCache map[string]string
+var longnameCacheLock sync.Mutex
+
+// Very simple cache cleaner: Nuke it every hour
+func longnameCacheCleaner() {
+ for {
+ time.Sleep(time.Hour)
+ longnameCacheLock.Lock()
+ longnameParentCache = map[string]string{}
+ longnameCacheLock.Unlock()
+ }
+}
+
+func initLongnameCache() {
+ if longnameParentCache != nil {
+ return
+ }
+ longnameParentCache = map[string]string{}
+ go longnameCacheCleaner()
+}
+
+// findLongnameParent converts "longname" = "gocryptfs.longname.XYZ" to the
+// plaintext name. "dir" = relative plaintext path to the directory the
+// longname file is in, "dirIV" = directory IV of the directory.
+func (rfs *ReverseFS) findLongnameParent(dir string, dirIV []byte, longname string) (plaintextName string, err error) {
+ longnameCacheLock.Lock()
+ hit := longnameParentCache[dir+"/"+longname]
+ longnameCacheLock.Unlock()
+ if hit != "" {
+ return hit, nil
+ }
+ dirfd, err := syscallcompat.OpenDirNofollow(rfs.args.Cipherdir, filepath.Dir(dir))
+ if err != nil {
+ tlog.Warn.Printf("findLongnameParent: OpenDirNofollow failed: %v\n", err)
+ return "", err
+ }
+ fd, err := syscallcompat.Openat(dirfd, filepath.Base(dir), syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0)
+ syscall.Close(dirfd)
+ if err != nil {
+ tlog.Warn.Printf("findLongnameParent: Openat failed: %v\n", err)
+ return "", err
+ }
+ dirEntries, err := syscallcompat.Getdents(fd)
+ syscall.Close(fd)
+ if err != nil {
+ tlog.Warn.Printf("findLongnameParent: Getdents failed: %v\n", err)
+ return "", err
+ }
+ longnameCacheLock.Lock()
+ defer longnameCacheLock.Unlock()
+ for _, entry := range dirEntries {
+ plaintextName := entry.Name
+ if len(plaintextName) <= shortNameMax {
+ continue
+ }
+ cName := rfs.nameTransform.EncryptName(plaintextName, dirIV)
+ if len(cName) <= unix.NAME_MAX {
+ // Entry should have been skipped by the "continue" above
+ log.Panic("logic error or wrong shortNameMax constant?")
+ }
+ hName := rfs.nameTransform.HashLongName(cName)
+ longnameParentCache[dir+"/"+hName] = plaintextName
+ if longname == hName {
+ hit = plaintextName
+ }
+ }
+ if hit == "" {
+ return "", syscall.ENOENT
+ }
+ return hit, nil
+}
+
+func (rfs *ReverseFS) newNameFile(relPath string) (nodefs.File, fuse.Status) {
+ dotName := filepath.Base(relPath) // gocryptfs.longname.XYZ.name
+ longname := nametransform.RemoveLongNameSuffix(dotName) // gocryptfs.longname.XYZ
+ // cipher directory
+ cDir := nametransform.Dir(relPath)
+ // plain directory
+ pDir, err := rfs.decryptPath(cDir)
+ if err != nil {
+ return nil, fuse.ToStatus(err)
+ }
+ dirIV := pathiv.Derive(cDir, pathiv.PurposeDirIV)
+ // plain name
+ pName, err := rfs.findLongnameParent(pDir, dirIV, longname)
+ if err != nil {
+ return nil, fuse.ToStatus(err)
+ }
+ content := []byte(rfs.nameTransform.EncryptName(pName, dirIV))
+ parentFile := filepath.Join(pDir, pName)
+ return rfs.newVirtualFile(content, rfs.args.Cipherdir, parentFile, inoTagNameFile)
+}
diff --git a/internal/fusefrontend_reverse_v1api/rfile.go b/internal/fusefrontend_reverse_v1api/rfile.go
new file mode 100644
index 0000000..68f7309
--- /dev/null
+++ b/internal/fusefrontend_reverse_v1api/rfile.go
@@ -0,0 +1,209 @@
+package fusefrontend_reverse
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "path/filepath"
+ "syscall"
+
+ // In newer Go versions, this has moved to just "sync/syncmap".
+ "golang.org/x/sync/syncmap"
+
+ "github.com/hanwen/go-fuse/v2/fuse"
+ "github.com/hanwen/go-fuse/v2/fuse/nodefs"
+
+ "github.com/rfjakob/gocryptfs/internal/contentenc"
+ "github.com/rfjakob/gocryptfs/internal/pathiv"
+ "github.com/rfjakob/gocryptfs/internal/syscallcompat"
+ "github.com/rfjakob/gocryptfs/internal/tlog"
+)
+
+type reverseFile struct {
+ // Embed nodefs.defaultFile for a ENOSYS implementation of all methods
+ nodefs.File
+ // Backing FD
+ fd *os.File
+ // File header (contains the IV)
+ header contentenc.FileHeader
+ // IV for block 0
+ block0IV []byte
+ // Content encryption helper
+ contentEnc *contentenc.ContentEnc
+}
+
+var inodeTable syncmap.Map
+
+// newFile receives a ciphered path "relPath" and its corresponding
+// decrypted path "pRelPath", opens it and returns a reverseFile
+// object. The backing file descriptor is always read-only.
+func (rfs *ReverseFS) newFile(relPath string, pRelPath string) (*reverseFile, fuse.Status) {
+ if rfs.isExcludedPlain(pRelPath) {
+ // Excluded paths should have been filtered out beforehand. Better safe
+ // than sorry.
+ tlog.Warn.Printf("BUG: newFile: received excluded path %q. This should not happen.", relPath)
+ return nil, fuse.ENOENT
+ }
+ dir := filepath.Dir(pRelPath)
+ dirfd, err := syscallcompat.OpenDirNofollow(rfs.args.Cipherdir, dir)
+ if err != nil {
+ return nil, fuse.ToStatus(err)
+ }
+ fd, err := syscallcompat.Openat(dirfd, filepath.Base(pRelPath), syscall.O_RDONLY|syscall.O_NOFOLLOW, 0)
+ syscall.Close(dirfd)
+ if err != nil {
+ return nil, fuse.ToStatus(err)
+ }
+ var st syscall.Stat_t
+ err = syscall.Fstat(fd, &st)
+ if err != nil {
+ tlog.Warn.Printf("newFile: Fstat error: %v", err)
+ syscall.Close(fd)
+ return nil, fuse.ToStatus(err)
+ }
+ // Reject access if the file descriptor does not refer to a regular file.
+ var a fuse.Attr
+ a.FromStat(&st)
+ if !a.IsRegular() {
+ tlog.Warn.Printf("ino%d: newFile: not a regular file", st.Ino)
+ syscall.Close(fd)
+ return nil, fuse.ToStatus(syscall.EACCES)
+ }
+ // See if we have that inode number already in the table
+ // (even if Nlink has dropped to 1)
+ var derivedIVs pathiv.FileIVs
+ v, found := inodeTable.Load(st.Ino)
+ if found {
+ tlog.Debug.Printf("ino%d: newFile: found in the inode table", st.Ino)
+ derivedIVs = v.(pathiv.FileIVs)
+ } else {
+ derivedIVs = pathiv.DeriveFile(relPath)
+ // Nlink > 1 means there is more than one path to this file.
+ // Store the derived values so we always return the same data,
+ // regardless of the path that is used to access the file.
+ // This means that the first path wins.
+ if st.Nlink > 1 {
+ v, found = inodeTable.LoadOrStore(st.Ino, derivedIVs)
+ if found {
+ // Another thread has stored a different value before we could.
+ derivedIVs = v.(pathiv.FileIVs)
+ } else {
+ tlog.Debug.Printf("ino%d: newFile: Nlink=%d, stored in the inode table", st.Ino, st.Nlink)
+ }
+ }
+ }
+ header := contentenc.FileHeader{
+ Version: contentenc.CurrentVersion,
+ ID: derivedIVs.ID,
+ }
+ return &reverseFile{
+ File: nodefs.NewDefaultFile(),
+ fd: os.NewFile(uintptr(fd), pRelPath),
+ header: header,
+ block0IV: derivedIVs.Block0IV,
+ contentEnc: rfs.contentEnc,
+ }, fuse.OK
+}
+
+// GetAttr - FUSE call
+// Triggered by fstat() from userspace
+func (rf *reverseFile) GetAttr(*fuse.Attr) fuse.Status {
+ tlog.Debug.Printf("reverseFile.GetAttr fd=%d\n", rf.fd.Fd())
+ // The kernel should fall back to stat()
+ return fuse.ENOSYS
+}
+
+// encryptBlocks - encrypt "plaintext" into a number of ciphertext blocks.
+// "plaintext" must already be block-aligned.
+func (rf *reverseFile) encryptBlocks(plaintext []byte, firstBlockNo uint64, fileID []byte, block0IV []byte) []byte {
+ inBuf := bytes.NewBuffer(plaintext)
+ var outBuf bytes.Buffer
+ bs := int(rf.contentEnc.PlainBS())
+ for blockNo := firstBlockNo; inBuf.Len() > 0; blockNo++ {
+ inBlock := inBuf.Next(bs)
+ iv := pathiv.BlockIV(block0IV, blockNo)
+ outBlock := rf.contentEnc.EncryptBlockNonce(inBlock, blockNo, fileID, iv)
+ outBuf.Write(outBlock)
+ }
+ return outBuf.Bytes()
+}
+
+// readBackingFile: read from the backing plaintext file, encrypt it, return the
+// ciphertext.
+// "off" ... ciphertext offset (must be >= HEADER_LEN)
+// "length" ... ciphertext length
+func (rf *reverseFile) readBackingFile(off uint64, length uint64) (out []byte, err error) {
+ blocks := rf.contentEnc.ExplodeCipherRange(off, length)
+
+ // Read the backing plaintext in one go
+ alignedOffset, alignedLength := contentenc.JointPlaintextRange(blocks)
+ plaintext := make([]byte, int(alignedLength))
+ n, err := rf.fd.ReadAt(plaintext, int64(alignedOffset))
+ if err != nil && err != io.EOF {
+ tlog.Warn.Printf("readBackingFile: ReadAt: %s", err.Error())
+ return nil, err
+ }
+ // Truncate buffer down to actually read bytes
+ plaintext = plaintext[0:n]
+
+ // Encrypt blocks
+ ciphertext := rf.encryptBlocks(plaintext, blocks[0].BlockNo, rf.header.ID, rf.block0IV)
+
+ // Crop down to the relevant part
+ lenHave := len(ciphertext)
+ skip := blocks[0].Skip
+ endWant := int(skip + length)
+ if lenHave > endWant {
+ out = ciphertext[skip:endWant]
+ } else if lenHave > int(skip) {
+ out = ciphertext[skip:lenHave]
+ } // else: out stays empty, file was smaller than the requested offset
+
+ return out, nil
+}
+
+// Read - FUSE call
+func (rf *reverseFile) Read(buf []byte, ioff int64) (resultData fuse.ReadResult, status fuse.Status) {
+ length := uint64(len(buf))
+ off := uint64(ioff)
+ var out bytes.Buffer
+ var header []byte
+
+ // Synthesize file header
+ if off < contentenc.HeaderLen {
+ header = rf.header.Pack()
+ // Truncate to requested part
+ end := int(off) + len(buf)
+ if end > len(header) {
+ end = len(header)
+ }
+ header = header[off:end]
+ // Write into output buffer and adjust offsets
+ out.Write(header)
+ hLen := uint64(len(header))
+ off += hLen
+ length -= hLen
+ }
+
+ // Read actual file data
+ if length > 0 {
+ fileData, err := rf.readBackingFile(off, length)
+ if err != nil {
+ return nil, fuse.ToStatus(err)
+ }
+ if len(fileData) == 0 {
+ // If we could not read any actual data, we also don't want to
+ // return the file header. An empty file stays empty in encrypted
+ // form.
+ return nil, fuse.OK
+ }
+ out.Write(fileData)
+ }
+
+ return fuse.ReadResultData(out.Bytes()), fuse.OK
+}
+
+// Release - FUSE call, close file
+func (rf *reverseFile) Release() {
+ rf.fd.Close()
+}
diff --git a/internal/fusefrontend_reverse_v1api/rfs.go b/internal/fusefrontend_reverse_v1api/rfs.go
new file mode 100644
index 0000000..9f7d1f6
--- /dev/null
+++ b/internal/fusefrontend_reverse_v1api/rfs.go
@@ -0,0 +1,472 @@
+package fusefrontend_reverse
+
+import (
+ "fmt"
+ "path/filepath"
+ "syscall"
+
+ "golang.org/x/sys/unix"
+
+ "github.com/hanwen/go-fuse/v2/fuse"
+ "github.com/hanwen/go-fuse/v2/fuse/nodefs"
+ "github.com/hanwen/go-fuse/v2/fuse/pathfs"
+
+ "github.com/rfjakob/gocryptfs/internal/configfile"
+ "github.com/rfjakob/gocryptfs/internal/contentenc"
+ "github.com/rfjakob/gocryptfs/internal/cryptocore"
+ "github.com/rfjakob/gocryptfs/internal/fusefrontend"
+ "github.com/rfjakob/gocryptfs/internal/inomap"
+ "github.com/rfjakob/gocryptfs/internal/nametransform"
+ "github.com/rfjakob/gocryptfs/internal/pathiv"
+ "github.com/rfjakob/gocryptfs/internal/syscallcompat"
+ "github.com/rfjakob/gocryptfs/internal/tlog"
+
+ "github.com/sabhiram/go-gitignore"
+)
+
+// ReverseFS implements the pathfs.FileSystem interface and provides an
+// encrypted view of a plaintext directory.
+type ReverseFS struct {
+ // Embed pathfs.defaultFileSystem for a ENOSYS implementation of all methods
+ pathfs.FileSystem
+ // pathfs.loopbackFileSystem, see go-fuse/fuse/pathfs/loopback.go
+ loopbackfs pathfs.FileSystem
+ // Stores configuration arguments
+ args fusefrontend.Args
+ // Filename encryption helper
+ nameTransform nametransform.NameTransformer
+ // Content encryption helper
+ contentEnc *contentenc.ContentEnc
+ // Tests whether a path is excluded (hiden) from the user. Used by -exclude.
+ excluder ignore.IgnoreParser
+ // inoMap translates inode numbers from different devices to unique inode
+ // numbers.
+ inoMap *inomap.InoMap
+}
+
+var _ pathfs.FileSystem = &ReverseFS{}
+
+// NewFS returns an encrypted FUSE overlay filesystem.
+// In this case (reverse mode) the backing directory is plain-text and
+// ReverseFS provides an encrypted view.
+func NewFS(args fusefrontend.Args, c *contentenc.ContentEnc, n nametransform.NameTransformer) *ReverseFS {
+ initLongnameCache()
+ fs := &ReverseFS{
+ // pathfs.defaultFileSystem returns ENOSYS for all operations
+ FileSystem: pathfs.NewDefaultFileSystem(),
+ loopbackfs: pathfs.NewLoopbackFileSystem(args.Cipherdir),
+ args: args,
+ nameTransform: n,
+ contentEnc: c,
+ inoMap: inomap.New(),
+ }
+ fs.prepareExcluder(args)
+ return fs
+}
+
+// relDir is identical to filepath.Dir excepts that it returns "" when
+// filepath.Dir would return ".".
+// In the FUSE API, the root directory is called "", and we actually want that.
+func relDir(path string) string {
+ dir := filepath.Dir(path)
+ if dir == "." {
+ return ""
+ }
+ return dir
+}
+
+// getFileInfo returns information on a ciphertext path "relPath":
+// - ftype: file type (as returned by getFileType)
+// - excluded: if the path is excluded
+// - pPath: if it's not a special file, the decrypted path
+// - err: non nil if any error happens
+func (rfs *ReverseFS) getFileInfo(relPath string) (ftype fileType, excluded bool, pPath string, err error) {
+ ftype = rfs.getFileType(relPath)
+ if ftype == typeConfig {
+ excluded, pPath, err = false, "", nil
+ return
+ }
+ if ftype == typeDiriv {
+ parentDir := nametransform.Dir(relPath)
+ _, excluded, _, err = rfs.getFileInfo(parentDir)
+ pPath = ""
+ return
+ }
+ if ftype == typeName {
+ parentDir := nametransform.Dir(relPath)
+ var parentExcluded bool
+ _, parentExcluded, _, err = rfs.getFileInfo(parentDir)
+ if parentExcluded || err != nil {
+ excluded, pPath = parentExcluded, ""
+ return
+ }
+ relPath = nametransform.RemoveLongNameSuffix(relPath)
+ }
+ pPath, err = rfs.decryptPath(relPath)
+ excluded = err == nil && rfs.isExcludedPlain(pPath)
+ return
+}
+
+type fileType int
+
+// Values returned by getFileType
+const (
+ // A regular file/directory/symlink
+ typeRegular fileType = iota
+ // A DirIV (gocryptfs.diriv) file
+ typeDiriv
+ // A gocryptfs.longname.*.name file for a file with a long name
+ typeName
+ // The config file gocryptfs.conf
+ typeConfig
+)
+
+// getFileType returns the type of file (one of the fileType constants above).
+func (rfs *ReverseFS) getFileType(cPath string) fileType {
+ if !rfs.args.PlaintextNames {
+ cName := filepath.Base(cPath)
+ // Is it a gocryptfs.diriv file?
+ if cName == nametransform.DirIVFilename {
+ return typeDiriv
+ }
+ // Is it a gocryptfs.longname.*.name file?
+ if t := nametransform.NameType(cName); t == nametransform.LongNameFilename {
+ return typeName
+ }
+ }
+ if rfs.isTranslatedConfig(cPath) {
+ return typeConfig
+ }
+ return typeRegular
+}
+
+// isTranslatedConfig returns true if the default config file name is in use
+// and the ciphertext path is "gocryptfs.conf".
+// "gocryptfs.conf" then maps to ".gocryptfs.reverse.conf" in the plaintext
+// directory.
+func (rfs *ReverseFS) isTranslatedConfig(relPath string) bool {
+ if rfs.args.ConfigCustom {
+ return false
+ }
+ if relPath == configfile.ConfDefaultName {
+ return true
+ }
+ return false
+}
+
+// GetAttr - FUSE call
+// "relPath" is the relative ciphertext path
+func (rfs *ReverseFS) GetAttr(relPath string, context *fuse.Context) (*fuse.Attr, fuse.Status) {
+ ftype, excluded, pPath, err := rfs.getFileInfo(relPath)
+ if excluded {
+ return nil, fuse.ENOENT
+ }
+ if err != nil {
+ return nil, fuse.ToStatus(err)
+ }
+ // Handle "gocryptfs.conf"
+ if ftype == typeConfig {
+ absConfPath, _ := rfs.abs(configfile.ConfReverseName, nil)
+ var st syscall.Stat_t
+ err = syscall.Lstat(absConfPath, &st)
+ if err != nil {
+ return nil, fuse.ToStatus(err)
+ }
+ rfs.inoMap.TranslateStat(&st)
+ var a fuse.Attr
+ a.FromStat(&st)
+ if rfs.args.ForceOwner != nil {
+ a.Owner = *rfs.args.ForceOwner
+ }
+ return &a, fuse.OK
+ }
+ // Handle virtual files (gocryptfs.diriv, *.name)
+ var f nodefs.File
+ var status fuse.Status
+ virtual := false
+ if ftype == typeDiriv {
+ virtual = true
+ f, status = rfs.newDirIVFile(relPath)
+ }
+ if ftype == typeName {
+ virtual = true
+ f, status = rfs.newNameFile(relPath)
+ }
+ if virtual {
+ if !status.Ok() {
+ tlog.Warn.Printf("GetAttr %q: newXFile failed: %v\n", relPath, status)
+ return nil, status
+ }
+ var a fuse.Attr
+ status = f.GetAttr(&a)
+ if rfs.args.ForceOwner != nil {
+ a.Owner = *rfs.args.ForceOwner
+ }
+ return &a, status
+ }
+ // Normal file / directory
+ dirfd, name, err := rfs.openBackingDir(pPath)
+ if err != nil {
+ return nil, fuse.ToStatus(err)
+ }
+ // Stat the backing file/dir using Fstatat
+ var st syscall.Stat_t
+ {
+ var st2 unix.Stat_t
+ err = syscallcompat.Fstatat(dirfd, name, &st2, unix.AT_SYMLINK_NOFOLLOW)
+ syscall.Close(dirfd)
+ if err != nil {
+ return nil, fuse.ToStatus(err)
+ }
+ st = syscallcompat.Unix2syscall(st2)
+ }
+ rfs.inoMap.TranslateStat(&st)
+ var a fuse.Attr
+ a.FromStat(&st)
+ // Calculate encrypted file size
+ if a.IsRegular() {
+ a.Size = rfs.contentEnc.PlainSizeToCipherSize(a.Size)
+ } else if a.IsSymlink() {
+ var linkTarget string
+ var readlinkStatus fuse.Status
+
+ linkTarget, readlinkStatus = rfs.Readlink(relPath, context)
+ if !readlinkStatus.Ok() {
+ return nil, readlinkStatus
+ }
+
+ a.Size = uint64(len(linkTarget))
+ }
+ if rfs.args.ForceOwner != nil {
+ a.Owner = *rfs.args.ForceOwner
+ }
+ return &a, fuse.OK
+}
+
+// Access - FUSE call
+func (rfs *ReverseFS) Access(relPath string, mode uint32, context *fuse.Context) fuse.Status {
+ ftype, excluded, pPath, err := rfs.getFileInfo(relPath)
+ if excluded {
+ return fuse.ENOENT
+ }
+ if err != nil {
+ return fuse.ToStatus(err)
+ }
+ if ftype != typeRegular {
+ // access(2) R_OK flag for checking if the file is readable, always 4 as defined in POSIX.
+ ROK := uint32(0x4)
+ // Virtual files can always be read and never written
+ if mode == ROK || mode == 0 {
+ return fuse.OK
+ }
+ return fuse.EPERM
+ }
+ dirfd, name, err := rfs.openBackingDir(pPath)
+ if err != nil {
+ return fuse.ToStatus(err)
+ }
+ err = syscallcompat.Faccessat(dirfd, name, mode)
+ syscall.Close(dirfd)
+ return fuse.ToStatus(err)
+}
+
+// Open - FUSE call
+func (rfs *ReverseFS) Open(relPath string, flags uint32, context *fuse.Context) (fuseFile nodefs.File, status fuse.Status) {
+ ftype, excluded, pPath, err := rfs.getFileInfo(relPath)
+ if excluded {
+ return nil, fuse.ENOENT
+ }
+ if err != nil {
+ return nil, fuse.ToStatus(err)
+ }
+ if ftype == typeConfig {
+ return rfs.loopbackfs.Open(configfile.ConfReverseName, flags, context)
+ }
+ if ftype == typeDiriv {
+ return rfs.newDirIVFile(relPath)
+ }
+ if ftype == typeName {
+ return rfs.newNameFile(relPath)
+ }
+ return rfs.newFile(relPath, pPath)
+}
+
+func (rfs *ReverseFS) openDirPlaintextnames(relPath string, entries []fuse.DirEntry) ([]fuse.DirEntry, fuse.Status) {
+ if relPath != "" || rfs.args.ConfigCustom {
+ return entries, fuse.OK
+ }
+ // We are in the root dir and the default config file name
+ // ".gocryptfs.reverse.conf" is used. We map it to "gocryptfs.conf".
+ dupe := -1
+ status := fuse.OK
+ for i := range entries {
+ if entries[i].Name == configfile.ConfReverseName {
+ entries[i].Name = configfile.ConfDefaultName
+ } else if entries[i].Name == configfile.ConfDefaultName {
+ dupe = i
+ }
+ }
+ if dupe >= 0 {
+ // Warn the user loudly: The gocryptfs.conf_NAME_COLLISION file will
+ // throw ENOENT errors that are hard to miss.
+ tlog.Warn.Printf("The file %q is mapped to %q and shadows another file. Please rename %q in directory %q.",
+ configfile.ConfReverseName, configfile.ConfDefaultName, configfile.ConfDefaultName, rfs.args.Cipherdir)
+ entries[dupe].Name = "gocryptfs.conf_NAME_COLLISION_" + fmt.Sprintf("%d", cryptocore.RandUint64())
+ }
+ return entries, status
+}
+
+// OpenDir - FUSE readdir call
+func (rfs *ReverseFS) OpenDir(cipherPath string, context *fuse.Context) ([]fuse.DirEntry, fuse.Status) {
+ ftype, excluded, relPath, err := rfs.getFileInfo(cipherPath)
+ if excluded {
+ return nil, fuse.ENOENT
+ }
+ if err != nil {
+ return nil, fuse.ToStatus(err)
+ }
+ if ftype != typeRegular {
+ return nil, fuse.ENOTDIR
+ }
+ // Read plaintext dir
+ dirfd, err := syscallcompat.OpenDirNofollow(rfs.args.Cipherdir, filepath.Dir(relPath))
+ if err != nil {
+ return nil, fuse.ToStatus(err)
+ }
+ fd, err := syscallcompat.Openat(dirfd, filepath.Base(relPath), syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0)
+ syscall.Close(dirfd)
+ if err != nil {
+ return nil, fuse.ToStatus(err)
+ }
+ entries, err := syscallcompat.Getdents(fd)
+ syscall.Close(fd)
+ if err != nil {
+ return nil, fuse.ToStatus(err)
+ }
+ if rfs.args.PlaintextNames {
+ var status fuse.Status
+ entries, status = rfs.openDirPlaintextnames(cipherPath, entries)
+ if !status.Ok() {
+ return nil, status
+ }
+ entries = rfs.excludeDirEntries(relPath, entries)
+ return entries, fuse.OK
+ }
+ // Filter out excluded entries
+ entries = rfs.excludeDirEntries(relPath, entries)
+ // Allocate maximum possible number of virtual files.
+ // If all files have long names we need a virtual ".name" file for each,
+ // plus one for gocryptfs.diriv.
+ virtualFiles := make([]fuse.DirEntry, len(entries)+1)
+ // Virtual gocryptfs.diriv file
+ virtualFiles[0] = fuse.DirEntry{
+ Mode: virtualFileMode,
+ Name: nametransform.DirIVFilename,
+ }
+ // Actually used entries
+ nVirtual := 1
+
+ // Encrypt names
+ dirIV := pathiv.Derive(cipherPath, pathiv.PurposeDirIV)
+ for i := range entries {
+ var cName string
+ // ".gocryptfs.reverse.conf" in the root directory is mapped to "gocryptfs.conf"
+ if cipherPath == "" && entries[i].Name == configfile.ConfReverseName &&
+ !rfs.args.ConfigCustom {
+ cName = configfile.ConfDefaultName
+ } else {
+ cName = rfs.nameTransform.EncryptName(entries[i].Name, dirIV)
+ if len(cName) > unix.NAME_MAX {
+ cName = rfs.nameTransform.HashLongName(cName)
+ dotNameFile := fuse.DirEntry{
+ Mode: virtualFileMode,
+ Name: cName + nametransform.LongNameSuffix,
+ }
+ virtualFiles[nVirtual] = dotNameFile
+ nVirtual++
+ }
+ }
+ entries[i].Name = cName
+ }
+ // Add virtual files
+ entries = append(entries, virtualFiles[:nVirtual]...)
+ return entries, fuse.OK
+}
+
+// excludeDirEntries filters out directory entries that are "-exclude"d.
+// pDir is the relative plaintext path to the directory these entries are
+// from. The entries should be plaintext files.
+func (rfs *ReverseFS) excludeDirEntries(pDir string, entries []fuse.DirEntry) (filtered []fuse.DirEntry) {
+ if rfs.excluder == nil {
+ return entries
+ }
+ filtered = make([]fuse.DirEntry, 0, len(entries))
+ for _, entry := range entries {
+ // filepath.Join handles the case of pDir="" correctly:
+ // Join("", "foo") -> "foo". This does not: pDir + "/" + name"
+ p := filepath.Join(pDir, entry.Name)
+ if rfs.isExcludedPlain(p) {
+ // Skip file
+ continue
+ }
+ filtered = append(filtered, entry)
+ }
+ return filtered
+}
+
+// StatFs - FUSE call. Returns information about the filesystem (free space
+// etc).
+// Securing statfs against symlink races seems to be more trouble than
+// it's worth, so we just ignore the path and always return info about the
+// backing storage root dir.
+func (rfs *ReverseFS) StatFs(relPath string) *fuse.StatfsOut {
+ _, excluded, _, err := rfs.getFileInfo(relPath)
+ if excluded || err != nil {
+ return nil
+ }
+ var s syscall.Statfs_t
+ err = syscall.Statfs(rfs.args.Cipherdir, &s)
+ if err != nil {
+ return nil
+ }
+ out := &fuse.StatfsOut{}
+ out.FromStatfsT(&s)
+ return out
+}
+
+// Readlink - FUSE call
+func (rfs *ReverseFS) Readlink(relPath string, context *fuse.Context) (string, fuse.Status) {
+ ftype, excluded, pPath, err := rfs.getFileInfo(relPath)
+ if excluded {
+ return "", fuse.ENOENT
+ }
+ if err != nil {
+ return "", fuse.ToStatus(err)
+ }
+ if ftype != typeRegular {
+ return "", fuse.EINVAL
+ }
+ dirfd, name, err := rfs.openBackingDir(pPath)
+ if err != nil {
+ return "", fuse.ToStatus(err)
+ }
+ // read the link target using Readlinkat
+ plainTarget, err := syscallcompat.Readlinkat(dirfd, name)
+ syscall.Close(dirfd)
+ if err != nil {
+ return "", fuse.ToStatus(err)
+ }
+ if rfs.args.PlaintextNames {
+ return plainTarget, fuse.OK
+ }
+ nonce := pathiv.Derive(relPath, pathiv.PurposeSymlinkIV)
+ // Symlinks are encrypted like file contents and base64-encoded
+ cBinTarget := rfs.contentEnc.EncryptBlockNonce([]byte(plainTarget), 0, nil, nonce)
+ cTarget := rfs.nameTransform.B64EncodeToString(cBinTarget)
+ // The kernel will reject a symlink target above 4096 chars and return
+ // and I/O error to the user. Better emit the proper error ourselves.
+ if len(cTarget) > syscallcompat.PATH_MAX {
+ return "", fuse.Status(syscall.ENAMETOOLONG)
+ }
+ return cTarget, fuse.OK
+}
diff --git a/internal/fusefrontend_reverse_v1api/rfs_test.go b/internal/fusefrontend_reverse_v1api/rfs_test.go
new file mode 100644
index 0000000..eccaab9
--- /dev/null
+++ b/internal/fusefrontend_reverse_v1api/rfs_test.go
@@ -0,0 +1,124 @@
+package fusefrontend_reverse
+
+import (
+ "testing"
+
+ "github.com/rfjakob/gocryptfs/internal/configfile"
+ "github.com/rfjakob/gocryptfs/internal/nametransform"
+)
+
+func TestShouldDetectDirIV(t *testing.T) {
+ var rfs ReverseFS
+ ftype := rfs.getFileType("some/path/" + nametransform.DirIVFilename)
+ if ftype != typeDiriv {
+ t.Errorf("Expecting %d, got %d\n", typeDiriv, ftype)
+ }
+}
+
+func TestShouldDetectNameFile(t *testing.T) {
+ var rfs ReverseFS
+ ftype := rfs.getFileType("dir1/dir2/gocryptfs.longname.URrM8kgxTKYMgCk4hKk7RO9Lcfr30XQof4L_5bD9Iro=" + nametransform.LongNameSuffix)
+ if ftype != typeName {
+ t.Errorf("Expecting %d, got %d\n", typeName, ftype)
+ }
+}
+
+func TestShouldDetectConfigFile(t *testing.T) {
+ var rfs ReverseFS
+ ftype := rfs.getFileType(configfile.ConfDefaultName)
+ if ftype != typeConfig {
+ t.Errorf("Expecting %d, got %d\n", typeConfig, ftype)
+ }
+}
+
+func TestShouldDetectRegularFile(t *testing.T) {
+ var rfs ReverseFS
+ ftype := rfs.getFileType("documents/text_file.txt")
+ if ftype != typeRegular {
+ t.Errorf("Expecting %d, got %d\n", typeRegular, ftype)
+ }
+}
+
+// Note: For path exclusion, see also the integration tests in
+// tests/reverse/exclude_test.go
+func TestShouldNotCallIgnoreParserForTranslatedConfig(t *testing.T) {
+ rfs, ignorerMock := createRFSWithMocks()
+
+ ftype, excluded, _, err := rfs.getFileInfo(configfile.ConfDefaultName)
+ if err != nil {
+ t.Errorf("Unexpected error %q\n", err)
+ }
+ if ftype != typeConfig {
+ t.Errorf("Wrong file type, expecting %d, got %d\n", typeConfig, ftype)
+ }
+ if excluded {
+ t.Error("Should not exclude translated config")
+ }
+ if ignorerMock.calledWith != "" {
+ t.Error("Should not call IgnoreParser for translated config")
+ }
+}
+
+func TestShouldCheckIfParentIsExcludedForDirIV(t *testing.T) {
+ rfs, ignorerMock := createRFSWithMocks()
+ path := "dir"
+ ignorerMock.toExclude = "mockdecrypt_dir"
+ dirIV := path + "/" + nametransform.DirIVFilename
+
+ ftype, excluded, _, err := rfs.getFileInfo(dirIV)
+ if err != nil {
+ t.Errorf("Unexpected error %q\n", err)
+ }
+ if ftype != typeDiriv {
+ t.Errorf("Wrong file type, expecting %d, got %d\n", typeDiriv, ftype)
+ }
+ if !excluded {
+ t.Error("Should have excluded DirIV based on parent")
+ }
+ if ignorerMock.calledWith != "mockdecrypt_dir" {
+ t.Errorf("Should have checked parent dir, checked %q", ignorerMock.calledWith)
+ }
+}
+
+func TestShouldCheckIfParentIsExcludedForLongName(t *testing.T) {
+ rfs, ignorerMock := createRFSWithMocks()
+ path := "parent"
+ ignorerMock.toExclude = "mockdecrypt_parent"
+ dirIV := path + "/" + "gocryptfs.longname.fake.name"
+
+ ftype, excluded, _, err := rfs.getFileInfo(dirIV)
+ if err != nil {
+ t.Errorf("Unexpected error %q\n", err)
+ }
+ if ftype != typeName {
+ t.Errorf("Wrong file type, expecting %d, got %d\n", typeName, ftype)
+ }
+ if !excluded {
+ t.Error("Should have excluded LongName based on parent")
+ }
+ if ignorerMock.calledWith != "mockdecrypt_parent" {
+ t.Errorf("Should have checked parent dir, checked %q", ignorerMock.calledWith)
+ }
+}
+
+func TestShouldDecryptPathAndReturnTrueForExcludedPath(t *testing.T) {
+ rfs, ignorerMock := createRFSWithMocks()
+ ignorerMock.toExclude = "mockdecrypt_file.txt"
+
+ ftype, excluded, pPath, err := rfs.getFileInfo("file.txt")
+ if err != nil {
+ t.Errorf("Unexpected error %q\n", err)
+ }
+ if ftype != typeRegular {
+ t.Errorf("Wrong file type, expecting %d, got %d\n", typeRegular, ftype)
+ }
+ if !excluded {
+ t.Error("Should have excluded")
+ }
+ if pPath != "mockdecrypt_file.txt" {
+ t.Errorf("Wrong pPath returned, got %q\n", pPath)
+ }
+ if ignorerMock.calledWith != "mockdecrypt_file.txt" {
+ t.Error("Didn't call IgnoreParser with decrypted path")
+ }
+}
diff --git a/internal/fusefrontend_reverse_v1api/rpath.go b/internal/fusefrontend_reverse_v1api/rpath.go
new file mode 100644
index 0000000..2da8379
--- /dev/null
+++ b/internal/fusefrontend_reverse_v1api/rpath.go
@@ -0,0 +1,113 @@
+package fusefrontend_reverse
+
+import (
+ "encoding/base64"
+ "path/filepath"
+ "strings"
+ "syscall"
+
+ "github.com/rfjakob/gocryptfs/internal/nametransform"
+ "github.com/rfjakob/gocryptfs/internal/pathiv"
+ "github.com/rfjakob/gocryptfs/internal/syscallcompat"
+ "github.com/rfjakob/gocryptfs/internal/tlog"
+)
+
+// abs basically returns storage dir + "/" + relPath.
+// It takes an error parameter so it can directly wrap decryptPath like this:
+// a, err := rfs.abs(rfs.decryptPath(relPath))
+// abs never generates an error on its own. In other words, abs(p, nil) never
+// fails.
+func (rfs *ReverseFS) abs(relPath string, err error) (string, error) {
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(rfs.args.Cipherdir, relPath), nil
+}
+
+// rDecryptName decrypts the ciphertext name "cName", given the dirIV of the
+// directory "cName" lies in. The relative plaintext path to the directory
+// "pDir" is used if a "gocryptfs.longname.XYZ.name" must be resolved.
+func (rfs *ReverseFS) rDecryptName(cName string, dirIV []byte, pDir string) (pName string, err error) {
+ nameType := nametransform.NameType(cName)
+ if nameType == nametransform.LongNameNone {
+ pName, err = rfs.nameTransform.DecryptName(cName, dirIV)
+ if err != nil {
+ // We get lots of decrypt requests for names like ".Trash" that
+ // are invalid base64. Convert them to ENOENT so the correct
+ // error gets returned to the user.
+ if _, ok := err.(base64.CorruptInputError); ok {
+ return "", syscall.ENOENT
+ }
+ // Stat attempts on the link target of encrypted symlinks.
+ // These are always valid base64 but the length is not a
+ // multiple of 16.
+ if err == syscall.EBADMSG {
+ return "", syscall.ENOENT
+ }
+ return "", err
+ }
+ } else if nameType == nametransform.LongNameContent {
+ pName, err = rfs.findLongnameParent(pDir, dirIV, cName)
+ if err != nil {
+ return "", err
+ }
+ } else {
+ // It makes no sense to decrypt a ".name" file. This is a virtual file
+ // that has no representation in the plaintext filesystem. ".name"
+ // files should have already been handled in virtualfile.go.
+ tlog.Warn.Printf("rDecryptName: cannot decrypt virtual file %q", cName)
+ return "", syscall.EINVAL
+ }
+ return pName, nil
+}
+
+// decryptPath decrypts a relative ciphertext path to a relative plaintext
+// path.
+func (rfs *ReverseFS) decryptPath(relPath string) (string, error) {
+ if rfs.args.PlaintextNames || relPath == "" {
+ return relPath, nil
+ }
+ // Check if the parent dir is in the cache
+ cDir := nametransform.Dir(relPath)
+ dirIV, pDir := rPathCache.lookup(cDir)
+ if dirIV != nil {
+ cName := filepath.Base(relPath)
+ pName, err := rfs.rDecryptName(cName, dirIV, pDir)
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(pDir, pName), nil
+ }
+ parts := strings.Split(relPath, "/")
+ var transformedParts []string
+ for i := range parts {
+ // Start at the top and recurse
+ currentCipherDir := filepath.Join(parts[:i]...)
+ currentPlainDir := filepath.Join(transformedParts[:i]...)
+ dirIV = pathiv.Derive(currentCipherDir, pathiv.PurposeDirIV)
+ transformedPart, err := rfs.rDecryptName(parts[i], dirIV, currentPlainDir)
+ if err != nil {
+ return "", err
+ }
+ transformedParts = append(transformedParts, transformedPart)
+ }
+ pRelPath := filepath.Join(transformedParts...)
+ rPathCache.store(cDir, dirIV, nametransform.Dir(pRelPath))
+ return pRelPath, nil
+}
+
+// openBackingDir receives an already decrypted relative path
+// "pRelPath", opens the directory that contains the target file/dir
+// and returns the fd to the directory and the decrypted name of the
+// target file. The fd/name pair is intended for use with fchownat and
+// friends.
+func (rfs *ReverseFS) openBackingDir(pRelPath string) (dirfd int, pName string, err error) {
+ // Open directory, safe against symlink races
+ pDir := filepath.Dir(pRelPath)
+ dirfd, err = syscallcompat.OpenDirNofollow(rfs.args.Cipherdir, pDir)
+ if err != nil {
+ return -1, "", err
+ }
+ pName = filepath.Base(pRelPath)
+ return dirfd, pName, nil
+}
diff --git a/internal/fusefrontend_reverse_v1api/rpath_cache.go b/internal/fusefrontend_reverse_v1api/rpath_cache.go
new file mode 100644
index 0000000..221f578
--- /dev/null
+++ b/internal/fusefrontend_reverse_v1api/rpath_cache.go
@@ -0,0 +1,45 @@
+package fusefrontend_reverse
+
+import (
+ "sync"
+)
+
+// rPathCacheContainer is a simple one entry path cache. Because the dirIV
+// is generated deterministically from the directory path, there is no need
+// to ever invalidate entries.
+type rPathCacheContainer struct {
+ sync.Mutex
+ // Relative ciphertext path to the directory
+ cPath string
+ // Relative plaintext path
+ pPath string
+ // Directory IV of the directory
+ dirIV []byte
+}
+
+// lookup relative ciphertext path "cPath". Returns dirIV, relative
+// plaintext path.
+func (c *rPathCacheContainer) lookup(cPath string) ([]byte, string) {
+ c.Lock()
+ defer c.Unlock()
+ if cPath == c.cPath {
+ // hit
+ return c.dirIV, c.pPath
+ }
+ // miss
+ return nil, ""
+}
+
+// store - write entry for the directory at relative ciphertext path "cPath"
+// into the cache.
+// "dirIV" = directory IV of the directory, "pPath" = relative plaintext path
+func (c *rPathCacheContainer) store(cPath string, dirIV []byte, pPath string) {
+ c.Lock()
+ defer c.Unlock()
+ c.cPath = cPath
+ c.dirIV = dirIV
+ c.pPath = pPath
+}
+
+// rPathCache: see rPathCacheContainer above for a detailed description
+var rPathCache rPathCacheContainer
diff --git a/internal/fusefrontend_reverse_v1api/virtualfile.go b/internal/fusefrontend_reverse_v1api/virtualfile.go
new file mode 100644
index 0000000..f258e7f
--- /dev/null
+++ b/internal/fusefrontend_reverse_v1api/virtualfile.go
@@ -0,0 +1,110 @@
+package fusefrontend_reverse
+
+import (
+ "log"
+ "path/filepath"
+ "syscall"
+
+ "golang.org/x/sys/unix"
+
+ "github.com/hanwen/go-fuse/v2/fuse"
+ "github.com/hanwen/go-fuse/v2/fuse/nodefs"
+
+ "github.com/rfjakob/gocryptfs/internal/inomap"
+ "github.com/rfjakob/gocryptfs/internal/nametransform"
+ "github.com/rfjakob/gocryptfs/internal/pathiv"
+ "github.com/rfjakob/gocryptfs/internal/syscallcompat"
+ "github.com/rfjakob/gocryptfs/internal/tlog"
+)
+
+const (
+ // virtualFileMode is the mode to use for virtual files (gocryptfs.diriv and
+ // *.name). They are always readable, as stated in func Access
+ virtualFileMode = syscall.S_IFREG | 0444
+ // We use inomap's `Tag` feature to generate unique inode numbers for
+ // virtual files. These are the tags we use.
+ inoTagDirIV = 1
+ inoTagNameFile = 2
+)
+
+func (rfs *ReverseFS) newDirIVFile(cRelPath string) (nodefs.File, fuse.Status) {
+ cDir := nametransform.Dir(cRelPath)
+ dir, err := rfs.decryptPath(cDir)
+ if err != nil {
+ return nil, fuse.ToStatus(err)
+ }
+ iv := pathiv.Derive(cDir, pathiv.PurposeDirIV)
+ return rfs.newVirtualFile(iv, rfs.args.Cipherdir, dir, inoTagDirIV)
+}
+
+type virtualFile struct {
+ // Embed nodefs.defaultFile for a ENOSYS implementation of all methods
+ nodefs.File
+ // pointer to parent filesystem
+ rfs *ReverseFS
+ // file content
+ content []byte
+ // backing directory
+ cipherdir string
+ // path to a parent file (relative to cipherdir)
+ parentFile string
+ // inomap `Tag`.
+ // Depending on the file type, either `inoTagDirIV` or `inoTagNameFile`.
+ inoTag uint8
+}
+
+// newVirtualFile creates a new in-memory file that does not have a representation
+// on disk. "content" is the file content. Timestamps and file owner are copied
+// from "parentFile" (plaintext path relative to "cipherdir").
+// For a "gocryptfs.diriv" file, you would use the parent directory as
+// "parentFile".
+func (rfs *ReverseFS) newVirtualFile(content []byte, cipherdir string, parentFile string, inoTag uint8) (nodefs.File, fuse.Status) {
+ if inoTag == 0 {
+ log.Panicf("BUG: inoTag for virtual file is zero - this will cause ino collisions!")
+ }
+ return &virtualFile{
+ File: nodefs.NewDefaultFile(),
+ rfs: rfs,
+ content: content,
+ cipherdir: cipherdir,
+ parentFile: parentFile,
+ inoTag: inoTag,
+ }, fuse.OK
+}
+
+// Read - FUSE call
+func (f *virtualFile) Read(buf []byte, off int64) (resultData fuse.ReadResult, status fuse.Status) {
+ if off >= int64(len(f.content)) {
+ return nil, fuse.OK
+ }
+ end := int(off) + len(buf)
+ if end > len(f.content) {
+ end = len(f.content)
+ }
+ return fuse.ReadResultData(f.content[off:end]), fuse.OK
+}
+
+// GetAttr - FUSE call
+func (f *virtualFile) GetAttr(a *fuse.Attr) fuse.Status {
+ dir := filepath.Dir(f.parentFile)
+ dirfd, err := syscallcompat.OpenDirNofollow(f.cipherdir, dir)
+ if err != nil {
+ return fuse.ToStatus(err)
+ }
+ defer syscall.Close(dirfd)
+ name := filepath.Base(f.parentFile)
+ var st2 unix.Stat_t
+ err = syscallcompat.Fstatat(dirfd, name, &st2, unix.AT_SYMLINK_NOFOLLOW)
+ if err != nil {
+ tlog.Debug.Printf("GetAttr: Fstatat %q: %v\n", f.parentFile, err)
+ return fuse.ToStatus(err)
+ }
+ st := syscallcompat.Unix2syscall(st2)
+ q := inomap.NewQIno(uint64(st.Dev), f.inoTag, uint64(st.Ino))
+ st.Ino = f.rfs.inoMap.Translate(q)
+ st.Size = int64(len(f.content))
+ st.Mode = virtualFileMode
+ st.Nlink = 1
+ a.FromStat(&st)
+ return fuse.OK
+}