mirror of
https://github.com/OpenListTeam/OpenList.git
synced 2026-11-13 22:20:47 +08:00
* fix(webdav): correct COPY/MOVE semantics with named copy support Signed-off-by: Lythen <intro-iu@outlook.com> * feat(webdav): persist dead properties with MOVE migration Signed-off-by: Lythen <intro-iu@outlook.com> * fix(webdav): enforce locks on resolved paths, support shared locks Signed-off-by: Lythen <intro-iu@outlook.com> * fix(fs): fall back to transfer task for same-dir copy Signed-off-by: Lythen <intro-iu@outlook.com> * fix(webdav): escape LIKE patterns, check dstDir is dir, bound PROPFIND body Signed-off-by: Lythen <intro-iu@outlook.com> * fix(webdav): escape backslash in LIKE, reduce dead prop retry Signed-off-by: Lythen <intro-iu@outlook.com> * fix(webdav): isolate shared lock instances - Track each lock token with independent details and expiry state.\n- Keep resource nodes for path conflict accounting only.\n- Cover independent shared-lock expiration in memLS tests. * fix(webdav): preserve dead property lifecycle - Copy and replace dead properties with COPY destinations.\n- Remove dead-property subtrees after successful DELETE.\n- Stage MOVE properties before clearing the destination subtree.\n- Add database-backed regression coverage for copy and delete. * fix(webdav): protect overwrite copy and move - Validate source objects before touching overwrite destinations.\n- Stage COPY content under a collision-free name and restore destinations on failure.\n- Preserve MOVE destinations with a backup until the resource operation succeeds.\n- Route named same-storage copies through the direct destination-name transfer path. * fix(webdav): harden overwrite staging - Skip NoOverwriteUpload backup handling for collision-free internal COPY staging.\n- Treat post-commit overwrite backup cleanup failures as warnings. * test(webdav): update lock lookup expectations - Compare lookup results with independent lock instances.\n- Keep invalid token expectations nil after the lock state split. * fix(webdav): complete litmus compliance - Preserve named same-storage collection copies with isolated native staging.\n- Evaluate WebDAV ETag conditions and distinguish 412 from 423 lock failures.\n- Return 201 for lock-null resources and cover the new condition behavior. * fix(fs): harden named copy staging - retry staging cleanup from the deferred path and report residual directories - document hook context semantics and wrap staging operation errors Signed-off-by: Lythen <intro-iu@outlook.com> * fix(core): support Go 1.27 checks - Replace dynamic format strings with explicit format verbs and guard optional network configuration. - Stabilize multipart cleanup, proxy, search, and MCP tests. Co-authored-by: Codex <267193182+codex@users.noreply.github.com> * fix(webdav): coordinate file and property transactions - Keep MOVE, COPY, and DELETE file changes coordinated with dead-property transactions. - Compensate filesystem changes when file operations or database commits fail. - Add WebDAV coverage for transaction and recovery failures. Co-authored-by: Codex <267193182+codex@users.noreply.github.com> * fix(webdav): reject writes without parent collections - Require an existing parent collection for PUT. - Reject MKCOL when its parent is missing or not a collection. Co-authored-by: Codex <267193182+codex@users.noreply.github.com> * fix(webdav): refresh parent before delete checks - Refresh the parent listing before checking whether a DELETE target exists. - Return 404 for missing resources, including storage-not-found errors. Co-authored-by: Codex <267193182+codex@users.noreply.github.com> --------- Signed-off-by: Lythen <intro-iu@outlook.com> Co-authored-by: Intro <Intro-iu@outlook.com> Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
484 lines
15 KiB
Go
484 lines
15 KiB
Go
// Copyright 2014 The Go Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package webdav
|
|
|
|
import (
|
|
"context"
|
|
stderrors "errors"
|
|
"fmt"
|
|
"net/http"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
|
"github.com/OpenListTeam/OpenList/v4/internal/errs"
|
|
"github.com/OpenListTeam/OpenList/v4/internal/fs"
|
|
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
|
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
|
"github.com/OpenListTeam/OpenList/v4/server/common"
|
|
"github.com/google/uuid"
|
|
"github.com/pkg/errors"
|
|
log "github.com/sirupsen/logrus"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// slashClean is equivalent to but slightly more efficient than
|
|
// path.Clean("/" + name).
|
|
func slashClean(name string) string {
|
|
if name == "" || name[0] != '/' {
|
|
name = "/" + name
|
|
}
|
|
return path.Clean(name)
|
|
}
|
|
|
|
func moveResource(ctx context.Context, src, dstDir, srcDir, srcName, dstName string) error {
|
|
if srcDir == dstDir {
|
|
return fs.Rename(ctx, src, dstName)
|
|
}
|
|
if _, err := fs.Move(context.WithValue(ctx, conf.NoTaskKey, struct{}{}), src, dstDir); err != nil {
|
|
return err
|
|
}
|
|
if srcName != dstName {
|
|
return fs.Rename(ctx, path.Join(dstDir, srcName), dstName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type fileMutation struct {
|
|
rollback func() error
|
|
finalize func()
|
|
backupPaths []string
|
|
}
|
|
|
|
type fileBackup struct {
|
|
originalPath string
|
|
backupPath string
|
|
renamed bool
|
|
}
|
|
|
|
func stagingContext(ctx context.Context) context.Context {
|
|
ctx = context.WithValue(ctx, conf.NoTaskKey, struct{}{})
|
|
return context.WithValue(ctx, conf.SkipHookKey, struct{}{})
|
|
}
|
|
|
|
func removeIfExists(ctx context.Context, name string) error {
|
|
if _, err := fs.Get(ctx, name, &fs.GetArgs{}); err != nil {
|
|
if errs.IsObjectNotFound(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return fs.Remove(ctx, name)
|
|
}
|
|
|
|
func cleanupAfterFailure(ctx context.Context, name string, cause error) error {
|
|
if err := removeIfExists(stagingContext(context.WithoutCancel(ctx)), name); err != nil {
|
|
log.WithError(err).Errorf("failed to clean up WebDAV staging path %s", name)
|
|
return fmt.Errorf("%w; failed to clean up %s: %w", cause, name, err)
|
|
}
|
|
return cause
|
|
}
|
|
|
|
func newFileBackup(ctx context.Context, originalPath string, keepOriginal bool) (*fileBackup, error) {
|
|
backupPath := path.Join(path.Dir(originalPath), ".openlist-backup-"+uuid.NewString())
|
|
backup := &fileBackup{originalPath: originalPath, backupPath: backupPath}
|
|
if !keepOriginal {
|
|
err := fs.Rename(stagingContext(ctx), originalPath, path.Base(backupPath))
|
|
if err == nil {
|
|
backup.renamed = true
|
|
return backup, nil
|
|
}
|
|
if !errs.IsNotImplementError(err) && !errs.IsNotSupportError(err) {
|
|
return nil, errors.WithMessage(err, "failed to rename object into rollback backup")
|
|
}
|
|
}
|
|
if _, err := fs.CopyTo(stagingContext(ctx), originalPath, path.Dir(originalPath), path.Base(backupPath)); err != nil {
|
|
return nil, cleanupAfterFailure(ctx, backupPath, errors.WithMessage(err, "failed to copy object into rollback backup"))
|
|
}
|
|
return backup, nil
|
|
}
|
|
|
|
func (b *fileBackup) restore(ctx context.Context) error {
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
ctx = context.WithoutCancel(ctx)
|
|
if err := removeIfExists(ctx, b.originalPath); err != nil {
|
|
return err
|
|
}
|
|
if b.renamed {
|
|
if err := fs.Rename(ctx, b.backupPath, path.Base(b.originalPath)); err != nil {
|
|
return errors.WithMessagef(err, "failed to restore %s from %s", b.originalPath, b.backupPath)
|
|
}
|
|
return nil
|
|
}
|
|
if _, err := fs.CopyTo(context.WithValue(ctx, conf.NoTaskKey, struct{}{}), b.backupPath, path.Dir(b.originalPath), path.Base(b.originalPath)); err != nil {
|
|
return errors.WithMessagef(err, "failed to restore %s from %s", b.originalPath, b.backupPath)
|
|
}
|
|
if err := fs.Remove(stagingContext(ctx), b.backupPath); err != nil {
|
|
if !errs.IsObjectNotFound(err) {
|
|
log.Warnf("failed to remove restored WebDAV backup %s: %v", b.backupPath, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *fileBackup) discard(ctx context.Context) {
|
|
if b == nil {
|
|
return
|
|
}
|
|
if err := removeIfExists(stagingContext(context.WithoutCancel(ctx)), b.backupPath); err != nil {
|
|
log.Warnf("failed to remove WebDAV backup %s: %v", b.backupPath, err)
|
|
}
|
|
}
|
|
|
|
func installStagedObject(ctx context.Context, stagePath, dstDir, dstName string) error {
|
|
if err := fs.Rename(ctx, stagePath, dstName); err == nil {
|
|
return nil
|
|
} else if !errs.IsNotImplementError(err) && !errs.IsNotSupportError(err) {
|
|
return err
|
|
}
|
|
copyCtx := context.WithValue(ctx, conf.NoTaskKey, struct{}{})
|
|
if _, err := fs.CopyTo(copyCtx, stagePath, dstDir, dstName); err != nil {
|
|
return err
|
|
}
|
|
if err := fs.Remove(stagingContext(context.WithoutCancel(ctx)), stagePath); err != nil {
|
|
log.Warnf("failed to remove installed WebDAV staging object %s: %v", stagePath, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyWithRollback(ctx context.Context, src, dstDir, dstName string, dstExisted, shallow bool) (*fileMutation, error) {
|
|
stagePath := path.Join(dstDir, ".openlist-copy-"+uuid.NewString())
|
|
var err error
|
|
if shallow {
|
|
err = fs.MakeDir(stagingContext(ctx), stagePath)
|
|
} else {
|
|
_, err = fs.CopyTo(stagingContext(ctx), src, dstDir, path.Base(stagePath))
|
|
}
|
|
if err != nil {
|
|
return nil, cleanupAfterFailure(ctx, stagePath, err)
|
|
}
|
|
dstPath := path.Join(dstDir, dstName)
|
|
var dstBackup *fileBackup
|
|
if dstExisted {
|
|
var err error
|
|
dstBackup, err = newFileBackup(ctx, dstPath, false)
|
|
if err != nil {
|
|
return nil, cleanupAfterFailure(ctx, stagePath, err)
|
|
}
|
|
if !dstBackup.renamed {
|
|
if err := removeIfExists(ctx, dstPath); err != nil {
|
|
mutation := copyMutation(ctx, dstPath, stagePath, dstBackup)
|
|
return mutation, errors.WithMessage(err, "failed to remove destination after creating rollback copy")
|
|
}
|
|
}
|
|
}
|
|
mutation := copyMutation(ctx, dstPath, stagePath, dstBackup)
|
|
if err := installStagedObject(ctx, stagePath, dstDir, dstName); err != nil {
|
|
return mutation, errors.WithMessage(err, "failed to install staged COPY")
|
|
}
|
|
return mutation, nil
|
|
}
|
|
|
|
func copyMutation(ctx context.Context, dstPath, stagePath string, dstBackup *fileBackup) *fileMutation {
|
|
paths := []string{stagePath}
|
|
if dstBackup != nil {
|
|
paths = append(paths, dstBackup.backupPath)
|
|
}
|
|
return &fileMutation{
|
|
backupPaths: paths,
|
|
rollback: func() error {
|
|
var rollbackErrs []error
|
|
if err := removeIfExists(context.WithoutCancel(ctx), dstPath); err != nil {
|
|
rollbackErrs = append(rollbackErrs, err)
|
|
}
|
|
if err := dstBackup.restore(ctx); err != nil {
|
|
rollbackErrs = append(rollbackErrs, err)
|
|
}
|
|
if err := removeIfExists(stagingContext(context.WithoutCancel(ctx)), stagePath); err != nil {
|
|
rollbackErrs = append(rollbackErrs, err)
|
|
}
|
|
return stderrors.Join(rollbackErrs...)
|
|
},
|
|
finalize: func() {
|
|
if err := removeIfExists(stagingContext(context.WithoutCancel(ctx)), stagePath); err != nil {
|
|
log.Warnf("failed to remove WebDAV COPY staging object %s: %v", stagePath, err)
|
|
}
|
|
dstBackup.discard(ctx)
|
|
},
|
|
}
|
|
}
|
|
|
|
func moveWithRollback(ctx context.Context, src, dst, dstDir, srcDir, srcName, dstName string, dstExisted bool) (*fileMutation, error) {
|
|
// Keep a source copy so a partially completed cross-storage MOVE can be undone.
|
|
srcBackup, err := newFileBackup(ctx, src, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var dstBackup *fileBackup
|
|
if dstExisted {
|
|
dstBackup, err = newFileBackup(ctx, dst, false)
|
|
if err != nil {
|
|
srcBackup.discard(ctx)
|
|
return nil, err
|
|
}
|
|
if !dstBackup.renamed {
|
|
if err := removeIfExists(ctx, dst); err != nil {
|
|
mutation := moveMutation(ctx, dst, dstDir, srcName, dstName, srcBackup, dstBackup)
|
|
return mutation, errors.WithMessage(err, "failed to remove destination after creating rollback copy")
|
|
}
|
|
}
|
|
}
|
|
mutation := moveMutation(ctx, dst, dstDir, srcName, dstName, srcBackup, dstBackup)
|
|
if err := moveResource(ctx, src, dstDir, srcDir, srcName, dstName); err != nil {
|
|
return mutation, err
|
|
}
|
|
return mutation, nil
|
|
}
|
|
|
|
func moveMutation(ctx context.Context, dst, dstDir, srcName, dstName string, srcBackup, dstBackup *fileBackup) *fileMutation {
|
|
paths := []string{srcBackup.backupPath}
|
|
if dstBackup != nil {
|
|
paths = append(paths, dstBackup.backupPath)
|
|
}
|
|
return &fileMutation{
|
|
backupPaths: paths,
|
|
rollback: func() error {
|
|
rollbackCtx := context.WithoutCancel(ctx)
|
|
var rollbackErrs []error
|
|
candidates := []string{dst}
|
|
if srcName != dstName {
|
|
candidates = append(candidates, path.Join(dstDir, srcName))
|
|
}
|
|
for _, candidate := range candidates {
|
|
if err := removeIfExists(rollbackCtx, candidate); err != nil {
|
|
rollbackErrs = append(rollbackErrs, err)
|
|
}
|
|
}
|
|
if err := srcBackup.restore(rollbackCtx); err != nil {
|
|
rollbackErrs = append(rollbackErrs, err)
|
|
}
|
|
if err := dstBackup.restore(rollbackCtx); err != nil {
|
|
rollbackErrs = append(rollbackErrs, err)
|
|
}
|
|
return stderrors.Join(rollbackErrs...)
|
|
},
|
|
finalize: func() {
|
|
srcBackup.discard(ctx)
|
|
dstBackup.discard(ctx)
|
|
},
|
|
}
|
|
}
|
|
|
|
func runDeadPropsFileMutation(ctx context.Context, update func(*gorm.DB) error, apply func() (*fileMutation, error)) error {
|
|
var mutation *fileMutation
|
|
err := withDeadPropsTransaction(ctx, func(tx *gorm.DB) error {
|
|
if err := update(tx); err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
mutation, err = apply()
|
|
return err
|
|
})
|
|
if err != nil {
|
|
if mutation != nil && mutation.rollback != nil {
|
|
if rollbackErr := mutation.rollback(); rollbackErr != nil {
|
|
log.WithError(rollbackErr).Errorf("failed to compensate WebDAV file operation; backups may remain at %s", strings.Join(mutation.backupPaths, ", "))
|
|
return fmt.Errorf("%w; filesystem compensation failed: %w", err, rollbackErr)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
if mutation != nil && mutation.finalize != nil {
|
|
mutation.finalize()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// moveFiles moves files and/or directories from src to dst.
|
|
// Individual item permission checks are skipped for performance reasons.
|
|
//
|
|
// See section 9.9.4 for when various HTTP status codes apply.
|
|
func moveFiles(ctx context.Context, src, dst string, overwrite bool) (status int, err error) {
|
|
srcDir := path.Dir(src)
|
|
dstDir := path.Dir(dst)
|
|
srcName := path.Base(src)
|
|
dstName := path.Base(dst)
|
|
user := ctx.Value(conf.UserKey).(*model.User)
|
|
if srcDir != dstDir && !user.CanMove() {
|
|
return http.StatusForbidden, nil
|
|
}
|
|
if srcName != dstName && !user.CanRename() {
|
|
return http.StatusForbidden, nil
|
|
}
|
|
srcMeta, err := op.GetNearestMeta(srcDir)
|
|
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
dstMeta, err := op.GetNearestMeta(dstDir)
|
|
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
if !common.CanWrite(user, srcMeta, srcDir) || !common.CanWrite(user, dstMeta, dstDir) {
|
|
return http.StatusForbidden, nil
|
|
}
|
|
if _, err = fs.Get(ctx, src, &fs.GetArgs{}); err != nil {
|
|
if errs.IsObjectNotFound(err) {
|
|
return http.StatusNotFound, err
|
|
}
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
if dstDirInfo, err := fs.Get(ctx, dstDir, &fs.GetArgs{}); err != nil {
|
|
if errs.IsObjectNotFound(err) {
|
|
return http.StatusConflict, err
|
|
}
|
|
return http.StatusMethodNotAllowed, err
|
|
} else if !dstDirInfo.IsDir() {
|
|
return http.StatusConflict, nil
|
|
}
|
|
dstExisted := false
|
|
if _, err = fs.Get(ctx, dst, &fs.GetArgs{}); err == nil {
|
|
dstExisted = true
|
|
if !overwrite {
|
|
return http.StatusPreconditionFailed, nil
|
|
}
|
|
} else if !errs.IsObjectNotFound(err) {
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
err = runDeadPropsFileMutation(ctx,
|
|
func(tx *gorm.DB) error { return moveDeadProps(tx, src, dst) },
|
|
func() (*fileMutation, error) {
|
|
return moveWithRollback(ctx, src, dst, dstDir, srcDir, srcName, dstName, dstExisted && overwrite)
|
|
},
|
|
)
|
|
if err != nil {
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
if dstExisted {
|
|
return http.StatusNoContent, nil
|
|
}
|
|
return http.StatusCreated, nil
|
|
}
|
|
|
|
// copyFiles copies files and/or directories from src to dst.
|
|
// Individual item permission checks are skipped for performance reasons.
|
|
//
|
|
// See section 9.8.5 for when various HTTP status codes apply.
|
|
func copyFiles(ctx context.Context, src, dst string, overwrite bool, depth int) (status int, err error) {
|
|
srcDir := path.Dir(src)
|
|
dstDir := path.Dir(dst)
|
|
dstName := path.Base(dst)
|
|
user := ctx.Value(conf.UserKey).(*model.User)
|
|
if !user.CanCopy() {
|
|
return http.StatusForbidden, nil
|
|
}
|
|
srcMeta, err := op.GetNearestMeta(srcDir)
|
|
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
if !common.CanRead(user, srcMeta, srcDir) {
|
|
return http.StatusForbidden, nil
|
|
}
|
|
dstMeta, err := op.GetNearestMeta(dstDir)
|
|
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
if !common.CanWrite(user, dstMeta, dstDir) {
|
|
return http.StatusForbidden, nil
|
|
}
|
|
srcInfo, err := fs.Get(ctx, src, &fs.GetArgs{})
|
|
if err != nil {
|
|
if errs.IsObjectNotFound(err) {
|
|
return http.StatusNotFound, err
|
|
}
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
shallow := depth == 0 && srcInfo.IsDir()
|
|
if dstDirInfo, err := fs.Get(ctx, dstDir, &fs.GetArgs{}); err != nil {
|
|
if errs.IsObjectNotFound(err) {
|
|
return http.StatusConflict, err
|
|
}
|
|
return http.StatusMethodNotAllowed, err
|
|
} else if !dstDirInfo.IsDir() {
|
|
return http.StatusConflict, nil
|
|
}
|
|
dstExisted := false
|
|
if _, err = fs.Get(ctx, dst, &fs.GetArgs{}); err == nil {
|
|
dstExisted = true
|
|
if !overwrite {
|
|
return http.StatusPreconditionFailed, nil
|
|
}
|
|
} else if !errs.IsObjectNotFound(err) {
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
|
|
err = runDeadPropsFileMutation(ctx,
|
|
func(tx *gorm.DB) error { return copyDeadProps(tx, src, dst) },
|
|
func() (*fileMutation, error) {
|
|
return copyWithRollback(ctx, src, dstDir, dstName, dstExisted && overwrite, shallow)
|
|
},
|
|
)
|
|
if err != nil {
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
if dstExisted {
|
|
return http.StatusNoContent, nil
|
|
}
|
|
return http.StatusCreated, nil
|
|
}
|
|
|
|
// walkFS traverses filesystem fs starting at name up to depth levels.
|
|
//
|
|
// Allowed values for depth are 0, 1 or infiniteDepth. For each visited node,
|
|
// walkFS calls walkFn. If a visited file system node is a directory and
|
|
// walkFn returns path.SkipDir, walkFS will skip traversal of this node.
|
|
func walkFS(ctx context.Context, depth int, name string, info model.Obj, walkFn func(reqPath string, info model.Obj, err error) error) error {
|
|
// This implementation is based on Walk's code in the standard path/path package.
|
|
err := walkFn(name, info, nil)
|
|
if err != nil {
|
|
if info.IsDir() && err == filepath.SkipDir {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if !info.IsDir() || depth == 0 {
|
|
return nil
|
|
}
|
|
if depth == 1 {
|
|
depth = 0
|
|
}
|
|
meta, _ := op.GetNearestMeta(name)
|
|
// Read directory names.
|
|
objs, err := fs.List(context.WithValue(ctx, conf.MetaKey, meta), name, &fs.ListArgs{})
|
|
//f, err := fs.OpenFile(ctx, name, os.O_RDONLY, 0)
|
|
//if err != nil {
|
|
// return walkFn(name, info, err)
|
|
//}
|
|
//fileInfos, err := f.Readdir(0)
|
|
//f.Close()
|
|
if err != nil {
|
|
return walkFn(name, info, err)
|
|
}
|
|
|
|
for _, fileInfo := range objs {
|
|
filename := path.Join(name, fileInfo.GetName())
|
|
if err != nil {
|
|
if err := walkFn(filename, fileInfo, err); err != nil && err != filepath.SkipDir {
|
|
return err
|
|
}
|
|
} else {
|
|
err = walkFS(ctx, depth, filename, fileInfo, walkFn)
|
|
if err != nil {
|
|
if !fileInfo.IsDir() || err != filepath.SkipDir {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|