Documents
XML Decode Error Annotation
XML Decode Error Annotation
Type
Topic
Status
Published
Created
Apr 18, 2026
Updated
Apr 19, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

XML Decode Error Annotation#

Problem: XML field type mismatches (e.g., a field typed as int receiving the value "on") produce bare Go runtime errors like strconv.ParseInt: parsing "on": invalid syntax with no indication of which XML element is at fault. Issue #558 is a concrete example: a user got this error with no element path, making the offending field unlocatable.

Solution: The project annotates XML decode errors with element path context using two helpers in internal/cfgparser/errors.go:

Both produce a cfgparser.ParseError, which carries Line, Column, and Message and is detectable via cfgparser.IsParseError(err).

Note: The background context references parser.WrapDecodeError() — this function does not exist in the current codebase. The canonical helpers are WrapXMLSyntaxError and WrapXMLSyntaxErrorWithOffset in internal/cfgparser/errors.go.

OPNsense Wiring (Section-by-Section)#

The OPNsense XML parser in internal/cfgparser/xml.go uses a streaming, token-based approach. Each top-level XML element under <opnsense> is individually decoded by handleStartElement(), which dispatches to a section-specific decodeChild() call (30+ cases: system, interfaces, nat, filter, gateways, openvpn, etc.).

When the token loop encounters a decode error, it calls handleXMLError():

func handleXMLError(err error, dec *xml.Decoder) error {
    if wrappedErr := WrapXMLSyntaxErrorWithOffset(err, "opnsense", dec); wrappedErr != nil {
        return fmt.Errorf("failed to decode XML: %w", wrappedErr)
    }
    return fmt.Errorf("failed to read token: %w", err)
}

This means XML syntax errors from OPNsense parsing will carry the element path "opnsense" in their message. The path is coarse (document root only) — individual section names are not appended at the error annotation level, though the error message from within a section decode will often identify the offending token in context.

The decodeChild() helper is a thin wrapper around dec.DecodeElement() and does not add additional path context itself. Errors from DecodeElement (such as field-type mismatches that produce strconv errors) bubble up through handleStartElement and return directly to Parse() — they do not pass through handleXMLError, so they are not wrapped as cfgparser.ParseError and carry no element path. This is a known gap: decode errors from within a section are currently indistinguishable from general errors at the exit-code and JSON output level.

pfSense Wiring (Document-Root, Single-Pass)#

The pfSense parser in pkg/parser/pfsense/parser.go decodes the entire document in a single dec.Decode(&doc) call inside decode():

if err := dec.Decode(&doc); err != nil {
    return nil, fmt.Errorf("XML decode: %w", err)
}

This wraps the raw encoding/xml error with the prefix "XML decode:" but does not call WrapXMLSyntaxError or WrapXMLSyntaxErrorWithOffset. Consequently, pfSense decode errors are not typed as cfgparser.ParseError — they are plain fmt.Errorf-wrapped errors.

What this means in practice:

  • Error messages lack element path context beyond "pfsense parser: XML decode: ...".
  • cfgparser.IsParseError(err) returns false for these errors.
  • DetermineExitCode() falls through to ExitGeneralError (1) rather than ExitParseError (2).

Deeper per-section path annotation for pfSense (analogous to the OPNsense section switch) is tracked as a follow-up item (referenced in the background context as todo 099, though that file is not present in the current repo HEAD).

Structured Error Propagation Gaps (Exit Codes & JSONError)#

Exit Codes#

cmd/exitcodes.go defines the exit code ladder:

CodeConstantMeaning
0ExitSuccessOK
1ExitGeneralErrorUnknown/general error
2ExitParseErrorXML parse failure
3ExitValidationErrorSchema validation failure
4ExitFileErrorFile I/O error

DetermineExitCode() uses cfgparser.IsParseError(err) to gate ExitParseError. Because pfSense decode errors are not wrapped in cfgparser.ParseError, they fall through and emit ExitGeneralError (1) — masking parse failures as generic errors in CI/CD pipelines.

JSONError.Details#

OutputJSONError() populates JSONError.Details only when cfgparser.IsParseError(err) is true, and only with line and message fields:

jsonErr.Details = map[string]any{
    "line": parseErr.Line,
    "message": parseErr.Message,
}

There is currently no element_path field in JSONError.Details. Automation consumers that need to locate the offending XML element must regex-scrape the message string for the "(in element path: ...)" fragment — a fragile approach. Adding a dedicated element_path key is future work.

Requirements for New Device Parsers#

Any new device parser registered with parser.Register() should follow these rules at its decode boundary:

  1. Use WrapXMLSyntaxErrorWithOffset(err, "<root-element>", dec) to convert raw encoding/xml errors into cfgparser.ParseError. This ensures cfgparser.IsParseError() returns true, DetermineExitCode() maps to ExitParseError (2), and OutputJSONError() populates JSONError.Details.
  2. Pass the most specific available element path as the second argument — at minimum the document root element name (e.g., "pfsense", "opnsense"). For section-by-section parsers, pass the section name (e.g., "opnsense.interfaces").
  3. Do not suppress decode errors with a workaround catch-all. Any strconv.ParseInt, strconv.ParseBool, or strconv.Atoi error appearing in the stack trace is almost certainly a schema miscategorisation — the struct field is typed wrong relative to the XML values the device emits. Fix the schema type; do not add a custom error handler to swallow it.

Schema Bug Rule#

If a bug report shows strconv.ParseInt, strconv.ParseBool, or strconv.Atoi in the error text, treat it as a field typed incorrectly in the schema (e.g., int where the device uses "on" / "yes"). See issue #558 for a canonical example.

Reference files: