A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(date): add -d, -I[FMT], -R, %E/%O fallback, %N

Implements GNU coreutils compatibility for date:

- -d/--date STRING parser: @TIMESTAMP epoch, RFC3339,
POSIX-strict, and basic relative offsets ("5 minutes
ago", "+1 day")
- -I[FMT]/--iso-8601 with optional FMT (date, hours,
minutes, seconds, ns)
- -R/--rfc-email RFC 5322 format
- %E/%O locale-modifier graceful fallback
- %N nanoseconds conversion specifier

Documents the always-UTC sandbox invariant inline so future
contributors don't "fix" it as a bug.

Refs: docs/posix2018/CONFORMANCE.md
Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

+223 -15
+144 -12
command/internal/date/date.go
··· 22 22 months = []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"} 23 23 ) 24 24 25 + // validIsoFmts are the precision arguments accepted by `-I[FMT]` / 26 + // `--iso-8601[=FMT]`. Mirrors GNU coreutils. 27 + var validIsoFmts = map[string]string{ 28 + "date": "%Y-%m-%d", 29 + "hours": "%Y-%m-%dT%H%z", 30 + "minutes": "%Y-%m-%dT%H:%M%z", 31 + "seconds": "%Y-%m-%dT%H:%M:%S%z", 32 + "ns": "%Y-%m-%dT%H:%M:%S.%N%z", 33 + } 34 + 25 35 func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 26 36 if ec == nil { 27 37 return errors.New("date: nil ExecContext") ··· 36 46 stderr = io.Discard 37 47 } 38 48 49 + // `-I` takes an OPTIONAL argument (`-I`, `-Idate`, `-Ihours`, etc.) and 50 + // `--iso-8601` accepts `--iso-8601=FMT`. getopt/v2 does not model 51 + // optional arguments natively, so we pre-scan args, capture the 52 + // requested precision, and normalize the flag to a plain boolean before 53 + // handing the remainder to the parser. 54 + isoFmt := "" 55 + args, isoSet, isoFmt, isoErr := extractIsoFlag(args) 56 + if isoErr != nil { 57 + fmt.Fprintf(stderr, "date: %s\n", isoErr) 58 + return interp.ExitStatus(1) 59 + } 60 + 39 61 set := getopt.New() 40 62 set.SetProgram("date") 41 63 set.SetParameters("[+FORMAT]") ··· 43 65 usage := func() { 44 66 fmt.Fprint(stderr, "Usage: date [OPTION]... [+FORMAT]\n") 45 67 fmt.Fprint(stderr, "Display the current time in the given FORMAT.\n\n") 46 - fmt.Fprint(stderr, " -d, --date=STRING display time described by STRING\n") 47 - fmt.Fprint(stderr, " -u, --utc print Coordinated Universal Time (UTC)\n") 48 - fmt.Fprint(stderr, " -I, --iso-8601 output date/time in ISO 8601 format\n") 49 - fmt.Fprint(stderr, " -R, --rfc-email output RFC 5322 date format\n") 50 - fmt.Fprint(stderr, " --help display this help and exit\n") 68 + fmt.Fprint(stderr, " -d, --date=STRING display time described by STRING\n") 69 + fmt.Fprint(stderr, " -u, --utc print Coordinated Universal Time (UTC)\n") 70 + fmt.Fprint(stderr, " -I[FMT], --iso-8601[=FMT] output in ISO 8601 format; FMT may be\n") 71 + fmt.Fprint(stderr, " 'date' (default), 'hours', 'minutes',\n") 72 + fmt.Fprint(stderr, " 'seconds', or 'ns'\n") 73 + fmt.Fprint(stderr, " -R, --rfc-email output RFC 5322 date format\n") 74 + fmt.Fprint(stderr, " --help display this help and exit\n") 51 75 } 52 76 set.SetUsage(usage) 53 77 54 78 dateStr := set.StringLong("date", 'd', "", "display time described by STRING") 55 79 utc := set.BoolLong("utc", 'u', "print Coordinated Universal Time (UTC)") 56 - iso := set.BoolLong("iso-8601", 'I', "output date/time in ISO 8601 format") 57 80 rfc := set.BoolLong("rfc-email", 'R', "output RFC 5322 date format") 58 81 help := set.BoolLong("help", 0, "display this help and exit") 59 82 ··· 67 90 return nil 68 91 } 69 92 70 - // The sandbox always renders in UTC to avoid leaking the host 71 - // timezone through %Z, %z, or wall-clock fields. The flag is 72 - // accepted for compatibility but is otherwise a no-op. 93 + // kefka invariant: the sandbox always renders dates in UTC, regardless 94 + // of the host's `TZ` environment. POSIX says `-u` selects UTC and that 95 + // `TZ` controls otherwise, so this is a deliberate deviation. Keeping 96 + // time output stable also prevents leaking host timezone through `%Z` 97 + // or `%z`. The flag is accepted for compatibility but is otherwise a 98 + // no-op. See docs/posix2018/CONFORMANCE.md "### `date`" — please do 99 + // not "fix" this without updating that document. 73 100 _ = *utc 74 101 75 102 var fmtStr string ··· 99 126 switch { 100 127 case hasFmt: 101 128 out = formatDate(d, fmtStr) 102 - case *iso: 103 - out = formatDate(d, "%Y-%m-%dT%H:%M:%S%z") 129 + case isoSet: 130 + out = formatDate(d, isoFmt) 104 131 case *rfc: 105 132 out = formatDate(d, "%a, %d %b %Y %H:%M:%S %z") 106 133 default: ··· 111 138 return nil 112 139 } 113 140 141 + // extractIsoFlag pre-processes argv to handle `-I[FMT]` and 142 + // `--iso-8601[=FMT]`, which carry optional arguments that getopt/v2 cannot 143 + // represent natively. It returns the remaining args, whether the flag was 144 + // seen, the resolved strftime template, and any error from an invalid FMT. 145 + func extractIsoFlag(args []string) ([]string, bool, string, error) { 146 + out := make([]string, 0, len(args)) 147 + seen := false 148 + tmpl := validIsoFmts["date"] 149 + stopParsing := false 150 + for i := 0; i < len(args); i++ { 151 + a := args[i] 152 + if stopParsing { 153 + out = append(out, a) 154 + continue 155 + } 156 + if a == "--" { 157 + stopParsing = true 158 + out = append(out, a) 159 + continue 160 + } 161 + switch { 162 + case a == "-I" || a == "--iso-8601": 163 + seen = true 164 + case strings.HasPrefix(a, "--iso-8601="): 165 + seen = true 166 + fmtName := strings.TrimPrefix(a, "--iso-8601=") 167 + t, ok := validIsoFmts[fmtName] 168 + if !ok { 169 + return nil, false, "", fmt.Errorf("invalid argument '%s' for '--iso-8601'", fmtName) 170 + } 171 + tmpl = t 172 + case strings.HasPrefix(a, "-I") && len(a) > 2 && !strings.HasPrefix(a, "--"): 173 + seen = true 174 + fmtName := a[2:] 175 + t, ok := validIsoFmts[fmtName] 176 + if !ok { 177 + return nil, false, "", fmt.Errorf("invalid argument '%s' for '-I'", fmtName) 178 + } 179 + tmpl = t 180 + default: 181 + out = append(out, a) 182 + } 183 + } 184 + return out, seen, tmpl, nil 185 + } 186 + 187 + func isAlpha(b byte) bool { 188 + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') 189 + } 190 + 114 191 func formatDate(d time.Time, f string) string { 115 192 var b strings.Builder 116 193 for i := 0; i < len(f); i++ { ··· 119 196 continue 120 197 } 121 198 i++ 199 + // POSIX `%E` / `%O` are locale-modifier prefixes. They precede 200 + // another conversion specifier. Per POSIX, an implementation 201 + // that does not support the locale alternative MUST fall back 202 + // to the unmodified specifier. Skip the modifier byte (only 203 + // when it is followed by an ASCII letter, a real spec char) and 204 + // let the next iteration handle the spec normally. 205 + if (f[i] == 'E' || f[i] == 'O') && i+1 < len(f) && isAlpha(f[i+1]) { 206 + i++ 207 + } 122 208 switch f[i] { 123 209 case '%': 124 210 b.WriteByte('%') ··· 146 232 fmt.Fprintf(&b, "%02d", d.Minute()) 147 233 case 'n': 148 234 b.WriteByte('\n') 235 + case 'N': 236 + fmt.Fprintf(&b, "%09d", d.Nanosecond()) 149 237 case 'p': 150 238 if d.Hour() < 12 { 151 239 b.WriteString("AM") ··· 192 280 return b.String() 193 281 } 194 282 195 - var digitsOnly = regexp.MustCompile(`^\d+$`) 283 + var ( 284 + digitsOnly = regexp.MustCompile(`^\d+$`) 285 + // matches `N <unit> ago`, `N <unit>`, `+N <unit>`, `-N <unit>` where 286 + // unit is one of second(s), minute(s), hour(s), day(s), week(s). 287 + relativeRe = regexp.MustCompile(`^([+-]?\d+)\s+(second|minute|hour|day|week)s?(\s+ago)?$`) 288 + ) 196 289 197 290 func parseDate(s string) (time.Time, bool) { 291 + s = strings.TrimSpace(s) 292 + 293 + // `@TIMESTAMP` — explicit GNU epoch syntax. 294 + if strings.HasPrefix(s, "@") { 295 + if n, err := strconv.ParseInt(s[1:], 10, 64); err == nil { 296 + return time.Unix(n, 0).UTC(), true 297 + } 298 + return time.Time{}, false 299 + } 300 + 198 301 layouts := []string{ 199 302 time.RFC3339Nano, 200 303 time.RFC3339, ··· 227 330 case "tomorrow": 228 331 return time.Now().UTC().Add(24 * time.Hour), true 229 332 } 333 + 334 + // Basic GNU-style relative parsing: "5 minutes ago", "+1 day", "-2 hours". 335 + // More elaborate forms ("next Friday", "last week", multi-segment phrases 336 + // like "1 day 2 hours") are intentionally deferred — they require a full 337 + // parsedatetime-style engine. 338 + if m := relativeRe.FindStringSubmatch(strings.ToLower(s)); m != nil { 339 + n, err := strconv.ParseInt(m[1], 10, 64) 340 + if err != nil { 341 + return time.Time{}, false 342 + } 343 + if m[3] != "" { // "ago" 344 + n = -n 345 + } 346 + var dur time.Duration 347 + switch m[2] { 348 + case "second": 349 + dur = time.Duration(n) * time.Second 350 + case "minute": 351 + dur = time.Duration(n) * time.Minute 352 + case "hour": 353 + dur = time.Duration(n) * time.Hour 354 + case "day": 355 + dur = time.Duration(n) * 24 * time.Hour 356 + case "week": 357 + dur = time.Duration(n) * 7 * 24 * time.Hour 358 + } 359 + return time.Now().UTC().Add(dur), true 360 + } 361 + 230 362 return time.Time{}, false 231 363 }
+79 -3
command/internal/date/date_test.go
··· 45 45 wantStdout: "Wed Jan 15 12:00:00 UTC 2025\n", 46 46 }, 47 47 { 48 - name: "iso 8601 short form", 48 + name: "iso 8601 default is date", 49 49 args: []string{"-I", "-d", fixed}, 50 + wantStdout: "2025-01-15\n", 51 + }, 52 + { 53 + name: "iso 8601 long form default is date", 54 + args: []string{"--iso-8601", "-d", fixed}, 55 + wantStdout: "2025-01-15\n", 56 + }, 57 + { 58 + name: "iso 8601 hours precision", 59 + args: []string{"-Ihours", "-d", fixed}, 60 + wantStdout: "2025-01-15T12+0000\n", 61 + }, 62 + { 63 + name: "iso 8601 minutes precision", 64 + args: []string{"-Iminutes", "-d", fixed}, 65 + wantStdout: "2025-01-15T12:00+0000\n", 66 + }, 67 + { 68 + name: "iso 8601 seconds precision", 69 + args: []string{"-Iseconds", "-d", fixed}, 50 70 wantStdout: "2025-01-15T12:00:00+0000\n", 51 71 }, 52 72 { 53 - name: "iso 8601 long form", 54 - args: []string{"--iso-8601", "-d", fixed}, 73 + name: "iso 8601 long form with seconds", 74 + args: []string{"--iso-8601=seconds", "-d", fixed}, 55 75 wantStdout: "2025-01-15T12:00:00+0000\n", 76 + }, 77 + { 78 + name: "iso 8601 invalid precision errors", 79 + args: []string{"-Ibogus", "-d", fixed}, 80 + wantErr: true, 56 81 }, 57 82 { 58 83 name: "rfc email short form", ··· 205 230 wantStdout: "2025-01-15T12:00:00Z\n", 206 231 }, 207 232 { 233 + name: "epoch at-sign zero", 234 + args: []string{"-d", "@0", "+%Y-%m-%d"}, 235 + wantStdout: "1970-01-01\n", 236 + }, 237 + { 238 + name: "epoch at-sign timestamp", 239 + args: []string{"-d", "@1736942400", "+%Y-%m-%dT%H:%M:%SZ"}, 240 + wantStdout: "2025-01-15T12:00:00Z\n", 241 + }, 242 + { 243 + // %E followed by %, which is a literal-spec, not a date 244 + // spec. GNU coreutils prints %E literally and proceeds. 245 + name: "locale modifier E with non-spec next char", 246 + args: []string{"-d", fixed, "+%E%Y"}, 247 + wantStdout: "%E2025\n", 248 + }, 249 + { 250 + name: "locale modifier O falls back to d", 251 + args: []string{"-d", fixed, "+%Od"}, 252 + wantStdout: "15\n", 253 + }, 254 + { 255 + name: "locale modifier EY falls back to Y", 256 + args: []string{"-d", fixed, "+%EY"}, 257 + wantStdout: "2025\n", 258 + }, 259 + { 208 260 name: "date keyword now is accepted", 209 261 args: []string{"-d", "now", "+%Y"}, 210 262 wantStdout: "", // year depends on real clock; only assert non-empty below ··· 240 292 } 241 293 if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 242 294 t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 295 + } 296 + }) 297 + } 298 + } 299 + 300 + func TestRelativeOffsets(t *testing.T) { 301 + tests := []string{ 302 + "5 minutes ago", 303 + "1 hour ago", 304 + "2 days ago", 305 + "+1 day", 306 + "-3 hours", 307 + "1 week ago", 308 + "30 seconds ago", 309 + } 310 + for _, tt := range tests { 311 + t.Run(tt, func(t *testing.T) { 312 + stdout, stderr, err := run(t, []string{"-d", tt, "+%Y-%m-%d"}) 313 + if err != nil { 314 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 315 + } 316 + s := strings.TrimSpace(stdout) 317 + if _, err := time.Parse("2006-01-02", s); err != nil { 318 + t.Errorf("expected ISO date, got %q (parse err: %v)", s, err) 243 319 } 244 320 }) 245 321 }