1use serde::Serialize;
2
3use crate::command::{masks_contain_exact, redact_with_masks};
4use crate::receipt::{Check, Location};
5
6const STEP_SUMMARY_LIMIT_BYTES: usize = 1024 * 1024;
7
8#[derive(Clone, Debug, Serialize)]
9pub struct EnvFileAnalysis {
10 pub kind: EnvFileKind,
11 #[serde(skip_serializing_if = "Option::is_none")]
12 pub source: Option<String>,
13 pub bytes: usize,
14 pub records: Vec<EnvFileRecord>,
15 #[serde(skip)]
16 pub checks: Vec<Check>,
17}
18
19impl EnvFileAnalysis {
20 fn with_checks(mut self, checks: Vec<Check>) -> Self {
21 self.checks = checks;
22 self
23 }
24}
25
26#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, clap::ValueEnum)]
27#[serde(rename_all = "kebab-case")]
28pub enum EnvFileKind {
29 Env,
30 Output,
31 State,
32 Path,
33 StepSummary,
34}
35
36impl EnvFileKind {
37 pub fn context_name(self) -> &'static str {
38 match self {
39 Self::Env => "GITHUB_ENV",
40 Self::Output => "GITHUB_OUTPUT",
41 Self::State => "GITHUB_STATE",
42 Self::Path => "GITHUB_PATH",
43 Self::StepSummary => "GITHUB_STEP_SUMMARY",
44 }
45 }
46
47 fn check_prefix(self) -> &'static str {
48 match self {
49 Self::Env => "env_file.env",
50 Self::Output => "env_file.output",
51 Self::State => "env_file.state",
52 Self::Path => "env_file.path",
53 Self::StepSummary => "env_file.step_summary",
54 }
55 }
56}
57
58#[derive(Clone, Debug, Serialize)]
59pub struct EnvFileRecord {
60 pub line: usize,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub end_line: Option<usize>,
63 pub kind: EnvFileRecordKind,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub name: Option<String>,
66 #[serde(skip_serializing_if = "String::is_empty")]
67 pub value: String,
68 pub value_bytes: usize,
69}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
72#[serde(rename_all = "kebab-case")]
73pub enum EnvFileRecordKind {
74 Assignment,
75 Heredoc,
76 Path,
77 Summary,
78}
79
80pub fn analyze_env_file(
81 kind: EnvFileKind,
82 text: &str,
83 source: Option<String>,
84 masks: &[String],
85) -> EnvFileAnalysis {
86 analyze_env_file_with_checks(kind, text, source, masks)
87}
88
89pub(crate) fn analyze_env_file_with_checks(
90 kind: EnvFileKind,
91 text: &str,
92 source: Option<String>,
93 masks: &[String],
94) -> EnvFileAnalysis {
95 match kind {
96 EnvFileKind::Path => analyze_path_file(kind, text, source, masks),
97 EnvFileKind::StepSummary => analyze_step_summary(kind, text, source, masks),
98 EnvFileKind::Env | EnvFileKind::Output | EnvFileKind::State => {
99 analyze_key_value_file(kind, text, source, masks)
100 }
101 }
102}
103
104fn analyze_key_value_file(
105 kind: EnvFileKind,
106 text: &str,
107 source: Option<String>,
108 masks: &[String],
109) -> EnvFileAnalysis {
110 let mut checks = Vec::new();
111 let mut records = Vec::new();
112 let lines = logical_lines(text);
113 let mut index = 0;
114
115 while index < lines.len() {
116 let line_number = lines[index].number;
117 let line = lines[index].text.as_str();
118 index += 1;
119
120 if line.is_empty() {
121 continue;
122 }
123
124 let equals = line.find('=');
125 let heredoc = line.find("<<");
126 if let Some(equals) = equals
127 && heredoc.is_none_or(|heredoc| equals < heredoc)
128 {
129 let name = &line[..equals];
130 let value = &line[equals + 1..];
131 if name.is_empty() {
132 checks.push(Check::fail(
133 format!("{}.name", kind.check_prefix()),
134 "environment file assignment name must not be empty",
135 Some(Location::line(source.as_deref(), line_number)),
136 ));
137 continue;
138 }
139 validate_key_value(
140 kind,
141 name,
142 value,
143 line_number,
144 source.as_deref(),
145 masks,
146 &mut checks,
147 );
148 records.push(record(
149 line_number,
150 None,
151 EnvFileRecordKind::Assignment,
152 Some(name),
153 value,
154 masks,
155 ));
156 continue;
157 }
158
159 if let Some(heredoc) = heredoc
160 && equals.is_none_or(|equals| heredoc < equals)
161 {
162 let name = &line[..heredoc];
163 let delimiter = &line[heredoc + 2..];
164 if name.is_empty() || delimiter.is_empty() {
165 checks.push(Check::fail(
166 format!("{}.heredoc.header", kind.check_prefix()),
167 "heredoc syntax requires non-empty name and delimiter",
168 Some(Location::line(source.as_deref(), line_number)),
169 ));
170 continue;
171 }
172
173 let value_start = index;
174 let mut value_end = None;
175 while index < lines.len() {
176 if lines[index].text == delimiter {
177 value_end = Some(index);
178 break;
179 }
180 index += 1;
181 }
182
183 let Some(end_index) = value_end else {
184 checks.push(Check::fail(
185 format!("{}.heredoc.delimiter", kind.check_prefix()),
186 "matching heredoc delimiter was not found",
187 Some(Location::line(source.as_deref(), line_number)),
188 ));
189 break;
190 };
191
192 let value = lines[value_start..end_index]
193 .iter()
194 .map(|line| line.text.as_str())
195 .collect::<Vec<_>>()
196 .join("\n");
197 validate_key_value(
198 kind,
199 name,
200 &value,
201 line_number,
202 source.as_deref(),
203 masks,
204 &mut checks,
205 );
206 records.push(record(
207 line_number,
208 Some(lines[end_index].number),
209 EnvFileRecordKind::Heredoc,
210 Some(name),
211 &value,
212 masks,
213 ));
214 index = end_index + 1;
215 continue;
216 }
217
218 checks.push(Check::fail(
219 format!("{}.format", kind.check_prefix()),
220 "environment file line must use `NAME=VALUE` or `NAME<<DELIMITER` syntax",
221 Some(Location::line(source.as_deref(), line_number)),
222 ));
223 }
224
225 if records.is_empty() {
226 checks.push(Check::skip(
227 format!("{}.records", kind.check_prefix()),
228 format!("{} contains no records", kind.context_name()),
229 source
230 .as_deref()
231 .map(|source| Location::new(Some(source.to_string()), None)),
232 ));
233 } else {
234 checks.push(Check::pass(
235 format!("{}.records", kind.check_prefix()),
236 format!("parsed {} {} records", records.len(), kind.context_name()),
237 source
238 .as_deref()
239 .map(|source| Location::new(Some(source.to_string()), None)),
240 ));
241 }
242
243 EnvFileAnalysis {
244 kind,
245 source,
246 bytes: text.len(),
247 records,
248 checks: Vec::new(),
249 }
250 .with_checks(checks)
251}
252
253fn analyze_path_file(
254 kind: EnvFileKind,
255 text: &str,
256 source: Option<String>,
257 masks: &[String],
258) -> EnvFileAnalysis {
259 let mut records = Vec::new();
260 for line in logical_lines(text) {
261 if line.text.is_empty() {
262 continue;
263 }
264 records.push(record(
265 line.number,
266 None,
267 EnvFileRecordKind::Path,
268 None,
269 &line.text,
270 masks,
271 ));
272 }
273
274 let location = source
275 .as_deref()
276 .map(|source| Location::new(Some(source.to_string()), None));
277 let checks = if records.is_empty() {
278 vec![Check::skip(
279 "env_file.path.records",
280 "GITHUB_PATH contains no path records",
281 location,
282 )]
283 } else {
284 vec![Check::pass(
285 "env_file.path.records",
286 format!("parsed {} GITHUB_PATH records", records.len()),
287 location,
288 )]
289 };
290
291 EnvFileAnalysis {
292 kind,
293 source,
294 bytes: text.len(),
295 records,
296 checks: Vec::new(),
297 }
298 .with_checks(checks)
299}
300
301fn analyze_step_summary(
302 kind: EnvFileKind,
303 text: &str,
304 source: Option<String>,
305 masks: &[String],
306) -> EnvFileAnalysis {
307 let mut checks = Vec::new();
308 let location = source
309 .as_deref()
310 .map(|source| Location::new(Some(source.to_string()), None));
311 if text.is_empty() {
312 checks.push(Check::skip(
313 "env_file.step_summary.content",
314 "GITHUB_STEP_SUMMARY is empty",
315 location.clone(),
316 ));
317 } else {
318 checks.push(Check::pass(
319 "env_file.step_summary.content",
320 "GITHUB_STEP_SUMMARY contains Markdown content",
321 location.clone(),
322 ));
323 }
324
325 if text.len() > STEP_SUMMARY_LIMIT_BYTES {
326 checks.push(Check::fail(
327 "env_file.step_summary.size",
328 format!(
329 "GITHUB_STEP_SUMMARY is {} bytes, above the 1 MiB runner attachment limit",
330 text.len()
331 ),
332 location,
333 ));
334 } else {
335 checks.push(Check::pass(
336 "env_file.step_summary.size",
337 "GITHUB_STEP_SUMMARY is within the 1 MiB runner attachment limit",
338 location,
339 ));
340 }
341
342 let records = if text.is_empty() {
343 Vec::new()
344 } else {
345 vec![record(
346 1,
347 Some(logical_lines(text).last().map_or(1, |line| line.number)),
348 EnvFileRecordKind::Summary,
349 None,
350 text,
351 masks,
352 )]
353 };
354
355 EnvFileAnalysis {
356 kind,
357 source,
358 bytes: text.len(),
359 records,
360 checks: Vec::new(),
361 }
362 .with_checks(checks)
363}
364
365fn validate_key_value(
366 kind: EnvFileKind,
367 name: &str,
368 value: &str,
369 line: usize,
370 source: Option<&str>,
371 masks: &[String],
372 checks: &mut Vec<Check>,
373) {
374 let location = Some(Location::line(source, line));
375 match kind {
376 EnvFileKind::Env => {
377 if name.eq_ignore_ascii_case("NODE_OPTIONS") {
378 checks.push(Check::fail(
379 "env_file.env.node_options",
380 "GITHUB_ENV cannot set NODE_OPTIONS on GitHub runners",
381 location.clone(),
382 ));
383 }
384 if is_default_runner_variable(name) {
385 checks.push(Check::warn(
386 "env_file.env.default_variable",
387 format!(
388 "`{name}` is a default runner variable and cannot be reliably overwritten"
389 ),
390 location,
391 ));
392 }
393 }
394 EnvFileKind::Output => {
395 if masks_contain_exact(masks, value) {
396 checks.push(Check::fail(
397 "env_file.output.masked_value",
398 "GITHUB_OUTPUT attempts to set a value previously registered with add-mask",
399 location,
400 ));
401 }
402 }
403 EnvFileKind::State | EnvFileKind::Path | EnvFileKind::StepSummary => {}
404 }
405}
406
407fn record(
408 line: usize,
409 end_line: Option<usize>,
410 kind: EnvFileRecordKind,
411 name: Option<&str>,
412 value: &str,
413 masks: &[String],
414) -> EnvFileRecord {
415 EnvFileRecord {
416 line,
417 end_line,
418 kind,
419 name: name.map(ToString::to_string),
420 value: redact_with_masks(value, masks),
421 value_bytes: value.len(),
422 }
423}
424
425fn is_default_runner_variable(name: &str) -> bool {
426 if name.eq_ignore_ascii_case("CI") {
427 return false;
428 }
429 let upper = name.to_ascii_uppercase();
430 upper.starts_with("GITHUB_") || upper.starts_with("RUNNER_")
431}
432
433#[derive(Clone, Debug)]
434struct LogicalLine {
435 number: usize,
436 text: String,
437}
438
439fn logical_lines(text: &str) -> Vec<LogicalLine> {
440 let normalized = text.replace("\r\n", "\n");
441 normalized
442 .split('\n')
443 .enumerate()
444 .filter_map(|(index, line)| {
445 if index == normalized.matches('\n').count() && line.is_empty() {
446 None
447 } else {
448 Some(LogicalLine {
449 number: index + 1,
450 text: line.trim_end_matches('\r').to_string(),
451 })
452 }
453 })
454 .collect()
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 #[test]
462 fn parses_assignments_and_heredocs() {
463 let analysis = analyze_env_file_with_checks(
464 EnvFileKind::Output,
465 "ONE=two\nJSON<<EOF\n{\"ok\":true}\nEOF\n",
466 Some("GITHUB_OUTPUT".to_string()),
467 &[],
468 );
469 assert_eq!(analysis.records.len(), 2);
470 assert_eq!(analysis.records[0].name.as_deref(), Some("ONE"));
471 assert_eq!(analysis.records[1].value, "{\"ok\":true}");
472 assert!(
473 analysis
474 .checks
475 .iter()
476 .any(|check| check.id == "env_file.output.records")
477 );
478 }
479
480 #[test]
481 fn reports_missing_heredoc_delimiter() {
482 let analysis =
483 analyze_env_file_with_checks(EnvFileKind::Env, "NAME<<EOF\nvalue\n", None, &[]);
484 assert!(
485 analysis
486 .checks
487 .iter()
488 .any(|check| check.id == "env_file.env.heredoc.delimiter")
489 );
490 }
491
492 #[test]
493 fn blocks_node_options() {
494 let analysis =
495 analyze_env_file_with_checks(EnvFileKind::Env, "NODE_OPTIONS=--inspect\n", None, &[]);
496 assert!(
497 analysis
498 .checks
499 .iter()
500 .any(|check| check.id == "env_file.env.node_options")
501 );
502 }
503
504 #[test]
505 fn flags_masked_output_values() {
506 let analysis = analyze_env_file_with_checks(
507 EnvFileKind::Output,
508 "token=s3cr3t\n",
509 None,
510 &["s3cr3t".to_string()],
511 );
512 assert_eq!(analysis.records[0].value, "***");
513 assert!(
514 analysis
515 .checks
516 .iter()
517 .any(|check| check.id == "env_file.output.masked_value")
518 );
519 }
520
521 #[test]
522 fn rejects_large_step_summary() {
523 let huge = "x".repeat(STEP_SUMMARY_LIMIT_BYTES + 1);
524 let analysis = analyze_env_file_with_checks(EnvFileKind::StepSummary, &huge, None, &[]);
525 assert!(
526 analysis
527 .checks
528 .iter()
529 .any(|check| check.id == "env_file.step_summary.size")
530 );
531 }
532}