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