Writing & Formatting¶
Hornet can serialise any parsed AST back to valid BIND9 text. The output is controlled by
WriteOptions, which supports configurable indentation, keyword style, and statement spacing.
WriteOptions¶
use hornet_bind9::writer::WriteOptions;
let opts = WriteOptions {
indent: 4, // spaces per indent level
modern_keywords: true, // master → primary, slave → secondary
explicit_class: false, // print the class on every zone/view
blank_between_statements: true, // blank line between top-level statements
};
All fields have sensible defaults:
let opts = WriteOptions::default();
// indent: 4
// modern_keywords: true
// explicit_class: false
// blank_between_statements: true
Writing named.conf¶
use hornet_bind9::{parse_named_conf, write_named_conf};
use hornet_bind9::writer::WriteOptions;
let conf = parse_named_conf(input)?;
// Default formatting
let output = write_named_conf(&conf, &WriteOptions::default());
println!("{output}");
Compact formatting¶
let opts = WriteOptions {
indent: 2,
blank_between_statements: false,
..Default::default()
};
let compact = write_named_conf(&conf, &opts);
Legacy keyword preservation¶
let opts = WriteOptions {
modern_keywords: false, // keep master/slave as-is
..Default::default()
};
let legacy = write_named_conf(&conf, &opts);
Explicit classes¶
With explicit_class: true, every zone and view statement is written with its DNS
class, even when the source did not name one. A zone or view without a class gets the
class BIND9 would give it: IN at the top level, and the enclosing view's class for a
zone inside a view. A class the zone or view names itself always wins.
let opts = WriteOptions { explicit_class: true, ..Default::default() };
let output = write_named_conf(&conf, &opts);
view "chaos" CHAOS {
zone "version.bind" CHAOS {
type primary;
file "version.db";
};
};
zone "example.com" IN {
type primary;
file "example.com.db";
};
See explicit_class for details.
What the writer emits¶
The writer emits every field the AST models, so a parsed file written back keeps all of its typed options, not only the common ones. This includes, among others:
- options:
memstatistics-file,session-keyfile,allow-update,dnssec-enable,max-cache-ttl,min-cache-ttl,rate-limit { ... },response-policy { ... } - zone:
update-policy { ... },forwarders { ... },journal,max-journal-size,notify-source/notify-source-v6,check-names,auto-dnssec - server:
transfer-format,transfer-source/transfer-source-v6,notify-source/notify-source-v6,query-source/query-source-v6,send-cookie,edns,edns-version,request-nsid - primaries: the per-server
tlsname - controls:
unixchannels andread-onlyon bothinetandunix - options (since 0.3.0):
allow-new-zones,key-directory,dnssec-policy - logging:
print-timewithyes,no,local,iso8601,iso8601-utc - dnssec-policy (since 0.3.0): every clause of the BIND 9.18 and 9.20 grammar; see
DnssecPolicyStmt
The -v6 spelling of the source options is chosen from the address family of the stored
address. A unix control's perm is written in decimal (perm 384 for 0600), the
form BIND9 itself prints. An empty address-match list is written as { }; BIND9 rejects
{ ; }.
Options removed from BIND 9.20
auto-dnssec (zone) and controls { unix ...; } were removed in BIND 9.20. hornet
still parses and writes them so older configurations round-trip, but a 9.20 server
rejects a file that contains them. Use dnssec-policy instead of auto-dnssec.
Escaping and injection safety¶
hornet's output is loaded by BIND9, which trusts it completely. If a program builds an
AST from data it does not fully control (a Kubernetes custom resource, a web form, an
API request), a value such as a TXT string ending in a backslash, or an ACL name
containing ; and a newline, must never be able to end its own token and start new
records or statements. The writer guarantees this for every modelled field by giving
each string exactly one treatment, chosen by its position
(ADR-0003).
The writer stays infallible: write_named_conf and write_zone_file still return
String.
named.conf¶
| Position | Examples | Treatment |
|---|---|---|
| String | file paths, key names, secrets, view names in in-view, update-policy identities, tls names, unix control paths, dnssec-policy names (statement and option), key-store names, cds-digest-types entries, key-directory |
Always quoted; " and \ are backslash-escaped |
| Name or keyword | ACL references, key algorithm, record types in update-policy |
Bare only when the value is a plain name (an ASCII letter, then letters, digits, -, _, .) and, for ACL references, not a reserved word; otherwise quoted and escaped |
| Unquoted-only | RPZ policy, update-policy name type |
Same rule; an unsafe value is quoted, and BIND9 rejects the file ("expected unquoted string") instead of reading injected text |
| DNSSEC algorithm | dnssec-policy key algorithm |
Bare when ASCII letters and digits only (ECDSAP256SHA256, 13); otherwise quoted, which BIND9 rejects ("expected unquoted string") |
| Duration | every dnssec-policy duration and key lifetime |
Bare when is_duration accepts it; otherwise quoted, which BIND9 rejects ("expected ISO 8601 duration or TTL value") |
The reserved words for ACL references are any, none, localhost, localnets and
key (compared case-insensitively). An AddressMatchElement::AclRef("any") is therefore
written as "any", which BIND9 (and hornet's parser) read as a reference to an ACL named
any, not as the built-in.
use hornet_bind9::ast::named_conf::{AclStmt, AddressMatchElement, NamedConf, Statement};
use hornet_bind9::write_named_conf;
use hornet_bind9::writer::WriteOptions;
let conf = NamedConf {
statements: vec![Statement::Acl(AclStmt {
name: "clients".into(),
addresses: vec![
AddressMatchElement::AclRef("trusted".into()),
AddressMatchElement::AclRef("evil; }; zone \"x\" {".into()),
],
})],
};
let text = write_named_conf(&conf, &WriteOptions::default());
// acl "clients" {
// trusted;
// "evil; }; zone \"x\" {";
// };
Zone files¶
Zone-file escaping follows RFC 1035 section 5.1:
| Position | Examples | Treatment |
|---|---|---|
| Character-string | TXT, HINFO, NAPTR flags/service/regexp, CAA value, quoted SVCB values, the $INCLUDE path |
Always quoted; " and \ are backslash-escaped; printable ASCII is kept; every other byte (control characters, newlines, non-ASCII) is written as \DDD |
| Name | owner names, NS/CNAME/MX targets, SOA names | Letters, digits, -, _, ., *, a non-leading / and a whole-name @ are kept; an existing \ escape is kept; other printable ASCII becomes \X; everything else becomes \DDD. A leading $ or / is always escaped, so a name can never start a directive or a comment |
| Token | hex, base64, type mnemonics, timestamps, $GENERATE templates |
Printable ASCII except ", (, ) and ; is kept; the rest is escaped as for names |
The same rules are public, so callers can apply them to their own output:
use hornet_bind9::ast::zone_file::Name;
use hornet_bind9::writer::zone_file::{escape_char_string, escape_name, escape_token};
assert_eq!(escape_char_string("say \"hi\"\n"), "say \\\"hi\\\"\\010");
assert_eq!(escape_name(&Name::new("a b.example.com.")), "a\\032b.example.com.");
assert_eq!(escape_token("abc;def"), "abc\\;def");
escape_char_string returns the body without the surrounding quotes.
Raw carriers hold trusted text only¶
Raw carriers are written verbatim: never put untrusted text in them
Three fields exist to round-trip text hornet does not model, and the writer copies them out without any escaping:
extraonOptionsBlock,ZoneOptions,ViewOptionsandServerOptionsStatement::Unknown { keyword, raw }RData::Unknown { rtype, data }(zone files)
Anything stored in these fields reaches BIND9 as configuration. A value that came
from a user, an API or a custom resource can inject arbitrary statements, zones or
records. Only put text in them that you would be willing to paste into named.conf
yourself, such as text hornet's own parser produced from a trusted file. To set a
value from untrusted input, use a modelled field, which the writer escapes.
In zone files, control characters in raw fields are still written as \DDD, so a raw
field can never start a new line; it can still add text to its own line.
Writing zone files¶
use hornet_bind9::{parse_zone_file, write_zone_file};
use hornet_bind9::writer::WriteOptions;
let zone = parse_zone_file(zone_text)?;
let output = write_zone_file(&zone, &WriteOptions::default());
println!("{output}");
Round-trip fidelity¶
Hornet is designed for round-trip fidelity: parsing a file and writing it back with default
options produces semantically equivalent output. Whitespace and formatting may differ, but
all structured data is preserved, and the writer escapes values so that
parse(write(x)) == x holds even for strings containing quotes, backslashes and control
characters.
Comments are not preserved
The AST has no place for comments, so writing a parsed file drops them. The
hornet fmt and convert --in-place commands refuse to rewrite a file that contains
comments unless --force is given; library callers must make that check themselves.
let conf_a = parse_named_conf(input)?;
let text_b = write_named_conf(&conf_a, &WriteOptions::default());
let conf_b = parse_named_conf(&text_b)?;
// conf_a and conf_b are semantically equivalent
assert_eq!(conf_a.statements.len(), conf_b.statements.len());
In-place formatting¶
To reformat a file on disk:
use std::path::Path;
use hornet_bind9::{parse_named_conf_file, write_named_conf};
use hornet_bind9::writer::WriteOptions;
fn fmt_file(path: &Path) -> hornet_bind9::Result<()> {
let conf = parse_named_conf_file(path)?;
let formatted = write_named_conf(&conf, &WriteOptions::default());
std::fs::write(path, &formatted)?;
Ok(())
}
Tip
Use the CLI's hornet fmt command for in-place formatting without writing Rust code.
See fmt.
Check-only mode¶
To verify formatting without writing:
let original = std::fs::read_to_string(path)?;
let conf = parse_named_conf_file(path)?;
let formatted = write_named_conf(&conf, &WriteOptions::default());
if formatted != original {
eprintln!("File would be reformatted: {}", path.display());
std::process::exit(1);
}
Next Steps¶
- Validating — Check configs for semantic errors before writing
- WriteOptions Reference — Full field documentation
- Threat model: Why the escaping contract exists
- fmt CLI — In-place formatting via the CLI
- convert CLI — Keyword migration via the CLI