diff --git a/cmd/artex/main.go b/cmd/artex/main.go index 056e496..a09553e 100644 --- a/cmd/artex/main.go +++ b/cmd/artex/main.go @@ -88,7 +88,11 @@ func run() int { } defer mgr.Close() - skillDir := config.SkillDir() + skillDir, err := config.SkillDir() + if err != nil { + log.Printf("[config] %v", err) + return 1 + } if abs, err := filepath.Abs(skillDir); err == nil { skillDir = abs } diff --git a/config/config.go b/config/config.go index 1734e82..e42487e 100644 --- a/config/config.go +++ b/config/config.go @@ -4,7 +4,9 @@ package config import ( "encoding/json" + "errors" "fmt" + "io/fs" "net/url" "os" "path/filepath" @@ -96,16 +98,23 @@ func Path() string { return candidates[0] } -// Load reads and parses the config file. A missing/unreadable file yields a zero -// Config (so callers fall back to defaults) rather than an error. -func Load() Config { +// Load reads and parses the config file. A missing file yields a zero Config (so +// callers fall back to defaults). A file that exists but cannot be read or parsed +// is an error: ignoring it would run with defaults the operator did not choose. +func Load() (Config, error) { var c Config - b, err := os.ReadFile(Path()) - if err != nil { - return c + path := Path() + b, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return c, nil } - _ = json.Unmarshal(b, &c) - return c + if err != nil { + return c, fmt.Errorf("read config file %s: %w", path, err) + } + if err := json.Unmarshal(b, &c); err != nil { + return Config{}, fmt.Errorf("parse config file %s: %w", path, err) + } + return c, nil } // SkillDir returns the skill root directory with precedence: @@ -113,17 +122,23 @@ func Load() Config { // env ARTEX_SKILL_DIR > config file (skill_dir) > BaseDir()/skills // // The directory is created if it does not exist. -func SkillDir() string { +func SkillDir() (string, error) { var d string if v := strings.TrimSpace(os.Getenv("ARTEX_SKILL_DIR")); v != "" { d = v - } else if v := strings.TrimSpace(Load().SkillDir); v != "" { - d = v } else { - d = filepath.Join(BaseDir(), "skills") + c, err := Load() + if err != nil { + return "", err + } + if v := strings.TrimSpace(c.SkillDir); v != "" { + d = v + } else { + d = filepath.Join(BaseDir(), "skills") + } } _ = os.MkdirAll(d, 0o755) - return d + return d, nil } // PostgresDSN resolves the connection string with precedence: @@ -138,7 +153,11 @@ func PostgresDSN() (dsn, source string, err error) { if v := strings.TrimSpace(os.Getenv("ARTEX_PG_DSN")); v != "" { return v, "environment variable ARTEX_PG_DSN", nil } - db := Load().Database + c, err := Load() + if err != nil { + return "", "", err + } + db := c.Database if d := strings.TrimSpace(db.DSN); d != "" { return d, "config file " + Path() + " (database.dsn)", nil } diff --git a/config/config_test.go b/config/config_test.go index e2afb16..e30c7bc 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -40,3 +41,24 @@ func TestPostgresDSNPrecedence(t *testing.T) { t.Fatalf("file dsn verbatim, got %q err %v", got, err) } } + +// A config file that exists but is not valid JSON must fail loudly, not run with +// defaults (or report "no database config") as if the file were absent. +func TestMalformedConfigIsAnError(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.json") + // What install.sh used to write for a password containing a double quote. + os.WriteFile(cfgPath, []byte(`{"database":{"host":"h","password":"a"b","dbname":"d"},"skill_dir":"/s"}`), 0o644) + t.Setenv("ARTEX_CONFIG", cfgPath) + t.Setenv("ARTEX_PG_DSN", "") + t.Setenv("ARTEX_SKILL_DIR", "") + + if _, _, err := PostgresDSN(); err == nil || !strings.Contains(err.Error(), "parse config file") { + t.Fatalf("PostgresDSN: want a parse error, got %v", err) + } + if d, err := SkillDir(); err == nil { + t.Fatalf("SkillDir: want a parse error, got dir %q", d) + } + if _, err := Load(); err == nil { + t.Fatal("Load: want a parse error") + } +} diff --git a/install.sh b/install.sh index e3cb659..a3ae41f 100755 --- a/install.sh +++ b/install.sh @@ -9,6 +9,16 @@ warn(){ printf '\033[33m[!]\033[0m %s\n' "$*"; } die(){ printf '\033[31m[x]\033[0m %s\n' "$*" >&2; exit 1; } ask(){ local p="$1" d="${2:-}" a; read -rp "$p${d:+ [$d]}: " a; echo "${a:-$d}"; } rand(){ head -c 18 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24; } +# json_str prints $1 as a JSON string literal: backslash, double quote and the +# control characters read -r can return are escaped. +json_str(){ + local s="$1" + s="${s//\\/\\\\}"; s="${s//\"/\\\"}" + s="${s//$'\t'/\\t}"; s="${s//$'\r'/\\r}" + printf '"%s"' "$s" +} +# sed_repl escapes $1 for the replacement side of s|...|...|: \, | and &. +sed_repl(){ printf '%s' "$1" | sed 's/[\\|&]/\\&/g'; } # ── docker environment check / auto-install ─────────────────── ensure_docker(){ @@ -38,8 +48,8 @@ install_docker(){ local pw key pw="$(ask 'Postgres password (press Enter to generate randomly)' "$(rand)")" key="$(ask 'ANTHROPIC_API_KEY (may be empty, configure later in the UI)' '')" - sed -i.bak "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${pw}|" .env - sed -i.bak "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=${key}|" .env + sed -i.bak "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(sed_repl "$pw")|" .env + sed -i.bak "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=$(sed_repl "$key")|" .env rm -f .env.bak ok ".env generated (POSTGRES_PASSWORD set)" else @@ -76,16 +86,17 @@ install_local(){ DB_SSL="$(ask 'sslmode (disable/require)' disable)" ;; esac - # generate config.json + # generate config.json (string values JSON-escaped: a password may contain " or \) + case "$DB_PORT" in (*[!0-9]*|'') die "port must be a number: $DB_PORT" ;; esac cat > config.json <