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 content to a file. Creates automatic backups of existing files for undo support. Supports append mode."
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 let output = std::process::Command::new("df")
248 .arg("-B1")
249 .arg(parent)
250 .output()
251 .map_err(|e| format!("df command failed: {}", e))?;
252
253 let stdout = String::from_utf8_lossy(&output.stdout);
254 if let Some(line) = stdout.lines().nth(1) {
255 let parts: Vec<&str> = line.split_whitespace().collect();
256 if parts.len() >= 4 {
257 if let Ok(available) = parts[3].parse::<u64>() {
258 let required = content_size as u64 + MIN_FREE_DISK_BYTES;
259 if available < required {
260 return Err(format!(
261 "insufficient disk space: {} bytes available, {} bytes required",
262 available, required
263 ));
264 }
265 return Ok(());
266 }
267 }
268 }
269 Ok(())
270}
271
272fn atomic_write(path: &std::path::Path, content: &str) -> Result<usize> {
273 use std::io::Write;
274
275 let tmp_name = format!(
276 ".{}.tmp",
277 path.file_name()
278 .map(|n| n.to_string_lossy())
279 .unwrap_or_default()
280 );
281 let tmp_path = path.parent().unwrap_or(std::path::Path::new(".")).join(&tmp_name);
282
283 {
284 let mut file = std::fs::File::create(&tmp_path).map_err(|e| {
285 Error::ExecutionFailed(format!("create temp {}: {}", tmp_path.display(), e))
286 })?;
287 file.write_all(content.as_bytes()).map_err(|e| {
288 Error::ExecutionFailed(format!("write temp {}: {}", tmp_path.display(), e))
289 })?;
290 file.sync_all().map_err(|e| {
291 Error::ExecutionFailed(format!("fsync temp {}: {}", tmp_path.display(), e))
292 })?;
293 }
294
295 std::fs::rename(&tmp_path, path).map_err(|e| {
296 let _ = std::fs::remove_file(&tmp_path);
297 Error::ExecutionFailed(format!("atomic rename {} -> {}: {}", tmp_path.display(), path.display(), e))
298 })?;
299
300 if let Ok(dir) = std::fs::File::open(path.parent().unwrap_or(std::path::Path::new("."))) {
301 let _ = dir.sync_all();
302 }
303
304 Ok(content.len())
305}
306
307fn atomic_append(path: &std::path::Path, content: &str) -> Result<usize> {
308 use std::io::{Read, Write};
309
310 let existing = if path.exists() {
311 let mut file = std::fs::File::open(path).map_err(|e| {
312 Error::ExecutionFailed(format!("open {} for append: {}", path.display(), e))
313 })?;
314 let mut buf = Vec::new();
315 file.read_to_end(&mut buf).map_err(|e| {
316 Error::ExecutionFailed(format!("read {} for append: {}", path.display(), e))
317 })?;
318 buf
319 } else {
320 Vec::new()
321 };
322
323 let mut combined = existing;
324 combined.extend_from_slice(content.as_bytes());
325
326 let tmp_name = format!(
327 ".{}.tmp",
328 path.file_name()
329 .map(|n| n.to_string_lossy())
330 .unwrap_or_default()
331 );
332 let tmp_path = path.parent().unwrap_or(std::path::Path::new(".")).join(&tmp_name);
333
334 {
335 let mut file = std::fs::File::create(&tmp_path).map_err(|e| {
336 Error::ExecutionFailed(format!("create temp {}: {}", tmp_path.display(), e))
337 })?;
338 file.write_all(&combined).map_err(|e| {
339 Error::ExecutionFailed(format!("write temp {}: {}", tmp_path.display(), e))
340 })?;
341 file.sync_all().map_err(|e| {
342 Error::ExecutionFailed(format!("fsync temp {}: {}", tmp_path.display(), e))
343 })?;
344 }
345
346 std::fs::rename(&tmp_path, path).map_err(|e| {
347 let _ = std::fs::remove_file(&tmp_path);
348 Error::ExecutionFailed(format!("atomic rename {} -> {}: {}", tmp_path.display(), path.display(), e))
349 })?;
350
351 if let Ok(dir) = std::fs::File::open(path.parent().unwrap_or(std::path::Path::new("."))) {
352 let _ = dir.sync_all();
353 }
354
355 Ok(content.len())
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 fn test_backup_dir() -> PathBuf {
363 std::env::temp_dir().join("runtimo_fw_test")
364 }
365
366 #[test]
367 fn writes_new_file() {
368 let target = std::env::temp_dir().join("runtimo_fw_new.txt");
369 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
370
371 let result = cap
372 .execute(
373 &serde_json::json!({
374 "path": target.to_str().unwrap(),
375 "content": "hello from runtimo"
376 }),
377 &Context {
378 dry_run: false,
379 job_id: "t1".into(),
380 working_dir: std::env::temp_dir(),
381 },
382 )
383 .expect("Execution failed");
384
385 assert!(result.success);
386 assert_eq!(
387 std::fs::read_to_string(&target).unwrap(),
388 "hello from runtimo"
389 );
390
391 std::fs::remove_file(&target).ok();
392 std::fs::remove_dir_all(test_backup_dir()).ok();
393 }
394
395 #[test]
396 fn dry_run_does_not_write() {
397 let target = std::env::temp_dir().join("runtimo_fw_dry.txt");
398 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
399
400 cap.execute(
401 &serde_json::json!({
402 "path": target.to_str().unwrap(),
403 "content": "should not exist"
404 }),
405 &Context {
406 dry_run: true,
407 job_id: "t2".into(),
408 working_dir: std::env::temp_dir(),
409 },
410 )
411 .expect("Execution failed");
412
413 assert!(!target.exists());
414 std::fs::remove_dir_all(test_backup_dir()).ok();
415 }
416
417 #[test]
418 fn rejects_path_traversal() {
419 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
420 let err = cap
421 .validate(&serde_json::json!({
422 "path": "../../../etc/passwd",
423 "content": "malicious"
424 }))
425 .unwrap_err();
426 assert!(err.to_string().contains("traversal"));
427 std::fs::remove_dir_all(test_backup_dir()).ok();
428 }
429
430 #[test]
431 fn rejects_critical_file() {
432 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
433 let err = cap
434 .execute(
435 &serde_json::json!({
436 "path": "/tmp/.bashrc",
437 "content": "malicious"
438 }),
439 &Context {
440 dry_run: false,
441 job_id: "t3".into(),
442 working_dir: std::env::temp_dir(),
443 },
444 )
445 .unwrap_err();
446 assert!(err.to_string().contains("critical file"));
447 std::fs::remove_dir_all(test_backup_dir()).ok();
448 }
449
450 #[test]
451 fn atomic_write_produces_correct_content() {
452 let target = std::env::temp_dir().join("runtimo_fw_atomic.txt");
453 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
454
455 let result = cap
456 .execute(
457 &serde_json::json!({
458 "path": target.to_str().unwrap(),
459 "content": "atomic content"
460 }),
461 &Context {
462 dry_run: false,
463 job_id: "t4".into(),
464 working_dir: std::env::temp_dir(),
465 },
466 )
467 .expect("Execution failed");
468
469 assert!(result.success);
470 assert_eq!(std::fs::read_to_string(&target).unwrap(), "atomic content");
471 let tmp = target.parent().unwrap().join(".runtimo_fw_atomic.txt.tmp");
472 assert!(!tmp.exists(), "temp file should not remain");
473
474 std::fs::remove_file(&target).ok();
475 std::fs::remove_dir_all(test_backup_dir()).ok();
476 }
477
478 #[test]
479 fn append_mode_works() {
480 let target = std::env::temp_dir().join("runtimo_fw_append.txt");
481 std::fs::write(&target, "initial").ok();
482
483 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
484
485 let result = cap
486 .execute(
487 &serde_json::json!({
488 "path": target.to_str().unwrap(),
489 "content": " appended",
490 "append": true
491 }),
492 &Context {
493 dry_run: false,
494 job_id: "t5".into(),
495 working_dir: std::env::temp_dir(),
496 },
497 )
498 .expect("Execution failed");
499
500 assert!(result.success);
501 assert_eq!(
502 std::fs::read_to_string(&target).unwrap(),
503 "initial appended"
504 );
505
506 std::fs::remove_file(&target).ok();
507 std::fs::remove_dir_all(test_backup_dir()).ok();
508 }
509
510 #[test]
511 fn dry_run_does_not_create_backup() {
512 let target = std::env::temp_dir().join("runtimo_fw_dry_backup.txt");
513 std::fs::write(&target, "existing content").ok();
514
515 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
516
517 let result = cap
518 .execute(
519 &serde_json::json!({
520 "path": target.to_str().unwrap(),
521 "content": "new content"
522 }),
523 &Context {
524 dry_run: true,
525 job_id: "t6".into(),
526 working_dir: std::env::temp_dir(),
527 },
528 )
529 .expect("Execution failed");
530
531 assert!(result.success);
532 assert!(result.data["dry_run"].as_bool().unwrap());
533 assert_eq!(
534 std::fs::read_to_string(&target).unwrap(),
535 "existing content"
536 );
537 let backup_dir = test_backup_dir();
538 if backup_dir.exists() {
539 let entries: Vec<_> = std::fs::read_dir(&backup_dir)
540 .map(|d| d.filter_map(|e| e.ok()).collect::<Vec<_>>())
541 .unwrap_or_default();
542 assert!(entries.is_empty());
543 }
544
545 std::fs::remove_file(&target).ok();
546 std::fs::remove_dir_all(test_backup_dir()).ok();
547 }
548
549 #[test]
550 fn telemetry_included_in_output() {
551 let target = std::env::temp_dir().join("runtimo_fw_telemetry.txt");
552 let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
553
554 let result = cap
555 .execute(
556 &serde_json::json!({
557 "path": target.to_str().unwrap(),
558 "content": "telemetry test"
559 }),
560 &Context {
561 dry_run: false,
562 job_id: "t7".into(),
563 working_dir: std::env::temp_dir(),
564 },
565 )
566 .expect("Execution failed");
567
568 assert!(result.success);
569 assert!(result.data["telemetry_before"].is_object());
570 assert!(result.data["telemetry_after"].is_object());
571 assert!(result.data["process_before_count"].is_u64());
572 assert!(result.data["process_after_count"].is_u64());
573
574 std::fs::remove_file(&target).ok();
575 std::fs::remove_dir_all(test_backup_dir()).ok();
576 }
577}