use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct DevEnvironment {
pub run: String,
#[serde(skip_serializing_if = "GithubSetup::is_empty")]
pub github: GithubSetup,
#[serde(skip_serializing_if = "GitlabSetup::is_empty")]
pub gitlab: GitlabSetup,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct GithubSetup {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub setup: Vec<ActionStep>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub cache: Vec<ActionStep>,
}
impl GithubSetup {
const fn is_empty(&self) -> bool {
self.setup.is_empty() && self.cache.is_empty()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct GitlabSetup {
#[serde(skip_serializing_if = "String::is_empty")]
pub image: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub before_script: Vec<String>,
}
impl GitlabSetup {
const fn is_empty(&self) -> bool {
self.image.is_empty() && self.before_script.is_empty()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct ActionStep {
pub uses: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub tag: String,
#[serde(rename = "with", skip_serializing_if = "BTreeMap::is_empty")]
pub inputs: BTreeMap<String, Input>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum Input {
Bool(bool),
Integer(i64),
Text(String),
}
impl Input {
#[must_use]
pub fn yaml(&self) -> String {
match self {
Self::Bool(value) => value.to_string(),
Self::Integer(value) => value.to_string(),
Self::Text(value) => serde_json::Value::String(value.clone()).to_string(),
}
}
}
impl DevEnvironment {
#[must_use]
pub fn answered(self) -> Option<Self> {
(!self.run.is_empty()).then_some(self)
}
pub fn check(&self) -> Result<(), String> {
if self.run.is_empty() {
if self.github.is_empty() && self.gitlab.is_empty() {
return Ok(());
}
return Err("dev_environment.run is empty while the table names a setup; an empty run states that the project gives no answer".to_owned());
}
check_run(&self.run)?;
for (list, steps) in [("setup", &self.github.setup), ("cache", &self.github.cache)] {
for (at, step) in steps.iter().enumerate() {
check_step(&format!("dev_environment.github.{list}[{at}]"), step)?;
}
}
if !self.gitlab.image.is_empty() && !is_image(&self.gitlab.image) {
return Err(format!(
"dev_environment.gitlab.image is {:?}, which is no image reference; write one such as nixos/nix:2.35.2",
self.gitlab.image
));
}
for (at, line) in self.gitlab.before_script.iter().enumerate() {
one_line(&format!("dev_environment.gitlab.before_script[{at}]"), line)?;
}
Ok(())
}
pub fn check_forge(&self, forge: Option<&str>) -> Result<(), String> {
match forge {
Some("github") if self.github.setup.is_empty() => Err(
"dev_environment.run is set and dev_environment.github.setup names no step; a GitHub runner needs the steps that put the environment on it".to_owned(),
),
Some("gitlab") if self.gitlab.image.is_empty() => Err(
"dev_environment.run is set and dev_environment.gitlab.image is empty; a GitLab job needs the image that carries the environment".to_owned(),
),
_ => Ok(()),
}
}
}
fn check_run(run: &str) -> Result<(), String> {
one_line("dev_environment.run", run)?;
let starts = run
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '_'));
if !starts || run.trim() != run || run.contains(" #") || run.contains(": ") {
return Err(format!(
"dev_environment.run is {run:?}; a prefix starts with a letter, a digit, '.', '/', or '_', has no surrounding space, and carries no ' #' and no ': '"
));
}
Ok(())
}
fn check_step(key: &str, step: &ActionStep) -> Result<(), String> {
if !is_pinned(&step.uses) {
return Err(format!(
"{key}.uses is {:?}, which is not pinned to a full commit SHA; write owner/repo@<40 hex digits>",
step.uses
));
}
if !step.tag.is_empty()
&& !step
.tag
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/'))
{
return Err(format!(
"{key}.tag is {:?}; a tag is letters, digits, '.', '_', '-', and '/'",
step.tag
));
}
for (name, value) in &step.inputs {
let named = !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'));
if !named {
return Err(format!(
"{key}.with names {name:?}; an input name is letters, digits, '_', and '-'"
));
}
if let Input::Text(text) = value {
one_line(&format!("{key}.with.{name}"), text)?;
}
}
Ok(())
}
fn is_pinned(uses: &str) -> bool {
let Some((action, commit)) = uses.rsplit_once('@') else {
return false;
};
let mut parts = action.split('/');
let owner = parts.next().unwrap_or_default();
let repo = parts.next().unwrap_or_default();
let word = |part: &str| {
!part.is_empty()
&& part
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
};
word(owner)
&& word(repo)
&& parts.all(word)
&& commit.len() == 40
&& commit
.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
}
fn is_image(image: &str) -> bool {
image
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric())
&& image
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | ':' | '@'))
}
fn one_line(key: &str, value: &str) -> Result<(), String> {
if value.trim().is_empty() || value.chars().any(char::is_control) {
return Err(format!(
"{key} is {value:?}; it is one non-empty line with no control character"
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{ActionStep, DevEnvironment, GithubSetup, GitlabSetup, Input};
const SHA: &str = "90bb610b90bf290cad97484ba341453bd1cbefea";
fn answer(uses: &str) -> DevEnvironment {
DevEnvironment {
run: "nix develop --command".to_owned(),
github: GithubSetup {
setup: vec![ActionStep {
uses: uses.to_owned(),
tag: "v19".to_owned(),
inputs: [("use-flakehub".to_owned(), Input::Bool(false))].into(),
}],
cache: Vec::new(),
},
gitlab: GitlabSetup::default(),
}
}
#[test]
fn a_pinned_answer_is_accepted() {
let uses = format!("DeterminateSystems/nix-installer-action@{SHA}");
assert_eq!(answer(&uses).check(), Ok(()));
assert_eq!(answer(&uses).check_forge(Some("github")), Ok(()));
}
#[test]
fn an_unpinned_setup_reference_refuses_by_name() {
for uses in [
"DeterminateSystems/nix-installer-action@v19",
"DeterminateSystems/nix-installer-action",
"nix-installer-action@90bb610b90bf290cad97484ba341453bd1cbefea",
"DeterminateSystems/nix-installer-action@90BB610B90BF290CAD97484BA341453BD1CBEFEA",
] {
let refusal = answer(uses).check().expect_err(uses);
assert!(
refusal.contains("dev_environment.github.setup[0].uses")
&& refusal.contains("full commit SHA"),
"{refusal}"
);
}
}
#[test]
fn a_run_that_breaks_its_line_refuses() {
for run in [
"",
" nix develop",
"nix develop # here",
"a: b",
"nix\ndevelop",
"-x",
] {
let mut held = answer(&format!("o/r@{SHA}"));
held.run = run.to_owned();
assert!(held.check().is_err(), "{run:?}");
}
}
#[test]
fn a_run_with_no_forge_setup_refuses_on_that_forge() {
let mut held = answer(&format!("o/r@{SHA}"));
held.github.setup.clear();
assert!(
held.check_forge(Some("github"))
.is_err_and(|refusal| refusal.contains("dev_environment.github.setup"))
);
assert!(
held.check_forge(Some("gitlab"))
.is_err_and(|refusal| refusal.contains("dev_environment.gitlab.image"))
);
assert_eq!(held.check_forge(None), Ok(()));
}
#[test]
fn an_empty_run_states_no_answer() {
assert_eq!(DevEnvironment::default().check(), Ok(()));
assert_eq!(DevEnvironment::default().answered(), None);
let mut held = answer(&format!("o/r@{SHA}"));
held.run.clear();
assert!(held.check().is_err());
}
}