From 44d083d2df49e0b5a9e708e82147487f308741f6 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 26 Oct 2026 15:23:16 -0400 Subject: [PATCH] fix(backup,templates): keep colon-less inline-credential remotes out of the bare path map; count stored key.2..10 in the template gate (D-1156, D-1170) --- cmd/commands/init_templates_source.go | 7 +++++++ cmd/commands/init_templates_test.go | 19 +++++++++++++++++++ internal/backup/stream_dest_compat.go | 13 ++++++++++++- internal/backup/stream_dest_compat_test.go | 2 ++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/cmd/commands/init_templates_source.go b/cmd/commands/init_templates_source.go index 99d1b5359..1865199d4 100644 --- a/cmd/commands/init_templates_source.go +++ b/cmd/commands/init_templates_source.go @@ -47,6 +47,13 @@ var templateSource clonetemplate.Source = func(ctx context.Context) (clonetempla // coverage under any of them counts (multi-key support, as plugin install). func templateLicenseKeys() []string { keys := license.CollectLicenseKeys() + // CollectLicenseKeys reads only the primary key file; key.2 to key.10 are + // stored keys too, and a covered second key must not read as E110. + for _, k := range license.GetAllStoredKeys() { + if k = strings.TrimSpace(k); k != "" && !slices.Contains(keys, k) { + keys = append(keys, k) + } + } if k := strings.TrimSpace(license.CollectLicenseKey()); k != "" { keys = append([]string{k}, slices.DeleteFunc(keys, func(x string) bool { return x == k })...) } diff --git a/cmd/commands/init_templates_test.go b/cmd/commands/init_templates_test.go index 4b9695be0..0557e3499 100644 --- a/cmd/commands/init_templates_test.go +++ b/cmd/commands/init_templates_test.go @@ -254,6 +254,25 @@ func TestTemplateLicenseKeysOwnerFirst(t *testing.T) { } } +// TestTemplateLicenseKeysStoredNumbered: stored key.2 to key.10 files count as +// configured keys, after the primary key and each once (D-1170). +func TestTemplateLicenseKeysStoredNumbered(t *testing.T) { + compattest.Set(t, true) + isolateSource(t, func(http.ResponseWriter, *http.Request) {}) + dir := filepath.Join(os.Getenv("HOME"), ".nself", "license") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + for name, key := range map[string]string{"key": "fake-key-one", "key.2": "fake-key-two\n", "key.10": "fake-key-ten", "key.3": "fake-key-one"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(key), 0o600); err != nil { + t.Fatal(err) + } + } + if got := strings.Join(templateLicenseKeys(), ","); got != "fake-key-one,fake-key-two,fake-key-ten" { + t.Errorf("keys = %q, want the primary then key.2 and key.10, each once", got) + } +} + // TestTemplateLicenseKeysOwnerKeyTrimmed: a trailing newline on the owner key // must not make the licence server read as unreachable. func TestTemplateLicenseKeysOwnerKeyTrimmed(t *testing.T) { diff --git a/internal/backup/stream_dest_compat.go b/internal/backup/stream_dest_compat.go index c18aaf9c3..217ea1215 100644 --- a/internal/backup/stream_dest_compat.go +++ b/internal/backup/stream_dest_compat.go @@ -12,6 +12,7 @@ import ( "io" "os" "path/filepath" + "regexp" "strings" "sync" @@ -29,13 +30,19 @@ var ( bareNoticed = map[string]bool{} ) +// inlineOptionRe is the `,key=` shape of an inline rclone option +// ("myr,pass=x"). A relative value carrying it is a malformed remote, not a +// directory, even when no ":" follows. +var inlineOptionRe = regexp.MustCompile(`,[A-Za-z0-9_]+=`) + // isBarePath reports whether rclone would have read uri as a local path: not // empty, not option-shaped, and no ":" before the first "/" (so no scheme, no // name:path remote and no :backend connection string). A value starting with // "/" is always a path (no rclone remote starts with "/"), commas included. // A value with padding whitespace or a control character, and a relative // value with an inline-credential shape (a "," before a ":", e.g. -// "myr,pass=ab/cd:p"), are left to the parser, which refuses them without +// "myr,pass=ab/cd:p", or a ",key=" option with no ":" at all, "myr,pass=ab/cd"; "./" and "../" always mean a path), +// are left to the parser, which refuses them without // echoing them (D-0467). func isBarePath(uri string) bool { if uri == "" || strings.HasPrefix(uri, "-") || uri != strings.TrimSpace(uri) { @@ -50,6 +57,10 @@ func isBarePath(uri string) bool { if strings.Contains(uri, ":") && destinations.HasInlineCredentials(uri) { return false } + explicitPath := strings.HasPrefix(uri, "./") || strings.HasPrefix(uri, "../") + if !explicitPath && inlineOptionRe.MatchString(uri) { + return false + } head, _, _ := strings.Cut(uri, "/") return !strings.Contains(head, ":") } diff --git a/internal/backup/stream_dest_compat_test.go b/internal/backup/stream_dest_compat_test.go index 68ba475eb..9eb542b64 100644 --- a/internal/backup/stream_dest_compat_test.go +++ b/internal/backup/stream_dest_compat_test.go @@ -77,6 +77,8 @@ func TestSettleBarePath(t *testing.T) { {"old,2026", "path:///srv/proj/old,2026", true}, {"myr,pass=fake/secret-not-real:p", "myr,pass=fake/secret-not-real:p", false}, {"s3,secret_access_key=fake/secret-not-real:b", "s3,secret_access_key=fake/secret-not-real:b", false}, + {"myr,pass=fake/secret-not-real", "myr,pass=fake/secret-not-real", false}, + {"./myr,pass=fake-secret-not-real", "path:///srv/proj/myr,pass=fake-secret-not-real", true}, {"-x", "-x", false}, {" /mnt/backups", " /mnt/backups", false}, {"/mnt/a\nb", "/mnt/a\nb", false},