Skip to main content

sui_store/
profile.rs

1//! Nix profile management — symlink-based generation chains.
2//!
3//! Nix profiles are symlink chains:
4//! `/nix/var/nix/profiles/system` -> `system-42-link` -> `/nix/store/...`
5//!
6//! This module provides [`ProfileManager`] for creating, listing, switching,
7//! and rolling back profile generations without shelling out to `nix-env`.
8
9use std::path::{Path, PathBuf};
10
11// ── Error type ────────────────────────────────────────────────
12
13/// Errors that can occur during profile operations.
14#[derive(Debug, thiserror::Error)]
15pub enum ProfileError {
16    #[error("I/O error: {0}")]
17    Io(#[from] std::io::Error),
18    #[error("invalid profile symlink")]
19    InvalidProfile,
20    #[error("generation {0} not found")]
21    GenerationNotFound(u32),
22    #[error("no current generation")]
23    NoCurrentGeneration,
24    #[error("no previous generation to rollback to")]
25    NoPreviousGeneration,
26}
27
28// ── Core types ────────────────────────────────────────────────
29
30/// A single profile generation.
31#[derive(Debug, Clone)]
32pub struct Generation {
33    /// Generation number (1-based, monotonically increasing).
34    pub number: u32,
35    /// The store path this generation points to.
36    pub path: PathBuf,
37    /// When this generation was created (from symlink metadata).
38    pub created: Option<std::time::SystemTime>,
39    /// Whether this is the currently active generation.
40    pub current: bool,
41}
42
43/// Manages a Nix-style symlink profile (e.g., system, per-user).
44///
45/// The profile lives as a symlink at `{profile_dir}/{profile_name}` pointing
46/// to `{profile_dir}/{profile_name}-{N}-link` where N is the generation
47/// number. Each generation link in turn points to a store path.
48pub struct ProfileManager {
49    profile_dir: PathBuf,
50    profile_name: String,
51}
52
53impl ProfileManager {
54    /// Create a new profile manager.
55    ///
56    /// # Arguments
57    /// - `profile_dir` — directory containing profile symlinks
58    /// - `name` — profile name (e.g., `"system"`)
59    pub fn new(profile_dir: impl Into<PathBuf>, name: impl Into<String>) -> Self {
60        Self {
61            profile_dir: profile_dir.into(),
62            profile_name: name.into(),
63        }
64    }
65
66    /// System profile manager using default paths.
67    #[must_use]
68    pub fn system() -> Self {
69        Self::new("/nix/var/nix/profiles", "system")
70    }
71
72    /// Path to the main profile symlink (e.g., `/nix/var/nix/profiles/system`).
73    #[must_use]
74    pub fn profile_path(&self) -> PathBuf {
75        self.profile_dir.join(&self.profile_name)
76    }
77
78    /// Path to a generation link (e.g., `/nix/var/nix/profiles/system-42-link`).
79    fn generation_link(&self, gen_num: u32) -> PathBuf {
80        self.profile_dir
81            .join(format!("{}-{}-link", self.profile_name, gen_num))
82    }
83
84    /// Get the current generation number by reading the profile symlink.
85    ///
86    /// Returns `Ok(None)` if the profile symlink does not exist yet.
87    pub fn current_generation(&self) -> Result<Option<u32>, ProfileError> {
88        let profile = self.profile_path();
89        if !profile.exists() {
90            return Ok(None);
91        }
92        let target = std::fs::read_link(&profile)?;
93        // Parse "system-42-link" from the target filename.
94        let filename = target
95            .file_name()
96            .and_then(|f| f.to_str())
97            .ok_or(ProfileError::InvalidProfile)?;
98        let number = parse_generation_number(filename, &self.profile_name)?;
99        Ok(Some(number))
100    }
101
102    /// List all generations, sorted by number.
103    pub fn list_generations(&self) -> Result<Vec<Generation>, ProfileError> {
104        if !self.profile_dir.exists() {
105            return Ok(Vec::new());
106        }
107
108        let mut generations = Vec::new();
109        let current = self.current_generation()?;
110
111        let prefix = format!("{}-", self.profile_name);
112        let suffix = "-link";
113
114        for entry in std::fs::read_dir(&self.profile_dir)? {
115            let entry = entry?;
116            let name = entry.file_name();
117            let name_str = name.to_string_lossy();
118
119            if !name_str.starts_with(&prefix) || !name_str.ends_with(suffix) {
120                continue;
121            }
122
123            // Also skip the `-tmp-link` placeholder used during atomic switches.
124            if name_str.ends_with("-tmp-link") {
125                continue;
126            }
127
128            let mid = &name_str[prefix.len()..name_str.len() - suffix.len()];
129            if let Ok(num) = mid.parse::<u32>() {
130                let path = std::fs::read_link(entry.path())?;
131                let created = entry.metadata().ok().and_then(|m| m.created().ok());
132                generations.push(Generation {
133                    number: num,
134                    path,
135                    created,
136                    current: current == Some(num),
137                });
138            }
139        }
140
141        generations.sort_by_key(|g| g.number);
142        Ok(generations)
143    }
144
145    /// Set the profile to a new store path, creating a new generation.
146    ///
147    /// Returns the new generation number.
148    pub fn set(&self, store_path: &Path) -> Result<u32, ProfileError> {
149        std::fs::create_dir_all(&self.profile_dir)?;
150
151        let next = self.next_generation_number()?;
152        let link = self.generation_link(next);
153
154        // Create the generation symlink: system-N-link -> /nix/store/...
155        std::os::unix::fs::symlink(store_path, &link)?;
156
157        // Atomically update the profile symlink: system -> system-N-link
158        self.atomic_switch(&link)?;
159
160        Ok(next)
161    }
162
163    /// Switch to a specific existing generation number.
164    pub fn switch_generation(&self, gen_num: u32) -> Result<(), ProfileError> {
165        let link = self.generation_link(gen_num);
166        if !link.exists() {
167            return Err(ProfileError::GenerationNotFound(gen_num));
168        }
169
170        self.atomic_switch(&link)?;
171        Ok(())
172    }
173
174    /// Rollback to the previous generation.
175    ///
176    /// Returns the generation number that was switched to.
177    pub fn rollback(&self) -> Result<u32, ProfileError> {
178        let current = self
179            .current_generation()?
180            .ok_or(ProfileError::NoCurrentGeneration)?;
181
182        // Find the highest generation less than current.
183        let generations = self.list_generations()?;
184        let prev = generations
185            .iter()
186            .filter(|g| g.number < current)
187            .max_by_key(|g| g.number)
188            .ok_or(ProfileError::NoPreviousGeneration)?;
189
190        self.switch_generation(prev.number)?;
191        Ok(prev.number)
192    }
193
194    /// Delete a specific generation (remove the `-N-link` symlink).
195    ///
196    /// Cannot delete the currently active generation.
197    pub fn delete_generation(&self, gen_num: u32) -> Result<(), ProfileError> {
198        let current = self.current_generation()?;
199        if current == Some(gen_num) {
200            return Err(ProfileError::Io(std::io::Error::new(
201                std::io::ErrorKind::PermissionDenied,
202                "cannot delete the current generation",
203            )));
204        }
205
206        let link = self.generation_link(gen_num);
207        if !link.exists() {
208            return Err(ProfileError::GenerationNotFound(gen_num));
209        }
210
211        std::fs::remove_file(&link)?;
212        Ok(())
213    }
214
215    // ── Private helpers ──────────────────────────────────────
216
217    /// Atomically replace the profile symlink via tmp + rename.
218    fn atomic_switch(&self, target: &Path) -> Result<(), ProfileError> {
219        let profile = self.profile_path();
220        let tmp = self
221            .profile_dir
222            .join(format!("{}-tmp-link", self.profile_name));
223
224        // Remove stale tmp if present from a previous crash.
225        let _ = std::fs::remove_file(&tmp);
226
227        std::os::unix::fs::symlink(target, &tmp)?;
228        std::fs::rename(&tmp, &profile)?; // atomic on same filesystem
229        Ok(())
230    }
231
232    fn next_generation_number(&self) -> Result<u32, ProfileError> {
233        let generations = self.list_generations()?;
234        Ok(generations.last().map(|g| g.number + 1).unwrap_or(1))
235    }
236}
237
238/// Parse a generation number from a filename like `"system-42-link"`.
239fn parse_generation_number(filename: &str, profile_name: &str) -> Result<u32, ProfileError> {
240    let prefix = format!("{profile_name}-");
241    let suffix = "-link";
242    if !filename.starts_with(&prefix) || !filename.ends_with(suffix) {
243        return Err(ProfileError::InvalidProfile);
244    }
245    let mid = &filename[prefix.len()..filename.len() - suffix.len()];
246    mid.parse().map_err(|_| ProfileError::InvalidProfile)
247}
248
249// ── Tests ─────────────────────────────────────────────────────
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    /// Helper to create a fake store path inside a temp dir.
256    fn fake_store_path(tmp: &Path, name: &str) -> PathBuf {
257        let store = tmp.join("store");
258        let p = store.join(name);
259        std::fs::create_dir_all(&p).unwrap();
260        p
261    }
262
263    // ── set creates generation link and updates profile ──────
264
265    #[test]
266    fn set_creates_generation_and_profile_symlink() {
267        let tmp = tempfile::tempdir().unwrap();
268        let profiles_dir = tmp.path().join("profiles");
269        let pm = ProfileManager::new(&profiles_dir, "system");
270
271        let store_path = fake_store_path(tmp.path(), "abc123-foo");
272        let gen_num = pm.set(&store_path).unwrap();
273        assert_eq!(gen_num, 1);
274
275        // The profile symlink exists and ultimately resolves to the store path.
276        let profile = pm.profile_path();
277        assert!(profile.is_symlink());
278
279        // The generation link exists.
280        let gen_link = pm.generation_link(1);
281        assert!(gen_link.is_symlink());
282        let gen_target = std::fs::read_link(&gen_link).unwrap();
283        assert_eq!(gen_target, store_path);
284    }
285
286    // ── current_generation reads the right number ────────────
287
288    #[test]
289    fn current_generation_returns_correct_number() {
290        let tmp = tempfile::tempdir().unwrap();
291        let profiles_dir = tmp.path().join("profiles");
292        let pm = ProfileManager::new(&profiles_dir, "system");
293
294        let sp1 = fake_store_path(tmp.path(), "gen1-path");
295        let sp2 = fake_store_path(tmp.path(), "gen2-path");
296
297        pm.set(&sp1).unwrap();
298        assert_eq!(pm.current_generation().unwrap(), Some(1));
299
300        pm.set(&sp2).unwrap();
301        assert_eq!(pm.current_generation().unwrap(), Some(2));
302    }
303
304    // ── list_generations finds all generations ────────────────
305
306    #[test]
307    fn list_generations_returns_all_sorted() {
308        let tmp = tempfile::tempdir().unwrap();
309        let profiles_dir = tmp.path().join("profiles");
310        let pm = ProfileManager::new(&profiles_dir, "test");
311
312        let sp1 = fake_store_path(tmp.path(), "g1");
313        let sp2 = fake_store_path(tmp.path(), "g2");
314        let sp3 = fake_store_path(tmp.path(), "g3");
315
316        pm.set(&sp1).unwrap();
317        pm.set(&sp2).unwrap();
318        pm.set(&sp3).unwrap();
319
320        let generations = pm.list_generations().unwrap();
321        assert_eq!(generations.len(), 3);
322        assert_eq!(generations[0].number, 1);
323        assert_eq!(generations[1].number, 2);
324        assert_eq!(generations[2].number, 3);
325
326        // Only the latest should be current.
327        assert!(!generations[0].current);
328        assert!(!generations[1].current);
329        assert!(generations[2].current);
330    }
331
332    // ── switch_generation changes the profile target ─────────
333
334    #[test]
335    fn switch_generation_updates_profile() {
336        let tmp = tempfile::tempdir().unwrap();
337        let profiles_dir = tmp.path().join("profiles");
338        let pm = ProfileManager::new(&profiles_dir, "myprofile");
339
340        let sp1 = fake_store_path(tmp.path(), "first");
341        let sp2 = fake_store_path(tmp.path(), "second");
342
343        pm.set(&sp1).unwrap();
344        pm.set(&sp2).unwrap();
345        assert_eq!(pm.current_generation().unwrap(), Some(2));
346
347        pm.switch_generation(1).unwrap();
348        assert_eq!(pm.current_generation().unwrap(), Some(1));
349    }
350
351    // ── rollback goes to previous generation ─────────────────
352
353    #[test]
354    fn rollback_switches_to_previous() {
355        let tmp = tempfile::tempdir().unwrap();
356        let profiles_dir = tmp.path().join("profiles");
357        let pm = ProfileManager::new(&profiles_dir, "sys");
358
359        let sp1 = fake_store_path(tmp.path(), "a");
360        let sp2 = fake_store_path(tmp.path(), "b");
361        let sp3 = fake_store_path(tmp.path(), "c");
362
363        pm.set(&sp1).unwrap();
364        pm.set(&sp2).unwrap();
365        pm.set(&sp3).unwrap();
366        assert_eq!(pm.current_generation().unwrap(), Some(3));
367
368        let prev = pm.rollback().unwrap();
369        assert_eq!(prev, 2);
370        assert_eq!(pm.current_generation().unwrap(), Some(2));
371    }
372
373    // ── multiple set calls increment generation numbers ──────
374
375    #[test]
376    fn multiple_sets_increment_generations() {
377        let tmp = tempfile::tempdir().unwrap();
378        let profiles_dir = tmp.path().join("profiles");
379        let pm = ProfileManager::new(&profiles_dir, "default");
380
381        for i in 1..=5 {
382            let sp = fake_store_path(tmp.path(), &format!("generation-{i}"));
383            let number = pm.set(&sp).unwrap();
384            assert_eq!(number, i);
385        }
386    }
387
388    // ── generation with highest number but not current ───────
389
390    #[test]
391    fn highest_generation_not_current_after_switch() {
392        let tmp = tempfile::tempdir().unwrap();
393        let profiles_dir = tmp.path().join("profiles");
394        let pm = ProfileManager::new(&profiles_dir, "test");
395
396        let sp1 = fake_store_path(tmp.path(), "x1");
397        let sp2 = fake_store_path(tmp.path(), "x2");
398        let sp3 = fake_store_path(tmp.path(), "x3");
399
400        pm.set(&sp1).unwrap();
401        pm.set(&sp2).unwrap();
402        pm.set(&sp3).unwrap();
403
404        // Switch back to generation 1 — generation 3 is highest but not current.
405        pm.switch_generation(1).unwrap();
406
407        let generations = pm.list_generations().unwrap();
408        assert_eq!(generations.len(), 3);
409        assert!(generations[0].current);  // generation 1 is current
410        assert!(!generations[1].current);
411        assert!(!generations[2].current); // generation 3 is highest but not current
412    }
413
414    // ── empty profile directory ──────────────────────────────
415
416    #[test]
417    fn empty_profile_dir_returns_none_and_empty_list() {
418        let tmp = tempfile::tempdir().unwrap();
419        let profiles_dir = tmp.path().join("empty-profiles");
420        // Do NOT create the directory.
421        let pm = ProfileManager::new(&profiles_dir, "system");
422
423        assert_eq!(pm.current_generation().unwrap(), None);
424        assert!(pm.list_generations().unwrap().is_empty());
425    }
426
427    // ── set is atomic (tmp + rename pattern) ─────────────────
428
429    #[test]
430    fn set_does_not_leave_tmp_symlink() {
431        let tmp = tempfile::tempdir().unwrap();
432        let profiles_dir = tmp.path().join("profiles");
433        let pm = ProfileManager::new(&profiles_dir, "atomic");
434
435        let sp = fake_store_path(tmp.path(), "store-path");
436        pm.set(&sp).unwrap();
437
438        // No tmp symlink should remain.
439        let tmp_link = profiles_dir.join("atomic-tmp-link");
440        assert!(!tmp_link.exists());
441    }
442
443    // ── rollback from generation 1 returns error ─────────────
444
445    #[test]
446    fn rollback_from_first_generation_errors() {
447        let tmp = tempfile::tempdir().unwrap();
448        let profiles_dir = tmp.path().join("profiles");
449        let pm = ProfileManager::new(&profiles_dir, "sys");
450
451        let sp = fake_store_path(tmp.path(), "only");
452        pm.set(&sp).unwrap();
453
454        let result = pm.rollback();
455        assert!(result.is_err());
456        assert!(matches!(
457            result.unwrap_err(),
458            ProfileError::NoPreviousGeneration
459        ));
460    }
461
462    // ── rollback with no current generation ──────────────────
463
464    #[test]
465    fn rollback_with_no_profile_errors() {
466        let tmp = tempfile::tempdir().unwrap();
467        let profiles_dir = tmp.path().join("profiles");
468        let pm = ProfileManager::new(&profiles_dir, "sys");
469
470        let result = pm.rollback();
471        assert!(matches!(
472            result.unwrap_err(),
473            ProfileError::NoCurrentGeneration
474        ));
475    }
476
477    // ── switch to non-existent generation ────────────────────
478
479    #[test]
480    fn switch_to_nonexistent_generation_errors() {
481        let tmp = tempfile::tempdir().unwrap();
482        let profiles_dir = tmp.path().join("profiles");
483        std::fs::create_dir_all(&profiles_dir).unwrap();
484        let pm = ProfileManager::new(&profiles_dir, "test");
485
486        let result = pm.switch_generation(99);
487        assert!(matches!(
488            result.unwrap_err(),
489            ProfileError::GenerationNotFound(99)
490        ));
491    }
492
493    // ── delete generation ────────────────────────────────────
494
495    #[test]
496    fn delete_generation_removes_link() {
497        let tmp = tempfile::tempdir().unwrap();
498        let profiles_dir = tmp.path().join("profiles");
499        let pm = ProfileManager::new(&profiles_dir, "test");
500
501        let sp1 = fake_store_path(tmp.path(), "d1");
502        let sp2 = fake_store_path(tmp.path(), "d2");
503
504        pm.set(&sp1).unwrap();
505        pm.set(&sp2).unwrap();
506
507        pm.delete_generation(1).unwrap();
508        let generations = pm.list_generations().unwrap();
509        assert_eq!(generations.len(), 1);
510        assert_eq!(generations[0].number, 2);
511    }
512
513    // ── cannot delete current generation ─────────────────────
514
515    #[test]
516    fn delete_current_generation_errors() {
517        let tmp = tempfile::tempdir().unwrap();
518        let profiles_dir = tmp.path().join("profiles");
519        let pm = ProfileManager::new(&profiles_dir, "test");
520
521        let sp = fake_store_path(tmp.path(), "current");
522        pm.set(&sp).unwrap();
523
524        let result = pm.delete_generation(1);
525        assert!(result.is_err());
526    }
527
528    // ── generation paths point to correct store paths ────────
529
530    #[test]
531    fn generation_paths_are_correct() {
532        let tmp = tempfile::tempdir().unwrap();
533        let profiles_dir = tmp.path().join("profiles");
534        let pm = ProfileManager::new(&profiles_dir, "test");
535
536        let sp1 = fake_store_path(tmp.path(), "path-a");
537        let sp2 = fake_store_path(tmp.path(), "path-b");
538
539        pm.set(&sp1).unwrap();
540        pm.set(&sp2).unwrap();
541
542        let generations = pm.list_generations().unwrap();
543        assert_eq!(generations[0].path, sp1);
544        assert_eq!(generations[1].path, sp2);
545    }
546
547    // ── parse_generation_number ──────────────────────────────
548
549    #[test]
550    fn parse_gen_number_valid() {
551        assert_eq!(parse_generation_number("system-42-link", "system").unwrap(), 42);
552        assert_eq!(parse_generation_number("default-1-link", "default").unwrap(), 1);
553    }
554
555    #[test]
556    fn parse_gen_number_invalid_format() {
557        assert!(parse_generation_number("system-abc-link", "system").is_err());
558        assert!(parse_generation_number("other-42-link", "system").is_err());
559        assert!(parse_generation_number("system-42", "system").is_err());
560        assert!(parse_generation_number("system--link", "system").is_err());
561    }
562
563    // ── system() constructor ─────────────────────────────────
564
565    #[test]
566    fn system_profile_has_expected_paths() {
567        let pm = ProfileManager::system();
568        assert_eq!(
569            pm.profile_path(),
570            PathBuf::from("/nix/var/nix/profiles/system")
571        );
572    }
573}