#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct TftpMessage {
pub opcode: TftpOpcode,
pub filename: Option<String>,
pub mode: Option<TftpMode>,
pub block: Option<u16>,
pub data_len: Option<usize>,
pub error_code: Option<TftpErrorCode>,
pub error_message: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(
feature = "serde",
serde(tag = "kind", content = "value", rename_all = "snake_case")
)]
#[non_exhaustive]
pub enum TftpOpcode {
ReadRequest,
WriteRequest,
Data,
Ack,
Error,
Other(u16),
}
impl TftpOpcode {
pub fn as_str(self) -> &'static str {
match self {
TftpOpcode::ReadRequest => "rrq",
TftpOpcode::WriteRequest => "wrq",
TftpOpcode::Data => "data",
TftpOpcode::Ack => "ack",
TftpOpcode::Error => "error",
TftpOpcode::Other(_) => "other",
}
}
pub fn from_raw(v: u16) -> Self {
match v {
1 => TftpOpcode::ReadRequest,
2 => TftpOpcode::WriteRequest,
3 => TftpOpcode::Data,
4 => TftpOpcode::Ack,
5 => TftpOpcode::Error,
other => TftpOpcode::Other(other),
}
}
pub fn as_u16(self) -> u16 {
match self {
TftpOpcode::ReadRequest => 1,
TftpOpcode::WriteRequest => 2,
TftpOpcode::Data => 3,
TftpOpcode::Ack => 4,
TftpOpcode::Error => 5,
TftpOpcode::Other(n) => n,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
#[non_exhaustive]
pub enum TftpMode {
NetAscii,
Octet,
Mail,
Other,
}
impl TftpMode {
pub fn parse_str(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"netascii" => TftpMode::NetAscii,
"octet" => TftpMode::Octet,
"mail" => TftpMode::Mail,
_ => TftpMode::Other,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(
feature = "serde",
serde(tag = "kind", content = "value", rename_all = "snake_case")
)]
#[non_exhaustive]
pub enum TftpErrorCode {
NotDefined,
FileNotFound,
AccessViolation,
DiskFull,
IllegalOperation,
UnknownTransferId,
FileAlreadyExists,
NoSuchUser,
Other(u16),
}
impl TftpErrorCode {
pub fn from_raw(v: u16) -> Self {
match v {
0 => TftpErrorCode::NotDefined,
1 => TftpErrorCode::FileNotFound,
2 => TftpErrorCode::AccessViolation,
3 => TftpErrorCode::DiskFull,
4 => TftpErrorCode::IllegalOperation,
5 => TftpErrorCode::UnknownTransferId,
6 => TftpErrorCode::FileAlreadyExists,
7 => TftpErrorCode::NoSuchUser,
other => TftpErrorCode::Other(other),
}
}
pub fn as_u16(self) -> u16 {
match self {
TftpErrorCode::NotDefined => 0,
TftpErrorCode::FileNotFound => 1,
TftpErrorCode::AccessViolation => 2,
TftpErrorCode::DiskFull => 3,
TftpErrorCode::IllegalOperation => 4,
TftpErrorCode::UnknownTransferId => 5,
TftpErrorCode::FileAlreadyExists => 6,
TftpErrorCode::NoSuchUser => 7,
TftpErrorCode::Other(n) => n,
}
}
}
impl TftpMessage {
pub fn is_device_config_transfer(&self) -> bool {
let Some(name) = self.filename.as_deref() else {
return false;
};
if !matches!(
self.opcode,
TftpOpcode::ReadRequest | TftpOpcode::WriteRequest
) {
return false;
}
let lower = name.to_ascii_lowercase();
lower.contains("running-config")
|| lower.contains("startup-config")
|| lower == "confg"
|| lower.ends_with(".cfg")
|| lower.ends_with(".conf")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opcode_round_trip() {
for n in 1..=5u16 {
assert_eq!(TftpOpcode::from_raw(n).as_u16(), n);
}
assert_eq!(TftpOpcode::from_raw(99), TftpOpcode::Other(99));
assert_eq!(TftpOpcode::Other(99).as_u16(), 99);
}
#[test]
fn opcode_slugs_locked() {
assert_eq!(TftpOpcode::ReadRequest.as_str(), "rrq");
assert_eq!(TftpOpcode::WriteRequest.as_str(), "wrq");
assert_eq!(TftpOpcode::Data.as_str(), "data");
assert_eq!(TftpOpcode::Ack.as_str(), "ack");
assert_eq!(TftpOpcode::Error.as_str(), "error");
}
#[test]
fn mode_case_insensitive() {
assert_eq!(TftpMode::parse_str("octet"), TftpMode::Octet);
assert_eq!(TftpMode::parse_str("OCTET"), TftpMode::Octet);
assert_eq!(TftpMode::parse_str("NetAscii"), TftpMode::NetAscii);
assert_eq!(TftpMode::parse_str("mail"), TftpMode::Mail);
assert_eq!(TftpMode::parse_str("anything-else"), TftpMode::Other);
}
#[test]
fn error_code_round_trip() {
for n in 0..=7u16 {
assert_eq!(TftpErrorCode::from_raw(n).as_u16(), n);
}
assert_eq!(TftpErrorCode::from_raw(50), TftpErrorCode::Other(50));
}
fn req(op: TftpOpcode, file: &str) -> TftpMessage {
TftpMessage {
opcode: op,
filename: Some(file.into()),
mode: Some(TftpMode::Octet),
block: None,
data_len: None,
error_code: None,
error_message: None,
}
}
#[test]
fn device_config_transfer_detector_fires_on_cisco_shapes() {
assert!(req(TftpOpcode::ReadRequest, "running-config").is_device_config_transfer());
assert!(req(TftpOpcode::WriteRequest, "startup-config").is_device_config_transfer());
assert!(req(TftpOpcode::ReadRequest, "router1.cfg").is_device_config_transfer());
assert!(req(TftpOpcode::WriteRequest, "site.conf").is_device_config_transfer());
assert!(req(TftpOpcode::ReadRequest, "confg").is_device_config_transfer());
}
#[test]
fn device_config_transfer_detector_misses_non_config_files() {
assert!(!req(TftpOpcode::ReadRequest, "pxelinux.0").is_device_config_transfer());
assert!(!req(TftpOpcode::ReadRequest, "vmlinuz").is_device_config_transfer());
assert!(!req(TftpOpcode::ReadRequest, "firmware.bin").is_device_config_transfer());
}
#[test]
fn device_config_transfer_only_for_rrq_or_wrq() {
let mut m = req(TftpOpcode::ReadRequest, "running-config");
m.opcode = TftpOpcode::Data;
assert!(!m.is_device_config_transfer());
m.opcode = TftpOpcode::Ack;
assert!(!m.is_device_config_transfer());
}
}