Skip to content

Commit f659150

Browse files
committed
Address various minor issues
LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%)
1 parent d5b9bf3 commit f659150

9 files changed

Lines changed: 132 additions & 45 deletions

File tree

internal/reviewquery/aliases.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package reviewquery
22

33
import (
44
"fmt"
5+
"maps"
56
"os"
67
"path/filepath"
78
"sort"
@@ -19,7 +20,7 @@ import (
1920
// the installer writes ~/.lrc/queries.toml. User-defined aliases override these.
2021
func builtinAliases() map[string]string {
2122
return map[string]string{
22-
"stats": "SELECT action AS Action, COUNT(*) AS Commits, ROUND(AVG(iterations),1) AS AvgIter, ROUND(AVG(coverage)) AS AvgCoveragePct FROM review_log GROUP BY action ORDER BY Commits DESC",
23+
"stats": "SELECT action AS Action, COUNT(*) AS Commits, ROUND(AVG(iterations),1) AS AvgIter, ROUND(AVG(coverage),1) AS AvgCoveragePct FROM review_log GROUP BY action ORDER BY Commits DESC",
2324
"by-author": "SELECT author AS Author, COUNT(*) AS Commits, SUM(action = 'reviewed') AS Reviewed FROM review_log GROUP BY author ORDER BY Commits DESC",
2425
"recent": "SELECT short_hash AS Hash, date AS Date, action AS Action, subject AS Subject FROM review_log ORDER BY date DESC LIMIT 20",
2526
}
@@ -52,17 +53,20 @@ func loadUserAliases() (map[string]string, error) {
5253
if os.IsNotExist(err) {
5354
return map[string]string{}, nil
5455
}
55-
return nil, fmt.Errorf("failed to access %s: %w", path, err)
56+
return nil, fmt.Errorf("failed to access user aliases file %s: %w", path, err)
5657
}
5758

5859
k := koanf.New(".")
5960
if err := k.Load(file.Provider(path), toml.Parser()); err != nil {
60-
return nil, fmt.Errorf("failed to parse %s: %w", path, err)
61+
return nil, fmt.Errorf("failed to parse user aliases file %s: %w", path, err)
6162
}
62-
out := map[string]string{}
63-
for name, val := range k.StringMap("queries") {
64-
out[name] = val
63+
// A non-empty file that lacks the [queries] table entirely is malformed —
64+
// surface that instead of silently loading zero aliases.
65+
if len(k.Keys()) > 0 && !k.Exists("queries") {
66+
return nil, fmt.Errorf("user aliases file %s has no [queries] table", path)
6567
}
68+
out := map[string]string{}
69+
maps.Copy(out, k.StringMap("queries"))
6670
return out, nil
6771
}
6872

@@ -119,6 +123,9 @@ func AddAlias(name, sql string) error {
119123
if strings.TrimSpace(sql) == "" {
120124
return fmt.Errorf("alias SQL cannot be empty")
121125
}
126+
if err := validateReadOnlySQL(sql); err != nil {
127+
return fmt.Errorf("alias SQL rejected: %w", err)
128+
}
122129
user, err := loadUserAliases()
123130
if err != nil {
124131
return err

internal/reviewquery/command.go

Lines changed: 43 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,15 @@ import (
1010
// RunQuery is the default action for `lrc query`. It either saves an alias
1111
// (--add/--name) or runs a saved alias / raw SQL and prints a table or JSON.
1212
func RunQuery(c *cli.Context) error {
13-
if add := strings.TrimSpace(c.String("add")); add != "" {
13+
if c.IsSet("add") {
14+
add := strings.TrimSpace(c.String("add"))
1415
name := strings.TrimSpace(c.String("name"))
15-
if name == "" {
16+
if !c.IsSet("name") || name == "" {
1617
return fmt.Errorf("--add requires --name")
1718
}
19+
if add == "" {
20+
return fmt.Errorf("--add requires non-empty SQL")
21+
}
1822
if err := AddAlias(name, add); err != nil {
1923
return err
2024
}
@@ -28,7 +32,10 @@ func RunQuery(c *cli.Context) error {
2832

2933
// urfave/cli stops parsing flags at the first positional arg, so also scan
3034
// the remaining args for trailing flags (e.g. `lrc query stats --from 2024-01-01`).
31-
positionals := parseTrailingFlags(c.Args().Slice(), &jsonOut, &filter)
35+
positionals, err := parseTrailingFlags(c.Args().Slice(), &jsonOut, &filter)
36+
if err != nil {
37+
return err
38+
}
3239

3340
arg := "stats" // default alias
3441
if len(positionals) > 0 && strings.TrimSpace(positionals[0]) != "" {
@@ -64,45 +71,50 @@ func RunQuery(c *cli.Context) error {
6471
// parseTrailingFlags pulls flags out of args that cli left unparsed (anything
6572
// after the first positional). Supports `--flag value` and `--flag=value`.
6673
// Returns the remaining positional args; sets jsonOut/filter via pointers.
67-
func parseTrailingFlags(args []string, jsonOut *bool, filter *Filter) []string {
74+
// Returns an error if a bound flag (--from/--to/--range) is the last arg with
75+
// no value following it, rather than silently swallowing the flag name into
76+
// the positionals (where it would end up mangling the SQL/alias lookup).
77+
func parseTrailingFlags(args []string, jsonOut *bool, filter *Filter) ([]string, error) {
78+
boundFlags := []struct {
79+
name string
80+
dest *string
81+
}{
82+
{"--from", &filter.From},
83+
{"--to", &filter.To},
84+
{"--range", &filter.Range},
85+
}
86+
6887
positionals := make([]string, 0, len(args))
6988
for i := 0; i < len(args); i++ {
7089
a := args[i]
71-
// flag=value form
72-
switch {
73-
case a == "--json" || a == "-j":
90+
if a == "--json" || a == "-j" {
7491
*jsonOut = true
7592
continue
76-
case strings.HasPrefix(a, "--from="):
77-
filter.From = strings.TrimPrefix(a, "--from=")
78-
continue
79-
case strings.HasPrefix(a, "--to="):
80-
filter.To = strings.TrimPrefix(a, "--to=")
81-
continue
82-
case strings.HasPrefix(a, "--range="):
83-
filter.Range = strings.TrimPrefix(a, "--range=")
84-
continue
8593
}
86-
// flag value form (consume next arg)
87-
if i+1 < len(args) {
88-
switch a {
89-
case "--from":
90-
filter.From = args[i+1]
91-
i++
92-
continue
93-
case "--to":
94-
filter.To = args[i+1]
95-
i++
96-
continue
97-
case "--range":
98-
filter.Range = args[i+1]
94+
95+
consumed := false
96+
for _, bf := range boundFlags {
97+
if val, ok := strings.CutPrefix(a, bf.name+"="); ok {
98+
*bf.dest = val
99+
consumed = true
100+
break
101+
}
102+
if a == bf.name {
103+
if i+1 >= len(args) {
104+
return nil, fmt.Errorf("%s requires a value", bf.name)
105+
}
106+
*bf.dest = args[i+1]
99107
i++
100-
continue
108+
consumed = true
109+
break
101110
}
102111
}
112+
if consumed {
113+
continue
114+
}
103115
positionals = append(positionals, a)
104116
}
105-
return positionals
117+
return positionals, nil
106118
}
107119

108120
// RunQueryList prints every alias and its source.

internal/reviewquery/command_test.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ func TestParseTrailingFlags(t *testing.T) {
2323
t.Run(tc.name, func(t *testing.T) {
2424
jsonOut := false
2525
f := Filter{}
26-
pos := parseTrailingFlags(tc.args, &jsonOut, &f)
26+
pos, err := parseTrailingFlags(tc.args, &jsonOut, &f)
27+
if err != nil {
28+
t.Fatalf("parseTrailingFlags error: %v", err)
29+
}
2730
if len(pos) != len(tc.wantPos) || (len(pos) > 0 && pos[0] != tc.wantPos[0]) {
2831
t.Errorf("positionals = %v; want %v", pos, tc.wantPos)
2932
}
@@ -34,3 +37,16 @@ func TestParseTrailingFlags(t *testing.T) {
3437
})
3538
}
3639
}
40+
41+
func TestParseTrailingFlagsMissingValue(t *testing.T) {
42+
for _, flag := range []string{"--from", "--to", "--range"} {
43+
t.Run(flag, func(t *testing.T) {
44+
jsonOut := false
45+
f := Filter{}
46+
_, err := parseTrailingFlags([]string{"stats", flag}, &jsonOut, &f)
47+
if err == nil {
48+
t.Fatalf("expected error when %s has no value, got nil", flag)
49+
}
50+
})
51+
}
52+
}

internal/reviewquery/engine.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package reviewquery
22

33
import (
44
"fmt"
5+
"log"
6+
"strings"
57

68
"github.com/HexmosTech/git-lrc/storage"
79
)
@@ -33,7 +35,9 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`
3335

3436
// Run extracts the review history (scoped by filter), loads it into an in-memory
3537
// SQLite table named review_log, and runs sqlText against it. sqlText must
36-
// already be resolved (alias -> SQL) by the caller.
38+
// already be resolved (alias -> SQL) by the caller, but may otherwise be raw
39+
// text typed by the user (an unresolved alias name is treated as ad-hoc SQL) —
40+
// RunOnRecords enforces that it's a single read-only statement before running it.
3741
func Run(f Filter, sqlText string) (QueryResult, error) {
3842
records, err := Extract(f)
3943
if err != nil {
@@ -42,14 +46,43 @@ func Run(f Filter, sqlText string) (QueryResult, error) {
4246
return RunOnRecords(records, sqlText)
4347
}
4448

49+
// validateReadOnlySQL guards the in-memory review_log database against
50+
// anything but a single read-only SELECT/WITH statement. sqlText can come
51+
// straight from a user's shell (unresolved alias -> raw positional args) or
52+
// from a saved alias, so this rejects stacked statements (which could smuggle
53+
// a DROP/ATTACH/PRAGMA in after a semicolon) and any non-SELECT statement type.
54+
func validateReadOnlySQL(sqlText string) error {
55+
stmt := strings.TrimSpace(sqlText)
56+
if stmt == "" {
57+
return fmt.Errorf("query is empty")
58+
}
59+
stmt = strings.TrimSpace(strings.TrimSuffix(stmt, ";"))
60+
if strings.Contains(stmt, ";") {
61+
return fmt.Errorf("only a single SQL statement is allowed")
62+
}
63+
upper := strings.ToUpper(stmt)
64+
if !strings.HasPrefix(upper, "SELECT") && !strings.HasPrefix(upper, "WITH") {
65+
return fmt.Errorf("only read-only SELECT queries are allowed")
66+
}
67+
return nil
68+
}
69+
4570
// RunOnRecords loads records into an in-memory review_log table and runs sqlText
4671
// against it. Split out from Run so it can be tested/benchmarked without git.
4772
func RunOnRecords(records []ReviewRecord, sqlText string) (QueryResult, error) {
73+
if err := validateReadOnlySQL(sqlText); err != nil {
74+
return QueryResult{}, err
75+
}
76+
4877
db, err := storage.OpenInMemorySQLite()
4978
if err != nil {
5079
return QueryResult{}, err
5180
}
52-
defer func() { _ = db.Close() }()
81+
defer func() {
82+
if cerr := db.Close(); cerr != nil {
83+
log.Printf("reviewquery: failed to close in-memory sqlite db: %v", cerr)
84+
}
85+
}()
5386

5487
if _, err := storage.ExecSQL(db, createTableSQL); err != nil {
5588
return QueryResult{}, fmt.Errorf("failed to create review_log table: %w", err)

internal/reviewquery/extract.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package reviewquery
22

33
import (
44
"fmt"
5+
"log"
56
"os/exec"
67
"regexp"
78
"strconv"
@@ -67,9 +68,11 @@ func parseRecord(raw, branch string) (ReviewRecord, bool) {
6768
}
6869
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(parts[4])); err == nil {
6970
rec.Date = t
71+
} else {
72+
log.Printf("reviewquery: failed to parse commit date %q for %s: %v", parts[4], rec.ShortHash, err)
7073
}
7174
body := parts[6]
72-
for _, line := range strings.Split(body, "\n") {
75+
for line := range strings.SplitSeq(body, "\n") {
7376
if action, iter, cov, ok := parseTrailer(line); ok {
7477
rec.Action = action
7578
rec.Iterations = iter
@@ -84,6 +87,7 @@ func parseRecord(raw, branch string) (ReviewRecord, bool) {
8487
func currentBranch() string {
8588
out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output()
8689
if err != nil {
90+
log.Printf("reviewquery: failed to determine current branch: %v", err)
8791
return ""
8892
}
8993
return strings.TrimSpace(string(out))
@@ -104,7 +108,10 @@ func Extract(f Filter) ([]ReviewRecord, error) {
104108
args = append(args, "--until="+f.To)
105109
}
106110
if f.Range != "" {
107-
args = append(args, f.Range)
111+
// --end-of-options stops git from treating f.Range as an option if it
112+
// happens to start with '-' (e.g. a crafted --range value); the rest of
113+
// the args up to "--" are already our own well-formed --flag=value pairs.
114+
args = append(args, "--end-of-options", f.Range)
108115
}
109116
if f.PathPrefix != "" {
110117
args = append(args, "--", f.PathPrefix)

internal/reviewquery/format.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ func FormatTable(r QueryResult) string {
5252

5353
// FormatJSON renders a QueryResult as a JSON array of row objects, preserving
5454
// column order. All values are strings (the engine stringifies cells).
55+
//
56+
// This builds the object syntax by hand rather than json.Marshal-ing a
57+
// map[string]string per row: encoding/json has no way to preserve key order
58+
// for a Go map (it always sorts map keys alphabetically), and column order is
59+
// part of this format's contract. Each key/value is still run through
60+
// json.Marshal so escaping stays correct.
5561
func FormatJSON(r QueryResult) (string, error) {
5662
var b strings.Builder
5763
b.WriteString("[")

scripts/lrc-install.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,7 @@ if (-not (Test-Path $LRC_QUERIES_FILE)) {
656656
# Add your own with: lrc query --add "<sql>" --name "<name>"
657657
# Table columns: hash, short_hash, author, email, date, branch, subject, action, iterations, coverage
658658
[queries]
659-
stats = "SELECT action AS Action, COUNT(*) AS Commits, ROUND(AVG(iterations),1) AS AvgIter, ROUND(AVG(coverage)) AS AvgCoveragePct FROM review_log GROUP BY action ORDER BY Commits DESC"
659+
stats = "SELECT action AS Action, COUNT(*) AS Commits, ROUND(AVG(iterations),1) AS AvgIter, ROUND(AVG(coverage),1) AS AvgCoveragePct FROM review_log GROUP BY action ORDER BY Commits DESC"
660660
by-author = "SELECT author AS Author, COUNT(*) AS Commits, SUM(action = 'reviewed') AS Reviewed FROM review_log GROUP BY author ORDER BY Commits DESC"
661661
recent = "SELECT short_hash AS Hash, date AS Date, action AS Action, subject AS Subject FROM review_log ORDER BY date DESC LIMIT 20"
662662
'@ | Set-Content -Path $LRC_QUERIES_FILE -Encoding UTF8

scripts/lrc-install.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,7 @@ if [ ! -f "$LRC_QUERIES_FILE" ]; then
537537
# Add your own with: lrc query --add "<sql>" --name "<name>"
538538
# Table columns: hash, short_hash, author, email, date, branch, subject, action, iterations, coverage
539539
[queries]
540-
stats = "SELECT action AS Action, COUNT(*) AS Commits, ROUND(AVG(iterations),1) AS AvgIter, ROUND(AVG(coverage)) AS AvgCoveragePct FROM review_log GROUP BY action ORDER BY Commits DESC"
540+
stats = "SELECT action AS Action, COUNT(*) AS Commits, ROUND(AVG(iterations),1) AS AvgIter, ROUND(AVG(coverage),1) AS AvgCoveragePct FROM review_log GROUP BY action ORDER BY Commits DESC"
541541
by-author = "SELECT author AS Author, COUNT(*) AS Commits, SUM(action = 'reviewed') AS Reviewed FROM review_log GROUP BY author ORDER BY Commits DESC"
542542
recent = "SELECT short_hash AS Hash, date AS Date, action AS Action, subject AS Subject FROM review_log ORDER BY date DESC LIMIT 20"
543543
QUERIESEOF

storage/sqlite_query_io.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ func OpenInMemorySQLite() (*sql.DB, error) {
2424

2525
// BulkInsert inserts many rows under a single transaction with a prepared
2626
// statement — far faster than autocommitting each row (matters for large repos).
27+
// query is executed as-is with no restriction on statement type, so callers
28+
// must only pass trusted, internally-constructed SQL (e.g. a fixed INSERT
29+
// template) — never untrusted/user-supplied text.
2730
func BulkInsert(db *sql.DB, query string, rows [][]any) error {
2831
if db == nil {
2932
return fmt.Errorf("failed bulk insert: nil database handle")
@@ -51,9 +54,12 @@ func BulkInsert(db *sql.DB, query string, rows [][]any) error {
5154
return nil
5255
}
5356

54-
// QueryRows runs a read-only query and returns the column names plus each row
57+
// QueryRows runs a query and returns the column names plus each row
5558
// stringified (NULL -> ""). Keeping database/sql access inside the storage
5659
// boundary lets callers render results without importing database/sql.
60+
// This package does not itself enforce that query is read-only or a single
61+
// statement — when query may originate from user input (as it does for the
62+
// reviewquery engine), the caller is responsible for validating it first.
5763
func QueryRows(db *sql.DB, query string, args ...any) (columns []string, rows [][]string, err error) {
5864
if db == nil {
5965
return nil, nil, fmt.Errorf("failed SQL query: nil database handle")

0 commit comments

Comments
 (0)