cellos_ctl/cmd/apply.rs
1//! `cellctl apply -f formation.yaml` → POST /v1/formations.
2//!
3//! YAML is parsed locally only to validate shape and convert to JSON. The
4//! authoritative validation happens on the server; we do a minimal local check
5//! to surface obvious schema errors with exit-code 3 before any network call.
6//!
7//! ## Red-team wave 2 hardening
8//!
9//! `serde_yaml` (the unmaintained YAML 1.1 parser still in the workspace) is
10//! known to be vulnerable to the "billion-laughs" alias-expansion attack: a
11//! ~1 KiB YAML document can expand to gigabytes of in-memory `Value` tree
12//! during deserialization. The full fix is to migrate off `serde_yaml`; until
13//! that lands, we defend in depth here:
14//!
15//! 1. **Hard byte cap on the source file.** A formation document is ~12 KiB
16//! for a 64-cell formation; 256 KiB is two orders of magnitude over any
17//! legitimate input and small enough that even an aggressive alias bomb
18//! cannot inflate past a few hundred MiB of allocator pressure on the
19//! parsing pass (still bad, but not unbounded-CPU bad).
20//! 2. **Reject symlinks at the final path component.** `cellctl apply -f
21//! /tmp/attacker-controlled` cannot redirect the read to `/etc/shadow`
22//! via a swapped-in symlink. Matches the SEC-15b / SEC-08 protection
23//! applied to `CELLOS_POLICY_PACK_PATH` and the supervisor spec path.
24//! 3. **Reject non-regular files.** Named pipes, devices, and sockets are
25//! not legitimate config sources — `cellctl apply -f /dev/zero` should
26//! fail fast, not spin forever inside `read_to_string`.
27
28use std::path::Path;
29
30use serde_json::Value;
31
32use crate::client::CellosClient;
33use crate::exit::{CtlError, CtlResult};
34use crate::model::Formation;
35
36/// Maximum size of a `cellctl apply -f` source file. See module docs.
37pub(crate) const APPLY_MAX_INPUT_BYTES: u64 = 256 * 1024;
38
39pub async fn run(client: &CellosClient, path: &Path) -> CtlResult<()> {
40 let yaml = read_apply_input(path)?;
41
42 let body: Value = serde_yaml::from_str(&yaml)?;
43 validate_formation_spec(&body, path)?;
44
45 let created: Formation = client.post_json("/v1/formations", &body).await?;
46 let name = if !created.name.is_empty() {
47 &created.name
48 } else {
49 &created.id
50 };
51 println!("formation/{name} created (state={})", created.state);
52 Ok(())
53}
54
55/// Local shape check — the server is authoritative, but obvious mistakes
56/// should fail fast with exit code 3 (validation) rather than 2 (api).
57fn validate_formation_spec(v: &Value, path: &Path) -> CtlResult<()> {
58 let Some(obj) = v.as_object() else {
59 return Err(CtlError::validation(format!(
60 "{}: top-level must be a YAML mapping",
61 path.display()
62 )));
63 };
64
65 // Accept either `kind: Formation` (kubectl-style) OR a bare formation spec
66 // (which is what Session 15/16 sketched). Don't be pedantic — server decides.
67 if let Some(kind) = obj.get("kind").and_then(|k| k.as_str()) {
68 if kind != "Formation" {
69 return Err(CtlError::validation(format!(
70 "{}: kind must be \"Formation\" (got \"{kind}\")",
71 path.display()
72 )));
73 }
74 }
75
76 // Require either `name` or `metadata.name`.
77 let has_name = obj.get("name").and_then(|n| n.as_str()).is_some()
78 || obj
79 .get("metadata")
80 .and_then(|m| m.get("name"))
81 .and_then(|n| n.as_str())
82 .is_some();
83 if !has_name {
84 return Err(CtlError::validation(format!(
85 "{}: missing required field `name` (or `metadata.name`)",
86 path.display()
87 )));
88 }
89 Ok(())
90}
91
92/// Read `cellctl apply -f` input with red-team wave 2 hardening:
93/// - reject symlinks (`symlink_metadata` then refuse `is_symlink`),
94/// - reject non-regular files (named pipes, devices, sockets),
95/// - cap total bytes at [`APPLY_MAX_INPUT_BYTES`] before allocating the body.
96///
97/// See module docs for the threat model. All failures map to [`CtlError::usage`]
98/// (exit code 1) since they describe an invalid invocation, not a server-side
99/// validation failure of the document's contents.
100pub(crate) fn read_apply_input(path: &Path) -> CtlResult<String> {
101 use std::io::Read;
102 // symlink_metadata does NOT follow the final component, so an attacker who
103 // swapped /tmp/spec.yaml -> /etc/shadow between the user typing the command
104 // and us opening the file is caught here rather than silently followed.
105 let meta = std::fs::symlink_metadata(path)
106 .map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
107 if meta.file_type().is_symlink() {
108 return Err(CtlError::usage(format!(
109 "{}: refusing to follow symlink (apply requires a regular file)",
110 path.display()
111 )));
112 }
113 if !meta.file_type().is_file() {
114 return Err(CtlError::usage(format!(
115 "{}: not a regular file (named pipes, devices, and sockets are rejected)",
116 path.display()
117 )));
118 }
119 // Pre-size check via metadata.len() — for regular files this is the exact
120 // byte count and lets us refuse before any allocation. We still cap the
121 // read itself with `take()` so a TOCTOU expansion between stat and open
122 // cannot blow past the limit.
123 if meta.len() > APPLY_MAX_INPUT_BYTES {
124 return Err(CtlError::usage(format!(
125 "{}: file is {} bytes; maximum is {} (resists serde_yaml billion-laughs)",
126 path.display(),
127 meta.len(),
128 APPLY_MAX_INPUT_BYTES,
129 )));
130 }
131
132 let mut file = std::fs::File::open(path)
133 .map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
134 // +1 so we can detect a TOCTOU race that grew the file past the cap.
135 let mut buf = String::with_capacity(meta.len() as usize);
136 let limited = std::io::Read::take(&mut file, APPLY_MAX_INPUT_BYTES + 1);
137 let n = std::io::BufReader::new(limited)
138 .read_to_string(&mut buf)
139 .map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
140 if n as u64 > APPLY_MAX_INPUT_BYTES {
141 return Err(CtlError::usage(format!(
142 "{}: file exceeded {} byte cap mid-read (TOCTOU growth?)",
143 path.display(),
144 APPLY_MAX_INPUT_BYTES,
145 )));
146 }
147 Ok(buf)
148}
149
150#[cfg(test)]
151mod tests {
152 //! Red-team wave 2 hostile-input tests for `cellctl apply` source files.
153 use super::*;
154 use std::io::Write;
155
156 /// Helper: a tempdir-scoped writable file.
157 fn write_temp(name: &str, contents: &[u8]) -> std::path::PathBuf {
158 let dir =
159 std::env::temp_dir().join(format!("cellctl-apply-rt2-{}-{}", name, std::process::id()));
160 std::fs::create_dir_all(&dir).expect("mkdir tempdir");
161 let p = dir.join(format!("{name}.yaml"));
162 let mut f = std::fs::File::create(&p).expect("create temp file");
163 f.write_all(contents).expect("write temp file");
164 p
165 }
166
167 /// Red-team W2C-M2 / W2C-H1: a 1 MiB YAML file exceeds the
168 /// `APPLY_MAX_INPUT_BYTES` cap and must be refused with a `usage` error
169 /// before `serde_yaml` is invoked. This defends against the
170 /// serde_yaml billion-laughs vector by capping the *source*: a 256 KiB
171 /// alias bomb cannot inflate past a few hundred MiB even at worst case,
172 /// far below "OOM the operator's laptop".
173 #[test]
174 fn rejects_oversized_input_before_parse() {
175 let big = vec![b'a'; 1024 * 1024];
176 let p = write_temp("oversized", &big);
177 let err = read_apply_input(&p).expect_err("oversized must fail");
178 assert!(
179 err.message.contains("maximum is")
180 || err.message.contains("byte cap")
181 || err.message.contains("billion-laughs"),
182 "expected size-cap error, got {err:?}",
183 );
184 let _ = std::fs::remove_file(&p);
185 }
186
187 /// Red-team W2C-L1: a symlink as the final path component is refused so
188 /// `cellctl apply -f /tmp/spec.yaml` cannot be redirected to `/etc/shadow`
189 /// or `/dev/zero` by an attacker who swapped the symlink in between
190 /// `argv` parsing and the open.
191 #[cfg(unix)]
192 #[test]
193 fn rejects_symlink_at_final_component() {
194 let target = write_temp("symlink-target", b"name: t\n");
195 let link_dir = target.parent().unwrap().join("link-rt2");
196 std::fs::create_dir_all(&link_dir).expect("mkdir link dir");
197 let link = link_dir.join("link.yaml");
198 let _ = std::fs::remove_file(&link);
199 std::os::unix::fs::symlink(&target, &link).expect("symlink");
200 let err = read_apply_input(&link).expect_err("symlink must be refused");
201 assert!(
202 err.message.contains("symlink"),
203 "expected symlink-refusal, got {err:?}",
204 );
205 let _ = std::fs::remove_file(&link);
206 let _ = std::fs::remove_file(&target);
207 }
208
209 /// Red-team: a tiny well-formed YAML still parses (regression guard).
210 #[test]
211 fn accepts_small_well_formed_yaml() {
212 let p = write_temp(
213 "small",
214 b"name: demo\ncoordinator: coord\nmembers:\n - id: coord\n",
215 );
216 let s = read_apply_input(&p).expect("small file ok");
217 assert!(s.contains("coord"));
218 let _ = std::fs::remove_file(&p);
219 }
220
221 /// Red-team: `validate_formation_spec` must reject a `kind` that is the
222 /// right case-folded shape but the wrong literal value. We accept exact
223 /// "Formation" only — case-tweaked variants ("formation", "FORMATION") are
224 /// rejected because an admission-gate that accepts case variants is one
225 /// step from accepting Unicode lookalikes.
226 #[test]
227 fn validate_rejects_wrong_kind_case() {
228 let v: Value = serde_yaml::from_str(
229 "kind: formation\nname: demo\ncoordinator: coord\nmembers:\n - id: coord\n",
230 )
231 .unwrap();
232 let err = validate_formation_spec(&v, std::path::Path::new("/dev/null"))
233 .expect_err("wrong kind must fail");
234 assert!(
235 err.message.contains("kind"),
236 "expected kind-mismatch, got {err:?}",
237 );
238 }
239
240 /// Red-team W2C-M2 (named-pipe variant): /dev/zero must fail fast with
241 /// "not a regular file" rather than spinning forever inside
242 /// `read_to_string`. On non-unix this test is skipped because the path
243 /// likely does not exist.
244 #[cfg(unix)]
245 #[test]
246 fn rejects_dev_zero() {
247 let p = std::path::Path::new("/dev/zero");
248 if !p.exists() {
249 return;
250 }
251 let err = read_apply_input(p).expect_err("/dev/zero must be refused");
252 assert!(
253 err.message.contains("not a regular file") || err.message.contains("symlink"),
254 "expected non-regular-file refusal, got {err:?}",
255 );
256 }
257}