|
| 1 | +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 2 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 3 | +// (MPL-2.0 is automatic legal fallback until PMPL is formally recognised) |
| 4 | + |
| 5 | +//! GAP group-theory coprocessor — subprocess to `gap`. |
| 6 | +//! |
| 7 | +//! Provides permutation group computations: order, abelianness test, and |
| 8 | +//! symmetric group order. Trust tier `ExternalSubprocess` (Tier 1). |
| 9 | +//! |
| 10 | +//! Permutation generators are validated against cycle-notation characters |
| 11 | +//! only: digits, `(`, `)`, `,`, and space. No letters or shell |
| 12 | +//! metacharacters can appear in a permutation. GAP is invoked with `-q` |
| 13 | +//! (quiet, no banner) and a 30-second timeout. |
| 14 | +
|
| 15 | +use anyhow::{anyhow, Result}; |
| 16 | +use async_trait::async_trait; |
| 17 | +use std::process::Stdio; |
| 18 | +use std::time::Duration; |
| 19 | +use tokio::io::AsyncWriteExt; |
| 20 | +use tokio::process::Command; |
| 21 | + |
| 22 | +use super::trust::CoprocessorTrustTier; |
| 23 | +use super::types::{ |
| 24 | + CoprocessorCapabilities, CoprocessorHealth, CoprocessorKind, CoprocessorOp, |
| 25 | + CoprocessorOutcome, |
| 26 | +}; |
| 27 | +use super::Coprocessor; |
| 28 | + |
| 29 | +const TIMEOUT: Duration = Duration::from_secs(30); |
| 30 | + |
| 31 | +pub struct GapBackend { |
| 32 | + capabilities: CoprocessorCapabilities, |
| 33 | + health: CoprocessorHealth, |
| 34 | +} |
| 35 | + |
| 36 | +impl GapBackend { |
| 37 | + pub fn new() -> Self { |
| 38 | + let health = if which("gap") { |
| 39 | + CoprocessorHealth::Healthy |
| 40 | + } else { |
| 41 | + CoprocessorHealth::Unhealthy |
| 42 | + }; |
| 43 | + GapBackend { |
| 44 | + capabilities: CoprocessorCapabilities { |
| 45 | + supported_ops: vec![ |
| 46 | + "GapGroupOrder".into(), |
| 47 | + "GapIsAbelian".into(), |
| 48 | + "GapSymmetricGroupOrder".into(), |
| 49 | + ], |
| 50 | + typical_latency_us: 200_000, |
| 51 | + deterministic: true, |
| 52 | + }, |
| 53 | + health, |
| 54 | + } |
| 55 | + } |
| 56 | +} |
| 57 | + |
| 58 | +fn which(name: &str) -> bool { |
| 59 | + if let Ok(path) = std::env::var("PATH") { |
| 60 | + for dir in path.split(':') { |
| 61 | + let candidate = format!("{dir}/{name}"); |
| 62 | + if std::path::Path::new(&candidate).is_file() { |
| 63 | + return true; |
| 64 | + } |
| 65 | + } |
| 66 | + } |
| 67 | + false |
| 68 | +} |
| 69 | + |
| 70 | +/// Accept only characters that appear in cycle notation: digits, `()`, `,`, |
| 71 | +/// and ASCII space. No letters, no shell metacharacters. |
| 72 | +fn is_safe_permutation(s: &str) -> bool { |
| 73 | + !s.is_empty() |
| 74 | + && s.chars().all(|c| c.is_ascii_digit() || "(,) ".contains(c)) |
| 75 | +} |
| 76 | + |
| 77 | +/// Render a list of permutations as a comma-separated GAP argument list. |
| 78 | +/// Each entry is already validated to contain only cycle-notation chars, so |
| 79 | +/// direct interpolation is safe. |
| 80 | +fn gen_list(generators: &[String]) -> String { |
| 81 | + generators.join(",") |
| 82 | +} |
| 83 | + |
| 84 | +impl Default for GapBackend { |
| 85 | + fn default() -> Self { |
| 86 | + Self::new() |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +#[async_trait] |
| 91 | +impl Coprocessor for GapBackend { |
| 92 | + fn kind(&self) -> CoprocessorKind { |
| 93 | + CoprocessorKind::Gap |
| 94 | + } |
| 95 | + fn capabilities(&self) -> &CoprocessorCapabilities { |
| 96 | + &self.capabilities |
| 97 | + } |
| 98 | + fn health(&self) -> CoprocessorHealth { |
| 99 | + self.health |
| 100 | + } |
| 101 | + fn trust_tier(&self) -> CoprocessorTrustTier { |
| 102 | + CoprocessorTrustTier::ExternalSubprocess |
| 103 | + } |
| 104 | + |
| 105 | + async fn dispatch(&self, op: CoprocessorOp) -> Result<CoprocessorOutcome> { |
| 106 | + if self.health == CoprocessorHealth::Unhealthy { |
| 107 | + return Ok(CoprocessorOutcome::Failure( |
| 108 | + "Gap: gap not found on PATH".into(), |
| 109 | + )); |
| 110 | + } |
| 111 | + match op { |
| 112 | + CoprocessorOp::GapGroupOrder { generators } => { |
| 113 | + if generators.is_empty() { |
| 114 | + return Ok(CoprocessorOutcome::Failure( |
| 115 | + "GapGroupOrder: generators list is empty".into(), |
| 116 | + )); |
| 117 | + } |
| 118 | + if let Some(bad) = generators.iter().find(|g| !is_safe_permutation(g)) { |
| 119 | + return Ok(CoprocessorOutcome::Failure(format!( |
| 120 | + "GapGroupOrder: unsafe permutation '{bad}'" |
| 121 | + ))); |
| 122 | + } |
| 123 | + let script = format!( |
| 124 | + "g := Group({gens});\nPrint(Order(g), \"\\n\");\nQUIT;\n", |
| 125 | + gens = gen_list(&generators) |
| 126 | + ); |
| 127 | + let out = run_gap(&script).await?; |
| 128 | + Ok(CoprocessorOutcome::BigInt(out.trim().to_string())) |
| 129 | + } |
| 130 | + CoprocessorOp::GapIsAbelian { generators } => { |
| 131 | + if generators.is_empty() { |
| 132 | + return Ok(CoprocessorOutcome::Failure( |
| 133 | + "GapIsAbelian: generators list is empty".into(), |
| 134 | + )); |
| 135 | + } |
| 136 | + if let Some(bad) = generators.iter().find(|g| !is_safe_permutation(g)) { |
| 137 | + return Ok(CoprocessorOutcome::Failure(format!( |
| 138 | + "GapIsAbelian: unsafe permutation '{bad}'" |
| 139 | + ))); |
| 140 | + } |
| 141 | + let script = format!( |
| 142 | + "g := Group({gens});\nPrint(IsAbelian(g), \"\\n\");\nQUIT;\n", |
| 143 | + gens = gen_list(&generators) |
| 144 | + ); |
| 145 | + let out = run_gap(&script).await?; |
| 146 | + let trimmed = out.trim(); |
| 147 | + match trimmed { |
| 148 | + "true" => Ok(CoprocessorOutcome::Boolean(true)), |
| 149 | + "false" => Ok(CoprocessorOutcome::Boolean(false)), |
| 150 | + other => Ok(CoprocessorOutcome::Failure(format!( |
| 151 | + "GapIsAbelian: unexpected output '{other}'" |
| 152 | + ))), |
| 153 | + } |
| 154 | + } |
| 155 | + CoprocessorOp::GapSymmetricGroupOrder { n } => { |
| 156 | + let script = format!( |
| 157 | + "Print(Order(SymmetricGroup({n})), \"\\n\");\nQUIT;\n" |
| 158 | + ); |
| 159 | + let out = run_gap(&script).await?; |
| 160 | + Ok(CoprocessorOutcome::BigInt(out.trim().to_string())) |
| 161 | + } |
| 162 | + other => Ok(CoprocessorOutcome::Failure(format!( |
| 163 | + "Gap backend does not support {:?}", |
| 164 | + std::mem::discriminant(&other) |
| 165 | + ))), |
| 166 | + } |
| 167 | + } |
| 168 | +} |
| 169 | + |
| 170 | +async fn run_gap(script: &str) -> Result<String> { |
| 171 | + let mut child = Command::new("gap") |
| 172 | + .arg("-q") |
| 173 | + .stdin(Stdio::piped()) |
| 174 | + .stdout(Stdio::piped()) |
| 175 | + .stderr(Stdio::piped()) |
| 176 | + .kill_on_drop(true) |
| 177 | + .spawn() |
| 178 | + .map_err(|e| anyhow!("gap spawn: {e}"))?; |
| 179 | + |
| 180 | + if let Some(mut stdin) = child.stdin.take() { |
| 181 | + stdin |
| 182 | + .write_all(script.as_bytes()) |
| 183 | + .await |
| 184 | + .map_err(|e| anyhow!("gap stdin write: {e}"))?; |
| 185 | + } |
| 186 | + |
| 187 | + let out = tokio::time::timeout(TIMEOUT, child.wait_with_output()) |
| 188 | + .await |
| 189 | + .map_err(|_| anyhow!("gap timed out after 30s"))? |
| 190 | + .map_err(|e| anyhow!("gap wait: {e}"))?; |
| 191 | + |
| 192 | + if !out.status.success() { |
| 193 | + let stderr = String::from_utf8_lossy(&out.stderr); |
| 194 | + return Err(anyhow!( |
| 195 | + "gap exit {}: {}", |
| 196 | + out.status.code().unwrap_or(-1), |
| 197 | + stderr.lines().take(3).collect::<Vec<_>>().join(" | ") |
| 198 | + )); |
| 199 | + } |
| 200 | + Ok(String::from_utf8_lossy(&out.stdout).to_string()) |
| 201 | +} |
| 202 | + |
| 203 | +#[cfg(test)] |
| 204 | +mod tests { |
| 205 | + use super::*; |
| 206 | + |
| 207 | + #[test] |
| 208 | + fn safe_permutation_validation() { |
| 209 | + assert!(is_safe_permutation("(1,2,3)")); |
| 210 | + assert!(is_safe_permutation("(1,2)(3,4)")); |
| 211 | + assert!(is_safe_permutation("(12,34)")); |
| 212 | + assert!(!is_safe_permutation("")); |
| 213 | + assert!(!is_safe_permutation("(1,2); QUIT")); |
| 214 | + assert!(!is_safe_permutation("(a,b)")); |
| 215 | + assert!(!is_safe_permutation("(1,2)\n(3,4)")); |
| 216 | + } |
| 217 | + |
| 218 | + #[tokio::test] |
| 219 | + async fn unhealthy_returns_failure_without_spawn() { |
| 220 | + let backend = GapBackend { |
| 221 | + capabilities: CoprocessorCapabilities { |
| 222 | + supported_ops: vec![], |
| 223 | + typical_latency_us: 0, |
| 224 | + deterministic: true, |
| 225 | + }, |
| 226 | + health: CoprocessorHealth::Unhealthy, |
| 227 | + }; |
| 228 | + let r = backend |
| 229 | + .dispatch(CoprocessorOp::GapSymmetricGroupOrder { n: 3 }) |
| 230 | + .await |
| 231 | + .unwrap(); |
| 232 | + assert!(matches!(r, CoprocessorOutcome::Failure(_))); |
| 233 | + } |
| 234 | + |
| 235 | + #[tokio::test] |
| 236 | + async fn unsafe_permutation_rejected_before_spawn() { |
| 237 | + let backend = GapBackend { |
| 238 | + capabilities: CoprocessorCapabilities { |
| 239 | + supported_ops: vec![], |
| 240 | + typical_latency_us: 0, |
| 241 | + deterministic: true, |
| 242 | + }, |
| 243 | + health: CoprocessorHealth::Healthy, |
| 244 | + }; |
| 245 | + let r = backend |
| 246 | + .dispatch(CoprocessorOp::GapGroupOrder { |
| 247 | + generators: vec!["(1,2); QUIT; Print(\"pwned\")".into()], |
| 248 | + }) |
| 249 | + .await |
| 250 | + .unwrap(); |
| 251 | + assert!( |
| 252 | + matches!(&r, CoprocessorOutcome::Failure(msg) if msg.contains("unsafe permutation")), |
| 253 | + "got {r:?}" |
| 254 | + ); |
| 255 | + } |
| 256 | + |
| 257 | + #[tokio::test] |
| 258 | + async fn empty_generators_rejected() { |
| 259 | + let backend = GapBackend { |
| 260 | + capabilities: CoprocessorCapabilities { |
| 261 | + supported_ops: vec![], |
| 262 | + typical_latency_us: 0, |
| 263 | + deterministic: true, |
| 264 | + }, |
| 265 | + health: CoprocessorHealth::Healthy, |
| 266 | + }; |
| 267 | + let r = backend |
| 268 | + .dispatch(CoprocessorOp::GapIsAbelian { generators: vec![] }) |
| 269 | + .await |
| 270 | + .unwrap(); |
| 271 | + assert!(matches!(r, CoprocessorOutcome::Failure(_))); |
| 272 | + } |
| 273 | + |
| 274 | + #[test] |
| 275 | + fn gen_list_format() { |
| 276 | + let gens = vec!["(1,2,3)".to_string(), "(1,2)".to_string()]; |
| 277 | + assert_eq!(gen_list(&gens), "(1,2,3),(1,2)"); |
| 278 | + } |
| 279 | + |
| 280 | + #[test] |
| 281 | + fn backend_construction_probes_path() { |
| 282 | + let b = GapBackend::new(); |
| 283 | + assert!(matches!( |
| 284 | + b.health(), |
| 285 | + CoprocessorHealth::Healthy | CoprocessorHealth::Unhealthy |
| 286 | + )); |
| 287 | + assert_eq!(b.kind(), CoprocessorKind::Gap); |
| 288 | + assert_eq!(b.trust_tier(), CoprocessorTrustTier::ExternalSubprocess); |
| 289 | + } |
| 290 | +} |
0 commit comments