Skip to main content

conclavelib/
identity.rs

1//! Local identity and keystore: the per-machine keypair, signing, and on-disk state.
2//!
3//! Owns everything under `~/.config/conclave` (`Constant::CONFIG_DIR_NAME`): the machine's own
4//! Ed25519 keypair (the 32-byte seed wrapped in a `secrecy` `SecretBox`, never logged), and the
5//! local `config.toml` — known-server registrations (username + machine name) and the permission
6//! config (default level + per-`(server, channel)` / whisper overrides, DESIGN.md §9).
7//!
8//! Auth is challenge-response: the machine signs a server-issued nonce and the server resolves the
9//! public key to a `(user, machine)` (DESIGN.md §5). Keys are generated from OS entropy via `ring`'s
10//! system RNG and the key pair is reconstructed from the seed on demand, so the private seed is only
11//! ever materialized transiently for a signature.
12
13use std::{
14    fmt, fs,
15    path::{Path, PathBuf},
16};
17
18use anyhow::Context as _;
19use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
20use ring::{
21    rand::{SecureRandom as _, SystemRandom},
22    signature::{self, Ed25519KeyPair, KeyPair as _, UnparsedPublicKey},
23};
24use secrecy::{
25    ExposeSecret as _, SecretBox,
26    zeroize::{Zeroize as _, Zeroizing},
27};
28use serde::{Deserialize, Serialize};
29
30use crate::{
31    base::{Constant, PermissionLevel, Res, SessionPath, Void},
32    protocol::ProtocolError,
33};
34
35/// Byte lengths of an Ed25519 private seed, public key, and signature.
36const SEED_LEN: usize = 32;
37const PUBLIC_KEY_LEN: usize = 32;
38const SIGNATURE_LEN: usize = 64;
39
40/// Errors at the authentication / identity boundary (DESIGN.md §16), matched on by the server.
41#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
42pub enum AuthError {
43    /// The presented public key is not enrolled on the server.
44    #[error("unknown machine key")]
45    UnknownKey,
46    /// The presented key has been revoked (lost-laptop kill switch, DESIGN.md §5.1).
47    #[error("machine key has been revoked")]
48    RevokedKey,
49    /// The signature did not verify against the challenge and public key.
50    #[error("signature verification failed")]
51    BadSignature,
52    /// A key or signature was the wrong length or otherwise unparseable.
53    #[error("malformed key or signature: {0}")]
54    Malformed(String),
55    /// The requested username is already claimed on this server.
56    #[error("username `{0}` is already taken")]
57    UsernameTaken(String),
58    /// The username is on the admin allowlist pinned to a different key (anti-squat, PRD-0007 §7).
59    #[error("username `{0}` is reserved for a different key")]
60    Reserved(String),
61}
62
63impl From<AuthError> for ProtocolError {
64    fn from(err: AuthError) -> Self {
65        let message = err.to_string();
66        match err {
67            AuthError::Malformed(_) => Self::MalformedFrame(message),
68            _ => Self::Unauthorized(message),
69        }
70    }
71}
72
73/// This machine's Ed25519 identity: a `secrecy`-wrapped seed plus the derived public key.
74///
75/// The seed never leaves the [`SecretBox`] except transiently to sign or to be persisted to the
76/// (0600) keyfile; the [`fmt::Debug`] impl redacts it.
77pub struct Identity {
78    secret_seed: SecretBox<[u8; SEED_LEN]>,
79    public_key: [u8; PUBLIC_KEY_LEN],
80}
81
82impl Identity {
83    /// Generates a fresh identity from operating-system entropy.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if the OS random source cannot be read or the seed is rejected.
88    pub fn generate() -> Res<Self> {
89        // The generated seed is zeroized on drop; `from_seed` zeroizes the copy it takes (#28).
90        let mut seed = Zeroizing::new([0_u8; SEED_LEN]);
91        SystemRandom::new().fill(&mut *seed).map_err(|_| anyhow::anyhow!("failed to gather entropy"))?;
92        Self::from_seed(*seed)
93    }
94
95    /// Reconstructs an identity from its 32-byte seed.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if the seed is rejected by the signing backend.
100    pub fn from_seed(mut seed: [u8; SEED_LEN]) -> Res<Self> {
101        let key_pair = Ed25519KeyPair::from_seed_unchecked(&seed).map_err(|e| anyhow::anyhow!("invalid Ed25519 seed: {e}"))?;
102        let public_key = key_pair.public_key().as_ref().try_into().context("unexpected public key length")?;
103
104        let identity = Self {
105            secret_seed: SecretBox::new(Box::new(seed)),
106            public_key,
107        };
108        // The `SecretBox` holds the only resident copy — wipe the plaintext argument (#28).
109        seed.zeroize();
110        Ok(identity)
111    }
112
113    /// This identity's raw 32-byte public key.
114    #[must_use]
115    pub fn public_key(&self) -> [u8; PUBLIC_KEY_LEN] {
116        self.public_key
117    }
118
119    /// This identity's public key as a URL-safe base64 string, for display and pasting.
120    #[must_use]
121    pub fn public_key_base64(&self) -> String {
122        encode_key(&self.public_key)
123    }
124
125    /// Signs `message` (e.g. a server challenge nonce), returning the 64-byte signature.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if the signing key cannot be reconstructed from the seed.
130    pub fn sign(&self, message: &[u8]) -> Res<[u8; SIGNATURE_LEN]> {
131        // Reconstruct the key pair transiently so the private seed is not held resident beyond signing.
132        let key_pair = Ed25519KeyPair::from_seed_unchecked(self.secret_seed.expose_secret()).map_err(|e| anyhow::anyhow!("failed to load signing key: {e}"))?;
133        key_pair.sign(message).as_ref().try_into().context("unexpected signature length")
134    }
135
136    /// The base64 seed, materialized only to persist the keyfile.
137    fn secret_seed_base64(&self) -> String {
138        encode_key(self.secret_seed.expose_secret())
139    }
140}
141
142impl fmt::Debug for Identity {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        f.debug_struct("Identity").field("public_key", &self.public_key_base64()).field("secret_seed", &"<redacted>").finish()
145    }
146}
147
148/// Verifies `signature` over `message` against the raw `public_key`.
149///
150/// # Errors
151///
152/// Returns [`AuthError::Malformed`] if the key or signature is the wrong length, or
153/// [`AuthError::BadSignature`] if verification fails.
154pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result<(), AuthError> {
155    if public_key.len() != PUBLIC_KEY_LEN {
156        return Err(AuthError::Malformed("public key must be 32 bytes".to_owned()));
157    }
158    if signature.len() != SIGNATURE_LEN {
159        return Err(AuthError::Malformed("signature must be 64 bytes".to_owned()));
160    }
161
162    UnparsedPublicKey::new(&signature::ED25519, public_key).verify(message, signature).map_err(|_| AuthError::BadSignature)
163}
164
165/// Generates a fresh random challenge nonce (server-side, DESIGN.md §5).
166///
167/// # Errors
168///
169/// Returns an error if the OS random source cannot be read.
170pub fn generate_challenge() -> Res<[u8; Constant::CHALLENGE_SIZE]> {
171    let mut nonce = [0_u8; Constant::CHALLENGE_SIZE];
172    SystemRandom::new().fill(&mut nonce).map_err(|_| anyhow::anyhow!("failed to gather entropy"))?;
173    Ok(nonce)
174}
175
176/// Mints a fresh, opaque invite token: 24 bytes of OS entropy, URL-safe base64 (DESIGN.md §6).
177///
178/// # Errors
179///
180/// Returns an error if the OS random source cannot be read.
181pub fn generate_token() -> Res<String> {
182    let mut bytes = [0_u8; 24];
183    SystemRandom::new().fill(&mut bytes).map_err(|_| anyhow::anyhow!("failed to gather entropy"))?;
184    Ok(encode_key(&bytes))
185}
186
187/// Encodes raw key bytes as a URL-safe base64 string — the canonical on-wire / on-disk key
188/// encoding, symmetric with [`decode_key`]. The server stores machine public keys this way.
189#[must_use]
190pub fn encode_key(bytes: &[u8]) -> String {
191    URL_SAFE_NO_PAD.encode(bytes)
192}
193
194/// Decodes a URL-safe base64 key string back to bytes.
195///
196/// # Errors
197///
198/// Returns [`AuthError::Malformed`] if the string is not valid base64.
199pub fn decode_key(text: &str) -> Result<Vec<u8>, AuthError> {
200    URL_SAFE_NO_PAD.decode(text.trim()).map_err(|e| AuthError::Malformed(e.to_string()))
201}
202
203// ---------------------------------------------------------------------------
204// On-disk local configuration (`~/.config/conclave/config.toml`).
205// ---------------------------------------------------------------------------
206
207/// This machine's registration on one server (DESIGN.md §5).
208#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
209pub struct ServerRegistration {
210    /// The server URL.
211    pub url: String,
212    /// The username claimed on that server.
213    pub username: String,
214    /// The machine name this key is enrolled under.
215    pub machine: String,
216}
217
218/// A local permission override, keyed by `(server, scope)` (DESIGN.md §9).
219#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
220pub struct PermissionOverride {
221    /// The server the override applies to.
222    pub server: String,
223    /// The channel the override applies to; `None` denotes the whisper scope.
224    #[serde(default)]
225    pub channel: Option<String>,
226    /// The autonomy level for that scope.
227    pub level: PermissionLevel,
228}
229
230/// The scope a permission level resolves for.
231#[derive(Clone, Debug, PartialEq, Eq)]
232pub enum Scope {
233    /// A named channel.
234    Channel(String),
235    /// The whisper (direct-message) scope.
236    Whisper,
237}
238
239/// The local machine configuration: identity-adjacent state and the permission policy.
240#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
241pub struct Config {
242    /// The machine-wide default autonomy level (ships `notify`).
243    #[serde(default)]
244    pub default_permission: PermissionLevel,
245    /// Known-server registrations.
246    #[serde(default)]
247    pub servers: Vec<ServerRegistration>,
248    /// Per-`(server, scope)` overrides.
249    #[serde(default)]
250    pub overrides: Vec<PermissionOverride>,
251}
252
253impl Config {
254    /// Resolves the autonomy level for a `(server, scope)`: a matching override, else the default.
255    #[must_use]
256    pub fn resolve_permission(&self, server: &str, scope: &Scope) -> PermissionLevel {
257        let target_channel = match scope {
258            Scope::Channel(name) => Some(name.as_str()),
259            Scope::Whisper => None,
260        };
261
262        self.overrides
263            .iter()
264            .find(|o| o.server == server && o.channel.as_deref() == target_channel)
265            .map_or(self.default_permission, |o| o.level)
266    }
267}
268
269/// The default keystore / config directory, `~/.config/conclave` (`Constant::CONFIG_DIR_NAME`).
270///
271/// # Errors
272///
273/// Returns an error if the OS configuration directory cannot be determined.
274pub fn default_config_dir() -> Res<PathBuf> {
275    let base = dirs::config_dir().context("could not determine the OS configuration directory")?;
276    Ok(base.join(Constant::CONFIG_DIR_NAME))
277}
278
279/// Writes the identity's seed to `dir/key` with owner-only (0600) permissions.
280///
281/// # Errors
282///
283/// Returns an error if the directory or file cannot be created or written.
284pub fn save_identity(dir: &Path, identity: &Identity) -> Void {
285    use std::io::Write as _;
286
287    fs::create_dir_all(dir).with_context(|| format!("failed to create keystore directory `{}`", dir.display()))?;
288
289    let key_file = dir.join("key");
290    // Create with owner-only mode from the start so the seed is never briefly world-readable (#33);
291    // the base64 seed is wiped once written (#28).
292    let seed_b64 = Zeroizing::new(identity.secret_seed_base64());
293    let mut file = create_key_file(&key_file)?;
294    file.write_all(seed_b64.as_bytes()).with_context(|| format!("failed to write keyfile `{}`", key_file.display()))?;
295    // Belt-and-suspenders: fix the mode if the file already existed with looser permissions.
296    restrict_permissions(&key_file)?;
297
298    Ok(())
299}
300
301/// Opens `dir/key` for writing, creating it with owner-only (0600) permissions on unix so the seed
302/// is never written through a world-readable file (PRD-0008 T-007, #33).
303#[cfg(unix)]
304fn create_key_file(path: &Path) -> Res<fs::File> {
305    use std::os::unix::fs::OpenOptionsExt as _;
306    fs::OpenOptions::new()
307        .write(true)
308        .create(true)
309        .truncate(true)
310        .mode(0o600)
311        .open(path)
312        .with_context(|| format!("failed to create keyfile `{}`", path.display()))
313}
314
315#[cfg(not(unix))]
316fn create_key_file(path: &Path) -> Res<fs::File> {
317    fs::OpenOptions::new()
318        .write(true)
319        .create(true)
320        .truncate(true)
321        .open(path)
322        .with_context(|| format!("failed to create keyfile `{}`", path.display()))
323}
324
325/// Loads the identity whose seed is stored at `dir/key`.
326///
327/// # Errors
328///
329/// Returns an error if the keyfile is missing or does not contain a valid 32-byte seed.
330pub fn load_identity(dir: &Path) -> Res<Identity> {
331    let key_file = dir.join("key");
332    // Wipe every plaintext copy of the seed material on the way in (#28).
333    let contents = Zeroizing::new(fs::read_to_string(&key_file).with_context(|| format!("failed to read keyfile `{}` (run `conclave key` first)", key_file.display()))?);
334
335    let mut seed_bytes = decode_key(&contents)?;
336    let mut seed: [u8; SEED_LEN] = seed_bytes.as_slice().try_into().context("keyfile does not contain a 32-byte seed")?;
337    seed_bytes.zeroize();
338
339    let identity = Identity::from_seed(seed);
340    seed.zeroize();
341    identity
342}
343
344/// Writes the local configuration to `dir/config.toml`.
345///
346/// # Errors
347///
348/// Returns an error if the config cannot be serialized or written.
349pub fn save_config(dir: &Path, config: &Config) -> Void {
350    fs::create_dir_all(dir).with_context(|| format!("failed to create config directory `{}`", dir.display()))?;
351
352    let path = dir.join("config.toml");
353    let text = toml::to_string_pretty(config).context("failed to serialize config")?;
354    atomic_write(&path, &text)?;
355
356    Ok(())
357}
358
359/// Writes `contents` to `path` atomically: a per-process temp file in the same directory, then a
360/// rename. A crash mid-write can only truncate the temp — the live file is replaced in one atomic
361/// step, so it is never left partial to brick every later verb (PRD-0008 T-006, #26).
362fn atomic_write(path: &Path, contents: &str) -> Void {
363    let name = path.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("conclave");
364    let tmp = path.with_file_name(format!("{name}.{}.tmp", std::process::id()));
365    fs::write(&tmp, contents).with_context(|| format!("failed to write temp file `{}`", tmp.display()))?;
366    fs::rename(&tmp, path).with_context(|| format!("failed to replace `{}`", path.display()))?;
367    Ok(())
368}
369
370/// Loads the local configuration from `dir/config.toml`, returning the default if it is absent.
371///
372/// # Errors
373///
374/// Returns an error if the config file exists but cannot be read or parsed.
375pub fn load_config(dir: &Path) -> Res<Config> {
376    let path = dir.join("config.toml");
377    if !path.exists() {
378        return Ok(Config::default());
379    }
380
381    let text = fs::read_to_string(&path).with_context(|| format!("failed to read config `{}`", path.display()))?;
382    toml::from_str(&text).with_context(|| format!("failed to parse config `{}`", path.display()))
383}
384
385#[cfg(unix)]
386fn restrict_permissions(path: &Path) -> Void {
387    use std::os::unix::fs::PermissionsExt as _;
388    fs::set_permissions(path, fs::Permissions::from_mode(0o600)).with_context(|| format!("failed to restrict permissions on `{}`", path.display()))
389}
390
391#[cfg(not(unix))]
392fn restrict_permissions(_path: &Path) -> Void {
393    // Best-effort only; non-unix platforms rely on the user profile directory's ACLs.
394    Ok(())
395}
396
397/// The default session handle for a working directory — its final path component (DESIGN.md §5).
398#[must_use]
399pub fn default_handle(working_dir: &Path) -> String {
400    working_dir.file_name().map_or_else(|| "session".to_owned(), |name| name.to_string_lossy().into_owned())
401}
402
403/// Builds a full participant path from a registration and a session handle.
404#[must_use]
405pub fn session_path(registration: &ServerRegistration, handle: &str) -> SessionPath {
406    SessionPath::new(registration.username.clone(), registration.machine.clone(), handle.to_owned())
407}
408
409#[cfg(test)]
410mod tests {
411    // Tests relax `unwrap_used` (house convention; DESIGN.md §22).
412    #![allow(clippy::unwrap_used)]
413
414    use super::*;
415    use pretty_assertions::assert_eq;
416    use tempfile::TempDir;
417
418    #[test]
419    fn signs_and_verifies_a_server_challenge() {
420        let identity = Identity::generate().unwrap();
421        let challenge = generate_challenge().unwrap();
422
423        let signature = identity.sign(&challenge).unwrap();
424
425        verify(&identity.public_key(), &challenge, &signature).unwrap();
426    }
427
428    #[test]
429    fn verification_rejects_a_wrong_key() {
430        let signer = Identity::generate().unwrap();
431        let impostor = Identity::generate().unwrap();
432        let challenge = generate_challenge().unwrap();
433        let signature = signer.sign(&challenge).unwrap();
434
435        assert_eq!(verify(&impostor.public_key(), &challenge, &signature), Err(AuthError::BadSignature));
436    }
437
438    #[test]
439    fn verification_rejects_a_tampered_message_or_signature() {
440        let identity = Identity::generate().unwrap();
441        let challenge = generate_challenge().unwrap();
442        let mut signature = identity.sign(&challenge).unwrap();
443
444        let mut tampered_challenge = challenge;
445        tampered_challenge[0] ^= 0xFF;
446        assert_eq!(verify(&identity.public_key(), &tampered_challenge, &signature), Err(AuthError::BadSignature));
447
448        signature[0] ^= 0xFF;
449        assert_eq!(verify(&identity.public_key(), &challenge, &signature), Err(AuthError::BadSignature));
450    }
451
452    #[test]
453    fn verification_rejects_a_malformed_key() {
454        let identity = Identity::generate().unwrap();
455        let challenge = generate_challenge().unwrap();
456        let signature = identity.sign(&challenge).unwrap();
457
458        assert!(matches!(verify(&[0_u8; 8], &challenge, &signature), Err(AuthError::Malformed(_))));
459    }
460
461    #[test]
462    fn debug_never_reveals_the_secret_seed() {
463        let seed = [7_u8; SEED_LEN];
464        let identity = Identity::from_seed(seed).unwrap();
465        let rendered = format!("{identity:?}");
466
467        assert!(!rendered.contains(&encode_key(&seed)), "debug output leaked the secret seed: {rendered}");
468        assert!(rendered.contains("redacted"));
469        assert!(rendered.contains(&identity.public_key_base64()));
470    }
471
472    #[test]
473    fn challenges_are_random() {
474        assert_ne!(generate_challenge().unwrap(), generate_challenge().unwrap());
475    }
476
477    #[test]
478    fn invite_tokens_are_random_and_url_safe() {
479        let a = generate_token().unwrap();
480        let b = generate_token().unwrap();
481        assert_ne!(a, b);
482        assert!(!a.is_empty());
483        // URL-safe base64 (no `+`, `/`, or `=` padding).
484        assert!(a.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_'), "token is not URL-safe: {a}");
485    }
486
487    #[test]
488    fn keystore_round_trips_the_identity() {
489        let dir = TempDir::new().unwrap();
490        let identity = Identity::generate().unwrap();
491
492        save_identity(dir.path(), &identity).unwrap();
493        let loaded = load_identity(dir.path()).unwrap();
494
495        assert_eq!(loaded.public_key(), identity.public_key());
496
497        // The reloaded key still produces verifiable signatures.
498        let challenge = generate_challenge().unwrap();
499        verify(&loaded.public_key(), &challenge, &loaded.sign(&challenge).unwrap()).unwrap();
500    }
501
502    #[cfg(unix)]
503    #[test]
504    fn keyfile_is_owner_only() {
505        use std::os::unix::fs::PermissionsExt as _;
506
507        let dir = TempDir::new().unwrap();
508        save_identity(dir.path(), &Identity::generate().unwrap()).unwrap();
509
510        let mode = fs::metadata(dir.path().join("key")).unwrap().permissions().mode();
511        assert_eq!(mode & 0o777, 0o600);
512    }
513
514    #[test]
515    fn permission_resolution_prefers_the_most_specific_override() {
516        let config = Config {
517            default_permission: PermissionLevel::Notify,
518            servers: vec![],
519            overrides: vec![
520                PermissionOverride {
521                    server: "s1".to_owned(),
522                    channel: Some("ops".to_owned()),
523                    level: PermissionLevel::Act,
524                },
525                PermissionOverride {
526                    server: "s1".to_owned(),
527                    channel: None,
528                    level: PermissionLevel::Converse,
529                },
530            ],
531        };
532
533        assert_eq!(config.resolve_permission("s1", &Scope::Channel("ops".to_owned())), PermissionLevel::Act);
534        assert_eq!(config.resolve_permission("s1", &Scope::Whisper), PermissionLevel::Converse);
535        // No matching override falls back to the machine default.
536        assert_eq!(config.resolve_permission("s1", &Scope::Channel("other".to_owned())), PermissionLevel::Notify);
537        assert_eq!(config.resolve_permission("s2", &Scope::Channel("ops".to_owned())), PermissionLevel::Notify);
538    }
539
540    #[test]
541    fn config_round_trips_through_toml_with_lowercase_levels() {
542        let dir = TempDir::new().unwrap();
543        let config = Config {
544            default_permission: PermissionLevel::Notify,
545            servers: vec![ServerRegistration {
546                url: "wss://s1".to_owned(),
547                username: "aaron".to_owned(),
548                machine: "workstation".to_owned(),
549            }],
550            overrides: vec![PermissionOverride {
551                server: "wss://s1".to_owned(),
552                channel: Some("ops".to_owned()),
553                level: PermissionLevel::Act,
554            }],
555        };
556
557        save_config(dir.path(), &config).unwrap();
558        let text = fs::read_to_string(dir.path().join("config.toml")).unwrap();
559        assert!(text.contains("notify"), "levels should serialize lowercase: {text}");
560        assert!(text.contains("act"));
561
562        assert_eq!(load_config(dir.path()).unwrap(), config);
563    }
564
565    #[test]
566    fn load_config_defaults_when_absent() {
567        let dir = TempDir::new().unwrap();
568        assert_eq!(load_config(dir.path()).unwrap(), Config::default());
569    }
570
571    #[test]
572    fn save_config_atomic_round_trips_and_leaves_no_temp() {
573        let dir = TempDir::new().unwrap();
574        let config = Config {
575            default_permission: PermissionLevel::Converse,
576            ..Config::default()
577        };
578
579        save_config(dir.path(), &config).unwrap();
580        assert_eq!(load_config(dir.path()).unwrap(), config);
581
582        // The atomic temp file was renamed away — no `.tmp` residue is left to confuse later runs.
583        let residue: Vec<_> = fs::read_dir(dir.path())
584            .unwrap()
585            .filter_map(Result::ok)
586            .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
587            .collect();
588        assert!(residue.is_empty(), "atomic write must leave no temp file: {residue:?}");
589
590        // Overwriting an existing config replaces it completely (no truncation / partial merge).
591        let updated = Config {
592            default_permission: PermissionLevel::Act,
593            ..config
594        };
595        save_config(dir.path(), &updated).unwrap();
596        assert_eq!(load_config(dir.path()).unwrap(), updated);
597    }
598
599    #[test]
600    fn auth_errors_map_onto_wire_protocol_errors() {
601        assert!(matches!(ProtocolError::from(AuthError::BadSignature), ProtocolError::Unauthorized(_)));
602        assert!(matches!(ProtocolError::from(AuthError::Malformed("x".to_owned())), ProtocolError::MalformedFrame(_)));
603    }
604
605    #[test]
606    fn default_handle_uses_the_final_path_component() {
607        assert_eq!(default_handle(Path::new("/home/aaron/projects/razel")), "razel");
608    }
609}