| 1 | //! Read-only DeepSeek Harness credential import. |
| 2 | //! |
| 3 | //! Official `dsh` stores API keys as a YAML mapping in |
| 4 | //! `$DSH_HOME/.credentials.yaml`. Codewhale may read `DEEPSEEK_API_KEY` from |
| 5 | //! that exact file only after `codewhale auth external-consent`. The file is |
| 6 | //! never written, refreshed, or loaded into the process environment. |
| 7 | |
| 8 | use anyhow::{Result, bail}; |
| 9 | use codewhale_config::ExternalCredentialReadGrant; |
| 10 | |
| 11 | const DEEPSEEK_API_KEY_REF: &str = "DEEPSEEK_API_KEY"; |
| 12 | |
| 13 | /// Extract the DeepSeek API key from a granted dsh credentials document. |
| 14 | pub(crate) fn deepseek_api_key_from_grant( |
| 15 | grant: &ExternalCredentialReadGrant, |
| 16 | ) -> Result<Option<String>> { |
| 17 | if grant.source() != codewhale_config::ExternalCredentialSource::DshCli { |
| 18 | bail!( |
| 19 | "DeepSeek Harness import requires a dsh_cli grant, not {}", |
| 20 | grant.source().as_str() |
| 21 | ); |
| 22 | } |
| 23 | let Some(text) = crate::external_credentials::read_to_string(grant)? else { |
| 24 | return Ok(None); |
| 25 | }; |
| 26 | parse_dsh_deepseek_api_key(&text) |
| 27 | } |
| 28 | |
| 29 | /// Strict subset of dsh-credentials-local: a mapping of POSIX identifiers to |
| 30 | /// non-empty strings. Nested values, empty strings, and duplicate keys fail |
| 31 | /// closed. Only `DEEPSEEK_API_KEY` is returned. |
| 32 | pub(crate) fn parse_dsh_deepseek_api_key(text: &str) -> Result<Option<String>> { |
| 33 | let mut found = None; |
| 34 | let mut seen = std::collections::BTreeSet::new(); |
| 35 | for (index, raw) in text.lines().enumerate() { |
| 36 | let line = raw.trim(); |
| 37 | if line.is_empty() || line.starts_with('#') { |
| 38 | continue; |
| 39 | } |
| 40 | let Some((key, value)) = line.split_once(':') else { |
| 41 | bail!( |
| 42 | "DeepSeek Harness credentials line {} is not `KEY: value`", |
| 43 | index + 1 |
| 44 | ); |
| 45 | }; |
| 46 | let key = key.trim(); |
| 47 | if !is_posix_identifier(key) { |
| 48 | bail!( |
| 49 | "DeepSeek Harness credentials line {} has a non-identifier key", |
| 50 | index + 1 |
| 51 | ); |
| 52 | } |
| 53 | if !seen.insert(key.to_string()) { |
| 54 | bail!("DeepSeek Harness credentials declare `{key}` more than once"); |
| 55 | } |
| 56 | let value = unquote_yaml_string(value.trim()).map_err(|reason| { |
| 57 | anyhow::anyhow!( |
| 58 | "DeepSeek Harness credentials line {} is invalid: {reason}", |
| 59 | index + 1 |
| 60 | ) |
| 61 | })?; |
| 62 | if value.is_empty() { |
| 63 | bail!( |
| 64 | "DeepSeek Harness credentials line {} has an empty value", |
| 65 | index + 1 |
| 66 | ); |
| 67 | } |
| 68 | if key == DEEPSEEK_API_KEY_REF { |
| 69 | found = Some(value); |
| 70 | } |
| 71 | } |
| 72 | Ok(found) |
| 73 | } |
| 74 | |
| 75 | fn is_posix_identifier(value: &str) -> bool { |
| 76 | let mut chars = value.chars(); |
| 77 | matches!(chars.next(), Some('A'..='Z' | 'a'..='z' | '_')) |
| 78 | && chars.all(|ch| matches!(ch, 'A'..='Z' | 'a'..='z' | '0'..='9' | '_')) |
| 79 | } |
| 80 | |
| 81 | fn unquote_yaml_string(value: &str) -> Result<String, &'static str> { |
| 82 | if value.starts_with('{') |
| 83 | || value.starts_with('[') |
| 84 | || value.starts_with('|') |
| 85 | || value.starts_with('>') |
| 86 | { |
| 87 | return Err("nested or block YAML is not supported"); |
| 88 | } |
| 89 | if let Some(inner) = value |
| 90 | .strip_prefix('"') |
| 91 | .and_then(|rest| rest.strip_suffix('"')) |
| 92 | { |
| 93 | if inner.contains('\\') { |
| 94 | return Err("escaped quoted strings are not supported"); |
| 95 | } |
| 96 | return Ok(inner.to_string()); |
| 97 | } |
| 98 | if let Some(inner) = value |
| 99 | .strip_prefix('\'') |
| 100 | .and_then(|rest| rest.strip_suffix('\'')) |
| 101 | { |
| 102 | if inner.contains('\'') { |
| 103 | return Err("escaped single-quoted strings are not supported"); |
| 104 | } |
| 105 | return Ok(inner.to_string()); |
| 106 | } |
| 107 | if value.contains(':') && value.contains(' ') { |
| 108 | return Err("unquoted mapping values are not supported"); |
| 109 | } |
| 110 | Ok(value.to_string()) |
| 111 | } |
| 112 | |
| 113 | #[cfg(test)] |
| 114 | mod tests { |
| 115 | use super::*; |
| 116 | |
| 117 | #[test] |
| 118 | fn parses_plain_and_quoted_deepseek_key() { |
| 119 | assert_eq!( |
| 120 | parse_dsh_deepseek_api_key("DEEPSEEK_API_KEY: sk-live\n").unwrap(), |
| 121 | Some("sk-live".to_string()) |
| 122 | ); |
| 123 | assert_eq!( |
| 124 | parse_dsh_deepseek_api_key( |
| 125 | "DEEPSEEK_API_KEY: \"sk-quoted\"\nOPENAI_API_KEY: sk-other\n" |
| 126 | ) |
| 127 | .unwrap(), |
| 128 | Some("sk-quoted".to_string()) |
| 129 | ); |
| 130 | } |
| 131 | |
| 132 | #[test] |
| 133 | fn missing_deepseek_key_is_absent_not_an_error() { |
| 134 | assert_eq!( |
| 135 | parse_dsh_deepseek_api_key("OPENAI_API_KEY: sk-other\n").unwrap(), |
| 136 | None |
| 137 | ); |
| 138 | } |
| 139 | |
| 140 | #[test] |
| 141 | fn rejects_empty_values_duplicates_and_nested_yaml() { |
| 142 | assert!(parse_dsh_deepseek_api_key("DEEPSEEK_API_KEY:\n").is_err()); |
| 143 | assert!( |
| 144 | parse_dsh_deepseek_api_key("DEEPSEEK_API_KEY: sk-a\nDEEPSEEK_API_KEY: sk-b\n").is_err() |
| 145 | ); |
| 146 | assert!(parse_dsh_deepseek_api_key("DEEPSEEK_API_KEY: {nested: true}\n").is_err()); |
| 147 | } |
| 148 | } |
| 149 |