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.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user