Skip to main content

mur_common/
limits_global.rs

1//! Where the GLOBAL `limits:` scope lives on disk, and the one loader every
2//! reader goes through (issue #1794).
3//!
4//! The global scope used to be a block inside `<mur_home>/config.yaml`. That
5//! file is granted to a sealed agent as a single FILE, and Landlock pins the
6//! inode that existed at seal time: every atomic temp-file-and-rename edit
7//! made after the seal was unreadable on Linux, so a running agent never saw
8//! a new global limit. The scope now lives in its own file under a DIRECTORY
9//! grant (`<mur_home>/limits/`), the same shape that keeps `profile.yaml`
10//! readable under `agent_home`.
11//!
12//! Migration is read-side only: when the new file is absent the legacy
13//! `config.yaml limits:` block is still honoured. Nothing here ever writes
14//! `config.yaml` — the first `mur limits --global` write creates the new file
15//! and the legacy block simply stops being read.
16
17use std::path::{Path, PathBuf};
18
19use super::Limits;
20
21/// Directory under `mur_home` holding the global scope. Granted read-only to
22/// sealed agents as a directory, so edits that replace the file stay visible.
23pub const GLOBAL_LIMITS_DIR: &str = "limits";
24/// The global scope's file inside [`GLOBAL_LIMITS_DIR`]: a bare `Limits`
25/// mapping, no `limits:` wrapper.
26pub const GLOBAL_LIMITS_FILE: &str = "global.yaml";
27/// The user config that carried the global scope before #1794.
28pub const LEGACY_CONFIG_FILE: &str = "config.yaml";
29
30pub fn global_limits_dir(mur_home: &Path) -> PathBuf {
31    mur_home.join(GLOBAL_LIMITS_DIR)
32}
33
34pub fn global_limits_path(mur_home: &Path) -> PathBuf {
35    global_limits_dir(mur_home).join(GLOBAL_LIMITS_FILE)
36}
37
38pub fn legacy_config_path(mur_home: &Path) -> PathBuf {
39    mur_home.join(LEGACY_CONFIG_FILE)
40}
41
42/// Which file the global scope was read from.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum GlobalSource {
45    /// `<mur_home>/limits/global.yaml`.
46    File,
47    /// No new file; `config.yaml limits:` (possibly empty) was used.
48    Legacy,
49}
50
51impl GlobalSource {
52    pub fn label(self) -> &'static str {
53        match self {
54            GlobalSource::File => "limits/global.yaml",
55            GlobalSource::Legacy => "config.yaml limits (legacy)",
56        }
57    }
58}
59
60#[derive(Debug, Clone, PartialEq)]
61pub struct GlobalLimits {
62    pub limits: Limits,
63    pub source: GlobalSource,
64}
65
66/// The new file alone. `Ok(None)` when it does not exist; an empty file is
67/// an empty scope (it still shadows the legacy block). A file that is
68/// present and does not parse is an error, never the defaults.
69pub fn read_global_file(path: &Path) -> Result<Option<Limits>, String> {
70    let text = match std::fs::read_to_string(path) {
71        Ok(t) => t,
72        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
73        Err(e) => return Err(format!("{}: {e}", path.display())),
74    };
75    if text.trim().is_empty() {
76        return Ok(Some(Limits::default()));
77    }
78    let parsed: Option<Limits> =
79        serde_yaml_ng::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
80    Ok(Some(parsed.unwrap_or_default()))
81}
82
83/// The global scope for CLI readers. The new file is strict; the legacy
84/// fallback keeps the old lenient `Config::load_or_default` behaviour, so a
85/// `config.yaml` broken somewhere unrelated still reads as "no global
86/// limits" exactly as before #1794.
87pub fn load_global(mur_home: &Path) -> Result<GlobalLimits, String> {
88    load_with(mur_home, |p| {
89        Ok(crate::config::Config::load_or_default(p).limits)
90    })
91}
92
93/// The global scope for a reader that already holds a good copy and must not
94/// trade it for defaults (the agent runtime's hot reload): both files strict.
95pub fn load_global_strict(mur_home: &Path) -> Result<GlobalLimits, String> {
96    load_with(mur_home, |p| {
97        crate::config::Config::try_load(p).map(|c| c.limits)
98    })
99}
100
101fn load_with(
102    mur_home: &Path,
103    legacy: impl FnOnce(&Path) -> Result<Limits, String>,
104) -> Result<GlobalLimits, String> {
105    if let Some(limits) = read_global_file(&global_limits_path(mur_home))? {
106        return Ok(GlobalLimits {
107            limits,
108            source: GlobalSource::File,
109        });
110    }
111    Ok(GlobalLimits {
112        limits: legacy(&legacy_config_path(mur_home))?,
113        source: GlobalSource::Legacy,
114    })
115}
116
117/// Whether `config.yaml` still carries a non-empty `limits:` block. Doctor
118/// uses it to say the block is ignored once the new file exists; MUR never
119/// removes it — that file is the user's.
120pub fn legacy_block_present(mur_home: &Path) -> bool {
121    !crate::config::Config::load_or_default(&legacy_config_path(mur_home))
122        .limits
123        .is_empty()
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    fn home() -> tempfile::TempDir {
131        tempfile::tempdir().unwrap()
132    }
133
134    fn write_new(h: &Path, text: &str) {
135        std::fs::create_dir_all(global_limits_dir(h)).unwrap();
136        std::fs::write(global_limits_path(h), text).unwrap();
137    }
138
139    fn write_legacy(h: &Path, text: &str) {
140        std::fs::write(legacy_config_path(h), text).unwrap();
141    }
142
143    #[test]
144    fn nothing_on_disk_is_an_empty_legacy_scope() {
145        let h = home();
146        let g = load_global(h.path()).unwrap();
147        assert_eq!(g.source, GlobalSource::Legacy);
148        assert!(g.limits.is_empty());
149    }
150
151    #[test]
152    fn new_file_only() {
153        let h = home();
154        write_new(h.path(), "deadline: 2h\nstuck: off\n");
155        let g = load_global_strict(h.path()).unwrap();
156        assert_eq!(g.source, GlobalSource::File);
157        assert_eq!(g.limits.deadline.as_deref(), Some("2h"));
158        assert_eq!(g.limits.stuck.as_deref(), Some("off"));
159    }
160
161    #[test]
162    fn legacy_only_falls_back_to_config_yaml() {
163        let h = home();
164        write_legacy(h.path(), "limits:\n  cost_usd: 3\n");
165        let g = load_global(h.path()).unwrap();
166        assert_eq!(g.source, GlobalSource::Legacy);
167        assert_eq!(g.limits.cost_usd, Some(3.0));
168        assert!(legacy_block_present(h.path()));
169    }
170
171    #[test]
172    fn both_present_the_new_file_wins_outright() {
173        let h = home();
174        write_legacy(h.path(), "limits:\n  deadline: 9h\n  cost_usd: 3\n");
175        write_new(h.path(), "deadline: 1h\n");
176        let g = load_global(h.path()).unwrap();
177        assert_eq!(g.source, GlobalSource::File);
178        assert_eq!(g.limits.deadline.as_deref(), Some("1h"));
179        // Replace, not merge: the legacy cost cap does not leak through.
180        assert_eq!(g.limits.cost_usd, None);
181        assert!(legacy_block_present(h.path()));
182    }
183
184    #[test]
185    fn an_empty_new_file_still_shadows_the_legacy_block() {
186        let h = home();
187        write_legacy(h.path(), "limits:\n  deadline: 9h\n");
188        write_new(h.path(), "");
189        let g = load_global(h.path()).unwrap();
190        assert_eq!(g.source, GlobalSource::File);
191        assert!(g.limits.is_empty());
192    }
193
194    #[test]
195    fn a_broken_new_file_is_an_error_not_the_defaults() {
196        let h = home();
197        write_legacy(h.path(), "limits:\n  deadline: 9h\n");
198        write_new(h.path(), "deadline: [unclosed\n");
199        assert!(load_global(h.path()).is_err());
200        write_new(h.path(), "max_iterations: 3\n");
201        let e = load_global_strict(h.path()).unwrap_err();
202        assert!(e.contains("max_iterations"), "{e}");
203    }
204
205    #[test]
206    fn legacy_strictness_differs_between_the_two_loaders() {
207        let h = home();
208        write_legacy(h.path(), "limits: [unclosed\n");
209        assert!(load_global(h.path()).unwrap().limits.is_empty());
210        assert!(load_global_strict(h.path()).is_err());
211    }
212}