Skip to main content

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}