From 458e2f2456a6cc2e9b8ddcb7475698019b0a4d3b Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Wed, 23 Sep 2026 12:51:29 -0700 Subject: [PATCH] fix(deps): protect owned destination files Signed-off-by: Joe Beda --- internal/deps/deps.go | 10 ++++++---- internal/deps/deps_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/internal/deps/deps.go b/internal/deps/deps.go index 52f07c2..a1ca8c4 100644 --- a/internal/deps/deps.go +++ b/internal/deps/deps.go @@ -298,12 +298,14 @@ func guardTarget(target string, src Source) (replaced bool, err error) { "%s already exists and carries no provenance header, so it is a model this repository owns rather than a copy of one — importing would overwrite it. Import into a different directory, or move that file aside first", target) } - // A header too malformed to name where it came from is still a vendored - // copy, and replacing it is how it gets repaired; only a header that names - // a *different* model blocks the write. + // A malformed header can still be repaired, but only if its surviving + // identity names this exact source. A reserved-prefix comment alone is not + // enough to establish that this repository does not own the file. h, _ := provenance.Parse(existing) if h.Origin == "" || h.Path == "" { - return true, nil + return false, fmt.Errorf( + "%s cannot be identified as a copy of the source (%s/%s) because its provenance header does not name both an origin and path. Import into a different directory, or deliberately move or delete the existing file first", + target, src.Origin, src.Path) } switch { // GitHub treats an owner and a repository name case-insensitively, and diff --git a/internal/deps/deps_test.go b/internal/deps/deps_test.go index 94ce8ed..560bcdb 100644 --- a/internal/deps/deps_test.go +++ b/internal/deps/deps_test.go @@ -443,6 +443,39 @@ func TestImport_RefusesToOverwriteWhatItDidNotWrite(t *testing.T) { } } +func TestImport_RefusesUnknownReservedPrefixAtTarget(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + target := filepath.Join(dir, "payments.modelith.yaml") + existing := "# modelith-note: locally owned\n" + strings.Replace(upstream, "title: Payments", "title: Ours", 1) + if err := os.WriteFile(target, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + _, err := importInto(t, dir, &fakeRunner{content: upstream, sha: sha}, blobURL) + if err == nil { + t.Fatal("an unknown reserved-prefix comment allowed import to overwrite a local model") + } + for _, want := range []string{ + "cannot be identified as a copy of the source", + "Import into a different directory", + "deliberately move or delete", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("the error does not offer %q: %v", want, err) + } + } + + got, readErr := os.ReadFile(target) + if readErr != nil { + t.Fatal(readErr) + } + if string(got) != existing { + t.Errorf("the refused import wrote over the file anyway:\n%s", got) + } +} + // TestSplitHint pins that the ref/path hint is offered for the failure it // explains and no other. It was appended to every fetch error, so "gh is not // installed" arrived with a paragraph about ref splitting attached — advice