Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions rclone/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ $ rclone config show | plakar store import configname

> *Note:* The `configname` is the name of the Rclone remote you configured in `rclone config`.

For wrapper remotes such as `crypt`, `alias`, or `chunker`, the imported remote
may depend on another remote section from your Rclone configuration. The
integration loads the local Rclone config into its runtime config when available,
so dependent sections can be resolved.

If the Rclone config is not in the default location, pass it explicitly:

```bash
$ rclone config show | plakar store import myCryptRemote
$ plakar store set myCryptRemote rclone_config_file=/path/to/rclone.conf
```

## Supported Providers

Plakar supports the following Rclone providers for backup and restore operations:
Expand Down
127 changes: 113 additions & 14 deletions rclone/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,51 +17,150 @@
package rclone

import (
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strings"
)

// mapconfig implements config.Storage using a flat map.
type mapconfig struct {
name string
data map[string]string
name string
sections map[string]map[string]string
}

func newMapConfig(name string, data map[string]string, baseConfigPath string) (*mapconfig, error) {
sections, err := loadRcloneConfigSections(baseConfigPath)
if err != nil {
return nil, err
}

sections[name] = data

return &mapconfig{
name: name,
sections: sections,
}, nil
}

func (m *mapconfig) Load() error { return nil }
func (m *mapconfig) Save() error { return nil }
func (m *mapconfig) Serialize() (string, error) { return "", nil }
func (m *mapconfig) GetSectionList() []string { return []string{m.name} }
func (m *mapconfig) HasSection(section string) bool { return section == m.name }
func (m *mapconfig) GetSectionList() []string { return slices.Sorted(maps.Keys(m.sections)) }
func (m *mapconfig) HasSection(section string) bool { _, ok := m.sections[section]; return ok }
func (m *mapconfig) DeleteSection(section string) { return }

func (m *mapconfig) GetKeyList(section string) []string {
if section != m.name {
data := m.sections[section]
if data == nil {
return nil
}
return slices.Collect(maps.Keys(m.data))
return slices.Collect(maps.Keys(data))
}

func (m *mapconfig) GetValue(section, key string) (string, bool) {
if section != m.name {
data := m.sections[section]
if data == nil {
return "", false
}
v, ok := m.data[key]
v, ok := data[key]
return v, ok
}

func (m *mapconfig) SetValue(section, key, value string) {
if section != m.name {
return
data := m.sections[section]
if data == nil {
data = make(map[string]string)
m.sections[section] = data
}
m.data[key] = value
data[key] = value
}

func (m *mapconfig) DeleteKey(section, key string) bool {
if section != m.name {
data := m.sections[section]
if data == nil {
return false
}

_, ok := m.data[key]
delete(m.data, key)
_, ok := data[key]
delete(data, key)
return ok
}

func loadRcloneConfigSections(explicitPath string) (map[string]map[string]string, error) {
sections := make(map[string]map[string]string)

configPath, explicit := resolveRcloneConfigPath(explicitPath)
if configPath == "" {
return sections, nil
}

content, err := os.ReadFile(configPath)
if err != nil {
if explicit {
return nil, fmt.Errorf("failed to read rclone config file %s: %w", configPath, err)
}
return sections, nil
}

var current map[string]string
for _, line := range strings.Split(string(content), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}

if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
section := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]"))
if section == "" {
current = nil
continue
}
current = sections[section]
if current == nil {
current = make(map[string]string)
sections[section] = current
}
continue
}

if current == nil {
continue
}

key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
current[strings.TrimSpace(key)] = strings.TrimSpace(value)
}

return sections, nil
}

func resolveRcloneConfigPath(explicitPath string) (string, bool) {
if explicitPath != "" {
return explicitPath, true
}

candidates := []string{}
if path := os.Getenv("RCLONE_CONFIG"); path != "" {
candidates = append(candidates, path)
}
if configDir, err := os.UserConfigDir(); err == nil && configDir != "" {
candidates = append(candidates, filepath.Join(configDir, "rclone", "rclone.conf"))
}
if homeDir, err := os.UserHomeDir(); err == nil && homeDir != "" {
candidates = append(candidates, filepath.Join(homeDir, ".config", "rclone", "rclone.conf"))
}

for _, path := range candidates {
if stat, err := os.Stat(path); err == nil && !stat.IsDir() {
return path, false
}
}

return "", false
}
13 changes: 12 additions & 1 deletion rclone/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,18 @@ func New(ctx context.Context, opts *connectors.Options, name string, params map[
}
}

config.SetData(&mapconfig{name: typ, data: rconfig})
baseConfigPath := rconfig["config_file"]
if baseConfigPath == "" {
baseConfigPath = rconfig["config"]
}
delete(rconfig, "config_file")
delete(rconfig, "config")

rcloneConfig, err := newMapConfig(typ, rconfig, baseConfigPath)
if err != nil {
return nil, err
}
config.SetData(rcloneConfig)

f, err := rclonefs.NewFs(ctx, fmt.Sprintf("%s:%s", typ, base))
if err != nil {
Expand Down
38 changes: 38 additions & 0 deletions rclone/connector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import (
"bytes"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
"testing"
Expand All @@ -36,6 +38,42 @@ import (
_ "github.com/rclone/rclone/backend/memory" // register memory backend
)

func TestMapConfigLoadsDependentSections(t *testing.T) {
dir := t.TempDir()
sourceConfig := filepath.Join(dir, "rclone.conf")
err := os.WriteFile(sourceConfig, []byte(
"[gdrive]\n"+
"type = drive\n"+
"token = redacted\n\n"+
"[crypt]\n"+
"type = crypt\n"+
"remote = stale:path\n",
), 0o600)
require.NoError(t, err)

configMap, err := newMapConfig("crypt", map[string]string{
"type": "crypt",
"remote": "gdrive:Workspace/Backups",
"password": "redacted",
}, sourceConfig)
require.NoError(t, err)

require.True(t, configMap.HasSection("gdrive"))
require.True(t, configMap.HasSection("crypt"))
require.False(t, configMap.HasSection("missing"))

remote, ok := configMap.GetValue("crypt", "remote")
require.True(t, ok)
assert.Equal(t, "gdrive:Workspace/Backups", remote)

token, ok := configMap.GetValue("gdrive", "token")
require.True(t, ok)
assert.Equal(t, "redacted", token)

_, ok = configMap.GetValue("crypt", "config_file")
assert.False(t, ok)
}

func newRclone(t *testing.T) *Rclone {
t.Helper()
name := strings.ReplaceAll(t.Name(), "/", "-")
Expand Down
Loading