small refactor*

This commit is contained in:
royalcat 2025-03-22 08:49:14 +04:00
parent b6b541e050
commit 24a4d30275
232 changed files with 2164 additions and 1906 deletions

View file

@ -0,0 +1,14 @@
package cowutils
import (
"errors"
)
// ErrNotSupported is returned by Always() if the operation is not
// supported on the current operating system. Auto() will never return this
// error.
var (
ErrNotSupported = errors.New("cow is not supported on this OS")
ErrFailed = errors.New("cow is not supported on this OS or file")
ErrTooSmall = errors.New("file is too smaller then filesystem block size")
)

View file

@ -0,0 +1,88 @@
package cowutils
import (
"context"
"os"
"golang.org/x/sys/unix"
)
func DedupeFiles(ctx context.Context, paths []string) (deduped uint64, err error) {
srcF, err := os.Open(paths[0])
if err != nil {
return deduped, err
}
defer srcF.Close()
srcStat, err := srcF.Stat()
if err != nil {
return deduped, err
}
srcFd := int(srcF.Fd())
srcSize := srcStat.Size()
fsStat := unix.Statfs_t{}
err = unix.Fstatfs(srcFd, &fsStat)
if err != nil {
return deduped, err
}
if int64(fsStat.Bsize) > srcSize { // for btrfs it means file residing in metadata and can't be deduplicated
return deduped, nil
}
blockSize := uint64((srcSize % int64(fsStat.Bsize)) * int64(fsStat.Bsize))
fdr := unix.FileDedupeRange{
Src_offset: 0,
Src_length: blockSize,
Info: []unix.FileDedupeRangeInfo{},
}
for _, dst := range paths[1:] {
if ctx.Err() != nil {
return deduped, ctx.Err()
}
destF, err := os.OpenFile(dst, os.O_RDWR, os.ModePerm)
if err != nil {
return deduped, err
}
// defer in cycle is intended, file must be closed only at the end of the function,
// and, most importantly, this keeps GC from closing descriptor while dudupe in progress
defer destF.Close()
fdr.Info = append(fdr.Info, unix.FileDedupeRangeInfo{
Dest_fd: int64(destF.Fd()),
Dest_offset: 0,
})
}
if len(fdr.Info) == 0 {
return deduped, nil
}
if ctx.Err() != nil {
return deduped, ctx.Err()
}
fdr.Src_offset = 0
for i := range fdr.Info {
fdr.Info[i].Dest_offset = 0
}
err = unix.IoctlFileDedupeRange(srcFd, &fdr)
if err != nil {
return deduped, err
}
for i := range fdr.Info {
deduped += fdr.Info[i].Bytes_deduped
fdr.Info[i].Status = 0
fdr.Info[i].Bytes_deduped = 0
}
return deduped, nil
}

View file

@ -0,0 +1,54 @@
package cowutils
import (
"context"
"fmt"
"io"
"io/fs"
"os"
)
// Reflink performs the reflink operation on the passed files, replacing
// dst's contents with src. If fallback is true and reflink fails,
// copy_file_range will be used first, and if that fails too io.Copy will
// be used to copy the data.
func Reflink(ctx context.Context, dst, src *os.File, fallback bool) error {
err := reflink(dst, src)
if (err != nil) && fallback {
// reflink failed, but we can fallback, but first we need to know the file's size
var st fs.FileInfo
st, err = src.Stat()
if err != nil {
// couldn't stat source, this can't be helped
return fmt.Errorf("failed to stat source: %w", err)
}
_, err = copyFileRange(dst, src, 0, 0, st.Size())
if err != nil {
// copyFileRange failed too, switch to simple io copy
reader := io.NewSectionReader(src, 0, st.Size())
writer := &sectionWriter{w: dst}
_ = dst.Truncate(0) // assuming any error in trucate will result in copy error
_, err = io.Copy(writer, reader)
}
}
return err
}
// ReflinkRange performs a range reflink operation on the passed files, replacing
// part of dst's contents with data from src. If fallback is true and reflink
// fails, copy_file_range will be used first, and if that fails too io.CopyN
// will be used to copy the data.
func ReflinkRange(ctx context.Context, dst, src *os.File, dstOffset, srcOffset, n int64, fallback bool) error {
err := reflinkRange(dst, src, dstOffset, srcOffset, n)
if (err != nil) && fallback {
_, err = copyFileRange(dst, src, dstOffset, srcOffset, n)
}
if (err != nil) && fallback {
// seek both src & dst
reader := io.NewSectionReader(src, srcOffset, n)
writer := &sectionWriter{w: dst, base: dstOffset}
_, err = io.CopyN(writer, reader, n)
}
return err
}

View file

@ -0,0 +1,53 @@
//!build +unix
package cowutils
import (
"errors"
"os"
"golang.org/x/sys/unix"
)
// reflink performs the actual reflink action without worrying about fallback
func reflink(dst, src *os.File) error {
srcFd := int(src.Fd())
dstFd := int(dst.Fd())
err := unix.IoctlFileClone(dstFd, srcFd)
if err != nil && errors.Is(err, unix.ENOTSUP) {
return ErrNotSupported
}
return err
}
func reflinkRange(dst, src *os.File, dstOffset, srcOffset, n int64) error {
srcFd := int(src.Fd())
dstFd := int(dst.Fd())
req := &unix.FileCloneRange{
Src_fd: int64(srcFd),
Src_offset: uint64(srcOffset),
Src_length: uint64(n),
Dest_offset: uint64(dstOffset),
}
err := unix.IoctlFileCloneRange(dstFd, req)
if err != nil && errors.Is(err, unix.ENOTSUP) {
return ErrNotSupported
}
return err
}
func copyFileRange(dst, src *os.File, dstOffset, srcOffset, n int64) (int64, error) {
srcFd := int(src.Fd())
dstFd := int(dst.Fd())
resN, err := unix.CopyFileRange(srcFd, &srcOffset, dstFd, &dstOffset, int(n), 0)
return int64(resN), err
}

View file

@ -0,0 +1,39 @@
package cowutils
import (
"errors"
"io"
)
// sectionWriter is a helper used when we need to fallback into copying data manually
type sectionWriter struct {
w io.WriterAt // target file
base int64 // base position in file
off int64 // current relative offset
}
// Write writes & updates offset
func (s *sectionWriter) Write(p []byte) (int, error) {
n, err := s.w.WriteAt(p, s.base+s.off)
s.off += int64(n)
return n, err
}
func (s *sectionWriter) Seek(offset int64, whence int) (int64, error) {
switch whence {
case io.SeekStart:
// nothing needed
case io.SeekCurrent:
offset += s.off
case io.SeekEnd:
// we don't support io.SeekEnd
fallthrough
default:
return s.off, errors.New("Seek: invalid whence")
}
if offset < 0 {
return s.off, errors.New("Seek: invalid offset")
}
s.off = offset
return offset, nil
}

View file

@ -0,0 +1,27 @@
package ctxbilly
import (
"context"
"os"
"time"
)
// Change abstract the FileInfo change related operations in a storage-agnostic
// interface as an extension to the Basic interface
type Change interface {
// Chmod changes the mode of the named file to mode. If the file is a
// symbolic link, it changes the mode of the link's target.
Chmod(ctx context.Context, name string, mode os.FileMode) error
// Lchown changes the numeric uid and gid of the named file. If the file is
// a symbolic link, it changes the uid and gid of the link itself.
Lchown(ctx context.Context, name string, uid, gid int) error
// Chown changes the numeric uid and gid of the named file. If the file is a
// symbolic link, it changes the uid and gid of the link's target.
Chown(ctx context.Context, name string, uid, gid int) error
// Chtimes changes the access and modification times of the named file,
// similar to the Unix utime() or utimes() functions.
//
// The underlying filesystem may truncate or round the values to a less
// precise time unit.
Chtimes(ctx context.Context, name string, atime time.Time, mtime time.Time) error
}

97
server/pkg/ctxbilly/fs.go Normal file
View file

@ -0,0 +1,97 @@
package ctxbilly
import (
"context"
"io"
"os"
"github.com/royalcat/ctxio"
)
type Filesystem interface {
// Create creates the named file with mode 0666 (before umask), truncating
// it if it already exists. If successful, methods on the returned File can
// be used for I/O; the associated file descriptor has mode O_RDWR.
Create(ctx context.Context, filename string) (File, error)
// OpenFile is the generalized open call; most users will use Open or Create
// instead. It opens the named file with specified flag (O_RDONLY etc.) and
// perm, (0666 etc.) if applicable. If successful, methods on the returned
// File can be used for I/O.
OpenFile(ctx context.Context, filename string, flag int, perm os.FileMode) (File, error)
// Stat returns a FileInfo describing the named file.
Stat(ctx context.Context, filename string) (os.FileInfo, error)
// Rename renames (moves) oldpath to newpath. If newpath already exists and
// is not a directory, Rename replaces it. OS-specific restrictions may
// apply when oldpath and newpath are in different directories.
Rename(ctx context.Context, oldpath, newpath string) error
// Remove removes the named file or directory.
Remove(ctx context.Context, filename string) error
// Join joins any number of path elements into a single path, adding a
// Separator if necessary. Join calls filepath.Clean on the result; in
// particular, all empty strings are ignored. On Windows, the result is a
// UNC path if and only if the first path element is a UNC path.
Join(elem ...string) string
// ReadDir reads the directory named by d(irname and returns a list of
// directory entries sorted by filename.
ReadDir(ctx context.Context, path string) ([]os.FileInfo, error)
// MkdirAll creates a directory named path, along with any necessary
// parents, and returns nil, or else returns an error. The permission bits
// perm are used for all directories that MkdirAll creates. If path is/
// already a directory, MkdirAll does nothing and returns nil.
MkdirAll(ctx context.Context, filename string, perm os.FileMode) error
// Lstat returns a FileInfo describing the named file. If the file is a
// symbolic link, the returned FileInfo describes the symbolic link. Lstat
// makes no attempt to follow the link.
Lstat(ctx context.Context, filename string) (os.FileInfo, error)
// Symlink creates a symbolic-link from link to target. target may be an
// absolute or relative path, and need not refer to an existing node.
// Parent directories of link are created as necessary.
Symlink(ctx context.Context, target, link string) error
// Readlink returns the target path of link.
Readlink(ctx context.Context, link string) (string, error)
// // Chroot returns a new filesystem from the same type where the new root is
// // the given path. Files outside of the designated directory tree cannot be
// // accessed.
// Chroot(path string) (Filesystem, error)
// // Root returns the root path of the filesystem.
// Root() string
}
type TempFileFS interface {
// TempFile creates a new temporary file in the directory dir with a name
// beginning with prefix, opens the file for reading and writing, and
// returns the resulting *os.File. If dir is the empty string, TempFile
// uses the default directory for temporary files (see os.TempDir).
// Multiple programs calling TempFile simultaneously will not choose the
// same file. The caller can use f.Name() to find the pathname of the file.
// It is the caller's responsibility to remove the file when no longer
// needed.
TempFile(ctx context.Context, dir, prefix string) (File, error)
}
type File interface {
// Name returns the name of the file as presented to Open.
Name() string
ctxio.Writer
ctxio.WriterAt
ctxio.Reader
ctxio.ReaderAt
io.Seeker
ctxio.Closer
}
type LockFile interface {
// Lock locks the file like e.g. flock. It protects against access from
// other processes.
Lock() error
// Unlock unlocks the file.
Unlock() error
}
type TruncateFile interface {
// Truncate the file.
Truncate(ctx context.Context, size int64) error
}

175
server/pkg/ctxbilly/mem.go Normal file
View file

@ -0,0 +1,175 @@
package ctxbilly
import (
"context"
"io/fs"
"github.com/go-git/go-billy/v5"
)
func WrapFileSystem(bf billy.Filesystem) Filesystem {
return &wrapFS{
Filesystem: bf,
}
}
type wrapFS struct {
billy.Filesystem
}
var _ Filesystem = (*wrapFS)(nil)
// Create implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Create of MemFS.Filesystem.
func (m *wrapFS) Create(ctx context.Context, filename string) (File, error) {
bf, err := m.Filesystem.Create(filename)
if err != nil {
return nil, err
}
return &wrapFile{bf}, nil
}
// Lstat implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Lstat of MemFS.Filesystem.
func (m *wrapFS) Lstat(ctx context.Context, filename string) (fs.FileInfo, error) {
return m.Filesystem.Lstat(filename)
}
// MkdirAll implements Filesystem.
// Subtle: this method shadows the method (Filesystem).MkdirAll of MemFS.Filesystem.
func (m *wrapFS) MkdirAll(ctx context.Context, filename string, perm fs.FileMode) error {
return m.Filesystem.MkdirAll(filename, perm)
}
// Open implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Open of MemFS.Filesystem.
func (m *wrapFS) Open(ctx context.Context, filename string) (File, error) {
bf, err := m.Filesystem.Open(filename)
if err != nil {
return nil, err
}
return WrapFile(bf), nil
}
// OpenFile implements Filesystem.
// Subtle: this method shadows the method (Filesystem).OpenFile of MemFS.Filesystem.
func (m *wrapFS) OpenFile(ctx context.Context, filename string, flag int, perm fs.FileMode) (File, error) {
bf, err := m.Filesystem.OpenFile(filename, flag, perm)
if err != nil {
return nil, err
}
return WrapFile(bf), nil
}
// ReadDir implements Filesystem.
// Subtle: this method shadows the method (Filesystem).ReadDir of MemFS.Filesystem.
func (m *wrapFS) ReadDir(ctx context.Context, path string) ([]fs.FileInfo, error) {
return m.Filesystem.ReadDir(path)
}
// Readlink implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Readlink of MemFS.Filesystem.
func (m *wrapFS) Readlink(ctx context.Context, link string) (string, error) {
return m.Filesystem.Readlink(link)
}
// Remove implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Remove of MemFS.Filesystem.
func (m *wrapFS) Remove(ctx context.Context, filename string) error {
return m.Filesystem.Remove(filename)
}
// Rename implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Rename of MemFS.Filesystem.
func (m *wrapFS) Rename(ctx context.Context, oldpath string, newpath string) error {
return m.Filesystem.Rename(oldpath, newpath)
}
// Stat implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Stat of MemFS.Filesystem.
func (m *wrapFS) Stat(ctx context.Context, filename string) (fs.FileInfo, error) {
return m.Filesystem.Stat(filename)
}
// Symlink implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Symlink of MemFS.Filesystem.
func (m *wrapFS) Symlink(ctx context.Context, target string, link string) error {
return m.Filesystem.Symlink(target, link)
}
// TempFile implements Filesystem.
// Subtle: this method shadows the method (Filesystem).TempFile of MemFS.Filesystem.
func (m *wrapFS) TempFile(ctx context.Context, dir string, prefix string) (File, error) {
file, err := m.Filesystem.TempFile(dir, prefix)
if err != nil {
return nil, err
}
return WrapFile(file), nil
}
func WrapFile(bf billy.File) File {
return &wrapFile{File: bf}
}
type wrapFile struct {
billy.File
}
var _ File = (*wrapFile)(nil)
// Close implements File.
// Subtle: this method shadows the method (File).Close of MemFile.File.
func (m *wrapFile) Close(ctx context.Context) error {
return m.File.Close()
}
// Lock implements File.
// Subtle: this method shadows the method (File).Lock of MemFile.File.
func (m *wrapFile) Lock() error {
return m.File.Lock()
}
// Name implements File.
// Subtle: this method shadows the method (File).Name of MemFile.File.
func (m *wrapFile) Name() string {
return m.File.Name()
}
// Truncate implements File.
// Subtle: this method shadows the method (File).Truncate of memFile.File.
func (m *wrapFile) Truncate(ctx context.Context, size int64) error {
return m.File.Truncate(size)
}
// Read implements File.
// Subtle: this method shadows the method (File).Read of MemFile.File.
func (m *wrapFile) Read(ctx context.Context, p []byte) (n int, err error) {
return m.File.Read(p)
}
// ReadAt implements File.
// Subtle: this method shadows the method (File).ReadAt of MemFile.File.
func (m *wrapFile) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) {
return m.File.ReadAt(p, off)
}
// Unlock implements File.
// Subtle: this method shadows the method (File).Unlock of MemFile.File.
func (m *wrapFile) Unlock() error {
return m.File.Unlock()
}
// Write implements File.
// Subtle: this method shadows the method (File).Write of MemFile.File.
func (m *wrapFile) Write(ctx context.Context, p []byte) (n int, err error) {
return m.File.Write(p)
}
// WriteAt implements File.
func (m *wrapFile) WriteAt(ctx context.Context, p []byte, off int64) (n int, err error) {
_, err = m.File.Seek(off, 0)
if err != nil {
return 0, err
}
return m.File.Write(p)
}

View file

@ -0,0 +1,355 @@
package ctxbilly
// import (
// "context"
// "errors"
// "fmt"
// "os"
// "path/filepath"
// "strings"
// securejoin "github.com/cyphar/filepath-securejoin"
// "github.com/iceber/iouring-go"
// )
// func NewURingFS() (*UringFS, error) {
// ur, err := iouring.New(64, iouring.WithAsync())
// if err != nil {
// return nil, err
// }
// return &UringFS{
// ur: ur,
// }, nil
// }
// var _ Filesystem = (*UringFS)(nil)
// const (
// defaultDirectoryMode = 0o755
// defaultCreateMode = 0o666
// )
// // UringFS is a fs implementation based on the OS filesystem which is bound to
// // a base dir.
// // Prefer this fs implementation over ChrootOS.
// //
// // Behaviours of note:
// // 1. Read and write operations can only be directed to files which descends
// // from the base dir.
// // 2. Symlinks don't have their targets modified, and therefore can point
// // to locations outside the base dir or to non-existent paths.
// // 3. Readlink and Lstat ensures that the link file is located within the base
// // dir, evaluating any symlinks that file or base dir may contain.
// type UringFS struct {
// ur *iouring.IOURing
// baseDir string
// }
// func newBoundOS(d string) *UringFS {
// return &UringFS{baseDir: d}
// }
// func (fs *UringFS) Create(ctx context.Context, filename string) (File, error) {
// return fs.OpenFile(ctx, filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, defaultCreateMode)
// }
// func (fs *UringFS) OpenFile(ctx context.Context, filename string, flag int, perm os.FileMode) (File, error) {
// fn, err := fs.abs(filename)
// if err != nil {
// return nil, err
// }
// f, err := os.OpenFile(fn, flag, perm)
// if err != nil {
// return nil, err
// }
// return newFile(fs.ur, f)
// }
// func (fs *UringFS) ReadDir(ctx context.Context, path string) ([]os.FileInfo, error) {
// dir, err := fs.abs(path)
// if err != nil {
// return nil, err
// }
// entries, err := os.ReadDir(dir)
// if err != nil {
// return nil, err
// }
// infos := make([]os.FileInfo, 0, len(entries))
// for _, v := range entries {
// info, err := v.Info()
// if err != nil {
// return nil, err
// }
// infos = append(infos, info)
// }
// return infos, nil
// }
// func (fs *UringFS) Rename(ctx context.Context, from, to string) error {
// f, err := fs.abs(from)
// if err != nil {
// return err
// }
// t, err := fs.abs(to)
// if err != nil {
// return err
// }
// // MkdirAll for target name.
// if err := fs.createDir(t); err != nil {
// return err
// }
// return os.Rename(f, t)
// }
// func (fs *UringFS) MkdirAll(ctx context.Context, path string, perm os.FileMode) error {
// dir, err := fs.abs(path)
// if err != nil {
// return err
// }
// return os.MkdirAll(dir, perm)
// }
// func (fs *UringFS) Stat(ctx context.Context, filename string) (os.FileInfo, error) {
// filename, err := fs.abs(filename)
// if err != nil {
// return nil, err
// }
// return os.Stat(filename)
// }
// func (fs *UringFS) Remove(ctx context.Context, filename string) error {
// fn, err := fs.abs(filename)
// if err != nil {
// return err
// }
// return os.Remove(fn)
// }
// func (fs *UringFS) Join(elem ...string) string {
// return filepath.Join(elem...)
// }
// func (fs *UringFS) RemoveAll(path string) error {
// dir, err := fs.abs(path)
// if err != nil {
// return err
// }
// return os.RemoveAll(dir)
// }
// func (fs *UringFS) Symlink(ctx context.Context, target, link string) error {
// ln, err := fs.abs(link)
// if err != nil {
// return err
// }
// // MkdirAll for containing dir.
// if err := fs.createDir(ln); err != nil {
// return err
// }
// return os.Symlink(target, ln)
// }
// func (fs *UringFS) Lstat(ctx context.Context, filename string) (os.FileInfo, error) {
// filename = filepath.Clean(filename)
// if !filepath.IsAbs(filename) {
// filename = filepath.Join(fs.baseDir, filename)
// }
// if ok, err := fs.insideBaseDirEval(filename); !ok {
// return nil, err
// }
// return os.Lstat(filename)
// }
// func (fs *UringFS) Readlink(ctx context.Context, link string) (string, error) {
// if !filepath.IsAbs(link) {
// link = filepath.Clean(filepath.Join(fs.baseDir, link))
// }
// if ok, err := fs.insideBaseDirEval(link); !ok {
// return "", err
// }
// return os.Readlink(link)
// }
// // Chroot returns a new OS filesystem, with the base dir set to the
// // result of joining the provided path with the underlying base dir.
// // func (fs *UringFS) Chroot(path string) (Filesystem, error) {
// // joined, err := securejoin.SecureJoin(fs.baseDir, path)
// // if err != nil {
// // return nil, err
// // }
// // return newBoundOS(joined), nil
// // }
// // Root returns the current base dir of the billy.Filesystem.
// // This is required in order for this implementation to be a drop-in
// // replacement for other upstream implementations (e.g. memory and osfs).
// func (fs *UringFS) Root() string {
// return fs.baseDir
// }
// func (fs *UringFS) createDir(fullpath string) error {
// dir := filepath.Dir(fullpath)
// if dir != "." {
// if err := os.MkdirAll(dir, defaultDirectoryMode); err != nil {
// return err
// }
// }
// return nil
// }
// // abs transforms filename to an absolute path, taking into account the base dir.
// // Relative paths won't be allowed to ascend the base dir, so `../file` will become
// // `/working-dir/file`.
// //
// // Note that if filename is a symlink, the returned address will be the target of the
// // symlink.
// func (fs *UringFS) abs(filename string) (string, error) {
// if filename == fs.baseDir {
// filename = string(filepath.Separator)
// }
// path, err := securejoin.SecureJoin(fs.baseDir, filename)
// if err != nil {
// return "", nil
// }
// return path, nil
// }
// // insideBaseDirEval checks whether filename is contained within
// // a dir that is within the fs.baseDir, by first evaluating any symlinks
// // that either filename or fs.baseDir may contain.
// func (fs *UringFS) insideBaseDirEval(filename string) (bool, error) {
// dir, err := filepath.EvalSymlinks(filepath.Dir(filename))
// if dir == "" || os.IsNotExist(err) {
// dir = filepath.Dir(filename)
// }
// wd, err := filepath.EvalSymlinks(fs.baseDir)
// if wd == "" || os.IsNotExist(err) {
// wd = fs.baseDir
// }
// if filename != wd && dir != wd && !strings.HasPrefix(dir, wd+string(filepath.Separator)) {
// return false, fmt.Errorf("path outside base dir")
// }
// return true, nil
// }
// func newFile(fsur *iouring.IOURing, f *os.File) (*URingFile, error) {
// ur, err := iouring.New(64, iouring.WithAttachWQ(fsur))
// if err != nil {
// return nil, err
// }
// return &URingFile{
// ur: ur,
// f: f,
// }, nil
// }
// type URingFile struct {
// ur *iouring.IOURing
// f *os.File
// }
// // Close implements File.
// func (o *URingFile) Close(ctx context.Context) error {
// return errors.Join(o.ur.UnregisterFile(o.f), o.Close(ctx))
// }
// // Name implements File.
// func (o *URingFile) Name() string {
// return o.f.Name()
// }
// // Read implements File.
// func (o *URingFile) Read(ctx context.Context, p []byte) (n int, err error) {
// req, err := o.ur.Read(o.f, p, nil)
// if err != nil {
// return 0, err
// }
// defer req.Cancel()
// select {
// case <-req.Done():
// return req.GetRes()
// case <-ctx.Done():
// req.Cancel()
// <-req.Done()
// return req.GetRes()
// }
// }
// // ReadAt implements File.
// func (o *URingFile) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) {
// req, err := o.ur.Pread(o.f, p, uint64(off), nil)
// if err != nil {
// return 0, err
// }
// defer req.Cancel()
// select {
// case <-req.Done():
// return req.GetRes()
// case <-ctx.Done():
// req.Cancel()
// <-req.Done()
// return req.GetRes()
// }
// }
// // Write implements File.
// func (o *URingFile) Write(ctx context.Context, p []byte) (n int, err error) {
// req, err := o.ur.Write(o.f, p, nil)
// if err != nil {
// return 0, err
// }
// defer req.Cancel()
// select {
// case <-req.Done():
// return req.GetRes()
// case <-ctx.Done():
// req.Cancel()
// <-req.Done()
// return req.GetRes()
// }
// }
// // WriteAt implements File.
// func (o *URingFile) WriteAt(ctx context.Context, p []byte, off int64) (n int, err error) {
// req, err := o.ur.Pwrite(o.f, p, uint64(off), nil)
// if err != nil {
// return 0, err
// }
// defer req.Cancel()
// select {
// case <-req.Done():
// return req.GetRes()
// case <-ctx.Done():
// req.Cancel()
// <-req.Done()
// return req.GetRes()
// }
// }
// // Seek implements File.
// func (o *URingFile) Seek(offset int64, whence int) (int64, error) {
// return o.f.Seek(offset, whence)
// }
// // Truncate implements File.
// func (o *URingFile) Truncate(ctx context.Context, size int64) error {
// return o.f.Truncate(size)
// }
// var _ File = (*URingFile)(nil)

View file

@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/" # Location of package manifests
schedule:
interval: "daily"

View file

@ -0,0 +1,51 @@
name: "Code scanning - action"
on:
push:
pull_request:
schedule:
- cron: '0 18 * * 3'
jobs:
CodeQL-Build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 2
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
# Override language selection by uncommenting this and choosing your languages
# with:
# languages: go, javascript, csharp, python, cpp, java
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View file

@ -0,0 +1,36 @@
name: Go
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
permissions:
contents: read
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v3
with:
go-version: ^1.19
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Get dependencies
run: go get -v -t -d ./...
- name: Build
run: go build -v ./...
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
- name: Test
run: go test -v .

View file

@ -0,0 +1,11 @@
# Contributing Guidelines
We appreciate your interest in improving go-nfs!
## Looking for ways to contribute?
There are several ways you can contribute:
- Start contributing immediately via the [opened](https://github.com/willscott/go-nfs/issues) issues on GitHub.
Defined issues provide an excellent starting point.
- Reporting issues, bugs, mistakes, or inconsistencies.
As many open source projects, we are short-staffed, we thus kindly ask you to be open to contribute a fix for discovered issues.

202
server/pkg/go-nfs/LICENSE Normal file
View file

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,96 @@
Golang Network File Server
===
NFSv3 protocol implementation in pure Golang.
Current Status:
* Minimally tested
* Mounts, read-only and read-write support
Usage
===
The most interesting demo is currently in `example/osview`.
Start the server
`go run ./example/osview .`.
The local folder at `.` will be the initial view in the mount. mutations to metadata or contents
will be stored purely in memory and not written back to the OS. When run, this
demo will print the port it is listening on.
The mount can be accessed using a command similar to
`mount -o port=<n>,mountport=<n> -t nfs localhost:/mount <mountpoint>` (For Mac users)
or
`mount -o port=<n>,mountport=<n>,nfsvers=3,noacl,tcp -t nfs localhost:/mount <mountpoint>` (For Linux users)
API
===
The NFS server runs on a `net.Listener` to export a file system to NFS clients.
Usage is structured similarly to many other golang network servers.
```golang
package main
import (
"fmt"
"log"
"net"
"github.com/go-git/go-billy/v5/memfs"
nfs "github.com/willscott/go-nfs"
nfshelper "github.com/willscott/go-nfs/helpers"
)
func main() {
listener, err := net.Listen("tcp", ":0")
panicOnErr(err, "starting TCP listener")
fmt.Printf("Server running at %s\n", listener.Addr())
mem := memfs.New()
f, err := mem.Create("hello.txt")
panicOnErr(err, "creating file")
_, err = f.Write([]byte("hello world"))
panicOnErr(err, "writing data")
f.Close()
handler := nfshelper.NewNullAuthHandler(mem)
cacheHelper := nfshelper.NewCachingHandler(handler, 1)
panicOnErr(nfs.Serve(listener, cacheHelper), "serving nfs")
}
func panicOnErr(err error, desc ...interface{}) {
if err == nil {
return
}
log.Println(desc...)
log.Panicln(err)
}
```
Notes
---
* Ports are typically determined through portmap. The need for running portmap
(which is the only part that needs a privileged listening port) can be avoided
through specific mount options. e.g.
`mount -o port=n,mountport=n -t nfs host:/mount /localmount`
* This server currently uses [billy](https://github.com/go-git/go-billy/) to
provide a file system abstraction layer. There are some edges of the NFS protocol
which do not translate to this abstraction.
* NFS expects access to an `inode` or equivalent unique identifier to reference
files in a file system. These are considered opaque identifiers here, which
means they will not work as expected in cases of hard linking.
* The billy abstraction layer does not extend to exposing `uid` and `gid`
ownership of files. If ownership is important to your file system, you
will need to ensure that the `os.FileInfo` meets additional constraints.
In particular, the `Sys()` escape hatch is queried by this library, and
if your file system populates a [`syscall.Stat_t`](https://golang.org/pkg/syscall/#Stat_t)
concrete struct, the ownership specified in that object will be used.
* Relevant RFCS:
[5531 - RPC protocol](https://tools.ietf.org/html/rfc5531),
[1813 - NFSv3](https://tools.ietf.org/html/rfc1813),
[1094 - NFS](https://tools.ietf.org/html/rfc1094)

View file

@ -0,0 +1,11 @@
# Security Policy
## Supported Versions
The latest release reflects the current best recommendation / supported version at this time.
## Reporting a Vulnerability
Please email Will (the git commit author) if you need to report issues privately.
I will endeavor to respond within a day, but if I am offline, responses may be delayed longer than that.
If you need a stronger SLA to have confidence in using this code, feel free to reach out.

View file

@ -0,0 +1,9 @@
package nfs
import (
billy "github.com/go-git/go-billy/v5"
)
func CapabilityCheck(fs Filesystem, cap billy.Capability) bool {
return true
}

344
server/pkg/go-nfs/conn.go Normal file
View file

@ -0,0 +1,344 @@
package nfs
import (
"bufio"
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
xdr2 "github.com/rasky/go-xdr/xdr2"
"github.com/willscott/go-nfs-client/nfs/rpc"
"github.com/willscott/go-nfs-client/nfs/xdr"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/codes"
)
var (
// ErrInputInvalid is returned when input cannot be parsed
ErrInputInvalid = errors.New("invalid input")
// ErrAlreadySent is returned when writing a header/status multiple times
ErrAlreadySent = errors.New("response already started")
)
// ResponseCode is a combination of accept_stat and reject_stat.
type ResponseCode uint32
// ResponseCode Codes
const (
ResponseCodeSuccess ResponseCode = iota
ResponseCodeProgUnavailable
ResponseCodeProcUnavailable
ResponseCodeGarbageArgs
ResponseCodeSystemErr
ResponseCodeRPCMismatch
ResponseCodeAuthError
)
type conn struct {
*Server
writeSerializer chan []byte
net.Conn
}
var tracer = otel.Tracer("git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs")
func (c *conn) serve() {
ctx := context.Background() // TODO implement correct timeout on serve side
c.writeSerializer = make(chan []byte, 1)
go c.serializeWrites(ctx)
bio := bufio.NewReader(c.Conn)
for {
w, err := c.readRequestHeader(ctx, bio)
if err != nil {
if err == io.EOF {
// Clean close.
c.Close()
return
}
return
}
Log.Tracef("request: %v", w.req)
err = c.handle(ctx, w)
respErr := w.finish(ctx)
if err != nil {
Log.Errorf("error handling req: %v", err)
// failure to handle at a level needing to close the connection.
c.Close()
return
}
if respErr != nil {
Log.Errorf("error sending response: %v", respErr)
c.Close()
return
}
}
}
func (c *conn) serializeWrites(ctx context.Context) {
// todo: maybe don't need the extra buffer
writer := bufio.NewWriter(c.Conn)
var fragmentBuf [4]byte
var fragmentInt uint32
for {
select {
case <-ctx.Done():
return
case msg, ok := <-c.writeSerializer:
if !ok {
return
}
// prepend the fragmentation header
fragmentInt = uint32(len(msg))
fragmentInt |= (1 << 31)
binary.BigEndian.PutUint32(fragmentBuf[:], fragmentInt)
n, err := writer.Write(fragmentBuf[:])
if n < 4 || err != nil {
return
}
n, err = writer.Write(msg)
if err != nil {
return
}
if n < len(msg) {
panic("todo: ensure writes complete fully.")
}
if err = writer.Flush(); err != nil {
return
}
}
}
}
// Handle a request. errors from this method indicate a failure to read or
// write on the network stream, and trigger a disconnection of the connection.
func (c *conn) handle(ctx context.Context, w *response) (err error) {
ctx, span := tracer.Start(ctx, fmt.Sprintf("nfs.handle.%s", NFSProcedure(w.req.Header.Proc).String()))
defer span.End()
defer func() {
if err != nil {
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
}
}()
handler := c.Server.handlerFor(w.req.Header.Prog, w.req.Header.Proc)
if handler == nil {
Log.Errorf("No handler for %d.%d", w.req.Header.Prog, w.req.Header.Proc)
if err := w.drain(ctx); err != nil {
return err
}
return c.err(ctx, w, &ResponseCodeProcUnavailableError{})
}
appError := handler(ctx, w, c.Server.Handler)
if drainErr := w.drain(ctx); drainErr != nil {
return drainErr
}
if appError != nil && !w.responded {
Log.Errorf("call to %+v failed: %v", handler, appError)
if err := c.err(ctx, w, appError); err != nil {
return err
}
}
if !w.responded {
Log.Errorf("Handler did not indicate response status via writing or erroring")
if err := c.err(ctx, w, &ResponseCodeSystemError{}); err != nil {
return err
}
}
return nil
}
func (c *conn) err(ctx context.Context, w *response, err error) error {
select {
case <-ctx.Done():
return nil
default:
}
if w.err == nil {
w.err = err
}
if w.responded {
return nil
}
rpcErr := w.errorFmt(err)
if writeErr := w.writeHeader(rpcErr.Code()); writeErr != nil {
return writeErr
}
body, _ := rpcErr.MarshalBinary()
return w.Write(body)
}
type request struct {
xid uint32
rpc.Header
Body io.Reader
}
func (r *request) String() string {
if r.Header.Prog == nfsServiceID {
return fmt.Sprintf("RPC #%d (nfs.%s)", r.xid, NFSProcedure(r.Header.Proc))
} else if r.Header.Prog == mountServiceID {
return fmt.Sprintf("RPC #%d (mount.%s)", r.xid, MountProcedure(r.Header.Proc))
}
return fmt.Sprintf("RPC #%d (%d.%d)", r.xid, r.Header.Prog, r.Header.Proc)
}
type response struct {
*conn
writer *bytes.Buffer
responded bool
err error
errorFmt func(error) RPCError
req *request
}
func (w *response) writeXdrHeader() error {
err := xdr.Write(w.writer, &w.req.xid)
if err != nil {
return err
}
respType := uint32(1)
err = xdr.Write(w.writer, &respType)
if err != nil {
return err
}
return nil
}
func (w *response) writeHeader(code ResponseCode) error {
if w.responded {
return ErrAlreadySent
}
w.responded = true
if err := w.writeXdrHeader(); err != nil {
return err
}
status := rpc.MsgAccepted
if code == ResponseCodeAuthError || code == ResponseCodeRPCMismatch {
status = rpc.MsgDenied
}
err := xdr.Write(w.writer, &status)
if err != nil {
return err
}
if status == rpc.MsgAccepted {
// Write opaque_auth header.
err = xdr.Write(w.writer, &rpc.AuthNull)
if err != nil {
return err
}
}
return xdr.Write(w.writer, &code)
}
// Write a response to an xdr message
func (w *response) Write(dat []byte) error {
if !w.responded {
if err := w.writeHeader(ResponseCodeSuccess); err != nil {
return err
}
}
acc := 0
for acc < len(dat) {
n, err := w.writer.Write(dat[acc:])
if err != nil {
return err
}
acc += n
}
return nil
}
// drain reads the rest of the request frame if not consumed by the handler.
func (w *response) drain(ctx context.Context) error {
if reader, ok := w.req.Body.(*io.LimitedReader); ok {
if reader.N == 0 {
return nil
}
// todo: wrap body in a context reader.
_, err := io.CopyN(io.Discard, w.req.Body, reader.N)
if err == nil || err == io.EOF {
return nil
}
return err
}
return io.ErrUnexpectedEOF
}
func (w *response) finish(ctx context.Context) error {
select {
case w.conn.writeSerializer <- w.writer.Bytes():
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (c *conn) readRequestHeader(ctx context.Context, reader *bufio.Reader) (w *response, err error) {
fragment, err := xdr.ReadUint32(reader)
if err != nil {
if xdrErr, ok := err.(*xdr2.UnmarshalError); ok {
if xdrErr.Err == io.EOF {
return nil, io.EOF
}
}
return nil, err
}
if fragment&(1<<31) == 0 {
Log.Warnf("Warning: haven't implemented fragment reconstruction.\n")
return nil, ErrInputInvalid
}
reqLen := fragment - uint32(1<<31)
if reqLen < 40 {
return nil, ErrInputInvalid
}
r := io.LimitedReader{R: reader, N: int64(reqLen)}
xid, err := xdr.ReadUint32(&r)
if err != nil {
return nil, err
}
reqType, err := xdr.ReadUint32(&r)
if err != nil {
return nil, err
}
if reqType != 0 { // 0 = request, 1 = response
return nil, ErrInputInvalid
}
req := request{
xid,
rpc.Header{},
&r,
}
if err = xdr.Read(&r, &req.Header); err != nil {
return nil, err
}
w = &response{
conn: c,
req: &req,
errorFmt: basicErrorFormatter,
// TODO: use a pool for these.
writer: bytes.NewBuffer([]byte{}),
}
return w, nil
}

230
server/pkg/go-nfs/errors.go Normal file
View file

@ -0,0 +1,230 @@
package nfs
import (
"encoding"
"encoding/binary"
"errors"
"fmt"
)
// RPCError provides the error interface for errors thrown by
// procedures to be transmitted over the XDR RPC channel
type RPCError interface {
// An RPCError is an `error` with this method
Error() string
// Code is the RPC Response code to send
Code() ResponseCode
// BinaryMarshaler is the on-wire representation of this error
encoding.BinaryMarshaler
}
// AuthStat is an enumeration of why authentication ahs failed
type AuthStat uint32
// AuthStat Codes
const (
AuthStatOK AuthStat = iota
AuthStatBadCred
AuthStatRejectedCred
AuthStatBadVerifier
AuthStatRejectedVerfier
AuthStatTooWeak
AuthStatInvalidResponse
AuthStatFailed
AuthStatKerbGeneric
AuthStatTimeExpire
AuthStatTktFile
AuthStatDecode
AuthStatNetAddr
AuthStatRPCGSSCredProblem
AuthStatRPCGSSCTXProblem
)
// AuthError is an RPCError
type AuthError struct {
AuthStat
}
// Code for AuthErrors is ResponseCodeAuthError
func (a *AuthError) Code() ResponseCode {
return ResponseCodeAuthError
}
// Error is a textual representaiton of the auth error. From the RFC
func (a *AuthError) Error() string {
switch a.AuthStat {
case AuthStatOK:
return "Auth Status: OK"
case AuthStatBadCred:
return "Auth Status: bad credential"
case AuthStatRejectedCred:
return "Auth Status: client must begin new session"
case AuthStatBadVerifier:
return "Auth Status: bad verifier"
case AuthStatRejectedVerfier:
return "Auth Status: verifier expired or replayed"
case AuthStatTooWeak:
return "Auth Status: rejected for security reasons"
case AuthStatInvalidResponse:
return "Auth Status: bogus response verifier"
case AuthStatFailed:
return "Auth Status: reason unknown"
case AuthStatKerbGeneric:
return "Auth Status: kerberos generic error"
case AuthStatTimeExpire:
return "Auth Status: time of credential expired"
case AuthStatTktFile:
return "Auth Status: problem with ticket file"
case AuthStatDecode:
return "Auth Status: can't decode authenticator"
case AuthStatNetAddr:
return "Auth Status: wrong net address in ticket"
case AuthStatRPCGSSCredProblem:
return "Auth Status: no credentials for user"
case AuthStatRPCGSSCTXProblem:
return "Auth Status: problem with context"
}
return "Auth Status: Unknown"
}
// MarshalBinary sends the specific auth status
func (a *AuthError) MarshalBinary() (data []byte, err error) {
var resp [4]byte
binary.LittleEndian.PutUint32(resp[:], uint32(a.AuthStat))
return resp[:], nil
}
// RPCMismatchError is an RPCError
type RPCMismatchError struct {
Low uint32
High uint32
}
// Code for RPCMismatchError is ResponseCodeRPCMismatch
func (r *RPCMismatchError) Code() ResponseCode {
return ResponseCodeRPCMismatch
}
func (r *RPCMismatchError) Error() string {
return fmt.Sprintf("RPC Mismatch: Expected version between %d and %d.", r.Low, r.High)
}
// MarshalBinary sends the specific rpc mismatch range
func (r *RPCMismatchError) MarshalBinary() (data []byte, err error) {
var resp [8]byte
binary.LittleEndian.PutUint32(resp[0:4], uint32(r.Low))
binary.LittleEndian.PutUint32(resp[4:8], uint32(r.High))
return resp[:], nil
}
// ResponseCodeProcUnavailableError is an RPCError
type ResponseCodeProcUnavailableError struct {
}
// Code for ResponseCodeProcUnavailableError
func (r *ResponseCodeProcUnavailableError) Code() ResponseCode {
return ResponseCodeProcUnavailable
}
func (r *ResponseCodeProcUnavailableError) Error() string {
return "The requested procedure is unexported"
}
// MarshalBinary - this error has no associated body
func (r *ResponseCodeProcUnavailableError) MarshalBinary() (data []byte, err error) {
return []byte{}, nil
}
// ResponseCodeSystemError is an RPCError
type ResponseCodeSystemError struct {
}
// Code for ResponseCodeSystemError
func (r *ResponseCodeSystemError) Code() ResponseCode {
return ResponseCodeSystemErr
}
func (r *ResponseCodeSystemError) Error() string {
return "memory allocation failure"
}
// MarshalBinary - this error has no associated body
func (r *ResponseCodeSystemError) MarshalBinary() (data []byte, err error) {
return []byte{}, nil
}
// basicErrorFormatter is the default error handler for response errors.
// if the error is already formatted, it is directly written. Otherwise,
// ResponseCodeSystemError is sent to the client.
func basicErrorFormatter(err error) RPCError {
var rpcErr RPCError
if errors.As(err, &rpcErr) {
return rpcErr
}
return &ResponseCodeSystemError{}
}
// NFSStatusError represents an error at the NFS level.
type NFSStatusError struct {
NFSStatus
WrappedErr error
}
// Error is The wrapped error
func (s *NFSStatusError) Error() string {
message := s.NFSStatus.String()
if s.WrappedErr != nil {
message = fmt.Sprintf("%s: %v", message, s.WrappedErr)
}
return message
}
// Code for NFS issues are successful RPC responses
func (s *NFSStatusError) Code() ResponseCode {
return ResponseCodeSuccess
}
// MarshalBinary - The binary form of the code.
func (s *NFSStatusError) MarshalBinary() (data []byte, err error) {
var resp [4]byte
binary.BigEndian.PutUint32(resp[0:4], uint32(s.NFSStatus))
return resp[:], nil
}
// Unwrap unpacks wrapped errors
func (s *NFSStatusError) Unwrap() error {
return s.WrappedErr
}
// StatusErrorWithBody is an NFS error with a payload.
type StatusErrorWithBody struct {
NFSStatusError
Body []byte
}
// MarshalBinary provides the wire format of the error response
func (s *StatusErrorWithBody) MarshalBinary() (data []byte, err error) {
head, err := s.NFSStatusError.MarshalBinary()
return append(head, s.Body...), err
}
// errFormatterWithBody appends a provided body to errors
func errFormatterWithBody(body []byte) func(err error) RPCError {
return func(err error) RPCError {
if nerr, ok := err.(*NFSStatusError); ok {
return &StatusErrorWithBody{*nerr, body[:]}
}
var rErr RPCError
if errors.As(err, &rErr) {
return rErr
}
return &ResponseCodeSystemError{}
}
}
var (
opAttrErrorBody = [4]byte{}
opAttrErrorFormatter = errFormatterWithBody(opAttrErrorBody[:])
wccDataErrorBody = [8]byte{}
wccDataErrorFormatter = errFormatterWithBody(wccDataErrorBody[:])
)

View file

@ -0,0 +1,52 @@
package main
import (
"context"
"fmt"
"net"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/memfs"
nfs "git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs"
"git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs/helpers"
nfshelper "git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs/helpers"
)
// ROFS is an intercepter for the filesystem indicating it should
// be read only. The undelrying billy.Memfs indicates it supports
// writing, but does not in implement billy.Change to support
// modification of permissions / modTimes, and as such cannot be
// used as RW system.
type ROFS struct {
nfs.Filesystem
}
// Capabilities exports the filesystem as readonly
func (ROFS) Capabilities() billy.Capability {
return billy.ReadCapability | billy.SeekCapability
}
func main() {
ctx := context.Background()
listener, err := net.Listen("tcp", ":0")
if err != nil {
fmt.Printf("Failed to listen: %v\n", err)
return
}
fmt.Printf("Server running at %s\n", listener.Addr())
mem := helpers.WrapBillyFS(memfs.New())
f, err := mem.Create(ctx, "hello.txt")
if err != nil {
fmt.Printf("Failed to create file: %v\n", err)
return
}
_, _ = f.Write(ctx, []byte("hello world"))
_ = f.Close(ctx)
handler := nfshelper.NewNullAuthHandler(ROFS{mem})
cacheHelper := nfshelper.NewCachingHandler(handler, 1024)
fmt.Printf("%v", nfs.Serve(listener, cacheHelper))
}

View file

@ -0,0 +1,38 @@
package main
import (
"os"
"time"
"github.com/go-git/go-billy/v5"
)
// NewChangeOSFS wraps billy osfs to add the change interface
func NewChangeOSFS(fs billy.Filesystem) billy.Filesystem {
return COS{fs}
}
// COS or OSFS + Change wraps a billy.FS to not fail the `Change` interface.
type COS struct {
billy.Filesystem
}
// Chmod changes mode
func (fs COS) Chmod(name string, mode os.FileMode) error {
return os.Chmod(fs.Join(fs.Root(), name), mode)
}
// Lchown changes ownership
func (fs COS) Lchown(name string, uid, gid int) error {
return os.Lchown(fs.Join(fs.Root(), name), uid, gid)
}
// Chown changes ownership
func (fs COS) Chown(name string, uid, gid int) error {
return os.Chown(fs.Join(fs.Root(), name), uid, gid)
}
// Chtimes changes access time
func (fs COS) Chtimes(name string, atime time.Time, mtime time.Time) error {
return os.Chtimes(fs.Join(fs.Root(), name), atime, mtime)
}

View file

@ -0,0 +1,28 @@
//go:build darwin || dragonfly || freebsd || linux || nacl || netbsd || openbsd || solaris
package main
import (
"golang.org/x/sys/unix"
)
func (fs COS) Mknod(path string, mode uint32, major uint32, minor uint32) error {
dev := unix.Mkdev(major, minor)
return unix.Mknod(fs.Join(fs.Root(), path), mode, int(dev))
}
func (fs COS) Mkfifo(path string, mode uint32) error {
return unix.Mkfifo(fs.Join(fs.Root(), path), mode)
}
func (fs COS) Link(path string, link string) error {
return unix.Link(fs.Join(fs.Root(), path), link)
}
func (fs COS) Socket(path string) error {
fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM, 0)
if err != nil {
return err
}
return unix.Bind(fd, &unix.SockaddrUnix{Name: fs.Join(fs.Root(), path)})
}

View file

@ -0,0 +1,36 @@
package main
import (
"fmt"
"net"
"os"
nfs "git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs"
"git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs/helpers"
nfshelper "git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs/helpers"
osfs "github.com/go-git/go-billy/v5/osfs"
)
func main() {
port := ""
if len(os.Args) < 2 {
fmt.Printf("Usage: osnfs </path/to/folder> [port]\n")
return
} else if len(os.Args) == 3 {
port = os.Args[2]
}
listener, err := net.Listen("tcp", ":"+port)
if err != nil {
fmt.Printf("Failed to listen: %v\n", err)
return
}
fmt.Printf("osnfs server running at %s\n", listener.Addr())
bfs := osfs.New(os.Args[1])
bfsPlusChange := helpers.WrapBillyFS(NewChangeOSFS(bfs))
handler := nfshelper.NewNullAuthHandler(bfsPlusChange)
cacheHelper := nfshelper.NewCachingHandler(handler, 1024)
fmt.Printf("%v", nfs.Serve(listener, cacheHelper))
}

View file

@ -0,0 +1,37 @@
package main
import (
"fmt"
"net"
"os"
"github.com/willscott/memphis"
nfs "git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs"
"git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs/helpers"
nfshelper "git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs/helpers"
)
func main() {
port := ""
if len(os.Args) < 2 {
fmt.Printf("Usage: osview </path/to/folder> [port]\n")
return
} else if len(os.Args) == 3 {
port = os.Args[2]
}
listener, err := net.Listen("tcp", ":"+port)
if err != nil {
fmt.Printf("Failed to listen: %v\n", err)
return
}
fmt.Printf("Server running at %s\n", listener.Addr())
fs := memphis.FromOS(os.Args[1])
bfs := helpers.WrapBillyFS(fs.AsBillyFS(0, 0))
handler := nfshelper.NewNullAuthHandler(bfs)
cacheHelper := nfshelper.NewCachingHandler(handler, 1024)
fmt.Printf("%v", nfs.Serve(listener, cacheHelper))
}

379
server/pkg/go-nfs/file.go Normal file
View file

@ -0,0 +1,379 @@
package nfs
import (
"context"
"errors"
"hash/fnv"
"io"
"math"
"os"
"time"
"git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs/file"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
// FileAttribute holds metadata about a filesystem object
type FileAttribute struct {
Type FileType
FileMode uint32
Nlink uint32
UID uint32
GID uint32
Filesize uint64
Used uint64
SpecData [2]uint32
FSID uint64
Fileid uint64
Atime, Mtime, Ctime FileTime
}
// FileType represents a NFS File Type
type FileType uint32
// Enumeration of NFS FileTypes
const (
FileTypeRegular FileType = iota + 1
FileTypeDirectory
FileTypeBlock
FileTypeCharacter
FileTypeLink
FileTypeSocket
FileTypeFIFO
)
func (f FileType) String() string {
switch f {
case FileTypeRegular:
return "Regular"
case FileTypeDirectory:
return "Directory"
case FileTypeBlock:
return "Block Device"
case FileTypeCharacter:
return "Character Device"
case FileTypeLink:
return "Symbolic Link"
case FileTypeSocket:
return "Socket"
case FileTypeFIFO:
return "FIFO"
default:
return "Unknown"
}
}
// Mode provides the OS interpreted mode of the file attributes
func (f *FileAttribute) Mode() os.FileMode {
return os.FileMode(f.FileMode)
}
// FileCacheAttribute is the subset of FileAttribute used by
// wcc_attr
type FileCacheAttribute struct {
Filesize uint64
Mtime, Ctime FileTime
}
// AsCache provides the wcc view of the file attributes
func (f FileAttribute) AsCache() *FileCacheAttribute {
wcc := FileCacheAttribute{
Filesize: f.Filesize,
Mtime: f.Mtime,
Ctime: f.Ctime,
}
return &wcc
}
// ToFileAttribute creates an NFS fattr3 struct from an OS.FileInfo
func ToFileAttribute(info os.FileInfo, filePath string) *FileAttribute {
f := FileAttribute{}
m := info.Mode()
f.FileMode = uint32(m)
if info.IsDir() {
f.Type = FileTypeDirectory
} else if m&os.ModeSymlink != 0 {
f.Type = FileTypeLink
} else if m&os.ModeCharDevice != 0 {
f.Type = FileTypeCharacter
} else if m&os.ModeDevice != 0 {
f.Type = FileTypeBlock
} else if m&os.ModeSocket != 0 {
f.Type = FileTypeSocket
} else if m&os.ModeNamedPipe != 0 {
f.Type = FileTypeFIFO
} else {
f.Type = FileTypeRegular
}
// The number of hard links to the file.
f.Nlink = 1
if a := file.GetInfo(info); a != nil {
f.Nlink = a.Nlink
f.UID = a.UID
f.GID = a.GID
f.SpecData = [2]uint32{a.Major, a.Minor}
f.Fileid = a.Fileid
} else {
hasher := fnv.New64()
_, _ = hasher.Write([]byte(filePath))
f.Fileid = hasher.Sum64()
}
f.Filesize = uint64(info.Size())
f.Used = uint64(info.Size())
f.Atime = ToNFSTime(info.ModTime())
f.Mtime = f.Atime
f.Ctime = f.Atime
return &f
}
// tryStat attempts to create a FileAttribute from a path.
func tryStat(ctx context.Context, fs Filesystem, path []string) *FileAttribute {
fullPath := fs.Join(path...)
attrs, err := fs.Lstat(ctx, fullPath)
if err != nil || attrs == nil {
Log.Errorf("err loading attrs for %s: %v", fs.Join(path...), err)
return nil
}
return ToFileAttribute(attrs, fullPath)
}
// WriteWcc writes the `wcc_data` representation of an object.
func WriteWcc(writer io.Writer, pre *FileCacheAttribute, post *FileAttribute) error {
if pre == nil {
if err := xdr.Write(writer, uint32(0)); err != nil {
return err
}
} else {
if err := xdr.Write(writer, uint32(1)); err != nil {
return err
}
if err := xdr.Write(writer, *pre); err != nil {
return err
}
}
if post == nil {
if err := xdr.Write(writer, uint32(0)); err != nil {
return err
}
} else {
if err := xdr.Write(writer, uint32(1)); err != nil {
return err
}
if err := xdr.Write(writer, *post); err != nil {
return err
}
}
return nil
}
// WritePostOpAttrs writes the `post_op_attr` representation of a files attributes
func WritePostOpAttrs(writer io.Writer, post *FileAttribute) error {
if post == nil {
if err := xdr.Write(writer, uint32(0)); err != nil {
return err
}
} else {
if err := xdr.Write(writer, uint32(1)); err != nil {
return err
}
if err := xdr.Write(writer, *post); err != nil {
return err
}
}
return nil
}
// SetFileAttributes represents a command to update some metadata
// about a file.
type SetFileAttributes struct {
SetMode *uint32
SetUID *uint32
SetGID *uint32
SetSize *uint64
SetAtime *time.Time
SetMtime *time.Time
}
// Apply uses a `Change` implementation to set defined attributes on a
// provided file.
func (s *SetFileAttributes) Apply(ctx context.Context, changer Change, fs Filesystem, file string) error {
curOS, err := fs.Lstat(ctx, file)
if errors.Is(err, os.ErrNotExist) {
return &NFSStatusError{NFSStatusNoEnt, os.ErrNotExist}
} else if errors.Is(err, os.ErrPermission) {
return &NFSStatusError{NFSStatusAccess, os.ErrPermission}
} else if err != nil {
return nil
}
curr := ToFileAttribute(curOS, file)
if s.SetMode != nil {
mode := os.FileMode(*s.SetMode) & os.ModePerm
if mode != curr.Mode().Perm() {
if changer == nil {
return &NFSStatusError{NFSStatusNotSupp, os.ErrPermission}
}
if err := changer.Chmod(ctx, file, mode); err != nil {
if errors.Is(err, os.ErrPermission) {
return &NFSStatusError{NFSStatusAccess, os.ErrPermission}
}
return err
}
}
}
if s.SetUID != nil || s.SetGID != nil {
euid := curr.UID
if s.SetUID != nil {
euid = *s.SetUID
}
egid := curr.GID
if s.SetGID != nil {
egid = *s.SetGID
}
if euid != curr.UID || egid != curr.GID {
if changer == nil {
return &NFSStatusError{NFSStatusNotSupp, os.ErrPermission}
}
if err := changer.Lchown(ctx, file, int(euid), int(egid)); err != nil {
if errors.Is(err, os.ErrPermission) {
return &NFSStatusError{NFSStatusAccess, os.ErrPermission}
}
return err
}
}
}
if s.SetSize != nil {
if curr.Mode()&os.ModeSymlink != 0 {
return &NFSStatusError{NFSStatusNotSupp, os.ErrInvalid}
}
fp, err := fs.OpenFile(ctx, file, os.O_WRONLY|os.O_EXCL, 0)
if errors.Is(err, os.ErrPermission) {
return &NFSStatusError{NFSStatusAccess, err}
} else if err != nil {
return err
}
defer fp.Close(ctx)
if *s.SetSize > math.MaxInt64 {
return &NFSStatusError{NFSStatusInval, os.ErrInvalid}
}
if err := fp.Truncate(ctx, int64(*s.SetSize)); err != nil {
return err
}
if err := fp.Close(ctx); err != nil {
return err
}
}
if s.SetAtime != nil || s.SetMtime != nil {
atime := curr.Atime.Native()
if s.SetAtime != nil {
atime = s.SetAtime
}
mtime := curr.Mtime.Native()
if s.SetMtime != nil {
mtime = s.SetMtime
}
if atime != curr.Atime.Native() || mtime != curr.Mtime.Native() {
if changer == nil {
return &NFSStatusError{NFSStatusNotSupp, os.ErrPermission}
}
if err := changer.Chtimes(ctx, file, *atime, *mtime); err != nil {
if errors.Is(err, os.ErrPermission) {
return &NFSStatusError{NFSStatusAccess, err}
}
return err
}
}
}
return nil
}
// Mode returns a mode if specified or the provided default mode.
func (s *SetFileAttributes) Mode(def os.FileMode) os.FileMode {
if s.SetMode != nil {
return os.FileMode(*s.SetMode) & os.ModePerm
}
return def
}
// ReadSetFileAttributes reads an sattr3 xdr stream into a go struct.
func ReadSetFileAttributes(r io.Reader) (*SetFileAttributes, error) {
attrs := SetFileAttributes{}
hasMode, err := xdr.ReadUint32(r)
if err != nil {
return nil, err
}
if hasMode != 0 {
mode, err := xdr.ReadUint32(r)
if err != nil {
return nil, err
}
attrs.SetMode = &mode
}
hasUID, err := xdr.ReadUint32(r)
if err != nil {
return nil, err
}
if hasUID != 0 {
uid, err := xdr.ReadUint32(r)
if err != nil {
return nil, err
}
attrs.SetUID = &uid
}
hasGID, err := xdr.ReadUint32(r)
if err != nil {
return nil, err
}
if hasGID != 0 {
gid, err := xdr.ReadUint32(r)
if err != nil {
return nil, err
}
attrs.SetGID = &gid
}
hasSize, err := xdr.ReadUint32(r)
if err != nil {
return nil, err
}
if hasSize != 0 {
var size uint64
attrs.SetSize = &size
if err := xdr.Read(r, &size); err != nil {
return nil, err
}
}
aTime, err := xdr.ReadUint32(r)
if err != nil {
return nil, err
}
if aTime == 1 {
now := time.Now()
attrs.SetAtime = &now
} else if aTime == 2 {
t := FileTime{}
if err := xdr.Read(r, &t); err != nil {
return nil, err
}
attrs.SetAtime = t.Native()
}
mTime, err := xdr.ReadUint32(r)
if err != nil {
return nil, err
}
if mTime == 1 {
now := time.Now()
attrs.SetMtime = &now
} else if mTime == 2 {
t := FileTime{}
if err := xdr.Read(r, &t); err != nil {
return nil, err
}
attrs.SetMtime = t.Native()
}
return &attrs, nil
}

View file

@ -0,0 +1,17 @@
package file
import "os"
type FileInfo struct {
Nlink uint32
UID uint32
GID uint32
Major uint32
Minor uint32
Fileid uint64
}
// GetInfo extracts some non-standardized items from the result of a Stat call.
func GetInfo(fi os.FileInfo) *FileInfo {
return getInfo(fi)
}

View file

@ -0,0 +1,24 @@
//go:build darwin || dragonfly || freebsd || linux || nacl || netbsd || openbsd || solaris
package file
import (
"os"
"syscall"
"golang.org/x/sys/unix"
)
func getInfo(info os.FileInfo) *FileInfo {
fi := &FileInfo{}
if s, ok := info.Sys().(*syscall.Stat_t); ok {
fi.Nlink = uint32(s.Nlink)
fi.UID = s.Uid
fi.GID = s.Gid
fi.Major = unix.Major(uint64(s.Rdev))
fi.Minor = unix.Minor(uint64(s.Rdev))
fi.Fileid = s.Ino
return fi
}
return nil
}

View file

@ -0,0 +1,12 @@
//go:build windows
package file
import "os"
func getInfo(info os.FileInfo) *FileInfo {
// https://godoc.org/golang.org/x/sys/windows#GetFileInformationByHandle
// can be potentially used to populate Nlink
return nil
}

View file

@ -0,0 +1,97 @@
package nfs
import (
"context"
"io"
"os"
"time"
"github.com/royalcat/ctxio"
)
// FSStat returns metadata about a file system
type FSStat struct {
TotalSize uint64
FreeSize uint64
AvailableSize uint64
TotalFiles uint64
FreeFiles uint64
AvailableFiles uint64
// CacheHint is called "invarsec" in the nfs standard
CacheHint time.Duration
}
type Filesystem interface {
// Create creates the named file with mode 0666 (before umask), truncating
// it if it already exists. If successful, methods on the returned File can
// be used for I/O; the associated file descriptor has mode O_RDWR.
Create(ctx context.Context, filename string) (File, error)
// OpenFile is the generalized open call; most users will use Open or Create
// instead. It opens the named file with specified flag (O_RDONLY etc.) and
// perm, (0666 etc.) if applicable. If successful, methods on the returned
// File can be used for I/O.
OpenFile(ctx context.Context, filename string, flag int, perm os.FileMode) (File, error)
// Stat returns a FileInfo describing the named file.
Stat(ctx context.Context, filename string) (os.FileInfo, error)
// Rename renames (moves) oldpath to newpath. If newpath already exists and
// is not a directory, Rename replaces it. OS-specific restrictions may
// apply when oldpath and newpath are in different directories.
Rename(ctx context.Context, oldpath, newpath string) error
// Remove removes the named file or directory.
Remove(ctx context.Context, filename string) error
// Join joins any number of path elements into a single path, adding a
// Separator if necessary. Join calls filepath.Clean on the result; in
// particular, all empty strings are ignored. On Windows, the result is a
// UNC path if and only if the first path element is a UNC path.
Join(elem ...string) string
// ReadDir reads the directory named by d(irname and returns a list of
// directory entries sorted by filename.
ReadDir(ctx context.Context, path string) ([]os.FileInfo, error)
// MkdirAll creates a directory named path, along with any necessary
// parents, and returns nil, or else returns an error. The permission bits
// perm are used for all directories that MkdirAll creates. If path is/
// already a directory, MkdirAll does nothing and returns nil.
MkdirAll(ctx context.Context, filename string, perm os.FileMode) error
// Lstat returns a FileInfo describing the named file. If the file is a
// symbolic link, the returned FileInfo describes the symbolic link. Lstat
// makes no attempt to follow the link.
Lstat(ctx context.Context, filename string) (os.FileInfo, error)
// Symlink creates a symbolic-link from link to target. target may be an
// absolute or relative path, and need not refer to an existing node.
// Parent directories of link are created as necessary.
Symlink(ctx context.Context, target, link string) error
// Readlink returns the target path of link.
Readlink(ctx context.Context, link string) (string, error)
}
type File interface {
// Name returns the name of the file as presented to Open.
Name() string
ctxio.Writer
// ctxio.Reader
ctxio.ReaderAt
io.Seeker
ctxio.Closer
// Truncate the file.
Truncate(ctx context.Context, size int64) error
}
// Change abstract the FileInfo change related operations in a storage-agnostic
// interface as an extension to the Basic interface
type Change interface {
// Chmod changes the mode of the named file to mode. If the file is a
// symbolic link, it changes the mode of the link's target.
Chmod(ctx context.Context, name string, mode os.FileMode) error
// Lchown changes the numeric uid and gid of the named file. If the file is
// a symbolic link, it changes the uid and gid of the link itself.
Lchown(ctx context.Context, name string, uid, gid int) error
// Chtimes changes the access and modification times of the named file,
// similar to the Unix utime() or utimes() functions.
//
// The underlying filesystem may truncate or round the values to a less
// precise time unit.
Chtimes(ctx context.Context, name string, atime time.Time, mtime time.Time) error
}

View file

@ -0,0 +1,52 @@
package nfs
import (
"context"
"io/fs"
"net"
"git.kmsign.ru/royalcat/tstor/server/pkg/ctxbilly"
)
// Handler represents the interface of the file system / vfs being exposed over NFS
type Handler interface {
// Required methods
Mount(context.Context, net.Conn, MountRequest) (MountStatus, Filesystem, []AuthFlavor)
// Change can return 'nil' if filesystem is read-only
// If the returned value can be cast to `UnixChange`, mknod and link RPCs will be available.
Change(Filesystem) Change
// Optional methods - generic helpers or trivial implementations can be sufficient depending on use case.
// Fill in information about a file system's free space.
FSStat(context.Context, Filesystem, *FSStat) error
// represent file objects as opaque references
// Can be safely implemented via helpers/cachinghandler.
ToHandle(ctx context.Context, fs Filesystem, path []string) []byte
FromHandle(ctx context.Context, fh []byte) (Filesystem, []string, error)
InvalidateHandle(context.Context, Filesystem, []byte) error
// How many handles can be safely maintained by the handler.
HandleLimit() int
}
// UnixChange extends the billy `Change` interface with support for special files.
type UnixChange interface {
ctxbilly.Change
Mknod(ctx context.Context, path string, mode uint32, major uint32, minor uint32) error
Mkfifo(ctx context.Context, path string, mode uint32) error
Socket(ctx context.Context, path string) error
Link(ctx context.Context, path string, link string) error
}
// CachingHandler represents the optional caching work that a user may wish to over-ride with
// their own implementations, but which can be otherwise provided through defaults.
type CachingHandler interface {
VerifierFor(path string, contents []fs.FileInfo) uint64
// fs.FileInfo needs to be sorted by Name(), nil in case of a cache-miss
DataForVerifier(path string, verifier uint64) []fs.FileInfo
}

View file

@ -0,0 +1,157 @@
package helpers
import (
"context"
"io/fs"
"git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs"
"github.com/go-git/go-billy/v5"
)
func WrapBillyFS(bf billy.Filesystem) nfs.Filesystem {
return &wrapFS{
Filesystem: bf,
}
}
type wrapFS struct {
billy.Filesystem
}
var _ nfs.Filesystem = (*wrapFS)(nil)
// Create implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Create of MemFS.Filesystem.
func (m *wrapFS) Create(ctx context.Context, filename string) (nfs.File, error) {
bf, err := m.Filesystem.Create(filename)
if err != nil {
return nil, err
}
return &wrapFile{bf}, nil
}
// Lstat implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Lstat of MemFS.Filesystem.
func (m *wrapFS) Lstat(ctx context.Context, filename string) (fs.FileInfo, error) {
return m.Filesystem.Lstat(filename)
}
// MkdirAll implements Filesystem.
// Subtle: this method shadows the method (Filesystem).MkdirAll of MemFS.Filesystem.
func (m *wrapFS) MkdirAll(ctx context.Context, filename string, perm fs.FileMode) error {
return m.Filesystem.MkdirAll(filename, perm)
}
// Open implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Open of MemFS.Filesystem.
func (m *wrapFS) Open(ctx context.Context, filename string) (nfs.File, error) {
bf, err := m.Filesystem.Open(filename)
if err != nil {
return nil, err
}
return WrapFile(bf), nil
}
// OpenFile implements Filesystem.
// Subtle: this method shadows the method (Filesystem).OpenFile of MemFS.Filesystem.
func (m *wrapFS) OpenFile(ctx context.Context, filename string, flag int, perm fs.FileMode) (nfs.File, error) {
bf, err := m.Filesystem.OpenFile(filename, flag, perm)
if err != nil {
return nil, err
}
return WrapFile(bf), nil
}
// ReadDir implements Filesystem.
// Subtle: this method shadows the method (Filesystem).ReadDir of MemFS.Filesystem.
func (m *wrapFS) ReadDir(ctx context.Context, path string) ([]fs.FileInfo, error) {
return m.Filesystem.ReadDir(path)
}
// Readlink implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Readlink of MemFS.Filesystem.
func (m *wrapFS) Readlink(ctx context.Context, link string) (string, error) {
return m.Filesystem.Readlink(link)
}
// Remove implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Remove of MemFS.Filesystem.
func (m *wrapFS) Remove(ctx context.Context, filename string) error {
return m.Filesystem.Remove(filename)
}
// Rename implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Rename of MemFS.Filesystem.
func (m *wrapFS) Rename(ctx context.Context, oldpath string, newpath string) error {
return m.Filesystem.Rename(oldpath, newpath)
}
// Stat implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Stat of MemFS.Filesystem.
func (m *wrapFS) Stat(ctx context.Context, filename string) (fs.FileInfo, error) {
return m.Filesystem.Stat(filename)
}
// Symlink implements Filesystem.
// Subtle: this method shadows the method (Filesystem).Symlink of MemFS.Filesystem.
func (m *wrapFS) Symlink(ctx context.Context, target string, link string) error {
return m.Filesystem.Symlink(target, link)
}
func WrapFile(bf billy.File) nfs.File {
return &wrapFile{File: bf}
}
type wrapFile struct {
billy.File
}
var _ nfs.File = (*wrapFile)(nil)
// Close implements File.
// Subtle: this method shadows the method (File).Close of MemFile.File.
func (m *wrapFile) Close(ctx context.Context) error {
return m.File.Close()
}
// Lock implements File.
// Subtle: this method shadows the method (File).Lock of MemFile.File.
func (m *wrapFile) Lock() error {
return m.File.Lock()
}
// Name implements File.
// Subtle: this method shadows the method (File).Name of MemFile.File.
func (m *wrapFile) Name() string {
return m.File.Name()
}
// Truncate implements File.
// Subtle: this method shadows the method (File).Truncate of memFile.File.
func (m *wrapFile) Truncate(ctx context.Context, size int64) error {
return m.File.Truncate(size)
}
// Read implements File.
// Subtle: this method shadows the method (File).Read of MemFile.File.
func (m *wrapFile) Read(ctx context.Context, p []byte) (n int, err error) {
return m.File.Read(p)
}
// ReadAt implements File.
// Subtle: this method shadows the method (File).ReadAt of MemFile.File.
func (m *wrapFile) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) {
return m.File.ReadAt(p, off)
}
// Unlock implements File.
// Subtle: this method shadows the method (File).Unlock of MemFile.File.
func (m *wrapFile) Unlock() error {
return m.File.Unlock()
}
// Write implements File.
// Subtle: this method shadows the method (File).Write of MemFile.File.
func (m *wrapFile) Write(ctx context.Context, p []byte) (n int, err error) {
return m.File.Write(p)
}

View file

@ -0,0 +1,199 @@
package helpers
import (
"context"
"crypto/sha256"
"encoding/binary"
"io/fs"
"reflect"
"git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs"
"github.com/google/uuid"
lru "github.com/hashicorp/golang-lru/v2"
)
// NewCachingHandler wraps a handler to provide a basic to/from-file handle cache.
func NewCachingHandler(h nfs.Handler, limit int) nfs.Handler {
return NewCachingHandlerWithVerifierLimit(h, limit, limit)
}
// NewCachingHandlerWithVerifierLimit provides a basic to/from-file handle cache that can be tuned with a smaller cache of active directory listings.
func NewCachingHandlerWithVerifierLimit(h nfs.Handler, limit int, verifierLimit int) nfs.Handler {
if limit < 2 || verifierLimit < 2 {
nfs.Log.Warnf("Caching handler created with insufficient cache to support directory listing", "size", limit, "verifiers", verifierLimit)
}
cache, _ := lru.New[uuid.UUID, entry](limit)
reverseCache := make(map[string][]uuid.UUID)
verifiers, _ := lru.New[uint64, verifier](verifierLimit)
return &CachingHandler{
Handler: h,
activeHandles: cache,
reverseHandles: reverseCache,
activeVerifiers: verifiers,
cacheLimit: limit,
}
}
// CachingHandler implements to/from handle via an LRU cache.
type CachingHandler struct {
nfs.Handler
activeHandles *lru.Cache[uuid.UUID, entry]
reverseHandles map[string][]uuid.UUID
activeVerifiers *lru.Cache[uint64, verifier]
cacheLimit int
}
type entry struct {
f nfs.Filesystem
p []string
}
// ToHandle takes a file and represents it with an opaque handle to reference it.
// In stateless nfs (when it's serving a unix fs) this can be the device + inode
// but we can generalize with a stateful local cache of handed out IDs.
func (c *CachingHandler) ToHandle(ctx context.Context, f nfs.Filesystem, path []string) []byte {
joinedPath := f.Join(path...)
if handle := c.searchReverseCache(f, joinedPath); handle != nil {
return handle
}
id := uuid.New()
newPath := make([]string, len(path))
copy(newPath, path)
evictedKey, evictedPath, ok := c.activeHandles.GetOldest()
if evicted := c.activeHandles.Add(id, entry{f, newPath}); evicted && ok {
rk := evictedPath.f.Join(evictedPath.p...)
c.evictReverseCache(rk, evictedKey)
}
if _, ok := c.reverseHandles[joinedPath]; !ok {
c.reverseHandles[joinedPath] = []uuid.UUID{}
}
c.reverseHandles[joinedPath] = append(c.reverseHandles[joinedPath], id)
b, _ := id.MarshalBinary()
return b
}
// FromHandle converts from an opaque handle to the file it represents
func (c *CachingHandler) FromHandle(ctx context.Context, fh []byte) (nfs.Filesystem, []string, error) {
id, err := uuid.FromBytes(fh)
if err != nil {
return nil, []string{}, err
}
if f, ok := c.activeHandles.Get(id); ok {
for _, k := range c.activeHandles.Keys() {
candidate, _ := c.activeHandles.Peek(k)
if hasPrefix(f.p, candidate.p) {
_, _ = c.activeHandles.Get(k)
}
}
if ok {
newP := make([]string, len(f.p))
copy(newP, f.p)
return f.f, newP, nil
}
}
return nil, []string{}, &nfs.NFSStatusError{NFSStatus: nfs.NFSStatusStale}
}
func (c *CachingHandler) searchReverseCache(f nfs.Filesystem, path string) []byte {
uuids, exists := c.reverseHandles[path]
if !exists {
return nil
}
for _, id := range uuids {
if candidate, ok := c.activeHandles.Get(id); ok {
if reflect.DeepEqual(candidate.f, f) {
return id[:]
}
}
}
return nil
}
func (c *CachingHandler) evictReverseCache(path string, handle uuid.UUID) {
uuids, exists := c.reverseHandles[path]
if !exists {
return
}
for i, u := range uuids {
if u == handle {
uuids = append(uuids[:i], uuids[i+1:]...)
c.reverseHandles[path] = uuids
return
}
}
}
func (c *CachingHandler) InvalidateHandle(ctx context.Context, fs nfs.Filesystem, handle []byte) error {
//Remove from cache
id, _ := uuid.FromBytes(handle)
entry, ok := c.activeHandles.Get(id)
if ok {
rk := entry.f.Join(entry.p...)
c.evictReverseCache(rk, id)
}
c.activeHandles.Remove(id)
return nil
}
// HandleLimit exports how many file handles can be safely stored by this cache.
func (c *CachingHandler) HandleLimit() int {
return c.cacheLimit
}
func hasPrefix(path, prefix []string) bool {
if len(prefix) > len(path) {
return false
}
for i, e := range prefix {
if path[i] != e {
return false
}
}
return true
}
type verifier struct {
path string
contents []fs.FileInfo
}
func hashPathAndContents(path string, contents []fs.FileInfo) uint64 {
//calculate a cookie-verifier.
vHash := sha256.New()
// Add the path to avoid collisions of directories with the same content
vHash.Write(binary.BigEndian.AppendUint64([]byte{}, uint64(len(path))))
vHash.Write([]byte(path))
for _, c := range contents {
vHash.Write([]byte(c.Name())) // Never fails according to the docs
}
verify := vHash.Sum(nil)[0:8]
return binary.BigEndian.Uint64(verify)
}
func (c *CachingHandler) VerifierFor(path string, contents []fs.FileInfo) uint64 {
id := hashPathAndContents(path, contents)
c.activeVerifiers.Add(id, verifier{path, contents})
return id
}
func (c *CachingHandler) DataForVerifier(path string, id uint64) []fs.FileInfo {
if cache, ok := c.activeVerifiers.Get(id); ok {
return cache.contents
}
return nil
}

View file

@ -0,0 +1,414 @@
// Package memfs is a variant of "github.com/go-git/go-billy/v5/memfs" with
// stable mtimes for items.
package memfs
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/helper/chroot"
"github.com/go-git/go-billy/v5/util"
)
const separator = filepath.Separator
// Memory a very convenient filesystem based on memory files
type Memory struct {
s *storage
}
// New returns a new Memory filesystem.
func New() billy.Filesystem {
fs := &Memory{s: newStorage()}
return chroot.New(fs, string(separator))
}
func (fs *Memory) Create(filename string) (billy.File, error) {
return fs.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
}
func (fs *Memory) Open(filename string) (billy.File, error) {
return fs.OpenFile(filename, os.O_RDONLY, 0)
}
func (fs *Memory) OpenFile(filename string, flag int, perm os.FileMode) (billy.File, error) {
f, has := fs.s.Get(filename)
if !has {
if !isCreate(flag) {
return nil, os.ErrNotExist
}
var err error
f, err = fs.s.New(filename, perm, flag)
if err != nil {
return nil, err
}
} else {
if isExclusive(flag) {
return nil, os.ErrExist
}
if target, isLink := fs.resolveLink(filename, f); isLink {
return fs.OpenFile(target, flag, perm)
}
}
if f.mode.IsDir() {
return nil, fmt.Errorf("cannot open directory: %s", filename)
}
return f.Duplicate(filename, perm, flag), nil
}
func (fs *Memory) resolveLink(fullpath string, f *file) (target string, isLink bool) {
if !isSymlink(f.mode) {
return fullpath, false
}
target = string(f.content.bytes)
if !isAbs(target) {
target = fs.Join(filepath.Dir(fullpath), target)
}
return target, true
}
// On Windows OS, IsAbs validates if a path is valid based on if stars with a
// unit (eg.: `C:\`) to assert that is absolute, but in this mem implementation
// any path starting by `separator` is also considered absolute.
func isAbs(path string) bool {
return filepath.IsAbs(path) || strings.HasPrefix(path, string(separator))
}
func (fs *Memory) Stat(filename string) (os.FileInfo, error) {
f, has := fs.s.Get(filename)
if !has {
return nil, os.ErrNotExist
}
fi, _ := f.Stat()
var err error
if target, isLink := fs.resolveLink(filename, f); isLink {
fi, err = fs.Stat(target)
if err != nil {
return nil, err
}
}
// the name of the file should always the name of the stated file, so we
// overwrite the Stat returned from the storage with it, since the
// filename may belong to a link.
fi.(*fileInfo).name = filepath.Base(filename)
return fi, nil
}
func (fs *Memory) Lstat(filename string) (os.FileInfo, error) {
f, has := fs.s.Get(filename)
if !has {
return nil, os.ErrNotExist
}
return f.Stat()
}
type ByName []os.FileInfo
func (a ByName) Len() int { return len(a) }
func (a ByName) Less(i, j int) bool { return a[i].Name() < a[j].Name() }
func (a ByName) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (fs *Memory) ReadDir(path string) ([]os.FileInfo, error) {
if f, has := fs.s.Get(path); has {
if target, isLink := fs.resolveLink(path, f); isLink {
return fs.ReadDir(target)
}
} else {
return nil, &os.PathError{Op: "open", Path: path, Err: syscall.ENOENT}
}
var entries []os.FileInfo
for _, f := range fs.s.Children(path) {
fi, _ := f.Stat()
entries = append(entries, fi)
}
sort.Sort(ByName(entries))
return entries, nil
}
func (fs *Memory) MkdirAll(path string, perm os.FileMode) error {
_, err := fs.s.New(path, perm|os.ModeDir, 0)
return err
}
func (fs *Memory) TempFile(dir, prefix string) (billy.File, error) {
return util.TempFile(fs, dir, prefix)
}
func (fs *Memory) Rename(from, to string) error {
return fs.s.Rename(from, to)
}
func (fs *Memory) Remove(filename string) error {
return fs.s.Remove(filename)
}
func (fs *Memory) Join(elem ...string) string {
return filepath.Join(elem...)
}
func (fs *Memory) Symlink(target, link string) error {
_, err := fs.Stat(link)
if err == nil {
return os.ErrExist
}
if !os.IsNotExist(err) {
return err
}
return util.WriteFile(fs, link, []byte(target), 0777|os.ModeSymlink)
}
func (fs *Memory) Readlink(link string) (string, error) {
f, has := fs.s.Get(link)
if !has {
return "", os.ErrNotExist
}
if !isSymlink(f.mode) {
return "", &os.PathError{
Op: "readlink",
Path: link,
Err: fmt.Errorf("not a symlink"),
}
}
return string(f.content.bytes), nil
}
// Capabilities implements the Capable interface.
func (fs *Memory) Capabilities() billy.Capability {
return billy.WriteCapability |
billy.ReadCapability |
billy.ReadAndWriteCapability |
billy.SeekCapability |
billy.TruncateCapability
}
type file struct {
name string
content *content
position int64
flag int
mode os.FileMode
mtime time.Time
isClosed bool
}
func (f *file) Name() string {
return f.name
}
func (f *file) Read(b []byte) (int, error) {
n, err := f.ReadAt(b, f.position)
f.position += int64(n)
if err == io.EOF && n != 0 {
err = nil
}
return n, err
}
func (f *file) ReadAt(b []byte, off int64) (int, error) {
if f.isClosed {
return 0, os.ErrClosed
}
if !isReadAndWrite(f.flag) && !isReadOnly(f.flag) {
return 0, errors.New("read not supported")
}
n, err := f.content.ReadAt(b, off)
return n, err
}
func (f *file) Seek(offset int64, whence int) (int64, error) {
if f.isClosed {
return 0, os.ErrClosed
}
switch whence {
case io.SeekCurrent:
f.position += offset
case io.SeekStart:
f.position = offset
case io.SeekEnd:
f.position = int64(f.content.Len()) + offset
}
return f.position, nil
}
func (f *file) Write(p []byte) (int, error) {
return f.WriteAt(p, f.position)
}
func (f *file) WriteAt(p []byte, off int64) (int, error) {
if f.isClosed {
return 0, os.ErrClosed
}
if !isReadAndWrite(f.flag) && !isWriteOnly(f.flag) {
return 0, errors.New("write not supported")
}
n, err := f.content.WriteAt(p, off)
f.position = off + int64(n)
f.mtime = time.Now()
return n, err
}
func (f *file) Close() error {
if f.isClosed {
return os.ErrClosed
}
f.isClosed = true
return nil
}
func (f *file) Truncate(size int64) error {
if size < int64(len(f.content.bytes)) {
f.content.bytes = f.content.bytes[:size]
} else if more := int(size) - len(f.content.bytes); more > 0 {
f.content.bytes = append(f.content.bytes, make([]byte, more)...)
}
f.mtime = time.Now()
return nil
}
func (f *file) Duplicate(filename string, mode os.FileMode, flag int) billy.File {
new := &file{
name: filename,
content: f.content,
mode: mode,
flag: flag,
mtime: time.Now(),
}
if isTruncate(flag) {
new.content.Truncate()
}
if isAppend(flag) {
new.position = int64(new.content.Len())
}
return new
}
func (f *file) Stat() (os.FileInfo, error) {
return &fileInfo{
name: f.Name(),
mode: f.mode,
size: f.content.Len(),
mtime: f.mtime,
}, nil
}
// Lock is a no-op in memfs.
func (f *file) Lock() error {
return nil
}
// Unlock is a no-op in memfs.
func (f *file) Unlock() error {
return nil
}
type fileInfo struct {
name string
size int
mode os.FileMode
mtime time.Time
}
func (fi *fileInfo) Name() string {
return fi.name
}
func (fi *fileInfo) Size() int64 {
return int64(fi.size)
}
func (fi *fileInfo) Mode() os.FileMode {
return fi.mode
}
func (fi *fileInfo) ModTime() time.Time {
return fi.mtime
}
func (fi *fileInfo) IsDir() bool {
return fi.mode.IsDir()
}
func (*fileInfo) Sys() interface{} {
return nil
}
func (c *content) Truncate() {
c.bytes = make([]byte, 0)
}
func (c *content) Len() int {
return len(c.bytes)
}
func isCreate(flag int) bool {
return flag&os.O_CREATE != 0
}
func isExclusive(flag int) bool {
return flag&os.O_EXCL != 0
}
func isAppend(flag int) bool {
return flag&os.O_APPEND != 0
}
func isTruncate(flag int) bool {
return flag&os.O_TRUNC != 0
}
func isReadAndWrite(flag int) bool {
return flag&os.O_RDWR != 0
}
func isReadOnly(flag int) bool {
return flag == os.O_RDONLY
}
func isWriteOnly(flag int) bool {
return flag&os.O_WRONLY != 0
}
func isSymlink(m os.FileMode) bool {
return m&os.ModeSymlink != 0
}

View file

@ -0,0 +1,243 @@
package memfs
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
type storage struct {
files map[string]*file
children map[string]map[string]*file
}
func newStorage() *storage {
return &storage{
files: make(map[string]*file, 0),
children: make(map[string]map[string]*file, 0),
}
}
func (s *storage) Has(path string) bool {
path = clean(path)
_, ok := s.files[path]
return ok
}
func (s *storage) New(path string, mode os.FileMode, flag int) (*file, error) {
path = clean(path)
if s.Has(path) {
if !s.MustGet(path).mode.IsDir() {
return nil, fmt.Errorf("file already exists %q", path)
}
return nil, nil
}
name := filepath.Base(path)
f := &file{
name: name,
content: &content{name: name},
mode: mode,
flag: flag,
mtime: time.Now(),
}
s.files[path] = f
if err := s.createParent(path, mode, f); err != nil {
return nil, err
}
return f, nil
}
func (s *storage) createParent(path string, mode os.FileMode, f *file) error {
base := filepath.Dir(path)
base = clean(base)
if f.Name() == string(separator) {
return nil
}
if _, err := s.New(base, mode.Perm()|os.ModeDir, 0); err != nil {
return err
}
if _, ok := s.children[base]; !ok {
s.children[base] = make(map[string]*file, 0)
}
s.children[base][f.Name()] = f
return nil
}
func (s *storage) Children(path string) []*file {
path = clean(path)
l := make([]*file, 0)
for _, f := range s.children[path] {
l = append(l, f)
}
return l
}
func (s *storage) MustGet(path string) *file {
f, ok := s.Get(path)
if !ok {
panic(fmt.Errorf("couldn't find %q", path))
}
return f
}
func (s *storage) Get(path string) (*file, bool) {
path = clean(path)
if !s.Has(path) {
return nil, false
}
file, ok := s.files[path]
return file, ok
}
func (s *storage) Rename(from, to string) error {
from = clean(from)
to = clean(to)
if !s.Has(from) {
return os.ErrNotExist
}
move := [][2]string{{from, to}}
for pathFrom := range s.files {
if pathFrom == from || !strings.HasPrefix(pathFrom, from) {
continue
}
rel, _ := filepath.Rel(from, pathFrom)
pathTo := filepath.Join(to, rel)
move = append(move, [2]string{pathFrom, pathTo})
}
for _, ops := range move {
from := ops[0]
to := ops[1]
if err := s.move(from, to); err != nil {
return err
}
}
return nil
}
func (s *storage) move(from, to string) error {
s.files[to] = s.files[from]
s.files[to].name = filepath.Base(to)
s.children[to] = s.children[from]
defer func() {
delete(s.children, from)
delete(s.files, from)
delete(s.children[filepath.Dir(from)], filepath.Base(from))
}()
return s.createParent(to, 0644, s.files[to])
}
func (s *storage) Remove(path string) error {
path = clean(path)
f, has := s.Get(path)
if !has {
return os.ErrNotExist
}
if f.mode.IsDir() && len(s.children[path]) != 0 {
return fmt.Errorf("dir: %s contains files", path)
}
base, file := filepath.Split(path)
base = filepath.Clean(base)
delete(s.children[base], file)
delete(s.files, path)
return nil
}
func clean(path string) string {
return filepath.Clean(filepath.FromSlash(path))
}
type content struct {
name string
bytes []byte
m sync.RWMutex
}
func (c *content) WriteAt(p []byte, off int64) (int, error) {
if off < 0 {
return 0, &os.PathError{
Op: "writeat",
Path: c.name,
Err: errors.New("negative offset"),
}
}
c.m.Lock()
prev := len(c.bytes)
diff := int(off) - prev
if diff > 0 {
c.bytes = append(c.bytes, make([]byte, diff)...)
}
c.bytes = append(c.bytes[:off], p...)
if len(c.bytes) < prev {
c.bytes = c.bytes[:prev]
}
c.m.Unlock()
return len(p), nil
}
func (c *content) ReadAt(b []byte, off int64) (n int, err error) {
if off < 0 {
return 0, &os.PathError{
Op: "readat",
Path: c.name,
Err: errors.New("negative offset"),
}
}
c.m.RLock()
size := int64(len(c.bytes))
if off >= size {
c.m.RUnlock()
return 0, io.EOF
}
l := int64(len(b))
if off+l > size {
l = size - off
}
btr := c.bytes[off : off+l]
n = copy(b, btr)
if len(btr) < len(b) {
err = io.EOF
}
c.m.RUnlock()
return
}

View file

@ -0,0 +1,59 @@
package helpers
import (
"context"
"net"
"git.kmsign.ru/royalcat/tstor/server/pkg/ctxbilly"
nfs "git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs"
)
// NewNullAuthHandler creates a handler for the provided filesystem
func NewNullAuthHandler(fs nfs.Filesystem) nfs.Handler {
return &NullAuthHandler{fs}
}
// NullAuthHandler returns a NFS backing that exposes a given file system in response to all mount requests.
type NullAuthHandler struct {
fs nfs.Filesystem
}
// Mount backs Mount RPC Requests, allowing for access control policies.
func (h *NullAuthHandler) Mount(ctx context.Context, conn net.Conn, req nfs.MountRequest) (status nfs.MountStatus, hndl nfs.Filesystem, auths []nfs.AuthFlavor) {
status = nfs.MountStatusOk
hndl = h.fs
auths = []nfs.AuthFlavor{nfs.AuthFlavorNull}
return
}
// Change provides an interface for updating file attributes.
func (h *NullAuthHandler) Change(fs nfs.Filesystem) nfs.Change {
if c, ok := h.fs.(ctxbilly.Change); ok {
return c
}
return nil
}
// FSStat provides information about a filesystem.
func (h *NullAuthHandler) FSStat(context.Context, nfs.Filesystem, *nfs.FSStat) error {
return nil
}
// ToHandle handled by CachingHandler
func (h *NullAuthHandler) ToHandle(context.Context, nfs.Filesystem, []string) []byte {
return []byte{}
}
// FromHandle handled by CachingHandler
func (h *NullAuthHandler) FromHandle(context.Context, []byte) (nfs.Filesystem, []string, error) {
return nil, []string{}, nil
}
func (c *NullAuthHandler) InvalidateHandle(context.Context, nfs.Filesystem, []byte) error {
return nil
}
// HandleLImit handled by cachingHandler
func (h *NullAuthHandler) HandleLimit() int {
return -1
}

216
server/pkg/go-nfs/log.go Normal file
View file

@ -0,0 +1,216 @@
package nfs
import (
"fmt"
"log"
"os"
)
var (
Log Logger = &DefaultLogger{}
)
type LogLevel int
const (
PanicLevel LogLevel = iota
FatalLevel
ErrorLevel
WarnLevel
InfoLevel
DebugLevel
TraceLevel
panicLevelStr string = "[PANIC] "
fatalLevelStr string = "[FATAL] "
errorLevelStr string = "[ERROR] "
warnLevelStr string = "[WARN] "
infoLevelStr string = "[INFO] "
debugLevelStr string = "[DEBUG] "
traceLevelStr string = "[TRACE] "
)
type Logger interface {
SetLevel(level LogLevel)
GetLevel() LogLevel
ParseLevel(level string) (LogLevel, error)
Panic(args ...interface{})
Fatal(args ...interface{})
Error(args ...interface{})
Warn(args ...interface{})
Info(args ...interface{})
Debug(args ...interface{})
Trace(args ...interface{})
Print(args ...interface{})
Panicf(format string, args ...interface{})
Fatalf(format string, args ...interface{})
Errorf(format string, args ...interface{})
Warnf(format string, args ...interface{})
Infof(format string, args ...interface{})
Debugf(format string, args ...interface{})
Tracef(format string, args ...interface{})
Printf(format string, args ...interface{})
}
type DefaultLogger struct {
Level LogLevel
}
func SetLogger(logger Logger) {
Log = logger
}
func init() {
if os.Getenv("LOG_LEVEL") != "" {
if level, err := Log.ParseLevel(os.Getenv("LOG_LEVEL")); err == nil {
Log.SetLevel(level)
}
} else {
// set default log level to info
Log.SetLevel(InfoLevel)
}
}
func (l *DefaultLogger) GetLevel() LogLevel {
return l.Level
}
func (l *DefaultLogger) SetLevel(level LogLevel) {
l.Level = level
}
func (l *DefaultLogger) ParseLevel(level string) (LogLevel, error) {
switch level {
case "panic":
return PanicLevel, nil
case "fatal":
return FatalLevel, nil
case "error":
return ErrorLevel, nil
case "warn":
return WarnLevel, nil
case "info":
return InfoLevel, nil
case "debug":
return DebugLevel, nil
case "trace":
return TraceLevel, nil
}
var ll LogLevel
return ll, fmt.Errorf("invalid log level %q", level)
}
func (l *DefaultLogger) Panic(args ...interface{}) {
if l.Level < PanicLevel {
return
}
args = append([]interface{}{panicLevelStr}, args...)
log.Print(args...)
}
func (l *DefaultLogger) Panicf(format string, args ...interface{}) {
if l.Level < PanicLevel {
return
}
log.Printf(panicLevelStr+format, args...)
}
func (l *DefaultLogger) Fatal(args ...interface{}) {
if l.Level < FatalLevel {
return
}
args = append([]interface{}{fatalLevelStr}, args...)
log.Print(args...)
}
func (l *DefaultLogger) Fatalf(format string, args ...interface{}) {
if l.Level < FatalLevel {
return
}
log.Printf(fatalLevelStr+format, args...)
}
func (l *DefaultLogger) Error(args ...interface{}) {
if l.Level < ErrorLevel {
return
}
args = append([]interface{}{errorLevelStr}, args...)
log.Print(args...)
}
func (l *DefaultLogger) Errorf(format string, args ...interface{}) {
if l.Level < ErrorLevel {
return
}
log.Printf(errorLevelStr+format, args...)
}
func (l *DefaultLogger) Warn(args ...interface{}) {
if l.Level < WarnLevel {
return
}
args = append([]interface{}{warnLevelStr}, args...)
log.Print(args...)
}
func (l *DefaultLogger) Warnf(format string, args ...interface{}) {
if l.Level < WarnLevel {
return
}
log.Printf(warnLevelStr+format, args...)
}
func (l *DefaultLogger) Info(args ...interface{}) {
if l.Level < InfoLevel {
return
}
args = append([]interface{}{infoLevelStr}, args...)
log.Print(args...)
}
func (l *DefaultLogger) Infof(format string, args ...interface{}) {
if l.Level < InfoLevel {
return
}
log.Printf(infoLevelStr+format, args...)
}
func (l *DefaultLogger) Debug(args ...interface{}) {
if l.Level < DebugLevel {
return
}
args = append([]interface{}{debugLevelStr}, args...)
log.Print(args...)
}
func (l *DefaultLogger) Debugf(format string, args ...interface{}) {
if l.Level < DebugLevel {
return
}
log.Printf(debugLevelStr+format, args...)
}
func (l *DefaultLogger) Trace(args ...interface{}) {
if l.Level < TraceLevel {
return
}
args = append([]interface{}{traceLevelStr}, args...)
log.Print(args...)
}
func (l *DefaultLogger) Tracef(format string, args ...interface{}) {
if l.Level < TraceLevel {
return
}
log.Printf(traceLevelStr+format, args...)
}
func (l *DefaultLogger) Print(args ...interface{}) {
log.Print(args...)
}
func (l *DefaultLogger) Printf(format string, args ...interface{}) {
log.Printf(format, args...)
}

View file

@ -0,0 +1,58 @@
package nfs
import (
"bytes"
"context"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
const (
mountServiceID = 100005
)
func init() {
_ = RegisterMessageHandler(mountServiceID, uint32(MountProcNull), onMountNull)
_ = RegisterMessageHandler(mountServiceID, uint32(MountProcMount), onMount)
_ = RegisterMessageHandler(mountServiceID, uint32(MountProcUmnt), onUMount)
}
func onMountNull(ctx context.Context, w *response, userHandle Handler) error {
return w.writeHeader(ResponseCodeSuccess)
}
func onMount(ctx context.Context, w *response, userHandle Handler) error {
// TODO: auth check.
dirpath, err := xdr.ReadOpaque(w.req.Body)
if err != nil {
return err
}
mountReq := MountRequest{Header: w.req.Header, Dirpath: dirpath}
status, handle, flavors := userHandle.Mount(ctx, w.conn, mountReq)
if err := w.writeHeader(ResponseCodeSuccess); err != nil {
return err
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(status)); err != nil {
return err
}
rootHndl := userHandle.ToHandle(ctx, handle, []string{})
if status == MountStatusOk {
_ = xdr.Write(writer, rootHndl)
_ = xdr.Write(writer, flavors)
}
return w.Write(writer.Bytes())
}
func onUMount(ctx context.Context, w *response, userHandle Handler) error {
_, err := xdr.ReadOpaque(w.req.Body)
if err != nil {
return err
}
return w.writeHeader(ResponseCodeSuccess)
}

View file

@ -0,0 +1,90 @@
package nfs
import (
"github.com/willscott/go-nfs-client/nfs/rpc"
)
// FHSize is the maximum size of a FileHandle
const FHSize = 64
// MNTNameLen is the maximum size of a mount name
const MNTNameLen = 255
// MntPathLen is the maximum size of a mount path
const MntPathLen = 1024
// FileHandle maps to a fhandle3
type FileHandle []byte
// MountStatus defines the response to the Mount Procedure
type MountStatus uint32
// MountStatus Codes
const (
MountStatusOk MountStatus = 0
MountStatusErrPerm MountStatus = 1
MountStatusErrNoEnt MountStatus = 2
MountStatusErrIO MountStatus = 5
MountStatusErrAcces MountStatus = 13
MountStatusErrNotDir MountStatus = 20
MountStatusErrInval MountStatus = 22
MountStatusErrNameTooLong MountStatus = 63
MountStatusErrNotSupp MountStatus = 10004
MountStatusErrServerFault MountStatus = 10006
)
// MountProcedure is the valid RPC calls for the mount service.
type MountProcedure uint32
// MountProcedure Codes
const (
MountProcNull MountProcedure = iota
MountProcMount
MountProcDump
MountProcUmnt
MountProcUmntAll
MountProcExport
)
func (m MountProcedure) String() string {
switch m {
case MountProcNull:
return "Null"
case MountProcMount:
return "Mount"
case MountProcDump:
return "Dump"
case MountProcUmnt:
return "Umnt"
case MountProcUmntAll:
return "UmntAll"
case MountProcExport:
return "Export"
default:
return "Unknown"
}
}
// AuthFlavor is a form of authentication, per rfc1057 section 7.2
type AuthFlavor uint32
// AuthFlavor Codes
const (
AuthFlavorNull AuthFlavor = 0
AuthFlavorUnix AuthFlavor = 1
AuthFlavorShort AuthFlavor = 2
AuthFlavorDES AuthFlavor = 3
)
// MountRequest contains the format of a client request to open a mount.
type MountRequest struct {
rpc.Header
Dirpath []byte
}
// MountResponse is the server's response with status `MountStatusOk`
type MountResponse struct {
rpc.Header
FileHandle
AuthFlavors []int
}

38
server/pkg/go-nfs/nfs.go Normal file
View file

@ -0,0 +1,38 @@
package nfs
import (
"context"
)
const (
nfsServiceID = 100003
)
func init() {
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureNull), onNull) // 0
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureGetAttr), onGetAttr) // 1
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureSetAttr), onSetAttr) // 2
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureLookup), onLookup) // 3
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureAccess), onAccess) // 4
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureReadlink), onReadLink) // 5
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureRead), onRead) // 6
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureWrite), onWrite) // 7
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureCreate), onCreate) // 8
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureMkDir), onMkdir) // 9
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureSymlink), onSymlink) // 10
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureMkNod), onMknod) // 11
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureRemove), onRemove) // 12
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureRmDir), onRmDir) // 13
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureRename), onRename) // 14
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureLink), onLink) // 15
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureReadDir), onReadDir) // 16
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureReadDirPlus), onReadDirPlus) // 17
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureFSStat), onFSStat) // 18
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureFSInfo), onFSInfo) // 19
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedurePathConf), onPathConf) // 20
_ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureCommit), onCommit) // 21
}
func onNull(ctx context.Context, w *response, userHandle Handler) error {
return w.Write([]byte{})
}

View file

@ -0,0 +1,45 @@
package nfs
import (
"bytes"
"context"
billy "github.com/go-git/go-billy/v5"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
func onAccess(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = opAttrErrorFormatter
roothandle, err := xdr.ReadOpaque(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs, path, err := userHandle.FromHandle(ctx, roothandle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
mask, err := xdr.ReadUint32(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if !CapabilityCheck(fs, billy.WriteCapability) {
mask = mask & (1 | 2 | 0x20)
}
if err := xdr.Write(writer, mask); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,51 @@
package nfs
import (
"bytes"
"context"
"os"
billy "github.com/go-git/go-billy/v5"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
// onCommit - note this is a no-op, as we always push writes to the backing store.
func onCommit(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = wccDataErrorFormatter
handle, err := xdr.ReadOpaque(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
// The conn will drain the unread offset and count arguments.
fs, path, err := userHandle.FromHandle(ctx, handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
if !CapabilityCheck(fs, billy.WriteCapability) {
return &NFSStatusError{NFSStatusServerFault, os.ErrPermission}
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return err
}
// no pre-op cache data.
if err := xdr.Write(writer, uint32(0)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
// write the 8 bytes of write verification.
if err := xdr.Write(writer, w.Server.ID); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,125 @@
package nfs
import (
"bytes"
"context"
"os"
billy "github.com/go-git/go-billy/v5"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
const (
createModeUnchecked = 0
createModeGuarded = 1
createModeExclusive = 2
)
func onCreate(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = wccDataErrorFormatter
obj := DirOpArg{}
err := xdr.Read(w.req.Body, &obj)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
how, err := xdr.ReadUint32(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
var attrs *SetFileAttributes
if how == createModeUnchecked || how == createModeGuarded {
sattr, err := ReadSetFileAttributes(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
attrs = sattr
} else if how == createModeExclusive {
// read createverf3
var verf [8]byte
if err := xdr.Read(w.req.Body, &verf); err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
Log.Errorf("failing create to indicate lack of support for 'exclusive' mode.")
// TODO: support 'exclusive' mode.
return &NFSStatusError{NFSStatusNotSupp, os.ErrPermission}
} else {
// invalid
return &NFSStatusError{NFSStatusNotSupp, os.ErrInvalid}
}
fs, path, err := userHandle.FromHandle(ctx, obj.Handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
if !CapabilityCheck(fs, billy.WriteCapability) {
return &NFSStatusError{NFSStatusROFS, os.ErrPermission}
}
if len(string(obj.Filename)) > PathNameMax {
return &NFSStatusError{NFSStatusNameTooLong, nil}
}
newFile := append(path, string(obj.Filename))
newFilePath := fs.Join(newFile...)
if s, err := fs.Stat(ctx, newFilePath); err == nil {
if s.IsDir() {
return &NFSStatusError{NFSStatusExist, nil}
}
if how == createModeGuarded {
return &NFSStatusError{NFSStatusExist, os.ErrPermission}
}
} else {
if s, err := fs.Stat(ctx, fs.Join(path...)); err != nil {
return &NFSStatusError{NFSStatusAccess, err}
} else if !s.IsDir() {
return &NFSStatusError{NFSStatusNotDir, nil}
}
}
file, err := fs.Create(ctx, newFilePath)
if err != nil {
Log.Errorf("Error Creating: %v", err)
return &NFSStatusError{NFSStatusAccess, err}
}
if err := file.Close(ctx); err != nil {
Log.Errorf("Error Creating: %v", err)
return &NFSStatusError{NFSStatusAccess, err}
}
fp := userHandle.ToHandle(ctx, fs, newFile)
changer := userHandle.Change(fs)
if err := attrs.Apply(ctx, changer, fs, newFilePath); err != nil {
Log.Errorf("Error applying attributes: %v\n", err)
return &NFSStatusError{NFSStatusIO, err}
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
// "handle follows"
if err := xdr.Write(writer, uint32(1)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := xdr.Write(writer, fp); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, []string{file.Name()})); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
// dir_wcc (we don't include pre_op_attr)
if err := xdr.Write(writer, uint32(0)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,89 @@
package nfs
import (
"bytes"
"context"
"github.com/go-git/go-billy/v5"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
const (
// FSInfoPropertyLink does the FS support hard links?
FSInfoPropertyLink = 0x0001
// FSInfoPropertySymlink does the FS support soft links?
FSInfoPropertySymlink = 0x0002
// FSInfoPropertyHomogeneous does the FS need PATHCONF calls for each file
FSInfoPropertyHomogeneous = 0x0008
// FSInfoPropertyCanSetTime can the FS support setting access/mod times?
FSInfoPropertyCanSetTime = 0x0010
)
func onFSInfo(ctx context.Context, w *response, userHandle Handler) error {
roothandle, err := xdr.ReadOpaque(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs, path, err := userHandle.FromHandle(ctx, roothandle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
type fsinfores struct {
Rtmax uint32
Rtpref uint32
Rtmult uint32
Wtmax uint32
Wtpref uint32
Wtmult uint32
Dtpref uint32
Maxfilesize uint64
TimeDelta uint64
Properties uint32
}
res := fsinfores{
Rtmax: 1 << 30,
Rtpref: 1 << 30,
Rtmult: 4096,
Wtmax: 1 << 30,
Wtpref: 1 << 30,
Wtmult: 4096,
Dtpref: 8192,
Maxfilesize: 1 << 62, // wild guess. this seems big.
TimeDelta: 1, // nanosecond precision.
Properties: 0,
}
// TODO: these aren't great indications of support, really.
// if _, ok := fs.(billy.Symlink); ok {
// res.Properties |= FSInfoPropertyLink
// res.Properties |= FSInfoPropertySymlink
// }
// TODO: if the nfs share spans multiple virtual mounts, may need
// to support granular PATHINFO responses.
res.Properties |= FSInfoPropertyHomogeneous
// TODO: not a perfect indicator
if CapabilityCheck(fs, billy.WriteCapability) {
res.Properties |= FSInfoPropertyCanSetTime
}
// TODO: this whole struct should be specifiable by the userhandler.
if err := xdr.Write(writer, res); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,59 @@
package nfs
import (
"bytes"
"context"
billy "github.com/go-git/go-billy/v5"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
func onFSStat(ctx context.Context, w *response, userHandle Handler) error {
roothandle, err := xdr.ReadOpaque(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs, path, err := userHandle.FromHandle(ctx, roothandle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
defaults := FSStat{
TotalSize: 1 << 62,
FreeSize: 1 << 62,
AvailableSize: 1 << 62,
TotalFiles: 1 << 62,
FreeFiles: 1 << 62,
AvailableFiles: 1 << 62,
CacheHint: 0,
}
if !CapabilityCheck(fs, billy.WriteCapability) {
defaults.AvailableFiles = 0
defaults.AvailableSize = 0
}
err = userHandle.FSStat(ctx, fs, &defaults)
if err != nil {
if _, ok := err.(*NFSStatusError); ok {
return err
}
return &NFSStatusError{NFSStatusServerFault, err}
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := xdr.Write(writer, defaults); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,48 @@
package nfs
import (
"bytes"
"context"
"errors"
"os"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
func onGetAttr(ctx context.Context, w *response, userHandle Handler) error {
handle, err := xdr.ReadOpaque(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs, path, err := userHandle.FromHandle(ctx, handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
fullPath := fs.Join(path...)
info, err := fs.Lstat(ctx, fullPath)
if err != nil {
if os.IsNotExist(err) {
return &NFSStatusError{NFSStatusNoEnt, err}
}
if errors.Is(err, context.DeadlineExceeded) {
return &NFSStatusError{timeoutStatus, err}
}
return &NFSStatusError{NFSStatusIO, err}
}
attr := ToFileAttribute(info, fullPath)
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := xdr.Write(writer, attr); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,94 @@
package nfs
import (
"bytes"
"context"
"os"
"github.com/go-git/go-billy/v5"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
// Backing billy.FS doesn't support hard links
func onLink(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = wccDataErrorFormatter
obj := DirOpArg{}
err := xdr.Read(w.req.Body, &obj)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
attrs, err := ReadSetFileAttributes(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
target, err := xdr.ReadOpaque(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs, path, err := userHandle.FromHandle(ctx, obj.Handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
if !CapabilityCheck(fs, billy.WriteCapability) {
return &NFSStatusError{NFSStatusROFS, os.ErrPermission}
}
if len(string(obj.Filename)) > PathNameMax {
return &NFSStatusError{NFSStatusNameTooLong, os.ErrInvalid}
}
newFilePath := fs.Join(append(path, string(obj.Filename))...)
if _, err := fs.Stat(ctx, newFilePath); err == nil {
return &NFSStatusError{NFSStatusExist, os.ErrExist}
}
if s, err := fs.Stat(ctx, fs.Join(path...)); err != nil {
return &NFSStatusError{NFSStatusAccess, err}
} else if !s.IsDir() {
return &NFSStatusError{NFSStatusNotDir, nil}
}
fp := userHandle.ToHandle(ctx, fs, append(path, string(obj.Filename)))
changer := userHandle.Change(fs)
if changer == nil {
return &NFSStatusError{NFSStatusAccess, err}
}
cos, ok := changer.(UnixChange)
if !ok {
return &NFSStatusError{NFSStatusAccess, err}
}
err = cos.Link(ctx, string(target), newFilePath)
if err != nil {
return &NFSStatusError{NFSStatusAccess, err}
}
if err := attrs.Apply(ctx, changer, fs, newFilePath); err != nil {
return &NFSStatusError{NFSStatusIO, err}
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
// "handle follows"
if err := xdr.Write(writer, uint32(1)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := xdr.Write(writer, fp); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, append(path, string(obj.Filename)))); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WriteWcc(writer, nil, tryStat(ctx, fs, path)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,86 @@
package nfs
import (
"bytes"
"context"
"os"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
func lookupSuccessResponse(ctx context.Context, handle []byte, entPath, dirPath []string, fs Filesystem) ([]byte, error) {
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return nil, err
}
if err := xdr.Write(writer, handle); err != nil {
return nil, err
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, entPath)); err != nil {
return nil, err
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, dirPath)); err != nil {
return nil, err
}
return writer.Bytes(), nil
}
func onLookup(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = opAttrErrorFormatter
obj := DirOpArg{}
err := xdr.Read(w.req.Body, &obj)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs, p, err := userHandle.FromHandle(ctx, obj.Handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
dirInfo, err := fs.Lstat(ctx, fs.Join(p...))
if err != nil || !dirInfo.IsDir() {
return &NFSStatusError{NFSStatusNotDir, err}
}
// Special cases for "." and ".."
if bytes.Equal(obj.Filename, []byte(".")) {
resp, err := lookupSuccessResponse(ctx, obj.Handle, p, p, fs)
if err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(resp); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}
if bytes.Equal(obj.Filename, []byte("..")) {
if len(p) == 0 {
return &NFSStatusError{NFSStatusAccess, os.ErrPermission}
}
pPath := p[0 : len(p)-1]
pHandle := userHandle.ToHandle(ctx, fs, pPath)
resp, err := lookupSuccessResponse(ctx, pHandle, pPath, p, fs)
if err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(resp); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}
reqPath := append(p, string(obj.Filename))
if _, err = fs.Lstat(ctx, fs.Join(reqPath...)); err != nil {
return &NFSStatusError{NFSStatusNoEnt, os.ErrNotExist}
}
newHandle := userHandle.ToHandle(ctx, fs, reqPath)
resp, err := lookupSuccessResponse(ctx, newHandle, reqPath, p, fs)
if err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(resp); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,94 @@
package nfs
import (
"bytes"
"context"
"os"
"github.com/go-git/go-billy/v5"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
const (
mkdirDefaultMode = 755
)
func onMkdir(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = wccDataErrorFormatter
obj := DirOpArg{}
err := xdr.Read(w.req.Body, &obj)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
attrs, err := ReadSetFileAttributes(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs, path, err := userHandle.FromHandle(ctx, obj.Handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
if !CapabilityCheck(fs, billy.WriteCapability) {
return &NFSStatusError{NFSStatusROFS, os.ErrPermission}
}
if len(string(obj.Filename)) > PathNameMax {
return &NFSStatusError{NFSStatusNameTooLong, os.ErrInvalid}
}
if string(obj.Filename) == "." || string(obj.Filename) == ".." {
return &NFSStatusError{NFSStatusExist, os.ErrExist}
}
newFolder := append(path, string(obj.Filename))
newFolderPath := fs.Join(newFolder...)
if s, err := fs.Stat(ctx, newFolderPath); err == nil {
if s.IsDir() {
return &NFSStatusError{NFSStatusExist, nil}
}
} else {
if s, err := fs.Stat(ctx, fs.Join(path...)); err != nil {
return &NFSStatusError{NFSStatusAccess, err}
} else if !s.IsDir() {
return &NFSStatusError{NFSStatusNotDir, nil}
}
}
if err := fs.MkdirAll(ctx, newFolderPath, attrs.Mode(mkdirDefaultMode)); err != nil {
return &NFSStatusError{NFSStatusAccess, err}
}
fp := userHandle.ToHandle(ctx, fs, newFolder)
changer := userHandle.Change(fs)
if changer != nil {
if err := attrs.Apply(ctx, changer, fs, newFolderPath); err != nil {
return &NFSStatusError{NFSStatusIO, err}
}
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
// "handle follows"
if err := xdr.Write(writer, uint32(1)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := xdr.Write(writer, fp); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, newFolder)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WriteWcc(writer, nil, tryStat(ctx, fs, path)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,158 @@
package nfs
import (
"bytes"
"context"
"os"
billy "github.com/go-git/go-billy/v5"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
type nfs_ftype int32
const (
FTYPE_NF3REG nfs_ftype = 1
FTYPE_NF3DIR nfs_ftype = 2
FTYPE_NF3BLK nfs_ftype = 3
FTYPE_NF3CHR nfs_ftype = 4
FTYPE_NF3LNK nfs_ftype = 5
FTYPE_NF3SOCK nfs_ftype = 6
FTYPE_NF3FIFO nfs_ftype = 7
)
// Backing billy.FS doesn't support creation of
// char, block, socket, or fifo pipe nodes
func onMknod(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = wccDataErrorFormatter
obj := DirOpArg{}
err := xdr.Read(w.req.Body, &obj)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
ftype, err := xdr.ReadUint32(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
// see if the filesystem supports mknod
fs, path, err := userHandle.FromHandle(ctx, obj.Handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
if !CapabilityCheck(fs, billy.WriteCapability) {
return &NFSStatusError{NFSStatusROFS, os.ErrPermission}
}
c := userHandle.Change(fs)
if c == nil {
return &NFSStatusError{NFSStatusAccess, os.ErrPermission}
}
cu, ok := c.(UnixChange)
if !ok {
return &NFSStatusError{NFSStatusAccess, os.ErrPermission}
}
if len(string(obj.Filename)) > PathNameMax {
return &NFSStatusError{NFSStatusNameTooLong, os.ErrInvalid}
}
newFilePath := fs.Join(append(path, string(obj.Filename))...)
if _, err := fs.Stat(ctx, newFilePath); err == nil {
return &NFSStatusError{NFSStatusExist, os.ErrExist}
}
parent, err := fs.Stat(ctx, fs.Join(path...))
if err != nil {
return &NFSStatusError{NFSStatusAccess, err}
} else if !parent.IsDir() {
return &NFSStatusError{NFSStatusNotDir, nil}
}
fp := userHandle.ToHandle(ctx, fs, append(path, string(obj.Filename)))
switch nfs_ftype(ftype) {
case FTYPE_NF3CHR:
case FTYPE_NF3BLK:
// read devicedata3 = {sattr3, specdata3}
attrs, err := ReadSetFileAttributes(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
specData1, err := xdr.ReadUint32(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
specData2, err := xdr.ReadUint32(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
err = cu.Mknod(ctx, newFilePath, uint32(attrs.Mode(parent.Mode())), specData1, specData2)
if err != nil {
return &NFSStatusError{NFSStatusAccess, err}
}
if err = attrs.Apply(ctx, cu, fs, newFilePath); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
case FTYPE_NF3SOCK:
// read sattr3
attrs, err := ReadSetFileAttributes(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
if err := cu.Socket(ctx, newFilePath); err != nil {
return &NFSStatusError{NFSStatusAccess, err}
}
if err = attrs.Apply(ctx, cu, fs, newFilePath); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
case FTYPE_NF3FIFO:
// read sattr3
attrs, err := ReadSetFileAttributes(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
err = cu.Mkfifo(ctx, newFilePath, uint32(attrs.Mode(parent.Mode())))
if err != nil {
return &NFSStatusError{NFSStatusAccess, err}
}
if err = attrs.Apply(ctx, cu, fs, newFilePath); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
default:
return &NFSStatusError{NFSStatusBadType, os.ErrInvalid}
// end of input.
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
// "handle follows"
if err := xdr.Write(writer, uint32(1)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
// fh3
if err := xdr.Write(writer, fp); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
// attr
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, append(path, string(obj.Filename)))); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
// wcc
if err := WriteWcc(writer, nil, tryStat(ctx, fs, path)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,55 @@
package nfs
import (
"bytes"
"context"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
// PathNameMax is the maximum length for a file name
const PathNameMax = 255
func onPathConf(ctx context.Context, w *response, userHandle Handler) error {
roothandle, err := xdr.ReadOpaque(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs, path, err := userHandle.FromHandle(ctx, roothandle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
type PathConf struct {
LinkMax uint32
NameMax uint32
NoTrunc uint32
ChownRestricted uint32
CaseInsensitive uint32
CasePreserving uint32
}
defaults := PathConf{
LinkMax: 1,
NameMax: PathNameMax,
NoTrunc: 1,
ChownRestricted: 0,
CaseInsensitive: 0,
CasePreserving: 1,
}
if err := xdr.Write(writer, defaults); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,98 @@
package nfs
import (
"bytes"
"context"
"errors"
"io"
"os"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
type nfsReadArgs struct {
Handle []byte
Offset uint64
Count uint32
}
type nfsReadResponse struct {
Count uint32
EOF uint32
Data []byte
}
// MaxRead is the advertised largest buffer the server is willing to read
const MaxRead = 1 << 24
// CheckRead is a size where - if a request to read is larger than this,
// the server will stat the file to learn it's actual size before allocating
// a buffer to read into.
const CheckRead = 1 << 15
func onRead(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = opAttrErrorFormatter
var obj nfsReadArgs
err := xdr.Read(w.req.Body, &obj)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs, path, err := userHandle.FromHandle(ctx, obj.Handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
fh, err := fs.OpenFile(ctx, fs.Join(path...), os.O_RDONLY, 0)
if err != nil {
if os.IsNotExist(err) {
return &NFSStatusError{NFSStatusNoEnt, err}
}
if errors.Is(err, context.DeadlineExceeded) {
return &NFSStatusError{timeoutStatus, err}
}
return &NFSStatusError{NFSStatusAccess, err}
}
defer fh.Close(ctx)
resp := nfsReadResponse{}
if obj.Count > CheckRead {
info, err := fs.Stat(ctx, fs.Join(path...))
if err != nil {
return &NFSStatusError{NFSStatusAccess, err}
}
if info.Size()-int64(obj.Offset) < int64(obj.Count) {
obj.Count = uint32(uint64(info.Size()) - obj.Offset)
}
}
if obj.Count > MaxRead {
obj.Count = MaxRead
}
resp.Data = make([]byte, obj.Count)
// todo: multiple reads if size isn't full
cnt, err := fh.ReadAt(ctx, resp.Data, int64(obj.Offset))
if err != nil && !errors.Is(err, io.EOF) {
return &NFSStatusError{NFSStatusIO, err}
}
resp.Count = uint32(cnt)
resp.Data = resp.Data[:resp.Count]
if errors.Is(err, io.EOF) {
resp.EOF = 1
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := xdr.Write(writer, resp); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,195 @@
package nfs
import (
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"io"
"io/fs"
"os"
"path"
"sort"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
type readDirArgs struct {
Handle []byte
Cookie uint64
CookieVerif uint64
Count uint32
}
type readDirEntity struct {
FileID uint64
Name []byte
Cookie uint64
Next bool
}
func onReadDir(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = opAttrErrorFormatter
obj := readDirArgs{}
err := xdr.Read(w.req.Body, &obj)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
if obj.Count < 1024 {
return &NFSStatusError{NFSStatusTooSmall, io.ErrShortBuffer}
}
fs, p, err := userHandle.FromHandle(ctx, obj.Handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
contents, verifier, err := getDirListingWithVerifier(ctx, userHandle, obj.Handle, obj.CookieVerif)
if err != nil {
return err
}
if obj.Cookie > 0 && obj.CookieVerif > 0 && verifier != obj.CookieVerif {
return &NFSStatusError{NFSStatusBadCookie, nil}
}
entities := make([]readDirEntity, 0)
maxBytes := uint32(100) // conservative overhead measure
started := obj.Cookie == 0
if started {
// add '.' and '..' to entities
dotdotFileID := uint64(0)
if len(p) > 0 {
dda := tryStat(ctx, fs, p[0:len(p)-1])
if dda != nil {
dotdotFileID = dda.Fileid
}
}
dotFileID := uint64(0)
da := tryStat(ctx, fs, p)
if da != nil {
dotFileID = da.Fileid
}
entities = append(entities,
readDirEntity{Name: []byte("."), Cookie: 0, Next: true, FileID: dotFileID},
readDirEntity{Name: []byte(".."), Cookie: 1, Next: true, FileID: dotdotFileID},
)
}
eof := true
maxEntities := userHandle.HandleLimit() / 2
for i, c := range contents {
// cookie equates to index within contents + 2 (for '.' and '..')
cookie := uint64(i + 2)
if started {
maxBytes += 512 // TODO: better estimation.
if maxBytes > obj.Count || len(entities) > maxEntities {
eof = false
break
}
attrs := ToFileAttribute(c, path.Join(append(p, c.Name())...))
entities = append(entities, readDirEntity{
FileID: attrs.Fileid,
Name: []byte(c.Name()),
Cookie: cookie,
Next: true,
})
} else if cookie == obj.Cookie {
started = true
}
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, p)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := xdr.Write(writer, verifier); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := xdr.Write(writer, len(entities) > 0); err != nil { // next
return &NFSStatusError{NFSStatusServerFault, err}
}
if len(entities) > 0 {
entities[len(entities)-1].Next = false
// no next for last entity
for _, e := range entities {
if err := xdr.Write(writer, e); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
}
}
if err := xdr.Write(writer, eof); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
// TODO: track writer size at this point to validate maxcount estimation and stop early if needed.
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}
func getDirListingWithVerifier(ctx context.Context, userHandle Handler, fsHandle []byte, verifier uint64) ([]fs.FileInfo, uint64, error) {
// figure out what directory it is.
fs, p, err := userHandle.FromHandle(ctx, fsHandle)
if err != nil {
return nil, 0, &NFSStatusError{NFSStatusStale, err}
}
path := fs.Join(p...)
// see if the verifier has this dir cached:
if vh, ok := userHandle.(CachingHandler); verifier != 0 && ok {
entries := vh.DataForVerifier(path, verifier)
if entries != nil {
return entries, verifier, nil
}
}
// load the entries.
contents, err := fs.ReadDir(ctx, path)
if err != nil {
if os.IsPermission(err) {
return nil, 0, &NFSStatusError{NFSStatusAccess, err}
}
if errors.Is(err, context.DeadlineExceeded) {
return nil, 0, &NFSStatusError{timeoutStatus, err}
}
return nil, 0, &NFSStatusError{NFSStatusIO, err}
}
sort.Slice(contents, func(i, j int) bool {
return contents[i].Name() < contents[j].Name()
})
if vh, ok := userHandle.(CachingHandler); ok {
// let the user handler make a verifier if it can.
v := vh.VerifierFor(path, contents)
return contents, v, nil
}
id := hashPathAndContents(path, contents)
return contents, id, nil
}
func hashPathAndContents(path string, contents []fs.FileInfo) uint64 {
//calculate a cookie-verifier.
vHash := sha256.New()
// Add the path to avoid collisions of directories with the same content
vHash.Write([]byte(path))
for _, c := range contents {
vHash.Write([]byte(c.Name())) // Never fails according to the docs
}
verify := vHash.Sum(nil)[0:8]
return binary.BigEndian.Uint64(verify)
}

View file

@ -0,0 +1,153 @@
package nfs
import (
"bytes"
"context"
"path"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
type readDirPlusArgs struct {
Handle []byte
Cookie uint64
CookieVerif uint64
DirCount uint32
MaxCount uint32
}
type readDirPlusEntity struct {
FileID uint64
Name []byte
Cookie uint64
Attributes *FileAttribute `xdr:"optional"`
Handle *[]byte `xdr:"optional"`
Next bool
}
func joinPath(parent []string, elements ...string) []string {
joinedPath := make([]string, 0, len(parent)+len(elements))
joinedPath = append(joinedPath, parent...)
joinedPath = append(joinedPath, elements...)
return joinedPath
}
func onReadDirPlus(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = opAttrErrorFormatter
obj := readDirPlusArgs{}
if err := xdr.Read(w.req.Body, &obj); err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
// in case of test, nfs-client send:
// DirCount = 512
// MaxCount = 4096
if obj.DirCount < 512 || obj.MaxCount < 4096 {
return &NFSStatusError{NFSStatusTooSmall, nil}
}
fs, p, err := userHandle.FromHandle(ctx, obj.Handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
contents, verifier, err := getDirListingWithVerifier(ctx, userHandle, obj.Handle, obj.CookieVerif)
if err != nil {
return err
}
if obj.Cookie > 0 && obj.CookieVerif > 0 && verifier != obj.CookieVerif {
return &NFSStatusError{NFSStatusBadCookie, nil}
}
entities := make([]readDirPlusEntity, 0)
dirBytes := uint32(0)
maxBytes := uint32(100) // conservative overhead measure
started := obj.Cookie == 0
if started {
// add '.' and '..' to entities
dotdotFileID := uint64(0)
if len(p) > 0 {
dda := tryStat(ctx, fs, p[0:len(p)-1])
if dda != nil {
dotdotFileID = dda.Fileid
}
}
dotFileID := uint64(0)
da := tryStat(ctx, fs, p)
if da != nil {
dotFileID = da.Fileid
}
entities = append(entities,
readDirPlusEntity{Name: []byte("."), Cookie: 0, Next: true, FileID: dotFileID, Attributes: da},
readDirPlusEntity{Name: []byte(".."), Cookie: 1, Next: true, FileID: dotdotFileID},
)
}
eof := true
maxEntities := userHandle.HandleLimit() / 2
fb := 0
fss := 0
for i, c := range contents {
// cookie equates to index within contents + 2 (for '.' and '..')
cookie := uint64(i + 2)
fb++
if started {
fss++
dirBytes += uint32(len(c.Name()) + 20)
maxBytes += 512 // TODO: better estimation.
if dirBytes > obj.DirCount || maxBytes > obj.MaxCount || len(entities) > maxEntities {
eof = false
break
}
filePath := joinPath(p, c.Name())
handle := userHandle.ToHandle(ctx, fs, filePath)
attrs := ToFileAttribute(c, path.Join(filePath...))
entities = append(entities, readDirPlusEntity{
FileID: attrs.Fileid,
Name: []byte(c.Name()),
Cookie: cookie,
Attributes: attrs,
Handle: &handle,
Next: true,
})
} else if cookie == obj.Cookie {
started = true
}
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, p)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := xdr.Write(writer, verifier); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := xdr.Write(writer, len(entities) > 0); err != nil { // next
return &NFSStatusError{NFSStatusServerFault, err}
}
if len(entities) > 0 {
entities[len(entities)-1].Next = false
// no next for last entity
for _, e := range entities {
if err := xdr.Write(writer, e); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
}
}
if err := xdr.Write(writer, eof); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
// TODO: track writer size at this point to validate maxcount estimation and stop early if needed.
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,55 @@
package nfs
import (
"bytes"
"context"
"errors"
"os"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
func onReadLink(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = opAttrErrorFormatter
handle, err := xdr.ReadOpaque(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs, path, err := userHandle.FromHandle(ctx, handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
out, err := fs.Readlink(ctx, fs.Join(path...))
if err != nil {
if info, err := fs.Stat(ctx, fs.Join(path...)); err == nil {
if info.Mode()&os.ModeSymlink == 0 {
return &NFSStatusError{NFSStatusInval, err}
}
}
if os.IsNotExist(err) {
return &NFSStatusError{NFSStatusNoEnt, err}
}
if errors.Is(err, context.DeadlineExceeded) {
return &NFSStatusError{timeoutStatus, err}
}
return &NFSStatusError{NFSStatusAccess, err}
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := xdr.Write(writer, out); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,85 @@
package nfs
import (
"bytes"
"context"
"errors"
"os"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
func onRemove(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = wccDataErrorFormatter
obj := DirOpArg{}
if err := xdr.Read(w.req.Body, &obj); err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs, path, err := userHandle.FromHandle(ctx, obj.Handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
// TODO
// if !CapabilityCheck(fs, billy.WriteCapability) {
// return &NFSStatusError{NFSStatusROFS, os.ErrPermission}
// }
if len(string(obj.Filename)) > PathNameMax {
return &NFSStatusError{NFSStatusNameTooLong, nil}
}
fullPath := fs.Join(path...)
dirInfo, err := fs.Stat(ctx, fullPath)
if err != nil {
if os.IsNotExist(err) {
return &NFSStatusError{NFSStatusNoEnt, err}
}
if os.IsPermission(err) {
return &NFSStatusError{NFSStatusAccess, err}
}
if errors.Is(err, context.DeadlineExceeded) {
return &NFSStatusError{timeoutStatus, err}
}
return &NFSStatusError{NFSStatusIO, err}
}
if !dirInfo.IsDir() {
return &NFSStatusError{NFSStatusNotDir, nil}
}
preCacheData := ToFileAttribute(dirInfo, fullPath).AsCache()
toDelete := fs.Join(append(path, string(obj.Filename))...)
toDeleteHandle := userHandle.ToHandle(ctx, fs, append(path, string(obj.Filename)))
err = fs.Remove(ctx, toDelete)
if err != nil {
if os.IsNotExist(err) {
return &NFSStatusError{NFSStatusNoEnt, err}
}
if os.IsPermission(err) {
return &NFSStatusError{NFSStatusAccess, err}
}
if errors.Is(err, context.DeadlineExceeded) {
return &NFSStatusError{timeoutStatus, err}
}
return &NFSStatusError{NFSStatusIO, err}
}
if err := userHandle.InvalidateHandle(ctx, fs, toDeleteHandle); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WriteWcc(writer, preCacheData, tryStat(ctx, fs, path)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,120 @@
package nfs
import (
"bytes"
"context"
"errors"
"os"
"reflect"
"github.com/go-git/go-billy/v5"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
var doubleWccErrorBody = [16]byte{}
func onRename(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = errFormatterWithBody(doubleWccErrorBody[:])
from := DirOpArg{}
err := xdr.Read(w.req.Body, &from)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs, fromPath, err := userHandle.FromHandle(ctx, from.Handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
to := DirOpArg{}
if err = xdr.Read(w.req.Body, &to); err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs2, toPath, err := userHandle.FromHandle(ctx, to.Handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
// check the two fs are the same
if !reflect.DeepEqual(fs, fs2) {
return &NFSStatusError{NFSStatusNotSupp, os.ErrPermission}
}
if !CapabilityCheck(fs, billy.WriteCapability) {
return &NFSStatusError{NFSStatusROFS, os.ErrPermission}
}
if len(string(from.Filename)) > PathNameMax || len(string(to.Filename)) > PathNameMax {
return &NFSStatusError{NFSStatusNameTooLong, os.ErrInvalid}
}
fromDirPath := fs.Join(fromPath...)
fromDirInfo, err := fs.Stat(ctx, fromDirPath)
if err != nil {
if os.IsNotExist(err) {
return &NFSStatusError{NFSStatusNoEnt, err}
}
if errors.Is(err, context.DeadlineExceeded) {
return &NFSStatusError{timeoutStatus, err}
}
return &NFSStatusError{NFSStatusIO, err}
}
if !fromDirInfo.IsDir() {
return &NFSStatusError{NFSStatusNotDir, nil}
}
preCacheData := ToFileAttribute(fromDirInfo, fromDirPath).AsCache()
toDirPath := fs.Join(toPath...)
toDirInfo, err := fs.Stat(ctx, toDirPath)
if err != nil {
if os.IsNotExist(err) {
return &NFSStatusError{NFSStatusNoEnt, err}
}
if errors.Is(err, context.DeadlineExceeded) {
return &NFSStatusError{timeoutStatus, err}
}
return &NFSStatusError{NFSStatusIO, err}
}
if !toDirInfo.IsDir() {
return &NFSStatusError{NFSStatusNotDir, nil}
}
preDestData := ToFileAttribute(toDirInfo, toDirPath).AsCache()
oldHandle := userHandle.ToHandle(ctx, fs, append(fromPath, string(from.Filename)))
fromLoc := fs.Join(append(fromPath, string(from.Filename))...)
toLoc := fs.Join(append(toPath, string(to.Filename))...)
err = fs.Rename(ctx, fromLoc, toLoc)
if err != nil {
if os.IsNotExist(err) {
return &NFSStatusError{NFSStatusNoEnt, err}
}
if os.IsPermission(err) {
return &NFSStatusError{NFSStatusAccess, err}
}
if errors.Is(err, context.DeadlineExceeded) {
return &NFSStatusError{timeoutStatus, err}
}
return &NFSStatusError{NFSStatusIO, err}
}
if err := userHandle.InvalidateHandle(ctx, fs, oldHandle); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WriteWcc(writer, preCacheData, tryStat(ctx, fs, fromPath)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WriteWcc(writer, preDestData, tryStat(ctx, fs, toPath)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,9 @@
package nfs
import (
"context"
)
func onRmDir(ctx context.Context, w *response, userHandle Handler) error {
return onRemove(ctx, w, userHandle)
}

View file

@ -0,0 +1,80 @@
package nfs
import (
"bytes"
"context"
"errors"
"os"
"github.com/go-git/go-billy/v5"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
func onSetAttr(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = wccDataErrorFormatter
handle, err := xdr.ReadOpaque(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs, path, err := userHandle.FromHandle(ctx, handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
attrs, err := ReadSetFileAttributes(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fullPath := fs.Join(path...)
info, err := fs.Lstat(ctx, fullPath)
if err != nil {
if os.IsNotExist(err) {
return &NFSStatusError{NFSStatusNoEnt, err}
}
if errors.Is(err, context.DeadlineExceeded) {
return &NFSStatusError{timeoutStatus, err}
}
return &NFSStatusError{NFSStatusAccess, err}
}
// see if there's a "guard"
if guard, err := xdr.ReadUint32(w.req.Body); err != nil {
return &NFSStatusError{NFSStatusInval, err}
} else if guard != 0 {
// read the ctime.
t := FileTime{}
if err := xdr.Read(w.req.Body, &t); err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
attr := ToFileAttribute(info, fullPath)
if t != attr.Ctime {
return &NFSStatusError{NFSStatusNotSync, nil}
}
}
if !CapabilityCheck(fs, billy.WriteCapability) {
return &NFSStatusError{NFSStatusROFS, os.ErrPermission}
}
changer := userHandle.Change(fs)
if err := attrs.Apply(ctx, changer, fs, fs.Join(path...)); err != nil {
// Already an nfsstatuserror
return err
}
preAttr := ToFileAttribute(info, fullPath).AsCache()
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WriteWcc(writer, preAttr, tryStat(ctx, fs, path)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,88 @@
package nfs
import (
"bytes"
"context"
"os"
"github.com/go-git/go-billy/v5"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
func onSymlink(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = wccDataErrorFormatter
obj := DirOpArg{}
err := xdr.Read(w.req.Body, &obj)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
attrs, err := ReadSetFileAttributes(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
target, err := xdr.ReadOpaque(w.req.Body)
if err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs, path, err := userHandle.FromHandle(ctx, obj.Handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
if !CapabilityCheck(fs, billy.WriteCapability) {
return &NFSStatusError{NFSStatusROFS, os.ErrPermission}
}
if len(string(obj.Filename)) > PathNameMax {
return &NFSStatusError{NFSStatusNameTooLong, os.ErrInvalid}
}
newFilePath := fs.Join(append(path, string(obj.Filename))...)
if _, err := fs.Stat(ctx, newFilePath); err == nil {
return &NFSStatusError{NFSStatusExist, os.ErrExist}
}
if s, err := fs.Stat(ctx, fs.Join(path...)); err != nil {
return &NFSStatusError{NFSStatusAccess, err}
} else if !s.IsDir() {
return &NFSStatusError{NFSStatusNotDir, nil}
}
err = fs.Symlink(ctx, string(target), newFilePath)
if err != nil {
return &NFSStatusError{NFSStatusAccess, err}
}
fp := userHandle.ToHandle(ctx, fs, append(path, string(obj.Filename)))
changer := userHandle.Change(fs)
if changer != nil {
if err := attrs.Apply(ctx, changer, fs, newFilePath); err != nil {
return &NFSStatusError{NFSStatusIO, err}
}
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
// "handle follows"
if err := xdr.Write(writer, uint32(1)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := xdr.Write(writer, fp); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WritePostOpAttrs(writer, tryStat(ctx, fs, append(path, string(obj.Filename)))); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WriteWcc(writer, nil, tryStat(ctx, fs, path)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,117 @@
package nfs
import (
"bytes"
"context"
"errors"
"io"
"math"
"os"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
// writeStability is the level of durability requested with the write
type writeStability uint32
const (
unstable writeStability = 0
dataSync writeStability = 1
fileSync writeStability = 2
)
type writeArgs struct {
Handle []byte
Offset uint64
Count uint32
How uint32
Data []byte
}
func onWrite(ctx context.Context, w *response, userHandle Handler) error {
w.errorFmt = wccDataErrorFormatter
var req writeArgs
if err := xdr.Read(w.req.Body, &req); err != nil {
return &NFSStatusError{NFSStatusInval, err}
}
fs, path, err := userHandle.FromHandle(ctx, req.Handle)
if err != nil {
return &NFSStatusError{NFSStatusStale, err}
}
// TODO
// if !CapabilityCheck(fs, billy.WriteCapability) {
// return &NFSStatusError{NFSStatusROFS, os.ErrPermission}
// }
if len(req.Data) > math.MaxInt32 || req.Count > math.MaxInt32 {
return &NFSStatusError{NFSStatusFBig, os.ErrInvalid}
}
if req.How != uint32(unstable) && req.How != uint32(dataSync) && req.How != uint32(fileSync) {
return &NFSStatusError{NFSStatusInval, os.ErrInvalid}
}
// stat first for pre-op wcc.
fullPath := fs.Join(path...)
info, err := fs.Stat(ctx, fullPath)
if err != nil {
if os.IsNotExist(err) {
return &NFSStatusError{NFSStatusNoEnt, err}
}
if errors.Is(err, context.DeadlineExceeded) {
return &NFSStatusError{timeoutStatus, err}
}
return &NFSStatusError{NFSStatusAccess, err}
}
if !info.Mode().IsRegular() {
return &NFSStatusError{NFSStatusInval, os.ErrInvalid}
}
preOpCache := ToFileAttribute(info, fullPath).AsCache()
// now the actual op.
file, err := fs.OpenFile(ctx, fs.Join(path...), os.O_RDWR, info.Mode().Perm())
if err != nil {
return &NFSStatusError{NFSStatusAccess, err}
}
defer file.Close(ctx)
if req.Offset > 0 {
if _, err := file.Seek(int64(req.Offset), io.SeekStart); err != nil {
return &NFSStatusError{NFSStatusIO, err}
}
}
end := req.Count
if len(req.Data) < int(end) {
end = uint32(len(req.Data))
}
writtenCount, err := file.Write(ctx, req.Data[:end])
if err != nil {
Log.Errorf("Error writing: %v", err)
return &NFSStatusError{NFSStatusIO, err}
}
if err := file.Close(ctx); err != nil {
Log.Errorf("error closing: %v", err)
return &NFSStatusError{NFSStatusIO, err}
}
writer := bytes.NewBuffer([]byte{})
if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := WriteWcc(writer, preOpCache, tryStat(ctx, fs, path)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := xdr.Write(writer, uint32(writtenCount)); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := xdr.Write(writer, fileSync); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := xdr.Write(writer, w.Server.ID); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
if err := w.Write(writer.Bytes()); err != nil {
return &NFSStatusError{NFSStatusServerFault, err}
}
return nil
}

View file

@ -0,0 +1,294 @@
package nfs_test
import (
"bytes"
"context"
"fmt"
"net"
"os"
"reflect"
"sort"
"testing"
nfs "git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs"
"git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs/helpers"
"git.kmsign.ru/royalcat/tstor/server/pkg/go-nfs/helpers/memfs"
nfsc "github.com/willscott/go-nfs-client/nfs"
rpc "github.com/willscott/go-nfs-client/nfs/rpc"
"github.com/willscott/go-nfs-client/nfs/util"
"github.com/willscott/go-nfs-client/nfs/xdr"
)
func TestNFS(t *testing.T) {
ctx := context.Background()
if testing.Verbose() {
util.DefaultLogger.SetDebug(true)
}
// make an empty in-memory server.
listener, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatal(err)
}
mem := helpers.WrapBillyFS(memfs.New())
// File needs to exist in the root for memfs to acknowledge the root exists.
_, _ = mem.Create(ctx, "/test")
handler := helpers.NewNullAuthHandler(mem)
cacheHelper := helpers.NewCachingHandler(handler, 1024)
go func() {
_ = nfs.Serve(listener, cacheHelper)
}()
c, err := rpc.DialTCP(listener.Addr().Network(), listener.Addr().(*net.TCPAddr).String(), false)
if err != nil {
t.Fatal(err)
}
defer c.Close()
var mounter nfsc.Mount
mounter.Client = c
target, err := mounter.Mount("/", rpc.AuthNull)
if err != nil {
t.Fatal(err)
}
defer func() {
_ = mounter.Unmount()
}()
_, err = target.FSInfo()
if err != nil {
t.Fatal(err)
}
// Validate sample file creation
_, err = target.Create("/helloworld.txt", 0666)
if err != nil {
t.Fatal(err)
}
if info, err := mem.Stat(ctx, "/helloworld.txt"); err != nil {
t.Fatal(err)
} else {
if info.Size() != 0 || info.Mode().Perm() != 0666 {
t.Fatal("incorrect creation.")
}
}
// Validate writing to a file.
f, err := target.OpenFile("/helloworld.txt", 0666)
if err != nil {
t.Fatal(err)
}
b := []byte("hello world")
_, err = f.Write(b)
if err != nil {
t.Fatal(err)
}
mf, _ := mem.OpenFile(ctx, "/helloworld.txt", os.O_RDONLY, 0)
buf := make([]byte, len(b))
if _, err = mf.ReadAt(ctx, buf[:], 0); err != nil {
t.Fatal(err)
}
if !bytes.Equal(buf, b) {
t.Fatal("written does not match expected")
}
// for test nfs.ReadDirPlus in case of many files
dirF1, err := mem.ReadDir(ctx, "/")
if err != nil {
t.Fatal(err)
}
shouldBeNames := []string{}
for _, f := range dirF1 {
shouldBeNames = append(shouldBeNames, f.Name())
}
for i := 0; i < 2000; i++ {
fName := fmt.Sprintf("f-%04d.txt", i)
shouldBeNames = append(shouldBeNames, fName)
f, err := mem.Create(ctx, fName)
if err != nil {
t.Fatal(err)
}
f.Close(ctx)
}
manyEntitiesPlus, err := target.ReadDirPlus("/")
if err != nil {
t.Fatal(err)
}
actualBeNamesPlus := []string{}
for _, e := range manyEntitiesPlus {
actualBeNamesPlus = append(actualBeNamesPlus, e.Name())
}
as := sort.StringSlice(shouldBeNames)
bs := sort.StringSlice(actualBeNamesPlus)
as.Sort()
bs.Sort()
if !reflect.DeepEqual(as, bs) {
t.Fatal("nfs.ReadDirPlus error")
}
// for test nfs.ReadDir in case of many files
manyEntities, err := readDir(target, "/")
if err != nil {
t.Fatal(err)
}
actualBeNames := []string{}
for _, e := range manyEntities {
actualBeNames = append(actualBeNames, e.FileName)
}
as2 := sort.StringSlice(shouldBeNames)
bs2 := sort.StringSlice(actualBeNames)
as2.Sort()
bs2.Sort()
if !reflect.DeepEqual(as2, bs2) {
fmt.Printf("should be %v\n", as2)
fmt.Printf("actual be %v\n", bs2)
t.Fatal("nfs.ReadDir error")
}
// confirm rename works as expected
oldFA, _, err := target.Lookup("/f-0010.txt", false)
if err != nil {
t.Fatal(err)
}
if err := target.Rename("/f-0010.txt", "/g-0010.txt"); err != nil {
t.Fatal(err)
}
new, _, err := target.Lookup("/g-0010.txt", false)
if err != nil {
t.Fatal(err)
}
if new.Sys() != oldFA.Sys() {
t.Fatal("rename failed to update")
}
_, _, err = target.Lookup("/f-0010.txt", false)
if err == nil {
t.Fatal("old handle should be invalid")
}
// for test nfs.ReadDirPlus in case of empty directory
_, err = target.Mkdir("/empty", 0755)
if err != nil {
t.Fatal(err)
}
emptyEntitiesPlus, err := target.ReadDirPlus("/empty")
if err != nil {
t.Fatal(err)
}
if len(emptyEntitiesPlus) != 0 {
t.Fatal("nfs.ReadDirPlus error reading empty dir")
}
// for test nfs.ReadDir in case of empty directory
emptyEntities, err := readDir(target, "/empty")
if err != nil {
t.Fatal(err)
}
if len(emptyEntities) != 0 {
t.Fatal("nfs.ReadDir error reading empty dir")
}
}
type readDirEntry struct {
FileId uint64
FileName string
Cookie uint64
}
// readDir implementation "appropriated" from go-nfs-client implementation of READDIRPLUS
func readDir(target *nfsc.Target, dir string) ([]*readDirEntry, error) {
_, fh, err := target.Lookup(dir)
if err != nil {
return nil, err
}
type readDirArgs struct {
rpc.Header
Handle []byte
Cookie uint64
CookieVerif uint64
Count uint32
}
type readDirList struct {
IsSet bool `xdr:"union"`
Entry readDirEntry `xdr:"unioncase=1"`
}
type readDirListOK struct {
DirAttrs nfsc.PostOpAttr
CookieVerf uint64
}
cookie := uint64(0)
cookieVerf := uint64(0)
eof := false
var entries []*readDirEntry
for !eof {
res, err := target.Call(&readDirArgs{
Header: rpc.Header{
Rpcvers: 2,
Vers: nfsc.Nfs3Vers,
Prog: nfsc.Nfs3Prog,
Proc: uint32(nfs.NFSProcedureReadDir),
Cred: rpc.AuthNull,
Verf: rpc.AuthNull,
},
Handle: fh,
Cookie: cookie,
CookieVerif: cookieVerf,
Count: 4096,
})
if err != nil {
return nil, err
}
status, err := xdr.ReadUint32(res)
if err != nil {
return nil, err
}
if err = nfsc.NFS3Error(status); err != nil {
return nil, err
}
dirListOK := new(readDirListOK)
if err = xdr.Read(res, dirListOK); err != nil {
return nil, err
}
for {
var item readDirList
if err = xdr.Read(res, &item); err != nil {
return nil, err
}
if !item.IsSet {
break
}
cookie = item.Entry.Cookie
if item.Entry.FileName == "." || item.Entry.FileName == ".." {
continue
}
entries = append(entries, &item.Entry)
}
if err = xdr.Read(res, &eof); err != nil {
return nil, err
}
cookieVerf = dirListOK.CookieVerf
}
return entries, nil
}

View file

@ -0,0 +1,190 @@
package nfs
// NFSProcedure is the valid RPC calls for the nfs service.
type NFSProcedure uint32
// NfsProcedure Codes
const (
NFSProcedureNull NFSProcedure = iota
NFSProcedureGetAttr
NFSProcedureSetAttr
NFSProcedureLookup
NFSProcedureAccess
NFSProcedureReadlink
NFSProcedureRead
NFSProcedureWrite
NFSProcedureCreate
NFSProcedureMkDir
NFSProcedureSymlink
NFSProcedureMkNod
NFSProcedureRemove
NFSProcedureRmDir
NFSProcedureRename
NFSProcedureLink
NFSProcedureReadDir
NFSProcedureReadDirPlus
NFSProcedureFSStat
NFSProcedureFSInfo
NFSProcedurePathConf
NFSProcedureCommit
)
func (n NFSProcedure) String() string {
switch n {
case NFSProcedureNull:
return "Null"
case NFSProcedureGetAttr:
return "GetAttr"
case NFSProcedureSetAttr:
return "SetAttr"
case NFSProcedureLookup:
return "Lookup"
case NFSProcedureAccess:
return "Access"
case NFSProcedureReadlink:
return "ReadLink"
case NFSProcedureRead:
return "Read"
case NFSProcedureWrite:
return "Write"
case NFSProcedureCreate:
return "Create"
case NFSProcedureMkDir:
return "Mkdir"
case NFSProcedureSymlink:
return "Symlink"
case NFSProcedureMkNod:
return "Mknod"
case NFSProcedureRemove:
return "Remove"
case NFSProcedureRmDir:
return "Rmdir"
case NFSProcedureRename:
return "Rename"
case NFSProcedureLink:
return "Link"
case NFSProcedureReadDir:
return "ReadDir"
case NFSProcedureReadDirPlus:
return "ReadDirPlus"
case NFSProcedureFSStat:
return "FSStat"
case NFSProcedureFSInfo:
return "FSInfo"
case NFSProcedurePathConf:
return "PathConf"
case NFSProcedureCommit:
return "Commit"
default:
return "Unknown"
}
}
const timeoutStatus = NFSStatusIO
// NFSStatus (nfsstat3) is a result code for nfs rpc calls
type NFSStatus uint32
// NFSStatus codes
const (
NFSStatusOk NFSStatus = 0
NFSStatusPerm NFSStatus = 1
NFSStatusNoEnt NFSStatus = 2
NFSStatusIO NFSStatus = 5
NFSStatusNXIO NFSStatus = 6
NFSStatusAccess NFSStatus = 13
NFSStatusExist NFSStatus = 17
NFSStatusXDev NFSStatus = 18
NFSStatusNoDev NFSStatus = 19
NFSStatusNotDir NFSStatus = 20
NFSStatusIsDir NFSStatus = 21
NFSStatusInval NFSStatus = 22
NFSStatusFBig NFSStatus = 27
NFSStatusNoSPC NFSStatus = 28
NFSStatusROFS NFSStatus = 30
NFSStatusMlink NFSStatus = 31
NFSStatusNameTooLong NFSStatus = 63
NFSStatusNotEmpty NFSStatus = 66
NFSStatusDQuot NFSStatus = 69
NFSStatusStale NFSStatus = 70
NFSStatusRemote NFSStatus = 71
NFSStatusBadHandle NFSStatus = 10001
NFSStatusNotSync NFSStatus = 10002
NFSStatusBadCookie NFSStatus = 10003
NFSStatusNotSupp NFSStatus = 10004
NFSStatusTooSmall NFSStatus = 10005
NFSStatusServerFault NFSStatus = 10006
NFSStatusBadType NFSStatus = 10007
NFSStatusJukebox NFSStatus = 10008
)
func (s NFSStatus) String() string {
switch s {
case NFSStatusOk:
return "Call Completed Successfull"
case NFSStatusPerm:
return "Not Owner"
case NFSStatusNoEnt:
return "No such file or directory"
case NFSStatusIO:
return "I/O error"
case NFSStatusNXIO:
return "I/O error: No such device"
case NFSStatusAccess:
return "Permission denied"
case NFSStatusExist:
return "File exists"
case NFSStatusXDev:
return "Attempt to do a cross device hard link"
case NFSStatusNoDev:
return "No such device"
case NFSStatusNotDir:
return "Not a directory"
case NFSStatusIsDir:
return "Is a directory"
case NFSStatusInval:
return "Invalid argument"
case NFSStatusFBig:
return "File too large"
case NFSStatusNoSPC:
return "No space left on device"
case NFSStatusROFS:
return "Read only file system"
case NFSStatusMlink:
return "Too many hard links"
case NFSStatusNameTooLong:
return "Name too long"
case NFSStatusNotEmpty:
return "Not empty"
case NFSStatusDQuot:
return "Resource quota exceeded"
case NFSStatusStale:
return "Invalid file handle"
case NFSStatusRemote:
return "Too many levels of remote in path"
case NFSStatusBadHandle:
return "Illegal NFS file handle"
case NFSStatusNotSync:
return "Synchronization mismatch"
case NFSStatusBadCookie:
return "Cookie is Stale"
case NFSStatusNotSupp:
return "Operation not supported"
case NFSStatusTooSmall:
return "Buffer or request too small"
case NFSStatusServerFault:
return "Unmapped error (EIO)"
case NFSStatusBadType:
return "Type not supported"
case NFSStatusJukebox:
return "Initiated, but too slow. Try again with new txn"
default:
return "unknown"
}
}
// DirOpArg is a common serialization used for referencing an object in a directory
type DirOpArg struct {
Handle []byte
Filename []byte
}

102
server/pkg/go-nfs/server.go Normal file
View file

@ -0,0 +1,102 @@
package nfs
import (
"bytes"
"context"
"crypto/rand"
"errors"
"net"
"time"
)
// Server is a handle to the listening NFS server.
type Server struct {
Handler
ID [8]byte
}
// RegisterMessageHandler registers a handler for a specific
// XDR procedure.
func RegisterMessageHandler(protocol uint32, proc uint32, handler HandleFunc) error {
if registeredHandlers == nil {
registeredHandlers = make(map[registeredHandlerID]HandleFunc)
}
for k := range registeredHandlers {
if k.protocol == protocol && k.proc == proc {
return errors.New("already registered")
}
}
id := registeredHandlerID{protocol, proc}
registeredHandlers[id] = handler
return nil
}
// HandleFunc represents a handler for a specific protocol message.
type HandleFunc func(ctx context.Context, w *response, userHandler Handler) error
// TODO: store directly as a uint64 for more efficient lookups
type registeredHandlerID struct {
protocol uint32
proc uint32
}
var registeredHandlers map[registeredHandlerID]HandleFunc
// Serve listens on the provided listener port for incoming client requests.
func (s *Server) Serve(l net.Listener) error {
defer l.Close()
if bytes.Equal(s.ID[:], []byte{0, 0, 0, 0, 0, 0, 0, 0}) {
if _, err := rand.Reader.Read(s.ID[:]); err != nil {
return err
}
}
var tempDelay time.Duration
for {
conn, err := l.Accept()
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
time.Sleep(tempDelay)
continue
}
return err
}
tempDelay = 0
c := s.newConn(conn)
go c.serve()
}
}
func (s *Server) newConn(nc net.Conn) *conn {
c := &conn{
Server: s,
Conn: nc,
}
return c
}
// TODO: keep an immutable map for each server instance to have less
// chance of races.
func (s *Server) handlerFor(prog uint32, proc uint32) HandleFunc {
for k, v := range registeredHandlers {
if k.protocol == prog && k.proc == proc {
return v
}
}
return nil
}
// Serve is a singleton listener paralleling http.Serve
func Serve(l net.Listener, handler Handler) error {
srv := &Server{Handler: handler}
return srv.Serve(l)
}

32
server/pkg/go-nfs/time.go Normal file
View file

@ -0,0 +1,32 @@
package nfs
import (
"time"
)
// FileTime is the NFS wire time format
// This is equivalent to go-nfs-client/nfs.NFS3Time
type FileTime struct {
Seconds uint32
Nseconds uint32
}
// ToNFSTime generates the nfs 64bit time format from a golang time.
func ToNFSTime(t time.Time) FileTime {
return FileTime{
Seconds: uint32(t.Unix()),
Nseconds: uint32(t.UnixNano() % int64(time.Second)),
}
}
// Native generates a golang time from an nfs time spec
func (t FileTime) Native() *time.Time {
ts := time.Unix(int64(t.Seconds), int64(t.Nseconds))
return &ts
}
// EqualTimespec returns if this time is equal to a local time spec
func (t FileTime) EqualTimespec(sec int64, nsec int64) bool {
// TODO: bounds check on sec/nsec overflow
return t.Nseconds == uint32(nsec) && t.Seconds == uint32(sec)
}

View file

@ -0,0 +1,71 @@
package ioutils
import (
"context"
"errors"
"io"
"sync"
"github.com/royalcat/ctxio"
)
type FileReader interface {
ctxio.ReaderAt
ctxio.Reader
ctxio.Closer
}
type CacheReader struct {
m sync.Mutex
fo int64
fr *FileBuffer
to int64
tr ctxio.Reader
}
var _ FileReader = (*CacheReader)(nil)
func NewCacheReader(r ctxio.Reader) (FileReader, error) {
fr := NewFileBuffer(nil)
tr := ctxio.TeeReader(r, fr)
return &CacheReader{fr: fr, tr: tr}, nil
}
func (dtr *CacheReader) ReadAt(ctx context.Context, p []byte, off int64) (int, error) {
dtr.m.Lock()
defer dtr.m.Unlock()
tb := off + int64(len(p))
if tb > dtr.fo {
w, err := ctxio.CopyN(ctx, ctxio.Discard, dtr.tr, tb-dtr.fo)
dtr.to += w
if err != nil && err != io.EOF {
return 0, err
}
}
n, err := dtr.fr.ReadAt(ctx, p, off)
dtr.fo += int64(n)
return n, err
}
func (dtr *CacheReader) Read(ctx context.Context, p []byte) (n int, err error) {
dtr.m.Lock()
defer dtr.m.Unlock()
// use directly tee reader here
n, err = dtr.tr.Read(ctx, p)
dtr.to += int64(n)
return
}
func (dtr *CacheReader) Close(ctx context.Context) error {
frcloser := dtr.fr.Close(ctx)
var closeerr error
if rc, ok := dtr.tr.(ctxio.ReadCloser); ok {
closeerr = rc.Close(ctx)
}
return errors.Join(frcloser, closeerr)
}

View file

@ -0,0 +1,72 @@
package ioutils
import (
"context"
"io"
"os"
"sync"
"github.com/royalcat/ctxio"
)
type DiskCacheReader struct {
m sync.Mutex
fo int64
fr *os.File
to int64
tr ctxio.Reader
}
var _ ctxio.ReaderAt = (*DiskCacheReader)(nil)
var _ ctxio.Reader = (*DiskCacheReader)(nil)
var _ ctxio.Closer = (*DiskCacheReader)(nil)
func NewDiskCacheReader(r ctxio.Reader) (*DiskCacheReader, error) {
tempDir, err := os.MkdirTemp("/tmp", "tstor")
if err != nil {
return nil, err
}
fr, err := os.CreateTemp(tempDir, "dtb_tmp")
if err != nil {
return nil, err
}
tr := ctxio.TeeReader(r, ctxio.WrapIoWriter(fr))
return &DiskCacheReader{fr: fr, tr: tr}, nil
}
func (dtr *DiskCacheReader) ReadAt(ctx context.Context, p []byte, off int64) (int, error) {
dtr.m.Lock()
defer dtr.m.Unlock()
tb := off + int64(len(p))
if tb > dtr.fo {
w, err := ctxio.CopyN(ctx, ctxio.Discard, dtr.tr, tb-dtr.fo)
dtr.to += w
if err != nil && err != io.EOF {
return 0, err
}
}
n, err := dtr.fr.ReadAt(p, off)
dtr.fo += int64(n)
return n, err
}
func (dtr *DiskCacheReader) Read(ctx context.Context, p []byte) (n int, err error) {
dtr.m.Lock()
defer dtr.m.Unlock()
// use directly tee reader here
n, err = dtr.tr.Read(ctx, p)
dtr.to += int64(n)
return
}
func (dtr *DiskCacheReader) Close(ctx context.Context) error {
if err := dtr.fr.Close(); err != nil {
return err
}
return os.Remove(dtr.fr.Name())
}

View file

@ -0,0 +1,205 @@
package ioutils
import (
"bytes"
"context"
"errors"
"io"
"os"
"sync"
"github.com/royalcat/ctxio"
)
// FileBuffer implements interfaces implemented by files.
// The main purpose of this type is to have an in memory replacement for a
// file.
type FileBuffer struct {
// buff is the backing buffer
buff *bytes.Buffer
// index indicates where in the buffer we are at
index int64
isClosed bool
mu sync.RWMutex
}
var _ FileReader = (*FileBuffer)(nil)
var _ ctxio.Writer = (*FileBuffer)(nil)
// NewFileBuffer returns a new populated Buffer
func NewFileBuffer(b []byte) *FileBuffer {
return &FileBuffer{buff: bytes.NewBuffer(b)}
}
// NewFileBufferFromReader is a convenience method that returns a new populated Buffer
// whose contents are sourced from a supplied reader by loading it entirely
// into memory.
func NewFileBufferFromReader(ctx context.Context, reader ctxio.Reader) (*FileBuffer, error) {
data, err := ctxio.ReadAll(ctx, reader)
if err != nil {
return nil, err
}
return NewFileBuffer(data), nil
}
// NewFileBufferFromReader is a convenience method that returns a new populated Buffer
// whose contents are sourced from a supplied reader by loading it entirely
// into memory.
func NewFileBufferFromIoReader(reader io.Reader) (*FileBuffer, error) {
data, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
return NewFileBuffer(data), nil
}
// Bytes returns the bytes available until the end of the buffer.
func (f *FileBuffer) Bytes() []byte {
f.mu.RLock()
defer f.mu.RUnlock()
if f.isClosed || f.index >= int64(f.buff.Len()) {
return []byte{}
}
return bytes.Clone(f.buff.Bytes()[f.index:])
}
// String implements the Stringer interface
func (f *FileBuffer) String() string {
f.mu.RLock()
defer f.mu.RUnlock()
return string(f.buff.Bytes()[f.index:])
}
// Read implements io.Reader https://golang.org/pkg/io/#Reader
// Read reads up to len(p) bytes into p. It returns the number of bytes read (0 <= n <= len(p))
// and any error encountered. Even if Read returns n < len(p), it may use all of p as scratch
// space during the call. If some data is available but not len(p) bytes, Read conventionally
// returns what is available instead of waiting for more.
// When Read encounters an error or end-of-file condition after successfully reading n > 0 bytes,
// it returns the number of bytes read. It may return the (non-nil) error from the same call or
// return the error (and n == 0) from a subsequent call. An instance of this general case is
// that a Reader returning a non-zero number of bytes at the end of the input stream may return
// either err == EOF or err == nil. The next Read should return 0, EOF.
func (f *FileBuffer) Read(ctx context.Context, b []byte) (n int, err error) {
f.mu.RLock()
defer f.mu.RUnlock()
if f.isClosed {
return 0, os.ErrClosed
}
if len(b) == 0 {
return 0, nil
}
if f.index >= int64(f.buff.Len()) {
return 0, io.EOF
}
n, err = bytes.NewBuffer(f.buff.Bytes()[f.index:]).Read(b)
f.index += int64(n)
return n, err
}
// ReadAt implements io.ReaderAt https://golang.org/pkg/io/#ReaderAt
// ReadAt reads len(p) bytes into p starting at offset off in the underlying input source.
// It returns the number of bytes read (0 <= n <= len(p)) and any error encountered.
//
// When ReadAt returns n < len(p), it returns a non-nil error explaining why more bytes were not returned.
// In this respect, ReadAt is stricter than Read.
//
// Even if ReadAt returns n < len(p), it may use all of p as scratch space during the call.
// If some data is available but not len(p) bytes, ReadAt blocks until either all the data is available or an error occurs.
// In this respect ReadAt is different from Read.
//
// If the n = len(p) bytes returned by ReadAt are at the end of the input source,
// ReadAt may return either err == EOF or err == nil.
//
// If ReadAt is reading from an input source with a seek offset,
// ReadAt should not affect nor be affected by the underlying seek offset.
// Clients of ReadAt can execute parallel ReadAt calls on the same input source.
func (f *FileBuffer) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) {
f.mu.RLock()
defer f.mu.RUnlock()
if f.isClosed {
return 0, os.ErrClosed
}
if off < 0 {
return 0, errors.New("filebuffer.ReadAt: negative offset")
}
reqLen := len(p)
buffLen := int64(f.buff.Len())
if off >= buffLen {
return 0, io.EOF
}
n = copy(p, f.buff.Bytes()[off:])
if n < reqLen {
err = io.EOF
}
return n, err
}
// Write implements io.Writer https://golang.org/pkg/io/#Writer
// by appending the passed bytes to the buffer unless the buffer is closed or index negative.
func (f *FileBuffer) Write(ctx context.Context, p []byte) (n int, err error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.isClosed {
return 0, os.ErrClosed
}
if f.index < 0 {
return 0, io.EOF
}
// we might have rewinded, let's reset the buffer before appending to it
idx := int(f.index)
buffLen := f.buff.Len()
if idx != buffLen && idx <= buffLen {
f.buff = bytes.NewBuffer(f.Bytes()[:f.index])
}
n, err = f.buff.Write(p)
f.index += int64(n)
return n, err
}
// Seek implements io.Seeker https://golang.org/pkg/io/#Seeker
func (f *FileBuffer) Seek(offset int64, whence int) (idx int64, err error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.isClosed {
return 0, os.ErrClosed
}
var abs int64
switch whence {
case 0:
abs = offset
case 1:
abs = int64(f.index) + offset
case 2:
abs = int64(f.buff.Len()) + offset
default:
return 0, errors.New("filebuffer.Seek: invalid whence")
}
if abs < 0 {
return 0, errors.New("filebuffer.Seek: negative position")
}
f.index = abs
return abs, nil
}
// Close implements io.Closer https://golang.org/pkg/io/#Closer
// It closes the buffer, rendering it unusable for I/O. It returns an error, if any.
func (f *FileBuffer) Close(ctx context.Context) error {
f.mu.Lock()
defer f.mu.Unlock()
f.isClosed = true
f.buff = nil
return nil
}

View file

@ -0,0 +1,49 @@
package ioutils
import (
"context"
"sync"
"github.com/royalcat/ctxio"
)
type ReaderReaderAtWrapper struct {
mu sync.Mutex
rat ctxio.ReaderAt
offset int64
}
func NewReaderReaderAtWrapper(rat ctxio.ReaderAt) *ReaderReaderAtWrapper {
return &ReaderReaderAtWrapper{
rat: rat,
}
}
var _ ctxio.Reader = (*ReaderReaderAtWrapper)(nil)
var _ ctxio.ReaderAt = (*ReaderReaderAtWrapper)(nil)
var _ ctxio.Closer = (*ReaderReaderAtWrapper)(nil)
// Read implements Reader.
func (r *ReaderReaderAtWrapper) Read(ctx context.Context, p []byte) (n int, err error) {
r.mu.Lock()
defer r.mu.Unlock()
n, err = r.rat.ReadAt(ctx, p, r.offset)
r.offset += int64(n)
return n, err
}
// ReadAt implements ReaderAt.
func (r *ReaderReaderAtWrapper) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) {
return r.rat.ReadAt(ctx, p, off)
}
// Close implements Closer.
func (r *ReaderReaderAtWrapper) Close(ctx context.Context) (err error) {
if c, ok := r.rat.(ctxio.Closer); ok {
err = c.Close(ctx)
if err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,104 @@
package ioutils
import (
"context"
"io"
"sync"
"github.com/royalcat/ctxio"
)
type ioSeekerWrapper struct {
ctx context.Context
mu sync.Mutex
pos int64
size int64
r ctxio.ReaderAt
}
func WrapIoReadSeeker(ctx context.Context, r ctxio.ReaderAt, size int64) io.ReadSeeker {
return &ioSeekerWrapper{
ctx: ctx,
r: r,
size: size,
}
}
func (r *ioSeekerWrapper) Seek(offset int64, whence int) (int64, error) {
r.mu.Lock()
defer r.mu.Unlock()
switch whence {
case io.SeekStart:
r.pos = offset
case io.SeekCurrent:
r.pos = r.pos + offset
case io.SeekEnd:
r.pos = r.size + offset
}
return r.pos, nil
}
func (r *ioSeekerWrapper) Read(p []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
n, err := r.r.ReadAt(r.ctx, p, r.pos)
r.pos += int64(n)
return n, err
}
var _ io.ReadSeekCloser = (*ioSeekerCloserWrapper)(nil)
type ioSeekerCloserWrapper struct {
ctx context.Context
mu sync.Mutex
pos int64
size int64
r FileReader
}
func IoReadSeekCloserWrapper(ctx context.Context, r FileReader, size int64) io.ReadSeekCloser {
return &ioSeekerCloserWrapper{
ctx: ctx,
r: r,
size: size,
}
}
func (r *ioSeekerCloserWrapper) Seek(offset int64, whence int) (int64, error) {
r.mu.Lock()
defer r.mu.Unlock()
switch whence {
case io.SeekStart:
r.pos = offset
case io.SeekCurrent:
r.pos = r.pos + offset
case io.SeekEnd:
r.pos = r.size + offset
}
return r.pos, nil
}
func (r *ioSeekerCloserWrapper) Read(p []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
n, err := r.r.ReadAt(r.ctx, p, r.pos)
r.pos += int64(n)
return n, err
}
// Close implements io.ReadSeekCloser.
func (r *ioSeekerCloserWrapper) Close() error {
return r.r.Close(r.ctx)
}

View file

@ -0,0 +1,28 @@
package kvsingle
import (
"context"
"github.com/royalcat/kv"
)
type Value[K, V any] struct {
Key K
db kv.Store[K, V]
}
func New[K, V any](db kv.Store[K, V], key K) *Value[K, V] {
return &Value[K, V]{Key: key, db: db}
}
func (s *Value[K, V]) Get(ctx context.Context) (V, error) {
return s.db.Get(ctx, s.Key)
}
func (s *Value[K, V]) Set(ctx context.Context, value V) error {
return s.db.Set(ctx, s.Key, value)
}
func (s *Value[K, V]) Edit(ctx context.Context, edit kv.Edit[V]) error {
return s.db.Edit(ctx, s.Key, edit)
}

View file

@ -0,0 +1,150 @@
package kvtrace
import (
"context"
"github.com/royalcat/kv"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
var tracer = otel.Tracer("github.com/royalcat/kv/tracer")
type traceStore[K, V any] struct {
kv kv.Store[K, V]
attrs []attribute.KeyValue
}
func WrapTracing[K, V any](kv kv.Store[K, V], attrs ...attribute.KeyValue) kv.Store[K, V] {
return &traceStore[K, V]{
kv: kv,
attrs: attrs,
}
}
// Close implements kv.Store.
func (m *traceStore[K, V]) Close(ctx context.Context) (err error) {
ctx, span := tracer.Start(ctx, "Close", trace.WithAttributes(m.attrs...))
defer span.End()
defer func() {
if err != nil && err != kv.ErrKeyNotFound {
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
}
}()
return m.kv.Close(ctx)
}
// Delete implements kv.Store.
func (m *traceStore[K, V]) Delete(ctx context.Context, k K) (err error) {
ctx, span := tracer.Start(ctx, "Delete", trace.WithAttributes(m.attrs...))
defer span.End()
defer func() {
if err != nil && err != kv.ErrKeyNotFound {
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
}
}()
return m.kv.Delete(ctx, k)
}
// Get implements kv.Store.
func (m *traceStore[K, V]) Get(ctx context.Context, k K) (v V, err error) {
ctx, span := tracer.Start(ctx, "Get", trace.WithAttributes(m.attrs...))
defer span.End()
defer func() {
if err != nil && err != kv.ErrKeyNotFound {
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
}
}()
return m.kv.Get(ctx, k)
}
// Get implements kv.Store.
func (m *traceStore[K, V]) Edit(ctx context.Context, k K, edit kv.Edit[V]) (err error) {
ctx, span := tracer.Start(ctx, "Get", trace.WithAttributes(m.attrs...))
defer span.End()
defer func() {
if err != nil && err != kv.ErrKeyNotFound {
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
}
}()
return m.kv.Edit(ctx, k, edit)
}
// Range implements kv.Store.
func (m *traceStore[K, V]) Range(ctx context.Context, iter kv.Iter[K, V]) (err error) {
ctx, span := tracer.Start(ctx, "Range", trace.WithAttributes(m.attrs...))
defer span.End()
defer func() {
if err != nil {
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
}
}()
count := 0
iterCount := func(k K, v V) error {
count++
return iter(k, v)
}
defer func() {
span.SetAttributes(attribute.Int("count", count))
}()
return m.kv.Range(ctx, iterCount)
}
// RangeWithPrefix implements kv.Store.
func (m *traceStore[K, V]) RangeWithPrefix(ctx context.Context, k K, iter kv.Iter[K, V]) (err error) {
ctx, span := tracer.Start(ctx, "RangeWithPrefix", trace.WithAttributes(m.attrs...))
defer span.End()
defer func() {
if err != nil {
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
}
}()
count := 0
iterCount := func(k K, v V) error {
count++
return iter(k, v)
}
defer func() {
span.SetAttributes(attribute.Int("count", count))
}()
return m.kv.Range(ctx, iterCount)
}
// Set implements kv.Store.
func (m *traceStore[K, V]) Set(ctx context.Context, k K, v V) (err error) {
ctx, span := tracer.Start(ctx, "Set", trace.WithAttributes(m.attrs...))
defer span.End()
defer func() {
if err != nil {
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
}
}()
return m.kv.Set(ctx, k, v)
}
var _ kv.Store[any, any] = (*traceStore[any, any])(nil)

View file

@ -0,0 +1,114 @@
package maxcache
import (
"context"
"sync"
"time"
"github.com/cespare/xxhash/v2"
"github.com/goware/singleflight"
lru "github.com/hashicorp/golang-lru/v2"
)
func NewCache[K comparable, V any](size int, freshFor, ttl time.Duration) *Cache[K, V] {
values, _ := lru.New[K, value[V]](size)
return &Cache[K, V]{
values: values,
}
}
type Cache[K comparable, V any] struct {
values *lru.Cache[K, value[V]]
mu sync.RWMutex
callGroup singleflight.Group[K, V]
}
func (c *Cache[K, V]) Get(ctx context.Context, key K, fn singleflight.DoFunc[V]) (V, error) {
return c.get(ctx, key, false, fn)
}
func (c *Cache[K, V]) GetFresh(ctx context.Context, key K, fn singleflight.DoFunc[V]) (V, error) {
return c.get(ctx, key, true, fn)
}
func (c *Cache[K, V]) Set(ctx context.Context, key K, fn singleflight.DoFunc[V]) (V, bool, error) {
v, err, shared := c.callGroup.Do(key, c.set(key, fn))
return v, shared, err
}
func (c *Cache[K, V]) get(ctx context.Context, key K, freshOnly bool, fn singleflight.DoFunc[V]) (V, error) {
c.mu.RLock()
val, ok := c.values.Get(key)
c.mu.RUnlock()
// value exists and is fresh - just return
if ok && val.IsFresh() {
return val.Value(), nil
}
// value exists and is stale, and we're OK with serving it stale while updating in the background
// note: stale means its still okay, but not fresh. but if its expired, then it means its useless.
if ok && !freshOnly && !val.IsExpired() {
// TODO: technically could be a stampede of goroutines here if the value is expired
// and we're OK with serving it stale
go c.Set(ctx, key, fn)
return val.Value(), nil
}
// value doesn't exist or is expired, or is stale and we need it fresh (freshOnly:true) - sync update
v, _, err := c.Set(ctx, key, fn)
return v, err
}
func (c *Cache[K, V]) set(key K, fn singleflight.DoFunc[V]) singleflight.DoFunc[V] {
return singleflight.DoFunc[V](func() (V, error) {
val, err := fn()
if err != nil {
return val, err
}
c.mu.Lock()
c.values.Add(key, value[V]{
v: val,
})
c.mu.Unlock()
return val, nil
})
}
type value[V any] struct {
v V
bestBefore time.Time // cache entry freshness cutoff
expiry time.Time // cache entry time to live cutoff
}
func (v *value[V]) IsFresh() bool {
return v.bestBefore.After(time.Now())
}
func (v *value[V]) IsExpired() bool {
return v.expiry.Before(time.Now())
}
func (v *value[V]) Value() V {
return v.v
}
func BytesToHash(b ...[]byte) uint64 {
d := xxhash.New()
for _, v := range b {
d.Write(v)
}
return d.Sum64()
}
func StringToHash(s ...string) uint64 {
d := xxhash.New()
for _, v := range s {
d.WriteString(v)
}
return d.Sum64()
}

View file

@ -0,0 +1,204 @@
package maxcache_test
import (
"context"
"io"
"log"
"net/http"
"net/http/httptest"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/go-chi/stampede"
"github.com/stretchr/testify/assert"
)
func TestGet(t *testing.T) {
var count uint64
cache := stampede.NewCache(512, time.Duration(2*time.Second), time.Duration(5*time.Second))
// repeat test multiple times
for x := 0; x < 5; x++ {
// time.Sleep(1 * time.Second)
var wg sync.WaitGroup
numGoroutines := runtime.NumGoroutine()
n := 10
ctx := context.Background()
for i := 0; i < n; i++ {
t.Logf("numGoroutines now %d", runtime.NumGoroutine())
wg.Add(1)
go func() {
defer wg.Done()
val, err := cache.Get(ctx, "t1", func() (any, error) {
t.Log("cache.Get(t1, ...)")
// some extensive op..
time.Sleep(2 * time.Second)
atomic.AddUint64(&count, 1)
return "result1", nil
})
assert.NoError(t, err)
assert.Equal(t, "result1", val)
}()
}
wg.Wait()
// ensure single call
assert.Equal(t, uint64(1), count)
// confirm same before/after num of goroutines
t.Logf("numGoroutines now %d", runtime.NumGoroutine())
assert.Equal(t, numGoroutines, runtime.NumGoroutine())
}
}
func TestHandler(t *testing.T) {
var numRequests = 30
var hits uint32
var expectedStatus int = 201
var expectedBody = []byte("hi")
app := func(w http.ResponseWriter, r *http.Request) {
// log.Println("app handler..")
atomic.AddUint32(&hits, 1)
hitsNow := atomic.LoadUint32(&hits)
if hitsNow > 1 {
// panic("uh oh")
}
// time.Sleep(100 * time.Millisecond) // slow handler
w.Header().Set("X-Httpjoin", "test")
w.WriteHeader(expectedStatus)
w.Write(expectedBody)
}
var count uint32
counter := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddUint32(&count, 1)
next.ServeHTTP(w, r)
atomic.AddUint32(&count, ^uint32(0))
// log.Println("COUNT:", atomic.LoadUint32(&count))
})
}
recoverer := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if r := recover(); r != nil {
log.Println("recovered panicing request:", r)
}
}()
next.ServeHTTP(w, r)
})
}
h := stampede.Handler(512, 1*time.Second)
ts := httptest.NewServer(counter(recoverer(h(http.HandlerFunc(app)))))
defer ts.Close()
var wg sync.WaitGroup
for i := 0; i < numRequests; i++ {
wg.Add(1)
go func() {
defer wg.Done()
resp, err := http.Get(ts.URL)
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
// log.Println("got resp:", resp, "len:", len(body), "body:", string(body))
if string(body) != string(expectedBody) {
t.Error("expecting response body:", string(expectedBody))
}
if resp.StatusCode != expectedStatus {
t.Error("expecting response status:", expectedStatus)
}
assert.Equal(t, "test", resp.Header.Get("X-Httpjoin"), "expecting x-httpjoin test header")
}()
}
wg.Wait()
totalHits := atomic.LoadUint32(&hits)
// if totalHits > 1 {
// t.Error("handler was hit more than once. hits:", totalHits)
// }
log.Println("total hits:", totalHits)
finalCount := atomic.LoadUint32(&count)
if finalCount > 0 {
t.Error("queue count was expected to be empty, but count:", finalCount)
}
log.Println("final count:", finalCount)
}
func TestHash(t *testing.T) {
h1 := stampede.BytesToHash([]byte{1, 2, 3})
assert.Equal(t, uint64(8376154270085342629), h1)
h2 := stampede.StringToHash("123")
assert.Equal(t, uint64(4353148100880623749), h2)
}
func TestPanic(t *testing.T) {
mux := http.NewServeMux()
middleware := stampede.Handler(100, 1*time.Hour)
mux.Handle("/", middleware(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
t.Log(r.Method, r.URL)
})))
ts := httptest.NewServer(mux)
defer ts.Close()
{
req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
t.Log(resp.StatusCode)
}
{
req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
t.Log(resp.StatusCode)
}
}

142
server/pkg/rlog/rlog.go Normal file
View file

@ -0,0 +1,142 @@
package rlog
import (
"context"
"log/slog"
"os"
"runtime"
"strings"
"time"
"github.com/rs/zerolog"
slogmulti "github.com/samber/slog-multi"
slogzerolog "github.com/samber/slog-zerolog"
)
var (
zl = zerolog.New(&zerolog.ConsoleWriter{Out: os.Stderr})
handlers = []slog.Handler{
slogzerolog.Option{Logger: &zl}.NewZerologHandler(),
}
handler = slogmulti.Fanout(handlers...)
defaultLogger = slog.New(handler)
)
func init() {
slog.SetDefault(defaultLogger)
}
func AddHandler(nh slog.Handler) {
handlers = append(handlers, nh)
handler = slogmulti.Fanout(handlers...)
defaultLogger = slog.New(handler)
slog.SetDefault(defaultLogger)
}
type Logger struct {
handler slog.Handler
callNesting int
component []string
}
const functionKey = "function"
const componentKey = "component"
const componentSep = "."
func (l *Logger) log(ctx context.Context, level slog.Level, msg string, attrs ...slog.Attr) {
var pcs [1]uintptr
runtime.Callers(3+l.callNesting, pcs[:])
pc := pcs[0]
f := runtime.FuncForPC(pc)
if f != nil {
attrs = append(attrs, slog.String(functionKey, f.Name()))
}
if len(l.component) > 0 {
attrs = append(attrs, slog.String(componentKey, strings.Join(l.component, componentSep)))
}
r := slog.NewRecord(time.Now(), level, msg, pc)
r.AddAttrs(attrs...)
if ctx == nil {
ctx = context.Background()
}
_ = l.handler.Handle(ctx, r)
}
func (l *Logger) Debug(ctx context.Context, msg string, attrs ...slog.Attr) {
l.log(ctx, slog.LevelDebug, msg, attrs...)
}
func (l *Logger) Info(ctx context.Context, msg string, attrs ...slog.Attr) {
l.log(ctx, slog.LevelInfo, msg, attrs...)
}
func (l *Logger) Warn(ctx context.Context, msg string, attrs ...slog.Attr) {
l.log(ctx, slog.LevelWarn, msg, attrs...)
}
func (l *Logger) Error(ctx context.Context, msg string, attrs ...slog.Attr) {
l.log(ctx, slog.LevelError, msg, attrs...)
}
func (log *Logger) WithComponent(name string) *Logger {
return &Logger{
handler: log.handler,
component: append(log.component, name),
}
}
func (l *Logger) With(attrs ...slog.Attr) *Logger {
return &Logger{
handler: l.handler.WithAttrs(attrs),
component: l.component,
}
}
func (l *Logger) Nested(callNesting int) *Logger {
return &Logger{
handler: l.handler,
component: l.component,
callNesting: callNesting,
}
}
// returns a new slog logger with the same attribures as the original logger
// TODO currently not logging function name
func (l *Logger) Slog() *slog.Logger {
return slog.New(l.handler)
}
const errKey = "error"
func Error(err error) slog.Attr {
return slog.Attr{Key: errKey, Value: errValue(err)}
}
// errValue returns a slog.GroupValue with keys "msg" and "trace". If the error
// does not implement interface { StackTrace() errors.StackTrace }, the "trace"
// key is omitted.
func errValue(err error) slog.Value {
if err == nil {
return slog.AnyValue(nil)
}
var groupValues []slog.Attr
groupValues = append(groupValues,
slog.String("msg", err.Error()),
slog.Any("value", err),
)
return slog.GroupValue(groupValues...)
}
func Component(name ...string) *Logger {
return &Logger{
handler: handler,
component: name,
}
}

View file

@ -0,0 +1,38 @@
package slicesutils
func Intersection[T comparable](slices ...[]T) []T {
counts := map[T]int{}
result := []T{}
for _, slice := range slices {
for _, val := range slice {
counts[val]++
}
}
for val, count := range counts {
if count == len(slices) {
result = append(result, val)
}
}
return result
}
func IntersectionFunc[T any](s1 []T, s2 []T, cmp func(T, T) bool) []T {
set := make([]T, 0)
for _, a := range s1 {
for _, b := range s2 {
if cmp(a, b) {
set = append(set, a)
}
}
}
return set
}

112
server/pkg/uring/file.go Normal file
View file

@ -0,0 +1,112 @@
package uring
// import (
// "context"
// "os"
// "github.com/iceber/iouring-go"
// "go.opentelemetry.io/otel"
// "go.opentelemetry.io/otel/attribute"
// "go.opentelemetry.io/otel/trace"
// )
// var tracer = otel.Tracer("github.com/royalcat/tstor/pkg/uring")
// type FS struct {
// ur *iouring.IOURing
// }
// func NewFS(ur *iouring.IOURing) *FS {
// return &FS{
// ur: ur,
// }
// }
// func (o *FS) OpenFile(ctx context.Context, name string) (File, error) {
// ctx, span := tracer.Start(ctx, "uring.FS.OpenFile", trace.WithAttributes(attribute.String("name", name)))
// defer span.End()
// f, err := os.Open(name)
// if err != nil {
// return File{}, err
// }
// return File{
// ur: o.ur,
// f: f,
// }, nil
// }
// func NewFile(ur *iouring.IOURing, f *os.File) *File {
// return &File{
// ur: ur,
// f: f,
// }
// }
// type File struct {
// ur *iouring.IOURing
// f *os.File
// }
// func (o *File) pread(ctx context.Context, b []byte, off uint64) (int, error) {
// ctx, span := tracer.Start(ctx, "uring.File.pread", trace.WithAttributes(attribute.Int("size", len(b))))
// defer span.End()
// req, err := o.ur.Pread(o.f, b, off, nil)
// if err != nil {
// return 0, err
// }
// select {
// case <-req.Done():
// return req.GetRes()
// case <-ctx.Done():
// if _, err := req.Cancel(); err != nil {
// return 0, err
// }
// <-req.Done()
// return 0, ctx.Err()
// }
// }
// func (f *File) ReadAt(ctx context.Context, b []byte, off int64) (n int, err error) {
// ctx, span := tracer.Start(ctx, "uring.File.ReadAt", trace.WithAttributes(attribute.Int("size", len(b))))
// defer span.End()
// return f.f.ReadAt(b, off)
// for len(b) > 0 {
// if ctx.Err() != nil {
// err = ctx.Err()
// break
// }
// m, e := f.pread(ctx, b, uint64(off))
// if e != nil {
// err = e
// break
// }
// n += m
// b = b[m:]
// off += int64(m)
// }
// return n, err
// }
// func (o *File) Close(ctx context.Context) error {
// return o.f.Close()
// }
// func waitRequest(ctx context.Context, req iouring.Request) (int, error) {
// select {
// case <-req.Done():
// return req.GetRes()
// case <-ctx.Done():
// if _, err := req.Cancel(); err != nil {
// return 0, err
// }
// return 0, ctx.Err()
// }
// }

102
server/pkg/uuid/uuid.go Normal file
View file

@ -0,0 +1,102 @@
package uuid
import (
"encoding/json"
"fmt"
"io"
"strconv"
"time"
fuuid "github.com/gofrs/uuid/v5"
)
var Nil = UUID{}
type UUIDList = []UUID
type UUID struct {
fuuid.UUID
}
func New() UUID {
return UUID{fuuid.Must(fuuid.NewV7())}
}
func NewFromTime(t time.Time) UUID {
gen := fuuid.NewGenWithOptions(
fuuid.WithEpochFunc(func() time.Time { return t }),
)
return UUID{fuuid.Must(gen.NewV7())}
}
func NewP() *UUID {
return &UUID{fuuid.Must(fuuid.NewV7())}
}
func FromString(text string) (UUID, error) {
u, err := fuuid.FromString(text)
if err != nil {
return Nil, err
}
return UUID{u}, nil
}
func MustFromString(text string) UUID {
u, err := fuuid.FromString(text)
if err != nil {
panic(err)
}
return UUID{u}
}
func FromBytes(input []byte) (UUID, error) {
u, err := fuuid.FromBytes(input)
if err != nil {
return Nil, err
}
return UUID{u}, nil
}
func (a *UUID) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
if s == "" {
a.UUID = fuuid.Nil
return nil
}
return a.UUID.Parse(s)
}
func (a UUID) MarshalJSON() ([]byte, error) {
if a.IsNil() {
return json.Marshal("")
}
return json.Marshal(a.UUID)
}
// UnmarshalGQL implements the graphql.Unmarshaler interface
func (u *UUID) UnmarshalGQL(v interface{}) error {
id, ok := v.(string)
if !ok {
return fmt.Errorf("uuid must be a string")
}
return u.Parse(id)
}
// MarshalGQL implements the graphql.Marshaler interface
func (u UUID) MarshalGQL(w io.Writer) {
b := []byte(strconv.Quote(u.String()))
_, err := w.Write(b)
if err != nil {
panic(err)
}
}

View file

@ -0,0 +1,9 @@
package ytdlp
type Client struct {
binary string
}
func New() (*Client, error) {
return &Client{binary: "yt-dlp"}, nil
}

View file

@ -0,0 +1,105 @@
package ytdlp
import (
"context"
"encoding/json"
"io"
"os/exec"
"strings"
"github.com/royalcat/ctxprogress"
"golang.org/x/sync/errgroup"
)
type DownloadStatus string
const (
StatusDownloading DownloadStatus = "downloading"
StatusFinished DownloadStatus = "finished"
StatusErrored DownloadStatus = "error"
)
// Progress for the Running call
type DownloadProgress struct {
Status DownloadStatus `json:"status"`
Filename string `json:"filename"`
TmpFilename string `json:"tmpfilename"`
DownloadedBytes int64 `json:"downloaded_bytes"`
TotalBytes int64 `json:"total_bytes"`
TotalBytesEstimate float64 `json:"total_bytes_estimate"`
Elapsed float64 `json:"elapsed"`
ETA float64 `json:"eta"`
Speed float64 `json:"speed"`
FragmentIndex int64 `json:"fragment_index"`
FragmentCount int64 `json:"fragment_count"`
}
// Current implements ctxprogress.Progress.
func (d DownloadProgress) Progress() (int, int) {
if d.TotalBytes != -1 && d.TotalBytes != 0 && d.DownloadedBytes != -1 {
return int(d.DownloadedBytes), int(d.TotalBytes)
}
if d.TotalBytesEstimate != -1 && d.TotalBytesEstimate != 0 && d.DownloadedBytes != -1 {
return int(d.DownloadedBytes), int(d.TotalBytesEstimate)
}
return int(d.FragmentIndex), int(d.FragmentCount)
}
const rawProgressTemplate = `download:
%{
"status":"%(progress.status)s",
"eta":%(progress.eta|-1)s,
"speed":%(progress.speed|0)s,
"downloaded_bytes":%(progress.downloaded_bytes|-1)s,
"total_bytes": %(progress.total_bytes|-1)s,
"total_bytes_estimate": %(progress.total_bytes_estimate|-1)s,
"fragment_index":%(progress.fragment_index|-1)s,
"fragment_count":%(progress.fragment_count|-1)s
}`
var progressTemplate = strings.NewReplacer("\n", "", "\t", "", " ", "").Replace(rawProgressTemplate)
func (c *Client) Download(ctx context.Context, url string, w io.Writer) error {
args := []string{
"--progress", "--newline", "--progress-template", progressTemplate,
"-o", "-",
url,
}
group, ctx := errgroup.WithContext(ctx)
stderr, lines, err := lineReader(group)
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, c.binary, args...)
cmd.Stdout = w
cmd.Stderr = stderr
group.Go(func() error {
err := cmd.Run()
stderr.Close()
if err != nil {
return err
}
return nil
})
for line := range lines {
if line, ok := strings.CutPrefix(line, "%"); ok {
p := DownloadProgress{}
err = json.Unmarshal([]byte(line), &p)
if err != nil {
//TODO: handle error
continue
}
ctxprogress.Set(ctx, p)
}
}
return group.Wait()
}

View file

@ -0,0 +1,27 @@
package ytdlp_test
import (
"context"
"fmt"
"io"
"testing"
"git.kmsign.ru/royalcat/tstor/server/pkg/ytdlp"
"github.com/royalcat/ctxprogress"
"github.com/stretchr/testify/require"
)
func TestDownload(t *testing.T) {
require := require.New(t)
ctx := context.Background()
c, err := ytdlp.New()
require.NoError(err)
ctx = ctxprogress.New(ctx)
ctxprogress.AddCallback(ctx, func(p ctxprogress.Progress) {
cur, total := p.Progress()
fmt.Printf("%d/%d\n", cur, total)
})
err = c.Download(ctx, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", io.Discard)
require.NoError(err)
}

33
server/pkg/ytdlp/info.go Normal file
View file

@ -0,0 +1,33 @@
package ytdlp
import (
"bytes"
"context"
"encoding/json"
"os/exec"
)
func (c *Client) Info(ctx context.Context, url string) (*Info, error) {
args := []string{
"-q", "-J", url,
}
cmd := exec.CommandContext(ctx, c.binary, args...)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return nil, err
}
var info Info
err = json.Unmarshal(stdout.Bytes(), &info)
if err != nil {
return nil, err
}
return &info, nil
}

389
server/pkg/ytdlp/model.go Normal file
View file

@ -0,0 +1,389 @@
package ytdlp
type Info struct {
ID string `json:"id"`
Title string `json:"title"`
Availability string `json:"availability"`
ChannelFollowerCount *int64 `json:"channel_follower_count"`
Description string `json:"description"`
Tags []string `json:"tags"`
Thumbnails []Thumbnail `json:"thumbnails"`
ModifiedDate *string `json:"modified_date,omitempty"`
ViewCount int64 `json:"view_count"`
PlaylistCount *int64 `json:"playlist_count,omitempty"`
Channel string `json:"channel"`
ChannelID string `json:"channel_id"`
UploaderID string `json:"uploader_id"`
Uploader string `json:"uploader"`
ChannelURL string `json:"channel_url"`
UploaderURL string `json:"uploader_url"`
Type string `json:"_type"`
Entries []Entry `json:"entries,omitempty"`
ExtractorKey string `json:"extractor_key"`
Extractor string `json:"extractor"`
WebpageURL string `json:"webpage_url"`
OriginalURL string `json:"original_url"`
WebpageURLBasename string `json:"webpage_url_basename"`
WebpageURLDomain string `json:"webpage_url_domain"`
ReleaseYear interface{} `json:"release_year"`
Epoch int64 `json:"epoch"`
FilesToMove *FilesToMove `json:"__files_to_move,omitempty"`
Version Version `json:"_version"`
Formats []Format `json:"formats,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Duration *int64 `json:"duration,omitempty"`
AverageRating interface{} `json:"average_rating"`
AgeLimit *int64 `json:"age_limit,omitempty"`
Categories []string `json:"categories,omitempty"`
PlayableInEmbed *bool `json:"playable_in_embed,omitempty"`
LiveStatus *string `json:"live_status,omitempty"`
ReleaseTimestamp interface{} `json:"release_timestamp"`
FormatSortFields []string `json:"_format_sort_fields,omitempty"`
AutomaticCaptions map[string][]AutomaticCaption `json:"automatic_captions,omitempty"`
Subtitles *FilesToMove `json:"subtitles,omitempty"`
CommentCount *int64 `json:"comment_count,omitempty"`
Chapters interface{} `json:"chapters"`
Heatmap []Heatmap `json:"heatmap,omitempty"`
LikeCount *int64 `json:"like_count,omitempty"`
ChannelIsVerified *bool `json:"channel_is_verified,omitempty"`
UploadDate *string `json:"upload_date,omitempty"`
Timestamp *int64 `json:"timestamp,omitempty"`
Playlist interface{} `json:"playlist"`
PlaylistIndex interface{} `json:"playlist_index"`
DisplayID *string `json:"display_id,omitempty"`
Fulltitle *string `json:"fulltitle,omitempty"`
DurationString *string `json:"duration_string,omitempty"`
IsLive *bool `json:"is_live,omitempty"`
WasLive *bool `json:"was_live,omitempty"`
RequestedSubtitles interface{} `json:"requested_subtitles"`
HasDRM interface{} `json:"_has_drm"`
RequestedDownloads []RequestedDownload `json:"requested_downloads,omitempty"`
RequestedFormats []Format `json:"requested_formats,omitempty"`
Format *string `json:"format,omitempty"`
FormatID *string `json:"format_id,omitempty"`
EXT *MediaEXT `json:"ext,omitempty"`
Protocol *string `json:"protocol,omitempty"`
Language *Language `json:"language,omitempty"`
FormatNote *string `json:"format_note,omitempty"`
FilesizeApprox *int64 `json:"filesize_approx,omitempty"`
Tbr *float64 `json:"tbr,omitempty"`
Width *int64 `json:"width,omitempty"`
Height *int64 `json:"height,omitempty"`
Resolution *Resolution `json:"resolution,omitempty"`
FPS *int64 `json:"fps,omitempty"`
DynamicRange *DynamicRange `json:"dynamic_range,omitempty"`
Vcodec *string `json:"vcodec,omitempty"`
Vbr *float64 `json:"vbr,omitempty"`
StretchedRatio interface{} `json:"stretched_ratio"`
AspectRatio *float64 `json:"aspect_ratio,omitempty"`
Acodec *Acodec `json:"acodec,omitempty"`
ABR *float64 `json:"abr,omitempty"`
ASR *int64 `json:"asr,omitempty"`
AudioChannels *int64 `json:"audio_channels,omitempty"`
}
type AutomaticCaption struct {
EXT AutomaticCaptionEXT `json:"ext"`
URL string `json:"url"`
Name string `json:"name"`
}
type Entry struct {
ID string `json:"id"`
Title string `json:"title"`
Formats []Format `json:"formats"`
Thumbnails []Thumbnail `json:"thumbnails"`
Thumbnail string `json:"thumbnail"`
Description string `json:"description"`
ChannelID string `json:"channel_id"`
ChannelURL string `json:"channel_url"`
Duration int64 `json:"duration"`
ViewCount int64 `json:"view_count"`
AverageRating interface{} `json:"average_rating"`
AgeLimit int64 `json:"age_limit"`
WebpageURL string `json:"webpage_url"`
Categories []string `json:"categories"`
Tags []string `json:"tags"`
PlayableInEmbed bool `json:"playable_in_embed"`
LiveStatus string `json:"live_status"`
ReleaseTimestamp interface{} `json:"release_timestamp"`
FormatSortFields []string `json:"_format_sort_fields"`
AutomaticCaptions map[string][]AutomaticCaption `json:"automatic_captions"`
Subtitles FilesToMove `json:"subtitles"`
CommentCount int64 `json:"comment_count"`
Chapters interface{} `json:"chapters"`
Heatmap interface{} `json:"heatmap"`
LikeCount int64 `json:"like_count"`
Channel string `json:"channel"`
ChannelFollowerCount int64 `json:"channel_follower_count"`
Uploader string `json:"uploader"`
UploaderID string `json:"uploader_id"`
UploaderURL string `json:"uploader_url"`
UploadDate string `json:"upload_date"`
Timestamp int64 `json:"timestamp"`
Availability string `json:"availability"`
OriginalURL string `json:"original_url"`
WebpageURLBasename string `json:"webpage_url_basename"`
WebpageURLDomain string `json:"webpage_url_domain"`
Extractor string `json:"extractor"`
ExtractorKey string `json:"extractor_key"`
PlaylistCount int64 `json:"playlist_count"`
Playlist string `json:"playlist"`
PlaylistID string `json:"playlist_id"`
PlaylistTitle string `json:"playlist_title"`
PlaylistUploader string `json:"playlist_uploader"`
PlaylistUploaderID string `json:"playlist_uploader_id"`
NEntries int64 `json:"n_entries"`
PlaylistIndex int64 `json:"playlist_index"`
LastPlaylistIndex int64 `json:"__last_playlist_index"`
PlaylistAutonumber int64 `json:"playlist_autonumber"`
DisplayID string `json:"display_id"`
Fulltitle string `json:"fulltitle"`
DurationString string `json:"duration_string"`
ReleaseYear interface{} `json:"release_year"`
IsLive bool `json:"is_live"`
WasLive bool `json:"was_live"`
RequestedSubtitles interface{} `json:"requested_subtitles"`
HasDRM interface{} `json:"_has_drm"`
Epoch int64 `json:"epoch"`
RequestedDownloads []RequestedDownload `json:"requested_downloads"`
RequestedFormats []Format `json:"requested_formats"`
Format string `json:"format"`
FormatID string `json:"format_id"`
EXT string `json:"ext"`
Protocol string `json:"protocol"`
Language *Language `json:"language"`
FormatNote string `json:"format_note"`
FilesizeApprox int64 `json:"filesize_approx"`
Tbr float64 `json:"tbr"`
Width int64 `json:"width"`
Height int64 `json:"height"`
Resolution Resolution `json:"resolution"`
FPS int64 `json:"fps"`
DynamicRange DynamicRange `json:"dynamic_range"`
Vcodec string `json:"vcodec"`
Vbr float64 `json:"vbr"`
StretchedRatio interface{} `json:"stretched_ratio"`
AspectRatio float64 `json:"aspect_ratio"`
Acodec Acodec `json:"acodec"`
ABR float64 `json:"abr"`
ASR int64 `json:"asr"`
AudioChannels int64 `json:"audio_channels"`
}
type Format struct {
FormatID string `json:"format_id"`
FormatNote *FormatNote `json:"format_note,omitempty"`
EXT MediaEXT `json:"ext"`
Protocol Protocol `json:"protocol"`
Acodec *Acodec `json:"acodec,omitempty"`
Vcodec string `json:"vcodec"`
URL string `json:"url"`
Width *int64 `json:"width"`
Height *int64 `json:"height"`
FPS *float64 `json:"fps"`
Rows *int64 `json:"rows,omitempty"`
Columns *int64 `json:"columns,omitempty"`
Fragments []Fragment `json:"fragments,omitempty"`
Resolution Resolution `json:"resolution"`
AspectRatio *float64 `json:"aspect_ratio"`
FilesizeApprox *int64 `json:"filesize_approx"`
HTTPHeaders HTTPHeaders `json:"http_headers"`
AudioEXT MediaEXT `json:"audio_ext"`
VideoEXT MediaEXT `json:"video_ext"`
Vbr *float64 `json:"vbr"`
ABR *float64 `json:"abr"`
Tbr *float64 `json:"tbr"`
Format string `json:"format"`
FormatIndex interface{} `json:"format_index"`
ManifestURL *string `json:"manifest_url,omitempty"`
Language *Language `json:"language"`
Preference interface{} `json:"preference"`
Quality *float64 `json:"quality,omitempty"`
HasDRM *bool `json:"has_drm,omitempty"`
SourcePreference *int64 `json:"source_preference,omitempty"`
ASR *int64 `json:"asr"`
Filesize *int64 `json:"filesize"`
AudioChannels *int64 `json:"audio_channels"`
LanguagePreference *int64 `json:"language_preference,omitempty"`
DynamicRange *DynamicRange `json:"dynamic_range"`
Container *Container `json:"container,omitempty"`
DownloaderOptions *DownloaderOptions `json:"downloader_options,omitempty"`
}
type DownloaderOptions struct {
HTTPChunkSize int64 `json:"http_chunk_size"`
}
type Fragment struct {
URL string `json:"url"`
Duration float64 `json:"duration"`
}
type HTTPHeaders struct {
UserAgent string `json:"User-Agent"`
Accept Accept `json:"Accept"`
AcceptLanguage AcceptLanguage `json:"Accept-Language"`
SECFetchMode SECFetchMode `json:"Sec-Fetch-Mode"`
}
type RequestedDownload struct {
RequestedFormats []Format `json:"requested_formats"`
Format string `json:"format"`
FormatID string `json:"format_id"`
EXT string `json:"ext"`
Protocol string `json:"protocol"`
FormatNote string `json:"format_note"`
FilesizeApprox int64 `json:"filesize_approx"`
Tbr float64 `json:"tbr"`
Width int64 `json:"width"`
Height int64 `json:"height"`
Resolution Resolution `json:"resolution"`
FPS int64 `json:"fps"`
DynamicRange DynamicRange `json:"dynamic_range"`
Vcodec string `json:"vcodec"`
Vbr float64 `json:"vbr"`
AspectRatio float64 `json:"aspect_ratio"`
Acodec Acodec `json:"acodec"`
ABR float64 `json:"abr"`
ASR int64 `json:"asr"`
AudioChannels int64 `json:"audio_channels"`
FilenameOld string `json:"_filename"`
Filename string `json:"filename"`
WriteDownloadArchive bool `json:"__write_download_archive"`
Language *Language `json:"language,omitempty"`
}
type FilesToMove struct {
}
type Thumbnail struct {
URL string `json:"url"`
Preference *int64 `json:"preference,omitempty"`
ID string `json:"id"`
Height *int64 `json:"height,omitempty"`
Width *int64 `json:"width,omitempty"`
Resolution *string `json:"resolution,omitempty"`
}
type Heatmap struct {
StartTime float64 `json:"start_time"`
EndTime float64 `json:"end_time"`
Value float64 `json:"value"`
}
type Version struct {
Version string `json:"version"`
CurrentGitHead interface{} `json:"current_git_head"`
ReleaseGitHead string `json:"release_git_head"`
Repository string `json:"repository"`
}
type Acodec string
const (
AcodecNone Acodec = "none"
Mp4A402 Acodec = "mp4a.40.2"
Mp4A405 Acodec = "mp4a.40.5"
Opus Acodec = "opus"
)
type AutomaticCaptionEXT string
const (
Json3 AutomaticCaptionEXT = "json3"
Srv1 AutomaticCaptionEXT = "srv1"
Srv2 AutomaticCaptionEXT = "srv2"
Srv3 AutomaticCaptionEXT = "srv3"
Ttml AutomaticCaptionEXT = "ttml"
Vtt AutomaticCaptionEXT = "vtt"
)
type DynamicRange string
const (
SDR DynamicRange = "SDR"
HDR DynamicRange = "HDR"
)
type MediaEXT string
const (
EXTNone MediaEXT = "none"
EXTMhtml MediaEXT = "mhtml"
M4A MediaEXT = "m4a"
Mp4 MediaEXT = "mp4"
Webm MediaEXT = "webm"
)
type Container string
const (
M4ADash Container = "m4a_dash"
Mp4Dash Container = "mp4_dash"
WebmDash Container = "webm_dash"
)
type FormatNote string
const (
Default FormatNote = "Default"
Low FormatNote = "low"
Medium FormatNote = "medium"
Premium FormatNote = "Premium"
Storyboard FormatNote = "storyboard"
The1080P FormatNote = "1080p"
The144P FormatNote = "144p"
The240P FormatNote = "240p"
The360P FormatNote = "360p"
The480P FormatNote = "480p"
The720P FormatNote = "720p"
)
type Accept string
const (
TextHTMLApplicationXHTMLXMLApplicationXMLQ09Q08 Accept = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
)
type AcceptLanguage string
const (
EnUsEnQ05 AcceptLanguage = "en-us,en;q=0.5"
)
type SECFetchMode string
const (
Navigate SECFetchMode = "navigate"
)
type Language string
const (
En Language = "en"
)
type Protocol string
const (
HTTPS Protocol = "https"
M3U8Native Protocol = "m3u8_native"
ProtocolMhtml Protocol = "mhtml"
)
type Resolution string
const (
AudioOnly Resolution = "audio only"
The1280X720 Resolution = "1280x720"
The160X90 Resolution = "160x90"
The1920X1080 Resolution = "1920x1080"
The256X144 Resolution = "256x144"
The320X180 Resolution = "320x180"
The426X240 Resolution = "426x240"
The48X27 Resolution = "48x27"
The640X360 Resolution = "640x360"
The80X45 Resolution = "80x45"
The854X480 Resolution = "854x480"
)

View file

@ -0,0 +1,115 @@
package ytdlp
import (
"bufio"
"context"
"encoding/json"
"errors"
"io"
"os"
"os/exec"
"strings"
"github.com/royalcat/ctxprogress"
"golang.org/x/sync/errgroup"
)
// Progress implements ctxprogress.Progress.
func (p Entry) Progress() (current int, total int) {
return int(p.PlaylistIndex), int(p.PlaylistCount)
}
// func (p PlaylistEntry) Url() string {
// if p.URL != "" {
// return p.URL
// }
// if p.WebpageURL != "" {
// return p.WebpageURL
// }
// if p.OriginalURL != "" {
// return p.OriginalURL
// }
// return ""
// }
func (yt *Client) Playlist(ctx context.Context, url string) ([]Entry, error) {
group, ctx := errgroup.WithContext(ctx)
w, lines, err := lineReader(group)
if err != nil {
return nil, err
}
cmd := exec.CommandContext(ctx, yt.binary, "-j", url)
cmd.Stdout = w
group.Go(func() error {
err := cmd.Run()
if err != nil {
return err
}
return w.Close()
})
playlists := []Entry{}
for line := range lines {
entry := Entry{}
err = json.Unmarshal([]byte(line), &entry)
if err != nil {
return nil, err
}
playlists = append(playlists, entry)
}
return playlists, nil
}
func lineReader(group *errgroup.Group) (io.WriteCloser, <-chan string, error) {
lines := make(chan string)
var r io.Reader
r, w := io.Pipe()
r = io.TeeReader(r, os.Stdout)
group.Go(func() error {
defer close(lines)
reader := bufio.NewReader(r)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
return w.Close()
}
return errors.Join(err, w.Close())
}
line = strings.Trim(line, " \r\t")
if line == "" {
continue
}
lines <- line
}
})
return w, lines, nil
}
var _ ctxprogress.Progress = (*Entry)(nil)
var _ ctxprogress.Progress = (*DownloadProgress)(nil)
func parseProgress(line string) (ctxprogress.Progress, error) {
line = strings.Trim(line, " \r\t")
p := &DownloadProgress{}
err := json.Unmarshal([]byte(line), p)
if err != nil {
return nil, err
}
return p, nil
}

View file

@ -0,0 +1,27 @@
package ytdlp_test
import (
"context"
"errors"
"testing"
"git.kmsign.ru/royalcat/tstor/server/pkg/ytdlp"
"github.com/stretchr/testify/require"
)
func TestPlaylist(t *testing.T) {
require := require.New(t)
ctx := context.Background()
if deadline, ok := t.Deadline(); ok {
var cancel context.CancelFunc
ctx, cancel = context.WithDeadlineCause(ctx, deadline, errors.New("test deadline done"))
defer cancel()
}
client, err := ytdlp.New()
require.NoError(err)
entries, err := client.Playlist(ctx, "https://www.youtube.com/playlist?list=PLUay9m6GhoyCXdloEa-VYtnVeshaKl4AW")
require.NoError(err)
require.NotEmpty(entries)
require.Len(entries, int(entries[0].PlaylistCount))
}