1use std::{
2 fs,
3 fs::OpenOptions,
4 io::Write,
5 time::{SystemTime, UNIX_EPOCH},
6};
7
8use camino::{Utf8Path, Utf8PathBuf};
9use directories::BaseDirs;
10use fs2::FileExt;
11use uuid::Uuid;
12
13use crate::command::external_tool_path_arg;
14use crate::{AndroidError, AndroidToolchain, CommandSpec, error::io_error, run_command};
15
16pub const DEBUG_KEY_ALIAS: &str = "rustferrydebugkey";
18
19#[derive(Clone, Debug, Eq, PartialEq)]
21pub enum SigningPasswordSource {
22 File(Utf8PathBuf),
24 Environment(String),
26}
27
28impl SigningPasswordSource {
29 fn apksigner_argument(&self) -> Result<String, AndroidError> {
30 match self {
31 Self::File(path) => Ok(format!("file:{}", external_tool_path_arg(path)?)),
32 Self::Environment(name)
33 if !name.is_empty()
34 && name
35 .chars()
36 .all(|character| character.is_ascii_alphanumeric() || character == '_') =>
37 {
38 Ok(format!("env:{name}"))
39 }
40 Self::Environment(name) => Err(AndroidError::InvalidRequest(format!(
41 "signing password environment variable `{name}` is not a safe variable name"
42 ))),
43 }
44 }
45}
46
47#[derive(Clone, Debug, Eq, PartialEq)]
49pub enum AndroidSigningConfig {
50 Debug {
52 config_dir: Option<Utf8PathBuf>,
54 },
55 Keystore {
57 keystore: Utf8PathBuf,
59 key_alias: String,
61 store_password: SigningPasswordSource,
63 key_password: Option<SigningPasswordSource>,
65 },
66}
67
68impl Default for AndroidSigningConfig {
69 fn default() -> Self {
70 Self::Debug { config_dir: None }
71 }
72}
73
74#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct DebugSigningPaths {
77 pub keystore: Utf8PathBuf,
79 pub password_file: Utf8PathBuf,
81 pub lock_file: Utf8PathBuf,
83}
84
85#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct ResolvedSigningConfig {
88 pub keystore: Utf8PathBuf,
90 pub key_alias: String,
92 pub store_password: SigningPasswordSource,
94 pub key_password: Option<SigningPasswordSource>,
96}
97
98pub fn default_debug_signing_paths(
104 config_dir: Option<&Utf8Path>,
105) -> Result<DebugSigningPaths, AndroidError> {
106 let root = if let Some(root) = config_dir {
107 root.to_owned()
108 } else {
109 let base = BaseDirs::new().ok_or_else(|| {
110 AndroidError::InvalidRequest(
111 "the operating-system configuration directory could not be resolved".to_owned(),
112 )
113 })?;
114 Utf8PathBuf::from_path_buf(base.config_dir().to_owned())
115 .map_err(AndroidError::NonUtf8Path)?
116 .join("cargo-ferry")
117 };
118 let android = root.join("android");
119 Ok(DebugSigningPaths {
120 keystore: android.join("debug.keystore"),
121 password_file: android.join("debug-keystore.pass"),
122 lock_file: android.join("debug-keystore.lock"),
123 })
124}
125
126pub fn resolve_signing_config(
133 signing: &AndroidSigningConfig,
134 toolchain: &AndroidToolchain,
135 log_dir: &Utf8Path,
136) -> Result<ResolvedSigningConfig, AndroidError> {
137 match signing {
138 AndroidSigningConfig::Debug { config_dir } => {
139 let paths = default_debug_signing_paths(config_dir.as_deref())?;
140 ensure_debug_keystore(&paths, toolchain, log_dir)?;
141 Ok(ResolvedSigningConfig {
142 keystore: paths.keystore,
143 key_alias: DEBUG_KEY_ALIAS.to_owned(),
144 store_password: SigningPasswordSource::File(paths.password_file),
145 key_password: None,
146 })
147 }
148 AndroidSigningConfig::Keystore {
149 keystore,
150 key_alias,
151 store_password,
152 key_password,
153 } => {
154 if !keystore.is_file() {
155 return Err(AndroidError::InvalidRequest(format!(
156 "signing keystore does not exist: {keystore}"
157 )));
158 }
159 if key_alias.trim().is_empty() {
160 return Err(AndroidError::InvalidRequest(
161 "signing key alias cannot be empty".to_owned(),
162 ));
163 }
164 validate_password_source(store_password)?;
165 if let Some(source) = key_password {
166 validate_password_source(source)?;
167 }
168 Ok(ResolvedSigningConfig {
169 keystore: keystore.clone(),
170 key_alias: key_alias.clone(),
171 store_password: store_password.clone(),
172 key_password: key_password.clone(),
173 })
174 }
175 }
176}
177
178pub fn preview_signing_config(
184 signing: &AndroidSigningConfig,
185) -> Result<ResolvedSigningConfig, AndroidError> {
186 match signing {
187 AndroidSigningConfig::Debug { config_dir } => {
188 let paths = default_debug_signing_paths(config_dir.as_deref())?;
189 Ok(ResolvedSigningConfig {
190 keystore: paths.keystore,
191 key_alias: DEBUG_KEY_ALIAS.to_owned(),
192 store_password: SigningPasswordSource::File(paths.password_file),
193 key_password: None,
194 })
195 }
196 AndroidSigningConfig::Keystore {
197 keystore,
198 key_alias,
199 store_password,
200 key_password,
201 } => {
202 if key_alias.trim().is_empty() {
203 return Err(AndroidError::InvalidRequest(
204 "signing key alias cannot be empty".to_owned(),
205 ));
206 }
207 store_password.apksigner_argument()?;
208 if let Some(source) = key_password {
209 source.apksigner_argument()?;
210 }
211 Ok(ResolvedSigningConfig {
212 keystore: keystore.clone(),
213 key_alias: key_alias.clone(),
214 store_password: store_password.clone(),
215 key_password: key_password.clone(),
216 })
217 }
218 }
219}
220
221pub fn apksigner_sign_command(
227 toolchain: &AndroidToolchain,
228 signing: &ResolvedSigningConfig,
229 input: &Utf8Path,
230 output: &Utf8Path,
231 current_dir: &Utf8Path,
232) -> Result<CommandSpec, AndroidError> {
233 let apksigner =
234 toolchain
235 .build_tools
236 .apksigner
237 .clone()
238 .ok_or_else(|| AndroidError::ToolMissing {
239 tool: "apksigner".to_owned(),
240 searched: vec![toolchain.build_tools.directory.clone()],
241 fix: "Install a complete Android SDK Build Tools revision.".to_owned(),
242 })?;
243 let mut command = CommandSpec::new(
244 "sign APK",
245 apksigner,
246 Utf8PathBuf::from(external_tool_path_arg(current_dir)?),
247 );
248 command.args = vec![
249 "sign".to_owned(),
250 "--ks".to_owned(),
251 external_tool_path_arg(&signing.keystore)?,
252 "--ks-key-alias".to_owned(),
253 signing.key_alias.clone(),
254 "--ks-pass".to_owned(),
255 signing.store_password.apksigner_argument()?,
256 ];
257 if let Some(source) = &signing.key_password {
258 command.args.push("--key-pass".to_owned());
259 command.args.push(source.apksigner_argument()?);
260 }
261 command.args.extend([
262 "--out".to_owned(),
263 external_tool_path_arg(output)?,
264 external_tool_path_arg(input)?,
265 ]);
266 Ok(command)
267}
268
269pub fn apksigner_verify_command(
275 toolchain: &AndroidToolchain,
276 apk: &Utf8Path,
277 current_dir: &Utf8Path,
278) -> Result<CommandSpec, AndroidError> {
279 let apksigner =
280 toolchain
281 .build_tools
282 .apksigner
283 .clone()
284 .ok_or_else(|| AndroidError::ToolMissing {
285 tool: "apksigner".to_owned(),
286 searched: vec![toolchain.build_tools.directory.clone()],
287 fix: "Install a complete Android SDK Build Tools revision.".to_owned(),
288 })?;
289 let mut command = CommandSpec::new(
290 "verify APK signature",
291 apksigner,
292 Utf8PathBuf::from(external_tool_path_arg(current_dir)?),
293 );
294 command.args = vec![
295 "verify".to_owned(),
296 "--verbose".to_owned(),
297 "--print-certs".to_owned(),
298 external_tool_path_arg(apk)?,
299 ];
300 Ok(command)
301}
302
303fn ensure_debug_keystore(
304 paths: &DebugSigningPaths,
305 toolchain: &AndroidToolchain,
306 log_dir: &Utf8Path,
307) -> Result<(), AndroidError> {
308 let parent = paths.keystore.parent().ok_or_else(|| {
309 AndroidError::InvalidRequest("debug keystore has no parent directory".to_owned())
310 })?;
311 fs::create_dir_all(parent)
312 .map_err(|source| io_error("create signing configuration directory", parent, source))?;
313 #[cfg(unix)]
314 secure_directory(parent)?;
315 let lock = OpenOptions::new()
316 .create(true)
317 .truncate(false)
318 .read(true)
319 .write(true)
320 .open(&paths.lock_file)
321 .map_err(|source| io_error("open debug keystore lock", &paths.lock_file, source))?;
322 lock.lock_exclusive()
323 .map_err(|source| io_error("lock debug keystore", &paths.lock_file, source))?;
324
325 if paths.keystore.exists() && !paths.password_file.is_file() {
326 return Err(AndroidError::InvalidRequest(format!(
327 "debug keystore exists but its password file is missing: {}. Move the keystore aside or restore the password file; cargo-ferry will not overwrite the key.",
328 paths.keystore
329 )));
330 }
331 if !paths.password_file.exists() {
332 create_password_file(&paths.password_file)?;
333 }
334 #[cfg(unix)]
335 secure_file(&paths.password_file)?;
336
337 if !paths.keystore.exists() {
338 let mut command = CommandSpec::new(
339 "create persistent debug keystore",
340 toolchain.keytool.clone(),
341 Utf8PathBuf::from(external_tool_path_arg(parent)?),
342 );
343 command.args = vec![
344 "-genkeypair".to_owned(),
345 "-keystore".to_owned(),
346 external_tool_path_arg(&paths.keystore)?,
347 "-storetype".to_owned(),
348 "PKCS12".to_owned(),
349 "-storepass:file".to_owned(),
350 external_tool_path_arg(&paths.password_file)?,
351 "-alias".to_owned(),
352 DEBUG_KEY_ALIAS.to_owned(),
353 "-keyalg".to_owned(),
354 "RSA".to_owned(),
355 "-keysize".to_owned(),
356 "2048".to_owned(),
357 "-validity".to_owned(),
358 "10000".to_owned(),
359 "-dname".to_owned(),
360 "CN=RustFerry Debug,O=RustFerry,C=US".to_owned(),
361 "-noprompt".to_owned(),
362 ];
363 run_command(&command, &log_dir.join("keytool-create.log"))?;
364 #[cfg(unix)]
365 secure_file(&paths.keystore)?;
366 }
367
368 let mut verify = CommandSpec::new(
369 "validate persistent debug keystore",
370 toolchain.keytool.clone(),
371 Utf8PathBuf::from(external_tool_path_arg(parent)?),
372 );
373 verify.args = vec![
374 "-J-Duser.language=en".to_owned(),
375 "-J-Duser.country=US".to_owned(),
376 "-list".to_owned(),
377 "-keystore".to_owned(),
378 external_tool_path_arg(&paths.keystore)?,
379 "-storetype".to_owned(),
380 "PKCS12".to_owned(),
381 "-storepass:file".to_owned(),
382 external_tool_path_arg(&paths.password_file)?,
383 "-alias".to_owned(),
384 DEBUG_KEY_ALIAS.to_owned(),
385 "-v".to_owned(),
386 ];
387 let output = run_command(&verify, &log_dir.join("keytool-verify.log"))?;
388 let stdout = std::str::from_utf8(&output.stdout).map_err(|error| {
389 AndroidError::InvalidRequest(format!(
390 "keytool returned non-UTF-8 certificate metadata; inspect keytool-verify.log: {error}"
391 ))
392 })?;
393 validate_debug_certificate_expiry(stdout, utc_today()?)?;
394 Ok(())
395}
396
397#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
398struct CalendarDate {
399 year: i64,
400 month: u8,
401 day: u8,
402}
403
404fn validate_debug_certificate_expiry(
405 keytool_output: &str,
406 today: CalendarDate,
407) -> Result<(), AndroidError> {
408 let expiration = parse_keytool_expiration(keytool_output).ok_or_else(|| {
409 AndroidError::InvalidRequest(
410 "keytool did not report a parseable debug certificate expiry; inspect keytool-verify.log and recreate the debug key pair deliberately"
411 .to_owned(),
412 )
413 })?;
414 if expiration <= today {
415 return Err(AndroidError::InvalidRequest(format!(
416 "persistent debug certificate expired on {:04}-{:02}-{:02}; move the debug keystore and password file aside together, then rebuild to create a fresh identity",
417 expiration.year, expiration.month, expiration.day
418 )));
419 }
420 Ok(())
421}
422
423fn parse_keytool_expiration(output: &str) -> Option<CalendarDate> {
424 let expiry = output
425 .lines()
426 .find_map(|line| line.split_once("until:").map(|(_, expiry)| expiry.trim()))?;
427 let fields = expiry.split_whitespace().collect::<Vec<_>>();
428 let month = match *fields.get(1)? {
429 "Jan" => 1,
430 "Feb" => 2,
431 "Mar" => 3,
432 "Apr" => 4,
433 "May" => 5,
434 "Jun" => 6,
435 "Jul" => 7,
436 "Aug" => 8,
437 "Sep" => 9,
438 "Oct" => 10,
439 "Nov" => 11,
440 "Dec" => 12,
441 _ => return None,
442 };
443 let day = fields.get(2)?.parse::<u8>().ok()?;
444 let year = fields.last()?.parse::<i64>().ok()?;
445 (day > 0 && day <= days_in_month(year, month)).then_some(CalendarDate { year, month, day })
446}
447
448fn utc_today() -> Result<CalendarDate, AndroidError> {
449 let elapsed = SystemTime::now().duration_since(UNIX_EPOCH).map_err(|_| {
450 AndroidError::InvalidRequest(
451 "system clock predates the Unix epoch; debug certificate expiry cannot be checked"
452 .to_owned(),
453 )
454 })?;
455 let days = i64::try_from(elapsed.as_secs() / 86_400).map_err(|_| {
456 AndroidError::InvalidRequest(
457 "system clock is outside the supported certificate-validation range".to_owned(),
458 )
459 })?;
460 Ok(civil_date_from_unix_days(days))
461}
462
463fn civil_date_from_unix_days(days: i64) -> CalendarDate {
464 let shifted = days + 719_468;
465 let era = if shifted >= 0 {
466 shifted
467 } else {
468 shifted - 146_096
469 } / 146_097;
470 let day_of_era = shifted - era * 146_097;
471 let year_of_era =
472 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
473 let mut year = year_of_era + era * 400;
474 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
475 let month_prime = (5 * day_of_year + 2) / 153;
476 let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
477 let month = month_prime + if month_prime < 10 { 3 } else { -9 };
478 if month <= 2 {
479 year += 1;
480 }
481 CalendarDate {
482 year,
483 month: u8::try_from(month).expect("civil month is bounded"),
484 day: u8::try_from(day).expect("civil day is bounded"),
485 }
486}
487
488fn days_in_month(year: i64, month: u8) -> u8 {
489 match month {
490 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
491 4 | 6 | 9 | 11 => 30,
492 2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
493 2 => 28,
494 _ => 0,
495 }
496}
497
498fn validate_password_source(source: &SigningPasswordSource) -> Result<(), AndroidError> {
499 match source {
500 SigningPasswordSource::File(path) if !path.is_file() => Err(AndroidError::InvalidRequest(
501 format!("signing password file does not exist: {path}"),
502 )),
503 _ => source.apksigner_argument().map(|_| ()),
504 }
505}
506
507fn create_password_file(path: &Utf8Path) -> Result<(), AndroidError> {
508 let mut file = OpenOptions::new()
509 .write(true)
510 .create_new(true)
511 .open(path)
512 .map_err(|source| io_error("create debug keystore password file", path, source))?;
513 #[cfg(unix)]
514 secure_file(path)?;
515 writeln!(
516 file,
517 "{}{}",
518 Uuid::new_v4().simple(),
519 Uuid::new_v4().simple()
520 )
521 .map_err(|source| io_error("write debug keystore password file", path, source))
522}
523
524#[cfg(unix)]
525fn secure_directory(path: &Utf8Path) -> Result<(), AndroidError> {
526 use std::os::unix::fs::PermissionsExt;
527 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
528 .map_err(|source| io_error("set signing directory permissions", path, source))
529}
530
531#[cfg(unix)]
532fn secure_file(path: &Utf8Path) -> Result<(), AndroidError> {
533 use std::os::unix::fs::PermissionsExt;
534 fs::set_permissions(path, fs::Permissions::from_mode(0o600))
535 .map_err(|source| io_error("set signing file permissions", path, source))
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 #[cfg(windows)]
542 use crate::{AndroidBuildTools, AndroidNdk, AndroidPlatform};
543
544 #[cfg(windows)]
545 fn signing_toolchain() -> AndroidToolchain {
546 AndroidToolchain {
547 sdk_root: r"C:\Android\Sdk".into(),
548 platform: AndroidPlatform {
549 sdk_root: r"C:\Android\Sdk".into(),
550 api_level: 36,
551 directory: r"C:\Android\Sdk\platforms\android-36".into(),
552 android_jar: r"C:\Android\Sdk\platforms\android-36\android.jar".into(),
553 },
554 build_tools: AndroidBuildTools {
555 sdk_root: r"C:\Android\Sdk".into(),
556 version: "36.0.0".to_owned(),
557 directory: r"C:\Android\Sdk\build-tools\36.0.0".into(),
558 aapt2: None,
559 d8: None,
560 zipalign: None,
561 apksigner: Some(r"C:\Android\Sdk\build-tools\36.0.0\apksigner.bat".into()),
562 },
563 ndk: AndroidNdk {
564 root: r"C:\Android\Sdk\ndk\29.0.0".into(),
565 version: "29.0.0".to_owned(),
566 llvm_prebuilt: None,
567 },
568 cargo: r"C:\Rust\cargo.exe".into(),
569 rustc: None,
570 rustup: None,
571 java: None,
572 javac: None,
573 keytool: r"C:\Java\bin\keytool.exe".into(),
574 }
575 }
576
577 #[test]
578 fn password_values_are_never_command_arguments() {
579 #[cfg(windows)]
580 let path = r"C:\secure\signing.pass";
581 #[cfg(not(windows))]
582 let path = "/secure/signing.pass";
583 let source = SigningPasswordSource::File(path.into());
584 assert_eq!(source.apksigner_argument().unwrap(), format!("file:{path}"));
585 assert!(!source.apksigner_argument().unwrap().contains("secret"));
586 }
587
588 #[test]
589 fn rejects_unsafe_environment_names() {
590 let source = SigningPasswordSource::Environment("PASSWORD;echo".to_owned());
591 assert!(source.apksigner_argument().is_err());
592 }
593
594 #[cfg(windows)]
595 #[test]
596 fn apksigner_commands_normalize_windows_verbatim_paths() {
597 let signing = ResolvedSigningConfig {
598 keystore: r"\\?\C:\secure\debug.keystore".into(),
599 key_alias: "debug".to_owned(),
600 store_password: SigningPasswordSource::File(
601 r"\\?\C:\secure\debug-keystore.pass".into(),
602 ),
603 key_password: None,
604 };
605 let command = apksigner_sign_command(
606 &signing_toolchain(),
607 &signing,
608 Utf8Path::new(r"\\?\C:\work\aligned.apk"),
609 Utf8Path::new(r"\\?\C:\work\calculator.apk"),
610 Utf8Path::new(r"\\?\C:\work"),
611 )
612 .unwrap();
613 assert_eq!(command.current_dir, Utf8Path::new(r"C:\work"));
614 assert!(command.args.iter().all(|value| !value.contains(r"\\?\")));
615 assert!(command.args.contains(&r"C:\work\calculator.apk".to_owned()));
616
617 let verify = apksigner_verify_command(
618 &signing_toolchain(),
619 Utf8Path::new(r"\\?\C:\work\calculator.apk"),
620 Utf8Path::new(r"\\?\C:\work"),
621 )
622 .unwrap();
623 assert!(verify.args.iter().all(|value| !value.contains(r"\\?\")));
624 }
625
626 #[test]
627 fn parses_and_enforces_debug_certificate_expiry() {
628 let output =
629 "Valid from: Sat Aug 01 04:45:48 MSK 2026 until: Wed Dec 17 04:45:48 MSK 2053\n";
630 let expiration = CalendarDate {
631 year: 2053,
632 month: 12,
633 day: 17,
634 };
635 assert_eq!(parse_keytool_expiration(output), Some(expiration));
636 assert!(
637 validate_debug_certificate_expiry(
638 output,
639 CalendarDate {
640 year: 2053,
641 month: 12,
642 day: 16,
643 }
644 )
645 .is_ok()
646 );
647 assert!(validate_debug_certificate_expiry(output, expiration).is_err());
648 }
649
650 #[test]
651 fn unix_day_conversion_handles_epoch_and_leap_days() {
652 assert_eq!(
653 civil_date_from_unix_days(0),
654 CalendarDate {
655 year: 1970,
656 month: 1,
657 day: 1,
658 }
659 );
660 assert_eq!(
661 civil_date_from_unix_days(19_782),
662 CalendarDate {
663 year: 2024,
664 month: 2,
665 day: 29,
666 }
667 );
668 }
669}