2024-06-05 03:51:13 -04:00
|
|
|
// Copyright (c) 2015-2024 MinIO, Inc.
|
2023-04-15 10:34:02 -04:00
|
|
|
//
|
|
|
|
// This file is part of MinIO Object Storage stack
|
|
|
|
//
|
|
|
|
// This program is free software: you can redistribute it and/or modify
|
|
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
|
|
// (at your option) any later version.
|
|
|
|
//
|
|
|
|
// This program is distributed in the hope that it will be useful
|
|
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
// GNU Affero General Public License for more details.
|
|
|
|
//
|
|
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
|
|
|
|
package cmd
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"context"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"os"
|
2024-04-19 08:23:42 -04:00
|
|
|
"path"
|
2023-04-15 10:34:02 -04:00
|
|
|
"strings"
|
|
|
|
"sync"
|
|
|
|
"time"
|
|
|
|
|
2023-06-19 20:53:08 -04:00
|
|
|
"github.com/minio/madmin-go/v3"
|
2023-04-15 10:34:02 -04:00
|
|
|
"github.com/minio/minio-go/v7"
|
|
|
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
2024-01-28 13:04:17 -05:00
|
|
|
xioutil "github.com/minio/minio/internal/ioutil"
|
2024-05-24 19:05:23 -04:00
|
|
|
"github.com/minio/pkg/v3/mimedb"
|
2023-04-15 10:34:02 -04:00
|
|
|
"github.com/pkg/sftp"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
)
|
|
|
|
|
2024-05-31 17:28:07 -04:00
|
|
|
// Maximum write offset for incoming SFTP blocks.
|
|
|
|
// Set to 100MiB to prevent hostile DOS attacks.
|
|
|
|
const ftpMaxWriteOffset = 100 << 20
|
|
|
|
|
2023-04-15 10:34:02 -04:00
|
|
|
type sftpDriver struct {
|
|
|
|
permissions *ssh.Permissions
|
|
|
|
endpoint string
|
|
|
|
}
|
|
|
|
|
|
|
|
//msgp:ignore sftpMetrics
|
|
|
|
type sftpMetrics struct{}
|
|
|
|
|
|
|
|
var globalSftpMetrics sftpMetrics
|
|
|
|
|
2024-06-03 11:45:54 -04:00
|
|
|
func sftpTrace(s *sftp.Request, startTime time.Time, source string, user string, err error, sz int64) madmin.TraceInfo {
|
2023-04-15 10:34:02 -04:00
|
|
|
var errStr string
|
|
|
|
if err != nil {
|
|
|
|
errStr = err.Error()
|
|
|
|
}
|
|
|
|
return madmin.TraceInfo{
|
|
|
|
TraceType: madmin.TraceFTP,
|
|
|
|
Time: startTime,
|
|
|
|
NodeName: globalLocalNodeName,
|
2024-06-03 11:45:54 -04:00
|
|
|
FuncName: s.Method,
|
2023-04-15 10:34:02 -04:00
|
|
|
Duration: time.Since(startTime),
|
|
|
|
Path: s.Filepath,
|
|
|
|
Error: errStr,
|
2024-06-03 11:45:54 -04:00
|
|
|
Bytes: sz,
|
|
|
|
Custom: map[string]string{
|
|
|
|
"user": user,
|
|
|
|
"cmd": s.Method,
|
|
|
|
"param": s.Filepath,
|
|
|
|
"source": source,
|
|
|
|
},
|
2023-04-15 10:34:02 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-06-03 11:45:54 -04:00
|
|
|
func (m *sftpMetrics) log(s *sftp.Request, user string) func(sz int64, err error) {
|
2023-04-15 10:34:02 -04:00
|
|
|
startTime := time.Now()
|
|
|
|
source := getSource(2)
|
2024-06-03 11:45:54 -04:00
|
|
|
return func(sz int64, err error) {
|
|
|
|
globalTrace.Publish(sftpTrace(s, startTime, source, user, err, sz))
|
2023-04-15 10:34:02 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewSFTPDriver initializes sftp.Handlers implementation of following interfaces
|
|
|
|
//
|
|
|
|
// - sftp.Fileread
|
|
|
|
// - sftp.Filewrite
|
|
|
|
// - sftp.Filelist
|
|
|
|
// - sftp.Filecmd
|
|
|
|
func NewSFTPDriver(perms *ssh.Permissions) sftp.Handlers {
|
|
|
|
handler := &sftpDriver{endpoint: fmt.Sprintf("127.0.0.1:%s", globalMinioPort), permissions: perms}
|
|
|
|
return sftp.Handlers{
|
|
|
|
FileGet: handler,
|
|
|
|
FilePut: handler,
|
|
|
|
FileCmd: handler,
|
|
|
|
FileList: handler,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (f *sftpDriver) getMinIOClient() (*minio.Client, error) {
|
2024-06-05 03:51:13 -04:00
|
|
|
mcreds := credentials.NewStaticV4(
|
|
|
|
f.permissions.CriticalOptions["AccessKey"],
|
|
|
|
f.permissions.CriticalOptions["SecretKey"],
|
|
|
|
f.permissions.CriticalOptions["SessionToken"],
|
|
|
|
)
|
2023-04-15 10:34:02 -04:00
|
|
|
return minio.New(f.endpoint, &minio.Options{
|
2024-06-05 03:51:13 -04:00
|
|
|
Creds: mcreds,
|
2023-04-15 10:34:02 -04:00
|
|
|
Secure: globalIsTLS,
|
2023-06-30 11:04:13 -04:00
|
|
|
Transport: globalRemoteFTPClientTransport,
|
2023-04-15 10:34:02 -04:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
func (f *sftpDriver) AccessKey() string {
|
2024-06-05 03:51:13 -04:00
|
|
|
return f.permissions.CriticalOptions["AccessKey"]
|
2023-04-15 10:34:02 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
func (f *sftpDriver) Fileread(r *sftp.Request) (ra io.ReaderAt, err error) {
|
2024-06-03 11:45:54 -04:00
|
|
|
// This is not timing the actual read operation, but the time it takes to prepare the reader.
|
2023-04-15 10:34:02 -04:00
|
|
|
stopFn := globalSftpMetrics.log(r, f.AccessKey())
|
2024-06-03 11:45:54 -04:00
|
|
|
defer stopFn(0, err)
|
2023-04-15 10:34:02 -04:00
|
|
|
|
|
|
|
flags := r.Pflags()
|
|
|
|
if !flags.Read {
|
|
|
|
// sanity check
|
|
|
|
return nil, os.ErrInvalid
|
|
|
|
}
|
|
|
|
|
|
|
|
bucket, object := path2BucketObject(r.Filepath)
|
|
|
|
if bucket == "" {
|
|
|
|
return nil, errors.New("bucket name cannot be empty")
|
|
|
|
}
|
|
|
|
|
|
|
|
clnt, err := f.getMinIOClient()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
obj, err := clnt.GetObject(context.Background(), bucket, object, minio.GetObjectOptions{})
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
_, err = obj.Stat()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return obj, nil
|
|
|
|
}
|
|
|
|
|
2023-11-14 12:31:00 -05:00
|
|
|
// TransferError will catch network errors during transfer.
|
|
|
|
// When TransferError() is called Close() will also
|
|
|
|
// be called, so we do not need to Wait() here.
|
|
|
|
func (w *writerAt) TransferError(err error) {
|
|
|
|
_ = w.w.CloseWithError(err)
|
|
|
|
_ = w.r.CloseWithError(err)
|
|
|
|
w.err = err
|
|
|
|
}
|
|
|
|
|
2023-11-11 03:14:41 -05:00
|
|
|
func (w *writerAt) Close() (err error) {
|
2023-11-14 12:31:00 -05:00
|
|
|
switch {
|
|
|
|
case len(w.buffer) > 0:
|
|
|
|
err = errors.New("some file segments were not flushed from the queue")
|
|
|
|
_ = w.w.CloseWithError(err)
|
|
|
|
case w.err != nil:
|
|
|
|
// No need to close here since both pipes were
|
|
|
|
// closing inside TransferError()
|
|
|
|
err = w.err
|
|
|
|
default:
|
2023-11-11 03:14:41 -05:00
|
|
|
err = w.w.Close()
|
|
|
|
}
|
2023-11-14 12:31:00 -05:00
|
|
|
for i := range w.buffer {
|
|
|
|
delete(w.buffer, i)
|
|
|
|
}
|
2023-04-15 10:34:02 -04:00
|
|
|
w.wg.Wait()
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2023-11-11 03:14:41 -05:00
|
|
|
type writerAt struct {
|
|
|
|
w *io.PipeWriter
|
2023-11-14 12:31:00 -05:00
|
|
|
r *io.PipeReader
|
2023-11-11 03:14:41 -05:00
|
|
|
wg *sync.WaitGroup
|
|
|
|
buffer map[int64][]byte
|
2023-11-14 12:31:00 -05:00
|
|
|
err error
|
2023-11-11 03:14:41 -05:00
|
|
|
|
|
|
|
nextOffset int64
|
|
|
|
m sync.Mutex
|
|
|
|
}
|
|
|
|
|
2023-04-15 10:34:02 -04:00
|
|
|
func (w *writerAt) WriteAt(b []byte, offset int64) (n int, err error) {
|
2023-11-11 03:14:41 -05:00
|
|
|
w.m.Lock()
|
|
|
|
defer w.m.Unlock()
|
|
|
|
|
|
|
|
if w.nextOffset == offset {
|
|
|
|
n, err = w.w.Write(b)
|
|
|
|
w.nextOffset += int64(n)
|
|
|
|
} else {
|
2024-05-31 17:28:07 -04:00
|
|
|
if offset > w.nextOffset+ftpMaxWriteOffset {
|
|
|
|
return 0, fmt.Errorf("write offset %d is too far ahead of next offset %d", offset, w.nextOffset)
|
|
|
|
}
|
2023-11-11 03:14:41 -05:00
|
|
|
w.buffer[offset] = make([]byte, len(b))
|
|
|
|
copy(w.buffer[offset], b)
|
|
|
|
n = len(b)
|
|
|
|
}
|
|
|
|
|
|
|
|
again:
|
|
|
|
nextOut, ok := w.buffer[w.nextOffset]
|
|
|
|
if ok {
|
|
|
|
n, err = w.w.Write(nextOut)
|
|
|
|
delete(w.buffer, w.nextOffset)
|
|
|
|
w.nextOffset += int64(n)
|
|
|
|
if n != len(nextOut) {
|
|
|
|
return 0, fmt.Errorf("expected write size %d but wrote %d bytes", len(nextOut), n)
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return 0, err
|
|
|
|
}
|
|
|
|
goto again
|
|
|
|
}
|
|
|
|
|
|
|
|
return len(b), nil
|
2023-04-15 10:34:02 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
func (f *sftpDriver) Filewrite(r *sftp.Request) (w io.WriterAt, err error) {
|
|
|
|
stopFn := globalSftpMetrics.log(r, f.AccessKey())
|
2024-06-03 11:45:54 -04:00
|
|
|
defer func() {
|
|
|
|
if err != nil {
|
|
|
|
// If there is an error, we never started the goroutine.
|
|
|
|
stopFn(0, err)
|
|
|
|
}
|
|
|
|
}()
|
2023-04-15 10:34:02 -04:00
|
|
|
|
|
|
|
flags := r.Pflags()
|
|
|
|
if !flags.Write {
|
|
|
|
// sanity check
|
|
|
|
return nil, os.ErrInvalid
|
|
|
|
}
|
|
|
|
|
|
|
|
bucket, object := path2BucketObject(r.Filepath)
|
|
|
|
if bucket == "" {
|
|
|
|
return nil, errors.New("bucket name cannot be empty")
|
|
|
|
}
|
|
|
|
|
|
|
|
clnt, err := f.getMinIOClient()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2023-11-22 00:36:32 -05:00
|
|
|
ok, err := clnt.BucketExists(r.Context(), bucket)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if !ok {
|
|
|
|
return nil, os.ErrNotExist
|
|
|
|
}
|
2023-04-15 10:34:02 -04:00
|
|
|
|
|
|
|
pr, pw := io.Pipe()
|
|
|
|
|
2023-11-11 03:14:41 -05:00
|
|
|
wa := &writerAt{
|
|
|
|
buffer: make(map[int64][]byte),
|
|
|
|
w: pw,
|
2023-11-14 12:31:00 -05:00
|
|
|
r: pr,
|
2023-11-11 03:14:41 -05:00
|
|
|
wg: &sync.WaitGroup{},
|
|
|
|
}
|
2023-04-15 10:34:02 -04:00
|
|
|
wa.wg.Add(1)
|
|
|
|
go func() {
|
2024-06-03 11:45:54 -04:00
|
|
|
oi, err := clnt.PutObject(r.Context(), bucket, object, pr, -1, minio.PutObjectOptions{
|
2024-04-19 08:23:42 -04:00
|
|
|
ContentType: mimedb.TypeByExtension(path.Ext(object)),
|
|
|
|
DisableContentSha256: true,
|
|
|
|
})
|
2024-06-03 11:45:54 -04:00
|
|
|
stopFn(oi.Size, err)
|
2023-04-15 10:34:02 -04:00
|
|
|
pr.CloseWithError(err)
|
|
|
|
wa.wg.Done()
|
|
|
|
}()
|
|
|
|
return wa, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (f *sftpDriver) Filecmd(r *sftp.Request) (err error) {
|
|
|
|
stopFn := globalSftpMetrics.log(r, f.AccessKey())
|
2024-06-03 11:45:54 -04:00
|
|
|
defer stopFn(0, err)
|
2023-04-15 10:34:02 -04:00
|
|
|
|
|
|
|
clnt, err := f.getMinIOClient()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
switch r.Method {
|
|
|
|
case "Setstat", "Rename", "Link", "Symlink":
|
2024-05-14 06:29:30 -04:00
|
|
|
return sftp.ErrSSHFxOpUnsupported
|
2023-04-15 10:34:02 -04:00
|
|
|
|
|
|
|
case "Rmdir":
|
|
|
|
bucket, prefix := path2BucketObject(r.Filepath)
|
|
|
|
if bucket == "" {
|
|
|
|
return errors.New("deleting all buckets not allowed")
|
|
|
|
}
|
|
|
|
|
|
|
|
cctx, cancel := context.WithCancel(context.Background())
|
|
|
|
defer cancel()
|
|
|
|
|
2023-06-28 16:15:21 -04:00
|
|
|
if prefix == "" {
|
|
|
|
// if all objects are not deleted yet this call may fail.
|
|
|
|
return clnt.RemoveBucket(cctx, bucket)
|
|
|
|
}
|
|
|
|
|
2023-04-15 10:34:02 -04:00
|
|
|
objectsCh := make(chan minio.ObjectInfo)
|
|
|
|
|
|
|
|
// Send object names that are needed to be removed to objectsCh
|
|
|
|
go func() {
|
2024-01-28 13:04:17 -05:00
|
|
|
defer xioutil.SafeClose(objectsCh)
|
2023-06-28 16:15:21 -04:00
|
|
|
opts := minio.ListObjectsOptions{
|
|
|
|
Prefix: prefix,
|
|
|
|
Recursive: true,
|
|
|
|
}
|
2023-04-15 10:34:02 -04:00
|
|
|
for object := range clnt.ListObjects(cctx, bucket, opts) {
|
|
|
|
if object.Err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
objectsCh <- object
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
// Call RemoveObjects API
|
|
|
|
for err := range clnt.RemoveObjects(context.Background(), bucket, objectsCh, minio.RemoveObjectsOptions{}) {
|
|
|
|
if err.Err != nil {
|
|
|
|
return err.Err
|
|
|
|
}
|
|
|
|
}
|
2023-11-14 12:26:46 -05:00
|
|
|
return err
|
2023-04-15 10:34:02 -04:00
|
|
|
|
|
|
|
case "Remove":
|
|
|
|
bucket, object := path2BucketObject(r.Filepath)
|
|
|
|
if bucket == "" {
|
|
|
|
return errors.New("bucket name cannot be empty")
|
|
|
|
}
|
|
|
|
|
|
|
|
return clnt.RemoveObject(context.Background(), bucket, object, minio.RemoveObjectOptions{})
|
|
|
|
|
|
|
|
case "Mkdir":
|
|
|
|
bucket, prefix := path2BucketObject(r.Filepath)
|
|
|
|
if bucket == "" {
|
|
|
|
return errors.New("bucket name cannot be empty")
|
|
|
|
}
|
|
|
|
|
2023-06-28 16:15:21 -04:00
|
|
|
if prefix == "" {
|
2024-05-16 19:13:47 -04:00
|
|
|
return clnt.MakeBucket(context.Background(), bucket, minio.MakeBucketOptions{Region: globalSite.Region()})
|
2023-06-28 16:15:21 -04:00
|
|
|
}
|
|
|
|
|
2023-04-15 10:34:02 -04:00
|
|
|
dirPath := buildMinioDir(prefix)
|
|
|
|
|
|
|
|
_, err = clnt.PutObject(context.Background(), bucket, dirPath, bytes.NewReader([]byte("")), 0,
|
2024-04-19 08:23:42 -04:00
|
|
|
minio.PutObjectOptions{DisableContentSha256: true},
|
2023-04-15 10:34:02 -04:00
|
|
|
)
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return NotImplemented{}
|
|
|
|
}
|
|
|
|
|
|
|
|
type listerAt []os.FileInfo
|
|
|
|
|
|
|
|
// Modeled after strings.Reader's ReadAt() implementation
|
|
|
|
func (f listerAt) ListAt(ls []os.FileInfo, offset int64) (int, error) {
|
|
|
|
var n int
|
|
|
|
if offset >= int64(len(f)) {
|
|
|
|
return 0, io.EOF
|
|
|
|
}
|
|
|
|
n = copy(ls, f[offset:])
|
|
|
|
if n < len(ls) {
|
|
|
|
return n, io.EOF
|
|
|
|
}
|
|
|
|
return n, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (f *sftpDriver) Filelist(r *sftp.Request) (la sftp.ListerAt, err error) {
|
|
|
|
stopFn := globalSftpMetrics.log(r, f.AccessKey())
|
2024-06-03 11:45:54 -04:00
|
|
|
defer stopFn(0, err)
|
2023-04-15 10:34:02 -04:00
|
|
|
|
|
|
|
clnt, err := f.getMinIOClient()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
switch r.Method {
|
|
|
|
case "List":
|
|
|
|
var files []os.FileInfo
|
|
|
|
|
|
|
|
bucket, prefix := path2BucketObject(r.Filepath)
|
|
|
|
if bucket == "" {
|
|
|
|
buckets, err := clnt.ListBuckets(r.Context())
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, bucket := range buckets {
|
|
|
|
files = append(files, &minioFileInfo{
|
|
|
|
p: bucket.Name,
|
|
|
|
info: minio.ObjectInfo{Key: bucket.Name, LastModified: bucket.CreationDate},
|
|
|
|
isDir: true,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
return listerAt(files), nil
|
|
|
|
}
|
|
|
|
|
|
|
|
prefix = retainSlash(prefix)
|
|
|
|
|
|
|
|
for object := range clnt.ListObjects(r.Context(), bucket, minio.ListObjectsOptions{
|
|
|
|
Prefix: prefix,
|
|
|
|
Recursive: false,
|
|
|
|
}) {
|
|
|
|
if object.Err != nil {
|
|
|
|
return nil, object.Err
|
|
|
|
}
|
|
|
|
|
|
|
|
if object.Key == prefix {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
isDir := strings.HasSuffix(object.Key, SlashSeparator)
|
|
|
|
files = append(files, &minioFileInfo{
|
|
|
|
p: pathClean(strings.TrimPrefix(object.Key, prefix)),
|
|
|
|
info: object,
|
|
|
|
isDir: isDir,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
return listerAt(files), nil
|
|
|
|
|
|
|
|
case "Stat":
|
|
|
|
if r.Filepath == SlashSeparator {
|
|
|
|
return listerAt{&minioFileInfo{
|
|
|
|
p: r.Filepath,
|
|
|
|
isDir: true,
|
|
|
|
}}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
bucket, object := path2BucketObject(r.Filepath)
|
|
|
|
if bucket == "" {
|
|
|
|
return nil, errors.New("bucket name cannot be empty")
|
|
|
|
}
|
|
|
|
|
|
|
|
if object == "" {
|
|
|
|
ok, err := clnt.BucketExists(context.Background(), bucket)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if !ok {
|
|
|
|
return nil, os.ErrNotExist
|
|
|
|
}
|
|
|
|
return listerAt{&minioFileInfo{
|
|
|
|
p: pathClean(bucket),
|
|
|
|
info: minio.ObjectInfo{Key: bucket},
|
|
|
|
isDir: true,
|
|
|
|
}}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
objInfo, err := clnt.StatObject(context.Background(), bucket, object, minio.StatObjectOptions{})
|
|
|
|
if err != nil {
|
|
|
|
if minio.ToErrorResponse(err).Code == "NoSuchKey" {
|
|
|
|
// dummy return to satisfy LIST (stat -> list) behavior.
|
|
|
|
return listerAt{&minioFileInfo{
|
|
|
|
p: pathClean(object),
|
|
|
|
info: minio.ObjectInfo{Key: object},
|
|
|
|
isDir: true,
|
|
|
|
}}, nil
|
|
|
|
}
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
isDir := strings.HasSuffix(objInfo.Key, SlashSeparator)
|
|
|
|
return listerAt{&minioFileInfo{
|
|
|
|
p: pathClean(object),
|
|
|
|
info: objInfo,
|
|
|
|
isDir: isDir,
|
|
|
|
}}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil, NotImplemented{}
|
|
|
|
}
|