v0.6.0 aba298d

guestforge.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close