8 Commits

21 changed files with 689 additions and 468 deletions

View File

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

View File

@@ -1,9 +1,13 @@
# TODOs # TODOs
1. Consider giving db its own allocator 1. envr scan crashes when there are zero results.
27. Commands are still leaking. 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. 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. 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.flags = make(map[string]string)
cmd.bool_set = make(map[string]bool) cmd.bool_set = make(map[string]bool)
// TODO: Optimize loop?
i := 2 i := 2
for i < len(args) { for i < len(args) {
arg := args[i] arg := args[i]
@@ -136,13 +137,38 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
return false return false
} }
fmt.wprintf(w, "Usage: %s [flags]\n\n", info.usage, flush = false) fmt.wprintf(
fmt.wprintf(w, "%s\n", info.short, flush = false) 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 { 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 { 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) fmt.wprintf(w, "\n", flush = false)
} }
@@ -153,7 +179,20 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
fmt.wprintf( fmt.wprintf(
w, 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, info.name,
flush = false, flush = false,
) )
@@ -178,11 +217,11 @@ find_command :: proc(name: string) -> (CommandInfo, bool) {
write_usage :: proc(w: io.Writer) { write_usage :: proc(w: io.Writer) {
fmt.wprintf( fmt.wprintf(
w, 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 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. 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: Getting started is easy:
@@ -209,21 +248,29 @@ at before, restore your backup with:
> envr restore ~/<path to repository>/.env > envr restore ~/<path to repository>/.env
Usage: %sUsage:%s
envr [command]
Available Commands: %senvr%s [command]
%sAvailable Commands:%s
`, `,
COLOR_HEADINGS,
ANSI_RESET,
COLOR_FLAGS,
ANSI_RESET,
COLOR_HEADINGS,
ANSI_RESET,
flush = false, flush = false,
) )
for c in COMMANDS { for c in COMMANDS {
name_start := len(c.name) 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 { for a in c.aliases {
fmt.wprintf(w, ", %s", a, flush = false) fmt.wprintf(w, ", %s", a, flush = false)
name_start += len(a) + 2 name_start += len(a) + 2
} }
fmt.wprint(w, ANSI_RESET)
padding := 20 - name_start padding := 20 - name_start
if padding > 0 { if padding > 0 {
for _ in 0 ..< padding { for _ in 0 ..< padding {
@@ -235,24 +282,32 @@ Available Commands:
fmt.wprintf( fmt.wprintf(
w, w,
` "\n" +
Flags: COLOR_HEADINGS +
-h, --help help for envr "Flags:" +
-c, --config-file <path> config file (default "~/.envr/config.json") 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, flush = false,
) )
} }
has_flag :: proc(cmd: ^Command, name: string) -> bool { has_flag :: proc(cmd: ^Command, name: string) -> bool {
_, ok := cmd.flags[name] return name in cmd.flags || name in cmd.bool_set
if ok {
return true
}
_, ok2 := cmd.bool_set[name]
return ok2
} }
delete_command :: proc(cmd: ^Command) { 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, "Flags:"), "missing Flags section")
testing.expect(t, strings.contains(text, "--help"), "missing --help flag") testing.expect(t, strings.contains(text, "--help"), "missing --help flag")
testing.expect(t, strings.contains(text, "[command] --help"), "missing help hint") testing.expect(t, strings.contains(text, "[command] --help"), "missing help hint")
} }
@(test) @(test)
test_command_help_backup :: proc(t: ^testing.T) { test_command_help_backup :: proc(t: ^testing.T) {

View File

@@ -4,6 +4,8 @@ import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath" 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) { cmd_check :: proc(cmd: ^Command) {
check_path: string check_path: string
if len(cmd.args) > 0 { if len(cmd.args) > 0 {
@@ -37,7 +39,8 @@ cmd_check :: proc(cmd: ^Command) {
is_dir := os.is_directory(abs_path) 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 { if is_dir {
scanned, scan_ok := scan_path(abs_path, db.cfg) scanned, scan_ok := scan_path(abs_path, db.cfg)
@@ -54,8 +57,6 @@ cmd_check :: proc(cmd: ^Command) {
if !list_ok { if !list_ok {
return return
} }
defer delete(db_files)
defer for &file in db_files {delete_envfile(&file)}
not_backed := find_unbacked(files_in_path[:], db_files[:]) 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 config_path := cmd.config_path
_, stat_err := os.stat(config_path, context.allocator) if !os.exists(config_path) {
if stat_err != nil {
fmt.wprintf( fmt.wprintf(
cmd.err, cmd.err,
"Config file does not exist at %s. Run 'envr init' first.\n", "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 { if !list_ok {
return return
} }
defer delete(rows)
defer for &row in rows {delete_envfile(&row)}
if terminal.is_terminal(os.stdout) { if terminal.is_terminal(os.stdout) {
headers := []string{"Directory", "Path"} headers := []string{"Directory", "Path"}

View File

@@ -12,7 +12,7 @@ cmd_scan :: proc(cmd: ^Command) {
} }
defer db_close(&db) defer db_close(&db)
search_dirs := search_paths(db.cfg) search_dirs := search_paths(db.cfg, context.temp_allocator)
if len(search_dirs) == 0 { if len(search_dirs) == 0 {
fmt.wprintln( fmt.wprintln(
cmd.err, cmd.err,
@@ -23,9 +23,15 @@ cmd_scan :: proc(cmd: ^Command) {
} }
// TODO: Figure out a sane default // 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 { for dir in search_dirs {
found, scan_ok := scan_path(dir, db.cfg) found, scan_ok := scan_path(dir, db.cfg)
defer delete(found)
if !scan_ok { if !scan_ok {
fmt.wprintf(cmd.err, "Error scanning %s\n", dir, flush = false) fmt.wprintf(cmd.err, "Error scanning %s\n", dir, flush = false)
continue continue

View File

@@ -24,68 +24,55 @@ cmd_sync :: proc(cmd: ^Command) {
if !list_ok { if !list_ok {
return return
} }
defer delete(files)
// TODO: Set sane default size // TODO: Can't use temp allocator becuase strings inside are copied to context.allocator
results: [dynamic]SyncEntry results := make([]SyncEntry, len(files))
defer delete(results) defer {
for &e in results {
delete(e.Path)
delete(e.Status)
}
delete(results)
}
for &file in files { for &file, i in files {
old_path: string result, err := db_sync(&db, &file)
old_path, _ = strings.clone(file.Path, context.temp_allocator)
result, err_msg := db_sync(&db, &file)
status: string status: string
is_dir_updated := .DirUpdated in result if err != .None {
status = sync_error_message(err)
switch { } else if .BackedUp in result {
case .Error in result: status = .DirUpdated in result ? "Moved & Backed Up" : "Backed Up"
if len(err_msg) > 0 { } else if .Restored in result {
status = err_msg status = .DirUpdated in result ? "Moved & Restored" : "Restored"
} else { } else if .DirUpdated in result {
status = "error"
}
case .BackedUp in result:
status = "Backed Up"
case .Restored in result:
status = "Restored"
case .DirUpdated in result:
status = "Moved" status = "Moved"
case: } else {
status = "OK" status = "OK"
} }
if is_dir_updated { // TODO: Handle errors
if !db_delete(&db, old_path) { path_str, _ := strings.clone(file.Path, context.temp_allocator)
return 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) { if terminal.is_terminal(os.stdout) {
headers := []string{"File", "Status"} 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 { for res in results {
row_slice := make([]string, 2) row_slice := [2]string{res.Path, res.Status}
row_slice[0] = res.Path append(&table_rows, row_slice[:])
row_slice[1] = res.Status
append(&table_rows, row_slice)
} }
render_table(cmd.out, headers, table_rows[:]) render_table(cmd.out, headers, table_rows[:])
} else { } else {
data, marshal_err := json.marshal(results[:]) data, marshal_err := json.marshal(results[:], allocator = context.temp_allocator)
if marshal_err != nil { if marshal_err != nil {
fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false) fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return 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:"-"`, config_path: string `json:"-"`,
} }
load_config :: proc(config_path: string) -> (Config, bool) { load_config :: proc(config_path: string, allocator := context.allocator) -> (Config, bool) {
data, read_err := os.read_entire_file_from_path(config_path, context.allocator) // 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 { if read_err != nil {
fmt.println("No config file found. Please run `envr init` to generate one.") fmt.println("No config file found. Please run `envr init` to generate one.")
return Config{}, false return Config{}, false
} }
defer delete(data)
cfg: Config cfg: Config
// TODO: use json 5 err := json.unmarshal(data, &cfg, .JSON5, allocator)
err := json.unmarshal(data, &cfg)
if err != nil { if err != nil {
fmt.printf("Error parsing config: %v\n", err) fmt.printf("Error parsing config: %v\n", err)
return Config{}, false return Config{}, false
@@ -53,22 +52,22 @@ default_config_path :: proc(home: string, allocator := context.allocator) -> str
return path return path
} }
delete_config :: proc(cfg: ^Config) { delete_config :: proc(cfg: ^Config, allocator := context.allocator) {
for key in cfg.Keys { for key in cfg.Keys {
delete(key.Private) delete(key.Private, allocator)
delete(key.Public) delete(key.Public, allocator)
} }
delete(cfg.Keys) delete(cfg.Keys)
delete(cfg.ScanConfig.Matcher) delete(cfg.ScanConfig.Matcher, allocator)
for exclude in cfg.ScanConfig.Exclude { for exclude in cfg.ScanConfig.Exclude {
delete(exclude) delete(exclude, allocator)
} }
delete(cfg.ScanConfig.Exclude) delete(cfg.ScanConfig.Exclude)
for include in cfg.ScanConfig.Include { for include in cfg.ScanConfig.Include {
delete(include) delete(include, allocator)
} }
delete(cfg.ScanConfig.Include) delete(cfg.ScanConfig.Include)
} }
@@ -85,9 +84,9 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
} }
if os.exists(cfg.config_path) && !force { 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 { 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 { if info.size > 0 {
fmt.println("Config file already exists. Run again with --force to reinitialize.") fmt.println("Config file already exists. Run again with --force to reinitialize.")
return false 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 { if marshal_err != nil {
fmt.printf("Error marshaling config: %v\n", marshal_err) fmt.printf("Error marshaling config: %v\n", marshal_err)
return false return false
} }
defer delete(data)
write_err := os.write_entire_file(cfg.config_path, data) write_err := os.write_entire_file(cfg.config_path, data)
if write_err != nil { if write_err != nil {
@@ -188,32 +190,40 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
return return
} }
find_git_roots :: proc(cfg: Config) -> (roots: [dynamic]string, ok: bool) { find_git_roots :: proc(
paths := search_paths(cfg) 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()) findr.find_repos(paths[:], &roots, os.get_processor_core_count())
ok = true ok = true
return return
} }
search_paths :: proc(cfg: Config) -> (paths: [dynamic]string) { search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]string {
// TODO: Is this okay?
// TODO: handle error // TODO: handle error
home, _ := os.user_home_dir(context.temp_allocator) 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? // 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) { if filepath.is_abs(expanded) {
append(&paths, expanded) include = expanded
} else { } else {
defer delete(expanded) resolved, err := filepath.abs(expanded, allocator)
resolved, err := filepath.abs(expanded)
if err == nil { if err == nil {
append(&paths, resolved) include = resolved
} }
} }
} }
return return paths^
} }
envr_dir :: proc(config_path: string) -> string { envr_dir :: proc(config_path: string) -> string {

View File

@@ -187,14 +187,10 @@ test_search_paths_expands_tilde :: proc(t: ^testing.T) {
cfg := Config { cfg := Config {
ScanConfig = ScanConfig{Include = make([dynamic]string, 0, 1)}, ScanConfig = ScanConfig{Include = make([dynamic]string, 0, 1)},
} }
defer delete(cfg.ScanConfig.Include)
append(&cfg.ScanConfig.Include, "~") append(&cfg.ScanConfig.Include, "~")
defer delete(cfg.ScanConfig.Include)
paths := search_paths(cfg) paths := search_paths(cfg, context.temp_allocator)
defer delete(paths)
for path in paths {
defer delete(path)
}
testing.expect(t, len(paths) == 1, "should have 1 path") testing.expect(t, len(paths) == 1, "should have 1 path")
if len(paths) > 0 { 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) { 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 { if !pairs_ok {
return return
} }
defer delete(x25519_pairs)
sym_key: [CRYPTO_SECRETBOX_KEY_BYTES]u8 sym_key: [CRYPTO_SECRETBOX_KEY_BYTES]u8
randombytes_buf(&sym_key[0], CRYPTO_SECRETBOX_KEY_BYTES) 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) randombytes_buf(&main_nonce[0], CRYPTO_SECRETBOX_NONCE_BYTES)
ct_len := len(plaintext) + CRYPTO_SECRETBOX_MAC_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 pt_ptr: [^]u8
if len(plaintext) > 0 { if len(plaintext) > 0 {
pt_ptr = &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)) 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 i in 0 ..< len(x25519_pairs) {
for j in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES { 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) mem.copy(&ciphertext[pos], &secret_ct[0], ct_len)
delete(entries)
delete(secret_ct)
ok = true ok = true
return return
} }
@@ -176,11 +174,10 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
enc_nonce: [CRYPTO_BOX_NONCE_BYTES]u8 enc_nonce: [CRYPTO_BOX_NONCE_BYTES]u8
enc_pub: [CRYPTO_BOX_PUBLICKEY_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 { if !pairs_ok {
return return
} }
defer delete(x25519_pairs)
found := false found := false
matched_pi := 0 matched_pi := 0
@@ -272,33 +269,39 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
return 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 { if len(keys) == 0 {
return return {}, false
} }
pairs = make([]X25519Keypair, len(keys)) pairs := make([]X25519Keypair, len(keys), allocator)
for i in 0 ..< len(keys) { for i in 0 ..< len(keys) {
ssh_kp, parse_ok := parse_ssh_private_key(keys[i].Private) ssh_kp, parse_ok := parse_ssh_private_key(keys[i].Private)
if !parse_ok { if !parse_ok {
fmt.printf("Error: failed to parse SSH private key: %s\n", keys[i].Private) fmt.printf("Error: failed to parse SSH private key: %s\n", keys[i].Private)
delete(pairs) delete(pairs)
return return pairs, false
} }
ssh_pub, pub_ok := parse_ssh_public_key(keys[i].Public) ssh_pub, pub_ok := parse_ssh_public_key(keys[i].Public)
if !pub_ok { if !pub_ok {
fmt.printf("Error: failed to parse SSH public key: %s\n", keys[i].Public) fmt.printf("Error: failed to parse SSH public key: %s\n", keys[i].Public)
delete(pairs) delete(pairs)
return return pairs, false
} }
pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0]) pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0])
if pk_rc != 0 { if pk_rc != 0 {
fmt.println("Error: failed to convert ed25519 public key to curve25519") fmt.println("Error: failed to convert ed25519 public key to curve25519")
delete(pairs) delete(pairs)
return return pairs, false
} }
ed25519_sk: [64]u8 ed25519_sk: [64]u8
@@ -313,11 +316,10 @@ ssh_to_x25519 :: proc(keys: []SshKeyPair) -> (pairs: []X25519Keypair, ok: bool)
if sk_rc != 0 { if sk_rc != 0 {
fmt.println("Error: failed to convert ed25519 private key to curve25519") fmt.println("Error: failed to convert ed25519 private key to curve25519")
delete(pairs) delete(pairs)
return return pairs, false
} }
} }
ok = true return pairs, true
return
} }

311
db.odin
View File

@@ -5,6 +5,7 @@ import "core:encoding/hex"
import "core:encoding/ini" import "core:encoding/ini"
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:mem"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings" import "core:strings"
@@ -12,18 +13,21 @@ import "core:strings"
import "sqlite" import "sqlite"
SyncFlagEnum :: enum { SyncFlagEnum :: enum {
Noop,
DirUpdated, DirUpdated,
Restored, Restored,
BackedUp, BackedUp,
Error,
} }
SyncFlag :: bit_set[SyncFlagEnum] SyncFlag :: bit_set[SyncFlagEnum]
SyncDirection :: enum { SyncError :: enum {
TrustDatabase, None,
TrustFilesystem, DirMissing,
MultipleDirs,
GitRootFailed,
WriteFailed,
ReadFailed,
DbFailed,
} }
Db :: struct { Db :: struct {
@@ -31,6 +35,7 @@ Db :: struct {
db: ^rawptr, db: ^rawptr,
cfg: Config, cfg: Config,
changed: bool, changed: bool,
arena: mem.Dynamic_Arena,
} }
EnvFile :: struct { EnvFile :: struct {
@@ -41,6 +46,7 @@ EnvFile :: struct {
contents: string, contents: string,
} }
@(deprecated = "call db_close to clean up EnvFiles")
delete_envfile :: proc(f: ^EnvFile) { delete_envfile :: proc(f: ^EnvFile) {
delete(f.Path) delete(f.Path)
for &remote in f.Remotes { for &remote in f.Remotes {
@@ -52,9 +58,27 @@ delete_envfile :: proc(f: ^EnvFile) {
} }
db_open :: proc(cfg_path: string) -> (database: Db, ok: bool) { db_open :: proc(cfg_path: string) -> (database: Db, ok: bool) {
database.cfg = load_config(cfg_path) or_return 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 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 db: ^rawptr
rc := sqlite.db_open(":memory:", &db) rc := sqlite.db_open(":memory:", &db)
if rc != sqlite.OK { if rc != sqlite.OK {
@@ -70,31 +94,24 @@ db_open :: proc(cfg_path: string) -> (database: Db, ok: bool) {
return return
} }
database.db = db database.db = db
}
// TODO: Use different allocators? mem.dynamic_arena_init(&database.arena)
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 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 { 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) encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.temp_allocator)
defer delete(encrypted_data)
if read_err != nil { if read_err != nil {
fmt.printf("Error reading encrypted database: %v\n", read_err) fmt.printf("Error reading encrypted database: %v\n", read_err)
return false return false
} }
// TODO: Use context.temp_allocator
plaintext, dec_ok := decrypt(encrypted_data, db.cfg.Keys[:]) plaintext, dec_ok := decrypt(encrypted_data, db.cfg.Keys[:])
if !dec_ok { if !dec_ok {
fmt.println("Error: decryption failed") 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) { db_close :: proc(d: ^Db) {
defer sqlite.db_close(d.db) allocator := db_allocator(d)
defer delete_config(&d.cfg)
defer {
sqlite.db_close(d.db)
delete_config(&d.cfg, allocator)
mem.dynamic_arena_destroy(&d.arena)
}
if d.changed { if d.changed {
rc := sqlite.db_exec(d.db, "VACUUM", nil, nil, nil) rc := sqlite.db_exec(d.db, "VACUUM", nil, nil, nil)
@@ -147,13 +171,14 @@ db_close :: proc(d: ^Db) {
defer sqlite.free(data) defer sqlite.free(data)
sqlite_data := data[:sz] sqlite_data := data[:sz]
// TODO: PAss allocator chain
encrypted, enc_ok := encrypt(sqlite_data, d.cfg.Keys[:]) encrypted, enc_ok := encrypt(sqlite_data, d.cfg.Keys[:])
if !enc_ok { if !enc_ok {
fmt.println("Error: encryption failed") fmt.println("Error: encryption failed")
return 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) envr_d := envr_dir(d.cfg.config_path)
os.mkdir_all(envr_d) 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 stmt: ^rawptr
rc := sqlite.prepare_v2( rc := sqlite.prepare_v2(
d.db, d.db,
@@ -179,10 +205,13 @@ db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]En
) )
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db))
return return []EnvFile{}, false
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
allocator := db_allocator(d)
results := make([dynamic]EnvFile, 0, 10, allocator)
for { for {
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc == sqlite.DONE { if rc == sqlite.DONE {
@@ -190,7 +219,7 @@ db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]En
} }
if rc != sqlite.ROW { if rc != sqlite.ROW {
fmt.printf("Error stepping query: %s\n", sqlite.db_errmsg(d.db)) 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)) 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 #no_bounds_check return results[:], true
return
} }
// TODO: Should we use context.temp_allocator for proc scoped lifetimes?
db_insert :: proc(d: ^Db, file: EnvFile) -> bool { 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 { if marshal_err != nil {
fmt.printf("Error marshaling remotes: %v\n", marshal_err) fmt.printf("Error marshaling remotes: %v\n", marshal_err)
return false return false
} }
defer delete(remotes_json)
sql: cstring = sql: cstring =
"INSERT OR REPLACE INTO " + "INSERT OR REPLACE INTO " +
@@ -278,7 +306,8 @@ db_insert :: proc(d: ^Db, file: EnvFile) -> bool {
return true 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 = ?" sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?"
stmt: ^rawptr stmt: ^rawptr
rc := sqlite.prepare_v2(d.db, sql, -1, &stmt, nil) 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) defer sqlite.finalize(stmt)
allocator := db_allocator(d)
cpath := to_cstring(path, allocator) cpath := to_cstring(path, allocator)
defer delete(cpath, allocator) defer delete(cpath, allocator)
rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) 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) 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 { return EnvFile {
Path = file_path, Path = file_path,
@@ -364,7 +395,8 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
dir := filepath.dir(abs_path) 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) data, read_err := os.read_entire_file_from_path(abs_path, context.allocator)
defer delete(data) 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) digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator)
// TODO: Handle error // TODO: Handle error
hex_bytes, _ := hex.encode(digest) hex_bytes, _ := hex.encode(digest, context.temp_allocator)
return EnvFile { return EnvFile {
Path = abs_path, Path = abs_path,
@@ -387,126 +419,106 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
true true
} }
db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, string) { // Reconciles `f` with the filesystem and persists changes to the database.
return env_file_sync(f, .TrustFilesystem, d) db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
} allocator := db_allocator(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 = {} result: SyncFlag = {}
old_path := f.Path
_, stat_err := os.stat(f.Dir, context.allocator) if !os.exists(f.Dir) {
if stat_err != nil { moved, err := try_move_dir(d, f, allocator)
moved_dirs: [dynamic]string if !moved {
return {}, err
if d != nil {
dirs, dirs_ok := find_moved_dirs(d, f)
if !dirs_ok {
return {.Error}, "failed to find moved dirs"
} }
moved_dirs = dirs result += {.DirUpdated}
} }
if len(moved_dirs) == 0 { if !os.exists(f.Path) {
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) write_err := os.write_entire_file(f.Path, f.contents)
if write_err != nil { if write_err != nil {
msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)}) fmt.eprintf("db_sync: failed to write %s: %v\n", f.Path, write_err)
return {.Error}, msg 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 { if read_err != nil {
msg, _ := strings.concatenate( fmt.eprintf("db_sync: failed to read %s: %v\n", f.Path, read_err)
{"failed to read file for SHA comparison: ", fmt.tprintf("%v", read_err)}, return result, .ReadFailed
)
return {.Error}, msg
} }
digest := hash.hash_bytes(hash.Algorithm.SHA256, data) digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator)
// TODO: Handle error hex_bytes, hex_err := hex.encode(digest, allocator)
hex_bytes, _ := hex.encode(digest) 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) current_sha := string(hex_bytes)
if current_sha == f.Sha256 { if current_sha == f.Sha256 {
return result, "" if !db_persist(d, f, old_path) {
return result, .DbFailed
} }
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.contents = string(data)
digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator) f.Sha256 = current_sha
hex_bytes, alloc_err := hex.encode(digest) if !db_persist(d, f, old_path) {
if alloc_err != nil { return result, .DbFailed
fmt.printf("Error generating hash for file %s: %v\n", f.Path, alloc_err) }
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 false
} }
f.Sha256 = string(hex_bytes) }
return true 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
}
} }
shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool { shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool {
@@ -520,38 +532,35 @@ shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool {
return false return false
} }
get_git_remotes :: proc(dir: string) -> [dynamic]string { get_git_remotes :: proc(dir: string, allocator: mem.Allocator) -> [dynamic]string {
remotes: [dynamic]string
remote_set: map[string]bool
defer delete(remote_set)
config_path, _ := filepath.join({dir, ".git", "config"}, context.temp_allocator) 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 { if !ok {
return remotes return nil
} }
defer ini.delete_map(m)
remotes := make([dynamic]string, 0, 1, allocator)
for section_name, section in m { for section_name, section in m {
if strings.has_prefix(section_name, "remote ") { if strings.has_prefix(section_name, "remote ") {
if url, ok := section["url"]; ok { 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)
for remote in remote_set {
cloned, _ := strings.clone(remote)
append(&remotes, cloned) append(&remotes, cloned)
} }
}
}
}
return remotes return remotes
} }
db_update_required :: proc(status: SyncFlag) -> bool {
return .BackedUp in status || .DirUpdated in status
}
to_cstring :: proc { to_cstring :: proc {
string_to_cstring, string_to_cstring,
strings.to_cstring, strings.to_cstring,
@@ -566,7 +575,7 @@ string_to_cstring :: proc(s: string, allocator := context.allocator) -> cstring
return cs 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 { clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string {
str, err := strings.clone_from_cstring(c, allocator) str, err := strings.clone_from_cstring(c, allocator)
if err != nil { if err != nil {

View File

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

View File

@@ -1,5 +1,7 @@
package main package main
import "core:crypto/hash"
import "core:encoding/hex"
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
@@ -8,30 +10,13 @@ import "core:testing"
import "sqlite" 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 { make_test_env_file :: proc(path, sha, contents: string, remotes: []string = {}) -> EnvFile {
f := EnvFile { f := EnvFile {
Path = path, Path = path,
Dir = "", Dir = "",
Sha256 = sha, Sha256 = sha,
contents = contents, contents = contents,
Remotes = make([dynamic]string, 0, len(remotes)), Remotes = make([dynamic]string, 0, len(remotes), context.temp_allocator),
} }
for r in remotes { for r in remotes {
append(&f.Remotes, r) append(&f.Remotes, r)
@@ -41,10 +26,10 @@ make_test_env_file :: proc(path, sha, contents: string, remotes: []string = {})
@(test) @(test)
test_db_insert_and_fetch :: proc(t: ^testing.T) { 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") testing.expect(t, ok, "failed to create test db")
if !ok do return if !ok do return
defer sqlite.db_close(d.db) defer db_close(&d)
path := "/project/.env" path := "/project/.env"
sha := "abc123" 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") testing.expect(t, db_insert(&d, f), "insert should succeed")
fetched, fetch_ok := db_fetch(&d, "/project/.env") fetched, fetch_ok := db_fetch(&d, "/project/.env")
defer delete_envfile(&fetched) // defer delete_envfile(&fetched)
testing.expect(t, fetch_ok, "fetch should succeed") testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return if !fetch_ok do return
@@ -69,10 +54,10 @@ test_db_insert_and_fetch :: proc(t: ^testing.T) {
@(test) @(test)
test_db_fetch_missing :: proc(t: ^testing.T) { 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") testing.expect(t, ok, "failed to create test db")
if !ok do return if !ok do return
defer sqlite.db_close(d.db) defer db_close(&d)
_, fetch_ok := db_fetch(&d, "/nonexistent/.env") _, fetch_ok := db_fetch(&d, "/nonexistent/.env")
testing.expect(t, !fetch_ok, "fetch missing should return false") testing.expect(t, !fetch_ok, "fetch missing should return false")
@@ -80,10 +65,9 @@ test_db_fetch_missing :: proc(t: ^testing.T) {
@(test) @(test)
test_db_insert_or_replace :: proc(t: ^testing.T) { 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") 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") f1 := make_test_env_file("/project/.env", "sha1", "KEY=old")
defer delete(f1.Remotes) defer delete(f1.Remotes)
@@ -95,18 +79,13 @@ test_db_insert_or_replace :: proc(t: ^testing.T) {
results, list_ok := db_list(&d) results, list_ok := db_list(&d)
testing.expect(t, list_ok, "list should succeed") 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") testing.expect(t, len(results) == 1, "should have 1 row, not 2")
fetched, fetch_ok := db_fetch(&d, "/project/.env") fetched, fetch_ok := db_fetch(&d, "/project/.env")
testing.expect(t, fetch_ok, "fetch should succeed") testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return 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.contents, "KEY=new")
testing.expect_value(t, fetched.Sha256, "sha2") testing.expect_value(t, fetched.Sha256, "sha2")
@@ -114,10 +93,10 @@ test_db_insert_or_replace :: proc(t: ^testing.T) {
@(test) @(test)
test_db_delete_existing :: proc(t: ^testing.T) { 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") testing.expect(t, ok, "failed to create test db")
if !ok do return if !ok do return
defer sqlite.db_close(d.db) defer db_close(&d)
f := make_test_env_file("/project/.env", "sha", "KEY=val") f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes) defer delete(f.Remotes)
@@ -131,20 +110,19 @@ test_db_delete_existing :: proc(t: ^testing.T) {
@(test) @(test)
test_db_delete_missing :: proc(t: ^testing.T) { 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") testing.expect(t, ok, "failed to create test db")
if !ok do return 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") testing.expect(t, !db_delete(&d, "/nonexistent/.env"), "delete missing should return false")
} }
@(test) @(test)
test_db_list_multiple :: proc(t: ^testing.T) { 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") testing.expect(t, ok, "failed to create test db")
if !ok do return defer db_close(&d)
defer sqlite.db_close(d.db)
f1 := make_test_env_file("/proj1/.env", "sha1", "A=1", []string{"git@github.com:a/repo.git"}) f1 := make_test_env_file("/proj1/.env", "sha1", "A=1", []string{"git@github.com:a/repo.git"})
defer delete(f1.Remotes) defer delete(f1.Remotes)
@@ -158,36 +136,27 @@ test_db_list_multiple :: proc(t: ^testing.T) {
results, list_ok := db_list(&d) results, list_ok := db_list(&d)
testing.expect(t, list_ok, "list should succeed") 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) testing.expect_value(t, len(results), 3)
} }
@(test) @(test)
test_db_list_empty :: proc(t: ^testing.T) { 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") testing.expect(t, ok, "failed to create test db")
if !ok do return defer db_close(&d)
defer sqlite.db_close(d.db)
results, list_ok := db_list(&d) results, list_ok := db_list(&d)
testing.expect(t, list_ok, "list should succeed on empty db") testing.expect(t, list_ok, "list should succeed on empty db")
testing.expect(t, len(results) == 0, "should have 0 rows") testing.expect(t, len(results) == 0, "should have 0 rows")
if list_ok do delete(results)
} }
@(test) @(test)
test_db_insert_sets_changed :: proc(t: ^testing.T) { 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") testing.expect(t, ok, "failed to create test db")
if !ok do return if !ok do return
defer sqlite.db_close(d.db) defer db_close(&d)
testing.expect(t, !d.changed, "changed should start false") testing.expect(t, !d.changed, "changed should start false")
@@ -200,10 +169,10 @@ test_db_insert_sets_changed :: proc(t: ^testing.T) {
@(test) @(test)
test_db_delete_sets_changed :: proc(t: ^testing.T) { 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") testing.expect(t, ok, "failed to create test db")
if !ok do return if !ok do return
defer sqlite.db_close(d.db) defer db_close(&d)
f := make_test_env_file("/project/.env", "sha", "KEY=val") f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes) defer delete(f.Remotes)
@@ -216,10 +185,10 @@ test_db_delete_sets_changed :: proc(t: ^testing.T) {
@(test) @(test)
test_db_serialize :: proc(t: ^testing.T) { 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") testing.expect(t, ok, "failed to create test db")
if !ok do return if !ok do return
defer sqlite.db_close(d.db) defer db_close(&d)
f := make_test_env_file("/project/.env", "sha", "KEY=val") f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes) 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") 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)
test_shares_remote_overlap :: proc(t: ^testing.T) { test_shares_remote_overlap :: proc(t: ^testing.T) {
f := EnvFile { 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) err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config") testing.expect(t, err == nil, "should write .git/config")
remotes := get_git_remotes(base) remotes := get_git_remotes(base, context.temp_allocator)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 1, "should find 1 remote") testing.expect(t, len(remotes) == 1, "should find 1 remote")
if len(remotes) != 1 do return 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) err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config") testing.expect(t, err == nil, "should write .git/config")
remotes := get_git_remotes(base) remotes := get_git_remotes(base, context.temp_allocator)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 2, "should find 2 remotes") 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) os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
remotes := get_git_remotes(base) remotes := get_git_remotes(base, context.temp_allocator)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 0, "should return empty when no .git/config") 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) err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config") testing.expect(t, err == nil, "should write .git/config")
remotes := get_git_remotes(base) remotes := get_git_remotes(base, context.temp_allocator)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 0, "should return empty when no remote sections") 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") 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)
test_closing_db_has_no_leaks :: proc(t: ^testing.T) { test_closing_db_has_no_leaks :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-leak-%d", os.get_pid()) 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) cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect(t, err == nil, "cfgPath should build successfully")
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path) 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") testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
}
db, ok := db_open(cfg_path) db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open") testing.expect(t, ok, "db should open")
if !ok do return
db_close(&db) 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) cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect(t, err == nil, "cfgPath should build successfully")
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path) 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") testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
}
// First open/close creates data.envr on disk // First open/close creates data.envr on disk
db, ok := db_open(cfg_path) db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open") testing.expect(t, ok, "db should open")
if !ok do return 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) defer delete(f.Remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed") testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db) db_close(&db)
@@ -520,3 +419,150 @@ test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
db_close(&db2) 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 ### 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 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. 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: Getting started is easy:

View File

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

View File

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

View File

@@ -4,9 +4,10 @@ import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:io" import "core:io"
import "core:strings" import "core:strings"
import "core:terminal/ansi"
render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) { 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) { for i in 0 ..< len(headers) {
append(&col_widths, strings.rune_count(headers[i])) 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 b: strings.Builder
strings.builder_init(&b) strings.builder_init(&b, context.temp_allocator)
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) strings.write_string(b, left)
for i in 0 ..< len(widths) { for i in 0 ..< len(widths) {
for _ in 0 ..< widths[i] + 2 { 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) hline(w, &b, "\u250c", "\u252c", "\u2510", col_widths)
cell :: proc(b: ^strings.Builder, s: string, width: int) { cell :: proc(b: ^strings.Builder, s: string, width: int, color: string = "", center := false) {
extra := len(s) - strings.rune_count(s) before: int
fmt.sbprintf(b, " %-*s \u2502", width + extra, s) 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") strings.write_string(&b, "\u2502")
for i in 0 ..< len(headers) { 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) fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b) strings.builder_reset(&b)

View File

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