8 Commits

21 changed files with 689 additions and 468 deletions

View File

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

View File

@@ -1,9 +1,13 @@
# TODOs
1. Consider giving db its own allocator
1. envr scan crashes when there are zero results.
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,6 +75,7 @@ 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]
@@ -136,13 +137,38 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
return false
}
fmt.wprintf(w, "Usage: %s [flags]\n\n", info.usage, flush = false)
fmt.wprintf(w, "%s\n", info.short, flush = 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,
)
if len(info.aliases) > 0 {
fmt.wprintf(w, "\nAliases:\n %s", info.name, flush = false)
fmt.wprintf(
w,
"\n" +
COLOR_HEADINGS +
"Aliases:" +
ANSI_RESET +
"\n\n " +
COLOR_COMMANDS +
"%s" +
ANSI_RESET,
info.name,
flush = false,
)
for a in info.aliases {
fmt.wprintf(w, ", %s", a, flush = false)
fmt.wprintf(w, ", " + COLOR_COMMANDS + "%s" + ANSI_RESET, a, flush = false)
}
fmt.wprintf(w, "\n", flush = false)
}
@@ -153,7 +179,20 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
fmt.wprintf(
w,
"\nFlags:\n -h, --help help for %s\n -c, --config-file <path> config file (default \"~/.envr/config.json\")\n",
"\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")
`,
info.name,
flush = false,
)
@@ -178,11 +217,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, age encrypted database.
`envr keeps your .env synced to a local, 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 ~/data.age
All your data is stored in ~/.envr/data.envr
Getting started is easy:
@@ -209,21 +248,29 @@ at before, restore your backup with:
> envr restore ~/<path to repository>/.env
Usage:
envr [command]
%sUsage:%s
Available Commands:
%senvr%s [command]
%sAvailable Commands:%s
`,
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", c.name, flush = false)
fmt.wprintf(w, " %s%s", COLOR_COMMANDS, 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 {
@@ -235,24 +282,32 @@ Available Commands:
fmt.wprintf(
w,
`
Flags:
-h, --help help for envr
-c, --config-file <path> config file (default "~/.envr/config.json")
"\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")
Use "envr [command] --help" for more information about a command.
Use "` +
COLOR_FLAGS +
"envr" +
ANSI_RESET +
` [command] --help" for more information about a command.
`,
flush = false,
)
}
has_flag :: proc(cmd: ^Command, name: string) -> bool {
_, ok := cmd.flags[name]
if ok {
return true
}
_, ok2 := cmd.bool_set[name]
return ok2
return name in cmd.flags || name in cmd.bool_set
}
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, "[command] --help"), "missing help hint")
}
}
@(test)
test_command_help_backup :: proc(t: ^testing.T) {

View File

@@ -4,6 +4,8 @@ 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 {
@@ -37,7 +39,8 @@ cmd_check :: proc(cmd: ^Command) {
is_dir := os.is_directory(abs_path)
files_in_path: [dynamic]string
// TODO: set a reasonable default
files_in_path := make([dynamic]string, context.temp_allocator)
if is_dir {
scanned, scan_ok := scan_path(abs_path, db.cfg)
@@ -54,8 +57,6 @@ 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,8 +12,7 @@ cmd_edit_config :: proc(cmd: ^Command) {
config_path := cmd.config_path
_, stat_err := os.stat(config_path, context.allocator)
if stat_err != nil {
if !os.exists(config_path) {
fmt.wprintf(
cmd.err,
"Config file does not exist at %s. Run 'envr init' first.\n",

View File

@@ -25,8 +25,6 @@ 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)
search_dirs := search_paths(db.cfg, context.temp_allocator)
if len(search_dirs) == 0 {
fmt.wprintln(
cmd.err,
@@ -23,9 +23,15 @@ cmd_scan :: proc(cmd: ^Command) {
}
// TODO: Figure out a sane default
all_files: [dynamic]string
// 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)
}
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,68 +24,55 @@ cmd_sync :: proc(cmd: ^Command) {
if !list_ok {
return
}
defer delete(files)
// TODO: Set sane default size
results: [dynamic]SyncEntry
defer delete(results)
// 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)
}
for &file in files {
old_path: string
old_path, _ = strings.clone(file.Path, context.temp_allocator)
result, err_msg := db_sync(&db, &file)
for &file, i in files {
result, err := db_sync(&db, &file)
status: string
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:
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"
case:
} else {
status = "OK"
}
if is_dir_updated {
if !db_delete(&db, old_path) {
return
}
// 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 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"}
table_rows := make([dynamic][]string, 0, len(results))
// TODO: Use [2]string instead of slice here
table_rows := make([dynamic][]string, 0, len(results), context.temp_allocator)
for res in results {
row_slice := make([]string, 2)
row_slice[0] = res.Path
row_slice[1] = res.Status
append(&table_rows, row_slice)
row_slice := [2]string{res.Path, res.Status}
append(&table_rows, row_slice[:])
}
render_table(cmd.out, headers, table_rows[:])
} else {
data, marshal_err := json.marshal(results[:])
data, marshal_err := json.marshal(results[:], allocator = context.temp_allocator)
if marshal_err != nil {
fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return
@@ -94,3 +81,23 @@ 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"
}

15
colors.odin Normal file
View File

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

View File

@@ -187,14 +187,10 @@ test_search_paths_expands_tilde :: proc(t: ^testing.T) {
cfg := Config {
ScanConfig = ScanConfig{Include = make([dynamic]string, 0, 1)},
}
defer delete(cfg.ScanConfig.Include)
append(&cfg.ScanConfig.Include, "~")
defer delete(cfg.ScanConfig.Include)
paths := search_paths(cfg)
defer delete(paths)
for path in paths {
defer delete(path)
}
paths := search_paths(cfg, context.temp_allocator)
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)
x25519_pairs, pairs_ok := ssh_to_x25519(keys, context.temp_allocator)
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)
secret_ct := make([]u8, ct_len, context.temp_allocator)
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)
entries := make([]RecipientEntry, num_recipients, context.temp_allocator)
for i in 0 ..< len(x25519_pairs) {
for j in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
@@ -126,8 +126,6 @@ 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
}
@@ -176,11 +174,10 @@ 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)
x25519_pairs, pairs_ok := ssh_to_x25519(keys, context.temp_allocator)
if !pairs_ok {
return
}
defer delete(x25519_pairs)
found := false
matched_pi := 0
@@ -272,33 +269,39 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
return
}
ssh_to_x25519 :: proc(keys: []SshKeyPair) -> (pairs: []X25519Keypair, ok: bool) {
ssh_to_x25519 :: proc(
keys: []SshKeyPair,
allocator := context.temp_allocator,
) -> (
[]X25519Keypair,
bool,
) {
if len(keys) == 0 {
return
return {}, false
}
pairs = make([]X25519Keypair, len(keys))
pairs := make([]X25519Keypair, len(keys), allocator)
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
return pairs, false
}
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
return pairs, false
}
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
return pairs, false
}
ed25519_sk: [64]u8
@@ -313,11 +316,10 @@ ssh_to_x25519 :: proc(keys: []SshKeyPair) -> (pairs: []X25519Keypair, ok: bool)
if sk_rc != 0 {
fmt.println("Error: failed to convert ed25519 private key to curve25519")
delete(pairs)
return
return pairs, false
}
}
ok = true
return
return pairs, true
}

325
db.odin
View File

@@ -5,6 +5,7 @@ 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"
@@ -12,18 +13,21 @@ import "core:strings"
import "sqlite"
SyncFlagEnum :: enum {
Noop,
DirUpdated,
Restored,
BackedUp,
Error,
}
SyncFlag :: bit_set[SyncFlagEnum]
SyncDirection :: enum {
TrustDatabase,
TrustFilesystem,
SyncError :: enum {
None,
DirMissing,
MultipleDirs,
GitRootFailed,
WriteFailed,
ReadFailed,
DbFailed,
}
Db :: struct {
@@ -31,6 +35,7 @@ Db :: struct {
db: ^rawptr,
cfg: Config,
changed: bool,
arena: mem.Dynamic_Arena,
}
EnvFile :: struct {
@@ -41,6 +46,7 @@ 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 {
@@ -52,32 +58,15 @@ delete_envfile :: proc(f: ^EnvFile) {
}
db_open :: proc(cfg_path: string) -> (database: Db, ok: bool) {
database.cfg = load_config(cfg_path) or_return
{
db: ^rawptr
rc := sqlite.db_open(":memory:", &db)
if rc != sqlite.OK {
fmt.printf("Error opening in-memory database: %s\n", sqlite.db_errmsg(db))
return
}
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 {
fmt.printf("Error creating table: %s\n", sqlite.db_errmsg(db))
sqlite.db_close(db)
return
}
database.db = db
}
database = db_init() or_return
database.cfg = load_config(cfg_path, db_allocator(&database)) 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
return database, false
}
} else {
// DB was created
@@ -87,14 +76,42 @@ db_open :: proc(cfg_path: string) -> (database: Db, ok: bool) {
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 {
fmt.printf("Error opening in-memory database: %s\n", sqlite.db_errmsg(db))
return
}
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 {
fmt.printf("Error creating table: %s\n", sqlite.db_errmsg(db))
sqlite.db_close(db)
return
}
database.db = db
mem.dynamic_arena_init(&database.arena)
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.allocator)
defer delete(encrypted_data)
encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.temp_allocator)
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")
@@ -128,8 +145,15 @@ db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool {
}
db_close :: proc(d: ^Db) {
defer sqlite.db_close(d.db)
defer delete_config(&d.cfg)
allocator := db_allocator(d)
defer {
sqlite.db_close(d.db)
delete_config(&d.cfg, allocator)
mem.dynamic_arena_destroy(&d.arena)
}
if d.changed {
rc := sqlite.db_exec(d.db, "VACUUM", nil, nil, nil)
@@ -147,13 +171,14 @@ 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)
data_path := data_path(d.cfg.config_path, allocator)
envr_d := envr_dir(d.cfg.config_path)
os.mkdir_all(envr_d)
@@ -168,7 +193,8 @@ db_close :: proc(d: ^Db) {
}
}
db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]EnvFile, ok: bool) {
// Results will be freed when `db_close` is called.
db_list :: proc(d: ^Db) -> ([]EnvFile, bool) {
stmt: ^rawptr
rc := sqlite.prepare_v2(
d.db,
@@ -179,10 +205,13 @@ db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]En
)
if rc != sqlite.OK {
fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db))
return
return []EnvFile{}, false
}
defer sqlite.finalize(stmt)
allocator := db_allocator(d)
results := make([dynamic]EnvFile, 0, 10, allocator)
for {
rc = sqlite.step(stmt)
if rc == sqlite.DONE {
@@ -190,7 +219,7 @@ db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]En
}
if rc != sqlite.ROW {
fmt.printf("Error stepping query: %s\n", sqlite.db_errmsg(d.db))
return
#no_bounds_check return results[:], false
}
remotes_json := string(sqlite.column_text(stmt, 1))
@@ -212,17 +241,16 @@ db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]En
)
}
ok = true
return
#no_bounds_check return results[:], true
}
// 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)
remotes_json, marshal_err := json.marshal(file.Remotes, allocator = context.temp_allocator)
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 " +
@@ -278,7 +306,8 @@ db_insert :: proc(d: ^Db, file: EnvFile) -> bool {
return true
}
db_fetch :: proc(d: ^Db, path: string, allocator := context.allocator) -> (EnvFile, bool) {
// Result will be freed when `db_close` is called.
db_fetch :: proc(d: ^Db, path: string) -> (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)
@@ -288,6 +317,8 @@ db_fetch :: proc(d: ^Db, path: string, allocator := context.allocator) -> (EnvFi
}
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)
@@ -311,7 +342,7 @@ db_fetch :: proc(d: ^Db, path: string, allocator := context.allocator) -> (EnvFi
json.unmarshal_string(remotes_json, &remotes, allocator = allocator)
}
file_path := clone_cstring(sqlite.column_text(stmt, 0))
file_path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
return EnvFile {
Path = file_path,
@@ -364,7 +395,8 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
dir := filepath.dir(abs_path)
remotes := get_git_remotes(dir)
// TODO: Should we use the db allocator here?
remotes := get_git_remotes(dir, context.allocator)
data, read_err := os.read_entire_file_from_path(abs_path, context.allocator)
defer delete(data)
@@ -375,7 +407,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)
hex_bytes, _ := hex.encode(digest, context.temp_allocator)
return EnvFile {
Path = abs_path,
@@ -387,126 +419,106 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
true
}
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) {
// Reconciles `f` with the filesystem and persists changes to the database.
db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
allocator := db_allocator(d)
result: SyncFlag = {}
old_path := f.Path
_, 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"
}
moved_dirs = dirs
}
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"
if !os.exists(f.Dir) {
moved, err := try_move_dir(d, f, allocator)
if !moved {
return {}, err
}
result += {.DirUpdated}
}
_, file_stat_err := os.stat(f.Path, context.allocator)
if file_stat_err != nil {
if !os.exists(f.Path) {
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
fmt.eprintf("db_sync: failed to write %s: %v\n", f.Path, write_err)
return result, .WriteFailed
}
return result + {.Restored}, ""
if !db_persist(d, f, old_path) {
return result, .DbFailed
}
return result + {.Restored}, .None
}
data, read_err := os.read_entire_file_from_path(f.Path, context.allocator)
data, read_err := os.read_entire_file_from_path(f.Path, allocator)
if read_err != nil {
msg, _ := strings.concatenate(
{"failed to read file for SHA comparison: ", fmt.tprintf("%v", read_err)},
)
return {.Error}, msg
fmt.eprintf("db_sync: failed to read %s: %v\n", f.Path, read_err)
return result, .ReadFailed
}
digest := hash.hash_bytes(hash.Algorithm.SHA256, data)
// TODO: Handle error
hex_bytes, _ := hex.encode(digest)
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
}
current_sha := string(hex_bytes)
if current_sha == f.Sha256 {
return result, ""
}
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
if !db_persist(d, f, old_path) {
return result, .DbFailed
}
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
return result, .None
}
f.contents = string(data)
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
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) {
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 {
@@ -520,38 +532,35 @@ shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool {
return false
}
get_git_remotes :: proc(dir: string) -> [dynamic]string {
remotes: [dynamic]string
remote_set: map[string]bool
defer delete(remote_set)
get_git_remotes :: proc(dir: string, allocator: mem.Allocator) -> [dynamic]string {
config_path, _ := filepath.join({dir, ".git", "config"}, context.temp_allocator)
m, _, ok := ini.load_map_from_path(config_path, context.allocator)
// TODO: Handle error
m, _, ok := ini.load_map_from_path(config_path, context.temp_allocator)
if !ok {
return remotes
return nil
}
defer ini.delete_map(m)
remotes := make([dynamic]string, 0, 1, allocator)
for section_name, section in m {
if strings.has_prefix(section_name, "remote ") {
if url, ok := section["url"]; ok {
remote_set[url] = true
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)
}
}
}
}
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,
@@ -566,7 +575,7 @@ string_to_cstring :: proc(s: string, allocator := context.allocator) -> cstring
return cs
}
// Caller is responsible for freeing the result
// Unless an explicit allocator is passed, 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,7 +309,6 @@ 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,5 +1,7 @@
package main
import "core:crypto/hash"
import "core:encoding/hex"
import "core:fmt"
import "core:os"
import "core:path/filepath"
@@ -8,30 +10,13 @@ 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)),
Remotes = make([dynamic]string, 0, len(remotes), context.temp_allocator),
}
for r in remotes {
append(&f.Remotes, r)
@@ -41,10 +26,10 @@ make_test_env_file :: proc(path, sha, contents: string, remotes: []string = {})
@(test)
test_db_insert_and_fetch :: proc(t: ^testing.T) {
d, ok := make_test_db()
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&d)
path := "/project/.env"
sha := "abc123"
@@ -56,7 +41,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
@@ -69,10 +54,10 @@ test_db_insert_and_fetch :: proc(t: ^testing.T) {
@(test)
test_db_fetch_missing :: proc(t: ^testing.T) {
d, ok := make_test_db()
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&d)
_, fetch_ok := db_fetch(&d, "/nonexistent/.env")
testing.expect(t, !fetch_ok, "fetch missing should return false")
@@ -80,10 +65,9 @@ test_db_fetch_missing :: proc(t: ^testing.T) {
@(test)
test_db_insert_or_replace :: proc(t: ^testing.T) {
d, ok := make_test_db()
d, ok := db_init()
defer db_close(&d)
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)
@@ -95,18 +79,13 @@ 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")
@@ -114,10 +93,10 @@ test_db_insert_or_replace :: proc(t: ^testing.T) {
@(test)
test_db_delete_existing :: proc(t: ^testing.T) {
d, ok := make_test_db()
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&d)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
@@ -131,20 +110,19 @@ test_db_delete_existing :: proc(t: ^testing.T) {
@(test)
test_db_delete_missing :: proc(t: ^testing.T) {
d, ok := make_test_db()
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&d)
testing.expect(t, !db_delete(&d, "/nonexistent/.env"), "delete missing should return false")
}
@(test)
test_db_list_multiple :: proc(t: ^testing.T) {
d, ok := make_test_db()
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&d)
f1 := make_test_env_file("/proj1/.env", "sha1", "A=1", []string{"git@github.com:a/repo.git"})
defer delete(f1.Remotes)
@@ -158,36 +136,27 @@ 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 := make_test_db()
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&d)
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 := make_test_db()
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&d)
testing.expect(t, !d.changed, "changed should start false")
@@ -200,10 +169,10 @@ test_db_insert_sets_changed :: proc(t: ^testing.T) {
@(test)
test_db_delete_sets_changed :: proc(t: ^testing.T) {
d, ok := make_test_db()
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&d)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
@@ -216,10 +185,10 @@ test_db_delete_sets_changed :: proc(t: ^testing.T) {
@(test)
test_db_serialize :: proc(t: ^testing.T) {
d, ok := make_test_db()
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&d)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
@@ -234,37 +203,6 @@ 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 {
@@ -340,8 +278,7 @@ 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)
defer delete_remotes(remotes)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect(t, len(remotes) == 1, "should find 1 remote")
if len(remotes) != 1 do return
@@ -362,8 +299,7 @@ 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)
defer delete_remotes(remotes)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect(t, len(remotes) == 2, "should find 2 remotes")
}
@@ -374,8 +310,7 @@ test_get_git_remotes_no_config :: proc(t: ^testing.T) {
os.mkdir_all(base)
defer os.remove_all(base)
remotes := get_git_remotes(base)
defer delete_remotes(remotes)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect(t, len(remotes) == 0, "should return empty when no .git/config")
}
@@ -394,8 +329,7 @@ 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)
defer delete_remotes(remotes)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect(t, len(remotes) == 0, "should return empty when no remote sections")
}
@@ -429,49 +363,6 @@ 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())
@@ -481,13 +372,14 @@ 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")
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
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)
}
@@ -500,15 +392,22 @@ 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")
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
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)
@@ -520,3 +419,150 @@ 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, age encrypted database.
envr keeps your .env synced to a local, 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
All your data is stored in ~/.envr/data.envr
Getting started is easy:

View File

@@ -36,11 +36,11 @@
inputs',
...
}:
let
mysqlite = pkgs.sqlite.overrideAttrs (old: {
configureFlags = (old.configureFlags or [ ]) ++ [ "--enable-deserialize" ];
});
in
let
mysqlite = pkgs.sqlite.overrideAttrs (old: {
configureFlags = (old.configureFlags or [ ]) ++ [ "--enable-deserialize" ];
});
in
{
_module.args.pkgs = import nixpkgs {
inherit system;
@@ -79,6 +79,13 @@
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, 0, len(options))
selected = make([dynamic]bool, len(options))
cursor: int = 0
scroll_offset: int = 0

View File

@@ -4,9 +4,10 @@ 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))
col_widths := make([dynamic]int, 0, len(headers), context.temp_allocator)
for i in 0 ..< len(headers) {
append(&col_widths, strings.rune_count(headers[i]))
}
@@ -20,11 +21,14 @@ render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) {
}
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
defer delete(col_widths)
strings.builder_init(&b, context.temp_allocator)
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 {
@@ -42,14 +46,38 @@ 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) {
extra := len(s) - strings.rune_count(s)
fmt.sbprintf(b, " %-*s \u2502", width + extra, s)
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,
)
}
strings.write_string(&b, "\u2502")
for i in 0 ..< len(headers) {
cell(&b, headers[i], col_widths[i])
cell(&b, headers[i], col_widths[i], ansi.FG_BRIGHT_GREEN, true)
}
fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b)

View File

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