use std::{
fmt::{self, Display},
str::FromStr,
};
use indexmap::IndexMap;
use serde::{Deserialize, Deserializer, Serialize, de};
pub mod expr;
#[derive(Deserialize, Debug, PartialEq)]
#[serde(rename_all = "kebab-case", untagged)]
pub enum Permissions {
Base(BasePermission),
Explicit(IndexMap<String, Permission>),
}
impl Default for Permissions {
fn default() -> Self {
Self::Base(BasePermission::Default)
}
}
#[derive(Deserialize, Debug, Default, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum BasePermission {
#[default]
Default,
ReadAll,
WriteAll,
}
#[derive(Deserialize, Debug, Default, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum Permission {
Read,
Write,
#[default]
None,
}
pub type Env = IndexMap<String, EnvValue>;
#[derive(Deserialize, Serialize, Debug, PartialEq)]
#[serde(untagged)]
pub enum EnvValue {
#[serde(deserialize_with = "null_to_default")]
String(String),
Number(f64),
Boolean(bool),
}
impl Display for EnvValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::String(s) => write!(f, "{s}"),
Self::Number(n) => write!(f, "{n}"),
Self::Boolean(b) => write!(f, "{b}"),
}
}
}
impl EnvValue {
pub fn csharp_trueish(&self) -> bool {
match self {
EnvValue::Boolean(true) => true,
EnvValue::String(maybe) => maybe.trim().eq_ignore_ascii_case("true"),
_ => false,
}
}
}
#[derive(Deserialize, Debug, PartialEq)]
#[serde(untagged)]
enum SoV<T> {
One(T),
Many(Vec<T>),
}
impl<T> From<SoV<T>> for Vec<T> {
fn from(val: SoV<T>) -> Vec<T> {
match val {
SoV::One(v) => vec![v],
SoV::Many(vs) => vs,
}
}
}
pub(crate) fn scalar_or_vector<'de, D, T>(de: D) -> Result<Vec<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
SoV::deserialize(de).map(Into::into)
}
#[derive(Deserialize, Debug, PartialEq)]
#[serde(untagged)]
enum BoS {
Bool(bool),
String(String),
}
impl From<BoS> for String {
fn from(value: BoS) -> Self {
match value {
BoS::Bool(b) => b.to_string(),
BoS::String(s) => s,
}
}
}
#[derive(Deserialize, Serialize, Debug, PartialEq)]
#[serde(untagged)]
pub enum If {
Bool(bool),
Expr(String),
}
pub(crate) fn bool_is_string<'de, D>(de: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
BoS::deserialize(de).map(Into::into)
}
fn null_to_default<'de, D, T>(de: D) -> Result<T, D::Error>
where
D: Deserializer<'de>,
T: Default + Deserialize<'de>,
{
let key = Option::<T>::deserialize(de)?;
Ok(key.unwrap_or_default())
}
#[derive(Debug, PartialEq)]
pub struct UsesError(String);
impl fmt::Display for UsesError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "malformed `uses` ref: {}", self.0)
}
}
#[derive(Debug, PartialEq)]
pub enum Uses {
Local(LocalUses),
Repository(RepositoryUses),
Docker(DockerUses),
}
impl FromStr for Uses {
type Err = UsesError;
fn from_str(uses: &str) -> Result<Self, Self::Err> {
if uses.starts_with("./") {
LocalUses::from_str(uses).map(Self::Local)
} else if let Some(image) = uses.strip_prefix("docker://") {
DockerUses::from_str(image).map(Self::Docker)
} else {
RepositoryUses::from_str(uses).map(Self::Repository)
}
}
}
#[derive(Debug, PartialEq)]
pub struct LocalUses {
pub path: String,
}
impl FromStr for LocalUses {
type Err = UsesError;
fn from_str(uses: &str) -> Result<Self, Self::Err> {
Ok(LocalUses { path: uses.into() })
}
}
#[derive(Debug, PartialEq)]
pub struct RepositoryUses {
pub owner: String,
pub repo: String,
pub subpath: Option<String>,
pub git_ref: Option<String>,
}
impl FromStr for RepositoryUses {
type Err = UsesError;
fn from_str(uses: &str) -> Result<Self, Self::Err> {
let (path, git_ref) = match uses.rsplit_once('@') {
Some((path, git_ref)) => (path, Some(git_ref)),
None => (uses, None),
};
let components = path.splitn(3, '/').collect::<Vec<_>>();
if components.len() < 2 {
return Err(UsesError(format!("owner/repo slug is too short: {uses}")));
}
Ok(RepositoryUses {
owner: components[0].into(),
repo: components[1].into(),
subpath: components.get(2).map(ToString::to_string),
git_ref: git_ref.map(Into::into),
})
}
}
#[derive(Debug, PartialEq)]
pub struct DockerUses {
pub registry: Option<String>,
pub image: String,
pub tag: Option<String>,
pub hash: Option<String>,
}
impl DockerUses {
fn is_registry(registry: &str) -> bool {
registry == "localhost" || registry.contains('.') || registry.contains(':')
}
}
impl FromStr for DockerUses {
type Err = UsesError;
fn from_str(uses: &str) -> Result<Self, Self::Err> {
let (registry, image) = match uses.split_once('/') {
Some((registry, image)) if Self::is_registry(registry) => (Some(registry), image),
_ => (None, uses),
};
if let Some(at_pos) = image.find('@') {
let (image, hash) = image.split_at(at_pos);
let hash = if hash.is_empty() {
None
} else {
Some(&hash[1..])
};
Ok(DockerUses {
registry: registry.map(Into::into),
image: image.into(),
tag: None,
hash: hash.map(Into::into),
})
} else {
let (image, tag) = match image.split_once(':') {
Some((image, "")) => (image, None),
Some((image, tag)) => (image, Some(tag)),
_ => (image, None),
};
Ok(DockerUses {
registry: registry.map(Into::into),
image: image.into(),
tag: tag.map(Into::into),
hash: None,
})
}
}
}
pub(crate) fn step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
where
D: Deserializer<'de>,
{
let uses = <&str>::deserialize(de)?;
Uses::from_str(uses).map_err(de::Error::custom)
}
pub(crate) fn reusable_step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
where
D: Deserializer<'de>,
{
let uses = step_uses(de)?;
match uses {
Uses::Repository(ref repo) => {
if repo.git_ref.is_none() {
Err(de::Error::custom(
"repo action must have `@<ref>` in reusable workflow",
))
} else {
Ok(uses)
}
}
Uses::Local(ref local) => {
if local.path.contains('@') {
Err(de::Error::custom(
"local reusable workflow reference can't specify `@<ref>`",
))
} else {
Ok(uses)
}
}
Uses::Docker(_) => Err(de::Error::custom(
"docker action invalid in reusable workflow `uses`",
)),
}
}
#[cfg(test)]
mod tests {
use indexmap::IndexMap;
use serde::Deserialize;
use crate::common::{BasePermission, Env, EnvValue, Permission};
use super::{
DockerUses, LocalUses, Permissions, RepositoryUses, Uses, UsesError, reusable_step_uses,
};
#[test]
fn test_permissions() {
assert_eq!(
serde_yaml::from_str::<Permissions>("read-all").unwrap(),
Permissions::Base(BasePermission::ReadAll)
);
let perm = "security-events: write";
assert_eq!(
serde_yaml::from_str::<Permissions>(perm).unwrap(),
Permissions::Explicit(IndexMap::from([(
"security-events".into(),
Permission::Write
)]))
);
}
#[test]
fn test_env_empty_value() {
let env = "foo:";
assert_eq!(
serde_yaml::from_str::<Env>(env).unwrap()["foo"],
EnvValue::String("".into())
);
}
#[test]
fn test_env_value_csharp_trueish() {
let vectors = [
(EnvValue::Boolean(true), true),
(EnvValue::Boolean(false), false),
(EnvValue::String("true".to_string()), true),
(EnvValue::String("TRUE".to_string()), true),
(EnvValue::String("TrUe".to_string()), true),
(EnvValue::String(" true ".to_string()), true),
(EnvValue::String(" \n\r\t True\n\n".to_string()), true),
(EnvValue::String("false".to_string()), false),
(EnvValue::String("1".to_string()), false),
(EnvValue::String("yes".to_string()), false),
(EnvValue::String("on".to_string()), false),
(EnvValue::String("random".to_string()), false),
(EnvValue::Number(1.0), false),
(EnvValue::Number(0.0), false),
(EnvValue::Number(666.0), false),
];
for (val, expected) in vectors {
assert_eq!(val.csharp_trueish(), expected, "failed for {:?}", val);
}
}
#[test]
fn test_uses_parses() {
let vectors = [
(
"actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
Ok(Uses::Repository(RepositoryUses {
owner: "actions".to_owned(),
repo: "checkout".to_owned(),
subpath: None,
git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()),
})),
),
(
"actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
Ok(Uses::Repository(RepositoryUses {
owner: "actions".to_owned(),
repo: "aws".to_owned(),
subpath: Some("ec2".to_owned()),
git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()),
})),
),
(
"example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
Ok(Uses::Repository(RepositoryUses {
owner: "example".to_owned(),
repo: "foo".to_owned(),
subpath: Some("bar/baz/quux".to_owned()),
git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()),
})),
),
(
"actions/checkout@v4",
Ok(Uses::Repository(RepositoryUses {
owner: "actions".to_owned(),
repo: "checkout".to_owned(),
subpath: None,
git_ref: Some("v4".to_owned()),
})),
),
(
"actions/checkout@abcd",
Ok(Uses::Repository(RepositoryUses {
owner: "actions".to_owned(),
repo: "checkout".to_owned(),
subpath: None,
git_ref: Some("abcd".to_owned()),
})),
),
(
"actions/checkout",
Ok(Uses::Repository(RepositoryUses {
owner: "actions".to_owned(),
repo: "checkout".to_owned(),
subpath: None,
git_ref: None,
})),
),
(
"docker://alpine:3.8",
Ok(Uses::Docker(DockerUses {
registry: None,
image: "alpine".to_owned(),
tag: Some("3.8".to_owned()),
hash: None,
})),
),
(
"docker://localhost/alpine:3.8",
Ok(Uses::Docker(DockerUses {
registry: Some("localhost".to_owned()),
image: "alpine".to_owned(),
tag: Some("3.8".to_owned()),
hash: None,
})),
),
(
"docker://localhost:1337/alpine:3.8",
Ok(Uses::Docker(DockerUses {
registry: Some("localhost:1337".to_owned()),
image: "alpine".to_owned(),
tag: Some("3.8".to_owned()),
hash: None,
})),
),
(
"docker://ghcr.io/foo/alpine:3.8",
Ok(Uses::Docker(DockerUses {
registry: Some("ghcr.io".to_owned()),
image: "foo/alpine".to_owned(),
tag: Some("3.8".to_owned()),
hash: None,
})),
),
(
"docker://ghcr.io/foo/alpine",
Ok(Uses::Docker(DockerUses {
registry: Some("ghcr.io".to_owned()),
image: "foo/alpine".to_owned(),
tag: None,
hash: None,
})),
),
(
"docker://ghcr.io/foo/alpine:",
Ok(Uses::Docker(DockerUses {
registry: Some("ghcr.io".to_owned()),
image: "foo/alpine".to_owned(),
tag: None,
hash: None,
})),
),
(
"docker://alpine",
Ok(Uses::Docker(DockerUses {
registry: None,
image: "alpine".to_owned(),
tag: None,
hash: None,
})),
),
(
"docker://alpine@hash",
Ok(Uses::Docker(DockerUses {
registry: None,
image: "alpine".to_owned(),
tag: None,
hash: Some("hash".to_owned()),
})),
),
(
"./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89",
Ok(Uses::Local(LocalUses {
path: "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89".to_owned(),
})),
),
(
"./.github/actions/hello-world-action",
Ok(Uses::Local(LocalUses {
path: "./.github/actions/hello-world-action".to_owned(),
})),
),
(
"checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
Err(UsesError(
"owner/repo slug is too short: checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3".to_owned()
)),
),
];
for (input, expected) in vectors {
assert_eq!(input.parse(), expected);
}
}
#[test]
fn test_uses_deser_reusable() {
let vectors = [
(
"octo-org/this-repo/.github/workflows/workflow-1.yml@\
172239021f7ba04fe7327647b213799853a9eb89",
Some(Uses::Repository(RepositoryUses {
owner: "octo-org".to_owned(),
repo: "this-repo".to_owned(),
subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
git_ref: Some("172239021f7ba04fe7327647b213799853a9eb89".to_owned()),
})),
),
(
"octo-org/this-repo/.github/workflows/workflow-1.yml@notahash",
Some(Uses::Repository(RepositoryUses {
owner: "octo-org".to_owned(),
repo: "this-repo".to_owned(),
subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
git_ref: Some("notahash".to_owned()),
})),
),
(
"octo-org/this-repo/.github/workflows/workflow-1.yml@abcd",
Some(Uses::Repository(RepositoryUses {
owner: "octo-org".to_owned(),
repo: "this-repo".to_owned(),
subpath: Some(".github/workflows/workflow-1.yml".to_owned()),
git_ref: Some("abcd".to_owned()),
})),
),
("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
(
"./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
None,
),
("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
(".github/workflows/workflow-1.yml", None),
(
"workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
None,
),
];
#[derive(Deserialize)]
#[serde(transparent)]
struct Dummy(#[serde(deserialize_with = "reusable_step_uses")] Uses);
for (input, expected) in vectors {
assert_eq!(
serde_yaml::from_str::<Dummy>(input).map(|d| d.0).ok(),
expected
);
}
}
}