use std::collections::HashSet;
use kftray_commons::models::hostfile::HostEntry;
use kftray_commons::utils::hostsfile::{
HostsDocument,
SectionEntry,
edit_hosts,
read_hosts,
};
use log::debug;
pub const KFTRAY_HOSTS_TAG: &str = "kftray-hosts";
pub const KFTRAY_DIRECT_HOSTS_TAG: &str = "kftray-hosts-direct";
#[derive(Default)]
pub struct DirectHostfileManager;
impl DirectHostfileManager {
pub fn new() -> Self {
Self
}
pub fn add_host_entry(&self, id: String, entry: HostEntry) -> std::io::Result<()> {
debug!("Adding host entry for ID {id}: {entry:?}");
let owned = SectionEntry {
ip: entry.ip,
hostname: entry.hostname.clone(),
owner: Some(id.clone()),
};
edit_hosts(|document| {
document.reconcile_owners(KFTRAY_DIRECT_HOSTS_TAG, &[id.as_str()], &[owned])?;
Ok(())
})
.map_err(std::io::Error::from)
}
pub fn remove_host_entries(
&self, ids: &[&str], protected: &[HostEntry],
) -> std::io::Result<()> {
debug!("Removing host entries for IDs {ids:?}");
edit_hosts(|document| {
document.merge_duplicate_sections(KFTRAY_DIRECT_HOSTS_TAG)?;
let dropping = legacy_mappings_to_prune(
&document.section(KFTRAY_DIRECT_HOSTS_TAG)?,
ids,
protected,
);
document.reconcile_owners(KFTRAY_DIRECT_HOSTS_TAG, ids, &[])?;
prune_legacy(document, &dropping)?;
Ok(())
})
.map_err(std::io::Error::from)
}
pub fn prune_legacy_entries(
&self, mappings: &[(std::net::IpAddr, String)],
) -> std::io::Result<()> {
edit_hosts(|document| prune_legacy(document, mappings)).map_err(std::io::Error::from)
}
pub fn remove_owned_from_helper_section(&self, ids: &[&str]) -> std::io::Result<()> {
edit_hosts(|document| remove_owned_lines(document, ids)).map_err(std::io::Error::from)
}
pub fn direct_section() -> std::io::Result<Vec<SectionEntry>> {
read_hosts(|document| document.section_merging_duplicates(KFTRAY_DIRECT_HOSTS_TAG))
.map_err(std::io::Error::from)
}
pub fn helper_section() -> std::io::Result<Vec<SectionEntry>> {
read_hosts(|document| document.section_merging_duplicates(KFTRAY_HOSTS_TAG))
.map_err(std::io::Error::from)
}
pub fn list_host_entries(&self) -> std::io::Result<Vec<(String, HostEntry)>> {
Ok(
read_hosts(|document| document.section(KFTRAY_DIRECT_HOSTS_TAG))
.map_err(std::io::Error::from)?
.into_iter()
.filter_map(|entry| {
entry.owner.map(|owner| {
(
owner,
HostEntry {
ip: entry.ip,
hostname: entry.hostname,
},
)
})
})
.collect(),
)
}
pub fn stranded_in_helper_section<'a>(
section: &[SectionEntry], ids: &[&'a str], handed: &HashSet<(String, HostEntry)>,
) -> Vec<&'a str> {
ids.iter()
.copied()
.filter(|id| {
section.iter().any(|entry| match &entry.owner {
Some(owner) => owner == id,
None => handed.contains(&(
(*id).to_owned(),
HostEntry {
ip: entry.ip,
hostname: entry.hostname.clone(),
},
)),
})
})
.collect()
}
}
fn legacy_mappings_to_prune(
owned: &[SectionEntry], ids: &[&str], protected: &[HostEntry],
) -> Vec<(std::net::IpAddr, String)> {
owned
.iter()
.filter(|entry| {
entry
.owner
.as_deref()
.is_some_and(|owner| ids.contains(&owner))
})
.filter(|entry| {
!protected
.iter()
.any(|kept| kept.ip == entry.ip && kept.hostname == entry.hostname)
})
.map(|entry| (entry.ip, entry.hostname.clone()))
.collect()
}
fn prune_legacy(
document: &mut HostsDocument, mappings: &[(std::net::IpAddr, String)],
) -> kftray_commons::utils::hostsfile::Result<()> {
if mappings.is_empty() {
return Ok(());
}
document.retain(KFTRAY_HOSTS_TAG, |entry: &SectionEntry| {
entry.owner.is_some()
|| !mappings
.iter()
.any(|(ip, hostname)| entry.ip == *ip && entry.hostname == *hostname)
})
}
fn remove_owned_lines(
document: &mut HostsDocument, ids: &[&str],
) -> kftray_commons::utils::hostsfile::Result<()> {
document
.reconcile_owners(KFTRAY_HOSTS_TAG, ids, &[])
.map(|_| ())
}
#[cfg(test)]
mod tests {
use std::net::IpAddr;
use kftray_commons::utils::hostsfile::HostsFile;
use super::*;
fn addr(last: u8) -> IpAddr {
IpAddr::from([127, 0, 0, last])
}
#[test]
fn a_mapping_a_running_configuration_still_needs_is_not_pruned_on_anothers_behalf() {
let owned = vec![
SectionEntry {
ip: addr(1),
hostname: "shared.local".to_owned(),
owner: Some("7".to_owned()),
},
SectionEntry {
ip: addr(1),
hostname: "only-mine.local".to_owned(),
owner: Some("7".to_owned()),
},
];
let protected = [HostEntry {
ip: addr(1),
hostname: "shared.local".to_owned(),
}];
assert_eq!(
legacy_mappings_to_prune(&owned, &["7"], &protected),
vec![(addr(1), "only-mine.local".to_owned())]
);
}
#[test]
fn pruning_spares_lines_the_helper_attributed_to_another_configuration() {
let (_temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
let mut legacy = HostsFile::new(KFTRAY_HOSTS_TAG);
legacy
.add_entry(addr(1), "shared.local")
.unwrap()
.add_owned_entry(addr(1), "shared.local", "7")
.unwrap()
.add_entry(addr(1), "other.local")
.unwrap();
legacy.write_to(&temp_path).unwrap();
kftray_commons::utils::hostsfile::edit_hosts_at(&temp_path, |document| {
prune_legacy(document, &[(addr(1), "shared.local".to_owned())])
})
.unwrap();
let remaining: Vec<(String, Option<String>)> = HostsFile::new(KFTRAY_HOSTS_TAG)
.read_section_from(&temp_path)
.unwrap()
.into_iter()
.map(|entry| (entry.hostname, entry.owner))
.collect();
assert_eq!(
remaining,
vec![
("shared.local".to_owned(), Some("7".to_owned())),
("other.local".to_owned(), None),
],
"only the unmarked copy of the pruned alias goes; configuration 7's line and an \
unrelated alias stay"
);
}
#[test]
fn helper_lines_are_attributed_by_owner_or_by_what_was_handed_over() {
let section = vec![
SectionEntry {
ip: addr(1),
hostname: "marked.local".to_owned(),
owner: Some("1-https".to_owned()),
},
SectionEntry {
ip: addr(1),
hostname: "unmarked.local".to_owned(),
owner: None,
},
];
let handed = HashSet::from([(
"2-https".to_owned(),
HostEntry {
ip: addr(1),
hostname: "unmarked.local".to_owned(),
},
)]);
let stranded = DirectHostfileManager::stranded_in_helper_section(
§ion,
&["1-https", "2-https", "3-https"],
&handed,
);
assert_eq!(
stranded,
vec!["1-https", "2-https"],
"a marked line names its owner; an unmarked one counts only for the id it was \
handed over for; an id with neither is not stranded"
);
}
#[test]
fn no_helper_fallback_removes_only_the_stranded_ids_own_marked_line() {
let (_temp_file, temp_path) = tempfile::NamedTempFile::new().unwrap().into_parts();
let mut file = HostsFile::new(KFTRAY_HOSTS_TAG);
file.add_owned_entry(addr(1), "mine.local", "7")
.unwrap()
.add_owned_entry(addr(1), "theirs.local", "9")
.unwrap()
.add_entry(addr(1), "unmarked.local")
.unwrap();
file.write_to(&temp_path).unwrap();
kftray_commons::utils::hostsfile::edit_hosts_at(&temp_path, |document| {
remove_owned_lines(document, &["7"])
})
.unwrap();
let remaining: Vec<(String, Option<String>)> = HostsFile::new(KFTRAY_HOSTS_TAG)
.read_section_from(&temp_path)
.unwrap()
.into_iter()
.map(|entry| (entry.hostname, entry.owner))
.collect();
assert_eq!(
remaining,
vec![
("theirs.local".to_owned(), Some("9".to_owned())),
("unmarked.local".to_owned(), None),
],
"the no-helper fallback removes only the stranded id's own marked line; another \
configuration's line and an unmarked one stay, so a stop does not fail forever \
once the helper is gone"
);
}
}