Mayo commited on
Commit
cdd39eb
·
unverified ·
1 Parent(s): e0d770b

refactor: move keyring to koharu-runtime

Browse files
Cargo.lock CHANGED
@@ -4610,14 +4610,12 @@ dependencies = [
4610
  "anyhow",
4611
  "bindgen",
4612
  "clap",
4613
- "dirs",
4614
  "encoding_rs",
4615
  "enumflags2",
4616
  "flate2",
4617
  "futures",
4618
  "hf-hub",
4619
  "image",
4620
- "keyring",
4621
  "koharu-runtime",
4622
  "libloading 0.8.9",
4623
  "minijinja",
@@ -4632,7 +4630,6 @@ dependencies = [
4632
  "strum",
4633
  "syn 2.0.117",
4634
  "tar",
4635
- "tempfile",
4636
  "thiserror 2.0.18",
4637
  "tokio",
4638
  "tracing",
@@ -4746,6 +4743,7 @@ name = "koharu-runtime"
4746
  version = "0.47.8"
4747
  dependencies = [
4748
  "anyhow",
 
4749
  "camino",
4750
  "dirs",
4751
  "flate2",
@@ -4754,6 +4752,7 @@ dependencies = [
4754
  "indexmap 2.14.0",
4755
  "indicatif",
4756
  "inventory",
 
4757
  "koharu-core",
4758
  "libloading 0.8.9",
4759
  "num_cpus",
 
4610
  "anyhow",
4611
  "bindgen",
4612
  "clap",
 
4613
  "encoding_rs",
4614
  "enumflags2",
4615
  "flate2",
4616
  "futures",
4617
  "hf-hub",
4618
  "image",
 
4619
  "koharu-runtime",
4620
  "libloading 0.8.9",
4621
  "minijinja",
 
4630
  "strum",
4631
  "syn 2.0.117",
4632
  "tar",
 
4633
  "thiserror 2.0.18",
4634
  "tokio",
4635
  "tracing",
 
4743
  version = "0.47.8"
4744
  dependencies = [
4745
  "anyhow",
4746
+ "atomicwrites",
4747
  "camino",
4748
  "dirs",
4749
  "flate2",
 
4752
  "indexmap 2.14.0",
4753
  "indicatif",
4754
  "inventory",
4755
+ "keyring",
4756
  "koharu-core",
4757
  "libloading 0.8.9",
4758
  "num_cpus",
koharu-app/src/config.rs CHANGED
@@ -2,8 +2,7 @@ use std::fs;
2
 
3
  use anyhow::{Context, Result};
4
  use camino::Utf8PathBuf;
5
- use koharu_llm::providers::{get_saved_api_key, set_saved_api_key};
6
- use koharu_runtime::default_app_data_root;
7
  use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
  use utoipa::ToSchema;
9
 
@@ -11,6 +10,8 @@ use crate::pipeline::{Artifact, Registry};
11
 
12
  const CONFIG_FILE: &str = "config.toml";
13
  const REDACTED: &str = "[REDACTED]";
 
 
14
 
15
  // ---------------------------------------------------------------------------
16
  // RedactedSecret
@@ -163,8 +164,9 @@ pub fn load() -> Result<AppConfig> {
163
  }
164
 
165
  // Populate api_key from credential storage for every known provider.
 
166
  for provider in &mut config.providers {
167
- if let Ok(Some(key)) = get_saved_api_key(&provider.id)
168
  && !key.trim().is_empty()
169
  {
170
  provider.api_key = Some(RedactedSecret::new(key));
@@ -355,13 +357,19 @@ fn validate_engine_name(
355
  /// - `None` → clear from credential storage
356
  /// - `Some(RedactedSecret)` with value == "[REDACTED]" → unchanged
357
  pub fn sync_secrets(config: &AppConfig) -> Result<()> {
 
358
  for provider in &config.providers {
359
  match &provider.api_key {
360
  Some(secret) if secret.expose() != REDACTED => {
361
- set_saved_api_key(&provider.id, secret.expose())?;
 
 
 
 
 
362
  }
363
  None => {
364
- set_saved_api_key(&provider.id, "")?;
365
  }
366
  _ => {} // "[REDACTED]" means unchanged
367
  }
@@ -369,6 +377,10 @@ pub fn sync_secrets(config: &AppConfig) -> Result<()> {
369
  Ok(())
370
  }
371
 
 
 
 
 
372
  #[cfg(test)]
373
  mod tests {
374
  use super::*;
@@ -413,6 +425,14 @@ mod tests {
413
  assert!(path.as_str().contains("Koharu"));
414
  }
415
 
 
 
 
 
 
 
 
 
416
  #[test]
417
  fn invalid_pipeline_engines_reset_to_defaults() {
418
  let mut config = AppConfig::default();
 
2
 
3
  use anyhow::{Context, Result};
4
  use camino::Utf8PathBuf;
5
+ use koharu_runtime::{SecretStore, default_app_data_root};
 
6
  use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
  use utoipa::ToSchema;
8
 
 
10
 
11
  const CONFIG_FILE: &str = "config.toml";
12
  const REDACTED: &str = "[REDACTED]";
13
+ const SECRET_SERVICE: &str = "koharu";
14
+ const PROVIDER_API_KEY_SECRET_PREFIX: &str = "llm_provider_api_key_";
15
 
16
  // ---------------------------------------------------------------------------
17
  // RedactedSecret
 
164
  }
165
 
166
  // Populate api_key from credential storage for every known provider.
167
+ let secrets = SecretStore::new(SECRET_SERVICE);
168
  for provider in &mut config.providers {
169
+ if let Ok(Some(key)) = secrets.get(&provider_api_key_secret_key(&provider.id))
170
  && !key.trim().is_empty()
171
  {
172
  provider.api_key = Some(RedactedSecret::new(key));
 
357
  /// - `None` → clear from credential storage
358
  /// - `Some(RedactedSecret)` with value == "[REDACTED]" → unchanged
359
  pub fn sync_secrets(config: &AppConfig) -> Result<()> {
360
+ let secrets = SecretStore::new(SECRET_SERVICE);
361
  for provider in &config.providers {
362
  match &provider.api_key {
363
  Some(secret) if secret.expose() != REDACTED => {
364
+ let key = provider_api_key_secret_key(&provider.id);
365
+ if secret.expose().trim().is_empty() {
366
+ secrets.delete(&key)?;
367
+ } else {
368
+ secrets.set(&key, secret.expose())?;
369
+ }
370
  }
371
  None => {
372
+ secrets.delete(&provider_api_key_secret_key(&provider.id))?;
373
  }
374
  _ => {} // "[REDACTED]" means unchanged
375
  }
 
377
  Ok(())
378
  }
379
 
380
+ fn provider_api_key_secret_key(provider_id: &str) -> String {
381
+ format!("{PROVIDER_API_KEY_SECRET_PREFIX}{provider_id}")
382
+ }
383
+
384
  #[cfg(test)]
385
  mod tests {
386
  use super::*;
 
425
  assert!(path.as_str().contains("Koharu"));
426
  }
427
 
428
+ #[test]
429
+ fn provider_api_key_secret_key_preserves_legacy_keyring_user() {
430
+ assert_eq!(
431
+ provider_api_key_secret_key("openai"),
432
+ "llm_provider_api_key_openai"
433
+ );
434
+ }
435
+
436
  #[test]
437
  fn invalid_pipeline_engines_reset_to_defaults() {
438
  let mut config = AppConfig::default();
koharu-llm/Cargo.toml CHANGED
@@ -29,7 +29,6 @@ path = "bin/paddleocr-vl.rs"
29
  [dependencies]
30
  anyhow = { workspace = true }
31
  clap = { workspace = true, features = ["derive"] }
32
- dirs = { workspace = true }
33
  flate2 = { workspace = true }
34
  futures = { workspace = true }
35
  hf-hub = { workspace = true }
@@ -54,13 +53,6 @@ tracing-core = "0.1"
54
  zip = { workspace = true }
55
  encoding_rs = "0.8"
56
  enumflags2 = "0.7.12"
57
- keyring = { workspace = true }
58
-
59
- [target.'cfg(windows)'.dependencies]
60
- keyring = { workspace = true, features = ["windows-native"] }
61
-
62
- [target.'cfg(target_os = "macos")'.dependencies]
63
- keyring = { workspace = true, features = ["apple-native"] }
64
 
65
  [build-dependencies]
66
  anyhow = { workspace = true }
@@ -71,6 +63,3 @@ quote = "1.0"
71
  reqwest = { workspace = true }
72
  syn = { version = "2.0", features = ["full"] }
73
  tar = "0.4"
74
-
75
- [dev-dependencies]
76
- tempfile = { workspace = true }
 
29
  [dependencies]
30
  anyhow = { workspace = true }
31
  clap = { workspace = true, features = ["derive"] }
 
32
  flate2 = { workspace = true }
33
  futures = { workspace = true }
34
  hf-hub = { workspace = true }
 
53
  zip = { workspace = true }
54
  encoding_rs = "0.8"
55
  enumflags2 = "0.7.12"
 
 
 
 
 
 
 
56
 
57
  [build-dependencies]
58
  anyhow = { workspace = true }
 
63
  reqwest = { workspace = true }
64
  syn = { version = "2.0", features = ["full"] }
65
  tar = "0.4"
 
 
 
koharu-llm/src/providers/mod.rs CHANGED
@@ -19,7 +19,6 @@ pub(crate) fn resolve_system_prompt(custom: Option<&str>, target_language: Langu
19
  pub mod caiyun;
20
  mod chat_completions;
21
  pub mod claude;
22
- mod credentials;
23
  pub mod deepl;
24
  pub mod deepseek;
25
  pub mod gemini;
@@ -27,8 +26,6 @@ pub mod google_translate;
27
  pub mod openai;
28
  pub mod openai_compatible;
29
 
30
- pub use credentials::{get_saved_api_key, set_saved_api_key};
31
-
32
  #[derive(Debug, Clone, Copy)]
33
  pub struct ProviderModelDescriptor {
34
  pub id: &'static str,
 
19
  pub mod caiyun;
20
  mod chat_completions;
21
  pub mod claude;
 
22
  pub mod deepl;
23
  pub mod deepseek;
24
  pub mod gemini;
 
26
  pub mod openai;
27
  pub mod openai_compatible;
28
 
 
 
29
  #[derive(Debug, Clone, Copy)]
30
  pub struct ProviderModelDescriptor {
31
  pub id: &'static str,
koharu-runtime/Cargo.toml CHANGED
@@ -13,6 +13,7 @@ publish.workspace = true
13
 
14
  [dependencies]
15
  anyhow = { workspace = true }
 
16
  camino = { workspace = true }
17
  koharu-core = { workspace = true }
18
  dirs = { workspace = true }
@@ -21,6 +22,7 @@ hf-hub = { workspace = true }
21
  indexmap = { workspace = true }
22
  indicatif = { workspace = true }
23
  inventory = { workspace = true }
 
24
  reqwest = { workspace = true }
25
  reqwest-middleware = { workspace = true }
26
  reqwest-retry = { workspace = true }
@@ -38,5 +40,11 @@ tar = "0.4"
38
  [target.'cfg(target_os = "windows")'.dependencies]
39
  windows-sys = { workspace = true }
40
 
 
 
 
 
 
 
41
  [dev-dependencies]
42
  tempfile = { workspace = true }
 
13
 
14
  [dependencies]
15
  anyhow = { workspace = true }
16
+ atomicwrites = { workspace = true }
17
  camino = { workspace = true }
18
  koharu-core = { workspace = true }
19
  dirs = { workspace = true }
 
22
  indexmap = { workspace = true }
23
  indicatif = { workspace = true }
24
  inventory = { workspace = true }
25
+ keyring = { workspace = true }
26
  reqwest = { workspace = true }
27
  reqwest-middleware = { workspace = true }
28
  reqwest-retry = { workspace = true }
 
40
  [target.'cfg(target_os = "windows")'.dependencies]
41
  windows-sys = { workspace = true }
42
 
43
+ [target.'cfg(windows)'.dependencies]
44
+ keyring = { workspace = true, features = ["windows-native"] }
45
+
46
+ [target.'cfg(target_os = "macos")'.dependencies]
47
+ keyring = { workspace = true, features = ["apple-native"] }
48
+
49
  [dev-dependencies]
50
  tempfile = { workspace = true }
koharu-runtime/src/lib.rs CHANGED
@@ -6,6 +6,7 @@ mod llama;
6
  mod loader;
7
  pub mod packages;
8
  mod runtime;
 
9
  mod zluda;
10
 
11
  pub use cuda::{
@@ -19,3 +20,4 @@ pub use packages::{PackageCatalog as Catalog, PackageFuture, PackageKind, Packag
19
  pub use runtime::{
20
  ComputePolicy, Runtime, RuntimeHttpConfig, RuntimeManager, default_app_data_root,
21
  };
 
 
6
  mod loader;
7
  pub mod packages;
8
  mod runtime;
9
+ mod secrets;
10
  mod zluda;
11
 
12
  pub use cuda::{
 
20
  pub use runtime::{
21
  ComputePolicy, Runtime, RuntimeHttpConfig, RuntimeManager, default_app_data_root,
22
  };
23
+ pub use secrets::{SecretStore, delete_secret, get_secret, set_secret};
koharu-llm/src/providers/credentials.rs → koharu-runtime/src/secrets.rs RENAMED
@@ -2,12 +2,39 @@ use std::sync::Once;
2
 
3
  use keyring::Entry;
4
 
5
- const API_KEY_SERVICE: &str = "koharu";
6
-
7
  static INIT_CREDENTIAL_STORE: Once = Once::new();
8
 
9
- pub fn get_saved_api_key(provider: &str) -> anyhow::Result<Option<String>> {
10
- let entry = provider_entry(provider)?;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  match entry.get_password() {
12
  Ok(value) => Ok(Some(value)),
13
  Err(keyring::Error::NoEntry) => Ok(None),
@@ -15,29 +42,26 @@ pub fn get_saved_api_key(provider: &str) -> anyhow::Result<Option<String>> {
15
  }
16
  }
17
 
18
- pub fn set_saved_api_key(provider: &str, api_key: &str) -> anyhow::Result<()> {
19
- let entry = provider_entry(provider)?;
20
- if api_key.trim().is_empty() {
21
- return match entry.delete_credential() {
22
- Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
23
- Err(err) => Err(err.into()),
24
- };
25
- }
26
-
27
- entry.set_password(api_key)?;
28
  Ok(())
29
  }
30
 
31
- fn provider_entry(provider: &str) -> anyhow::Result<Entry> {
32
- INIT_CREDENTIAL_STORE.call_once(configure_platform_store);
 
 
 
 
33
 
34
- let username = format!("llm_provider_api_key_{provider}");
35
- Ok(Entry::new(API_KEY_SERVICE, &username)?)
 
36
  }
37
 
38
  #[cfg(target_os = "linux")]
39
  fn configure_platform_store() {
40
- let root = koharu_runtime::default_app_data_root()
41
  .as_std_path()
42
  .join("secrets")
43
  .join("keyring");
@@ -51,14 +75,14 @@ fn configure_platform_store() {}
51
  mod filesystem {
52
  use std::fmt::Write as _;
53
  use std::fs;
 
 
54
  use std::path::{Path, PathBuf};
55
- use std::sync::atomic::{AtomicU64, Ordering};
56
 
 
57
  use keyring::credential::{Credential, CredentialApi, CredentialBuilderApi};
58
  use keyring::{Error, Result};
59
 
60
- static TEMP_ID: AtomicU64 = AtomicU64::new(0);
61
-
62
  #[derive(Debug)]
63
  pub(super) struct Builder {
64
  root: PathBuf,
@@ -98,15 +122,6 @@ mod filesystem {
98
  fn path(&self) -> PathBuf {
99
  self.root.join(&self.name)
100
  }
101
-
102
- fn temp_path(&self) -> PathBuf {
103
- self.root.join(format!(
104
- "{}.tmp-{}-{}",
105
- self.name,
106
- std::process::id(),
107
- TEMP_ID.fetch_add(1, Ordering::Relaxed)
108
- ))
109
- }
110
  }
111
 
112
  impl CredentialApi for FileCredential {
@@ -115,14 +130,12 @@ mod filesystem {
115
  set_mode(&self.root, 0o700)?;
116
 
117
  let path = self.path();
118
- let temp_path = self.temp_path();
119
- fs::write(&temp_path, secret).map_err(storage_error)?;
120
- set_mode(&temp_path, 0o600)?;
121
-
122
- fs::rename(&temp_path, &path).map_err(|err| {
123
- let _ = fs::remove_file(&temp_path);
124
- storage_error(err)
125
- })?;
126
  set_mode(&path, 0o600)
127
  }
128
 
@@ -157,6 +170,26 @@ mod filesystem {
157
  Ok(())
158
  }
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  fn storage_error(err: std::io::Error) -> Error {
161
  Error::NoStorageAccess(Box::new(err))
162
  }
 
2
 
3
  use keyring::Entry;
4
 
 
 
5
  static INIT_CREDENTIAL_STORE: Once = Once::new();
6
 
7
+ /// Service-scoped access to Koharu's platform-backed string secret storage.
8
+ #[derive(Debug, Clone)]
9
+ pub struct SecretStore {
10
+ service: String,
11
+ }
12
+
13
+ impl SecretStore {
14
+ pub fn new(service: impl Into<String>) -> Self {
15
+ Self {
16
+ service: service.into(),
17
+ }
18
+ }
19
+
20
+ /// Load a secret by key, returning `None` when no credential exists.
21
+ pub fn get(&self, key: &str) -> anyhow::Result<Option<String>> {
22
+ get_secret(&self.service, key)
23
+ }
24
+
25
+ /// Store a secret by key. Use `delete` to clear an existing credential.
26
+ pub fn set(&self, key: &str, secret: &str) -> anyhow::Result<()> {
27
+ set_secret(&self.service, key, secret)
28
+ }
29
+
30
+ /// Clear a secret by key. Missing credentials are treated as success.
31
+ pub fn delete(&self, key: &str) -> anyhow::Result<()> {
32
+ delete_secret(&self.service, key)
33
+ }
34
+ }
35
+
36
+ pub fn get_secret(service: &str, key: &str) -> anyhow::Result<Option<String>> {
37
+ let entry = secret_entry(service, key)?;
38
  match entry.get_password() {
39
  Ok(value) => Ok(Some(value)),
40
  Err(keyring::Error::NoEntry) => Ok(None),
 
42
  }
43
  }
44
 
45
+ pub fn set_secret(service: &str, key: &str, secret: &str) -> anyhow::Result<()> {
46
+ secret_entry(service, key)?.set_password(secret)?;
 
 
 
 
 
 
 
 
47
  Ok(())
48
  }
49
 
50
+ pub fn delete_secret(service: &str, key: &str) -> anyhow::Result<()> {
51
+ match secret_entry(service, key)?.delete_credential() {
52
+ Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
53
+ Err(err) => Err(err.into()),
54
+ }
55
+ }
56
 
57
+ fn secret_entry(service: &str, key: &str) -> anyhow::Result<Entry> {
58
+ INIT_CREDENTIAL_STORE.call_once(configure_platform_store);
59
+ Ok(Entry::new(service, key)?)
60
  }
61
 
62
  #[cfg(target_os = "linux")]
63
  fn configure_platform_store() {
64
+ let root = crate::runtime::default_app_data_root()
65
  .as_std_path()
66
  .join("secrets")
67
  .join("keyring");
 
75
  mod filesystem {
76
  use std::fmt::Write as _;
77
  use std::fs;
78
+ use std::io;
79
+ use std::io::Write as _;
80
  use std::path::{Path, PathBuf};
 
81
 
82
+ use atomicwrites::{AtomicFile, OverwriteBehavior};
83
  use keyring::credential::{Credential, CredentialApi, CredentialBuilderApi};
84
  use keyring::{Error, Result};
85
 
 
 
86
  #[derive(Debug)]
87
  pub(super) struct Builder {
88
  root: PathBuf,
 
122
  fn path(&self) -> PathBuf {
123
  self.root.join(&self.name)
124
  }
 
 
 
 
 
 
 
 
 
125
  }
126
 
127
  impl CredentialApi for FileCredential {
 
130
  set_mode(&self.root, 0o700)?;
131
 
132
  let path = self.path();
133
+ AtomicFile::new(&path, OverwriteBehavior::AllowOverwrite)
134
+ .write(|file| -> io::Result<()> {
135
+ set_file_mode(file, 0o600)?;
136
+ file.write_all(secret)
137
+ })
138
+ .map_err(atomic_write_error)?;
 
 
139
  set_mode(&path, 0o600)
140
  }
141
 
 
170
  Ok(())
171
  }
172
 
173
+ #[cfg(unix)]
174
+ fn set_file_mode(file: &fs::File, mode: u32) -> io::Result<()> {
175
+ use std::os::unix::fs::PermissionsExt;
176
+
177
+ file.set_permissions(fs::Permissions::from_mode(mode))
178
+ }
179
+
180
+ #[cfg(not(unix))]
181
+ fn set_file_mode(_file: &fs::File, _mode: u32) -> io::Result<()> {
182
+ Ok(())
183
+ }
184
+
185
+ fn atomic_write_error(err: atomicwrites::Error<io::Error>) -> Error {
186
+ match err {
187
+ atomicwrites::Error::Internal(err) | atomicwrites::Error::User(err) => {
188
+ storage_error(err)
189
+ }
190
+ }
191
+ }
192
+
193
  fn storage_error(err: std::io::Error) -> Error {
194
  Error::NoStorageAccess(Box::new(err))
195
  }