package sftpserver import ( "errors" "io" "os" "path/filepath" "strings" "github.com/pkg/sftp" ) // tenantFS implements the four github.com/pkg/sftp request-server // interfaces (FileReader/FileWriter/FileCmder/FileLister) on top of a single // real directory (root) — the authenticated tenant's // inbox// folder. // // This is the "virtual chroot": every incoming SFTP path is resolved // relative to root and validated to never escape it (no "..", no absolute // paths pointing elsewhere). v1 intentionally supports only a flat // directory — no subfolder create/navigate/delete — which keeps the path // validation trivial: a request path may only name a direct child of root. type tenantFS struct { root string } // resolve maps a virtual SFTP path ("/", "/foo.pdf", ...) onto a real path // under fs.root, rejecting anything that isn't a direct child of the root // (blocks path traversal and subfolder use in one check). func (fs *tenantFS) resolve(virtual string) (string, error) { clean := filepath.Clean("/" + virtual) if clean == "/" { return fs.root, nil } clean = strings.TrimPrefix(clean, "/") if strings.Contains(clean, "/") || clean == ".." || clean == "." { return "", errors.New("sftpserver: path escapes tenant root or is not a direct child") } return filepath.Join(fs.root, clean), nil } // Fileread implements sftp.FileReader (GET). Reading back an already // uploaded-but-not-yet-processed file is allowed (harmless), but there is // nothing to read once the watcher has moved the file into store/ (by // design — inbox/ is a transient staging area, not a browsable archive). func (fs *tenantFS) Fileread(r *sftp.Request) (io.ReaderAt, error) { path, err := fs.resolve(r.Filepath) if err != nil { return nil, err } f, err := os.Open(path) if err != nil { return nil, err } return f, nil } // Filewrite implements sftp.FileWriter (PUT). New files are created at the // root of the tenant's inbox only; existing files may not be overwritten // (O_EXCL) to avoid a client silently clobbering a file the watcher hasn't // picked up yet. func (fs *tenantFS) Filewrite(r *sftp.Request) (io.WriterAt, error) { path, err := fs.resolve(r.Filepath) if err != nil { return nil, err } f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640) if err != nil { return nil, err } return f, nil } // Filecmd implements sftp.FileCmder for out-of-band filesystem operations // (Remove, Rename, Mkdir, Setstat, ...). v1 deliberately supports none of // these beyond what's needed for a plain "put a file" workflow — clients // get a clean permission error rather than silently succeeding. func (fs *tenantFS) Filecmd(r *sftp.Request) error { return errors.New("sftpserver: operation not permitted (only uploading new files is supported)") } // Filelist implements sftp.FileLister (LIST/STAT). Listing the root shows // the tenant's pending (not-yet-watched) inbox files; anything else is // rejected by resolve. func (fs *tenantFS) Filelist(r *sftp.Request) (sftp.ListerAt, error) { switch r.Method { case "List": path, err := fs.resolve(r.Filepath) if err != nil { return nil, err } entries, err := os.ReadDir(path) if err != nil { return nil, err } infos := make([]os.FileInfo, 0, len(entries)) for _, e := range entries { info, err := e.Info() if err != nil { continue } infos = append(infos, info) } return listerAt(infos), nil case "Stat", "Lstat": path, err := fs.resolve(r.Filepath) if err != nil { return nil, err } info, err := os.Stat(path) if err != nil { return nil, err } return listerAt([]os.FileInfo{info}), nil default: return nil, errors.New("sftpserver: unsupported list method " + r.Method) } } // listerAt is the minimal []os.FileInfo -> sftp.ListerAt adapter expected by // github.com/pkg/sftp's request server. type listerAt []os.FileInfo func (l listerAt) ListAt(dst []os.FileInfo, offset int64) (int, error) { if offset >= int64(len(l)) { return 0, io.EOF } n := copy(dst, l[offset:]) if n < len(dst) { return n, io.EOF } return n, nil }