From 1630e8b873b16343cbe31b35ef9ad51188e638d2 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Fri, 13 Dec 2024 19:51:11 -0500 Subject: [PATCH 1/5] Rewrite version 2.0 to Rust --- .gitignore | 1 + .travis.yml | 32 --- Cargo.lock | 512 ++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 16 ++ LICENSE | 4 +- README.md | 187 +++++++++-------- cmd_all.go | 11 - cmd_linux.go | 14 -- flags.go | 75 ------- go.mod | 3 - main.go | 241 ---------------------- src/env_parser.rs | 153 ++++++++++++++ src/main.rs | 195 ++++++++++++++++++ utils.go | 131 ------------ 14 files changed, 975 insertions(+), 600 deletions(-) create mode 100644 .gitignore delete mode 100644 .travis.yml create mode 100644 Cargo.lock create mode 100644 Cargo.toml delete mode 100644 cmd_all.go delete mode 100644 cmd_linux.go delete mode 100644 flags.go delete mode 100644 go.mod delete mode 100644 main.go create mode 100644 src/env_parser.rs create mode 100644 src/main.rs delete mode 100644 utils.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 127f6ae..0000000 --- a/.travis.yml +++ /dev/null @@ -1,32 +0,0 @@ -language: go - -go: -- 1.13.4 - -install: -- true - -env: - global: - - GO111MODULE=on - - GOFLAGS="-mod=vendor" - -script: -- go vet -- go test ./... -- go build -o dotenv -- "./dotenv --version" -- if [ -z "$TRAVIS_TAG" ]; then exit 0; else - GOOS=linux go build -a -tags netgo -ldflags "-s -w -X main.version=$TRAVIS_TAG" -o binaries/dotenv_linux && - GOOS=darwin go build -a -tags netgo -ldflags "-s -w -X main.version=$TRAVIS_TAG" -o binaries/dotenv_darwin ; - fi - -deploy: - provider: releases - api_key: - secure: nZ4h6NJad5EP85TOeptQG9cEa5G//33kF4n+pZqkpasWJkOKG+/BlE3XuzGBj8683NNzuXLoY+9ZRLFnuigrKQEnlVhMvZEbgCq5j2Gdvi26rtF4uUMsGCs3Hj7tP1fucXm/y2R2/vkrWJIoKxDiOz1xE7894rk5yf3wrAiorVzTk9Zse8dC0WKJqh1oEk7A3vAols5u2IeFDVwn8bI68MjM25gjfh8tFaClD6SJtA3lR3Z5/viAqg3ud4FeHNKymQ6dDQaE/AGsWUgGj7lrIUMOq3dl7bfqJyXqOG7DpMDtMrWv6xfycYV52ycTfXW2xbaKCrgZjqZQtGIOYICWxI2bbzI8XV7tM0mmwujELhfJn+fWxdqMbcF21/npQvDXnrULfyzKjAxyI4AJjSU1Ttrm5LXjYecoy1bqRQxGp2UhStPwMBRMuMeSvM/i7vd9vpPVnZh+JXKKBp2sG3YfNGNkjSUFwaIIqLL1iinwOAobpfTzqBugFBFJmFhndZ67VW6psKHqR68ouIdPr+FdkmVKD5atbA608aPX2Eem2603Njeg0WqRTmy4GXdPrpVG2WxhXnChT/ys5f/S0DWY6XydXjLnl2u0VTGpteAaJMFsmfbugGAlAHWhIiF4yp84j1p1fnr5wigd/JBM2Bd2IFL8s6o5owPyvYh4jGIut5Y= - file_glob: true - file: binaries/* - skip_cleanup: true - on: - tags: true diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..55653fe --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,512 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +dependencies = [ + "anstyle", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.5.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dotenv" +version = "2.0.0" +dependencies = [ + "anyhow", + "clap", + "dirs", + "tempfile", + "which", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "libc" +version = "0.2.168" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "proc-macro2" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "rustix" +version = "0.38.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "which" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cad3279ade7346b96e38731a641d7343dd6a53d55083dd54eadfa5a1b38c6b" +dependencies = [ + "either", + "home", + "rustix", + "winsafe", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cebf502 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dotenv" +version = "2.0.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.94" +clap = { version = "4.5.23", features = ["derive"] } +dirs = "5.0.1" +tempfile = "3.14.0" +which = "7.0.0" + +[profile.release] +opt-level = "z" # Optimize for size. +lto = true # Enable link time optimization. +codegen-units = 1 # Reduce parallel code generation units. diff --git a/LICENSE b/LICENSE index 4f86704..e962648 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Patrick D'appollonio +Copyright (c) 2024 Patrick D'appollonio Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md index 3b049ad..2f92b2a 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,134 @@ # `dotenv` -[![Download](https://img.shields.io/badge/download-here-brightgreen?logo=github)](https://github.com/patrickdappollonio/dotenv/releases) [![Build Status](https://travis-ci.org/patrickdappollonio/dotenv.svg?branch=master)](https://travis-ci.org/patrickdappollonio/dotenv) +**dotenv** is a small command-line utility that allows you to inject environment variables from a `.env` file into a command's environment before running it. It also supports a "strict" mode that only includes variables from the `.env` file plus a few common whitelist of essential environment variables, like `PATH`, `HOME` or even `SHLVL`. -Usage: `dotenv [--environment | -e path] [command] [args...]` +- [`dotenv`](#dotenv) + - [Features](#features) + - [Installation](#installation) + - [Precompiled Binaries](#precompiled-binaries) + - [Rust and Cargo](#rust-and-cargo) + - [Usage](#usage) + - [Loading environment variables](#loading-environment-variables) + - [From the current working directory](#from-the-current-working-directory) + - [From a named environment](#from-a-named-environment) + - [Strict Mode](#strict-mode) + - [`.env` Format](#env-format) -Place a `.env` file at the same level where the current working directory is, -then execute `dotenv [command] [args...]`. +## Features -Additionally, use a `.env` file from `~/.dotenv/` or wherever `$DOTENV_FOLDER_PATH` -points to, by specifying `$DOTENV` or `--environment=filename` or `-e=filename` (without -the extension) and it will be used automatically. If the path passed is absolute, -then whatever file passed will be used as environment if it can be parsed as a -`key=value` format. +- **Automatic `.env` loading:** + If an `.env` file is present in the current directory, `dotenv` loads it automatically. -If the `dotenv` file sets an environment variable named `DOTENV_COMMAND` whose value -is a valid, runnable command, the command will be used and all the remaining -arguments will be sent to the command. For example, the following call will execute -`kubectl get pods` +- **Named environments:** + Use `--environment ` to load variables from `~/.dotenv/.env`. -```bash -$ cat ~/.dotenv/kubectl.env -DOTENV_COMMAND=kubectl -KUBECONFIG=/home/patrick/.kube/cluster.yaml - -$ dotenv -e=kubectl get pods -# since the command is already set in the dotenv file, you -# don't need to specify it like "dotenv -e=kubectl kubectl get pods" -``` +- **Strict mode:** + Use `--strict` to start the command with only the variables from the `.env` file and a minimal whitelist (like `PATH`, `HOME`, etc.). + The `.env` file itself can enforce strict mode by setting `DOTENV_STRICT=true`. -If `$DOTENV_STRICT` is set to any value, and set either through environment variables -or in the environment variables file, strict mode is applied, where the command -gets executed only with the environment variables from the environment file, and -without the environment variables from the environment. This mode is useful to not -leak environment variables to your commmands that don't really need them, but also -keep in mind some programs rely on `$PATH` to be set, or `$HOME` or other useful -environment variables. +- **Transparent command execution:** + After loading the environment variables, `dotenv` executes the specified command, passing all arguments along. -A cool example with no arguments but configuration given via environment variables: +- **Compatibility with commands requiring their own flags:** + Use a double dash (`--`) to signal that subsequent arguments belong to the executed command, not to `dotenv`. -```bash -$ DOTENV=<(echo -e "DOTENV_COMMAND=env\nNAME=joe\nDOTENV_STRICT=1") dotenv -NAME=joe -``` +## Installation -`dotenv` will execute your command, `stdin`, `stdout` and `stderr` will be piped, and the -exit code will be passed to your terminal. +### Precompiled Binaries -## Installation +Precompiled binaries for Linux, macOS, and Windows are available on the [Releases page](https://github.com/patrickdappollonio/dotenv/releases). -[Download the binary from the Releases page](https://github.com/patrickdappollonio/dotenv/releases) -and place the binary in a place in your `$PATH`. Then simply call `dotenv` with whatever -configuration needed. +Download the binary for your platform, then move it to a directory in your `PATH`. -## ... but why? +### Rust and Cargo -`dotenv` comes as a solution to a problem I was running pretty often. I do a lot of -terminal stuff and some CLIs use files to configure themselves while others use a -combination of an environment variable that somehows configure the rest of the CLI via -a file. +1. Ensure you have [Rust and Cargo](https://www.rust-lang.org/tools/install) installed. +2. Clone this repository: + ```bash + git clone https://github.com/yourusername/dotenv.git + cd dotenv + ``` +3. Build the project: + ```bash + cargo build --release + ``` +4. The compiled binary can be found in `target/release/dotenv`. -As an example, the `openstack` CLI uses `OS_CLOUD` to define a "cloud configuration" which -is then taken from the file at `~/.config/openstack/clouds.yaml` which can define multiple -clouds. I'm always changing which cloud I use, and I realize `openstack` can also be -configured with environment variables such as: +## Usage ```bash -export OS_AUTH_URL= -export OS_PROJECT_NAME= -export OS_USERNAME= -export OS_PASSWORD= # (optional) +dotenv [OPTIONS] -- COMMAND [ARGS...] ``` -So rather than doing that, I created a few files in `~/.dotenv/` which point to a specific -cloud configuration, say `~/.dotenv/cloud1.env`, `~/.dotenv/cloud2.env`. They all contain -the same set of environment variables but with different values, plus, they contain a single -`DOTENV_COMMAND` variable which is always set to `openstack`, like this: +### Loading environment variables -``` -DOTENV_COMMAND=openstack -OS_AUTH_URL= -OS_PROJECT_NAME= -OS_USERNAME= -OS_PASSWORD= # (optional) -``` +`dotenv` supports two modes of operation: loading environment variables from a `.env` file in the current directory or from a named environment file. -Now, depending on what cloud I want to execute commands, like getting servers (`openstack server list`) -or loadbalancers (`openstack loadbalancer list`), I simply do: +### From the current working directory + +By default, `dotenv` loads environment variables from a `.env` file in the current working directory if you specify no arguments. + +Consider the following scenario: ```bash -$ dotenv -e=cloud1 server list -$ dotenv -e=cloud2 loadbalancer list +$ cat .env +HELLO=world + +$ dotenv -- printenv HELLO +world ``` -Or I can even alias the commands to work a bit faster: +### From a named environment + +If you prefer custom environment variables, you can overwrite `dotenv`'s default `.env` file by specifying a different file. This file however has to come from `dotenv`'s configuration directory, which is `$HOME/.dotenv/`. + +Any file here named `.env` can be loaded by specifying `--environment ` or `-e `: ```bash -alias os1='dotenv -e=cloud1' -alias os2='dotenv -e=cloud2' +$ cat ~/.dotenv/example.env +FOO=bar + +$ dotenv --environment example -- printenv FOO +bar ``` -I realize though that, at the end, we come down to managing multiple files rather than just one -(as in, we went from one `clouds.yaml` to 2 `.env` files), but the solution is highly scriptable -and oftentimes the `clouds.yaml` file is being edited to support new clouds (I work with quite a -few usernames, passwords and cloud endpoints). +### Strict Mode + +Sometimes we might not trust a specific command from wreaking havoc in our environment, and we would rather provide just a limited set of environment variables without exposing the entire environment. This is where strict mode comes in. + +A common case scenario might be that you have an environment where you have your AWS credentials stored in the environment variables. You might not want to expose these to a command that you don't trust. -Other more commonly used feature is that I store some `.env` file in the local directory where I'm -developing a Go web server which needs to have environment variables that I can't post to Github, -like `$PORT` or even `$SMTP_USERNAME`. This is the easiest to solve with `dotenv` because of this: +To avoid this, simply run the command with the `--strict` flag: ```bash -$ cat .env -PORT=8081 -SMTP_HOST=localhost -SMTP_USER=patrick -SMTP_PASS=demo +$ printenv AWS_ACCESS_KEY_ID +AKIAIOSFODNN7EXAMPLE + +$ dotenv --strict -- printenv AWS_ACCESS_KEY_ID +# no output -$ dotenv go run *.go -Server listening on port 8081... +$ dotenv --strict -- printenv PATH +/usr/local/bin:/usr/bin:/bin ``` -## Adding new features? +The program still received some basic environment variables that are often needed to find other programs, but none of the AWS credentials were exposed. + +> [!CAUTION] +> **`dotenv` makes no effort preventing the program to gain access to these environment variables** by other means (like reading configuration files or the untrusted program being able to upload your entire configuration to a remote location). +> It only prevents them from being passed directly to the program. -I welcome any Pull Request. The license of this software is also permissive enough that -you can make it yours and / or use it in corporate environments. The sky is the limit! +## `.env` Format + +Use simple `KEY=VALUE` lines: + +```env +# A comment line +FOO=bar +MYNAME=Alice +DOTENV_STRICT=true +``` -Most of the code here has been written in a rush, so assume typos are a thing. You can -also see there's a lack of testing, so if you're into that, send me a PR! No PR is -useless when it comes down to this app. \ No newline at end of file +- Lines starting with `#` are ignored as comments. +- Trailing comments after `#` on the same line are also ignored, and the lines are space-trimmed. +- Empty lines are ignored. +- Shebangs (`#!`) are ignored and have no effect on how we run the command. diff --git a/cmd_all.go b/cmd_all.go deleted file mode 100644 index 2194183..0000000 --- a/cmd_all.go +++ /dev/null @@ -1,11 +0,0 @@ -// +build !linux - -package main - -import ( - "os/exec" -) - -func getCommand(command string, args ...string) *exec.Cmd { - return exec.Command(command, args...) -} diff --git a/cmd_linux.go b/cmd_linux.go deleted file mode 100644 index c85e54f..0000000 --- a/cmd_linux.go +++ /dev/null @@ -1,14 +0,0 @@ -// +build linux - -package main - -import ( - "os/exec" - "syscall" -) - -func getCommand(command string, args ...string) *exec.Cmd { - cmd := exec.Command(command, args...) - cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM} - return cmd -} diff --git a/flags.go b/flags.go deleted file mode 100644 index 7e191c8..0000000 --- a/flags.go +++ /dev/null @@ -1,75 +0,0 @@ -package main - -import "os" - -func isControlFlagSet(flag ...string) bool { - if len(os.Args) <= 1 { - return false - } - - found := false - - for _, v := range flag { - if v == os.Args[1] { - found = true - break - } - - if startswith(os.Args[1], v+"=") { - found = true - break - } - } - - return found -} - -func getFlagValue(keys ...string) map[string]string { - out := make(map[string]string) - - if len(keys) == 0 { - return out - } - - args := os.Args[1:] - for pos, arg := range args { - if len(arg) > 0 && arg[0] != '-' { - continue - } - - for _, name := range keys { - if prefix := name + "="; len(arg) >= len(prefix) && arg[:len(prefix)] == prefix { - key := arg[:len(prefix)-1] - value := arg[len(prefix):] - out[key] = value - continue - } - - if arg == name { - if next := pos + 1; next < len(args) { - nextval := args[next] - - if nextval != "" && nextval[0] == '-' { - continue - } - - out[name] = nextval - continue - } - } - } - } - - return out -} - -func getAllArgsAfter(value string) []string { - args := os.Args[1:] - for pos, v := range args { - if len(v) >= len(value) && v[len(v)-len(value):] == value { - return append([]string{}, args[pos+1:]...) - } - } - - return nil -} diff --git a/go.mod b/go.mod deleted file mode 100644 index 82e6708..0000000 --- a/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/patrickdappollonio/dotenv - -go 1.13 diff --git a/main.go b/main.go deleted file mode 100644 index c680e07..0000000 --- a/main.go +++ /dev/null @@ -1,241 +0,0 @@ -package main - -import ( - "io/ioutil" - "log" - "os" - "os/exec" -) - -const ( - aliasKey = "DOTENV_COMMAND" - strictKey = "DOTENV_STRICT" - debugKey = "DOTENV_DEBUG" -) - -var ( - dotenvLocations = envOrDefault("DOTENV_FOLDER_PATH", "~/.dotenv/") - dotenvUse = envOrDefault("DOTENV", "") - dotenvStrict = envOrDefault(strictKey, "") - version = "development" - - knownDotenvVars = [...]string{"DOTENV_FOLDER_PATH", "DOTENV", debugKey, strictKey, aliasKey} -) - -const usage = `Usage: dotenv [--environment | -e path] [command] [args...] - -Place a ".env" file at the same level where the current working directory is, -then execute dotenv [command] [args...]. - -Additionally, use a ".env" file from ~/.dotenv/ or wherever $DOTENV_FOLDER_PATH -points to, by specifying $DOTENV or --environment=filename or -e=filename (without -the extension) and it will be used automatically. If the path passed is absolute, -then whatever file passed will be used as environment if it can be parsed as a -key=value format. - -If the dotenv file sets an environment variable named DOTENV_COMMAND whose value -is a valid, runnable command, the command will be used and all the remaining -arguments will be sent to the command. For example, the following call will execute -"kubectl get pods" - - $ cat ~/.dotenv/kubectl.env - DOTENV_COMMAND=kubectl - KUBECONFIG=/home/patrick/.kube/cluster.yaml - - $ dotenv -e=kubectl get pods - # since the command is already set in the dotenv file, you - # don't need to specify it like "dotenv -e=kubectl kubectl get pods" - -If $DOTENV_STRICT is set to any value, and set either through environment variables -or in the environment variables file, strict mode is applied, where the command -gets executed only with the environment variables from the environment file, and -without the environment variables from the environment. This mode is useful to not -leak environment variables to your commmands that don't really need them, but also -keep in mind some programs rely on $PATH to be set, or $HOME or other useful -environment variables. - -A cool example with no arguments but configuration given via environment variables: - - $ DOTENV=<(echo -e "DOTENV_COMMAND=env\nNAME=joe\nDOTENV_STRICT=1") dotenv - NAME=joe - -dotenv will execute your command, stdin, stdout and stderr will be piped, and the -exit code will be passed to your terminal.` - -func main() { - logger := log.New(ioutil.Discard, "[dotenv-debug] ", log.Lshortfile|log.LstdFlags) - - if os.Getenv(debugKey) != "" { - logger.SetOutput(os.Stdout) - } - - var ( - command string - evfile string - ) - - args := os.Args[1:] - - if isControlFlagSet("-h", "--help") { - os.Stdout.WriteString(usage + "\n") - return - } - - if isControlFlagSet("-v", "--version") { - os.Stdout.WriteString("[dotenv] version " + version + "\n") - return - } - - if dotenvUse != "" { - logger.Printf("environment variable $DOTENV set to: %q -- using that as the file", dotenvUse) - evfile = dotenvUse - } - - if isControlFlagSet("--environment", "-e") { - vals := getFlagValue("--environment", "-e") - venv := "" - - logger.Printf("environment parameters parsed: %v", vals) - - if v, found := vals["--environment"]; found { - logger.Printf("long parameter --environment set to: %q", v) - venv = v - } - - if v, found := vals["-e"]; found { - if venv != "" { - logger.Printf("exiting because both flags, --environment and -e were provided") - errexit("Both flags provided: --environment and -e -- must specify only one") - } - - logger.Printf("short parameter -e set to: %q", v) - venv = v - } - - if startswith(venv, "/") || startswith(venv, "./") { - logger.Printf("environment file passed %q starts with a control character, assuming full path", venv) - evfile = venv - } else { - if fp, found := envFilePresentInHome(venv); found { - logger.Printf("found a file in the user's directory with the file name matching %q: %s", venv, fp) - evfile = fp - } else { - logger.Printf("no file found in user's directory for %q, assuming full path", venv) - evfile = venv - } - } - - args = getAllArgsAfter(venv) - logger.Printf("parsed arguments after environment flags to be: %#v", args) - } - - if evfile == "" { - logger.Printf("no env file set, defaulting to assuming there's one in the current directory") - evfile = ".env" - } - - envvars, err := loadVirtualEnv(evfile) - if err != nil { - if _, ok := err.(*filenotfound); ok { - logger.Printf("unable to find dotenv file at %q", evfile) - errexit("No dotenv file found at %q", evfile) - } - - logger.Printf("unknown error while handling envfile %q: %s", evfile, err.Error()) - errexit("Can't read environment variable file: %s", err.Error()) - } - - aliascmd, hasalias := envvars[aliasKey] - logger.Printf("found alias in env file? %v -- alias: %q", hasalias, aliascmd) - - switch len(args) { - case 0: - if !hasalias { - logger.Printf("exiting just because no alias was set and no commands were passed") - errexit("missing command and / or arguments, see --help") - } - - case 1: - command = args[0] - args = []string{} - - default: - command = args[0] - args = args[1:] - } - - logger.Printf("got command %q -- args: %#v", command, args) - - if hasalias { - if command != "" { - args = append([]string{command}, args...) - } - - command = aliascmd - delete(envvars, aliasKey) - - logger.Printf("swapping command due to alias to %q -- args: %#v", command, args) - } - - if strict, found := envvars[strictKey]; found { - dotenvStrict = strict - delete(envvars, strictKey) - } - - environ := make([]string, 0, len(os.Environ())) - for _, v := range os.Environ() { - known := false - for _, m := range knownDotenvVars { - if startswith(v, m+"=") { - known = true - } - } - - if !known { - logger.Printf("Adding unknown env var %q", v) - environ = append(environ, v) - } - } - - vars := make([]string, 0, len(envvars)+len(environ)) - - logOffset := 0 - if dotenvStrict == "" { - logger.Printf("strict mode environment variable not set: appending all current environment variables") - vars = append(vars, environ...) - logOffset = len(environ) - } - - for k, v := range envvars { - known := false - for _, m := range knownDotenvVars { - if m == v { - known = true - } - } - - if !known { - vars = append(vars, k+"="+v) - } - } - - logger.Printf("environment variables to be injected to command (besides %d current env vars): %v", len(environ), vars[logOffset:]) - - cmd := getCommand(command, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = vars - - logger.Printf("command to be executed: %s %v", command, args) - - if err := cmd.Run(); err != nil { - if e, ok := err.(*exec.ExitError); ok { - logger.Printf("command exited with exit code: %v", e) - os.Exit(e.ExitCode()) - } - - logger.Printf("unable to execute command %q: %s", command, err.Error()) - errexit("Unable to execute command %q: %s", command, err.Error()) - } -} diff --git a/src/env_parser.rs b/src/env_parser.rs new file mode 100644 index 0000000..2749d0e --- /dev/null +++ b/src/env_parser.rs @@ -0,0 +1,153 @@ +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +/// Parse a `.env` file and return key-value pairs of environment variables. +pub fn parse_env_file(file_path: &PathBuf) -> Result> { + let content = fs::read_to_string(file_path) + .with_context(|| format!("Failed to read .env file at {}", file_path.display()))?; + + parse_env_str(&content) +} + +/// Parse a `.env` format string and return key-value pairs. +/// +/// Rules: +/// - Ignore empty lines. +/// - Ignore lines starting with `#` or `#!` (shebang). +/// - If a line contains a `#`, treat that and the rest of the line as a comment. +/// - Keys and values are trimmed. +/// - Invalid lines (no `=` or empty key/value) are ignored. +pub fn parse_env_str(content: &str) -> Result> { + let mut env_vars = HashMap::new(); + + for line in content.lines() { + let line = line.trim(); + + // Ignore empty lines + if line.is_empty() { + continue; + } + + // Ignore shebang or line that starts with '#' + if line.starts_with("#!") || line.starts_with('#') { + continue; + } + + // Strip trailing comments + let line = if let Some(idx) = line.find('#') { + &line[..idx] + } else { + line + }; + + let line = line.trim(); + if line.is_empty() { + continue; + } + + if let Some((key, value)) = parse_env_line(line) { + env_vars.insert(key, value); + } + } + + Ok(env_vars) +} + +/// Parse a single line of the form `KEY=VALUE`. +fn parse_env_line(line: &str) -> Option<(String, String)> { + let mut split = line.splitn(2, '='); + let key = split.next()?.trim(); + let val = split.next()?.trim(); + + if key.is_empty() || val.is_empty() { + return None; + } + + Some((key.to_string(), val.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_env_line() { + assert_eq!( + parse_env_line("KEY=VALUE"), + Some(("KEY".to_string(), "VALUE".to_string())) + ); + assert_eq!( + parse_env_line(" KEY = VALUE "), + Some(("KEY".to_string(), "VALUE".to_string())) + ); + assert_eq!(parse_env_line("EMPTY= "), None); + assert_eq!(parse_env_line("NOEQUALS"), None); + assert_eq!(parse_env_line("#COMMENT"), None); + } + + #[test] + fn test_parse_env_str_basic() -> Result<()> { + let input = r#" + KEY=VALUE + FOO=BAR + "#; + + let vars = parse_env_str(input)?; + assert_eq!(vars.get("KEY"), Some(&"VALUE".to_string())); + assert_eq!(vars.get("FOO"), Some(&"BAR".to_string())); + Ok(()) + } + + #[test] + fn test_parse_env_str_comments_and_spaces() -> Result<()> { + let input = r#" + # A comment + # Another comment + + MYNAME=Patrick # inline comment + + # Another line + SHELL=/bin/bash + "#; + + let vars = parse_env_str(input)?; + assert_eq!(vars.get("MYNAME"), Some(&"Patrick".to_string())); + assert_eq!(vars.get("SHELL"), Some(&"/bin/bash".to_string())); + Ok(()) + } + + #[test] + fn test_parse_env_str_shebang_and_complex_comment_lines() -> Result<()> { + let input = r#" + #!/usr/bin/env bash # this line should be ignored if present + # This is a comment that should be ignored + + MYNAME=Patrick # comments at the end of the line should also be okay + # and comments that don't start at the beginning of the line should also be okay + "#; + + let vars = parse_env_str(input)?; + assert_eq!(vars.get("MYNAME"), Some(&"Patrick".to_string())); + Ok(()) + } + + #[test] + fn test_parse_env_str_invalid_lines() -> Result<()> { + let input = r#" + KEY=VALUE + INVALIDLINE + ANOTHER=GOODVALUE + NOTHING= + "#; + + let vars = parse_env_str(input)?; + assert_eq!(vars.get("KEY"), Some(&"VALUE".to_string())); + assert_eq!(vars.get("ANOTHER"), Some(&"GOODVALUE".to_string())); + // "INVALIDLINE" and "NOTHING=" should be ignored. + assert!(!vars.contains_key("INVALIDLINE")); + assert!(!vars.contains_key("NOTHING")); + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ed2b29d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,195 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use std::{collections::HashMap, env, path::PathBuf, process::Command}; + +mod env_parser; + +static STRICT_WHITELIST: &[&str] = &["PATH", "HOME", "SHELL", "USER", "SHLVL", "LANG", "TERM"]; + +#[derive(Parser, Debug)] +#[command( + name = "dotenv", + author = "Patrick D'appollonio ", + about = "Dynamically inject just the environment variables you allow to the command you're about to execute." +)] +struct Cli { + /// Specify the named environment file in ~/.dotenv/ (e.g. `example` for ~/.dotenv/example.env) + #[arg(short, long)] + environment: Option, + + /// Strict mode: only environment variables from the .env file plus a minimal whitelist are kept + #[arg(long)] + strict: bool, + + /// The command and arguments to run (e.g. `python main.py`) + #[arg(required = true)] + command: Vec, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let mut strict = cli.strict; + + if cli.command.is_empty() { + anyhow::bail!("No command provided."); + } + + // Determine the environment file to use + let env_file = match &cli.environment { + Some(name) => get_named_env_file(name)?, + None => { + let current = env::current_dir().context("Could not get current directory")?; + let file = current.join(".env"); + if file.exists() { + Some(file) + } else { + None + } + } + }; + + // Load environment variables from the file if the file exists + let env_vars_from_file = if let Some(file_path) = env_file { + if file_path.exists() { + env_parser::parse_env_file(&file_path).with_context(|| { + format!("Could not parse environment file: {}", file_path.display(),) + })? + } else { + HashMap::new() + } + } else { + HashMap::new() + }; + + // Check if there is an env var for strict mode + if !strict { + if let Some(val) = env_vars_from_file.get("DOTENV_STRICT") { + if is_truthy(val) { + strict = true; + } + } + } + + // Check if we finally have strict mode, if so, strip + // all env vars except the whitelisted ones + if strict { + let mut new_env_vars: HashMap = HashMap::new(); + for &var in STRICT_WHITELIST { + if let Ok(val) = env::var(var) { + new_env_vars.insert(var.to_string(), val); + } + } + + for (key, value) in env_vars_from_file { + new_env_vars.insert(key, value); + } + + clear_environment(); + + for (key, value) in new_env_vars { + env::set_var(key, value); + } + } else { + // Strict mode is disabled, so we can inject all the + // variables + for (key, value) in env_vars_from_file { + env::set_var(key, value); + } + } + + // Execute the program with the new variables + let (program, args) = cli.command.split_first().context("No program specified")?; + let status = Command::new(program) + .args(args) + .status() + .with_context(|| format!("Failed to execute command: {}", program))?; + + // Grab the exit code from the executed program + std::process::exit(status.code().unwrap_or(1)); +} + +/// Clear all environment variables +fn clear_environment() { + let keys: Vec = env::vars().map(|(k, _)| k).collect(); + for key in keys { + env::remove_var(key); + } +} + +/// Check if a value is considered truthy +fn is_truthy(value: &str) -> bool { + matches!(value.to_lowercase().as_str(), "true" | "yes" | "1") +} + +fn get_named_env_file(name: &str) -> Result> { + let home_dir = dirs::home_dir().context("Could not get home directory: the home directory is required to fetch specific environment files.")?; + let dotenv_dir = home_dir.join(".dotenv"); + + let file = dotenv_dir.join(format!("{}.env", name)); + if file.exists() { + Ok(Some(file)) + } else { + eprintln!( + "Environment file does not exist in home directory settings folder: {}", + file.display() + ); + Ok(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{io::Write, path}; + use tempfile::NamedTempFile; + + #[test] + fn test_is_truthy() { + assert!(is_truthy("true")); + assert!(is_truthy("True")); + assert!(is_truthy("yes")); + assert!(is_truthy("YES")); + assert!(is_truthy("1")); + assert!(!is_truthy("false")); + assert!(!is_truthy("no")); + assert!(!is_truthy("0")); + assert!(!is_truthy("random")); + } + + #[test] + fn test_dotenv_strict_sets_strict_mode() -> anyhow::Result<()> { + let mut file = NamedTempFile::new()?; + writeln!(file, "DOTENV_STRICT=true")?; + let location = path::absolute(file.path())?; + let vars = env_parser::parse_env_file(&location)?; + + let mut strict = false; + if let Some(val) = vars.get("DOTENV_STRICT") { + if super::is_truthy(val) { + strict = true; + } + } + assert!(strict); + + let mut file2 = NamedTempFile::new()?; + writeln!(file2, "DOTENV_STRICT=false")?; + let location2 = path::absolute(file2.path())?; + let vars2 = env_parser::parse_env_file(&location2)?; + + let mut strict2 = false; + if let Some(val) = vars2.get("DOTENV_STRICT") { + if super::is_truthy(val) { + strict2 = true; + } + } + assert!(!strict2); + Ok(()) + } + + #[test] + fn test_clear_environment() { + env::set_var("TESTVAR", "VALUE"); + clear_environment(); + assert!(env::var("TESTVAR").is_err()); + } +} diff --git a/utils.go b/utils.go deleted file mode 100644 index c08b552..0000000 --- a/utils.go +++ /dev/null @@ -1,131 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "fmt" - "io" - "os" - "os/user" - "path/filepath" - "strings" -) - -type filenotfound struct { - name string -} - -func (e *filenotfound) Error() string { - return "file not found: " + e.name -} - -func loadFile(fp string) (*bytes.Buffer, error) { - tmplfile, err := filepath.Abs(fp) - if err != nil { - return nil, fmt.Errorf("unable to get path to file %q: %s", fp, err.Error()) - } - - f, err := os.Open(tmplfile) - if err != nil { - if os.IsNotExist(err) { - return nil, &filenotfound{name: tmplfile} - } - - return nil, fmt.Errorf("unable to open file %q: %s", fp, err.Error()) - } - - defer f.Close() - - var buf bytes.Buffer - if _, err := io.Copy(&buf, f); err != nil { - return nil, fmt.Errorf("unable to read file %q: %s", fp, err.Error()) - } - - return &buf, nil -} - -func startswith(s, prefix string) bool { - return len(s) >= len(prefix) && s[0:len(prefix)] == prefix -} - -func expand(path string) (string, error) { - if !startswith(path, "~/") { - return path, nil - } - - usr, err := user.Current() - if err != nil { - return path, err - } - return filepath.Join(usr.HomeDir, path[1:]), nil -} - -func loadVirtualEnv(fp string) (map[string]string, error) { - if fp == "" { - return nil, nil - } - - fp, err := expand(fp) - if err != nil { - return nil, fmt.Errorf("unable to expand %q in path: %s", "~", err.Error()) - } - - data, err := loadFile(fp) - if err != nil { - return nil, err - } - - ev := make(map[string]string) - sc := bufio.NewScanner(data) - - for sc.Scan() { - k, v := parseLine(sc.Text()) - if k == "" || v == "" { - continue - } - - ev[k] = v - } - - return ev, nil -} - -func parseLine(line string) (string, string) { - if startswith(strings.TrimSpace(line), "#") { - return "", "" - } - - items := strings.Split(line, "=") - if len(items) < 2 { - return "", "" - } - - return strings.ToUpper(items[0]), strings.Join(items[1:], "=") -} - -func envOrDefault(key, defval string) string { - if v, found := os.LookupEnv(key); found { - if s := strings.TrimSpace(v); s != "" { - return s - } - } - - return defval -} - -func errexit(format string, args ...interface{}) { - fmt.Fprintf(os.Stderr, "[dotenv] "+format+"\n", args...) - os.Exit(1) -} - -func envFilePresentInHome(filename string) (string, bool) { - filename = filepath.Join(dotenvLocations, filename+".env") - filename, _ = expand(filename) - - info, err := os.Stat(filename) - if os.IsNotExist(err) { - return "", false - } - - return filename, !info.IsDir() -} From 3e8eb16ce2b5d2ceb9a131316a86e73d6a86e8a6 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Sat, 14 Dec 2024 00:44:25 -0500 Subject: [PATCH 2/5] Ensure Pdeathsig is called on Linux. --- Cargo.lock | 1 + Cargo.toml | 1 + src/main.rs | 45 ++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 55653fe..f38befe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,7 @@ dependencies = [ "anyhow", "clap", "dirs", + "libc", "tempfile", "which", ] diff --git a/Cargo.toml b/Cargo.toml index cebf502..366d1b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" anyhow = "1.0.94" clap = { version = "4.5.23", features = ["derive"] } dirs = "5.0.1" +libc = "0.2.168" tempfile = "3.14.0" which = "7.0.0" diff --git a/src/main.rs b/src/main.rs index ed2b29d..b1f16cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,12 +99,44 @@ fn main() -> Result<()> { // Execute the program with the new variables let (program, args) = cli.command.split_first().context("No program specified")?; - let status = Command::new(program) - .args(args) - .status() - .with_context(|| format!("Failed to execute command: {}", program))?; + + // Create the command and set the arguments apart so they outlive + // the borrow checker + let mut cmd = Command::new(program); + cmd.args(args); + + // On Linux, set the Pdeathsig so the child receives SIGTERM if the parent dies + #[cfg(target_os = "linux")] + { + use std::io::{Error, ErrorKind}; + use std::os::unix::process::CommandExt; + + unsafe { + cmd.pre_exec(|| { + // Set the parent-death signal to SIGTERM + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM, 0, 0, 0) != 0 { + return Err(Error::last_os_error()); + } + + // Double-check parent PID + let ppid = libc::getppid(); + if ppid == 1 { + // The parent is init, meaning we won't get PDEATHSIG if the original parent is gone + return Err(Error::new( + ErrorKind::Other, + "Unable to operate on a program whose parent is init", + )); + } + + Ok(()) + }); + } + } // Grab the exit code from the executed program + let status = cmd + .status() + .with_context(|| format!("Failed to execute command: {}", program))?; std::process::exit(status.code().unwrap_or(1)); } @@ -118,7 +150,10 @@ fn clear_environment() { /// Check if a value is considered truthy fn is_truthy(value: &str) -> bool { - matches!(value.to_lowercase().as_str(), "true" | "yes" | "1") + matches!( + value.to_lowercase().as_str(), + "1" | "t" | "true" | "y" | "yes" + ) } fn get_named_env_file(name: &str) -> Result> { From 09cdbd1a59ce94c78bfbcf6aaa223075f0ab4d2b Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Sat, 14 Dec 2024 00:45:29 -0500 Subject: [PATCH 3/5] Add additional steps as part of coverage. --- src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main.rs b/src/main.rs index b1f16cf..8778b66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -182,8 +182,12 @@ mod tests { fn test_is_truthy() { assert!(is_truthy("true")); assert!(is_truthy("True")); + assert!(is_truthy("t")); + assert!(is_truthy("T")); assert!(is_truthy("yes")); assert!(is_truthy("YES")); + assert!(is_truthy("y")); + assert!(is_truthy("Y")); assert!(is_truthy("1")); assert!(!is_truthy("false")); assert!(!is_truthy("no")); From a1aa0e80cf9397c367cd15626360a3a846bbd3e0 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Sat, 14 Dec 2024 01:01:32 -0500 Subject: [PATCH 4/5] Include SIGKILL. Add GitHub Actions. --- .github/workflows/release.yaml | 51 ++++++++++++++++++++++++++++++++++ .github/workflows/test.yaml | 21 ++++++++++++++ README.md | 25 +++++++++++++---- src/main.rs | 5 ++++ 4 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/release.yaml create mode 100644 .github/workflows/test.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..8515c63 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,51 @@ +name: Release Rust Application + +on: + release: + types: [created] + +permissions: + contents: write + +jobs: + release: + name: Release for ${{ matrix.target }} + strategy: + matrix: + include: + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + archive: linux-arm64 + - target: aarch64-apple-darwin + os: macos-latest + archive: darwin-arm64 + - target: x86_64-apple-darwin + os: macos-latest + archive: darwin-x86_64 + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + archive: linux-x86_64 + - target: x86_64-pc-windows-msvc + os: windows-latest + archive: windows-x86_64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - shell: bash + run: | + # Update the version in Cargo.toml + TAG_NAME="${{ github.event.release.tag_name }}" + TAG_NAME="${TAG_NAME#v}" + if [[ "${{ runner.os }}" == "macOS" ]]; then + sed -i"" -e "s/^version = .*/version = \"$TAG_NAME\"/" Cargo.toml + else + sed -i -e "s/^version = .*/version = \"$TAG_NAME\"/" Cargo.toml + fi + - uses: taiki-e/upload-rust-binary-action@v1 + with: + bin: gc-rust + archive: $bin-$tag-${{ matrix.archive }} + target: ${{ matrix.target }} + tar: unix + zip: windows + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..c1570c1 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,21 @@ +name: Test Rust Application + +on: + push: + +jobs: + test: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Setup Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Run tests + run: cargo test diff --git a/README.md b/README.md index 2f92b2a..5436f61 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # `dotenv` -**dotenv** is a small command-line utility that allows you to inject environment variables from a `.env` file into a command's environment before running it. It also supports a "strict" mode that only includes variables from the `.env` file plus a few common whitelist of essential environment variables, like `PATH`, `HOME` or even `SHLVL`. +**`dotenv`** is a small command-line utility that allows you to **inject environment variables from a `.env` file into a command's environment before running it.** It also supports a "strict" mode that only includes variables from the `.env` file without leaking potentially private environment variables, plus a few common whitelist of essential environment variables, like `PATH`, `HOME` or even `SHLVL`. - [`dotenv`](#dotenv) - [Features](#features) @@ -20,11 +20,12 @@ If an `.env` file is present in the current directory, `dotenv` loads it automatically. - **Named environments:** - Use `--environment ` to load variables from `~/.dotenv/.env`. + Use `--environment ` to load variables from `$HOME/.dotenv/.env`. - **Strict mode:** Use `--strict` to start the command with only the variables from the `.env` file and a minimal whitelist (like `PATH`, `HOME`, etc.). - The `.env` file itself can enforce strict mode by setting `DOTENV_STRICT=true`. + + The `.env` file itself can enforce strict mode by setting `DOTENV_STRICT=true` without needing to specify `--strict`. - **Transparent command execution:** After loading the environment variables, `dotenv` executes the specified command, passing all arguments along. @@ -32,13 +33,27 @@ - **Compatibility with commands requiring their own flags:** Use a double dash (`--`) to signal that subsequent arguments belong to the executed command, not to `dotenv`. +- **Death signal propagation:** + If the parent is killed by a `SIGTERM` or `SIGKILL` signal, the child process is also killed using `PR_SET_PDEATHSIG` *(only available in Linux)*. + ## Installation ### Precompiled Binaries Precompiled binaries for Linux, macOS, and Windows are available on the [Releases page](https://github.com/patrickdappollonio/dotenv/releases). -Download the binary for your platform, then move it to a directory in your `PATH`. +Download the binary for your platform, then move it to a directory in your `$PATH`, or use `install`: + +```bash +$ ls +dotenv + +# add executable permissions +$ chmod +x dotenv + +# install it to /usr/local/bin +$ sudo install -m 755 dotenv /usr/local/bin/dotenv +``` ### Rust and Cargo @@ -85,7 +100,7 @@ If you prefer custom environment variables, you can overwrite `dotenv`'s default Any file here named `.env` can be loaded by specifying `--environment ` or `-e `: ```bash -$ cat ~/.dotenv/example.env +$ cat $HOME/.dotenv/example.env FOO=bar $ dotenv --environment example -- printenv FOO diff --git a/src/main.rs b/src/main.rs index 8778b66..db4a85d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -118,6 +118,11 @@ fn main() -> Result<()> { return Err(Error::last_os_error()); } + // Set the parent-death signal to SIGKILL + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL, 0, 0, 0) != 0 { + return Err(Error::last_os_error()); + } + // Double-check parent PID let ppid = libc::getppid(); if ppid == 1 { From d432ae74b60d6b67b625ad9b25e51256cba8f901 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Sat, 14 Dec 2024 01:04:32 -0500 Subject: [PATCH 5/5] Optimize readme. --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5436f61..99be996 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,18 @@ # `dotenv` +[![Download](https://img.shields.io/badge/download-here-brightgreen?logo=github)](https://github.com/patrickdappollonio/dotenv/releases) +[![Github Downloads](https://img.shields.io/github/downloads/patrickdappollonio/dotenv/total?color=orange&label=github%20downloads&logo=github)](https://github.com/patrickdappollonio/dotenv/releases) + **`dotenv`** is a small command-line utility that allows you to **inject environment variables from a `.env` file into a command's environment before running it.** It also supports a "strict" mode that only includes variables from the `.env` file without leaking potentially private environment variables, plus a few common whitelist of essential environment variables, like `PATH`, `HOME` or even `SHLVL`. +```bash +$ cat .env +HELLO=world + +$ dotenv -- printenv HELLO +world +``` + - [`dotenv`](#dotenv) - [Features](#features) - [Installation](#installation) @@ -81,9 +92,7 @@ dotenv [OPTIONS] -- COMMAND [ARGS...] ### From the current working directory -By default, `dotenv` loads environment variables from a `.env` file in the current working directory if you specify no arguments. - -Consider the following scenario: +By default, `dotenv` loads environment variables from a `.env` file in the current working directory if you specify no arguments: ```bash $ cat .env