Files
archivdms/internal/sftpserver/tenantfs.go
T
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

134 lines
4.0 KiB
Go

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/<tenant_id>/ 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
}