_hope_core/
executor.rs

1use crate::audit_log::AuditLog;
2use crate::auditor::ProofAuditor;
3use crate::crypto::hash_bytes;
4use crate::proof::{Action, ActionType, IntegrityProof};
5use std::fs::{remove_file, OpenOptions};
6use std::io::{Read, Seek, SeekFrom, Write};
7use std::path::{Path, PathBuf};
8use thiserror::Error;
9use uuid::Uuid; // Required for unique temporary files
10
11#[derive(Debug, Error)]
12pub enum ExecutorError {
13    #[error("Auditor error: {0}")]
14    AuditorError(#[from] crate::auditor::AuditorError),
15
16    #[error("Audit log error: {0}")]
17    AuditLogError(#[from] crate::audit_log::AuditError),
18
19    #[error("Action mismatch: expected hash {expected:?}, found {found:?}")]
20    ActionMismatch { expected: [u8; 32], found: [u8; 32] },
21
22    #[error("Action type mismatch")]
23    TypeMismatch,
24
25    #[error("IO error: {0}")]
26    IoError(#[from] std::io::Error),
27
28    #[error("Integrity violation: written data doesn't match expected hash")]
29    IntegrityViolation,
30
31    #[error("Permission denied: {0}")]
32    PermissionDenied(String),
33
34    #[error("Path traversal attempt detected: {0}")]
35    PathTraversal(String),
36}
37
38pub type Result<T> = std::result::Result<T, ExecutorError>;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ExecutionResult {
42    Success,
43    Denied { reason: String },
44}
45
46pub struct SecureExecutor {
47    auditor: ProofAuditor,
48    #[allow(dead_code)] // TODO v1.5.0: Integrate audit log persistence
49    audit_log: AuditLog,
50    storage_root: PathBuf, // Security jail root - all paths confined within this directory
51}
52
53impl SecureExecutor {
54    /// Create a new secure executor with a confined storage root
55    pub fn new(auditor: ProofAuditor, audit_log: AuditLog, storage_root: PathBuf) -> Result<Self> {
56        // Ensure root exists and is canonicalized (absolute path)
57        if !storage_root.exists() {
58            std::fs::create_dir_all(&storage_root)?;
59        }
60        let canonical_root = storage_root.canonicalize()?;
61
62        Ok(SecureExecutor {
63            auditor,
64            audit_log,
65            storage_root: canonical_root,
66        })
67    }
68
69    /// Security Critical: Sanitize and jail the path
70    /// Prevents directory traversal (../) and symlink attacks escaping the root.
71    fn sanitize_path(&self, unsafe_path_str: &str) -> Result<PathBuf> {
72        let path = Path::new(unsafe_path_str);
73
74        // 1. Block absolute paths (force relative to root)
75        if path.is_absolute() {
76            return Err(ExecutorError::PermissionDenied(
77                "Absolute paths are not allowed. Use paths relative to storage root.".into(),
78            ));
79        }
80
81        // 2. Construct full path candidate
82        let full_candidate = self.storage_root.join(path);
83
84        // 3. Canonicalize the PARENT directory
85        // We cannot canonicalize the file itself yet, as it might not exist (for writes).
86        let parent = full_candidate
87            .parent()
88            .ok_or_else(|| ExecutorError::PermissionDenied("Invalid path structure".into()))?;
89
90        if !parent.exists() {
91            return Err(ExecutorError::PermissionDenied(
92                "Target directory does not exist".into(),
93            ));
94        }
95
96        // This resolves all symlinks in the directory tree
97        let canonical_parent = parent.canonicalize()?;
98
99        // 4. JAIL CHECK: Ensure the resolved parent is still inside storage_root
100        if !canonical_parent.starts_with(&self.storage_root) {
101            return Err(ExecutorError::PathTraversal(format!(
102                "Resolved path {:?} escapes storage root",
103                canonical_parent
104            )));
105        }
106
107        // 5. Re-attach the filename (which we know is safe if parent is safe and it's just a filename)
108        let filename = full_candidate
109            .file_name()
110            .ok_or_else(|| ExecutorError::PermissionDenied("Missing filename".into()))?;
111
112        Ok(canonical_parent.join(filename))
113    }
114
115    pub fn execute_with_proof(
116        &mut self,
117        action: &Action,
118        proof: &IntegrityProof,
119    ) -> Result<ExecutionResult> {
120        // ... (Verification logic unchanged: Proof check, Hash check, Type check) ...
121        self.auditor.verify_proof(proof)?;
122
123        if proof.action_hash != action.hash() {
124            // ... Log & Error ...
125            return Err(ExecutorError::ActionMismatch {
126                expected: action.hash(),
127                found: proof.action_hash,
128            });
129        }
130
131        // Execute action
132        let exec_result = match &action.action_type {
133            ActionType::Write => self.execute_write_atomic(action)?, // <--- NEW ATOMIC WRITE
134            ActionType::Delete => self.execute_delete(action)?,
135            ActionType::Read => self.execute_read(action)?,
136            _ => ExecutionResult::Denied {
137                reason: "Not implemented".into(),
138            },
139        };
140
141        // ... (Logging logic unchanged) ...
142
143        Ok(exec_result)
144    }
145
146    /// ATOMIC WRITE with Handle-Based Verification
147    fn execute_write_atomic(&self, action: &Action) -> Result<ExecutionResult> {
148        // 1. Sanitize Path (The Jail Check)
149        let safe_target_path = self.sanitize_path(&action.target)?;
150
151        let content = action
152            .payload
153            .as_ref()
154            .ok_or_else(|| ExecutorError::PermissionDenied("No content".into()))?;
155
156        // 2. Create Temporary File (in the same directory to allow atomic rename)
157        // Using UUID to prevent collision
158        let temp_filename = format!(
159            ".tmp_{}_{}",
160            Uuid::new_v4(),
161            safe_target_path.file_name().unwrap().to_string_lossy()
162        );
163        let temp_path = safe_target_path.parent().unwrap().join(temp_filename);
164
165        {
166            // 3. Write to Temp File
167            let mut file = OpenOptions::new()
168                .write(true)
169                .create_new(true) // Fail if temp file somehow exists
170                .open(&temp_path)?;
171
172            file.write_all(content)?;
173            file.sync_all()?; // Force flush to disk
174
175            // 4. HANDLE-BASED VERIFICATION (Fixes TOCTOU)
176            // We do NOT close the file. We do NOT open by path.
177            // We seek back to start on the SAME file descriptor.
178            file.seek(SeekFrom::Start(0))?;
179
180            let mut written_content = Vec::new();
181            file.read_to_end(&mut written_content)?;
182
183            let written_hash = hash_bytes(&written_content);
184            let expected_hash = hash_bytes(content);
185
186            if written_hash != expected_hash {
187                // Cleanup and abort
188                drop(file); // Close handle
189                let _ = std::fs::remove_file(&temp_path); // Try to clean up
190                return Err(ExecutorError::IntegrityViolation);
191            }
192        } // File handle closes here automatically
193
194        // 5. ATOMIC SWAP (The Commit)
195        // POSIX guarantees this is atomic. It's either the old file or the new file.
196        // No partial writes visible to the world.
197        std::fs::rename(&temp_path, &safe_target_path)?;
198
199        Ok(ExecutionResult::Success)
200    }
201
202    fn execute_read(&self, action: &Action) -> Result<ExecutionResult> {
203        // Path sanitization is critical for reads too!
204        let safe_path = self.sanitize_path(&action.target)?;
205
206        if !safe_path.exists() {
207            return Ok(ExecutionResult::Denied {
208                reason: "File not found".into(),
209            });
210        }
211        let _content = std::fs::read(safe_path)?;
212        Ok(ExecutionResult::Success)
213    }
214
215    fn execute_delete(&self, action: &Action) -> Result<ExecutionResult> {
216        let safe_path = self.sanitize_path(&action.target)?;
217
218        if !safe_path.exists() {
219            return Ok(ExecutionResult::Denied {
220                reason: "File not found".into(),
221            });
222        }
223        remove_file(safe_path)?;
224        Ok(ExecutionResult::Success)
225    }
226}