im-core 0.1.0

Rust IM SDK for Awiki clients built on Agent Network Protocol (ANP)
Documentation
use super::record::{SecretRef, VaultSecretRecord};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone)]
pub struct FileSecretVaultStore {
    root_dir: PathBuf,
}

impl FileSecretVaultStore {
    pub fn new(root_dir: impl Into<PathBuf>) -> Self {
        Self {
            root_dir: root_dir.into(),
        }
    }

    pub(crate) fn put(&self, record: &VaultSecretRecord) -> crate::ImResult<SecretRef> {
        let secret_ref = record.secret_ref();
        let path = self.record_path(&secret_ref);
        let raw =
            serde_json::to_vec_pretty(record).map_err(|err| crate::ImError::Serialization {
                detail: err.to_string(),
            })?;
        write_secure_file(&path, &raw)?;
        Ok(secret_ref)
    }

    pub(crate) fn get(&self, secret_ref: &SecretRef) -> crate::ImResult<VaultSecretRecord> {
        let path = self.record_path(secret_ref);
        let raw = fs::read(&path).map_err(|err| crate::ImError::CredentialFileUnreadable {
            path_kind: "secret_vault_record".to_owned(),
            detail: err.to_string(),
        })?;
        let record: VaultSecretRecord =
            serde_json::from_slice(&raw).map_err(|err| crate::ImError::Serialization {
                detail: err.to_string(),
            })?;
        if record.secret_ref() != *secret_ref {
            return Err(crate::ImError::PermissionDenied);
        }
        Ok(record)
    }

    pub(crate) fn delete(&self, secret_ref: &SecretRef) -> crate::ImResult<()> {
        let path = self.record_path(secret_ref);
        match fs::remove_file(&path) {
            Ok(()) => Ok(()),
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(err) => Err(crate::ImError::Io {
                detail: format!("delete secret vault record {}: {err}", path.display()),
            }),
        }
    }

    pub(crate) fn list(&self) -> crate::ImResult<Vec<SecretRef>> {
        let records_dir = self.records_dir();
        let entries = match fs::read_dir(&records_dir) {
            Ok(entries) => entries,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
            Err(err) => {
                return Err(crate::ImError::Io {
                    detail: format!("read secret vault records {}: {err}", records_dir.display()),
                });
            }
        };
        let mut refs = Vec::new();
        for entry in entries {
            let entry = entry.map_err(crate::ImError::from)?;
            if !is_record_file(&entry.path()) {
                continue;
            }
            if entry.file_type().map_err(crate::ImError::from)?.is_file() {
                let raw = fs::read(entry.path()).map_err(crate::ImError::from)?;
                let record: VaultSecretRecord =
                    serde_json::from_slice(&raw).map_err(|err| crate::ImError::Serialization {
                        detail: err.to_string(),
                    })?;
                refs.push(record.secret_ref());
            }
        }
        refs.sort_by(|left, right| {
            record_storage_key(left)
                .as_str()
                .cmp(record_storage_key(right).as_str())
        });
        Ok(refs)
    }

    pub(crate) fn record_path(&self, secret_ref: &SecretRef) -> PathBuf {
        self.records_dir()
            .join(format!("{}.json", record_storage_key(secret_ref)))
    }

    fn records_dir(&self) -> PathBuf {
        self.root_dir.join("records")
    }
}

fn is_record_file(path: &Path) -> bool {
    path.extension().and_then(|value| value.to_str()) == Some("json")
}

fn record_storage_key(secret_ref: &SecretRef) -> String {
    let mut hasher = Sha256::new();
    push_hash_field(&mut hasher, "workspace_id", &secret_ref.workspace_id);
    push_hash_field(&mut hasher, "device_id", &secret_ref.device_id);
    push_optional_hash_field(
        &mut hasher,
        "identity_id",
        secret_ref.identity_id.as_deref(),
    );
    push_optional_hash_field(&mut hasher, "did", secret_ref.did.as_deref());
    push_hash_field(&mut hasher, "kind", secret_ref.kind.as_str());
    push_hash_field(&mut hasher, "key_id", &secret_ref.key_id);
    push_hash_field(
        &mut hasher,
        "key_version",
        &secret_ref.key_version.to_string(),
    );
    URL_SAFE_NO_PAD.encode(hasher.finalize())
}

fn push_optional_hash_field(hasher: &mut Sha256, name: &str, value: Option<&str>) {
    match value {
        Some(value) => push_hash_field(hasher, name, value),
        None => push_hash_field(hasher, name, "<none>"),
    }
}

fn push_hash_field(hasher: &mut Sha256, name: &str, value: &str) {
    hasher.update(name.as_bytes());
    hasher.update([0]);
    hasher.update(value.len().to_string().as_bytes());
    hasher.update([0]);
    hasher.update(value.as_bytes());
    hasher.update([0xff]);
}

fn write_secure_file(path: &Path, raw: &[u8]) -> crate::ImResult<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
        set_private_dir_mode(parent)?;
    }
    let temp = temp_path(path);
    {
        let mut file = create_private_file(&temp)?;
        file.write_all(raw).map_err(crate::ImError::from)?;
        file.sync_all().map_err(crate::ImError::from)?;
    }
    fs::rename(&temp, path).map_err(|err| crate::ImError::Io {
        detail: format!(
            "rename secret vault temp file {} to {}: {err}",
            temp.display(),
            path.display()
        ),
    })?;
    set_private_file_mode(path)?;
    Ok(())
}

fn temp_path(path: &Path) -> PathBuf {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|duration| duration.as_nanos())
        .unwrap_or(0);
    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("record.json");
    path.with_file_name(format!(".{file_name}.{}.{}.tmp", std::process::id(), nanos))
}

#[cfg(unix)]
fn create_private_file(path: &Path) -> crate::ImResult<fs::File> {
    use std::os::unix::fs::OpenOptionsExt;
    fs::OpenOptions::new()
        .create_new(true)
        .write(true)
        .mode(0o600)
        .open(path)
        .map_err(crate::ImError::from)
}

#[cfg(not(unix))]
fn create_private_file(path: &Path) -> crate::ImResult<fs::File> {
    fs::OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(path)
        .map_err(crate::ImError::from)
}

#[cfg(unix)]
fn set_private_dir_mode(path: &Path) -> crate::ImResult<()> {
    use std::os::unix::fs::PermissionsExt;
    fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
    Ok(())
}

#[cfg(not(unix))]
fn set_private_dir_mode(_path: &Path) -> crate::ImResult<()> {
    Ok(())
}

#[cfg(unix)]
fn set_private_file_mode(path: &Path) -> crate::ImResult<()> {
    use std::os::unix::fs::PermissionsExt;
    fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
    Ok(())
}

#[cfg(not(unix))]
fn set_private_file_mode(_path: &Path) -> crate::ImResult<()> {
    Ok(())
}