Skip to main content

debian_analyzer/
maintscripts.rs

1//! Maintscript file parsing and generation
2use debversion::Version;
3
4#[derive(Debug, PartialEq, Eq)]
5/// An error that occurred while parsing a maintscript file
6pub enum ParseError {
7    /// An unknown maintscript command
8    UnknownCommand(String),
9    /// A maintscript command is missing an argument
10    MissingArgument(String),
11    /// An invalid version was encountered
12    InvalidVersion(debversion::ParseError),
13}
14
15impl std::fmt::Display for ParseError {
16    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
17        match self {
18            ParseError::UnknownCommand(command) => {
19                write!(f, "Unknown maintscript command: {}", command)
20            }
21            ParseError::MissingArgument(command) => {
22                write!(f, "Missing argument for maintscript command: {}", command)
23            }
24            ParseError::InvalidVersion(e) => write!(f, "Invalid version: {}", e),
25        }
26    }
27}
28
29impl std::error::Error for ParseError {}
30
31impl From<debversion::ParseError> for ParseError {
32    fn from(e: debversion::ParseError) -> Self {
33        ParseError::InvalidVersion(e)
34    }
35}
36
37#[derive(Debug, PartialEq, Eq, Clone)]
38/// An entry in a maintscript file
39pub enum Entry {
40    /// A command that is supported by the maintscript
41    Supports(String),
42    /// Remove a conffile
43    RemoveConffile {
44        /// The pathname of the conffile
45        conffile: String,
46        /// The version of the package that is being upgraded
47        prior_version: Option<Version>,
48        /// The name of the package that is being upgraded
49        package: Option<String>,
50    },
51    /// Move a conffile
52    MoveConffile {
53        /// The old pathname of the conffile
54        old_conffile: String,
55        /// The new pathname of the conffile
56        new_conffile: String,
57        /// The version of the package that is being upgraded
58        prior_version: Option<Version>,
59        /// The name of the package that is being upgraded
60        package: Option<String>,
61    },
62    /// Convert a symlink to a directory
63    SymlinkToDir {
64        /// The pathname of the symlink
65        pathname: String,
66        /// The old target of the symlink
67        old_target: String,
68        /// The version of the package that is being upgraded
69        prior_version: Option<Version>,
70        /// The name of the package that is being upgraded
71        package: Option<String>,
72    },
73    /// Convert a directory to a symlink
74    DirToSymlink {
75        /// The pathname of the directory
76        pathname: String,
77        /// The new target of the symlink
78        new_target: String,
79        /// The version of the package that is being upgraded
80        prior_version: Option<Version>,
81        /// The name of the package that is being upgraded
82        package: Option<String>,
83    },
84}
85
86impl Entry {
87    /// Get the arguments of the entry
88    fn args(&self) -> Vec<String> {
89        match self {
90            Entry::Supports(command) => vec!["supports".to_string(), command.to_string()],
91            Entry::RemoveConffile {
92                conffile,
93                prior_version,
94                package,
95            } => {
96                let mut ret = vec!["rm_conffile".to_string(), conffile.to_string()];
97                if let Some(prior_version) = prior_version.as_ref() {
98                    ret.push(prior_version.to_string());
99                    if let Some(package) = package.as_ref() {
100                        ret.push(package.to_string());
101                    }
102                }
103                ret
104            }
105            Entry::MoveConffile {
106                old_conffile,
107                new_conffile,
108                prior_version,
109                package,
110            } => {
111                let mut ret = vec![
112                    "mv_conffile".to_string(),
113                    old_conffile.to_string(),
114                    new_conffile.to_string(),
115                ];
116                if let Some(prior_version) = prior_version.as_ref() {
117                    ret.push(prior_version.to_string());
118                    if let Some(package) = package.as_ref() {
119                        ret.push(package.to_string());
120                    }
121                }
122                ret
123            }
124            Entry::SymlinkToDir {
125                pathname,
126                old_target,
127                prior_version,
128                package,
129            } => {
130                let mut ret = vec![
131                    "symlink_to_dir".to_string(),
132                    pathname.to_string(),
133                    old_target.to_string(),
134                ];
135                if let Some(prior_version) = prior_version.as_ref() {
136                    ret.push(prior_version.to_string());
137                    if let Some(package) = package.as_ref() {
138                        ret.push(package.to_string());
139                    }
140                }
141                ret
142            }
143            Entry::DirToSymlink {
144                pathname,
145                new_target,
146                prior_version,
147                package,
148            } => {
149                let mut ret = vec![
150                    "dir_to_symlink".to_string(),
151                    pathname.to_string(),
152                    new_target.to_string(),
153                ];
154                if let Some(prior_version) = prior_version.as_ref() {
155                    ret.push(prior_version.to_string());
156                    if let Some(package) = package.as_ref() {
157                        ret.push(package.to_string());
158                    }
159                }
160                ret
161            }
162        }
163    }
164
165    /// Get the name of the package that is being upgraded
166    pub fn package(&self) -> Option<&String> {
167        match self {
168            Entry::RemoveConffile { package, .. } => package.as_ref(),
169            Entry::MoveConffile { package, .. } => package.as_ref(),
170            Entry::SymlinkToDir { package, .. } => package.as_ref(),
171            Entry::DirToSymlink { package, .. } => package.as_ref(),
172            _ => None,
173        }
174    }
175
176    /// Get the version of the package that is being upgraded
177    pub fn prior_version(&self) -> Option<&Version> {
178        match self {
179            Entry::RemoveConffile { prior_version, .. } => prior_version.as_ref(),
180            Entry::MoveConffile { prior_version, .. } => prior_version.as_ref(),
181            Entry::SymlinkToDir { prior_version, .. } => prior_version.as_ref(),
182            Entry::DirToSymlink { prior_version, .. } => prior_version.as_ref(),
183            _ => None,
184        }
185    }
186}
187
188impl std::fmt::Display for Entry {
189    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
190        write!(f, "{}", self.args().join(" "))
191    }
192}
193
194impl std::str::FromStr for Entry {
195    type Err = ParseError;
196
197    fn from_str(s: &str) -> Result<Self, Self::Err> {
198        let args: Vec<&str> = s.split_whitespace().collect();
199        match args[0] {
200            "supports" => {
201                if args.len() != 2 {
202                    return Err(ParseError::MissingArgument("supports".to_string()));
203                }
204                Ok(Entry::Supports(args[1].to_string()))
205            }
206            "rm_conffile" => {
207                if args.len() < 2 {
208                    return Err(ParseError::MissingArgument("rm_conffile".to_string()));
209                }
210                let conffile = args[1].to_string();
211                let prior_version = if args.len() > 2 {
212                    Some(args[2].parse()?)
213                } else {
214                    None
215                };
216                let package = if args.len() > 3 {
217                    Some(args[3].to_string())
218                } else {
219                    None
220                };
221                Ok(Entry::RemoveConffile {
222                    conffile,
223                    prior_version,
224                    package,
225                })
226            }
227            "mv_conffile" => {
228                if args.len() < 3 {
229                    return Err(ParseError::MissingArgument("mv_conffile".to_string()));
230                }
231                let old_conffile = args[1].to_string();
232                let new_conffile = args[2].to_string();
233                let prior_version = if args.len() > 3 {
234                    Some(args[3].parse()?)
235                } else {
236                    None
237                };
238                let package = if args.len() > 4 {
239                    Some(args[4].to_string())
240                } else {
241                    None
242                };
243                Ok(Entry::MoveConffile {
244                    old_conffile,
245                    new_conffile,
246                    prior_version,
247                    package,
248                })
249            }
250            "symlink_to_dir" => {
251                if args.len() < 3 {
252                    return Err(ParseError::MissingArgument("symlink_to_dir".to_string()));
253                }
254                let pathname = args[1].to_string();
255                let old_target = args[2].to_string();
256                let prior_version = if args.len() > 3 {
257                    Some(args[3].parse()?)
258                } else {
259                    None
260                };
261                let package = if args.len() > 4 {
262                    Some(args[4].to_string())
263                } else {
264                    None
265                };
266                Ok(Entry::SymlinkToDir {
267                    pathname,
268                    old_target,
269                    prior_version,
270                    package,
271                })
272            }
273            "dir_to_symlink" => {
274                if args.len() < 3 {
275                    return Err(ParseError::MissingArgument("dir_to_symlink".to_string()));
276                }
277                let pathname = args[1].to_string();
278                let new_target = args[2].to_string();
279                let prior_version = if args.len() > 3 {
280                    Some(args[3].parse()?)
281                } else {
282                    None
283                };
284                let package = if args.len() > 4 {
285                    Some(args[4].to_string())
286                } else {
287                    None
288                };
289                Ok(Entry::DirToSymlink {
290                    pathname,
291                    new_target,
292                    prior_version,
293                    package,
294                })
295            }
296            n => Err(ParseError::UnknownCommand(n.to_string())),
297        }
298    }
299}
300
301#[derive(Debug, PartialEq, Eq, Clone)]
302/// A line in a maintscript file
303enum Line {
304    /// A comment
305    Comment(String),
306    /// An entry
307    Entry(Entry),
308}
309
310impl std::fmt::Display for Line {
311    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
312        match self {
313            // Comment lines are stored verbatim by FromStr (with their
314            // leading `#` and any blank lines kept as-is); write them back
315            // unchanged.
316            Line::Comment(comment) => write!(f, "{}", comment),
317            Line::Entry(entry) => write!(f, "{}", entry),
318        }
319    }
320}
321
322#[derive(Debug, PartialEq, Eq, Clone)]
323/// A maintscript file
324pub struct Maintscript {
325    lines: Vec<Line>,
326}
327
328impl Default for Maintscript {
329    fn default() -> Self {
330        Self::new()
331    }
332}
333
334impl Maintscript {
335    /// Create a new maintscript file
336    pub fn new() -> Self {
337        Maintscript { lines: Vec::new() }
338    }
339
340    /// Check if the maintscript file is empty
341    pub fn is_empty(&self) -> bool {
342        self.lines.is_empty()
343    }
344
345    /// Iterate over the entries in the maintscript file
346    pub fn entries(&self) -> Vec<&Entry> {
347        self.lines
348            .iter()
349            .filter_map(|l| match l {
350                Line::Entry(e) => Some(e),
351                _ => None,
352            })
353            .collect()
354    }
355
356    /// Remove the entry at `index` (where `index` is an index into the
357    /// list returned by [`Self::entries`]). Any contiguous comment or
358    /// blank lines immediately preceding the entry are removed too.
359    pub fn remove(&mut self, index: usize) {
360        let mut comments: Vec<usize> = vec![];
361        let mut entries_seen = 0usize;
362        for (i, line) in self.lines.iter().enumerate() {
363            match line {
364                Line::Comment(_) => comments.push(i),
365                Line::Entry(_) => {
366                    if entries_seen == index {
367                        // Remove preceding comments (in descending index
368                        // order) plus the entry itself.
369                        for c in comments.iter().rev() {
370                            self.lines.remove(*c);
371                        }
372                        self.lines.remove(i - comments.len());
373                        return;
374                    }
375                    entries_seen += 1;
376                    comments.clear();
377                }
378            }
379        }
380    }
381}
382
383impl std::fmt::Display for Maintscript {
384    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
385        write!(
386            f,
387            "{}",
388            self.lines
389                .iter()
390                .map(|e| e.to_string())
391                .collect::<Vec<String>>()
392                .join("\n")
393        )
394    }
395}
396
397impl std::str::FromStr for Maintscript {
398    type Err = ParseError;
399
400    fn from_str(s: &str) -> Result<Self, Self::Err> {
401        let lines = s
402            .lines()
403            .map(|l| {
404                if l.starts_with('#') || l.trim().is_empty() {
405                    Ok(Line::Comment(l.to_string()))
406                } else {
407                    Ok(Line::Entry(Entry::from_str(l)?))
408                }
409            })
410            .collect::<Result<Vec<Line>, Self::Err>>()?;
411        Ok(Maintscript { lines })
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    #[test]
418    fn test_maintscript() {
419        let maintscript = "supports preinst
420rm_conffile /etc/foo.conf 1.2.3-4
421mv_conffile /etc/foo.conf /etc/bar.conf 1.2.3-4
422symlink_to_dir /etc/foo /etc/bar 1.2.3-4
423dir_to_symlink /etc/foo /etc/bar 1.2.3-4";
424        let maintscript = maintscript.parse::<super::Maintscript>().unwrap();
425        assert_eq!(
426            maintscript.entries(),
427            vec![
428                &super::Entry::Supports("preinst".to_string()),
429                &super::Entry::RemoveConffile {
430                    conffile: "/etc/foo.conf".to_string(),
431                    prior_version: Some("1.2.3-4".parse().unwrap()),
432                    package: None
433                },
434                &super::Entry::MoveConffile {
435                    old_conffile: "/etc/foo.conf".to_string(),
436                    new_conffile: "/etc/bar.conf".to_string(),
437                    prior_version: Some("1.2.3-4".parse().unwrap()),
438                    package: None
439                },
440                &super::Entry::SymlinkToDir {
441                    pathname: "/etc/foo".to_string(),
442                    old_target: "/etc/bar".to_string(),
443                    prior_version: Some("1.2.3-4".parse().unwrap()),
444                    package: None
445                },
446                &super::Entry::DirToSymlink {
447                    pathname: "/etc/foo".to_string(),
448                    new_target: "/etc/bar".to_string(),
449                    prior_version: Some("1.2.3-4".parse().unwrap()),
450                    package: None
451                },
452            ]
453        );
454    }
455
456    #[test]
457    fn test_round_trip_preserves_comments() {
458        let original = "# leading comment\nrm_conffile /etc/foo.conf 1.2.3-4\n# trailing comment";
459        let parsed = original.parse::<super::Maintscript>().unwrap();
460        assert_eq!(parsed.to_string(), original);
461    }
462
463    #[test]
464    fn test_round_trip_preserves_blank_lines() {
465        let original = "rm_conffile /etc/foo.conf 1.2.3-4\n\nrm_conffile /etc/bar.conf 1.2.3-4";
466        let parsed = original.parse::<super::Maintscript>().unwrap();
467        assert_eq!(parsed.to_string(), original);
468    }
469
470    #[test]
471    fn test_remove_drops_preceding_comments() {
472        let original = "# comment for foo\nrm_conffile /etc/foo.conf 1.2.3-4\nrm_conffile /etc/bar.conf 1.2.3-4";
473        let mut parsed = original.parse::<super::Maintscript>().unwrap();
474        parsed.remove(0);
475        assert_eq!(parsed.to_string(), "rm_conffile /etc/bar.conf 1.2.3-4");
476    }
477}