1// SPDX-License-Identifier: MIT
2
3package host
4
5import (
6 "bytes"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "strings"
12
13 "bnema.dev/ermite-runner/internal/urlorigin"
14)
15
16// serverURLKeys are the keys of the github context that actions use to reach
17// the forge: the server's web URL, its REST API and its GraphQL API. The
18// environment variables GITHUB_SERVER_URL, GITHUB_API_URL and GITHUB_GRAPHQL_URL
19// are derived from them by the executor (State.RuntimeEnv), so rewriting the
20// context rewrites the environment too. Everything else that carries the
21// origin (the URLs inside github.event, recorded as the server built them;
22// repositoryUrl, which is a git:// URL) is left alone.
23var serverURLKeys = []string{"server_url", "api_url", "graphql_url"}
24
25type jsonMember struct {
26 key string
27 val json.RawMessage
28}
29
30// RewriteServerURLs returns the contexts JSON of a job with the origin of
31// github.server_url, github.api_url and github.graphql_url replaced by target
32// (scheme://host[:port], the ERMITE_RUNNER_URL origin) when, and only when, the
33// value has the same origin as github.server_url, the server's public origin.
34// Paths, queries and fragments are kept ("/-/github/api"); values with another
35// origin, with credentials, or that are not strings are untouched, as is the
36// rest of the document (order of keys included). The input is returned as it
37// is when nothing changes.
38func RewriteServerURLs(contexts, target string) (string, error) {
39 if target == "" {
40 return contexts, nil
41 }
42 top, err := parseObject([]byte(contexts))
43 if err != nil {
44 return "", fmt.Errorf("job contexts: %w", err)
45 }
46 gi := -1
47 for i, m := range top {
48 if m.key == "github" {
49 gi = i
50 }
51 }
52 if gi < 0 {
53 return contexts, nil
54 }
55 gh, err := parseObject(top[gi].val)
56 if err != nil {
57 return "", fmt.Errorf("job contexts: github: %w", err)
58 }
59 str := func(raw json.RawMessage) (string, bool) {
60 var s string
61 return s, json.Unmarshal(raw, &s) == nil
62 }
63 var public string
64 for _, m := range gh {
65 if m.key == "server_url" {
66 if s, ok := str(m.val); ok {
67 public = originOf(s)
68 }
69 }
70 }
71 if public == "" || public == originOf(target) {
72 return contexts, nil
73 }
74 changed := false
75 for i, m := range gh {
76 isURL := false
77 for _, k := range serverURLKeys {
78 isURL = isURL || m.key == k
79 }
80 if !isURL {
81 continue
82 }
83 s, ok := str(m.val)
84 if !ok {
85 continue
86 }
87 u, ok := rewriteOrigin(s, public, target)
88 if !ok {
89 continue
90 }
91 nv, err := marshalNoEscape(u)
92 if err != nil {
93 return "", err
94 }
95 gh[i].val = nv
96 changed = true
97 }
98 if !changed {
99 return contexts, nil
100 }
101 if top[gi].val, err = encodeObject(gh); err != nil {
102 return "", err
103 }
104 out, err := encodeObject(top)
105 if err != nil {
106 return "", err
107 }
108 return string(out), nil
109}
110
111// rewriteOrigin returns raw with its origin replaced by target and true when
112// the origin of raw is public (as originOf spells it); the path, the query and
113// the fragment are kept. Anything else is returned as it is, with false.
114func rewriteOrigin(raw, public, target string) (string, bool) {
115 if public == "" || target == "" || originOf(raw) != public {
116 return raw, false
117 }
118 rest := raw[strings.Index(raw, "://")+3:]
119 if k := strings.IndexAny(rest, "/?#"); k >= 0 {
120 rest = rest[k:]
121 } else {
122 rest = ""
123 }
124 return target + rest, true
125}
126
127// originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and
128// without the default port, an IPv6 host in brackets, or "" for anything else
129// (including URLs with credentials, which are never rewritten). It is
130// urlorigin.Prefix, the form the executor compares by urlorigin.Canonical.
131func originOf(raw string) string { return urlorigin.Prefix(raw) }
132
133func marshalNoEscape(v any) (json.RawMessage, error) {
134 var b bytes.Buffer
135 enc := json.NewEncoder(&b)
136 enc.SetEscapeHTML(false)
137 if err := enc.Encode(v); err != nil {
138 return nil, err
139 }
140 return bytes.TrimRight(b.Bytes(), "\n"), nil
141}
142
143// parseObject reads a JSON object into its members, in order.
144func parseObject(b []byte) ([]jsonMember, error) {
145 dec := json.NewDecoder(bytes.NewReader(b))
146 if t, err := dec.Token(); err != nil || t != json.Delim('{') {
147 return nil, errors.New("not a JSON object")
148 }
149 var out []jsonMember
150 for dec.More() {
151 kt, err := dec.Token()
152 if err != nil {
153 return nil, err
154 }
155 k, ok := kt.(string)
156 if !ok {
157 return nil, errors.New("not a JSON object")
158 }
159 var v json.RawMessage
160 if err := dec.Decode(&v); err != nil {
161 return nil, err
162 }
163 out = append(out, jsonMember{k, v})
164 }
165 if _, err := dec.Token(); err != nil {
166 return nil, err
167 }
168 if _, err := dec.Token(); err != io.EOF {
169 return nil, errors.New("data after the JSON object")
170 }
171 return out, nil
172}
173
174func encodeObject(ms []jsonMember) (json.RawMessage, error) {
175 var b bytes.Buffer
176 b.WriteByte('{')
177 for i, m := range ms {
178 if i > 0 {
179 b.WriteByte(',')
180 }
181 k, err := marshalNoEscape(m.key)
182 if err != nil {
183 return nil, err
184 }
185 b.Write(k)
186 b.WriteByte(':')
187 b.Write(m.val)
188 }
189 b.WriteByte('}')
190 return b.Bytes(), nil
191}