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