package main // What the journal verb may be asked (the operator's direction 2026-10-07, recorded in novox/hq): a time // window, a priority and a fixed text, beside the unit and how many lines. // // **Nothing a caller says reaches a shell, and nothing is read as an option.** journalctl is run with an // argv of its own words (execRunner), under `sudo -n` for the system journal (issue 255) — so a value that // journalctl read as an option would be root's option. Every value is therefore held to the forms // journalctl reads for it and given as `--name=value`, one word: a relative "-30min" is the value of // --since, never a flag. A value in any other form is refused naming the forms, before anything runs. import ( "fmt" "regexp" "strconv" "strings" "time" ) const ( // MostLines is the most lines one answer carries: well below what the runtime carries back in one reply. MostLines = 2000 // DefaultLines is how many when the caller does not say. DefaultLines = 100 // MatchScan is how many of the window's last lines a match is looked for in. MatchScan = 50000 // LongestLine is where one line is cut, so a single runaway line cannot fill the answer. LongestLine = 4096 // LongestMatch is the longest text a match may be. LongestMatch = 256 ) // JournalQuery is what a journal read is narrowed by. type JournalQuery struct { Lines int Since string Until string Match string Priority string } // relative is a time journalctl reads relative to now: -30min, +1h, 2h30min ago, and the day words. var relative = regexp.MustCompile(`^(now|today|yesterday|tomorrow|[+-]?([0-9]+(usec|us|msec|ms|seconds|second|sec|s|minutes|minute|min|m|hours|hour|hr|h|days|day|d|weeks|week|w|months|month|M|years|year|y))+|([0-9]+(usec|us|msec|ms|seconds|second|sec|s|minutes|minute|min|m|hours|hour|hr|h|days|day|d|weeks|week|w|months|month|M|years|year|y) ?)+ago)$`) // localDate is a date, or a date and a time, in the machine's own zone, as journalctl reads it. var localDate = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}( [0-9]{2}:[0-9]{2}(:[0-9]{2})?)?$`) // priorities are journalctl's priority names, and the words people use for them. var priorities = map[string]int{ "emerg": 0, "emergency": 0, "panic": 0, "alert": 1, "crit": 2, "critical": 2, "err": 3, "error": 3, "warning": 4, "warn": 4, "notice": 5, "info": 6, "debug": 7, } // when is one bound of the window as journalctl is given it, and the absolute time it names when it is // one: an RFC 3339 time becomes seconds since the epoch, which every journalctl reads whatever its version. func when(name, v string, ceil bool) (string, *time.Time, error) { if t, err := time.Parse(time.RFC3339Nano, v); err == nil { s := t.Unix() if ceil && t.Nanosecond() > 0 { s++ } return "@" + strconv.FormatInt(s, 10), &t, nil } if relative.MatchString(v) || localDate.MatchString(v) { return v, nil, nil } return "", nil, fmt.Errorf("%s %q: an RFC 3339 time (2026-10-07T09:30:00Z), a time relative to now "+ "(-30min, -2h, 1h ago, yesterday) or a local date (2026-10-07 09:30)", name, v) } // valid is the query with its defaults applied and every value held to its forms. func (q JournalQuery) valid() (JournalQuery, error) { switch { case q.Lines == 0: q.Lines = DefaultLines case q.Lines < 0: return q, fmt.Errorf("lines %d: at least 1", q.Lines) case q.Lines > MostLines: q.Lines = MostLines } var since, until *time.Time var err error if q.Since != "" { if _, since, err = when("since", q.Since, false); err != nil { return q, err } } if q.Until != "" { if _, until, err = when("until", q.Until, true); err != nil { return q, err } } if since != nil && until != nil && until.Before(*since) { return q, fmt.Errorf("the window ends (%s) before it starts (%s)", q.Until, q.Since) } if q.Priority != "" { p := strings.ToLower(q.Priority) if n, ok := priorities[p]; ok { q.Priority = strconv.Itoa(n) } else if len(p) != 1 || p[0] < '0' || p[0] > '7' { return q, fmt.Errorf("priority %q: 0-7, or emerg, alert, crit, err, warning, notice, info, debug", q.Priority) } } if len(q.Match) > LongestMatch { return q, fmt.Errorf("a match is at most %d bytes", LongestMatch) } if strings.ContainsAny(q.Match, "\n\r\x00") { return q, fmt.Errorf("a match is one line of text") } return q, nil } // argv is journalctl's words for a valid query: each value inside the word of its own option. func (q JournalQuery) argv(unit string, lines int) []string { args := []string{"--no-pager", "--output=short-iso", "--unit=" + unit} if q.Since != "" { v, _, _ := when("since", q.Since, false) args = append(args, "--since="+v) } if q.Until != "" { v, _, _ := when("until", q.Until, true) args = append(args, "--until="+v) } if q.Priority != "" { args = append(args, "--priority="+q.Priority) } return append(args, "--lines="+strconv.Itoa(lines)) } // journalQuery is the query a call's arguments say. A number may come as a JSON number or as its text: // the seat's schema carries every argument as a string. func journalQuery(a map[string]any) (JournalQuery, error) { q := JournalQuery{} switch v := a["lines"].(type) { case nil: case float64: if v != float64(int(v)) { return q, fmt.Errorf("lines %v: a whole number", v) } q.Lines = int(v) case string: if strings.TrimSpace(v) != "" { n, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil { return q, fmt.Errorf("lines %q: a whole number", v) } q.Lines = n } default: return q, fmt.Errorf("lines: a whole number") } for name, into := range map[string]*string{"since": &q.Since, "until": &q.Until, "match": &q.Match, "priority": &q.Priority} { switch v := a[name].(type) { case nil: case string: if name == "match" { *into = v } else { *into = strings.TrimSpace(v) } case float64: if name != "priority" { return q, fmt.Errorf("%s: text", name) } *into = strconv.FormatFloat(v, 'f', -1, 64) default: return q, fmt.Errorf("%s: text", name) } } return q, nil }