1
use std::{collections::HashSet, path::Path};
2

            
3
use anyhow::Context;
4
use nix::unistd::{Uid, User};
5
use serde::{Deserialize, Serialize};
6

            
7
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
8
pub enum IgnoreEntry {
9
    Uid(u32),
10
    User(String),
11
}
12

            
13
#[derive(Debug, Clone, Default, PartialEq, Eq)]
14
pub struct IgnoreList {
15
    entries: HashSet<IgnoreEntry>,
16
}
17

            
18
impl IgnoreList {
19
    pub fn load_optional(path: Option<&Path>) -> anyhow::Result<Option<Self>> {
20
        match path {
21
            Some(path) => Self::load(path).map(Some),
22
            None => Ok(None),
23
        }
24
    }
25

            
26
    pub fn load(path: &Path) -> anyhow::Result<Self> {
27
        let content = std::fs::read_to_string(path)
28
            .with_context(|| format!("Failed to read ignore list {}", path.display()))?;
29
        Self::parse(&content)
30
    }
31

            
32
2
    pub fn parse(content: &str) -> anyhow::Result<Self> {
33
2
        let mut entries = HashSet::new();
34

            
35
8
        for (idx, raw_line) in content.lines().enumerate() {
36
8
            let line = raw_line.split('#').next().unwrap_or("").trim();
37
8
            if line.is_empty() {
38
2
                continue;
39
6
            }
40

            
41
6
            let entry = if let Some(uid) = line.strip_prefix("uid:") {
42
2
                let uid = uid.trim().parse::<u32>().with_context(|| {
43
                    format!("Invalid uid on ignore list line {}: {}", idx + 1, raw_line)
44
                })?;
45
2
                IgnoreEntry::Uid(uid)
46
4
            } else if let Some(user) = line.strip_prefix("user:") {
47
4
                let user = user.trim();
48
4
                if user.is_empty() {
49
                    anyhow::bail!("Invalid user on ignore list line {}: {}", idx + 1, raw_line);
50
4
                }
51
4
                IgnoreEntry::User(user.to_string())
52
            } else {
53
                anyhow::bail!(
54
                    "Invalid ignore list entry on line {}: {}",
55
                    idx + 1,
56
                    raw_line
57
                );
58
            };
59

            
60
6
            entries.insert(entry);
61
        }
62

            
63
2
        Ok(Self { entries })
64
2
    }
65

            
66
4
    pub fn ignores_username(&self, username: &str) -> bool {
67
4
        if self
68
4
            .entries
69
4
            .contains(&IgnoreEntry::User(username.to_string()))
70
        {
71
4
            return true;
72
        }
73

            
74
        match User::from_name(username) {
75
            Ok(Some(user)) => self.entries.contains(&IgnoreEntry::Uid(user.uid.as_raw())),
76
            Ok(None) => false,
77
            Err(err) => {
78
                tracing::warn!(
79
                    "Failed to resolve username '{}' for ignore list lookup: {}",
80
                    username,
81
                    err
82
                );
83
                false
84
            }
85
        }
86
4
    }
87

            
88
2
    pub fn ignores_uid(&self, uid: u32) -> bool {
89
2
        if self.entries.contains(&IgnoreEntry::Uid(uid)) {
90
2
            return true;
91
        }
92

            
93
        match User::from_uid(Uid::from_raw(uid)) {
94
            Ok(Some(user)) => self.entries.contains(&IgnoreEntry::User(user.name)),
95
            Ok(None) => false,
96
            Err(err) => {
97
                tracing::warn!(
98
                    "Failed to resolve uid {} for ignore list lookup: {}",
99
                    uid,
100
                    err
101
                );
102
                false
103
            }
104
        }
105
2
    }
106
}
107

            
108
#[cfg(test)]
109
mod tests {
110
    use super::*;
111

            
112
    #[test]
113
1
    fn test_parse_ignore_list() {
114
1
        let list = IgnoreList::parse(
115
1
            &["uid:1000", "user:alice", "  user:bob  "].join("\n"), // "\n# comment\nuid:1000\nuser:alice # trailing comment\n\nuser:bob\n",
116
        )
117
1
        .unwrap();
118

            
119
1
        assert!(list.ignores_uid(1000));
120
1
        assert!(list.ignores_username("alice"));
121
1
        assert!(list.ignores_username("bob"));
122
1
    }
123

            
124
    #[test]
125
1
    fn test_parse_ignore_list_with_comments() {
126
1
        let list = IgnoreList::parse(
127
1
            &[
128
1
                "# This is a comment",
129
1
                "uid:1000",
130
1
                "user:alice # trailing comment",
131
1
                "",
132
1
                "user:bob",
133
1
            ]
134
1
            .join("\n"),
135
        )
136
1
        .unwrap();
137

            
138
1
        assert!(list.ignores_uid(1000));
139
1
        assert!(list.ignores_username("alice"));
140
1
        assert!(list.ignores_username("bob"));
141
1
    }
142
}