services_hardening_process.rs (9564B)
1 #![forbid(unsafe_code)] 2 #![cfg(any(target_os = "linux", target_os = "macos"))] 3 4 use std::io::Write as _; 5 use std::os::unix::fs::PermissionsExt as _; 6 use std::path::PathBuf; 7 use std::process::{Command, Output, Stdio}; 8 9 use sha2::{Digest, Sha256}; 10 11 const CONFIG_EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); 12 13 struct ProcessFixture { 14 root: tempfile::TempDir, 15 config: PathBuf, 16 } 17 18 impl ProcessFixture { 19 fn new() -> Self { 20 let root = tempfile::tempdir().expect("repo-local root"); 21 std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)) 22 .expect("secure repo-local root"); 23 let config = root.path().join("myc.toml"); 24 Self { root, config } 25 } 26 27 fn command(&self, command: &[&str]) -> Command { 28 let mut process = Command::new(env!("CARGO_BIN_EXE_myc")); 29 process 30 .args(["--profile", "repo-local", "--instance", "primary"]) 31 .arg("--repo-local-root") 32 .arg(self.root.path()) 33 .arg("--config") 34 .arg(&self.config) 35 .args(command); 36 process 37 } 38 39 fn run(&self, command: &[&str]) -> Output { 40 self.command(command).output().expect("Myc process") 41 } 42 43 fn run_with_stdin(&self, command: &[&str], bytes: &[u8]) -> Output { 44 let mut child = self 45 .command(command) 46 .stdin(Stdio::piped()) 47 .stdout(Stdio::piped()) 48 .stderr(Stdio::piped()) 49 .spawn() 50 .expect("Myc process"); 51 child 52 .stdin 53 .take() 54 .expect("stdin") 55 .write_all(bytes) 56 .expect("write stdin"); 57 child.wait_with_output().expect("process result") 58 } 59 60 fn state_directory(&self) -> PathBuf { 61 self.root.path().join("data/services/myc/primary") 62 } 63 } 64 65 fn repo_local_config() -> String { 66 CONFIG_EXAMPLE 67 .replace("wss://relay-primary.example.test/", "ws://127.0.0.1:9/") 68 .replace("wss://relay-secondary.example.test/", "ws://127.0.0.1:10/") 69 .replace("connect_deadline_ms = 10000", "connect_deadline_ms = 100") 70 } 71 72 fn stderr_code(output: &Output) -> String { 73 let value: serde_json::Value = serde_json::from_slice(&output.stderr).expect("diagnostic JSON"); 74 value["code"].as_str().expect("diagnostic code").to_owned() 75 } 76 77 fn assert_success(output: &Output) { 78 assert_eq!(output.status.code(), Some(0), "stderr: {:?}", output.stderr); 79 assert_eq!(stderr_code(output), "success"); 80 } 81 82 #[test] 83 fn binary_executes_config_state_backup_restore_and_doctor_boundaries() { 84 let fixture = ProcessFixture::new(); 85 let config = repo_local_config(); 86 87 let initialized = fixture.run_with_stdin(&["config", "init"], config.as_bytes()); 88 assert_success(&initialized); 89 assert_eq!(initialized.stdout, b"config_initialized\n"); 90 assert_eq!( 91 std::fs::metadata(&fixture.config) 92 .expect("config metadata") 93 .permissions() 94 .mode() 95 & 0o777, 96 0o600 97 ); 98 std::fs::create_dir_all(fixture.state_directory()).expect("provisioned state directory"); 99 std::fs::set_permissions( 100 fixture.state_directory(), 101 std::fs::Permissions::from_mode(0o700), 102 ) 103 .expect("secure state directory"); 104 105 for (command, result) in [ 106 (&["config", "validate"][..], b"config_valid\n".as_slice()), 107 (&["state", "init"][..], b"state_initialized\n".as_slice()), 108 (&["state", "verify"][..], b"state_verified\n".as_slice()), 109 (&["state", "migrate"][..], b"state_migrated\n".as_slice()), 110 ] { 111 let output = fixture.run(command); 112 assert_eq!( 113 output.status.code(), 114 Some(0), 115 "command {command:?}, stderr: {:?}", 116 output.stderr 117 ); 118 assert_success(&output); 119 assert_eq!(output.stdout, result); 120 } 121 122 let shown = fixture.run(&["--output", "json", "config", "show"]); 123 assert_success(&shown); 124 let shown_value: serde_json::Value = 125 serde_json::from_slice(&shown.stdout).expect("effective configuration JSON"); 126 assert_eq!(shown_value["schema"], "radroots.myc.effective-config"); 127 128 let schema = fixture.run(&["config", "schema"]); 129 assert_success(&schema); 130 let schema_value: serde_json::Value = 131 serde_json::from_slice(&schema.stdout).expect("configuration schema JSON"); 132 assert_eq!( 133 schema_value["$id"], 134 "https://github.com/radrootslabs/myc/contracts/services_hardening/config.v1.schema.json" 135 ); 136 137 let status = fixture.run(&["state", "status"]); 138 assert_success(&status); 139 let status_value: serde_json::Value = 140 serde_json::from_slice(&status.stdout).expect("state status JSON"); 141 assert_eq!(status_value["generation"], 1); 142 assert_eq!(status_value["schema_version"], 12); 143 assert_eq!(status_value["integrity"], "verified"); 144 145 let service_status = fixture.run(&["status"]); 146 assert_success(&service_status); 147 let service_status_value: serde_json::Value = 148 serde_json::from_slice(&service_status.stdout).expect("service status JSON"); 149 assert_eq!( 150 service_status_value["myc"]["connection_counts"], 151 serde_json::json!({ 152 "active": 0, 153 "denied": 0, 154 "expired": 0, 155 "pending": 0, 156 }) 157 ); 158 assert_eq!( 159 service_status_value["myc"]["outbox"], 160 serde_json::json!({"pending": 0, "unknown": 0}) 161 ); 162 163 let bundle = fixture.root.path().join("backup"); 164 let backup = fixture 165 .command(&["state", "backup", "--operation-id", "process-backup-01"]) 166 .arg("--target") 167 .arg(&bundle) 168 .args(["--expected-generation", "1", "--confirm"]) 169 .output() 170 .expect("backup process"); 171 assert_success(&backup); 172 assert!(!backup.stdout.ends_with(b"\n")); 173 let manifest_bytes = backup.stdout.as_slice(); 174 let manifest: serde_json::Value = 175 serde_json::from_slice(manifest_bytes).expect("backup manifest"); 176 assert_eq!(manifest["service"], "myc"); 177 assert_eq!(manifest["instance"], "primary"); 178 let digest = hex::encode(Sha256::digest(manifest_bytes)); 179 let manifest_path = fixture.root.path().join("manifest.json"); 180 std::fs::write(&manifest_path, manifest_bytes).expect("manifest file"); 181 std::fs::set_permissions(&manifest_path, std::fs::Permissions::from_mode(0o600)) 182 .expect("secure manifest"); 183 184 let live_database = fixture.state_directory().join("state.sqlite"); 185 let mut corrupted = std::fs::OpenOptions::new() 186 .write(true) 187 .truncate(true) 188 .open(&live_database) 189 .expect("open live database for corruption fixture"); 190 corrupted 191 .write_all(b"corrupt-live-database") 192 .and_then(|()| corrupted.sync_all()) 193 .expect("persist corrupt live database fixture"); 194 drop(corrupted); 195 196 let restored = fixture 197 .command(&["state", "restore"]) 198 .arg("--manifest") 199 .arg(&manifest_path) 200 .arg("--manifest-sha256") 201 .arg(&digest) 202 .arg("--bundle") 203 .arg(&bundle) 204 .args(["--maximum-state-bytes", "16777216", "--confirm"]) 205 .output() 206 .expect("restore process"); 207 assert_success(&restored); 208 assert_eq!(restored.stdout, b"state_restore_finalized\n"); 209 210 let verified_after_restore = fixture.run(&["state", "verify"]); 211 assert_success(&verified_after_restore); 212 for artifact in [ 213 "state.restore-marker.v1", 214 "state.restore-marker.v1.next", 215 "state.restore-staged.sqlite", 216 "state.restore-backup.sqlite", 217 ] { 218 assert!(!fixture.state_directory().join(artifact).exists()); 219 } 220 221 let doctor = fixture.run(&["doctor"]); 222 assert_eq!(doctor.status.code(), Some(6)); 223 assert_eq!(stderr_code(&doctor), "doctor_required_check_failed"); 224 let doctor_value: serde_json::Value = 225 serde_json::from_slice(&doctor.stdout).expect("doctor report JSON"); 226 assert_eq!(doctor_value["checks"].as_array().expect("checks").len(), 13); 227 assert_eq!(doctor_value["checks"][12]["status"], "skipped"); 228 229 let run = fixture.run(&["run"]); 230 assert_eq!(run.status.code(), Some(3)); 231 assert!(run.stdout.is_empty()); 232 assert_eq!(stderr_code(&run), "service_or_dependency_unavailable"); 233 } 234 235 #[test] 236 fn binary_rejects_insecure_or_oversized_selected_documents_without_disclosure() { 237 let fixture = ProcessFixture::new(); 238 std::fs::write(&fixture.config, repo_local_config()).expect("config"); 239 std::fs::set_permissions(&fixture.config, std::fs::Permissions::from_mode(0o620)) 240 .expect("insecure config mode"); 241 242 let insecure = fixture.run(&["config", "validate"]); 243 assert_eq!(insecure.status.code(), Some(2)); 244 assert!(insecure.stdout.is_empty()); 245 assert_eq!(stderr_code(&insecure), "input_or_configuration"); 246 let rendered = String::from_utf8(insecure.stderr).expect("diagnostic text"); 247 assert!(!rendered.contains(fixture.config.to_str().expect("UTF-8 path"))); 248 249 std::fs::set_permissions(&fixture.config, std::fs::Permissions::from_mode(0o600)) 250 .expect("secure config mode"); 251 std::fs::write( 252 &fixture.config, 253 vec![b'x'; myc::MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES + 1], 254 ) 255 .expect("oversized config"); 256 let oversized = fixture.run(&["config", "validate"]); 257 assert_eq!(oversized.status.code(), Some(2)); 258 assert!(oversized.stdout.is_empty()); 259 assert_eq!(stderr_code(&oversized), "input_or_configuration"); 260 }