Skip to content

Commit

Permalink
Add a new Samba backend
Browse files Browse the repository at this point in the history
The storage url is smb://user@server[:port]/share/path.  The password can be
set in the environment variable DUPLICACY_SMB_PASSWORD for default storage or
DUPLICACY_<STORAGE_NAME>_SMB_PASSWORD.

This backend is based on https://github.com/hirochachacha/go-smb2.  The
previous samba:// backend is just an alias for the disk-based backend with
caching enabled.
  • Loading branch information
gilbertchen committed Jul 6, 2023
1 parent cdf8f5a commit 3a81c10
Show file tree
Hide file tree
Showing 3 changed files with 305 additions and 3 deletions.
250 changes: 250 additions & 0 deletions src/duplicacy_sambastorage.go
@@ -0,0 +1,250 @@
// Copyright (c) Acrosync LLC. All rights reserved.
// Free for personal use and commercial trial
// Commercial use requires per-user licenses available from https://duplicacy.com

package duplicacy

import (
"io"
"os"
"fmt"
"net"
"path"
"time"
"strings"
"syscall"
"math/rand"

"github.com/hirochachacha/go-smb2"
)

// SambaStorage is a local on-disk file storage implementing the Storage interface.
type SambaStorage struct {
StorageBase

share *smb2.Share
storageDir string
numberOfThreads int
}

// CreateSambaStorage creates a file storage.
func CreateSambaStorage(server string, port int, username string, password string, shareName string, storageDir string, threads int) (storage *SambaStorage, err error) {

connection, err := net.Dial("tcp", fmt.Sprintf("%s:%d", server, port))
if err != nil {
return nil, err
}

dialer := &smb2.Dialer{
Initiator: &smb2.NTLMInitiator{
User: username,
Password: password,
},
}

client, err := dialer.Dial(connection)
if err != nil {
return nil, err
}

share, err := client.Mount(shareName)
if err != nil {
return nil, err
}

// Random number fo generating the temporary chunk file suffix.
rand.Seed(time.Now().UnixNano())

storage = &SambaStorage{
share: share,
numberOfThreads: threads,
}

exist, isDir, _, err := storage.GetFileInfo(0, storageDir)
if err != nil {
return nil, fmt.Errorf("Failed to check the storage path %s: %v", storageDir, err)
}

if !exist {
return nil, fmt.Errorf("The storage path %s does not exist", storageDir)
}

if !isDir {
return nil, fmt.Errorf("The storage path %s is not a directory", storageDir)
}

storage.storageDir = storageDir
storage.DerivedStorage = storage
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
return storage, nil
}

// ListFiles return the list of files and subdirectories under 'dir' (non-recursively).
func (storage *SambaStorage) ListFiles(threadIndex int, dir string) (files []string, sizes []int64, err error) {

fullPath := path.Join(storage.storageDir, dir)

list, err := storage.share.ReadDir(fullPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil, nil
}
return nil, nil, err
}

for _, f := range list {
name := f.Name()
if (f.IsDir() || f.Mode() & os.ModeSymlink != 0) && name[len(name)-1] != '/' {
name += "/"
}
files = append(files, name)
sizes = append(sizes, f.Size())
}

return files, sizes, nil
}

// DeleteFile deletes the file or directory at 'filePath'.
func (storage *SambaStorage) DeleteFile(threadIndex int, filePath string) (err error) {
err = storage.share.Remove(path.Join(storage.storageDir, filePath))
if err == nil || os.IsNotExist(err) {
return nil
} else {
return err
}
}

// MoveFile renames the file.
func (storage *SambaStorage) MoveFile(threadIndex int, from string, to string) (err error) {
return storage.share.Rename(path.Join(storage.storageDir, from), path.Join(storage.storageDir, to))
}

// CreateDirectory creates a new directory.
func (storage *SambaStorage) CreateDirectory(threadIndex int, dir string) (err error) {
fmt.Printf("Creating directory %s\n", dir)
err = storage.share.Mkdir(path.Join(storage.storageDir, dir), 0744)
if err != nil && os.IsExist(err) {
return nil
} else {
return err
}
}

// GetFileInfo returns the information about the file or directory at 'filePath'.
func (storage *SambaStorage) GetFileInfo(threadIndex int, filePath string) (exist bool, isDir bool, size int64, err error) {
stat, err := storage.share.Stat(path.Join(storage.storageDir, filePath))
if err != nil {
if os.IsNotExist(err) {
return false, false, 0, nil
} else {
return false, false, 0, err
}
}

return true, stat.IsDir(), stat.Size(), nil
}

// DownloadFile reads the file at 'filePath' into the chunk.
func (storage *SambaStorage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) {

file, err := storage.share.Open(path.Join(storage.storageDir, filePath))

if err != nil {
return err
}

defer file.Close()
if _, err = RateLimitedCopy(chunk, file, storage.DownloadRateLimit/storage.numberOfThreads); err != nil {
return err
}

return nil

}

// UploadFile writes 'content' to the file at 'filePath'
func (storage *SambaStorage) UploadFile(threadIndex int, filePath string, content []byte) (err error) {

fullPath := path.Join(storage.storageDir, filePath)

if len(strings.Split(filePath, "/")) > 2 {
dir := path.Dir(fullPath)
stat, err := storage.share.Stat(dir)
if err != nil {
if !os.IsNotExist(err) {
return err
}
err = storage.share.MkdirAll(dir, 0744)
if err != nil {
return err
}
} else {
if !stat.IsDir() && stat.Mode() & os.ModeSymlink == 0 {
return fmt.Errorf("The path %s is not a directory or symlink", dir)
}
}
}

letters := "abcdefghijklmnopqrstuvwxyz"
suffix := make([]byte, 8)
for i := range suffix {
suffix[i] = letters[rand.Intn(len(letters))]
}

temporaryFile := fullPath + "." + string(suffix) + ".tmp"

file, err := storage.share.Create(temporaryFile)
if err != nil {
return err
}

reader := CreateRateLimitedReader(content, storage.UploadRateLimit/storage.numberOfThreads)
_, err = io.Copy(file, reader)
if err != nil {
file.Close()
return err
}

if err = file.Sync(); err != nil {
pathErr, ok := err.(*os.PathError)
isNotSupported := ok && pathErr.Op == "sync" && pathErr.Err == syscall.ENOTSUP
if !isNotSupported {
_ = file.Close()
return err
}
}

err = file.Close()
if err != nil {
return err
}

err = storage.share.Rename(temporaryFile, fullPath)
if err != nil {

if _, e := storage.share.Stat(fullPath); e == nil {
storage.share.Remove(temporaryFile)
return nil
} else {
return err
}
}

return nil
}

// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
// managing snapshots.
func (storage *SambaStorage) IsCacheNeeded() bool { return true }

// If the 'MoveFile' method is implemented.
func (storage *SambaStorage) IsMoveFileImplemented() bool { return true }

// If the storage can guarantee strong consistency.
func (storage *SambaStorage) IsStrongConsistent() bool { return true }

// If the storage supports fast listing of files names.
func (storage *SambaStorage) IsFastListing() bool { return false }

// Enable the test mode.
func (storage *SambaStorage) EnableTestMode() {}
37 changes: 37 additions & 0 deletions src/duplicacy_storage.go
Expand Up @@ -757,6 +757,43 @@ func CreateStorage(preference Preference, resetPassword bool, threads int) (stor
return nil
}
return storjStorage
} else if matched[1] == "smb" {
server := matched[3]
username := matched[2]
if username == "" {
LOG_ERROR("STORAGE_CREATE", "No username is provided to access the SAMBA storage")
return nil
}
username = username[:len(username)-1]
storageDir := matched[5]
port := 445

if strings.Contains(server, ":") {
index := strings.Index(server, ":")
port, _ = strconv.Atoi(server[index+1:])
server = server[:index]
}

if !strings.Contains(storageDir, "/") {
LOG_ERROR("STORAGE_CREATE", "No share name specified for the SAMBA storage")
return nil
}

index := strings.Index(storageDir, "/")
shareName := storageDir[:index]
storageDir = storageDir[index+1:]

prompt := fmt.Sprintf("Enter the SAMBA password:")
password := GetPassword(preference, "smb_password", prompt, true, resetPassword)
sambaStorage, err := CreateSambaStorage(server, port, username, password, shareName, storageDir, threads)
if err != nil {
LOG_ERROR("STORAGE_CREATE", "Failed to load the SAMBA storage at %s: %v", storageURL, err)
return nil
}
SavePassword(preference, "smb_password", password)
return sambaStorage


} else {
LOG_ERROR("STORAGE_CREATE", "The storage type '%s' is not supported", matched[1])
return nil
Expand Down
21 changes: 18 additions & 3 deletions src/duplicacy_storage_test.go
Expand Up @@ -136,15 +136,15 @@ func loadStorage(localStoragePath string, threads int) (Storage, error) {
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
return storage, err
} else if *testStorageName == "one" {
storage, err := CreateOneDriveStorage(config["token_file"], false, config["storage_path"], threads)
storage, err := CreateOneDriveStorage(config["token_file"], false, config["storage_path"], threads, "", "", "")
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
return storage, err
} else if *testStorageName == "odb" {
storage, err := CreateOneDriveStorage(config["token_file"], true, config["storage_path"], threads)
storage, err := CreateOneDriveStorage(config["token_file"], true, config["storage_path"], threads, "", "", "")
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
return storage, err
} else if *testStorageName == "one" {
storage, err := CreateOneDriveStorage(config["token_file"], false, config["storage_path"], threads)
storage, err := CreateOneDriveStorage(config["token_file"], false, config["storage_path"], threads, "", "", "")
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
return storage, err
} else if *testStorageName == "hubic" {
Expand Down Expand Up @@ -176,6 +176,21 @@ func loadStorage(localStoragePath string, threads int) (Storage, error) {
}
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
return storage, err
} else if *testStorageName == "storj" {
storage, err := CreateStorjStorage(config["satellite"], config["key"], config["passphrase"], config["bucket"], config["storage_path"], threads)
if err != nil {
return nil, err
}
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
return storage, err
} else if *testStorageName == "smb" {
port, _ := strconv.Atoi(config["port"])
storage, err := CreateSambaStorage(config["server"], port, config["username"], config["password"], config["share"], config["storage_path"], threads)
if err != nil {
return nil, err
}
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
return storage, err
}

return nil, fmt.Errorf("Invalid storage named: %s", *testStorageName)
Expand Down

3 comments on commit 3a81c10

@gilbertchen
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This commit has been mentioned on Duplicacy Forum. There might be relevant details there:

https://forum.duplicacy.com/t/direct-samba-support-in-cli/7750/1

@gilbertchen
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This commit has been mentioned on Duplicacy Forum. There might be relevant details there:

https://forum.duplicacy.com/t/cli-release-3-2-0-is-now-available/7952/1

@gilbertchen
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This commit has been mentioned on Duplicacy Forum. There might be relevant details there:

https://forum.duplicacy.com/t/duplicacy-cli-3-2-2-release/8019/1

Please sign in to comment.