Files
Mitchell-JohnsonandClaude Opus 5 a07a444170 The Space tab is Usage; the Cron tab is Jobs
Both are named for what they do rather than the tool underneath, the way Services is.
Old links and saved rail orders still land in the right place. The Go packages and their
endpoints keep their names, with a pointer in each to what the UI calls them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:54:23 -07:00

814 lines
22 KiB
Go

// Package cron reads and writes what the UI calls the **Jobs** tab (named "Cron" until
// 2026-09-24; the package and its /api/cron endpoints kept the old name).
//
// Package cron reads every scheduled job on the host into one list: /etc/crontab,
// /etc/cron.d/*, per-user crontabs, the run-parts directories they drive, and systemd
// timers. It computes next runs from the cron expressions, pulls last runs from the
// journal (cron logs "(user) CMD (command)" via syslog), and can toggle, edit and run
// jobs. Reads/writes that need root go through passwordless sudo when available.
package cron
import (
"bufio"
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"hamlab/internal/priv"
)
type Job struct {
ID string `json:"id"`
Source string `json:"source"` // "/etc/crontab", "/etc/cron.d/x", "crontab:user", "/etc/cron.daily"
Line int `json:"line"` // 1-based line in Source (0 for run-parts scripts)
User string `json:"user"`
Schedule string `json:"schedule"`
Command string `json:"command"`
Enabled bool `json:"enabled"`
Next int64 `json:"next,omitempty"` // unix ms
Last int64 `json:"last,omitempty"`
OutFile string `json:"out_file,omitempty"` // parsed from a ">> file" redirect
Note string `json:"note,omitempty"`
Editable bool `json:"editable"`
raw string
}
type Timer struct {
Unit string `json:"unit"`
Activates string `json:"activates"`
Description string `json:"description"`
Active string `json:"active"`
Sub string `json:"sub"`
Enabled string `json:"enabled"`
Next int64 `json:"next,omitempty"`
Last int64 `json:"last,omitempty"`
}
type Overview struct {
Jobs []Job `json:"jobs"`
Timers []Timer `json:"timers"`
Logging bool `json:"logging"` // cron lines seen in the journal
Anacron bool `json:"anacron"`
Sudo bool `json:"sudo"`
}
type Run struct {
ID string `json:"id"`
JobID string `json:"job_id"`
Command string `json:"command"`
User string `json:"user"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
Running bool `json:"running"`
ExitCode int `json:"exit_code"`
Output string `json:"output"`
mu sync.Mutex
buf bytes.Buffer
}
type Manager struct {
sudo bool
journalSudo bool
self string
mu sync.Mutex
lastRuns map[string]int64 // user\x00command -> unix ms
lastAt time.Time
lastCursor string // journal cursor of the newest entry folded into lastRuns
logging bool
runs map[string]*Run
}
func New(sudo, journalSudo bool) *Manager {
m := &Manager{sudo: sudo, journalSudo: journalSudo, runs: map[string]*Run{}}
if u, err := user.Current(); err == nil {
m.self = u.Username
}
return m
}
// ---- helpers ----
func run(ctx context.Context, stdin string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(errb.String())
if msg == "" {
msg = err.Error()
}
return out.String(), errors.New(msg)
}
return out.String(), nil
}
// runLines streams a command's stdout to fn line by line, so multi-megabyte journal
// dumps never sit in memory whole.
func runLines(ctx context.Context, fn func(line []byte), args ...string) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
var errb bytes.Buffer
cmd.Stderr = &errb
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
for sc.Scan() {
fn(sc.Bytes())
}
if err := cmd.Wait(); err != nil {
if msg := strings.TrimSpace(errb.String()); msg != "" {
return errors.New(msg)
}
return err
}
return nil
}
func (m *Manager) sudoArgs(args ...string) []string {
if m.sudo {
return priv.Wrap(args...)
}
return args
}
func jobID(source, raw string) string {
h := sha1.Sum([]byte(source + "\n" + raw))
return hex.EncodeToString(h[:6])
}
var userRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
var cronDName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // cron ignores other names in /etc/cron.d
// ---- parsing ----
// parseLine splits one crontab line. withUser is true for system crontabs (6th field is
// the user). Returns ok=false for comments, blanks, env assignments and garbage.
func parseLine(line string, withUser bool) (schedule, usr, command string, ok bool) {
t := strings.TrimSpace(line)
if t == "" || strings.HasPrefix(t, "#") {
return
}
if i := strings.Index(t, "="); i > 0 && !strings.ContainsAny(t[:i], " \t*") { // NAME=value
return
}
var fields []string
if strings.HasPrefix(t, "@") {
f := strings.Fields(t)
if len(f) < 2 {
return
}
fields = []string{f[0]}
t = strings.TrimSpace(t[len(f[0]):])
} else {
f := strings.Fields(t)
if len(f) < 6 {
return
}
fields = f[:5]
for _, x := range fields {
t = strings.TrimSpace(t[len(x):])
}
}
schedule = strings.Join(fields, " ")
if _, err := parseSchedule(schedule); err != nil {
return
}
if withUser {
f := strings.Fields(t)
if len(f) < 2 || !userRe.MatchString(f[0]) {
return
}
usr = f[0]
t = strings.TrimSpace(t[len(f[0]):])
}
if t == "" {
return
}
return schedule, usr, t, true
}
var redirectRe = regexp.MustCompile(`(?:^|\s)(?:>>|>|2>&1\s*>>|&>>|&>)\s*("?)([^\s"&|;]+)`)
func outFile(cmd string) string {
for _, m := range redirectRe.FindAllStringSubmatch(cmd, -1) {
if p := m[2]; strings.HasPrefix(p, "/") && p != "/dev/null" {
return p
}
}
return ""
}
// parseSource turns crontab text into jobs. Commented-out lines that still parse as a
// job are reported disabled, so a "# 0 5 * * * ..." example shows up as such.
func (m *Manager) parseSource(source, text string, withUser, editable bool, defaultUser string, now time.Time, anacron bool) []Job {
var jobs []Job
lines := strings.Split(text, "\n")
isComment := func(i int) bool {
return i >= 0 && i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), "#")
}
for i, line := range lines {
enabled := true
probe := line
if t := strings.TrimSpace(line); strings.HasPrefix(t, "#") {
// A commented-out job sits on its own; a job-shaped line inside a block of
// comments is documentation (Debian's stock header has two such examples).
if isComment(i-1) && isComment(i+1) {
continue
}
enabled = false
probe = strings.TrimLeft(t, "# ")
}
schedule, usr, command, ok := parseLine(probe, withUser)
if !ok {
continue
}
if usr == "" {
usr = defaultUser
}
if !enabled {
if _, err := user.Lookup(usr); err != nil {
continue // "user-name" placeholder and the like
}
}
j := Job{ID: jobID(source, line), Source: source, Line: i + 1, User: usr, Schedule: schedule, Command: command,
Enabled: enabled, OutFile: outFile(command), Editable: editable, raw: line}
if enabled {
if next, ok := nextRun(schedule, now); ok {
j.Next = next.UnixMilli()
}
}
if strings.Contains(command, "run-parts") {
if dir := runPartsDir(command); dir != "" {
if anacron && strings.Contains(command, "anacron") {
j.Note = "skipped: anacron is installed and runs " + dir + " instead"
}
jobs = append(jobs, j)
jobs = append(jobs, m.runPartsJobs(dir, j)...)
continue
}
}
jobs = append(jobs, j)
}
return jobs
}
var runPartsRe = regexp.MustCompile(`run-parts\s+(?:--\S+\s+)*(/\S+)`)
func runPartsDir(cmd string) string {
if m := runPartsRe.FindStringSubmatch(cmd); m != nil {
return m[1]
}
return ""
}
// runPartsJobs lists the executable scripts a run-parts line will run, as read-only jobs.
func (m *Manager) runPartsJobs(dir string, parent Job) []Job {
ents, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []Job
for _, e := range ents {
if e.IsDir() || !cronDName.MatchString(e.Name()) {
continue
}
info, err := e.Info()
if err != nil || info.Mode()&0o111 == 0 {
continue
}
p := filepath.Join(dir, e.Name())
out = append(out, Job{ID: jobID(dir, e.Name()), Source: dir, User: parent.User, Schedule: parent.Schedule, Command: p,
Enabled: parent.Enabled, Next: parent.Next, Note: "run by " + parent.Source + " via run-parts", raw: e.Name()})
}
return out
}
// ---- sources ----
type source struct {
name string // job Source
withUser bool
user string // default user for user crontabs
editable bool
}
func (m *Manager) readSource(ctx context.Context, s source) (string, error) {
if strings.HasPrefix(s.name, "crontab:") {
u := s.user
if u == m.self {
out, err := run(ctx, "", "crontab", "-l")
if err != nil && strings.Contains(err.Error(), "no crontab") {
return "", nil
}
return out, err
}
if !m.sudo {
return "", errors.New("needs sudo")
}
out, err := run(ctx, "", priv.Wrap("crontab", "-u", u, "-l")...)
if err != nil && strings.Contains(err.Error(), "no crontab") {
return "", nil
}
return out, err
}
b, err := os.ReadFile(s.name)
if err != nil && m.sudo {
out, err2 := run(ctx, "", priv.Wrap("cat", s.name)...)
return out, err2
}
return string(b), err
}
func (m *Manager) sources(ctx context.Context) []source {
var out []source
if _, err := os.Stat("/etc/crontab"); err == nil {
out = append(out, source{name: "/etc/crontab", withUser: true, editable: m.sudo})
}
if ents, err := os.ReadDir("/etc/cron.d"); err == nil {
for _, e := range ents {
if !e.IsDir() && cronDName.MatchString(e.Name()) {
out = append(out, source{name: "/etc/cron.d/" + e.Name(), withUser: true, editable: m.sudo})
}
}
}
// per-user crontabs: Debian keeps them in /var/spool/cron/crontabs, cronie in /var/spool/cron
users := map[string]bool{}
if m.sudo {
for _, dir := range []string{"/var/spool/cron/crontabs", "/var/spool/cron"} {
if out, err := run(ctx, "", priv.Wrap("ls", "-1", dir)...); err == nil {
for _, n := range strings.Split(out, "\n") {
if n = strings.TrimSpace(n); userRe.MatchString(n) {
users[n] = true
}
}
break
}
}
}
if m.self != "" {
users[m.self] = true
}
names := make([]string, 0, len(users))
for u := range users {
names = append(names, u)
}
sort.Strings(names)
for _, u := range names {
out = append(out, source{name: "crontab:" + u, user: u, editable: u == m.self || m.sudo})
}
return out
}
func (m *Manager) findSource(ctx context.Context, name string) (source, bool) {
for _, s := range m.sources(ctx) {
if s.name == name {
return s, true
}
}
return source{}, false
}
// ---- overview ----
func (m *Manager) Overview(ctx context.Context) Overview {
now := time.Now()
_, anacronErr := os.Stat("/usr/sbin/anacron")
anacron := anacronErr == nil
ov := Overview{Jobs: []Job{}, Timers: []Timer{}, Anacron: anacron, Sudo: m.sudo}
for _, s := range m.sources(ctx) {
text, err := m.readSource(ctx, s)
if err != nil {
continue
}
ov.Jobs = append(ov.Jobs, m.parseSource(s.name, text, s.withUser, s.editable, s.user, now, anacron)...)
}
last := m.lastRunsFromJournal(ctx)
ov.Logging = m.logging
for i := range ov.Jobs {
j := &ov.Jobs[i]
if t, ok := last[j.User+"\x00"+j.Command]; ok {
j.Last = t
}
}
ov.Timers = m.timers(ctx)
return ov
}
var cronLogRe = regexp.MustCompile(`^\((\S+)\) CMD \((.*)\)$`)
// lastRunsFromJournal maps user+command to the most recent "(user) CMD (command)" line.
// The first call scans 30 days; later calls only read entries after the last cursor, so
// the cost per refresh is proportional to new cron activity, not to history.
func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 {
m.mu.Lock()
if time.Since(m.lastAt) < 2*time.Second && m.lastRuns != nil {
r := m.lastRuns
m.mu.Unlock()
return r
}
cursor := m.lastCursor
res := map[string]int64{}
for k, v := range m.lastRuns {
res[k] = v
}
m.mu.Unlock()
args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q"}
if cursor != "" {
args = append(args, "--after-cursor="+cursor)
} else {
args = append(args, "--since", "-30d", "-n", "20000")
}
if m.journalSudo {
args = priv.Wrap(args...)
}
seen := m.logging
_ = runLines(ctx, func(line []byte) {
var raw struct {
Msg any `json:"MESSAGE"`
TS string `json:"__REALTIME_TIMESTAMP"`
Cursor string `json:"__CURSOR"`
}
if json.Unmarshal(line, &raw) != nil {
return
}
seen = true
if raw.Cursor != "" {
cursor = raw.Cursor
}
msg, _ := raw.Msg.(string)
mm := cronLogRe.FindStringSubmatch(msg)
if mm == nil {
return
}
us, _ := strconv.ParseInt(raw.TS, 10, 64)
k := mm[1] + "\x00" + mm[2]
if us/1000 > res[k] {
res[k] = us / 1000
}
}, args...)
m.mu.Lock()
m.lastRuns, m.lastAt, m.lastCursor, m.logging = res, time.Now(), cursor, seen
m.mu.Unlock()
return res
}
// JobLogs returns the journal lines for one job's command (most recent last).
func (m *Manager) JobLogs(ctx context.Context, j Job, n int) ([]map[string]any, error) {
args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q", "-n", "20000"}
if m.journalSudo {
args = priv.Wrap(args...)
}
var res []map[string]any
needle := "(" + j.User + ") CMD (" + j.Command + ")"
err := runLines(ctx, func(line []byte) {
if !bytes.Contains(line, []byte(j.Command[:min(len(j.Command), 40)])) { // cheap pre-filter before JSON
return
}
var raw struct {
Msg any `json:"MESSAGE"`
TS string `json:"__REALTIME_TIMESTAMP"`
PID string `json:"_PID"`
}
if json.Unmarshal(line, &raw) != nil {
return
}
msg, _ := raw.Msg.(string)
if !strings.Contains(msg, needle) {
return
}
us, _ := strconv.ParseInt(raw.TS, 10, 64)
res = append(res, map[string]any{"t": us / 1000, "p": 6, "ident": "CRON", "pid": raw.PID, "msg": msg})
}, args...)
if err != nil {
return nil, err
}
if len(res) > n {
res = res[len(res)-n:]
}
if res == nil {
res = []map[string]any{}
}
return res, nil
}
// OutputTail returns the last n lines of the job's redirect target.
func (m *Manager) OutputTail(ctx context.Context, j Job, n int) (string, error) {
if j.OutFile == "" {
return "", errors.New("job has no output file")
}
out, err := run(ctx, "", m.sudoArgs("tail", "-n", strconv.Itoa(n), j.OutFile)...)
return out, err
}
// ---- timers ----
// timers: one `systemctl show` over every loaded timer instead of three list commands.
func (m *Manager) timers(ctx context.Context) []Timer {
out, err := run(ctx, "", "systemctl", "show", "*.timer", "--no-pager",
"-p", "Id,Description,ActiveState,SubState,UnitFileState,NextElapseUSecRealtime,LastTriggerUSec,Triggers")
if err != nil {
return []Timer{}
}
unix := func(v string) int64 { // "Sat 2026-09-19 05:47:13 PDT", "n/a" or ""
for _, layout := range []string{"Mon 2006-01-02 15:04:05 MST", "2006-01-02 15:04:05 MST", "Mon 2006-01-02 15:04:05"} {
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
return t.UnixMilli()
}
}
return 0
}
res := []Timer{}
for _, block := range strings.Split(out, "\n\n") {
t := Timer{}
for _, line := range strings.Split(block, "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch k {
case "Id":
t.Unit = v
case "Description":
t.Description = v
case "ActiveState":
t.Active = v
case "SubState":
t.Sub = v
case "UnitFileState":
t.Enabled = v
case "NextElapseUSecRealtime":
t.Next = unix(v)
case "LastTriggerUSec":
t.Last = unix(v)
case "Triggers":
t.Activates = strings.Fields(v + " ")[0:1][0]
}
}
if t.Unit != "" {
res = append(res, t)
}
}
sort.Slice(res, func(i, j int) bool { return res[i].Unit < res[j].Unit })
return res
}
var timerRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9@._:\\-]{0,255}\.timer$`)
func (m *Manager) TimerAction(ctx context.Context, unit, action string) error {
if !timerRe.MatchString(unit) {
return errors.New("invalid timer name")
}
switch action {
case "start", "stop", "enable", "disable":
_, err := run(ctx, "", m.sudoArgs("systemctl", action, unit)...)
return err
case "run": // fire the service the timer activates, right now
out, err := run(ctx, "", "systemctl", "show", unit, "-p", "Triggers", "--value")
if err != nil {
return err
}
svc := strings.TrimSpace(strings.Split(out, " ")[0])
if svc == "" {
return errors.New("timer triggers nothing")
}
_, err = run(ctx, "", m.sudoArgs("systemctl", "start", svc)...)
return err
}
return errors.New("unknown action")
}
// ---- job lookup and editing ----
func (m *Manager) FindJob(ctx context.Context, id string) (Job, bool) {
for _, j := range m.Overview(ctx).Jobs {
if j.ID == id {
return j, true
}
}
return Job{}, false
}
// Source returns a crontab's raw text for the editor.
func (m *Manager) Source(ctx context.Context, name string) (text string, editable bool, err error) {
s, ok := m.findSource(ctx, name)
if !ok {
return "", false, errors.New("unknown source")
}
text, err = m.readSource(ctx, s)
return text, s.editable, err
}
// validate rejects any line that is neither blank, comment, env assignment, nor a job.
func validate(text string, withUser bool) error {
for i, line := range strings.Split(text, "\n") {
t := strings.TrimSpace(line)
if t == "" || strings.HasPrefix(t, "#") {
continue
}
if k, _, ok := strings.Cut(t, "="); ok && !strings.ContainsAny(strings.TrimSpace(k), " \t*") {
continue
}
if _, _, _, ok := parseLine(t, withUser); !ok {
return fmt.Errorf("line %d: not a valid cron entry", i+1)
}
}
return nil
}
// WriteSource replaces a crontab. User crontabs go through crontab(1) so cron reloads
// them; system files are written in place with their permissions preserved.
func (m *Manager) WriteSource(ctx context.Context, name, text string) error {
s, ok := m.findSource(ctx, name)
if !ok {
return errors.New("unknown source")
}
if !s.editable {
return errors.New("source is read-only here (no sudo)")
}
if !strings.HasSuffix(text, "\n") {
text += "\n"
}
if err := validate(text, s.withUser); err != nil {
return err
}
if strings.HasPrefix(name, "crontab:") {
if s.user == m.self {
_, err := run(ctx, text, "crontab", "-")
return err
}
_, err := run(ctx, text, priv.Wrap("crontab", "-u", s.user, "-")...)
return err
}
if name != "/etc/crontab" && !strings.HasPrefix(name, "/etc/cron.d/") {
return errors.New("refusing to write outside /etc/crontab and /etc/cron.d")
}
_, err := run(ctx, text, priv.Wrap("tee", name)...)
m.mu.Lock()
m.lastAt = time.Time{}
m.mu.Unlock()
return err
}
// Toggle comments or uncomments a job's line. The line must still read as the job did
// when it was listed, so a concurrent edit can't be clobbered.
func (m *Manager) Toggle(ctx context.Context, j Job, enable bool) error {
if j.Line == 0 || !j.Editable {
return errors.New("this job can't be toggled here")
}
s, ok := m.findSource(ctx, j.Source)
if !ok {
return errors.New("unknown source")
}
text, err := m.readSource(ctx, s)
if err != nil {
return err
}
lines := strings.Split(text, "\n")
if j.Line > len(lines) || lines[j.Line-1] != j.raw {
return errors.New("crontab changed underneath; reload and try again")
}
if enable {
lines[j.Line-1] = strings.TrimLeft(strings.TrimSpace(j.raw), "# ")
} else {
lines[j.Line-1] = "#" + j.raw
}
return m.WriteSource(ctx, j.Source, strings.Join(lines, "\n"))
}
// ---- run now ----
// StartRun executes the job's command as its user, the way cron would (sh -c, cwd = home,
// '%' turned into newlines/stdin), in the background. Output is kept in memory.
func (m *Manager) StartRun(ctx context.Context, j Job) (*Run, error) {
if j.User != m.self && !m.sudo {
return nil, errors.New("needs sudo to run as " + j.User)
}
cmdline, stdin := cronPercent(j.Command)
args := []string{"sh", "-c", cmdline}
if j.User != m.self {
args = priv.AsUser(j.User, args...)
}
r := &Run{ID: jobID("run", strconv.FormatInt(time.Now().UnixNano(), 10)), JobID: j.ID, Command: j.Command, User: j.User, Started: time.Now(), Running: true}
runCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
cmd := exec.CommandContext(runCtx, args[0], args[1:]...)
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
w := &capWriter{r: r}
cmd.Stdout, cmd.Stderr = w, w
if err := cmd.Start(); err != nil {
cancel()
return nil, err
}
m.mu.Lock()
m.runs[r.ID] = r
for id, old := range m.runs { // keep the map small
if !old.Running && time.Since(old.Finished) > time.Hour {
delete(m.runs, id)
}
}
m.mu.Unlock()
go func() {
defer cancel()
err := cmd.Wait()
r.mu.Lock()
r.Running, r.Finished = false, time.Now()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
r.ExitCode = ee.ExitCode()
} else {
r.ExitCode = -1
}
}
r.mu.Unlock()
m.mu.Lock()
m.lastAt = time.Time{}
m.mu.Unlock()
}()
return r, nil
}
func (m *Manager) GetRun(id string) (*Run, bool) {
m.mu.Lock()
r, ok := m.runs[id]
m.mu.Unlock()
return r, ok
}
// Snapshot returns a copy safe to encode.
func (r *Run) Snapshot() Run {
r.mu.Lock()
defer r.mu.Unlock()
return Run{ID: r.ID, JobID: r.JobID, Command: r.Command, User: r.User, Started: r.Started, Finished: r.Finished,
Running: r.Running, ExitCode: r.ExitCode, Output: r.buf.String()}
}
type capWriter struct{ r *Run }
func (w *capWriter) Write(p []byte) (int, error) {
w.r.mu.Lock()
defer w.r.mu.Unlock()
w.r.buf.Write(p)
if w.r.buf.Len() > 256<<10 { // keep the tail
b := w.r.buf.Bytes()
w.r.buf = *bytes.NewBuffer(append([]byte("…\n"), b[len(b)-200<<10:]...))
}
return len(p), nil
}
// cronPercent applies crontab(5) rules: an unescaped % ends the command; later ones are
// newlines fed to stdin. "\%" is a literal percent.
func cronPercent(cmd string) (string, string) {
var out, in strings.Builder
cur := &out
esc := false
for _, c := range cmd {
switch {
case esc:
if c != '%' {
cur.WriteRune('\\')
}
cur.WriteRune(c)
esc = false
case c == '\\':
esc = true
case c == '%':
if cur == &out {
cur = &in
} else {
in.WriteRune('\n')
}
default:
cur.WriteRune(c)
}
}
return out.String(), in.String()
}