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