package mount import ( "context" "os" "path/filepath" "strings" "sync" "syscall" "time" "github.com/jacobsa/fuse" "github.com/jacobsa/fuse/fuseops" "github.com/jacobsa/fuse/fuseutil" "github.com/lain/larc/internal/core" "github.com/lain/larc/internal/protocol" ) /* LarcFS implements a read-only FUSE filesystem for larc repositories. * Files are lazily fetched from the server and cached locally. */ const ( rootInodeID fuseops.InodeID = fuseops.RootInodeID ) type inodeInfo struct { id fuseops.InodeID name string entry *core.TreeEntry // nil for synthetic directories children map[string]fuseops.InodeID // name -> inode (only for dirs) parent fuseops.InodeID isDir bool } // LarcFS is the FUSE filesystem implementation type LarcFS struct { fuseutil.NotImplementedFileSystem fetcher *BlobFetcher repoName string revision int64 inodes map[fuseops.InodeID]*inodeInfo nextInode fuseops.InodeID mu sync.RWMutex /* file handle tracking */ handles map[fuseops.HandleID]fuseops.InodeID nextHandle fuseops.HandleID handleMu sync.Mutex } // NewLarcFS creates a new filesystem from a tree func NewLarcFS(client *protocol.Client, repoName string, revision int64, tree *core.Tree, cache *BlobCache) *LarcFS { fs := &LarcFS{ fetcher: NewBlobFetcher(client, repoName, cache), repoName: repoName, revision: revision, inodes: make(map[fuseops.InodeID]*inodeInfo), nextInode: rootInodeID + 1, handles: make(map[fuseops.HandleID]fuseops.InodeID), nextHandle: 1, } fs.buildInodeTree(tree) return fs } func (fs *LarcFS) buildInodeTree(tree *core.Tree) { /* create root inode */ root := &inodeInfo{ id: rootInodeID, name: "", isDir: true, children: make(map[string]fuseops.InodeID), parent: rootInodeID, } fs.inodes[rootInodeID] = root /* process all entries and build directory structure */ for i := range tree.Entries { entry := &tree.Entries[i] fs.addEntry(entry) } } func (fs *LarcFS) addEntry(entry *core.TreeEntry) { parts := strings.Split(entry.Path, string(filepath.Separator)) if len(parts) == 0 { return } /* ensure all parent directories exist */ currentInode := rootInodeID for i, part := range parts[:len(parts)-1] { current := fs.inodes[currentInode] if childID, ok := current.children[part]; ok { currentInode = childID } else { /* create synthetic directory */ newID := fs.nextInode fs.nextInode++ dirPath := strings.Join(parts[:i+1], string(filepath.Separator)) newDir := &inodeInfo{ id: newID, name: part, isDir: true, children: make(map[string]fuseops.InodeID), parent: currentInode, entry: &core.TreeEntry{ Path: dirPath, Kind: core.EntryKindDir, Mode: 0755, }, } fs.inodes[newID] = newDir current.children[part] = newID currentInode = newID } } /* add the file/final entry */ name := parts[len(parts)-1] parent := fs.inodes[currentInode] newID := fs.nextInode fs.nextInode++ isDir := entry.Kind == core.EntryKindDir info := &inodeInfo{ id: newID, name: name, entry: entry, parent: currentInode, isDir: isDir, } if isDir { info.children = make(map[string]fuseops.InodeID) } fs.inodes[newID] = info parent.children[name] = newID } func (fs *LarcFS) allocHandle(inode fuseops.InodeID) fuseops.HandleID { fs.handleMu.Lock() defer fs.handleMu.Unlock() h := fs.nextHandle fs.nextHandle++ fs.handles[h] = inode return h } func (fs *LarcFS) freeHandle(h fuseops.HandleID) { fs.handleMu.Lock() defer fs.handleMu.Unlock() delete(fs.handles, h) } /* FUSE operations */ func (fs *LarcFS) StatFS(ctx context.Context, op *fuseops.StatFSOp) error { op.BlockSize = 4096 op.Blocks = 1000000 op.BlocksFree = 0 op.BlocksAvailable = 0 op.IoSize = 65536 op.Inodes = uint64(len(fs.inodes)) op.InodesFree = 0 return nil } func (fs *LarcFS) LookUpInode(ctx context.Context, op *fuseops.LookUpInodeOp) error { fs.mu.RLock() defer fs.mu.RUnlock() parent, ok := fs.inodes[op.Parent] if !ok { return fuse.ENOENT } childID, ok := parent.children[op.Name] if !ok { return fuse.ENOENT } child := fs.inodes[childID] op.Entry.Child = childID op.Entry.Attributes = fs.getAttrs(child) op.Entry.AttributesExpiration = time.Now().Add(time.Hour) op.Entry.EntryExpiration = time.Now().Add(time.Hour) return nil } func (fs *LarcFS) GetInodeAttributes(ctx context.Context, op *fuseops.GetInodeAttributesOp) error { fs.mu.RLock() defer fs.mu.RUnlock() info, ok := fs.inodes[op.Inode] if !ok { return fuse.ENOENT } op.Attributes = fs.getAttrs(info) op.AttributesExpiration = time.Now().Add(time.Hour) return nil } func (fs *LarcFS) getAttrs(info *inodeInfo) fuseops.InodeAttributes { attrs := fuseops.InodeAttributes{ Nlink: 1, Uid: uint32(os.Getuid()), Gid: uint32(os.Getgid()), } if info.isDir { attrs.Mode = os.ModeDir | 0755 attrs.Size = 4096 } else if info.entry != nil { attrs.Mode = os.FileMode(info.entry.Mode) & 0777 attrs.Size = uint64(info.entry.Size) } else { attrs.Mode = 0644 } return attrs } func (fs *LarcFS) OpenDir(ctx context.Context, op *fuseops.OpenDirOp) error { fs.mu.RLock() defer fs.mu.RUnlock() info, ok := fs.inodes[op.Inode] if !ok { return fuse.ENOENT } if !info.isDir { return syscall.ENOTDIR } op.Handle = fs.allocHandle(op.Inode) return nil } func (fs *LarcFS) ReadDir(ctx context.Context, op *fuseops.ReadDirOp) error { fs.mu.RLock() defer fs.mu.RUnlock() info, ok := fs.inodes[op.Inode] if !ok { return fuse.ENOENT } /* collect children in stable order */ type dirEntry struct { name string inode fuseops.InodeID } var entries []dirEntry for name, id := range info.children { entries = append(entries, dirEntry{name: name, inode: id}) } /* write entries starting from offset */ var bytesWritten int for i := int(op.Offset); i < len(entries); i++ { e := entries[i] child := fs.inodes[e.inode] var dtype fuseutil.DirentType if child.isDir { dtype = fuseutil.DT_Directory } else { dtype = fuseutil.DT_File } dirent := fuseutil.Dirent{ Offset: fuseops.DirOffset(i + 1), Inode: e.inode, Name: e.name, Type: dtype, } n := fuseutil.WriteDirent(op.Dst[bytesWritten:], dirent) if n == 0 { break } bytesWritten += n } op.BytesRead = bytesWritten return nil } func (fs *LarcFS) ReleaseDirHandle(ctx context.Context, op *fuseops.ReleaseDirHandleOp) error { fs.freeHandle(op.Handle) return nil } func (fs *LarcFS) OpenFile(ctx context.Context, op *fuseops.OpenFileOp) error { fs.mu.RLock() defer fs.mu.RUnlock() info, ok := fs.inodes[op.Inode] if !ok { return fuse.ENOENT } if info.isDir { return syscall.EISDIR } op.Handle = fs.allocHandle(op.Inode) op.KeepPageCache = true return nil } func (fs *LarcFS) ReadFile(ctx context.Context, op *fuseops.ReadFileOp) error { fs.mu.RLock() info, ok := fs.inodes[op.Inode] fs.mu.RUnlock() if !ok { return fuse.ENOENT } if info.entry == nil || info.entry.BlobHash == "" { return fuse.EIO } /* fetch blob (from cache or server) */ data, err := fs.fetcher.Fetch(info.entry.BlobHash) if err != nil { return fuse.EIO } /* handle offset and size */ start := int(op.Offset) if start >= len(data) { op.BytesRead = 0 return nil } end := start + len(op.Dst) if end > len(data) { end = len(data) } op.BytesRead = copy(op.Dst, data[start:end]) return nil } func (fs *LarcFS) ReleaseFileHandle(ctx context.Context, op *fuseops.ReleaseFileHandleOp) error { fs.freeHandle(op.Handle) return nil } // Mount mounts the filesystem at the given path func Mount(mountPoint string, fs *LarcFS) (*fuse.MountedFileSystem, error) { server := fuseutil.NewFileSystemServer(fs) cfg := &fuse.MountConfig{ FSName: "larc:" + fs.repoName, ReadOnly: true, } return fuse.Mount(mountPoint, server, cfg) } // Unmount unmounts the filesystem at the given path func Unmount(mountPoint string) error { return fuse.Unmount(mountPoint) }