ufwctl 0.1.0

Linux-only Rust library for managing UFW firewall rules
Documentation
use std::process::Command;

use crate::error::UfwError;

pub(crate) struct CommandRunner;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DeleteOutcome {
    Deleted,
    NotFound,
}

impl CommandRunner {
    pub(crate) fn run(program: &str, args: &[&str]) -> Result<(), UfwError> {
        let output = Command::new(program).args(args).output()?;

        if output.status.success() {
            Ok(())
        } else {
            let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
            Err(UfwError::CommandFailed {
                program: program.to_string(),
                args: args.iter().map(|a| a.to_string()).collect(),
                stderr,
                code: output.status.code(),
            })
        }
    }

    pub(crate) fn output(program: &str, args: &[&str]) -> Result<String, UfwError> {
        let output = Command::new(program).args(args).output()?;

        if output.status.success() {
            let stdout = String::from_utf8(output.stdout)?;
            Ok(stdout)
        } else {
            let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
            Err(UfwError::CommandFailed {
                program: program.to_string(),
                args: args.iter().map(|a| a.to_string()).collect(),
                stderr,
                code: output.status.code(),
            })
        }
    }

    pub(crate) fn delete(program: &str, args: &[&str]) -> Result<DeleteOutcome, UfwError> {
        let output = Command::new(program).args(args).output()?;
        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);

        match classify_delete_output(&stdout, &stderr, output.status.success()) {
            Ok(outcome) => Ok(outcome),
            Err(_) => Err(UfwError::CommandFailed {
                program: program.to_string(),
                args: args.iter().map(|a| a.to_string()).collect(),
                stderr: stderr.into_owned(),
                code: output.status.code(),
            }),
        }
    }
}

pub(crate) fn classify_delete_output(
    stdout: &str,
    stderr: &str,
    success: bool,
) -> Result<DeleteOutcome, UfwError> {
    let deleted = stdout.contains("Rule deleted") || stderr.contains("Rule deleted");
    let not_found = stdout.contains("Could not delete non-existent rule")
        || stderr.contains("Could not delete non-existent rule");

    if deleted {
        Ok(DeleteOutcome::Deleted)
    } else if not_found {
        Ok(DeleteOutcome::NotFound)
    } else if success {
        Err(UfwError::ParseError(
            "delete command succeeded but output did not contain expected message".into(),
        ))
    } else {
        Err(UfwError::ParseError(format!(
            "delete command failed with unexpected output: {stderr}"
        )))
    }
}

pub(crate) fn combine_delete_outcomes(
    outcomes: impl IntoIterator<Item = Result<DeleteOutcome, UfwError>>,
) -> Result<bool, UfwError> {
    let mut any_deleted = false;
    let mut first_error: Option<UfwError> = None;

    for outcome in outcomes {
        match outcome {
            Ok(DeleteOutcome::Deleted) => any_deleted = true,
            Ok(DeleteOutcome::NotFound) => {}
            Err(e) => {
                if first_error.is_none() {
                    first_error = Some(e);
                }
            }
        }
    }

    if let Some(err) = first_error {
        return Err(err);
    }

    Ok(any_deleted)
}

#[cfg(test)]
mod tests {
    use super::*;

    // -----------------------------------------------------------------------
    // classify_delete_output
    // -----------------------------------------------------------------------

    #[test]
    fn classify_stdout_rule_deleted() {
        assert_eq!(
            classify_delete_output("Rule deleted\n", "", true).expect("parsed"),
            DeleteOutcome::Deleted,
        );
    }

    #[test]
    fn classify_stdout_rule_deleted_v6() {
        assert_eq!(
            classify_delete_output("Rule deleted\nRule deleted (v6)\n", "", true).expect("parsed"),
            DeleteOutcome::Deleted,
        );
    }

    #[test]
    fn classify_stderr_not_found() {
        assert_eq!(
            classify_delete_output("", "Could not delete non-existent rule\n", false)
                .expect("parsed"),
            DeleteOutcome::NotFound,
        );
    }

    #[test]
    fn classify_stdout_not_found_v6() {
        assert_eq!(
            classify_delete_output(
                "Could not delete non-existent rule\nCould not delete non-existent rule (v6)\n",
                "",
                false,
            )
            .expect("parsed"),
            DeleteOutcome::NotFound,
        );
    }

    #[test]
    fn classify_mixed_deleted_and_not_found() {
        // Both deleted and not-found messages — deleted wins
        assert_eq!(
            classify_delete_output(
                "Rule deleted\nCould not delete non-existent rule\n",
                "",
                false,
            )
            .expect("parsed"),
            DeleteOutcome::Deleted,
        );
    }

    #[test]
    fn classify_unexpected_failure() {
        let result = classify_delete_output("", "some random error\n", false);
        assert!(result.is_err());
    }

    #[test]
    fn classify_success_without_known_message() {
        let result = classify_delete_output("OK\n", "", true);
        assert!(result.is_err());
    }

    // -----------------------------------------------------------------------
    // combine_delete_outcomes
    // -----------------------------------------------------------------------

    #[test]
    fn combine_all_deleted() {
        let outcomes = vec![Ok(DeleteOutcome::Deleted)];
        assert!(combine_delete_outcomes(outcomes).expect("ok"));
    }

    #[test]
    fn combine_all_not_found() {
        let outcomes = vec![Ok(DeleteOutcome::NotFound)];
        assert!(!combine_delete_outcomes(outcomes).expect("ok"));
    }

    #[test]
    fn combine_multiple_not_found() {
        let outcomes = vec![Ok(DeleteOutcome::NotFound), Ok(DeleteOutcome::NotFound)];
        assert!(!combine_delete_outcomes(outcomes).expect("ok"));
    }

    #[test]
    fn combine_not_found_then_deleted() {
        let outcomes = vec![Ok(DeleteOutcome::NotFound), Ok(DeleteOutcome::Deleted)];
        assert!(combine_delete_outcomes(outcomes).expect("ok"));
    }

    #[test]
    fn combine_deleted_then_not_found() {
        let outcomes = vec![Ok(DeleteOutcome::Deleted), Ok(DeleteOutcome::NotFound)];
        assert!(combine_delete_outcomes(outcomes).expect("ok"));
    }

    #[test]
    fn combine_error_only() {
        let err = UfwError::Io(std::io::Error::other("boom"));
        let outcomes: Vec<Result<DeleteOutcome, UfwError>> = vec![Err(err)];
        let result = combine_delete_outcomes(outcomes);
        assert!(result.is_err());
    }

    #[test]
    fn combine_not_found_and_error() {
        let err = UfwError::Io(std::io::Error::other("boom"));
        let outcomes = vec![Ok(DeleteOutcome::NotFound), Err(err)];
        let result = combine_delete_outcomes(outcomes);
        assert!(result.is_err());
    }

    #[test]
    fn combine_deleted_and_error() {
        let err = UfwError::Io(std::io::Error::other("boom"));
        let outcomes = vec![Ok(DeleteOutcome::Deleted), Err(err)];
        let result = combine_delete_outcomes(outcomes);
        assert!(result.is_err());
    }

    #[test]
    fn combine_empty_iterator() {
        let outcomes: Vec<Result<DeleteOutcome, UfwError>> = vec![];
        assert!(!combine_delete_outcomes(outcomes).expect("ok"));
    }
}