cadmus_core/github/
device_flow.rs

1use secrecy::{ExposeSecret, SecretString};
2use std::fs::{self, File};
3use std::io::{Read, Write};
4use std::path::PathBuf;
5
6/// Subdirectory and filename appended to the card root for token storage.
7///
8/// Uses `.adds/cadmus/` for normal builds and `.adds/cadmus-tst/` for test
9/// builds.
10#[cfg(not(feature = "test"))]
11const TOKEN_RELATIVE_PATH: &str = ".adds/cadmus/.github_token";
12
13#[cfg(feature = "test")]
14const TOKEN_RELATIVE_PATH: &str = ".adds/cadmus-tst/.github_token";
15
16/// Persists a GitHub OAuth token to disk for reuse across app restarts.
17///
18/// Writes to `{card_root}/.adds/cadmus/.github_token` with `0600` permissions.
19///
20/// # Errors
21///
22/// Returns an error string if directory creation or file write fails.
23pub fn save_token(token: &SecretString) -> Result<(), String> {
24    let path = token_path();
25    tracing::debug!(path = %path.display(), "Saving GitHub token");
26
27    if let Some(parent) = path.parent() {
28        fs::create_dir_all(parent).map_err(|e| format!("Failed to create token dir: {}", e))?;
29    }
30
31    let mut file =
32        File::create(&path).map_err(|e| format!("Failed to create token file: {}", e))?;
33    file.write_all(token.expose_secret().as_bytes())
34        .map_err(|e| format!("Failed to write token: {}", e))?;
35
36    #[cfg(unix)]
37    {
38        use std::os::unix::fs::PermissionsExt;
39        fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
40            .map_err(|e| format!("Failed to set token file permissions: {}", e))?;
41    }
42
43    tracing::info!("GitHub token saved");
44    Ok(())
45}
46
47/// Loads a previously saved GitHub OAuth token from disk.
48///
49/// Returns `None` if no token file exists (first-time setup).
50///
51/// # Errors
52///
53/// Returns an error string if the file exists but cannot be read.
54pub fn load_token() -> Result<Option<SecretString>, String> {
55    let path = token_path();
56    tracing::debug!(path = %path.display(), "Loading GitHub token");
57
58    if !path.exists() {
59        tracing::debug!("No saved token found");
60        return Ok(None);
61    }
62
63    let mut contents = String::new();
64    File::open(&path)
65        .map_err(|e| format!("Failed to open token file: {}", e))?
66        .read_to_string(&mut contents)
67        .map_err(|e| format!("Failed to read token file: {}", e))?;
68
69    let token = contents.trim().to_owned();
70    if token.is_empty() {
71        tracing::warn!("Token file exists but is empty");
72        return Ok(None);
73    }
74
75    tracing::info!("GitHub token loaded from disk");
76    Ok(Some(SecretString::from(token)))
77}
78
79/// Deletes the saved GitHub OAuth token from disk.
80///
81/// Called when a token is found to be invalid or revoked, so the next
82/// authentication attempt starts fresh via device flow.
83///
84/// Returns `Ok(())` if the file was deleted or did not exist.
85///
86/// # Errors
87///
88/// Returns an error string if the file exists but cannot be removed.
89pub fn delete_token() -> Result<(), String> {
90    let path = token_path();
91    tracing::debug!(path = %path.display(), "Deleting GitHub token");
92
93    if !path.exists() {
94        return Ok(());
95    }
96
97    fs::remove_file(&path).map_err(|e| format!("Failed to delete token file: {}", e))?;
98    tracing::info!("GitHub token deleted");
99    Ok(())
100}
101
102fn token_path() -> PathBuf {
103    #[cfg(test)]
104    return std::env::temp_dir()
105        .join("cadmus-test")
106        .join(TOKEN_RELATIVE_PATH);
107
108    #[cfg(all(not(test), feature = "emulator"))]
109    return PathBuf::from("/tmp").join(TOKEN_RELATIVE_PATH);
110
111    #[cfg(all(not(test), not(feature = "emulator")))]
112    return PathBuf::from(crate::settings::INTERNAL_CARD_ROOT).join(TOKEN_RELATIVE_PATH);
113}