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
310fn atomic_write(path: &std::path::Path, content: &str) -> Result<usize> {
311 use std::io::Write;
312 use std::os::unix::fs::OpenOptionsExt;
313
314 let tmp_name = format!(
315 ".{}.tmp",
316 path.file_name()
317 .map(|n| n.to_string_lossy())
318 .unwrap_or_default()
319 );
320 let tmp_path = path
321 .parent()
322 .unwrap_or_else(|| std::path::Path::new("."))
323 .join(&tmp_name);
324
325 {
326 #[allow(clippy::cast_possible_wrap)] let mut file = std::fs::OpenOptions::new()
328 .write(true)
329 .create(true)
330 .truncate(true)
331 .custom_flags(libc::O_NOFOLLOW)
332 .open(&tmp_path)
333 .map_err(|e| {
334 Error::ExecutionFailed(format!("create temp {}: {}", tmp_path.display(), e))
335 })?;
336 file.write_all(content.as_bytes()).map_err(|e| {
337 Error::ExecutionFailed(format!("write temp {}: {}", tmp_path.display(), e))
338 })?;
339 file.sync_all().map_err(|e| {
340 Error::ExecutionFailed(format!("fsync temp {}: {}", tmp_path.display(), e))
341 })?;
342 }
343
344 std::fs::rename(&tmp_path, path).map_err(|e| {
345 let _ = std::fs::remove_file(&tmp_path);
346 Error::ExecutionFailed(format!(
347 "atomic rename {} -> {}: {}",
348 tmp_path.display(),
349 path.display(),
350 e
351 ))
352 })?;
353
354 if let Ok(dir) = std::fs::File::open(path.parent().unwrap_or_else(|| std::path::Path::new(".")))
355 {
356 let _ = dir.sync_all();
357 }
358
359 Ok(content.len())
360}
361
362fn atomic_append(path: &std::path::Path, content: &str) -> Result<usize> {
363 use std::io::{Read, Write};
364 use std::os::unix::fs::OpenOptionsExt;
365
366 let existing = if path.exists() {
367 #[allow(clippy::cast_possible_wrap)] let mut file = std::fs::OpenOptions::new()
369 .read(true)
370 .custom_flags(libc::O_NOFOLLOW)
371 .open(path)
372 .map_err(|e| {
373 Error::ExecutionFailed(format!("open {} for append: {}", path.display(), e))
374 })?;
375 let mut buf = Vec::new();
376 file.read_to_end(&mut buf).map_err(|e| {
377 Error::ExecutionFailed(format!("read {} for append: {}", path.display(), e))
378 })?;
379 buf
380 } else {
381 Vec::new()
382 };
383
384 let mut combined = existing;
385 combined.extend_from_slice(content.as_bytes());
386
387 let tmp_name = format!(
388 ".{}.tmp",
389 path.file_name()
390 .map(|n| n.to_string_lossy())
391 .unwrap_or_default()
392 );
393 let tmp_path = path
394 .parent()
395 .unwrap_or_else(|| std::path::Path::new("."))
396 .join(&tmp_name);
397
398 {
399 #[allow(clippy::cast_possible_wrap)] let mut file = std::fs::OpenOptions::new()
401 .write(true)
402 .create(true)
403 .truncate(true)
404 .custom_flags(libc::O_NOFOLLOW)
405 .open(&tmp_path)
406 .map_err(|e| {
407 Error::ExecutionFailed(format!("create temp {}: {}", tmp_path.display(), e))
408 })?;
409 file.write_all(&combined).map_err(|e| {
410 Error::ExecutionFailed(format!("write temp {}: {}", tmp_path.display(), e))
411 })?;
412 file.sync_all().map_err(|e| {
413 Error::ExecutionFailed(format!("fsync temp {}: {}", tmp_path.display(), e))
414 })?;
415 }
416
417 std::fs::rename(&tmp_path, path).map_err(|e| {
418 let _ = std::fs::remove_file(&tmp_path);
419 Error::ExecutionFailed(format!(
420 "atomic rename {} -> {}: {}",
421 tmp_path.display(),
422 path.display(),
423 e
424 ))
425 })?;
426
427 if let Ok(dir) = std::fs::File::open(path.parent().unwrap_or_else(|| std::path::Path::new(".")))
428 {
429 let _ = dir.sync_all();
430 }
431
432 Ok(content.len())
433}
434
435#[cfg(test)]
436#[allow(clippy::expect_used)]
437mod tests {
438 use super::*;
439
440 fn test_backup_dir() -> PathBuf {
441 std::env::temp_dir().join("runtimo_fw_test")
442 }
443
444 #[test]
445 fn writes_new_file() {
446 let target = std::env::temp_dir().join("runtimo_fw_new.txt");
447 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
448
449 let result = cap
450 .execute(
451 &serde_json::json!({
452 "path": target.to_str().unwrap(),
453 "content": "hello from runtimo"
454 }),
455 &Context {
456 dry_run: false,
457 job_id: "t1".into(),
458 working_dir: std::env::temp_dir(),
459 },
460 )
461 .expect("Execution failed");
462
463 assert!(result.success);
464 assert_eq!(
465 std::fs::read_to_string(&target).unwrap(),
466 "hello from runtimo"
467 );
468
469 std::fs::remove_file(&target).ok();
470 std::fs::remove_dir_all(test_backup_dir()).ok();
471 }
472
473 #[test]
474 fn dry_run_does_not_write() {
475 let target = std::env::temp_dir().join("runtimo_fw_dry.txt");
476 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
477
478 cap.execute(
479 &serde_json::json!({
480 "path": target.to_str().unwrap(),
481 "content": "should not exist"
482 }),
483 &Context {
484 dry_run: true,
485 job_id: "t2".into(),
486 working_dir: std::env::temp_dir(),
487 },
488 )
489 .expect("Execution failed");
490
491 assert!(!target.exists());
492 std::fs::remove_dir_all(test_backup_dir()).ok();
493 }
494
495 #[test]
496 fn rejects_path_traversal() {
497 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
498 let err = cap
499 .validate(&serde_json::json!({
500 "path": "../../../etc/passwd",
501 "content": "malicious"
502 }))
503 .unwrap_err();
504 assert!(err.to_string().contains("traversal"));
505 std::fs::remove_dir_all(test_backup_dir()).ok();
506 }
507
508 #[test]
509 fn rejects_critical_file() {
510 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
511 let err = cap
512 .execute(
513 &serde_json::json!({
514 "path": "/tmp/.bashrc",
515 "content": "malicious"
516 }),
517 &Context {
518 dry_run: false,
519 job_id: "t3".into(),
520 working_dir: std::env::temp_dir(),
521 },
522 )
523 .unwrap_err();
524 assert!(err.to_string().contains("critical file"));
525 std::fs::remove_dir_all(test_backup_dir()).ok();
526 }
527
528 #[test]
529 fn atomic_write_produces_correct_content() {
530 let target = std::env::temp_dir().join("runtimo_fw_atomic.txt");
531 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
532
533 let result = cap
534 .execute(
535 &serde_json::json!({
536 "path": target.to_str().unwrap(),
537 "content": "atomic content"
538 }),
539 &Context {
540 dry_run: false,
541 job_id: "t4".into(),
542 working_dir: std::env::temp_dir(),
543 },
544 )
545 .expect("Execution failed");
546
547 assert!(result.success);
548 assert_eq!(std::fs::read_to_string(&target).unwrap(), "atomic content");
549 let tmp = target.parent().unwrap().join(".runtimo_fw_atomic.txt.tmp");
550 assert!(!tmp.exists(), "temp file should not remain");
551
552 std::fs::remove_file(&target).ok();
553 std::fs::remove_dir_all(test_backup_dir()).ok();
554 }
555
556 #[test]
557 fn append_mode_works() {
558 let target = std::env::temp_dir().join("runtimo_fw_append.txt");
559 std::fs::write(&target, "initial").ok();
560
561 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
562
563 let result = cap
564 .execute(
565 &serde_json::json!({
566 "path": target.to_str().unwrap(),
567 "content": " appended",
568 "append": true
569 }),
570 &Context {
571 dry_run: false,
572 job_id: "t5".into(),
573 working_dir: std::env::temp_dir(),
574 },
575 )
576 .expect("Execution failed");
577
578 assert!(result.success);
579 assert_eq!(
580 std::fs::read_to_string(&target).unwrap(),
581 "initial appended"
582 );
583
584 std::fs::remove_file(&target).ok();
585 std::fs::remove_dir_all(test_backup_dir()).ok();
586 }
587
588 #[test]
589 fn dry_run_does_not_create_backup() {
590 let target = std::env::temp_dir().join("runtimo_fw_dry_backup.txt");
591 std::fs::write(&target, "existing content").ok();
592
593 let backup_dir = std::env::temp_dir().join("runtimo_fw_dry_backup_test");
595 let _ = std::fs::remove_dir_all(&backup_dir);
596 let cap = FileWrite::new(backup_dir.clone()).expect("Failed to create FileWrite");
597
598 let result = cap
599 .execute(
600 &serde_json::json!({
601 "path": target.to_str().unwrap(),
602 "content": "new content"
603 }),
604 &Context {
605 dry_run: true,
606 job_id: "t6".into(),
607 working_dir: std::env::temp_dir(),
608 },
609 )
610 .expect("Execution failed");
611
612 assert!(result.success);
613 assert!(result.data["dry_run"].as_bool().unwrap());
614 assert_eq!(
615 std::fs::read_to_string(&target).unwrap(),
616 "existing content"
617 );
618 if backup_dir.exists() {
619 let entries: Vec<_> = std::fs::read_dir(&backup_dir)
620 .map(|d| d.filter_map(|e| e.ok()).collect::<Vec<_>>())
621 .unwrap_or_default();
622 assert!(entries.is_empty());
623 }
624
625 std::fs::remove_file(&target).ok();
626 std::fs::remove_dir_all(&backup_dir).ok();
627 }
628
629 #[test]
630 fn telemetry_included_in_output() {
631 let target = std::env::temp_dir().join("runtimo_fw_telemetry.txt");
632 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
633
634 let result = cap
635 .execute(
636 &serde_json::json!({
637 "path": target.to_str().unwrap(),
638 "content": "telemetry test"
639 }),
640 &Context {
641 dry_run: false,
642 job_id: "t7".into(),
643 working_dir: std::env::temp_dir(),
644 },
645 )
646 .expect("Execution failed");
647
648 assert!(result.success);
649 assert!(result.data["telemetry_before"].is_object());
650 assert!(result.data["telemetry_after"].is_object());
651 assert!(result.data["process_before_count"].is_u64());
652 assert!(result.data["process_after_count"].is_u64());
653
654 std::fs::remove_file(&target).ok();
655 std::fs::remove_dir_all(test_backup_dir()).ok();
656 }
657
658 #[test]
659 fn test_check_disk_space_writable_tmp_is_ok() {
660 let result = check_disk_space(&std::env::temp_dir().join("runtimo_df_test.txt"), 100);
661 assert!(result.is_ok(), "df on /tmp should succeed");
662 }
663
664 #[test]
665 fn test_content_too_large_rejected() {
666 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
667 let large_content = "x".repeat(101 * 1024 * 1024); let result = cap.execute(
669 &serde_json::json!({
670 "path": "/tmp/runtimo_large_test.txt",
671 "content": large_content
672 }),
673 &Context {
674 dry_run: false,
675 job_id: "t8".into(),
676 working_dir: std::env::temp_dir(),
677 },
678 );
679 assert!(result.is_err());
680 assert!(result.unwrap_err().to_string().contains("too large"));
681
682 std::fs::remove_dir_all(test_backup_dir()).ok();
683 }
684
685 #[test]
686 fn test_critical_file_ssh_authorized_keys_blocked() {
687 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
688 let result = cap.execute(
689 &serde_json::json!({
690 "path": "/tmp/.ssh/authorized_keys",
691 "content": "ssh-rsa AAA..."
692 }),
693 &Context {
694 dry_run: false,
695 job_id: "t9".into(),
696 working_dir: std::env::temp_dir(),
697 },
698 );
699 assert!(result.is_err());
700 assert!(result.unwrap_err().to_string().contains("critical file"));
701
702 std::fs::remove_dir_all(test_backup_dir()).ok();
703 }
704
705 #[test]
706 fn test_atomic_write_syncs_directory() {
707 let target = std::env::temp_dir().join("runtimo_fw_sync.txt");
708 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
709
710 let result = cap
711 .execute(
712 &serde_json::json!({
713 "path": target.to_str().unwrap(),
714 "content": "sync test"
715 }),
716 &Context {
717 dry_run: false,
718 job_id: "t10".into(),
719 working_dir: std::env::temp_dir(),
720 },
721 )
722 .expect("Execution failed");
723
724 assert!(result.success);
725
726 let tmp = target.parent().unwrap().join(".runtimo_fw_sync.txt.tmp");
727 assert!(
728 !tmp.exists(),
729 "temp file should be cleaned up after atomic rename"
730 );
731
732 std::fs::remove_file(&target).ok();
733 std::fs::remove_dir_all(test_backup_dir()).ok();
734 }
735
736 #[test]
737 fn test_append_exceeds_max_size_rejected() {
738 let target = std::env::temp_dir().join("runtimo_fw_append_overflow.txt");
739 std::fs::write(&target, "x".repeat(99 * 1024 * 1024)).ok(); let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
742 let large_append = "y".repeat(2 * 1024 * 1024); let result = cap.execute(
744 &serde_json::json!({
745 "path": target.to_str().unwrap(),
746 "content": large_append,
747 "append": true
748 }),
749 &Context {
750 dry_run: false,
751 job_id: "t11".into(),
752 working_dir: std::env::temp_dir(),
753 },
754 );
755 assert!(result.is_err());
756 assert!(result.unwrap_err().to_string().contains("exceed"));
757
758 std::fs::remove_file(&target).ok();
759 std::fs::remove_dir_all(test_backup_dir()).ok();
760 }
761}