use std::{
borrow::Cow,
fmt::{self, Display},
};
use indexmap::IndexMap;
use self_cell::self_cell;
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 is_empty(&self) -> bool {
match self {
EnvValue::String(s) => s.is_empty(),
_ => false,
}
}
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(Serialize, Debug, PartialEq)]
pub enum If {
Bool(bool),
Expr(String),
}
impl<'de> Deserialize<'de> for If {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum RawIf {
Bool(bool),
Int(i64),
Float(f64),
Expr(String),
}
match RawIf::deserialize(deserializer)? {
RawIf::Bool(b) => Ok(If::Bool(b)),
RawIf::Int(n) => Ok(If::Bool(n != 0)),
RawIf::Float(f) => Ok(If::Bool(f != 0.0 && !f.is_nan())),
RawIf::Expr(s) => Ok(If::Expr(s)),
}
}
}
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 Uses {
pub fn parse<'a>(uses: impl Into<Cow<'a, str>>) -> Result<Self, UsesError> {
let uses = uses.into();
let uses = uses.trim();
if uses.starts_with("./") {
Ok(Self::Local(LocalUses::new(uses)))
} else if let Some(image) = uses.strip_prefix("docker://") {
Ok(Self::Docker(DockerUses::parse(image)))
} else {
RepositoryUses::parse(uses).map(Self::Repository)
}
}
pub fn raw(&self) -> &str {
match self {
Uses::Local(local) => &local.path,
Uses::Repository(repo) => repo.raw(),
Uses::Docker(docker) => docker.raw(),
}
}
}
#[derive(Debug, PartialEq)]
#[non_exhaustive]
pub struct LocalUses {
pub path: String,
}
impl LocalUses {
fn new(path: impl Into<String>) -> Self {
LocalUses { path: path.into() }
}
}
#[derive(Debug, PartialEq)]
struct RepositoryUsesInner<'a> {
owner: &'a str,
repo: &'a str,
slug: &'a str,
subpath: Option<&'a str>,
git_ref: &'a str,
}
impl<'a> RepositoryUsesInner<'a> {
fn from_str(uses: &'a str) -> Result<Self, UsesError> {
let uses = uses.trim();
let (path, git_ref) = match uses.rsplit_once('@') {
Some((path, git_ref)) => (path, git_ref),
None => return Err(UsesError(format!("missing `@<ref>` in {uses}"))),
};
let mut components = path.splitn(3, '/');
if let Some(owner) = components.next()
&& let Some(repo) = components.next()
{
let subpath = components.next();
let slug = if subpath.is_none() {
path
} else {
&path[..owner.len() + 1 + repo.len()]
};
Ok(RepositoryUsesInner {
owner,
repo,
slug,
subpath,
git_ref,
})
} else {
Err(UsesError(format!("owner/repo slug is too short: {uses}")))
}
}
}
self_cell!(
pub struct RepositoryUses {
owner: String,
#[covariant]
dependent: RepositoryUsesInner,
}
impl {Debug, PartialEq}
);
impl Display for RepositoryUses {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.raw())
}
}
impl RepositoryUses {
pub fn parse(uses: impl Into<String>) -> Result<Self, UsesError> {
RepositoryUses::try_new(uses.into(), |s| {
let inner = RepositoryUsesInner::from_str(s)?;
Ok(inner)
})
}
pub fn raw(&self) -> &str {
self.borrow_owner()
}
pub fn owner(&self) -> &str {
self.borrow_dependent().owner
}
pub fn repo(&self) -> &str {
self.borrow_dependent().repo
}
pub fn slug(&self) -> &str {
self.borrow_dependent().slug
}
pub fn subpath(&self) -> Option<&str> {
self.borrow_dependent().subpath
}
pub fn git_ref(&self) -> &str {
self.borrow_dependent().git_ref
}
}
#[derive(Debug, PartialEq)]
#[non_exhaustive]
pub struct DockerUsesInner<'a> {
registry: Option<&'a str>,
image: &'a str,
tag: Option<&'a str>,
hash: Option<&'a str>,
}
impl<'a> DockerUsesInner<'a> {
fn is_registry(registry: &str) -> bool {
registry == "localhost" || registry.contains('.') || registry.contains(':')
}
fn from_str(uses: &'a str) -> Self {
let uses = uses.trim();
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..])
};
DockerUsesInner {
registry,
image,
tag: None,
hash,
}
} else {
let (image, tag) = match image.split_once(':') {
Some((image, "")) => (image, None),
Some((image, tag)) => (image, Some(tag)),
_ => (image, None),
};
DockerUsesInner {
registry,
image,
tag,
hash: None,
}
}
}
}
self_cell!(
pub struct DockerUses {
owner: String,
#[covariant]
dependent: DockerUsesInner,
}
impl {Debug, PartialEq}
);
impl DockerUses {
pub fn parse(uses: impl Into<String>) -> Self {
DockerUses::new(uses.into(), |s| DockerUsesInner::from_str(s))
}
pub fn raw(&self) -> &str {
self.borrow_owner()
}
pub fn registry(&self) -> Option<&str> {
self.borrow_dependent().registry
}
pub fn image(&self) -> &str {
self.borrow_dependent().image
}
pub fn tag(&self) -> Option<&str> {
self.borrow_dependent().tag
}
pub fn hash(&self) -> Option<&str> {
self.borrow_dependent().hash
}
}
impl<'de> Deserialize<'de> for DockerUses {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let uses = <Cow<'de, str>>::deserialize(deserializer)?;
Ok(DockerUses::parse(uses))
}
}
pub(crate) fn custom_error<'de, D>(msg: impl Display) -> D::Error
where
D: Deserializer<'de>,
{
let msg = msg.to_string();
tracing::error!(msg);
de::Error::custom(msg)
}
pub(crate) fn step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
where
D: Deserializer<'de>,
{
let uses = <Cow<'de, str>>::deserialize(de)?;
Uses::parse(uses).map_err(custom_error::<D>)
}
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(_) => Ok(uses),
Uses::Local(ref local) => {
if local.path.contains('@') {
Err(custom_error::<D>(
"local reusable workflow reference can't specify `@<ref>`",
))
} else {
Ok(uses)
}
}
Uses::Docker(_) => Err(custom_error::<D>(
"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::{Permissions, Uses, 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() {
insta::assert_debug_snapshot!(
Uses::parse("actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
@r#"
Repository(
RepositoryUses {
owner: "actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
dependent: RepositoryUsesInner {
owner: "actions",
repo: "checkout",
slug: "actions/checkout",
subpath: None,
git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
},
},
)
"#,
);
insta::assert_debug_snapshot!(
Uses::parse("actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
@r#"
Repository(
RepositoryUses {
owner: "actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
dependent: RepositoryUsesInner {
owner: "actions",
repo: "aws",
slug: "actions/aws",
subpath: Some(
"ec2",
),
git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
},
},
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
@r#"
Repository(
RepositoryUses {
owner: "example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
dependent: RepositoryUsesInner {
owner: "example",
repo: "foo",
slug: "example/foo",
subpath: Some(
"bar/baz/quux",
),
git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
},
},
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("actions/checkout@v4").unwrap(),
@r#"
Repository(
RepositoryUses {
owner: "actions/checkout@v4",
dependent: RepositoryUsesInner {
owner: "actions",
repo: "checkout",
slug: "actions/checkout",
subpath: None,
git_ref: "v4",
},
},
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("actions/checkout@abcd").unwrap(),
@r#"
Repository(
RepositoryUses {
owner: "actions/checkout@abcd",
dependent: RepositoryUsesInner {
owner: "actions",
repo: "checkout",
slug: "actions/checkout",
subpath: None,
git_ref: "abcd",
},
},
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("actions/checkout").unwrap_err(),
@r#"
UsesError(
"missing `@<ref>` in actions/checkout",
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("docker://alpine:3.8").unwrap(),
@r#"
Docker(
DockerUses {
owner: "alpine:3.8",
dependent: DockerUsesInner {
registry: None,
image: "alpine",
tag: Some(
"3.8",
),
hash: None,
},
},
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("docker://localhost/alpine:3.8").unwrap(),
@r#"
Docker(
DockerUses {
owner: "localhost/alpine:3.8",
dependent: DockerUsesInner {
registry: Some(
"localhost",
),
image: "alpine",
tag: Some(
"3.8",
),
hash: None,
},
},
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("docker://localhost:1337/alpine:3.8").unwrap(),
@r#"
Docker(
DockerUses {
owner: "localhost:1337/alpine:3.8",
dependent: DockerUsesInner {
registry: Some(
"localhost:1337",
),
image: "alpine",
tag: Some(
"3.8",
),
hash: None,
},
},
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("docker://ghcr.io/foo/alpine:3.8").unwrap(),
@r#"
Docker(
DockerUses {
owner: "ghcr.io/foo/alpine:3.8",
dependent: DockerUsesInner {
registry: Some(
"ghcr.io",
),
image: "foo/alpine",
tag: Some(
"3.8",
),
hash: None,
},
},
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("docker://ghcr.io/foo/alpine").unwrap(),
@r#"
Docker(
DockerUses {
owner: "ghcr.io/foo/alpine",
dependent: DockerUsesInner {
registry: Some(
"ghcr.io",
),
image: "foo/alpine",
tag: None,
hash: None,
},
},
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("docker://ghcr.io/foo/alpine:").unwrap(),
@r#"
Docker(
DockerUses {
owner: "ghcr.io/foo/alpine:",
dependent: DockerUsesInner {
registry: Some(
"ghcr.io",
),
image: "foo/alpine",
tag: None,
hash: None,
},
},
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("docker://alpine").unwrap(),
@r#"
Docker(
DockerUses {
owner: "alpine",
dependent: DockerUsesInner {
registry: None,
image: "alpine",
tag: None,
hash: None,
},
},
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("docker://alpine@hash").unwrap(),
@r#"
Docker(
DockerUses {
owner: "alpine@hash",
dependent: DockerUsesInner {
registry: None,
image: "alpine",
tag: None,
hash: Some(
"hash",
),
},
},
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89").unwrap(),
@r#"
Local(
LocalUses {
path: "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89",
},
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("./.github/actions/hello-world-action").unwrap(),
@r#"
Local(
LocalUses {
path: "./.github/actions/hello-world-action",
},
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap_err(),
@r#"
UsesError(
"owner/repo slug is too short: checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("\nactions/checkout@v4 \n").unwrap(),
@r#"
Repository(
RepositoryUses {
owner: "actions/checkout@v4",
dependent: RepositoryUsesInner {
owner: "actions",
repo: "checkout",
slug: "actions/checkout",
subpath: None,
git_ref: "v4",
},
},
)
"#,
);
insta::assert_debug_snapshot!(
Uses::parse("\ndocker://alpine:3.8 \n").unwrap(),
@r#"
Docker(
DockerUses {
owner: "alpine:3.8",
dependent: DockerUsesInner {
registry: None,
image: "alpine",
tag: Some(
"3.8",
),
hash: None,
},
},
)
"#
);
insta::assert_debug_snapshot!(
Uses::parse("\n./.github/workflows/example.yml \n").unwrap(),
@r#"
Local(
LocalUses {
path: "./.github/workflows/example.yml",
},
)
"#
);
}
#[test]
fn test_uses_deser_reusable() {
#[derive(Deserialize)]
#[serde(transparent)]
struct Dummy(#[serde(deserialize_with = "reusable_step_uses")] Uses);
insta::assert_debug_snapshot!(
serde_yaml::from_str::<Dummy>(
"octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
)
.map(|d| d.0)
.unwrap(),
@r#"
Repository(
RepositoryUses {
owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
dependent: RepositoryUsesInner {
owner: "octo-org",
repo: "this-repo",
slug: "octo-org/this-repo",
subpath: Some(
".github/workflows/workflow-1.yml",
),
git_ref: "172239021f7ba04fe7327647b213799853a9eb89",
},
},
)
"#
);
insta::assert_debug_snapshot!(
serde_yaml::from_str::<Dummy>(
"octo-org/this-repo/.github/workflows/workflow-1.yml@notahash"
).map(|d| d.0).unwrap(),
@r#"
Repository(
RepositoryUses {
owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash",
dependent: RepositoryUsesInner {
owner: "octo-org",
repo: "this-repo",
slug: "octo-org/this-repo",
subpath: Some(
".github/workflows/workflow-1.yml",
),
git_ref: "notahash",
},
},
)
"#
);
insta::assert_debug_snapshot!(
serde_yaml::from_str::<Dummy>(
"octo-org/this-repo/.github/workflows/workflow-1.yml@abcd"
).map(|d| d.0).unwrap(),
@r#"
Repository(
RepositoryUses {
owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd",
dependent: RepositoryUsesInner {
owner: "octo-org",
repo: "this-repo",
slug: "octo-org/this-repo",
subpath: Some(
".github/workflows/workflow-1.yml",
),
git_ref: "abcd",
},
},
)
"#
);
insta::assert_debug_snapshot!(
serde_yaml::from_str::<Dummy>(
"octo-org/this-repo/.github/workflows/workflow-1.yml"
).map(|d| d.0).unwrap_err(),
@r#"Error("malformed `uses` ref: missing `@<ref>` in octo-org/this-repo/.github/workflows/workflow-1.yml")"#
);
insta::assert_debug_snapshot!(
serde_yaml::from_str::<Dummy>(
"./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
).map(|d| d.0).unwrap_err(),
@r#"Error("local reusable workflow reference can't specify `@<ref>`")"#
);
insta::assert_debug_snapshot!(
serde_yaml::from_str::<Dummy>(
".github/workflows/workflow-1.yml"
).map(|d| d.0).unwrap_err(),
@r#"Error("malformed `uses` ref: missing `@<ref>` in .github/workflows/workflow-1.yml")"#
);
insta::assert_debug_snapshot!(
serde_yaml::from_str::<Dummy>(
"workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
).map(|d| d.0).unwrap_err(),
@r#"Error("malformed `uses` ref: owner/repo slug is too short: workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89")"#
);
}
}