1use planarian_changes::CatalogConfig;
5use planarian_policy::{Settings, Tables};
6use planarian_schema::{ChangeId, ProbeDef, Sensitivities, Tier};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::{Path, PathBuf};
10
11pub const CONFIG: &str = "/etc/planarian/config.json";
12pub const BOOTED_CONFIG: &str = "/run/booted-system/etc/planarian/config.json";
14pub const CORE_HASH_RECORD: &str = "/etc/planarian/core.sha256";
17
18pub fn core_hash(record: &std::path::Path) -> Result<planarian_schema::Hash, String> {
23 match std::fs::read(record) {
24 Ok(b) => Ok(planarian_schema::Hash::of(&b)),
25 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(planarian_schema::Hash::ZERO),
26 Err(e) => Err(format!("{}: {e}", record.display())),
27 }
28}
29
30#[derive(Clone, Debug, Default, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase", default)]
32pub struct RuntimeOptionConfig {
33 pub path: String,
34 pub keywords: Vec<String>,
35 pub current: Option<String>,
36 pub step: String,
37 pub min: String,
38 pub max: String,
39}
40
41#[derive(Clone, Debug, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase", default)]
43pub struct FileConfig {
44 pub owner: PathBuf,
45 pub flake_attr: String,
46 pub state_dir: PathBuf,
47 pub max_tier: Tier,
48 pub user_controllable_units: BTreeSet<String>,
49 pub user_controllable_changes: BTreeSet<String>,
50 pub user_controllable_options: BTreeSet<String>,
51 pub runtime_allowlist: BTreeSet<String>,
52 pub option_sensitivity: BTreeMap<String, Sensitivities>,
53 pub catalog: CatalogConfig,
54 pub runtime_options: Vec<RuntimeOptionConfig>,
55 pub devices: BTreeMap<String, PathBuf>,
57 pub admin_group: String,
58 pub gc: planarian_schema::gc::GcBudget,
60 pub startup_grace_secs: u64,
63 #[serde(default)]
65 pub session_compositors: Vec<String>,
66 #[serde(default)]
68 pub posture: planarian_schema::Posture,
69 #[serde(default = "default_boot_ready_secs")]
71 pub boot_ready_timeout_secs: u64,
72 pub journal_bytes: u64,
73 #[serde(default)]
75 pub external_source: Option<crate::hooks::ExternalSourceConfig>,
76 #[serde(default = "default_health_secs")]
78 pub health_report_secs: u64,
79 #[serde(default)]
82 pub allow_test_build: bool,
83 #[serde(default)]
85 pub agent_limits: planarian_schema::backend::AgentLimits,
86}
87
88fn default_health_secs() -> u64 {
89 3600
90}
91
92impl Default for FileConfig {
93 fn default() -> Self {
94 let s = Settings::default();
95 FileConfig {
96 owner: "/etc/nixos".into(),
97 flake_attr: "nixosConfigurations.default".into(),
98 state_dir: "/var/lib/planarian".into(),
99 max_tier: Tier::Tested,
100 user_controllable_units: s.user_controllable_units.into_iter().collect(),
101 user_controllable_changes: s.user_controllable_changes.into_iter().map(|c| c.0).collect(),
102 user_controllable_options: s.user_controllable_options.into_iter().collect(),
103 runtime_allowlist: ["font.scale", "wifi.network"].map(String::from).into(),
104 option_sensitivity: BTreeMap::new(),
105 catalog: CatalogConfig::default(),
106 runtime_options: vec![],
107 devices: BTreeMap::new(),
108 admin_group: "planarian-admin".into(),
109 gc: Default::default(),
110 startup_grace_secs: planarian_incident::STARTUP_GRACE.0 / 1000,
111 journal_bytes: planarian_txn::store::DEFAULT_CAPACITY,
112 posture: Default::default(),
113 boot_ready_timeout_secs: default_boot_ready_secs(),
114 session_compositors: vec![],
115 external_source: None,
116 health_report_secs: default_health_secs(),
117 allow_test_build: false,
118 agent_limits: Default::default(),
119 }
120 }
121}
122
123fn default_boot_ready_secs() -> u64 {
124 120
125}
126
127#[derive(Clone, Debug, Default, PartialEq, Eq)]
132pub struct PostureDecl {
133 pub declared: planarian_schema::Posture,
135 pub declared_timeout_secs: u64,
136 pub booted: Option<(planarian_schema::Posture, u64)>,
138}
139
140impl PostureDecl {
141 pub fn of(current: &FileConfig, booted: Option<&FileConfig>) -> PostureDecl {
142 PostureDecl {
143 declared: current.posture,
144 declared_timeout_secs: current.boot_ready_timeout_secs,
145 booted: booted.map(|b| (b.posture, b.boot_ready_timeout_secs)),
146 }
147 }
148
149 pub fn in_force(&self) -> planarian_schema::Posture {
151 self.booted.map_or(self.declared, |(p, _)| p.max(self.declared))
152 }
153
154 pub fn timeout_in_force(&self) -> u64 {
156 self.booted.map_or(self.declared_timeout_secs, |(_, t)| t.min(self.declared_timeout_secs))
157 }
158
159 pub fn weaker_waiting(&self) -> bool {
161 self.in_force() != self.declared || self.timeout_in_force() != self.declared_timeout_secs
162 }
163}
164
165impl FileConfig {
166 pub fn load(path: &Path) -> Result<FileConfig, String> {
167 let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
168 serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))
169 }
170
171 pub fn settings(&self) -> Settings {
172 Settings {
173 max_tier: self.max_tier.min(Tier::Tested),
174 user_controllable_units: self.user_controllable_units.iter().cloned().collect(),
175 user_controllable_changes: self.user_controllable_changes.iter().map(|c| ChangeId(c.clone())).collect(),
176 user_controllable_options: self.user_controllable_options.iter().cloned().collect(),
177 }
178 }
179
180 pub fn tables(&self) -> Tables {
181 let catalog = planarian_changes::entries(&self.catalog).into_iter().map(|e| e.id).collect();
182 Tables {
183 catalog,
184 runtime_allowlist: self.runtime_allowlist.iter().cloned().collect(),
185 option_sensitivity: self.option_sensitivity.iter().map(|(k, v)| (k.clone(), *v)).collect(),
186 model_catalog: Default::default(),
187 }
188 }
189
190 pub fn runtime_options(&self) -> Vec<planarian_intent::RuntimeOption> {
191 let milli = |s: &str| -> i64 {
192 let (i, f) = s.split_once('.').unwrap_or((s, ""));
193 i.parse::<i64>().unwrap_or(0) * 1000 + format!("{f:0<3}").get(..3).and_then(|x| x.parse().ok()).unwrap_or(0)
194 };
195 self.runtime_options
196 .iter()
197 .map(|o| planarian_intent::RuntimeOption {
198 path: o.path.clone(),
199 keywords: o.keywords.clone(),
200 current: o.current.clone().map(planarian_schema::Value::Str),
201 step_milli: milli(&o.step),
202 min_milli: milli(&o.min),
203 max_milli: milli(&o.max),
204 })
205 .collect()
206 }
207}
208
209pub fn load_canonical() -> Result<BTreeMap<String, Vec<ProbeDef>>, String> {
212 let exe = std::env::current_exe().map_err(|e| e.to_string())?;
213 let prefix = exe.parent().and_then(Path::parent).ok_or("no install prefix")?;
214 let p = prefix.join("share/planarian/canonical.json");
215 let text = std::fs::read_to_string(&p).map_err(|e| format!("{}: {e}", p.display()))?;
216 serde_json::from_str(&text).map_err(|e| format!("{}: {e}", p.display()))
217}
218
219pub fn is_admin(uid: u32, group: &str) -> bool {
221 if uid == 0 {
222 return true;
223 }
224 let passwd = std::fs::read_to_string("/etc/passwd").unwrap_or_default();
225 let user = passwd.lines().find_map(|l| {
226 let f: Vec<&str> = l.split(':').collect();
227 (f.len() > 3 && f[2].parse::<u32>().ok() == Some(uid)).then(|| (f[0].to_string(), f[3].to_string()))
228 });
229 let Some((name, primary_gid)) = user else { return false };
230 let groups = std::fs::read_to_string("/etc/group").unwrap_or_default();
231 groups.lines().any(|l| {
232 let f: Vec<&str> = l.split(':').collect();
233 f.len() > 3 && f[0] == group && (f[2] == primary_gid || f[3].split(',').any(|m| m == name))
234 })
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
243 fn shipped_canonical_probes_are_valid() {
244 let text = include_str!("../../../share/canonical.json");
245 let c: BTreeMap<String, Vec<ProbeDef>> = serde_json::from_str(text).unwrap();
246 for (unit, probes) in &c {
247 for p in probes {
248 assert_eq!(p.origin, planarian_schema::ProbeOrigin::Canonical, "{unit}");
249 p.check_contract().unwrap_or_else(|e| panic!("{unit}: {e:?}"));
250 }
251 }
252 assert!(c.contains_key("sshd") && c.contains_key("graphical-session") && c.contains_key("fs-journal"));
253 }
254
255 #[test]
261 fn shipped_canonical_probe_reaches_the_wireplumber_user_unit() {
262 use planarian_schema::{ProbeId, ProbeOrigin, SessionInfo, Timestamp, UnitId};
263 let text = include_str!("../../../share/canonical.json");
264 let canonical: BTreeMap<String, Vec<ProbeDef>> = serde_json::from_str(text).unwrap();
265 let template: UnitId = "service@0:wireplumber".parse().unwrap();
266 let mut def = planarian_fake::fixtures::gym_user_units().into_iter().next().expect("a user unit fixture");
267 def.id = template.clone();
268 def.probes.clear();
269 let manifest = planarian_schema::txn::Manifest { units: Default::default(), user_units: vec![def] };
270 let mut asm = planarian_state::Assembler::new(manifest, canonical);
271 let active = ProbeId("wireplumber-active".into());
272 let has_it = |d: &planarian_schema::UnitDef| {
273 d.probes.iter().any(|p| p.id == active && p.origin == ProbeOrigin::Canonical)
274 };
275 let t = asm.manifest().user_units.iter().find(|d| d.id == template).expect("the template");
276 assert!(has_it(t), "template probes: {:?}", t.probes);
277
278 asm.sessions = vec![SessionInfo {
280 id: "s1".into(),
281 uid: 1000,
282 class: "user".into(),
283 seat: Some("seat0".into()),
284 remote: false,
285 created: Timestamp(0),
286 active: true,
287 idle: false,
288 }];
289 let defs = asm.defs();
290 let inst = defs.values().find(|d| d.id.name == "wireplumber").expect("an instance for uid 1000");
291 assert!(matches!(inst.id.scope, planarian_schema::Scope::User { uid: 1000, .. }), "{:?}", inst.id);
292 assert!(has_it(inst), "instance probes: {:?}", inst.probes);
293 }
294
295 #[test]
296 fn default_config_roundtrips() {
297 let c = FileConfig::default();
298 let j = serde_json::to_string(&c).unwrap();
299 let back: FileConfig = serde_json::from_str(&j).unwrap();
300 assert_eq!(back.max_tier, c.max_tier);
301 assert!(serde_json::from_str::<FileConfig>("{}").is_ok(), "every field has a default");
302 }
303}
304
305#[cfg(test)]
306mod posture_tests {
307 use super::*;
308 use planarian_schema::Posture;
309
310 #[test]
313 fn a_weaker_posture_waits_for_the_boot() {
314 let with = |p: Posture, t: u64| FileConfig { posture: p, boot_ready_timeout_secs: t, ..Default::default() };
315 let booted = with(Posture::Standard, 120);
316 let lower = PostureDecl::of(&with(Posture::Relaxed, 600), Some(&booted));
317 assert_eq!((lower.in_force(), lower.timeout_in_force()), (Posture::Standard, 120));
318 assert!(lower.weaker_waiting());
319 let higher = PostureDecl::of(&with(Posture::Guarded, 60), Some(&booted));
320 assert_eq!((higher.in_force(), higher.timeout_in_force()), (Posture::Guarded, 60));
321 assert!(!higher.weaker_waiting());
322 let fresh = PostureDecl::of(&with(Posture::Relaxed, 600), None);
323 assert_eq!(fresh.in_force(), Posture::Relaxed, "no booted declaration: the running one");
324 }
325}