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