tsafe-cli 1.0.21

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
//! KeePass entry reader — opens a `.kdbx` file and extracts key/value pairs.

use std::fs::File;

use keepass::{
    db::{fields, GroupRef},
    Database, DatabaseKey,
};

use super::{config::KeePassConfig, error::KeePassError};

/// Open a KeePass database and extract all matching entries as `(key, value)` pairs.
///
/// Key names follow the normalisation rule: spaces and hyphens → underscores,
/// then uppercase.  The entry title is used as the key root.  Standard fields
/// map to `TITLE_USERNAME`, `TITLE_PASSWORD`, `TITLE_URL`.  Custom fields map
/// to `TITLE_<FIELD_NAME_NORMALISED>`.  Empty values are skipped.  Notes are
/// skipped by default.
///
/// # Group filtering
///
/// When `cfg.group` is set, only entries whose direct parent group name matches
/// (case-insensitive) are included.  When `cfg.recursive` is also true, entries
/// in descendant groups of the matched group are included as well.
pub fn pull_entries(cfg: &KeePassConfig) -> Result<Vec<(String, String)>, KeePassError> {
    let mut file =
        File::open(&cfg.path).map_err(|e| KeePassError::Open(format!("{}: {e}", cfg.path)))?;

    let key = build_database_key(cfg)?;

    let db = Database::open(&mut file, key).map_err(|e| {
        // DatabaseOpenError::Key(DatabaseKeyError::IncorrectKey) is the auth failure.
        // We surface that as KeePassError::Auth; all other errors are Open.
        let msg = e.to_string();
        if msg.contains("Incorrect key") || msg.contains("IncorrectKey") {
            KeePassError::Auth
        } else {
            KeePassError::Open(format!("{}: {e}", cfg.path))
        }
    })?;

    let root = db.root();

    let mut results: Vec<(String, String)> = Vec::new();

    match &cfg.group {
        None => {
            // No group filter: import all entries from the root group.
            // When recursive is true also descend into child groups.
            collect_entries(&root, cfg.recursive, &mut results);
        }
        Some(target_group) => {
            // Find the target group (direct child of root only; recursive flag
            // controls whether we then descend *within* the matched group).
            let matched = root
                .groups()
                .find(|g| g.name.eq_ignore_ascii_case(target_group));
            match matched {
                None => return Err(KeePassError::GroupNotFound(target_group.clone())),
                Some(group) => {
                    collect_entries(&group, cfg.recursive, &mut results);
                }
            }
        }
    }

    Ok(results)
}

/// Build a [`DatabaseKey`] from the resolved config.
fn build_database_key(cfg: &KeePassConfig) -> Result<DatabaseKey, KeePassError> {
    let mut key = DatabaseKey::new();

    if let Some(pw) = &cfg.password {
        key = key.with_password(pw);
    }

    if let Some(kf_path) = &cfg.keyfile_path {
        let mut kf = File::open(kf_path).map_err(|e| KeePassError::KeyFile {
            path: kf_path.clone(),
            source: e,
        })?;
        key = key
            .with_keyfile(&mut kf)
            .map_err(|e| KeePassError::KeyFile {
                path: kf_path.clone(),
                source: e,
            })?;
    }

    Ok(key)
}

/// Collect `(key_name, value)` pairs from all entries in `group`.
///
/// When `recursive` is true, also descends into child groups.
fn collect_entries(group: &GroupRef<'_>, recursive: bool, out: &mut Vec<(String, String)>) {
    for entry in group.entries() {
        let Some(title) = entry.get(fields::TITLE) else {
            continue;
        };
        if title.is_empty() {
            continue;
        }
        let title_key = normalise_key_segment(title);

        // USERNAME
        if let Some(v) = entry.get(fields::USERNAME) {
            if !v.is_empty() {
                out.push((format!("{title_key}_USERNAME"), v.to_string()));
            }
        }

        // PASSWORD
        if let Some(v) = entry.get(fields::PASSWORD) {
            if !v.is_empty() {
                out.push((format!("{title_key}_PASSWORD"), v.to_string()));
            }
        }

        // URL
        if let Some(v) = entry.get(fields::URL) {
            if !v.is_empty() {
                out.push((format!("{title_key}_URL"), v.to_string()));
            }
        }

        // Custom fields — everything except the standard known fields.
        for (field_name, value) in &entry.fields {
            match field_name.as_str() {
                fields::TITLE
                | fields::USERNAME
                | fields::PASSWORD
                | fields::URL
                | fields::NOTES => continue,
                _ => {}
            }
            let val = value.as_str();
            if val.is_empty() {
                continue;
            }
            let field_key = normalise_key_segment(field_name);
            out.push((format!("{title_key}_{field_key}"), val.to_string()));
        }
    }

    if recursive {
        for child in group.groups() {
            collect_entries(&child, true, out);
        }
    }
}

/// Normalise a single key segment: spaces and hyphens → underscores, uppercase.
///
/// Example: `"My Secret"` → `"MY_SECRET"`, `"db-password"` → `"DB_PASSWORD"`.
pub(crate) fn normalise_key_segment(s: &str) -> String {
    s.replace([' ', '-'], "_").to_uppercase()
}

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

    #[test]
    fn normalise_spaces_and_hyphens_to_underscores_and_uppercase() {
        assert_eq!(normalise_key_segment("My Entry"), "MY_ENTRY");
        assert_eq!(normalise_key_segment("db-password"), "DB_PASSWORD");
        assert_eq!(normalise_key_segment("API_KEY"), "API_KEY");
        assert_eq!(normalise_key_segment("my field name"), "MY_FIELD_NAME");
    }
}