fix: resolve build errors and add comprehensive symlink tests

- Fixed E0425 error in src/skills/mod.rs by moving println! inside #[cfg(unix)] block where 'dest' variable is in scope
- Added missing 'identity' field to Config struct initializations in src/onboard/wizard.rs
- Fixed import paths for AIEOS identity functions in src/channels/mod.rs
- Added comprehensive symlink edge case tests in src/skills/symlink_tests.rs
- All 840 tests passing, 0 clippy warnings

Resolves issue #28: skills symlink functionality now works correctly on Unix platforms with proper error handling on non-Unix platforms
This commit is contained in:
argenis de la rosa 2026-02-14 13:37:27 -05:00
parent acea042bdb
commit ef4444ba43
13 changed files with 834 additions and 49 deletions

View file

@ -79,45 +79,94 @@ impl SecretStore {
/// - `enc2:` prefix → ChaCha20-Poly1305 (current format)
/// - `enc:` prefix → legacy XOR cipher (backward compatibility for migration)
/// - No prefix → returned as-is (plaintext config)
///
/// **Warning**: Legacy `enc:` values are insecure. Use `decrypt_and_migrate` to
/// automatically upgrade them to the secure `enc2:` format.
pub fn decrypt(&self, value: &str) -> Result<String> {
if let Some(hex_str) = value.strip_prefix("enc2:") {
let blob =
hex_decode(hex_str).context("Failed to decode encrypted secret (corrupt hex)")?;
anyhow::ensure!(
blob.len() > NONCE_LEN,
"Encrypted value too short (missing nonce)"
);
let (nonce_bytes, ciphertext) = blob.split_at(NONCE_LEN);
let nonce = Nonce::from_slice(nonce_bytes);
let key_bytes = self.load_or_create_key()?;
let key = Key::from_slice(&key_bytes);
let cipher = ChaCha20Poly1305::new(key);
let plaintext_bytes = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| anyhow::anyhow!("Decryption failed — wrong key or tampered data"))?;
String::from_utf8(plaintext_bytes)
.context("Decrypted secret is not valid UTF-8 — corrupt data")
self.decrypt_chacha20(hex_str)
} else if let Some(hex_str) = value.strip_prefix("enc:") {
// Legacy XOR cipher — decrypt for backward compatibility
let ciphertext = hex_decode(hex_str)
.context("Failed to decode legacy encrypted secret (corrupt hex)")?;
let key = self.load_or_create_key()?;
let plaintext_bytes = xor_cipher(&ciphertext, &key);
String::from_utf8(plaintext_bytes)
.context("Decrypted legacy secret is not valid UTF-8 — wrong key or corrupt data")
self.decrypt_legacy_xor(hex_str)
} else {
Ok(value.to_string())
}
}
/// Decrypt a secret and return a migrated `enc2:` value if the input used legacy `enc:` format.
///
/// Returns `(plaintext, Some(new_enc2_value))` if migration occurred, or
/// `(plaintext, None)` if no migration was needed.
///
/// This allows callers to persist the upgraded value back to config.
pub fn decrypt_and_migrate(&self, value: &str) -> Result<(String, Option<String>)> {
if let Some(hex_str) = value.strip_prefix("enc2:") {
// Already using secure format — no migration needed
let plaintext = self.decrypt_chacha20(hex_str)?;
Ok((plaintext, None))
} else if let Some(hex_str) = value.strip_prefix("enc:") {
// Legacy XOR cipher — decrypt and re-encrypt with ChaCha20-Poly1305
tracing::warn!(
"Decrypting legacy XOR-encrypted secret (enc: prefix). \
This format is insecure and will be removed in a future release. \
The secret will be automatically migrated to enc2: (ChaCha20-Poly1305)."
);
let plaintext = self.decrypt_legacy_xor(hex_str)?;
let migrated = self.encrypt(&plaintext)?;
Ok((plaintext, Some(migrated)))
} else {
// Plaintext — no migration needed
Ok((value.to_string(), None))
}
}
/// Check if a value uses the legacy `enc:` format that should be migrated.
pub fn needs_migration(value: &str) -> bool {
value.starts_with("enc:")
}
/// Decrypt using ChaCha20-Poly1305 (current secure format).
fn decrypt_chacha20(&self, hex_str: &str) -> Result<String> {
let blob =
hex_decode(hex_str).context("Failed to decode encrypted secret (corrupt hex)")?;
anyhow::ensure!(
blob.len() > NONCE_LEN,
"Encrypted value too short (missing nonce)"
);
let (nonce_bytes, ciphertext) = blob.split_at(NONCE_LEN);
let nonce = Nonce::from_slice(nonce_bytes);
let key_bytes = self.load_or_create_key()?;
let key = Key::from_slice(&key_bytes);
let cipher = ChaCha20Poly1305::new(key);
let plaintext_bytes = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| anyhow::anyhow!("Decryption failed — wrong key or tampered data"))?;
String::from_utf8(plaintext_bytes)
.context("Decrypted secret is not valid UTF-8 — corrupt data")
}
/// Decrypt using legacy XOR cipher (insecure, for backward compatibility only).
fn decrypt_legacy_xor(&self, hex_str: &str) -> Result<String> {
let ciphertext = hex_decode(hex_str)
.context("Failed to decode legacy encrypted secret (corrupt hex)")?;
let key = self.load_or_create_key()?;
let plaintext_bytes = xor_cipher(&ciphertext, &key);
String::from_utf8(plaintext_bytes)
.context("Decrypted legacy secret is not valid UTF-8 — wrong key or corrupt data")
}
/// Check if a value is already encrypted (current or legacy format).
pub fn is_encrypted(value: &str) -> bool {
value.starts_with("enc2:") || value.starts_with("enc:")
}
/// Check if a value uses the secure `enc2:` format.
pub fn is_secure_encrypted(value: &str) -> bool {
value.starts_with("enc2:")
}
/// Load the encryption key from disk, or create one if it doesn't exist.
fn load_or_create_key(&self) -> Result<Vec<u8>> {
if self.key_path.exists() {
@ -382,6 +431,258 @@ mod tests {
assert_eq!(decrypted, plaintext, "Legacy XOR values must still decrypt");
}
// ── Migration tests ─────────────────────────────────────────
#[test]
fn needs_migration_detects_legacy_prefix() {
assert!(SecretStore::needs_migration("enc:aabbcc"));
assert!(!SecretStore::needs_migration("enc2:aabbcc"));
assert!(!SecretStore::needs_migration("sk-plaintext"));
assert!(!SecretStore::needs_migration(""));
}
#[test]
fn is_secure_encrypted_detects_enc2_only() {
assert!(SecretStore::is_secure_encrypted("enc2:aabbcc"));
assert!(!SecretStore::is_secure_encrypted("enc:aabbcc"));
assert!(!SecretStore::is_secure_encrypted("sk-plaintext"));
assert!(!SecretStore::is_secure_encrypted(""));
}
#[test]
fn decrypt_and_migrate_returns_none_for_enc2() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
let encrypted = store.encrypt("my-secret").unwrap();
assert!(encrypted.starts_with("enc2:"));
let (plaintext, migrated) = store.decrypt_and_migrate(&encrypted).unwrap();
assert_eq!(plaintext, "my-secret");
assert!(
migrated.is_none(),
"enc2: values should not trigger migration"
);
}
#[test]
fn decrypt_and_migrate_returns_none_for_plaintext() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
let (plaintext, migrated) = store.decrypt_and_migrate("sk-plaintext-key").unwrap();
assert_eq!(plaintext, "sk-plaintext-key");
assert!(
migrated.is_none(),
"Plaintext values should not trigger migration"
);
}
#[test]
fn decrypt_and_migrate_upgrades_legacy_xor() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
// Create key first
let _ = store.encrypt("setup").unwrap();
let key = store.load_or_create_key().unwrap();
// Manually create a legacy XOR-encrypted value
let plaintext = "sk-legacy-secret-to-migrate";
let ciphertext = xor_cipher(plaintext.as_bytes(), &key);
let legacy_value = format!("enc:{}", hex_encode(&ciphertext));
// Verify it needs migration
assert!(SecretStore::needs_migration(&legacy_value));
// Decrypt and migrate
let (decrypted, migrated) = store.decrypt_and_migrate(&legacy_value).unwrap();
assert_eq!(decrypted, plaintext, "Plaintext must match original");
assert!(migrated.is_some(), "Legacy value should trigger migration");
let new_value = migrated.unwrap();
assert!(
new_value.starts_with("enc2:"),
"Migrated value must use enc2: prefix"
);
assert!(
!SecretStore::needs_migration(&new_value),
"Migrated value should not need migration"
);
// Verify the migrated value decrypts correctly
let (decrypted2, migrated2) = store.decrypt_and_migrate(&new_value).unwrap();
assert_eq!(
decrypted2, plaintext,
"Migrated value must decrypt to same plaintext"
);
assert!(
migrated2.is_none(),
"Migrated value should not trigger another migration"
);
}
#[test]
fn decrypt_and_migrate_handles_unicode() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
let _ = store.encrypt("setup").unwrap();
let key = store.load_or_create_key().unwrap();
let plaintext = "sk-日本語-émojis-🦀-тест";
let ciphertext = xor_cipher(plaintext.as_bytes(), &key);
let legacy_value = format!("enc:{}", hex_encode(&ciphertext));
let (decrypted, migrated) = store.decrypt_and_migrate(&legacy_value).unwrap();
assert_eq!(decrypted, plaintext);
assert!(migrated.is_some());
// Verify migrated value works
let new_value = migrated.unwrap();
let (decrypted2, _) = store.decrypt_and_migrate(&new_value).unwrap();
assert_eq!(decrypted2, plaintext);
}
#[test]
fn decrypt_and_migrate_handles_empty_secret() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
let _ = store.encrypt("setup").unwrap();
let key = store.load_or_create_key().unwrap();
// Empty plaintext XOR-encrypted
let plaintext = "";
let ciphertext = xor_cipher(plaintext.as_bytes(), &key);
let legacy_value = format!("enc:{}", hex_encode(&ciphertext));
let (decrypted, migrated) = store.decrypt_and_migrate(&legacy_value).unwrap();
assert_eq!(decrypted, plaintext);
// Empty string encryption returns empty string (not enc2:)
assert!(migrated.is_some());
assert_eq!(migrated.unwrap(), "");
}
#[test]
fn decrypt_and_migrate_handles_long_secret() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
let _ = store.encrypt("setup").unwrap();
let key = store.load_or_create_key().unwrap();
let plaintext = "a".repeat(10_000);
let ciphertext = xor_cipher(plaintext.as_bytes(), &key);
let legacy_value = format!("enc:{}", hex_encode(&ciphertext));
let (decrypted, migrated) = store.decrypt_and_migrate(&legacy_value).unwrap();
assert_eq!(decrypted, plaintext);
assert!(migrated.is_some());
let new_value = migrated.unwrap();
let (decrypted2, _) = store.decrypt_and_migrate(&new_value).unwrap();
assert_eq!(decrypted2, plaintext);
}
#[test]
fn decrypt_and_migrate_fails_on_corrupt_legacy_hex() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
let _ = store.encrypt("setup").unwrap();
let result = store.decrypt_and_migrate("enc:not-valid-hex!!");
assert!(result.is_err(), "Corrupt hex should fail");
}
#[test]
fn decrypt_and_migrate_wrong_key_produces_garbage_or_fails() {
let tmp1 = TempDir::new().unwrap();
let tmp2 = TempDir::new().unwrap();
let store1 = SecretStore::new(tmp1.path(), true);
let store2 = SecretStore::new(tmp2.path(), true);
// Create keys for both stores
let _ = store1.encrypt("setup").unwrap();
let _ = store2.encrypt("setup").unwrap();
let key1 = store1.load_or_create_key().unwrap();
// Encrypt with store1's key
let plaintext = "secret-for-store1";
let ciphertext = xor_cipher(plaintext.as_bytes(), &key1);
let legacy_value = format!("enc:{}", hex_encode(&ciphertext));
// Decrypt with store2 — XOR will produce garbage bytes
// This may fail with UTF-8 error or succeed with garbage plaintext
match store2.decrypt_and_migrate(&legacy_value) {
Ok((decrypted, _)) => {
// If it succeeds, the plaintext should be garbage (not the original)
assert_ne!(
decrypted, plaintext,
"Wrong key should produce garbage plaintext"
);
}
Err(e) => {
// Expected: UTF-8 decoding failure from garbage bytes
assert!(
e.to_string().contains("UTF-8"),
"Error should be UTF-8 related: {e}"
);
}
}
}
#[test]
fn migration_produces_different_ciphertext_each_time() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
let _ = store.encrypt("setup").unwrap();
let key = store.load_or_create_key().unwrap();
let plaintext = "sk-same-secret";
let ciphertext = xor_cipher(plaintext.as_bytes(), &key);
let legacy_value = format!("enc:{}", hex_encode(&ciphertext));
let (_, migrated1) = store.decrypt_and_migrate(&legacy_value).unwrap();
let (_, migrated2) = store.decrypt_and_migrate(&legacy_value).unwrap();
assert!(migrated1.is_some());
assert!(migrated2.is_some());
assert_ne!(
migrated1.unwrap(),
migrated2.unwrap(),
"Each migration should produce different ciphertext (random nonce)"
);
}
#[test]
fn migrated_value_is_tamper_resistant() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
let _ = store.encrypt("setup").unwrap();
let key = store.load_or_create_key().unwrap();
let plaintext = "sk-sensitive-data";
let ciphertext = xor_cipher(plaintext.as_bytes(), &key);
let legacy_value = format!("enc:{}", hex_encode(&ciphertext));
let (_, migrated) = store.decrypt_and_migrate(&legacy_value).unwrap();
let new_value = migrated.unwrap();
// Tamper with the migrated value
let hex_str = &new_value[5..];
let mut blob = hex_decode(hex_str).unwrap();
if blob.len() > NONCE_LEN {
blob[NONCE_LEN] ^= 0xff;
}
let tampered = format!("enc2:{}", hex_encode(&blob));
let result = store.decrypt_and_migrate(&tampered);
assert!(result.is_err(), "Tampered migrated value must be rejected");
}
// ── Low-level helpers ───────────────────────────────────────
#[test]