osdns 0.2.0

Safe, transactional control of operating-system DNS configuration
Documentation
use std::fmt;

use serde::{Deserialize, Serialize};

/// Identifies the DNS configuration backend in use.
///
/// The backend is selected by [`DnsManager`](crate::DnsManager) construction
/// from the component that actually owns DNS state on the host. See
/// [`Capabilities`] for what the active backend guarantees.
///
/// [`BackendKind`] is [`#[non_exhaustive]`](https://doc.rust-lang.org/reference/attributes/type_system.html)
/// so new backends can be added without a breaking change; match with a
/// wildcard arm.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "kebab-case")]
pub enum BackendKind {
    /// `systemd-resolved` via `org.freedesktop.resolve1`.
    SystemdResolved,
    /// NetworkManager via its D-Bus API.
    NetworkManager,
    /// The `resolvconf` or `openresolv` utility.
    Resolvconf,
    /// Direct manipulation of `/etc/resolv.conf`.
    ResolvConfFile,
    /// The Windows IP Helper API (`GetInterfaceDnsSettings` et al.).
    WindowsIpHelper,
    /// Apple SystemConfiguration.
    MacosSystemConfiguration,
    /// In-memory backend used by the `test-util` feature for tests.
    ///
    /// Never selected for real managers; construct it explicitly through
    /// [`FakeDns`](crate::testing::FakeDns) in tests.
    Fake,
}

impl BackendKind {
    /// Returns `true` for real operating-system backends and `false` for the
    /// in-memory test backend.
    pub fn is_real(&self) -> bool {
        *self != BackendKind::Fake
    }
}

impl fmt::Display for BackendKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let name = match self {
            BackendKind::SystemdResolved => "systemd-resolved",
            BackendKind::NetworkManager => "network-manager",
            BackendKind::Resolvconf => "resolvconf",
            BackendKind::ResolvConfFile => "resolv-conf-file",
            BackendKind::WindowsIpHelper => "windows-ip-helper",
            BackendKind::MacosSystemConfiguration => "macos-system-configuration",
            BackendKind::Fake => "fake",
        };
        f.write_str(name)
    }
}

/// Describes what the active backend can actually guarantee.
///
/// Returned by [`DnsManager::capabilities`](crate::DnsManager::capabilities).
/// Configuration is rejected with [`Error::Unsupported`](crate::Error) before
/// any mutation when the backend cannot represent it. Never assume two
/// backends behave identically: these fields exist precisely because they do
/// not.
///
/// Linux: systemd-resolved mutations are unconditional and best-effort.
/// NetworkManager can compare-and-mutate on applied-connection
/// `version_id` but Reapply does not return the resulting version, so
/// ownership identity is best-effort. resolvconf, `/etc/resolv.conf`,
/// Windows, and macOS are unconditional and best-effort. Check
/// [`Capabilities::mutation_guard`] and
/// [`Capabilities::ownership_identity`].
///
/// The struct is `#[non_exhaustive]`: construct with [`Capabilities::new`]
/// plus `with_*` builders, never with a literal.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Capabilities {
    /// The backend these capabilities describe.
    pub backend: BackendKind,
    /// Whether current DNS state can be read.
    pub read: bool,
    /// Whether global (system-wide) DNS can be configured.
    pub global_dns: bool,
    /// Whether per-interface DNS can be configured.
    pub per_interface_dns: bool,
    /// Whether search domains can be configured.
    pub search_domains: bool,
    /// Whether routing domains (split DNS) can be configured.
    pub split_dns: bool,
    /// Whether explicit default-route semantics (`default_route`) can be
    /// faithfully represented. When `false`, any config with
    /// `default_route.is_some()` is rejected with
    /// [`Error::Unsupported`](crate::Error::Unsupported)
    /// before mutation; backends must never silently ignore it.
    pub default_route: bool,
    /// Whether native change notifications are supported.
    pub watch: bool,
    /// Whether the OS DNS cache can be flushed (best-effort only).
    pub cache_flush: bool,
    /// How strongly this backend can condition a mutation on current state.
    pub mutation_guard: MutationGuard,
    /// Whether a successful mutation can name the resulting state.
    pub ownership_identity: OwnershipIdentity,
    /// How strongly an observed resource incarnation is bound to mutation.
    pub resource_binding: ResourceBinding,
}

impl Capabilities {
    /// Creates capabilities for `backend` with every capability disabled.
    pub fn new(backend: BackendKind) -> Self {
        Self {
            backend,
            read: false,
            global_dns: false,
            per_interface_dns: false,
            search_domains: false,
            split_dns: false,
            default_route: false,
            watch: false,
            cache_flush: false,
            mutation_guard: MutationGuard::Unconditional,
            ownership_identity: OwnershipIdentity::BestEffort,
            resource_binding: ResourceBinding::StableTarget,
        }
    }

    /// Sets [`Capabilities::read`].
    pub fn with_read(mut self, enabled: bool) -> Self {
        self.read = enabled;
        self
    }

    /// Sets [`Capabilities::global_dns`].
    pub fn with_global_dns(mut self, enabled: bool) -> Self {
        self.global_dns = enabled;
        self
    }

    /// Sets [`Capabilities::per_interface_dns`].
    pub fn with_per_interface_dns(mut self, enabled: bool) -> Self {
        self.per_interface_dns = enabled;
        self
    }

    /// Sets [`Capabilities::search_domains`].
    pub fn with_search_domains(mut self, enabled: bool) -> Self {
        self.search_domains = enabled;
        self
    }

    /// Sets [`Capabilities::split_dns`].
    pub fn with_split_dns(mut self, enabled: bool) -> Self {
        self.split_dns = enabled;
        self
    }

    /// Sets [`Capabilities::default_route`].
    pub fn with_default_route(mut self, enabled: bool) -> Self {
        self.default_route = enabled;
        self
    }

    /// Sets [`Capabilities::watch`].
    pub fn with_watch(mut self, enabled: bool) -> Self {
        self.watch = enabled;
        self
    }

    /// Sets [`Capabilities::cache_flush`].
    pub fn with_cache_flush(mut self, enabled: bool) -> Self {
        self.cache_flush = enabled;
        self
    }

    /// Sets [`Capabilities::mutation_guard`].
    pub fn with_mutation_guard(mut self, guard: MutationGuard) -> Self {
        self.mutation_guard = guard;
        self
    }

    /// Sets [`Capabilities::ownership_identity`].
    pub fn with_ownership_identity(mut self, identity: OwnershipIdentity) -> Self {
        self.ownership_identity = identity;
        self
    }

    /// Sets [`Capabilities::resource_binding`].
    pub fn with_resource_binding(mut self, binding: ResourceBinding) -> Self {
        self.resource_binding = binding;
        self
    }
}

/// Strength of the binding between a captured incarnation and mutation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub enum ResourceBinding {
    /// The mutation target itself has stable native identity.
    StableTarget,
    /// A native generation/object guard binds mutation to the observation.
    NativeGuarded,
    /// Identity is rechecked immediately before an unconditional native API;
    /// the platform still permits a final selector-reuse race.
    PreflightOnly,
}

/// How a backend conditions mutations on observed state.
///
/// Independent of [`OwnershipIdentity`]: a backend may refuse a write when
/// the expected generation does not match and still be unable to name the
/// state that write produced.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub enum MutationGuard {
    /// Native compare-and-mutate. A rejection means the backend did not
    /// mutate.
    CompareAndMutate,
    /// The platform has no atomic conditional mutation. Applies and restores
    /// are ordinary writes. A compare-then-write sequence can lose a race
    /// with another writer.
    Unconditional,
}

/// Whether a backend can name the state produced by a mutation.
///
/// [`OwnershipIdentity::Durable`] means later ownership checks use that
/// identity. [`OwnershipIdentity::BestEffort`] means they compare DNS values
/// only, which cannot tell an external rewrite of the same values apart from
/// our own write.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub enum OwnershipIdentity {
    /// Generation, version, or file identity issued with the mutation.
    Durable,
    /// No mutation identity. Restoration compares DNS values and then writes.
    BestEffort,
}