1use crate::backup::BackupManager;
26use crate::capability::{Capability, Context, Output};
27use crate::processes::ProcessSnapshot;
28use crate::telemetry::Telemetry;
29use crate::validation::path::{validate_path, PathContext};
30use crate::{Error, Result};
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use std::path::PathBuf;
34
35const MAX_WRITE_SIZE: usize = 100 * 1024 * 1024;
37
38const MAX_APPEND_SIZE: usize = 100 * 1024 * 1024;
40
41const MIN_FREE_DISK_BYTES: u64 = 10 * 1024 * 1024;
43
44const CRITICAL_FILES: &[&str] = &[
46 ".bashrc",
47 ".bash_profile",
48 ".profile",
49 ".zshrc",
50 ".zshenv",
51 ".ssh/authorized_keys",
52 ".ssh/id_rsa",
53 ".ssh/id_ed25519",
54 ".ssh/config",
55 ".vimrc",
56 ".gitconfig",
57 ".netrc",
58 ".npmrc",
59 ".pypirc",
60 "authorized_keys",
61 "id_rsa",
62 "id_ed25519",
63];
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct FileWriteArgs {
71 pub path: String,
73 pub content: String,
75 #[serde(default)]
77 pub append: bool,
78}
79
80pub struct FileWrite {
86 backup_mgr: BackupManager,
87}
88
89impl FileWrite {
90 #[allow(clippy::missing_errors_doc)] pub fn new(backup_dir: PathBuf) -> Result<Self> {
93 Ok(Self {
94 backup_mgr: BackupManager::new(backup_dir)?,
95 })
96 }
97}
98
99impl Capability for FileWrite {
100 fn name(&self) -> &'static str {
101 "FileWrite"
102 }
103
104 fn description(&self) -> &'static str {
105 "write file. auto-backup for undo. append ok."
106 }
107
108 fn schema(&self) -> Value {
109 serde_json::json!({
110 "type": "object",
111 "properties": {
112 "path": { "type": "string" },
113 "content": { "type": "string" },
114 "append": { "type": "boolean" }
115 },
116 "required": ["path", "content"]
117 })
118 }
119
120 fn validate(&self, args: &Value) -> Result<()> {
121 let args: FileWriteArgs = serde_json::from_value(args.clone())
122 .map_err(|e| Error::SchemaValidationFailed(e.to_string()))?;
123
124 let ctx = PathContext {
125 require_exists: false,
126 require_file: false,
127 ..Default::default()
128 };
129
130 validate_path(&args.path, &ctx).map_err(Error::SchemaValidationFailed)?;
131
132 Ok(())
133 }
134
135 fn execute(&self, args: &Value, ctx: &Context) -> Result<Output> {
136 let args: FileWriteArgs = serde_json::from_value(args.clone())
137 .map_err(|e| Error::ExecutionFailed(e.to_string()))?;
138
139 if args.content.len() > MAX_WRITE_SIZE {
140 return Err(Error::ExecutionFailed(format!(
141 "Content too large: {} bytes (limit: {} bytes)",
142 args.content.len(),
143 MAX_WRITE_SIZE
144 )));
145 }
146
147 let telemetry_before = Telemetry::capture();
148 let process_before = ProcessSnapshot::capture();
149
150 let write_ctx = PathContext {
151 require_exists: false,
152 require_file: false,
153 ..Default::default()
154 };
155
156 let path = validate_path(&args.path, &write_ctx)
157 .map_err(|e| Error::ExecutionFailed(format!("path validation: {}", e)))?;
158
159 if is_critical_file(&path) {
160 return Err(Error::ExecutionFailed(format!(
161 "critical file denied: {}",
162 path.display()
163 )));
164 }
165
166 if let Err(e) = check_disk_space(&path, args.content.len()) {
167 return Err(Error::ExecutionFailed(e));
168 }
169
170 if args.append {
171 if let Ok(meta) = std::fs::metadata(&path) {
172 let existing = usize::try_from(meta.len()).unwrap_or(usize::MAX);
173 if existing.saturating_add(args.content.len()) > MAX_APPEND_SIZE {
174 return Err(Error::ExecutionFailed(format!(
175 "append would exceed max file size: {} + {} > {} bytes",
176 existing,
177 args.content.len(),
178 MAX_APPEND_SIZE
179 )));
180 }
181 }
182 }
183
184 if ctx.dry_run {
185 return Ok(Output {
186 success: true,
187 data: serde_json::json!({
188 "path": path.display().to_string(),
189 "content_length": args.content.len(),
190 "dry_run": true,
191 "backup_path": null,
192 "telemetry_before": serde_json::to_value(&telemetry_before).unwrap_or(Value::Null),
193 "process_before_count": process_before.summary.total_processes,
194 }),
195 message: Some(format!(
196 "DRY RUN: would write {} bytes to {}",
197 args.content.len(),
198 path.display()
199 )),
200 });
201 }
202
203 let backup_path = if path.exists() {
204 match self.backup_mgr.create_backup(&path, &ctx.job_id) {
205 Ok(bp) => Some(bp),
206 Err(e) => return Err(Error::ExecutionFailed(format!("backup: {}", e))),
207 }
208 } else {
209 if let Some(parent) = path.parent() {
210 std::fs::create_dir_all(parent).map_err(|e| {
211 Error::ExecutionFailed(format!("mkdir {}: {}", parent.display(), e))
212 })?;
213 }
214 None
215 };
216
217 let bytes_written = if args.append {
218 atomic_append(&path, &args.content)?
219 } else {
220 atomic_write(&path, &args.content)?
221 };
222
223 let telemetry_after = Telemetry::capture();
224 let process_after = ProcessSnapshot::capture();
225
226 Ok(Output {
227 success: true,
228 data: serde_json::json!({
229 "path": path.display().to_string(),
230 "bytes_written": bytes_written,
231 "append": args.append,
232 "backup_path": backup_path.map(|p| p.to_string_lossy().to_string()),
233 "telemetry_before": serde_json::to_value(&telemetry_before).unwrap_or(Value::Null),
234 "telemetry_after": serde_json::to_value(&telemetry_after).unwrap_or(Value::Null),
235 "process_before_count": process_before.summary.total_processes,
236 "process_after_count": process_after.summary.total_processes,
237 }),
238 message: Some(format!(
239 "Wrote {} bytes to {}",
240 bytes_written,
241 path.display()
242 )),
243 })
244 }
245}
246
247fn is_critical_file(path: &std::path::Path) -> bool {
248 let path_str = path.to_string_lossy();
249 for critical in CRITICAL_FILES {
250 if path_str.ends_with(critical) {
251 return true;
252 }
253 }
254 false
255}
256
257fn check_disk_space(
258 path: &std::path::Path,
259 content_size: usize,
260) -> std::result::Result<(), String> {
261 let parent = path.parent().unwrap_or_else(|| std::path::Path::new("/"));
262
263 if !parent.exists() {
266 return Ok(());
267 }
268
269 let output = std::process::Command::new("df")
270 .arg("-B1")
271 .arg(parent)
272 .output()
273 .map_err(|e| format!("df command failed: {}", e))?;
274
275 if !output.status.success() {
276 let stderr = String::from_utf8_lossy(&output.stderr);
277 return Err(format!("df command failed: {}", stderr.trim()));
278 }
279
280 let stdout = String::from_utf8_lossy(&output.stdout);
281 let mut lines = stdout.lines();
282
283 let Some(header) = lines.next() else {
284 return Ok(());
285 };
286 let headers: Vec<&str> = header.split_whitespace().collect();
287 let avail_idx = headers
288 .iter()
289 .position(|&h| h.eq_ignore_ascii_case("Available") || h.eq_ignore_ascii_case("Avail"));
290
291 if let Some(line) = lines.next() {
292 let parts: Vec<&str> = line.split_whitespace().collect();
293 let idx = avail_idx.unwrap_or(3); if let Some(available_str) = parts.get(idx) {
295 if let Ok(available) = available_str.parse::<u64>() {
296 let required = (content_size as u64).saturating_add(MIN_FREE_DISK_BYTES);
297 if available < required {
298 return Err(format!(
299 "insufficient disk space: {} bytes available, {} bytes required",
300 available, required
301 ));
302 }
303 return Ok(());
304 }
305 }
306 }
307 Ok(())
308}
309
310#[cfg(unix)]
321fn open_write_nofollow(path: &std::path::Path) -> std::io::Result<std::fs::File> {
322 use std::os::unix::fs::OpenOptionsExt;
323 std::fs::OpenOptions::new()
324 .write(true)
325 .create(true)
326 .truncate(true)
327 .custom_flags(libc::O_NOFOLLOW)
328 .open(path)
329}
330
331#[cfg(not(unix))]
332fn open_write_nofollow(path: &std::path::Path) -> std::io::Result<std::fs::File> {
333 std::fs::OpenOptions::new()
335 .write(true)
336 .create(true)
337 .truncate(true)
338 .open(path)
339}
340
341#[cfg(unix)]
344fn open_read_nofollow(path: &std::path::Path) -> std::io::Result<std::fs::File> {
345 use std::os::unix::fs::OpenOptionsExt;
346 std::fs::OpenOptions::new()
347 .read(true)
348 .custom_flags(libc::O_NOFOLLOW)
349 .open(path)
350}
351
352#[cfg(not(unix))]
353fn open_read_nofollow(path: &std::path::Path) -> std::io::Result<std::fs::File> {
354 std::fs::OpenOptions::new().read(true).open(path)
355}
356
357fn atomic_write(path: &std::path::Path, content: &str) -> Result<usize> {
358 use std::io::Write;
359
360 let tmp_name = format!(
361 ".{}.tmp",
362 path.file_name()
363 .map(|n| n.to_string_lossy())
364 .unwrap_or_default()
365 );
366 let tmp_path = path
367 .parent()
368 .unwrap_or_else(|| std::path::Path::new("."))
369 .join(&tmp_name);
370
371 {
372 let mut file = open_write_nofollow(&tmp_path).map_err(|e| {
373 Error::ExecutionFailed(format!("create temp {}: {}", tmp_path.display(), e))
374 })?;
375 file.write_all(content.as_bytes()).map_err(|e| {
376 Error::ExecutionFailed(format!("write temp {}: {}", tmp_path.display(), e))
377 })?;
378 file.sync_all().map_err(|e| {
379 Error::ExecutionFailed(format!("fsync temp {}: {}", tmp_path.display(), e))
380 })?;
381 }
382
383 std::fs::rename(&tmp_path, path).map_err(|e| {
384 let _ = std::fs::remove_file(&tmp_path);
385 Error::ExecutionFailed(format!(
386 "atomic rename {} -> {}: {}",
387 tmp_path.display(),
388 path.display(),
389 e
390 ))
391 })?;
392
393 if let Ok(dir) = std::fs::File::open(path.parent().unwrap_or_else(|| std::path::Path::new(".")))
394 {
395 let _ = dir.sync_all();
396 }
397
398 Ok(content.len())
399}
400
401fn atomic_append(path: &std::path::Path, content: &str) -> Result<usize> {
402 use std::io::{Read, Write};
403
404 let existing = if path.exists() {
405 let mut file = open_read_nofollow(path).map_err(|e| {
406 Error::ExecutionFailed(format!("open {} for append: {}", path.display(), e))
407 })?;
408 let mut buf = Vec::new();
409 file.read_to_end(&mut buf).map_err(|e| {
410 Error::ExecutionFailed(format!("read {} for append: {}", path.display(), e))
411 })?;
412 buf
413 } else {
414 Vec::new()
415 };
416
417 let mut combined = existing;
418 combined.extend_from_slice(content.as_bytes());
419
420 let tmp_name = format!(
421 ".{}.tmp",
422 path.file_name()
423 .map(|n| n.to_string_lossy())
424 .unwrap_or_default()
425 );
426 let tmp_path = path
427 .parent()
428 .unwrap_or_else(|| std::path::Path::new("."))
429 .join(&tmp_name);
430
431 {
432 let mut file = open_write_nofollow(&tmp_path).map_err(|e| {
433 Error::ExecutionFailed(format!("create temp {}: {}", tmp_path.display(), e))
434 })?;
435 file.write_all(&combined).map_err(|e| {
436 Error::ExecutionFailed(format!("write temp {}: {}", tmp_path.display(), e))
437 })?;
438 file.sync_all().map_err(|e| {
439 Error::ExecutionFailed(format!("fsync temp {}: {}", tmp_path.display(), e))
440 })?;
441 }
442
443 std::fs::rename(&tmp_path, path).map_err(|e| {
444 let _ = std::fs::remove_file(&tmp_path);
445 Error::ExecutionFailed(format!(
446 "atomic rename {} -> {}: {}",
447 tmp_path.display(),
448 path.display(),
449 e
450 ))
451 })?;
452
453 if let Ok(dir) = std::fs::File::open(path.parent().unwrap_or_else(|| std::path::Path::new(".")))
454 {
455 let _ = dir.sync_all();
456 }
457
458 Ok(content.len())
459}
460
461#[cfg(test)]
462#[allow(clippy::expect_used)]
463mod tests {
464 use super::*;
465
466 fn test_backup_dir() -> PathBuf {
467 std::env::temp_dir().join("runtimo_fw_test")
468 }
469
470 #[test]
471 fn writes_new_file() {
472 let target = std::env::temp_dir().join("runtimo_fw_new.txt");
473 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
474
475 let result = cap
476 .execute(
477 &serde_json::json!({
478 "path": target.to_str().unwrap(),
479 "content": "hello from runtimo"
480 }),
481 &Context {
482 dry_run: false,
483 job_id: "t1".into(),
484 working_dir: std::env::temp_dir(),
485 },
486 )
487 .expect("Execution failed");
488
489 assert!(result.success);
490 assert_eq!(
491 std::fs::read_to_string(&target).unwrap(),
492 "hello from runtimo"
493 );
494
495 std::fs::remove_file(&target).ok();
496 std::fs::remove_dir_all(test_backup_dir()).ok();
497 }
498
499 #[test]
500 fn dry_run_does_not_write() {
501 let target = std::env::temp_dir().join("runtimo_fw_dry.txt");
502 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
503
504 cap.execute(
505 &serde_json::json!({
506 "path": target.to_str().unwrap(),
507 "content": "should not exist"
508 }),
509 &Context {
510 dry_run: true,
511 job_id: "t2".into(),
512 working_dir: std::env::temp_dir(),
513 },
514 )
515 .expect("Execution failed");
516
517 assert!(!target.exists());
518 std::fs::remove_dir_all(test_backup_dir()).ok();
519 }
520
521 #[test]
522 fn rejects_path_traversal() {
523 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
524 let err = cap
525 .validate(&serde_json::json!({
526 "path": "../../../etc/passwd",
527 "content": "malicious"
528 }))
529 .unwrap_err();
530 assert!(err.to_string().contains("traversal"));
531 std::fs::remove_dir_all(test_backup_dir()).ok();
532 }
533
534 #[test]
535 fn rejects_critical_file() {
536 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
537 let err = cap
538 .execute(
539 &serde_json::json!({
540 "path": "/tmp/.bashrc",
541 "content": "malicious"
542 }),
543 &Context {
544 dry_run: false,
545 job_id: "t3".into(),
546 working_dir: std::env::temp_dir(),
547 },
548 )
549 .unwrap_err();
550 assert!(err.to_string().contains("critical file"));
551 std::fs::remove_dir_all(test_backup_dir()).ok();
552 }
553
554 #[test]
555 fn atomic_write_produces_correct_content() {
556 let target = std::env::temp_dir().join("runtimo_fw_atomic.txt");
557 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
558
559 let result = cap
560 .execute(
561 &serde_json::json!({
562 "path": target.to_str().unwrap(),
563 "content": "atomic content"
564 }),
565 &Context {
566 dry_run: false,
567 job_id: "t4".into(),
568 working_dir: std::env::temp_dir(),
569 },
570 )
571 .expect("Execution failed");
572
573 assert!(result.success);
574 assert_eq!(std::fs::read_to_string(&target).unwrap(), "atomic content");
575 let tmp = target.parent().unwrap().join(".runtimo_fw_atomic.txt.tmp");
576 assert!(!tmp.exists(), "temp file should not remain");
577
578 std::fs::remove_file(&target).ok();
579 std::fs::remove_dir_all(test_backup_dir()).ok();
580 }
581
582 #[test]
583 fn append_mode_works() {
584 let target = std::env::temp_dir().join("runtimo_fw_append.txt");
585 std::fs::write(&target, "initial").ok();
586
587 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
588
589 let result = cap
590 .execute(
591 &serde_json::json!({
592 "path": target.to_str().unwrap(),
593 "content": " appended",
594 "append": true
595 }),
596 &Context {
597 dry_run: false,
598 job_id: "t5".into(),
599 working_dir: std::env::temp_dir(),
600 },
601 )
602 .expect("Execution failed");
603
604 assert!(result.success);
605 assert_eq!(
606 std::fs::read_to_string(&target).unwrap(),
607 "initial appended"
608 );
609
610 std::fs::remove_file(&target).ok();
611 std::fs::remove_dir_all(test_backup_dir()).ok();
612 }
613
614 #[test]
615 fn dry_run_does_not_create_backup() {
616 let target = std::env::temp_dir().join("runtimo_fw_dry_backup.txt");
617 std::fs::write(&target, "existing content").ok();
618
619 let backup_dir = std::env::temp_dir().join("runtimo_fw_dry_backup_test");
621 let _ = std::fs::remove_dir_all(&backup_dir);
622 let cap = FileWrite::new(backup_dir.clone()).expect("Failed to create FileWrite");
623
624 let result = cap
625 .execute(
626 &serde_json::json!({
627 "path": target.to_str().unwrap(),
628 "content": "new content"
629 }),
630 &Context {
631 dry_run: true,
632 job_id: "t6".into(),
633 working_dir: std::env::temp_dir(),
634 },
635 )
636 .expect("Execution failed");
637
638 assert!(result.success);
639 assert!(result.data["dry_run"].as_bool().unwrap());
640 assert_eq!(
641 std::fs::read_to_string(&target).unwrap(),
642 "existing content"
643 );
644 if backup_dir.exists() {
645 let entries: Vec<_> = std::fs::read_dir(&backup_dir)
646 .map(|d| d.filter_map(|e| e.ok()).collect::<Vec<_>>())
647 .unwrap_or_default();
648 assert!(entries.is_empty());
649 }
650
651 std::fs::remove_file(&target).ok();
652 std::fs::remove_dir_all(&backup_dir).ok();
653 }
654
655 #[test]
656 fn telemetry_included_in_output() {
657 let target = std::env::temp_dir().join("runtimo_fw_telemetry.txt");
658 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
659
660 let result = cap
661 .execute(
662 &serde_json::json!({
663 "path": target.to_str().unwrap(),
664 "content": "telemetry test"
665 }),
666 &Context {
667 dry_run: false,
668 job_id: "t7".into(),
669 working_dir: std::env::temp_dir(),
670 },
671 )
672 .expect("Execution failed");
673
674 assert!(result.success);
675 assert!(result.data["telemetry_before"].is_object());
676 assert!(result.data["telemetry_after"].is_object());
677 assert!(result.data["process_before_count"].is_u64());
678 assert!(result.data["process_after_count"].is_u64());
679
680 std::fs::remove_file(&target).ok();
681 std::fs::remove_dir_all(test_backup_dir()).ok();
682 }
683
684 #[test]
685 fn test_check_disk_space_writable_tmp_is_ok() {
686 let result = check_disk_space(&std::env::temp_dir().join("runtimo_df_test.txt"), 100);
687 assert!(result.is_ok(), "df on /tmp should succeed");
688 }
689
690 #[test]
691 fn test_content_too_large_rejected() {
692 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
693 let large_content = "x".repeat(101 * 1024 * 1024); let result = cap.execute(
695 &serde_json::json!({
696 "path": "/tmp/runtimo_large_test.txt",
697 "content": large_content
698 }),
699 &Context {
700 dry_run: false,
701 job_id: "t8".into(),
702 working_dir: std::env::temp_dir(),
703 },
704 );
705 assert!(result.is_err());
706 assert!(result.unwrap_err().to_string().contains("too large"));
707
708 std::fs::remove_dir_all(test_backup_dir()).ok();
709 }
710
711 #[test]
712 fn test_critical_file_ssh_authorized_keys_blocked() {
713 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
714 let result = cap.execute(
715 &serde_json::json!({
716 "path": "/tmp/.ssh/authorized_keys",
717 "content": "ssh-rsa AAA..."
718 }),
719 &Context {
720 dry_run: false,
721 job_id: "t9".into(),
722 working_dir: std::env::temp_dir(),
723 },
724 );
725 assert!(result.is_err());
726 assert!(result.unwrap_err().to_string().contains("critical file"));
727
728 std::fs::remove_dir_all(test_backup_dir()).ok();
729 }
730
731 #[test]
732 fn test_atomic_write_syncs_directory() {
733 let target = std::env::temp_dir().join("runtimo_fw_sync.txt");
734 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
735
736 let result = cap
737 .execute(
738 &serde_json::json!({
739 "path": target.to_str().unwrap(),
740 "content": "sync test"
741 }),
742 &Context {
743 dry_run: false,
744 job_id: "t10".into(),
745 working_dir: std::env::temp_dir(),
746 },
747 )
748 .expect("Execution failed");
749
750 assert!(result.success);
751
752 let tmp = target.parent().unwrap().join(".runtimo_fw_sync.txt.tmp");
753 assert!(
754 !tmp.exists(),
755 "temp file should be cleaned up after atomic rename"
756 );
757
758 std::fs::remove_file(&target).ok();
759 std::fs::remove_dir_all(test_backup_dir()).ok();
760 }
761
762 #[test]
763 fn test_append_exceeds_max_size_rejected() {
764 let target = std::env::temp_dir().join("runtimo_fw_append_overflow.txt");
765 std::fs::write(&target, "x".repeat(99 * 1024 * 1024)).ok(); let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
768 let large_append = "y".repeat(2 * 1024 * 1024); let result = cap.execute(
770 &serde_json::json!({
771 "path": target.to_str().unwrap(),
772 "content": large_append,
773 "append": true
774 }),
775 &Context {
776 dry_run: false,
777 job_id: "t11".into(),
778 working_dir: std::env::temp_dir(),
779 },
780 );
781 assert!(result.is_err());
782 assert!(result.unwrap_err().to_string().contains("exceed"));
783
784 std::fs::remove_file(&target).ok();
785 std::fs::remove_dir_all(test_backup_dir()).ok();
786 }
787}