1 Commits

Author SHA1 Message Date
Spencer Brower
f0b12582ba chore(dev): release 0.4.0 2026-06-18 10:35:49 -04:00
23 changed files with 489 additions and 693 deletions

View File

@@ -2,6 +2,8 @@ on:
push:
branches:
- main
- dev
- odin
permissions:
contents: write

View File

@@ -1,5 +1,22 @@
# Changelog
## [0.4.0](https://github.com/sbrow/envr/compare/v0.3.0...v0.4.0) (2026-06-18)
### Features
* Removed runtime git dependency. ([12574e1](https://github.com/sbrow/envr/commit/12574e123bdedba3aca813143e906ec5e0b95719))
### Bug Fixes
* Fixed memory leaks in the db. ([5059572](https://github.com/sbrow/envr/commit/5059572951b3ec20b3d2027032a9c3be5cb14dba))
### Performance Improvements
* Replaced `fd` with custom internals. ([2ef733f](https://github.com/sbrow/envr/commit/2ef733fe58594b0a0b6e3ef85142b74af445ccb8))
## [0.3.0](https://github.com/sbrow/envr/compare/v0.2.1...v0.3.0) (2026-06-16)
Version 0.3.0 represents a significant departure (and improvement) for envr.

View File

@@ -1,13 +1,9 @@
# TODOs
1. envr scan crashes when there are zero results.
1. Consider giving db its own allocator
27. Commands are still leaking.
28. **db.odin** — Inconsistencies in how struct vs sqlite are named.
29. Add color flag and support non colored output.
2. Generate md and man pages again.
3. **db.odin:324-327** — Map iteration (`remote_set`) is non-deterministic. Same file can produce different JSON on each backup, causing spurious DB diffs. Sort remotes before storing.

View File

@@ -75,7 +75,6 @@ parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Comm
cmd.flags = make(map[string]string)
cmd.bool_set = make(map[string]bool)
// TODO: Optimize loop?
i := 2
for i < len(args) {
arg := args[i]
@@ -137,38 +136,13 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
return false
}
fmt.wprintf(
w,
"%s\n\n\n" +
COLOR_HEADINGS +
"Usage:" +
ANSI_RESET +
"\n\n " +
COLOR_FLAGS +
"%s" +
ANSI_RESET +
" [flags]\n\n",
info.short,
info.usage,
flush = false,
)
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.wprintf(
w,
"\n" +
COLOR_HEADINGS +
"Aliases:" +
ANSI_RESET +
"\n\n " +
COLOR_COMMANDS +
"%s" +
ANSI_RESET,
info.name,
flush = false,
)
fmt.wprintf(w, "\nAliases:\n %s", info.name, flush = false)
for a in info.aliases {
fmt.wprintf(w, ", " + COLOR_COMMANDS + "%s" + ANSI_RESET, a, flush = false)
fmt.wprintf(w, ", %s", a, flush = false)
}
fmt.wprintf(w, "\n", flush = false)
}
@@ -179,20 +153,7 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
fmt.wprintf(
w,
"\n" +
COLOR_HEADINGS +
"Flags:" +
ANSI_RESET +
"\n\n " +
COLOR_FLAGS +
"-h, --help" +
ANSI_RESET +
" help for %s\n " +
COLOR_FLAGS +
"-c, --config-file" +
ANSI_RESET +
` <path> config file (default "~/.envr/config.json")
`,
"\nFlags:\n -h, --help help for %s\n -c, --config-file <path> config file (default \"~/.envr/config.json\")\n",
info.name,
flush = false,
)
@@ -217,11 +178,11 @@ find_command :: proc(name: string) -> (CommandInfo, bool) {
write_usage :: proc(w: io.Writer) {
fmt.wprintf(
w,
`envr keeps your .env synced to a local, encrypted database.
`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.
All your data is stored in ~/.envr/data.envr
All your data is stored in ~/data.age
Getting started is easy:
@@ -248,29 +209,21 @@ at before, restore your backup with:
> envr restore ~/<path to repository>/.env
%sUsage:%s
Usage:
envr [command]
%senvr%s [command]
%sAvailable Commands:%s
Available Commands:
`,
COLOR_HEADINGS,
ANSI_RESET,
COLOR_FLAGS,
ANSI_RESET,
COLOR_HEADINGS,
ANSI_RESET,
flush = false,
)
for c in COMMANDS {
name_start := len(c.name)
fmt.wprintf(w, " %s%s", COLOR_COMMANDS, c.name, flush = false)
fmt.wprintf(w, "%s", c.name, flush = false)
for a in c.aliases {
fmt.wprintf(w, ", %s", a, flush = false)
name_start += len(a) + 2
}
fmt.wprint(w, ANSI_RESET)
padding := 20 - name_start
if padding > 0 {
for _ in 0 ..< padding {
@@ -282,32 +235,24 @@ at before, restore your backup with:
fmt.wprintf(
w,
"\n" +
COLOR_HEADINGS +
"Flags:" +
ANSI_RESET +
"\n\n " +
COLOR_FLAGS +
"-h, --help" +
ANSI_RESET +
" help for envr\n" +
COLOR_FLAGS +
` -c, --config-file` +
ANSI_RESET +
` <path> config file (default "~/.envr/config.json")
`
Flags:
-h, --help help for envr
-c, --config-file <path> config file (default "~/.envr/config.json")
Use "` +
COLOR_FLAGS +
"envr" +
ANSI_RESET +
` [command] --help" for more information about a command.
Use "envr [command] --help" for more information about a command.
`,
flush = false,
)
}
has_flag :: proc(cmd: ^Command, name: string) -> bool {
return name in cmd.flags || name in cmd.bool_set
_, ok := cmd.flags[name]
if ok {
return true
}
_, ok2 := cmd.bool_set[name]
return ok2
}
delete_command :: proc(cmd: ^Command) {

View File

@@ -57,7 +57,7 @@ test_usage_text_contains_flags_and_help_hint :: proc(t: ^testing.T) {
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")
}
}
@(test)
test_command_help_backup :: proc(t: ^testing.T) {

View File

@@ -4,8 +4,6 @@ import "core:fmt"
import "core:os"
import "core:path/filepath"
// TODO: What happens if you pass a non existent path to cmd_check?
// TODO: UX could be improved, so "run envr add ." if file not exists.
cmd_check :: proc(cmd: ^Command) {
check_path: string
if len(cmd.args) > 0 {
@@ -39,8 +37,7 @@ cmd_check :: proc(cmd: ^Command) {
is_dir := os.is_directory(abs_path)
// TODO: set a reasonable default
files_in_path := make([dynamic]string, context.temp_allocator)
files_in_path: [dynamic]string
if is_dir {
scanned, scan_ok := scan_path(abs_path, db.cfg)
@@ -57,6 +54,8 @@ cmd_check :: proc(cmd: ^Command) {
if !list_ok {
return
}
defer delete(db_files)
defer for &file in db_files {delete_envfile(&file)}
not_backed := find_unbacked(files_in_path[:], db_files[:])

View File

@@ -12,7 +12,8 @@ cmd_edit_config :: proc(cmd: ^Command) {
config_path := cmd.config_path
if !os.exists(config_path) {
_, stat_err := os.stat(config_path, context.allocator)
if stat_err != nil {
fmt.wprintf(
cmd.err,
"Config file does not exist at %s. Run 'envr init' first.\n",

View File

@@ -25,6 +25,8 @@ cmd_list :: proc(cmd: ^Command) {
if !list_ok {
return
}
defer delete(rows)
defer for &row in rows {delete_envfile(&row)}
if terminal.is_terminal(os.stdout) {
headers := []string{"Directory", "Path"}

View File

@@ -12,7 +12,7 @@ cmd_scan :: proc(cmd: ^Command) {
}
defer db_close(&db)
search_dirs := search_paths(db.cfg, context.temp_allocator)
search_dirs := search_paths(db.cfg)
if len(search_dirs) == 0 {
fmt.wprintln(
cmd.err,
@@ -23,15 +23,9 @@ cmd_scan :: proc(cmd: ^Command) {
}
// TODO: Figure out a sane default
// Can't use temp allocator becuase strings inside are copied to context.allocator
all_files := make([dynamic]string)
defer {
for &f in all_files {delete(f)}
delete(all_files)
}
all_files: [dynamic]string
for dir in search_dirs {
found, scan_ok := scan_path(dir, db.cfg)
defer delete(found)
if !scan_ok {
fmt.wprintf(cmd.err, "Error scanning %s\n", dir, flush = false)
continue

View File

@@ -24,55 +24,68 @@ cmd_sync :: proc(cmd: ^Command) {
if !list_ok {
return
}
defer delete(files)
// TODO: Can't use temp allocator becuase strings inside are copied to context.allocator
results := make([]SyncEntry, len(files))
defer {
for &e in results {
delete(e.Path)
delete(e.Status)
}
delete(results)
}
// TODO: Set sane default size
results: [dynamic]SyncEntry
defer delete(results)
for &file, i in files {
result, err := db_sync(&db, &file)
for &file in files {
old_path: string
old_path, _ = strings.clone(file.Path, context.temp_allocator)
result, err_msg := db_sync(&db, &file)
status: string
if err != .None {
status = sync_error_message(err)
} else if .BackedUp in result {
status = .DirUpdated in result ? "Moved & Backed Up" : "Backed Up"
} else if .Restored in result {
status = .DirUpdated in result ? "Moved & Restored" : "Restored"
} else if .DirUpdated in result {
status = "Moved"
is_dir_updated := .DirUpdated in result
switch {
case .Error in result:
if len(err_msg) > 0 {
status = err_msg
} else {
status = "error"
}
case .BackedUp in result:
status = "Backed Up"
case .Restored in result:
status = "Restored"
case .DirUpdated in result:
status = "Moved"
case:
status = "OK"
}
// TODO: Handle errors
path_str, _ := strings.clone(file.Path, context.temp_allocator)
status_str, _ := strings.clone(status, context.temp_allocator)
results[i] = SyncEntry {
Path = path_str,
Status = status_str,
if is_dir_updated {
if !db_delete(&db, old_path) {
return
}
}
if db_update_required(result) {
if !db_insert(&db, file) {
return
}
}
path_str, _ := strings.clone(file.Path)
status_str, _ := strings.clone(status)
append(&results, SyncEntry{Path = path_str, Status = status_str})
}
if terminal.is_terminal(os.stdout) {
headers := []string{"File", "Status"}
// TODO: Use [2]string instead of slice here
table_rows := make([dynamic][]string, 0, len(results), context.temp_allocator)
table_rows := make([dynamic][]string, 0, len(results))
for res in results {
row_slice := [2]string{res.Path, res.Status}
append(&table_rows, row_slice[:])
row_slice := make([]string, 2)
row_slice[0] = res.Path
row_slice[1] = res.Status
append(&table_rows, row_slice)
}
render_table(cmd.out, headers, table_rows[:])
} else {
data, marshal_err := json.marshal(results[:], allocator = context.temp_allocator)
data, marshal_err := json.marshal(results[:])
if marshal_err != nil {
fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return
@@ -81,23 +94,3 @@ cmd_sync :: proc(cmd: ^Command) {
}
}
sync_error_message :: proc(e: SyncError) -> string {
switch e {
case .None:
return ""
case .DirMissing:
return "directory missing"
case .MultipleDirs:
return "multiple directories found"
case .GitRootFailed:
return "failed to find git roots"
case .WriteFailed:
return "failed to write file"
case .ReadFailed:
return "failed to read file"
case .DbFailed:
return "failed to update database"
}
return "unknown error"
}

View File

@@ -1,15 +0,0 @@
package main
import "core:terminal/ansi"
COLOR_HEADINGS ::
ansi.CSI + ansi.FG_BRIGHT_GREEN + ";" + ansi.BOLD + ";" + ansi.UNDERLINE + ansi.SGR
COLOR_COMMANDS :: ansi.CSI + ansi.FG_BRIGHT_CYAN + ";" + ansi.BOLD + ansi.SGR
COLOR_EXAMPLE :: ansi.CSI + ansi.ITALIC + ansi.SGR
COLOR_FLAGS :: ansi.CSI + ansi.BOLD + ";" + ansi.FG_BRIGHT_WHITE + ansi.SGR
ANSI_RESET :: ansi.CSI + ansi.RESET + ansi.SGR

View File

@@ -25,16 +25,17 @@ Config :: struct {
config_path: string `json:"-"`,
}
load_config :: proc(config_path: string, allocator := context.allocator) -> (Config, bool) {
// TODO: Should we use context.allocator + defer delete()?
data, read_err := os.read_entire_file_from_path(config_path, context.temp_allocator)
load_config :: proc(config_path: string) -> (Config, bool) {
data, read_err := os.read_entire_file_from_path(config_path, context.allocator)
if read_err != nil {
fmt.println("No config file found. Please run `envr init` to generate one.")
return Config{}, false
}
defer delete(data)
cfg: Config
err := json.unmarshal(data, &cfg, .JSON5, allocator)
// TODO: use json 5
err := json.unmarshal(data, &cfg)
if err != nil {
fmt.printf("Error parsing config: %v\n", err)
return Config{}, false
@@ -52,22 +53,22 @@ default_config_path :: proc(home: string, allocator := context.allocator) -> str
return path
}
delete_config :: proc(cfg: ^Config, allocator := context.allocator) {
delete_config :: proc(cfg: ^Config) {
for key in cfg.Keys {
delete(key.Private, allocator)
delete(key.Public, allocator)
delete(key.Private)
delete(key.Public)
}
delete(cfg.Keys)
delete(cfg.ScanConfig.Matcher, allocator)
delete(cfg.ScanConfig.Matcher)
for exclude in cfg.ScanConfig.Exclude {
delete(exclude, allocator)
delete(exclude)
}
delete(cfg.ScanConfig.Exclude)
for include in cfg.ScanConfig.Include {
delete(include, allocator)
delete(include)
}
delete(cfg.ScanConfig.Include)
}
@@ -84,9 +85,9 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
}
if os.exists(cfg.config_path) && !force {
info, stat_err := os.stat(cfg.config_path, context.temp_allocator)
info, stat_err := os.stat(cfg.config_path, context.allocator)
if stat_err == nil {
defer os.file_info_delete(info, context.temp_allocator)
defer os.file_info_delete(info, context.allocator)
if info.size > 0 {
fmt.println("Config file already exists. Run again with --force to reinitialize.")
return false
@@ -94,15 +95,12 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
}
}
data, marshal_err := json.marshal(
cfg,
{pretty = true, use_spaces = true, spaces = 2},
context.temp_allocator,
)
data, marshal_err := json.marshal(cfg, {pretty = true, use_spaces = true, spaces = 2})
if marshal_err != nil {
fmt.printf("Error marshaling config: %v\n", marshal_err)
return false
}
defer delete(data)
write_err := os.write_entire_file(cfg.config_path, data)
if write_err != nil {
@@ -190,40 +188,32 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
return
}
find_git_roots :: proc(
cfg: Config,
allocator := context.temp_allocator,
) -> (
roots: [dynamic]string,
ok: bool,
) {
paths := search_paths(cfg, allocator)
// TODO: Pass allocator to findr
// findr.find_repos(paths[:], &roots, os.get_processor_core_count(), allocator)
find_git_roots :: proc(cfg: Config) -> (roots: [dynamic]string, ok: bool) {
paths := search_paths(cfg)
findr.find_repos(paths[:], &roots, os.get_processor_core_count())
ok = true
return
}
search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]string {
search_paths :: proc(cfg: Config) -> (paths: [dynamic]string) {
// TODO: Is this okay?
// TODO: handle error
home, _ := os.user_home_dir(context.temp_allocator)
paths, _ := new_clone(cfg.ScanConfig.Include, allocator)
for &include in paths {
for include in cfg.ScanConfig.Include {
// TODO: Do we need to manually expand ~/ in odin?
expanded, _ := strings.replace(include, "~", home, 1, allocator)
expanded, _ := strings.replace(include, "~", home, 1)
if filepath.is_abs(expanded) {
include = expanded
append(&paths, expanded)
} else {
resolved, err := filepath.abs(expanded, allocator)
defer delete(expanded)
resolved, err := filepath.abs(expanded)
if err == nil {
include = resolved
append(&paths, resolved)
}
}
}
return paths^
return
}
envr_dir :: proc(config_path: string) -> string {

View File

@@ -187,10 +187,14 @@ test_search_paths_expands_tilde :: proc(t: ^testing.T) {
cfg := Config {
ScanConfig = ScanConfig{Include = make([dynamic]string, 0, 1)},
}
append(&cfg.ScanConfig.Include, "~")
defer delete(cfg.ScanConfig.Include)
append(&cfg.ScanConfig.Include, "~")
paths := search_paths(cfg, context.temp_allocator)
paths := search_paths(cfg)
defer delete(paths)
for path in paths {
defer delete(path)
}
testing.expect(t, len(paths) == 1, "should have 1 path")
if len(paths) > 0 {

View File

@@ -33,12 +33,12 @@ init_sodium :: proc "contextless" () {
}
}
// TODO: Optimize performance
encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: bool) {
x25519_pairs, pairs_ok := ssh_to_x25519(keys, context.temp_allocator)
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)
@@ -47,7 +47,7 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
randombytes_buf(&main_nonce[0], CRYPTO_SECRETBOX_NONCE_BYTES)
ct_len := len(plaintext) + CRYPTO_SECRETBOX_MAC_BYTES
secret_ct := make([]u8, ct_len, context.temp_allocator)
secret_ct := make([]u8, ct_len)
pt_ptr: [^]u8
if len(plaintext) > 0 {
pt_ptr = &plaintext[0]
@@ -66,7 +66,7 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
}
num_recipients := u32(len(x25519_pairs))
entries := make([]RecipientEntry, num_recipients, context.temp_allocator)
entries := make([]RecipientEntry, num_recipients)
for i in 0 ..< len(x25519_pairs) {
for j in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
@@ -126,6 +126,8 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
mem.copy(&ciphertext[pos], &secret_ct[0], ct_len)
delete(entries)
delete(secret_ct)
ok = true
return
}
@@ -174,10 +176,11 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
enc_nonce: [CRYPTO_BOX_NONCE_BYTES]u8
enc_pub: [CRYPTO_BOX_PUBLICKEY_BYTES]u8
x25519_pairs, pairs_ok := ssh_to_x25519(keys, context.temp_allocator)
x25519_pairs, pairs_ok := ssh_to_x25519(keys)
if !pairs_ok {
return
}
defer delete(x25519_pairs)
found := false
matched_pi := 0
@@ -269,39 +272,33 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
return
}
ssh_to_x25519 :: proc(
keys: []SshKeyPair,
allocator := context.temp_allocator,
) -> (
[]X25519Keypair,
bool,
) {
ssh_to_x25519 :: proc(keys: []SshKeyPair) -> (pairs: []X25519Keypair, ok: bool) {
if len(keys) == 0 {
return {}, false
return
}
pairs := make([]X25519Keypair, len(keys), allocator)
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 pairs, false
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 pairs, false
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 pairs, false
return
}
ed25519_sk: [64]u8
@@ -316,10 +313,11 @@ ssh_to_x25519 :: proc(
if sk_rc != 0 {
fmt.println("Error: failed to convert ed25519 private key to curve25519")
delete(pairs)
return pairs, false
return
}
}
return pairs, true
ok = true
return
}

315
db.odin
View File

@@ -5,7 +5,6 @@ import "core:encoding/hex"
import "core:encoding/ini"
import "core:encoding/json"
import "core:fmt"
import "core:mem"
import "core:os"
import "core:path/filepath"
import "core:strings"
@@ -13,21 +12,18 @@ import "core:strings"
import "sqlite"
SyncFlagEnum :: enum {
Noop,
DirUpdated,
Restored,
BackedUp,
Error,
}
SyncFlag :: bit_set[SyncFlagEnum]
SyncError :: enum {
None,
DirMissing,
MultipleDirs,
GitRootFailed,
WriteFailed,
ReadFailed,
DbFailed,
SyncDirection :: enum {
TrustDatabase,
TrustFilesystem,
}
Db :: struct {
@@ -35,7 +31,6 @@ Db :: struct {
db: ^rawptr,
cfg: Config,
changed: bool,
arena: mem.Dynamic_Arena,
}
EnvFile :: struct {
@@ -46,7 +41,6 @@ EnvFile :: struct {
contents: string,
}
@(deprecated = "call db_close to clean up EnvFiles")
delete_envfile :: proc(f: ^EnvFile) {
delete(f.Path)
for &remote in f.Remotes {
@@ -58,27 +52,9 @@ delete_envfile :: proc(f: ^EnvFile) {
}
db_open :: proc(cfg_path: string) -> (database: Db, ok: bool) {
database = db_init() or_return
database.cfg = load_config(cfg_path, db_allocator(&database)) or_return
database.cfg = load_config(cfg_path) or_return
// TODO: Use different allocators?
data_path := data_path(database.cfg.config_path, context.temp_allocator)
if os.exists(data_path) {
if ok = db_restore_from_encrypted(&database, data_path); !ok {
sqlite.db_close(database.db)
return database, false
}
} else {
// DB was created
database.changed = true
}
return database, true
}
// Creates a database an allocator and fresh, empty table, with zero encryption.
// In production, you most likely want to use `db_open`.
db_init :: proc() -> (database: Db, ok: bool) {
{
db: ^rawptr
rc := sqlite.db_open(":memory:", &db)
if rc != sqlite.OK {
@@ -94,24 +70,31 @@ db_init :: proc() -> (database: Db, ok: bool) {
return
}
database.db = db
}
mem.dynamic_arena_init(&database.arena)
// TODO: Use different allocators?
data_path := data_path(database.cfg.config_path, context.temp_allocator)
if os.exists(data_path) {
if ok = db_restore_from_encrypted(&database, data_path); !ok {
sqlite.db_close(database.db)
return
}
} else {
// DB was created
database.changed = true
}
return database, true
}
db_allocator :: proc(db: ^Db) -> mem.Allocator {
return mem.dynamic_arena_allocator(&db.arena)
}
db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool {
encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.temp_allocator)
encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.allocator)
defer delete(encrypted_data)
if read_err != nil {
fmt.printf("Error reading encrypted database: %v\n", read_err)
return false
}
// TODO: Use context.temp_allocator
plaintext, dec_ok := decrypt(encrypted_data, db.cfg.Keys[:])
if !dec_ok {
fmt.println("Error: decryption failed")
@@ -145,15 +128,8 @@ db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool {
}
db_close :: proc(d: ^Db) {
allocator := db_allocator(d)
defer {
sqlite.db_close(d.db)
delete_config(&d.cfg, allocator)
mem.dynamic_arena_destroy(&d.arena)
}
defer sqlite.db_close(d.db)
defer delete_config(&d.cfg)
if d.changed {
rc := sqlite.db_exec(d.db, "VACUUM", nil, nil, nil)
@@ -171,14 +147,13 @@ db_close :: proc(d: ^Db) {
defer sqlite.free(data)
sqlite_data := data[:sz]
// TODO: PAss allocator chain
encrypted, enc_ok := encrypt(sqlite_data, d.cfg.Keys[:])
if !enc_ok {
fmt.println("Error: encryption failed")
return
}
data_path := data_path(d.cfg.config_path, allocator)
data_path := data_path(d.cfg.config_path)
envr_d := envr_dir(d.cfg.config_path)
os.mkdir_all(envr_d)
@@ -193,8 +168,7 @@ db_close :: proc(d: ^Db) {
}
}
// Results will be freed when `db_close` is called.
db_list :: proc(d: ^Db) -> ([]EnvFile, bool) {
db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]EnvFile, ok: bool) {
stmt: ^rawptr
rc := sqlite.prepare_v2(
d.db,
@@ -205,13 +179,10 @@ db_list :: proc(d: ^Db) -> ([]EnvFile, bool) {
)
if rc != sqlite.OK {
fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db))
return []EnvFile{}, false
return
}
defer sqlite.finalize(stmt)
allocator := db_allocator(d)
results := make([dynamic]EnvFile, 0, 10, allocator)
for {
rc = sqlite.step(stmt)
if rc == sqlite.DONE {
@@ -219,7 +190,7 @@ db_list :: proc(d: ^Db) -> ([]EnvFile, bool) {
}
if rc != sqlite.ROW {
fmt.printf("Error stepping query: %s\n", sqlite.db_errmsg(d.db))
#no_bounds_check return results[:], false
return
}
remotes_json := string(sqlite.column_text(stmt, 1))
@@ -241,16 +212,17 @@ db_list :: proc(d: ^Db) -> ([]EnvFile, bool) {
)
}
#no_bounds_check return results[:], true
ok = true
return
}
// TODO: Should we use context.temp_allocator for proc scoped lifetimes?
db_insert :: proc(d: ^Db, file: EnvFile) -> bool {
remotes_json, marshal_err := json.marshal(file.Remotes, allocator = context.temp_allocator)
remotes_json, marshal_err := json.marshal(file.Remotes)
if marshal_err != nil {
fmt.printf("Error marshaling remotes: %v\n", marshal_err)
return false
}
defer delete(remotes_json)
sql: cstring =
"INSERT OR REPLACE INTO " +
@@ -306,8 +278,7 @@ db_insert :: proc(d: ^Db, file: EnvFile) -> bool {
return true
}
// Result will be freed when `db_close` is called.
db_fetch :: proc(d: ^Db, path: string) -> (EnvFile, bool) {
db_fetch :: proc(d: ^Db, path: string, allocator := context.allocator) -> (EnvFile, bool) {
sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?"
stmt: ^rawptr
rc := sqlite.prepare_v2(d.db, sql, -1, &stmt, nil)
@@ -317,8 +288,6 @@ db_fetch :: proc(d: ^Db, path: string) -> (EnvFile, bool) {
}
defer sqlite.finalize(stmt)
allocator := db_allocator(d)
cpath := to_cstring(path, allocator)
defer delete(cpath, allocator)
rc = sqlite.bind_text(stmt, 1, cpath, -1, nil)
@@ -342,7 +311,7 @@ db_fetch :: proc(d: ^Db, path: string) -> (EnvFile, bool) {
json.unmarshal_string(remotes_json, &remotes, allocator = allocator)
}
file_path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
file_path := clone_cstring(sqlite.column_text(stmt, 0))
return EnvFile {
Path = file_path,
@@ -395,8 +364,7 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
dir := filepath.dir(abs_path)
// TODO: Should we use the db allocator here?
remotes := get_git_remotes(dir, context.allocator)
remotes := get_git_remotes(dir)
data, read_err := os.read_entire_file_from_path(abs_path, context.allocator)
defer delete(data)
@@ -407,7 +375,7 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator)
// TODO: Handle error
hex_bytes, _ := hex.encode(digest, context.temp_allocator)
hex_bytes, _ := hex.encode(digest)
return EnvFile {
Path = abs_path,
@@ -419,106 +387,126 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
true
}
// Reconciles `f` with the filesystem and persists changes to the database.
db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
allocator := db_allocator(d)
db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, string) {
return env_file_sync(f, .TrustFilesystem, d)
}
// If SyncFlag is .BackedUp, Caller is responsible for calling delete on f.contents and f.Sha256
env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncFlag, string) {
result: SyncFlag = {}
old_path := f.Path
if !os.exists(f.Dir) {
moved, err := try_move_dir(d, f, allocator)
if !moved {
return {}, err
_, stat_err := os.stat(f.Dir, context.allocator)
if stat_err != nil {
moved_dirs: [dynamic]string
if d != nil {
dirs, dirs_ok := find_moved_dirs(d, f)
if !dirs_ok {
return {.Error}, "failed to find moved dirs"
}
result += {.DirUpdated}
moved_dirs = dirs
}
if !os.exists(f.Path) {
if len(moved_dirs) == 0 {
return {.Error}, "directory missing"
} else if len(moved_dirs) == 1 {
update_dir(f, moved_dirs[0])
result = {.DirUpdated}
} else {
return {.Error}, "multiple directories found"
}
}
_, file_stat_err := os.stat(f.Path, context.allocator)
if file_stat_err != nil {
write_err := os.write_entire_file(f.Path, f.contents)
if write_err != nil {
fmt.eprintf("db_sync: failed to write %s: %v\n", f.Path, write_err)
return result, .WriteFailed
msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)})
return {.Error}, msg
}
if !db_persist(d, f, old_path) {
return result, .DbFailed
}
return result + {.Restored}, .None
return result + {.Restored}, ""
}
data, read_err := os.read_entire_file_from_path(f.Path, allocator)
data, read_err := os.read_entire_file_from_path(f.Path, context.allocator)
if read_err != nil {
fmt.eprintf("db_sync: failed to read %s: %v\n", f.Path, read_err)
return result, .ReadFailed
msg, _ := strings.concatenate(
{"failed to read file for SHA comparison: ", fmt.tprintf("%v", read_err)},
)
return {.Error}, msg
}
digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator)
hex_bytes, hex_err := hex.encode(digest, allocator)
if hex_err != nil {
fmt.eprintf("db_sync: failed to encode hash for %s: %v\n", f.Path, hex_err)
return result, .ReadFailed
}
digest := hash.hash_bytes(hash.Algorithm.SHA256, data)
// TODO: Handle error
hex_bytes, _ := hex.encode(digest)
current_sha := string(hex_bytes)
if current_sha == f.Sha256 {
if !db_persist(d, f, old_path) {
return result, .DbFailed
return result, ""
}
return result, .None
switch dir {
case .TrustDatabase:
write_err := os.write_entire_file(f.Path, f.contents)
if write_err != nil {
msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)})
return {.Error}, msg
}
return result + {.Restored}, ""
case .TrustFilesystem:
if !env_file_backup(f) {
return {.Error}, "failed to backup file"
}
return result + {.BackedUp}, ""
}
return result, ""
}
find_moved_dirs :: proc(d: ^Db, f: ^EnvFile) -> ([dynamic]string, bool) {
roots, roots_ok := find_git_roots(d.cfg)
if !roots_ok {
return {}, false
}
moved: [dynamic]string
for root in roots {
remotes := get_git_remotes(root)
if shares_remote(f, remotes[:]) {
cloned, _ := strings.clone(root)
append(&moved, cloned)
}
}
return moved, true
}
update_dir :: proc(f: ^EnvFile, new_dir: string) {
f.Dir = new_dir
base := filepath.base(f.Path)
new_path, _ := strings.concatenate({new_dir, "/", base})
f.Path = new_path
f.Remotes = get_git_remotes(new_dir)
}
// Loads the contents of the the file at f.Path into f.contents
//
// Caller is responsible for calling delete on f.contents and f.Sha256
env_file_backup :: proc(f: ^EnvFile) -> bool {
data, read_err := os.read_entire_file_from_path(f.Path, context.allocator)
if read_err != nil {
fmt.printf("Error reading file %s: %v\n", f.Path, read_err)
return false
}
f.contents = string(data)
f.Sha256 = current_sha
if !db_persist(d, f, old_path) {
return result, .DbFailed
}
return result + {.BackedUp}, .None
}
db_persist :: proc(d: ^Db, f: ^EnvFile, old_path: string) -> bool {
if f.Path != old_path {
if !db_delete(d, old_path) {
digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator)
hex_bytes, alloc_err := hex.encode(digest)
if alloc_err != nil {
fmt.printf("Error generating hash for file %s: %v\n", f.Path, alloc_err)
return false
}
}
return db_insert(d, f^)
}
try_move_dir :: proc(d: ^Db, f: ^EnvFile, allocator: mem.Allocator) -> (bool, SyncError) {
roots, ok := find_git_roots(d.cfg)
if !ok {
return false, .GitRootFailed
}
defer {
for root in roots {
delete(root)
}
delete(roots)
}
match_count := 0
matched_dir: string
for root in roots {
remotes := get_git_remotes(root, context.temp_allocator)
if shares_remote(f, remotes[:]) {
match_count += 1
matched_dir = root
}
}
switch match_count {
case 0:
return false, .DirMissing
case 1:
f.Dir, _ = strings.clone(matched_dir, allocator)
base := filepath.base(f.Path)
new_path, _ := filepath.join({f.Dir, base}, allocator)
f.Path = new_path
f.Remotes = get_git_remotes(f.Dir, allocator)
return true, .None
case:
return false, .MultipleDirs
}
f.Sha256 = string(hex_bytes)
return true
}
shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool {
@@ -532,35 +520,38 @@ shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool {
return false
}
get_git_remotes :: proc(dir: string, allocator: mem.Allocator) -> [dynamic]string {
config_path, _ := filepath.join({dir, ".git", "config"}, context.temp_allocator)
// TODO: Handle error
m, _, ok := ini.load_map_from_path(config_path, context.temp_allocator)
if !ok {
return nil
}
get_git_remotes :: proc(dir: string) -> [dynamic]string {
remotes: [dynamic]string
remote_set: map[string]bool
defer delete(remote_set)
remotes := make([dynamic]string, 0, 1, allocator)
config_path, _ := filepath.join({dir, ".git", "config"}, context.temp_allocator)
m, _, ok := ini.load_map_from_path(config_path, context.allocator)
if !ok {
return remotes
}
defer ini.delete_map(m)
for section_name, section in m {
if strings.has_prefix(section_name, "remote ") {
if url, ok := section["url"]; ok {
found := false
for r in remotes {
if r == url {found = true; break}
}
if !found {
// FIXME: Currently leaks when adding a file with envr scan
cloned, _ := strings.clone(url, allocator)
append(&remotes, cloned)
}
remote_set[url] = true
}
}
}
for remote in remote_set {
cloned, _ := strings.clone(remote)
append(&remotes, cloned)
}
return remotes
}
db_update_required :: proc(status: SyncFlag) -> bool {
return .BackedUp in status || .DirUpdated in status
}
to_cstring :: proc {
string_to_cstring,
strings.to_cstring,
@@ -575,7 +566,7 @@ string_to_cstring :: proc(s: string, allocator := context.allocator) -> cstring
return cs
}
// Unless an explicit allocator is passed, caller is responsible for freeing the result
// Caller is responsible for freeing the result
clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string {
str, err := strings.clone_from_cstring(c, allocator)
if err != nil {

View File

@@ -309,6 +309,7 @@ test_ssh_key_parse_from_fixtures :: proc(t: ^testing.T) {
if !x_ok {
return
}
defer delete(x25519_pairs)
testing.expect(t, len(x25519_pairs) == 1, "should have 1 x25519 keypair")
}

View File

@@ -1,7 +1,5 @@
package main
import "core:crypto/hash"
import "core:encoding/hex"
import "core:fmt"
import "core:os"
import "core:path/filepath"
@@ -10,13 +8,30 @@ import "core:testing"
import "sqlite"
make_test_db :: proc() -> (Db, bool) {
db: ^rawptr
rc := sqlite.db_open(":memory:", &db)
if rc != sqlite.OK {
return Db{}, false
}
create_sql: cstring = "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(db, create_sql, nil, nil, nil)
if rc != sqlite.OK {
sqlite.db_close(db)
return Db{}, false
}
return Db{db = db}, true
}
make_test_env_file :: proc(path, sha, contents: string, remotes: []string = {}) -> EnvFile {
f := EnvFile {
Path = path,
Dir = "",
Sha256 = sha,
contents = contents,
Remotes = make([dynamic]string, 0, len(remotes), context.temp_allocator),
Remotes = make([dynamic]string, 0, len(remotes)),
}
for r in remotes {
append(&f.Remotes, r)
@@ -26,10 +41,10 @@ make_test_env_file :: proc(path, sha, contents: string, remotes: []string = {})
@(test)
test_db_insert_and_fetch :: proc(t: ^testing.T) {
d, ok := db_init()
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&d)
defer sqlite.db_close(d.db)
path := "/project/.env"
sha := "abc123"
@@ -41,7 +56,7 @@ test_db_insert_and_fetch :: proc(t: ^testing.T) {
testing.expect(t, db_insert(&d, f), "insert should succeed")
fetched, fetch_ok := db_fetch(&d, "/project/.env")
// defer delete_envfile(&fetched)
defer delete_envfile(&fetched)
testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return
@@ -54,10 +69,10 @@ test_db_insert_and_fetch :: proc(t: ^testing.T) {
@(test)
test_db_fetch_missing :: proc(t: ^testing.T) {
d, ok := db_init()
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&d)
defer sqlite.db_close(d.db)
_, fetch_ok := db_fetch(&d, "/nonexistent/.env")
testing.expect(t, !fetch_ok, "fetch missing should return false")
@@ -65,9 +80,10 @@ test_db_fetch_missing :: proc(t: ^testing.T) {
@(test)
test_db_insert_or_replace :: proc(t: ^testing.T) {
d, ok := db_init()
defer db_close(&d)
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
f1 := make_test_env_file("/project/.env", "sha1", "KEY=old")
defer delete(f1.Remotes)
@@ -79,13 +95,18 @@ test_db_insert_or_replace :: proc(t: ^testing.T) {
results, list_ok := db_list(&d)
testing.expect(t, list_ok, "list should succeed")
if !list_ok do return
defer delete(results)
for &result in results {
defer delete_envfile(&result)
}
testing.expect(t, len(results) == 1, "should have 1 row, not 2")
fetched, fetch_ok := db_fetch(&d, "/project/.env")
testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return
// defer delete_envfile(&fetched)
defer delete_envfile(&fetched)
testing.expect_value(t, fetched.contents, "KEY=new")
testing.expect_value(t, fetched.Sha256, "sha2")
@@ -93,10 +114,10 @@ test_db_insert_or_replace :: proc(t: ^testing.T) {
@(test)
test_db_delete_existing :: proc(t: ^testing.T) {
d, ok := db_init()
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&d)
defer sqlite.db_close(d.db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
@@ -110,19 +131,20 @@ test_db_delete_existing :: proc(t: ^testing.T) {
@(test)
test_db_delete_missing :: proc(t: ^testing.T) {
d, ok := db_init()
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&d)
defer sqlite.db_close(d.db)
testing.expect(t, !db_delete(&d, "/nonexistent/.env"), "delete missing should return false")
}
@(test)
test_db_list_multiple :: proc(t: ^testing.T) {
d, ok := db_init()
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
defer db_close(&d)
if !ok do return
defer sqlite.db_close(d.db)
f1 := make_test_env_file("/proj1/.env", "sha1", "A=1", []string{"git@github.com:a/repo.git"})
defer delete(f1.Remotes)
@@ -136,27 +158,36 @@ test_db_list_multiple :: proc(t: ^testing.T) {
results, list_ok := db_list(&d)
testing.expect(t, list_ok, "list should succeed")
if !list_ok do return
defer delete(results)
defer {
for &result in results {
delete_envfile(&result)
}
}
testing.expect_value(t, len(results), 3)
}
@(test)
test_db_list_empty :: proc(t: ^testing.T) {
d, ok := db_init()
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
defer db_close(&d)
if !ok do return
defer sqlite.db_close(d.db)
results, list_ok := db_list(&d)
testing.expect(t, list_ok, "list should succeed on empty db")
testing.expect(t, len(results) == 0, "should have 0 rows")
if list_ok do delete(results)
}
@(test)
test_db_insert_sets_changed :: proc(t: ^testing.T) {
d, ok := db_init()
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&d)
defer sqlite.db_close(d.db)
testing.expect(t, !d.changed, "changed should start false")
@@ -169,10 +200,10 @@ test_db_insert_sets_changed :: proc(t: ^testing.T) {
@(test)
test_db_delete_sets_changed :: proc(t: ^testing.T) {
d, ok := db_init()
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&d)
defer sqlite.db_close(d.db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
@@ -185,10 +216,10 @@ test_db_delete_sets_changed :: proc(t: ^testing.T) {
@(test)
test_db_serialize :: proc(t: ^testing.T) {
d, ok := db_init()
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&d)
defer sqlite.db_close(d.db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
@@ -203,6 +234,37 @@ test_db_serialize :: proc(t: ^testing.T) {
testing.expect(t, sz > 0, "serialized size should be > 0")
}
@(test)
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 {
@@ -278,7 +340,8 @@ test_get_git_remotes_single :: proc(t: ^testing.T) {
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config")
remotes := get_git_remotes(base, context.temp_allocator)
remotes := get_git_remotes(base)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 1, "should find 1 remote")
if len(remotes) != 1 do return
@@ -299,7 +362,8 @@ test_get_git_remotes_multiple :: proc(t: ^testing.T) {
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config")
remotes := get_git_remotes(base, context.temp_allocator)
remotes := get_git_remotes(base)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 2, "should find 2 remotes")
}
@@ -310,7 +374,8 @@ test_get_git_remotes_no_config :: proc(t: ^testing.T) {
os.mkdir_all(base)
defer os.remove_all(base)
remotes := get_git_remotes(base, context.temp_allocator)
remotes := get_git_remotes(base)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 0, "should return empty when no .git/config")
}
@@ -329,7 +394,8 @@ test_get_git_remotes_no_remotes :: proc(t: ^testing.T) {
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config")
remotes := get_git_remotes(base, context.temp_allocator)
remotes := get_git_remotes(base)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 0, "should return empty when no remote sections")
}
@@ -363,6 +429,49 @@ test_new_env_file_missing :: proc(t: ^testing.T) {
testing.expect(t, !ok, "missing file should return false")
}
@(test)
test_env_file_backup :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-backup-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
err := os.write_entire_file(env_path, "KEY=12345\n")
testing.expect(t, err == nil, ".env file should exist")
f := EnvFile {
Path = env_path,
}
defer delete(f.contents)
defer delete(f.Sha256)
testing.expect(t, env_file_backup(&f), "backup should succeed")
testing.expect_value(t, f.contents, "KEY=12345\n")
testing.expect_value(t, len(f.Sha256), 64)
}
@(test)
test_env_file_backup_missing :: proc(t: ^testing.T) {
f := EnvFile {
Path = "/tmp/envr-nonexistent-backup/.env",
}
testing.expect(t, !env_file_backup(&f), "missing file should return false")
}
@(test)
test_update_dir :: proc(t: ^testing.T) {
f := EnvFile {
Path = "/old/project/.env",
Dir = "/old/project",
Remotes = make([dynamic]string, 0),
}
defer delete_envfile(&f)
update_dir(&f, "/new/location")
testing.expect_value(t, f.Dir, "/new/location")
testing.expect_value(t, f.Path, "/new/location/.env")
}
@(test)
test_closing_db_has_no_leaks :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-leak-%d", os.get_pid())
@@ -372,14 +481,13 @@ test_closing_db_has_no_leaks :: proc(t: ^testing.T) {
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully")
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
}
db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open")
if !ok do return
db_close(&db)
}
@@ -392,22 +500,15 @@ test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully")
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
}
// First open/close creates data.envr on disk
db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open")
if !ok do return
f := make_test_env_file(
"/project/.env",
"abc123",
"SECRET=value",
[]string{"git@github.com:user/repo.git"},
)
f := make_test_env_file("/project/.env", "abc123", "SECRET=value", []string{"git@github.com:user/repo.git"})
defer delete(f.Remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
@@ -419,150 +520,3 @@ test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
db_close(&db2)
}
@(test)
test_db_sync_noop :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-sync-noop-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
content := "KEY=value\n"
write_err := os.write_entire_file(env_path, transmute([]u8)content)
testing.expect(t, write_err == nil, "should write .env file")
digest := hash.hash_bytes(
hash.Algorithm.SHA256,
transmute([]u8)content,
context.temp_allocator,
)
hex_bytes, _ := hex.encode(digest, context.temp_allocator)
sha := string(hex_bytes)
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&d)
f := make_test_env_file(env_path, sha, content)
f.Dir = base
db_insert(&d, f)
result, sync_err := db_sync(&d, &f)
testing.expect(t, sync_err == .None, "sync should not error")
testing.expect(t, result == {}, "should be noop")
}
@(test)
test_db_sync_backed_up :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-sync-backup-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
changed_content := "KEY=changed\n"
write_err := os.write_entire_file(env_path, transmute([]u8)changed_content)
testing.expect(t, write_err == nil, "should write .env file")
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&d)
f := make_test_env_file(env_path, "old_sha", "KEY=original")
f.Dir = base
db_insert(&d, f)
result, sync_err := db_sync(&d, &f)
testing.expect(t, sync_err == .None, "sync should not error")
testing.expect(t, .BackedUp in result, "should be backed up")
}
@(test)
test_db_sync_restored :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-sync-restore-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&d)
f := make_test_env_file(env_path, "some_sha", "SECRET=value")
f.Dir = base
defer delete(f.Remotes)
db_insert(&d, f)
result, err := db_sync(&d, &f)
testing.expect(t, err == .None, "sync should not error")
testing.expect(t, .Restored in result, "should be restored")
data, read_err := os.read_entire_file_from_path(env_path, context.temp_allocator)
testing.expect(t, read_err == nil, "file should exist after restore")
if read_err == nil {
testing.expect_value(t, string(data), "SECRET=value")
}
}
@(test)
test_db_sync_dir_missing :: proc(t: ^testing.T) {
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&d)
f := make_test_env_file("/nonexistent/path/.env", "sha", "KEY=val")
db_insert(&d, f)
result, err := db_sync(&d, &f)
testing.expect(t, err == .DirMissing, "should return DirMissing error")
}
@(test)
test_db_sync_moved :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-sync-moved-%d", os.get_pid())
search_root := fmt.tprintf("%s/search", base)
repo_dir := fmt.tprintf("%s/myproject", search_root)
git_dir := fmt.tprintf("%s/.git", repo_dir)
defer os.remove_all(base)
os.mkdir_all(git_dir)
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir)
write_err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, write_err == nil, "should write .git/config")
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&d)
d.cfg.ScanConfig.Include = make([dynamic]string, 0, 1, context.temp_allocator)
append(&d.cfg.ScanConfig.Include, search_root)
f := make_test_env_file(
"/old/nonexistent/path/.env",
"some_sha",
"SECRET=value",
[]string{"git@github.com:user/repo.git"},
)
testing.expect(t, db_insert(&d, f), "insert should succeed")
result, err := db_sync(&d, &f)
testing.expect(t, err == .None, "sync should not error")
if err != .None do return
testing.expect(t, .DirUpdated in result, "should have DirUpdated flag")
testing.expect(t, .Restored in result, "should have Restored flag")
expected_path := fmt.tprintf("%s/.env", repo_dir)
testing.expect_value(t, f.Path, expected_path)
testing.expect_value(t, f.Dir, repo_dir)
_, old_exists := db_fetch(&d, "/old/nonexistent/path/.env")
testing.expect(t, !old_exists, "old path should be deleted from db")
new_fetched, new_ok := db_fetch(&d, expected_path)
testing.expect(t, new_ok, "new path should exist in db")
if new_ok {
testing.expect_value(t, new_fetched.contents, "SECRET=value")
}
}

View File

@@ -4,11 +4,11 @@ Manage your .env files.
### Synopsis
envr keeps your .env synced to a local, encrypted database.
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 ~/.envr/data.envr
All your data is stored in ~/data.age
Getting started is easy:

View File

@@ -79,13 +79,6 @@
mysqlite
];
doCheck = true;
checkPhase = ''
runHook preCheck
odin test . -all-packages
runHook postCheck
'';
buildPhase = ''
runHook preBuild
echo '${version}' > version.txt

View File

@@ -36,7 +36,7 @@ multi_select :: proc(
return
}
selected = make([dynamic]bool, len(options))
selected = make([dynamic]bool, 0, len(options))
cursor: int = 0
scroll_offset: int = 0

View File

@@ -4,10 +4,9 @@ import "core:encoding/json"
import "core:fmt"
import "core:io"
import "core:strings"
import "core:terminal/ansi"
render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) {
col_widths := make([dynamic]int, 0, len(headers), context.temp_allocator)
col_widths := make([dynamic]int, 0, len(headers))
for i in 0 ..< len(headers) {
append(&col_widths, strings.rune_count(headers[i]))
}
@@ -21,14 +20,11 @@ render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) {
}
b: strings.Builder
strings.builder_init(&b, context.temp_allocator)
strings.builder_init(&b)
defer strings.builder_destroy(&b)
defer delete(col_widths)
hline :: proc(
w: io.Writer,
b: ^strings.Builder,
left, mid, right: string,
widths: [dynamic]int,
) {
hline :: proc(w: io.Writer, 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 {
@@ -46,38 +42,14 @@ render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) {
hline(w, &b, "\u250c", "\u252c", "\u2510", col_widths)
cell :: proc(b: ^strings.Builder, s: string, width: int, color: string = "", center := false) {
before: int
after: int
total_pad := width - strings.rune_count(s)
if center {
before = total_pad / 2
after = total_pad - before
} else {
before = 0
after = total_pad
}
fmt.sbprintf(
b,
" %s%s%s%*s%s%*s%s \u2502",
ansi.CSI,
color,
ansi.SGR,
before,
"",
s,
after,
"",
ansi.CSI + ansi.RESET + ansi.SGR,
)
cell :: proc(b: ^strings.Builder, s: string, width: int) {
extra := len(s) - strings.rune_count(s)
fmt.sbprintf(b, " %-*s \u2502", width + extra, s)
}
strings.write_string(&b, "\u2502")
for i in 0 ..< len(headers) {
cell(&b, headers[i], col_widths[i], ansi.FG_BRIGHT_GREEN, true)
cell(&b, headers[i], col_widths[i])
}
fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b)

View File

@@ -3,7 +3,6 @@ package main
import "core:encoding/json"
import "core:fmt"
import "core:strings"
import "core:terminal/ansi"
import "core:testing"
@(test)
@@ -117,30 +116,13 @@ test_render_table_normal :: proc(t: ^testing.T) {
output := strings.to_string(b)
g := ansi.CSI + ansi.FG_BRIGHT_GREEN + ansi.SGR
r := ANSI_RESET
n := ansi.CSI + ansi.SGR
expected := fmt.tprintf(
"┌───────────────────────────────┐\n" +
"│ %sName%s │ %s Path %s │\n" +
"├──────┼─────────────────────────┤\n" +
"│ %sfoo %s │ %s/home/user/.env %s │\n" +
"│ %sbar %s │ %s/home/user/project/.env%s │\n" +
"└──────┴─────────────────────────┘\n",
g,
r,
g,
r,
n,
r,
n,
r,
n,
r,
n,
r,
)
expected := `┌──────┬─────────────────────────┐
│ Name │ Path │
├──────┼─────────────────────────┤
│ foo │ /home/user/.env │
│ bar │ /home/user/project/.env │
───────────────────────────────
`
testing.expect(
t,
output == expected,
@@ -166,17 +148,11 @@ test_render_table_empty :: proc(t: ^testing.T) {
output := strings.to_string(b)
g := ansi.CSI + ansi.FG_BRIGHT_GREEN + ansi.SGR
r := ANSI_RESET
expected := fmt.tprintf(
"┌──────┐\n" +
"│ %sName%s │\n" +
"├──────┤\n" +
"└──────┘\n",
g,
r,
)
expected := `┌──────┐
│ Name │
├──────┤
└──────┘
`
testing.expect(
t,
output == expected,
@@ -202,30 +178,13 @@ test_render_table_unicode :: proc(t: ^testing.T) {
output := strings.to_string(b)
g := ansi.CSI + ansi.FG_BRIGHT_GREEN + ansi.SGR
r := ANSI_RESET
n := ansi.CSI + ansi.SGR
expected := fmt.tprintf(
"┌─────────────────────┐\n" +
"│ %s Status %s │ %sDetail%s │\n" +
"├─────────────┼────────┤\n" +
"│ %s✓ Available%s │ %sok %s │\n" +
"│ %s✗ Missing %s │ %sfail %s │\n" +
"└─────────────┴────────┘\n",
g,
r,
g,
r,
n,
r,
n,
r,
n,
r,
n,
r,
)
expected := `┌─────────────┬────────┐
│ Status │ Detail │
├─────────────┼────────┤
│ ✓ Available │ ok │
│ ✗ Missing │ fail │
─────────────────────
`
testing.expect(
t,
output == expected,

View File

@@ -1 +1 @@
0.3.0
0.4.0