use std::fmt::{self, Display, Formatter};
use std::str::FromStr;
use schemars::{JsonSchema, json_schema};
use serde::{Deserialize, Deserializer, Serialize};
use super::names::{InputName, Text};
use crate::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileSource {
Source(String),
Content(String),
Input(InputName),
Secret(String),
}
impl FileSource {
#[must_use]
pub fn is_value(&self) -> bool {
matches!(self, FileSource::Input(_) | FileSource::Secret(_))
}
#[must_use]
pub fn key(&self) -> &'static str {
match self {
FileSource::Source(_) => "source",
FileSource::Content(_) => "content",
FileSource::Input(_) => "input",
FileSource::Secret(_) => "secret",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Mode(u32);
impl Mode {
pub const CONFIG: Mode = Mode(0o644);
pub const VALUE: Mode = Mode(0o400);
#[must_use]
pub fn bits(self) -> u32 {
self.0
}
}
impl FromStr for Mode {
type Err = Error;
fn from_str(digits: &str) -> Result<Mode, Error> {
u32::from_str_radix(digits, 8)
.ok()
.filter(|m| *m <= 0o777)
.map(Mode)
.ok_or_else(|| Error::Mode(digits.to_owned()))
}
}
impl Display for Mode {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{:04o}", self.0)
}
}
impl Serialize for Mode {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Mode {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Mode, D::Error> {
d.deserialize_any(Text("octal digits, like \"0444\""))?
.parse()
.map_err(serde::de::Error::custom)
}
}
impl JsonSchema for Mode {
fn schema_name() -> std::borrow::Cow<'static, str> {
"Mode".into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
json_schema!({ "type": ["string", "integer"], "pattern": "^0?[0-7]{1,3}$" })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(try_from = "FileInput")]
pub struct File {
pub path: String,
pub source: FileSource,
pub uid: Option<u32>,
pub gid: Option<u32>,
pub mode: Option<Mode>,
}
impl File {
#[must_use]
pub fn mode_or_default(&self) -> Mode {
self.mode.unwrap_or(if self.source.is_value() { Mode::VALUE } else { Mode::CONFIG })
}
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct FileInput {
path: String,
#[serde(default)]
source: Option<String>,
#[serde(default)]
content: Option<String>,
#[serde(default)]
input: Option<InputName>,
#[serde(default)]
secret: Option<String>,
#[serde(default)]
uid: Option<u32>,
#[serde(default)]
gid: Option<u32>,
#[serde(default)]
mode: Option<Mode>,
}
impl TryFrom<FileInput> for File {
type Error = Error;
fn try_from(f: FileInput) -> Result<File, Error> {
let given: Vec<FileSource> = [
f.source.map(FileSource::Source),
f.content.map(FileSource::Content),
f.input.map(FileSource::Input),
f.secret.map(FileSource::Secret),
]
.into_iter()
.flatten()
.collect();
let source = match <[FileSource; 1]>::try_from(given) {
Ok([one]) => one,
Err(given) if given.is_empty() => return Err(Error::NoFileSource),
Err(given) => return Err(Error::FileSources(given.iter().map(FileSource::key).collect())),
};
Ok(File {
path: f.path,
source,
uid: f.uid,
gid: f.gid,
mode: f.mode,
})
}
}
impl JsonSchema for File {
fn schema_name() -> std::borrow::Cow<'static, str> {
"File".into()
}
fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
FileInput::json_schema(g)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_file_has_one_source_and_a_mode_by_kind() {
let value: File = serde_yaml_ng::from_str("{ path: /ctf/flag, input: FLAG }").unwrap();
assert!(value.source.is_value());
assert_eq!(value.mode_or_default().to_string(), "0400");
let widened: File = serde_yaml_ng::from_str("{ path: /a, content: x, mode: 0444 }").unwrap();
assert_eq!(widened.mode_or_default(), Mode(0o444));
let config: File = serde_yaml_ng::from_str("{ path: /a, source: a.ini }").unwrap();
assert_eq!(config.mode_or_default(), Mode::CONFIG);
}
#[test]
fn a_mode_is_octal_text_or_the_number_yaml_reads() {
assert_eq!(serde_yaml_ng::from_str::<Mode>("\"0444\"").map(Mode::bits).ok(), Some(0o444));
assert_eq!(serde_yaml_ng::from_str::<Mode>("640").map(Mode::bits).ok(), Some(0o640));
assert_eq!(serde_json::to_string(&Mode::VALUE).ok().as_deref(), Some("\"0400\""));
let wrong = serde_yaml_ng::from_str::<Mode>("[4]").unwrap_err().to_string();
assert!(wrong.contains("octal digits, like \"0444\""), "{wrong}");
}
#[test]
fn a_file_with_two_sources_or_none_is_refused() {
let two = serde_yaml_ng::from_str::<File>("{ path: /a, source: a, input: FLAG }").unwrap_err().to_string();
assert!(two.contains("`source` and `input`"), "{two}");
let none = serde_yaml_ng::from_str::<File>("{ path: /a }").unwrap_err().to_string();
assert!(none.contains("needs one of"), "{none}");
assert!(serde_yaml_ng::from_str::<File>("{ path: /a, content: x, mode: 0999 }").is_err());
}
}