use std::io::Read;
use std::path::Path;
use colored::Colorize;
use crate::cli;
use crate::error::Result;
use crate::export;
use crate::export::transport;
use crate::parser;
use crate::store::queries::{self, SaveInput};
use super::transport as remote;
pub fn run(
cwd: &Path,
file: Option<&str>,
key_file: Option<&str>,
password_file: Option<&Path>,
from: Option<&str>,
) -> Result<()> {
let mut conn = cli::require_store()?;
let aes_key = cli::load_encryption_key(&conn, key_file)?;
let (project_path, _git_ctx) = cli::resolve_project(cwd)?;
let raw_bytes = if let Some(source) = from {
remote::fetch(source)?
} else {
match file {
Some(path) => std::fs::read(path)?,
None => {
let mut buf = Vec::new();
std::io::stdin().read_to_end(&mut buf)?;
buf
}
}
};
if raw_bytes.is_empty() {
return Err(crate::error::Error::Other(
"empty input: nothing to import".to_string(),
));
}
let decrypted = if transport::detect(&raw_bytes) == transport::TransportEncryption::Password {
let pw = crate::crypto::password::resolve_password(password_file)?;
transport::decrypt_auto(&raw_bytes, Some(&pw))?
} else {
transport::decrypt_auto(&raw_bytes, None)?
};
let input = std::str::from_utf8(&decrypted)
.map_err(|e| crate::error::Error::Other(format!("invalid UTF-8 in import data: {e}")))?;
let envelope = export::auto_detect(input)?;
if super::apply::has_path_traversal(&envelope.file) {
return Err(crate::error::Error::Other(format!(
"Refusing to import: file path '{}' contains path traversal components",
envelope.file
)));
}
let entries = export::to_env_entries(&envelope);
let computed_hash = parser::content_hash(&entries);
if !envelope.content_hash.is_empty() && computed_hash != envelope.content_hash {
eprintln!(
"{} content hash mismatch (expected {}, computed {})",
"warning:".yellow(),
envelope.content_hash,
computed_hash
);
}
queries::insert_save_input(
&mut conn,
&SaveInput {
project_path: &project_path,
file_path: &envelope.file,
branch: &envelope.branch,
commit_hash: &envelope.commit,
timestamp: &envelope.timestamp,
content_hash: &computed_hash,
entries: &entries,
aes_key: aes_key.as_deref(),
message: envelope.message.as_deref(),
},
)?;
println!(
"{} {} ({} variables, branch: {}, timestamp: {})",
"Imported".green().bold(),
envelope.file,
entries.len(),
if envelope.branch.is_empty() {
"(none)"
} else {
&envelope.branch
},
envelope.timestamp,
);
Ok(())
}
#[cfg(test)]
mod tests {
use crate::export::transport;
use crate::export::{self, ExportEntry, ExportEnvelope};
use crate::store::queries;
use crate::test_helpers::{sample_entries, test_conn};
fn sample_envelope() -> ExportEnvelope {
ExportEnvelope {
version: 1,
file: ".env".to_string(),
branch: "main".to_string(),
commit: "abc123".to_string(),
timestamp: "2024-06-17T12:00:00Z".to_string(),
content_hash: "will_be_recomputed".to_string(),
message: None,
entries: vec![
ExportEntry {
key: "DB_HOST".to_string(),
value: "localhost".to_string(),
comment: Some("Host config".to_string()),
},
ExportEntry {
key: "DB_PORT".to_string(),
value: "5432".to_string(),
comment: None,
},
],
}
}
#[test]
fn import_json_envelope() {
let mut conn = test_conn();
let envelope = sample_envelope();
let json = export::to_json(&envelope).unwrap();
let parsed = export::auto_detect(&json).unwrap();
let entries = export::to_env_entries(&parsed);
let hash = crate::parser::content_hash(&entries);
queries::insert_save(
&mut conn,
"/proj",
&parsed.file,
&parsed.branch,
&parsed.commit,
&parsed.timestamp,
&hash,
&entries,
None,
)
.unwrap();
let saves = queries::list_saves(&conn, "/proj", None, None, 10, None).unwrap();
assert_eq!(saves.len(), 1);
assert_eq!(saves[0].file_path, ".env");
assert_eq!(saves[0].branch, "main");
let loaded = queries::get_save_entries(&conn, saves[0].id, None).unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].key, "DB_HOST");
}
#[test]
fn import_text_envelope() {
let mut conn = test_conn();
let envelope = sample_envelope();
let text = export::to_text(&envelope);
let parsed = export::auto_detect(&text).unwrap();
let entries = export::to_env_entries(&parsed);
let hash = crate::parser::content_hash(&entries);
queries::insert_save(
&mut conn,
"/proj",
&parsed.file,
&parsed.branch,
&parsed.commit,
&parsed.timestamp,
&hash,
&entries,
None,
)
.unwrap();
let saves = queries::list_saves(&conn, "/proj", None, None, 10, None).unwrap();
assert_eq!(saves.len(), 1);
assert_eq!(saves[0].branch, "main");
}
#[test]
fn import_into_encrypted_store() {
let mut conn = test_conn();
let key = crate::crypto::aes::generate_key();
let envelope = sample_envelope();
let json = export::to_json(&envelope).unwrap();
let parsed = export::auto_detect(&json).unwrap();
let entries = export::to_env_entries(&parsed);
let hash = crate::parser::content_hash(&entries);
queries::insert_save(
&mut conn,
"/proj",
&parsed.file,
&parsed.branch,
&parsed.commit,
&parsed.timestamp,
&hash,
&entries,
Some(&key),
)
.unwrap();
let saves = queries::list_saves(&conn, "/proj", None, None, 10, None).unwrap();
let loaded = queries::get_save_entries(&conn, saves[0].id, Some(&key)).unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].key, "DB_HOST");
assert_eq!(loaded[0].value, "localhost");
}
#[test]
fn pipe_simulation_json() {
let mut conn = test_conn();
let entries = sample_entries();
queries::insert_save(
&mut conn,
"/proj",
".env",
"main",
"abc",
"2024-06-17T12:00:00Z",
"h1",
&entries,
None,
)
.unwrap();
let saves = queries::list_saves(&conn, "/proj", Some("main"), None, 1, None).unwrap();
let loaded = queries::get_save_entries(&conn, saves[0].id, None).unwrap();
let envelope = export::build_envelope(&saves[0], &loaded);
let shared = export::to_json(&envelope).unwrap();
let parsed = export::auto_detect(&shared).unwrap();
let imported_entries = export::to_env_entries(&parsed);
let hash = crate::parser::content_hash(&imported_entries);
queries::insert_save(
&mut conn,
"/proj2",
&parsed.file,
&parsed.branch,
&parsed.commit,
&parsed.timestamp,
&hash,
&imported_entries,
None,
)
.unwrap();
let proj2_saves = queries::list_saves(&conn, "/proj2", None, None, 10, None).unwrap();
assert_eq!(proj2_saves.len(), 1);
let proj2_entries = queries::get_save_entries(&conn, proj2_saves[0].id, None).unwrap();
assert_eq!(proj2_entries, entries);
}
#[test]
fn pipe_simulation_text() {
let mut conn = test_conn();
let entries = sample_entries();
queries::insert_save(
&mut conn,
"/proj",
".env",
"dev",
"def",
"2024-06-17T12:00:00Z",
"h2",
&entries,
None,
)
.unwrap();
let saves = queries::list_saves(&conn, "/proj", Some("dev"), None, 1, None).unwrap();
let loaded = queries::get_save_entries(&conn, saves[0].id, None).unwrap();
let envelope = export::build_envelope(&saves[0], &loaded);
let shared = export::to_text(&envelope);
let parsed = export::auto_detect(&shared).unwrap();
let imported_entries = export::to_env_entries(&parsed);
let hash = crate::parser::content_hash(&imported_entries);
queries::insert_save(
&mut conn,
"/proj2",
&parsed.file,
&parsed.branch,
&parsed.commit,
&parsed.timestamp,
&hash,
&imported_entries,
None,
)
.unwrap();
let proj2_saves = queries::list_saves(&conn, "/proj2", None, None, 10, None).unwrap();
assert_eq!(proj2_saves.len(), 1);
let proj2_entries = queries::get_save_entries(&conn, proj2_saves[0].id, None).unwrap();
assert_eq!(proj2_entries, entries);
}
#[test]
fn import_auto_detects_plaintext() {
let envelope = sample_envelope();
let json = export::to_json(&envelope).unwrap();
let raw_bytes = json.as_bytes();
assert_eq!(
transport::detect(raw_bytes),
transport::TransportEncryption::None,
);
let decrypted = transport::decrypt_auto(raw_bytes, None).unwrap();
assert_eq!(decrypted, raw_bytes);
let text = std::str::from_utf8(&decrypted).unwrap();
let parsed = export::auto_detect(text).unwrap();
assert_eq!(parsed.entries.len(), 2);
}
#[test]
fn import_auto_detects_password_encrypted() {
let envelope = sample_envelope();
let json = export::to_json(&envelope).unwrap();
let encrypted = transport::encrypt_password(json.as_bytes(), "import-pw").unwrap();
assert_eq!(
transport::detect(&encrypted),
transport::TransportEncryption::Password,
);
let decrypted = transport::decrypt_auto(&encrypted, Some("import-pw")).unwrap();
let text = std::str::from_utf8(&decrypted).unwrap();
let parsed = export::auto_detect(text).unwrap();
assert_eq!(parsed.entries.len(), 2);
assert_eq!(parsed.branch, "main");
}
#[test]
fn import_password_encrypted_wrong_password_fails() {
let envelope = sample_envelope();
let json = export::to_json(&envelope).unwrap();
let encrypted = transport::encrypt_password(json.as_bytes(), "correct").unwrap();
let result = transport::decrypt_auto(&encrypted, Some("wrong"));
assert!(result.is_err());
}
#[test]
fn import_password_encrypted_no_password_fails() {
let envelope = sample_envelope();
let json = export::to_json(&envelope).unwrap();
let encrypted = transport::encrypt_password(json.as_bytes(), "pw").unwrap();
let result = transport::decrypt_auto(&encrypted, None);
assert!(result.is_err());
}
#[test]
fn full_encrypted_share_import_round_trip() {
let mut conn = test_conn();
let entries = sample_entries();
queries::insert_save(
&mut conn,
"/proj",
".env",
"main",
"abc",
"2024-06-17T12:00:00Z",
"h1",
&entries,
None,
)
.unwrap();
let saves = queries::list_saves(&conn, "/proj", Some("main"), None, 1, None).unwrap();
let loaded = queries::get_save_entries(&conn, saves[0].id, None).unwrap();
let envelope = export::build_envelope(&saves[0], &loaded);
let serialized = export::to_json(&envelope).unwrap();
let encrypted = transport::encrypt_password(serialized.as_bytes(), "roundtrip-pw").unwrap();
let decrypted = transport::decrypt_auto(&encrypted, Some("roundtrip-pw")).unwrap();
let text = std::str::from_utf8(&decrypted).unwrap();
let parsed = export::auto_detect(text).unwrap();
let imported_entries = export::to_env_entries(&parsed);
let hash = crate::parser::content_hash(&imported_entries);
queries::insert_save(
&mut conn,
"/proj2",
&parsed.file,
&parsed.branch,
&parsed.commit,
&parsed.timestamp,
&hash,
&imported_entries,
None,
)
.unwrap();
let proj2_saves = queries::list_saves(&conn, "/proj2", None, None, 10, None).unwrap();
assert_eq!(proj2_saves.len(), 1);
let proj2_entries = queries::get_save_entries(&conn, proj2_saves[0].id, None).unwrap();
assert_eq!(proj2_entries, entries);
}
#[test]
fn full_encrypted_text_format_round_trip() {
let mut conn = test_conn();
let entries = sample_entries();
queries::insert_save(
&mut conn,
"/proj",
".env",
"dev",
"def",
"2024-06-17T12:00:00Z",
"h2",
&entries,
None,
)
.unwrap();
let saves = queries::list_saves(&conn, "/proj", Some("dev"), None, 1, None).unwrap();
let loaded = queries::get_save_entries(&conn, saves[0].id, None).unwrap();
let envelope = export::build_envelope(&saves[0], &loaded);
let serialized = export::to_text(&envelope);
let encrypted = transport::encrypt_password(serialized.as_bytes(), "text-rt").unwrap();
let decrypted = transport::decrypt_auto(&encrypted, Some("text-rt")).unwrap();
let text = std::str::from_utf8(&decrypted).unwrap();
let parsed = export::auto_detect(text).unwrap();
let imported_entries = export::to_env_entries(&parsed);
let hash = crate::parser::content_hash(&imported_entries);
queries::insert_save(
&mut conn,
"/proj2",
&parsed.file,
&parsed.branch,
&parsed.commit,
&parsed.timestamp,
&hash,
&imported_entries,
None,
)
.unwrap();
let proj2_saves = queries::list_saves(&conn, "/proj2", None, None, 10, None).unwrap();
let proj2_entries = queries::get_save_entries(&conn, proj2_saves[0].id, None).unwrap();
assert_eq!(proj2_entries, entries);
}
#[test]
fn import_rejects_path_traversal() {
use super::super::apply::has_path_traversal;
assert!(has_path_traversal("../../.bashrc"));
assert!(has_path_traversal("../secret"));
assert!(has_path_traversal("apps/../../.bashrc"));
assert!(!has_path_traversal(".env"));
assert!(!has_path_traversal("apps/backend/.env"));
}
#[test]
fn import_preserves_message_json() {
let mut conn = test_conn();
let mut envelope = sample_envelope();
envelope.message = Some("important config".to_string());
let json = export::to_json(&envelope).unwrap();
let parsed = export::auto_detect(&json).unwrap();
assert_eq!(parsed.message.as_deref(), Some("important config"));
let entries = export::to_env_entries(&parsed);
let hash = crate::parser::content_hash(&entries);
queries::insert_save_with_message(
&mut conn,
"/proj",
&parsed.file,
&parsed.branch,
&parsed.commit,
&parsed.timestamp,
&hash,
&entries,
None,
parsed.message.as_deref(),
)
.unwrap();
let saves = queries::list_saves(&conn, "/proj", None, None, 10, None).unwrap();
assert_eq!(saves[0].message.as_deref(), Some("important config"));
}
#[test]
fn import_preserves_message_text() {
let mut conn = test_conn();
let mut envelope = sample_envelope();
envelope.message = Some("text format message".to_string());
let text = export::to_text(&envelope);
let parsed = export::auto_detect(&text).unwrap();
assert_eq!(parsed.message.as_deref(), Some("text format message"));
let entries = export::to_env_entries(&parsed);
let hash = crate::parser::content_hash(&entries);
queries::insert_save_with_message(
&mut conn,
"/proj",
&parsed.file,
&parsed.branch,
&parsed.commit,
&parsed.timestamp,
&hash,
&entries,
None,
parsed.message.as_deref(),
)
.unwrap();
let saves = queries::list_saves(&conn, "/proj", None, None, 10, None).unwrap();
assert_eq!(saves[0].message.as_deref(), Some("text format message"));
}
}