Skip to main content

linux_targets/
lib.rs

1//! Linux target selection shared by the rustcommons toolchain.
2//!
3//! A target is chosen in one of two ways:
4//!
5//! - **A distribution preset** (`--distro sles-12-sp5`): supplies the glibc
6//!   floor, the kernel floor, and a container image with the real userland.
7//! - **Explicit floors** (`--glibc 2.22`, `--kernel 4.12`, or a versioned
8//!   target such as `x86_64-unknown-linux-gnu.2.22`): each is optional. A
9//!   missing kernel floor means the kernel is **not simulated**: programs run
10//!   against the host kernel.
11//!
12//! A preset cannot be combined with explicit floors: "SLES 12 SP5 with glibc
13//! 2.28" is not a real system. [`Selection::resolve`] enforces these rules.
14
15use std::cmp::Ordering;
16use std::fmt;
17
18const DISTRO_DATA: &str = include_str!("../data/distros.tsv");
19
20/// A `major.minor` release number, such as a kernel (4.12) or glibc (2.22)
21/// release. Comparison uses only these two components.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23pub struct Release {
24    pub major: u32,
25    pub minor: u32,
26}
27
28impl Release {
29    /// Parses `4.12`, `4.12.14`, `v5.14`, or a distribution kernel release
30    /// such as `4.12.14-122.37-default`. Only the leading `major.minor` is
31    /// used.
32    pub fn parse(s: &str) -> Result<Self, String> {
33        let s = s.trim().trim_start_matches(['v', 'V']);
34        let mut parts = s.split(|c: char| !c.is_ascii_digit());
35        let major = parts.next().and_then(|p| p.parse().ok());
36        let minor = parts.next().and_then(|p| p.parse().ok());
37        match (major, minor) {
38            (Some(major), Some(minor)) => Ok(Release { major, minor }),
39            _ => Err(format!("not a release number: {s:?} (expected e.g. 4.12)")),
40        }
41    }
42}
43
44impl Ord for Release {
45    fn cmp(&self, other: &Self) -> Ordering {
46        (self.major, self.minor).cmp(&(other.major, other.minor))
47    }
48}
49
50impl PartialOrd for Release {
51    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
52        Some(self.cmp(other))
53    }
54}
55
56impl fmt::Display for Release {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        write!(f, "{}.{}", self.major, self.minor)
59    }
60}
61
62/// A mainline Linux kernel release.
63pub type KernelVersion = Release;
64/// A GNU C Library release.
65pub type GlibcVersion = Release;
66
67/// A distribution preset: a kernel floor, a glibc floor, and a test image.
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct Distro {
70    pub id: &'static str,
71    pub name: &'static str,
72    /// Mainline base of the release's GA kernel.
73    pub kernel: KernelVersion,
74    pub glibc: GlibcVersion,
75    /// Container image providing the release's userland.
76    pub image: &'static str,
77    /// Syscalls the vendor kernel backports beyond its mainline base.
78    pub kernel_backports: Vec<&'static str>,
79    /// How the values were established, e.g. `image-checked`.
80    pub status: &'static str,
81}
82
83/// All distribution presets from `data/distros.tsv`.
84pub fn distros() -> Vec<Distro> {
85    DISTRO_DATA
86        .lines()
87        .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
88        .map(|line| {
89            let cols: Vec<&'static str> = line.split('\t').map(str::trim).collect();
90            let [id, name, kernel, glibc, image, backports, status] = cols[..] else {
91                panic!("distro data row needs 7 columns: {cols:?}");
92            };
93            Distro {
94                id,
95                name,
96                kernel: Release::parse(kernel).expect("distro kernel"),
97                glibc: Release::parse(glibc).expect("distro glibc"),
98                image,
99                kernel_backports: if backports == "-" {
100                    Vec::new()
101                } else {
102                    backports.split(',').collect()
103                },
104                status,
105            }
106        })
107        .collect()
108}
109
110/// The preset with this id.
111pub fn distro(id: &str) -> Option<Distro> {
112    distros().into_iter().find(|d| d.id == id)
113}
114
115/// What the user selected, before validation.
116#[derive(Clone, Debug, Default, PartialEq, Eq)]
117pub struct Request<'a> {
118    /// `--distro ID`
119    pub distro: Option<&'a str>,
120    /// `--glibc X.Y`
121    pub glibc: Option<&'a str>,
122    /// `--kernel X.Y`
123    pub kernel: Option<&'a str>,
124    /// `--target TRIPLE` or `--target TRIPLE.X.Y`
125    pub target: Option<&'a str>,
126}
127
128/// A validated selection.
129#[derive(Clone, Debug, PartialEq, Eq)]
130pub struct Selection {
131    /// The preset, when one was chosen.
132    pub distro: Option<Distro>,
133    /// Rust target triple without any glibc suffix, when one was given.
134    pub triple: Option<String>,
135    /// glibc floor; `None` means the host's glibc.
136    pub glibc: Option<GlibcVersion>,
137    /// Kernel floor; `None` means the kernel is not simulated (host kernel).
138    pub kernel: Option<KernelVersion>,
139}
140
141impl Selection {
142    /// Applies the selection rules:
143    ///
144    /// - `distro` excludes `glibc`, `kernel`, and a versioned `target`.
145    /// - A versioned `target` (`TRIPLE.X.Y`) sets the glibc floor; it must
146    ///   agree with `glibc` if both are given. It never implies a kernel.
147    /// - Anything not given stays `None`: host glibc, host kernel.
148    pub fn resolve(req: &Request<'_>) -> Result<Self, String> {
149        let (triple, suffix) = match req.target {
150            Some(t) => {
151                let (triple, suffix) = split_versioned_target(t)?;
152                (Some(triple), suffix)
153            }
154            None => (None, None),
155        };
156        if let Some(id) = req.distro {
157            if req.glibc.is_some() || req.kernel.is_some() || suffix.is_some() {
158                return Err(format!(
159                    "--distro {id} sets the glibc and kernel floors; \
160                     do not combine it with --glibc, --kernel, or a versioned --target"
161                ));
162            }
163            let d = distro(id).ok_or_else(|| format!("unknown distro {id:?}"))?;
164            return Ok(Selection {
165                glibc: Some(d.glibc),
166                kernel: Some(d.kernel),
167                distro: Some(d),
168                triple,
169            });
170        }
171        let glibc = match (req.glibc.map(Release::parse).transpose()?, suffix) {
172            (Some(a), Some(b)) if a != b => {
173                return Err(format!("--glibc {a} conflicts with --target suffix .{b}"))
174            }
175            (a, b) => a.or(b),
176        };
177        Ok(Selection {
178            distro: None,
179            triple,
180            glibc,
181            kernel: req.kernel.map(Release::parse).transpose()?,
182        })
183    }
184
185    /// The container image for runtime tests, if the selection has one.
186    pub fn image(&self) -> Option<&'static str> {
187        self.distro.as_ref().map(|d| d.image)
188    }
189
190    /// A one-line description for tool output, e.g.
191    /// `glibc 2.22, kernel 4.12 (SUSE Linux Enterprise Server 12 SP5)` or
192    /// `glibc host, kernel not simulated (host)`.
193    pub fn describe(&self) -> String {
194        let glibc = self.glibc.map_or("host".to_string(), |g| g.to_string());
195        let kernel = self
196            .kernel
197            .map_or("not simulated (host)".to_string(), |k| k.to_string());
198        match &self.distro {
199            Some(d) => format!("glibc {glibc}, kernel {kernel} ({})", d.name),
200            None => format!("glibc {glibc}, kernel {kernel}"),
201        }
202    }
203}
204
205/// Splits `x86_64-unknown-linux-gnu.2.17` into the Rust triple and the glibc
206/// floor. A triple without a numeric `.X.Y` suffix is returned unchanged.
207pub fn split_versioned_target(target: &str) -> Result<(String, Option<GlibcVersion>), String> {
208    let mut parts = target.rsplitn(3, '.');
209    let (minor, major, triple) = (parts.next(), parts.next(), parts.next());
210    match (triple, major, minor) {
211        (Some(triple), Some(major), Some(minor))
212            if !major.is_empty()
213                && major.bytes().all(|b| b.is_ascii_digit())
214                && !minor.is_empty()
215                && minor.bytes().all(|b| b.is_ascii_digit()) =>
216        {
217            if !triple.ends_with("-gnu") {
218                return Err(format!(
219                    "a glibc suffix needs a -gnu target triple: {target}"
220                ));
221            }
222            Ok((
223                triple.to_string(),
224                Some(Release::parse(&format!("{major}.{minor}"))?),
225            ))
226        }
227        _ => Ok((target.to_string(), None)),
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn r(s: &str) -> Release {
236        Release::parse(s).unwrap()
237    }
238
239    #[test]
240    fn parses_release_strings() {
241        assert_eq!(r("4.12.14-122.37-default"), r("4.12"));
242        assert_eq!(r("v5.14"), r("5.14.0"));
243        assert!(r("2.17") < r("2.22"));
244        assert!(Release::parse("linux").is_err());
245    }
246
247    #[test]
248    fn presets_parse() {
249        let all = distros();
250        assert_eq!(all.len(), 28);
251        let sles = distro("sles-12-sp5").unwrap();
252        assert_eq!((sles.glibc, sles.kernel), (r("2.22"), r("4.12")));
253        assert_eq!(sles.image, "registry.suse.com/suse/sles12sp5:latest");
254    }
255
256    #[test]
257    fn distro_sets_both_floors() {
258        let s = Selection::resolve(&Request {
259            distro: Some("sles-12-sp5"),
260            ..Default::default()
261        })
262        .unwrap();
263        assert_eq!((s.glibc, s.kernel), (Some(r("2.22")), Some(r("4.12"))));
264        assert_eq!(s.image(), Some("registry.suse.com/suse/sles12sp5:latest"));
265        assert_eq!(
266            s.describe(),
267            "glibc 2.22, kernel 4.12 (SUSE Linux Enterprise Server 12 SP5)"
268        );
269    }
270
271    #[test]
272    fn distro_excludes_explicit_floors() {
273        for req in [
274            Request {
275                distro: Some("sles-12-sp5"),
276                glibc: Some("2.28"),
277                ..Default::default()
278            },
279            Request {
280                distro: Some("sles-12-sp5"),
281                kernel: Some("5.14"),
282                ..Default::default()
283            },
284            Request {
285                distro: Some("sles-12-sp5"),
286                target: Some("x86_64-unknown-linux-gnu.2.17"),
287                ..Default::default()
288            },
289        ] {
290            assert!(Selection::resolve(&req).is_err(), "{req:?}");
291        }
292        assert!(Selection::resolve(&Request {
293            distro: Some("nope"),
294            ..Default::default()
295        })
296        .is_err());
297    }
298
299    #[test]
300    fn missing_kernel_means_host_kernel() {
301        let s = Selection::resolve(&Request {
302            target: Some("x86_64-unknown-linux-gnu.2.17"),
303            ..Default::default()
304        })
305        .unwrap();
306        assert_eq!(s.triple.as_deref(), Some("x86_64-unknown-linux-gnu"));
307        assert_eq!(
308            (s.glibc, s.kernel, s.image()),
309            (Some(r("2.17")), None, None)
310        );
311        assert_eq!(s.describe(), "glibc 2.17, kernel not simulated (host)");
312    }
313
314    #[test]
315    fn explicit_floors_combine() {
316        let s = Selection::resolve(&Request {
317            glibc: Some("2.22"),
318            kernel: Some("4.12"),
319            ..Default::default()
320        })
321        .unwrap();
322        assert_eq!((s.glibc, s.kernel), (Some(r("2.22")), Some(r("4.12"))));
323        let k = Selection::resolve(&Request {
324            kernel: Some("4.12"),
325            ..Default::default()
326        })
327        .unwrap();
328        assert_eq!((k.glibc, k.kernel), (None, Some(r("4.12"))));
329        assert_eq!(k.describe(), "glibc host, kernel 4.12");
330    }
331
332    #[test]
333    fn versioned_target_must_agree_with_glibc() {
334        let agree = Request {
335            glibc: Some("2.17"),
336            target: Some("x86_64-unknown-linux-gnu.2.17"),
337            ..Default::default()
338        };
339        assert_eq!(Selection::resolve(&agree).unwrap().glibc, Some(r("2.17")));
340        let conflict = Request {
341            glibc: Some("2.22"),
342            ..agree
343        };
344        assert!(Selection::resolve(&conflict).is_err());
345    }
346
347    #[test]
348    fn splits_versioned_targets() {
349        assert_eq!(
350            split_versioned_target("x86_64-unknown-linux-gnu.2.17").unwrap(),
351            ("x86_64-unknown-linux-gnu".to_string(), Some(r("2.17")))
352        );
353        assert_eq!(
354            split_versioned_target("x86_64-unknown-linux-gnu").unwrap(),
355            ("x86_64-unknown-linux-gnu".to_string(), None)
356        );
357        assert_eq!(
358            split_versioned_target("thumbv7em-none-eabihf").unwrap().1,
359            None
360        );
361        assert!(split_versioned_target("x86_64-unknown-linux-musl.1.2").is_err());
362    }
363}