├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md └── src └── main.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /Cargo.lock 2 | /target 3 | **/*.rs.bk 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "envrc" 3 | version = "0.5.0" 4 | authors = ["roxma "] 5 | license = "MIT" 6 | repository = "https://github.com/roxma/envrc-rs" 7 | description = "Auto source bash .envrc of your workspace" 8 | 9 | 10 | [[bin]] 11 | name = "envrc" 12 | path = "src/main.rs" 13 | 14 | [dependencies] 15 | clap = "2" 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2018 Rox Ma 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included 11 | in all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 15 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 18 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 19 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # envrc - Auto source bash .envrc of your workspace 2 | 3 | ## Wny? 4 | 5 | Firstly, [direnv](https://github.com/direnv/direnv) doesn't officially 6 | [support alias](https://github.com/direnv/direnv/issues/73) at the moment. 7 | 8 | Secondly, 9 | 10 | > direnv is actually creating a new bash process to load the stdlib, direnvrc 11 | > and .envrc, and only exports the environment diff back to the original 12 | > shell. 13 | 14 | However, envrc is simpler. It spawns a new interactive bash and load `.envrc`. 15 | When you `cd` out of the directory, the shell exits and returns terminal back 16 | to the original shell. 17 | 18 | ## Install 19 | 20 | - `cargo install envrc` 21 | - Add `PROMPT_COMMAND='eval "$(envrc bash)"'` to the end of your bashrc 22 | 23 | ## Usage 24 | 25 | ``` 26 | $ mkdir foo 27 | $ 28 | $ echo 'echo in foo directory' > foo/.envrc 29 | $ 30 | $ cd foo 31 | envrc: spawning new /bin/bash 32 | envrc: loading [/home/roxma/test/envrc/foo/.envrc] 33 | in foo directory 34 | $ 35 | $ cd .. 36 | envrc: exit [/home/roxma/test/envrc/foo/.envrc] 37 | ``` 38 | 39 | ``` 40 | $ envrc 41 | envrc 0.2 42 | Rox Ma roxma@qq.com 43 | auto source .envrc of your workspace 44 | 45 | USAGE: 46 | envrc [SUBCOMMAND] 47 | 48 | FLAGS: 49 | -h, --help Prints help information 50 | -V, --version Prints version information 51 | 52 | SUBCOMMANDS: 53 | allow Grant permission to envrc to load the .envrc 54 | bash for bashrc: PROMPT_COMMAND='eval "$(envrc bash)"' 55 | deny Remove the permission 56 | help Prints this message or the help of the given subcommand(s) 57 | prune Remove expired or non-existing-file permissions 58 | ``` 59 | 60 | Note: Take care of your background jobs before getting out of `.envrc`. 61 | 62 | ## .envrc tips 63 | 64 | - `export WORKSPACE_DIR=$(readlink -f "$(dirname "${BASH_SOURCE[0]}")")` for 65 | `.envrc` to locate its directory. 66 | - `exec bash` to reload the modifed `.envrc` 67 | 68 | ## .bashrc config 69 | 70 | ```bash 71 | # If the `.envrc` is allowed, but not sourced for 1d since last unload, It 72 | # will be considered expired 73 | export ENVRC_ALLOW_DURATION=$((60*60*24)) 74 | PROMPT_COMMAND='eval "$(envrc bash)"' 75 | ``` 76 | 77 | ## Why not bash/python? 78 | 79 | The first working commit is written in python. But there's noticeable time lag 80 | with the python version on my PC. Rewriting it with perl doesn't help either. 81 | Then I decided to switch to rust. 82 | 83 | ``` 84 | $ time envrc.py bash-prompt-command >/dev/null 85 | real 0m0.079s 86 | user 0m0.044s 87 | sys 0m0.004s 88 | ``` 89 | 90 | I have also tried a pure bash implementation. It works better than the python 91 | implementation, since most of the python overhead is its startup time. Most 92 | of the bash overhead is fork/exec of sub-processes and it's way slower than 93 | the rust implementation. Read [#1](https://github.com/roxma/envrc-rs/issues/1) 94 | for more information. 95 | 96 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | extern crate clap; 2 | 3 | use clap::{App, AppSettings, SubCommand, Arg}; 4 | use std::env::{current_dir, current_exe, var}; 5 | use std::io::{Write, BufReader, BufRead}; 6 | use std::path::{PathBuf}; 7 | use std::fs::{create_dir_all, OpenOptions, canonicalize}; 8 | use std::time::{SystemTime, UNIX_EPOCH}; 9 | 10 | fn main() { 11 | let bash = SubCommand::with_name("bash") 12 | .about("for bashrc: PROMPT_COMMAND='eval \"$(envrc bash)\"'"); 13 | 14 | let allow = SubCommand::with_name("allow") 15 | .about("Grant permission to envrc to load the .envrc"); 16 | 17 | let deny = SubCommand::with_name("deny") 18 | .arg(Arg::with_name("envrc-file") 19 | .required(false) 20 | .help(".envrc files to be denied")) 21 | .about("Remove the permission"); 22 | 23 | let prune = SubCommand::with_name("prune") 24 | .about("Remove expired or non-existing-file permissions"); 25 | 26 | let matches = App::new("envrc") 27 | .version("0.2") 28 | .author("Rox Ma roxma@qq.com") 29 | .setting(AppSettings::ArgRequiredElseHelp) 30 | .about("auto source .envrc of your workspace") 31 | .subcommand(bash) 32 | .subcommand(allow) 33 | .subcommand(deny) 34 | .subcommand(prune) 35 | .get_matches(); 36 | 37 | if let Some(_) = matches.subcommand_matches("bash") { 38 | do_bash(); 39 | } 40 | else if let Some(_) = matches.subcommand_matches("allow") { 41 | let cur_dir = current_dir().unwrap(); 42 | let rc_found = find_envrc(cur_dir).unwrap(); 43 | add_allow(&rc_found); 44 | } 45 | else if let Some(matches) = matches.subcommand_matches("deny") { 46 | if let Some(file) = matches.value_of("envrc-file") { 47 | let mut path = canonicalize(file).unwrap(); 48 | if path.is_dir() { 49 | let dir = PathBuf::from(path.clone()); 50 | path = PathBuf::from(find_envrc(dir).unwrap()); 51 | } 52 | let path = String::from(path.to_str().unwrap()); 53 | remove_allow(&path); 54 | println!("{} is denied", path); 55 | } else { 56 | let cur_dir = current_dir().unwrap(); 57 | let rc_found = find_envrc(cur_dir).unwrap(); 58 | remove_allow(&rc_found); 59 | println!("{} is denied", rc_found); 60 | } 61 | } 62 | else if let Some(_) = matches.subcommand_matches("prune") { 63 | prune_allow(); 64 | } 65 | } 66 | 67 | fn do_bash() { 68 | let exe = current_exe() 69 | .unwrap() 70 | .into_os_string() 71 | .into_string() 72 | .unwrap(); 73 | 74 | let begin = format!(r#" 75 | {{ 76 | while : 77 | do 78 | if [ -n "$ENVRC_PPID" -a "$ENVRC_PPID" != "$PPID" ] 79 | then 80 | unset ENVRC_LOAD 81 | unset ENVRC_PPID 82 | unset ENVRC_TMP 83 | unset envrc_loaded 84 | unset envrc_not_allowed 85 | eval "$({exe} bash)" 86 | break 87 | fi 88 | "#, exe=exe); 89 | println!("{}", begin); 90 | 91 | do_bash_wrapped(); 92 | 93 | let end = format!(r#" 94 | break 95 | done 96 | }}"#); 97 | println!("{}", end); 98 | } 99 | 100 | fn do_bash_wrapped() { 101 | let rc_cur = current_envrc(); 102 | let cur_dir = current_dir().unwrap(); 103 | let rc_found = find_envrc(cur_dir); 104 | 105 | let rc_found = rc_found.as_ref(); 106 | let rc_cur = rc_cur.as_ref(); 107 | 108 | let exe = current_exe().unwrap().into_os_string().into_string().unwrap(); 109 | 110 | if rc_cur.is_some() { 111 | let rc_cur = rc_cur.unwrap(); 112 | 113 | update_if_allowed(rc_cur); 114 | 115 | if is_out_of_scope(rc_cur) { 116 | return bash_to_parent() 117 | } 118 | } 119 | 120 | let allow_err = check_allow(rc_found); 121 | 122 | if rc_found == rc_cur { 123 | if allow_err.is_some() { 124 | return bash_to_parent_eval(format!(r#" 125 | envrc_not_allowed={} 126 | "#, rc_cur.unwrap())) 127 | } 128 | 129 | let p = format!(r#" 130 | if [ -n "$ENVRC_LOAD" -a -z "$envrc_loaded" ] 131 | then 132 | envrc_loaded=1 133 | echo "envrc: loading [$ENVRC_LOAD]" 134 | if [ -f "$ENVRC_LOAD" ] 135 | then 136 | . "$ENVRC_LOAD" 137 | else 138 | . "$ENVRC_LOAD/envrc" 139 | fi 140 | fi 141 | envrc_not_allowed= 142 | "#); 143 | 144 | println!("{}", p); 145 | return 146 | } 147 | 148 | if allow_err.is_some() { 149 | let allow_err = match allow_err.unwrap() { 150 | AllowError::AllowDenied => "NOT ALLOWED.", 151 | AllowError::AllowExpired => "PERMISSION EXPIRED." 152 | }; 153 | 154 | // found an .envrc, but it's not allowed to be loaded 155 | let p = format!(r#" 156 | if [ "$envrc_not_allowed" != "{rc_found}" ] 157 | then 158 | tput setaf 3 159 | tput bold 160 | echo "envrc: [{rc_found}] {allow_err}" 161 | echo ' try execute "envrc allow"' 162 | tput sgr0 163 | envrc_not_allowed="{rc_found}" 164 | fi 165 | "#, 166 | rc_found = rc_found.unwrap(), 167 | allow_err = allow_err); 168 | 169 | println!("{}", p); 170 | return 171 | } 172 | 173 | if rc_cur.is_some() { 174 | // we're in an .envrc scope, but need to load another one 175 | return bash_to_parent() 176 | } 177 | 178 | // we're in parent shell, ENVRC_LOAD is empty 179 | // now we're going to load rc_found 180 | let rc_found = rc_found.unwrap(); 181 | 182 | let p = format!(r#" 183 | if [ "$(jobs)" == "" ] 184 | then 185 | echo "envrc: spwan $BASH" 186 | export ENVRC_TMP="$(mktemp "${{TMPDIR-/tmp}}/envrc.XXXXXXXXXX")" 187 | ENVRC_LOAD="{rc_found}" ENVRC_PPID=$$ $BASH 188 | eval "$(if [ -s $ENVRC_TMP ]; then cat $ENVRC_TMP; else echo exit 0; fi; rm $ENVRC_TMP)" 189 | unset ENVRC_TMP 190 | eval "$({exe} bash)" 191 | else 192 | echo "envrc: you have jobs, cannot load envrc" 193 | fi 194 | "#, 195 | rc_found = rc_found, 196 | exe = exe); 197 | 198 | println!("{}", p); 199 | } 200 | 201 | fn bash_to_parent() { 202 | bash_to_parent_eval(String::new()) 203 | } 204 | 205 | fn bash_to_parent_eval(extra: String) { 206 | // let the parent shell to take over 207 | println!(r#" 208 | echo "cd '$PWD' 209 | export OLDPWD='$OLDPWD' 210 | {} 211 | " > $ENVRC_TMP 212 | echo "envrc: exit [$ENVRC_LOAD]" 213 | exit 0 214 | "#, extra); 215 | } 216 | 217 | fn is_out_of_scope(rc: &String) -> bool { 218 | let dir = current_dir(); 219 | if dir.is_err() { 220 | return true 221 | } 222 | let dir = dir.unwrap(); 223 | 224 | let rc_path = PathBuf::from(rc); 225 | let rc_dir = rc_path.parent().unwrap(); 226 | 227 | if dir.strip_prefix(rc_dir).is_ok() { 228 | return false 229 | } 230 | 231 | return true 232 | } 233 | 234 | fn add_allow(rc: &String) { 235 | let now = SystemTime::now(); 236 | let now = now.duration_since(UNIX_EPOCH).unwrap().as_secs(); 237 | 238 | let dir = get_config_dir(); 239 | let _ = create_dir_all(dir.clone()); 240 | 241 | let mut allow_list = dir; 242 | allow_list.push("allow.list"); 243 | 244 | let list = load_allow_list(); 245 | 246 | let mut file = OpenOptions::new() 247 | .create(true) 248 | .write(true) 249 | .truncate(true) 250 | .open(allow_list.to_str().unwrap()) 251 | .unwrap(); 252 | 253 | for (name, ts) in &list { 254 | if name == rc { 255 | continue; 256 | } 257 | file.write_fmt(format_args!("{} {}\n", name, ts)).unwrap(); 258 | } 259 | 260 | file.write_fmt(format_args!("{} {}\n", rc, now)).unwrap(); 261 | } 262 | 263 | fn remove_allow(rc: &String) { 264 | let dir = get_config_dir(); 265 | let _ = create_dir_all(dir.clone()); 266 | 267 | let mut allow_list = dir; 268 | allow_list.push("allow.list"); 269 | 270 | let list = load_allow_list(); 271 | 272 | let mut file = OpenOptions::new() 273 | .create(true) 274 | .write(true) 275 | .truncate(true) 276 | .open(allow_list.to_str().unwrap()) 277 | .unwrap(); 278 | 279 | for (name, ts) in &list { 280 | if name == rc { 281 | continue; 282 | } 283 | file.write_fmt(format_args!("{} {}\n", name, ts)).unwrap(); 284 | } 285 | } 286 | 287 | fn prune_allow() { 288 | let now = timestamp(); 289 | let duration = get_allow_duration(); 290 | 291 | let dir = get_config_dir(); 292 | let _ = create_dir_all(dir.clone()); 293 | 294 | let mut allow_list = dir; 295 | allow_list.push("allow.list"); 296 | 297 | let list = load_allow_list(); 298 | 299 | let mut file = OpenOptions::new() 300 | .create(true) 301 | .write(true) 302 | .truncate(true) 303 | .open(allow_list.to_str().unwrap()) 304 | .unwrap(); 305 | 306 | for (name, ts) in &list { 307 | if now >= ts + duration { 308 | println!("envrc: filter expired [{}]", name); 309 | continue; 310 | } 311 | let path = PathBuf::from(name); 312 | if path.is_file() == false && path.is_dir() == false { 313 | println!("envrc: filter non-existing [{}]", name); 314 | continue; 315 | } 316 | file.write_fmt(format_args!("{} {}\n", name, ts)).unwrap(); 317 | } 318 | } 319 | 320 | fn timestamp() -> u64 { 321 | let now = SystemTime::now(); 322 | now.duration_since(UNIX_EPOCH).unwrap().as_secs() 323 | } 324 | 325 | fn update_if_allowed(rc: &String) { 326 | let now = timestamp(); 327 | // let duration = get_allow_duration(); 328 | 329 | let dir = get_config_dir(); 330 | let _ = create_dir_all(dir.clone()); 331 | 332 | let mut allow_list = dir; 333 | allow_list.push("allow.list"); 334 | 335 | let list = load_allow_list(); 336 | let mut allowed = false; 337 | 338 | let mut file = OpenOptions::new() 339 | .create(true) 340 | .write(true) 341 | .truncate(true) 342 | .open(allow_list.to_str().unwrap()) 343 | .unwrap(); 344 | 345 | for (name, ts) in &list { 346 | if name == rc { 347 | allowed = true; 348 | continue; 349 | } 350 | file.write_fmt(format_args!("{} {}\n", name, ts)).unwrap(); 351 | } 352 | 353 | if allowed { 354 | file.write_fmt(format_args!("{} {}\n", rc, now)).unwrap(); 355 | } 356 | } 357 | 358 | fn get_config_dir() -> PathBuf { 359 | let home = var("HOME").unwrap(); 360 | let mut dir = PathBuf::from(home); 361 | 362 | let dirs = vec![".config", "envrc"]; 363 | 364 | for (_, e) in dirs.iter().enumerate() { 365 | dir.push(e); 366 | } 367 | dir 368 | } 369 | 370 | enum AllowError { 371 | AllowDenied, 372 | AllowExpired 373 | } 374 | 375 | fn get_allow_duration() -> u64 { 376 | match var("ENVRC_ALLOW_DURATION") { 377 | Ok(val) => val.parse::().unwrap(), 378 | Err(_) => 60 * 60 * 24 379 | } 380 | } 381 | 382 | fn check_allow(rc: Option<&String>) -> Option { 383 | if rc.is_none() { 384 | return None 385 | } 386 | let rc = rc.unwrap(); 387 | 388 | let now = timestamp(); 389 | 390 | let duration = get_allow_duration(); 391 | 392 | let list = load_allow_list(); 393 | 394 | for (name, ts) in &list { 395 | if name == rc { 396 | if now >= ts + duration { 397 | return Some(AllowError::AllowExpired) 398 | } else { 399 | return None 400 | } 401 | } 402 | } 403 | 404 | return Some(AllowError::AllowDenied) 405 | } 406 | 407 | fn load_allow_list() -> Vec<(String, u64)> { 408 | let dir = get_config_dir(); 409 | 410 | let mut allow_list = dir; 411 | allow_list.push("allow.list"); 412 | 413 | let file = OpenOptions::new() 414 | .read(true) 415 | .open(allow_list.to_str().unwrap()); 416 | if file.is_err() { 417 | return Vec::new() 418 | } 419 | let file = file.unwrap(); 420 | 421 | let mut ret :Vec<(String, u64)> = Vec::new(); 422 | 423 | for line in BufReader::new(file).lines() { 424 | let line = line.unwrap(); 425 | let fields = line.split(" "); 426 | let fields = fields.collect::>(); 427 | let mut ts = 0u64; 428 | let name = String::from(fields[0]); 429 | if fields.len() > 1 { 430 | let tmp = String::from(fields[1]); 431 | ts = tmp.parse::().unwrap(); 432 | } 433 | ret.push((name, ts)) 434 | } 435 | 436 | return ret 437 | } 438 | 439 | fn current_envrc() -> Option { 440 | let key = "ENVRC_LOAD"; 441 | match var(key) { 442 | Ok(val) => Some(val), 443 | Err(_) => None 444 | } 445 | } 446 | 447 | fn find_envrc(mut d: PathBuf) -> Option { 448 | loop { 449 | let mut rc = d.clone(); 450 | rc.push(".envrc"); 451 | 452 | if rc.is_file() || rc.is_dir() { 453 | return match rc.into_os_string().into_string() { 454 | Ok(s) => Some(s), 455 | Err(_) => None 456 | } 457 | } 458 | 459 | d = d.parent()?.to_path_buf(); 460 | } 461 | } 462 | --------------------------------------------------------------------------------