agentsec_core/plain_mode/
mod.rs1use std::fs;
42use std::path::{Path, PathBuf};
43
44use serde::{Deserialize, Serialize};
45
46use crate::Paths;
47use crate::error::Result;
48
49pub const PLAIN_LEDGER_FILENAME: &str = "plain-mode.json";
51
52pub const SUSPECT_SUFFIX: &str = ".suspect";
55
56pub const PLAIN_STUB_BODY: &str = "{\n \"mcpServers\": {}\n}\n";
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct LedgerEntry {
63 pub original: PathBuf,
66 pub suspect: PathBuf,
68 pub stub_created: bool,
72}
73
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76pub struct PlainLedger {
77 pub entries: Vec<LedgerEntry>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83pub struct EnableRow {
84 pub original: PathBuf,
86 pub action: EnableAction,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
92pub enum EnableAction {
93 Renamed,
96 RenamedNoStub,
99 SkippedAlreadySuspect,
102 SkippedMissing,
104 WouldRename,
106 WouldSkip { reason: String },
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct EnableOutcome {
113 pub rows: Vec<EnableRow>,
115 pub applied: bool,
117 pub ledger_path: PathBuf,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123pub struct RestoreRow {
124 pub original: PathBuf,
126 pub action: RestoreAction,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132pub enum RestoreAction {
133 Restored,
136 SuspectMissing,
138 WouldRestore,
140 WouldSkip { reason: String },
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct RestoreOutcome {
147 pub rows: Vec<RestoreRow>,
149 pub applied: bool,
151 pub ledger_path: PathBuf,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct PlainStatus {
158 pub active: bool,
160 pub entries: Vec<LedgerEntry>,
162 pub ledger_path: PathBuf,
164}
165
166pub fn status(paths: &Paths) -> Result<PlainStatus> {
173 let ledger_path = paths.home.join(PLAIN_LEDGER_FILENAME);
174 let entries = if ledger_path.exists() {
175 let body = fs::read_to_string(&ledger_path)?;
176 serde_json::from_str::<PlainLedger>(&body)?.entries
177 } else {
178 Vec::new()
179 };
180 Ok(PlainStatus {
181 active: !entries.is_empty(),
182 entries,
183 ledger_path,
184 })
185}
186
187pub fn enable(
199 paths: &Paths,
200 targets: &[PathBuf],
201 dry_run: bool,
202 write_stub: bool,
203) -> Result<EnableOutcome> {
204 let ledger_path = paths.home.join(PLAIN_LEDGER_FILENAME);
205 let mut rows = Vec::with_capacity(targets.len());
206 let mut new_entries = Vec::new();
207
208 for target in targets {
209 let suspect = suspect_path(target);
210 let action = if !target.exists() {
211 if dry_run {
212 EnableAction::WouldSkip {
213 reason: "target does not exist".into(),
214 }
215 } else {
216 EnableAction::SkippedMissing
217 }
218 } else if suspect.exists() {
219 if dry_run {
220 EnableAction::WouldSkip {
221 reason: "suspect backup already present".into(),
222 }
223 } else {
224 EnableAction::SkippedAlreadySuspect
225 }
226 } else if dry_run {
227 EnableAction::WouldRename
228 } else {
229 fs::rename(target, &suspect)?;
230 if write_stub {
231 fs::write(target, PLAIN_STUB_BODY)?;
232 }
233 new_entries.push(LedgerEntry {
234 original: target.clone(),
235 suspect: suspect.clone(),
236 stub_created: write_stub,
237 });
238 if write_stub {
239 EnableAction::Renamed
240 } else {
241 EnableAction::RenamedNoStub
242 }
243 };
244 rows.push(EnableRow {
245 original: target.clone(),
246 action,
247 });
248 }
249
250 if !dry_run && !new_entries.is_empty() {
251 fs::create_dir_all(&paths.home)?;
252 let mut ledger = if ledger_path.exists() {
255 let body = fs::read_to_string(&ledger_path)?;
256 serde_json::from_str::<PlainLedger>(&body).unwrap_or_default()
257 } else {
258 PlainLedger::default()
259 };
260 ledger.entries.extend(new_entries);
261 let body = serde_json::to_string_pretty(&ledger)?;
262 fs::write(&ledger_path, body)?;
263 }
264
265 Ok(EnableOutcome {
266 rows,
267 applied: !dry_run,
268 ledger_path,
269 })
270}
271
272pub fn restore(paths: &Paths, dry_run: bool) -> Result<RestoreOutcome> {
284 let ledger_path = paths.home.join(PLAIN_LEDGER_FILENAME);
285 if !ledger_path.exists() {
286 return Ok(RestoreOutcome {
287 rows: Vec::new(),
288 applied: !dry_run,
289 ledger_path,
290 });
291 }
292
293 let body = fs::read_to_string(&ledger_path)?;
294 let ledger: PlainLedger = serde_json::from_str(&body)?;
295 let mut rows = Vec::with_capacity(ledger.entries.len());
296
297 for entry in &ledger.entries {
298 let action = if !entry.suspect.exists() {
299 if dry_run {
300 RestoreAction::WouldSkip {
301 reason: "suspect file missing".into(),
302 }
303 } else {
304 RestoreAction::SuspectMissing
305 }
306 } else if dry_run {
307 RestoreAction::WouldRestore
308 } else {
309 if entry.original.exists() {
312 fs::remove_file(&entry.original)?;
313 }
314 fs::rename(&entry.suspect, &entry.original)?;
315 RestoreAction::Restored
316 };
317 rows.push(RestoreRow {
318 original: entry.original.clone(),
319 action,
320 });
321 }
322
323 if !dry_run {
324 fs::remove_file(&ledger_path)?;
325 }
326
327 Ok(RestoreOutcome {
328 rows,
329 applied: !dry_run,
330 ledger_path,
331 })
332}
333
334fn suspect_path(original: &Path) -> PathBuf {
336 let mut s = original.as_os_str().to_os_string();
337 s.push(SUSPECT_SUFFIX);
338 PathBuf::from(s)
339}
340
341pub fn default_targets() -> Vec<PathBuf> {
344 vec![PathBuf::from(".mcp.json")]
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 fn paths_for(tmp: &tempfile::TempDir) -> Paths {
352 Paths {
353 home: tmp.path().to_path_buf(),
354 user_home: tmp.path().to_path_buf(),
355 }
356 }
357
358 fn write(p: &Path, body: &str) {
359 std::fs::write(p, body).unwrap();
360 }
361
362 #[test]
363 fn enable_dry_run_does_not_touch_filesystem() {
364 let tmp = tempfile::tempdir().unwrap();
365 let target = tmp.path().join(".mcp.json");
366 write(&target, "{\"mcpServers\":{\"x\":1}}");
367 let original_body = std::fs::read_to_string(&target).unwrap();
368
369 let outcome = enable(&paths_for(&tmp), &[target.clone()], true, true).unwrap();
370 assert!(!outcome.applied);
371 assert_eq!(outcome.rows[0].action, EnableAction::WouldRename);
372 assert!(target.exists());
374 assert_eq!(std::fs::read_to_string(&target).unwrap(), original_body);
375 assert!(!suspect_path(&target).exists());
376 assert!(!outcome.ledger_path.exists());
377 }
378
379 #[test]
380 fn enable_renames_and_writes_stub() {
381 let tmp = tempfile::tempdir().unwrap();
382 let target = tmp.path().join(".mcp.json");
383 write(
384 &target,
385 "{\"mcpServers\":{\"original\":{\"command\":\"x\"}}}",
386 );
387 let suspect = suspect_path(&target);
388
389 let outcome = enable(&paths_for(&tmp), &[target.clone()], false, true).unwrap();
390 assert!(outcome.applied);
391 assert_eq!(outcome.rows[0].action, EnableAction::Renamed);
392 assert!(suspect.exists(), "suspect file must be created");
393 assert!(target.exists(), "stub must be at original path");
394 let stub = std::fs::read_to_string(&target).unwrap();
395 assert_eq!(stub, PLAIN_STUB_BODY);
396 assert!(outcome.ledger_path.exists());
397 }
398
399 #[test]
400 fn enable_skips_when_suspect_already_present() {
401 let tmp = tempfile::tempdir().unwrap();
402 let target = tmp.path().join(".mcp.json");
403 write(&target, "{}");
404 write(&suspect_path(&target), "prior-backup");
405
406 let outcome = enable(&paths_for(&tmp), &[target.clone()], false, true).unwrap();
407 assert_eq!(outcome.rows[0].action, EnableAction::SkippedAlreadySuspect);
408 assert_eq!(
410 std::fs::read_to_string(suspect_path(&target)).unwrap(),
411 "prior-backup"
412 );
413 assert!(!outcome.ledger_path.exists());
415 }
416
417 #[test]
418 fn restore_round_trip_returns_original_content() {
419 let tmp = tempfile::tempdir().unwrap();
420 let target = tmp.path().join(".mcp.json");
421 let body = "{\"mcpServers\":{\"real\":{\"command\":\"x\"}}}";
422 write(&target, body);
423
424 enable(&paths_for(&tmp), &[target.clone()], false, true).unwrap();
425 assert_ne!(std::fs::read_to_string(&target).unwrap(), body);
426
427 let restore_outcome = restore(&paths_for(&tmp), false).unwrap();
428 assert!(restore_outcome.applied);
429 assert_eq!(restore_outcome.rows[0].action, RestoreAction::Restored);
430 assert_eq!(std::fs::read_to_string(&target).unwrap(), body);
432 assert!(!suspect_path(&target).exists());
433 assert!(!restore_outcome.ledger_path.exists());
434 }
435
436 #[test]
437 fn status_reflects_ledger_state() {
438 let tmp = tempfile::tempdir().unwrap();
439 let target = tmp.path().join(".mcp.json");
440 write(&target, "{}");
441
442 let before = status(&paths_for(&tmp)).unwrap();
443 assert!(!before.active);
444 assert!(before.entries.is_empty());
445
446 enable(&paths_for(&tmp), &[target.clone()], false, true).unwrap();
447 let during = status(&paths_for(&tmp)).unwrap();
448 assert!(during.active);
449 assert_eq!(during.entries.len(), 1);
450 assert_eq!(during.entries[0].original, target);
451
452 restore(&paths_for(&tmp), false).unwrap();
453 let after = status(&paths_for(&tmp)).unwrap();
454 assert!(!after.active);
455 assert!(after.entries.is_empty());
456 }
457
458 #[test]
459 fn restore_with_no_ledger_is_a_noop() {
460 let tmp = tempfile::tempdir().unwrap();
461 let outcome = restore(&paths_for(&tmp), false).unwrap();
462 assert!(outcome.rows.is_empty());
463 assert!(!outcome.ledger_path.exists());
465 }
466
467 #[test]
468 fn missing_target_yields_skipped_missing() {
469 let tmp = tempfile::tempdir().unwrap();
470 let outcome = enable(
471 &paths_for(&tmp),
472 &[tmp.path().join("not-there.json")],
473 false,
474 true,
475 )
476 .unwrap();
477 assert_eq!(outcome.rows[0].action, EnableAction::SkippedMissing);
478 assert!(!outcome.ledger_path.exists());
479 }
480}