debian_analyzer/
debhelper.rs

1//! Debhelper utilities.
2use debversion::Version;
3use std::path::Path;
4
5/// Parse the debhelper compat level from a string.
6fn parse_debhelper_compat(s: &str) -> Option<u8> {
7    s.split_once('#').map_or(s, |s| s.0).trim().parse().ok()
8}
9
10/// Read a debian/compat file.
11///
12/// # Arguments
13/// * `path` - The path to the debian/compat file.
14pub fn read_debhelper_compat_file(path: &Path) -> Result<Option<u8>, std::io::Error> {
15    match std::fs::read_to_string(path) {
16        Ok(content) => Ok(parse_debhelper_compat(&content)),
17        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
18        Err(e) => Err(e),
19    }
20}
21
22/// Retrieve the debhelper compat level from a debian/control file.
23///
24/// # Arguments
25/// * `control` - The debian/control file.
26///
27/// # Returns
28/// The debhelper compat level.
29pub fn get_debhelper_compat_level_from_control(control: &debian_control::Control) -> Option<u8> {
30    let source = control.source()?;
31
32    if let Some(dh_compat) = source.as_deb822().get("X-DH-Compat") {
33        return parse_debhelper_compat(dh_compat.as_str());
34    }
35
36    let build_depends = source.build_depends()?;
37
38    let rels = build_depends
39        .entries()
40        .flat_map(|entry| entry.relations().collect::<Vec<_>>())
41        .find(|r| r.name() == "debhelper-compat");
42
43    rels.and_then(|r| r.version().and_then(|v| v.1.to_string().parse().ok()))
44}
45
46/// Retrieve the debhelper compat level from a debian/compat file or debian/control file.
47///
48/// # Arguments
49/// * `path` - The path to the debian/ directory.
50///
51/// # Returns
52/// The debhelper compat level.
53pub fn get_debhelper_compat_level(path: &Path) -> Result<Option<u8>, std::io::Error> {
54    match read_debhelper_compat_file(&path.join("debian/compat")) {
55        Ok(Some(level)) => {
56            return Ok(Some(level));
57        }
58        Err(e) => {
59            return Err(e);
60        }
61        Ok(None) => {}
62    }
63
64    let p = path.join("debian/control");
65
66    match std::fs::File::open(p) {
67        Ok(f) => {
68            let control = debian_control::Control::read_relaxed(f).unwrap().0;
69            Ok(get_debhelper_compat_level_from_control(&control))
70        }
71        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
72        Err(e) => Err(e),
73    }
74}
75
76/// Retrieve the maximum supported debhelper compat version fior a release.
77///
78/// # Arguments
79/// * `compat_release` - A release name (Debian or Ubuntu, currently)
80///
81/// # Returns
82/// The debhelper compat version
83pub fn maximum_debhelper_compat_version(compat_release: &str) -> u8 {
84    crate::release_info::debhelper_versions
85        .get(compat_release)
86        .map(|v| {
87            v.upstream_version
88                .split('.')
89                .next()
90                .unwrap()
91                .parse()
92                .unwrap()
93        })
94        .unwrap_or_else(lowest_non_deprecated_compat_level)
95}
96
97/// Ask dh_assistant for the supported compat levels.
98///
99/// Cache the result.
100fn get_lintian_compat_levels() -> &'static SupportedCompatLevels {
101    lazy_static::lazy_static! {
102        static ref LINTIAN_COMPAT_LEVELS: SupportedCompatLevels = {
103            // TODO(jelmer): ideally we should be getting these numbers from the compat-release
104            // dh_assistant, rather than what's on the system
105            let output = std::process::Command::new("dh_assistant")
106                .arg("supported-compat-levels")
107                .output()
108                .expect("failed to run dh_assistant")
109                .stdout;
110            serde_json::from_slice(&output).expect("failed to parse dh_assistant output")
111        };
112    };
113    &LINTIAN_COMPAT_LEVELS
114}
115
116#[derive(Debug, serde::Deserialize)]
117#[allow(dead_code)]
118struct SupportedCompatLevels {
119    #[serde(rename = "HIGHEST_STABLE_COMPAT_LEVEL")]
120    highest_stable_compat_level: u8,
121    #[serde(rename = "LOWEST_NON_DEPRECATED_COMPAT_LEVEL")]
122    lowest_non_deprecated_compat_level: u8,
123    #[serde(rename = "LOWEST_VIRTUAL_DEBHELPER_COMPAT_LEVEL")]
124    lowest_virtual_debhelper_compat_level: u8,
125    #[serde(rename = "MAX_COMPAT_LEVEL")]
126    max_compat_level: u8,
127    #[serde(rename = "MIN_COMPAT_LEVEL")]
128    min_compat_level: u8,
129    #[serde(rename = "MIN_COMPAT_LEVEL_NOT_SCHEDULED_FOR_REMOVAL")]
130    min_compat_level_not_scheduled_for_removal: u8,
131}
132
133/// Find the lowest non-deprecated debhelper compat level.
134pub fn lowest_non_deprecated_compat_level() -> u8 {
135    get_lintian_compat_levels().lowest_non_deprecated_compat_level
136}
137
138/// Find the highest stable debhelper compat level.
139pub fn highest_stable_compat_level() -> u8 {
140    get_lintian_compat_levels().highest_stable_compat_level
141}
142
143/// Error type for ensure_minimum_debhelper_version
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum EnsureDebhelperError {
146    /// debhelper or debhelper-compat found in Build-Depends-Indep or Build-Depends-Arch
147    DebhelperInWrongField(String),
148    /// Complex rule for debhelper-compat (multiple alternatives or non-equal version)
149    ComplexDebhelperCompatRule,
150    /// debhelper-compat without version constraint
151    DebhelperCompatWithoutVersion,
152}
153
154impl std::fmt::Display for EnsureDebhelperError {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        match self {
157            EnsureDebhelperError::DebhelperInWrongField(field) => {
158                write!(f, "debhelper in {}", field)
159            }
160            EnsureDebhelperError::ComplexDebhelperCompatRule => {
161                write!(f, "Complex rule for debhelper-compat, aborting")
162            }
163            EnsureDebhelperError::DebhelperCompatWithoutVersion => {
164                write!(f, "debhelper-compat without version, aborting")
165            }
166        }
167    }
168}
169
170impl std::error::Error for EnsureDebhelperError {}
171
172/// Ensure that the package is at least using a specific version of debhelper.
173///
174/// This is a dedicated helper, since debhelper can now also be pulled in
175/// with a debhelper-compat dependency.
176///
177/// # Arguments
178/// * `source` - The source paragraph from debian/control
179/// * `minimum_version` - The minimum version required
180///
181/// # Returns
182/// Ok(true) if the Build-Depends field was modified,
183/// Ok(false) if no change was needed,
184/// Err(EnsureDebhelperError) if there was an error
185///
186/// # Examples
187/// ```rust
188/// use debian_analyzer::debhelper::ensure_minimum_debhelper_version;
189///
190/// let text = "Source: foo\nBuild-Depends: debhelper (>= 10)\n";
191/// let mut control = debian_control::Control::read_relaxed(text.as_bytes()).unwrap().0;
192/// let mut source = control.source().unwrap();
193/// let changed = ensure_minimum_debhelper_version(&mut source, &"11".parse().unwrap()).unwrap();
194/// assert!(changed);
195/// assert_eq!(source.build_depends().unwrap().to_string(), "debhelper (>= 11)");
196/// ```
197pub fn ensure_minimum_debhelper_version(
198    source: &mut debian_control::lossless::Source,
199    minimum_version: &Version,
200) -> Result<bool, EnsureDebhelperError> {
201    // Check that debhelper is not in Build-Depends-Indep or Build-Depends-Arch
202    for (field_name, rels_opt) in [
203        ("Build-Depends-Arch", source.build_depends_arch()),
204        ("Build-Depends-Indep", source.build_depends_indep()),
205    ] {
206        let Some(rels) = rels_opt else {
207            continue;
208        };
209
210        for entry in rels.entries() {
211            for rel in entry.relations() {
212                if rel.name() == "debhelper-compat" || rel.name() == "debhelper" {
213                    return Err(EnsureDebhelperError::DebhelperInWrongField(
214                        field_name.to_string(),
215                    ));
216                }
217            }
218        }
219    }
220
221    let mut rels = source.build_depends().unwrap_or_default();
222
223    // Check if debhelper-compat is present
224    for entry in rels.entries() {
225        let has_debhelper_compat = entry
226            .relations()
227            .any(|rel| rel.name() == "debhelper-compat");
228
229        if !has_debhelper_compat {
230            continue;
231        }
232
233        if entry.relations().count() > 1 {
234            return Err(EnsureDebhelperError::ComplexDebhelperCompatRule);
235        }
236
237        let rel = entry.relations().next().unwrap();
238        let Some((constraint, version)) = rel.version() else {
239            return Err(EnsureDebhelperError::DebhelperCompatWithoutVersion);
240        };
241
242        if constraint != debian_control::relations::VersionConstraint::Equal {
243            return Err(EnsureDebhelperError::ComplexDebhelperCompatRule);
244        }
245
246        if &version >= minimum_version {
247            return Ok(false);
248        }
249    }
250
251    // Update or add debhelper dependency
252    let changed = crate::relations::ensure_minimum_version(&mut rels, "debhelper", minimum_version);
253
254    if changed {
255        source.set_build_depends(&rels);
256    }
257
258    Ok(changed)
259}
260
261/// Get the debhelper sequences from Build-Depends.
262///
263/// Extracts all dh-sequence-* packages from the Build-Depends field.
264///
265/// # Arguments
266/// * `source` - The source paragraph from debian/control
267///
268/// # Returns
269/// An iterator over sequence names (without the "dh-sequence-" prefix)
270///
271/// # Examples
272/// ```rust
273/// use debian_analyzer::debhelper::get_sequences;
274///
275/// let text = "Source: foo\nBuild-Depends: dh-sequence-python3, dh-sequence-nodejs\n";
276/// let control = debian_control::Control::read_relaxed(text.as_bytes()).unwrap().0;
277/// let source = control.source().unwrap();
278/// let sequences: Vec<String> = get_sequences(&source).collect();
279/// assert_eq!(sequences, vec!["python3", "nodejs"]);
280/// ```
281pub fn get_sequences(source: &debian_control::lossless::Source) -> impl Iterator<Item = String> {
282    let build_depends = source.build_depends().unwrap_or_default();
283
284    build_depends
285        .entries()
286        .flat_map(|entry| entry.relations().collect::<Vec<_>>())
287        .filter_map(|rel| {
288            let name = rel.name();
289            if name.starts_with("dh-sequence-") {
290                Some(name[12..].to_string())
291            } else {
292                None
293            }
294        })
295        .collect::<Vec<_>>()
296        .into_iter()
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_parse_debhelper_compat() {
305        assert_eq!(super::parse_debhelper_compat("9"), Some(9));
306        assert_eq!(super::parse_debhelper_compat("9 # comment"), Some(9));
307        assert_eq!(
308            super::parse_debhelper_compat("9 # comment # comment"),
309            Some(9)
310        );
311        assert_eq!(super::parse_debhelper_compat(""), None);
312        assert_eq!(super::parse_debhelper_compat(" # comment"), None);
313    }
314
315    #[test]
316    fn test_get_debhelper_compat_level_from_control() {
317        let text = "Source: foo
318Build-Depends: debhelper-compat (= 9)
319
320Package: foo
321Architecture: any
322";
323
324        let control = debian_control::Control::read_relaxed(&mut text.as_bytes())
325            .unwrap()
326            .0;
327
328        assert_eq!(
329            super::get_debhelper_compat_level_from_control(&control),
330            Some(9)
331        );
332    }
333
334    #[test]
335    fn test_get_debhelper_compat_level_from_control_x_dh_compat() {
336        let text = "Source: foo
337X-DH-Compat: 9
338Build-Depends: debhelper
339";
340
341        let control = debian_control::Control::read_relaxed(&mut text.as_bytes())
342            .unwrap()
343            .0;
344
345        assert_eq!(
346            super::get_debhelper_compat_level_from_control(&control),
347            Some(9)
348        );
349    }
350
351    mod ensure_minimum_debhelper_version_tests {
352        use super::*;
353
354        #[test]
355        fn test_already() {
356            let text = "Source: foo\nBuild-Depends: debhelper (>= 10)\n";
357            let control = debian_control::Control::read_relaxed(text.as_bytes())
358                .unwrap()
359                .0;
360            let mut source = control.source().unwrap();
361
362            assert!(
363                !ensure_minimum_debhelper_version(&mut source, &"10".parse().unwrap()).unwrap()
364            );
365            assert_eq!(
366                source.build_depends().unwrap().to_string(),
367                "debhelper (>= 10)"
368            );
369
370            assert!(!ensure_minimum_debhelper_version(&mut source, &"9".parse().unwrap()).unwrap());
371            assert_eq!(
372                source.build_depends().unwrap().to_string(),
373                "debhelper (>= 10)"
374            );
375        }
376
377        #[test]
378        fn test_already_compat() {
379            let text = "Source: foo\nBuild-Depends: debhelper-compat (= 10)\n";
380            let control = debian_control::Control::read_relaxed(text.as_bytes())
381                .unwrap()
382                .0;
383            let mut source = control.source().unwrap();
384
385            assert!(
386                !ensure_minimum_debhelper_version(&mut source, &"10".parse().unwrap()).unwrap()
387            );
388            assert_eq!(
389                source.build_depends().unwrap().to_string(),
390                "debhelper-compat (= 10)"
391            );
392
393            assert!(!ensure_minimum_debhelper_version(&mut source, &"9".parse().unwrap()).unwrap());
394            assert_eq!(
395                source.build_depends().unwrap().to_string(),
396                "debhelper-compat (= 10)"
397            );
398        }
399
400        #[test]
401        fn test_bump() {
402            let text = "Source: foo\nBuild-Depends: debhelper (>= 10)\n";
403            let control = debian_control::Control::read_relaxed(text.as_bytes())
404                .unwrap()
405                .0;
406            let mut source = control.source().unwrap();
407
408            assert!(ensure_minimum_debhelper_version(&mut source, &"11".parse().unwrap()).unwrap());
409            assert_eq!(
410                source.build_depends().unwrap().to_string(),
411                "debhelper (>= 11)"
412            );
413        }
414
415        #[test]
416        fn test_bump_compat() {
417            let text = "Source: foo\nBuild-Depends: debhelper-compat (= 10)\n";
418            let control = debian_control::Control::read_relaxed(text.as_bytes())
419                .unwrap()
420                .0;
421            let mut source = control.source().unwrap();
422
423            assert!(ensure_minimum_debhelper_version(&mut source, &"11".parse().unwrap()).unwrap());
424            assert_eq!(
425                source.build_depends().unwrap().to_string(),
426                "debhelper (>= 11), debhelper-compat (= 10)"
427            );
428
429            assert!(
430                ensure_minimum_debhelper_version(&mut source, &"11.1".parse().unwrap()).unwrap()
431            );
432            assert_eq!(
433                source.build_depends().unwrap().to_string(),
434                "debhelper (>= 11.1), debhelper-compat (= 10)"
435            );
436        }
437
438        #[test]
439        fn test_not_set() {
440            let text = "Source: foo\n";
441            let control = debian_control::Control::read_relaxed(text.as_bytes())
442                .unwrap()
443                .0;
444            let mut source = control.source().unwrap();
445
446            assert!(ensure_minimum_debhelper_version(&mut source, &"10".parse().unwrap()).unwrap());
447            assert_eq!(
448                source.build_depends().unwrap().to_string(),
449                "debhelper (>= 10)"
450            );
451        }
452
453        #[test]
454        fn test_in_indep() {
455            let text = "Source: foo\nBuild-Depends-Indep: debhelper (>= 9)\n";
456            let control = debian_control::Control::read_relaxed(text.as_bytes())
457                .unwrap()
458                .0;
459            let mut source = control.source().unwrap();
460
461            let result = ensure_minimum_debhelper_version(&mut source, &"10".parse().unwrap());
462            assert!(result.is_err());
463            assert_eq!(
464                result.unwrap_err(),
465                EnsureDebhelperError::DebhelperInWrongField("Build-Depends-Indep".to_string())
466            );
467        }
468    }
469
470    mod get_sequences_tests {
471        use super::*;
472
473        #[test]
474        fn test_no_sequences() {
475            let text = "Source: foo\nBuild-Depends: debhelper (>= 10)\n";
476            let control = debian_control::Control::read_relaxed(text.as_bytes())
477                .unwrap()
478                .0;
479            let source = control.source().unwrap();
480
481            let sequences: Vec<String> = get_sequences(&source).collect();
482            assert_eq!(sequences, Vec::<String>::new());
483        }
484
485        #[test]
486        fn test_single_sequence() {
487            let text = "Source: foo\nBuild-Depends: dh-sequence-python3, debhelper (>= 10)\n";
488            let control = debian_control::Control::read_relaxed(text.as_bytes())
489                .unwrap()
490                .0;
491            let source = control.source().unwrap();
492
493            let sequences: Vec<String> = get_sequences(&source).collect();
494            assert_eq!(sequences, vec!["python3"]);
495        }
496
497        #[test]
498        fn test_multiple_sequences() {
499            let text = "Source: foo\nBuild-Depends: dh-sequence-python3, dh-sequence-nodejs, debhelper (>= 10)\n";
500            let control = debian_control::Control::read_relaxed(text.as_bytes())
501                .unwrap()
502                .0;
503            let source = control.source().unwrap();
504
505            let sequences: Vec<String> = get_sequences(&source).collect();
506            assert_eq!(sequences, vec!["python3", "nodejs"]);
507        }
508
509        #[test]
510        fn test_no_build_depends() {
511            let text = "Source: foo\n";
512            let control = debian_control::Control::read_relaxed(text.as_bytes())
513                .unwrap()
514                .0;
515            let source = control.source().unwrap();
516
517            let sequences: Vec<String> = get_sequences(&source).collect();
518            assert_eq!(sequences, Vec::<String>::new());
519        }
520    }
521}