cedar_policy_symcc/symcc/solver.rs
1/*
2 * Copyright Cedar Contributors
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! A simple interface to an SMT solver.
18//!
19//! Callers communicate with the solver by issuing commands with s-expressions
20//! encoded as strings. The interface is based on
21//! [lean-smt](https://github.com/ufmg-smite/lean-smt/).
22//!
23//! Currently, we support only CVC5, running locally in a separate process. The
24//! function `LocalSolver::cvc5()` creates a fresh CVC5 solver process. This
25//! uses the value of the environment variable `CVC5` as the absolute path to
26//! the CVC5 executable, or if the environment variable is not set, looks for
27//! `cvc5` on the `PATH`.
28//!
29//! This module does not correspond in lockstep to Lean's `Solver.lean`, partly
30//! because Rust and Lean have different needs for solver functionality, and
31//! partly because the functionality in this module is not difftested and has no
32//! proofs about it on the Lean side.
33
34use super::smtlib_script::SmtLibScript;
35use miette::Diagnostic;
36use std::ffi::OsStr;
37use std::future::Future;
38use std::process::Stdio;
39use thiserror::Error;
40use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
41use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
42
43/// Satisfiability decision from the SMT solver.
44#[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd)]
45pub enum Decision {
46 /// Sat
47 Sat,
48 /// Unsat
49 Unsat,
50 /// Unknown
51 Unknown,
52}
53
54/// Satisfiability decision from the SMT solver, with a model in the SAT case.
55#[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd)]
56pub enum DecisionWithModel {
57 /// Sat
58 Sat {
59 /// Raw model from the solver
60 model: String,
61 },
62 /// Unsat
63 Unsat,
64 /// Unknown
65 Unknown,
66}
67
68/// Errors when interacting with a [`Solver`] instance.
69/// Corresponds to various errors in the Lean version at `Cedar.SymCC.Solver`
70#[derive(Debug, Diagnostic, Error)]
71pub enum SolverError {
72 /// IO error.
73 #[error("IO error during a solver operation")]
74 Io(#[from] std::io::Error),
75 /// Error from the solver.
76 #[error("solver error: {0}")]
77 Solver(String),
78 /// Unrecognized solver output.
79 #[error("unrecognized solver output: {0}")]
80 UnrecognizedSolverOutput(String),
81 /// The solver was marked as failed and can no longer be used.
82 #[error("solver was marked as failed")]
83 SolverMarkedFailed,
84}
85type Result<T> = std::result::Result<T, SolverError>;
86
87/// Trait for things which are capable of solving SMTLib queries
88///
89/// Does not really correspond to Lean's `Solver` type; see comments on this
90/// module
91pub trait Solver {
92 /// Get the input stream for the solver, so that you can write (more) input
93 /// to it. This input is expected to be in SMTLib format.
94 ///
95 /// Returns a `&mut dyn tokio::io::AsyncWrite`, which gets the methods in
96 /// the trait `SmtLibScript` for free, as long as the `SmtLibScript` trait
97 /// is brought into scope.
98 fn smtlib_input(&mut self) -> &mut (dyn tokio::io::AsyncWrite + Unpin + Send);
99
100 /// Enable models for the current solver query.
101 ///
102 /// This function is responsible for adding
103 /// `SmtLibScript::set_option("produce-models", "true")`.
104 /// It also may perform other functions depending on the `Solver`
105 /// implementation.
106 ///
107 /// This function _must_ be called before `check_sat_with_model()`, and in
108 /// fact, before writing anything to `smtlib_input()` prior to a
109 /// `check_sat_with_model()` (except optionally a `.reset()`).
110 ///
111 /// This signature could be written
112 /// `async fn enable_models(&mut self) -> Result<()>;`
113 /// but that would not allow us to include the `Send` bound we need.
114 /// What you see here is basically a desugaring of the above, plus the
115 /// `Send` bound. See <https://blog.rust-lang.org/2023/12/21/async-fn-rpit-in-traits/#async-fn-in-public-traits>
116 ///
117 /// Note that implementors of this trait, like `LocalSolver` and
118 /// `WriterSolver` below, can still use the `async fn` syntax sugar to
119 /// implement this.
120 fn enable_models(&mut self) -> impl Future<Output = Result<()>> + Send;
121 // minimal compliant implementation: (requires `Self: Send` so can't actually
122 // be a default implementation, but left here in comments)
123 /*
124 async fn enable_models(&mut self) -> Result<()> {
125 self.smtlib_input()
126 .set_option("produce-models", "true")
127 .await
128 .map_err(Into::into)
129 }
130 */
131
132 /// Execute the query that has been written via `smtlib_input()`, returning
133 /// the `Decision`.
134 ///
135 /// This function is also responsible for adding `SmtLibScript::check_sat()`.
136 ///
137 /// This signature could be written
138 /// `async fn check_sat(&mut self) -> Result<Decision>;`
139 /// but that would not allow us to include the `Send` bound we need.
140 /// What you see here is basically a desugaring of the above, plus the
141 /// `Send` bound. See <https://blog.rust-lang.org/2023/12/21/async-fn-rpit-in-traits/#async-fn-in-public-traits>
142 ///
143 /// Note that implementors of this trait, like `LocalSolver` and
144 /// `WriterSolver` below, can still use the `async fn` syntax sugar to
145 /// implement this.
146 fn check_sat(&mut self) -> impl Future<Output = Result<Decision>> + Send;
147
148 /// Like `check_sat()`, but in the SAT case, asks the solver for a model
149 /// and returns it as a string.
150 ///
151 /// This function is responsible for adding both `SmtLibScript::check_sat()`
152 /// and `SmtLibScript::get_model()`.
153 ///
154 /// This signature could be written
155 /// `async fn check_sat_with_model(&mut self) -> Result<DecisionWithModel>;`
156 /// but that would not allow us to include the `Send` bound we need.
157 /// What you see here is basically a desugaring of the above, plus the
158 /// `Send` bound. See <https://blog.rust-lang.org/2023/12/21/async-fn-rpit-in-traits/#async-fn-in-public-traits>
159 ///
160 /// Note that implementors of this trait, like `LocalSolver` and
161 /// `WriterSolver` below, can still use the `async fn` syntax sugar to
162 /// implement this.
163 fn check_sat_with_model(&mut self) -> impl Future<Output = Result<DecisionWithModel>> + Send;
164}
165
166/// A solver instance that communicates with a local SMT solver process
167/// through stdin/stdout.
168///
169/// We officially support [cvc5](https://github.com/cvc5/cvc5),
170/// but other SMT solvers such as [Z3](https://github.com/Z3Prover/z3)
171/// may also work with a subset of SymCC's functionality.
172///
173/// Examples:
174/// ```no_run
175/// use tokio::process::Command;
176/// use cedar_policy_symcc::solver::LocalSolver;
177///
178/// // Spawns a cvc5 process with the default arguments
179/// let solver = LocalSolver::cvc5().unwrap();
180///
181/// // Spawns a cvc5 process with custom arguments
182/// let solver = LocalSolver::cvc5_with_args(["--rlimit=1000"]).unwrap();
183///
184/// // Spawns a custom solver process
185/// let solver = LocalSolver::from_command(Command::new("z3").args(["rlimit", "1000"])).unwrap();
186/// ```
187#[derive(Debug)]
188pub struct LocalSolver {
189 /// The spawned solver process.
190 child: Child,
191 /// Stdin of the solver process (which we write to)
192 solver_stdin: BufWriter<ChildStdin>,
193 /// Stdout of the solver process (which we read from)
194 solver_stdout: BufReader<ChildStdout>,
195 /// Stderr of the solver process (which we read from)
196 #[expect(unused, reason = "included for completeness")]
197 solver_stderr: BufReader<ChildStderr>,
198}
199
200impl LocalSolver {
201 /// Creates a new [`LocalSolver`] from a custom [`Command`].
202 ///
203 /// The input command is expected to behave as an interactive SMT solver
204 /// that reads queries from stdin in SMT-LIB 2 format (e.g., `cvc5 --lang smt` or `z3`).
205 pub fn from_command(cmd: &mut Command) -> Result<Self> {
206 let mut child = cmd
207 .stdin(Stdio::piped())
208 .stdout(Stdio::piped())
209 .stderr(Stdio::piped())
210 .spawn()?;
211 let (stdin, stdout, stderr) =
212 match (child.stdin.take(), child.stdout.take(), child.stderr.take()) {
213 (Some(stdin), Some(stdout), Some(stderr)) => (stdin, stdout, stderr),
214 _ => {
215 return Err(SolverError::Solver(
216 "Failed to fetch IO pipes for solver process".into(),
217 ))
218 }
219 };
220 Ok(Self {
221 solver_stdin: BufWriter::new(stdin),
222 solver_stdout: BufReader::new(stdout),
223 solver_stderr: BufReader::new(stderr),
224 child,
225 })
226 }
227
228 /// Spawns a cvc5 solver process by looking up the
229 /// executable using the `CVC5` environment variable
230 /// or the `cvc5` binary in `PATH`.
231 pub fn cvc5() -> Result<Self> {
232 // Limit of 60000ms = 1 min of wall time for local solves, for now
233 Self::cvc5_with_args(["--tlimit=60000"])
234 }
235
236 /// Similar to [`Self::cvc5`] but with custom arguments.
237 pub fn cvc5_with_args(args: impl IntoIterator<Item = impl AsRef<OsStr>>) -> Result<Self> {
238 let path = std::env::var("CVC5").unwrap_or_else(|_| "cvc5".into());
239 Self::from_command(Command::new(path).args(["--lang", "smt"]).args(args))
240 }
241}
242
243impl Solver for LocalSolver {
244 fn smtlib_input(&mut self) -> &mut (dyn tokio::io::AsyncWrite + Unpin + Send) {
245 &mut self.solver_stdin
246 }
247
248 async fn enable_models(&mut self) -> Result<()> {
249 // The `LocalSolver` implementation doesn't need to do anything other than
250 // set the appropriate SMTLib option.
251 self.smtlib_input()
252 .set_option("produce-models", "true")
253 .await
254 .map_err(Into::into)
255 }
256
257 async fn check_sat(&mut self) -> Result<Decision> {
258 self.check_child_process_status().await?;
259 self.smtlib_input().check_sat().await?;
260 self.solver_stdin.flush().await?;
261 let mut output = String::new();
262 self.read_line(&mut output).await?;
263 match output.as_str().trim() {
264 "sat" => Ok(Decision::Sat),
265 "unsat" => Ok(Decision::Unsat),
266 "unknown" => Ok(Decision::Unknown),
267 s => Err(self.process_error_output(s).await),
268 }
269 }
270
271 async fn check_sat_with_model(&mut self) -> Result<DecisionWithModel> {
272 match self.check_sat().await? {
273 Decision::Sat => {
274 // in the SAT case, we ask the solver for a model, which we expect to be in one of the following forms:
275 // 1. "(\n<the actual model>\n)\n"
276 // 2. "(error ...)\n"
277 self.smtlib_input().get_model().await?;
278 self.solver_stdin.flush().await?;
279 let mut output = String::new();
280 self.read_line(&mut output).await?;
281 match output.as_str().trim() {
282 "(" => {
283 // Read until a line ")\n"
284 loop {
285 let len: usize = self.read_line(&mut output).await?;
286 #[expect(
287 clippy::string_slice,
288 reason = "`output.len() - len` gives the end index of `output` before the `read_line`"
289 )]
290 if output[output.len() - len..].trim() == ")" {
291 break;
292 }
293 }
294 Ok(DecisionWithModel::Sat { model: output })
295 }
296 s => Err(self.process_error_output(s).await),
297 }
298 }
299 // in the UNSAT/UNKNOWN case, we don't ask for a model
300 Decision::Unsat => Ok(DecisionWithModel::Unsat),
301 Decision::Unknown => Ok(DecisionWithModel::Unknown),
302 }
303 }
304}
305
306impl LocalSolver {
307 async fn check_child_process_status(&mut self) -> Result<()> {
308 if let Some(status) = self.child.try_wait()? {
309 Err(SolverError::Solver(format!(
310 "Solver process terminated unexpectedly with status: {:?}",
311 status.code()
312 )))?
313 }
314 Ok(())
315 }
316
317 async fn read_line(&mut self, buffer: &mut String) -> Result<usize> {
318 let len = self.solver_stdout.read_line(buffer).await?;
319 if len == 0 {
320 // An unexpected EOF was encountered while reading from solver output.
321 // Kill the child process and clean up its resources.
322 self.clean_up().await?;
323 // If `clean_up` succeeded, then the child process has exited completely and its
324 // status will be returned by the call to `try_wait`, causing `check_child_process_status`
325 // to return an `Err`.
326 self.check_child_process_status().await?;
327 }
328 Ok(len)
329 }
330
331 async fn process_error_output(&mut self, s: &str) -> SolverError {
332 match s
333 .strip_prefix("(error \"")
334 .and_then(|s| s.strip_suffix("\")"))
335 {
336 Some(e) => {
337 if e.starts_with("Parse Error: ") {
338 // Parse errors cause CVC5 to quit and need to be handled specially.
339 // Kill the child process and clean up its resources
340 let _ = self.clean_up().await;
341 }
342 SolverError::Solver(e.to_string())
343 }
344 _ => SolverError::UnrecognizedSolverOutput(s.to_string()),
345 }
346 }
347
348 /// Kills this solver's child process and waits for the child process to exit completely.
349 pub async fn clean_up(&mut self) -> Result<()> {
350 self.child.kill().await.map_err(|e| e.into())
351 }
352}
353
354/// Implements `Solver` by writing all issued commands to the given
355/// `tokio::io::AsyncWrite`.
356/// `check_sat()` writes the command to `f` and then returns `Decision::Unknown`,
357/// which is sound but not very useful.
358/// The purpose of this is for testing that only cares about the contents of the
359/// script.
360#[derive(Debug)]
361pub struct WriterSolver<W> {
362 /// where the `WriterSolver` will write the SMTLib commands to
363 pub w: W,
364}
365
366impl<W: tokio::io::AsyncWrite + Unpin + Send> Solver for WriterSolver<W> {
367 fn smtlib_input(&mut self) -> &mut (dyn tokio::io::AsyncWrite + Unpin + Send) {
368 &mut self.w
369 }
370 async fn enable_models(&mut self) -> Result<()> {
371 // The `WriterSolver` implementation doesn't need to do anything other
372 // than set the appropriate SMTLib option.
373 self.smtlib_input()
374 .set_option("produce-models", "true")
375 .await
376 .map_err(Into::into)
377 }
378 async fn check_sat(&mut self) -> Result<Decision> {
379 self.smtlib_input().check_sat().await?;
380 self.w.flush().await?;
381 Ok(Decision::Unknown)
382 }
383 async fn check_sat_with_model(&mut self) -> Result<DecisionWithModel> {
384 self.smtlib_input().check_sat().await?;
385 // Since the decision is always `Unknown`, we should not emit get-model for consistency.
386 self.w.flush().await?;
387 Ok(DecisionWithModel::Unknown)
388 }
389}
390
391#[cfg(test)]
392mod test {
393 use cool_asserts::assert_matches;
394
395 use super::*;
396
397 #[tokio::test]
398 async fn empty_cvc5_run() {
399 let mut my_solver = LocalSolver::cvc5().unwrap();
400 let decision = my_solver.check_sat().await.unwrap();
401 assert_eq!(decision, Decision::Sat);
402 }
403
404 #[tokio::test]
405 async fn set_logic_test() {
406 let mut my_solver = LocalSolver::cvc5().unwrap();
407 my_solver.smtlib_input().set_logic("ALL").await.unwrap();
408 let decision = my_solver.check_sat().await.unwrap();
409 assert_eq!(decision, Decision::Sat);
410 }
411
412 #[tokio::test]
413 async fn comment_test() {
414 let mut my_solver = LocalSolver::cvc5().unwrap();
415 my_solver
416 .smtlib_input()
417 .comment("(assert false)")
418 .await
419 .unwrap();
420 let decision = my_solver.check_sat().await.unwrap();
421 assert_eq!(decision, Decision::Sat);
422 }
423
424 #[tokio::test]
425 async fn comment_escaping_test() {
426 let mut my_solver = LocalSolver::cvc5().unwrap();
427 my_solver
428 .smtlib_input()
429 .comment("\n(assert false)")
430 .await
431 .unwrap();
432 let decision = my_solver.check_sat().await.unwrap();
433 assert_eq!(decision, Decision::Sat);
434 }
435
436 #[tokio::test]
437 async fn unsat_test() {
438 let mut my_solver = LocalSolver::cvc5().unwrap();
439 my_solver.smtlib_input().assert("false").await.unwrap();
440 let decision = my_solver.check_sat().await.unwrap();
441 assert_eq!(decision, Decision::Unsat);
442 }
443
444 #[tokio::test]
445 async fn get_model_sat() {
446 let mut my_solver = LocalSolver::cvc5().unwrap();
447 my_solver.enable_models().await.unwrap();
448 my_solver.smtlib_input().assert("true").await.unwrap();
449 let decision = my_solver.check_sat_with_model().await.unwrap();
450 assert_matches!(decision, DecisionWithModel::Sat { model } => {
451 assert!(!model.is_empty());
452 });
453 }
454
455 #[tokio::test]
456 async fn get_model_unsat() {
457 let mut my_solver = LocalSolver::cvc5().unwrap();
458 my_solver.enable_models().await.unwrap();
459 my_solver.smtlib_input().assert("false").await.unwrap();
460 let decision = my_solver.check_sat_with_model().await.unwrap();
461 assert_eq!(decision, DecisionWithModel::Unsat);
462 }
463
464 #[tokio::test]
465 async fn parse_error_test() {
466 let mut my_solver = LocalSolver::cvc5().unwrap();
467 // Send an invalid expression to the solver.
468 my_solver.smtlib_input().assert("tomato").await.unwrap();
469 // Check that the solver reports an error.
470 assert_matches!(my_solver.check_sat().await, Err(SolverError::Solver(_)));
471 // Attempt to reset the solver.
472 my_solver.smtlib_input().reset().await.unwrap();
473 assert_matches!(my_solver.check_sat().await, Err(SolverError::Solver(x)) => assert!(x.starts_with("Solver process terminated unexpectedly with status: ")));
474 }
475
476 #[tokio::test]
477 async fn clean_up_succeeds() {
478 let mut my_solver = LocalSolver::cvc5().unwrap();
479 my_solver.clean_up().await.unwrap();
480 let status = my_solver.child.try_wait().unwrap();
481 assert!(status.is_some());
482 }
483
484 #[tokio::test]
485 async fn check_sat_crlf_test() {
486 let mut cmd = Command::new("sh");
487 cmd.args(["-c", "read line && printf 'sat\r\n'"]);
488 let mut solver = LocalSolver::from_command(&mut cmd).unwrap();
489 let decision = solver.check_sat().await.unwrap();
490 assert_eq!(decision, Decision::Sat);
491 }
492
493 #[tokio::test]
494 async fn check_sat_with_model_crlf_test() {
495 let mut cmd = Command::new("sh");
496 cmd.args([
497 "-c",
498 "read line && printf 'sat\\r\\n' && read line && printf '(\\r\\n define-fun x () Int 0\\r\\n)\\r\\n'",
499 ]);
500 let mut solver = LocalSolver::from_command(&mut cmd).unwrap();
501
502 let decision = solver.check_sat_with_model().await.unwrap();
503
504 assert_matches!(decision, DecisionWithModel::Sat { model } => {
505 assert_eq!(model, "(\r\n define-fun x () Int 0\r\n)\r\n");
506 });
507 }
508
509 #[tokio::test]
510 async fn process_error_output_parse_error_no_line_ending_test() {
511 let mut solver = LocalSolver::cvc5().unwrap();
512 let res = solver
513 .process_error_output("(error \"Parse Error: mock windows error\")")
514 .await;
515 assert_matches!(res, SolverError::Solver(msg) => {
516 assert_eq!(msg, "Parse Error: mock windows error");
517 });
518 }
519}