ljos 0.35.5

Memory, a work tracker and a shell-command gate for coding agents (Claude Code, Codex, Grok Build, agy, opencode) over MCP and hooks.
Documentation
//! The optional `[mail]` table.
//!
//! It names the identity drafts are sent as, the deny list, and the
//! notmuch database, so only the user may set it. It is read from the
//! user's own files, `ljos.toml` then `mail.toml` in the ljos config dir,
//! each opened by `open_owned`, as are `deny_file` and `notmuch_config`
//! where they are read, so no other user can change them. Any other
//! config layer (`./ljos.toml`, `./vissue.toml`, `VISSUE_CONFIG`,
//! `LJOS_CONFIG`, the vissue config) that carries a `[mail]` table is an
//! error, as is a layer that cannot be read or parsed: a seat that can
//! write the working directory must not swap the identity or the deny
//! list.

use std::fs::File;
use std::io::{ErrorKind, Read as _};
use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _};
use std::path::{Path, PathBuf};

use anyhow::{bail, Context as _, Result};
use serde::Deserialize;

/// What the `[mail]` layers say. Every key is optional.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MailConfig {
    /// `NOTMUCH_CONFIG` for every read. The reader refuses without it.
    pub notmuch_config: Option<PathBuf>,
    /// The notmuch binary: an absolute path, or a bare name found on PATH.
    pub notmuch: Option<PathBuf>,
    /// The one address a draft's From may carry. Needs `deny_file`.
    pub identity: Option<String>,
    /// The deny list the content checks read.
    pub deny_file: Option<PathBuf>,
    /// Attribution phrases refused on top of the built-in ones.
    pub attribution: Vec<String>,
}

#[derive(Deserialize)]
struct Layer {
    mail: Option<Table>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Table {
    notmuch_config: Option<PathBuf>,
    notmuch: Option<PathBuf>,
    identity: Option<String>,
    deny_file: Option<PathBuf>,
    attribution: Option<Vec<String>>,
}

/// The `[mail]` config from the user's files, after checking that no
/// other layer sets it. `None` when no user file has the table.
pub fn load() -> Result<Option<MailConfig>> {
    let user: Vec<PathBuf> = crate::converge::config_base()
        .map(|base| vec![base.join("ljos/ljos.toml"), base.join("ljos/mail.toml")])
        .unwrap_or_default();
    let shared: Vec<PathBuf> = crate::converge::config_paths(None)
        .into_iter()
        .filter(|path| !user.contains(path))
        .collect();
    load_from(&user, &shared)
}

/// The `[mail]` config from the `user` files, in order, a later one
/// overriding the keys it sets. A `shared` file with a `[mail]` table is
/// an error. A missing file is skipped; any other read failure is an
/// error.
pub fn load_from(user: &[PathBuf], shared: &[PathBuf]) -> Result<Option<MailConfig>> {
    for path in shared {
        let Some(text) = read(path)? else {
            continue;
        };
        let layer: toml::Value =
            toml::from_str(&text).with_context(|| format!("mail config {}", path.display()))?;
        if layer.get("mail").is_some() {
            bail!(
                "mail config {}: [mail] may be set only in the user's own ljos.toml or mail.toml",
                path.display()
            );
        }
    }
    let mut config: Option<MailConfig> = None;
    for path in user {
        let Some((mut file, _)) = open_owned(path, "mail config")? else {
            continue;
        };
        let mut text = String::new();
        file.read_to_string(&mut text)
            .with_context(|| format!("mail config {}: cannot read", path.display()))?;
        let layer: Layer =
            toml::from_str(&text).with_context(|| format!("mail config {}", path.display()))?;
        let Some(table) = layer.mail else {
            continue;
        };
        check(&table).with_context(|| format!("mail config {}", path.display()))?;
        let merged = config.get_or_insert_with(MailConfig::default);
        if table.notmuch_config.is_some() {
            merged.notmuch_config = table.notmuch_config;
        }
        if table.notmuch.is_some() {
            merged.notmuch = table.notmuch;
        }
        if table.identity.is_some() {
            merged.identity = table.identity;
        }
        if table.deny_file.is_some() {
            merged.deny_file = table.deny_file;
        }
        if let Some(extra) = table.attribution {
            merged.attribution = extra;
        }
    }
    if config
        .as_ref()
        .is_some_and(|c| c.identity.is_some() && c.deny_file.is_none())
    {
        bail!("mail config: identity is set without deny_file, which would deny nothing");
    }
    Ok(config)
}

fn read(path: &Path) -> Result<Option<String>> {
    match std::fs::read_to_string(path) {
        Ok(text) => Ok(Some(text)),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(e).with_context(|| format!("mail config {}: cannot read", path.display())),
    }
}

/// `path` open for reading, and the path it was opened at, when no other
/// user can change what it reads. Every directory above it, with
/// symlinks resolved, must be owned by this user or root and not be
/// group or world writable unless it is sticky like `/tmp`. The file is
/// opened `O_NOFOLLOW`, and the descriptor must be a regular file owned
/// by this user or root and not group or world writable. `None` when it
/// does not exist. `what` names it in errors.
pub(crate) fn open_owned(path: &Path, what: &str) -> Result<Option<(File, PathBuf)>> {
    let Some(name) = path.file_name() else {
        bail!("{what} {}: not a file path", path.display());
    };
    let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
    let dir = match std::fs::canonicalize(parent.unwrap_or(Path::new("."))) {
        Ok(dir) => dir,
        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
        Err(e) => return Err(e).with_context(|| format!("{what} {}", path.display())),
    };
    let at = dir.join(name);
    let file = match std::fs::OpenOptions::new()
        .read(true)
        .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
        .open(&at)
    {
        Ok(file) => file,
        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
        Err(e) if e.raw_os_error() == Some(libc::ELOOP) => {
            bail!("{what} {}: is a symlink", path.display())
        }
        Err(e) => return Err(e).with_context(|| format!("{what} {}: cannot open", path.display())),
    };
    let trusted = |uid: u32| uid == crate::euid() || uid == 0;
    for above in dir.ancestors() {
        let meta = std::fs::symlink_metadata(above).with_context(|| {
            format!("{what} {}: cannot stat {}", path.display(), above.display())
        })?;
        if !trusted(meta.uid()) {
            bail!(
                "{what} {}: {} is not owned by this user or root",
                path.display(),
                above.display()
            );
        }
        if meta.mode() & 0o022 != 0 && meta.mode() & 0o1000 == 0 {
            bail!(
                "{what} {}: {} is group or world writable",
                path.display(),
                above.display()
            );
        }
    }
    let meta = file
        .metadata()
        .with_context(|| format!("{what} {}: cannot stat", path.display()))?;
    if !meta.is_file() {
        bail!("{what} {}: not a regular file", path.display());
    }
    if !trusted(meta.uid()) {
        bail!("{what} {}: not owned by this user or root", path.display());
    }
    if meta.mode() & 0o022 != 0 {
        bail!("{what} {}: group or world writable", path.display());
    }
    Ok(Some((file, at)))
}

fn check(table: &Table) -> Result<()> {
    for (key, path) in [
        ("notmuch_config", &table.notmuch_config),
        ("deny_file", &table.deny_file),
    ] {
        if path.as_deref().is_some_and(|p| !p.is_absolute()) {
            bail!("{key} must be an absolute path");
        }
    }
    if let Some(bin) = &table.notmuch {
        if !bin.is_absolute() && bin.components().count() != 1 {
            bail!("notmuch must be an absolute path or a bare name on PATH");
        }
    }
    if let Some(identity) = &table.identity {
        if !is_bare_address(identity) {
            bail!("identity must be a bare address such as owner@example.test");
        }
    }
    Ok(())
}

/// `local@domain` with no display name, brackets or spaces.
pub(crate) fn is_bare_address(text: &str) -> bool {
    let Some((local, domain)) = text.split_once('@') else {
        return false;
    };
    !local.is_empty()
        && domain.contains('.')
        && !domain.starts_with('.')
        && !domain.ends_with('.')
        && !text
            .chars()
            .any(|c| c.is_whitespace() || c.is_control() || "<>\"(),;:[]\\".contains(c))
        && !domain.contains('@')
}

impl MailConfig {
    /// The notmuch binary to run.
    #[must_use]
    pub fn notmuch_bin(&self) -> &Path {
        self.notmuch.as_deref().unwrap_or(Path::new("notmuch"))
    }
}