Adopt mature Git config and DNS codecs
This commit is contained in:
parent
d08d931b5f
commit
bda342890f
4 changed files with 634 additions and 122 deletions
|
|
@ -8,6 +8,7 @@ license.workspace = true
|
|||
[dependencies]
|
||||
bstr.workspace = true
|
||||
fuser.workspace = true
|
||||
gix-config.workspace = true
|
||||
gix-ignore.workspace = true
|
||||
libc.workspace = true
|
||||
nix.workspace = true
|
||||
|
|
|
|||
|
|
@ -330,8 +330,10 @@ impl RepositoryPolicy {
|
|||
gix_ignore::search::Ignore::default(),
|
||||
);
|
||||
}
|
||||
let ignore_case = read_external_optional(&metadata.config)?
|
||||
.is_some_and(|bytes| config_boolean(&bytes, b"core", b"ignorecase"));
|
||||
let ignore_case = match read_external_optional(&metadata.config)? {
|
||||
Some(bytes) => config_boolean(&bytes, "core", "ignoreCase")?,
|
||||
None => false,
|
||||
};
|
||||
let case = if ignore_case {
|
||||
gix_ignore::glob::pattern::Case::Fold
|
||||
} else {
|
||||
|
|
@ -414,7 +416,7 @@ fn repository_metadata(
|
|||
let value = bytes
|
||||
.strip_prefix(b"gitdir:")
|
||||
.ok_or_else(|| io::Error::other("worktree Git pointer has no gitdir prefix"))?;
|
||||
let value = trim_ascii(value);
|
||||
let value = value.trim_ascii();
|
||||
let path = PathBuf::from(OsString::from_vec(value.to_vec()));
|
||||
if path.is_absolute() {
|
||||
path
|
||||
|
|
@ -429,7 +431,7 @@ fn repository_metadata(
|
|||
let git_root = BackingRoot::open(&git_dir)?;
|
||||
let common_dir = match git_root.read_file(Path::new("commondir"), 64 * 1024) {
|
||||
Ok(bytes) => {
|
||||
let path = PathBuf::from(OsString::from_vec(trim_ascii(&bytes).to_vec()));
|
||||
let path = PathBuf::from(OsString::from_vec(bytes.trim_ascii().to_vec()));
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
|
|
@ -691,38 +693,18 @@ fn index_overflow() -> io::Error {
|
|||
io::Error::other("Git index offset is out of range")
|
||||
}
|
||||
|
||||
fn config_boolean(bytes: &[u8], section: &[u8], key: &[u8]) -> bool {
|
||||
let mut current = Vec::new();
|
||||
for line in bytes.split(|byte| *byte == b'\n') {
|
||||
let line = trim_ascii(line);
|
||||
if line.starts_with(b"[") && line.ends_with(b"]") {
|
||||
current = line[1..line.len() - 1]
|
||||
.split(u8::is_ascii_whitespace)
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
} else if current == section {
|
||||
let mut fields = line.splitn(2, |byte| *byte == b'=');
|
||||
let name = trim_ascii(fields.next().unwrap_or_default()).to_ascii_lowercase();
|
||||
let value = trim_ascii(fields.next().unwrap_or(b"true"));
|
||||
if name == key {
|
||||
return value.eq_ignore_ascii_case(b"true")
|
||||
|| value.eq_ignore_ascii_case(b"yes")
|
||||
|| value == b"1";
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn trim_ascii(mut bytes: &[u8]) -> &[u8] {
|
||||
while bytes.first().is_some_and(u8::is_ascii_whitespace) {
|
||||
bytes = &bytes[1..];
|
||||
}
|
||||
while bytes.last().is_some_and(u8::is_ascii_whitespace) {
|
||||
bytes = &bytes[..bytes.len() - 1];
|
||||
}
|
||||
bytes
|
||||
fn config_boolean(bytes: &[u8], section: &str, key: &str) -> io::Result<bool> {
|
||||
let configuration = gix_config::File::from_bytes_no_includes(
|
||||
bytes,
|
||||
gix_config::file::Metadata::api(),
|
||||
gix_config::file::init::Options::default(),
|
||||
)
|
||||
.map_err(|error| io::Error::other(format!("Git configuration is malformed: {error}")))?;
|
||||
configuration
|
||||
.boolean_by(section, None, key)
|
||||
.transpose()
|
||||
.map(|value| value.unwrap_or(false))
|
||||
.map_err(|error| io::Error::other(format!("Git configuration boolean is invalid: {error}")))
|
||||
}
|
||||
|
||||
fn input_stamp(path: &Path) -> io::Result<Option<InputStamp>> {
|
||||
|
|
@ -782,7 +764,7 @@ mod tests {
|
|||
use std::fs;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use super::{Classification, GitClassifier, parse_index_paths};
|
||||
use super::{Classification, GitClassifier, config_boolean, parse_index_paths};
|
||||
|
||||
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
|
|
@ -802,6 +784,34 @@ mod tests {
|
|||
assert!(parse_index_paths(&truncated).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_configuration_boolean_uses_git_casing_and_last_value_semantics() {
|
||||
assert!(config_boolean(b"[CoRe]\nIgNoReCaSe = on\n", "core", "ignoreCase").unwrap());
|
||||
assert!(
|
||||
!config_boolean(
|
||||
b"[core]\nignoreCase = true\nignoreCase = false\n",
|
||||
"core",
|
||||
"ignoreCase"
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(config_boolean(b"[core]\nignoreCase = perhaps\n", "core", "ignoreCase").is_err());
|
||||
assert!(!config_boolean(b"[core]\n", "core", "ignoreCase").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_with_invalid_ignore_case_is_refused() {
|
||||
let fixture = Fixture::new("invalid-ignore-case");
|
||||
repository(&fixture.workspace, &[]);
|
||||
fs::write(
|
||||
fixture.workspace.join(".git/config"),
|
||||
b"[core]\nignoreCase = perhaps\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(GitClassifier::open(&fixture.workspace, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_without_an_index_has_no_tracked_paths() {
|
||||
let fixture = Fixture::new("absent-index");
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ name = "lazaret-runtime-supervisor"
|
|||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
hickory-proto.workspace = true
|
||||
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
||||
lazaret-ignored-workspace = { version = "=0.0.0", path = "../lazaret-ignored-workspace" }
|
||||
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ use std::os::fd::{AsFd, AsRawFd, OwnedFd};
|
|||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use hickory_proto::{
|
||||
op::{Message, MessageType, OpCode, Query, ResponseCode},
|
||||
rr::{DNSClass, Name, RData, RecordType},
|
||||
};
|
||||
use lazaret_core::codec::decode;
|
||||
use lazaret_core::identity::{
|
||||
ConfigurationFingerprint, DerivedPolicyDigest, DomainId, GenerationId, NamespaceIdentity,
|
||||
|
|
@ -328,7 +332,7 @@ fn dns_probe(target: &DnsProbeTarget, transaction_id: u16) -> io::Result<DnsObse
|
|||
"DNS probe response source mismatched resolver",
|
||||
));
|
||||
}
|
||||
let resolved_addresses = dns_answers(&response[..length], transaction_id)?;
|
||||
let resolved_addresses = dns_answers(&response[..length], transaction_id, &target.query_name)?;
|
||||
Ok(DnsObservation {
|
||||
resolver: target.resolver,
|
||||
query_name: target.query_name.clone(),
|
||||
|
|
@ -337,13 +341,25 @@ fn dns_probe(target: &DnsProbeTarget, transaction_id: u16) -> io::Result<DnsObse
|
|||
}
|
||||
|
||||
fn dns_query(transaction_id: u16, name: &str) -> io::Result<Vec<u8>> {
|
||||
let name = dns_name(name)?;
|
||||
let mut query = Message::new(transaction_id, MessageType::Query, OpCode::Query);
|
||||
query.metadata.recursion_desired = true;
|
||||
query.add_query(Query::query(name, RecordType::A));
|
||||
let bytes = query
|
||||
.to_vec()
|
||||
.map_err(|error| io::Error::other(format!("DNS query encoding failed: {error}")))?;
|
||||
if bytes.len() > MAX_DNS_PACKET {
|
||||
return Err(io::Error::other("DNS query exceeds packet bound"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn dns_name(name: &str) -> io::Result<Name> {
|
||||
if name.is_empty() || name.len() > 253 || !name.is_ascii() {
|
||||
return Err(io::Error::other("DNS probe name is invalid"));
|
||||
}
|
||||
let mut query = Vec::with_capacity(name.len() + 18);
|
||||
query.extend_from_slice(&transaction_id.to_be_bytes());
|
||||
query.extend_from_slice(&[0x01, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
|
||||
for label in name.trim_end_matches('.').split('.') {
|
||||
let labels = name.trim_end_matches('.');
|
||||
for label in labels.split('.') {
|
||||
if label.is_empty()
|
||||
|| label.len() > 63
|
||||
|| !label
|
||||
|
|
@ -352,87 +368,62 @@ fn dns_query(transaction_id: u16, name: &str) -> io::Result<Vec<u8>> {
|
|||
{
|
||||
return Err(io::Error::other("DNS probe label is invalid"));
|
||||
}
|
||||
query.push(
|
||||
u8::try_from(label.len()).map_err(|_| io::Error::other("DNS label is oversized"))?,
|
||||
);
|
||||
query.extend_from_slice(label.as_bytes());
|
||||
}
|
||||
query.extend_from_slice(&[0, 0, 1, 0, 1]);
|
||||
Ok(query)
|
||||
let mut name = Name::from_ascii(labels)
|
||||
.map_err(|error| io::Error::other(format!("DNS probe name is invalid: {error}")))?;
|
||||
name.set_fqdn(true);
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
fn dns_answers(response: &[u8], transaction_id: u16) -> io::Result<Vec<IpAddress>> {
|
||||
if response.len() < 12
|
||||
|| response[..2] != transaction_id.to_be_bytes()
|
||||
|| response[2] & 0x80 == 0
|
||||
|| response[3] & 0x0f != 0
|
||||
|| u16::from_be_bytes([response[4], response[5]]) != 1
|
||||
fn dns_answers(
|
||||
response: &[u8],
|
||||
transaction_id: u16,
|
||||
expected_name: &str,
|
||||
) -> io::Result<Vec<IpAddress>> {
|
||||
if response.len() > MAX_DNS_PACKET {
|
||||
return Err(io::Error::other("DNS response exceeds packet bound"));
|
||||
}
|
||||
let expected_name = dns_name(expected_name)?;
|
||||
let message = Message::from_vec(response)
|
||||
.map_err(|error| io::Error::other(format!("DNS response decoding failed: {error}")))?;
|
||||
if message.metadata.id != transaction_id
|
||||
|| message.metadata.message_type != MessageType::Response
|
||||
|| message.metadata.op_code != OpCode::Query
|
||||
|| message.metadata.response_code != ResponseCode::NoError
|
||||
|| message.metadata.truncation
|
||||
{
|
||||
return Err(io::Error::other("DNS probe response header is invalid"));
|
||||
return Err(io::Error::other(
|
||||
"DNS response metadata does not match query",
|
||||
));
|
||||
}
|
||||
let answers = u16::from_be_bytes([response[6], response[7]]);
|
||||
let mut offset = skip_dns_name(response, 12)?;
|
||||
offset = offset
|
||||
.checked_add(4)
|
||||
.filter(|offset| *offset <= response.len())
|
||||
.ok_or_else(|| io::Error::other("DNS probe question is truncated"))?;
|
||||
let mut addresses = Vec::new();
|
||||
for _ in 0..answers {
|
||||
offset = skip_dns_name(response, offset)?;
|
||||
let header_end = offset
|
||||
.checked_add(10)
|
||||
.filter(|end| *end <= response.len())
|
||||
.ok_or_else(|| io::Error::other("DNS probe answer is truncated"))?;
|
||||
let record_type = u16::from_be_bytes([response[offset], response[offset + 1]]);
|
||||
let class = u16::from_be_bytes([response[offset + 2], response[offset + 3]]);
|
||||
let data_length = usize::from(u16::from_be_bytes([
|
||||
response[offset + 8],
|
||||
response[offset + 9],
|
||||
]));
|
||||
let data_end = header_end
|
||||
.checked_add(data_length)
|
||||
.filter(|end| *end <= response.len())
|
||||
.ok_or_else(|| io::Error::other("DNS probe record data is truncated"))?;
|
||||
if record_type == 1 && class == 1 && data_length == 4 {
|
||||
addresses.push(IpAddress::V4(
|
||||
response[header_end..data_end]
|
||||
.try_into()
|
||||
.map_err(|_| io::Error::other("DNS A record is malformed"))?,
|
||||
));
|
||||
}
|
||||
offset = data_end;
|
||||
let [question] = message.queries.as_slice() else {
|
||||
return Err(io::Error::other(
|
||||
"DNS response does not contain exactly one question",
|
||||
));
|
||||
};
|
||||
if question.name() != &expected_name
|
||||
|| question.query_type() != RecordType::A
|
||||
|| question.query_class() != DNSClass::IN
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"DNS response question does not match query",
|
||||
));
|
||||
}
|
||||
let addresses = message
|
||||
.answers
|
||||
.iter()
|
||||
.filter(|answer| answer.record_type() == RecordType::A && answer.dns_class == DNSClass::IN)
|
||||
.filter_map(|answer| match &answer.data {
|
||||
RData::A(address) => Some(IpAddress::V4(Ipv4Addr::from(*address).octets())),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if addresses.is_empty() {
|
||||
return Err(io::Error::other("DNS probe returned no IPv4 addresses"));
|
||||
}
|
||||
Ok(addresses)
|
||||
}
|
||||
|
||||
fn skip_dns_name(packet: &[u8], mut offset: usize) -> io::Result<usize> {
|
||||
loop {
|
||||
let length = *packet
|
||||
.get(offset)
|
||||
.ok_or_else(|| io::Error::other("DNS name is truncated"))?;
|
||||
offset += 1;
|
||||
if length == 0 {
|
||||
return Ok(offset);
|
||||
}
|
||||
if length & 0xc0 == 0xc0 {
|
||||
return offset
|
||||
.checked_add(1)
|
||||
.filter(|end| *end <= packet.len())
|
||||
.ok_or_else(|| io::Error::other("DNS compression pointer is truncated"));
|
||||
}
|
||||
if length & 0xc0 != 0 || length > 63 {
|
||||
return Err(io::Error::other("DNS name label is malformed"));
|
||||
}
|
||||
offset = offset
|
||||
.checked_add(usize::from(length))
|
||||
.filter(|end| *end <= packet.len())
|
||||
.ok_or_else(|| io::Error::other("DNS name label is truncated"))?;
|
||||
}
|
||||
}
|
||||
|
||||
fn read_sealed_policy(
|
||||
policy_file: &File,
|
||||
expected_digest: DerivedPolicyDigest,
|
||||
|
|
@ -660,6 +651,12 @@ fn ipv4_cidr(address: [u8; 4], prefix_length: u8) -> String {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use hickory_proto::rr::{
|
||||
Record,
|
||||
rdata::{A, CNAME},
|
||||
};
|
||||
use lazaret_core::network::derive_policy;
|
||||
use lazaret_core::records::{
|
||||
AddressFamily, AddressFamilyDisposition, AddressFamilyDispositionValue, DefaultAction,
|
||||
|
|
@ -687,13 +684,110 @@ mod tests {
|
|||
response[6..8].copy_from_slice(&1_u16.to_be_bytes());
|
||||
response.extend_from_slice(&[0xc0, 0x0c, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 93, 184, 216, 34]);
|
||||
assert_eq!(
|
||||
dns_answers(&response, transaction).unwrap(),
|
||||
dns_answers(&response, transaction, "example.com").unwrap(),
|
||||
vec![IpAddress::V4([93, 184, 216, 34])]
|
||||
);
|
||||
assert!(dns_answers(&response, transaction + 1).is_err());
|
||||
assert!(dns_answers(&response, transaction + 1, "example.com").is_err());
|
||||
assert!(dns_query(transaction, "bad..name").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dns_codec_refuses_changed_metadata_and_question() {
|
||||
assert!(
|
||||
dns_answers(
|
||||
&dns_response(|message| message.metadata.message_type = MessageType::Query),
|
||||
17,
|
||||
"example.com"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
dns_answers(
|
||||
&dns_response(|message| message.metadata.op_code = OpCode::Update),
|
||||
17,
|
||||
"example.com"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
dns_answers(
|
||||
&dns_response(|message| {
|
||||
message.metadata.response_code = ResponseCode::ServFail;
|
||||
}),
|
||||
17,
|
||||
"example.com"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
dns_answers(
|
||||
&dns_response(|message| message.metadata.truncation = true),
|
||||
17,
|
||||
"example.com"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
dns_answers(
|
||||
&dns_response(|message| {
|
||||
message.queries[0].set_name(Name::from_ascii("other.example").unwrap());
|
||||
}),
|
||||
17,
|
||||
"example.com"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
dns_answers(
|
||||
&dns_response(|message| {
|
||||
message.queries[0].set_query_type(RecordType::AAAA);
|
||||
}),
|
||||
17,
|
||||
"example.com"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
dns_answers(
|
||||
&dns_response(|message| {
|
||||
message.queries[0].set_query_class(DNSClass::CH);
|
||||
}),
|
||||
17,
|
||||
"example.com"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dns_codec_accepts_terminal_a_after_cname_and_refuses_malformed_packets() {
|
||||
let canonical = Name::from_ascii("canonical.example").unwrap();
|
||||
let response = dns_response(|message| {
|
||||
message.answers.clear();
|
||||
message.add_answer(Record::from_rdata(
|
||||
Name::from_ascii("example.com").unwrap(),
|
||||
60,
|
||||
RData::CNAME(CNAME(canonical.clone())),
|
||||
));
|
||||
message.add_answer(Record::from_rdata(
|
||||
canonical,
|
||||
60,
|
||||
RData::A(A(Ipv4Addr::new(192, 0, 2, 2))),
|
||||
));
|
||||
});
|
||||
assert_eq!(
|
||||
dns_answers(&response, 17, "example.com").unwrap(),
|
||||
vec![IpAddress::V4([192, 0, 2, 2])]
|
||||
);
|
||||
|
||||
let mut invalid_pointer = dns_response(|_| {});
|
||||
let answer_offset = dns_query(17, "example.com").unwrap().len();
|
||||
invalid_pointer[answer_offset] = 0xff;
|
||||
invalid_pointer[answer_offset + 1] = 0xff;
|
||||
assert!(dns_answers(&invalid_pointer, 17, "example.com").is_err());
|
||||
assert!(dns_answers(&vec![0; MAX_DNS_PACKET + 1], 17, "example.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_refuses_a_changed_enforcement_namespace_before_network_io() {
|
||||
let mut namespace = namespace_identity(Path::new("/proc/self/ns/net")).unwrap();
|
||||
|
|
@ -732,6 +826,21 @@ mod tests {
|
|||
assert_eq!(u32::from_le_bytes(mask.to_be_bytes()), 0x0000_00ff);
|
||||
}
|
||||
|
||||
fn dns_response(change: impl FnOnce(&mut Message)) -> Vec<u8> {
|
||||
let name = Name::from_ascii("example.com").unwrap();
|
||||
let mut response = Message::new(17, MessageType::Response, OpCode::Query);
|
||||
response.metadata.recursion_desired = true;
|
||||
response.metadata.recursion_available = true;
|
||||
response.add_query(Query::query(name.clone(), RecordType::A));
|
||||
response.add_answer(Record::from_rdata(
|
||||
name,
|
||||
60,
|
||||
RData::A(A(Ipv4Addr::new(93, 184, 216, 34))),
|
||||
));
|
||||
change(&mut response);
|
||||
response.to_vec().unwrap()
|
||||
}
|
||||
|
||||
fn policy() -> CanonicalDerivedNetworkPolicy {
|
||||
derive_policy(DerivedNetworkPolicyFields {
|
||||
address_families: vec![
|
||||
|
|
|
|||
Loading…
Reference in a new issue