Skip to content

fix(snapshots): Parse time of day using base 10 - #5583

Open
lsamaciel wants to merge 1 commit into
kopia:masterfrom
lsamaciel:fix/snapshot-time-leading-zero
Open

fix(snapshots): Parse time of day using base 10#5583
lsamaciel wants to merge 1 commit into
kopia:masterfrom
lsamaciel:fix/snapshot-time-leading-zero

Conversation

@lsamaciel

Copy link
Copy Markdown

Fixes #4325

Problem

kopia policy set <path> --snapshot-time=09:45 fails:

scheduling policy: unable to parse time of day: invalid time of day, must be HH:MM

09:45 is HH:MM, so the message points away from the actual problem. Only the
hour field is affected, and only for 08 and 09:

input before input before
0:30, 00:30 ok 08:30 error
07:30, 7:30 ok 09:30 error
8:30, 9:30 ok 10:30, 18:30 ok

The minute field has the same defect with a different symptom: 10:08 and
10:09 are accepted and silently stored as 10:00.

Both hit the natural way of writing a morning schedule, which is when a lot of
people run backups. A comma-separated list is rejected as a whole without saying
which entry failed, so --snapshot-time=3:45,09:45,15:45,21:45 fails while
--snapshot-time=3:45,9:45,15:45,21:45 works.

--snapshot-time-crontab is not affected.

Root cause

TimeOfDay.Parse parsed both halves with a single fmt.Sscanf:

if _, err := fmt.Sscanf(s, "%v:%02v", &t.Hour, &t.Minute); err != nil {

For integers the %v verb calls scanBasePrefix() in fmt/scan.go, which
treats a leading 0 as a base prefix and falls through to octal. So the hour
08 scans as the octal token 0, the leftover 8 no longer matches the literal
: in the format, and Sscanf fails. 0007 are valid octal, which is why
they appear to work.

The minute is scanned the same way, but it is the last verb in the format
string, and Sscanf ignores trailing input — so the leftover digit is dropped
and no error is reported. That is the silent 10:08 -> 10:00.

The 02 in %02v is a width, not a base, so it never made the two halves
behave differently.

Fix

Split on the colon and convert each half with strconv.Atoi, which always
parses base 10 and rejects trailing garbage. The receiver is only written once
both halves are valid, so a failed parse no longer leaves a half-updated value
behind, and each error message now includes the offending input — which also
identifies the bad entry when a list is passed to --snapshot-time.

Why not just %d

Changing the verb from %v to %d keeps Sscanf and fixes the reported bug:
the base is then fixed at 10, getBase() never calls scanBasePrefix(), and
every hour and minute parses correctly with or without a leading zero. I tested
that: TestTimeOfDayParse passes in full against Sscanf with "%d:%d".

It leaves three defects in place, though:

  • trailing garbage is still accepted and silently dropped: 1:2:3 -> 1:02,
    10:30abc -> 10:30, 10:0x8 -> 10:00;
  • a failed parse still leaves the receiver half-written, e.g. 99:99 returns an
    error after storing TimeOfDay{99, 99};
  • the error message still does not include the value that failed, so a rejected
    comma-separated list still does not say which entry caused it.

Keeping the original width ("%d:%02d") is worse still: the width truncates the
field, so 10:100 parses as 10:10 and 1:234 as 01:23.

Behavior changes beyond the fix

The new parser is stricter than Sscanf was. Input the old parser accepted, and
what happens to it now:

input before after
10:08 10:00 (silent) 10:08
10:30:00, 10:30abc 10:30 error
0x10:00 16:00 error
1_0:30 10:30 error
10: 30 10:30 error

Surrounding whitespace is still tolerated (--snapshot-time="8:00, 9:30" keeps
working). The stricter cases all look like input that was never intended to
parse; flagging it here in case it should be called out in the release notes.
Each row above has a case in TestTimeOfDayParseInvalid, so the grammar cannot
quietly loosen again.

TimeOfDay.String() is deliberately left alone: it renders the hour without a
leading zero, so a policy set as 08:45 is shown as 8:45 by
kopia policy show. Both forms parse, so this round-trips; changing the output
format felt out of scope for a bug fix.

Tests

TestTimeOfDayParse covers all 24 hours and all 60 minutes, each with and
without a leading zero, and asserts the stored value rather than only the
absence of an error — that is what catches the silent 10:08 -> 10:00 case.
TestTimeOfDayParseInvalid covers malformed and out-of-range input, and asserts
that the error names the input and that a failed parse leaves the receiver
untouched.

Against the previous implementation, 26 of the new subtests fail; all pass with
this change. go vet, golangci-lint, and go test ./snapshot/... ./cli/...
are clean.

TimeOfDay.Parse used fmt.Sscanf with the %v verb, which detects the number base
from the input, so an hour written with a leading zero was parsed as octal:
"08:30" and "09:30" were rejected with "invalid time of day, must be HH:MM",
while "00:30" through "07:30" happened to work.

The minute went through the same base detection, but since it is the last verb
in the format string, the leftover digit was silently discarded instead:
"10:08" and "10:09" were accepted and stored as 10:00.

Parse both halves with strconv.Atoi, which always uses base 10 and rejects
trailing garbage, and assign to the receiver only once both halves are valid.
The error messages now include the value that failed to parse, which also
identifies the offending entry when a comma-separated list is passed to
kopia policy set --snapshot-time.

Fixes kopia#4325
@lsamaciel
lsamaciel requested a review from a team August 26, 2026 10:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

kopia policy set - snapshot-time will leading zero fails with 'unable to parse time of day: invalid time of day, must be HH:MM'

1 participant