18 Commits

Author SHA1 Message Date
spencer 674f23ad2f feat: Added uninstall command. 2026-07-13 14:14:47 -04:00
spencer d4c935ec07 test: Added a test to ensure db updates don't break hardlinks. 2026-07-13 13:54:06 -04:00
spencer e0a339e8ec feat: Added bash completion support. 2026-07-13 13:35:05 -04:00
spencer b0635e035c refactor!: Renamed nushell-completion command to completion nushell. 2026-07-13 13:12:59 -04:00
spencer 997f11b257 perf: Added a pre-alloc opportunity. 2026-07-03 22:12:08 -04:00
spencer f5624e91f1 perf: Added #no_bounds_check to ssh parsers. 2026-07-03 21:43:06 -04:00
spencer 80353f387a chore: Updated todos. 2026-07-01 17:01:21 -04:00
spencer aae1495d59 refactor: Declared commands with named fields instead of positional. 2026-07-01 14:02:36 -04:00
spencer 209ec89add feat: Improved nushell wrapper output. 2026-07-01 14:02:36 -04:00
spencer 7ffc38171c chore: Updated TODOS.md 2026-07-01 11:23:42 -04:00
spencer 8b9a9789ab refactor: Removed needless guards from write_flags_table. 2026-07-01 11:11:24 -04:00
spencer 6244a6c8ce refactor(ssh.odin): removed offset from parser logic. 2026-07-01 10:49:36 -04:00
spencer 7aaa415777 test: Fixed leak in test. 2026-06-30 13:27:38 -04:00
spencer ca992f05f4 refactor(findr): Now accepts an allocator parameter. 2026-06-30 13:27:38 -04:00
spencer ae42d53f02 feat: Scan results can now be filtered during selection. 2026-06-30 12:31:28 -04:00
spencer af1860c970 fix: Removed irrelevent flags from nushell-completion command help text. 2026-06-29 18:17:56 -04:00
spencer 5282a4414b perf!: Removed support for JSON formatted remotes.
BREAKING CHANGE: Databases with a JSON formatted remotes column will no
longer parse correctly. If you have an old database, make sure you
upgrade to envr 0.4.0 and run `envr list` before upgrading to this
version.
2026-06-29 18:12:11 -04:00
spencer ea1627ec2e build: nix version is now pinned to version.txt. 2026-06-29 18:12:10 -04:00
25 changed files with 1073 additions and 368 deletions
+2
View File
@@ -1 +1,3 @@
use flake
export PATH="./:$PATH"
+1 -1
View File
@@ -2,7 +2,7 @@
# Builds release artifacts for GitHub releases
APP_NAME := envr
VERSION := $(shell grep 'version = ' flake.nix | head -1 | sed 's/.*version = "\(.*\)";/\1/')
VERSION := $(file < version.txt)
BUILD_DIR := builds
# Binary names
+6 -15
View File
@@ -6,25 +6,16 @@
3. procedures should be ordered by use, main at the top, then in the order they are called from main.
4. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
4. Test all command branches.
5. Test all cmds / terminal branches.
5. Generate md and man pages again.
6. Generate md and man pages again.
6. Add tests for untested commands.
7. Shell completion
7. Add uninstall command.
8. Add tests for untested commands.
8. Add purge command?
9. Update `read_wire_string` to use a slice.
10. Pass allocator to findr?
11. Consider getting rid of color global.
12. `write_flags_table` should never return false.
13. Add a text filter to the multi_select.
## Double-check AI output
@@ -54,7 +45,7 @@
- [ ] db_test.odin
- [ ] flags.odin
- [x] main.odin
- [x] prompt.odin
- [ ] prompt.odin
- [x] scan.odin
- [ ] scan_test.odin
- [ ] sodium.odin
+160 -76
View File
@@ -22,8 +22,8 @@ Command :: struct {
Flags :: struct {
help: bool `args:"short=h" usage:"show this documentation"`,
config_file: string `args:"name=config-file,short=c" usage:"config file" default:"~/.envr/config.json"`,
output: Output_Format `args:"short=o" usage:"the format of output data" default:"table"`,
color: Color_Mode `usage:"Whether or not to colorize output" default:"auto"`,
output: Output_Format `args:"short=o" usage:"the format of output data" default:"table" completion:"output"`,
color: Color_Mode `usage:"Whether or not to colorize output" default:"auto" completion:"color"`,
force: bool `args:"short=f" usage:"Overwrite existing config"`,
}
@@ -47,6 +47,13 @@ Color_Mode :: enum {
Never,
}
Positional_Arg :: struct {
name: string,
ntype: string, // nushell type: "path", "string". "" defaults to "path"
completion: string,
optional: bool,
}
CommandInfo :: struct {
name: string,
usage: string,
@@ -55,59 +62,93 @@ CommandInfo :: struct {
aliases: []string,
// Flags supported by the command
flags: bit_set[Flag_Type],
args: []Positional_Arg,
}
GLOBAL_FLAGS: bit_set[Flag_Type] = {.Help, .Config_File, .Color}
COMMANDS := []CommandInfo {
{
"init",
"envr init",
"Set up envr",
`The init command generates your initial config and saves it to
name = "init",
usage = "envr init",
short = "Set up envr",
long = `The init command generates your initial config and saves it to
~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to
encrypt your databse. **Make 100% sure** that you have **a remote copy** of this
key somewhere, otherwise your data could be lost forever.`,
{},
GLOBAL_FLAGS + {.Force},
},
{"scan", "envr scan", "Find and select .env files for backup", "", {}, GLOBAL_FLAGS},
{"sync", "envr sync", "Update or restore your env backups", "", {}, GLOBAL_FLAGS + {.Output}},
{"backup", "envr backup <path>", "Import a .env file into envr", "", {"add"}, GLOBAL_FLAGS},
{
"restore",
"envr restore <path>",
"Restore a .env file from the database",
"",
{},
GLOBAL_FLAGS,
},
{"list", "envr list", "View your tracked files", "", {}, GLOBAL_FLAGS + {.Output}},
{
"remove",
"envr remove <path>",
"Remove a .env file from your database",
"",
{},
GLOBAL_FLAGS,
},
{"check", "envr check [path]", "Check if files are backed up", "", {}, GLOBAL_FLAGS},
{"version", "envr version", "Show envr's version", "", {}, {.Help}},
{
"edit-config",
"envr edit-config",
"Edit your config with your default editor",
"",
{},
GLOBAL_FLAGS,
flags = GLOBAL_FLAGS + {.Force},
},
{
"nushell-completion",
"envr nushell-completion",
"Generate custom completions for nushell",
"",
{},
GLOBAL_FLAGS - {.Color},
name = "scan",
usage = "envr scan",
short = "Find and select .env files for backup",
flags = GLOBAL_FLAGS,
},
{
name = "sync",
usage = "envr sync",
short = "Update or restore your env backups",
flags = GLOBAL_FLAGS + {.Output},
},
{
name = "backup",
usage = "envr backup <path>",
short = "Import a .env file into envr",
aliases = {"add"},
flags = GLOBAL_FLAGS,
args = {{name = "path", completion = "untracked-paths"}},
},
{
name = "restore",
usage = "envr restore <path>",
short = "Restore a .env file from the database",
flags = GLOBAL_FLAGS,
args = {{name = "path", completion = "tracked-paths"}},
},
{
name = "list",
usage = "envr list",
short = "View your tracked files",
flags = GLOBAL_FLAGS + {.Output},
},
{
name = "remove",
usage = "envr remove <path>",
short = "Remove a .env file from your database",
flags = GLOBAL_FLAGS,
args = {{name = "path", completion = "tracked-paths"}},
},
{
name = "check",
usage = "envr check [path]",
short = "Check if files are backed up",
flags = GLOBAL_FLAGS,
args = {{name = "path", optional = true}},
},
{name = "version", usage = "envr version", short = "Show envr's version", flags = {.Help}},
{
name = "edit-config",
usage = "envr edit-config",
short = "Edit your config with your default editor",
flags = GLOBAL_FLAGS,
},
{
name = "completion",
usage = "envr completion <shell>",
short = "Generate shell completion scripts",
long = `Supported shells:
nushell
bash`,
flags = {.Help},
args = {{name = "shell", ntype = "string", completion = "shells"}},
},
{
name = "uninstall",
usage = "envr uninstall",
short = "Remove envr configuration and database",
long = "This will remove all files generated by envr from your system.",
flags = GLOBAL_FLAGS + {.Force},
},
}
@@ -127,7 +168,7 @@ parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Comm
}
cmd.name = args[1]
cmd.args = make([dynamic]string)
cmd.args = make([dynamic]string, 0, len(args[2:]))
overflow := parse_flags(&cmd.flags, args[2:])
for arg in overflow {
@@ -206,21 +247,30 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
tbl: table.Table
table.init(&tbl, context.temp_allocator, context.temp_allocator)
table.padding(&tbl, 2, 0)
if write_flags_table(&tbl, info.flags) {
fmt.wprintf(w, "\n", flush = false)
write_borderless_table(w, &tbl)
}
write_flags_table(&tbl, info.flags)
fmt.wprintf(w, "\n", flush = false)
write_borderless_table(w, &tbl)
table_reset(&tbl)
return true
}
flag_field_info :: proc(
ft: Flag_Type,
) -> (
names: string,
value_hint: string,
description: string,
) {
Flag_Kind :: enum {
Bool,
String,
Enum,
}
Flag_Field :: struct {
long_name: string,
short_name: string,
kind: Flag_Kind,
usage: string,
default_val: string,
enum_values: string,
completion: string,
}
flag_field :: proc(ft: Flag_Type) -> Flag_Field {
field := reflect.struct_field_at(Flags, int(ft))
args_tag := reflect.struct_tag_get(field.tag, "args")
@@ -230,45 +280,81 @@ flag_field_info :: proc(
}
short, has_short := get_subtag(args_tag, "short")
if has_short {
names = fmt.tprintf("-%s, --%s", short, long_name)
} else {
names = fmt.tprintf("--%s", long_name)
}
base_ti := runtime.type_info_base(field.type)
kind: Flag_Kind
enum_values: string
if _, is_bool := base_ti.variant.(runtime.Type_Info_Boolean); is_bool {
value_hint = ""
kind = .Bool
} else if _, is_string := base_ti.variant.(runtime.Type_Info_String); is_string {
value_hint = " <value>"
kind = .String
} else if enum_ti, is_enum := base_ti.variant.(runtime.Type_Info_Enum); is_enum {
kind = .Enum
parts := make([dynamic]string, 0, len(enum_ti.names), context.temp_allocator)
for name in enum_ti.names {
lower := strings.to_lower(name, context.temp_allocator)
append(&parts, fmt.tprintf("'%s'", lower))
}
value_hint = fmt.tprintf(" %s", strings.join(parts[:], "|", context.temp_allocator))
enum_values = strings.join(parts[:], "|", context.temp_allocator)
delete(parts)
}
usage := reflect.struct_tag_get(field.tag, "usage")
default_val := reflect.struct_tag_get(field.tag, "default")
completion := reflect.struct_tag_get(field.tag, "completion")
description = usage
if len(default_val) > 0 {
if _, is_string := base_ti.variant.(runtime.Type_Info_String); is_string {
description = fmt.tprintf(`%s (default "%s")`, usage, default_val)
} else if _, is_enum := base_ti.variant.(runtime.Type_Info_Enum); is_enum {
description = fmt.tprintf("%s (default '%s')", usage, default_val)
return {
long_name = long_name,
short_name = has_short ? short : "",
kind = kind,
usage = usage,
default_val = default_val,
enum_values = enum_values,
completion = completion,
}
}
flag_field_info :: proc(
ft: Flag_Type,
) -> (
names: string,
value_hint: string,
description: string,
) {
f := flag_field(ft)
if len(f.short_name) > 0 {
names = fmt.tprintf("-%s, --%s", f.short_name, f.long_name)
} else {
names = fmt.tprintf("--%s", f.long_name)
}
switch f.kind {
case .Bool:
value_hint = ""
case .String:
value_hint = " <value>"
case .Enum:
value_hint = fmt.tprintf(" %s", f.enum_values)
}
description = f.usage
if len(f.default_val) > 0 {
switch f.kind {
case .Bool:
// do nothing
case .String:
description = fmt.tprintf(`%s (default "%s")`, f.usage, f.default_val)
case .Enum:
description = fmt.tprintf("%s (default '%s')", f.usage, f.default_val)
}
}
return
}
write_flags_table :: proc(tbl: ^table.Table, flags: bit_set[Flag_Type]) -> (has_rows: bool) {
if flags == {} do return false
write_flags_table :: proc(tbl: ^table.Table, flags: bit_set[Flag_Type]) {
table.caption(tbl, "Flags:")
for ft in Flag_Type {
if ft not_in flags do continue
@@ -285,7 +371,6 @@ write_flags_table :: proc(tbl: ^table.Table, flags: bit_set[Flag_Type]) -> (has_
table.row(tbl, colorize(.Flag, names, tbl.format_allocator), desc)
}
}
return true
}
find_command :: proc(name: string) -> (CommandInfo, bool) {
@@ -365,9 +450,8 @@ at before, restore your backup with:
write_borderless_table(w, &tbl)
table_reset(&tbl)
if write_flags_table(&tbl, GLOBAL_FLAGS) {
write_borderless_table(w, &tbl)
}
write_flags_table(&tbl, GLOBAL_FLAGS)
write_borderless_table(w, &tbl)
table_reset(&tbl)
fmt.wprintf(
+268
View File
@@ -0,0 +1,268 @@
package main
import "core:fmt"
import "core:strings"
nushell_header :: `def tracked-paths [] {
(
^envr list
| from json
| each {
[$in.directory $in.path] | path join
}
)
}
def untracked-paths [] {
(
^envr scan
| from json
)
}
def color [] {
['auto' 'always' 'never']
}
def output [] {
['auto' 'table' 'json']
}
def shells [] {
['nushell' 'bash']
}
`
cmd_completion :: proc(cmd: ^Command) {
if len(cmd.args) == 0 {
print_command_help(cmd)
return
}
switch cmd.args[0] {
case "nushell":
fmt.wprint(cmd.out, generate_nushell_completion(), flush = true)
case "bash":
fmt.wprint(cmd.out, generate_bash_completion(), flush = true)
case:
fmt.wprintf(cmd.err, "Unsupported shell: %s\n", cmd.args[0])
fmt.wprintln(cmd.err, "Supported shells: nushell, bash")
}
}
generate_nushell_completion :: proc() -> string {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
fmt.sbprint(&sb, nushell_header)
for c in COMMANDS {
fmt.sbprintf(&sb, "# %s\n", c.short)
fmt.sbprintf(&sb, "export extern \"envr %s\" [\n", c.name)
for ft in Flag_Type {
if ft not_in c.flags do continue
f := flag_field(ft)
fmt.sbprintf(&sb, " %s\n", nushell_flag_line(f))
}
for arg in c.args {
fmt.sbprintf(&sb, " %s\n", nushell_positional_line(arg))
}
fmt.sbprintf(&sb, "]\n")
for a in c.aliases {
fmt.sbprintf(&sb, "\nexport alias \"envr %s\" = envr %s\n", a, c.name)
}
fmt.sbprintf(&sb, "\n")
}
return strings.to_string(sb)
}
nushell_flag_line :: proc(f: Flag_Field) -> string {
line: string
if len(f.short_name) > 0 {
line = fmt.tprintf("--%s(-%s)", f.long_name, f.short_name)
} else {
line = fmt.tprintf("--%s", f.long_name)
}
switch f.kind {
case .Bool:
case .String:
line = fmt.tprintf("%s: path", line)
case .Enum:
if len(f.completion) > 0 {
line = fmt.tprintf("%s: string@%s", line, f.completion)
} else {
line = fmt.tprintf("%s: string", line)
}
}
return fmt.tprintf("%s # %s", line, f.usage)
}
nushell_positional_line :: proc(arg: Positional_Arg) -> string {
name := arg.name
if arg.optional {
name = fmt.tprintf("%s?", name)
}
ntype := len(arg.ntype) > 0 ? arg.ntype : "path"
if len(arg.completion) > 0 {
return fmt.tprintf("%s: %s@%s", name, ntype, arg.completion)
}
return fmt.tprintf("%s: %s", name, ntype)
}
bash_header :: `
_envr() {
local cur prev cmd
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
cmd="${COMP_WORDS[1]}"
`
bash_footer :: `
}
complete -F _envr envr
`
generate_bash_completion :: proc() -> string {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
fmt.sbprint(&sb, bash_header)
// B: Subcommand completion
fmt.sbprintf(&sb, " if [[ $COMP_CWORD -eq 1 ]]; then\n")
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -W \"")
first := true
for c in COMMANDS {
if !first do fmt.sbprintf(&sb, " ")
fmt.sbprintf(&sb, "%s", c.name)
first = false
for a in c.aliases {
fmt.sbprintf(&sb, " %s", a)
}
}
fmt.sbprintf(&sb, "\" -- \"$cur\") )\n")
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " fi\n\n")
// C: Flag value completion
fmt.sbprintf(&sb, " case \"$prev\" in\n")
for ft in Flag_Type {
f := flag_field(ft)
if f.kind == .Bool do continue
pattern := fmt.tprintf("--%s", f.long_name)
if len(f.short_name) > 0 {
pattern = fmt.tprintf("%s|-%s", pattern, f.short_name)
}
fmt.sbprintf(&sb, " %s)\n", pattern)
switch f.kind {
case .Enum:
fmt.sbprintf(
&sb,
" COMPREPLY=( $(compgen -W \"%s\" -- \"$cur\") )\n",
bash_enum_values(f.enum_values),
)
case .String:
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -f -- \"$cur\") )\n")
case .Bool:
panic("unexpected")
}
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " ;;\n")
}
fmt.sbprintf(&sb, " esac\n\n")
// D: Flag name completion per command
fmt.sbprintf(&sb, " case \"$cur\" in -*)\n")
fmt.sbprintf(&sb, " case \"$cmd\" in\n")
for c in COMMANDS {
if c.flags == {} do continue
cmd_pattern := c.name
for a in c.aliases {
cmd_pattern = fmt.tprintf("%s|%s", cmd_pattern, a)
}
fmt.sbprintf(&sb, " %s)\n", cmd_pattern)
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -W \"")
flag_first := true
for ft in Flag_Type {
if ft not_in c.flags do continue
f := flag_field(ft)
if !flag_first do fmt.sbprintf(&sb, " ")
fmt.sbprintf(&sb, "--%s", f.long_name)
if len(f.short_name) > 0 {
fmt.sbprintf(&sb, " -%s", f.short_name)
}
flag_first = false
}
fmt.sbprintf(&sb, "\" -- \"$cur\") )\n")
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " ;;\n")
}
fmt.sbprintf(&sb, " esac\n")
fmt.sbprintf(&sb, " ;;\n")
fmt.sbprintf(&sb, " esac\n\n")
// E: Positional completion
fmt.sbprintf(&sb, " case \"$cmd\" in\n")
for c in COMMANDS {
has_comp := false
for arg in c.args {
if len(arg.completion) > 0 {
has_comp = true
break
}
}
if !has_comp do continue
cmd_pattern := c.name
for a in c.aliases {
cmd_pattern = fmt.tprintf("%s|%s", cmd_pattern, a)
}
fmt.sbprintf(&sb, " %s)\n", cmd_pattern)
for arg in c.args {
if len(arg.completion) == 0 do continue
comp: string
switch arg.completion {
case "tracked-paths":
comp = "$(envr list --output json 2>/dev/null)"
case "untracked-paths":
comp = "$(envr scan --output json 2>/dev/null)"
case "shells":
comp = "nushell bash"
case:
continue
}
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -W \"%s\" -- \"$cur\") )\n", comp)
}
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " ;;\n")
}
fmt.sbprintf(&sb, " esac\n")
fmt.sbprint(&sb, bash_footer)
return strings.to_string(sb)
}
bash_enum_values :: proc(enum_values: string) -> string {
s, _ := strings.replace(enum_values, "'", "", -1, context.temp_allocator)
s, _ = strings.replace(s, "|", " ", -1, context.temp_allocator)
return s
}
+133
View File
@@ -0,0 +1,133 @@
#+test
package main
import "core:fmt"
import "core:strings"
import "core:testing"
@(test)
test_nushell_completion_nonempty :: proc(t: ^testing.T) {
script := generate_nushell_completion()
testing.expect(t, len(script) > 0, "completion script should not be empty")
}
@(test)
test_nushell_completion_contains_commands :: proc(t: ^testing.T) {
script := generate_nushell_completion()
expected := []string{
"tracked-paths",
"untracked-paths",
"envr backup",
"envr check",
"envr edit-config",
"envr init",
"envr list",
"envr remove",
"envr restore",
"envr scan",
"envr sync",
"envr version",
"envr completion",
}
for ext in expected {
testing.expect(
t,
strings.contains(script, ext),
fmt.tprintf("expected script to contain %q", ext),
)
}
}
@(test)
test_nushell_completion_contains_flags :: proc(t: ^testing.T) {
script := generate_nushell_completion()
expected_flags := []string{
"--help(-h)",
"--config-file(-c)",
"--color",
"--force(-f)",
"--output(-o)",
}
for flag in expected_flags {
testing.expect(
t,
strings.contains(script, flag),
fmt.tprintf("expected script to contain %q", flag),
)
}
}
@(test)
test_nushell_completion_contains_aliases :: proc(t: ^testing.T) {
script := generate_nushell_completion()
testing.expect(
t,
strings.contains(script, "envr add"),
"expected script to contain 'envr add' alias",
)
}
@(test)
test_nushell_completion_no_help_command :: proc(t: ^testing.T) {
script := generate_nushell_completion()
testing.expect(
t,
!strings.contains(script, "envr help"),
"script should not contain 'envr help' (not a real command)",
)
}
@(test)
test_bash_completion_nonempty :: proc(t: ^testing.T) {
script := generate_bash_completion()
testing.expect(t, len(script) > 0, "bash completion script should not be empty")
}
@(test)
test_bash_completion_contains_registration :: proc(t: ^testing.T) {
script := generate_bash_completion()
testing.expect(
t,
strings.contains(script, "complete -F _envr envr"),
"expected bash script to register completion function",
)
}
@(test)
test_bash_completion_contains_commands :: proc(t: ^testing.T) {
script := generate_bash_completion()
expected := []string{
"init", "scan", "sync", "backup", "add",
"restore", "list", "remove", "check",
"version", "edit-config", "completion",
}
for cmd in expected {
testing.expect(
t,
strings.contains(script, cmd),
fmt.tprintf("expected bash script to contain %q", cmd),
)
}
}
@(test)
test_bash_completion_contains_flags :: proc(t: ^testing.T) {
script := generate_bash_completion()
expected := []string{
"--help", "--config-file", "--color", "--force", "--output",
}
for flag in expected {
testing.expect(
t,
strings.contains(script, flag),
fmt.tprintf("expected bash script to contain %q", flag),
)
}
}
@(test)
test_bash_completion_contains_enum_values :: proc(t: ^testing.T) {
script := generate_bash_completion()
testing.expect(t, strings.contains(script, "auto table json"), "missing output enum values")
testing.expect(t, strings.contains(script, "auto always never"), "missing color enum values")
}
-10
View File
@@ -1,10 +0,0 @@
package main
import "core:fmt"
COMPLETION_SCRIPT: string : string(#load("mod.nu"))
cmd_nushell_completion :: proc(cmd: ^Command) {
fmt.wprint(cmd.out, COMPLETION_SCRIPT, flush = false)
}
-37
View File
@@ -1,37 +0,0 @@
#+test
package main
import "core:fmt"
import "core:strings"
import "core:testing"
@(test)
test_nushell_completion_nonempty :: proc(t: ^testing.T) {
testing.expect(t, len(COMPLETION_SCRIPT) > 0, "completion script should not be empty")
}
@(test)
test_nushell_completion_contains_externs :: proc(t: ^testing.T) {
expected := []string{
"tracked-paths",
"untracked-paths",
"envr backup",
"envr check",
"envr edit-config",
"envr help",
"envr init",
"envr list",
"envr remove",
"envr restore",
"envr scan",
"envr sync",
"envr nushell-completion",
}
for ext in expected {
testing.expect(
t,
strings.contains(COMPLETION_SCRIPT, ext),
fmt.tprintf("expected script to contain %q", ext),
)
}
}
+64
View File
@@ -0,0 +1,64 @@
package main
import "core:bufio"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:terminal"
cmd_uninstall :: proc(cmd: ^Command) {
envr_d := envr_dir(cmd.flags.config_file)
if !os.exists(envr_d) {
fmt.wprintln(cmd.out, "Nothing to remove.", flush = false)
return
}
entries, err := os.read_all_directory_by_path(envr_d, context.temp_allocator)
if err != nil {
fmt.wprintf(cmd.err, "Error reading %s: %v\n", envr_d, err, flush = false)
return
}
paths := make([]string, len(entries), context.temp_allocator)
for entry, i in entries {
paths[i], _ = filepath.join([]string{envr_d, entry.name}, context.temp_allocator)
}
fmt.wprintln(cmd.out, "This will remove:", flush = false)
for path in paths {
fmt.wprintf(cmd.out, " %s\n", path, flush = false)
}
fmt.wprintf(cmd.out, " %s\n", envr_d, flush = false)
if !cmd.flags.force {
if !terminal.is_terminal(os.stdin) {
fmt.wprintln(cmd.err, "Run with --force to skip confirmation.", flush = false)
return
}
fmt.wprintf(cmd.out, "\nAre you sure? [y/N] ", flush = false)
bufio.writer_flush(cmd.out_buf)
buf: [128]u8
n, _ := os.read(os.stdin, buf[:])
response := strings.trim_space(string(buf[:n]))
if response != "y" && response != "Y" {
fmt.wprintln(cmd.out, "Aborted.", flush = false)
return
}
}
remove_err := os.remove_all(envr_d)
if remove_err != nil {
fmt.wprintf(cmd.err, "Error removing %s: %v\n", envr_d, remove_err, flush = false)
return
}
for path in paths {
fmt.wprintf(cmd.out, "Removed %s\n", path, flush = false)
}
fmt.wprintf(cmd.out, "Removed %s\n", envr_d, flush = false)
}
+97
View File
@@ -0,0 +1,97 @@
#+test
package main
import "core:bufio"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:testing"
@(test)
test_cmd_uninstall_force :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-uninstall-*")
cfg_path, _ := filepath.join([]string{base, ".envr", "config.json"}, context.temp_allocator)
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, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
defer delete(f.remotes)
db_insert(&db, f)
db_close(&db)
envr_d := filepath.dir(cfg_path)
testing.expect(t, os.exists(envr_d), ".envr directory should exist before uninstall")
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "uninstall", "--force", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_uninstall(&cmd)
bufio.writer_flush(cmd.out_buf)
testing.expect(t, !os.exists(envr_d), ".envr directory should be removed")
output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "Removed"), "output should list removed files")
testing.expect(
t,
strings.contains(output, "config.json"),
"output should mention config.json",
)
testing.expect(
t,
strings.contains(output, "data.envr"),
"output should mention data.envr",
)
}
@(test)
test_cmd_uninstall_nothing_to_remove :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-uninstall-empty-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join([]string{base, ".envr", "config.json"}, context.temp_allocator)
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "uninstall", "--force", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_uninstall(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
testing.expect(
t,
strings.contains(output, "Nothing to remove"),
"output should say nothing to remove",
)
}
+4 -3
View File
@@ -201,8 +201,7 @@ find_git_roots :: proc(
ok: bool,
) {
paths := search_paths(cfg, allocator)
// TODO: Pass allocator to findr
findr.find_repos(paths[:], &roots, os.get_processor_core_count())
findr.find_repos(paths[:], &roots, os.get_processor_core_count(), allocator)
ok = true
return
}
@@ -214,6 +213,7 @@ search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]st
}
paths := new_clone(cfg.scan_config.include, allocator)
defer free(paths, allocator)
for &include in paths {
expanded, _ := strings.replace(include, "~", home, 1, allocator)
@@ -227,7 +227,8 @@ search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]st
}
}
}
return paths^
result := paths^
return result
}
envr_dir :: proc(config_path: string) -> string {
+12
View File
@@ -2,6 +2,7 @@
package main
import "core:fmt"
import "core:mem"
import "core:os"
import "core:path/filepath"
import "core:strings"
@@ -194,3 +195,14 @@ test_search_paths_expands_tilde :: proc(t: ^testing.T) {
}
}
@(test)
test_search_paths_no_leak :: proc(t: ^testing.T) {
cfg := Config {
scan_config = ScanConfig{include = make([dynamic]string, 0, 1)},
}
defer delete(cfg.scan_config.include)
append(&cfg.scan_config.include, "/tmp")
_ = search_paths(cfg, context.allocator)
}
+1 -1
View File
@@ -5,7 +5,7 @@ import "core:mem"
import "core:os"
MAGIC :: "ENVR"
MAGIC_BYTES := [4]u8{u8('E'), u8('N'), u8('V'), u8('R')}
MAGIC_BYTES: [4]u8 = MAGIC
RECIPIENT_ENTRY_SIZE ::
CRYPTO_BOX_PUBLICKEY_BYTES +
+10 -66
View File
@@ -4,7 +4,6 @@ import "base:runtime"
import "core:crypto/hash"
import "core:encoding/hex"
import "core:encoding/ini"
import "core:encoding/json"
import "core:fmt"
import "core:mem"
import "core:os"
@@ -222,7 +221,6 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
allocator := db_allocator(db)
results := make([dynamic]EnvFile, 0, 10, allocator)
migrate := false
for {
rc = sqlite.step(stmt)
if rc == sqlite.DONE {
@@ -233,24 +231,13 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
#no_bounds_check return results[:], false
}
// TODO: Remove json support after next major release
remotes: [dynamic]string = ---
remotes_raw := string(sqlite.column_text(stmt, 1))
if len(remotes_raw) > 0 {
if remotes_raw[0] == '[' {
err := json.unmarshal_string(remotes_raw, &remotes, allocator = allocator)
if err != nil {
fmt.eprintf("Warning: malformed remotes JSON: %v\n", err)
}
migrate = true
} else {
split := strings.split_lines(remotes_raw, context.temp_allocator)
remotes = make([dynamic]string, 0, len(split), allocator = allocator)
for s in split {
append(&remotes, strings.clone(s, allocator))
}
}
split := strings.split_lines(remotes_raw, context.temp_allocator)
remotes := make([dynamic]string, 0, len(split), allocator = allocator)
for s in split {
append(&remotes, strings.clone(s, allocator))
}
path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
append(
@@ -265,10 +252,6 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
)
}
if migrate {
migrate_remotes(db)
}
#no_bounds_check return results[:], true
}
@@ -364,33 +347,15 @@ db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) {
return EnvFile{}, false
}
// TODO: Remove json support after next major release
migrate := false
remotes: [dynamic]string = ---
remotes_raw := string(sqlite.column_text(stmt, 1))
if len(remotes_raw) > 0 {
if remotes_raw[0] == '[' {
err := json.unmarshal_string(remotes_raw, &remotes, allocator = allocator)
if err != nil {
fmt.eprintf("Warning: malformed remotes JSON: %v\n", err)
}
migrate = true
} else {
split := strings.split_lines(remotes_raw, context.temp_allocator)
remotes = make([dynamic]string, 0, len(split), allocator = allocator)
for s in split {
append(&remotes, strings.clone(s, allocator))
}
}
split := strings.split_lines(remotes_raw, context.temp_allocator)
remotes := make([dynamic]string, 0, len(split), allocator = allocator)
for s in split {
append(&remotes, strings.clone(s, allocator))
}
file_path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
if migrate {
migrate_remotes(db)
}
return EnvFile {
path = file_path,
dir = filepath.dir(file_path),
@@ -525,29 +490,8 @@ db_persist :: proc(db: ^Db, f: ^EnvFile, old_path: string) -> bool {
return db_insert(db, f^)
}
// TODO: Remove after the next major release
migrate_remotes :: proc(db: ^Db) {
sql ::
"UPDATE envr_env_files " +
"SET remotes = COALESCE((" +
" SELECT group_concat(atom, char(10)) " +
" FROM json_each(envr_env_files.remotes)" +
"), '') " +
"WHERE remotes LIKE '[%'"
rc := sqlite.exec(db.conn, sql, nil, nil, nil)
if rc != sqlite.OK {
fmt.eprintf("Warning: failed to migrate remotes: %s\n", sqlite.errmsg(db.conn))
return
}
if sqlite.changes(db.conn) > 0 {
db.changed = true
}
}
try_move_dir :: proc(db: ^Db, f: ^EnvFile, allocator: mem.Allocator) -> (bool, SyncError) {
roots, ok := find_git_roots(db.cfg)
roots, ok := find_git_roots(db.cfg, context.allocator)
if !ok {
return false, .GitRootFailed
}
+67
View File
@@ -7,6 +7,7 @@ import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:sys/posix"
import "core:testing"
import "sqlite"
@@ -559,3 +560,69 @@ test_db_sync_moved :: proc(t: ^testing.T) {
}
}
@(test)
test_db_close_preserves_inodes :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-inode-*")
defer os.remove_all(base)
envr_dir, _ := filepath.join([]string{base, ".envr"}, context.temp_allocator)
config_path, _ := filepath.join([]string{envr_dir, "config.json"}, context.temp_allocator)
os.mkdir_all(envr_dir)
key_abs, _ := filepath.abs("fixtures/keys/insecure-test-key", context.temp_allocator)
cfg := new_config([]string{key_abs}, config_path)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "save_config should succeed")
// Create database
{
db, ok := db_open(config_path)
defer db_close(&db)
testing.expect(t, ok, "first db_open should succeed")
if !ok do return
env_path, _ := filepath.join([]string{base, ".env"}, context.temp_allocator)
f := make_test_env_file(env_path, "sha", "KEY=value")
db_insert(&db, f)
}
// Record inode of data.envr
data_file, _ := data_path(config_path, context.temp_allocator)
cdata := strings.clone_to_cstring(data_file)
defer delete(cdata)
sb1: posix.stat_t
testing.expect(t, posix.stat(cdata, &sb1) == .OK, "stat should succeed")
inode_before := sb1.st_ino
// Create hardlink
link_path, _ := filepath.join([]string{envr_dir, "data.envr.link"}, context.temp_allocator)
clink := strings.clone_to_cstring(link_path)
defer delete(clink)
testing.expect(t, posix.link(cdata, clink) == .OK, "hardlink should succeed")
// Update database
{
db, ok2 := db_open(config_path)
defer db_close(&db)
testing.expect(t, ok2, "second db_open should succeed")
if !ok2 do return
env_path2, _ := filepath.join([]string{base, ".env2"}, context.temp_allocator)
f2 := make_test_env_file(env_path2, "sha2", "KEY2=value2")
db_insert(&db, f2)
}
// Verify inode preserved
sb2: posix.stat_t
testing.expect(t, posix.stat(cdata, &sb2) == .OK, "stat should succeed after rewrite")
testing.expect(t, sb2.st_ino == inode_before, "data.envr inode should be unchanged")
// Verify hardlink still valid
sb_link: posix.stat_t
testing.expect(t, posix.stat(clink, &sb_link) == .OK, "stat on hardlink should succeed")
testing.expect(t, sb_link.st_ino == inode_before, "hardlink inode should match")
testing.expect(t, sb_link.st_nlink == 2, "hardlink count should still be 2")
}
+9 -1
View File
@@ -16,7 +16,14 @@ RepoPool :: struct {
threads: []^thread.Thread,
}
find_repos :: proc(roots: []string, results: ^[dynamic]string, thread_count: int) {
find_repos :: proc(
roots: []string,
results: ^[dynamic]string,
thread_count: int,
allocator := context.allocator,
) {
// TODO: This may be a code smell
context.allocator = allocator
if len(roots) == 0 do return
pool := new(RepoPool)
@@ -126,3 +133,4 @@ process_repo_dir :: proc(pool: ^RepoPool, dir_path: string) {
}
}
}
+9 -1
View File
@@ -78,7 +78,15 @@ collect_worker :: proc(t: ^thread.Thread) {
}
}
walk :: proc(roots: []string, results: ^[dynamic]string, opts: WalkOptions, thread_count: int) {
walk :: proc(
roots: []string,
results: ^[dynamic]string,
opts: WalkOptions,
thread_count: int,
allocator := context.allocator,
) {
// TODO: This may be a code smell
context.allocator = allocator
if len(roots) == 0 do return
ch, _ := chan.create(chan.Chan([]u8), max(2 * thread_count, 2), context.allocator)
+1 -1
View File
@@ -66,7 +66,7 @@
packages.default = pkgs.stdenv.mkDerivation rec {
pname = "envr";
version = "0.3.0";
version = nixpkgs.lib.trim (builtins.readFile ./version.txt);
src = ./.;
nativeBuildInputs = [
+100
View File
@@ -0,0 +1,100 @@
_envr() {
local cur prev cmd
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
cmd="${COMP_WORDS[1]}"
if [[ $COMP_CWORD -eq 1 ]]; then
COMPREPLY=( $(compgen -W "init scan sync backup add restore list remove check version edit-config completion" -- "$cur") )
return
fi
case "$prev" in
--config-file|-c)
COMPREPLY=( $(compgen -f -- "$cur") )
return
;;
--output|-o)
COMPREPLY=( $(compgen -W "auto table json" -- "$cur") )
return
;;
--color)
COMPREPLY=( $(compgen -W "auto always never" -- "$cur") )
return
;;
esac
case "$cur" in -*)
case "$cmd" in
init)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color --force -f" -- "$cur") )
return
;;
scan)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
sync)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --output -o --color" -- "$cur") )
return
;;
backup|add)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
restore)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
list)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --output -o --color" -- "$cur") )
return
;;
remove)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
check)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
version)
COMPREPLY=( $(compgen -W "--help -h" -- "$cur") )
return
;;
edit-config)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
completion)
COMPREPLY=( $(compgen -W "--help -h" -- "$cur") )
return
;;
esac
;;
esac
case "$cmd" in
backup|add)
COMPREPLY=( $(compgen -W "$(envr scan --output json 2>/dev/null)" -- "$cur") )
return
;;
restore)
COMPREPLY=( $(compgen -W "$(envr list --output json 2>/dev/null)" -- "$cur") )
return
;;
remove)
COMPREPLY=( $(compgen -W "$(envr list --output json 2>/dev/null)" -- "$cur") )
return
;;
completion)
COMPREPLY=( $(compgen -W "nushell bash" -- "$cur") )
return
;;
esac
}
complete -F _envr envr
+4 -2
View File
@@ -77,8 +77,10 @@ main :: proc() {
cmd_scan(&cmd)
case "sync":
cmd_sync(&cmd)
case "nushell-completion":
cmd_nushell_completion(&cmd)
case "completion":
cmd_completion(&cmd)
case "uninstall":
cmd_uninstall(&cmd)
case:
fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
write_usage(cmd.out)
-71
View File
@@ -1,71 +0,0 @@
# envr command extern definitions for Nushell
# A tool for managing environment files and backups
export def tracked-paths [] {
(
^envr list
| from json
| each {
[$in.directory $in.path] | path join
}
)
}
export def untracked-paths [] {
(
^envr scan
| from json
)
}
export extern envr [
...args: any
--help(-h) # Show help information
]
export extern "envr backup" [
--help(-h) # Show help for backup command
path: path@untracked-paths # Path to .env file to backup
]
export extern "envr check" [
--help(-h) # Show help for check command
]
export extern "envr edit-config" [
--help(-h) # Show help for edit-config command
]
export extern "envr help" [
command?: string # Show help for specific command
]
export extern "envr init" [
--help(-h) # Show help for init command
]
export extern "envr list" [
--help(-h) # Show help for list command
]
export extern "envr remove" [
--help(-h) # Show help for remove command
path: path@tracked-paths
]
export extern "envr restore" [
--help(-h) # Show help for restore command
path: path@tracked-paths
]
export extern "envr scan" [
--help(-h) # Show help for scan command
]
export extern "envr sync" [
--help(-h) # Show help for sync command
]
export extern "envr nushell-completion" [
--help(-h) # Show help for nushell-completion command
]
+76 -26
View File
@@ -1,6 +1,7 @@
package main
import "core:fmt"
import "core:strings"
import "core:sys/posix"
import "core:terminal/ansi"
@@ -15,6 +16,8 @@ Key :: enum {
Space,
Enter,
Escape,
Backspace,
Char,
Unknown,
}
@@ -38,11 +41,17 @@ multi_select :: proc(
}
selected = make([dynamic]bool, len(options))
filter: [dynamic]u8
defer delete(filter)
filtered: [dynamic]int
defer delete(filtered)
rebuild_filtered(options, filter[:], &filtered)
cursor: int = 0
scroll_offset: int = 0
fmt.printf(ansi.CSI + ansi.DECTCEM_HIDE)
visible := render_options(prompt, options, selected[:], cursor, scroll_offset)
visible := render_options(prompt, options, selected[:], filtered[:], string(filter[:]), cursor, scroll_offset)
raw, ok := enable_raw_mode(posix.STDIN_FILENO)
if !ok {
@@ -52,19 +61,31 @@ multi_select :: proc(
defer disable_raw_mode(&raw)
for {
key := read_key()
key, ch := read_key()
switch key {
case .Char:
append(&filter, u8(ch))
rebuild_filtered(options, filter[:], &filtered)
cursor = clamp(cursor, 0, max(0, len(filtered) - 1))
case .Backspace:
if len(filter) > 0 {
pop(&filter)
rebuild_filtered(options, filter[:], &filtered)
cursor = clamp(cursor, 0, max(0, len(filtered) - 1))
}
case .Up:
if cursor > 0 {
cursor -= 1
}
case .Down:
if cursor < len(options) - 1 {
if cursor < len(filtered) - 1 {
cursor += 1
}
case .Space:
selected[cursor] = !selected[cursor]
if len(filtered) > 0 {
selected[filtered[cursor]] = !selected[filtered[cursor]]
}
case .Enter:
fmt.printf(
ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW,
@@ -82,9 +103,21 @@ multi_select :: proc(
case .Unknown:
}
scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(options) - MAX_VISIBLE))
scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(filtered) - MAX_VISIBLE))
fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.RESET + ansi.ED, visible + 1)
visible = render_options(prompt, options, selected[:], cursor, scroll_offset)
visible = render_options(prompt, options, selected[:], filtered[:], string(filter[:]), cursor, scroll_offset)
}
}
rebuild_filtered :: proc(options: []string, filter: []u8, filtered: ^[dynamic]int) {
clear(filtered)
filter_str := string(filter)
filter_lower := strings.to_lower(filter_str, context.temp_allocator)
for opt, i in options {
opt_lower := strings.to_lower(opt, context.temp_allocator)
if strings.contains(opt_lower, filter_lower) {
append(filtered, i)
}
}
}
@@ -92,22 +125,34 @@ render_options :: proc(
prompt: string,
options: []string,
selected: []bool,
filtered: []int,
filter_text: string,
cursor: int,
scroll_offset: int,
) -> int {
fmt.printf(
"%s (↑/↓ move, space select, enter confirm)\r\n",
"%s (type to filter, ↑/↓ move, space select, enter confirm)\r\n",
colorize(.Option_Label, prompt),
)
fmt.printf("filter: %s\r\n", filter_text)
line_count := 1
if len(filtered) == 0 {
fmt.printf(" No matches\r\n")
return line_count + 1
}
end := scroll_offset + MAX_VISIBLE
if end > len(options) {
end = len(options)
if end > len(filtered) {
end = len(filtered)
}
for i in scroll_offset ..< end {
original := filtered[i]
checkbox := " "
if selected[i] {
if selected[original] {
checkbox = "x"
}
if i == cursor {
@@ -115,14 +160,14 @@ render_options :: proc(
"%s [%s] %s\r\n",
colorize(.Caret, ">"),
colorize(.Sucess, checkbox),
options[i],
options[original],
)
} else {
fmt.printf(" [%s] %s\r\n", colorize(.Sucess, checkbox), options[i])
fmt.printf(" [%s] %s\r\n", colorize(.Sucess, checkbox), options[original])
}
}
return end - scroll_offset
return line_count + (end - scroll_offset)
}
enable_raw_mode :: proc(fd: posix.FD) -> (Raw_State, bool) {
@@ -152,21 +197,23 @@ disable_raw_mode :: proc(state: ^Raw_State) {
posix.tcsetattr(state.fd, .TCSAFLUSH, &state.original)
}
read_key :: proc() -> Key {
read_key :: proc() -> (key: Key, ch: rune) {
buf: [3]u8
n := posix.read(posix.STDIN_FILENO, &buf[0], 1)
if n <= 0 {
return .Unknown
return .Unknown, 0
}
switch buf[0] {
case ' ':
return .Space
return .Space, 0
case '\n', '\r':
return .Enter
return .Enter, 0
case 0x03:
return .Escape
return .Escape, 0
case 0x08, 0x7F:
return .Backspace, 0
case 0x1b:
tv: posix.timeval
tv.tv_sec = 0
@@ -178,12 +225,12 @@ read_key :: proc() -> Key {
ready := posix.select(1, &set, nil, nil, &tv)
if ready <= 0 {
return .Escape
return .Escape, 0
}
n2 := posix.read(posix.STDIN_FILENO, &buf[1], 1)
if n2 <= 0 || buf[1] != '[' {
return .Escape
return .Escape, 0
}
posix.FD_ZERO(&set)
@@ -193,24 +240,27 @@ read_key :: proc() -> Key {
ready = posix.select(1, &set, nil, nil, &tv)
if ready <= 0 {
return .Escape
return .Escape, 0
}
n3 := posix.read(posix.STDIN_FILENO, &buf[2], 1)
if n3 <= 0 {
return .Escape
return .Escape, 0
}
switch buf[2] {
case 'A':
return .Up
return .Up, 0
case 'B':
return .Down
return .Down, 0
case:
return .Escape
return .Escape, 0
}
case:
return .Unknown
if buf[0] >= 0x20 && buf[0] <= 0x7E {
return .Char, rune(buf[0])
}
return .Unknown, 0
}
}
+42 -51
View File
@@ -35,18 +35,18 @@ parse_ssh_public_key :: proc(pub_path: string) -> (pub: [32]u8, ok: bool) {
return
}
offset := 0
key_type, type_ok := read_wire_string(decoded, &offset)
if !type_ok || key_type != SSH_ED25519 {
rest := decoded
key_type, type_ok := read_wire_string(&rest)
if !type_ok || string(key_type) != SSH_ED25519 {
return
}
pk_data, pk_ok := read_wire_string(decoded, &offset)
pk_data, pk_ok := read_wire_string(&rest)
if !pk_ok || len(pk_data) != 32 {
return
}
mem.copy_non_overlapping(&pub[0], raw_data(pk_data), 32)
mem.copy_non_overlapping(&pub[0], &pk_data[0], 32)
ok = true
return
@@ -91,81 +91,63 @@ parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: boo
return
}
offset := len(magic)
rest := decoded[len(magic):]
ciphername, cipher_ok := read_wire_string(decoded, &offset)
if !cipher_ok || ciphername != "none" {
ciphername, cipher_ok := read_wire_string(&rest)
if !cipher_ok || string(ciphername) != "none" {
return
}
kdfname, kdf_ok := read_wire_string(decoded, &offset)
if !kdf_ok || kdfname != "none" {
kdfname, kdf_ok := read_wire_string(&rest)
if !kdf_ok || string(kdfname) != "none" {
return
}
_, opts_ok := read_wire_string(decoded, &offset)
_, opts_ok := read_wire_string(&rest)
if !opts_ok {
return
}
if offset + 4 > len(decoded) {
num_keys, nkeys_ok := read_wire_u32(&rest)
if !nkeys_ok || num_keys != 1 {
return
}
num_keys := endian.get_u32(decoded[offset:offset + 4], .Big) or_return
offset += 4
if num_keys != 1 {
return
}
_, pub_blob_ok := read_wire_string(decoded, &offset)
_, pub_blob_ok := read_wire_string(&rest)
if !pub_blob_ok {
return
}
priv_blob, priv_blob_ok := read_wire_string(decoded, &offset)
priv_blob, priv_blob_ok := read_wire_string(&rest)
if !priv_blob_ok {
return
}
inner_offset := 0
if inner_offset + 8 > len(priv_blob) {
inner := priv_blob
check1, c1_ok := read_wire_u32(&inner)
check2, c2_ok := read_wire_u32(&inner)
if !c1_ok || !c2_ok || check1 != check2 {
return
}
check1 := endian.get_u32(
transmute([]u8)(priv_blob)[inner_offset:inner_offset + 4],
.Big,
) or_return
inner_offset += 4
check2 := endian.get_u32(
transmute([]u8)(priv_blob)[inner_offset:inner_offset + 4],
.Big,
) or_return
inner_offset += 4
if check1 != check2 {
priv_type, type_ok := read_wire_string(&inner)
if !type_ok || string(priv_type) != SSH_ED25519 {
return
}
priv_type, type_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
if !type_ok || priv_type != SSH_ED25519 {
return
}
pub_wire, pub_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
pub_wire, pub_ok := read_wire_string(&inner)
if !pub_ok || len(pub_wire) != 32 {
return
}
mem.copy_non_overlapping(&kp.Public[0], raw_data(pub_wire), 32)
mem.copy_non_overlapping(&kp.Public[0], &pub_wire[0], 32)
priv_wire, priv_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
priv_wire, priv_ok := read_wire_string(&inner)
if !priv_ok || len(priv_wire) != 64 {
return
}
mem.copy_non_overlapping(&kp.Private[0], raw_data(priv_wire), 32)
mem.copy_non_overlapping(&kp.Private[0], &priv_wire[0], 32)
ok = true
return
@@ -182,19 +164,28 @@ is_ed25519_key :: proc(
return ok, nil
}
read_wire_string :: proc(data: []u8, offset: ^int) -> (s: string, ok: bool) {
if offset^ + 4 > len(data) {
read_wire_string :: proc(data: ^[]u8) -> (s: []u8, ok: bool) #no_bounds_check {
if len(data^) < 4 {
return
}
length := endian.get_u32(data[offset^:offset^ + 4], .Big) or_return
offset^ += 4
length := endian.get_u32(data^[:4], .Big) or_return
data^ = data^[4:]
if offset^ + int(length) > len(data) {
if len(data^) < int(length) {
return
}
s = data^[:int(length)]
data^ = data^[int(length):]
ok = true
return
}
s = string(data[offset^:offset^ + int(length)])
offset^ += int(length)
read_wire_u32 :: proc(data: ^[]u8) -> (v: u32, ok: bool) #no_bounds_check {
if len(data^) < 4 {
return
}
v = endian.get_u32(data^[:4], .Big) or_return
data^ = data^[4:]
ok = true
return
}
+6 -6
View File
@@ -52,15 +52,15 @@ test_private_key_pub_matches_public_key :: proc(t: ^testing.T) {
@(test)
test_read_wire_string :: proc(t: ^testing.T) {
data := []u8{0, 0, 0, 5, u8('h'), u8('e'), u8('l'), u8('l'), u8('o'), 0, 0, 0, 0}
offset := 0
s, ok := read_wire_string(data, &offset)
buf := data
s, ok := read_wire_string(&buf)
testing.expect(t, ok, "expected read_wire_string to succeed")
testing.expect_value(t, s, "hello")
testing.expect_value(t, offset, 9)
testing.expect_value(t, string(s), "hello")
testing.expect_value(t, len(data) - len(buf), 9)
s2, ok2 := read_wire_string(data, &offset)
s2, ok2 := read_wire_string(&buf)
testing.expect(t, ok2, "expected second read to succeed")
testing.expect_value(t, s2, "")
testing.expect_value(t, len(s2), 0)
}
+1
View File
@@ -35,6 +35,7 @@ test_ansi_aware_width_multiple_escape_sequences :: proc(t: ^testing.T) {
colorize(.Heading, "b", disable = false),
colorize(.Heading, "c", disable = false),
},
context.temp_allocator,
)
testing.expect_value(t, ansi_aware_width(colored), 3)
}