@@ -789,32 +789,9 @@ fn parse_dotenv(text: &str, var: &impl Fn(&str) -> Option<String>) -> Vec<(Strin
789789 }
790790 let raw = raw. trim_start ( ) ;
791791 let ( value, interpolate) = if let Some ( rest) = raw. strip_prefix ( '\'' ) {
792- (
793- rest. split ( '\'' ) . next ( ) . unwrap_or_default ( ) . to_string ( ) ,
794- false ,
795- )
792+ ( dotenv_unquote ( rest, '\'' ) , false )
796793 } else if let Some ( rest) = raw. strip_prefix ( '"' ) {
797- let mut value = String :: new ( ) ;
798- let mut chars = rest. chars ( ) ;
799- while let Some ( c) = chars. next ( ) {
800- match c {
801- '"' => break ,
802- '\\' => match chars. next ( ) {
803- Some ( 'n' ) => value. push ( '\n' ) ,
804- Some ( 't' ) => value. push ( '\t' ) ,
805- Some ( 'r' ) => value. push ( '\r' ) ,
806- Some ( other) => {
807- if !matches ! ( other, '"' | '\\' | '\'' ) {
808- value. push ( '\\' ) ;
809- }
810- value. push ( other) ;
811- }
812- None => value. push ( '\\' ) ,
813- } ,
814- c => value. push ( c) ,
815- }
816- }
817- ( value, true )
794+ ( dotenv_unquote ( rest, '"' ) , true )
818795 } else {
819796 let unquoted = match raw. find ( " #" ) . or_else ( || raw. find ( "\t #" ) ) {
820797 Some ( at) => & raw [ ..at] ,
@@ -840,6 +817,45 @@ fn parse_dotenv(text: &str, var: &impl Fn(&str) -> Option<String>) -> Vec<(Strin
840817 pairs
841818}
842819
820+ /// The body of a quoted python-dotenv value up to the closing `quote`,
821+ /// decoding only the escapes python-dotenv decodes: `\\` and `\'` in single
822+ /// quotes; those plus `\"` and `\a \b \f \n \r \t \v` in double quotes. Any
823+ /// other backslash stays literal, so Windows paths survive.
824+ fn dotenv_unquote ( rest : & str , quote : char ) -> String {
825+ let mut value = String :: new ( ) ;
826+ let mut chars = rest. chars ( ) . peekable ( ) ;
827+ while let Some ( c) = chars. next ( ) {
828+ if c == quote {
829+ break ;
830+ }
831+ if c != '\\' {
832+ value. push ( c) ;
833+ continue ;
834+ }
835+ let decoded = match ( quote, chars. peek ( ) ) {
836+ ( _, Some ( '\\' ) ) => Some ( '\\' ) ,
837+ ( _, Some ( '\'' ) ) => Some ( '\'' ) ,
838+ ( '"' , Some ( '"' ) ) => Some ( '"' ) ,
839+ ( '"' , Some ( 'a' ) ) => Some ( '\u{7}' ) ,
840+ ( '"' , Some ( 'b' ) ) => Some ( '\u{8}' ) ,
841+ ( '"' , Some ( 'f' ) ) => Some ( '\u{c}' ) ,
842+ ( '"' , Some ( 'n' ) ) => Some ( '\n' ) ,
843+ ( '"' , Some ( 'r' ) ) => Some ( '\r' ) ,
844+ ( '"' , Some ( 't' ) ) => Some ( '\t' ) ,
845+ ( '"' , Some ( 'v' ) ) => Some ( '\u{b}' ) ,
846+ _ => None ,
847+ } ;
848+ match decoded {
849+ Some ( decoded) => {
850+ chars. next ( ) ;
851+ value. push ( decoded) ;
852+ }
853+ None => value. push ( '\\' ) ,
854+ }
855+ }
856+ value
857+ }
858+
843859/// python-dotenv's `${NAME}` / `${NAME:-default}` expansion (a bare
844860/// `$NAME` stays literal); an unset name takes the default, else "".
845861fn dotenv_interpolate ( value : & str , lookup : & impl Fn ( & str ) -> Option < String > ) -> String {
@@ -3512,7 +3528,11 @@ mod tests {
35123528 find_local_venv_site_packages_with( & project, & var) . await ,
35133529 vec![ custom. clone( ) , site. clone( ) ]
35143530 ) ;
3515- std:: fs:: remove_dir_all ( site. ancestors ( ) . nth ( 3 ) . unwrap ( ) ) . unwrap ( ) ;
3531+ // Remove only the default venv (`wh/proj-<hash>`); the site-packages
3532+ // depth under it differs between POSIX and Windows.
3533+ let wh = tmp. path ( ) . join ( "wh" ) ;
3534+ let default_root = site. ancestors ( ) . find ( |a| a. parent ( ) == Some ( wh. as_path ( ) ) ) ;
3535+ std:: fs:: remove_dir_all ( default_root. unwrap ( ) ) . unwrap ( ) ;
35163536 assert_eq ! (
35173537 find_local_venv_site_packages_with( & project, & var) . await ,
35183538 vec![ custom. clone( ) ] ,
@@ -3539,7 +3559,9 @@ mod tests {
35393559 std:: fs:: write ( project. join ( "Pipfile" ) , "[packages]\n six = \" ==1.16.0\" \n " ) . unwrap ( ) ;
35403560 let real = std:: fs:: canonicalize ( & project) . unwrap ( ) ;
35413561 let hash = pipenv_venv_hash ( & pipenv_path_string ( & real. join ( "Pipfile" ) ) ) ;
3542- let base = tmp. path ( ) . to_string_lossy ( ) . into_owned ( ) ;
3562+ // Forward slashes: python-dotenv decodes `\r`, `\t`... inside double
3563+ // quotes, so a quoted Windows path must not carry backslashes.
3564+ let base = tmp. path ( ) . to_string_lossy ( ) . replace ( '\\' , "/" ) ;
35433565 let elsewhere = fake_venv ( & tmp. path ( ) . join ( "elsewhere" ) , & format ! ( "proj-{hash}" ) ) ;
35443566 let home = env_of ( & [ (
35453567 "HOME" ,
@@ -3615,6 +3637,9 @@ C=\"quoted # not a comment\"
36153637D='single ${OUTER}'
36163638E=\" line\\ nbreak\"
36173639F=${A}-${OUTER}-${MISSING:-dflt}
3640+ H=\" C:\\ Users\\ dev\\ Temp\"
3641+ I='it\\ 's'
3642+ J='C:\\ Temp\\ x'
36183643NOVALUE
36193644G=
36203645=skipped
@@ -3629,7 +3654,11 @@ G=
36293654 assert_eq ! ( get( "F" ) , Some ( "1-out-dflt" ) ) ;
36303655 assert_eq ! ( get( "NOVALUE" ) , None ) ;
36313656 assert_eq ! ( get( "G" ) , Some ( "" ) ) ;
3632- assert_eq ! ( parsed. len( ) , 7 ) ;
3657+ // Unknown escapes stay literal (Windows paths); `\'` and `\\` decode.
3658+ assert_eq ! ( get( "H" ) , Some ( r"C:\Users\dev\Temp" ) ) ;
3659+ assert_eq ! ( get( "I" ) , Some ( "it's" ) ) ;
3660+ assert_eq ! ( get( "J" ) , Some ( r"C:\Temp\x" ) ) ;
3661+ assert_eq ! ( parsed. len( ) , 10 ) ;
36333662 }
36343663
36353664 #[ tokio:: test]
0 commit comments