use std::path::Path;
use serde_json::Value;
use crate::client::CellosClient;
use crate::exit::{CtlError, CtlResult};
use crate::model::Formation;
pub(crate) const APPLY_MAX_INPUT_BYTES: u64 = 256 * 1024;
pub async fn run(client: &CellosClient, path: &Path) -> CtlResult<()> {
let yaml = read_apply_input(path)?;
let body: Value = serde_yaml::from_str(&yaml)?;
validate_formation_spec(&body, path)?;
let created: Formation = client.post_json("/v1/formations", &body).await?;
let name = if !created.name.is_empty() {
&created.name
} else {
&created.id
};
println!("formation/{name} created (state={})", created.state);
Ok(())
}
fn validate_formation_spec(v: &Value, path: &Path) -> CtlResult<()> {
let Some(obj) = v.as_object() else {
return Err(CtlError::validation(format!(
"{}: top-level must be a YAML mapping",
path.display()
)));
};
if let Some(kind) = obj.get("kind").and_then(|k| k.as_str()) {
if kind != "Formation" {
return Err(CtlError::validation(format!(
"{}: kind must be \"Formation\" (got \"{kind}\")",
path.display()
)));
}
}
let has_name = obj.get("name").and_then(|n| n.as_str()).is_some()
|| obj
.get("metadata")
.and_then(|m| m.get("name"))
.and_then(|n| n.as_str())
.is_some();
if !has_name {
return Err(CtlError::validation(format!(
"{}: missing required field `name` (or `metadata.name`)",
path.display()
)));
}
Ok(())
}
pub(crate) fn read_apply_input(path: &Path) -> CtlResult<String> {
use std::io::Read;
let meta = std::fs::symlink_metadata(path)
.map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
if meta.file_type().is_symlink() {
return Err(CtlError::usage(format!(
"{}: refusing to follow symlink (apply requires a regular file)",
path.display()
)));
}
if !meta.file_type().is_file() {
return Err(CtlError::usage(format!(
"{}: not a regular file (named pipes, devices, and sockets are rejected)",
path.display()
)));
}
if meta.len() > APPLY_MAX_INPUT_BYTES {
return Err(CtlError::usage(format!(
"{}: file is {} bytes; maximum is {} (resists serde_yaml billion-laughs)",
path.display(),
meta.len(),
APPLY_MAX_INPUT_BYTES,
)));
}
let mut file = std::fs::File::open(path)
.map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
let mut buf = String::with_capacity(meta.len() as usize);
let limited = std::io::Read::take(&mut file, APPLY_MAX_INPUT_BYTES + 1);
let n = std::io::BufReader::new(limited)
.read_to_string(&mut buf)
.map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
if n as u64 > APPLY_MAX_INPUT_BYTES {
return Err(CtlError::usage(format!(
"{}: file exceeded {} byte cap mid-read (TOCTOU growth?)",
path.display(),
APPLY_MAX_INPUT_BYTES,
)));
}
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_temp(name: &str, contents: &[u8]) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("cellctl-apply-rt2-{}-{}", name, std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir tempdir");
let p = dir.join(format!("{name}.yaml"));
let mut f = std::fs::File::create(&p).expect("create temp file");
f.write_all(contents).expect("write temp file");
p
}
#[test]
fn rejects_oversized_input_before_parse() {
let big = vec![b'a'; 1024 * 1024];
let p = write_temp("oversized", &big);
let err = read_apply_input(&p).expect_err("oversized must fail");
assert!(
err.message.contains("maximum is")
|| err.message.contains("byte cap")
|| err.message.contains("billion-laughs"),
"expected size-cap error, got {err:?}",
);
let _ = std::fs::remove_file(&p);
}
#[cfg(unix)]
#[test]
fn rejects_symlink_at_final_component() {
let target = write_temp("symlink-target", b"name: t\n");
let link_dir = target.parent().unwrap().join("link-rt2");
std::fs::create_dir_all(&link_dir).expect("mkdir link dir");
let link = link_dir.join("link.yaml");
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(&target, &link).expect("symlink");
let err = read_apply_input(&link).expect_err("symlink must be refused");
assert!(
err.message.contains("symlink"),
"expected symlink-refusal, got {err:?}",
);
let _ = std::fs::remove_file(&link);
let _ = std::fs::remove_file(&target);
}
#[test]
fn accepts_small_well_formed_yaml() {
let p = write_temp(
"small",
b"name: demo\ncoordinator: coord\nmembers:\n - id: coord\n",
);
let s = read_apply_input(&p).expect("small file ok");
assert!(s.contains("coord"));
let _ = std::fs::remove_file(&p);
}
#[test]
fn validate_rejects_wrong_kind_case() {
let v: Value = serde_yaml::from_str(
"kind: formation\nname: demo\ncoordinator: coord\nmembers:\n - id: coord\n",
)
.unwrap();
let err = validate_formation_spec(&v, std::path::Path::new("/dev/null"))
.expect_err("wrong kind must fail");
assert!(
err.message.contains("kind"),
"expected kind-mismatch, got {err:?}",
);
}
#[cfg(unix)]
#[test]
fn rejects_dev_zero() {
let p = std::path::Path::new("/dev/zero");
if !p.exists() {
return;
}
let err = read_apply_input(p).expect_err("/dev/zero must be refused");
assert!(
err.message.contains("not a regular file") || err.message.contains("symlink"),
"expected non-regular-file refusal, got {err:?}",
);
}
}