11use std:: process:: { Command , Stdio } ;
22use std:: io:: Read ;
3- use std:: fs;
43use std:: path:: { Path , PathBuf } ;
54use std:: time:: { Duration , Instant } ;
65use std:: sync:: OnceLock ;
@@ -263,47 +262,62 @@ fn execute_with_sudo(args: &[&str], passphrase: Option<&str>, silent: bool) -> R
263262 }
264263 }
265264
266- // Fall back to askpass dialog
267- let askpass_script = create_askpass_script ( ) ?;
265+ // Fall back to native macOS authorization dialog (supports Touch ID)
266+ execute_with_osascript_admin ( cli_path, args, passphrase)
267+ }
268+
269+ /// Escape a string for use inside a single-quoted shell argument.
270+ /// Replaces `'` with `'\''` (end quote, escaped quote, start quote).
271+ fn shell_escape ( s : & str ) -> String {
272+ s. replace ( '\'' , "'\\ ''" )
273+ }
274+
275+ /// Escape a string for use inside an AppleScript double-quoted string.
276+ /// Escapes backslashes and double quotes.
277+ fn applescript_escape ( s : & str ) -> String {
278+ s. replace ( '\\' , "\\ \\ " ) . replace ( '"' , "\\ \" " )
279+ }
268280
269- // Preserve ALFS_PASSPHRASE through sudo — env_reset strips it otherwise
281+ /// Execute a command with administrator privileges via the native macOS
282+ /// authorization dialog (`do shell script ... with administrator privileges`).
283+ /// This shows the standard macOS auth prompt which supports Touch ID/Face ID.
284+ fn execute_with_osascript_admin ( cli_path : & Path , args : & [ & str ] , passphrase : Option < & str > ) -> Result < String , String > {
270285 let cli_path_str = cli_path. to_string_lossy ( ) ;
271- let mut sudo_args: Vec < & str > = if passphrase. is_some ( ) {
272- vec ! [ "-A" , "--preserve-env=ALFS_PASSPHRASE" , "--" , & cli_path_str]
273- } else {
274- vec ! [ "-A" , "--" , & cli_path_str]
275- } ;
276- sudo_args. extend ( args. iter ( ) . copied ( ) ) ;
277286
278- let mut cmd = Command :: new ( "sudo" ) ;
279- cmd. args ( & sudo_args) ;
280- cmd. env ( "SUDO_ASKPASS" , & askpass_script) ;
281- // Use piped stdin instead of null - libkrun's epoll fails with /dev/null
287+ // Build the inner shell command: ALFS_PASSPHRASE='...' /path/to/anylinuxfs arg1 arg2
288+ let mut shell_cmd = String :: new ( ) ;
289+ if let Some ( pass) = passphrase {
290+ shell_cmd. push_str ( & format ! ( "ALFS_PASSPHRASE='{}' " , shell_escape( pass) ) ) ;
291+ }
292+ shell_cmd. push_str ( & format ! ( "'{}'" , shell_escape( & cli_path_str) ) ) ;
293+ for arg in args {
294+ shell_cmd. push_str ( & format ! ( " '{}'" , shell_escape( arg) ) ) ;
295+ }
296+
297+ // Wrap in AppleScript: do shell script "<cmd>" with administrator privileges
298+ let applescript = format ! (
299+ "do shell script \" {}\" with administrator privileges" ,
300+ applescript_escape( & shell_cmd)
301+ ) ;
302+
303+ let mut cmd = Command :: new ( "osascript" ) ;
304+ cmd. arg ( "-e" ) . arg ( & applescript) ;
282305 cmd. stdin ( Stdio :: piped ( ) ) ;
283306 cmd. stdout ( Stdio :: piped ( ) ) ;
284307 cmd. stderr ( Stdio :: piped ( ) ) ;
285308
286- if let Some ( pass) = passphrase {
287- cmd. env ( "ALFS_PASSPHRASE" , pass) ;
288- }
289-
290- // Spawn the process so we can handle it with timeout
291309 let mut child = cmd. spawn ( )
292- . map_err ( |e| format ! ( "Failed to execute sudo : {}" , e) ) ?;
310+ . map_err ( |e| format ! ( "Failed to execute osascript : {}" , e) ) ?;
293311
294- // Wait for process with timeout (30 seconds for mount operations)
295- let timeout = Duration :: from_secs ( 30 ) ;
312+ // 60-second timeout — user needs time to authenticate via Touch ID / password
313+ let timeout = Duration :: from_secs ( 60 ) ;
296314 let start = Instant :: now ( ) ;
297315
298316 loop {
299317 match child. try_wait ( ) {
300318 Ok ( Some ( status) ) => {
301- // Process finished
302- let _ = fs:: remove_file ( & askpass_script) ;
303-
304319 let mut stdout = String :: new ( ) ;
305320 let mut stderr = String :: new ( ) ;
306-
307321 if let Some ( ref mut out) = child. stdout {
308322 let _ = out. read_to_string ( & mut stdout) ;
309323 }
@@ -313,64 +327,29 @@ fn execute_with_sudo(args: &[&str], passphrase: Option<&str>, silent: bool) -> R
313327
314328 if status. success ( ) {
315329 return Ok ( stdout) ;
316- } else {
317- // Check for wrong password or cancelled
318- if stderr. contains ( "Sorry, try again" ) || stderr. contains ( "incorrect password" ) {
319- return Err ( "Incorrect password" . to_string ( ) ) ;
320- } else if stderr. contains ( "no askpass program" ) || stderr. contains ( "no password was provided" ) {
321- return Err ( "Authentication cancelled" . to_string ( ) ) ;
322- } else {
323- return Err ( sanitize_error ( & stdout, & stderr) ) ;
324- }
325330 }
331+
332+ // User pressed Cancel in the auth dialog
333+ if stderr. contains ( "User canceled" )
334+ || stderr. contains ( "user canceled" )
335+ || stderr. contains ( "-128" )
336+ {
337+ return Err ( "Authentication cancelled" . to_string ( ) ) ;
338+ }
339+
340+ return Err ( sanitize_error ( & stdout, & stderr) ) ;
326341 }
327342 Ok ( None ) => {
328- // Process still running
329343 if start. elapsed ( ) > timeout {
330- // Timeout - kill the process
331344 let _ = child. kill ( ) ;
332345 let _ = child. wait ( ) ;
333- let _ = fs:: remove_file ( & askpass_script) ;
334346 return Err ( "Command timed out" . to_string ( ) ) ;
335347 }
336348 std:: thread:: sleep ( Duration :: from_millis ( 100 ) ) ;
337349 }
338350 Err ( e) => {
339- let _ = fs:: remove_file ( & askpass_script) ;
340351 return Err ( format ! ( "Error waiting for process: {}" , e) ) ;
341352 }
342353 }
343354 }
344355}
345-
346- fn create_askpass_script ( ) -> Result < String , String > {
347- use std:: io:: Write ;
348- use std:: os:: unix:: fs:: PermissionsExt ;
349-
350- // Create askpass script that uses osascript to prompt for password
351- // The password goes directly from osascript to sudo, never through our app
352- let script_content = r#"#!/bin/bash
353- osascript -e 'Tell application "System Events" to display dialog "anylinuxfs requires administrator privileges." & return & return & "Enter your password:" with hidden answer default answer "" buttons {"Cancel", "OK"} default button "OK" with title "Authentication Required" with icon caution' -e 'text returned of result' 2>/dev/null
354- "# ;
355-
356- // Use tempfile for a cryptographically random filename, preventing symlink attacks
357- let mut file = tempfile:: Builder :: new ( )
358- . prefix ( "anylinuxfs-askpass-" )
359- . suffix ( ".sh" )
360- . tempfile ( )
361- . map_err ( |e| format ! ( "Failed to create askpass script: {}" , e) ) ?;
362-
363- // Set restrictive permissions (owner-only executable)
364- file. as_file ( ) . set_permissions ( fs:: Permissions :: from_mode ( 0o700 ) )
365- . map_err ( |e| format ! ( "Failed to set askpass script permissions: {}" , e) ) ?;
366-
367- file. write_all ( script_content. as_bytes ( ) )
368- . map_err ( |e| format ! ( "Failed to write askpass script: {}" , e) ) ?;
369-
370- // Persist the file (disables auto-delete) so sudo can read it;
371- // the caller is responsible for removing it after use
372- let ( _, path) = file. keep ( )
373- . map_err ( |e| format ! ( "Failed to persist askpass script: {}" , e) ) ?;
374-
375- Ok ( path. to_string_lossy ( ) . to_string ( ) )
376- }
0 commit comments