-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathresolve-payload-fields.js
More file actions
202 lines (187 loc) · 6.31 KB
/
Copy pathresolve-payload-fields.js
File metadata and controls
202 lines (187 loc) · 6.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
/**
* Resolves the `client_payload` input of action.yml into the individual fields
* that later steps consume.
*
* The payload reaches the action in one of three shapes:
* - plain JSON (possibly double-encoded as a JSON string)
* - compressed base64(gzip(JSON))
* - reference small JSON pointing at a payload stashed on the resolver,
* used when the payload is too large to pass through GitHub
*
* Run from the `Resolve payload fields` step via actions/github-script.
*/
const { gunzipSync } = require('zlib')
const OVERSIZED_PAYLOAD_REFERENCE = 'oversized-payload-reference'
const COMPRESSED_PAYLOAD = 'compressed-payload'
const PAYLOAD_FETCH_TIMEOUT_MS = 10000
// 32MB
const MAX_INFLATED_PAYLOAD_BYTES = 32 * 1024 * 1024
/**
* @param {string} value
* @returns {string | null} the inflated text, or null if `value` is not gzip
*/
function inflateIfGzipped(value) {
const buffer = Buffer.from(value, 'base64')
const isGzip = buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b
if (!isGzip) {
return null
}
try {
return gunzipSync(buffer, {
maxOutputLength: MAX_INFLATED_PAYLOAD_BYTES
}).toString('utf8')
} catch (err) {
if (err.code === 'ERR_BUFFER_TOO_LARGE') {
throw new Error(
`payload inflates beyond ${MAX_INFLATED_PAYLOAD_BYTES} bytes; refusing to expand it`,
{ cause: err }
)
}
throw new Error(`gzip decompression failed: ${err.message}`, { cause: err })
}
}
/** Parses JSON that may have been encoded twice. */
function parsePayload(value) {
const parsed = JSON.parse(value)
return typeof parsed === 'string' ? JSON.parse(parsed) : parsed
}
/**
* @returns {object | null} the parsed value, or null when `raw` is not JSON at
* all - the bare base64(gzip) form, which has no envelope around it
*/
function tryParsePayload(raw) {
try {
const parsed = parsePayload(raw)
return parsed && typeof parsed === 'object' ? parsed : null
} catch {
return null
}
}
function stashUrl(payloadUrl, resolverUrl) {
if (!resolverUrl) {
throw new Error(
'resolver_url is not set; cannot validate the stashed payload origin'
)
}
const resolverOrigin = new URL(resolverUrl).origin
let requested
try {
// The trigger always sends an absolute URL; both it and resolver_url are
// built from the same base, so a relative one means that base was empty.
requested = new URL(payloadUrl)
} catch {
throw new Error(
`stashed payload URL is not absolute: ${payloadUrl} - the resolver's public API base is probably unset`
)
}
if (requested.origin !== resolverOrigin) {
throw new Error(
`refusing to fetch stashed payload from ${requested.origin}; expected ${resolverOrigin}`
)
}
const url = new URL(resolverOrigin)
url.pathname = requested.pathname
url.search = requested.search
return url
}
async function fetchStashedPayload(reference, resolverUrl, core) {
const url = stashUrl(reference.payloadUrl, resolverUrl)
core.setSecret(reference.resolverToken)
const response = await fetch(url, {
headers: { Authorization: `Bearer ${reference.resolverToken}` },
signal: AbortSignal.timeout(PAYLOAD_FETCH_TIMEOUT_MS)
})
if (!response.ok) {
throw new Error(`stashed payload fetch returned ${response.status}`)
}
const body = await response.text()
return parsePayload(inflateIfGzipped(body) ?? body)
}
async function resolvePayload(raw, resolverUrl, core) {
const parsed = tryParsePayload(raw)
if (parsed) {
if (parsed.type === OVERSIZED_PAYLOAD_REFERENCE) {
const payload = await fetchStashedPayload(parsed, resolverUrl, core)
return { mode: 'reference', payload }
}
if (parsed.type === COMPRESSED_PAYLOAD) {
const inflated = inflateIfGzipped(parsed.data || '')
if (inflated === null) {
throw new Error(`${COMPRESSED_PAYLOAD} envelope carries no gzip data`)
}
return { mode: 'compressed-envelope', payload: parsePayload(inflated) }
}
return { mode: 'plain', payload: parsed }
}
const inflated = inflateIfGzipped(raw)
if (inflated !== null) {
return { mode: 'compressed', payload: parsePayload(inflated) }
}
// Not JSON and not gzip - let the JSON error describe what arrived.
return { mode: 'plain', payload: parsePayload(raw) }
}
/**
* @param {string} raw the `client_payload` input, verbatim
* @returns {string} the form gitstream-core already understands
*/
function normalizeForEngine(raw) {
const envelope = tryParsePayload(raw)
if (!envelope) {
// Bare base64(gzip), which core inflates on its own. Bitbucket sends this shape today.
return raw
}
if (envelope.type === COMPRESSED_PAYLOAD && envelope.data) {
return envelope.data
}
if (envelope.type === OVERSIZED_PAYLOAD_REFERENCE) {
return JSON.stringify(envelope)
}
return raw
}
/**
* Maps a resolved payload to the step outputs. Output values are strings, so
* booleans are stringified to be compared as `== 'true'` in step conditions.
*/
function toStepOutputs(payload) {
const hasCmRepo = payload.hasCmRepo === true
const prNumber = Number(payload.pullRequestNumber)
// A positive integer or '': spliced into the fetch refspec without the safe-strings escaping step.
const isPrNumber = Number.isInteger(prNumber) && prNumber > 0
return {
github_token: payload.githubToken || '',
url: payload.headHttpUrl || payload.repoUrl || '',
pull_request_number: isPrNumber ? String(prNumber) : '',
has_cm_repo: String(hasCmRepo),
cm_repository: hasCmRepo ? `${payload.owner}/${payload.cmRepo}` : '',
cm_repo_ref: payload.cmRepoRef || '',
has_cm_org: String(payload.hasCmOrg === true),
cm_org_ref: payload.cmOrgRef || ''
}
}
async function run({ core, clientPayload, resolverUrl }) {
try {
const { mode, payload } = await resolvePayload(
clientPayload || '',
resolverUrl,
core
)
core.info(`client_payload mode=${mode}`)
const outputs = {
...toStepOutputs(payload),
client_payload: normalizeForEngine(clientPayload || '')
}
if (outputs.github_token) {
core.setSecret(outputs.github_token)
}
for (const [name, value] of Object.entries(outputs)) {
core.setOutput(name, value)
}
} catch (err) {
core.setFailed(`Failed resolving client payload: ${err}`)
}
}
module.exports = {
run,
toStepOutputs,
normalizeForEngine
}