diff --git a/README.md b/README.md index f540d8b..61a0aeb 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,21 @@ strict mode even when non-critical: "2023-08-07T14:30:00Z[u-ca=unknown]" // Error in strict mode ``` +#### Critical Tags the Library Does Not Understand + +RFC 9557 Section 3.3 requires a recipient to treat an IXDTF string as erroneous +when it cannot process a critical suffix tag. The only key this library +understands is `u-ca`, so the responsibility split is: + +- `strict=true`: the library acts as the recipient and rejects any critical key + other than `u-ca` (e.g. `[!knort=blargel]` → error). +- `strict=false`: unrecognized critical keys are accepted and recorded in + `IXDTFExtensions.Critical`; **the caller is the recipient** and MUST check + that map and reject strings whose critical tags it cannot process. + +A duplicate suffix key is an error in both modes when either occurrence carries +the critical flag (Section 3.3); elective duplicates keep the first occurrence. + ## API Reference ### Core Functions @@ -191,6 +206,12 @@ Notes: exposed as `IXDTFExtensions.CriticalLocation` and round-trips through `Format`. - An invalid or unresolvable time zone name yields an error in strict mode or when the annotation is critical; otherwise it is ignored per RFC 9557. +- Numeric-offset annotations such as `[+09:00]` are supported; the offset itself is + authoritative, and `Format` serializes it back in the same RFC 3339 form + (RFC 9557 Section 1.2), so parsed values round-trip. +- The RFC 9557 Section 4.1 grammar allows at most one time-zone annotation, placed + before any suffix tags. A second time-zone annotation — or one appearing after a + `key=value` tag — is an `ErrInvalidSuffix` error in both modes. - `Validate` follows the same policy as `Parse`: with `strict=false` an offset mismatch is acceptable unless the annotation is critical. - Extension tag syntax and critical tag handling are independent of `strict`, except for diff --git a/ixdtf.go b/ixdtf.go index 5ceeb58..b2b9f54 100644 --- a/ixdtf.go +++ b/ixdtf.go @@ -86,6 +86,12 @@ func (e *ParseError) Error() string { return "IXDTFE parsing time \"" + e.Value + "\" as \"" + string(e.Layout) + "\": " + e.Err.Error() } +// Unwrap returns the underlying error, so callers can match sentinel errors +// with errors.Is (e.g. errors.Is(err, ErrTimezoneOffsetMismatch)). +func (e *ParseError) Unwrap() error { + return e.Err +} + // TimezoneConsistencyResult holds information about timezone offset consistency. type TimezoneConsistencyResult struct { // Location is the loaded timezone location. @@ -113,6 +119,9 @@ func Format(t time.Time, ext *IXDTFExtensions) (string, error) { if err := validateExtensions(ext); err != nil { return "", err } + if err := validateCriticalLocation(t, ext); err != nil { + return "", err + } b := appendSuffix(t, ext, time.RFC3339) return string(b), nil @@ -126,6 +135,9 @@ func FormatNano(t time.Time, ext *IXDTFExtensions) (string, error) { if err := validateExtensions(ext); err != nil { return "", err } + if err := validateCriticalLocation(t, ext); err != nil { + return "", err + } b := appendSuffix(t, ext, time.RFC3339Nano) return string(b), nil @@ -268,31 +280,49 @@ func Validate(s string, strict bool) error { return nil } -func appendSuffix(t time.Time, ext *IXDTFExtensions, format string) []byte { - b := []byte(t.Format(format)) - - // Determine which location to use for timezone information - var loc *time.Location - if ext.Location != nil { - // Extension explicitly specifies a location - loc = ext.Location - } else if t.Location() != time.UTC && t.Location().String() != "Local" { - // time.Time has a specific location (not UTC or Local) +// formatLocation returns the location whose name is emitted as the time-zone +// annotation: ext.Location when set, otherwise the timestamp's own named zone. +// When falling back to the timestamp's zone, UTC, Local, and unnamed zones +// produce no annotation, so nil is returned. +func formatLocation(t time.Time, ext *IXDTFExtensions) *time.Location { + loc := ext.Location + if loc == nil { loc = t.Location() + if loc == time.UTC || loc.String() == "Local" { + return nil + } + } + if loc.String() == "" { + return nil } - // If ext.Location is nil and t.Location() is UTC or Local, don't add timezone + return loc +} + +// validateCriticalLocation rejects a critical time-zone flag that has no zone +// to attach to — neither ext.Location nor the timestamp's own named zone. +// Emitting output that silently drops the "!" would misrepresent the caller's +// declared critical intent (RFC 9557 Section 3.3). +func validateCriticalLocation(t time.Time, ext *IXDTFExtensions) error { + if ext != nil && ext.CriticalLocation && formatLocation(t, ext) == nil { + return ErrCriticalExtension + } + return nil +} + +func appendSuffix(t time.Time, ext *IXDTFExtensions, format string) []byte { + if ext == nil { + ext = NewIXDTFExtensions(nil) + } + b := []byte(t.Format(format)) // Add timezone if we have a valid location to display - if loc != nil { - locName := loc.String() - if locName != "" { - b = append(b, '[') - if ext.CriticalLocation { - b = append(b, '!') - } - b = append(b, locName...) - b = append(b, ']') + if loc := formatLocation(t, ext); loc != nil { + b = append(b, '[') + if ext.CriticalLocation { + b = append(b, '!') } + b = append(b, loc.String()...) + b = append(b, ']') } // set tags @@ -530,8 +560,20 @@ func parseRFC3339Portion(rfc3339Portion string) (time.Time, error) { return time.Time{}, lastErr } +// suffixParseState tracks which element kinds have been seen while parsing a +// suffix, enforcing the RFC 9557 Section 4.1 grammar +// "suffix = [time-zone] *suffix-tag": at most one time-zone annotation, and +// it must precede all suffix tags. The state is deliberately independent of +// ext.Location, which stays nil when a non-strict parse ignores an unknown +// zone. +type suffixParseState struct { + seenTimezone bool + seenTag bool +} + func parseSuffix(s string, strict bool) (*IXDTFExtensions, error) { ext := NewIXDTFExtensions(nil) + state := &suffixParseState{} i := 0 for i < len(s) { @@ -550,7 +592,7 @@ func parseSuffix(s string, strict bool) (*IXDTFExtensions, error) { // Parse the content between '[' and ']' content := s[i+1 : j] - if err := parseSuffixElement(content, ext, strict); err != nil { + if err := parseSuffixElement(content, ext, strict, state); err != nil { return ext, err } @@ -560,7 +602,7 @@ func parseSuffix(s string, strict bool) (*IXDTFExtensions, error) { return ext, nil } -func parseSuffixElement(content string, ext *IXDTFExtensions, strict bool) error { +func parseSuffixElement(content string, ext *IXDTFExtensions, strict bool, state *suffixParseState) error { if content == "" { return ErrInvalidSuffix } @@ -577,23 +619,26 @@ func parseSuffixElement(content string, ext *IXDTFExtensions, strict bool) error // Extension tag (has '=') vs timezone name. if strings.IndexByte(content[startIdx:], '=') >= 0 { - return handleExtensionTag(content, critical, startIdx, ext) + state.seenTag = true + return handleExtensionTag(content, critical, startIdx, ext, strict) } // Timezone name handling. // // RFC 9557 Section 4.1 permits a critical flag ("!") on a time-zone // annotation, e.g. "[!Europe/London]" (Figures 1 and 2 in Section 3.4). - // A critical - // annotation MUST be processable (Section 3.3), so an unknown or invalid - // name is rejected even in non-strict mode. + // A critical annotation MUST be processable (Section 3.3), so an unknown + // or invalid name is rejected even in non-strict mode. // - // The grammar allows at most one time-zone annotation; a second one would - // overwrite the zone and its critical flag, hiding a mandatory Section 3.4 - // inconsistency error. - if ext.Location != nil { + // The Section 4.1 grammar ("suffix = [time-zone] *suffix-tag") allows at + // most one time-zone annotation, placed before any suffix tags. A second + // one would overwrite the zone and its critical flag, hiding a mandatory + // Section 3.4 inconsistency error — even when the first zone was unknown + // and ignored by a non-strict parse. + if state.seenTimezone || state.seenTag { return ErrInvalidSuffix } + state.seenTimezone = true tzContent := content[startIdx:] if tzContent == "" { return nil @@ -611,9 +656,10 @@ func parseSuffixElement(content string, ext *IXDTFExtensions, strict bool) error if offsetErr := abnf.AbnfTimezoneTag.ValidateTimezoneTag(offsetPattern, false); offsetErr == nil { // Parse numeric offset if offset, err := parseNumericOffset(tzContent); err == nil { - // Convert "+09:00" to "+0900" format for timezone name - zoneName := formatOffsetName(tzContent) - ext.Location = time.FixedZone(zoneName, offset) + // Keep the RFC 3339 serialization form ("+09:00") as the zone + // name so Format round-trips the annotation per RFC 9557 + // Section 1.2 and the Section 4.1 time-numoffset grammar. + ext.Location = time.FixedZone(tzContent, offset) ext.CriticalLocation = critical return nil } @@ -644,7 +690,7 @@ func parseSuffixElement(content string, ext *IXDTFExtensions, strict bool) error } // handleExtensionTag processes an extension tag element (key=value pair). -func handleExtensionTag(content string, critical bool, startIdx int, ext *IXDTFExtensions) error { +func handleExtensionTag(content string, critical bool, startIdx int, ext *IXDTFExtensions, strict bool) error { equalIndex := strings.IndexByte(content[startIdx:], '=') if equalIndex < 0 { return ErrInvalidExtension @@ -662,8 +708,13 @@ func handleExtensionTag(content string, critical bool, startIdx int, ext *IXDTFE key := content[startIdx:equalIndex] - // Respect RFC 9557: first occurrence wins. + // RFC 9557 Section 3.3: for elective duplicates the first occurrence + // wins, but a duplicate suffix key involving a critical flag on either + // occurrence MUST be treated as erroneous — in both modes. if _, exists := ext.Tags[key]; exists { + if critical || ext.Critical[key] { + return ErrCriticalExtension + } return nil } value := content[equalIndex+1:] @@ -671,6 +722,14 @@ func handleExtensionTag(content string, critical bool, startIdx int, ext *IXDTFE if err := validateCriticalExtension(key, value); err != nil { return err } + // RFC 9557 Section 3.3: a recipient MUST treat the string as + // erroneous when it cannot process a critical suffix key. In strict + // mode this library acts as the recipient and only understands + // "u-ca"; in non-strict mode processing is delegated to the caller + // via the Critical map. + if strict && key != ExtensionUnicodeCalendar { + return ErrCriticalExtension + } } ext.Tags[key] = value if critical { @@ -694,13 +753,6 @@ func validateExtensionsStrict(ext *IXDTFExtensions, strict bool) error { return err } - // A critical time-zone flag without a zone cannot be honored; emitting - // output that silently drops the "!" would violate RFC 9557 Section 3.3. - // This mirrors validateCriticalTags for the Critical map. - if ext.CriticalLocation && ext.Location == nil { - return ErrCriticalExtension - } - if err := validateTagKeys(ext.Tags); err != nil { return err } @@ -844,23 +896,18 @@ func parseNumericOffset(s string) (int, error) { return sign * (hours*3600 + minutes*60), nil } -// formatOffsetName converts "+09:00" format to "+0900" format for timezone names. -func formatOffsetName(offset string) string { - if len(offset) == 6 && offset[3] == ':' { - return offset[:3] + offset[4:] - } - return offset -} - -// isOffsetLocationName reports whether name is a numeric-offset zone name as -// produced by formatOffsetName (e.g. "+0900", "-0330"). Such a location's -// offset is authoritative and has no timezone-database entry, so it must not -// be resolved via time.LoadLocation (the lookup would always fail). +// isOffsetLocationName reports whether name is a numeric-offset zone name in +// the RFC 3339 serialization form used for offset time-zone annotations +// (e.g. "+09:00", "-03:30"), as produced when parsing "[+09:00]". Such a +// location's offset is authoritative and has no timezone-database entry, so +// it must not be resolved via time.LoadLocation (the lookup would always +// fail). func isOffsetLocationName(name string) bool { - if len(name) != 5 || (name[0] != '+' && name[0] != '-') { + const offsetNameLength = 6 // len("+09:00") + if len(name) != offsetNameLength || (name[0] != '+' && name[0] != '-') || name[3] != ':' { return false } - for i := 1; i < len(name); i++ { + for _, i := range [...]int{1, 2, 4, 5} { if name[i] < '0' || name[i] > '9' { return false } diff --git a/ixdtf_internal_test.go b/ixdtf_internal_test.go index 5e8cc37..4a8f779 100644 --- a/ixdtf_internal_test.go +++ b/ixdtf_internal_test.go @@ -77,23 +77,6 @@ func TestCheckTimezoneConsistency(t *testing.T) { }) } -func TestFormatOffsetName(t *testing.T) { - t.Parallel() - t.Run("colon variant compression", func(t *testing.T) { - t.Parallel() - if got := formatOffsetName("+09:00"); got != "+0900" { - t.Fatalf("expected colon variant to compress, got %q", got) - } - }) - - t.Run("non-colon offset", func(t *testing.T) { - t.Parallel() - if got := formatOffsetName("+0900"); got != "+0900" { - t.Fatalf("expected formatOffsetName to return original value for non-colon offset, got %q", got) - } - }) -} - func TestParseErrorError(t *testing.T) { t.Parallel() tests := []struct { @@ -169,13 +152,14 @@ func TestParseNumericOffset(t *testing.T) { func TestIsOffsetLocationName(t *testing.T) { t.Parallel() cases := map[string]bool{ - "+0900": true, - "-0330": true, + "+09:00": true, + "-03:30": true, "": false, - "+090": false, - "+09:00": false, - "09000": false, - "+09a0": false, + "+09:0": false, + "+0900": false, + "09:000": false, + "+09:a0": false, + "+09.00": false, } for name, want := range cases { if got := isOffsetLocationName(name); got != want { @@ -224,7 +208,7 @@ func TestParseSuffix(t *testing.T) { t.Parallel() // RFC 9557 Section 4.1 permits a "!" flag on a time-zone annotation. ext := NewIXDTFExtensions(nil) - if err := parseSuffixElement("!Asia/Tokyo", ext, false); err != nil { + if err := parseSuffixElement("!Asia/Tokyo", ext, false, &suffixParseState{}); err != nil { t.Fatalf("expected critical timezone to be accepted, got %v", err) } if ext.Location == nil || ext.Location.String() != "Asia/Tokyo" { @@ -240,7 +224,7 @@ func TestParseSuffix(t *testing.T) { // A critical annotation MUST be processable (Section 3.3), so an // unknown name is an error even in non-strict mode. ext := NewIXDTFExtensions(nil) - if err := parseSuffixElement("!Foo/Bar", ext, false); !errors.Is(err, ErrInvalidTimezone) { + if err := parseSuffixElement("!Foo/Bar", ext, false, &suffixParseState{}); !errors.Is(err, ErrInvalidTimezone) { t.Fatalf("expected ErrInvalidTimezone for critical unknown timezone, got %v", err) } }) diff --git a/ixdtf_test.go b/ixdtf_test.go index 778b9d4..d2cdc64 100644 --- a/ixdtf_test.go +++ b/ixdtf_test.go @@ -122,6 +122,43 @@ func TestFormat(t *testing.T) { }), want: "2025-01-01T12:00:00Z", }, + { + name: "nil extensions behaves like empty extensions", + tm: time.Date(2025, 2, 3, 4, 5, 6, 0, tokyo), + ext: nil, + want: "2025-02-03T04:05:06+09:00[Asia/Tokyo]", + }, + { + // RFC 9557 Section 1.2: an offset time zone is serialized using + // the same numeric form as the RFC 3339 offset ("+09:00", not the + // Go zone-name convention "+0900"). + name: "offset time zone serialized in RFC form", + tm: time.Date(2025, 1, 1, 0, 0, 0, 0, time.FixedZone("+09:00", 9*3600)), + ext: ixdtf.NewIXDTFExtensions(&ixdtf.NewIXDTFExtensionsArgs{ + Location: time.FixedZone("+09:00", 9*3600), + }), + want: "2025-01-01T00:00:00+09:00[+09:00]", + }, + { + name: "critical offset time zone keeps flag and RFC form", + tm: time.Date(2025, 1, 1, 0, 0, 0, 0, time.FixedZone("+09:00", 9*3600)), + ext: ixdtf.NewIXDTFExtensions(&ixdtf.NewIXDTFExtensionsArgs{ + Location: time.FixedZone("+09:00", 9*3600), + CriticalLocation: true, + }), + want: "2025-01-01T00:00:00+09:00[!+09:00]", + }, + { + // The critical flag also applies to the timestamp's own named + // zone when ext.Location is unset — the same fallback used for + // non-critical output, so the "!" is not silently dropped. + name: "critical flag applies to fallback zone from timestamp", + tm: time.Date(2025, 2, 3, 4, 5, 6, 0, tokyo), + ext: ixdtf.NewIXDTFExtensions(&ixdtf.NewIXDTFExtensionsArgs{ + CriticalLocation: true, + }), + want: "2025-02-03T04:05:06+09:00[!Asia/Tokyo]", + }, } sort.Slice(tests, func(i, j int) bool { return tests[i].name < tests[j].name }) @@ -149,6 +186,61 @@ func TestFormat(t *testing.T) { } } +// TestOffsetTimezoneRoundTrip verifies that offset time-zone annotations +// survive a Parse -> Format -> Parse round trip in the RFC 9557 Section 1.2 +// serialization form, including the Section 1.2 example "+08:45[+08:45]" and +// the critical variant. +func TestOffsetTimezoneRoundTrip(t *testing.T) { + t.Parallel() + + inputs := []string{ + "2025-01-01T00:00:00+09:00[+09:00]", + "2022-07-08T00:14:07+08:45[+08:45]", // RFC 9557 Section 1.2 example + "2025-01-01T00:00:00+09:00[!+09:00]", + } + + for _, input := range inputs { + t.Run(input, func(t *testing.T) { + t.Parallel() + tm, ext, err := ixdtf.Parse(input, true) + if err != nil { + t.Fatalf("Parse(%q, true) unexpected error: %v", input, err) + } + + formatted, err := ixdtf.Format(tm, ext) + if err != nil { + t.Fatalf("Format after Parse(%q) unexpected error: %v", input, err) + } + if formatted != input { + t.Fatalf("round trip = %q, want %q", formatted, input) + } + + if _, _, reparseErr := ixdtf.Parse(formatted, true); reparseErr != nil { + t.Fatalf("re-Parse(%q, true) unexpected error: %v", formatted, reparseErr) + } + if validateErr := ixdtf.Validate(formatted, true); validateErr != nil { + t.Fatalf("Validate(%q, true) unexpected error: %v", formatted, validateErr) + } + }) + } +} + +// TestParseErrorUnwrap verifies that ParseError supports errors.Is matching +// against the package's sentinel errors via Unwrap. +func TestParseErrorUnwrap(t *testing.T) { + t.Parallel() + + _, _, err := ixdtf.Parse("2025-06-01T12:00:00+09:00[America/New_York]", true) + if !errors.Is(err, ixdtf.ErrTimezoneOffsetMismatch) { + t.Fatalf("expected errors.Is(err, ErrTimezoneOffsetMismatch), got %v", err) + } + + _, _, err = ixdtf.Parse("2022-07-08T00:14:07Z[Asia/Tokyo][Europe/Paris]", true) + if !errors.Is(err, ixdtf.ErrInvalidSuffix) { + t.Fatalf("expected errors.Is(err, ErrInvalidSuffix), got %v", err) + } +} + func TestFormatNano(t *testing.T) { t.Parallel() tokyo, paris, cet := getTestTimezones() @@ -503,20 +595,40 @@ func TestParse(t *testing.T) { wantErr: "IXDTFE parsing time", }, { - name: "suffix with non-existent timezone - non-strict mode", - input: "2025-01-01T00:00:00Z[!u-ca=gregory][t-invalid]", - strict: false, - wantTime: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), - wantExt: ixdtf.NewIXDTFExtensions(&ixdtf.NewIXDTFExtensionsArgs{ - Tags: map[string]string{"u-ca": "gregory"}, - Critical: map[string]bool{"u-ca": true}, - }), + // "[t-invalid]" has no "=" so it can only be a time-zone + // annotation, which the RFC 9557 Section 4.1 grammar + // ("suffix = [time-zone] *suffix-tag") forbids after a suffix tag. + name: "timezone-shaped element after tag - non-strict mode", + input: "2025-01-01T00:00:00Z[!u-ca=gregory][t-invalid]", + strict: false, + wantErr: "invalid IXDTF suffix format", }, { - name: "suffix with non-existent timezone - strict mode", + name: "timezone-shaped element after tag - strict mode", input: "2025-01-01T00:00:00Z[!u-ca=gregory][t-invalid]", strict: true, - wantErr: "invalid timezone name", + wantErr: "invalid IXDTF suffix format", + }, + { + name: "timezone annotation after suffix tag rejected in strict", + input: "2025-03-04T05:06:07Z[u-ca=hebrew][Asia/Tokyo]", + strict: true, + wantErr: "invalid IXDTF suffix format", + }, + { + name: "timezone annotation after suffix tag rejected in non-strict", + input: "2025-03-04T05:06:07Z[u-ca=hebrew][Asia/Tokyo]", + strict: false, + wantErr: "invalid IXDTF suffix format", + }, + { + // Even when the first zone is unknown and ignored by a non-strict + // parse, a second time-zone annotation stays a grammar violation; + // it must not be silently applied in place of the first. + name: "second timezone annotation after ignored unknown zone rejected", + input: "2022-07-08T00:14:07Z[Foo/Bar][Asia/Tokyo]", + strict: false, + wantErr: "invalid IXDTF suffix format", }, { name: "suffix with private extension", @@ -604,7 +716,7 @@ func TestParse(t *testing.T) { strict: false, wantTime: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), wantExt: ixdtf.NewIXDTFExtensions(&ixdtf.NewIXDTFExtensionsArgs{ - Location: time.FixedZone("+0900", 9*3600), + Location: time.FixedZone("+09:00", 9*3600), Tags: map[string]string{}, Critical: map[string]bool{}, }), @@ -616,9 +728,9 @@ func TestParse(t *testing.T) { name: "critical matching numeric offset is accepted", input: "2025-01-01T00:00:00+09:00[!+09:00]", strict: false, - wantTime: time.Date(2025, 1, 1, 0, 0, 0, 0, time.FixedZone("+0900", 9*3600)), + wantTime: time.Date(2025, 1, 1, 0, 0, 0, 0, time.FixedZone("+09:00", 9*3600)), wantExt: ixdtf.NewIXDTFExtensions(&ixdtf.NewIXDTFExtensionsArgs{ - Location: time.FixedZone("+0900", 9*3600), + Location: time.FixedZone("+09:00", 9*3600), CriticalLocation: true, }), }, @@ -635,9 +747,9 @@ func TestParse(t *testing.T) { name: "matching numeric offset is accepted in strict mode", input: "2025-01-01T00:00:00+09:00[+09:00]", strict: true, - wantTime: time.Date(2025, 1, 1, 0, 0, 0, 0, time.FixedZone("+0900", 9*3600)), + wantTime: time.Date(2025, 1, 1, 0, 0, 0, 0, time.FixedZone("+09:00", 9*3600)), wantExt: ixdtf.NewIXDTFExtensions(&ixdtf.NewIXDTFExtensionsArgs{ - Location: time.FixedZone("+0900", 9*3600), + Location: time.FixedZone("+09:00", 9*3600), }), }, { @@ -662,6 +774,49 @@ func TestParse(t *testing.T) { "key": "one", }}), }, + { + // RFC 9557 Section 3.3 example: a duplicate suffix key where the + // first occurrence is critical MUST be treated as erroneous. + name: "critical duplicate tag rejected (first critical)", + input: "2022-07-08T00:14:07Z[!u-ca=chinese][u-ca=japanese]", + strict: false, + wantErr: "critical extension cannot be processed", + }, + { + // RFC 9557 Section 3.3 example: the critical flag on the second + // occurrence must not be silently discarded either. + name: "critical duplicate tag rejected (second critical)", + input: "2022-07-08T00:14:07Z[u-ca=chinese][!u-ca=japanese]", + strict: false, + wantErr: "critical extension cannot be processed", + }, + { + name: "critical duplicate tag rejected in strict mode", + input: "2022-07-08T00:14:07Z[!u-ca=chinese][u-ca=japanese]", + strict: true, + wantErr: "critical extension cannot be processed", + }, + { + // RFC 9557 Section 3.3 example "[!knort=blargel]": in strict mode + // this library is the recipient, understands only "u-ca", and MUST + // treat an unrecognized critical key as erroneous. + name: "unknown critical suffix key rejected in strict mode", + input: "2022-07-08T00:14:07Z[!knort=blargel]", + strict: true, + wantErr: "critical extension cannot be processed", + }, + { + // In non-strict mode processing of unrecognized critical keys is + // delegated to the caller via the Critical map. + name: "unknown critical suffix key delegated in non-strict mode", + input: "2022-07-08T00:14:07Z[!knort=blargel]", + strict: false, + wantTime: time.Date(2022, 7, 8, 0, 14, 7, 0, time.UTC), + wantExt: ixdtf.NewIXDTFExtensions(&ixdtf.NewIXDTFExtensionsArgs{ + Tags: map[string]string{"knort": "blargel"}, + Critical: map[string]bool{"knort": true}, + }), + }, } sort.Slice(tests, func(i, j int) bool { return tests[i].name < tests[j].name })