package main import "core:c" import "core:crypto/hash" import "core:encoding/hex" import "core:encoding/json" import "core:fmt" import "core:os" import "core:path/filepath" import "core:strings" import "core:time" import "sqlite" Db :: struct { db: ^rawptr, cfg: Config, changed: bool, } EnvFile :: struct { Path: string, Dir: string, Remotes: [dynamic]string, Sha256: string, contents: string, } make_temp_path :: proc() -> string { ts := time.time_to_unix(time.now()) b: strings.Builder strings.builder_init(&b) fmt.sbprintf(&b, "/tmp/envr-%d-%d.db", os.get_pid(), ts) return strings.to_string(b) } db_open :: proc() -> (Db, bool) { cfg, ok := load_config() if !ok { return Db{}, false } age_path := data_age_path() _, stat_err := os.stat(age_path, context.allocator) db: ^rawptr rc := sqlite.db_open(":memory:", &db) if rc != sqlite.OK { fmt.printf("Error opening in-memory database: %s\n", sqlite.db_errmsg(db)) return Db{}, false } create_sql := "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" rc = sqlite.db_exec(db, string_to_cstring(create_sql), nil, nil, nil) if rc != sqlite.OK { fmt.printf("Error creating table: %s\n", sqlite.db_errmsg(db)) sqlite.db_close(db) return Db{}, false } if stat_err == nil { if !db_restore_from_age(db, cfg) { sqlite.db_close(db) return Db{}, false } } return Db{db = db, cfg = cfg, changed = stat_err != nil}, true } db_close :: proc(d: ^Db) { if d.changed { tmp_path := make_temp_path() if !db_vacuum_to_file(d.db, tmp_path) { os.remove(tmp_path) sqlite.db_close(d.db) return } db_encrypt_file(tmp_path, d.cfg.Keys) os.remove(tmp_path) d.changed = false } sqlite.db_close(d.db) } db_list :: proc(d: ^Db) -> (results: [dynamic]EnvFile, ok: bool) { sql := "SELECT path, remotes, sha256, contents FROM envr_env_files" stmt: ^rawptr rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil) if rc != sqlite.OK { fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db)) return } for { rc = sqlite.step(stmt) if rc == sqlite.DONE { break } if rc != sqlite.ROW { fmt.printf("Error stepping query: %s\n", sqlite.db_errmsg(d.db)) sqlite.finalize(stmt) return } path := cstring_to_string(sqlite.column_text(stmt, 0)) remotes_json := cstring_to_string(sqlite.column_text(stmt, 1)) sha := cstring_to_string(sqlite.column_text(stmt, 2)) contents := cstring_to_string(sqlite.column_text(stmt, 3)) remotes: [dynamic]string if len(remotes_json) > 0 { json.unmarshal_string(remotes_json, &remotes) } append(&results, EnvFile{ Path = path, Dir = filepath.dir(path), Remotes = remotes, Sha256 = sha, contents = contents, }) } sqlite.finalize(stmt) ok = true return } db_vacuum_to_file :: proc(db: ^rawptr, path: string) -> bool { b: strings.Builder strings.builder_init(&b) fmt.sbprintf(&b, "VACUUM INTO '%s'", path) sql := strings.to_string(b) rc := sqlite.db_exec(db, string_to_cstring(sql), nil, nil, nil) if rc != sqlite.OK { fmt.printf("Error vacuuming database: %s\n", sqlite.db_errmsg(db)) return false } return true } db_restore_from_age :: proc(db: ^rawptr, cfg: Config) -> bool { tmp_path := make_temp_path() defer os.remove(tmp_path) if !db_decrypt_to_file(tmp_path, cfg.Keys) { return false } if !db_attach_and_copy(db, tmp_path) { return false } return true } db_decrypt_to_file :: proc(tmp_path: string, keys: []SshKeyPair) -> bool { age_path := data_age_path() args := make([dynamic]string) append(&args, "age") append(&args, "--decrypt") append(&args, "-o") append(&args, tmp_path) for key in keys { append(&args, "-i") append(&args, key.Private) } append(&args, age_path) desc := os.Process_Desc{ command = args[:], stdout = os.stderr, stderr = os.stderr, } p, err := os.process_start(desc) if err != nil { fmt.printf("Error running age decrypt: %v\n", err) return false } state, wait_err := os.process_wait(p) if wait_err != nil { fmt.printf("Error waiting for age: %v\n", wait_err) return false } if state.exit_code != 0 { fmt.println("Error: age decryption failed") return false } return true } db_encrypt_file :: proc(tmp_path: string, keys: []SshKeyPair) -> bool { age_path := data_age_path() envr_d := envr_dir() os.mkdir_all(envr_d) args := make([dynamic]string) append(&args, "age") append(&args, "--encrypt") for key in keys { append(&args, "-r") pub_data, pub_err := os.read_entire_file_from_path(key.Public, context.allocator) if pub_err != nil { fmt.printf("Error reading public key: %s\n", key.Public) return false } pub_str := string(pub_data) if strings.has_suffix(pub_str, "\n") { pub_str = pub_str[:len(pub_str)-1] } append(&args, pub_str) } append(&args, "-o") append(&args, age_path) append(&args, tmp_path) desc := os.Process_Desc{ command = args[:], stdout = os.stderr, stderr = os.stderr, } p, err := os.process_start(desc) if err != nil { fmt.printf("Error running age encrypt: %v\n", err) return false } state, wait_err := os.process_wait(p) if wait_err != nil { fmt.printf("Error waiting for age: %v\n", wait_err) return false } if state.exit_code != 0 { fmt.println("Error: age encryption failed") return false } return true } db_attach_and_copy :: proc(mem_db: ^rawptr, src_path: string) -> bool { b: strings.Builder strings.builder_init(&b) fmt.sbprintf(&b, "ATTACH DATABASE '%s' AS source", src_path) attach_sql := strings.to_string(b) rc := sqlite.db_exec(mem_db, string_to_cstring(attach_sql), nil, nil, nil) if rc != sqlite.OK { fmt.printf("Error attaching database: %s\n", sqlite.db_errmsg(mem_db)) return false } rc = sqlite.db_exec(mem_db, "INSERT INTO main.envr_env_files SELECT * FROM source.envr_env_files", nil, nil, nil) if rc != sqlite.OK { fmt.printf("Error copying data: %s\n", sqlite.db_errmsg(mem_db)) sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil) return false } sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil) return true } get_git_remotes :: proc(dir: string) -> [dynamic]string { remotes: [dynamic]string remote_set: map[string]bool b: strings.Builder strings.builder_init(&b) fmt.sbprintf(&b, "%s-git-remotes", make_temp_path()) tmp_path := strings.to_string(b) tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC) if tmp_err != nil { return remotes } args := []string{"git", "remote", "-v"} desc := os.Process_Desc{ command = args, stdout = tmp_file, stderr = nil, working_dir = dir, } p, start_err := os.process_start(desc) os.close(tmp_file) if start_err != nil { os.remove(tmp_path) return remotes } state, wait_err := os.process_wait(p) if wait_err != nil || state.exit_code != 0 { os.remove(tmp_path) return remotes } data, read_err := os.read_entire_file_from_path(tmp_path, context.allocator) os.remove(tmp_path) if read_err != nil { return remotes } output_str := string(data) lines := strings.split(output_str, "\n") for &line in lines { line = strings.trim_space(line) if len(line) == 0 { continue } parts := strings.fields(line) if len(parts) >= 2 { remote_set[parts[1]] = true } } for remote, _ in remote_set { cloned, _ := strings.clone(remote) append(&remotes, cloned) } return remotes } new_env_file :: proc(path: string) -> (EnvFile, bool) { abs_path, abs_err := filepath.abs(path) if abs_err != nil { fmt.printf("Error getting absolute path: %v\n", abs_err) return EnvFile{}, false } cloned_path, _ := strings.clone(abs_path) dir := filepath.dir(cloned_path) remotes := get_git_remotes(dir) data, read_err := os.read_entire_file_from_path(cloned_path, context.allocator) if read_err != nil { fmt.printf("Error reading file %s: %v\n", cloned_path, read_err) return EnvFile{}, false } digest := hash.hash_bytes(hash.Algorithm.SHA256, data) hex_bytes, _ := hex.encode(digest) sha_str := string(hex_bytes) return EnvFile{ Path = cloned_path, Dir = dir, Remotes = remotes, Sha256 = sha_str, contents = string(data), }, true } db_insert :: proc(d: ^Db, file: EnvFile) -> bool { remotes_json, marshal_err := json.marshal(file.Remotes) if marshal_err != nil { fmt.printf("Error marshaling remotes: %v\n", marshal_err) return false } sql := "INSERT OR REPLACE INTO envr_env_files (path, remotes, sha256, contents) VALUES (?, ?, ?, ?)" stmt: ^rawptr rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil) if rc != sqlite.OK { fmt.printf("Error preparing insert: %s\n", sqlite.db_errmsg(d.db)) return false } defer sqlite.finalize(stmt) rc = sqlite.bind_text(stmt, 1, string_to_cstring(file.Path), -1, nil) rc = sqlite.bind_text(stmt, 2, string_to_cstring(string(remotes_json)), -1, nil) rc = sqlite.bind_text(stmt, 3, string_to_cstring(file.Sha256), -1, nil) rc = sqlite.bind_text(stmt, 4, string_to_cstring(file.contents), -1, nil) rc = sqlite.step(stmt) if rc != sqlite.DONE { fmt.printf("Error inserting: %s\n", sqlite.db_errmsg(d.db)) return false } d.changed = true return true } db_fetch :: proc(d: ^Db, path: string) -> (EnvFile, bool) { sql := "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?" stmt: ^rawptr rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil) if rc != sqlite.OK { fmt.printf("Error preparing fetch: %s\n", sqlite.db_errmsg(d.db)) return EnvFile{}, false } defer sqlite.finalize(stmt) rc = sqlite.bind_text(stmt, 1, string_to_cstring(path), -1, nil) rc = sqlite.step(stmt) if rc == sqlite.DONE { fmt.printf("No file found with path: %s\n", path) return EnvFile{}, false } if rc != sqlite.ROW { fmt.printf("Error fetching: %s\n", sqlite.db_errmsg(d.db)) return EnvFile{}, false } file_path := cstring_to_string(sqlite.column_text(stmt, 0)) remotes_json := cstring_to_string(sqlite.column_text(stmt, 1)) sha := cstring_to_string(sqlite.column_text(stmt, 2)) contents := cstring_to_string(sqlite.column_text(stmt, 3)) remotes: [dynamic]string if len(remotes_json) > 0 { json.unmarshal_string(remotes_json, &remotes) } cloned_path, _ := strings.clone(file_path) return EnvFile{ Path = cloned_path, Dir = filepath.dir(cloned_path), Remotes = remotes, Sha256 = sha, contents = contents, }, true } db_delete :: proc(d: ^Db, path: string) -> bool { sql := "DELETE FROM envr_env_files WHERE path = ?" stmt: ^rawptr rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil) if rc != sqlite.OK { fmt.printf("Error preparing delete: %s\n", sqlite.db_errmsg(d.db)) return false } defer sqlite.finalize(stmt) rc = sqlite.bind_text(stmt, 1, string_to_cstring(path), -1, nil) rc = sqlite.step(stmt) if rc != sqlite.DONE { fmt.printf("Error deleting: %s\n", sqlite.db_errmsg(d.db)) return false } if sqlite.changes(d.db) == 0 { fmt.printf("No file found with path: %s\n", path) return false } d.changed = true return true } cstring_to_string :: proc(cs: cstring) -> string { if cs == nil { return "" } s, _ := strings.clone_from_cstring(cs) return s } string_to_cstring :: proc(s: string) -> cstring { cs, _ := strings.clone_to_cstring(s) return cs }