From 5ce0614cad5e251915d0754de978f9b9698393b0 Mon Sep 17 00:00:00 2001 From: Overlord Date: Thu, 13 Aug 2026 23:04:30 +0200 Subject: [PATCH] Fix transfer rough edges: value-name hint, identity warnings, single-profile export - --help now shows instead of the format-agnostic . - Export warns (naming the affected profiles) that identity files aren't included, only their local paths; import warns per-profile when an identity path doesn't resolve on the importing machine, expanding a leading ~/ the same way ssh.rs does so that check isn't a false positive for tilde paths. - transfer now takes an optional profile-name positional (same shape as OpenArgs.name) so -e/--export can target a single profile instead of always dumping every saved one; combining it with -i/--import is rejected with a new TransferNameWithImport error. Co-Authored-By: Claude Sonnet 5 --- src/cli.rs | 9 +++-- src/commands/transfer.rs | 81 +++++++++++++++++++++++++++++++++++++--- src/error.rs | 3 ++ 3 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 104517e..2737d7a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -188,11 +188,14 @@ pub struct WipeArgs { #[derive(Args)] pub struct TransferArgs { - /// Export all saved profiles to a file. - #[arg(short, long, value_name = "PATH")] + /// Name of the profile to export; every saved profile if omitted. Not used for import. + pub name: Option, + + /// Export saved profiles to a file. + #[arg(short, long, value_name = "PATH.toml")] pub export: Option, /// Import profiles from a file. - #[arg(short, long, value_name = "PATH")] + #[arg(short, long, value_name = "PATH.toml")] pub import: Option, } diff --git a/src/commands/transfer.rs b/src/commands/transfer.rs index 692a36e..953ed5c 100644 --- a/src/commands/transfer.rs +++ b/src/commands/transfer.rs @@ -7,7 +7,7 @@ use crate::error::{PortholeError, Result}; use crate::profile::{self, Profile}; use crate::{atomic, ui}; use serde::{Deserialize, Serialize}; -use std::path::Path; +use std::path::{Path, PathBuf}; #[derive(Default, Serialize, Deserialize)] struct TransferFile { @@ -19,16 +19,32 @@ pub fn run(args: TransferArgs) -> Result<()> { match (&args.export, &args.import) { (Some(_), Some(_)) => Err(PortholeError::TransferConflictingMode), (None, None) => Err(PortholeError::TransferNoMode), - (Some(path), None) => export(path), + (None, Some(_)) if args.name.is_some() => Err(PortholeError::TransferNameWithImport), + (Some(path), None) => export(path, args.name.as_deref()), (None, Some(path)) => import(path), } } -fn export(path: &str) -> Result<()> { - let file = TransferFile { profiles: profile::list_all()? }; +fn export(path: &str, name: Option<&str>) -> Result<()> { + let profiles = match name { + Some(n) => { + let n = profile::normalize(n); + vec![profile::load(&n)?] + } + None => profile::list_all()?, + }; + + warn_about_identities(&profiles); + + let count = profiles.len(); + let file = TransferFile { profiles }; let text = toml::to_string_pretty(&file)?; atomic::write(Path::new(path), text.as_bytes())?; - ui::ok(&format!("Exported {} profile(s) to '{path}'.", file.profiles.len())); + + match name { + Some(n) => ui::ok(&format!("Exported profile '{n}' to '{path}'.")), + None => ui::ok(&format!("Exported {count} profile(s) to '{path}'.")), + } Ok(()) } @@ -47,5 +63,60 @@ fn import(path: &str) -> Result<()> { } ui::ok(&format!("Imported {} profile(s) from '{path}'.", file.profiles.len())); + warn_about_missing_identities(&file.profiles); Ok(()) } + +/// Identity files are never included in the export, only the path - warn so +/// that doesn't come as a surprise on the importing end. +fn warn_about_identities(profiles: &[Profile]) { + let names: Vec<&str> = profiles.iter().filter(|p| p.identity.is_some()).map(|p| p.name.as_str()).collect(); + if !names.is_empty() { + ui::warn(&format!( + "identity files are not included in the export ({}) - copy them to the importing machine yourself", + names.join(", ") + )); + } +} + +/// After import, flag any profile whose identity path doesn't resolve on +/// this machine - the most likely sign of a not-yet-copied key file. +fn warn_about_missing_identities(profiles: &[Profile]) { + for p in profiles { + if let Some(identity) = &p.identity { + if !expand_home(identity).is_file() { + ui::warn(&format!( + "'{}': identity file '{identity}' not found on this machine - fix it with \ + 'porthole edit {} -i ' before opening", + p.name, p.name + )); + } + } + } +} + +/// Expands a leading `~/` the same way `ssh` itself does at spawn time +/// (`ssh.rs`) - without this, a valid `~/...` identity path would be +/// misreported as missing since `Path::is_file` never expands `~` on its own. +fn expand_home(path: &str) -> PathBuf { + match path.strip_prefix("~/").zip(dirs::home_dir()) { + Some((rest, home)) => home.join(rest), + None => PathBuf::from(path), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expands_leading_tilde() { + let home = dirs::home_dir().unwrap(); + assert_eq!(expand_home("~/.ssh/id"), home.join(".ssh/id")); + } + + #[test] + fn leaves_absolute_path_untouched() { + assert_eq!(expand_home("/etc/ssh/id"), PathBuf::from("/etc/ssh/id")); + } +} diff --git a/src/error.rs b/src/error.rs index 49ff4d1..19bce56 100644 --- a/src/error.rs +++ b/src/error.rs @@ -35,6 +35,9 @@ pub enum PortholeError { #[error("only one of -i/--import, -e/--export may be given")] TransferConflictingMode, + #[error("a profile name only applies to -e/--export, not -i/--import")] + TransferNameWithImport, + #[error("nothing to do: {0}")] NothingToDo(String),