Ctrl K
ComunidadGuías…

src/credential.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! Credential storage with OS keyring and encrypted file fallback.
//!
//! Passwords are stored via the OS-native credential store when available
//! (macOS Keychain, Linux Secret Service, Windows Credential Manager). In
//! environments without a keyring backend (Docker, headless servers), an
//! encrypted file in the config directory is used instead.
//!
//! # Encrypted file backend — threat model
//!
//! The encrypted file backend prevents casual exposure — passwords won't
//! appear in `grep`, `cat`, or accidental backups. However, the encryption
//! key resides in the same directory as the ciphertext. An attacker with
//! filesystem access to the config volume can decrypt the password. For
//! stronger protection, use `--password-command` with an external secret
//! manager or the OS keyring on native platforms.

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

use aes_gcm::aead::{Aead, KeyInit, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm};
use anyhow::{Context, Result};
use secrecy::SecretString;

/// Service name used for keyring entries.
const KEYRING_SERVICE: &str = "kei";

#[derive(Debug)]
enum DeleteOutcome {
    Deleted,
    NotFound,
    Failed(anyhow::Error),
}

/// Backend that accepted a stored credential.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CredentialBackend {
    Keyring,
    EncryptedFile,
}

impl CredentialBackend {
    pub(crate) fn as_str(self) -> &'static str {
        match self {
            Self::Keyring => "keyring",
            Self::EncryptedFile => "encrypted-file",
        }
    }
}

/// Credential store that tries the OS keyring first, falling back to an
/// AES-256-GCM encrypted file in the config directory.
#[derive(Debug)]
pub(crate) struct CredentialStore {
    username: String,
    config_dir: PathBuf,
    realm: String,
}

impl CredentialStore {
    pub(crate) fn new(username: &str, config_dir: &Path, realm: &str) -> Self {
        Self {
            username: username.to_string(),
            config_dir: config_dir.to_path_buf(),
            realm: realm.to_string(),
        }
    }

    /// Store a password. Tries keyring first, falls back to encrypted file.
    pub(crate) fn store(&self, password: &str) -> Result<CredentialBackend> {
        match self.keyring_store(password) {
            Ok(()) => {
                tracing::debug!(backend = "keyring", "Credential stored");
                Ok(CredentialBackend::Keyring)
            }
            Err(e) => {
                tracing::warn!(
                    error = %e,
                    "Keyring unavailable; falling back to encrypted file in config directory"
                );
                self.file_store(password)?;
                tracing::debug!(backend = "encrypted-file", "Credential stored");
                Ok(CredentialBackend::EncryptedFile)
            }
        }
    }

    /// Retrieve a stored password. Tries keyring first, falls back to encrypted file.
    pub(crate) fn retrieve(&self) -> Result<Option<SecretString>> {
        match self.keyring_retrieve() {
            Ok(Some(pw)) => {
                tracing::debug!(backend = "keyring", "Credential retrieved");
                return Ok(Some(pw));
            }
            Ok(None) => {}
            Err(e) => {
                tracing::warn!(
                    error = %e,
                    "Keyring unavailable; trying encrypted file in config directory"
                );
            }
        }
        let result = self.file_retrieve()?;
        if result.is_some() {
            tracing::debug!(backend = "encrypted-file", "Credential retrieved");
        }
        Ok(result)
    }

    /// Delete stored credentials from all backends.
    pub(crate) fn delete(&self) -> Result<()> {
        finish_delete(
            &self.username,
            self.keyring_delete_outcome(),
            self.file_delete_outcome(),
        )
    }

    /// Check whether a credential exists in any backend (keyring or file).
    pub(crate) fn has_credential(&self) -> bool {
        if self.keyring_retrieve().ok().flatten().is_some() {
            return true;
        }
        self.credential_file_path().exists()
    }

    /// Return the name of the currently active backend.
    pub(crate) fn backend_name(&self) -> &'static str {
        if self
            .keyring_entry()
            .and_then(|e| e.get_password().map_err(Into::into))
            .is_ok()
        {
            return "keyring";
        }
        if self.credential_file_path().exists() {
            "encrypted-file"
        } else {
            "none"
        }
    }

    // ── Keyring backend ──────────────────���─────────────────────────

    fn keyring_entry(&self) -> Result<keyring::Entry> {
        keyring::Entry::new(KEYRING_SERVICE, &self.username)
            .context("Could not open the OS keyring entry")
    }

    fn keyring_store(&self, password: &str) -> Result<()> {
        let entry = self.keyring_entry()?;
        entry
            .set_password(password)
            .context("Could not store password in the OS keyring")
    }

    fn keyring_retrieve(&self) -> Result<Option<SecretString>> {
        let entry = self.keyring_entry()?;
        match entry.get_password() {
            Ok(pw) => Ok(Some(SecretString::from(pw))),
            Err(keyring::Error::NoEntry) => Ok(None),
            Err(e) => {
                Err(anyhow::anyhow!(e).context("Could not read password from the OS keyring"))
            }
        }
    }

    fn keyring_delete_outcome(&self) -> DeleteOutcome {
        let entry = match self.keyring_entry() {
            Ok(entry) => entry,
            Err(e) => return DeleteOutcome::Failed(e),
        };
        match entry.delete_credential() {
            Ok(()) => DeleteOutcome::Deleted,
            Err(e) => keyring_delete_error_outcome(e),
        }
    }

    // ── Encrypted file backend ─────────────────────────────────────

    fn key_file_path(&self) -> PathBuf {
        self.config_dir.join(".kei-state")
    }

    fn credential_file_path(&self) -> PathBuf {
        let sanitized = crate::account::namespace(&self.username, &self.realm);
        self.config_dir.join(format!("{sanitized}.credential"))
    }

    /// Load or generate the AES-256 key.
    fn load_or_create_key(&self) -> Result<[u8; 32]> {
        let key_path = self.key_file_path();
        if key_path.exists() {
            let data = std::fs::read(&key_path).with_context(|| {
                format!("Could not read credential key file {}", key_path.display())
            })?;
            anyhow::ensure!(
                data.len() == 32,
                "Credential key file is corrupt: expected 32 bytes, got {}.",
                data.len()
            );
            let mut key = [0u8; 32];
            key.copy_from_slice(&data);
            Ok(key)
        } else {
            let key: [u8; 32] = rand::random();
            atomic_write_sync(&key_path, &key)?;
            Ok(key)
        }
    }

    fn file_store(&self, password: &str) -> Result<()> {
        std::fs::create_dir_all(&self.config_dir).with_context(|| {
            format!(
                "Could not create config directory {}",
                self.config_dir.display()
            )
        })?;

        let key_bytes = self.load_or_create_key()?;
        let cipher = Aes256Gcm::new_from_slice(&key_bytes)
            .context("Could not initialize credential encryption")?;
        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
        let ciphertext = cipher
            .encrypt(&nonce, password.as_bytes())
            .map_err(|e| anyhow::anyhow!("Could not encrypt credential: {e}"))?;

        // File format: 12-byte nonce ‖ ciphertext
        let mut data = Vec::with_capacity(12 + ciphertext.len());
        data.extend_from_slice(&nonce);
        data.extend_from_slice(&ciphertext);

        atomic_write_sync(&self.credential_file_path(), &data)
    }

    fn file_retrieve(&self) -> Result<Option<SecretString>> {
        let cred_path = self.credential_file_path();
        if !cred_path.exists() {
            return Ok(None);
        }

        let key_path = self.key_file_path();
        anyhow::ensure!(
            key_path.exists(),
            "Credential file exists but key file is missing: {}",
            key_path.display()
        );

        let key_bytes = self.load_or_create_key()?;
        let data = std::fs::read(&cred_path)
            .with_context(|| format!("Could not read credential file {}", cred_path.display()))?;

        anyhow::ensure!(
            data.len() > 12,
            "Credential file is corrupt or incomplete: {}",
            cred_path.display()
        );

        let (nonce_bytes, ciphertext) = data.split_at(12);
        let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
        let cipher = Aes256Gcm::new_from_slice(&key_bytes)
            .context("Could not initialize credential decryption")?;
        let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|_e| {
            anyhow::anyhow!("Could not decrypt stored credential. The key may be wrong or the credential file may be corrupt.")
        })?;

        let password =
            String::from_utf8(plaintext).context("Stored credential is not valid UTF-8")?;
        Ok(Some(SecretString::from(password)))
    }

    #[cfg(test)]
    fn file_delete(&self) -> Result<()> {
        match self.file_delete_outcome() {
            DeleteOutcome::Deleted => Ok(()),
            DeleteOutcome::NotFound => {
                anyhow::bail!(
                    "No credential file found at {}",
                    self.credential_file_path().display()
                )
            }
            DeleteOutcome::Failed(e) => Err(e),
        }
    }

    fn file_delete_outcome(&self) -> DeleteOutcome {
        let cred_path = self.credential_file_path();
        if !cred_path.exists() {
            return DeleteOutcome::NotFound;
        }
        match std::fs::remove_file(&cred_path) {
            Ok(()) => DeleteOutcome::Deleted,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => DeleteOutcome::NotFound,
            Err(e) => DeleteOutcome::Failed(anyhow::anyhow!(e).context(format!(
                "Could not delete credential file {}",
                cred_path.display()
            ))),
        }
    }
}

fn keyring_delete_error_outcome(error: keyring::Error) -> DeleteOutcome {
    match error {
        keyring::Error::NoEntry => DeleteOutcome::NotFound,
        keyring::Error::PlatformFailure(e) if is_keyring_platform_unavailable(e.as_ref()) => {
            tracing::debug!(
                error = %e,
                "Keyring unavailable during credential deletion; treating it as absent"
            );
            DeleteOutcome::NotFound
        }
        e => DeleteOutcome::Failed(
            anyhow::anyhow!(e).context("Could not delete the OS keyring credential"),
        ),
    }
}

fn is_keyring_platform_unavailable(error: &(dyn std::error::Error + Send + Sync)) -> bool {
    let message = error.to_string();
    message.contains("org.freedesktop.secrets")
        || message.contains("The name is not activatable")
        || (message.contains("Failed to connect")
            && message.contains("/run/user/")
            && message.contains("/bus"))
}

fn finish_delete(
    username: &str,
    keyring: DeleteOutcome,
    encrypted_file: DeleteOutcome,
) -> Result<()> {
    let mut deleted = Vec::new();
    let mut failed = Vec::new();

    match keyring {
        DeleteOutcome::Deleted => deleted.push("keyring"),
        DeleteOutcome::NotFound => {}
        DeleteOutcome::Failed(e) => failed.push(("keyring", e)),
    }
    match encrypted_file {
        DeleteOutcome::Deleted => deleted.push("encrypted-file"),
        DeleteOutcome::NotFound => {}
        DeleteOutcome::Failed(e) => failed.push(("encrypted-file", e)),
    }

    if failed.is_empty() {
        if deleted.is_empty() {
            anyhow::bail!("No stored credential was found for {username}.");
        }
        return Ok(());
    }

    let failed_names = failed
        .iter()
        .map(|(name, _)| *name)
        .collect::<Vec<_>>()
        .join(", ");
    let details = failed
        .into_iter()
        .map(|(name, err)| format!("{name}: {err:#}"))
        .collect::<Vec<_>>()
        .join("; ");
    if deleted.is_empty() {
        anyhow::bail!("Could not delete credential from {failed_names}: {details}");
    }
    anyhow::bail!(
        "Credential was deleted from {}, but kei could not delete it from {failed_names}: {details}",
        deleted.join(", ")
    );
}

/// Atomically write data to a file with 0o600 permissions (synchronous).
///
/// The temp file is fsynced before the rename and the parent directory is
/// fsynced afterwards (Unix only) so a power loss between the rename
/// returning and the kernel committing data + directory blocks can't leave
/// the credential file pointing at uninitialised content. A corrupt
/// credential file forces the user to re-enter their password (loud
/// recovery), which is acceptable; an empty-or-garbage credential file
/// silently failing decrypt is the failure mode this avoids.
fn atomic_write_sync(path: &Path, data: &[u8]) -> Result<()> {
    use std::io::Write;

    let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
    tmp_name.push(".tmp");
    let tmp = path.with_file_name(tmp_name);
    {
        let mut f = std::fs::OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .open(&tmp)
            .with_context(|| format!("Failed to open temp file: {}", tmp.display()))?;
        f.write_all(data)
            .with_context(|| format!("Failed to write temp file: {}", tmp.display()))?;
        f.sync_all()
            .with_context(|| format!("Failed to fsync temp file: {}", tmp.display()))?;
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
    }
    std::fs::rename(&tmp, path)
        .with_context(|| format!("Failed to rename {} to {}", tmp.display(), path.display()))?;
    if let Err(e) = crate::fs_util::fsync_parent_dir(path) {
        tracing::warn!(
            path = %path.display(),
            error = %e,
            "fsync of parent directory failed after atomic_write_sync"
        );
    }
    Ok(())
}

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

    /// Each test gets its own temp directory to avoid parallel test
    /// interference with the shared `.kei-state` key file.
    fn test_dir(_name: &str) -> (tempfile::TempDir, PathBuf) {
        let td = tempfile::tempdir().unwrap();
        let path = td.path().to_path_buf();
        (td, path)
    }

    #[test]
    fn encrypted_namespace_does_not_read_copy_or_delete_colliding_legacy_credentials() {
        let directory = tempfile::tempdir().unwrap();
        let a = "first.last@example.invalid";
        let b = "firstlast@example.invalid";
        let legacy = directory.path().join("firstlastexampleinvalid.credential");
        std::fs::write(&legacy, b"synthetic legacy ciphertext").unwrap();
        let store = CredentialStore::new(a, directory.path(), "com");
        let other = CredentialStore::new(b, directory.path(), "com");
        let china = CredentialStore::new(a, directory.path(), "cn");
        assert!(store.file_retrieve().unwrap().is_none());
        assert_ne!(store.credential_file_path(), other.credential_file_path());
        assert_ne!(store.credential_file_path(), china.credential_file_path());
        store.file_store("synthetic new password").unwrap();
        assert!(other.file_retrieve().unwrap().is_none());
        assert!(china.file_retrieve().unwrap().is_none());
        store.file_delete_outcome();
        assert_eq!(
            std::fs::read(legacy).unwrap(),
            b"synthetic legacy ciphertext"
        );
    }

    #[test]
    fn encrypted_file_store_retrieve_cycle() {
        let (_td, dir) = test_dir("store_retrieve");
        let store = CredentialStore::new("user@example.com", &dir, "com");
        store.file_store("super_secret_pw").unwrap();
        let retrieved = store.file_retrieve().unwrap().unwrap();
        assert_eq!(retrieved.expose_secret(), "super_secret_pw");
    }

    #[test]
    fn encrypted_file_missing_returns_none() {
        let (_td, dir) = test_dir("missing");
        let store = CredentialStore::new("user@example.com", &dir, "com");
        let result = store.file_retrieve().unwrap();
        assert!(result.is_none());
    }

    #[test]
    fn encrypted_file_delete() {
        let (_td, dir) = test_dir("delete");
        let store = CredentialStore::new("user@example.com", &dir, "com");
        store.file_store("to_be_deleted").unwrap();
        assert!(store.credential_file_path().exists());

        store.file_delete().unwrap();
        assert!(!store.credential_file_path().exists());
        // Key file intentionally preserved
        assert!(store.key_file_path().exists());
    }

    #[test]
    fn delete_outcome_file_only_success_reports_backend_state() {
        let cases = [
            (
                "only encrypted-file deleted",
                DeleteOutcome::NotFound,
                DeleteOutcome::Deleted,
            ),
            (
                "only keyring deleted",
                DeleteOutcome::Deleted,
                DeleteOutcome::NotFound,
            ),
        ];

        for (label, keyring, file) in cases {
            finish_delete("user@example.com", keyring, file)
                .unwrap_or_else(|err| panic!("{label} must be treated as success: {err}"));
        }

        let partial = finish_delete(
            "user@example.com",
            DeleteOutcome::Failed(anyhow::anyhow!("keyring locked")),
            DeleteOutcome::Deleted,
        )
        .unwrap_err()
        .to_string();
        assert!(
            partial.contains("deleted from encrypted-file")
                && partial.contains("could not delete it from keyring"),
            "partial delete must preserve which backend succeeded and which failed: {partial}"
        );

        let missing = finish_delete(
            "user@example.com",
            DeleteOutcome::NotFound,
            DeleteOutcome::NotFound,
        )
        .unwrap_err()
        .to_string();
        assert!(
            missing.contains("No stored credential was found"),
            "two missing backends must not be treated as file-only success: {missing}"
        );
    }

    #[test]
    fn delete_outcome_both_missing_reports_no_stored_credential() {
        let err = finish_delete(
            "user@example.com",
            DeleteOutcome::NotFound,
            DeleteOutcome::NotFound,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("No stored credential was found"),
            "expected no-credential error, got: {err}"
        );
    }

    #[test]
    fn delete_outcome_deleted_plus_failure_reports_partial_backend() {
        let err = finish_delete(
            "user@example.com",
            DeleteOutcome::Deleted,
            DeleteOutcome::Failed(anyhow::anyhow!("permission denied")),
        )
        .unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("could not delete it from encrypted-file"),
            "partial failure must name the failed backend: {msg}"
        );
    }

    #[test]
    fn delete_outcome_failure_without_delete_names_backend() {
        let err = finish_delete(
            "user@example.com",
            DeleteOutcome::Failed(anyhow::anyhow!("keyring locked")),
            DeleteOutcome::NotFound,
        )
        .unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("keyring") && msg.contains("keyring locked"),
            "failed deletion must name backend and source error: {msg}"
        );
    }

    #[test]
    fn unavailable_secret_service_delete_is_not_found() {
        for message in [
            "DBus error: The name org.freedesktop.secrets was not provided by any .service files",
            "DBus error: The name is not activatable",
        ] {
            let outcome = keyring_delete_error_outcome(keyring::Error::PlatformFailure(Box::new(
                std::io::Error::other(message),
            )));
            assert!(
                matches!(outcome, DeleteOutcome::NotFound),
                "headless Secret Service delete should behave like an absent keyring"
            );
        }
    }

    #[test]
    fn inaccessible_keyring_delete_still_fails() {
        let outcome = keyring_delete_error_outcome(keyring::Error::NoStorageAccess(Box::new(
            std::io::Error::other("keyring locked"),
        )));
        assert!(
            matches!(outcome, DeleteOutcome::Failed(_)),
            "locked keyrings should not be treated as absent"
        );
    }

    #[test]
    fn encrypted_file_corrupt_data() {
        let (_td, dir) = test_dir("corrupt");
        let store = CredentialStore::new("user@example.com", &dir, "com");
        store.file_store("valid").unwrap();
        // Overwrite credential with garbage (too short)
        std::fs::write(store.credential_file_path(), b"short").unwrap();
        let err = store.file_retrieve().unwrap_err();
        assert!(err.to_string().contains("corrupt"), "{err}");
    }

    #[test]
    fn encrypted_file_wrong_key() {
        let (_td, dir) = test_dir("wrong_key");
        let store = CredentialStore::new("user@example.com", &dir, "com");
        store.file_store("secret").unwrap();
        // Overwrite key with different random key
        let bad_key: [u8; 32] = rand::random();
        std::fs::write(store.key_file_path(), bad_key).unwrap();
        let err = store.file_retrieve().unwrap_err();
        assert!(
            err.to_string().contains("decrypt"),
            "Expected decryption error, got: {err}"
        );
    }

    #[test]
    fn encrypted_file_rejects_valid_decrypt_invalid_utf8() {
        let (_td, dir) = test_dir("invalid_utf8");
        let store = CredentialStore::new("user@example.com", &dir, "com");
        std::fs::create_dir_all(&dir).unwrap();

        let key_bytes = store.load_or_create_key().unwrap();
        let cipher = Aes256Gcm::new_from_slice(&key_bytes).unwrap();
        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
        let ciphertext = cipher.encrypt(&nonce, [0xff, 0xfe].as_slice()).unwrap();
        let mut data = Vec::with_capacity(12 + ciphertext.len());
        data.extend_from_slice(&nonce);
        data.extend_from_slice(&ciphertext);
        atomic_write_sync(&store.credential_file_path(), &data).unwrap();

        let err = store.file_retrieve().unwrap_err();
        assert!(
            err.to_string().contains("valid UTF-8"),
            "invalid UTF-8 plaintext must be rejected loudly, got: {err}"
        );
    }

    #[test]
    fn encrypted_file_key_generation() {
        let (_td, dir) = test_dir("keygen");
        let store = CredentialStore::new("user@example.com", &dir, "com");
        assert!(!store.key_file_path().exists());

        store.file_store("pw").unwrap();
        assert!(store.key_file_path().exists());

        let key = std::fs::read(store.key_file_path()).unwrap();
        assert_eq!(key.len(), 32);
    }

    #[test]
    fn legacy_credential_key_is_not_auto_renamed() {
        let (_td, dir) = test_dir("legacy_key");
        let legacy_path = dir.join(".credential-key");
        std::fs::write(&legacy_path, [7u8; 32]).unwrap();

        let store = CredentialStore::new("user@example.com", &dir, "com");
        let key_path = store.key_file_path();
        assert_eq!(key_path, dir.join(".kei-state"));
        assert!(
            legacy_path.exists(),
            "legacy .credential-key must be left for manual user migration"
        );
        assert!(
            !key_path.exists(),
            ".kei-state must not be created or renamed by path lookup"
        );

        store.load_or_create_key().unwrap();
        assert!(
            legacy_path.exists(),
            "creating .kei-state must not consume legacy .credential-key"
        );
        assert!(key_path.exists());
        assert_ne!(
            std::fs::read(key_path).unwrap(),
            vec![7u8; 32],
            ".kei-state must not reuse the legacy key bytes automatically"
        );
    }

    #[test]
    fn has_credential_with_file() {
        let (_td, dir) = test_dir("has_cred");
        let store = CredentialStore::new("user@example.com", &dir, "com");
        assert!(!store.credential_file_path().exists());

        store.file_store("pw").unwrap();
        assert!(store.has_credential());
    }

    #[test]
    fn public_api_store_retrieve_round_trips() {
        let (_td, dir) = test_dir("pub_rt");
        let store = CredentialStore::new("pub-rt@kei-test.invalid", &dir, "com");
        store.store("public_api_password").unwrap();
        let retrieved = store.retrieve().unwrap().unwrap();
        assert_eq!(retrieved.expose_secret(), "public_api_password");
        let _ = store.delete();
    }

    #[test]
    fn public_api_delete_clears_credential() {
        let (_td, dir) = test_dir("pub_delete");
        let store = CredentialStore::new("pub-del@kei-test.invalid", &dir, "com");
        store.store("to_delete").unwrap();
        assert!(store.retrieve().unwrap().is_some());

        let delete_result = store.delete();
        if let Err(err) = delete_result {
            assert!(
                err.to_string().contains("partially failed") && err.to_string().contains("keyring"),
                "headless keyring failures should surface as partial deletion, got: {err}"
            );
            assert!(
                !store.credential_file_path().exists(),
                "partial delete should still remove the encrypted-file credential"
            );
        }
        assert!(store.retrieve().unwrap().is_none());
    }

    #[test]
    fn public_api_retrieve_empty_returns_none() {
        let (_td, dir) = test_dir("pub_empty");
        let store = CredentialStore::new("pub-empty@kei-test.invalid", &dir, "com");
        let result = store.retrieve().unwrap();
        assert!(result.is_none());
    }

    #[test]
    fn atomic_write_sync_permissions() {
        let (_td, dir) = test_dir("atomic_perms");
        let path = dir.join("test_atomic.bin");
        atomic_write_sync(&path, b"test data").unwrap();

        #[cfg(unix)]
        {
            use std::os::unix::fs::MetadataExt;
            let mode = std::fs::metadata(&path).unwrap().mode();
            assert_eq!(mode & 0o777, 0o600, "Expected 0o600, got {mode:o}");
        }
    }
}