diff --git a/go.mod b/go.mod index 204064d..976077b 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,10 @@ require ( github.com/github/go-spdx/v2 v2.4.0 github.com/go-playground/locales v0.14.1 github.com/go-playground/universal-translator v0.18.1 - github.com/go-playground/validator/v10 v10.30.2 + github.com/go-playground/validator/v10 v10.30.3-0.20260409172054-b599053e6029 github.com/goccy/go-yaml v1.19.2 github.com/italia/httpclient-lib-go v0.0.3-0.20260316100201-5dd490bc4896 github.com/rivo/uniseg v0.4.7 - golang.org/x/text v0.35.0 ) require ( @@ -19,6 +18,7 @@ require ( github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect ) go 1.25.0 diff --git a/go.sum b/go.sum index cba8de8..8cd2ce5 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= -github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/go-playground/validator/v10 v10.30.3-0.20260409172054-b599053e6029 h1:yPDgnVvfx/0o9KyD4lghxxBMXZVo8UKogLSxl/Tz3UU= +github.com/go-playground/validator/v10 v10.30.3-0.20260409172054-b599053e6029/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/italia/httpclient-lib-go v0.0.3-0.20260316100201-5dd490bc4896 h1:7JV5L0I++QbIMdVjU467tUW+dQnX6hdmhMLZAjrehjQ= diff --git a/validators/bcp47.go b/validators/bcp47.go deleted file mode 100644 index 8205d48..0000000 --- a/validators/bcp47.go +++ /dev/null @@ -1,389 +0,0 @@ -package validators - -import ( - "fmt" - "reflect" - "regexp" - "strings" - - "github.com/go-playground/validator/v10" - "golang.org/x/text/language" -) - -// isBCP47StrictLanguageTag validates a BCP 47 language tag according to -// https://www.rfc-editor.org/rfc/bcp/bcp47.txt, rejecting POSIX-style tags -// (e.g. en_GB) and Unicode extensions unlike the built-in bcp47_language_tag. -// -// From https://github.com/go-playground/validator/pull/1489/. -func isBCP47StrictLanguageTag(fl validator.FieldLevel) bool { - field := fl.Field() - - if field.Kind() == reflect.String { - return isValidBCP47StrictLanguageTag(field.String()) - } - - //nolint:forbidigo // programming error caught at runtime, it's right to panic - panic(fmt.Sprintf("Bad field type %s", field.Type())) -} - -func isValidBCP47StrictLanguageTag(s string) bool { - languageTagRe := regexp.MustCompile(strings.Join([]string{ - // group 1: - `^(`, - // irregular - `EN-GB-OED|I-AMI|I-BNN|I-DEFAULT|I-ENOCHIAN|I-HAK|I-KLINGON|I-LUX|I-MINGO|I-NAVAJO|I-PWN|I-TAO|I-TAY|I-TSU|`, - `SGN-BE-FR|SGN-BE-NL|SGN-CH-DE|`, - // regular - `ART-LOJBAN|CEL-GAULISH|NO-BOK|NO-NYN|ZH-GUOYU|ZH-HAKKA|ZH-MIN|ZH-MIN-NAN|ZH-XIANG|`, - // privateuse - `X-[A-Z0-9]{1,8}`, - `)$`, - - `|`, - - // langtag - `^`, - `((?:[A-Z]{2,3}(?:-[A-Z]{3}){0,3})|[A-Z]{4}|[A-Z]{5,8})`, // group 2: language - `(?:-([A-Z]{4}))?`, // group 3: script - `(?:-([A-Z]{2}|[0-9]{3}))?`, // group 4: region - `(?:-((?:[A-Z0-9]{5,8}|[0-9][A-Z0-9]{3})(?:-(?:[A-Z0-9]{5,8}|[0-9][A-Z0-9]{3}))*))?`, // group 5: variant - `(?:-((?:[A-WYZ0-9](?:-[A-Z0-9]{2,8})+)(?:-(?:[A-WYZ0-9](?:-[A-Z0-9]{2,8})+))*))?`, // group 6: extension - `(?:-X(?:-[A-Z0-9]{1,8})+)?`, - `$`, - }, "")) - - languageTag := strings.ToUpper(s) - - m := languageTagRe.FindStringSubmatch(languageTag) - if m == nil { - return false - } - - grandfatheredOrPrivateuse := m[1] - lang := m[2] - script := m[3] - region := m[4] - variant := m[5] - extension := m[6] - - if grandfatheredOrPrivateuse != "" { - return true - } - - switch n := len(lang); { - case strings.Contains(lang, "-"): - parts := strings.Split(lang, "-") - - baseLang := parts[0] - - base, err := language.ParseBase(baseLang) - if err != nil { - return false - } - - if strings.ToUpper(base.String()) != baseLang { - return false - } - - for _, e := range parts[1:] { - prefixes, ok := ianaExtlangs[strings.ToLower(e)] - if !ok { - return false - } - - if len(prefixes) > 0 { - found := false - - for _, p := range prefixes { - if strings.HasPrefix(strings.ToLower(languageTag)+"-", strings.ToLower(p)) { - found = true - - break - } - } - - if !found { - return false - } - } - } - case n <= 3: - base, err := language.ParseBase(lang) - if err != nil { - return false - } - - if strings.ToUpper(base.String()) != lang { - return false - } - case n == 4: - return false - default: - return false - } - - if script != "" { - _, err := language.ParseScript(script) - if err != nil { - return false - } - } - - if region != "" { - if len(region) == 2 { - _, err := language.ParseRegion(region) - if err != nil { - return false - } - } else { - if _, ok := ianaM49Codes[region]; !ok { - return false - } - } - } - - if variant != "" { - for v := range strings.SplitSeq(variant, "-") { - lowerVariant := strings.ToLower(v) - - _, err := language.ParseVariant(lowerVariant) - if err != nil { - return false - } - - prefixes, ok := ianaVariants[lowerVariant] - if !ok { - return false - } - - if len(prefixes) > 0 { - found := false - - for _, p := range prefixes { - if strings.HasPrefix(strings.ToLower(languageTag)+"-", strings.ToLower(p)) { - found = true - - break - } - } - - if !found { - return false - } - } - } - } - - if extension != "" { - _, err := language.ParseExtension(extension) - if err != nil { - return false - } - } - - return true -} - -// ianaVariants: variant subtags and their associated primary language prefixes. -// Source: https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry -var ianaVariants = map[string][]string{ - "1606nict": {"frm"}, - "1694acad": {"fr"}, - "1901": {"de"}, - "1959acad": {"be"}, - "1994": {"sl-rozaj", "sl-rozaj-biske", "sl-rozaj-njiva", "sl-rozaj-osojs", "sl-rozaj-solba"}, - "1996": {"de"}, - "abl1943": {"pt-BR"}, - "akhmimic": {"cop"}, - "akuapem": {"tw"}, - "alalc97": {}, - "aluku": {"djk"}, - "anpezo": {"lld"}, - "ao1990": {"pt", "gl"}, - "aranes": {"oc"}, - "arevela": {"hy"}, - "arevmda": {"hy"}, - "arkaika": {"eo"}, - "asante": {"tw"}, - "auvern": {"oc"}, - "baku1926": {"az", "ba", "crh", "kk", "krc", "ky", "sah", "tk", "tt", "uz"}, - "balanka": {"blo"}, - "barla": {"kea"}, - "basiceng": {"en"}, - "bauddha": {"sa"}, - "bciav": {"zbl"}, - "bcizbl": {"zbl"}, - "biscayan": {"eu"}, - "biske": {"sl-rozaj"}, - "blasl": {"ase", "sgn-ase"}, - "bohairic": {"cop"}, - "bohoric": {"sl"}, - "boont": {"en"}, - "bornholm": {"da"}, - "cisaup": {"oc"}, - "colb1945": {"pt"}, - "cornu": {"en"}, - "creiss": {"oc"}, - "dajnko": {"sl"}, - "ekavsk": {"sr", "sr-Latn", "sr-Cyrl"}, - "emodeng": {"en"}, - "fascia": {"lld"}, - "fayyumic": {"cop"}, - "fodom": {"lld"}, - "fonipa": {}, - "fonkirsh": {}, - "fonnapa": {}, - "fonupa": {}, - "fonxsamp": {}, - "gallo": {"fr"}, - "gascon": {"oc"}, - "gherd": {"lld"}, - "grclass": {"oc", "oc-aranes", "oc-auvern", "oc-cisaup", "oc-creiss", "oc-gascon", "oc-lemosin", "oc-lengadoc", "oc-nicard", "oc-provenc", "oc-vivaraup"}, //nolint:lll // long data literal - "grital": {"oc", "oc-cisaup", "oc-nicard", "oc-provenc"}, - "grmistr": {"oc", "oc-aranes", "oc-auvern", "oc-cisaup", "oc-creiss", "oc-gascon", "oc-lemosin", "oc-lengadoc", "oc-nicard", "oc-provenc", "oc-vivaraup"}, //nolint:lll // long data literal - "hanoi": {"vi"}, - "hepburn": {"ja-Latn"}, - "heploc": {"ja-Latn-hepburn"}, - "hognorsk": {"nn"}, - "hsistemo": {"eo"}, - "huett": {"vi"}, - "ijekavsk": {"sr", "sr-Latn", "sr-Cyrl"}, - "itihasa": {"sa"}, - "ivanchov": {"bg"}, - "jauer": {"rm"}, - "jyutping": {"yue"}, - "kkcor": {"kw"}, - "kleinsch": {"kl", "kl-tunumiit"}, - "kociewie": {"pl"}, - "kscor": {"kw"}, - "laukika": {"sa"}, - "leidentr": {"egy"}, - "lemosin": {"oc"}, - "lengadoc": {"oc"}, - "lipaw": {"sl-rozaj"}, - "ltg1929": {"ltg"}, - "ltg2007": {"ltg"}, - "luna1918": {"ru"}, - "lycopol": {"cop"}, - "mdcegyp": {"egy"}, - "mdctrans": {"egy"}, - "mesokem": {"cop"}, - "metelko": {"sl"}, - "monoton": {"el"}, - "ndyuka": {"djk"}, - "nedis": {"sl"}, - "newfound": {"en-CA"}, - "nicard": {"oc"}, - "njiva": {"sl-rozaj"}, - "nulik": {"vo"}, - "osojs": {"sl-rozaj"}, - "oxendict": {"en"}, - "pahawh2": {"mww", "hnj"}, - "pahawh3": {"mww", "hnj"}, - "pahawh4": {"mww", "hnj"}, - "pamaka": {"djk"}, - "peano": {"la"}, - "pehoeji": {"nan-Latn"}, - "petr1708": {"ru"}, - "pinyin": {"zh-Latn", "bo-Latn"}, - "polyton": {"el"}, - "provenc": {"oc"}, - "puter": {"rm"}, - "rigik": {"vo"}, - "rozaj": {"sl"}, - "rumgr": {"rm"}, - "sahidic": {"cop"}, - "saigon": {"vi"}, - "scotland": {"en"}, - "scouse": {"en"}, - "simple": {}, - "solba": {"sl-rozaj"}, - "sotav": {"kea"}, - "spanglis": {"en", "es"}, - "surmiran": {"rm"}, - "sursilv": {"rm"}, - "sutsilv": {"rm"}, - "synnejyl": {"da"}, - "tailo": {"nan-Latn"}, - "tarask": {"be"}, - "tongyong": {"zh-Latn"}, - "tunumiit": {"kl"}, - "uccor": {"kw"}, - "ucrcor": {"kw"}, - "ulster": {"sco"}, - "unifon": {"en", "hup", "kyh", "tol", "yur"}, - "vaidika": {"sa"}, - "valbadia": {"lld"}, - "valencia": {"ca"}, - "vallader": {"rm"}, - "vecdruka": {"lv"}, - "viennese": {"de"}, - "vivaraup": {"oc"}, - "wadegile": {"zh-Latn"}, - "xsistemo": {"eo"}, -} - -// ianaExtlangs: extended language subtags and their associated prefixes. -// Source: https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry -var ianaExtlangs = map[string][]string{ - "aao": {"ar"}, "abh": {"ar"}, "abv": {"ar"}, "acm": {"ar"}, "acq": {"ar"}, - "acw": {"ar"}, "acx": {"ar"}, "acy": {"ar"}, "adf": {"ar"}, "ads": {"sgn"}, - "aeb": {"ar"}, "aec": {"ar"}, "aed": {"sgn"}, "aen": {"sgn"}, "afb": {"ar"}, - "afg": {"sgn"}, "ajp": {"ar"}, "ajs": {"sgn"}, "apc": {"ar"}, "apd": {"ar"}, - "arb": {"ar"}, "arq": {"ar"}, "ars": {"ar"}, "ary": {"ar"}, "arz": {"ar"}, - "ase": {"sgn"}, "asf": {"sgn"}, "asp": {"sgn"}, "asq": {"sgn"}, "asw": {"sgn"}, - "auz": {"ar"}, "avl": {"ar"}, "ayh": {"ar"}, "ayl": {"ar"}, "ayn": {"ar"}, - "ayp": {"ar"}, "bbz": {"ar"}, "bfi": {"sgn"}, "bfk": {"sgn"}, "bjn": {"ms"}, - "bog": {"sgn"}, "bqn": {"sgn"}, "bqy": {"sgn"}, "btj": {"ms"}, "bve": {"ms"}, - "bvl": {"sgn"}, "bvu": {"ms"}, "bzs": {"sgn"}, "cdo": {"zh"}, "cds": {"sgn"}, - "cjy": {"zh"}, "cmn": {"zh"}, "cnp": {"zh"}, "coa": {"ms"}, "cpx": {"zh"}, - "csc": {"sgn"}, "csd": {"sgn"}, "cse": {"sgn"}, "csf": {"sgn"}, "csg": {"sgn"}, - "csl": {"sgn"}, "csn": {"sgn"}, "csp": {"zh"}, "csq": {"sgn"}, "csr": {"sgn"}, - "czh": {"zh"}, "czo": {"zh"}, "doq": {"sgn"}, "dse": {"sgn"}, "dsl": {"sgn"}, - "dup": {"ms"}, "ecs": {"sgn"}, "esl": {"sgn"}, "esn": {"sgn"}, "eso": {"sgn"}, - "eth": {"sgn"}, "fcs": {"sgn"}, "fse": {"sgn"}, "fsl": {"sgn"}, "fss": {"sgn"}, - "gan": {"zh"}, "gds": {"sgn"}, "ggg": {"sgn"}, "gsg": {"sgn"}, "gsm": {"ms"}, - "gss": {"sgn"}, "gus": {"sgn"}, "hab": {"sgn"}, "haf": {"sgn"}, "hak": {"zh"}, - "hds": {"sgn"}, "hji": {"ms"}, "hks": {"sgn"}, "hos": {"sgn"}, "hps": {"sgn"}, - "hsh": {"sgn"}, "hsl": {"sgn"}, "hsn": {"zh"}, "icl": {"sgn"}, "iks": {"sgn"}, - "ils": {"sgn"}, "inl": {"sgn"}, "ins": {"sgn"}, "ise": {"sgn"}, "isg": {"sgn"}, - "isr": {"sgn"}, "jak": {"ms"}, "jax": {"ms"}, "jcs": {"sgn"}, "jhs": {"sgn"}, - "jls": {"sgn"}, "jos": {"sgn"}, "jsl": {"sgn"}, "jus": {"sgn"}, "kgi": {"sgn"}, - "knn": {"ms"}, "kvb": {"ms"}, "kvk": {"sgn"}, "kvr": {"ms"}, "kxd": {"ms"}, - "lbs": {"sgn"}, "lce": {"ms"}, "lcf": {"ms"}, "liw": {"ms"}, "lls": {"sgn"}, - "lsg": {"sgn"}, "lsl": {"sgn"}, "lso": {"sgn"}, "lsp": {"sgn"}, "lst": {"sgn"}, - "lsy": {"sgn"}, "ltg": {"lv"}, "lvs": {"lv"}, "lws": {"sgn"}, "lzh": {"zh"}, - "max": {"ms"}, "mdl": {"sgn"}, "meo": {"ms"}, "mfa": {"ms"}, "mfb": {"ms"}, - "mfs": {"sgn"}, "min": {"ms"}, "mnp": {"zh"}, "mqg": {"ms"}, "mre": {"sgn"}, - "msd": {"sgn"}, "msi": {"ms"}, "msr": {"sgn"}, "mui": {"ms"}, "mzc": {"sgn"}, - "mzg": {"sgn"}, "mzy": {"sgn"}, "nan": {"zh"}, "nbs": {"sgn"}, "ncs": {"sgn"}, - "nsi": {"sgn"}, "nsl": {"sgn"}, "nsp": {"sgn"}, "nsr": {"sgn"}, "nzs": {"sgn"}, - "okl": {"sgn"}, "orn": {"ms"}, "ors": {"ms"}, "pel": {"ms"}, "pga": {"ar"}, - "pgz": {"sgn"}, "pks": {"sgn"}, "prl": {"sgn"}, "prz": {"sgn"}, "psc": {"sgn"}, - "psd": {"sgn"}, "pse": {"ms"}, "psg": {"sgn"}, "psl": {"sgn"}, "pso": {"sgn"}, - "psp": {"sgn"}, "psr": {"sgn"}, "pys": {"sgn"}, "rms": {"sgn"}, "rsi": {"sgn"}, - "rsl": {"sgn"}, "rsm": {"sgn"}, "sdl": {"sgn"}, "sfb": {"sgn"}, "sfs": {"sgn"}, - "sgg": {"sgn"}, "sgx": {"sgn"}, "shu": {"ar"}, "slf": {"sgn"}, "sls": {"sgn"}, - "sqs": {"sgn"}, "sqx": {"sgn"}, "ssh": {"ar"}, "ssp": {"sgn"}, "ssr": {"sgn"}, - "svk": {"sgn"}, "swc": {"sw"}, "swh": {"sw"}, "swl": {"sgn"}, "syy": {"sgn"}, - "szs": {"sgn"}, "tmw": {"ms"}, "tse": {"sgn"}, "tsm": {"sgn"}, "tsq": {"sgn"}, - "tss": {"sgn"}, "tsy": {"sgn"}, "tza": {"ms"}, "ugn": {"sgn"}, "ugy": {"sgn"}, - "ukl": {"sgn"}, "uks": {"sgn"}, "urk": {"ms"}, "uzn": {"uz"}, "uzs": {"uz"}, - "vgt": {"sgn"}, "vkk": {"ms"}, "vkt": {"ms"}, "vsi": {"sgn"}, "vsl": {"sgn"}, - "vsv": {"sgn"}, "wbs": {"sgn"}, "wuu": {"zh"}, "xki": {"sgn"}, "xml": {"sgn"}, - "xmm": {"ms"}, "xms": {"sgn"}, "yds": {"sgn"}, "ygs": {"sgn"}, "yhs": {"sgn"}, - "ysl": {"sgn"}, "yss": {"sgn"}, "zib": {"sgn"}, "zlm": {"ms"}, "zmi": {"ms"}, - "zsl": {"sgn"}, "zsm": {"ms"}, -} - -// ianaM49Codes: UN M.49 region codes present in the IANA subtag registry. -// Source: https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry -var ianaM49Codes = map[string]struct{}{ - "001": {}, "002": {}, "003": {}, "005": {}, "009": {}, - "011": {}, "013": {}, "014": {}, "015": {}, "017": {}, - "018": {}, "019": {}, "021": {}, "029": {}, "030": {}, - "034": {}, "035": {}, "039": {}, "053": {}, "054": {}, - "057": {}, "061": {}, "142": {}, "143": {}, "145": {}, - "150": {}, "151": {}, "154": {}, "155": {}, "202": {}, - "419": {}, -} diff --git a/validators/bcp47_test.go b/validators/bcp47_test.go deleted file mode 100644 index 00a4f9a..0000000 --- a/validators/bcp47_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package validators - -import ( - "testing" -) - -func TestIsValidBCP47StrictLanguageTag(t *testing.T) { - tests := []struct { - tag string - valid bool - }{ - // Valid 2-letter codes - {"en", true}, - {"it", true}, - {"de", true}, - // 3-letter code that maps to 2-letter: "eng" -> "en", so it fails the strict check - {"eng", false}, - // Invalid: unknown base language - {"xx", false}, - // Invalid: 4-letter language (not special) - {"xxxx", false}, - // With script - {"en-Latn", true}, - // With region (2-letter) - {"en-US", true}, - {"it-IT", true}, - // With region (3-digit M.49) - {"en-001", true}, - // Invalid M.49 (not in registry) - {"en-999", false}, - // With variant (basiceng is a variant for en) - {"en-basiceng", true}, - // With extension - {"en-u-ca-gregory", true}, - // Grandfathered/irregular - case-insensitive match - {"i-ami", true}, - // Private use - {"x-test", true}, - // Extended language: zh-cmn (Mandarin as extlang of Chinese) - {"zh-cmn", true}, - // Invalid extlang prefix: cmn's prefix is zh, not en - {"en-cmn", false}, - // Grandfathered regular - {"art-lojban", true}, - // Invalid: just garbage - {"not-valid-at-all-12345678", false}, - } - - for _, tc := range tests { - t.Run(tc.tag, func(t *testing.T) { - got := isValidBCP47StrictLanguageTag(tc.tag) - if got != tc.valid { - t.Errorf("isValidBCP47StrictLanguageTag(%q) = %v, want %v", tc.tag, got, tc.valid) - } - }) - } -} - -func TestIsValidBCP47StrictLanguageTagLongerBase(t *testing.T) { - // 5+ letter language (falls into default false case of the switch) - got := isValidBCP47StrictLanguageTag("toolong") - if got { - t.Error("expected false for language with 5+ chars not in special form") - } -} - -func TestIsValidBCP47StrictLanguageTagInvalidScript(t *testing.T) { - // Invalid script (not a recognized IANA script) - got := isValidBCP47StrictLanguageTag("en-Xxxx") - if got { - t.Errorf("expected false for unrecognized script, got true") - } -} - -func TestIsValidBCP47StrictLanguageTagInvalidRegion(t *testing.T) { - // "en-XX" is actually accepted by golang.org/x/text/language.ParseRegion - // The validator delegates to that library for 2-letter region checks. - // Use a truly invalid region to verify the false path. - got := isValidBCP47StrictLanguageTag("en-ZZ") - // ZZ is not a real ISO 3166-1 alpha-2 country, but ParseRegion may accept it. - // This just verifies no panic occurs. - _ = got -} - -func TestIsValidBCP47StrictLanguageTagWithVariantBadPrefix(t *testing.T) { - // rozaj is valid only for sl - got := isValidBCP47StrictLanguageTag("en-rozaj") - if got { - t.Errorf("expected false for variant with wrong prefix") - } -} - -func TestIsValidBCP47StrictLanguageTagWithVariantGoodPrefix(t *testing.T) { - // rozaj is a valid variant for sl - got := isValidBCP47StrictLanguageTag("sl-rozaj") - if !got { - t.Errorf("expected true for sl-rozaj") - } -} - -func TestIsValidBCP47StrictLanguageTagInvalidExtlang(t *testing.T) { - // zzz is not a known extlang - got := isValidBCP47StrictLanguageTag("zh-zzz") - if got { - t.Errorf("expected false for unknown extlang, got true") - } -} - -func TestIsBCP47StrictLanguageTagPanicNonString(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("expected panic for non-string field with bcp47_strict_language_tag") - } - }() - - v := New() - - type S struct { - Code int `validate:"bcp47_strict_language_tag"` - } - - _ = v.Struct(S{Code: 42}) -} - -func TestIsValidBCP47StrictLanguageTagExtlang3LetterBase(t *testing.T) { - // "eng" maps to canonical "en", so "eng-cmn" fails the canonical form check. - got := isValidBCP47StrictLanguageTag("eng-cmn") - if got { - t.Errorf("expected false for eng-cmn (eng normalizes to en)") - } -} - -func TestIsValidBCP47StrictLanguageTagUnknownVariant(t *testing.T) { - // "foobar" is not in ianaVariants; exercises the variant lookup failure path. - got := isValidBCP47StrictLanguageTag("en-foobar") - if got { - t.Errorf("expected false for en-foobar (unknown variant)") - } -} - -func TestIsValidBCP47StrictLanguageTagRegionZZ(t *testing.T) { - // "ZZ" is not a registered ISO 3166-1 alpha-2 region code; ParseRegion - // behaviour varies by Go version. Just verify no panic occurs. - _ = isValidBCP47StrictLanguageTag("en-ZZ") -} diff --git a/validators/common.go b/validators/common.go index f476d7d..6291c38 100644 --- a/validators/common.go +++ b/validators/common.go @@ -13,6 +13,10 @@ import ( "github.com/rivo/uniseg" ) +// bcp47KeyValidator is used solely by bcp47_keys to validate individual map keys +// against the upstream bcp47_strict_language_tag built-in validator. +var bcp47KeyValidator = validator.New(validator.WithRequiredStructEnabled()) + // Reference: https://github.com/jshttp/media-typer/ var reMIMEType = regexp.MustCompile(`^ *([A-Za-z0-9][A-Za-z0-9!#$&^_-]{0,126})/([A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}) *$`) //nolint:lll // RFC 2045 regex @@ -118,7 +122,7 @@ func bcp47_keys(fl validator.FieldLevel) bool { } for _, k := range fl.Field().MapKeys() { - if !isValidBCP47StrictLanguageTag(k.String()) { + if bcp47KeyValidator.Var(k.String(), "bcp47_strict_language_tag") != nil { return false } } diff --git a/validators/validator.go b/validators/validator.go index 09eeeb3..bc33cba 100644 --- a/validators/validator.go +++ b/validators/validator.go @@ -25,7 +25,6 @@ func New() *validator.Validate { _ = validate.RegisterValidation("is_italian_ipa_code", isItalianIpaCode) - _ = validate.RegisterValidation("bcp47_strict_language_tag", isBCP47StrictLanguageTag) _ = validate.RegisterValidation("bcp47_keys", bcp47_keys) validate.RegisterAlias("date", "datetime=2006-01-02")