From 9dfadd4e3fae87178a6bb45b1fb1b4f50f8d4581 Mon Sep 17 00:00:00 2001 From: FeelAutom Date: Sun, 12 Jul 2026 11:35:08 +0200 Subject: [PATCH 1/2] fix(rclone): load dependent config sections in memory --- rclone/config.go | 127 ++++++++++++++++++++++++++++++++++----- rclone/connector.go | 13 +++- rclone/connector_test.go | 38 ++++++++++++ 3 files changed, 163 insertions(+), 15 deletions(-) diff --git a/rclone/config.go b/rclone/config.go index 0912e6a..8322628 100644 --- a/rclone/config.go +++ b/rclone/config.go @@ -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 +} diff --git a/rclone/connector.go b/rclone/connector.go index 39f4065..3e515a9 100644 --- a/rclone/connector.go +++ b/rclone/connector.go @@ -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 { diff --git a/rclone/connector_test.go b/rclone/connector_test.go index 71ca794..617ff99 100644 --- a/rclone/connector_test.go +++ b/rclone/connector_test.go @@ -20,7 +20,9 @@ import ( "bytes" "io" "io/fs" + "os" "path" + "path/filepath" "sort" "strings" "testing" @@ -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(), "/", "-") From 71e96ed4a311a3ee876b09ad1098ae3ac5e42b5f Mon Sep 17 00:00:00 2001 From: FeelAutom Date: Sun, 12 Jul 2026 11:39:52 +0200 Subject: [PATCH 2/2] docs(rclone): document config file for wrapper remotes --- rclone/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/rclone/README.md b/rclone/README.md index 7536697..ca27071 100644 --- a/rclone/README.md +++ b/rclone/README.md @@ -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: