1use super::load::path;
2use super::Complete;
3
4use std::io::Write;
5use std::{fs, path::PathBuf};
6
7use anyhow::{bail, Context};
8use keyring::Entry;
9use log::{debug, warn};
10use pijul_interaction::{Confirm, Input, Select};
11use thrussh_keys::key::PublicKey;
12
13impl Complete {
14 pub async fn prompt_changes(
19 &self,
20 to_replace: Option<String>,
21 link_remote: bool,
22 ) -> Result<Self, anyhow::Error> {
23 let mut new_identity = self.clone();
24 let will_replace = to_replace.is_some();
25
26 new_identity.name = Input::new()?
27 .with_prompt("Unique identity name")
28 .with_default(String::from("default"))
29 .with_allow_empty(false)
30 .with_initial_text(if will_replace {
31 self.name.clone()
32 } else {
33 String::new()
34 })
35 .with_validator(move |input: &String| -> Result<(), String> {
36 if input.contains(['/', '\\', '.']) {
37 return Err("Name contains illegal characters".to_string());
38 }
39
40 match Self::load(input) {
41 Ok(existing_identity) => {
42 if let Some(name) = &to_replace {
43 if name == input {
44 Ok(())
46 } else {
47 Err(format!("The identity {existing_identity} already exists. Either remove the identity or edit it directly."))
49 }
50 } else {
51 Err(format!("The identity {existing_identity} already exists. Either remove the identity or edit it directly."))
53 }
54 }
55 Err(_) => Ok(()),
56 }
57 })
58 .interact()?;
59
60 new_identity.config.author.display_name = Input::new()?
61 .with_prompt("Display name")
62 .with_allow_empty(true)
63 .with_initial_text(&self.config.author.display_name)
64 .interact()?;
65
66 new_identity.config.author.email = Input::new()?
67 .with_prompt("Email (leave blank for none)")
68 .with_allow_empty(true)
69 .with_initial_text(&self.config.author.email)
70 .with_validator(move |input: &String| -> Result<(), &str> {
71 if input.is_empty() || validator::validate_email(input) {
72 Ok(())
73 } else {
74 Err("Invalid email address")
75 }
76 })
77 .interact()?;
78
79 if Confirm::new()?
80 .with_prompt(&format!(
81 "Do you want to change the encryption? (Current status: {})",
82 self.credentials
83 .clone()
84 .unwrap()
85 .secret_key
86 .encryption
87 .map_or("not encrypted", |_| "encrypted")
88 ))
89 .with_default(false)
90 .interact()?
91 {
92 new_identity.change_password()?;
93 }
94
95 new_identity.prompt_expiry()?;
97
98 if link_remote {
99 if Confirm::new()?
100 .with_prompt("Do you want to link this identity to a remote?")
101 .with_default(true)
102 .interact()?
103 {
104 new_identity.prompt_remote().await?;
105 } else {
106 new_identity.config.key_path = None;
108 new_identity.config.author.username = String::new();
109 new_identity.config.author.origin = String::new();
110 }
111 }
112
113 new_identity.last_modified = chrono::offset::Utc::now();
114
115 Ok(new_identity)
116 }
117
118 async fn prompt_ssh(&mut self) -> Result<(), anyhow::Error> {
119 let mut ssh_agent = thrussh_keys::agent::client::AgentClient::connect_env().await?;
120 let identities = ssh_agent.request_identities().await?;
121 let ssh_dir = dirs_next::home_dir().unwrap().join(".ssh");
122
123 let selection = Select::new()?
124 .with_prompt("Select key")
125 .with_items(
126 &identities
127 .iter()
128 .map(|id| {
129 format!(
130 "{}: {} ({})",
131 id.name(),
132 id.fingerprint(),
133 ssh_dir
134 .join(match id {
135 PublicKey::Ed25519(_) =>
136 thrussh_keys::key::ED25519.identity_file(),
137 PublicKey::RSA { ref hash, .. } => hash.name().identity_file(),
138 })
139 .display(),
140 )
141 })
142 .collect::<Vec<_>>(),
143 )
144 .with_default(0 as usize)
145 .interact()?;
146
147 self.config.key_path = Some(ssh_dir.join(match identities[selection] {
148 PublicKey::Ed25519(_) => thrussh_keys::key::ED25519.identity_file(),
149 PublicKey::RSA { ref hash, .. } => hash.name().identity_file(),
150 }));
151
152 Ok(())
153 }
154
155 async fn prompt_remote(&mut self) -> Result<(), anyhow::Error> {
156 self.config.author.username = Input::new()?
157 .with_prompt("Remote username")
158 .with_default(whoami::username())
159 .with_initial_text(&self.config.author.username)
160 .interact()?;
161
162 self.config.author.origin = Input::new()?
163 .with_prompt("Remote URL")
164 .with_initial_text(&self.config.author.origin)
165 .with_default(String::from("ssh.pijul.com"))
166 .interact()?;
167
168 if Confirm::new()?
169 .with_prompt(&format!(
170 "Do you want to change the default SSH key? (Current key: {})",
171 if let Some(path) = &self.config.key_path {
172 format!("{path:#?}")
173 } else {
174 String::from("none")
175 }
176 ))
177 .with_default(false)
178 .interact()?
179 {
180 self.prompt_ssh().await?;
181 }
182
183 debug!("prompt remote {:?}", self.config.author);
184
185 Ok(())
186 }
187
188 fn prompt_expiry(&mut self) -> Result<(), anyhow::Error> {
189 let expiry_message = self
190 .public_key
191 .expires
192 .map(|date| date.format("%Y-%m-%d %H:%M:%S").to_string());
193
194 self.public_key.expires = if Confirm::new()?
195 .with_prompt(format!(
196 "Do you want this key to expire? (Current expiry: {})",
197 expiry_message
198 .clone()
199 .unwrap_or_else(|| String::from("never"))
200 ))
201 .with_default(false)
202 .interact()?
203 {
204 let time_stamp: String = Input::new()?
205 .with_prompt("Expiry date (YYYY-MM-DD)")
206 .with_initial_text(expiry_message.unwrap_or_default())
207 .with_validator(move |input: &String| -> Result<(), &str> {
208 let parsed_date = dateparser::parse_with_timezone(input, &chrono::offset::Utc);
209 if parsed_date.is_err() {
210 return Err("Invalid date");
211 }
212
213 let date = parsed_date.unwrap();
214 if chrono::offset::Utc::now().timestamp_millis() > date.timestamp_millis() {
215 Err("Date is in the past")
216 } else {
217 Ok(())
218 }
219 })
220 .interact()?;
221
222 Some(dateparser::parse_with_timezone(
223 &time_stamp,
224 &chrono::offset::Utc,
225 )?)
226 } else {
227 None
228 };
229
230 Ok(())
231 }
232
233 fn write_config(&self, identity_dir: &PathBuf) -> Result<(), anyhow::Error> {
234 let config_data = toml::to_string_pretty(&self)?;
235 let mut config_file = std::fs::File::create(identity_dir.join("identity.toml"))?;
236 config_file.write_all(config_data.as_bytes())?;
237
238 Ok(())
239 }
240
241 fn write_secret_key(&self, identity_dir: &PathBuf) -> Result<(), anyhow::Error> {
242 let key_data = serde_json::to_string_pretty(&self.secret_key())?;
243 let mut key_file = std::fs::File::create(&identity_dir.join("secret_key.json"))?;
244 key_file.write_all(key_data.as_bytes())?;
245
246 Ok(())
247 }
248
249 fn write(&self) -> Result<(), anyhow::Error> {
251 if let Ok(existing_identity) = Self::load(&self.name) {
252 bail!("An identity with that name already exists: {existing_identity}");
253 }
254
255 let identity_dir = path(&self.name, false)?;
257
258 std::fs::create_dir_all(&identity_dir)?;
259 self.write_config(&identity_dir)?;
260 self.write_secret_key(&identity_dir)?;
261
262 Ok(())
263 }
264
265 pub async fn create(&self, link_remote: bool) -> Result<(), anyhow::Error> {
270 let confirmed_identity = self.prompt_changes(None, link_remote).await?;
272 confirmed_identity.write()?;
273
274 Ok(())
275 }
276
277 pub fn replace_with(self, new_identity: Self) -> Result<Self, anyhow::Error> {
282 let changed_names = self.name != new_identity.name;
283
284 if changed_names {
286 let old_identity_path = path(&self.name, true)?;
287 debug!("Removing old directory: {old_identity_path:?}");
288 fs::remove_dir_all(old_identity_path).context("Could not remove old identity.")?;
289
290 let new_identity_path = path(&new_identity.name, false)?;
291 debug!("Creating new directory: {new_identity_path:?}");
292 fs::create_dir_all(new_identity_path).context("Could not create new identity.")?;
293
294 new_identity.write()?;
295
296 if let Err(e) = Entry::new("pijul", &self.name).and_then(|x| x.delete_password()) {
298 warn!("Unable to delete password: {e:?}");
299 }
300 } else {
301 let identity_dir = path(&new_identity.name, false)?;
303 if self.config != new_identity.config {
304 new_identity.write_config(&identity_dir)?;
305 }
306 if self.secret_key() != new_identity.secret_key() {
307 new_identity.write_secret_key(&identity_dir)?;
308 }
309 }
310
311 if let Some(password) = new_identity.credentials.clone().unwrap().password.get() {
313 if let Err(e) =
314 Entry::new("pijul", &new_identity.name).and_then(|x| x.set_password(&password))
315 {
316 warn!("Unable to set password: {e:?}");
317 }
318 } else if let Err(e) =
319 Entry::new("pijul", &new_identity.name).and_then(|x| x.delete_password())
320 {
321 warn!("Unable to delete password: {e:?}");
322 }
323
324 Ok(new_identity)
325 }
326}