packages feed

idnaparse (empty) → 1.0.0.0

raw patch · 5 files changed

+1043/−0 lines, 5 filesdep +basedep +bytestringdep +containers

Dependencies added: base, bytestring, containers, idna2008, optparse-applicative, streaming, streaming-bytestring, text

Files

+ CHANGELOG.md view
@@ -0,0 +1,24 @@+# Changelog++## 0.1.0.0++Initial release as a standalone project, factored out of the+`dnsbase` library and rebased on the `idna2008` library.++- `idnaparse` CLI: reads UTF-8 presentation-form names from stdin+  (one per line) and emits one JSON record per name on stdout.+- Success rows report the canonical presentation form, the+  Unicode display form, and the set of label-form classifications+  (LDH, ALABEL, ULABEL, FAKEA, ATTRLEAF, OCTET, WILDLABEL).+- Failure rows report a structured error with the offending label+  index, the rule or codepoint that failed, and (for Bidi+  violations) the specific RFC 5893 rule.+- `--forms` selects the permitted label-form classes;+  `--opts` selects the IDNA validation flags, input mappings, and+  presentation policy.  Both accept comma-separated tokens with+  `+`/`-` prefix arithmetic and the same vocabulary as the+  `idna2008` library's `parseLabelFormSet` and `parseIdnaFlags`.+- `--summary` emits a trailing summary record with per-class and+  per-error counters.+- `--explain-forms` and `--explain-opts` print the full+  vocabulary glossaries.
+ LICENSE view
@@ -0,0 +1,31 @@+Copyright (c) 2026 Viktor Dukhovni++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions+are met:++  * Redistributions of source code must retain the above copyright+    notice, this list of conditions and the following disclaimer.++  * Redistributions in binary form must reproduce the above+    copyright notice, this list of conditions and the following+    disclaimer in the documentation and/or other materials provided+    with the distribution.++  * Neither the name of the author nor the names of his+    contributors may be used to endorse or promote products derived+    from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,173 @@+# idnaparse++An IDNA-aware DNS-name lint and reporter.  Reads+presentation-form domain names from stdin (one per line) and+emits one JSON record per name on stdout.++Useful for sweeping a zone file or registrar feed for IDNA+conformance, summarising the label kinds present in a corpus, or+as a parsing front end for ad-hoc DNS-name analysis pipelines.++Built on the [`idna2008`](https://github.com/dnsbase/idna2008)+library; was previously bundled with `dnsbase` and is now a+standalone project so it can be used without pulling in the+broader DNS-message machinery.++## Quick start++```sh+$ cabal install idnaparse++$ printf '%s\n' 'www.example.com' 'müllers.example.de' '_25._tcp.example' |+  idnaparse --summary | jq+{+  "input": {+    "text": "www.example.com",+    "forms": [+      "LDH",+      "LDH",+      "LDH"+    ]+  },+  "presentation": "www.example.com",+  "output": {+    "text": "www.example.com",+    "forms": [+      "LDH",+      "LDH",+      "LDH"+    ]+  }+}+{+  "input": {+    "text": "müllers.example.de",+    "forms": [+      "ULABEL",+      "LDH",+      "LDH"+    ]+  },+  "presentation": "xn--mllers-kva.example.de",+  "output": {+    "text": "müllers.example.de",+    "forms": [+      "ULABEL",+      "LDH",+      "LDH"+    ]+  }+}+{+  "input": {+    "text": "_25._tcp.example",+    "forms": [+      "ATTRLEAF",+      "ATTRLEAF",+      "LDH"+    ]+  },+  "presentation": "_25._tcp.example",+  "output": {+    "text": "_25._tcp.example",+    "forms": [+      "ATTRLEAF",+      "ATTRLEAF",+      "LDH"+    ]+  }+}+{+  "summary": {+    "ok": 3,+    "fail": 0,+    "discard": {+      "overlong": 0,+      "badutf8": 0+    },+    "forms": {+      "ATTRLEAF": 1,+      "LDH": 3,+      "ULABEL": 1+    },+    "reasons": {}+  }+}+```++## Output schema++### Success rows++```json+{ "input"        : { "text": "...", "forms": ["...", ...] },+  "presentation" : "...",                  // canonical RFC 1035 form+  "output"       : { "text": "...", "forms": ["...", ...] },+  "fakes"        : [{...}]                 // present iff a FAKEA label appeared+}+```++`input.forms` is the parser's per-label classification; `output.forms`+is the unparser's.  With validation enabled (the default), an `ALABEL`+in `output.forms` signals BIDI-driven ASCII fallback: the label would+have decoded to Unicode if the cross-label rules had allowed it.++When input mappings are enabled (e.g. `--opts +map-width`), the+parser's classification reflects the *post-mapping* label, not the+raw input bytes.  A label written in fullwidth Latin letters+(`0-9A-Za-z`) maps to plain ASCII and classifies as `LDH`,+even though the input bytes were non-ASCII.  This is by design:+the mappings exist to correct input-method artefacts that produce+non-ASCII representations of what the user meant to type as ASCII.+It does mean `input.forms` may not be a faithful record of "did the+input have non-ASCII bytes" when mappings are active; if you need+that distinction, inspect `input.text` directly (or don't enable+"width" mappings).++### Failure rows++Two shapes, depending on where the failure happens:++```json+// Parse failure -- the input text did not parse.+{ "input": { "text": "...",+             "error": { "reason": "...", ... } } }++// Unparse failure -- the input parsed but the wire form could not be+// rendered under the given options (e.g. cross-label Bidi without+// ascii-fallback, or an admitted form set whose output classification+// is not also admitted).+{ "input"        : { "text": "...", "forms": ["...", ...] },+  "presentation" : "...",+  "output"       : { "error": { "reason": "...", ... } },+  "fakes"        : [{...}] }                  // present iff a FAKEA label appeared+```++The `error` object carries the standard set of optional fields+(`label`, `cp`, `length`, `form`, `rule`).++Overlong input lines and ill-formed UTF-8 produce no row; they are+counted in the optional summary.++## Configuration++`--forms FORMS`  permitted label-form tokens (e.g. `host`,+`+attrleaf`, `idn,wildlabel`).  See `--explain-forms` for the+full vocabulary.++`--opts OPTS`  IDNA option tokens (e.g. `default`, `+emoji-ok`,+`map,bidi-check`).  See `--explain-opts` for the full vocabulary.++`--summary`  emit a trailing summary record with per-class and+per-error counters.++`--no-rows`  suppress per-row records (combine with `--summary`+when only aggregate output is wanted).++## Status++Initial release (`1.0.0.0`).++## License++BSD-3-Clause.
+ app/idnaparse.hs view
@@ -0,0 +1,749 @@+-- | idnaparse -- parse a stream of host-style domain names against the+-- IDNA-aware presentation parser and emit one JSON record per input.+--+-- Reads UTF-8 lines from stdin, one name per line.  Each non-discarded+-- line produces a JSON object on stdout:+--+-- * Successful parse + unparse:+--+--     @{ "input"        : { "text": ..., "forms": [...] }+--      , "presentation" : ...   -- canonical RFC 1035 form+--      , "output"       : { "text": ..., "forms": [...] }+--      , "fakes"        : [...] -- present iff at least one FAKEA label appears+--      }@+--+--   @input.forms@ classifies the label forms seen on input (after mappings);+--   @output.forms@ classifies the labels produced on output.+--   A-labels in the input may become U-labels on output.+--   With crossl-label BIDI rule violations, and ASCII fallback enabled,+--   U-labels in the input may become A-labels on output.+--+-- * Parse failure:+--+--     @{ "input": { "text": ..., "error": {...} } }@+--+-- * Unparse failure (parse succeeded, unparse rejected):+--+--     @{ "input"        : { "text": ..., "forms": [...] }+--      , "presentation" : ...+--      , "output"       : { "error": {...} }+--      , "fakes"        : [...] -- present iff at least one FAKEA label appears+--      }@+--+-- Discards (overlong lines, ill-formed UTF-8) produce no row; they are+-- counted in the optional summary.+--+-- See @--help@ for CLI flags.+module Main (main) where++import qualified Data.ByteString as BSStrict+import qualified Data.ByteString.Builder as BB+import qualified Data.ByteString.Char8 as BS+import qualified Data.Map.Strict as Map+import qualified Data.Text as T+import qualified Data.Text.Encoding as Enc+import qualified Streaming.ByteString.Char8 as Q+import qualified Streaming.Prelude as S+import Control.Monad ((>=>), unless, when)+import Data.Bits ((.&.), unsafeShiftR)+import qualified Data.List as List+import Data.IORef+import Data.Map.Strict (Map)+import Data.Word (Word8)+import Streaming (Stream, Of(..), inspect, lift)+import System.IO (BufferMode(..), hSetBinaryMode, hSetBuffering, stdin, stdout)++import Options.Applicative++import Text.IDNA2008+    ( AceReason(..)+    , BidiRuleViolation+    , IdnaError(..)+    , IdnaFlags(..)+    , LabelForm(..)+    , LabelFormSet+    , LabelReason(..)+    , defaultIdnaFlags+    , domainToAscii+    , getLabelForms+    , idnLabelForms+    , parseDomainOpts+    , parseIdnaFlagsStr+    , parseLabelFormSetStr+    , unparseDomainOpts+    , (<->)+    )++----------------------------------------------------------------------+-- Constants+----------------------------------------------------------------------++-- | Worst-case length of a presentation-form fully-qualified name+-- with maximal escaping.+nameLimit :: Int+nameLimit = 1024++----------------------------------------------------------------------+-- CLI+----------------------------------------------------------------------++data Options = Options+    { optForms :: !LabelFormSet+    , optFlags :: !IdnaFlags+    , optSummary :: !Bool+    , optNoRows :: !Bool+    }++optionsParser :: Parser Options+optionsParser = Options+    <$> option formsReader+        ( long "forms"+       <> metavar "FORMS"+       <> value idnLabelForms+       <> showDefault+       <> help formsHelp )+    <*> option idnaFlagsReader+        ( long "opts"+       <> metavar "OPTS"+       <> value defaultIdnaFlags+       <> showDefault+       <> help optsHelp )+    <*> switch+        ( long "summary"+       <> help "Emit a trailing summary record" )+    <*> switch+        ( long "no-rows"+       <> help "Suppress per-row records (combine with --summary)" )++formsHelp :: String+formsHelp =+    "Permitted label-form tokens (comma-separated, +/- prefix); \+    \see --explain-forms."++opts :: ParserInfo Options+opts = info (optionsParser <**> helper+                           <**> explainFormsFlag+                           <**> explainOptsFlag)+    ( fullDesc+   <> progDesc "Parse domain names from stdin (one per line) and emit \+               \one JSON record per name on stdout."+   <> header "idnaparse - IDNA-aware DNS-name lint and reporter" )++-- | --explain-forms: print the label-form glossary and exit.  Kept+-- separate from --help so the default option list stays compact;+-- callers who want the full vocabulary opt in explicitly.+explainFormsFlag :: Parser (a -> a)+explainFormsFlag = infoOption formsExplainText+    ( long "explain-forms"+   <> help "Print the label-form glossary and exit" )++-- | --explain-opts: print the IDNA-options glossary and exit.+-- Covers validation checks, input mappings, the Bidi rule+-- mechanism, the emoji relaxation, and the ASCII fallback policy+-- in one place.+explainOptsFlag :: Parser (a -> a)+explainOptsFlag = infoOption optsExplainText+    ( long "explain-opts"+   <> help "Print the IDNA-options glossary and exit" )++formsExplainText :: String+formsExplainText = unlines+    [ "SYNTAX"+    , "  Comma-separated tokens passed to --forms.  Tokens are"+    , "  case-insensitive; 3-character prefix matching is accepted"+    , "  (e.g. 'alab' for 'alabel', 'wild' for 'wildlabel')."+    , "  Default --forms value: \"default\"."+    , ""+    , "  Sign prefixes: \"-X\" subtracts; \"+X\" or unprefixed \"X\""+    , "  adds.  If the FIRST token starts with \"+\" or \"-\", the"+    , "  running result is seeded with \"default\" and your tokens"+    , "  tweak it.  Otherwise the running result starts empty and"+    , "  your tokens replace the default."+    , ""+    , "PRESETS (composite tokens that expand to a fixed set of bits)"+    , "  idn         LDH | ALABEL | ULABEL  (also: 'default' or 'strict')"+    , "  host        idn | RLDH | FAKEA"+    , "  all         host | ATTRLEAF | OCTET | WILDLABEL"+    , "LABEL FORMS"+    , "  LDH        Letter-Digit-Hyphen.  Lowercase a-z, digits 0-9, and"+    , "             '-'.  No leading or trailing hyphen.  The conventional"+    , "             hostname alphabet."+    , "  RLDH       Reserved LDH.  LDH-shaped label with '--' at positions"+    , "             3-4 that is NOT 'xn--'-prefixed.  Reserved by RFC 5890;"+    , "             typically these are pre-IDN registrations (such as,"+    , "             \"cd--shelves\", \"l---l\") that happen to share the"+    , "             'X--Y' shape without being A-labels."+    , "  ALABEL     IDNA A-label: an 'xn--' label whose Punycode body"+    , "             decodes to a valid U-label and re-encodes to the same"+    , "             bytes (under alabel-check; without it, every well-formed"+    , "             'xn--' LDH label is an ALABEL).  See --explain-opts."+    , "  FAKEA      'xn--' LDH label that fails the strict A-label"+    , "             round-trip test (bad Punycode, decoded form fails U-label"+    , "             validation, or re-encoded form differs).  Only produced"+    , "             under alabel-check."+    , "  ULABEL     U-label: input contains at least one non-ASCII codepoint"+    , "             admitted by IDNA2008; encoded to its A-label form on the"+    , "             wire."+    , "  ATTRLEAF   LDH-shaped label whose first byte is '_'.  Used for"+    , "             RFC 8552 attribute-leaf names (such as, '_25._tcp.example')."+    , "  OCTET      A label that's not (R)LDH: either the bytes include non-LDH"+    , "             values (control bytes, '_' outside an ATTRLEAF position,"+    , "             bytes >= 0x80, ...), or the input used backslash escapes"+    , "             to force literal interpretation of bytes that might"+    , "             otherwise be LDH."+    , "  WILDLABEL  A label consisting of a single '*' byte.  Distinct from"+    , "             OCTET so that callers can admit or reject it"+    , "             independently."+    , "  LAXULABEL  For debugging only, IDNA-disallowed Unicode label that"+    , "             encodes to or decodes from a plausible-looking FAKEA label."+    , "REFERENCES"+    , "  IDNA2008:  RFC 5890-5895"+    , "  Punycode:  RFC 3492"+    , "  Library:   https://github.com/dnsbase/idna2008"+    ]++optsExplainText :: String+optsExplainText = unlines+    [ "IDNA OPTIONS"+    , "  Comma-separated tokens passed to --opts.  Tokens are"+    , "  case-insensitive; 3-character prefix matching is accepted."+    , "  Default --opts value: \"default\"."+    , ""+    , "  Sign prefixes: \"-X\" subtracts; \"+X\" or unprefixed \"X\""+    , "  adds (\"+\" is the implicit default for additive operations)."+    , "  If the FIRST token starts with \"+\" or \"-\", the running"+    , "  result is seeded with \"default\" and your tokens tweak it"+    , "  (e.g. \"+emoji-ok\" means \"default plus emoji-ok\")."+    , "  Otherwise the running result starts empty and your tokens"+    , "  replace the default (e.g. \"map-case,map-nfc\" yields just"+    , "  those two flags)."+    , ""+    , "VALIDATION CHECKS  (on under \"default\")"+    , ""+    , "  alabel-check  (alias: xncheck)"+    , "      Strict A-label round-trip.  An \"xn--\"-prefixed label whose"+    , "      Punycode body doesn't decode to a valid U-label and re-encode"+    , "      to the same bytes is reported as FAKEA rather than ALABEL."+    , ""+    , "  nfc-check"+    , "      Require Unicode Normalization Form C on U-labels (RFC 5891"+    , "      section 5.3).  Labels with combining marks in non-canonical"+    , "      order, or decomposed sequences with a precomposed equivalent,"+    , "      are rejected."+    , ""+    , "  bidi-check"+    , "      Apply RFC 5893 Bidi rules.  Right-to-left scripts (Hebrew,"+    , "      Arabic, ...) carry a reading direction that bidirectional-"+    , "      text engines honour when laying out a paragraph.  Mixing"+    , "      right-to-left labels with left-to-right ones in a single"+    , "      domain name leaves room for visual reordering -- the rules"+    , "      constrain what a domain may contain so the layout stays"+    , "      unambiguous."+    , ""+    , "      Per label: an individual right-to-left label must be"+    , "      self-consistent (no stray left-to-right characters in the"+    , "      middle, no mixing of European and Arabic-Indic digits, and"+    , "      so on).  Once any label in the name is right-to-left, every"+    , "      other label -- including pure left-to-right ones -- must"+    , "      also pass the rules.  Catches mixtures like"+    , "      \"_tcp.<arabic-label>.example\": \"_tcp\" is fine on its own,"+    , "      but it starts with an underscore, and the rules forbid an"+    , "      underscore-leading label in the same domain as a right-to-"+    , "      left label."+    , ""+    , "      Names that fail are reported as errors, with the offending"+    , "      label index and the specific rule that failed (e.g."+    , "      \"BidiRule1FirstNotLRAL\")."+    , ""+    , "INPUT MAPPINGS  (RFC 5895; off under \"default\")"+    , ""+    , "  map-dots"+    , "      Treat the East Asian period characters (the ideographic"+    , "      period and its fullwidth and halfwidth variants) as label"+    , "      separators, just like '.'."+    , ""+    , "  map-case"+    , "      Lowercase the input.  ASCII A-Z always, and also other"+    , "      letters with a known lowercase form when they appear inside"+    , "      a label that contains non-ASCII characters."+    , ""+    , "  map-width"+    , "      Convert fullwidth and halfwidth characters to their normal-"+    , "      width form.  Fullwidth Latin letters become ordinary ASCII"+    , "      letters, and halfwidth katakana becomes regular katakana."+    , "      Implies map-dots so the fullwidth and halfwidth period"+    , "      characters act as separators."+    , ""+    , "  map-nfc"+    , "      Combine letter-and-accent sequences into single characters"+    , "      where possible.  Affects only labels with non-ASCII"+    , "      characters."+    , ""+    , "  Aliases: cmap = map-case, dmap = map-dots, nmap = map-nfc,"+    , "  wmap = map-width."+    , ""+    , "RELAXATIONS"+    , ""+    , "  emoji-ok"+    , "      Admit non-ASCII codepoints carrying the Unicode Emoji property"+    , "      even when otherwise disallowed by IDNA2008.  Applies both at"+    , "      parse time (the label classifies as ULABEL/ALABEL instead of"+    , "      FAKEA) and at render time (a strictly-round-tripping ACE"+    , "      label whose body decodes to emoji renders as the codepoint)."+    , "      Useful when analysing real-world IDN data that includes emoji"+    , "      registrations."+    , ""+    , "PRESENTATION POLICY"+    , ""+    , "  ascii-fallback"+    , "      Implies bidi-check.  When the cross-label Bidi rules would"+    , "      reject a name, render it in A-label form (every label as"+    , "      ASCII) instead of erroring.  Useful for display contexts"+    , "      where the goal is \"show the user something readable\";"+    , "      strict callers (registry tools, conformance validators)"+    , "      should not enable this -- they want the error."+    , ""+    , "      With validation enabled (the default), an ALABEL in"+    , "      the \"renderedForms\" array is the signal that the"+    , "      cross-label Bidi rules prevented the U-label form:"+    , "      the renderer's Unicode-preferred tiebreak would have"+    , "      picked ULABEL if the rules had not forced the fallback."+    , "      Comparison against the \"forms\" array (parser-time"+    , "      classification) identifies which specific labels were"+    , "      originally Unicode."+    , ""+    , "PRESETS"+    , "  default      alabel-check, nfc-check, bidi-check.  This is the"+    , "               default value of --opts."+    , "  map          The four input mappings above."+    , ""+    , "REFERENCES"+    , "  RFC 5891:  https://www.rfc-editor.org/rfc/rfc5891  (IDNA protocol)"+    , "  RFC 5893:  https://www.rfc-editor.org/rfc/rfc5893  (Bidi rules)"+    , "  RFC 5895:  https://www.rfc-editor.org/rfc/rfc5895  (input mappings)"+    ]++----------------------------------------------------------------------+-- Forms / flags parsers (CLI value readers)+----------------------------------------------------------------------++formsReader :: ReadM LabelFormSet+formsReader = eitherReader (parseLabelFormSetStr idnLabelForms)++idnaFlagsReader :: ReadM IdnaFlags+idnaFlagsReader = eitherReader (parseIdnaFlagsStr defaultIdnaFlags)++optsHelp :: String+optsHelp =+    "IDNA option tokens (comma-separated, +/- prefix); \+    \see --explain-opts."++----------------------------------------------------------------------+-- Counters+----------------------------------------------------------------------++data Counters = Counters+    { cOk       :: !(IORef Int)+    , cFail     :: !(IORef Int)+    , cOverlong :: !(IORef Int)+    , cBadUtf8  :: !(IORef Int)+    , cForms    :: !(IORef (Map BS.ByteString Int))+    , cReasons  :: !(IORef (Map BS.ByteString Int))+    }++newCounters :: IO Counters+newCounters = Counters+    <$> newIORef 0 <*> newIORef 0 <*> newIORef 0 <*> newIORef 0+    <*> newIORef Map.empty <*> newIORef Map.empty++bumpInt :: IORef Int -> IO ()+bumpInt ref = modifyIORef' ref (+ 1)+{-# INLINE bumpInt #-}++bumpKey :: IORef (Map BS.ByteString Int) -> BS.ByteString -> IO ()+bumpKey ref k = modifyIORef' ref (Map.insertWith (+) k 1)+{-# INLINE bumpKey #-}++----------------------------------------------------------------------+-- LabelForm -> sorted list of name-strings+----------------------------------------------------------------------++-- | Render a single 'LabelForm' to its canonical name.  Defers to+-- the library's 'Show' instance, which is authoritative -- a new+-- form constructor surfaces here without any app-side update.+formName :: LabelForm -> BS.ByteString+formName = BS.pack . show++-- | Per-row deduplicated form names.  Used by the summary counter:+-- each distinct form contributes one bump per input row regardless+-- of how many labels carried it.+uniqueFormNames :: [LabelForm] -> [BS.ByteString]+uniqueFormNames = map formName . List.nub++----------------------------------------------------------------------+-- IdnaError / reason classification+----------------------------------------------------------------------++-- | Description of the @"error"@ object's payload: the @reason@+-- vocabulary and any auxiliary fields.+data ErrorRec = ErrorRec+    { erReason :: !BS.ByteString+    , erLabel  :: !(Maybe Int)+    , erCp     :: !(Maybe Int)+    , erLength :: !(Maybe Int)+    , erForm   :: !(Maybe BS.ByteString)+    , erRule   :: !(Maybe BS.ByteString)+    }++emptyError :: BS.ByteString -> ErrorRec+emptyError r = ErrorRec r Nothing Nothing Nothing Nothing Nothing++withLabel :: ErrorRec -> Int -> ErrorRec+withLabel e n = e { erLabel = Just n }++classifyErr :: IdnaError -> ErrorRec+classifyErr = \case+    ErrEmptyLabel loc           ->+        emptyError "EmptyLabel" `withLabel` loc+    ErrLabelTooLong loc n       ->+        (emptyError "LabelTooLong" `withLabel` loc)+            { erLength = Just n }+    ErrNameTooLong n            ->+        (emptyError "NameTooLong") { erLength = Just n }+    ErrBadEscape loc _          ->+        emptyError "BadEscape" `withLabel` loc+    ErrInvalidUtf8 loc _        ->+        emptyError "InvalidUtf8" `withLabel` loc+    ErrCodepointTooLarge loc cp ->+        (emptyError "CodepointTooLarge" `withLabel` loc)+            { erCp = Just cp }+    ErrUnpresentableLabel loc   ->+        emptyError "UnpresentableLabel" `withLabel` loc+    ErrFormNotAllowed loc fm    ->+        (emptyError "FormNotAllowed" `withLabel` loc)+            { erForm = Just (BS.pack (show fm)) }+    ErrLabelInvalid loc r       ->+        let er = classifyU r+        in  er { erLabel = Just loc }+    ErrAceInvalid loc r         ->+        let er = classifyA r+        in  er { erLabel = Just loc }+    ErrPunycodeOverflow loc     ->+        emptyError "PunycodeOverflow" `withLabel` loc+    ErrCrossLabelBidi i rule    ->+        (emptyError "CrossLabelBidi" `withLabel` i)+            { erRule = Just (showBidiRule rule) }++classifyU :: LabelReason -> ErrorRec+classifyU = \case+    DisallowedCodepoint cp ->+        (emptyError "DisallowedCodepoint") { erCp = Just cp }+    ContextRule cp         ->+        (emptyError "ContextRule")         { erCp = Just cp }+    NotNFC                 -> emptyError "NotNFC"+    LabelBidi rule         ->+        (emptyError "BidiViolation") { erRule = Just (showBidiRule rule) }+    HyphenViolation        -> emptyError "HyphenViolation"+    LeadingCombiningMark cp ->+        (emptyError "LeadingCombiningMark") { erCp = Just cp }++classifyA :: AceReason -> ErrorRec+classifyA = \case+    BadPunycode         -> emptyError "BadPunycode"+    RoundTripMismatch   -> emptyError "RoundTripMismatch"+    DecodedInvalid r    -> classifyU r++-- | Render a 'BidiRuleViolation' as the constructor name (e.g.+-- @\"BidiRule1FirstNotLRAL\"@), so JSON consumers can grep for+-- it without us having to coin our own vocabulary.+showBidiRule :: BidiRuleViolation -> BS.ByteString+showBidiRule = BS.pack . show++----------------------------------------------------------------------+-- main+----------------------------------------------------------------------++main :: IO ()+main = do+    o <- execParser opts+    hSetBinaryMode stdin  True+    hSetBinaryMode stdout True+    hSetBuffering  stdout (BlockBuffering Nothing)+    counters <- newCounters+    S.mapM_ (processOne counters o (optFlags o))+            (textLines counters Q.stdin)+    when (optSummary o) (emitSummary counters)++processOne :: Counters -> Options -> IdnaFlags -> T.Text -> IO ()+processOne !c !o !idnaFlags !name =+    case parseDomainOpts (optForms o) idnaFlags name of+      Left e -> emitParseFailure (classifyErr e)+      Right (dn, parseInfo) -> do+        let !inSeq      = getLabelForms parseInfo+            !inFormNms  = map formName inSeq+            !pres       = domainToAscii dn+        -- Form counters reflect the parser's input characterisation;+        -- bump them on parse success regardless of whether the+        -- unparse step then succeeded.  The FAKEA per-label+        -- diagnostic is gathered for the row's @fakes@ JSON field+        -- but does /not/ feed 'cReasons': FAKEA admitted on input+        -- is not a failure reason, and FAKEA rejected on input+        -- already surfaces under the parse-failure reason.+        mapM_ (bumpKey (cForms c)) (uniqueFormNames inSeq)+        let !mFake = if FAKEA `elem` inSeq+                       then collectFake (optForms o) idnaFlags name+                       else Nothing+        case unparseDomainOpts (optForms o) idnaFlags dn of+          Left e ->+              emitUnparseFailure inFormNms pres mFake (classifyErr e)+          Right (output, renderInfo) -> do+              -- 'inSeq' is the parser's per-label classification in+              -- label order; 'outSeq' is the unparser's.  They+              -- differ when 'ASCIIFALLBACK' downgrades a U-label to+              -- its ACE form: 'input.forms' carries 'ULABEL',+              -- 'output.forms' carries 'ALABEL'.+              let !outSeq = getLabelForms renderInfo+              bumpInt (cOk c)+              unless (optNoRows o) $+                  BB.hPutBuilder stdout+                      (successRow name inFormNms pres+                                  output (map formName outSeq)+                                  mFake+                       <> BB.char7 '\n')+  where+    emitParseFailure !er = do+        bumpInt (cFail c)+        bumpKey (cReasons c) (erReason er)+        unless (optNoRows o) $+            BB.hPutBuilder stdout (parseFailureRow name er <> BB.char7 '\n')++    emitUnparseFailure !inForms !pres !mFake !er = do+        bumpInt (cFail c)+        bumpKey (cReasons c) (erReason er)+        unless (optNoRows o) $+            BB.hPutBuilder stdout+                (unparseFailureRow name inForms pres mFake er <> BB.char7 '\n')++-- | If the original parse classified at least one label as @FAKEA@,+-- re-parse with @FAKEA@ stripped from the allowed set so the parser+-- surfaces the precise 'AceReason' via @ErrAceInvalid@.  Returns+-- the first failing label's reason, or 'Nothing' on the (impossible)+-- case where the masked parse doesn't fail at all.+collectFake :: LabelFormSet -> IdnaFlags -> T.Text -> Maybe ErrorRec+collectFake forms idnaFlags name =+    case parseDomainOpts (forms <-> FAKEA) idnaFlags name of+      Left e@(ErrAceInvalid _ _) -> Just (classifyErr e)+      _                          -> Nothing++----------------------------------------------------------------------+-- Row builders+----------------------------------------------------------------------++-- | Parse + unparse both succeeded.  Both forms arrays carry the+-- per-label classification at the relevant phase; they differ when+-- 'ASCIIFALLBACK' fires (typically @ULABEL@ in @input.forms@,+-- @ALABEL@ in @output.forms@).+successRow :: T.Text+           -> [BS.ByteString]    -- ^ Parser's per-label classification+           -> T.Text             -- ^ Presentation form (canonical ASCII)+           -> T.Text             -- ^ Rendered output text+           -> [BS.ByteString]    -- ^ Unparser's per-label classification+           -> Maybe ErrorRec     -- ^ FAKEA per-label diagnostic+           -> BB.Builder+successRow input inForms pres output outForms mFake =+       BB.byteString "{\"input\":"        <> inputObject input inForms+    <> BB.byteString ",\"presentation\":" <> jsonString pres+    <> BB.byteString ",\"output\":"       <> outputObject output outForms+    <> maybe mempty+             (\er -> BB.byteString ",\"fakes\":["+                  <> errorObject er+                  <> BB.byteString "]")+             mFake+    <> BB.char7 '}'++-- | Parse failed: only the input text and the error.+parseFailureRow :: T.Text -> ErrorRec -> BB.Builder+parseFailureRow input er =+       BB.byteString "{\"input\":{\"text\":"+    <> jsonString input+    <> BB.byteString ",\"error\":"+    <> errorObject er+    <> BB.byteString "}}"++-- | Parse succeeded but unparse failed (e.g. cross-label Bidi+-- without @ASCIIFALLBACK@).  The input side carries the parser's+-- classification; the output side carries only the error.  The+-- FAKEA per-label diagnostic, when present, comes through too --+-- it characterises the parsed input, which the unparse failure+-- doesn't invalidate.+unparseFailureRow :: T.Text+                  -> [BS.ByteString]    -- ^ Parser's per-label classification+                  -> T.Text             -- ^ Presentation form+                  -> Maybe ErrorRec     -- ^ FAKEA per-label diagnostic+                  -> ErrorRec+                  -> BB.Builder+unparseFailureRow input inForms pres mFake er =+       BB.byteString "{\"input\":"        <> inputObject input inForms+    <> BB.byteString ",\"presentation\":" <> jsonString pres+    <> BB.byteString ",\"output\":{\"error\":"+    <> errorObject er+    <> BB.byteString "}"+    <> maybe mempty+             (\fake -> BB.byteString ",\"fakes\":["+                    <> errorObject fake+                    <> BB.byteString "]")+             mFake+    <> BB.char7 '}'++inputObject :: T.Text -> [BS.ByteString] -> BB.Builder+inputObject text forms =+       BB.byteString "{\"text\":"  <> jsonString text+    <> BB.byteString ",\"forms\":" <> jsonStringArray forms+    <> BB.char7 '}'++outputObject :: T.Text -> [BS.ByteString] -> BB.Builder+outputObject text forms =+       BB.byteString "{\"text\":"  <> jsonString text+    <> BB.byteString ",\"forms\":" <> jsonStringArray forms+    <> BB.char7 '}'++-- | Render an 'ErrorRec' as a JSON object @{ "reason":..., "label"?:..., ... }@.+-- Optional fields appear only when present.+errorObject :: ErrorRec -> BB.Builder+errorObject er =+       BB.byteString "{\"reason\":\""+    <> BB.byteString (erReason er)+    <> BB.char7 '"'+    <> maybe mempty intField (("label" ,) <$> erLabel  er)+    <> maybe mempty intField (("cp"    ,) <$> erCp     er)+    <> maybe mempty intField (("length",) <$> erLength er)+    <> maybe mempty strField (("form"  ,) <$> erForm   er)+    <> maybe mempty strField (("rule"  ,) <$> erRule   er)+    <> BB.char7 '}'+  where+    intField (k, v) =+           BB.byteString ",\""+        <> BB.byteString k+        <> BB.byteString "\":"+        <> BB.intDec v+    strField (k, v) =+           BB.byteString ",\""+        <> BB.byteString k+        <> BB.byteString "\":\""+        <> BB.byteString v+        <> BB.char7 '"'++----------------------------------------------------------------------+-- Summary record+----------------------------------------------------------------------++emitSummary :: Counters -> IO ()+emitSummary c = do+    ok       <- readIORef (cOk c)+    bad      <- readIORef (cFail c)+    over     <- readIORef (cOverlong c)+    badutf8  <- readIORef (cBadUtf8 c)+    formsM   <- readIORef (cForms c)+    reasonsM <- readIORef (cReasons c)+    BB.hPutBuilder stdout+        (   BB.byteString "{\"summary\":{\"ok\":"+         <> BB.intDec ok+         <> BB.byteString ",\"fail\":"+         <> BB.intDec bad+         <> BB.byteString ",\"discard\":{\"overlong\":"+         <> BB.intDec over+         <> BB.byteString ",\"badutf8\":"+         <> BB.intDec badutf8+         <> BB.byteString "},\"forms\":"+         <> jsonIntMap formsM+         <> BB.byteString ",\"reasons\":"+         <> jsonIntMap reasonsM+         <> BB.byteString "}}\n")++-- | Render a @Map ByteString Int@ as a JSON object with sorted keys.+jsonIntMap :: Map BS.ByteString Int -> BB.Builder+jsonIntMap m+    | Map.null m = BB.byteString "{}"+    | otherwise  =+           BB.char7 '{'+        <> mconcat (zipWith pair (Map.toAscList m) (False : repeat True))+        <> BB.char7 '}'+  where+    pair (k, v) sep =+           (if sep then BB.char7 ',' else mempty)+        <> BB.char7 '"'+        <> BB.byteString k+        <> BB.byteString "\":"+        <> BB.intDec v++----------------------------------------------------------------------+-- JSON encoding helpers+----------------------------------------------------------------------++jsonString :: T.Text -> BB.Builder+jsonString !t =+    BB.char7 '"' <> T.foldr step mempty t <> BB.char7 '"'+  where+    step !c !acc = escapeChar c <> acc++escapeChar :: Char -> BB.Builder+escapeChar !c+    | c == '"'          = BB.byteString "\\\""+    | c == '\\'         = BB.byteString "\\\\"+    | c == '\b'         = BB.byteString "\\b"+    | c == '\t'         = BB.byteString "\\t"+    | c == '\n'         = BB.byteString "\\n"+    | c == '\f'         = BB.byteString "\\f"+    | c == '\r'         = BB.byteString "\\r"+    | fromEnum c < 0x20 = uHex (fromEnum c)+    | otherwise         = BB.charUtf8 c++uHex :: Int -> BB.Builder+uHex !n =+       BB.byteString "\\u00"+    <> BB.word8 (hexNibble (n `unsafeShiftR` 4))+    <> BB.word8 (hexNibble (n .&. 0x0F))++hexNibble :: Int -> Word8+hexNibble !n+    | n < 10    = fromIntegral (0x30 + n)             -- '0'..'9'+    | otherwise = fromIntegral (0x57 + n)             -- 'a'..'f'++-- | Render a list of (already-ASCII) ByteStrings as a JSON array of+-- string literals.  Used for the @forms@ field, which only contains+-- the canonical form names ("LDH", "ULABEL", etc.) -- no escaping+-- needed.+jsonStringArray :: [BS.ByteString] -> BB.Builder+jsonStringArray xs =+       BB.char7 '['+    <> mconcat (zipWith one xs (False : repeat True))+    <> BB.char7 ']'+  where+    one s sep =+           (if sep then BB.char7 ',' else mempty)+        <> BB.char7 '"'+        <> BB.byteString s+        <> BB.char7 '"'++----------------------------------------------------------------------+-- Stream<>line<>Text+----------------------------------------------------------------------++textLines :: Counters -> Q.ByteStream IO () -> Stream (Of T.Text) IO ()+textLines !c = decode . Q.lines+  where+    decode = go++    go = lift . inspect >=> \case+        Left _  -> pure ()+        Right l -> do+            (bs :> rest) <-+                lift $ Q.toStrict+                     $ Q.drained+                     $ Q.splitAt (fromIntegral (nameLimit + 1)) l+            if BSStrict.length bs > nameLimit+              then lift (bumpInt (cOverlong c))+              else case Enc.decodeUtf8' bs of+                Right t -> S.yield t+                Left _  -> lift (bumpInt (cBadUtf8 c))+            go rest
+ idnaparse.cabal view
@@ -0,0 +1,66 @@+cabal-version: 3.0++name:           idnaparse+version:        1.0.0.0+synopsis:       IDNA-aware DNS-name lint and reporter+description:+    @idnaparse@ reads a stream of presentation-form domain names+    (one per line on stdin) and emits one JSON record per name on+    stdout.  Each record reports either a successful parse (with+    canonical presentation form, Unicode display form, and the+    set of label-form classifications -- LDH, ALABEL, ULABEL,+    FAKEA, ATTRLEAF, OCTET, WILDLABEL) or an error with enough+    detail to identify the offending label and the specific rule+    that failed.++    Built on the @idna2008@ library.  Useful for sweeping a zone+    file or registrar feed for IDNA conformance, building a+    summary of the label kinds present in a corpus, or as the+    parsing front end for ad-hoc DNS-name analysis pipelines.++    See @idnaparse --help@, @idnaparse --explain-forms@, and+    @idnaparse --explain-opts@ for the full CLI vocabulary.++author:         Viktor Dukhovni+maintainer:     ietf-dane@dukhovni.org+copyright:      2026 Viktor Dukhovni+license:        BSD-3-Clause+license-file:   LICENSE+category:       Network+homepage:       https://github.com/dnsbase/idnaparse+bug-reports:    https://github.com/dnsbase/idnaparse/issues+build-type:     Simple+tested-with: GHC == 9.6.7+           , GHC == 9.8.4+           , GHC == 9.10.3+           , GHC == 9.12.3+           , GHC == 9.14.1++extra-doc-files:+    CHANGELOG.md+    README.md++source-repository head+    type:     git+    location: https://github.com/dnsbase/idnaparse.git++executable idnaparse+  hs-source-dirs: app+  main-is: idnaparse.hs+  default-language: GHC2021+  default-extensions:+    BangPatterns+    BlockArguments+    LambdaCase+    OverloadedStrings+    PatternSynonyms+  ghc-options: -O2 -Wall+  build-depends:+      base                  >= 4.18   && < 5+    , bytestring            >= 0.11   && < 0.13+    , containers            >= 0.6    && < 0.9+    , idna2008              >= 1.0    && < 1.1+    , optparse-applicative  >= 0.17   && < 0.20+    , streaming             >= 0.2    && < 0.3+    , streaming-bytestring  >= 0.3    && < 0.4+    , text                  >= 2.0    && < 2.2