594 lines
18 KiB
Go
594 lines
18 KiB
Go
package tenantbackup
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/klarkxy/nekonest-cloud/relay/internal/tenantfs"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
const manifestVersion = 1
|
|
|
|
type File struct {
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size"`
|
|
SHA256 string `json:"sha256"`
|
|
}
|
|
|
|
type Manifest struct {
|
|
Version int `json:"version"`
|
|
TenantID string `json:"tenant_id"`
|
|
PlacementGeneration int64 `json:"placement_generation"`
|
|
CreatedAt string `json:"created_at"`
|
|
Files []File `json:"files"`
|
|
}
|
|
|
|
type restoreReceipt struct {
|
|
Version int `json:"version"`
|
|
TenantID string `json:"tenant_id"`
|
|
PlacementGeneration int64 `json:"placement_generation"`
|
|
ManifestSHA256 string `json:"manifest_sha256"`
|
|
}
|
|
|
|
type Result struct {
|
|
Path string
|
|
BackupRef string
|
|
Manifest Manifest
|
|
ManifestSHA256 string
|
|
}
|
|
|
|
func validBackupRef(value string) bool {
|
|
parts := strings.Split(filepath.ToSlash(value), "/")
|
|
if len(parts) != 2 || len(parts[0]) != 32 || !strings.HasPrefix(parts[1], "g") || strings.Contains(parts[1], ".tmp") {
|
|
return false
|
|
}
|
|
for _, char := range parts[0] {
|
|
if !strings.ContainsRune("0123456789abcdef", char) {
|
|
return false
|
|
}
|
|
}
|
|
if len(parts[1]) != len("g00000000000000000000-20060102T150405Z-0000000000000000") {
|
|
return false
|
|
}
|
|
for index, char := range parts[1] {
|
|
switch index {
|
|
case 0:
|
|
if char != 'g' {
|
|
return false
|
|
}
|
|
case 21, 38:
|
|
if char != '-' {
|
|
return false
|
|
}
|
|
case 30:
|
|
if char != 'T' {
|
|
return false
|
|
}
|
|
case 37:
|
|
if char != 'Z' {
|
|
return false
|
|
}
|
|
default:
|
|
if index >= 39 {
|
|
if !strings.ContainsRune("0123456789abcdef", char) {
|
|
return false
|
|
}
|
|
} else if char < '0' || char > '9' {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ResolveReference turns an opaque control-plane backup reference into a
|
|
// local immutable backup path without accepting an absolute path or symlink.
|
|
func ResolveReference(backupRoot, reference string) (string, error) {
|
|
if !validBackupRef(reference) {
|
|
return "", fmt.Errorf("invalid backup reference")
|
|
}
|
|
root, err := filepath.Abs(strings.TrimSpace(backupRoot))
|
|
if err != nil || strings.TrimSpace(backupRoot) == "" {
|
|
return "", fmt.Errorf("invalid backup root")
|
|
}
|
|
if info, err := os.Lstat(root); err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return "", fmt.Errorf("backup root is not a real directory")
|
|
}
|
|
parts := strings.Split(filepath.ToSlash(reference), "/")
|
|
parent := filepath.Join(root, parts[0])
|
|
if info, err := os.Lstat(parent); err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return "", fmt.Errorf("backup tenant directory is not real")
|
|
}
|
|
resolved := filepath.Join(parent, parts[1])
|
|
if filepath.Dir(resolved) != parent {
|
|
return "", fmt.Errorf("backup reference escaped root")
|
|
}
|
|
if info, err := os.Lstat(resolved); err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return "", fmt.Errorf("backup reference is unavailable")
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
func randomHex(size int) (string, error) {
|
|
value := make([]byte, size)
|
|
if _, err := rand.Read(value); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(value), nil
|
|
}
|
|
|
|
func ensureRealDirectory(path string) error {
|
|
if err := os.MkdirAll(path, 0o700); err != nil {
|
|
return err
|
|
}
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return fmt.Errorf("%s is not a real directory", path)
|
|
}
|
|
return os.Chmod(path, 0o700)
|
|
}
|
|
|
|
func regularFile(path string) (os.FileInfo, error) {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
return nil, fmt.Errorf("%s is not a regular file", path)
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
func validAttachmentName(name string) bool {
|
|
stem := ""
|
|
switch {
|
|
case strings.HasSuffix(name, ".bin"):
|
|
stem = strings.TrimSuffix(name, ".bin")
|
|
case strings.HasSuffix(name, ".json"):
|
|
stem = strings.TrimSuffix(name, ".json")
|
|
default:
|
|
return false
|
|
}
|
|
if len(stem) != 32 {
|
|
return false
|
|
}
|
|
for _, char := range stem {
|
|
if !strings.ContainsRune("0123456789abcdef", char) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func checkpointAndVerify(path string) error {
|
|
if _, err := regularFile(path); err != nil {
|
|
return fmt.Errorf("inspect sqlite database: %w", err)
|
|
}
|
|
database, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer database.Close()
|
|
var busy, logFrames, checkpointed int
|
|
if err := database.QueryRow(`PRAGMA wal_checkpoint(TRUNCATE)`).Scan(&busy, &logFrames, &checkpointed); err != nil {
|
|
return fmt.Errorf("checkpoint sqlite: %w", err)
|
|
}
|
|
if busy != 0 {
|
|
return fmt.Errorf("sqlite checkpoint remained busy")
|
|
}
|
|
var result string
|
|
if err := database.QueryRow(`PRAGMA integrity_check`).Scan(&result); err != nil {
|
|
return fmt.Errorf("check sqlite integrity: %w", err)
|
|
}
|
|
if result != "ok" {
|
|
return fmt.Errorf("sqlite integrity check failed: %s", result)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func verifySQLiteReadOnly(path string) error {
|
|
if _, err := regularFile(path); err != nil {
|
|
return fmt.Errorf("inspect sqlite database: %w", err)
|
|
}
|
|
database, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro&immutable=1&_pragma=query_only(1)")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer database.Close()
|
|
var result string
|
|
if err := database.QueryRow(`PRAGMA integrity_check`).Scan(&result); err != nil {
|
|
return fmt.Errorf("check restored sqlite integrity: %w", err)
|
|
}
|
|
if result != "ok" {
|
|
return fmt.Errorf("restored sqlite integrity check failed: %s", result)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hashFile(ctx context.Context, path string) (int64, string, error) {
|
|
if _, err := regularFile(path); err != nil {
|
|
return 0, "", err
|
|
}
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
defer file.Close()
|
|
hash := sha256.New()
|
|
buffer := make([]byte, 128<<10)
|
|
var total int64
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return 0, "", err
|
|
}
|
|
count, readErr := file.Read(buffer)
|
|
if count > 0 {
|
|
total += int64(count)
|
|
_, _ = hash.Write(buffer[:count])
|
|
}
|
|
if errors.Is(readErr, io.EOF) {
|
|
break
|
|
}
|
|
if readErr != nil {
|
|
return 0, "", readErr
|
|
}
|
|
}
|
|
return total, hex.EncodeToString(hash.Sum(nil)), nil
|
|
}
|
|
|
|
func copyFile(ctx context.Context, source, destination string) (File, error) {
|
|
if _, err := regularFile(source); err != nil {
|
|
return File{}, err
|
|
}
|
|
input, err := os.Open(source)
|
|
if err != nil {
|
|
return File{}, err
|
|
}
|
|
defer input.Close()
|
|
if err := ensureRealDirectory(filepath.Dir(destination)); err != nil {
|
|
return File{}, err
|
|
}
|
|
output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return File{}, err
|
|
}
|
|
remove := true
|
|
defer func() {
|
|
_ = output.Close()
|
|
if remove {
|
|
_ = os.Remove(destination)
|
|
}
|
|
}()
|
|
hash := sha256.New()
|
|
written, err := io.Copy(io.MultiWriter(output, hash), &contextReader{ctx: ctx, reader: input})
|
|
if err != nil {
|
|
return File{}, err
|
|
}
|
|
if err := output.Sync(); err != nil {
|
|
return File{}, err
|
|
}
|
|
if err := output.Close(); err != nil {
|
|
return File{}, err
|
|
}
|
|
remove = false
|
|
return File{Size: written, SHA256: hex.EncodeToString(hash.Sum(nil))}, nil
|
|
}
|
|
|
|
type contextReader struct {
|
|
ctx context.Context
|
|
reader io.Reader
|
|
}
|
|
|
|
func (reader *contextReader) Read(buffer []byte) (int, error) {
|
|
if err := reader.ctx.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
return reader.reader.Read(buffer)
|
|
}
|
|
|
|
func sourceFiles(paths tenantfs.Paths) ([]struct{ absolute, relative string }, error) {
|
|
if _, err := regularFile(paths.Database); err != nil {
|
|
return nil, err
|
|
}
|
|
attachmentInfo, err := os.Lstat(paths.Attachments)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if attachmentInfo.Mode()&os.ModeSymlink != 0 || !attachmentInfo.IsDir() {
|
|
return nil, fmt.Errorf("attachment root is not a real directory")
|
|
}
|
|
result := []struct{ absolute, relative string }{{paths.Database, "relay.db"}}
|
|
entries, err := os.ReadDir(paths.Attachments)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || !validAttachmentName(entry.Name()) {
|
|
return nil, fmt.Errorf("invalid attachment artifact %q", entry.Name())
|
|
}
|
|
result = append(result, struct{ absolute, relative string }{
|
|
filepath.Join(paths.Attachments, entry.Name()), filepath.ToSlash(filepath.Join("attachments", entry.Name())),
|
|
})
|
|
}
|
|
sort.Slice(result, func(left, right int) bool { return result[left].relative < result[right].relative })
|
|
return result, nil
|
|
}
|
|
|
|
func Create(ctx context.Context, dataRoot, backupRoot, tenantID string, generation int64, now time.Time) (Result, error) {
|
|
if generation < 1 || now.IsZero() {
|
|
return Result{}, fmt.Errorf("invalid backup generation or timestamp")
|
|
}
|
|
paths, err := tenantfs.Derive(dataRoot, tenantID)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := checkpointAndVerify(paths.Database); err != nil {
|
|
return Result{}, err
|
|
}
|
|
files, err := sourceFiles(paths)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
backupRoot, err = filepath.Abs(strings.TrimSpace(backupRoot))
|
|
if err != nil || strings.TrimSpace(backupRoot) == "" {
|
|
return Result{}, fmt.Errorf("invalid backup root")
|
|
}
|
|
if err := ensureRealDirectory(backupRoot); err != nil {
|
|
return Result{}, err
|
|
}
|
|
tenantBackupRoot := filepath.Join(backupRoot, filepath.Base(paths.Root))
|
|
if err := ensureRealDirectory(tenantBackupRoot); err != nil {
|
|
return Result{}, err
|
|
}
|
|
nonce, err := randomHex(8)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
name := fmt.Sprintf("g%020d-%s-%s", generation, now.UTC().Format("20060102T150405Z"), nonce)
|
|
finalPath := filepath.Join(tenantBackupRoot, name)
|
|
stagingPath := finalPath + ".tmp"
|
|
if err := os.Mkdir(stagingPath, 0o700); err != nil {
|
|
return Result{}, err
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = os.RemoveAll(stagingPath)
|
|
}
|
|
}()
|
|
manifest := Manifest{
|
|
Version: manifestVersion, TenantID: tenantID, PlacementGeneration: generation,
|
|
CreatedAt: now.UTC().Format(time.RFC3339Nano), Files: make([]File, 0, len(files)),
|
|
}
|
|
for _, item := range files {
|
|
copied, err := copyFile(ctx, item.absolute, filepath.Join(stagingPath, filepath.FromSlash(item.relative)))
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
copied.Path = item.relative
|
|
manifest.Files = append(manifest.Files, copied)
|
|
}
|
|
manifestBytes, err := json.Marshal(manifest)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
manifestPath := filepath.Join(stagingPath, "manifest.json")
|
|
manifestFile, err := os.OpenFile(manifestPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if _, err := manifestFile.Write(manifestBytes); err != nil {
|
|
_ = manifestFile.Close()
|
|
return Result{}, err
|
|
}
|
|
if err := manifestFile.Sync(); err != nil {
|
|
_ = manifestFile.Close()
|
|
return Result{}, err
|
|
}
|
|
if err := manifestFile.Close(); err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := os.Rename(stagingPath, finalPath); err != nil {
|
|
return Result{}, err
|
|
}
|
|
committed = true
|
|
digest := sha256.Sum256(manifestBytes)
|
|
backupRef := filepath.ToSlash(filepath.Join(filepath.Base(paths.Root), name))
|
|
if !validBackupRef(backupRef) {
|
|
return Result{}, fmt.Errorf("generated backup reference is invalid")
|
|
}
|
|
return Result{Path: finalPath, BackupRef: backupRef, Manifest: manifest, ManifestSHA256: hex.EncodeToString(digest[:])}, nil
|
|
}
|
|
|
|
func validManifestPath(path string) bool {
|
|
if path == "relay.db" {
|
|
return true
|
|
}
|
|
parts := strings.Split(path, "/")
|
|
return len(parts) == 2 && parts[0] == "attachments" && validAttachmentName(parts[1])
|
|
}
|
|
|
|
func Verify(ctx context.Context, backupPath, tenantID string, generation int64, expectedManifestSHA256 string) (Manifest, error) {
|
|
backupPath, err := filepath.Abs(strings.TrimSpace(backupPath))
|
|
if err != nil || strings.TrimSpace(backupPath) == "" {
|
|
return Manifest{}, fmt.Errorf("invalid backup path")
|
|
}
|
|
info, err := os.Lstat(backupPath)
|
|
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return Manifest{}, fmt.Errorf("backup path is not a real directory")
|
|
}
|
|
manifestPath := filepath.Join(backupPath, "manifest.json")
|
|
manifestInfo, err := regularFile(manifestPath)
|
|
if err != nil || manifestInfo.Size() > 1<<20 {
|
|
return Manifest{}, fmt.Errorf("invalid backup manifest")
|
|
}
|
|
manifestBytes, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
digest := sha256.Sum256(manifestBytes)
|
|
if expectedManifestSHA256 != hex.EncodeToString(digest[:]) {
|
|
return Manifest{}, fmt.Errorf("backup manifest digest mismatch")
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(manifestBytes))
|
|
decoder.DisallowUnknownFields()
|
|
var manifest Manifest
|
|
if err := decoder.Decode(&manifest); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
|
|
return Manifest{}, fmt.Errorf("invalid backup manifest JSON")
|
|
}
|
|
if manifest.Version != manifestVersion || manifest.TenantID != tenantID || manifest.PlacementGeneration != generation || len(manifest.Files) == 0 {
|
|
return Manifest{}, fmt.Errorf("backup manifest fence mismatch")
|
|
}
|
|
expectedFiles := map[string]struct{}{"manifest.json": {}}
|
|
previous := ""
|
|
for _, item := range manifest.Files {
|
|
if !validManifestPath(item.Path) || item.Path <= previous || item.Size < 0 || len(item.SHA256) != 64 {
|
|
return Manifest{}, fmt.Errorf("invalid backup file manifest")
|
|
}
|
|
previous = item.Path
|
|
absolute := filepath.Join(backupPath, filepath.FromSlash(item.Path))
|
|
if !strings.HasPrefix(absolute, backupPath+string(os.PathSeparator)) {
|
|
return Manifest{}, fmt.Errorf("backup file escaped root")
|
|
}
|
|
size, checksum, err := hashFile(ctx, absolute)
|
|
if err != nil || size != item.Size || checksum != item.SHA256 {
|
|
return Manifest{}, fmt.Errorf("backup file verification failed for %s", item.Path)
|
|
}
|
|
expectedFiles[filepath.Clean(item.Path)] = struct{}{}
|
|
}
|
|
err = filepath.WalkDir(backupPath, func(path string, entry os.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if path == backupPath {
|
|
return nil
|
|
}
|
|
relative, err := filepath.Rel(backupPath, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if entry.Type()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("backup contains a symbolic link")
|
|
}
|
|
if entry.IsDir() {
|
|
if filepath.Clean(relative) != "attachments" {
|
|
return fmt.Errorf("backup contains an unexpected directory")
|
|
}
|
|
return nil
|
|
}
|
|
if _, ok := expectedFiles[filepath.Clean(relative)]; !ok {
|
|
return fmt.Errorf("backup contains an unexpected file %q", relative)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
if err := verifySQLiteReadOnly(filepath.Join(backupPath, "relay.db")); err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
func Restore(ctx context.Context, backupPath, dataRoot, tenantID string, generation int64, manifestSHA256 string) (tenantfs.Paths, error) {
|
|
manifest, err := Verify(ctx, backupPath, tenantID, generation, manifestSHA256)
|
|
if err != nil {
|
|
return tenantfs.Paths{}, err
|
|
}
|
|
paths, err := tenantfs.Derive(dataRoot, tenantID)
|
|
if err != nil {
|
|
return tenantfs.Paths{}, err
|
|
}
|
|
dataRoot = filepath.Dir(filepath.Dir(paths.Root))
|
|
if err := ensureRealDirectory(dataRoot); err != nil {
|
|
return tenantfs.Paths{}, err
|
|
}
|
|
tenantsRoot := filepath.Dir(paths.Root)
|
|
if err := ensureRealDirectory(tenantsRoot); err != nil {
|
|
return tenantfs.Paths{}, err
|
|
}
|
|
if info, err := os.Lstat(paths.Root); err == nil {
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return tenantfs.Paths{}, fmt.Errorf("target tenant path is not a real directory")
|
|
}
|
|
receiptBytes, readErr := os.ReadFile(filepath.Join(paths.Root, ".restore-receipt.json"))
|
|
var receipt restoreReceipt
|
|
decoder := json.NewDecoder(bytes.NewReader(receiptBytes))
|
|
decoder.DisallowUnknownFields()
|
|
if readErr == nil && decoder.Decode(&receipt) == nil && decoder.Decode(&struct{}{}) == io.EOF &&
|
|
receipt.Version == manifestVersion && receipt.TenantID == tenantID &&
|
|
receipt.PlacementGeneration == generation && receipt.ManifestSHA256 == manifestSHA256 {
|
|
return paths, nil
|
|
}
|
|
return tenantfs.Paths{}, fmt.Errorf("target tenant directory already exists with another restore fence")
|
|
} else if !os.IsNotExist(err) {
|
|
return tenantfs.Paths{}, err
|
|
}
|
|
nonce, err := randomHex(8)
|
|
if err != nil {
|
|
return tenantfs.Paths{}, err
|
|
}
|
|
staging := filepath.Join(tenantsRoot, ".restore-"+filepath.Base(paths.Root)+"-"+nonce)
|
|
if err := os.Mkdir(staging, 0o700); err != nil {
|
|
return tenantfs.Paths{}, err
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = os.RemoveAll(staging)
|
|
}
|
|
}()
|
|
for _, item := range manifest.Files {
|
|
if _, err := copyFile(ctx, filepath.Join(backupPath, filepath.FromSlash(item.Path)), filepath.Join(staging, filepath.FromSlash(item.Path))); err != nil {
|
|
return tenantfs.Paths{}, err
|
|
}
|
|
}
|
|
if err := verifySQLiteReadOnly(filepath.Join(staging, "relay.db")); err != nil {
|
|
return tenantfs.Paths{}, err
|
|
}
|
|
receiptBytes, err := json.Marshal(restoreReceipt{
|
|
Version: manifestVersion, TenantID: tenantID,
|
|
PlacementGeneration: generation, ManifestSHA256: manifestSHA256,
|
|
})
|
|
if err != nil {
|
|
return tenantfs.Paths{}, err
|
|
}
|
|
receipt, err := os.OpenFile(filepath.Join(staging, ".restore-receipt.json"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return tenantfs.Paths{}, err
|
|
}
|
|
if _, err := receipt.Write(receiptBytes); err != nil {
|
|
_ = receipt.Close()
|
|
return tenantfs.Paths{}, err
|
|
}
|
|
if err := receipt.Sync(); err != nil {
|
|
_ = receipt.Close()
|
|
return tenantfs.Paths{}, err
|
|
}
|
|
if err := receipt.Close(); err != nil {
|
|
return tenantfs.Paths{}, err
|
|
}
|
|
if err := os.Rename(staging, paths.Root); err != nil {
|
|
return tenantfs.Paths{}, err
|
|
}
|
|
committed = true
|
|
return paths, nil
|
|
}
|