16 Commits

65 changed files with 2613 additions and 3248 deletions

View File

@@ -1,28 +0,0 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Go
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24.6'
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...

32
.github/workflows/odin.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Odin
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libsodium-dev sqlite3 libsqlite3-dev libsodium-dev
- name: Install Odin
run: |
git clone https://github.com/odin-lang/Odin.git /opt/odin
cd /opt/odin
./build_odin.sh release
echo "/opt/odin" >> "$GITHUB_PATH"
- name: Build
run: |
odin build . -o:speed -out:envr
- name: Test
run: odin test .

View File

@@ -2,6 +2,8 @@ on:
push:
branches:
- main
- dev
- odin
permissions:
contents: write
@@ -14,7 +16,7 @@ jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
- uses: googleapis/release-please-action@v5
with:
# this assumes that you have created a personal access token
# (PAT) and configured it as a GitHub action secret named
@@ -22,4 +24,4 @@ jobs:
token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }}
# this is a built-in strategy in release-please, see "Action Inputs"
# for more options
release-type: go
release-type: simple

3
.gitignore vendored
View File

@@ -1,6 +1,8 @@
# dev env
.direnv
list.json
# docs
man
@@ -9,3 +11,4 @@ builds
envr
envr-go
result
version.odin

View File

@@ -4,7 +4,6 @@
APP_NAME := envr
VERSION := $(shell grep 'version = ' flake.nix | head -1 | sed 's/.*version = "\(.*\)";/\1/')
BUILD_DIR := builds
LDFLAGS := -X github.com/sbrow/envr/cmd.version=v$(VERSION) -s -w
# Binary names
LINUX_AMD64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64
@@ -23,23 +22,23 @@ $(BUILD_DIR):
# Build Linux AMD64
$(LINUX_AMD64_BIN): $(BUILD_DIR)
@echo "Building for Linux AMD64..."
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(LINUX_AMD64_BIN) .
odin build . -target:linux_amd64 -o:speed -out:$(LINUX_AMD64_BIN)
@echo "Built $(LINUX_AMD64_BIN)"
# Build Linux ARM64
$(LINUX_ARM64_BIN): $(BUILD_DIR)
@echo "Building for Linux ARM64..."
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(LINUX_ARM64_BIN) .
odin build . -target:linux_arm64 -o:speed -out:$(LINUX_ARM64_BIN)
@echo "Built $(LINUX_ARM64_BIN)"
# Build Darwin ARM64 (Mac)
$(DARWIN_ARM64_BIN): $(BUILD_DIR)
@echo "Building for Darwin ARM64..."
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(DARWIN_ARM64_BIN) .
odin build . -target:darwin_arm64 -o:speed -out:$(DARWIN_ARM64_BIN)
@echo "Built $(DARWIN_ARM64_BIN)"
# Build all binaries
build-linux: $(LINUX_AMD64_BIN) $(LINUX_ARM64_BIN)
build-linux: $(LINUX_AMD64_BIN) # $(LINUX_ARM64_BIN)
build-darwin: $(DARWIN_ARM64_BIN)
# Compress Linux artifacts with gzip
@@ -58,11 +57,12 @@ $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64.zip: $(DARWIN_ARM64_BIN)
# Compress all artifacts
compress: $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64.tar.gz \
$(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64.tar.gz \
$(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64.zip
# $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64.tar.gz \
# $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64.zip
# Build and compress all release artifacts
release: build-linux build-darwin compress
# release: build-linux build-darwin compress
release: build-linux compress
@echo "Release artifacts created:"
@ls -la $(BUILD_DIR)/*.tar.gz $(BUILD_DIR)/*.zip 2>/dev/null || echo "No compressed artifacts found"
@@ -79,14 +79,14 @@ cleanall:
# Show available targets
help:
@echo "Available targets:"
@echo " all - Build all release artifacts (default)"
@echo " release - Build and compress all release artifacts"
@echo " build-linux - Build Linux binaries only"
@echo " all - Build all release artifacts (default)"
@echo " release - Build and compress all release artifacts"
@echo " build-linux - Build Linux binaries only"
@echo " build-darwin - Build Darwin binaries only"
@echo " compress - Compress all built binaries"
@echo " clean - Remove binary files only"
@echo " cleanall - Remove entire build directory"
@echo " help - Show this help message"
@echo " compress - Compress all built binaries"
@echo " clean - Remove binary files only"
@echo " cleanall - Remove entire build directory"
@echo " help - Show this help message"
@echo ""
@echo "Release artifacts will be created in $(BUILD_DIR)/"
@echo "Version: $(VERSION)"

View File

@@ -13,7 +13,7 @@ the tool [of your choosing](#backup-options).
## Features
- 🔐 **Encrypted Storage**: All `.env` files are encrypted using your ssh key and
[age](https://github.com/FiloSottile/age) encryption.
[libsodium](https://github.com/jedisct1/libsodium) encryption.
- 🔄 **Automatic Sync**: Update the database with one command, which can easily
be run on a cron.
- 🔍 **Smart Scanning**: Automatically discover and import `.env` files in your
@@ -37,12 +37,13 @@ repositories.
## Installation
### With Go
### With Odin
If you already have `go` installed:
If you already have `odin` installed:
```bash
go install github.com/sbrow/envr
# You'll need libsodium and sqlite
odin build -o:speed
envr init
```
@@ -104,18 +105,18 @@ The configuration file is created during initialization:
## Backup Options
`envr` merely gathers your `.env` files in one local place. It is up to you to
back up the database (found at `~/.envr/data.age`) to a *secure* and *remote*
back up the database (found at `~/.envr/data.envr`) to a *secure* and *remote*
location.
### Git
`envr` preserves inodes when updating the database, so you can safely hardlink
`~/.envr/data.age` into your [GNU Stow](https://www.gnu.org/software/stow/),
`~/.envr/data.envr` into your [GNU Stow](https://www.gnu.org/software/stow/),
[Home Manager](https://github.com/nix-community/home-manager), or
[NixOS](https://nixos.wiki/wiki/flakes) repository.
> [!CAUTION]
> For **maximum security**, only save your `data.age` file to a local
> For **maximum security**, only save your `data.envr` file to a local
(i.e. non-cloud) git server that **you personally control**.
>
> I take no responsibility if you push all your secrets to a public GitHub repo.

View File

@@ -4,14 +4,10 @@ Note: These todos can wait until all the subcommands have been ported.
## HIGH
1. [x] **table.odin:74-89** — Hand-rolled JSON output doesn't escape `"`, `\`, newlines. Reimplements `json.marshal` which is already imported in `cmd_list.odin`. Replace with `json.marshal`.
2. **db.odin:380-383, 405, 446**`sqlite.bind_text` return values overwritten but never checked. A failed bind means `sqlite.step` operates on unbound params.
3. **config.odin:52-54**`os.user_home_dir` error silently ignored. If it fails, `home` is `""` and all paths become relative (`".envr"` instead of `"~/.envr"`).
30. **cmd_sync.odin:46-50, 64-68** — Double `db_insert` when `BackedUp`: first insert on line 48, then `db_update_required` is also true for `BackedUp` so second insert runs on line 65. Redundant and wasteful.
## MEDIUM
4. **db.odin:29-35**`make_temp_path` never calls `strings.builder_destroy`. Leaks builder buffer every call.
@@ -32,17 +28,13 @@ Note: These todos can wait until all the subcommands have been ported.
12. **cmd_edit_config.odin:27**`$EDITOR` used as single binary name. Breaks for multi-word values like `"code -w"`. Needs `strings.fields()`.
13. [x] **cmd_list.odin:31-35, 58-61** — Uses a `strings.Builder` (never destroyed) for what is just `row.Dir + "/"`. Also `filepath.rel` used where `filepath.base` would suffice since dir is always the parent.
33. **config.odin:178**`search_paths` silently ignores `os.user_home_dir` error. If home is empty, `~` isn't expanded. Same class of bug as issue 3.
34. **table.odin:84-88**`render_json_rows` creates `map[string]string` per row, copies into dynamic array. `delete(entries)` frees the array but not individual map internals — potential map bucket leak per row.
35. **prompt.odin:124**`make([dynamic]bool, len(options))` creates N zero-initialized elements. Works because `false` is the default, but same footgun as original issue 1. Should be `make([dynamic]bool, 0, len(options))`.
## LOW
39. Lots of memory leaks to fix.
14. [x] **db.odin:338-341** — Unnecessary `strings.clone` before `filepath.dir` (which already returns a slice into the input).
## LOW
15. **db.odin:115**`json.unmarshal_string` error not checked. Malformed JSON silently produces empty/partial data.
@@ -50,10 +42,6 @@ Note: These todos can wait until all the subcommands have been ported.
18. **config.odin:51-60**`envr_dir` recomputes home dir on every call. Could cache.
19. **main.odin:42-46** — Dynamic array in `fallback_to_go` never deleted. Harmless since process exits.
36. **cli.odin:59-76** — Single-dash multi-char flags (e.g. `-force`) silently misparse. `-force` becomes flag `f` with value `o`, then `rce` as positional arg. Only `--force` and `-f` work correctly.
37. **cmd_sync.odin:80, cmd_list.odin:33, cmd_deps.odin:9**`make([]string, 2)` for table rows never freed. Leaks per row. Defer to memory pass.
## REFACTOR
@@ -62,8 +50,6 @@ Note: These todos can wait until all the subcommands have been ported.
21. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
22. Replace is_tty with terminal.is_terminal
23. Add a text filter to the multi_select.
24. Create backup / fallback fd.
@@ -76,4 +62,4 @@ Note: These todos can wait until all the subcommands have been ported.
28. 2 scan tests silently skip Low When fd isn't installed, tests pass without actually testing anything. These should use #assert to be sure that fd is in path.
29. nushell completions?
38. Try to do all encryption / decryption in memory - only read / write encrypted data to disk.

View File

@@ -1,267 +0,0 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"filippo.io/age"
"filippo.io/age/agessh"
)
type Config struct {
Keys []SshKeyPair `json:"keys"`
ScanConfig scanConfig `json:"scan"`
}
// Used by age to encrypt and decrypt the database.
type SshKeyPair struct {
Private string `json:"private"` // Path to the private key file
Public string `json:"public"` // Path to the public key file
}
type scanConfig struct {
// TODO: Support multiple matchers
Matcher string `json:"matcher"`
Exclude []string `json:"exclude"`
Include []string `json:"include"`
}
// Create a fresh config with sensible defaults.
func NewConfig(privateKeyPaths []string) Config {
var keys = []SshKeyPair{}
for _, priv := range privateKeyPaths {
var key = SshKeyPair{
Private: priv,
Public: priv + ".pub",
}
keys = append(keys, key)
}
return Config{
Keys: keys,
ScanConfig: scanConfig{
Matcher: "\\.env",
Exclude: []string{
"*\\.envrc",
"\\.local",
"node_modules",
"vendor",
},
Include: []string{"~"},
},
}
}
// Read the Config from disk.
func LoadConfig() (*Config, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
configPath := filepath.Join(homeDir, ".envr", "config.json")
data, err := os.ReadFile(configPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("No config file found. Please run `envr init` to generate one.")
} else {
return nil, err
}
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
// Write the Config to disk.
func (c *Config) Save() error {
// Create the ~/.envr directory
homeDir, err := os.UserHomeDir()
if err != nil {
return err
}
configDir := filepath.Join(homeDir, ".envr")
if err := os.MkdirAll(configDir, 0755); err != nil {
return err
}
configPath := filepath.Join(configDir, "config.json")
// Check if file exists and is not empty
if info, err := os.Stat(configPath); err == nil {
if info.Size() > 0 {
return os.ErrExist
}
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0644)
}
// buildFdArgs builds the fd command arguments with multiple exclude patterns
func (c Config) buildFdArgs(searchPath string, includeIgnored bool) []string {
args := []string{"-a", c.ScanConfig.Matcher}
// Add exclude patterns
for _, exclude := range c.ScanConfig.Exclude {
args = append(args, "-E", exclude)
}
if includeIgnored {
args = append(args, "-HI")
} else {
args = append(args, "-H")
}
args = append(args, searchPath)
return args
}
// Use fd to find all ignored .env files that match the config's parameters
func (c Config) scan() (paths []string, err error) {
searchPaths, err := c.searchPaths()
if err != nil {
return []string{}, err
}
for _, searchPath := range searchPaths {
// Find all files (including ignored ones)
fmt.Printf("Searching for all files in \"%s\"...\n", searchPath)
allCmd := exec.Command("fd", c.buildFdArgs(searchPath, true)...)
allOutput, err := allCmd.Output()
if err != nil {
return paths, err
}
allFiles := strings.Split(strings.TrimSpace(string(allOutput)), "\n")
if len(allFiles) == 1 && allFiles[0] == "" {
allFiles = []string{}
}
// Find unignored files
fmt.Printf("Search for unignored fies in \"%s\"...\n", searchPath)
unignoredCmd := exec.Command("fd", c.buildFdArgs(searchPath, false)...)
unignoredOutput, err := unignoredCmd.Output()
if err != nil {
return []string{}, err
}
unignoredFiles := strings.Split(strings.TrimSpace(string(unignoredOutput)), "\n")
if len(unignoredFiles) == 1 && unignoredFiles[0] == "" {
unignoredFiles = []string{}
}
// Create a map for faster lookup
unignoredMap := make(map[string]bool)
for _, file := range unignoredFiles {
unignoredMap[file] = true
}
// Filter to get only ignored files
var ignoredFiles []string
for _, file := range allFiles {
if !unignoredMap[file] {
ignoredFiles = append(ignoredFiles, file)
}
}
paths = append(paths, ignoredFiles...)
}
return paths, nil
}
func (c Config) searchPaths() (paths []string, err error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return paths, err
}
includes := c.ScanConfig.Include
for _, include := range includes {
path := strings.Replace(include, "~", homeDir, 1)
absPath, err := filepath.Abs(path)
if err != nil {
return paths, err
}
paths = append(paths, absPath)
}
return paths, nil
}
func (s SshKeyPair) identity() (age.Identity, error) {
sshKey, err := os.ReadFile(s.Private)
if err != nil {
return nil, fmt.Errorf("failed to read SSH key: %w", err)
}
id, err := agessh.ParseIdentity(sshKey)
if err != nil {
return nil, fmt.Errorf("failed to parse SSH identity: %w", err)
}
return id, nil
}
func (s SshKeyPair) recipient() (age.Recipient, error) {
sshKey, err := os.ReadFile(s.Public)
if err != nil {
return nil, fmt.Errorf("failed to read SSH key: %w", err)
}
id, err := agessh.ParseRecipient(string(sshKey))
if err != nil {
return nil, fmt.Errorf("failed to parse SSH identity: %w", err)
}
return id, nil
}
// Use fd to find all git roots in the config's search paths
func (c Config) findGitRoots() (paths []string, err error) {
searchPaths, err := c.searchPaths()
if err != nil {
return []string{}, err
}
for _, searchPath := range searchPaths {
allCmd := exec.Command("fd", "-H", "-t", "d", "^\\.git$", searchPath)
allOutput, err := allCmd.Output()
if err != nil {
return paths, err
}
allFiles := strings.Split(strings.TrimSpace(string(allOutput)), "\n")
if len(allFiles) == 1 && allFiles[0] == "" {
allFiles = []string{}
}
for i, file := range allFiles {
allFiles[i] = path.Dir(path.Clean(file))
}
paths = append(paths, allFiles...)
}
return paths, nil
}

421
app/db.go
View File

@@ -1,421 +0,0 @@
package app
// TODO: app/db.go should be reviewed.
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"filippo.io/age"
_ "modernc.org/sqlite"
)
type Db struct {
db *sql.DB
cfg Config
features *AvailableFeatures
// If true, the database will be saved to disk before closing
changed bool
}
func Open() (*Db, error) {
cfg, err := LoadConfig()
if err != nil {
return nil, err
}
if _, err := os.Stat("/home/spencer/.envr/data.age"); err != nil {
// Create a new DB
db, err := newDb()
return &Db{db, *cfg, nil, true}, err
} else {
// Open the existing DB
tmpFile, err := os.CreateTemp("", "envr-*.db")
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
defer os.Remove(tmpFile.Name())
err = decryptDb(tmpFile.Name(), (*cfg).Keys)
if err != nil {
return nil, fmt.Errorf("failed to decrypt database: %w", err)
}
memDb, err := newDb()
if err != nil {
return nil, fmt.Errorf("failed to open temp database: %w", err)
}
restoreDB(tmpFile.Name(), memDb)
return &Db{memDb, *cfg, nil, false}, nil
}
}
// Creates the database for the first time
func newDb() (*sql.DB, error) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
return nil, err
} else {
_, err := db.Exec(`create table envr_env_files (
path text primary key not null
, remotes text -- JSON
, sha256 text not null
, contents text not null
);`)
if err != nil {
return nil, err
} else {
return db, err
}
}
}
// Decrypt the database from the age file into a temp sqlite file.
func decryptDb(tmpFilePath string, keys []SshKeyPair) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
tmpFile, err := os.OpenFile(tmpFilePath, os.O_WRONLY, 0)
if err != nil {
return fmt.Errorf("failed to open temp file: %w", err)
}
defer tmpFile.Close()
ageFilePath := filepath.Join(homeDir, ".envr", "data.age")
ageFile, err := os.Open(ageFilePath)
if err != nil {
return fmt.Errorf("failed to open age file: %w", err)
}
defer ageFile.Close()
identities := make([]age.Identity, 0, len(keys))
for _, key := range keys {
id, err := key.identity()
if err != nil {
return err
}
identities = append(identities, id)
}
reader, err := age.Decrypt(ageFile, identities[:]...)
if err != nil {
return fmt.Errorf("failed to decrypt age file: %w", err)
}
_, err = io.Copy(tmpFile, reader)
if err != nil {
return fmt.Errorf("failed to copy decrypted content: %w", err)
}
return nil
}
// Restore the database from a file into memory
func restoreDB(path string, destDB *sql.DB) error {
// Attach the source database
_, err := destDB.Exec("ATTACH DATABASE ? AS source", path)
if err != nil {
return fmt.Errorf("failed to attach database: %w", err)
}
defer destDB.Exec("DETACH DATABASE source")
// Copy data from source to destination
_, err = destDB.Exec("INSERT INTO main.envr_env_files SELECT * FROM source.envr_env_files")
if err != nil {
return fmt.Errorf("failed to copy data: %w", err)
}
return nil
}
// Returns all the EnvFiles present in the database.
func (db *Db) List() (results []EnvFile, err error) {
rows, err := db.db.Query("select path, remotes, sha256, contents from envr_env_files")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var envFile EnvFile
var remotesJson []byte
err := rows.Scan(&envFile.Path, &remotesJson, &envFile.Sha256, &envFile.contents)
if err != nil {
return nil, err
}
// Populate Dir from Path
envFile.Dir = filepath.Dir(envFile.Path)
if err := json.Unmarshal(remotesJson, &envFile.Remotes); err != nil {
return nil, err
}
results = append(results, envFile)
}
if err = rows.Err(); err != nil {
return nil, err
}
return results, nil
}
func (db *Db) Close() error {
defer db.db.Close()
if db.changed {
// Create tmp file
tmpFile, err := os.CreateTemp("", "envr-*.db")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
defer os.Remove(tmpFile.Name())
if err := backupDb(db.db, tmpFile.Name()); err != nil {
return err
}
if err := encryptDb(tmpFile.Name(), db.cfg.Keys); err != nil {
return err
}
db.changed = false
}
return nil
}
// Save the in-memory database to a tmp file.
func backupDb(memDb *sql.DB, tmpFilePath string) error {
_, err := memDb.Exec("VACUUM INTO ?", tmpFilePath)
if err != nil {
return fmt.Errorf("failed to vacuum database to file: %w", err)
}
return nil
}
// Encrypt the database from the temp sqlite file into an age file.
func encryptDb(tmpFilePath string, keys []SshKeyPair) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
ageFilePath := filepath.Join(homeDir, ".envr", "data.age")
// Ensure .envr directory exists
err = os.MkdirAll(filepath.Dir(ageFilePath), 0755)
if err != nil {
return fmt.Errorf("failed to create .envr directory: %w", err)
}
// Open temp file for reading
tmpFile, err := os.Open(tmpFilePath)
if err != nil {
return fmt.Errorf("failed to open temp file: %w", err)
}
defer tmpFile.Close()
// Open/create age file for writing (this preserves hardlinks)
ageFile, err := os.OpenFile(ageFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to open age file: %w", err)
}
defer ageFile.Close()
recipients := make([]age.Recipient, 0, len(keys))
for _, key := range keys {
recipient, err := key.recipient()
if err != nil {
return err
}
recipients = append(recipients, recipient)
}
writer, err := age.Encrypt(ageFile, recipients...)
if err != nil {
return fmt.Errorf("failed to create age writer: %w", err)
}
_, err = io.Copy(writer, tmpFile)
if err != nil {
return fmt.Errorf("failed to encrypt and write data: %w", err)
}
err = writer.Close()
if err != nil {
return fmt.Errorf("failed to close age writer: %w", err)
}
return nil
}
func (db *Db) Insert(file EnvFile) error {
// Marshal remotes to JSON
remotesJSON, err := json.Marshal(file.Remotes)
if err != nil {
return fmt.Errorf("failed to marshal remotes: %w", err)
}
// Insert into database
_, err = db.db.Exec(`
INSERT OR REPLACE INTO envr_env_files (path, remotes, sha256, contents)
VALUES (?, ?, ?, ?)
`, file.Path, string(remotesJSON), file.Sha256, file.contents)
if err != nil {
return fmt.Errorf("failed to insert env file: %w", err)
}
db.changed = true
return nil
}
// Select a single EnvFile from the database.
func (db *Db) Fetch(path string) (envFile EnvFile, err error) {
var remotesJSON string
row := db.db.QueryRow("SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?", path)
err = row.Scan(&envFile.Path, &remotesJSON, &envFile.Sha256, &envFile.contents)
if err != nil {
return EnvFile{}, fmt.Errorf("failed to fetch env file: %w", err)
}
// Populate Dir from Path
envFile.Dir = filepath.Dir(envFile.Path)
if err = json.Unmarshal([]byte(remotesJSON), &envFile.Remotes); err != nil {
return EnvFile{}, fmt.Errorf("failed to unmarshal remotes: %w", err)
}
return envFile, nil
}
// Removes a file from the database, if present.
func (db *Db) Delete(path string) error {
result, err := db.db.Exec("DELETE FROM envr_env_files WHERE path = ?", path)
if err != nil {
return fmt.Errorf("failed to delete env file: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("no file found with path: %s", path)
}
db.changed = true
return nil
}
// Finds .env files in the filesystem that aren't present in the database.
// path overrides the already configured
func (db *Db) Scan(paths []string) ([]string, error) {
cfg := db.cfg
if paths != nil {
cfg.ScanConfig.Include = paths
}
all_paths, err := cfg.scan()
if err != nil {
return []string{}, err
}
untracked_paths := make([]string, 0, len(all_paths)/2)
env_files, err := db.List()
if err != nil {
return untracked_paths, err
}
for _, path := range all_paths {
backed_up := slices.ContainsFunc(env_files, func(e EnvFile) bool {
return e.Path == path
})
if backed_up {
continue
} else {
untracked_paths = append(untracked_paths, path)
}
}
return untracked_paths, nil
}
// Determine the available features on the installed system.
func (db *Db) Features() AvailableFeatures {
if db.features == nil {
feats := checkFeatures()
db.features = &feats
}
return *db.features
}
// Returns nil if [Db.Scan] is safe to use, null otherwise.
func (db *Db) CanScan() error {
if db.Features()&Fd == 0 {
return fmt.Errorf(
"please install fd to use the scan function (https://github.com/sharkdp/fd)",
)
} else {
return nil
}
}
// If true, [Db.Insert] should be called on the [EnvFile] that generated
// the given result
func (db Db) UpdateRequired(status EnvFileSyncResult) bool {
return status&(BackedUp|DirUpdated) != 0
}
func (db *Db) Sync(file *EnvFile) (result EnvFileSyncResult, err error) {
// TODO: This results in findMovedDirs being called multiple times.
return file.sync(TrustFilesystem, db)
}
// Looks for git directories that share one or more git remotes with
// the given file.
func (db Db) findMovedDirs(file *EnvFile) (movedDirs []string, err error) {
if err = db.Features().validateFeatures(Fd, Git); err != nil {
return movedDirs, err
}
gitRoots, err := db.cfg.findGitRoots()
if err != nil {
return movedDirs, err
} else {
for _, dir := range gitRoots {
if file.sharesRemote(getGitRemotes(dir)) {
movedDirs = append(movedDirs, dir)
}
}
return movedDirs, nil
}
}

View File

@@ -1,244 +0,0 @@
package app
import (
"crypto/sha256"
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
)
type EnvFile struct {
// TODO: Should use FileName in the struct and derive from the path.
Path string
// Dir is derived from Path, and is not stored in the database.
Dir string
Remotes []string // []string
Sha256 string
contents string
}
// The result returned by [EnvFile.Sync]
type EnvFileSyncResult int
const (
// The filesystem contents matches the struct
// no further action is required.
Noop EnvFileSyncResult = 0
// The directory changed, but the file contents matched.
// The database must be updated.
DirUpdated EnvFileSyncResult = 1
// The filesystem has been restored to match the struct
// no further action is required.
Restored EnvFileSyncResult = 1 << 1
// The filesystem has been restored to match the struct.
// The directory changed, so the database must be updated
RestoredAndDirUpdated EnvFileSyncResult = Restored | DirUpdated
// The struct has been updated from the filesystem
// and should be updated in the database.
BackedUp EnvFileSyncResult = 1 << 2
Error EnvFileSyncResult = 1 << 3
)
// Determines the source of truth when calling [EnvFile.Sync] or [EnvFile.Restore]
type syncDirection int
const (
TrustDatabase syncDirection = iota
TrustFilesystem
)
func NewEnvFile(path string) EnvFile {
// Get absolute path and directory
absPath, err := filepath.Abs(path)
if err != nil {
panic(fmt.Errorf("failed to get absolute path: %w", err))
}
dir := filepath.Dir(absPath)
// Get git remotes
remotes := getGitRemotes(dir)
// Read the file contents
contents, err := os.ReadFile(path)
if err != nil {
panic(fmt.Errorf("failed to read file %s: %w", path, err))
}
// Calculate SHA256 hash
hash := sha256.Sum256(contents)
sha256Hash := fmt.Sprintf("%x", hash)
return EnvFile{
Path: absPath,
Dir: dir,
Remotes: remotes,
Sha256: sha256Hash,
contents: string(contents),
}
}
func getGitRemotes(dir string) []string {
// TODO: Check for Git flag and change behaviour if unset.
cmd := exec.Command("git", "remote", "-v")
cmd.Dir = dir
output, err := cmd.Output()
if err != nil {
// Not a git repository or git command failed
return []string{}
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
remoteSet := make(map[string]bool)
for _, line := range lines {
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) >= 2 {
remoteSet[parts[1]] = true
}
}
remotes := make([]string, 0, len(remoteSet))
for remote := range remoteSet {
remotes = append(remotes, remote)
}
return remotes
}
// Reconcile the state of the database with the state of the filesystem, using
// dir to determine which side to use a the source of truth.
func (f *EnvFile) sync(dir syncDirection, db *Db) (result EnvFileSyncResult, err error) {
if result != Noop {
panic("Invalid state")
}
if _, err := os.Stat(f.Dir); err != nil {
// Directory doesn't exist
var movedDirs []string
if db != nil {
movedDirs, err = db.findMovedDirs(f)
}
if err != nil {
return Error, err
} else {
switch len(movedDirs) {
case 0:
return Error, fmt.Errorf("directory missing")
case 1:
f.updateDir(movedDirs[0])
result |= DirUpdated
default:
return Error, fmt.Errorf("multiple directories found")
}
}
}
if _, err := os.Stat(f.Path); err != nil {
if errors.Is(err, os.ErrNotExist) {
if err := os.WriteFile(f.Path, []byte(f.contents), 0644); err != nil {
return Error, fmt.Errorf("failed to write file: %w", err)
}
return result | Restored, nil
} else {
return Error, err
}
} else {
// File exists, check its hash
contents, err := os.ReadFile(f.Path)
if err != nil {
return Error, fmt.Errorf("failed to read file for SHA comparison: %w", err)
}
hash := sha256.Sum256(contents)
currentSha := fmt.Sprintf("%x", hash)
// Compare the hashes
if currentSha == f.Sha256 {
// No op, or DirUpdated
return result, nil
} else {
switch dir {
case TrustDatabase:
if err := os.WriteFile(f.Path, []byte(f.contents), 0644); err != nil {
return Error, fmt.Errorf("failed to write file: %w", err)
}
return result | Restored, nil
case TrustFilesystem:
// Overwrite the database
if err = f.Backup(); err != nil {
return Error, err
} else {
return BackedUp, nil
}
default:
panic("unknown sync direction")
}
}
}
}
func (f *EnvFile) sharesRemote(remotes []string) bool {
rMap := make(map[string]bool)
for _, remote := range f.Remotes {
rMap[remote] = true
}
for _, remote := range remotes {
if rMap[remote] {
return true
}
}
return false
}
func (f *EnvFile) updateDir(newDir string) {
f.Dir = newDir
f.Path = path.Join(newDir, path.Base(f.Path))
f.Remotes = getGitRemotes(newDir)
}
// Try to reconcile the EnvFile with the filesystem.
//
// If Updated is returned, [Db.Insert] should be called on file.
func (file *EnvFile) Sync() (result EnvFileSyncResult, err error) {
return file.sync(TrustFilesystem, nil)
}
// Install the file into the file system. If the file already exists,
// it will be overwritten.
func (file EnvFile) Restore() error {
_, err := file.sync(TrustDatabase, nil)
return err
}
// Update the EnvFile using the file system.
func (file *EnvFile) Backup() error {
// Read the contents of the file
contents, err := os.ReadFile(file.Path)
if err != nil {
return fmt.Errorf("failed to read file %s: %w", file.Path, err)
}
// Update file.contents to match
file.contents = string(contents)
// Update file.sha256
hash := sha256.Sum256(contents)
file.Sha256 = fmt.Sprintf("%x", hash)
return nil
}

View File

@@ -1,60 +0,0 @@
package app
import (
"fmt"
"os/exec"
)
type MissingFeatureError struct {
feature AvailableFeatures
}
func (m *MissingFeatureError) Error() string {
return fmt.Sprintf("Missing \"%s\" feature", m.feature)
}
// TODO: Features should really be renamed to Binaries
// Represents which binaries are present in $PATH.
// Used to fail safely when required features are unavailable
type AvailableFeatures int
const (
Git AvailableFeatures = 1
// fd
Fd AvailableFeatures = 2
// All features are present
All AvailableFeatures = Git | Fd
)
// Checks for available features.
func checkFeatures() (feats AvailableFeatures) {
// Check for git binary
if _, err := exec.LookPath("git"); err == nil {
feats |= Git
}
// Check for fd binary
if _, err := exec.LookPath("fd"); err == nil {
feats |= Fd
}
return feats
}
// Returns a MissingFeature error if the given features aren't present.
func (a AvailableFeatures) validateFeatures(features ...AvailableFeatures) error {
var missing AvailableFeatures
for _, feat := range features {
if a&feat == 0 {
missing |= feat
}
}
if missing == 0 {
return nil
} else {
return &MissingFeatureError{missing}
}
}

186
cli.odin
View File

@@ -1,6 +1,9 @@
package main
import "core:bufio"
import "core:fmt"
import "core:io"
import "core:mem"
import "core:os"
import "core:strings"
@@ -19,10 +22,14 @@ CommandInfo :: struct {
aliases: []string,
}
COMMANDS := []CommandInfo{
{"init", "envr init", "Set up envr",
COMMANDS := []CommandInfo {
{
"init",
"envr init",
"Set up envr",
"The init command generates your initial config and saves it to\n~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to\nencrypt your databse. **Make 100% sure** that you have **a remote copy** of this\nkey somewhere, otherwise your data could be lost forever.",
{}},
{},
},
{"scan", "envr scan", "Find and select .env files for backup", "", {}},
{"sync", "envr sync", "Update or restore your env backups", "", {}},
{"backup", "envr backup <path>", "Import a .env file into envr", "", {"add"}},
@@ -30,11 +37,16 @@ COMMANDS := []CommandInfo{
{"list", "envr list", "View your tracked files", "", {}},
{"remove", "envr remove <path>", "Remove a .env file from your database", "", {}},
{"check", "envr check [path]", "Check if files are backed up", "", {}},
{"deps", "envr deps", "Check for missing binaries",
{
"deps",
"envr deps",
"Check for missing binaries",
"envr relies on external binaries for certain functionality.\n\nThe check command reports on which binaries are available and which are not.",
{}},
{},
},
{"version", "envr version", "Show envr's version", "", {}},
{"edit-config", "envr edit-config", "Edit your config with your default editor", "", {}},
{"nushell-completion", "envr nushell-completion", "Generate custom completions for nushell", "", {}},
}
parse_args :: proc() -> (cmd: Command, ok: bool) {
@@ -60,8 +72,8 @@ parse_args :: proc() -> (cmd: Command, ok: bool) {
arg := args[i]
if strings.starts_with(arg, "--") {
key := arg[2:]
if i+1 < len(args) && !strings.starts_with(args[i+1], "-") {
cmd.flags[key] = args[i+1]
if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key] = args[i + 1]
i += 2
} else {
cmd.bool_set[key] = true
@@ -69,8 +81,8 @@ parse_args :: proc() -> (cmd: Command, ok: bool) {
}
} else if strings.starts_with(arg, "-") && len(arg) == 2 {
key_slice := arg[1:2]
if i+1 < len(args) && !strings.starts_with(args[i+1], "-") {
cmd.flags[key_slice] = args[i+1]
if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key_slice] = args[i + 1]
i += 2
} else {
cmd.bool_set[key_slice] = true
@@ -113,114 +125,122 @@ find_command :: proc(name: string) -> (CommandInfo, bool) {
return CommandInfo{}, false
}
command_help_text :: proc(name: string) -> (string, bool) {
write_command_help :: proc(name: string, w: io.Writer) -> bool {
info, found := find_command(name)
if !found {
return "", false
return false
}
b: strings.Builder
strings.builder_init(&b)
fmt.sbprintf(&b, "Usage: %s [flags]\n\n", info.usage)
fmt.sbprintf(&b, "%s\n", info.short)
fmt.wprintf(w, "Usage: %s [flags]\n\n", info.usage, flush = false)
fmt.wprintf(w, "%s\n", info.short, flush = false)
if len(info.aliases) > 0 {
fmt.sbprintf(&b, "\nAliases:\n %s", info.name)
fmt.wprintf(w, "\nAliases:\n %s", info.name, flush = false)
for a in info.aliases {
fmt.sbprintf(&b, ", %s", a)
fmt.wprintf(w, ", %s", a, flush = false)
}
fmt.sbprintf(&b, "\n")
fmt.wprintf(w, "\n", flush = false)
}
if len(info.long) > 0 {
fmt.sbprintf(&b, "\n%s\n", info.long)
fmt.wprintf(w, "\n%s\n", info.long, flush = false)
}
fmt.sbprintf(&b, "\nFlags:\n -h, --help help for %s\n", info.name)
s := strings.clone(strings.to_string(b))
strings.builder_destroy(&b)
return s, true
fmt.wprintf(w, "\nFlags:\n -h, --help help for %s\n", info.name, flush = false)
return true
}
print_command_help :: proc(name: string) {
text, ok := command_help_text(name)
bw: bufio.Writer
bufio.writer_init(&bw, io.to_writer(os.to_writer(os.stdout)), mem.DEFAULT_PAGE_SIZE)
defer bufio.writer_destroy(&bw)
w := bufio.writer_to_writer(&bw)
ok := write_command_help(name, w)
if !ok {
fmt.printf("Unknown command: %s\n", name)
print_usage()
return
}
fmt.println(text)
bufio.writer_flush(&bw)
}
usage_text :: proc() -> string {
b: strings.Builder
strings.builder_init(&b)
write_usage :: proc(w: io.Writer) {
fmt.wprintf(
w,
`envr keeps your .env synced to a local, age encrypted database.
Is a safe and easy way to gather all your .env files in one place where they can
easily be backed by another tool such as restic or git.
fmt.sbprintf(&b, "envr keeps your .env synced to a local, age encrypted database.\n")
fmt.sbprintf(&b, "Is a safe and easy way to gather all your .env files in one place where they can\n")
fmt.sbprintf(&b, "easily be backed by another tool such as restic or git.\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "All your data is stored in ~/data.age\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "Getting started is easy:\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "1. Create your configuration file and set up encrypted storage:\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "> envr init\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "2. Scan for existing .env files:\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "> envr scan\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "Select the files you want to back up from the interactive list.\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "3. Verify that it worked:\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "> envr list\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "4. After changing any of your .env files, update the backup with:\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "> envr sync\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "5. If you lose a repository, after re-cloning the repo into the same path it was\n")
fmt.sbprintf(&b, "at before, restore your backup with:\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "> envr restore ~/<path to repository>/.env\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "Usage:\n")
fmt.sbprintf(&b, " envr [command]\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "Available Commands:\n")
All your data is stored in ~/data.age
Getting started is easy:
1. Create your configuration file and set up encrypted storage:
> envr init
2. Scan for existing .env files:
> envr scan
Select the files you want to back up from the interactive list.
3. Verify that it worked:
> envr list
4. After changing any of your .env files, update the backup with:
> envr sync
5. If you lose a repository, after re-cloning the repo into the same path it was
at before, restore your backup with:
> envr restore ~/<path to repository>/.env
Usage:
envr [command]
Available Commands:
`,
flush = false,
)
for c in COMMANDS {
name_start := len(b.buf)
fmt.sbprintf(&b, "%s", c.name)
name_start := len(c.name)
fmt.wprintf(w, "%s", c.name, flush = false)
for a in c.aliases {
fmt.sbprintf(&b, ", %s", a)
fmt.wprintf(w, ", %s", a, flush = false)
name_start += len(a) + 2
}
name_len := len(b.buf) - name_start
padding := 20 - name_len
padding := 20 - name_start
if padding > 0 {
for _ in 0..<padding {
strings.write_byte(&b, ' ')
for _ in 0 ..< padding {
io.write_byte(w, ' ')
}
}
fmt.sbprintf(&b, " %s\n", c.short)
fmt.wprintf(w, " %s\n", c.short, flush = false)
}
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "Flags:\n")
fmt.sbprintf(&b, " -h, --help help for envr\n")
fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "Use \"envr [command] --help\" for more information about a command.\n")
fmt.wprintf(
w,
`
Flags:
-h, --help help for envr
s := strings.clone(strings.to_string(b))
strings.builder_destroy(&b)
return s
Use "envr [command] --help" for more information about a command.
`,
flush = false,
)
}
// TODO: Look at usages,might want to pass a writer
print_usage :: proc() {
fmt.print(usage_text())
bw: bufio.Writer
bufio.writer_init(&bw, io.to_writer(os.to_writer(os.stdout)), mem.DEFAULT_PAGE_SIZE)
defer bufio.writer_destroy(&bw)
defer bufio.writer_flush(&bw)
write_usage(bufio.writer_to_writer(&bw))
}

View File

@@ -1,3 +1,5 @@
#+feature dynamic-literals
package main
import "core:fmt"
import "core:strings"
@@ -6,27 +8,33 @@ import "core:testing"
@(test)
test_usage_text_contains_all_commands :: proc(t: ^testing.T) {
b: strings.Builder
text := usage_text()
strings.builder_init(&b)
defer strings.builder_destroy(&b)
write_usage(strings.to_writer(&b))
text := strings.to_string(b)
for c in COMMANDS {
testing.expect(
t,
strings.contains(text, c.name),
fmt.tprintf("usage missing command %q", c.name),
fmt.aprintf("usage_text missing command %q", c.name),
)
for a in c.aliases {
testing.expect(t, strings.contains(text, a), fmt.tprintf("usage missing alias %q", a))
testing.expect(
t,
strings.contains(text, a),
fmt.aprintf("usage_text missing alias %q", a),
)
}
}
}
@(test)
test_usage_text_contains_steps :: proc(t: ^testing.T) {
b: strings.Builder
text := usage_text()
strings.builder_init(&b)
defer strings.builder_destroy(&b)
write_usage(strings.to_writer(&b))
text := strings.to_string(b)
testing.expect(t, strings.contains(text, "1."), "missing step 1")
testing.expect(t, strings.contains(text, "2."), "missing step 2")
testing.expect(t, strings.contains(text, "3."), "missing step 3")
@@ -34,55 +42,51 @@ test_usage_text_contains_steps :: proc(t: ^testing.T) {
testing.expect(t, strings.contains(text, "5."), "missing step 5")
testing.expect(t, strings.contains(text, "> envr sync\n"), "step 4 missing 'envr sync'")
testing.expect(t, strings.contains(text, "> envr restore"), "step 5 missing 'envr restore'")
testing.expect(
t,
strings.contains(text, "> envr restore"),
"step 5 missing 'envr restore'",
)
}
@(test)
test_usage_text_contains_flags_and_help_hint :: proc(t: ^testing.T) {
b: strings.Builder
text := usage_text()
strings.builder_init(&b)
defer strings.builder_destroy(&b)
write_usage(strings.to_writer(&b))
text := strings.to_string(b)
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
testing.expect(t, strings.contains(text, "--help"), "missing --help flag")
testing.expect(t, strings.contains(text, "Use \"envr [command] --help\""), "missing help hint")
testing.expect(
t,
strings.contains(text, "Use \"envr [command] --help\""),
"missing help hint",
)
}
@(test)
test_command_help_backup :: proc(t: ^testing.T) {
b: strings.Builder
text, ok := command_help_text("backup")
testing.expect(t, ok, "command_help_text(\"backup\") returned false")
strings.builder_init(&b)
defer strings.builder_destroy(&b)
ok := write_command_help("backup", strings.to_writer(&b))
testing.expect(t, ok, "write_command_help(\"backup\") returned false")
text := strings.to_string(b)
testing.expect(t, strings.contains(text, "Usage:"), "missing Usage line")
testing.expect(t, strings.contains(text, "envr backup <path>"), "missing usage pattern")
testing.expect(
t,
strings.contains(text, "envr backup <path>"),
"missing usage pattern",
)
testing.expect(
t,
strings.contains(text, "Aliases:"),
"missing Aliases section",
)
testing.expect(t, strings.contains(text, "Aliases:"), "missing Aliases section")
testing.expect(t, strings.contains(text, "add"), "missing 'add' alias")
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
testing.expect(t, strings.contains(text, "--help"), "missing --help in flags")
testing.expect(
t,
strings.contains(text, "--help"),
"missing --help in flags",
)
}
@(test)
test_command_help_add_alias :: proc(t: ^testing.T) {
b: strings.Builder
text, ok := command_help_text("add")
testing.expect(t, ok, "command_help_text(\"add\") returned false")
strings.builder_init(&b)
defer strings.builder_destroy(&b)
ok := write_command_help("add", strings.to_writer(&b))
testing.expect(t, ok, "write_command_help(\"add\") returned false")
text := strings.to_string(b)
testing.expect(
t,
strings.contains(text, "envr backup <path>"),
"'add' alias should resolve to backup usage",
@@ -93,34 +97,43 @@ test_command_help_add_alias :: proc(t: ^testing.T) {
@(test)
test_command_help_init_no_aliases :: proc(t: ^testing.T) {
b: strings.Builder
text, ok := command_help_text("init")
testing.expect(t, ok, "command_help_text(\"init\") returned false")
strings.builder_init(&b)
defer strings.builder_destroy(&b)
ok := write_command_help("init", strings.to_writer(&b))
testing.expect(t, ok, "write_command_help(\"init\") returned false")
text := strings.to_string(b)
testing.expect(t, strings.contains(text, "Usage:"), "missing Usage line")
testing.expect(t, !strings.contains(text, "Aliases:"), "init should not have Aliases section")
testing.expect(
t,
!strings.contains(text, "Aliases:"),
"init should not have Aliases section",
)
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
testing.expect(t, strings.contains(text, "help for init"), "missing 'help for init'")
testing.expect(
t,
strings.contains(text, "help for init"),
"missing 'help for init'",
)
}
@(test)
test_command_help_unknown :: proc(t: ^testing.T) {
b: strings.Builder
text, ok := command_help_text("nonexistent")
testing.expect(t, !ok, "command_help_text(\"nonexistent\") should return false")
strings.builder_init(&b)
defer strings.builder_destroy(&b)
ok := write_command_help("nonexistent", strings.to_writer(&b))
testing.expect(t, !ok, "write_command_help(\"nonexistent\") should return false")
text := strings.to_string(b)
testing.expect(t, len(text) == 0, "text should be empty for unknown command")
}
@(test)
test_command_help_version :: proc(t: ^testing.T) {
b: strings.Builder
text, ok := command_help_text("version")
testing.expect(t, ok, "command_help_text(\"version\") returned false")
strings.builder_init(&b)
defer strings.builder_destroy(&b)
ok := write_command_help("version", strings.to_writer(&b))
testing.expect(t, ok, "write_command_help(\"version\") returned false")
text := strings.to_string(b)
testing.expect(t, strings.contains(text, "Usage:"), "missing Usage line")
testing.expect(
t,
!strings.contains(text, "Aliases:"),
@@ -128,3 +141,51 @@ test_command_help_version :: proc(t: ^testing.T) {
)
}
@(test)
test_has_flag_bool_set :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
bool_set = map[string]bool{"force" = true},
}
defer delete(cmd.bool_set)
testing.expect(t, has_flag(&cmd, "force"), "should find flag in bool_set")
testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag")
}
@(test)
test_has_flag_value_map :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
flags = map[string]string{"output" = "/tmp/out"},
}
defer delete(cmd.flags)
testing.expect(t, has_flag(&cmd, "output"), "should find flag in flags map")
testing.expect(t, !has_flag(&cmd, "force"), "should not find missing flag")
}
@(test)
test_has_flag_both_maps :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
flags = map[string]string{"output" = "/tmp/out"},
bool_set = map[string]bool{"force" = true},
}
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, has_flag(&cmd, "output"), "should find in flags")
testing.expect(t, has_flag(&cmd, "force"), "should find in bool_set")
testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag")
}
@(test)
test_has_flag_empty_command :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
}
testing.expect(t, !has_flag(&cmd, "anything"), "empty command should have no flags")
}

View File

@@ -1,56 +0,0 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"strings"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
// backupCmd represents the backup command
var backupCmd = &cobra.Command{
Use: "backup <path>",
Short: "Import a .env file into envr",
Aliases: []string{"add"},
Args: cobra.ExactArgs(1),
// Long: `Long desc`
RunE: func(cmd *cobra.Command, args []string) error {
path := args[0]
if len(strings.TrimSpace(path)) == 0 {
return fmt.Errorf("No path provided")
}
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close()
record := app.NewEnvFile(path)
if err := db.Insert(record); err != nil {
return err
} else {
fmt.Printf("Saved %s into the database", path)
return nil
}
}
},
}
func init() {
rootCmd.AddCommand(backupCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// backupCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// backupCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

View File

@@ -1,109 +0,0 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var checkCmd = &cobra.Command{
Use: "check [path]",
Short: "check if files in the current directory are backed up",
// TODO: Long description for new check command
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Accept an optional path arg, default to current working directory
var checkPath string
if len(args) > 0 {
checkPath = args[0]
} else {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current working directory: %w", err)
}
checkPath = cwd
}
// Get absolute path
absPath, err := filepath.Abs(checkPath)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
// Open database
db, err := app.Open()
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer db.Close()
// Check if the path is a file or directory
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("failed to stat path: %w", err)
}
var filesInPath []string
if info.IsDir() {
// Find .env files in the specified directory
if err := db.CanScan(); err != nil {
return err
}
// Scan only the specified path for .env files
filesInPath, err = db.Scan([]string{absPath})
if err != nil {
return fmt.Errorf("failed to scan path for env files: %w", err)
}
} else {
// Path is a file, just check this specific file
filesInPath = []string{absPath}
}
// Get all backed up files from the database
envFiles, err := db.List()
if err != nil {
return fmt.Errorf("failed to list files from database: %w", err)
}
// Check which files are not backed up
var notBackedUp []string
for _, file := range filesInPath {
isBackedUp := false
for _, envFile := range envFiles {
if envFile.Path == file {
isBackedUp = true
break
}
}
if !isBackedUp {
notBackedUp = append(notBackedUp, file)
}
}
// Display results
if len(notBackedUp) == 0 {
if len(filesInPath) == 0 {
fmt.Println("No .env files found in the specified directory.")
} else {
fmt.Println("✓ All .env files in the directory are backed up.")
}
} else {
fmt.Printf("Found %d .env file(s) that are not backed up:\n", len(notBackedUp))
for _, file := range notBackedUp {
fmt.Printf(" %s\n", file)
}
fmt.Println("\nRun 'envr sync' to back up these files.")
}
return nil
},
}
func init() {
rootCmd.AddCommand(checkCmd)
}

View File

@@ -1,51 +0,0 @@
package cmd
import (
"os"
"github.com/olekukonko/tablewriter"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var depsCmd = &cobra.Command{
Use: "deps",
Short: "Check for missing binaries",
Long: `envr relies on external binaries for certain functionality.
The check command reports on which binaries are available and which are not.`,
RunE: func(cmd *cobra.Command, args []string) error {
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close()
features := db.Features()
table := tablewriter.NewWriter(os.Stdout)
table.Header([]string{"Feature", "Status"})
// Check Git
if features&app.Git == 1 {
table.Append([]string{"Git", "✓ Available"})
} else {
table.Append([]string{"Git", "✗ Missing"})
}
// Check fd
if features&app.Fd == app.Fd {
table.Append([]string{"fd", "✓ Available"})
} else {
table.Append([]string{"fd", "✗ Missing"})
}
table.Render()
return nil
}
},
}
func init() {
rootCmd.AddCommand(depsCmd)
}

View File

@@ -1,55 +0,0 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/spf13/cobra"
)
var editConfigCmd = &cobra.Command{
Use: "edit-config",
Short: "Edit your config with your default editor",
// Long: ``,
Run: func(cmd *cobra.Command, args []string) {
editor := os.Getenv("EDITOR")
if editor == "" {
fmt.Println("Error: $EDITOR environment variable is not set")
return
}
homeDir, err := os.UserHomeDir()
if err != nil {
fmt.Printf("Error getting home directory: %v\n", err)
return
}
configPath := filepath.Join(homeDir, ".envr", "config.json")
// Check if config file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
fmt.Printf("Config file does not exist at %s. Run 'envr init' first.\n", configPath)
return
}
// Execute the editor
execCmd := exec.Command(editor, configPath)
execCmd.Stdin = os.Stdin
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
if err := execCmd.Run(); err != nil {
fmt.Printf("Error running editor: %v\n", err)
return
}
},
}
func init() {
rootCmd.AddCommand(editConfigCmd)
}

View File

@@ -1,96 +0,0 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var initCmd = &cobra.Command{
Use: "init",
Short: "Set up envr",
Long: `The init command generates your initial config and saves it to
~/.envr/config in JSON format.
During setup, you will be prompted to select one or more ssh keys with which to
encrypt your databse. **Make 100% sure** that you have **a remote copy** of this
key somewhere, otherwise your data could be lost forever.`,
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
config, _ := app.LoadConfig()
if config == nil || force {
keys, err := selectSSHKeys()
if err != nil {
return fmt.Errorf("Error selecting SSH keys: %v", err)
}
if len(keys) == 0 {
return fmt.Errorf("No SSH keys selected - Config not created")
}
cfg := app.NewConfig(keys)
if err := cfg.Save(); err != nil {
return err
}
fmt.Printf("Config initialized with %d SSH key(s). You are ready to use envr.\n", len(keys))
return nil
} else {
return fmt.Errorf(`You have already initialized envr.
Run again with the --force flag if you want to reinitialize.
`)
}
},
}
func init() {
initCmd.Flags().BoolP("force", "f", false, "Overwrite an existing config")
rootCmd.AddCommand(initCmd)
}
func selectSSHKeys() ([]string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
// TODO: Support reading from ssh-agent
sshDir := filepath.Join(homeDir, ".ssh")
entries, err := os.ReadDir(sshDir)
if err != nil {
return nil, fmt.Errorf("could not read ~/.ssh directory: %w", err)
}
var privateKeys []string
for _, entry := range entries {
name := entry.Name()
if !entry.IsDir() && !strings.HasSuffix(name, ".pub") &&
!strings.Contains(name, "known_hosts") && !strings.Contains(name, "config") {
privateKeys = append(privateKeys, filepath.Join(sshDir, name))
}
}
if len(privateKeys) == 0 {
return nil, fmt.Errorf("no SSH private keys found in ~/.ssh")
}
var selected []string
prompt := &survey.MultiSelect{
Message: "Select SSH private keys:",
Options: privateKeys,
}
err = survey.AskOne(prompt, &selected)
if err != nil {
return nil, err
}
return selected, nil
}

View File

@@ -1,69 +0,0 @@
package cmd
import (
"encoding/json"
"os"
"path/filepath"
"github.com/mattn/go-isatty"
"github.com/olekukonko/tablewriter"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
type listEntry struct {
Directory string `json:"directory"`
Path string `json:"path"`
}
var listCmd = &cobra.Command{
Use: "list",
Short: "View your tracked files",
RunE: func(cmd *cobra.Command, args []string) error {
db, err := app.Open()
if err != nil {
return err
}
defer db.Close()
rows, err := db.List()
if err != nil {
return err
}
if isatty.IsTerminal(os.Stdout.Fd()) {
table := tablewriter.NewWriter(os.Stdout)
table.Header([]string{"Directory", "Path"})
for _, row := range rows {
path, err := filepath.Rel(row.Dir, row.Path)
if err != nil {
return err
}
table.Append([]string{row.Dir + "/", path})
}
table.Render()
} else {
var entries []listEntry
for _, row := range rows {
path, err := filepath.Rel(row.Dir, row.Path)
if err != nil {
return err
}
entries = append(entries, listEntry{
Directory: row.Dir + "/",
Path: path,
})
}
encoder := json.NewEncoder(os.Stdout)
return encoder.Encode(entries)
}
return nil
},
}
func init() {
rootCmd.AddCommand(listCmd)
}

View File

@@ -1,26 +0,0 @@
package cmd
import (
_ "embed"
"fmt"
"github.com/spf13/cobra"
)
//go:embed mod.nu
var completion string
// nushellCompletionCmd represents the nushellCompletion command
var nushellCompletionCmd = &cobra.Command{
Use: "nushell-completion",
Short: "Generate custom completions for nushell",
Long: `At time of writing, cobra does not natively support nushell,
so a custom command had to be written`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println(completion)
},
}
func init() {
rootCmd.AddCommand(nushellCompletionCmd)
}

View File

@@ -1,51 +0,0 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"strings"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var removeCmd = &cobra.Command{
Use: "remove",
Short: "Remove a .env file from your database",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := args[0]
if len(strings.TrimSpace(path)) == 0 {
return fmt.Errorf("No path provided")
}
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close()
if err := db.Delete(path); err != nil {
return err
} else {
fmt.Printf("Removed %s from the database", path)
return nil
}
}
},
}
func init() {
rootCmd.AddCommand(removeCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// removeCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// removeCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

View File

@@ -1,60 +0,0 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"strings"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
// restoreCmd represents the restore command
var restoreCmd = &cobra.Command{
Use: "restore",
Short: "Install a .env file from the database into your file system",
// Long: ``,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := args[0]
if len(strings.TrimSpace(path)) == 0 {
return fmt.Errorf("No path provided")
}
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close()
record, err := db.Fetch(path)
if err != nil {
return err
} else {
err := record.Restore()
if err != nil {
return err
} else {
return nil
}
}
}
},
}
func init() {
rootCmd.AddCommand(restoreCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// restoreCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// restoreCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

View File

@@ -1,66 +0,0 @@
package cmd
import (
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "envr",
Short: "Manage your .env files.",
Long: `envr keeps your .env synced to a local, age encrypted database.
Is a safe and eay way to gather all your .env files in one place where they can
easily be backed by another tool such as restic or git.
All your data is stored in ~/data.age
Getting started is easy:
1. Create your configuration file and set up encrypted storage:
> envr init
2. Scan for existing .env files:
> envr scan
Select the files you want to back up from the interactive list.
3. Verify that it worked:
> envr list
4. After changing any of your .env files, update the backup with:
> envr sync
5. If you lose a repository, after re-cloning the repo into the same path it was
at before, restore your backup with:
> envr restore ~/&lt;path to repository&gt;/.env`,
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.envr.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
// rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
// Expose the root command for our generators.
func Root() *cobra.Command { return rootCmd }

View File

@@ -1,104 +0,0 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"github.com/AlecAivazis/survey/v2"
"github.com/mattn/go-isatty"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var scanCmd = &cobra.Command{
Use: "scan",
Short: "Find and select .env files for backup",
RunE: func(cmd *cobra.Command, args []string) error {
db, err := app.Open()
if err != nil {
return err
}
if db == nil {
return fmt.Errorf("No db was loaded")
}
if err := db.CanScan(); err != nil {
return err
}
files, err := db.Scan(nil)
if err != nil {
return err
}
if len(files) == 0 {
return fmt.Errorf("No .env files found to add.")
}
if isatty.IsTerminal(os.Stdout.Fd()) {
selectedFiles, err := selectEnvFiles(files)
if err != nil {
return err
}
// Insert selected files into database
var addedCount int
for _, file := range selectedFiles {
envFile := app.NewEnvFile(file)
err := db.Insert(envFile)
if err != nil {
fmt.Printf("Error adding %s: %v\n", file, err)
} else {
addedCount++
}
}
// Close database with write mode to persist changes
if addedCount > 0 {
err = db.Close()
if err != nil {
return fmt.Errorf("Error saving changes: %v\n", err)
} else {
fmt.Printf("Successfully added %d file(s) to backup.\n", addedCount)
return nil
}
} else {
err = db.Close()
if err != nil {
return fmt.Errorf("Error closing database: %v\n", err)
}
fmt.Println("No files were added.")
return nil
}
} else {
output, err := json.Marshal(files)
if err != nil {
return fmt.Errorf("Error marshaling files to JSON: %v", err)
}
fmt.Println(string(output))
return nil
}
},
}
func init() {
rootCmd.AddCommand(scanCmd)
}
func selectEnvFiles(files []string) ([]string, error) {
var selectedFiles []string
prompt := &survey.MultiSelect{
Message: "Select .env files to backup:",
Options: files,
}
err := survey.AskOne(prompt, &selectedFiles)
if err != nil {
return nil, err
}
return selectedFiles, nil
}

View File

@@ -1,101 +0,0 @@
package cmd
import (
"encoding/json"
"os"
"github.com/mattn/go-isatty"
"github.com/olekukonko/tablewriter"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var syncCmd = &cobra.Command{
Use: "sync",
Short: "Update or restore your env backups",
RunE: func(cmd *cobra.Command, args []string) error {
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close()
files, err := db.List()
if err != nil {
return err
} else {
type syncResult struct {
Path string `json:"path"`
Status string `json:"status"`
}
var results []syncResult
for _, file := range files {
// Syncronize the filesystem with the database.
oldPath := file.Path
changed, err := db.Sync(&file)
var status string
switch changed {
case app.BackedUp:
status = "Backed Up"
if err := db.Insert(file); err != nil {
return err
}
case app.Restored:
fallthrough
case app.RestoredAndDirUpdated:
status = "Restored"
case app.Error:
if err == nil {
panic("err cannot be nil when Sync returns Error")
}
status = err.Error()
case app.Noop:
status = "OK"
case app.DirUpdated:
status = "Moved"
default:
panic("Unknown result")
}
if changed&app.DirUpdated == app.DirUpdated {
if err := db.Delete(oldPath); err != nil {
return err
}
}
if db.UpdateRequired(changed) {
if err := db.Insert(file); err != nil {
return err
}
}
results = append(results, syncResult{
Path: file.Path,
Status: status,
})
}
if isatty.IsTerminal(os.Stdout.Fd()) {
table := tablewriter.NewWriter(os.Stdout)
table.Header([]string{"File", "Status"})
for _, result := range results {
table.Append([]string{result.Path, result.Status})
}
table.Render()
} else {
encoder := json.NewEncoder(os.Stdout)
return encoder.Encode(results)
}
return nil
}
}
},
}
func init() {
rootCmd.AddCommand(syncCmd)
}

View File

@@ -1,35 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var (
version = "dev"
commit = "none"
date = "unknown"
)
var long bool
// versionCmd represents the version command
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show envr's version",
Run: func(cmd *cobra.Command, args []string) {
if long {
fmt.Printf("envr version %s\n", version)
fmt.Printf("commit: %s\n", commit)
fmt.Printf("built: %s\n", date)
} else {
fmt.Printf("%s\n", version)
}
},
}
func init() {
versionCmd.Flags().BoolVarP(&long, "long", "l", false, "Show all version information")
rootCmd.AddCommand(versionCmd)
}

View File

@@ -3,77 +3,81 @@ package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
cmd_check :: proc(cmd: ^Command) {
check_path: string
if len(cmd.args) > 0 {
check_path = cmd.args[0]
} else {
cwd, cwd_err := os.get_working_directory(context.allocator)
if cwd_err != nil {
fmt.printf("Error getting current directory: %v\n", cwd_err)
return
}
check_path = cwd
}
feats := check_features()
abs_path: string
if filepath.is_abs(check_path) {
abs_path = check_path
} else {
resolved, abs_err := filepath.abs(check_path)
if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err)
return
}
abs_path = resolved
}
check_path: string
if len(cmd.args) > 0 {
check_path = cmd.args[0]
} else {
cwd, cwd_err := os.get_working_directory(context.allocator)
if cwd_err != nil {
fmt.printf("Error getting current directory: %v\n", cwd_err)
return
}
check_path = cwd
}
db, db_ok := db_open()
if !db_ok {
return
}
defer db_close(&db)
abs_path: string
if filepath.is_abs(check_path) {
abs_path = check_path
} else {
resolved, abs_err := filepath.abs(check_path)
if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err)
return
}
abs_path = resolved
}
is_dir := os.is_directory(abs_path)
db, db_ok := db_open()
if !db_ok {
return
}
defer db_close(&db)
files_in_path: [dynamic]string
is_dir := os.is_directory(abs_path)
if is_dir {
if !can_scan() {
fmt.println("Error: please install fd to use the check command (https://github.com/sharkdp/fd)")
return
}
files_in_path: [dynamic]string
scanned, scan_ok := scan_path(abs_path, db.cfg)
if !scan_ok {
fmt.println("Error scanning directory for .env files")
return
}
files_in_path = scanned
} else {
append(&files_in_path, abs_path)
}
if is_dir {
if cant_scan(feats) {
fmt.println(
"Error: please install fd to use the check command (https://github.com/sharkdp/fd)",
)
return
}
db_files, list_ok := db_list(&db)
if !list_ok {
return
}
scanned, scan_ok := scan_path(abs_path, db.cfg)
if !scan_ok {
fmt.println("Error scanning directory for .env files")
return
}
files_in_path = scanned
} else {
append(&files_in_path, abs_path)
}
not_backed := find_unbacked(files_in_path[:], db_files[:])
db_files, list_ok := db_list(&db)
if !list_ok {
return
}
if len(not_backed) == 0 {
if len(files_in_path) == 0 {
fmt.println("No .env files found in the specified directory.")
} else {
fmt.println("✓ All .env files in the directory are backed up.")
}
} else {
fmt.printf("Found %d .env file(s) that are not backed up:\n", len(not_backed))
for file in not_backed {
fmt.printf(" %s\n", file)
}
fmt.println("\nRun 'envr sync' to back up these files.")
}
not_backed := find_unbacked(files_in_path[:], db_files[:])
if len(not_backed) == 0 {
if len(files_in_path) == 0 {
fmt.println("No .env files found in the specified directory.")
} else {
fmt.println("✓ All .env files in the directory are backed up.")
}
} else {
fmt.printf("Found %d .env file(s) that are not backed up:\n", len(not_backed))
for file in not_backed {
fmt.printf(" %s\n", file)
}
fmt.println("\nRun 'envr sync' to back up these files.")
}
}

View File

@@ -5,39 +5,44 @@ import "core:testing"
@(test)
test_find_unbacked_finds_missing :: proc(t: ^testing.T) {
local := []string{"/a/.env", "/b/.env", "/c/.env"}
db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}}
local := []string{"/a/.env", "/b/.env", "/c/.env"}
db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}}
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 1, fmt.aprintf("expected 1 unbacked, got %d", len(result)))
if len(result) > 0 {
testing.expect(t, result[0] == "/c/.env", fmt.aprintf("expected /c/.env, got %s", result[0]))
}
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 1, fmt.tprintf("expected 1 unbacked, got %d", len(result)))
if len(result) > 0 {
testing.expect(
t,
result[0] == "/c/.env",
fmt.tprintf("expected /c/.env, got %s", result[0]),
)
}
}
@(test)
test_find_unbacked_all_backed :: proc(t: ^testing.T) {
local := []string{"/a/.env", "/b/.env"}
db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}}
local := []string{"/a/.env", "/b/.env"}
db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}}
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 0, fmt.aprintf("expected 0 unbacked, got %d", len(result)))
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result)))
}
@(test)
test_find_unbacked_no_local :: proc(t: ^testing.T) {
local: []string
db := []EnvFile{{Path = "/a/.env"}}
local: []string
db := []EnvFile{{Path = "/a/.env"}}
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 0, fmt.aprintf("expected 0 unbacked, got %d", len(result)))
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result)))
}
@(test)
test_find_unbacked_none_backed :: proc(t: ^testing.T) {
local := []string{"/a/.env", "/b/.env"}
db: []EnvFile
local := []string{"/a/.env", "/b/.env"}
db: []EnvFile
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 2, fmt.aprintf("expected 2 unbacked, got %d", len(result)))
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 2, fmt.tprintf("expected 2 unbacked, got %d", len(result)))
}

View File

@@ -1,7 +1,5 @@
package main
import "core:fmt"
cmd_deps :: proc(cmd: ^Command) {
feats := check_features()
@@ -20,11 +18,6 @@ cmd_deps :: proc(cmd: ^Command) {
append(&rows, []string{"fd", "\u2717 Missing"})
}
if .Age in feats {
append(&rows, []string{"age", "\u2713 Available"})
} else {
append(&rows, []string{"age", "\u2717 Missing"})
}
render_table(headers, rows[:])
}

View File

@@ -3,47 +3,47 @@ package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
cmd_edit_config :: proc(cmd: ^Command) {
editor := os.get_env("EDITOR", context.allocator)
if len(editor) == 0 {
fmt.println("Error: $EDITOR environment variable is not set")
return
}
editor := os.get_env("EDITOR", context.allocator)
if len(editor) == 0 {
fmt.println("Error: $EDITOR environment variable is not set")
return
}
config_path, join_err := filepath.join([]string{envr_dir(), "config.json"})
if join_err != nil {
fmt.printf("Error building config path: %v\n", join_err)
return
}
config_path, join_err := filepath.join([]string{envr_dir(), "config.json"})
if join_err != nil {
fmt.printf("Error building config path: %v\n", join_err)
return
}
_, stat_err := os.stat(config_path, context.allocator)
if stat_err != nil {
fmt.printf("Config file does not exist at %s. Run 'envr init' first.\n", config_path)
return
}
_, stat_err := os.stat(config_path, context.allocator)
if stat_err != nil {
fmt.printf("Config file does not exist at %s. Run 'envr init' first.\n", config_path)
return
}
args := []string{editor, config_path}
desc := os.Process_Desc{
command = args,
stdin = os.stdin,
stdout = os.stdout,
stderr = os.stderr,
}
args := []string{editor, config_path}
desc := os.Process_Desc {
command = args,
stdin = os.stdin,
stdout = os.stdout,
stderr = os.stderr,
}
p, start_err := os.process_start(desc)
if start_err != nil {
fmt.printf("Error running editor: %v\n", start_err)
return
}
p, start_err := os.process_start(desc)
if start_err != nil {
fmt.printf("Error running editor: %v\n", start_err)
return
}
state, wait_err := os.process_wait(p)
if wait_err != nil {
fmt.printf("Error waiting for editor: %v\n", wait_err)
return
}
if state.exit_code != 0 {
os.exit(int(state.exit_code))
}
state, wait_err := os.process_wait(p)
if wait_err != nil {
fmt.printf("Error waiting for editor: %v\n", wait_err)
return
}
if state.exit_code != 0 {
os.exit(int(state.exit_code))
}
}

View File

@@ -18,7 +18,8 @@ cmd_init :: proc(cmd: ^Command) {
}
if len(keys) == 0 {
fmt.println("No SSH private keys found in ~/.ssh")
fmt.println("No ssh-ed25519 keys found in ~/.ssh")
fmt.println("Generate one with: ssh-keygen -t ed25519")
return
}

View File

@@ -2,56 +2,62 @@ package main
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:terminal"
ListEntry :: struct {
Directory: string `json:"directory"`,
Path: string `json:"path"`,
Directory: string `json:"directory"`,
Path: string `json:"path"`,
}
cmd_list :: proc(cmd: ^Command) {
db, db_ok := db_open()
if !db_ok {
return
}
defer db_close(&db)
db, db_ok := db_open()
if !db_ok {
return
}
defer db_close(&db)
rows, list_ok := db_list(&db)
if !list_ok {
return
}
defer delete(rows)
rows, list_ok := db_list(&db)
if !list_ok {
return
}
defer delete(rows)
if is_tty() {
headers := []string{"Directory", "Path"}
table_rows := make([dynamic][]string, 0, len(rows))
if terminal.is_terminal(os.stdout) {
headers := []string{"Directory", "Path"}
table_rows := make([dynamic][]string, 0, len(rows), context.temp_allocator)
for row in rows {
dir_str := strings.concatenate({row.Dir, "/"})
filename := filepath.base(row.Path)
row_slice := make([]string, 2)
row_slice[0] = dir_str
row_slice[1] = filename
append(&table_rows, row_slice)
}
for row in rows {
dir_str := strings.concatenate({row.Dir, "/"}, context.temp_allocator)
filename := filepath.base(row.Path)
row_slice := make([]string, 2)
row_slice[0] = dir_str
row_slice[1] = filename
append(&table_rows, row_slice)
}
render_table(headers, table_rows[:])
} else {
entries: [dynamic]ListEntry
for row in rows {
filename := filepath.base(row.Path)
append(&entries, ListEntry{
Directory = strings.concatenate({row.Dir, "/"}),
Path = filename,
})
}
render_table(headers, table_rows[:])
} else {
entries: [dynamic]ListEntry
for row in rows {
filename := filepath.base(row.Path)
append(
&entries,
ListEntry {
Directory = strings.concatenate({row.Dir, "/"}, context.temp_allocator),
Path = filename,
},
)
}
data, marshal_err := json.marshal(entries[:])
if marshal_err != nil {
fmt.printf("Error marshaling JSON: %v\n", marshal_err)
return
}
fmt.println(string(data))
}
data, marshal_err := json.marshal(entries[:])
if marshal_err != nil {
fmt.printf("Error marshaling JSON: %v\n", marshal_err)
return
}
fmt.println(string(data))
}
}

View File

@@ -5,22 +5,14 @@ import "core:testing"
@(test)
test_filepath_base_equals_rel :: proc(t: ^testing.T) {
cases := []string{
"/home/user/.env",
"/home/user/project/.envrc",
"/tmp/foo",
"/a/b/c/d.txt",
}
cases := []string{"/home/user/.env", "/home/user/project/.envrc", "/tmp/foo", "/a/b/c/d.txt"}
for path in cases {
dir := filepath.dir(path)
rel, rel_err := filepath.rel(dir, path)
rel, rel_err := filepath.rel(dir, path, context.temp_allocator)
testing.expect(t, rel_err == nil, "filepath.rel returned an error")
base := filepath.base(path)
testing.expect(
t,
rel == base,
"filepath.rel(dir, path) should equal filepath.base(path)",
)
testing.expect(t, rel == base, "filepath.rel(dir, path) should equal filepath.base(path)")
}
}

View File

@@ -0,0 +1,9 @@
package main
import "core:fmt"
COMPLETION_SCRIPT: string : string(#load("mod.nu"))
cmd_nushell_completion :: proc(cmd: ^Command) {
fmt.print(COMPLETION_SCRIPT)
}

View File

@@ -0,0 +1,36 @@
package main
import "core:fmt"
import "core:strings"
import "core:testing"
@(test)
test_nushell_completion_nonempty :: proc(t: ^testing.T) {
testing.expect(t, len(COMPLETION_SCRIPT) > 0, "completion script should not be empty")
}
@(test)
test_nushell_completion_contains_externs :: proc(t: ^testing.T) {
expected := []string{
"tracked-paths",
"untracked-paths",
"envr backup",
"envr check",
"envr edit-config",
"envr help",
"envr init",
"envr list",
"envr remove",
"envr restore",
"envr scan",
"envr sync",
"envr nushell-completion",
}
for ext in expected {
testing.expect(
t,
strings.contains(COMPLETION_SCRIPT, ext),
fmt.tprintf("expected script to contain %q", ext),
)
}
}

View File

@@ -2,9 +2,12 @@ package main
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:terminal"
cmd_scan :: proc(cmd: ^Command) {
if !can_scan() {
feats := check_features()
if cant_scan(feats) {
fmt.println(
"Error: please install fd to use the scan command (https://github.com/sharkdp/fd)",
)
@@ -48,7 +51,7 @@ cmd_scan :: proc(cmd: ^Command) {
return
}
if !is_tty() {
if !terminal.is_terminal(os.stdout) {
output, marshal_err := json.marshal(files[:])
if marshal_err != nil {
fmt.printf("Error marshaling files to JSON: %v\n", marshal_err)

View File

@@ -2,13 +2,16 @@ package main
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:strings"
import "core:terminal"
SyncEntry :: struct {
Path: string `json:"path"`,
Status: string `json:"status"`,
}
// TODO: Check for quiet failures.
cmd_sync :: proc(cmd: ^Command) {
db, db_ok := db_open()
if !db_ok {
@@ -31,28 +34,22 @@ cmd_sync :: proc(cmd: ^Command) {
result, err_msg := db_sync(&db, &file)
status: string
s := i32(result)
is_error := (s & i32(SyncResult.Error)) != 0
is_backed := (s & i32(SyncResult.BackedUp)) != 0
is_restored := (s & i32(SyncResult.Restored)) != 0
is_dir_updated := (s & i32(SyncResult.DirUpdated)) != 0
is_dir_updated := .DirUpdated in result
if is_error {
switch {
case .Error in result:
if len(err_msg) > 0 {
status = err_msg
} else {
status = "error"
}
} else if is_backed {
case .BackedUp in result:
status = "Backed Up"
if !db_insert(&db, file) {
return
}
} else if is_restored {
case .Restored in result:
status = "Restored"
} else if is_dir_updated && !is_restored {
case .DirUpdated in result:
status = "Moved"
} else {
case:
status = "OK"
}
@@ -72,7 +69,7 @@ cmd_sync :: proc(cmd: ^Command) {
append(&results, SyncEntry{Path = path_str, Status = status_str})
}
if is_tty() {
if terminal.is_terminal(os.stdout) {
headers := []string{"File", "Status"}
table_rows := make([dynamic][]string, 0, len(results))
@@ -93,3 +90,4 @@ cmd_sync :: proc(cmd: ^Command) {
fmt.println(string(data))
}
}

View File

@@ -2,7 +2,7 @@ package main
import "core:fmt"
VERSION :: "0.2.0"
VERSION :: #load("version.txt", string)
cmd_version :: proc(cmd: ^Command) {
if has_flag(cmd, "long") || has_flag(cmd, "l") {
@@ -11,3 +11,4 @@ cmd_version :: proc(cmd: ^Command) {
fmt.println(VERSION)
}
}

View File

@@ -13,17 +13,17 @@ SshKeyPair :: struct {
ScanConfig :: struct {
Matcher: string `json:"matcher"`,
Exclude: []string `json:"exclude"`,
Include: []string `json:"include"`,
Exclude: [dynamic]string `json:"exclude"`,
Include: [dynamic]string `json:"include"`,
}
Config :: struct {
Keys: []SshKeyPair `json:"keys"`,
Keys: [dynamic]SshKeyPair `json:"keys"`,
ScanConfig: ScanConfig `json:"scan"`,
}
load_config :: proc() -> (Config, bool) {
home, home_err := os.user_home_dir(context.allocator)
home, home_err := os.user_home_dir(context.temp_allocator)
if home_err != nil {
fmt.printf("Error getting home dir: %v\n", home_err)
return Config{}, false
@@ -49,15 +49,21 @@ load_config :: proc() -> (Config, bool) {
return cfg, true
}
delete_config :: proc(cfg: Config) {
delete(cfg.Keys)
delete(cfg.ScanConfig.Exclude)
delete(cfg.ScanConfig.Include)
}
envr_dir :: proc() -> string {
home, _ := os.user_home_dir(context.allocator)
dir, _ := filepath.join([]string{home, ".envr"})
return dir
}
data_age_path :: proc() -> string {
data_encrypted_path :: proc() -> string {
dir := envr_dir()
path, _ := filepath.join([]string{dir, "data.age"})
path, _ := filepath.join([]string{dir, "data.envr"})
return path
}
@@ -97,6 +103,9 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
}
full_path, _ := filepath.join([]string{ssh_dir, name})
if !is_ed25519_key(full_path) {
continue
}
append(&keys, full_path)
}
@@ -107,7 +116,8 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
new_config :: proc(private_key_paths: []string) -> Config {
keys := make([dynamic]SshKeyPair, 0, len(private_key_paths))
for priv in private_key_paths {
pub, _ := strings.concatenate([]string{priv, ".pub"})
// TODO: Is this bad?
pub, _ := strings.concatenate([]string{priv, ".pub"}, context.temp_allocator)
append(&keys, SshKeyPair{Private = priv, Public = pub})
}
@@ -122,11 +132,11 @@ new_config :: proc(private_key_paths: []string) -> Config {
scan_cfg := ScanConfig {
Matcher = "\\.env",
Exclude = exclude[:],
Include = include[:],
Exclude = exclude,
Include = include,
}
return Config{Keys = keys[:], ScanConfig = scan_cfg}
return Config{Keys = keys, ScanConfig = scan_cfg}
}
save_config :: proc(cfg: Config, force: bool = false) -> bool {

63
config_test.odin Normal file
View File

@@ -0,0 +1,63 @@
package main
import "core:testing"
@(test)
test_new_config_single_key :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519"}
cfg := new_config(paths)
defer delete_config(cfg)
testing.expect(t, len(cfg.Keys) == 1, "should have 1 key")
testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519", "Private path mismatch")
testing.expect(
t,
cfg.Keys[0].Public == "/home/user/.ssh/id_ed25519.pub",
"Public path mismatch",
)
}
@(test)
test_new_config_multiple_keys :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519", "/home/user/.ssh/id_rsa"}
cfg := new_config(paths)
defer delete_config(cfg)
testing.expect(t, len(cfg.Keys) == 2, "should have 2 keys")
testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519")
testing.expect(t, cfg.Keys[1].Private == "/home/user/.ssh/id_rsa")
}
@(test)
test_new_config_empty_keys :: proc(t: ^testing.T) {
paths: []string
cfg := new_config(paths)
defer delete_config(cfg)
testing.expect(t, len(cfg.Keys) == 0, "should have 0 keys")
}
@(test)
test_new_config_scan_defaults :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519"}
cfg := new_config(paths)
defer delete_config(cfg)
testing.expect(t, cfg.ScanConfig.Matcher == "\\.env", "matcher should be \\.env")
testing.expect(t, len(cfg.ScanConfig.Exclude) == 4, "should have 4 exclude patterns")
testing.expect(t, len(cfg.ScanConfig.Include) == 1, "should have 1 include path")
testing.expect(t, cfg.ScanConfig.Include[0] == "~", "include should be ~")
}
@(test)
test_new_config_exclude_patterns :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519"}
cfg := new_config(paths)
defer delete_config(cfg)
expected := []string{"*\\.envrc", "\\.local/", "node_modules", "vendor"}
for i in 0 ..< len(expected) {
testing.expect(t, cfg.ScanConfig.Exclude[i] == expected[i])
}
}

338
crypto.odin Normal file
View File

@@ -0,0 +1,338 @@
package main
import "core:fmt"
import "core:mem"
MAGIC :: "ENVR"
MAGIC_BYTES := [4]u8{u8('E'), u8('N'), u8('V'), u8('R')}
RECIPIENT_ENTRY_SIZE ::
CRYPTO_BOX_PUBLICKEY_BYTES +
CRYPTO_BOX_NONCE_BYTES +
CRYPTO_SECRETBOX_KEY_BYTES +
CRYPTO_BOX_MAC_BYTES
HEADER_SIZE :: 4 + CRYPTO_BOX_PUBLICKEY_BYTES + CRYPTO_SECRETBOX_NONCE_BYTES + 4
RecipientEntry :: struct {
PublicKey: [CRYPTO_BOX_PUBLICKEY_BYTES]u8,
Nonce: [CRYPTO_BOX_NONCE_BYTES]u8,
EncryptedKey: [CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES]u8,
}
sodium_initialized: bool
ensure_sodium :: proc() -> bool {
if sodium_initialized {
return true
}
rc := sodium_init()
if rc < 0 {
fmt.println("Error: libsodium initialization failed")
return false
}
sodium_initialized = true
return true
}
X25519Keypair :: struct {
Public: [CRYPTO_BOX_PUBLICKEY_BYTES]u8,
Private: [CRYPTO_BOX_SECRETKEY_BYTES]u8,
}
ssh_to_x25519 :: proc(keys: []SshKeyPair) -> (pairs: []X25519Keypair, ok: bool) {
if len(keys) == 0 {
return
}
pairs = make([]X25519Keypair, len(keys))
for i in 0 ..< len(keys) {
ssh_kp, parse_ok := parse_ssh_private_key(keys[i].Private)
if !parse_ok {
fmt.printf("Error: failed to parse SSH private key: %s\n", keys[i].Private)
delete(pairs)
return
}
ssh_pub, pub_ok := parse_ssh_public_key(keys[i].Public)
if !pub_ok {
fmt.printf("Error: failed to parse SSH public key: %s\n", keys[i].Public)
delete(pairs)
return
}
pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0])
if pk_rc != 0 {
fmt.println("Error: failed to convert ed25519 public key to curve25519")
delete(pairs)
return
}
ed25519_sk: [64]u8
for j in 0 ..< 32 {
ed25519_sk[j] = ssh_kp.Private[j]
}
for j in 0 ..< 32 {
ed25519_sk[32 + j] = ssh_kp.Public[j]
}
sk_rc := crypto_sign_ed25519_sk_to_curve25519(&pairs[i].Private[0], &ed25519_sk[0])
if sk_rc != 0 {
fmt.println("Error: failed to convert ed25519 private key to curve25519")
delete(pairs)
return
}
}
ok = true
return
}
encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: bool) {
if !ensure_sodium() {
return
}
x25519_pairs, pairs_ok := ssh_to_x25519(keys)
if !pairs_ok {
return
}
defer delete(x25519_pairs)
sym_key: [CRYPTO_SECRETBOX_KEY_BYTES]u8
randombytes_buf(&sym_key[0], CRYPTO_SECRETBOX_KEY_BYTES)
main_nonce: [CRYPTO_SECRETBOX_NONCE_BYTES]u8
randombytes_buf(&main_nonce[0], CRYPTO_SECRETBOX_NONCE_BYTES)
ct_len := len(plaintext) + CRYPTO_SECRETBOX_MAC_BYTES
secret_ct := make([]u8, ct_len)
pt_ptr: [^]u8
if len(plaintext) > 0 {
pt_ptr = &plaintext[0]
}
rc := crypto_secretbox_easy(
&secret_ct[0],
pt_ptr,
u64(len(plaintext)),
&main_nonce[0],
&sym_key[0],
)
if rc != 0 {
fmt.println("Error: symmetric encryption failed")
delete(secret_ct)
return
}
num_recipients := u32(len(x25519_pairs))
entries := make([]RecipientEntry, num_recipients)
for i in 0 ..< len(x25519_pairs) {
for j in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
entries[i].PublicKey[j] = x25519_pairs[i].Public[j]
}
randombytes_buf(&entries[i].Nonce[0], CRYPTO_BOX_NONCE_BYTES)
rc = crypto_box_easy(
&entries[i].EncryptedKey[0],
&sym_key[0],
CRYPTO_SECRETBOX_KEY_BYTES,
&entries[i].Nonce[0],
&x25519_pairs[i].Public[0],
&x25519_pairs[0].Private[0],
)
if rc != 0 {
fmt.printf("Error: failed to encrypt for recipient %d\n", i)
delete(entries)
delete(secret_ct)
return
}
}
total_len := HEADER_SIZE + int(num_recipients) * RECIPIENT_ENTRY_SIZE + ct_len
ciphertext = make([]u8, total_len)
pos := 0
mem.copy(&ciphertext[pos], &MAGIC_BYTES[0], 4)
pos += 4
mem.copy(&ciphertext[pos], &x25519_pairs[0].Public[0], CRYPTO_BOX_PUBLICKEY_BYTES)
pos += CRYPTO_BOX_PUBLICKEY_BYTES
mem.copy(&ciphertext[pos], &main_nonce[0], CRYPTO_SECRETBOX_NONCE_BYTES)
pos += CRYPTO_SECRETBOX_NONCE_BYTES
ciphertext[pos] = u8((num_recipients >> 24) & 0xff)
ciphertext[pos + 1] = u8((num_recipients >> 16) & 0xff)
ciphertext[pos + 2] = u8((num_recipients >> 8) & 0xff)
ciphertext[pos + 3] = u8(num_recipients & 0xff)
pos += 4
for i in 0 ..< int(num_recipients) {
mem.copy(&ciphertext[pos], &entries[i].PublicKey[0], CRYPTO_BOX_PUBLICKEY_BYTES)
pos += CRYPTO_BOX_PUBLICKEY_BYTES
mem.copy(&ciphertext[pos], &entries[i].Nonce[0], CRYPTO_BOX_NONCE_BYTES)
pos += CRYPTO_BOX_NONCE_BYTES
mem.copy(
&ciphertext[pos],
&entries[i].EncryptedKey[0],
CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES,
)
pos += CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES
}
mem.copy(&ciphertext[pos], &secret_ct[0], ct_len)
delete(entries)
delete(secret_ct)
ok = true
return
}
decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: bool) {
if !ensure_sodium() {
return
}
if len(ciphertext) < HEADER_SIZE {
fmt.println("Error: ciphertext too short (header)")
return
}
for i in 0 ..< 4 {
if ciphertext[i] != MAGIC_BYTES[i] {
fmt.println("Error: invalid magic bytes")
return
}
}
offset := 4
sender_pk: [CRYPTO_BOX_PUBLICKEY_BYTES]u8
for i in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
sender_pk[i] = ciphertext[offset + i]
}
offset += CRYPTO_BOX_PUBLICKEY_BYTES
main_nonce: [CRYPTO_SECRETBOX_NONCE_BYTES]u8
for i in 0 ..< CRYPTO_SECRETBOX_NONCE_BYTES {
main_nonce[i] = ciphertext[offset + i]
}
offset += CRYPTO_SECRETBOX_NONCE_BYTES
num_recipients :=
u32(ciphertext[offset]) << 24 |
u32(ciphertext[offset + 1]) << 16 |
u32(ciphertext[offset + 2]) << 8 |
u32(ciphertext[offset + 3])
offset += 4
recipients_end := offset + int(num_recipients) * RECIPIENT_ENTRY_SIZE
if recipients_end > len(ciphertext) {
fmt.println("Error: ciphertext too short (recipient data)")
return
}
enc_sym_key: [CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES]u8
enc_nonce: [CRYPTO_BOX_NONCE_BYTES]u8
enc_pub: [CRYPTO_BOX_PUBLICKEY_BYTES]u8
x25519_pairs, pairs_ok := ssh_to_x25519(keys)
if !pairs_ok {
return
}
defer delete(x25519_pairs)
found := false
matched_pi := 0
for pi in 0 ..< len(x25519_pairs) {
scan_offset := offset
for _ in 0 ..< int(num_recipients) {
for i in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
enc_pub[i] = ciphertext[scan_offset + i]
}
scan_offset += CRYPTO_BOX_PUBLICKEY_BYTES
match := true
for i in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
if enc_pub[i] != x25519_pairs[pi].Public[i] {
match = false
break
}
}
if !match {
scan_offset +=
CRYPTO_BOX_NONCE_BYTES + CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES
continue
}
for i in 0 ..< CRYPTO_BOX_NONCE_BYTES {
enc_nonce[i] = ciphertext[scan_offset + i]
}
scan_offset += CRYPTO_BOX_NONCE_BYTES
for i in 0 ..< CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES {
enc_sym_key[i] = ciphertext[scan_offset + i]
}
scan_offset += CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES
found = true
matched_pi = pi
break
}
if found {
break
}
}
if !found {
fmt.println("Error: no matching recipient found")
return
}
sym_key: [CRYPTO_SECRETBOX_KEY_BYTES]u8
rc := crypto_box_open_easy(
&sym_key[0],
&enc_sym_key[0],
CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES,
&enc_nonce[0],
&sender_pk[0],
&x25519_pairs[matched_pi].Private[0],
)
if rc != 0 {
fmt.println("Error: failed to decrypt symmetric key")
return
}
ct_data := ciphertext[recipients_end:]
pt_len := len(ct_data) - CRYPTO_SECRETBOX_MAC_BYTES
if pt_len < 0 {
fmt.println("Error: ciphertext too short (no encrypted data)")
return
}
plaintext = make([]u8, pt_len)
pt_ptr: [^]u8
if len(plaintext) > 0 {
pt_ptr = &plaintext[0]
}
rc = crypto_secretbox_open_easy(
pt_ptr,
&ct_data[0],
u64(len(ct_data)),
&main_nonce[0],
&sym_key[0],
)
if rc != 0 {
fmt.println("Error: symmetric decryption failed")
delete(plaintext)
return
}
ok = true
return
}

102
crypto_test.odin Normal file
View File

@@ -0,0 +1,102 @@
package main
import "core:fmt"
import "core:testing"
CRYPTO_TEST_KEY_DIR :: "/tmp/envr-test-keys"
make_test_key_pair :: proc(name: string) -> SshKeyPair {
priv := fmt.tprintf("%s/%s", CRYPTO_TEST_KEY_DIR, name)
pub := fmt.tprintf("%s/%s.pub", CRYPTO_TEST_KEY_DIR, name)
return SshKeyPair{Private = priv, Public = pub}
}
@(test)
test_encrypt_decrypt_roundtrip :: proc(t: ^testing.T) {
key := make_test_key_pair("test_ed25519")
original := []u8{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
encrypted, enc_ok := encrypt(original, []SshKeyPair{key})
testing.expect(t, enc_ok, "encryption should succeed")
testing.expect(t, len(encrypted) > 0, "ciphertext should not be empty")
defer delete(encrypted)
decrypted, dec_ok := decrypt(encrypted, []SshKeyPair{key})
testing.expect(t, dec_ok, "decryption should succeed")
defer delete(decrypted)
testing.expect(t, len(decrypted) == len(original), fmt.tprintf("expected %d bytes, got %d", len(original), len(decrypted)))
for i in 0 ..< len(original) {
testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at index %d", i))
}
}
@(test)
test_encrypt_decrypt_multi_recipient :: proc(t: ^testing.T) {
key1 := make_test_key_pair("test_ed25519")
key2 := make_test_key_pair("test_ed25519_second")
original := []u8{42, 43, 44, 45}
encrypted, enc_ok := encrypt(original, []SshKeyPair{key1, key2})
testing.expect(t, enc_ok, "encryption with 2 keys should succeed")
defer delete(encrypted)
decrypted1, dec1_ok := decrypt(encrypted, []SshKeyPair{key1})
testing.expect(t, dec1_ok, "decryption with key1 should succeed")
defer delete(decrypted1)
decrypted2, dec2_ok := decrypt(encrypted, []SshKeyPair{key2})
testing.expect(t, dec2_ok, "decryption with key2 should succeed")
defer delete(decrypted2)
for i in 0 ..< len(original) {
testing.expect(t, decrypted1[i] == original[i], fmt.tprintf("key1: byte mismatch at %d", i))
testing.expect(t, decrypted2[i] == original[i], fmt.tprintf("key2: byte mismatch at %d", i))
}
}
@(test)
test_decrypt_wrong_key_fails :: proc(t: ^testing.T) {
key1 := make_test_key_pair("test_ed25519")
key2 := make_test_key_pair("test_ed25519_second")
original := []u8{1, 2, 3}
encrypted, enc_ok := encrypt(original, []SshKeyPair{key1})
testing.expect(t, enc_ok, "encryption should succeed")
defer delete(encrypted)
_, dec_ok := decrypt(encrypted, []SshKeyPair{key2})
testing.expect(t, !dec_ok, "decryption with wrong key should fail")
}
@(test)
test_encrypt_empty_plaintext :: proc(t: ^testing.T) {
key := make_test_key_pair("test_ed25519")
original: []u8
encrypted, enc_ok := encrypt(original, []SshKeyPair{key})
testing.expect(t, enc_ok, "encryption of empty data should succeed")
defer delete(encrypted)
decrypted, dec_ok := decrypt(encrypted, []SshKeyPair{key})
testing.expect(t, dec_ok, "decryption should succeed")
defer delete(decrypted)
testing.expect(t, len(decrypted) == 0, "decrypted empty data should be empty")
}
@(test)
test_ciphertext_has_magic :: proc(t: ^testing.T) {
key := make_test_key_pair("test_ed25519")
original := []u8{1, 2, 3}
encrypted, enc_ok := encrypt(original, []SshKeyPair{key})
testing.expect(t, enc_ok, "encryption should succeed")
defer delete(encrypted)
testing.expect(t, len(encrypted) >= 4, "ciphertext should have at least 4 bytes")
testing.expect(t, encrypted[0] == u8('E'), "magic byte 0")
testing.expect(t, encrypted[1] == u8('N'), "magic byte 1")
testing.expect(t, encrypted[2] == u8('V'), "magic byte 2")
testing.expect(t, encrypted[3] == u8('R'), "magic byte 3")
}

931
db.odin

File diff suppressed because it is too large Load Diff

327
db_integration_test.odin Normal file
View File

@@ -0,0 +1,327 @@
package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:testing"
import "sqlite"
FIXTURES :: "/home/spencer/github.com/envr-zig/fixtures"
fixture_key :: proc() -> SshKeyPair {
priv, _ := strings.concatenate([]string{FIXTURES, "/insecure-test-key"}, context.allocator)
pub, _ := strings.concatenate([]string{FIXTURES, "/insecure-test-key.pub"}, context.allocator)
return SshKeyPair{Private = priv, Public = pub}
}
fixture_db_path :: proc() -> string {
p, _ := strings.concatenate([]string{FIXTURES, "/single-file.db"}, context.allocator)
return p
}
fixture_config :: proc() -> Config {
cfg := Config {
Keys = make([dynamic]SshKeyPair, 0, 1),
}
append(&cfg.Keys, fixture_key())
return cfg
}
@(test)
test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.Keys)
}
db_path := fixture_db_path()
sqlite_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err)
if read_err != nil {
return
}
defer delete(sqlite_data)
encrypted, enc_ok := encrypt(sqlite_data, cfg.Keys[:])
testing.expect(t, enc_ok, "encryption should succeed")
if !enc_ok {
return
}
defer delete(encrypted)
testing.expect(t, len(encrypted) >= HEADER_SIZE, "ciphertext should have header")
testing.expect(t, encrypted[0] == u8('E'), "magic byte 0")
testing.expect(t, encrypted[1] == u8('N'), "magic byte 1")
testing.expect(t, encrypted[2] == u8('V'), "magic byte 2")
testing.expect(t, encrypted[3] == u8('R'), "magic byte 3")
plaintext, dec_ok := decrypt(encrypted, cfg.Keys[:])
testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok {
return
}
defer delete(plaintext)
testing.expectf(
t,
len(plaintext) == len(sqlite_data),
"round-trip size mismatch: expected %d, got %d",
len(sqlite_data),
len(plaintext),
)
match := true
for i in 0 ..< len(sqlite_data) {
if plaintext[i] != sqlite_data[i] {
match = false
break
}
}
testing.expect(t, match, "decrypted data should match original")
}
@(test)
test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.Keys)
}
db_path := fixture_db_path()
sqlite_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err)
if read_err != nil {
return
}
defer delete(sqlite_data)
encrypted, enc_ok := encrypt(sqlite_data, cfg.Keys[:])
testing.expect(t, enc_ok, "encryption should succeed")
if !enc_ok {
return
}
defer delete(encrypted)
tmp_enc_path := fmt.tprintf("/tmp/envr-test-ewrd-%d.envr", os.get_pid())
write_err := os.write_entire_file(tmp_enc_path, encrypted)
testing.expectf(t, write_err == nil, "failed to write encrypted file: %v", write_err)
if write_err != nil {
return
}
defer os.remove(tmp_enc_path)
read_back, rb_err := os.read_entire_file_from_path(tmp_enc_path, context.allocator)
testing.expectf(t, rb_err == nil, "failed to read back encrypted file: %v", rb_err)
if rb_err != nil {
return
}
defer delete(read_back)
plaintext, dec_ok := decrypt(read_back, cfg.Keys[:])
testing.expect(t, dec_ok, "decryption after write/read should succeed")
if !dec_ok {
return
}
defer delete(plaintext)
testing.expect(t, len(plaintext) == len(sqlite_data), "size mismatch after file round-trip")
}
@(test)
test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.Keys)
}
db_path := fixture_db_path()
sqlite_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err)
if read_err != nil {
return
}
defer delete(sqlite_data)
encrypted, enc_ok := encrypt(sqlite_data, cfg.Keys[:])
testing.expect(t, enc_ok, "encryption should succeed")
if !enc_ok {
return
}
defer delete(encrypted)
plaintext, dec_ok := decrypt(encrypted, cfg.Keys[:])
testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok {
return
}
defer delete(plaintext)
tmp_db_path := fmt.tprintf("/tmp/envr-test-attach-%d.db", os.get_pid())
write_err := os.write_entire_file(tmp_db_path, plaintext)
testing.expectf(t, write_err == nil, "failed to write temp db: %v", write_err)
if write_err != nil {
return
}
defer os.remove(tmp_db_path)
mem_db: ^rawptr
rc := sqlite.db_open(":memory:", &mem_db)
testing.expectf(t, rc == sqlite.OK, "failed to open in-memory db")
if rc != sqlite.OK {
return
}
defer sqlite.db_close(mem_db)
create_sql := "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)"
rc = sqlite.db_exec(mem_db, string_to_cstring(create_sql), nil, nil, nil)
testing.expect(t, rc == sqlite.OK, "failed to create table")
attach_ok := db_attach_and_copy(mem_db, tmp_db_path)
testing.expect(t, attach_ok, "failed to attach and copy")
sql := "SELECT path FROM envr_env_files"
stmt: ^rawptr
rc = sqlite.prepare_v2(mem_db, string_to_cstring(sql), -1, &stmt, nil)
testing.expect(t, rc == sqlite.OK, "prepare failed")
if rc != sqlite.OK {
return
}
defer sqlite.finalize(stmt)
rc = sqlite.step(stmt)
testing.expect(t, rc == sqlite.ROW, "expected at least one row")
if rc == sqlite.ROW {
path := cstring_to_string(sqlite.column_text(stmt, 0))
testing.expect(t, len(path) > 0, "path should not be empty")
}
}
@(test)
test_full_db_cycle :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.Keys)
}
db_path := fixture_db_path()
original_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err)
if read_err != nil {
return
}
defer delete(original_data)
encrypted, enc_ok := encrypt(original_data, cfg.Keys[:])
testing.expect(t, enc_ok, "first encryption should succeed")
if !enc_ok {
return
}
defer delete(encrypted)
envr_dir_path := fmt.tprintf("/tmp/envr-test-cycle-%d/.envr", os.get_pid())
os.mkdir_all(envr_dir_path)
data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"})
write_err := os.write_entire_file(data_path, encrypted)
testing.expectf(t, write_err == nil, "failed to write data.envr: %v", write_err)
if write_err != nil {
return
}
read_back, rb_err := os.read_entire_file_from_path(data_path, context.allocator)
testing.expectf(t, rb_err == nil, "failed to read data.envr: %v", rb_err)
if rb_err != nil {
return
}
defer delete(read_back)
plaintext, dec_ok := decrypt(read_back, cfg.Keys[:])
testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok {
return
}
defer delete(plaintext)
encrypted2, enc2_ok := encrypt(plaintext, cfg.Keys[:])
testing.expect(t, enc2_ok, "re-encryption should succeed")
if !enc2_ok {
return
}
defer delete(encrypted2)
plaintext2, dec2_ok := decrypt(encrypted2, cfg.Keys[:])
testing.expect(t, dec2_ok, "second decryption should succeed")
if !dec2_ok {
return
}
defer delete(plaintext2)
testing.expect(
t,
len(plaintext2) == len(original_data),
fmt.tprintf(
"double round-trip size mismatch: expected %d, got %d",
len(original_data),
len(plaintext2),
),
)
os.remove(data_path)
os.remove(envr_dir_path)
home := filepath.dir(filepath.dir(envr_dir_path))
os.remove(home)
}
@(test)
test_ssh_key_parse_from_fixtures :: proc(t: ^testing.T) {
key := fixture_key()
priv_kp, priv_ok := parse_ssh_private_key(key.Private)
testing.expect(t, priv_ok, "should parse private key from fixtures")
if !priv_ok {
return
}
pub_key, pub_ok := parse_ssh_public_key(key.Public)
testing.expect(t, pub_ok, "should parse public key from fixtures")
if !pub_ok {
return
}
for i in 0 ..< 32 {
testing.expectf(t, priv_kp.Public[i] == pub_key[i], "public key mismatch at byte %d", i)
}
x25519_pairs, x_ok := ssh_to_x25519([]SshKeyPair{key})
testing.expect(t, x_ok, "ssh_to_x25519 should succeed")
if !x_ok {
return
}
defer delete(x25519_pairs)
testing.expect(t, len(x25519_pairs) == 1, "should have 1 x25519 keypair")
}
@(test)
test_config_load_with_fixture_key :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.Keys)
}
testing.expect(t, len(cfg.Keys) == 1, "should have 1 key")
key := cfg.Keys[0]
testing.expectf(t, len(key.Private) > 0, "private key path should not be empty")
testing.expectf(t, len(key.Public) > 0, "public key path should not be empty")
_, priv_ok := parse_ssh_private_key(key.Private)
testing.expect(t, priv_ok, "should parse private key using config paths")
if !priv_ok {
fmt.printf(" private key path was: '%s'\n", key.Private)
}
}

View File

@@ -1,19 +1,89 @@
package main
import "core:path/filepath"
import "core:strings"
import "core:testing"
@(test)
test_dir_slice_owns_parent :: proc(t: ^testing.T) {
abs_path := "/home/user/project/.env"
cloned_path, _ := strings.clone(abs_path)
dir := filepath.dir(cloned_path)
testing.expect(t, dir == "/home/user/project", "filepath.dir should return parent directory")
testing.expect(t, len(dir) > 0, "dir should not be empty")
cloned_dir, _ := strings.clone(dir)
testing.expect(t, cloned_dir == dir, "clone of dir should equal dir")
test_db_update_required_noop :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({}), "Noop should not require update")
}
@(test)
test_db_update_required_backed_up :: proc(t: ^testing.T) {
testing.expect(t, db_update_required({.BackedUp}), "BackedUp should require update")
}
@(test)
test_db_update_required_dir_updated :: proc(t: ^testing.T) {
testing.expect(t, db_update_required({.DirUpdated}), "DirUpdated should require update")
}
@(test)
test_db_update_required_restored :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({.Restored}), "Restored alone should not require update")
}
@(test)
test_db_update_required_error :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({.Error}), "Error alone should not require update")
}
@(test)
test_db_update_required_combined :: proc(t: ^testing.T) {
combined := SyncFlag{.DirUpdated, .Restored}
testing.expect(t, db_update_required(combined), "DirUpdated|Restored should require update")
}
@(test)
test_shares_remote_overlap :: proc(t: ^testing.T) {
f := EnvFile {
Remotes = make([dynamic]string, 2, context.temp_allocator),
}
append(&f.Remotes, "git@github.com:user/repo.git")
append(&f.Remotes, "git@gitlab.com:user/repo.git")
remotes := []string{"git@github.com:user/repo.git"}
testing.expect(t, shares_remote(&f, remotes), "should share remote")
}
@(test)
test_shares_remote_no_overlap :: proc(t: ^testing.T) {
f := EnvFile {
Remotes = make([dynamic]string, 1, context.temp_allocator),
}
append(&f.Remotes, "git@github.com:user/repo.git")
remotes := []string{"git@github.com:other/repo.git"}
testing.expect(t, !shares_remote(&f, remotes), "should not share remote")
}
@(test)
test_shares_remote_empty_file_remotes :: proc(t: ^testing.T) {
f := EnvFile {
Remotes = make([dynamic]string, 0, context.temp_allocator),
}
remotes := []string{"git@github.com:user/repo.git"}
testing.expect(t, !shares_remote(&f, remotes), "empty file remotes should not share")
}
@(test)
test_shares_remote_empty_check_remotes :: proc(t: ^testing.T) {
f := EnvFile {
Remotes = make([dynamic]string, 1, context.temp_allocator),
}
append(&f.Remotes, "git@github.com:user/repo.git")
remotes: []string
testing.expect(t, !shares_remote(&f, remotes), "empty check remotes should not share")
}
@(test)
test_shares_remote_both_empty :: proc(t: ^testing.T) {
f := EnvFile {
Remotes = make([dynamic]string, 0),
}
remotes: []string
testing.expect(t, !shares_remote(&f, remotes), "both empty should not share")
}

View File

@@ -8,7 +8,6 @@ import "core:strings"
Feature :: enum {
Git,
Fd,
Age,
}
AvailableFeatures :: bit_set[Feature]
@@ -31,9 +30,6 @@ check_features :: proc() -> AvailableFeatures {
if find_binary(paths, "fd") != "" {
feats += {.Fd}
}
if find_binary(paths, "age") != "" {
feats += {.Age}
}
return feats
}

View File

@@ -6,8 +6,8 @@ import "core:testing"
@(test)
test_find_binary_exists :: proc(t: ^testing.T) {
path := os.get_env("PATH", context.allocator)
paths := strings.split(path, ":")
path := os.get_env("PATH", context.temp_allocator)
paths := strings.split(path, ":", context.temp_allocator)
result := find_binary(paths, "sh")
testing.expect(t, result != "", "sh should be found on PATH")
@@ -15,7 +15,7 @@ test_find_binary_exists :: proc(t: ^testing.T) {
@(test)
test_find_binary_not_exists :: proc(t: ^testing.T) {
old_path := os.get_env("PATH", context.allocator)
old_path := os.get_env("PATH", context.temp_allocator)
defer {
if old_path != "" {
os.set_env("PATH", old_path)
@@ -24,8 +24,8 @@ test_find_binary_not_exists :: proc(t: ^testing.T) {
os.set_env("PATH", "/tmp/envr-nope")
path := os.get_env("PATH", context.allocator)
paths := strings.split(path, ":")
path := os.get_env("PATH", context.temp_allocator)
paths := strings.split(path, ":", context.temp_allocator)
result := find_binary(paths, "no_such_binary_xyz")

View File

@@ -40,7 +40,6 @@
};
treefmt = {
# Used to find the project root
projectRootFile = "flake.nix";
settings.global.excludes = [
".direnv/**"
@@ -50,67 +49,57 @@
".env.local"
];
# Format nix files
programs.nixpkgs-fmt.enable = true;
# programs.deadnix.enable = true;
# Format go files
programs.goimports.enable = true;
};
packages.default = pkgs.buildGoModule rec {
packages.default = pkgs.stdenv.mkDerivation rec {
pname = "envr";
version = "0.2.0";
src = ./.;
# If the build complains, uncomment this line
# vendorHash = "sha256:0000000000000000000000000000000000000000000000000000";
vendorHash = "sha256-aC82an6vYifewx4amfXLzk639jz9fF5bD5cF6krY0Ks=";
nativeBuildInputs = [ pkgs.installShellFiles ];
ldflags = [
"-X github.com/sbrow/envr/cmd.version=v${version}"
# "-X github.com/sbrow/envr/cmd.commit=$(git rev-parse HEAD)"
# "-X github.com/sbrow/envr/cmd.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
nativeBuildInputs = [
pkgs.unstable.odin
pkgs.pkg-config
];
postBuild = ''
# Generate man pages
$GOPATH/bin/docgen -out ./man -format man
buildInputs = [
pkgs.libsodium
pkgs.sqlite
];
buildPhase = ''
runHook preBuild
echo '${version}' > version.txt
odin build . -o:speed -out:${pname}
runHook postBuild
'';
postInstall = ''
# Install man pages
installManPage ./man/*.1
installPhase = ''
runHook preInstall
install -Dm755 ${pname} $out/bin/${pname}
runHook postInstall
'';
};
devShells.default = pkgs.mkShell
{
buildInputs = with pkgs; [
fd
nushell
go
gopls
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
fd
nushell
gotools
cobra-cli
libsodium
sqlite
unstable.odin
unstable.ols
age
sqlite
unstable.odin
unstable.ols
# Build tools
zip
# Build tools
zip
# IDE
unstable.helix
typescript-language-server
vscode-langservers-extracted
];
};
# IDE
unstable.helix
typescript-language-server
vscode-langservers-extracted
];
};
};
};
}

41
go.mod
View File

@@ -1,41 +0,0 @@
module github.com/sbrow/envr
go 1.24.6
require (
filippo.io/age v1.2.1
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/mattn/go-isatty v0.0.20
github.com/olekukonko/tablewriter v1.1.0
github.com/spf13/cobra v1.10.1
modernc.org/sqlite v1.39.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.0.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/crypto v0.24.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

138
go.sum
View File

@@ -1,138 +0,0 @@
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -1,58 +0,0 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/sbrow/envr/cmd" // update to your module path
"github.com/spf13/cobra/doc"
)
func main() {
out := flag.String("out", "./docs/cli", "output directory")
format := flag.String("format", "markdown", "markdown|man|rest")
front := flag.Bool("frontmatter", false, "prepend simple YAML front matter to markdown")
flag.Parse()
if err := os.MkdirAll(*out, 0o755); err != nil {
log.Fatal(err)
}
root := cmd.Root()
root.DisableAutoGenTag = true // stable, reproducible files (no timestamp footer)
switch *format {
case "markdown":
if *front {
prep := func(filename string) string {
base := filepath.Base(filename)
name := strings.TrimSuffix(base, filepath.Ext(base))
title := strings.ReplaceAll(name, "_", " ")
return fmt.Sprintf("---\ntitle: %q\nslug: %q\ndescription: \"CLI reference for %s\"\n---\n\n", title, name, title)
}
link := func(name string) string { return strings.ToLower(name) }
if err := doc.GenMarkdownTreeCustom(root, *out, prep, link); err != nil {
log.Fatal(err)
}
} else {
if err := doc.GenMarkdownTree(root, *out); err != nil {
log.Fatal(err)
}
}
case "man":
hdr := &doc.GenManHeader{Title: strings.ToUpper(root.Name()), Section: "1"}
if err := doc.GenManTree(root, hdr, *out); err != nil {
log.Fatal(err)
}
case "rest":
if err := doc.GenReSTTree(root, *out); err != nil {
log.Fatal(err)
}
default:
log.Fatalf("unknown format: %s", *format)
}
}

View File

@@ -1,7 +0,0 @@
package main
import "github.com/sbrow/envr/cmd"
func main() {
cmd.Execute()
}

View File

@@ -32,6 +32,8 @@ main :: proc() {
cmd_scan(&cmd)
case "sync":
cmd_sync(&cmd)
case "nushell-completion":
cmd_nushell_completion(&cmd)
case:
fmt.printf("Unknown command: %s\n", cmd.name)
print_usage()

44
main.odin.bak Normal file
View File

@@ -0,0 +1,44 @@
package main
import "core:fmt"
import "core:os"
main :: proc() {
cmd, ok := parse_args()
if !ok {
return
}
switch cmd.name {
case "init":
cmd_init(&cmd)
case "version":
cmd_version(&cmd)
case "deps":
cmd_deps(&cmd)
case "list":
cmd_list(&cmd)
case "backup", "add":
cmd_backup(&cmd)
case "remove":
cmd_remove(&cmd)
case "restore":
cmd_restore(&cmd)
case "edit-config":
cmd_edit_config(&cmd)
case "check":
cmd_check(&cmd)
case "scan":
cmd_scan(&cmd)
case "sync":
cmd_sync(&cmd)
case "nushell-completion":
cmd_nushell_completion(&cmd)
case:
fmt.printf("Unknown command: %s\n", cmd.name)
print_usage()
os.exit(1)
}
}

View File

@@ -18,32 +18,20 @@ export def untracked-paths [] {
)
}
# Complete shell types for completion command
def shells [] {
["bash", "zsh", "fish", "powershell"]
}
export extern envr [
...args: any
--help(-h) # Show help information
--toggle(-t) # Help message for toggle
]
export extern "envr backup" [
--help(-h) # Show help for backup command
path: path@untracked-paths # Path to .env file to backup
]
#TODO: envr backup path.
export extern "envr check" [
--help(-h) # Show help for check command
]
export extern "envr completion" [
shell: string@shells # Shell to generate completion for
--help(-h) # Show help for completion command
]
export extern "envr edit-config" [
--help(-h) # Show help for edit-config command
]
@@ -77,3 +65,7 @@ export extern "envr scan" [
export extern "envr sync" [
--help(-h) # Show help for sync command
]
export extern "envr nushell-completion" [
--help(-h) # Show help for nushell-completion command
]

109
quash Normal file
View File

@@ -0,0 +1,109 @@
@ ukspssxz spencer.brower@proton.me 2026-06-12 16:45:22 default@ 548fe7ec
(no description set)
suwmwvkl spencer.brower@proton.me 2026-06-12 16:40:25 odin a1e93345
│ ci: Updated github action.
tqpkpmus spencer.brower@proton.me 2026-06-12 16:35:39 eed36089
│ feat: Removed go code.
yzzzmznw spencer.brower@proton.me 2026-06-12 16:35:34 75b77845
│ build: Converted Makefile and flake package.
kvtmxpyn spencer.brower@proton.me 2026-06-12 15:54:44 4ec2b22b
│ refactor: removed `is_tty`.
pouwppuo spencer.brower@proton.me 2026-06-12 15:48:12 0276db76
│ refactor: Switched from age to libsodium.
txoxnuzl spencer.brower@proton.me 2026-06-12 15:36:10 a0e2c995
│ docs: Updated TODOs.
zvrkmqpk spencer.brower@proton.me 2026-06-12 15:01:50 d0dc93ab
│ feat(odin): Migrated nushell-completion command to go.
zpmvtmzx spencer.brower@proton.me 2026-06-12 14:50:42 91ada61c
│ feat: Added tests.
vsqmlvlq spencer.brower@proton.me 2026-06-12 14:17:56 9b395677
│ fix: Fixed the rest of the (tested) leaks.
rwzttsll spencer.brower@proton.me 2026-06-12 13:37:09 43dd8aca
│ perf: Improved writer performance.
rovqumvz spencer.brower@proton.me 2026-06-12 13:25:50 db1b863e
│ fix: fixing leaks.
quqsmwmx spencer.brower@proton.me 2026-06-12 10:45:43 e9660501
│ fix: Added proper help text to all commands.
uupootzn spencer.brower@proton.me 2026-06-12 10:28:41 7629dd2c
│ fix: Got rid of go fallback code.
svkzoqxq spencer.brower@proton.me 2026-06-12 10:22:21 7c7ddf46
│ fix: Fixed memory leaks in `find_binary`.
yzvwlzvq spencer.brower@proton.me 2026-06-12 10:22:21 a1e945a6
│ feat(odin): Ported init command.
yklwuqrm spencer.brower@proton.me 2026-06-12 09:12:55 0a332adf
│ feat(odin): Ported scan command.
unktymmr spencer.brower@proton.me 2026-06-12 08:27:14 4e1e3590
│ feat(odin): port check command to odin.
oyllntvp spencer.brower@proton.me 2026-06-12 08:02:08 82bec68b
│ fix: Fixing AI oopsies.
lowokuok spencer.brower@proton.me 2026-06-11 21:26:59 2cb6067a
│ feat(odin): ported edit-config command to odin.
vlssoopk spencer.brower@proton.me 2026-06-11 21:25:11 3668df57
│ feat(odin): ported restore command to odin.
tunwtypr spencer.brower@proton.me 2026-06-11 21:21:59 d2127e47
│ feat(odin): Ported remove command.
nrnpskps spencer.brower@proton.me 2026-06-11 21:17:52 cb7db967
│ feat(odin): Added long text and --help flags.
swwzkunx spencer.brower@proton.me 2026-06-11 21:14:11 c92155a1
│ feat(odin): ported backup command.
tsnurnzr spencer.brower@proton.me 2026-06-11 21:05:39 b1d24161
│ feat(odin): ported list command.
vwolkxsl spencer.brower@proton.me 2026-06-11 21:05:33 40f0b3c3
│ feat(odin): ported deps command, added utilities (features, tty, table).
rqrrlqlk spencer.brower@proton.me 2026-06-11 20:34:53 d84e43d0
│ odin: scaffold project with CLI parser, version command, Go fallback
znnskorn spencer.brower@proton.me 2026-06-11 20:08:27 28f96df4
│ feat: Started odin setup.
│ ○ rykmnnwl spencer.brower@proton.me 2026-06-11 20:00:08 zig 42c01a08
│ │ feat: init command.
│ ○ ztntvnnw spencer.brower@proton.me 2026-06-09 11:01:15 d3eb4e84
│ │ fix: Fixed issue with buffer size.
│ ○ pqzlpytk spencer.brower@proton.me 2026-06-09 09:50:38 6acd1f9d
│ │ refactor: Moved deps into `root.zig`.
│ ○ slkwsoqy spencer.brower@proton.me 2026-06-09 09:41:13 681931fb
│ │ feat: Added table viewer.
│ ○ qkmlntsm spencer.brower@proton.me 2026-05-27 19:30:19 acbda090
│ │ feat: list cmd.
│ ○ vxnsyxqp spencer.brower@proton.me 2026-05-27 18:27:21 fc8474d7
│ │ feat: Restore db from file.
│ ○ uoowvkxx spencer.brower@proton.me 2026-05-03 12:45:43 8f2c2419
│ │ feat(config): Added data path.
│ ○ qrkuztko spencer.brower@proton.me 2026-05-01 10:30:12 3e6c1752
│ │ feat: accept config in Db
│ ○ vrxoyzlo spencer.brower@proton.me 2026-04-30 22:37:31 fd0f8bba
│ │ feat(age): accept multiple recipients.
│ ○ rquvonut spencer.brower@proton.me 2026-04-30 21:03:38 65571393
│ │ feat: Implemented basic db operation.
│ ○ nwzoqvoq spencer.brower@proton.me 2026-04-29 16:35:38 e5286527
│ │ feat: Created own age wrapper.
│ ○ rltyxtqr spencer.brower@proton.me 2026-04-28 17:49:04 02ce5e46
│ │ feat: Added age-ffi.
│ ○ krzuylpu spencer.brower@proton.me 2026-04-26 17:29:37 a13264c8
│ │ feat: zig-sqlite.
│ ○ nqlotzkk spencer.brower@proton.me 2026-04-24 11:19:31 799d95a4
│ │ feat: added Config parsing.
│ ○ npvzptmw spencer.brower@proton.me 2026-04-23 16:53:47 217bb413
│ │ feat(comma): Added help method.
│ ○ rrlywnkm spencer.brower@proton.me 2026-04-21 19:42:02 a547409e
│ │ docs: Added AI Disclaimer to README.md.
│ ○ plqqwlws spencer.brower@proton.me 2026-04-21 19:34:09 53cf22bc
│ │ feat: Added help output for commands.
│ ○ znpvknpm spencer.brower@proton.me 2026-04-21 18:13:35 ae445459
│ │ feat(comma): Added enum value for unknown commands.
│ ○ zqpvlvms spencer.brower@proton.me 2026-04-21 18:02:58 bd2a5455
│ │ feat: Migrated `deps` command.
│ ○ wqslwyqo spencer.brower@proton.me 2026-04-20 17:08:26 8a503ced
│ │ refactor: Broke comma into a separate package.
│ ○ trqurnkq spencer.brower@proton.me 2026-04-20 16:14:43 33b0063c
│ │ feat: Added command structure.
│ │ ○ spllvvwm spencer.brower@proton.me 2026-04-20 10:15:48 envr-zig@ ac94b33e
│ ├─╯ (empty) (no description set)
│ ○ olwurpsw spencer.brower@proton.me 2026-04-18 16:28:30 43b03e0a
│ │ wip: feat: Migrated version command to zig.
│ ○ mnqunpro spencer.brower@proton.me 2026-04-17 16:41:45 ce135e9c
│ │ feat: Created zig wrapper.
│ ○ unkrrvon spencer.brower@proton.me 2026-04-17 15:49:00 6a611150
├─╯ feat: Added zig config.
◆ psmotwus 6729162+sbrow@users.noreply.github.com 2026-01-12 15:42:05 go main v0.2.1 c6d03088
│ chore(main): release 0.2.1
~

110
scan.odin
View File

@@ -2,23 +2,50 @@ package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:sync"
import "core:terminal"
fd_counter: sync.Atomic_Mutex
fd_seq: int
next_fd_tmp_path :: proc() -> string {
sync.atomic_mutex_lock(&fd_counter)
n := fd_seq
fd_seq += 1
sync.atomic_mutex_unlock(&fd_counter)
return fmt.aprintf("/tmp/envr-fd-%d-%d", os.get_pid(), n)
// Caller is responsible for freeing paths
scan_path :: proc(search_path: string, cfg: Config) -> (paths: [dynamic]string, ok: bool) {
if terminal.is_terminal(os.stdout) {
fmt.printf("Searching for all files in \"%s\"...\n", search_path)
}
all_files, all_ok := run_fd(build_fd_args(search_path, cfg, true))
if !all_ok {
return
}
if terminal.is_terminal(os.stdout) {
fmt.printf("Search for unignored fies in \"%s\"...\n", search_path)
}
unignored_files, unignored_ok := run_fd(build_fd_args(search_path, cfg, false))
if !unignored_ok {
return
}
unignored_set := make(map[string]bool, len(unignored_files), context.temp_allocator)
for file in unignored_files {
unignored_set[file] = true
}
for file in all_files {
if !(file in unignored_set) {
append(&paths, file)
}
}
ok = true
return
}
@(private = "file")
build_fd_args :: proc(search_path: string, cfg: Config, include_ignored: bool) -> []string {
args := make([dynamic]string, 0, 3 + 2 * len(cfg.ScanConfig.Exclude) + 2)
args_len := 3 + 2 * len(cfg.ScanConfig.Exclude) + 2
args := make([dynamic]string, 0, args_len, context.temp_allocator)
append(&args, "fd")
append(&args, "-a")
append(&args, cfg.ScanConfig.Matcher)
@@ -38,7 +65,7 @@ build_fd_args :: proc(search_path: string, cfg: Config, include_ignored: bool) -
return args[:]
}
run_fd :: proc(args: []string) -> (lines: [dynamic]string, ok: bool) {
run_fd :: proc(args: []string) -> (lines: []string, ok: bool) {
tmp_path := next_fd_tmp_path()
tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC)
if tmp_err != nil {
@@ -64,7 +91,7 @@ run_fd :: proc(args: []string) -> (lines: [dynamic]string, ok: bool) {
return
}
data, read_err := os.read_entire_file_from_path(tmp_path, context.allocator)
data, read_err := os.read_entire_file_from_path(tmp_path, context.temp_allocator)
os.remove(tmp_path)
if read_err != nil {
return
@@ -77,69 +104,44 @@ run_fd :: proc(args: []string) -> (lines: [dynamic]string, ok: bool) {
return
}
raw_lines := strings.split(output, "\n")
raw_lines := strings.split(output, "\n", context.temp_allocator)
result := make([dynamic]string, 0, len(raw_lines), context.temp_allocator)
for line in raw_lines {
trimmed, _ := strings.clone(strings.trim_space(line))
trimmed := strings.trim_space(line)
if len(trimmed) > 0 {
append(&lines, trimmed)
append(&result, trimmed)
}
}
ok = true
return
return result[:], true
}
scan_path :: proc(search_path: string, cfg: Config) -> (paths: [dynamic]string, ok: bool) {
if is_tty() {
fmt.printf("Searching for all files in \"%s\"...\n", search_path)
}
all_args := build_fd_args(search_path, cfg, true)
all_files, all_ok := run_fd(all_args)
if !all_ok {
return
}
if is_tty() {
fmt.printf("Search for unignored fies in \"%s\"...\n", search_path)
}
unignored_args := build_fd_args(search_path, cfg, false)
unignored_files, unignored_ok := run_fd(unignored_args)
if !unignored_ok {
return
}
unignored_set: map[string]bool
for file in unignored_files {
unignored_set[file] = true
}
for file in all_files {
if !(file in unignored_set) {
append(&paths, file)
}
}
ok = true
return
@(private = "file")
next_fd_tmp_path :: proc() -> string {
sync.atomic_mutex_lock(&fd_counter)
n := fd_seq
fd_seq += 1
sync.atomic_mutex_unlock(&fd_counter)
return fmt.tprintf("/tmp/envr-fd-%d-%d", os.get_pid(), n)
}
can_scan :: proc() -> bool {
feats := check_features()
return Feature.Fd in feats
cant_scan :: proc(feats: AvailableFeatures) -> bool {
return Feature.Fd not_in feats
}
find_unbacked :: proc(local_files: []string, db_files: []EnvFile) -> [dynamic]string {
backed_set: map[string]bool
find_unbacked :: proc(local_files: []string, db_files: []EnvFile) -> []string {
// Lives until the end of the function
backed_set := make(map[string]bool, len(db_files), context.temp_allocator)
for file in db_files {
backed_set[file.Path] = true
}
unbacked: [dynamic]string
unbacked := make([dynamic]string, 0, len(db_files) / 2, context.temp_allocator)
for file in local_files {
if !(file in backed_set) {
append(&unbacked, file)
}
}
return unbacked
return unbacked[:]
}

View File

@@ -7,88 +7,81 @@ import "core:testing"
@(test)
test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) {
if !can_scan() {
return
}
feats := check_features()
testing.expect(t, cant_scan(feats) == false)
base := fmt.aprintf("/tmp/envr-scan-test-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
base := fmt.tprintf("/tmp/envr-scan-test-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
git_init := os.Process_Desc{
command = []string{"git", "-c", "advice.defaultBranchName=false", "init"},
working_dir = base,
stdout = os.stderr,
stderr = os.stderr,
}
p, err := os.process_start(git_init)
if err != nil {
return
}
_, wait_err := os.process_wait(p)
if wait_err != nil {
return
}
git_init := os.Process_Desc {
command = []string{"git", "-c", "advice.defaultBranchName=false", "init", "-q"},
working_dir = base,
stdout = os.stderr,
stderr = os.stderr,
}
p, err := os.process_start(git_init)
if err != nil {
return
}
_, wait_err := os.process_wait(p)
if wait_err != nil {
return
}
gitignore_path := fmt.aprintf("%s/.gitignore", base)
_ = os.write_entire_file(gitignore_path, ".env*\n")
gitignore_path := fmt.tprintf("%s/.gitignore", base)
_ = os.write_entire_file(gitignore_path, ".env*\n")
_ = os.write_entire_file(fmt.aprintf("%s/.env", base), "SECRET=1")
_ = os.write_entire_file(fmt.aprintf("%s/.env.testing", base), "TEST=1")
_ = os.write_entire_file(fmt.aprintf("%s/config.yaml", base), "key: value")
_ = os.write_entire_file(fmt.tprintf("%s/.env", base), "SECRET=1")
_ = os.write_entire_file(fmt.tprintf("%s/.env.testing", base), "TEST=1")
_ = os.write_entire_file(fmt.tprintf("%s/config.yaml", base), "key: value")
cfg := Config{
ScanConfig = ScanConfig{
Matcher = "\\.env",
Exclude = []string{},
Include = []string{},
},
}
cfg := Config {
ScanConfig = ScanConfig{Matcher = "\\.env"},
}
results, ok := scan_path(base, cfg)
testing.expect(t, ok, "scan_path should succeed")
results, ok := scan_path(base, cfg)
defer delete(results)
testing.expect(t, ok, "scan_path should succeed")
found_env := false
found_testing := false
found_config := false
found_env := false
found_testing := false
found_config := false
for path in results {
_, filename := filepath.split(path)
if filename == ".env" {
found_env = true
}
if filename == ".env.testing" {
found_testing = true
}
if filename == "config.yaml" {
found_config = true
}
}
for path in results {
_, filename := filepath.split(path)
if filename == ".env" {
found_env = true
}
if filename == ".env.testing" {
found_testing = true
}
if filename == "config.yaml" {
found_config = true
}
}
testing.expect(t, found_env, "should find .env (gitignored)")
testing.expect(t, found_testing, "should find .env.testing (gitignored)")
testing.expect(t, !found_config, "should NOT find config.yaml (not gitignored)")
testing.expect(t, found_env, "should find .env (gitignored)")
testing.expect(t, found_testing, "should find .env.testing (gitignored)")
testing.expect(t, !found_config, "should NOT find config.yaml (not gitignored)")
}
@(test)
test_scan_path_empty_dir :: proc(t: ^testing.T) {
if !can_scan() {
return
}
feats := check_features()
testing.expect(t, cant_scan(feats) == false)
base := fmt.aprintf("/tmp/envr-scan-empty-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
base := fmt.tprintf("/tmp/envr-scan-empty-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
cfg := Config{
ScanConfig = ScanConfig{
Matcher = "\\.env",
Exclude = []string{},
Include = []string{},
},
}
cfg := Config {
ScanConfig = ScanConfig{Matcher = "\\.env"},
}
results, ok := scan_path(base, cfg)
testing.expect(t, ok, "scan_path should succeed")
testing.expect(t, len(results) == 0, fmt.aprintf("expected 0 results, got %d", len(results)))
results, ok := scan_path(base, cfg)
defer delete(results)
testing.expect(t, ok, "scan_path should succeed")
testing.expect(t, len(results) == 0, fmt.tprintf("expected 0 results, got %d", len(results)))
}

31
sodium.odin Normal file
View File

@@ -0,0 +1,31 @@
package main
import "core:c"
foreign import libsodium "system:sodium"
CRYPTO_BOX_PUBLICKEY_BYTES :: 32
CRYPTO_BOX_SECRETKEY_BYTES :: 32
CRYPTO_BOX_NONCE_BYTES :: 24
CRYPTO_BOX_MAC_BYTES :: 16
CRYPTO_SECRETBOX_KEY_BYTES :: 32
CRYPTO_SECRETBOX_NONCE_BYTES :: 24
CRYPTO_SECRETBOX_MAC_BYTES :: 16
CRYPTO_SIGN_PUBLICKEY_BYTES :: 32
CRYPTO_SIGN_SECRETKEY_BYTES :: 64
@(default_calling_convention = "c")
foreign libsodium {
sodium_init :: proc() -> c.int ---
// crypto_box_keypair :: proc(pk: [^]u8, sk: [^]u8) -> c.int ---
crypto_box_easy :: proc(ciphertext: [^]u8, plaintext: [^]u8, mlen: c.ulong, nonce: [^]u8, pk: [^]u8, sk: [^]u8) -> c.int ---
crypto_box_open_easy :: proc(plaintext: [^]u8, ciphertext: [^]u8, clen: c.ulong, nonce: [^]u8, pk: [^]u8, sk: [^]u8) -> c.int ---
crypto_secretbox_easy :: proc(ciphertext: [^]u8, plaintext: [^]u8, mlen: c.ulong, nonce: [^]u8, key: [^]u8) -> c.int ---
crypto_secretbox_open_easy :: proc(plaintext: [^]u8, ciphertext: [^]u8, clen: c.ulong, nonce: [^]u8, key: [^]u8) -> c.int ---
crypto_sign_ed25519_pk_to_curve25519 :: proc(curve25519_pk: [^]u8, ed25519_pk: [^]u8) -> c.int ---
crypto_sign_ed25519_sk_to_curve25519 :: proc(curve25519_sk: [^]u8, ed25519_sk: [^]u8) -> c.int ---
randombytes_buf :: proc(buf: [^]u8, size: c.ulong) ---
}

255
ssh.odin Normal file
View File

@@ -0,0 +1,255 @@
package main
import "core:encoding/base64"
import "core:fmt"
import "core:os"
import "core:strings"
SSH_ED25519 :: "ssh-ed25519"
Ed25519Keypair :: struct {
Public: [32]u8,
Private: [32]u8,
}
read_wire_string :: proc(data: []u8, offset: ^int) -> (s: string, ok: bool) {
if offset^ + 4 > len(data) {
return
}
length := u32(data[offset^]) << 24 | u32(data[offset^ + 1]) << 16 |
u32(data[offset^ + 2]) << 8 | u32(data[offset^ + 3])
offset^ += 4
if offset^ + int(length) > len(data) {
return
}
s = string(data[offset^ : offset^ + int(length)])
offset^ += int(length)
ok = true
return
}
parse_ssh_public_key :: proc(pub_path: string) -> (pub: [32]u8, ok: bool) {
data, err := os.read_entire_file_from_path(pub_path, context.temp_allocator)
if err != nil {
return
}
text := strings.trim_right(string(data), "\n")
parts := strings.split(text, " ", context.temp_allocator)
if len(parts) < 2 {
return
}
if parts[0] != SSH_ED25519 {
return
}
decoded, decode_err := base64.decode(parts[1], allocator = context.temp_allocator)
if decode_err != nil || len(decoded) < 51 {
return
}
offset := 0
key_type, type_ok := read_wire_string(decoded, &offset)
if !type_ok || key_type != SSH_ED25519 {
return
}
pk_data, pk_ok := read_wire_string(decoded, &offset)
if !pk_ok || len(pk_data) != 32 {
return
}
for i in 0 ..< 32 {
pub[i] = pk_data[i]
}
ok = true
return
}
parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: bool) {
data, err := os.read_entire_file_from_path(priv_path, context.temp_allocator)
if err != nil {
return
}
text := string(data)
lines := strings.split(text, "\n", context.temp_allocator)
b: strings.Builder
strings.builder_init(&b, context.temp_allocator)
defer strings.builder_destroy(&b)
in_block := false
for line in lines {
trimmed := strings.trim_space(line)
if trimmed == "-----BEGIN OPENSSH PRIVATE KEY-----" {
in_block = true
continue
}
if trimmed == "-----END OPENSSH PRIVATE KEY-----" {
break
}
if in_block && len(trimmed) > 0 {
fmt.sbprintf(&b, "%s", trimmed)
}
}
b64_str := strings.to_string(b)
decoded, decode_err := base64.decode(b64_str, allocator = context.temp_allocator)
if decode_err != nil {
return
}
magic := "openssh-key-v1\x00"
if len(decoded) < len(magic) {
return
}
for i in 0 ..< len(magic) {
if decoded[i] != u8(magic[i]) {
return
}
}
offset := len(magic)
ciphername, cipher_ok := read_wire_string(decoded, &offset)
if !cipher_ok || ciphername != "none" {
return
}
kdfname, kdf_ok := read_wire_string(decoded, &offset)
if !kdf_ok || kdfname != "none" {
return
}
_, opts_ok := read_wire_string(decoded, &offset)
if !opts_ok {
return
}
if offset + 4 > len(decoded) {
return
}
num_keys := u32(decoded[offset]) << 24 | u32(decoded[offset + 1]) << 16 |
u32(decoded[offset + 2]) << 8 | u32(decoded[offset + 3])
offset += 4
if num_keys != 1 {
return
}
_, pub_blob_ok := read_wire_string(decoded, &offset)
if !pub_blob_ok {
return
}
priv_blob, priv_blob_ok := read_wire_string(decoded, &offset)
if !priv_blob_ok {
return
}
inner_offset := 0
if inner_offset + 8 > len(priv_blob) {
return
}
check1 := u32(priv_blob[inner_offset]) << 24 | u32(priv_blob[inner_offset + 1]) << 16 |
u32(priv_blob[inner_offset + 2]) << 8 | u32(priv_blob[inner_offset + 3])
inner_offset += 4
check2 := u32(priv_blob[inner_offset]) << 24 | u32(priv_blob[inner_offset + 1]) << 16 |
u32(priv_blob[inner_offset + 2]) << 8 | u32(priv_blob[inner_offset + 3])
inner_offset += 4
if check1 != check2 {
return
}
priv_type, type_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
if !type_ok || priv_type != SSH_ED25519 {
return
}
pub_wire, pub_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
if !pub_ok || len(pub_wire) != 32 {
return
}
for i in 0 ..< 32 {
kp.Public[i] = pub_wire[i]
}
priv_wire, priv_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
if !priv_ok || len(priv_wire) != 64 {
return
}
for i in 0 ..< 32 {
kp.Private[i] = priv_wire[i]
}
ok = true
return
}
is_ed25519_key :: proc(priv_path: string) -> bool {
pub_path, _ := strings.concatenate([]string{priv_path, ".pub"}, context.temp_allocator)
_, ok := parse_ssh_public_key(pub_path)
return ok
}
is_encrypted_key :: proc(priv_path: string) -> bool {
data, err := os.read_entire_file_from_path(priv_path, context.temp_allocator)
if err != nil {
return true
}
if !strings.contains(string(data), "BEGIN OPENSSH PRIVATE KEY") {
return true
}
text := string(data)
lines := strings.split(text, "\n", context.temp_allocator)
b2: strings.Builder
strings.builder_init(&b2, context.temp_allocator)
defer strings.builder_destroy(&b2)
in_block := false
for line in lines {
trimmed := strings.trim_space(line)
if trimmed == "-----BEGIN OPENSSH PRIVATE KEY-----" {
in_block = true
continue
}
if trimmed == "-----END OPENSSH PRIVATE KEY-----" {
break
}
if in_block && len(trimmed) > 0 {
fmt.sbprintf(&b2, "%s", trimmed)
}
}
b64_str := strings.to_string(b2)
decoded, decode_err := base64.decode(b64_str, allocator = context.temp_allocator)
if decode_err != nil {
return true
}
magic := "openssh-key-v1\x00"
if len(decoded) < len(magic) {
return true
}
for i in 0 ..< len(magic) {
if decoded[i] != u8(magic[i]) {
return true
}
}
offset := len(magic)
ciphername, cipher_ok := read_wire_string(decoded, &offset)
if !cipher_ok {
return true
}
return ciphername != "none"
}

72
ssh_test.odin Normal file
View File

@@ -0,0 +1,72 @@
package main
import "core:fmt"
import "core:testing"
TEST_KEY_DIR :: "/tmp/envr-test-keys"
@(test)
test_parse_ed25519_public_key :: proc(t: ^testing.T) {
pub, ok := parse_ssh_public_key(TEST_KEY_DIR + "/test_ed25519.pub")
testing.expect(t, ok, "expected ed25519 public key to parse")
testing.expect(t, pub != [32]u8{}, fmt.tprintf("expected non-zero public key"))
}
@(test)
test_parse_ed25519_private_key :: proc(t: ^testing.T) {
kp, ok := parse_ssh_private_key(TEST_KEY_DIR + "/test_ed25519")
testing.expect(t, ok, "expected ed25519 private key to parse")
testing.expect(t, kp.Public != [32]u8{}, "expected non-zero public key")
testing.expect(t, kp.Private != [32]u8{}, "expected non-zero private key")
}
@(test)
test_parse_rsa_public_key_fails :: proc(t: ^testing.T) {
_, ok := parse_ssh_public_key(TEST_KEY_DIR + "/test_rsa.pub")
testing.expect(t, !ok, "expected RSA key parsing to fail")
}
@(test)
test_is_ed25519_key_true :: proc(t: ^testing.T) {
testing.expect(t, is_ed25519_key(TEST_KEY_DIR + "/test_ed25519"))
}
@(test)
test_is_ed25519_key_false_for_rsa :: proc(t: ^testing.T) {
testing.expect(t, !is_ed25519_key(TEST_KEY_DIR + "/test_rsa"))
}
@(test)
test_private_key_pub_matches_public_key :: proc(t: ^testing.T) {
pub_from_pub, pub_ok := parse_ssh_public_key(TEST_KEY_DIR + "/test_ed25519.pub")
testing.expect(t, pub_ok, "expected public key to parse")
kp, priv_ok := parse_ssh_private_key(TEST_KEY_DIR + "/test_ed25519")
testing.expect(t, priv_ok, "expected private key to parse")
testing.expect(
t,
pub_from_pub == kp.Public,
fmt.tprintf(
"public key mismatch:\n from .pub: %v\n from priv: %v",
pub_from_pub,
kp.Public,
),
)
}
@(test)
test_read_wire_string :: proc(t: ^testing.T) {
data := []u8{0, 0, 0, 5, u8('h'), u8('e'), u8('l'), u8('l'), u8('o'), 0, 0, 0, 0}
offset := 0
s, ok := read_wire_string(data, &offset)
testing.expect(t, ok, "expected read_wire_string to succeed")
testing.expect(t, s == "hello", fmt.tprintf("expected 'hello', got %q", s))
testing.expect(t, offset == 9, fmt.tprintf("expected offset 9, got %d", offset))
s2, ok2 := read_wire_string(data, &offset)
testing.expect(t, ok2, "expected second read to succeed")
testing.expect(t, s2 == "", "expected empty string")
}

View File

@@ -5,9 +5,10 @@ import "core:fmt"
import "core:io"
import "core:os"
import "core:strings"
import "core:terminal"
render_table :: proc(headers: []string, rows: [][]string) {
if !is_tty() {
if !terminal.is_terminal(os.stdout) {
w := io.to_writer(os.to_writer(os.stdout))
render_json_rows(w, headers, rows)
io.write_string(w, "\n")
@@ -15,11 +16,11 @@ render_table :: proc(headers: []string, rows: [][]string) {
}
col_widths := make([dynamic]int, 0, len(headers))
for i in 0..<len(headers) {
for i in 0 ..< len(headers) {
append(&col_widths, strings.rune_count(headers[i]))
}
for r in rows {
for i in 0..<len(r) {
for i in 0 ..< len(r) {
w := strings.rune_count(r[i])
if i < len(col_widths) && w > col_widths[i] {
col_widths[i] = w
@@ -34,11 +35,11 @@ render_table :: proc(headers: []string, rows: [][]string) {
hline :: proc(b: ^strings.Builder, left, mid, right: string, widths: [dynamic]int) {
strings.write_string(b, left)
for i in 0..<len(widths) {
for _ in 0..<widths[i]+2 {
for i in 0 ..< len(widths) {
for _ in 0 ..< widths[i] + 2 {
strings.write_string(b, "\u2500")
}
if i < len(widths)-1 {
if i < len(widths) - 1 {
strings.write_string(b, mid)
} else {
strings.write_string(b, right)
@@ -56,7 +57,7 @@ render_table :: proc(headers: []string, rows: [][]string) {
}
strings.write_string(&b, "\u2502")
for i in 0..<len(headers) {
for i in 0 ..< len(headers) {
cell(&b, headers[i], col_widths[i])
}
fmt.println(strings.to_string(b))
@@ -66,7 +67,7 @@ render_table :: proc(headers: []string, rows: [][]string) {
for r in rows {
strings.write_string(&b, "\u2502")
for i in 0..<len(r) {
for i in 0 ..< len(r) {
cell(&b, r[i], col_widths[i])
}
fmt.println(strings.to_string(b))
@@ -77,21 +78,21 @@ render_table :: proc(headers: []string, rows: [][]string) {
}
render_json_rows :: proc(w: io.Writer, headers: []string, rows: [][]string) {
entries := make([dynamic]map[string]string, 0, len(rows))
defer delete(entries)
entries := make([dynamic]map[string]string, 0, len(rows), context.temp_allocator)
for row in rows {
entry: map[string]string
for i in 0..<len(headers) {
entry := make(map[string]string, len(headers), context.temp_allocator)
for i in 0 ..< len(headers) {
entry[headers[i]] = row[i]
}
append(&entries, entry)
}
data, err := json.marshal(entries[:])
data, err := json.marshal(entries[:], allocator = context.temp_allocator)
if err != nil {
fmt.eprintf("Error marshaling JSON: %v\n", err)
return
}
io.write_string(w, string(data))
fmt.wprintf(w, "%s", data, flush = false)
}

View File

@@ -2,7 +2,6 @@ package main
import "core:encoding/json"
import "core:fmt"
import "core:io"
import "core:strings"
import "core:testing"
@@ -20,18 +19,18 @@ test_render_json_rows_normal :: proc(t: ^testing.T) {
output := strings.to_string(b)
result: []map[string]string
unmarshal_err := json.unmarshal_string(output, &result)
result: []map[string]string = ---
unmarshal_err := json.unmarshal_string(output, &result, allocator = context.temp_allocator)
testing.expect(
t,
unmarshal_err == nil,
fmt.aprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
fmt.tprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
)
testing.expect(t, len(result) == 2, fmt.aprintf("expected 2 rows, got %d", len(result)))
testing.expect(t, len(result) == 2, fmt.tprintf("expected 2 rows, got %d", len(result)))
testing.expect(
t,
result[0]["name"] == "foo",
fmt.aprintf("expected name=foo, got %q", result[0]["name"]),
fmt.tprintf("expected name=foo, got %q", result[0]["name"]),
)
testing.expect(t, result[0]["path"] == "/home/user/.env")
testing.expect(t, result[1]["name"] == "bar")
@@ -57,18 +56,22 @@ test_render_json_rows_special_chars :: proc(t: ^testing.T) {
output := strings.to_string(b)
result: []map[string]string
unmarshal_err := json.unmarshal(transmute([]byte)output, &result)
result: []map[string]string = ---
unmarshal_err := json.unmarshal(
transmute([]byte)output,
&result,
allocator = context.temp_allocator,
)
testing.expect(
t,
unmarshal_err == nil,
fmt.aprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
fmt.tprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
)
testing.expect(t, len(result) == 4)
testing.expect(
t,
result[0]["value"] == `has "double quotes"`,
fmt.aprintf("got %q", result[0]["value"]),
fmt.tprintf("got %q", result[0]["value"]),
)
testing.expect(t, result[1]["value"] == `path\to\file`)
testing.expect(t, result[2]["value"] == "line1\nline2")
@@ -89,12 +92,12 @@ test_render_json_rows_empty :: proc(t: ^testing.T) {
output := strings.to_string(b)
result: []map[string]string
unmarshal_err := json.unmarshal_string(output, &result)
result: []map[string]string = ---
unmarshal_err := json.unmarshal_string(output, &result, allocator = context.temp_allocator)
testing.expect(
t,
unmarshal_err == nil,
fmt.aprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
fmt.tprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
)
testing.expect(t, len(result) == 0)
}

View File

@@ -1,8 +0,0 @@
package main
import "core:sys/posix"
is_tty :: proc() -> bool {
return bool(posix.isatty(1))
}

1
version.txt Normal file
View File

@@ -0,0 +1 @@
0.2.0