Fix transfer rough edges: value-name hint, identity warnings, single-profile export

- --help now shows <PATH.toml> instead of the format-agnostic <PATH>.
- 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 23:04:30 +02:00
parent 125d72c918
commit 5ce0614cad
3 changed files with 85 additions and 8 deletions

View File

@@ -188,11 +188,14 @@ pub struct WipeArgs {
#[derive(Args)] #[derive(Args)]
pub struct TransferArgs { pub struct TransferArgs {
/// Export all saved profiles to a file. /// Name of the profile to export; every saved profile if omitted. Not used for import.
#[arg(short, long, value_name = "PATH")] pub name: Option<String>,
/// Export saved profiles to a file.
#[arg(short, long, value_name = "PATH.toml")]
pub export: Option<String>, pub export: Option<String>,
/// Import profiles from a file. /// Import profiles from a file.
#[arg(short, long, value_name = "PATH")] #[arg(short, long, value_name = "PATH.toml")]
pub import: Option<String>, pub import: Option<String>,
} }

View File

@@ -7,7 +7,7 @@ use crate::error::{PortholeError, Result};
use crate::profile::{self, Profile}; use crate::profile::{self, Profile};
use crate::{atomic, ui}; use crate::{atomic, ui};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::Path; use std::path::{Path, PathBuf};
#[derive(Default, Serialize, Deserialize)] #[derive(Default, Serialize, Deserialize)]
struct TransferFile { struct TransferFile {
@@ -19,16 +19,32 @@ pub fn run(args: TransferArgs) -> Result<()> {
match (&args.export, &args.import) { match (&args.export, &args.import) {
(Some(_), Some(_)) => Err(PortholeError::TransferConflictingMode), (Some(_), Some(_)) => Err(PortholeError::TransferConflictingMode),
(None, None) => Err(PortholeError::TransferNoMode), (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), (None, Some(path)) => import(path),
} }
} }
fn export(path: &str) -> Result<()> { fn export(path: &str, name: Option<&str>) -> Result<()> {
let file = TransferFile { profiles: profile::list_all()? }; 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)?; let text = toml::to_string_pretty(&file)?;
atomic::write(Path::new(path), text.as_bytes())?; 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(()) Ok(())
} }
@@ -47,5 +63,60 @@ fn import(path: &str) -> Result<()> {
} }
ui::ok(&format!("Imported {} profile(s) from '{path}'.", file.profiles.len())); ui::ok(&format!("Imported {} profile(s) from '{path}'.", file.profiles.len()));
warn_about_missing_identities(&file.profiles);
Ok(()) 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 <path>' 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"));
}
}

View File

@@ -35,6 +35,9 @@ pub enum PortholeError {
#[error("only one of -i/--import, -e/--export may be given")] #[error("only one of -i/--import, -e/--export may be given")]
TransferConflictingMode, TransferConflictingMode,
#[error("a profile name only applies to -e/--export, not -i/--import")]
TransferNameWithImport,
#[error("nothing to do: {0}")] #[error("nothing to do: {0}")]
NothingToDo(String), NothingToDo(String),