Deconstructing SPF with Rust using Generics

As I mentioned in my previous article Deconstructing SPF Records with Rust. There is a case for using generics given the amount of overlap between the different mechanisms.

So in this article I will document how I transitioned from using unique structs for each mechanism, leading to less overall code and other benefits.

While I was considering how best to go about doing generics, I came across this very nice write up on dev.to entitled Rust Generic Types in Method Definitions. It’s certainly worth a read.

A Refresher on the issue

Let’s revisit some of the code from the previous article. Specifically we will just look at the struct for Include and Ip4:

Include

#[derive(Default, Debug, Clone)]
struct Include {
    qualifier: char,
    txt: String,
}

This is pretty straight forward. It holds a qualifier of char, and a txt of String. Other mechanisms in SPF use the exact same basic components. Specifically, A and MX. Redirect also uses the same components, with the qualifier always being + or simply blank. But I need to duplicate this struct definition for each mechanim.

Not only do I need to create the same structs, I also need to then impl the same functions on each struct.

impl Include {
    fn new(qualifier: char, txt: String) -> Self {
        Self { qualifier, txt }
    }
    fn as_mechanism(&self) -> String {
        // rebuild and return the string represensation of a include mechanism
        let mut txt = String::new();
        if self.qualifier != '+' {
            txt.push(self.qualifier);
        }
        txt.push_str("include:");
        txt.push_str(self.txt.as_str());
        txt
    }
    fn is_pass(&self) -> bool {
        if self.qualifier == '+' {
            true
        } else {
            false
        }
    }
    fn is_fail(&self) -> bool {
        if self.qualifier == '-' {
            true
        } else {
            false
        }
    }
    fn is_softfail(&self) -> bool {
        if self.qualifier == '~' {
            true
        } else {
            false
        }
    }
    fn is_neutral(&self) -> bool {
        if self.qualifier == '?' {
            true
        } else {
            false
        }
    }
}

This also applies to ip4 and ip6

Ip4

#[derive(Debug, Clone)]
struct Ip4 {
    qualifier: char,
    ip: IpNetwork,
}

impl Ip4 {
    fn new(qualifier: char, ip: IpNetwork) -> Self {
        Self { qualifier, ip }
    }
    fn as_string(&self) -> String {
        self.ip.to_string()
    }
    fn as_mechanism(&self) -> String {
        let mut ip4_string = String::new();
        if self.qualifier != '+' {
            ip4_string.push(self.qualifier);
        }
        ip4_string.push_str("ip4:");
        ip4_string.push_str(self.ip.to_string().as_str());
        ip4_string
    }
    fn as_ip(&self) -> IpNetwork {
        self.ip
    }
    fn is_pass(&self) -> bool {
        if self.qualifier == '+' {
            true
        } else {
            false
        }
    }
    fn is_fail(&self) -> bool {
        if self.qualifier == '-' {
            true
        } else {
            false
        }
    }
    fn is_softfail(&self) -> bool {
        if self.qualifier == '~' {
            true
        } else {
            false
        }
    }
    fn is_neutral(&self) -> bool {
        if self.qualifier == '?' {
            true
        } else {
            false
        }
    }
}

Doing things with Generics

My initial idea was to simply make a generic struct as follows.

#[derive(Debug, Clone)]
struct SpfMechanism<T> {
    qualifier: char,
    mechanism: T,
}

And then impliment the functions that would apply to all instance types of SpfMechanism. But as I sat thinking about it. I realised it wouldn’t work. Because the as_mechanism() function would need to be able to differentiate between a redirect, include, and so on. I needed to be able to use as_mechanism and create records similar to include:_spf.example.com, ip4:X.X.X.X, a, mx:mx-host.example.com and so on.

I realised I would need to actually have a unique type/kind for each mechanism, and so I added an enum; listing the kinds of mechanisms

#[derive(Debug, Clone)]
enum MechanismKind {
    Include,
    Redirect,
    A,
    MX,
    IpV4,
    IpV6,
    All,
}

And I updated the SpfMechanism to be

#[derive(Debug, Clone)]
struct SpfMechanism<T> {
    kind: MechanismKind,
    qualifier: char,
    mechanism: T,
}

Throw in a little magic so I can test the what Kind the SpfMechanism is:
Note: This was generated by my editor with a click of a button.

impl MechanismKind {
    /// Returns `true` if the mechanism_kind is [`Include`].
    fn is_include(&self) -> bool {
        matches!(self, Self::Include)
    }
    /// Returns `true` if the mechanism_kind is [`A`].
    fn is_a(&self) -> bool {
        matches!(self, Self::A)
    }

    /// Returns `true` if the mechanism_kind is [`MX`].
    fn is_mx(&self) -> bool {
        matches!(self, Self::MX)
    }

    /// Returns `true` if the mechanism_kind is [`IpV4`].
    fn is_ip_v4(&self) -> bool {
        matches!(self, Self::IpV4)
    }

    /// Returns `true` if the mechanism_kind is [`IpV6`].
    fn is_ip_v6(&self) -> bool {
        matches!(self, Self::IpV6)
    }

    /// Returns `true` if the mechanism_kind is [`All`].
    fn is_all(&self) -> bool {
        matches!(self, Self::All)
    }

    /// Returns `true` if the mechanism_kind is [`Redirect`].
    fn is_redirect(&self) -> bool {
        matches!(self, Self::Redirect)
    }
}

Next I wanted to implement the functions that would be applied to all SpfMechanism irrespective of the mechanism type, be it String or IpNetwork

All mechanism types support these functions.

impl<T> SpfMechanism<T> {
    fn new(kind: MechanismKind, qualifier: char, mechanism: T) -> Self {
        Self {
            kind,
            qualifier,
            mechanism,
        }
    }
    fn is_pass(&self) -> bool {
        self.qualifier == '+'
    }
    fn is_fail(&self) -> bool {
        self.qualifier == '-'
    }
    fn is_softfail(&self) -> bool {
        self.qualifier == '~'
    }
    fn is_neutral(&self) -> bool {
        self.qualifier == '?'
    }
    fn mechanism_prefix_from_kind(&self) -> String {
        let push_str = match self.kind {
            MechanismKind::Redirect => "redirect=",
            MechanismKind::Include => "include:",
            MechanismKind::A => "a:",   // requires modification
            MechanismKind::MX => "mx:", // requires modication
            MechanismKind::IpV4 => "ip4:",
            MechanismKind::IpV6 => "ip6:",
            MechanismKind::All => "",
        };
        push_str.to_string()
    }
}

The important thing to note here is that impl<T> is what makes these functions able to work no matter what <T> ultimately turns out to be.

Implementing SpfMechanism for redirect, include, a, and mx

These mechanism all share a common type of String for T but we do not need to recode the is_* functions. So we need to implement as_string() and as_mechanism() only at this time. You might noticed as you read through that as_mechanism() makes use of the mechanism_prefix_from_kind() function which is defined for the generic SpfMechanism

I also decided to implement new_* for each mechanism type. These are essentially aliases to SpfMechanism::new()

impl SpfMechanism<String> {
    fn new_include(qualifier: char, mechanism: String) -> Self {
        SpfMechanism::new(MechanismKind::Include, qualifier, mechanism)
    }
    fn new_redirect(qualifier: char, mechanism: String) -> Self {
        SpfMechanism::new(MechanismKind::Redirect, qualifier, mechanism)
    }
    fn new_all(qualifier: char, mechanism: String) -> Self {
        SpfMechanism::new(MechanismKind::All, qualifier, mechanism)
    }
    fn as_mechanism(&self) -> String {
        // rebuild and return the string representation of a include, redirect, a or mx mechanism
        let mut txt = String::new();
        if self.qualifier != '+' {
            txt.push(self.qualifier);
        } else {
            // Do nothing omitting '+'
        }
        if self.kind.is_all() {
            txt.push_str("all")
        } else {
            txt.push_str(self.mechanism_prefix_from_kind().as_str());
            txt.push_str(self.mechanism.as_str());
        }
        txt
    }
    fn as_string(&self) -> &String {
        &self.mechanism
    }
}

The real payoff here is that we have a single as_mechanism() and as_string() which works for all SpfMechanism where <T> is of type String. Those being redirect, include a, mx and all. Within as_mechanism(), I call mechanism_prefix_from_kind() to get the correct prefix string, be it include:, redirect= and so on.

Implementing SpfMechanism for Ip4 and Ip6

Ip4 and Ip6 are both of type IpNetwork the only difference being they are written as ip4:X.X.X.X and ip6:xxxx:xxxx:...

impl SpfMechanism<IpNetwork> {
    fn new_ip4(qualifier: char, mechanism: IpNetwork) -> Self {
        SpfMechanism::new(MechanismKind::IpV4, qualifier, mechanism)
    }
    fn new_ip6(qualifier: char, mechanism: IpNetwork) -> Self {
        SpfMechanism::new(MechanismKind::IpV6, qualifier, mechanism)
    }
    fn as_mechanism(&self) -> String {
        // rebuild and return the string represensation of a include, redirect mechanism
        let mut txt = String::new();
        if self.qualifier != '+' {
            txt.push(self.qualifier);
        } else {
            // Do nothing omitting '+'
        }
        txt.push_str(self.mechanism_prefix_from_kind().as_str());
        txt.push_str(self.mechanism.to_string().as_str());
        txt
    }
    fn as_string(&self) -> String {
        self.mechanism.to_string()
    }
}

In my previous article I didn’t actually implement the ip6: records. Now, using this more generic approach, by adding merely a few lines of code. I have been able to implement ip6

Test Output for _netblocks2.google.com

List of TXT records found for _netblocks2.google.com.
TXT Record 1:
v=spf1 ip6:2001:4860:4000::/36 ip6:2404:6800:4000::/36 ip6:2607:f8b0:4000::/36 ip6:2800:3f0:4000::/36 ip6:2a00:1450:4000::/36 ip6:2c0f:fb50:4000::/36 ~all

Decontructing SPF Record
Spf1 { source: "v=spf1 ip6:2001:4860:4000::/36 ip6:2404:6800:4000::/36 ip6:2607:f8b0:4000::/36 ip6:2800:3f0:4000::/36 ip6:2a00:1450:4000::/36 ip6:2c0f:fb50:4000::/36 ~all", include: None, redirect: None, is_redirected: false, a: None, mx: None, ip4: None, ip6: Some([SpfMechanism { kind: IpV6, qualifier: '+', mechanism: V6(Ipv6Network { addr: 2001:4860:4000::, prefix: 36 }) }, SpfMechanism { kind: IpV6, qualifier: '+', mechanism: V6(Ipv6Network { addr: 2404:6800:4000::, prefix: 36 }) }, SpfMechanism { kind: IpV6, qualifier: '+', mechanism: V6(Ipv6Network { addr: 2607:f8b0:4000::, prefix: 36 }) }, SpfMechanism { kind: IpV6, qualifier: '+', mechanism: V6(Ipv6Network { addr: 2800:3f0:4000::, prefix: 36 }) }, SpfMechanism { kind: IpV6, qualifier: '+', mechanism: V6(Ipv6Network { addr: 2a00:1450:4000::, prefix: 36 }) }, SpfMechanism { kind: IpV6, qualifier: '+', mechanism: V6(Ipv6Network { addr: 2c0f:fb50:4000::, prefix: 36 }) }]), all_qualifier: '~' }
SPF1: v=spf1 ip6:2001:4860:4000::/36 ip6:2404:6800:4000::/36 ip6:2607:f8b0:4000::/36 ip6:2800:3f0:4000::/36 ip6:2a00:1450:4000::/36 ip6:2c0f:fb50:4000::/36 ~all

There are no include elements
There are no ip4 networks
There are no ip4 spf records.
List of ip6 networks/hosts:
2001:4860:4000::/36
2404:6800:4000::/36
2607:f8b0:4000::/36
2800:3f0:4000::/36
2a00:1450:4000::/36
2c0f:fb50:4000::/36

List of ip6 mechanisms:
ip6:2001:4860:4000::/36
ip6:2404:6800:4000::/36
ip6:2607:f8b0:4000::/36
ip6:2800:3f0:4000::/36
ip6:2a00:1450:4000::/36
ip6:2c0f:fb50:4000::/36

Is a redirect: false

Code

The code for this aritcle can be found here

Final thoughts and thanks.

First, I would like to thank the people in the rust discord for answering my questions and making suggestions.

Also, this might not be the best solution. Exploring traits might be another way to go about getting things done.

Next steps?
I will consider breaking this single file up into seperate files to make the management easier. And learning to write actual tests. Always better to catch issues early.


See also