Skip to content
Merged
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
28 changes: 26 additions & 2 deletions replace.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package managers

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
Expand Down Expand Up @@ -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{
Expand All @@ -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)
Expand Down
38 changes: 30 additions & 8 deletions replace_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -208,33 +208,55 @@ 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:
return ""
}
}

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(&quoted, `\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)
Expand Down
108 changes: 108 additions & 0 deletions replace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package managers

import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"

Expand Down
Loading