Skip to content

NTP Assembling the Parser

Elbasiouny, Mahmoud edited this page May 29, 2026 · 5 revisions

We've built parsers for every component of an NTP packet: the header, the data fields, the extension fields, and the MAC. Now we wire them together into a complete parser and connect it to main().

Source file: ntp.c, lines 49-57 and main.c

Hammer concepts: h_choice · h_left · h_end_p


NTP Packet Types

An NTP packet always starts with the essential fields. After that, it may or may not have extension fields and/or a MAC. Two possible shapes:

Type Structure
Type 1 essential_fields + ext_fields (zero or more)
Type 2 essential_fields + ext_fields (zero or more) + mac

Because ext_fields uses h_many (which matches zero or more), Type 1 covers packets with no extensions as well as packets with extensions but no MAC. Type 2 covers every case that includes a MAC.


Defining the Message Types with h_sequence and h_end_p

Each type is a sequence that ends with h_end_p():

h_end_p()

A parser that succeeds only if there is no more input to consume. It doesn't read any bytes; it's a zero-width assertion. By placing it at the end of a sequence, we ensure the parser accounts for every byte in the input.

Without h_end_p(), a parser could match a prefix of the input and silently ignore trailing garbage.

// Type 1: essential fields + optional extension fields + end of input
H_RULE(type1, h_sequence(essential_fields, ext_fields, h_end_p(), NULL));

// Type 2: essential fields + optional extension fields + MAC + end of input
H_RULE(type2, h_sequence(essential_fields, ext_fields, mac, h_end_p(), NULL));

Choosing Between Types with h_choice

We need the parser to try Type 1 and Type 2 and succeed on whichever matches.

h_choice(p1, p2, ..., NULL)

Tries each parser in order from left to right. Returns the result of the first parser that succeeds. If none succeed, the entire choice fails.

Order matters. h_choice uses backtracking. If p1 partially matches but ultimately fails, the input position resets and p2 gets a fresh start.

Why is type1 listed before type2? For a packet with extensions but no MAC, type1 matches because ext_fields consumes the extensions and h_end_p() confirms there's nothing left. For a packet with a MAC, type1 fails because h_end_p() sees the remaining MAC bytes and rejects. Backtracking kicks in, type2 tries, and it succeeds because it parses the MAC before asserting end-of-input.


h_left - Keep Left, Discard Right

h_left(left, right)

Parses left, then right, but only returns left's result. right's result is discarded.

Useful when you want to assert something (like end-of-input) without including it in the result tree:

HParser *ntp = h_left(h_choice(type1, type2, NULL), h_end_p());

There's already an h_end_p() inside each type. The outer one is a defensive pattern: if someone later adds a new type and forgets the inner h_end_p(), the outer one catches it.


The Complete ntpParser() Function

The entire parser definition from ntp.c:

HParsedToken *opt_ext_len(const HParseResult *p, void *user_data) {
    unsigned int temp = p->ast->uint;
    return H_MAKE_UINT(temp - 4);
}

HParser *ntpParser() {
    // Header
    H_RULE(leap, h_int_range(h_bits(2, false), 0, 3));
    H_RULE(version, h_int_range(h_bits(3, false), 1, 4));
    H_RULE(mode, h_int_range(h_bits(3, false), 0, 7));
    H_RULE(stratum, h_int_range(h_uint8(), 0, 16));
    H_RULE(poll, h_int8());
    H_RULE(precision, h_int8());

    H_RULE(header, h_sequence(leap, version, mode, stratum, poll, precision, NULL));

    // Essential fields
    H_RULE(root_delay, h_sequence(h_int16(), h_int16(), NULL));
    H_RULE(root_disp, h_sequence(h_int16(), h_int16(), NULL));
    H_RULE(ref_id, h_uint32());
    H_RULE(ref_ts, h_sequence(h_uint32(), h_uint32(), NULL));
    H_RULE(org_ts, h_sequence(h_uint32(), h_uint32(), NULL));
    H_RULE(rec_ts, h_sequence(h_uint32(), h_uint32(), NULL));
    H_RULE(xmt_ts, h_sequence(h_uint32(), h_uint32(), NULL));

    H_RULE(essential_fields,
           h_sequence(header, root_delay, root_disp, ref_id,
                      ref_ts, org_ts, rec_ts, xmt_ts, NULL));

    // Optional extension fields
    H_RULE(field_type, h_int16());
    H_RULE(ext_field,
           h_sequence(
               field_type, h_put_value(h_uint16(), "opt_len_val"),
               h_length_value(
                   h_action(h_free_value("opt_len_val"), opt_ext_len, NULL),
                   h_uint8()),
               NULL));
    H_RULE(ext_fields, h_many(ext_field));

    // Optional MAC
    H_RULE(key_id, h_uint32());
    H_RULE(dgst, h_sequence(h_int64(), h_int64(), NULL));
    H_RULE(mac, h_sequence(key_id, dgst, NULL));

    // Packet types
    H_RULE(type1, h_sequence(essential_fields, ext_fields, h_end_p(), NULL));
    H_RULE(type2, h_sequence(essential_fields, ext_fields, mac, h_end_p(), NULL));

    // Choose the matching type
    HParser *ntp = h_left(h_choice(type1, type2, NULL), h_end_p());

    return ntp;
}

Wiring Into main()

main.c is intentionally simple. It reads raw bytes, runs the parser, and prints the result:

#include "hex.c"
#include "ntp.c"

int main(int argc, char *argv[]) {
    uint8_t input[1024] = {0};
    FILE *f_input = stdin;

    if (argc > 1) {
        f_input = fopen(argv[1], "rb");
        if (!f_input) {
            puts("ERR: cannot open file");
            return 1;
        }
    } else {
        puts("Reading from stdin");
    }

    size_t input_size = fread(input, 1, sizeof(input), f_input);

    HParseResult *result = h_parse(ntpParser(), input, input_size);

    if (result) {
        printf("Packet accepted\n");
        return 0;
    } else {
        printf("Packet rejected\n");
        return -1;
    }
}

Key points:

  • ntpParser() returns an HParser *, the top-level parser we just built.
  • h_parse() takes the parser, a byte array, and its length. Returns NULL on failure. (See Hammer Fundamentals.)
  • The input is raw bytes, not hex strings. See Running and Testing for how to provide input in different formats.

The Full Parser Structure

Top-to-bottom view of how the parser is organized:

h_left
├── h_choice
│   ├── type1: h_sequence
│   │   ├── essential_fields: h_sequence
│   │   │   ├── header: h_sequence
│   │   │   │   ├── leap         (h_int_range + h_bits)
│   │   │   │   ├── version      (h_int_range + h_bits)
│   │   │   │   ├── mode         (h_int_range + h_bits)
│   │   │   │   ├── stratum      (h_int_range + h_uint8)
│   │   │   │   ├── poll         (h_int8)
│   │   │   │   └── precision    (h_int8)
│   │   │   ├── root_delay       (h_int16 + h_int16)
│   │   │   ├── root_disp        (h_int16 + h_int16)
│   │   │   ├── ref_id           (h_uint32)
│   │   │   ├── ref_ts           (h_uint32 + h_uint32)
│   │   │   ├── org_ts           (h_uint32 + h_uint32)
│   │   │   ├── rec_ts           (h_uint32 + h_uint32)
│   │   │   └── xmt_ts           (h_uint32 + h_uint32)
│   │   ├── ext_fields: h_many
│   │   │   └── ext_field: h_sequence
│   │   │       ├── field_type   (h_int16)
│   │   │       ├── length       (h_put_value + h_uint16)
│   │   │       └── value        (h_length_value + h_action)
│   │   └── h_end_p
│   │
│   └── type2: h_sequence
│       ├── essential_fields     (same as above)
│       ├── ext_fields           (same as above)
│       ├── mac: h_sequence
│       │   ├── key_id           (h_uint32)
│       │   └── dgst             (h_int64 + h_int64)
│       └── h_end_p
│
└── h_end_p (safety net)

Summary

Combinator Purpose in This Parser
h_end_p() Ensures every byte is accounted for
h_choice(a, b, ..., NULL) Try packet types in order until one matches
h_left(a, b) Parse a and b, return only a's result
Design Pattern Why
h_end_p() inside each type Each type must consume all remaining input
h_end_p() outside h_choice Safety net for future additions
Order of types in h_choice More restrictive types can go first; backtracking handles failures

Next: Hex Input Preprocessing - A bonus parser that converts hex strings to bytes.

Previous: Extension Fields and MAC

Clone this wiki locally