use std::fs;
use std::io;
use std::path::Path;
use shep_client::shep_core::prelude::AppConfig;
use toml::{Table, Value};
use crate::build::BuildSpec;
use crate::error::Error;
pub fn app_config(release: &Path, sheep: &str) -> Result<AppConfig, Error> {
let merged = merged_document(release)?;
let app = select_app(&merged, sheep)?;
app.try_into().map_err(|source: toml::de::Error| {
Error::Config(format!(
"app {sheep:?} does not match shep's app schema: {source}"
))
})
}
pub fn build_spec(release: &Path) -> Result<BuildSpec, Error> {
let merged = merged_document(release)?;
let Some(build) = merged.as_table().and_then(|doc| doc.get("build")) else {
return Ok(BuildSpec::default());
};
build.clone().try_into().map_err(|source: toml::de::Error| {
Error::Config(format!(
"{}: `[build]` does not match the build schema: {source}",
release.display()
))
})
}
fn merged_document(release: &Path) -> Result<Value, Error> {
let committed = read_required(&release.join("Flockfile.toml"))?;
refuse_repo_privilege(&committed)?;
let override_doc = read_optional(&release.join("Flockfile.override.toml"))?;
Ok(deep_merge(committed, override_doc))
}
fn read_required(path: &Path) -> Result<Value, Error> {
let text = fs::read_to_string(path).map_err(|source| Error::Io {
path: path.to_owned(),
source,
})?;
parse(path, &text)
}
fn read_optional(path: &Path) -> Result<Value, Error> {
match fs::read_to_string(path) {
Ok(text) => parse(path, &text),
Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(Value::Table(Table::new())),
Err(source) => Err(Error::Io {
path: path.to_owned(),
source,
}),
}
}
fn parse(path: &Path, text: &str) -> Result<Value, Error> {
toml::from_str(text).map_err(|source| Error::Config(format!("{}: {source}", path.display())))
}
fn refuse_repo_privilege(committed: &Value) -> Result<(), Error> {
for app in apps(committed) {
let Some(table) = app.as_table() else {
continue;
};
for field in ["user", "group"] {
if table.contains_key(field) {
let name = table
.get("name")
.and_then(Value::as_str)
.unwrap_or("<unnamed>");
return Err(Error::Config(format!(
"Flockfile.toml sets `{field}` on app {name:?} - a committed Flockfile can \
never set user or group, on any app, because privilege is the one thing a \
compromised build cannot escalate to on its own. Pin `{field}` in \
Flockfile.override.toml instead - it is gitignored and never comes from the \
repo."
)));
}
}
}
Ok(())
}
fn apps(doc: &Value) -> &[Value] {
doc.as_table()
.and_then(|table| table.get("app"))
.and_then(Value::as_array)
.map_or(&[], Vec::as_slice)
}
fn deep_merge(base: Value, over: Value) -> Value {
match (base, over) {
(Value::Table(mut base), Value::Table(over)) => {
for (key, over_value) in over {
let merged = if key == "app" {
merge_apps(
base.remove(&key).unwrap_or(Value::Array(Vec::new())),
over_value,
)
} else {
match base.remove(&key) {
Some(base_value) => deep_merge(base_value, over_value),
None => over_value,
}
};
base.insert(key, merged);
}
Value::Table(base)
}
(_, over) => over,
}
}
fn merge_apps(base: Value, over: Value) -> Value {
let mut base_apps = match base {
Value::Array(apps) => apps,
_ => Vec::new(),
};
let over_apps = match over {
Value::Array(apps) => apps,
_ => Vec::new(),
};
for over_app in over_apps {
let name = app_name(&over_app).map(str::to_owned);
let existing = name
.as_deref()
.and_then(|name| base_apps.iter().position(|app| app_name(app) == Some(name)));
match existing {
Some(index) => {
let base_app = base_apps.remove(index);
base_apps.insert(index, deep_merge(base_app, over_app));
}
None => base_apps.push(over_app),
}
}
Value::Array(base_apps)
}
fn app_name(app: &Value) -> Option<&str> {
app.as_table()?.get("name")?.as_str()
}
fn select_app(merged: &Value, sheep: &str) -> Result<Value, Error> {
apps(merged)
.iter()
.find(|app| app_name(app) == Some(sheep))
.cloned()
.ok_or_else(|| Error::Config(format!("no app named {sheep:?} in Flockfile.toml")))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn fixture_release(files: &[(&str, &str)]) -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
for (name, contents) in files {
fs::write(dir.path().join(name), contents).expect("write fixture file");
}
dir
}
#[test]
fn the_override_wins_on_merge() {
let rel = fixture_release(&[
(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='upstream.js'\n",
),
(
"Flockfile.override.toml",
"[[app]]\nname='web'\nscript='mine.js'\n",
),
]);
let app = app_config(rel.path(), "web").expect("merges");
assert_eq!(app.script, "mine.js");
}
#[test]
fn a_committed_flockfile_cannot_set_user() {
let rel = fixture_release(&[(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x.js'\nuser='root'\n",
)]);
let err = app_config(rel.path(), "web").expect_err("refuses");
assert!(err.to_string().contains("user"));
}
#[test]
fn a_committed_flockfile_cannot_set_group() {
let rel = fixture_release(&[(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x.js'\ngroup='wheel'\n",
)]);
let err = app_config(rel.path(), "web").expect_err("refuses");
assert!(err.to_string().contains("group"));
}
#[test]
fn an_override_present_does_not_launder_a_committed_user_field() {
let rel = fixture_release(&[
(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x.js'\nuser='root'\n",
),
(
"Flockfile.override.toml",
"[[app]]\nname='web'\nuser='nobody'\n",
),
]);
let err = app_config(rel.path(), "web").expect_err("still refuses");
assert!(err.to_string().contains("user"));
}
#[test]
fn apps_in_different_files_merge_by_name_not_position() {
let rel = fixture_release(&[
(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='web.js'\n\n[[app]]\nname='worker'\nscript='worker.js'\n",
),
(
"Flockfile.override.toml",
"[[app]]\nname='worker'\nscript='worker-mine.js'\n\n[[app]]\nname='web'\nscript='web-mine.js'\n",
),
]);
let web = app_config(rel.path(), "web").expect("merges web");
let worker = app_config(rel.path(), "worker").expect("merges worker");
assert_eq!(web.script, "web-mine.js");
assert_eq!(worker.script, "worker-mine.js");
}
#[test]
fn an_override_can_add_a_new_app_not_in_the_committed_file() {
let rel = fixture_release(&[
("Flockfile.toml", "[[app]]\nname='web'\nscript='web.js'\n"),
(
"Flockfile.override.toml",
"[[app]]\nname='sidecar'\nscript='sidecar.js'\n",
),
]);
let sidecar = app_config(rel.path(), "sidecar").expect("the override's own app merges");
assert_eq!(sidecar.script, "sidecar.js");
}
#[test]
fn app_config_refuses_an_unknown_sheep_name() {
let rel = fixture_release(&[("Flockfile.toml", "[[app]]\nname='web'\nscript='x.js'\n")]);
let err = app_config(rel.path(), "ghost").expect_err("no such app");
assert!(err.to_string().contains("ghost"));
}
#[test]
fn a_build_block_parses_into_a_spec() {
let rel = fixture_release(&[(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x'\n\n[build]\ncommand = 'make build'\nenv = { CARGO_TARGET_DIR = '/srv/cache' }\nartifacts = ['target/release/koji']\n",
)]);
let spec = build_spec(rel.path()).expect("parses");
assert_eq!(spec.command.as_deref(), Some("make build"));
assert_eq!(
spec.env.get("CARGO_TARGET_DIR").map(String::as_str),
Some("/srv/cache")
);
assert_eq!(
spec.artifacts,
vec![std::path::PathBuf::from("target/release/koji")]
);
}
#[test]
fn an_absent_build_block_is_the_default_spec() {
let rel = fixture_release(&[("Flockfile.toml", "[[app]]\nname='web'\nscript='x'\n")]);
assert_eq!(
build_spec(rel.path()).expect("parses"),
BuildSpec::default()
);
}
#[test]
fn the_override_wins_on_the_build_block() {
let rel = fixture_release(&[
(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x'\n\n[build]\ncommand = 'make build'\n",
),
(
"Flockfile.override.toml",
"[build]\nenv = { CARGO_TARGET_DIR = '/srv/cache' }\n",
),
]);
let spec = build_spec(rel.path()).expect("parses");
assert_eq!(spec.command.as_deref(), Some("make build"));
assert_eq!(
spec.env.get("CARGO_TARGET_DIR").map(String::as_str),
Some("/srv/cache")
);
}
#[test]
fn an_unknown_build_key_is_refused() {
let rel = fixture_release(&[(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x'\n\n[build]\ncommands = 'make build'\n",
)]);
let err = build_spec(rel.path()).expect_err("refuses");
assert!(err.to_string().contains("commands"));
}
}