vj 0.3.0

vj: a SystemVerilog (IEEE 1800-2023) simulator built on slang and LLVM.
//! Protected envelopes (IEEE 1800-2023 clause 34).
//!
//! vj is not an encrypting tool and has no way for the user to supply a
//! decryption key, so it treats the two envelope kinds as follows:
//!
//! * An *encryption* envelope (`` `pragma protect begin `` ... `end`) is
//!   cleartext to a tool that does not encrypt: LRM 34.3 says such a tool
//!   "shall apply no other interpretation to text that is not part of a
//!   protect pragma directive", and slang ignores the directives, so the
//!   region is compiled as written.
//! * A *decryption* envelope (`begin_protected` ... `end_protected`) holds
//!   ciphertext. Decrypting it is the optional service of 34.3.2 ("tools
//!   that support decrypting compilation") and 34.3 conditions it on "the
//!   proper key [being] supplied by the user", which no vj option does.
//!   slang skips such a region without a word, so the design would silently
//!   lose the encrypted modules; [`refuse_decryption_envelopes`] turns that
//!   into a named error instead.
//!
//! The scan is lexical (comments and string literals are skipped) and follows
//! `` `include `` of files on disk, because the protect pragma's scope "may
//! cross file boundaries and included files" (34.4). A file with no
//! `protect` or `include` in it costs two substring searches.

use std::collections::HashSet;
use std::path::{Path, PathBuf};

/// `Err` naming the first decryption envelope (`file:line`) reachable from
/// `src`, the text of `path`, through the file itself and its `` `include ``s
/// (searched in the including file's directory, then `include_dirs`).
pub fn refuse_decryption_envelopes(
    src: &str,
    path: &str,
    include_dirs: &[String],
) -> Result<(), String> {
    let mut seen = HashSet::new();
    scan(src, Path::new(path), include_dirs, &mut seen, 0)
}

/// Include nesting beyond this is a cycle or a pathological tree: stop
/// looking (slang reports the include error itself).
const MAX_DEPTH: usize = 16;

fn scan(
    src: &str,
    path: &Path,
    include_dirs: &[String],
    seen: &mut HashSet<PathBuf>,
    depth: usize,
) -> Result<(), String> {
    if (!src.contains("protect") && !src.contains("include")) || depth > MAX_DEPTH {
        return Ok(());
    }
    let b = src.as_bytes();
    let mut i = 0;
    let mut line = 1usize;
    while i < b.len() {
        match b[i] {
            b'\n' => {
                line += 1;
                i += 1;
            }
            b'/' if b.get(i + 1) == Some(&b'/') => i = line_end(b, i),
            b'/' if b.get(i + 1) == Some(&b'*') => {
                i += 2;
                while i < b.len() && !(b[i] == b'*' && b.get(i + 1) == Some(&b'/')) {
                    line += usize::from(b[i] == b'\n');
                    i += 1;
                }
                i = (i + 2).min(b.len());
            }
            b'"' => i = skip_string(b, i, &mut line),
            b'`' => {
                let (word, next) = ident(b, i + 1);
                i = next;
                match word {
                    "pragma" => {
                        let (kw, after) = ident(b, skip_blank(b, i));
                        if kw == "protect"
                            && opens_decryption_envelope(&src[after..line_end(b, after)])
                        {
                            return Err(format!(
                                "{}:{line}: protected envelope (`pragma protect \
                                 begin_protected): decrypting compilation is not supported \
                                 (LRM 1800-2023 34.3.2): vj has no decryption keys and \
                                 slang would skip the encrypted text silently",
                                path.display()
                            ));
                        }
                    }
                    "include" => {
                        let at = skip_blank(b, i);
                        if b.get(at) == Some(&b'"') {
                            let close = b[at + 1..].iter().position(|&c| c == b'"' || c == b'\n');
                            if let Some(n) = close.filter(|&n| b[at + 1 + n] == b'"') {
                                let name = &src[at + 1..at + 1 + n];
                                if let Some(file) = find_include(name, path, include_dirs)
                                    && seen.insert(file.clone())
                                    && let Ok(text) = std::fs::read_to_string(&file)
                                {
                                    scan(&text, &file, include_dirs, seen, depth + 1)?;
                                }
                            }
                        }
                    }
                    _ => {}
                }
            }
            _ => i += 1,
        }
    }
    Ok(())
}

/// Whether the rest of a `` `pragma protect `` line (outside string
/// literals) names the `begin_protected` pragma expression (34.5.3).
fn opens_decryption_envelope(rest: &str) -> bool {
    let b = rest.as_bytes();
    let mut i = 0;
    while i < b.len() {
        if b[i] == b'"' {
            i = skip_string(b, i, &mut 0);
        } else if b[i] == b'/' && b.get(i + 1) == Some(&b'/') {
            return false;
        } else if b[i].is_ascii_alphabetic() || b[i] == b'_' {
            let (word, next) = ident(b, i);
            if word == "begin_protected" {
                return true;
            }
            i = next;
        } else {
            i += 1;
        }
    }
    false
}

fn find_include(name: &str, from: &Path, include_dirs: &[String]) -> Option<PathBuf> {
    let own = from.parent().unwrap_or(Path::new("."));
    std::iter::once(own.to_path_buf())
        .chain(include_dirs.iter().map(PathBuf::from))
        .map(|d| d.join(name))
        .find(|p| p.is_file())
}

fn ident(b: &[u8], at: usize) -> (&str, usize) {
    let mut e = at;
    while e < b.len() && (b[e].is_ascii_alphanumeric() || b[e] == b'_' || b[e] == b'$') {
        e += 1;
    }
    (std::str::from_utf8(&b[at..e]).unwrap_or(""), e)
}

fn skip_blank(b: &[u8], mut i: usize) -> usize {
    while i < b.len() && (b[i] == b' ' || b[i] == b'\t') {
        i += 1;
    }
    i
}

/// The index of the line break ending the line `i` is on (or the end).
fn line_end(b: &[u8], mut i: usize) -> usize {
    while i < b.len() && b[i] != b'\n' {
        i += 1;
    }
    i
}

/// The index after the string literal opening at `i` (escapes honoured).
fn skip_string(b: &[u8], mut i: usize, line: &mut usize) -> usize {
    i += 1;
    while i < b.len() && b[i] != b'"' {
        if b[i] == b'\\' {
            i += 1;
        }
        if i < b.len() && b[i] == b'\n' {
            *line += 1;
        }
        i += 1;
    }
    (i + 1).min(b.len())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn refused(src: &str) -> bool {
        refuse_decryption_envelopes(src, "t.sv", &[]).is_err()
    }

    #[test]
    fn begin_protected_is_refused_with_its_line() {
        let e = refuse_decryption_envelopes(
            "module m;\n`pragma protect data_method=\"x\", begin_protected\nendmodule\n",
            "t.sv",
            &[],
        )
        .unwrap_err();
        assert!(e.starts_with("t.sv:2:") && e.contains("34.3.2"), "{e}");
    }

    #[test]
    fn encryption_envelopes_comments_and_strings_pass() {
        assert!(!refused(
            "`pragma protect begin\nwire w;\n`pragma protect end\n"
        ));
        assert!(!refused("// `pragma protect begin_protected\nwire w;\n"));
        assert!(!refused("/* `pragma protect begin_protected */ wire w;\n"));
        assert!(!refused(
            "initial $display(\"`pragma protect begin_protected\");\n"
        ));
        assert!(!refused("`pragma protect comment=\"begin_protected\"\n"));
        assert!(!refused("`pragma once\n"));
    }
}