diff --git a/replace.go b/replace.go index c199974..af37041 100644 --- a/replace.go +++ b/replace.go @@ -1,7 +1,9 @@ package managers import ( + "bytes" "context" + "encoding/json" "errors" "fmt" "net/url" @@ -183,9 +185,17 @@ func buildComposerReplaceInputs(pkg string, mode replaceMode, opts ReplaceOption } switch mode { case replaceModePath: - input.Args["payload"] = fmt.Sprintf(`{"type":"path","url":%q}`, opts.Path) + payload, err := composerRepositoryPayload("path", opts.Path) + if err != nil { + return nil, err + } + input.Args["payload"] = payload case replaceModeGit: - input.Args["payload"] = fmt.Sprintf(`{"type":"vcs","url":%q}`, opts.Git) + payload, err := composerRepositoryPayload("vcs", opts.Git) + if err != nil { + return nil, err + } + input.Args["payload"] = payload inputs := []replaceInput{{operation: opReplace, input: input}} if opts.Ref != "" { inputs = append(inputs, replaceInput{ @@ -205,6 +215,20 @@ func buildComposerReplaceInputs(pkg string, mode replaceMode, opts ReplaceOption return []replaceInput{{operation: opReplace, input: input}}, nil } +func composerRepositoryPayload(kind, url string) (string, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + err := enc.Encode(struct { + Type string `json:"type"` + URL string `json:"url"` + }{kind, url}) + if err != nil { + return "", err + } + return strings.TrimSuffix(buf.String(), "\n"), nil +} + func (m *GenericManager) replaceInManifest(file, pkg string, mode replaceMode, opts ReplaceOptions) (*Result, error) { start := time.Now() path := filepath.Join(m.dir, file) diff --git a/replace_manifest.go b/replace_manifest.go index 1656aeb..cc523d0 100644 --- a/replace_manifest.go +++ b/replace_manifest.go @@ -183,7 +183,7 @@ func editGemfileReplaceEntry(path, pkg string, mode replaceMode, opts ReplaceOpt if mode == replaceModeDrop { return fmt.Errorf("gem %q not found in Gemfile", pkg) } - lines = append(lines, fmt.Sprintf("gem %q%s\n", pkg, gemReplacementSuffix(mode, opts))) + lines = append(lines, fmt.Sprintf("gem %s%s\n", quoteRubyString(pkg), gemReplacementSuffix(mode, opts))) } return os.WriteFile(path, []byte(strings.Join(lines, "")), manifestPerm) @@ -208,26 +208,26 @@ func updateGemfileLine(line, pkg string, mode replaceMode, opts ReplaceOptions) switch mode { case replaceModePath: - args = append(args, fmt.Sprintf("path: %q", opts.Path)) + args = append(args, "path: "+quoteRubyString(opts.Path)) case replaceModeGit: - args = append(args, fmt.Sprintf("git: %q", opts.Git)) + args = append(args, "git: "+quoteRubyString(opts.Git)) if opts.Ref != "" { - args = append(args, fmt.Sprintf("branch: %q", opts.Ref)) + args = append(args, "branch: "+quoteRubyString(opts.Ref)) } case replaceModeDrop: } - return fmt.Sprintf("%sgem %q%s%s", indent, pkg, formatGemfileArgs(args, comment), newline), nil + return fmt.Sprintf("%sgem %s%s%s", indent, quoteRubyString(pkg), formatGemfileArgs(args, comment), newline), nil } func gemReplacementSuffix(mode replaceMode, opts ReplaceOptions) string { switch mode { case replaceModePath: - return fmt.Sprintf(", path: %q", opts.Path) + return ", path: " + quoteRubyString(opts.Path) case replaceModeGit: - s := fmt.Sprintf(", git: %q", opts.Git) + s := ", git: " + quoteRubyString(opts.Git) if opts.Ref != "" { - s += fmt.Sprintf(", branch: %q", opts.Ref) + s += ", branch: " + quoteRubyString(opts.Ref) } return s default: @@ -235,6 +235,28 @@ func gemReplacementSuffix(mode replaceMode, opts ReplaceOptions) string { } } +func quoteRubyString(s string) string { + var quoted strings.Builder + quoted.WriteByte('"') + for i := range len(s) { + c := s[i] + switch c { + case '\\', '"', '#': + quoted.WriteByte('\\') + quoted.WriteByte(c) + default: + if c < ' ' || c >= '\x7f' { + // Byte escapes preserve invalid UTF-8 and avoid Go-only Unicode escapes. + fmt.Fprintf("ed, `\x%02x`, c) + } else { + quoted.WriteByte(c) + } + } + } + quoted.WriteByte('"') + return quoted.String() +} + func parseGemfileArgs(tail string) ([]string, string) { argsText, comment := splitRubyLineComment(tail) parts := splitRubyArgs(argsText) diff --git a/replace_test.go b/replace_test.go index 516cfe7..b381572 100644 --- a/replace_test.go +++ b/replace_test.go @@ -2,6 +2,7 @@ package managers import ( "context" + "encoding/json" "errors" "os" "path/filepath" @@ -226,6 +227,34 @@ func TestReplaceComposerGitRefRunsRequire(t *testing.T) { } } +func TestReplaceComposerPayloadIsJSON(t *testing.T) { + values := []string{ + `../a"b\c`, + "../\x00\a\v\x1b\x7f", + "../é\U000e0001", + "https://example.com/pkg?a=1&b=<2>", + } + for _, value := range values { + for _, opts := range []ReplaceOptions{{Path: value}, {Git: value}} { + runner := NewMockRunner() + mgr := newReplaceTestManager(t, "composer", "/test/dependent", runner) + if _, err := mgr.Replace(context.Background(), "vendor/pkg", opts); err != nil { + t.Fatalf("Replace: %v", err) + } + payload := runner.Captured[0][3] + var got struct { + URL string `json:"url"` + } + if err := json.Unmarshal([]byte(payload), &got); err != nil { + t.Fatalf("payload %q is not JSON: %v", payload, err) + } + if got.URL != value { + t.Errorf("payload url = %q, want %q", got.URL, value) + } + } + } +} + func TestReplaceComposerDrop(t *testing.T) { runner := NewMockRunner() mgr := newReplaceTestManager(t, "composer", "/test/dependent", runner) @@ -393,6 +422,85 @@ func TestReplaceGemfileAppendsMissingGem(t *testing.T) { } } +func TestReplaceGemfileQuotesReplacementValues(t *testing.T) { + tests := []struct { + name string + value string + quoted string + }{ + {"expression", `../#{raise('interpolated')}`, `"../\#{raise('interpolated')}"`}, + {"variables", `../#@path/#@@path/#$path`, `"../\#@path/\#@@path/\#$path"`}, + {"quotes and backslashes", `../a\"b'c`, `"../a\\\"b'c"`}, + {"backslash before interpolation", `../\#{1+1}`, `"../\\\#{1+1}"`}, + {"controls", "../\x00\a\b\t\n\v\f\r\x1b\x7f", `"../\x00\x07\x08\x09\x0a\x0b\x0c\x0d\x1b\x7f"`}, + {"unicode", "../é\U000e0001", `"../\xc3\xa9\xf3\xa0\x80\x81"`}, + {"invalid UTF-8", "../\xff", `"../\xff"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, variant := range []struct { + name string + initial string + git bool + }{ + {"path/append", "", false}, + {"path/edit", "gem \"rake\"\n", false}, + {"git/append", "", true}, + {"git/edit", "gem \"rake\"\n", true}, + } { + t.Run(variant.name, func(t *testing.T) { + dir := t.TempDir() + gemfile := filepath.Join(dir, "Gemfile") + writeFile(t, gemfile, variant.initial) + mgr := newReplaceTestManager(t, "bundler", dir, NewMockRunner()) + opts := ReplaceOptions{Path: tt.value} + want := `gem "rake", path: ` + tt.quoted + "\n" + if variant.git { + opts = ReplaceOptions{Git: tt.value, Ref: tt.value} + want = `gem "rake", git: ` + tt.quoted + ", branch: " + tt.quoted + "\n" + } + _, err := mgr.Replace(context.Background(), "rake", opts) + if err != nil { + t.Fatalf("Replace: %v", err) + } + if got := readFile(t, gemfile); got != want { + t.Fatalf("Gemfile = %q, want %q", got, want) + } + }) + } + }) + } +} + +func TestReplaceGemfileQuotesGemName(t *testing.T) { + const pkg = `rake#{raise('interpolated')}` + tests := []struct { + name string + initial string + opts ReplaceOptions + want string + }{ + {"append", "", ReplaceOptions{Path: "../rake"}, `gem "rake\#{raise('interpolated')}", path: "../rake"` + "\n"}, + {"edit", "gem '" + pkg + "'\n", ReplaceOptions{Path: "../rake"}, `gem "rake\#{raise('interpolated')}", path: "../rake"` + "\n"}, + {"drop", "gem '" + pkg + "', path: '../rake'\n", ReplaceOptions{Drop: true}, `gem "rake\#{raise('interpolated')}"` + "\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + gemfile := filepath.Join(dir, "Gemfile") + writeFile(t, gemfile, tt.initial) + mgr := newReplaceTestManager(t, "bundler", dir, NewMockRunner()) + _, err := mgr.Replace(context.Background(), pkg, tt.opts) + if err != nil { + t.Fatalf("Replace: %v", err) + } + if got := readFile(t, gemfile); got != tt.want { + t.Fatalf("Gemfile = %q, want %q", got, tt.want) + } + }) + } +} + func TestUpdateTOMLSectionEntry(t *testing.T) { in := "[patch.crates-io] # local overrides\nfoo = { path = \"../foo\" }\n\n[other]\nx = 1\n"