mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* cron: dep-free 5-field cron expression parser * cron: next-run evaluator with Vixie DOM/DOW semantics * cron: preset recipes + loop.md prompt resolution (cap + symlink reject) * cron: file-backed job store (metadata.json + runs.jsonl) * cli: zero cron add/list/rm/pause/resume over the cron store * cli: zero cron run foreground scheduler (--once/--catch-up, exec per fire) * cron: fix adversarial-review findings (DST loop, traversal, runaway re-fire, +) - Next: fix infinite loop on DST spring-forward gaps (advance hours by absolute addition + a forward-progress guard); widen the search window to 9 years so a Feb-29 schedule across a century non-leap year (2096->2104) isn't reported as impossible. - store: reject path-traversal job ids (Get/Update/Remove/AppendRun) so 'cron rm ../..' can't delete outside the store; List now surfaces corrupt jobs as a warning instead of silently dropping them. - runner: reject impossible schedules at add even with --run-now; auto-pause a job whose schedule can no longer advance (was re-firing every tick); only skip-reschedule STRICTLY-overdue jobs on startup (keep exactly-due ones); capture stderr into RunRecord.Error on non-zero exit; pass the prompt via the inline --prompt= form so dash-leading prompts aren't misparsed. - Regression tests for each (DST, leap gap, traversal, corrupt-list, pause, reconcile, dash-prompt). * cron: address #155 review (store-error surfacing, resume guard, Runs traversal, +) - fireJob/reconcile/cronRun now surface AppendRun/Update failures to stderr instead of discarding them (a failed write no longer silently re-fires/loses history); fireJob takes stderr. - cron add rejects extra positional arguments instead of silently ignoring them. - cronResume rejects an unparseable/impossible schedule instead of reactivating a job with a stale/zero NextRunAt. - store.AppendRun returns the Close() error (buffered-write failures surface on close); store.Runs now validates the id (path-traversal guard, matching the other store methods). - tests: Runs traversal rejection, cron add extra-args, resume-impossible. * cron: check store errors in cron CLI tests CodeRabbit (critical): the cron CLI tests ignored errors from store.List/Add/Get, so a store failure would surface as a misleading assertion on a zero-value job/slice instead of a clear failure. Check every store op's error across the test file (not only the two flagged sites). * cron: DefaultRoot falls back to os.UserHomeDir when HOME is unset Vasanth (P1): cron's DefaultRoot mirrored neither sessions.DefaultRoot nor reality — with no XDG_DATA_HOME and no HOME it produced a RELATIVE ".local/share/zero/cron" under the caller's cwd, so on Windows/restricted shells cron jobs scattered per working directory and could write repo-local data. Now falls back to os.UserHomeDir() like sessions.DefaultRoot. Adds TestDefaultRootEmptyHomeFallsBackToUserHome (asserts the path stays absolute).
91 lines
2.4 KiB
Go
91 lines
2.4 KiB
Go
package cron
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestRecipesHaveValidSchedules(t *testing.T) {
|
|
if len(Recipes()) == 0 {
|
|
t.Fatal("expected preset recipes")
|
|
}
|
|
for _, r := range Recipes() {
|
|
if _, err := Parse(r.Expr); err != nil {
|
|
t.Fatalf("recipe %q has invalid expr %q: %v", r.ID, r.Expr, err)
|
|
}
|
|
if strings.TrimSpace(r.Prompt) == "" || strings.TrimSpace(r.ID) == "" {
|
|
t.Fatalf("recipe %q missing id/prompt", r.ID)
|
|
}
|
|
}
|
|
if _, ok := Recipe("git-recap"); !ok {
|
|
t.Fatal("expected a git-recap recipe")
|
|
}
|
|
if _, ok := Recipe("nope"); ok {
|
|
t.Fatal("unknown recipe must not resolve")
|
|
}
|
|
}
|
|
|
|
func TestResolveLoopPromptPriority(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
home := t.TempDir()
|
|
// built-in fallback when nothing present
|
|
got, err := ResolveLoopPrompt(cwd, home)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != builtinLoopPrompt {
|
|
t.Fatalf("expected built-in fallback, got %q", got)
|
|
}
|
|
// home/.zero/loop.md is used when cwd has none
|
|
mustWrite(t, filepath.Join(home, ".zero", "loop.md"), "HOME PROMPT")
|
|
if got, _ = ResolveLoopPrompt(cwd, home); got != "HOME PROMPT" {
|
|
t.Fatalf("home loop.md not used, got %q", got)
|
|
}
|
|
// cwd/.zero/loop.md wins over home
|
|
mustWrite(t, filepath.Join(cwd, ".zero", "loop.md"), "CWD PROMPT")
|
|
if got, _ = ResolveLoopPrompt(cwd, home); got != "CWD PROMPT" {
|
|
t.Fatalf("cwd loop.md should win, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveLoopPromptRejectsSymlinkAndCap(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
home := t.TempDir()
|
|
// oversize file -> error
|
|
big := filepath.Join(cwd, ".zero", "loop.md")
|
|
mustWrite(t, big, strings.Repeat("a", maxLoopPromptBytes+1))
|
|
if _, err := ResolveLoopPrompt(cwd, home); err == nil {
|
|
t.Fatal("expected error for oversize loop.md")
|
|
}
|
|
// symlink -> rejected (skipped), falls through to built-in
|
|
cwd2 := t.TempDir()
|
|
target := filepath.Join(cwd2, "real.md")
|
|
mustWrite(t, target, "SECRET")
|
|
link := filepath.Join(cwd2, ".zero", "loop.md")
|
|
if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(target, link); err != nil {
|
|
t.Skip("symlinks unsupported")
|
|
}
|
|
got, err := ResolveLoopPrompt(cwd2, home)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got == "SECRET" {
|
|
t.Fatal("symlinked loop.md must not be read")
|
|
}
|
|
}
|
|
|
|
func mustWrite(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|