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 || originOf(s) != public {
85 continue
86 }
87 rest := s[strings.Index(s, "://")+3:]
88 if k := strings.IndexAny(rest, "/?#"); k >= 0 {
89 rest = rest[k:]
90 } else {
91 rest = ""
92 }
93 nv, err := marshalNoEscape(target + rest)
94 if err != nil {
95 return "", err
96 }
97 gh[i].val = nv
98 changed = true
99 }
100 if !changed {
101 return contexts, nil
102 }
103 if top[gi].val, err = encodeObject(gh); err != nil {
104 return "", err
105 }
106 out, err := encodeObject(top)
107 if err != nil {
108 return "", err
109 }
110 return string(out), nil
111}
112
113// originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and
114// without the default port, an IPv6 host in brackets, or "" for anything else
115// (including URLs with credentials, which are never rewritten). It is
116// urlorigin.Prefix, the form the executor compares by urlorigin.Canonical.
117func originOf(raw string) string { return urlorigin.Prefix(raw) }
118
119func marshalNoEscape(v any) (json.RawMessage, error) {
120 var b bytes.Buffer
121 enc := json.NewEncoder(&b)
122 enc.SetEscapeHTML(false)
123 if err := enc.Encode(v); err != nil {
124 return nil, err
125 }
126 return bytes.TrimRight(b.Bytes(), "\n"), nil
127}
128
129// parseObject reads a JSON object into its members, in order.
130func parseObject(b []byte) ([]jsonMember, error) {
131 dec := json.NewDecoder(bytes.NewReader(b))
132 if t, err := dec.Token(); err != nil || t != json.Delim('{') {
133 return nil, errors.New("not a JSON object")
134 }
135 var out []jsonMember
136 for dec.More() {
137 kt, err := dec.Token()
138 if err != nil {
139 return nil, err
140 }
141 k, ok := kt.(string)
142 if !ok {
143 return nil, errors.New("not a JSON object")
144 }
145 var v json.RawMessage
146 if err := dec.Decode(&v); err != nil {
147 return nil, err
148 }
149 out = append(out, jsonMember{k, v})
150 }
151 if _, err := dec.Token(); err != nil {
152 return nil, err
153 }
154 if _, err := dec.Token(); err != io.EOF {
155 return nil, errors.New("data after the JSON object")
156 }
157 return out, nil
158}
159
160func encodeObject(ms []jsonMember) (json.RawMessage, error) {
161 var b bytes.Buffer
162 b.WriteByte('{')
163 for i, m := range ms {
164 if i > 0 {
165 b.WriteByte(',')
166 }
167 k, err := marshalNoEscape(m.key)
168 if err != nil {
169 return nil, err
170 }
171 b.Write(k)
172 b.WriteByte(':')
173 b.Write(m.val)
174 }
175 b.WriteByte('}')
176 return b.Bytes(), nil
177}