| package timestamp | |
| import ( | |
| "errors" | |
| "fmt" | |
| "strconv" | |
| "strings" | |
| "time" | |
| ) | |
| // These are additional predefined layouts for use in Time.Format and Time.Parse | |
| // with --since and --until parameters for `docker logs` and `docker events` | |
| const ( | |
| rFC3339Local = "2006-01-02T15:04:05" // RFC3339 with local timezone | |
| rFC3339NanoLocal = "2006-01-02T15:04:05.999999999" // RFC3339Nano with local timezone | |
| dateWithZone = "2006-01-02Z07:00" // RFC3339 with time at 00:00:00 | |
| dateLocal = "2006-01-02" // RFC3339 with local timezone and time at 00:00:00 | |
| ) | |
| // Parse tries to parse given string as golang duration, then RFC3339 time and | |
| // finally as a Unix timestamp. The returned time is normalized to UTC. | |
| // | |
| // In case of duration input, the returned timestamp is computed as the given | |
| // reference time minus the amount of the duration. | |
| func Parse(value string, reference time.Time) (time.Time, error) { | |
| if strings.TrimSpace(value) == "" { | |
| return time.Time{}, errors.New("failed to parse value as time or duration: value is empty") | |
| } | |
| if d, err := time.ParseDuration(value); value != "0" && err == nil { | |
| return reference.Add(-d).UTC(), nil | |
| } | |
| var format string | |
| // if the string has a Z or a + or three dashes use parse otherwise use parseinlocation | |
| parseInLocation := !strings.ContainsAny(value, "zZ+") && strings.Count(value, "-") != 3 | |
| if strings.Contains(value, ".") { | |
| if parseInLocation { | |
| format = rFC3339NanoLocal | |
| } else { | |
| format = time.RFC3339Nano | |
| } | |
| } else if strings.Contains(value, "T") { | |
| // we want the number of colons in the T portion of the timestamp | |
| tcolons := strings.Count(value, ":") | |
| // if parseInLocation is off and we have a +/- zone offset (not Z) then | |
| // there will be an extra colon in the input for the tz offset subtract that | |
| // colon from the tcolons count | |
| if !parseInLocation && !strings.ContainsAny(value, "zZ") && tcolons > 0 { | |
| tcolons-- | |
| } | |
| if parseInLocation { | |
| switch tcolons { | |
| case 0: | |
| format = "2006-01-02T15" | |
| case 1: | |
| format = "2006-01-02T15:04" | |
| default: | |
| format = rFC3339Local | |
| } | |
| } else { | |
| switch tcolons { | |
| case 0: | |
| format = "2006-01-02T15Z07:00" | |
| case 1: | |
| format = "2006-01-02T15:04Z07:00" | |
| default: | |
| format = time.RFC3339 | |
| } | |
| } | |
| } else if parseInLocation { | |
| format = dateLocal | |
| } else { | |
| format = dateWithZone | |
| } | |
| var t time.Time | |
| var err error | |
| if parseInLocation { | |
| t, err = time.ParseInLocation(format, value, time.FixedZone(reference.Zone())) | |
| } else { | |
| t, err = time.Parse(format, value) | |
| } | |
| if err != nil { | |
| // if there is a `-` then it's an RFC3339 like timestamp | |
| if strings.Contains(value, "-") { | |
| return time.Time{}, err // was probably an RFC3339 like timestamp but the parser failed with an error | |
| } | |
| t, err = parseTimestamp(value) | |
| if err != nil { | |
| return time.Time{}, fmt.Errorf("failed to parse value as time or duration: %w", err) | |
| } | |
| } | |
| return t.UTC(), nil | |
| } | |
| // ParseUnixTimestamp parses a UNIX timestamp with optional nanoseconds | |
| // and returns it as a [time.Time]. | |
| // | |
| // Value should be formatted as | |
| // | |
| // "%d.%09d" (seconds.nanoseconds) | |
| // | |
| // It accepts either: | |
| // - "seconds" | |
| // - "seconds.nanoseconds" | |
| // | |
| // If the nanoseconds has less than 9 digits it is right-padded with zeros. | |
| // If it has more than 9 digits it is truncated. | |
| // | |
| // Empty values ("") produce a zero-value, with no error. | |
| func ParseUnixTimestamp(value string) (time.Time, error) { | |
| if value == "" { | |
| return time.Time{}, nil | |
| } | |
| t, err := parseTimestamp(value) | |
| if err != nil { | |
| return time.Time{}, fmt.Errorf("invalid timestamp %q: %w", value, err) | |
| } | |
| return t, nil | |
| } | |
| func parseTimestamp(value string) (time.Time, error) { | |
| s, n, ok := strings.Cut(value, ".") | |
| sec, err := strconv.ParseInt(s, 10, 64) | |
| if err != nil { | |
| var numErr *strconv.NumError | |
| if errors.As(err, &numErr) { | |
| err = numErr.Err | |
| } | |
| return time.Time{}, fmt.Errorf("invalid seconds %q: %w", s, err) | |
| } | |
| if !ok || n == "" || n == "000000000" || n == "0" { | |
| // Fast path for "zero" nanoseconds | |
| return time.Unix(sec, 0).UTC(), nil | |
| } | |
| // The documented format is 9 digits. Historically we allowed more and | |
| // truncated to nanoseconds precision. Let's add some sensible limit; | |
| // 20 digits would be a full uint64 value. | |
| const maxFracDigits = 20 | |
| if len(n) > maxFracDigits { | |
| return time.Time{}, fmt.Errorf("invalid nanoseconds: length %d exceeds maximum %d", len(n), maxFracDigits) | |
| } | |
| // Nanoseconds must be digits only (reject '+' / '-' and other junk). | |
| for i := range len(n) { | |
| if c := n[i]; c < '0' || c > '9' { | |
| return time.Time{}, fmt.Errorf("invalid nanoseconds: invalid character %q at position %d", c, i) | |
| } | |
| } | |
| // cap at 9 digits, or pad to 9 digits | |
| if len(n) > 9 { | |
| n = n[:9] | |
| } else if len(n) < 9 { | |
| n += strings.Repeat("0", 9-len(n)) | |
| } | |
| nsec, err := strconv.ParseInt(n, 10, 64) | |
| if err != nil { | |
| // this should never fail, but just in case | |
| return time.Time{}, fmt.Errorf("invalid nanoseconds %q: %w", n, err) | |
| } | |
| return time.Unix(sec, nsec).UTC(), nil | |
| } |