sdkt-storage 2.7.0

Storage monitoring utility and TTL extensions logic for Soroban contracts.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
//! Identity and Keystore Management.
//!
//! Stores `ed25519` keypairs securely using OS-portable config directories.
//! Keys are stored using strict permissions (`0600`) to prevent unauthorized access.

use crate::error::StorageError;
use directories::ProjectDirs;
use ed25519_dalek::SigningKey;
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use stellar_strkey::Strkey;

/// Reserved identity name that selects the keystore's configured default.
///
/// Every signing command's `--identity` flag defaults to this literal string,
/// but it can never name a key on disk — the keystore rejects it as an invalid
/// identity name. It is a sentinel, resolved through
/// [`IdentityStore::resolve_signing_identity`].
pub const DEFAULT_IDENTITY_NAME: &str = "default";

/// A local Soroban identity containing a named keypair.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Identity {
    pub name: String,
    pub public_key: String,
}

/// Keystore for managing named Soroban identities.
pub struct IdentityStore {
    dir: PathBuf,
}

impl IdentityStore {
    /// Initialize the keystore in the default OS-portable config directory (`~/.config/sdkt/identities/`).
    ///
    /// If the `SDKT_IDENTITY_DIR` environment variable is set, its value is used instead of the
    /// OS-default config directory. This provides a cross-platform override (works on macOS and
    /// Windows, where `XDG_CONFIG_HOME` is ignored by `directories::ProjectDirs`), and is the
    /// canonical way for tests and CI to isolate the identity store.
    pub fn new() -> Result<Self, StorageError> {
        if let Ok(dir) = std::env::var("SDKT_IDENTITY_DIR") {
            if !dir.is_empty() {
                return Self::with_dir(dir);
            }
        }

        let proj_dirs = ProjectDirs::from("com", "SorobanDevKit", "sdkt").ok_or_else(|| {
            StorageError::Io(std::io::Error::new(
                std::io::ErrorKind::NotFound,
                "No valid home directory found",
            ))
        })?;

        Self::with_dir(proj_dirs.config_dir().join("identities"))
    }

    /// Initialize the keystore in a custom directory.
    pub fn with_dir<P: AsRef<Path>>(dir: P) -> Result<Self, StorageError> {
        let dir = dir.as_ref().to_path_buf();
        if !dir.exists() {
            fs::create_dir_all(&dir).map_err(StorageError::Io)?;
        }
        Ok(Self { dir })
    }

    /// Generate a new random ED25519 identity.
    pub fn generate(&self, name: &str) -> Result<Identity, StorageError> {
        self.ensure_name_valid(name)?;

        let mut secret = [0u8; 32];
        getrandom::fill(&mut secret)
            .map_err(|e| StorageError::Io(std::io::Error::other(e.to_string())))?;
        let signing_key = SigningKey::from_bytes(&secret);
        secret.fill(0);

        self.save_key(name, &signing_key)
    }

    /// Import an identity from a secret key (S...) string.
    pub fn import(&self, name: &str, secret_key: &str) -> Result<Identity, StorageError> {
        self.ensure_name_valid(name)?;

        let key = stellar_strkey::ed25519::PrivateKey::from_string(secret_key).map_err(|e| {
            StorageError::Io(std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                format!("Invalid StrKey: {}", e),
            ))
        })?;

        let signing_key = SigningKey::from_bytes(&key.0);
        self.save_key(name, &signing_key)
    }

    /// Load an identity by name.
    pub fn get(&self, name: &str) -> Result<Identity, StorageError> {
        let path = self.dir.join(format!("{}.toml", name));
        if !path.exists() {
            return Err(StorageError::Io(std::io::Error::new(
                std::io::ErrorKind::NotFound,
                format!("Identity '{}' not found", name),
            )));
        }

        let signing_key = self.load_key(&path)?;

        Ok(Identity {
            name: name.to_string(),
            public_key: Strkey::PublicKeyEd25519(stellar_strkey::ed25519::PublicKey(
                signing_key.verifying_key().to_bytes(),
            ))
            .to_string()
            .as_str()
            .to_string(),
        })
    }

    /// Resolve the signing identity named by a CLI `--identity` value.
    ///
    /// The signing commands default `--identity` to the reserved literal
    /// [`DEFAULT_IDENTITY_NAME`] (`"default"`), which can never be a real key.
    /// When `name` is that sentinel this returns the identity selected by
    /// [`IdentityStore::set_default`]; for any other `name` it is exactly
    /// [`IdentityStore::get`], so an explicit `--identity <name>` is unchanged.
    ///
    /// # Errors
    /// Returns [`StorageError::NoDefaultIdentity`] when the sentinel is used and
    /// no default has been configured, and the [`IdentityStore::get`] error for
    /// any other unresolvable name.
    pub fn resolve_signing_identity(&self, name: &str) -> Result<Identity, StorageError> {
        if name == DEFAULT_IDENTITY_NAME {
            return self
                .get_default()
                .map_err(|_| StorageError::NoDefaultIdentity);
        }

        self.get(name)
    }

    /// Delete an identity.
    ///
    /// Returns [`StorageError::NotFound`] when no identity with that name
    /// exists, matching `NetworkStore::remove`, so a mistyped name is reported
    /// rather than treated as a successful removal.
    pub fn remove(&self, name: &str) -> Result<(), StorageError> {
        let path = self.dir.join(format!("{}.toml", name));
        if !path.exists() {
            return Err(StorageError::NotFound(format!(
                "Identity '{}' not found",
                name
            )));
        }
        fs::remove_file(path).map_err(StorageError::Io)?;

        // If it was the default identity, clear the default symlink.
        let default_path = self.dir.join("default");
        if default_path.exists() {
            if let Ok(target) = fs::read_link(&default_path) {
                if target.file_stem().and_then(|s| s.to_str()) == Some(name) {
                    let _ = fs::remove_file(default_path);
                }
            }
        }
        Ok(())
    }

    /// List all stored identities.
    pub fn list(&self) -> Result<Vec<Identity>, StorageError> {
        let mut identities = Vec::new();

        for entry in fs::read_dir(&self.dir).map_err(StorageError::Io)? {
            let entry = entry.map_err(StorageError::Io)?;
            let path = entry.path();

            if path.extension().and_then(|s| s.to_str()) == Some("toml") {
                if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
                    if let Ok(identity) = self.get(name) {
                        identities.push(identity);
                    }
                }
            }
        }

        Ok(identities)
    }

    /// Set an identity as the default.
    pub fn set_default(&self, name: &str) -> Result<(), StorageError> {
        let target_path = self.dir.join(format!("{}.toml", name));
        if !target_path.exists() {
            return Err(StorageError::Io(std::io::Error::new(
                std::io::ErrorKind::NotFound,
                format!("Identity '{}' not found", name),
            )));
        }

        let default_path = self.dir.join("default");
        if default_path.exists() {
            fs::remove_file(&default_path).map_err(StorageError::Io)?;
        }

        #[cfg(unix)]
        std::os::unix::fs::symlink(target_path, &default_path).map_err(StorageError::Io)?;

        #[cfg(windows)]
        std::os::windows::fs::symlink_file(target_path, &default_path).map_err(StorageError::Io)?;

        #[cfg(not(any(unix, windows)))]
        std::fs::copy(target_path, &default_path).map_err(StorageError::Io)?;

        Ok(())
    }

    /// Load the default identity.
    pub fn get_default(&self) -> Result<Identity, StorageError> {
        let default_path = self.dir.join("default");
        if !default_path.exists() {
            return Err(StorageError::Io(std::io::Error::new(
                std::io::ErrorKind::NotFound,
                "No default identity set",
            )));
        }

        let target_path = fs::read_link(&default_path).map_err(StorageError::Io)?;
        let name = target_path
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("unknown");

        self.get(name)
    }

    /// Load the signing key (`ed25519_dalek::SigningKey`) for a named identity.
    ///
    /// This is the keystore integration point used by transaction signing:
    /// the caller extracts the 32-byte seed via `signing_key.to_bytes()` and
    /// passes it to `sdkt_xdr::Ed25519Signer::from_seed`. The secret material
    /// is never exposed as a string; only the in-memory `SigningKey` is
    /// returned, and only for the duration the caller holds it.
    ///
    /// # Errors
    /// Returns [`StorageError`] if the identity does not exist or its secret
    /// cannot be loaded.
    pub fn load_signing_key(&self, name: &str) -> Result<SigningKey, StorageError> {
        let path = self.dir.join(format!("{}.toml", name));
        self.load_key(&path)
    }

    // --- Private Helpers ---

    fn ensure_name_valid(&self, name: &str) -> Result<(), StorageError> {
        if name.is_empty() || name == "default" || name.contains('/') || name.contains('\\') {
            return Err(StorageError::Io(std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                "Invalid identity name",
            )));
        }

        let path = self.dir.join(format!("{}.toml", name));
        if path.exists() {
            return Err(StorageError::Io(std::io::Error::new(
                std::io::ErrorKind::AlreadyExists,
                format!("Identity '{}' already exists", name),
            )));
        }
        Ok(())
    }

    fn save_key(&self, name: &str, signing_key: &SigningKey) -> Result<Identity, StorageError> {
        let path = self.dir.join(format!("{}.toml", name));

        let secret_str = stellar_strkey::Unredacted(&stellar_strkey::ed25519::PrivateKey(
            signing_key.to_bytes(),
        ))
        .to_string()
        .as_str()
        .to_string();

        let toml_content = format!(
            "# Soroban Identity: {}\nsecret_key = \"{}\"\n",
            name, secret_str
        );

        // Strict permissions 0600
        let mut opts = OpenOptions::new();
        opts.write(true).create(true).truncate(true);

        #[cfg(unix)]
        opts.mode(0o600);

        let mut file = opts.open(&path).map_err(StorageError::Io)?;

        file.write_all(toml_content.as_bytes())
            .map_err(StorageError::Io)?;

        let pub_key_str = Strkey::PublicKeyEd25519(stellar_strkey::ed25519::PublicKey(
            signing_key.verifying_key().to_bytes(),
        ))
        .to_string()
        .as_str()
        .to_string();

        Ok(Identity {
            name: name.to_string(),
            public_key: pub_key_str,
        })
    }

    fn load_key(&self, path: &Path) -> Result<SigningKey, StorageError> {
        let content = fs::read_to_string(path).map_err(StorageError::Io)?;

        let mut secret = None;
        for line in content.lines() {
            let line = line.trim();
            if line.starts_with("secret_key") {
                let parts: Vec<&str> = line.split('=').collect();
                if parts.len() == 2 {
                    secret = Some(parts[1].trim().trim_matches('"').to_string());
                }
            }
        }

        let secret = secret.ok_or_else(|| {
            StorageError::Io(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "secret_key not found in identity file",
            ))
        })?;

        let key = stellar_strkey::ed25519::PrivateKey::from_string(&secret).map_err(|e| {
            StorageError::Io(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                format!("Invalid stored StrKey: {}", e),
            ))
        })?;

        Ok(SigningKey::from_bytes(&key.0))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn test_generate_and_get() {
        let dir = tempdir().unwrap();
        let store = IdentityStore::with_dir(dir.path()).unwrap();

        let identity = store.generate("alice").unwrap();
        assert_eq!(identity.name, "alice");
        assert!(identity.public_key.starts_with('G'));

        let loaded = store.get("alice").unwrap();
        assert_eq!(loaded.name, "alice");
        assert_eq!(loaded.public_key, identity.public_key);
    }

    #[test]
    fn test_import_invalid_key() {
        let dir = tempdir().unwrap();
        let store = IdentityStore::with_dir(dir.path()).unwrap();

        assert!(store.import("bob", "not-a-key").is_err());
        assert!(store
            .import(
                "bob",
                "GBZXLHQZGOWBZY6W3U4Z7GZGGXYVQBZWYM3XEQZ7W5Z4QXYZ5Z3XYY"
            )
            .is_err()); // public key, not secret
    }

    #[test]
    fn test_list_and_remove() {
        let dir = tempdir().unwrap();
        let store = IdentityStore::with_dir(dir.path()).unwrap();

        store.generate("alice").unwrap();
        store.generate("bob").unwrap();

        let mut list = store.list().unwrap();
        list.sort_by(|a, b| a.name.cmp(&b.name));

        assert_eq!(list.len(), 2);
        assert_eq!(list[0].name, "alice");
        assert_eq!(list[1].name, "bob");

        store.remove("alice").unwrap();
        let list2 = store.list().unwrap();
        assert_eq!(list2.len(), 1);
        assert_eq!(list2[0].name, "bob");
    }

    #[test]
    fn test_remove_missing_identity_errors() {
        let dir = tempdir().unwrap();
        let store = IdentityStore::with_dir(dir.path()).unwrap();

        match store.remove("ghost") {
            Err(StorageError::NotFound(msg)) => assert_eq!(msg, "Identity 'ghost' not found"),
            other => panic!("expected NotFound, got {other:?}"),
        }
    }

    #[test]
    fn test_remove_twice_errors_the_second_time() {
        let dir = tempdir().unwrap();
        let store = IdentityStore::with_dir(dir.path()).unwrap();
        store.generate("alice").unwrap();

        store.remove("alice").unwrap();
        assert!(matches!(
            store.remove("alice"),
            Err(StorageError::NotFound(_))
        ));
    }

    #[test]
    fn test_default_identity() {
        let dir = tempdir().unwrap();
        let store = IdentityStore::with_dir(dir.path()).unwrap();

        let alice = store.generate("alice").unwrap();

        assert!(store.get_default().is_err());

        store.set_default("alice").unwrap();
        let def = store.get_default().unwrap();

        assert_eq!(def.name, "alice");
        assert_eq!(def.public_key, alice.public_key);
    }

    #[test]
    fn test_resolve_signing_identity_honors_default_sentinel() {
        let dir = tempdir().unwrap();
        let store = IdentityStore::with_dir(dir.path()).unwrap();
        let alice = store.generate("alice").unwrap();

        // Sentinel with no default configured: an actionable, specific error
        // (not the opaque "Identity 'default' not found" the literal name gave).
        match store.resolve_signing_identity(DEFAULT_IDENTITY_NAME) {
            Err(StorageError::NoDefaultIdentity) => {}
            other => panic!("expected NoDefaultIdentity, got {other:?}"),
        }

        store.set_default("alice").unwrap();
        let resolved = store
            .resolve_signing_identity(DEFAULT_IDENTITY_NAME)
            .expect("sentinel resolves once a default is set");

        assert_eq!(resolved.name, "alice");
        assert_eq!(resolved.public_key, alice.public_key);
    }

    #[test]
    fn test_resolve_signing_identity_explicit_names_unchanged() {
        let dir = tempdir().unwrap();
        let store = IdentityStore::with_dir(dir.path()).unwrap();
        let alice = store.generate("alice").unwrap();
        store.generate("bob").unwrap();

        // An explicit name is looked up verbatim, even when a *different*
        // identity is the default, and even before any default exists.
        let resolved = store.resolve_signing_identity("alice").unwrap();
        assert_eq!(resolved.name, "alice");
        assert_eq!(resolved.public_key, alice.public_key);

        store.set_default("bob").unwrap();
        let still_alice = store.resolve_signing_identity("alice").unwrap();
        assert_eq!(still_alice.name, "alice");

        // A missing explicit name keeps the plain `get` error, name included.
        let err = store.resolve_signing_identity("ghost").unwrap_err();
        assert!(
            err.to_string().contains("Identity 'ghost' not found"),
            "unexpected error: {err}"
        );
    }

    // ---------------------------------------------------------------------------
    // Windows-specific regression tests
    // ---------------------------------------------------------------------------

    /// Verifies the production IdentityStore::new() constructor resolves a
    /// platform-appropriate config directory on Windows.
    ///
    /// Read-only against production constructor — does not create identities
    /// in the real config directory.
    #[cfg(windows)]
    #[test]
    fn test_new_resolves_windows_config_path() {
        let store = IdentityStore::new().expect("IdentityStore::new() must succeed on Windows");

        // Windows: %APPDATA%\SorobanDevKit\sdkt\identities\
        let path_str = store.dir.to_string_lossy().to_lowercase();
        assert!(
            path_str.contains("sorobandevkit"),
            "Windows config path should contain the 'SorobanDevKit' namespace, got: {}",
            store.dir.display()
        );
        assert!(
            path_str.contains("sdkt"),
            "Windows config path should contain the 'sdkt' app name, got: {}",
            store.dir.display()
        );
    }

    /// Verifies that set_default/get_default works on Windows using the
    /// Windows-specific symlink implementation.
    ///
    /// This test exercises the existing:
    /// #[cfg(windows)] std::os::windows::fs::symlink_file
    /// path which is never exercised on Linux or macOS CI.
    #[cfg(windows)]
    #[test]
    fn test_default_identity_windows_symlink() {
        let dir = tempdir().unwrap();
        let store = IdentityStore::with_dir(dir.path()).unwrap();

        // Generate an identity
        let alice = store.generate("alice").unwrap();
        assert_eq!(alice.name, "alice");
        assert!(alice.public_key.starts_with('G'));

        // Verify no default is set initially
        assert!(
            store.get_default().is_err(),
            "no default should be set initially"
        );

        // Set default — this calls #[cfg(windows)] symlink_file on Windows
        store
            .set_default("alice")
            .expect("set_default must succeed on Windows");

        // Get default — this reads the symlink on Windows
        let def = store
            .get_default()
            .expect("get_default must succeed after set_default on Windows");

        assert_eq!(def.name, "alice");
        assert_eq!(def.public_key, alice.public_key);
    }
}