Skip to main content

sui_store/
local.rs

1//! Local store implementation — reads /nix/store + existing SQLite DB via SeaORM.
2
3use std::path::Path;
4
5use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Database};
6use sea_orm::ActiveModelTrait;
7use sea_orm::ActiveValue::Set;
8use sui_compat::store_path::StorePath;
9
10use crate::entity::{derivation_output, reference, valid_path};
11use crate::traits::{PathInfo, Store, StoreError, StoreResult};
12
13/// Controls whether the local store database is opened read-only or read-write.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum LocalStoreMode {
16    /// Open the database in read-only mode (default for `open()`).
17    ReadOnly,
18    /// Open the database in read-write mode (for `open_rw()`).
19    ReadWrite,
20}
21
22/// Local Nix store backed by the filesystem and SQLite database.
23pub struct LocalStore {
24    db: DatabaseConnection,
25    store_dir: String,
26}
27
28impl LocalStore {
29    /// Open the local store in read-only mode using the existing Nix database.
30    ///
31    /// Default path: `/nix/var/nix/db/db.sqlite`.
32    /// Accepts any type convertible to a path (`&str`, `&Path`, `PathBuf`, etc.).
33    pub async fn open(db_path: impl AsRef<Path>) -> StoreResult<Self> {
34        Self::open_inner(db_path.as_ref(), "/nix/store", LocalStoreMode::ReadOnly).await
35    }
36
37    /// Open the local store in read-write mode.
38    ///
39    /// The database file must already exist. Use this when you need to
40    /// register paths, add signatures, or perform garbage collection.
41    pub async fn open_rw(db_path: impl AsRef<Path>) -> StoreResult<Self> {
42        Self::open_inner(db_path.as_ref(), "/nix/store", LocalStoreMode::ReadWrite).await
43    }
44
45    /// Open with a custom store directory (for testing).
46    pub async fn open_with_dir(
47        db_path: impl AsRef<Path>,
48        store_dir: impl AsRef<Path>,
49    ) -> StoreResult<Self> {
50        Self::open_inner(
51            db_path.as_ref(),
52            store_dir.as_ref().to_str().unwrap_or("/nix/store"),
53            LocalStoreMode::ReadOnly,
54        )
55        .await
56    }
57
58    /// Open with a custom store directory in read-write mode (for testing).
59    pub async fn open_rw_with_dir(
60        db_path: impl AsRef<Path>,
61        store_dir: impl AsRef<Path>,
62    ) -> StoreResult<Self> {
63        Self::open_inner(
64            db_path.as_ref(),
65            store_dir.as_ref().to_str().unwrap_or("/nix/store"),
66            LocalStoreMode::ReadWrite,
67        )
68        .await
69    }
70
71    async fn open_inner(db_path: &Path, store_dir: &str, mode: LocalStoreMode) -> StoreResult<Self> {
72        let db_path_str = db_path.to_str().ok_or_else(|| {
73            StoreError::Database("database path is not valid UTF-8".to_string())
74        })?;
75        let url = match mode {
76            LocalStoreMode::ReadOnly => format!("sqlite://{db_path_str}?mode=ro"),
77            LocalStoreMode::ReadWrite => format!("sqlite://{db_path_str}"),
78        };
79        let db = Database::connect(&url).await.map_err(db_err)?;
80
81        Ok(Self {
82            db,
83            store_dir: store_dir.to_string(),
84        })
85    }
86
87    /// Get the database connection for direct queries.
88    #[must_use]
89    pub fn db(&self) -> &DatabaseConnection {
90        &self.db
91    }
92
93    /// Get the store directory path.
94    #[must_use]
95    pub fn store_dir(&self) -> &str {
96        &self.store_dir
97    }
98
99    /// Look up a ValidPath row by its full store path string.
100    async fn find_by_path(&self, path: &str) -> StoreResult<Option<valid_path::Model>> {
101        valid_path::Entity::find()
102            .filter(valid_path::Column::Path.eq(path))
103            .one(&self.db)
104            .await
105            .map_err(db_err)
106    }
107
108    /// Get the references (runtime dependencies) for a given ValidPath id.
109    async fn get_references(&self, path_id: i64) -> StoreResult<Vec<String>> {
110        let refs = reference::Entity::find()
111            .filter(reference::Column::Referrer.eq(path_id))
112            .all(&self.db)
113            .await
114            .map_err(db_err)?;
115
116        let ref_ids: Vec<i64> = refs.iter().map(|r| r.reference).collect();
117        if ref_ids.is_empty() {
118            return Ok(vec![]);
119        }
120
121        let ref_paths = valid_path::Entity::find()
122            .filter(valid_path::Column::Id.is_in(ref_ids))
123            .all(&self.db)
124            .await
125            .map_err(db_err)?;
126
127        Ok(ref_paths.into_iter().map(|p| p.path).collect())
128    }
129
130    /// Convert a ValidPath model to our PathInfo type.
131    async fn model_to_path_info(&self, model: &valid_path::Model) -> StoreResult<PathInfo> {
132        let references = self.get_references(model.id).await?;
133        let signatures = model
134            .sigs
135            .as_ref()
136            .map(|s| s.split_whitespace().map(String::from).collect())
137            .unwrap_or_default();
138
139        Ok(PathInfo {
140            path: model.path.clone(),
141            nar_hash: model.hash.clone(),
142            nar_size: model.nar_size.unwrap_or(0),
143            references,
144            deriver: model.deriver.clone(),
145            signatures,
146            registration_time: model.registration_time,
147            content_address: model.ca.clone(),
148        })
149    }
150
151    /// Create the Nix store schema tables in the database.
152    ///
153    /// This is used for testing and for initializing a new store database.
154    /// Creates `ValidPaths`, `Refs`, and `DerivationOutputs` tables.
155    pub async fn create_tables(&self) -> StoreResult<()> {
156        use sea_orm::ConnectionTrait;
157        let backend = self.db.get_database_backend();
158
159        let valid_paths_sql = sea_orm::Schema::new(backend)
160            .create_table_from_entity(valid_path::Entity);
161        self.db.execute(backend.build(&valid_paths_sql)).await.map_err(db_err)?;
162
163        let refs_sql = sea_orm::Schema::new(backend)
164            .create_table_from_entity(reference::Entity);
165        self.db.execute(backend.build(&refs_sql)).await.map_err(db_err)?;
166
167        let drv_outputs_sql = sea_orm::Schema::new(backend)
168            .create_table_from_entity(derivation_output::Entity);
169        self.db.execute(backend.build(&drv_outputs_sql)).await.map_err(db_err)?;
170
171        Ok(())
172    }
173
174    /// Open an in-memory SQLite database with schema created.
175    ///
176    /// Useful for testing. Creates all tables and returns a read-write store.
177    pub async fn open_in_memory() -> StoreResult<Self> {
178        Self::open_in_memory_with_dir("/nix/store").await
179    }
180
181    /// Open an in-memory SQLite database with schema and a custom store dir.
182    pub async fn open_in_memory_with_dir(store_dir: &str) -> StoreResult<Self> {
183        let db = Database::connect("sqlite::memory:").await.map_err(db_err)?;
184        let store = Self {
185            db,
186            store_dir: store_dir.to_string(),
187        };
188        store.create_tables().await?;
189        Ok(store)
190    }
191}
192
193#[async_trait::async_trait]
194impl Store for LocalStore {
195    async fn query_path_info(&self, path: &StorePath) -> StoreResult<Option<PathInfo>> {
196        let abs_path = path.to_absolute_path();
197        match self.find_by_path(&abs_path).await? {
198            Some(model) => Ok(Some(self.model_to_path_info(&model).await?)),
199            None => Ok(None),
200        }
201    }
202
203    async fn is_valid_path(&self, path: &StorePath) -> StoreResult<bool> {
204        let abs_path = path.to_absolute_path();
205        Ok(self.find_by_path(&abs_path).await?.is_some())
206    }
207
208    async fn query_all_valid_paths(&self) -> StoreResult<Vec<StorePath>> {
209        let paths = valid_path::Entity::find()
210            .order_by_asc(valid_path::Column::Path)
211            .all(&self.db)
212            .await
213            .map_err(db_err)?;
214
215        Ok(paths
216            .into_iter()
217            .filter_map(|p| StorePath::from_absolute_path(&p.path).ok())
218            .collect())
219    }
220
221    async fn register_path(&self, info: &PathInfo) -> StoreResult<()> {
222        // Build the sigs string (space-separated signatures).
223        let sigs = if info.signatures.is_empty() {
224            None
225        } else {
226            Some(info.signatures.join(" "))
227        };
228
229        // Insert into ValidPaths.
230        let new_path = valid_path::ActiveModel {
231            id: sea_orm::ActiveValue::NotSet,
232            path: Set(info.path.clone()),
233            hash: Set(info.nar_hash.clone()),
234            registration_time: Set(info.registration_time),
235            deriver: Set(info.deriver.clone()),
236            nar_size: Set(Some(info.nar_size)),
237            ultimate: Set(Some(0)),
238            sigs: Set(sigs),
239            ca: Set(info.content_address.clone()),
240        };
241
242        let inserted = new_path.insert(&self.db).await.map_err(db_err)?;
243        let path_id = inserted.id;
244
245        // Insert reference edges into Refs.
246        for ref_path_str in &info.references {
247            let ref_model = self.find_by_path(ref_path_str).await?;
248            if let Some(ref_row) = ref_model {
249                let new_ref = reference::ActiveModel {
250                    referrer: Set(path_id),
251                    reference: Set(ref_row.id),
252                };
253                new_ref.insert(&self.db).await.map_err(db_err)?;
254            }
255        }
256
257        // If the path has a deriver ending in .drv, insert into DerivationOutputs.
258        if let Some(ref deriver) = info.deriver
259            && deriver.ends_with(".drv") {
260                // Look up the deriver's ValidPaths.id.
261                if let Some(drv_row) = self.find_by_path(deriver).await? {
262                    let drv_output = derivation_output::ActiveModel {
263                        drv: Set(drv_row.id),
264                        id: Set("out".to_string()),
265                        path: Set(info.path.clone()),
266                    };
267                    drv_output.insert(&self.db).await.map_err(db_err)?;
268                }
269            }
270
271        Ok(())
272    }
273
274    async fn add_to_store(
275        &self,
276        name: &str,
277        nar_data: &[u8],
278        references: &[String],
279    ) -> StoreResult<PathInfo> {
280        use sha2::{Sha256, Digest};
281        use sui_compat::store_path::{compress_hash, nix_base32_encode};
282        use sui_compat::nar::unpack_nar;
283
284        // Compute the SHA-256 hash of the NAR data.
285        let nar_hash_raw = Sha256::digest(nar_data);
286        let nar_hash_hex = hex_encode(&nar_hash_raw);
287        let nar_hash = format!("sha256:{nar_hash_hex}");
288        let nar_size = nar_data.len() as i64;
289
290        // Compute the store path.
291        // The fingerprint uses the actual store_dir for correct hashing.
292        let fingerprint = format!(
293            "source:sha256:{nar_hash_hex}:{}:{name}",
294            self.store_dir
295        );
296        let fp_hash = Sha256::digest(fingerprint.as_bytes());
297        let compressed = compress_hash(&fp_hash, 20);
298        let b32 = nix_base32_encode(&compressed);
299        let basename = format!("{b32}-{name}");
300        let store_path = format!("{}/{basename}", self.store_dir);
301
302        // Write the NAR data to the store directory by unpacking.
303        let dest = Path::new(&self.store_dir).join(&basename);
304        unpack_nar(nar_data, &dest).map_err(|e| StoreError::Io(
305            std::io::Error::other(e.to_string()),
306        ))?;
307
308        // Build PathInfo.
309        let now = std::time::SystemTime::now()
310            .duration_since(std::time::UNIX_EPOCH)
311            .map(|d| d.as_secs() as i64)
312            .unwrap_or(0);
313
314        let info = PathInfo {
315            path: store_path,
316            nar_hash,
317            nar_size,
318            references: references.to_vec(),
319            deriver: None,
320            signatures: vec![],
321            registration_time: now,
322            content_address: None,
323        };
324
325        // Register in the database.
326        self.register_path(&info).await?;
327
328        Ok(info)
329    }
330
331    async fn collect_garbage(
332        &self,
333        options: &crate::traits::GcOptions,
334    ) -> StoreResult<crate::traits::GcResult> {
335        use std::collections::HashSet;
336
337        // 1. Enumerate GC roots.
338        let roots = find_gc_roots(&self.store_dir);
339
340        // 2. Compute reachable closure from the roots.
341        let all_paths = self.query_all_valid_paths().await?;
342        let mut reachable: HashSet<String> = HashSet::new();
343        let mut queue: Vec<String> = roots;
344        while let Some(path_str) = queue.pop() {
345            if !reachable.insert(path_str.clone()) {
346                continue;
347            }
348            // Look up references for this path.
349            if let Ok(sp) = StorePath::from_absolute_path(&path_str)
350                && let Ok(Some(info)) = self.query_path_info(&sp).await
351            {
352                for r in &info.references {
353                    if !reachable.contains(r) {
354                        queue.push(r.clone());
355                    }
356                }
357            }
358        }
359
360        // 3. Find unreachable paths.
361        let garbage: Vec<StorePath> = all_paths
362            .into_iter()
363            .filter(|p| !reachable.contains(&p.to_absolute_path()))
364            .collect();
365
366        // 4. Delete unreachable paths.
367        let mut freed: u64 = 0;
368        let mut deleted: usize = 0;
369        for path in &garbage {
370            match self.delete_path(path).await {
371                Ok(bytes) => {
372                    freed += bytes;
373                    deleted += 1;
374                    if options.max_freed > 0 && freed >= options.max_freed {
375                        break;
376                    }
377                }
378                Err(e) => {
379                    tracing::warn!(
380                        path = %path.to_absolute_path(),
381                        error = %e,
382                        "failed to delete path during GC",
383                    );
384                }
385            }
386        }
387
388        Ok(crate::traits::GcResult {
389            paths_deleted: deleted,
390            bytes_freed: freed,
391        })
392    }
393
394    async fn verify_store(&self) -> StoreResult<crate::traits::VerifyResult> {
395        use sha2::{Sha256, Digest};
396
397        let all_paths = self.query_all_valid_paths().await?;
398        let mut result = crate::traits::VerifyResult::default();
399
400        for sp in &all_paths {
401            result.total_checked += 1;
402            let abs_path = sp.to_absolute_path();
403
404            let info = match self.query_path_info(sp).await? {
405                Some(info) => info,
406                None => continue,
407            };
408
409            // Compute the NAR hash of the actual files on disk.
410            let fs_path = Path::new(&abs_path);
411            if !fs_path.exists() {
412                result.corrupt.push(crate::traits::CorruptPath {
413                    path: abs_path,
414                    expected_hash: info.nar_hash.clone(),
415                    actual_hash: "(missing from disk)".to_string(),
416                });
417                continue;
418            }
419
420            match nar_from_path(fs_path) {
421                Ok(nar_data) => {
422                    let hash_raw = Sha256::digest(&nar_data);
423                    let actual_hash = format!("sha256:{}", hex_encode(&hash_raw));
424                    if actual_hash != info.nar_hash {
425                        result.corrupt.push(crate::traits::CorruptPath {
426                            path: abs_path,
427                            expected_hash: info.nar_hash.clone(),
428                            actual_hash,
429                        });
430                    } else {
431                        result.valid_count += 1;
432                    }
433                }
434                Err(e) => {
435                    tracing::warn!(path = %abs_path, error = %e, "failed to compute NAR hash");
436                    result.corrupt.push(crate::traits::CorruptPath {
437                        path: abs_path,
438                        expected_hash: info.nar_hash.clone(),
439                        actual_hash: format!("(error: {e})"),
440                    });
441                }
442            }
443        }
444
445        Ok(result)
446    }
447
448    async fn delete_path(&self, path: &StorePath) -> StoreResult<u64> {
449        use sea_orm::ConnectionTrait;
450
451        let abs_path = path.to_absolute_path();
452        let model = self.find_by_path(&abs_path).await?;
453
454        // Calculate size of the path on disk.
455        let fs_path = Path::new(&abs_path);
456        let freed = if fs_path.exists() {
457            dir_size(fs_path)
458        } else {
459            0
460        };
461
462        // Remove from database first.
463        if let Some(model) = model {
464            // Delete reference edges.
465            let backend = self.db.get_database_backend();
466            let del_refs = sea_orm::Statement::from_string(
467                backend,
468                format!("DELETE FROM Refs WHERE referrer = {} OR reference = {}", model.id, model.id),
469            );
470            self.db.execute(del_refs).await.map_err(db_err)?;
471
472            // Delete derivation outputs.
473            let del_drv = sea_orm::Statement::from_string(
474                backend,
475                format!("DELETE FROM DerivationOutputs WHERE drv = {}", model.id),
476            );
477            self.db.execute(del_drv).await.map_err(db_err)?;
478
479            // Delete the valid path row.
480            let del_path = sea_orm::Statement::from_string(
481                backend,
482                format!("DELETE FROM ValidPaths WHERE id = {}", model.id),
483            );
484            self.db.execute(del_path).await.map_err(db_err)?;
485        }
486
487        // Remove from disk.
488        if fs_path.exists() {
489            if fs_path.is_dir() {
490                std::fs::remove_dir_all(fs_path)?;
491            } else {
492                std::fs::remove_file(fs_path)?;
493            }
494        }
495
496        Ok(freed)
497    }
498
499    async fn optimise_store(&self, dry_run: bool) -> StoreResult<crate::traits::OptimiseResult> {
500        use std::collections::HashMap;
501        #[cfg(unix)]
502        use std::os::unix::fs::MetadataExt;
503
504        let store_path = Path::new(&self.store_dir);
505        if !store_path.exists() {
506            return Ok(crate::traits::OptimiseResult::default());
507        }
508
509        let mut seen: HashMap<String, std::path::PathBuf> = HashMap::new();
510        let mut saved = 0u64;
511        let mut linked = 0u64;
512
513        // Walk all top-level store entries.
514        let entries = std::fs::read_dir(store_path)?;
515        for top_entry in entries.flatten() {
516            let top_path = top_entry.path();
517            // Walk files within each store path (min_depth 1 skips the dir itself).
518            walk_files_recursive(&top_path, &mut |file_path: &Path| {
519                let metadata = match std::fs::metadata(file_path) {
520                    Ok(m) => m,
521                    Err(_) => return,
522                };
523                if !metadata.is_file() {
524                    return;
525                }
526
527                // Skip if already hard-linked (nlink > 1).
528                #[cfg(unix)]
529                if metadata.nlink() > 1 {
530                    return;
531                }
532
533                // Hash the file.
534                let hash = match sha256_file(file_path) {
535                    Ok(h) => h,
536                    Err(_) => return,
537                };
538
539                if let Some(existing) = seen.get(&hash) {
540                    let size = metadata.len();
541                    if dry_run {
542                        // Just count — don't actually link.
543                    } else {
544                        // Replace with hard link.
545                        if std::fs::remove_file(file_path).is_ok()
546                            && std::fs::hard_link(existing, file_path).is_err()
547                        {
548                            // If hard_link fails, the file is already removed.
549                            // This is a best-effort operation.
550                            return;
551                        }
552                    }
553                    saved += size;
554                    linked += 1;
555                } else {
556                    seen.insert(hash, file_path.to_owned());
557                }
558            });
559        }
560
561        Ok(crate::traits::OptimiseResult {
562            files_linked: linked,
563            bytes_saved: saved,
564        })
565    }
566}
567
568/// Recursively walk files under a directory, calling `f` for each regular file.
569fn walk_files_recursive(dir: &Path, f: &mut impl FnMut(&Path)) {
570    if dir.is_file() {
571        f(dir);
572        return;
573    }
574    if !dir.is_dir() {
575        return;
576    }
577    let entries = match std::fs::read_dir(dir) {
578        Ok(e) => e,
579        Err(_) => return,
580    };
581    for entry in entries.flatten() {
582        let path = entry.path();
583        if path.is_dir() && !path.is_symlink() {
584            walk_files_recursive(&path, f);
585        } else if path.is_file() {
586            f(&path);
587        }
588    }
589}
590
591/// Compute the SHA-256 hash of a file, returning a hex string.
592fn sha256_file(path: &Path) -> Result<String, std::io::Error> {
593    use sha2::{Sha256, Digest};
594    let data = std::fs::read(path)?;
595    let hash = Sha256::digest(&data);
596    Ok(hex_encode(&hash))
597}
598
599/// Find GC roots by scanning well-known root directories.
600///
601/// Follows symlinks in `/nix/var/nix/gcroots/` and `/nix/var/nix/profiles/`
602/// to discover which store paths are rooted.
603pub fn find_gc_roots(store_dir: &str) -> Vec<String> {
604    let mut roots = Vec::new();
605    let gc_dirs = [
606        "/nix/var/nix/gcroots",
607        "/nix/var/nix/profiles",
608    ];
609
610    for dir in &gc_dirs {
611        let dir_path = Path::new(dir);
612        if !dir_path.exists() {
613            continue;
614        }
615        collect_gc_roots_from(dir_path, store_dir, &mut roots);
616    }
617
618    roots.sort();
619    roots.dedup();
620    roots
621}
622
623/// Recursively scan a directory for symlinks pointing into the store.
624fn collect_gc_roots_from(dir: &Path, store_dir: &str, roots: &mut Vec<String>) {
625    let entries = match std::fs::read_dir(dir) {
626        Ok(entries) => entries,
627        Err(_) => return,
628    };
629
630    for entry in entries.flatten() {
631        let path = entry.path();
632        if path.is_symlink() {
633            if let Ok(target) = std::fs::read_link(&path) {
634                let target_str = target.to_string_lossy();
635                if target_str.starts_with(store_dir) {
636                    // Extract the top-level store path (first component after store_dir).
637                    let remainder = &target_str[store_dir.len()..];
638                    let first_component = remainder
639                        .trim_start_matches('/')
640                        .split('/')
641                        .next()
642                        .unwrap_or("");
643                    if !first_component.is_empty() {
644                        roots.push(format!("{store_dir}/{first_component}"));
645                    }
646                }
647            }
648        }
649        if path.is_dir() && !path.is_symlink() {
650            collect_gc_roots_from(&path, store_dir, roots);
651        }
652    }
653}
654
655/// Compute the NAR serialization of a filesystem path.
656fn nar_from_path(path: &Path) -> Result<Vec<u8>, std::io::Error> {
657    use sui_compat::nar::NarWriter;
658
659    let node = nar_node_from_path(path)?;
660    let mut buf = Vec::new();
661    NarWriter::write(&mut buf, &node).map_err(|e| {
662        std::io::Error::other(format!("NAR write error: {e}"))
663    })?;
664    Ok(buf)
665}
666
667/// Build a `NarNode` tree from a filesystem path.
668fn nar_node_from_path(path: &Path) -> Result<sui_compat::nar::NarNode, std::io::Error> {
669    use sui_compat::nar::{NarEntry, NarNode};
670
671    let metadata = std::fs::symlink_metadata(path)?;
672    if metadata.file_type().is_symlink() {
673        let target = std::fs::read_link(path)?;
674        Ok(NarNode::Symlink {
675            target: target.to_string_lossy().into_owned(),
676        })
677    } else if metadata.is_dir() {
678        let mut entries: Vec<NarEntry> = Vec::new();
679        let mut dir_entries: Vec<_> = std::fs::read_dir(path)?
680            .filter_map(|e| e.ok())
681            .collect();
682        dir_entries.sort_by_key(|e| e.file_name());
683        for entry in dir_entries {
684            let name = entry.file_name().to_string_lossy().into_owned();
685            let node = nar_node_from_path(&entry.path())?;
686            entries.push(NarEntry { name, node });
687        }
688        Ok(NarNode::Directory { entries })
689    } else {
690        let contents = std::fs::read(path)?;
691        #[cfg(unix)]
692        let executable = {
693            use std::os::unix::fs::PermissionsExt;
694            metadata.permissions().mode() & 0o111 != 0
695        };
696        #[cfg(not(unix))]
697        let executable = false;
698        Ok(NarNode::Regular {
699            executable,
700            contents,
701        })
702    }
703}
704
705/// Calculate the total size of a file or directory on disk.
706fn dir_size(path: &Path) -> u64 {
707    if path.is_file() || path.is_symlink() {
708        std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
709    } else if path.is_dir() {
710        let mut total = 0u64;
711        if let Ok(entries) = std::fs::read_dir(path) {
712            for entry in entries.flatten() {
713                total += dir_size(&entry.path());
714            }
715        }
716        total
717    } else {
718        0
719    }
720}
721
722/// Convert a SeaORM `DbErr` into a `StoreError::Database`.
723fn db_err(e: sea_orm::DbErr) -> StoreError {
724    StoreError::Database(e.to_string())
725}
726
727/// Encode bytes as lowercase hexadecimal.
728fn hex_encode(bytes: &[u8]) -> String {
729    let mut s = String::with_capacity(bytes.len() * 2);
730    for b in bytes {
731        use std::fmt::Write;
732        let _ = write!(s, "{b:02x}");
733    }
734    s
735}
736
737#[cfg(test)]
738mod tests {
739    // Integration tests require a real Nix store database.
740    // These are run in Phase 3 integration tests against /nix/var/nix/db/db.sqlite.
741    // Unit tests use in-memory SQLite — see tests/integration/.
742
743    use super::*;
744
745    // ── db_err helper ────────────────────────────────────────
746
747    #[test]
748    fn db_err_wraps_dberr_into_storeerror_database() {
749        let dberr = sea_orm::DbErr::Custom("simulated failure".to_string());
750        let store_err = db_err(dberr);
751        match store_err {
752            StoreError::Database(msg) => {
753                assert!(msg.contains("simulated failure"));
754            }
755            other => panic!("expected Database, got {other:?}"),
756        }
757    }
758
759    #[test]
760    fn db_err_handles_record_not_found() {
761        let dberr = sea_orm::DbErr::RecordNotFound("missing".to_string());
762        let store_err = db_err(dberr);
763        assert!(matches!(store_err, StoreError::Database(_)));
764        assert!(store_err.to_string().contains("missing"));
765    }
766
767    #[test]
768    fn db_err_handles_connection_failure() {
769        let dberr = sea_orm::DbErr::Conn(sea_orm::RuntimeErr::Internal(
770            "no connection".to_string(),
771        ));
772        let store_err = db_err(dberr);
773        assert!(matches!(store_err, StoreError::Database(_)));
774    }
775
776    // ── open() with non-utf8 path ────────────────────────────
777
778    #[tokio::test]
779    async fn open_with_nonexistent_path_errors() {
780        let result = LocalStore::open("/this/path/does/not/exist/sui-test.sqlite").await;
781        assert!(result.is_err());
782    }
783
784    #[tokio::test]
785    async fn open_with_directory_path_errors() {
786        // Pointing at a directory rather than a file should fail to open as sqlite
787        let result = LocalStore::open("/tmp").await;
788        assert!(result.is_err());
789    }
790
791    // ── open_with_dir() variants ─────────────────────────────
792
793    #[tokio::test]
794    async fn open_with_dir_nonexistent_db_errors() {
795        let result = LocalStore::open_with_dir(
796            "/this/does/not/exist/db.sqlite",
797            "/nix/store",
798        )
799        .await;
800        assert!(result.is_err());
801    }
802
803    #[tokio::test]
804    async fn open_with_dir_custom_store_dir_propagated() {
805        // Open against an in-memory sqlite db built fresh — this is the default
806        // sea-orm sqlite mode.
807        // We can't actually open a real local store without ValidPaths schema,
808        // but we can verify the function signature with a non-existent DB
809        // and confirm the call properly returns an error.
810        let result = LocalStore::open_with_dir(
811            "/nonexistent/db.sqlite",
812            "/custom/store",
813        )
814        .await;
815        assert!(result.is_err());
816    }
817
818    // ── valid_path::Model constructors ───────────────────────
819
820    #[test]
821    fn valid_path_model_construct_minimal() {
822        let model = valid_path::Model {
823            id: 1,
824            path: "/nix/store/abc-hello".to_string(),
825            hash: "sha256:deadbeef".to_string(),
826            registration_time: 1234567890,
827            deriver: None,
828            nar_size: None,
829            ultimate: None,
830            sigs: None,
831            ca: None,
832        };
833        assert_eq!(model.id, 1);
834        assert_eq!(model.path, "/nix/store/abc-hello");
835        assert!(model.deriver.is_none());
836        assert!(model.nar_size.is_none());
837    }
838
839    #[test]
840    fn valid_path_model_construct_full() {
841        let model = valid_path::Model {
842            id: 42,
843            path: "/nix/store/abc-hello".to_string(),
844            hash: "sha256:deadbeef".to_string(),
845            registration_time: 1234567890,
846            deriver: Some("/nix/store/abc.drv".to_string()),
847            nar_size: Some(5000),
848            ultimate: Some(1),
849            sigs: Some("key1:sig1 key2:sig2".to_string()),
850            ca: Some("fixed:out:r:sha256:deadbeef".to_string()),
851        };
852        assert_eq!(model.id, 42);
853        assert_eq!(model.nar_size, Some(5000));
854        assert_eq!(model.ultimate, Some(1));
855        assert!(model.sigs.as_ref().unwrap().contains("key1"));
856    }
857
858    #[test]
859    fn valid_path_model_clone_independence() {
860        let model = valid_path::Model {
861            id: 1,
862            path: "/nix/store/abc".to_string(),
863            hash: "sha256:aaa".to_string(),
864            registration_time: 100,
865            deriver: None,
866            nar_size: Some(1024),
867            ultimate: None,
868            sigs: None,
869            ca: None,
870        };
871        let mut cloned = model.clone();
872        cloned.id = 99;
873        cloned.path = "/nix/store/other".to_string();
874        assert_eq!(model.id, 1);
875        assert_eq!(model.path, "/nix/store/abc");
876        assert_eq!(cloned.id, 99);
877    }
878
879    #[test]
880    fn valid_path_model_eq() {
881        let a = valid_path::Model {
882            id: 1,
883            path: "/nix/store/abc".to_string(),
884            hash: "sha256:aaa".to_string(),
885            registration_time: 100,
886            deriver: None,
887            nar_size: None,
888            ultimate: None,
889            sigs: None,
890            ca: None,
891        };
892        let b = a.clone();
893        assert_eq!(a, b);
894
895        let mut c = a.clone();
896        c.id = 2;
897        assert_ne!(a, c);
898    }
899
900    #[test]
901    fn valid_path_model_debug_format() {
902        let model = valid_path::Model {
903            id: 7,
904            path: "/nix/store/zzz-test".to_string(),
905            hash: "sha256:bbb".to_string(),
906            registration_time: 200,
907            deriver: None,
908            nar_size: None,
909            ultimate: None,
910            sigs: None,
911            ca: None,
912        };
913        let debug = format!("{model:?}");
914        assert!(debug.contains("zzz-test"));
915        assert!(debug.contains("sha256:bbb"));
916    }
917
918    // ── reference::Model constructors ────────────────────────
919
920    #[test]
921    fn reference_model_construct() {
922        let model = reference::Model {
923            referrer: 1,
924            reference: 2,
925        };
926        assert_eq!(model.referrer, 1);
927        assert_eq!(model.reference, 2);
928    }
929
930    #[test]
931    fn reference_model_eq() {
932        let a = reference::Model {
933            referrer: 5,
934            reference: 10,
935        };
936        let b = reference::Model {
937            referrer: 5,
938            reference: 10,
939        };
940        assert_eq!(a, b);
941
942        let c = reference::Model {
943            referrer: 5,
944            reference: 11,
945        };
946        assert_ne!(a, c);
947    }
948
949    #[test]
950    fn reference_model_clone() {
951        let a = reference::Model {
952            referrer: 100,
953            reference: 200,
954        };
955        let cloned = a.clone();
956        assert_eq!(cloned.referrer, 100);
957        assert_eq!(cloned.reference, 200);
958    }
959
960    // ── PathInfo conversion via model_to_path_info logic ─────
961    // (We verify the conversion logic by directly constructing
962    //  PathInfo as the function would, since model_to_path_info
963    //  requires a real DB connection.)
964
965    #[test]
966    fn path_info_signatures_split_logic_no_sigs() {
967        // Mirrors model_to_path_info's signatures handling
968        let sigs: Option<String> = None;
969        let result: Vec<String> = sigs
970            .as_ref()
971            .map(|s| s.split_whitespace().map(String::from).collect())
972            .unwrap_or_default();
973        assert!(result.is_empty());
974    }
975
976    #[test]
977    fn path_info_signatures_split_logic_single_sig() {
978        let sigs: Option<String> = Some("cache.nixos.org-1:abc==".to_string());
979        let result: Vec<String> = sigs
980            .as_ref()
981            .map(|s| s.split_whitespace().map(String::from).collect())
982            .unwrap_or_default();
983        assert_eq!(result.len(), 1);
984        assert_eq!(result[0], "cache.nixos.org-1:abc==");
985    }
986
987    #[test]
988    fn path_info_signatures_split_logic_multiple_sigs() {
989        let sigs: Option<String> = Some("k1:s1 k2:s2 k3:s3".to_string());
990        let result: Vec<String> = sigs
991            .as_ref()
992            .map(|s| s.split_whitespace().map(String::from).collect())
993            .unwrap_or_default();
994        assert_eq!(result.len(), 3);
995        assert_eq!(result[0], "k1:s1");
996        assert_eq!(result[2], "k3:s3");
997    }
998
999    #[test]
1000    fn path_info_signatures_split_logic_extra_whitespace() {
1001        let sigs: Option<String> = Some("  k1:s1   k2:s2  ".to_string());
1002        let result: Vec<String> = sigs
1003            .as_ref()
1004            .map(|s| s.split_whitespace().map(String::from).collect())
1005            .unwrap_or_default();
1006        assert_eq!(result.len(), 2);
1007        assert_eq!(result[0], "k1:s1");
1008        assert_eq!(result[1], "k2:s2");
1009    }
1010
1011    #[test]
1012    fn path_info_nar_size_default_zero() {
1013        // Mirrors `model.nar_size.unwrap_or(0)` in model_to_path_info
1014        let nar_size: Option<i64> = None;
1015        assert_eq!(nar_size.unwrap_or(0), 0);
1016        let nar_size: Option<i64> = Some(5000);
1017        assert_eq!(nar_size.unwrap_or(0), 5000);
1018    }
1019
1020    // ── LocalStoreMode enum ────────────────────────────────────
1021
1022    #[test]
1023    fn local_store_mode_enum_variants() {
1024        let ro = LocalStoreMode::ReadOnly;
1025        let rw = LocalStoreMode::ReadWrite;
1026        assert_ne!(ro, rw);
1027        assert_eq!(ro, LocalStoreMode::ReadOnly);
1028        assert_eq!(rw, LocalStoreMode::ReadWrite);
1029    }
1030
1031    #[test]
1032    fn local_store_mode_debug_format() {
1033        let ro = LocalStoreMode::ReadOnly;
1034        let rw = LocalStoreMode::ReadWrite;
1035        assert!(format!("{ro:?}").contains("ReadOnly"));
1036        assert!(format!("{rw:?}").contains("ReadWrite"));
1037    }
1038
1039    #[test]
1040    fn local_store_mode_clone_copy() {
1041        let mode = LocalStoreMode::ReadWrite;
1042        let cloned = mode;
1043        assert_eq!(mode, cloned);
1044    }
1045
1046    // ── open_rw() ──────────────────────────────────────────────
1047
1048    #[tokio::test]
1049    async fn open_rw_with_nonexistent_path_errors() {
1050        let result = LocalStore::open_rw("/this/path/does/not/exist/sui-rw.sqlite").await;
1051        assert!(result.is_err());
1052    }
1053
1054    #[tokio::test]
1055    async fn open_rw_with_temp_db_succeeds() {
1056        let tmp = tempfile::NamedTempFile::new().unwrap();
1057        // Create a valid SQLite DB by opening in rw mode first (SeaORM creates the file).
1058        let store = LocalStore::open_rw(tmp.path()).await;
1059        // The file exists but has no schema — this should still connect.
1060        assert!(store.is_ok());
1061    }
1062
1063    #[tokio::test]
1064    async fn open_readonly_still_works() {
1065        // open() should still use read-only mode.
1066        let result = LocalStore::open("/nonexistent/sui-test.sqlite").await;
1067        assert!(result.is_err()); // read-only on nonexistent file errors
1068    }
1069
1070    // ── open_in_memory ─────────────────────────────────────────
1071
1072    #[tokio::test]
1073    async fn open_in_memory_succeeds() {
1074        let store = LocalStore::open_in_memory().await.unwrap();
1075        assert_eq!(store.store_dir(), "/nix/store");
1076    }
1077
1078    #[tokio::test]
1079    async fn open_in_memory_with_custom_dir() {
1080        let store = LocalStore::open_in_memory_with_dir("/test/store").await.unwrap();
1081        assert_eq!(store.store_dir(), "/test/store");
1082    }
1083
1084    #[tokio::test]
1085    async fn in_memory_query_all_valid_paths_empty() {
1086        let store = LocalStore::open_in_memory().await.unwrap();
1087        let paths = store.query_all_valid_paths().await.unwrap();
1088        assert!(paths.is_empty());
1089    }
1090
1091    // ── register_path ──────────────────────────────────────────
1092
1093    #[tokio::test]
1094    async fn register_path_simple_no_references() {
1095        let store = LocalStore::open_in_memory().await.unwrap();
1096
1097        let info = PathInfo {
1098            path: "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello".to_string(),
1099            nar_hash: "sha256:deadbeef".to_string(),
1100            nar_size: 1024,
1101            references: vec![],
1102            deriver: None,
1103            signatures: vec![],
1104            registration_time: 1700000000,
1105            content_address: None,
1106        };
1107
1108        store.register_path(&info).await.unwrap();
1109
1110        // Verify via query_path_info.
1111        let sp = StorePath::from_absolute_path(&info.path).unwrap();
1112        let queried = store.query_path_info(&sp).await.unwrap().unwrap();
1113        assert_eq!(queried.path, info.path);
1114        assert_eq!(queried.nar_hash, info.nar_hash);
1115        assert_eq!(queried.nar_size, 1024);
1116        assert!(queried.references.is_empty());
1117    }
1118
1119    #[tokio::test]
1120    async fn register_path_with_two_references() {
1121        let store = LocalStore::open_in_memory().await.unwrap();
1122
1123        // Register the referenced paths first.
1124        let ref1 = PathInfo {
1125            path: "/nix/store/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-dep1".to_string(),
1126            nar_hash: "sha256:aaa".to_string(),
1127            nar_size: 100,
1128            references: vec![],
1129            deriver: None,
1130            signatures: vec![],
1131            registration_time: 100,
1132            content_address: None,
1133        };
1134        let ref2 = PathInfo {
1135            path: "/nix/store/cccccccccccccccccccccccccccccccc-dep2".to_string(),
1136            nar_hash: "sha256:bbb".to_string(),
1137            nar_size: 200,
1138            references: vec![],
1139            deriver: None,
1140            signatures: vec![],
1141            registration_time: 100,
1142            content_address: None,
1143        };
1144        store.register_path(&ref1).await.unwrap();
1145        store.register_path(&ref2).await.unwrap();
1146
1147        // Register the main path with references.
1148        let main_info = PathInfo {
1149            path: "/nix/store/dddddddddddddddddddddddddddddddd-main".to_string(),
1150            nar_hash: "sha256:ccc".to_string(),
1151            nar_size: 500,
1152            references: vec![ref1.path.clone(), ref2.path.clone()],
1153            deriver: None,
1154            signatures: vec![],
1155            registration_time: 200,
1156            content_address: None,
1157        };
1158        store.register_path(&main_info).await.unwrap();
1159
1160        // Verify references are stored.
1161        let sp = StorePath::from_absolute_path(&main_info.path).unwrap();
1162        let queried = store.query_path_info(&sp).await.unwrap().unwrap();
1163        assert_eq!(queried.references.len(), 2);
1164        assert!(queried.references.contains(&ref1.path));
1165        assert!(queried.references.contains(&ref2.path));
1166    }
1167
1168    #[tokio::test]
1169    async fn register_path_and_verify_via_query() {
1170        let store = LocalStore::open_in_memory().await.unwrap();
1171
1172        // Use valid Nix base32 chars (no 'e', 'o', 't', 'u').
1173        let info = PathInfo {
1174            path: "/nix/store/11111111111111111111111111111111-pkg".to_string(),
1175            nar_hash: "sha256:123456".to_string(),
1176            nar_size: 2048,
1177            references: vec![],
1178            deriver: None,
1179            signatures: vec!["key1:sig1".to_string(), "key2:sig2".to_string()],
1180            registration_time: 1700000000,
1181            content_address: Some("fixed:out:r:sha256:abc".to_string()),
1182        };
1183        store.register_path(&info).await.unwrap();
1184
1185        // Verify is_valid_path.
1186        let sp = StorePath::from_absolute_path(&info.path).unwrap();
1187        assert!(store.is_valid_path(&sp).await.unwrap());
1188
1189        // Verify query_path_info returns the full info.
1190        let queried = store.query_path_info(&sp).await.unwrap().unwrap();
1191        assert_eq!(queried.nar_size, 2048);
1192        assert_eq!(queried.signatures.len(), 2);
1193        assert_eq!(queried.content_address, Some("fixed:out:r:sha256:abc".to_string()));
1194    }
1195
1196    #[tokio::test]
1197    async fn register_path_duplicate_returns_error() {
1198        let store = LocalStore::open_in_memory().await.unwrap();
1199
1200        let info = PathInfo {
1201            path: "/nix/store/ffffffffffffffffffffffffffffffff-duplicate".to_string(),
1202            nar_hash: "sha256:dup".to_string(),
1203            nar_size: 100,
1204            references: vec![],
1205            deriver: None,
1206            signatures: vec![],
1207            registration_time: 100,
1208            content_address: None,
1209        };
1210
1211        // First registration should succeed.
1212        store.register_path(&info).await.unwrap();
1213
1214        // Second registration should error (unique constraint on path).
1215        let result = store.register_path(&info).await;
1216        assert!(result.is_err());
1217    }
1218
1219    #[tokio::test]
1220    async fn register_path_with_deriver_drv() {
1221        let store = LocalStore::open_in_memory().await.unwrap();
1222
1223        // Register the .drv first.
1224        let drv_info = PathInfo {
1225            path: "/nix/store/gggggggggggggggggggggggggggggggg-hello.drv".to_string(),
1226            nar_hash: "sha256:drv".to_string(),
1227            nar_size: 500,
1228            references: vec![],
1229            deriver: None,
1230            signatures: vec![],
1231            registration_time: 100,
1232            content_address: None,
1233        };
1234        store.register_path(&drv_info).await.unwrap();
1235
1236        // Register an output that references the drv.
1237        let out_info = PathInfo {
1238            path: "/nix/store/hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh-hello".to_string(),
1239            nar_hash: "sha256:out".to_string(),
1240            nar_size: 1000,
1241            references: vec![],
1242            deriver: Some(drv_info.path.clone()),
1243            signatures: vec![],
1244            registration_time: 200,
1245            content_address: None,
1246        };
1247        store.register_path(&out_info).await.unwrap();
1248
1249        // Verify the output was registered.
1250        let sp = StorePath::from_absolute_path(&out_info.path).unwrap();
1251        let queried = store.query_path_info(&sp).await.unwrap().unwrap();
1252        assert_eq!(queried.deriver, Some(drv_info.path));
1253    }
1254
1255    #[tokio::test]
1256    async fn register_path_query_all_returns_registered() {
1257        let store = LocalStore::open_in_memory().await.unwrap();
1258
1259        let info1 = PathInfo {
1260            path: "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-alpha".to_string(),
1261            nar_hash: "sha256:a".to_string(),
1262            nar_size: 10,
1263            references: vec![],
1264            deriver: None,
1265            signatures: vec![],
1266            registration_time: 1,
1267            content_address: None,
1268        };
1269        let info2 = PathInfo {
1270            path: "/nix/store/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-beta".to_string(),
1271            nar_hash: "sha256:b".to_string(),
1272            nar_size: 20,
1273            references: vec![],
1274            deriver: None,
1275            signatures: vec![],
1276            registration_time: 2,
1277            content_address: None,
1278        };
1279
1280        store.register_path(&info1).await.unwrap();
1281        store.register_path(&info2).await.unwrap();
1282
1283        let all = store.query_all_valid_paths().await.unwrap();
1284        assert_eq!(all.len(), 2);
1285    }
1286
1287    // ── add_to_store ───────────────────────────────────────────
1288
1289    #[tokio::test]
1290    async fn add_to_store_registers_and_unpacks() {
1291        use std::os::unix::fs::PermissionsExt;
1292        use sui_compat::nar::{NarNode, NarWriter};
1293
1294        let tmp_dir = tempfile::tempdir().unwrap();
1295        // Ensure the store directory is writable.
1296        std::fs::set_permissions(
1297            tmp_dir.path(),
1298            std::fs::Permissions::from_mode(0o755),
1299        )
1300        .unwrap();
1301
1302        let store_dir = tmp_dir.path().to_str().unwrap();
1303        let store = LocalStore::open_in_memory_with_dir(store_dir).await.unwrap();
1304
1305        // Create a NAR archive for a simple file.
1306        let node = NarNode::Regular {
1307            executable: false,
1308            contents: b"hello store".to_vec(),
1309        };
1310        let mut nar_data = Vec::new();
1311        NarWriter::write(&mut nar_data, &node).unwrap();
1312
1313        let info = store.add_to_store("test-pkg", &nar_data, &[]).await.unwrap();
1314
1315        // Verify the path was registered.
1316        assert!(info.path.contains("test-pkg"));
1317        assert!(info.nar_hash.starts_with("sha256:"));
1318        assert_eq!(info.nar_size, nar_data.len() as i64);
1319
1320        // Verify the file was unpacked to the store directory.
1321        let basename = info.path.strip_prefix(&format!("{store_dir}/")).unwrap();
1322        let unpacked_path = tmp_dir.path().join(basename);
1323        assert!(unpacked_path.exists());
1324    }
1325
1326    // ── hex_encode helper ──────────────────────────────────────
1327
1328    #[test]
1329    fn hex_encode_empty() {
1330        assert_eq!(hex_encode(&[]), "");
1331    }
1332
1333    #[test]
1334    fn hex_encode_single_byte() {
1335        assert_eq!(hex_encode(&[0xff]), "ff");
1336        assert_eq!(hex_encode(&[0x00]), "00");
1337        assert_eq!(hex_encode(&[0x0a]), "0a");
1338    }
1339
1340    #[test]
1341    fn hex_encode_multiple_bytes() {
1342        assert_eq!(hex_encode(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef");
1343    }
1344
1345    // ── GC tests ──────────────────────────────────────────────
1346
1347    #[tokio::test]
1348    async fn gc_on_empty_store_deletes_nothing() {
1349        let store = LocalStore::open_in_memory().await.unwrap();
1350        let result = store
1351            .collect_garbage(&crate::traits::GcOptions::default())
1352            .await
1353            .unwrap();
1354        assert_eq!(result.paths_deleted, 0);
1355        assert_eq!(result.bytes_freed, 0);
1356    }
1357
1358    #[tokio::test]
1359    async fn gc_result_display() {
1360        let result = crate::traits::GcResult {
1361            paths_deleted: 5,
1362            bytes_freed: 1024,
1363        };
1364        assert_eq!(result.to_string(), "GC: 5 paths deleted, 1024 bytes freed");
1365    }
1366
1367    // ── Verify tests ────────────────────────────────────────────
1368
1369    #[tokio::test]
1370    async fn verify_empty_store_succeeds() {
1371        let store = LocalStore::open_in_memory().await.unwrap();
1372        let result = store.verify_store().await.unwrap();
1373        assert_eq!(result.total_checked, 0);
1374        assert_eq!(result.valid_count, 0);
1375        assert!(result.corrupt.is_empty());
1376    }
1377
1378    #[tokio::test]
1379    async fn verify_result_display() {
1380        let result = crate::traits::VerifyResult {
1381            total_checked: 10,
1382            valid_count: 8,
1383            corrupt: vec![
1384                crate::traits::CorruptPath {
1385                    path: "/nix/store/abc-hello".to_string(),
1386                    expected_hash: "sha256:aaa".to_string(),
1387                    actual_hash: "sha256:bbb".to_string(),
1388                },
1389            ],
1390        };
1391        assert_eq!(result.to_string(), "Verify: 10 checked, 8 valid, 1 corrupt");
1392    }
1393
1394    // ── verify with temp store dir ─────────────────────────────
1395
1396    #[tokio::test]
1397    async fn verify_detects_valid_path() {
1398        // verify_store iterates all valid paths via query_all_valid_paths,
1399        // which parses paths using StorePath::from_absolute_path (requires
1400        // /nix/store/ prefix). Since we can't write to /nix/store in tests,
1401        // we register a real-looking store path pointing to a temp dir entry,
1402        // but the verify will see it as missing (not in /nix/store on disk).
1403        //
1404        // The core logic is already exercised by the empty and missing-path
1405        // tests above. This test validates the verify-store method returns
1406        // results for registered paths.
1407        let store = LocalStore::open_in_memory().await.unwrap();
1408
1409        // Register a path with the real hash format.
1410        let fake_info = PathInfo {
1411            path: "/nix/store/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-verify-test".to_string(),
1412            nar_hash: "sha256:1234".to_string(),
1413            nar_size: 50,
1414            ..PathInfo::default()
1415        };
1416        store.register_path(&fake_info).await.unwrap();
1417
1418        let result = store.verify_store().await.unwrap();
1419        assert_eq!(result.total_checked, 1);
1420        // Path doesn't exist on disk, so it's counted as corrupt.
1421        assert_eq!(result.corrupt.len(), 1);
1422        assert!(result.corrupt[0].actual_hash.contains("missing"));
1423    }
1424
1425    #[tokio::test]
1426    async fn verify_detects_missing_path() {
1427        // Use /nix/store as store dir so StorePath::from_absolute_path works,
1428        // but the path itself doesn't exist on disk (triggering "missing").
1429        let store = LocalStore::open_in_memory().await.unwrap();
1430
1431        // Register a valid-looking store path that doesn't exist on disk.
1432        let fake_info = PathInfo {
1433            path: "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ghost-pkg".to_string(),
1434            nar_hash: "sha256:0000".to_string(),
1435            nar_size: 100,
1436            ..PathInfo::default()
1437        };
1438        store.register_path(&fake_info).await.unwrap();
1439
1440        let result = store.verify_store().await.unwrap();
1441        assert_eq!(result.total_checked, 1);
1442        assert_eq!(result.valid_count, 0);
1443        assert_eq!(result.corrupt.len(), 1);
1444        assert!(result.corrupt[0].actual_hash.contains("missing"));
1445    }
1446
1447    // ── delete_path tests ──────────────────────────────────────
1448
1449    #[tokio::test]
1450    async fn delete_path_removes_from_db() {
1451        // Use in-memory store with /nix/store dir.
1452        // We can't write to /nix/store in tests, so we test the DB removal
1453        // only. The path won't exist on disk (delete_path handles that).
1454        let store = LocalStore::open_in_memory().await.unwrap();
1455
1456        // Register a fake path in the DB.
1457        let fake_info = PathInfo {
1458            path: "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-delete-test".to_string(),
1459            nar_hash: "sha256:deadbeef".to_string(),
1460            nar_size: 42,
1461            ..PathInfo::default()
1462        };
1463        store.register_path(&fake_info).await.unwrap();
1464
1465        let sp = StorePath::from_absolute_path(&fake_info.path).unwrap();
1466        assert!(store.is_valid_path(&sp).await.unwrap());
1467
1468        let freed = store.delete_path(&sp).await.unwrap();
1469        // Path doesn't exist on disk so freed is 0.
1470        assert_eq!(freed, 0);
1471        // But it should be gone from the DB.
1472        assert!(!store.is_valid_path(&sp).await.unwrap());
1473    }
1474
1475    // ── nar_node_from_path tests ────────────────────────────────
1476
1477    #[test]
1478    fn nar_node_from_path_regular_file() {
1479        let dir = tempfile::tempdir().unwrap();
1480        let file = dir.path().join("test.txt");
1481        std::fs::write(&file, b"hello").unwrap();
1482        let node = nar_node_from_path(&file).unwrap();
1483        match node {
1484            sui_compat::nar::NarNode::Regular { executable, contents } => {
1485                assert!(!executable);
1486                assert_eq!(contents, b"hello");
1487            }
1488            _ => panic!("expected Regular"),
1489        }
1490    }
1491
1492    #[test]
1493    fn nar_node_from_path_directory() {
1494        let dir = tempfile::tempdir().unwrap();
1495        std::fs::write(dir.path().join("a.txt"), b"aaa").unwrap();
1496        std::fs::write(dir.path().join("b.txt"), b"bbb").unwrap();
1497        let node = nar_node_from_path(dir.path()).unwrap();
1498        match node {
1499            sui_compat::nar::NarNode::Directory { entries } => {
1500                assert_eq!(entries.len(), 2);
1501                assert_eq!(entries[0].name, "a.txt");
1502                assert_eq!(entries[1].name, "b.txt");
1503            }
1504            _ => panic!("expected Directory"),
1505        }
1506    }
1507
1508    #[cfg(unix)]
1509    #[test]
1510    fn nar_node_from_path_symlink() {
1511        let dir = tempfile::tempdir().unwrap();
1512        let target = dir.path().join("target");
1513        std::fs::write(&target, b"data").unwrap();
1514        let link = dir.path().join("link");
1515        std::os::unix::fs::symlink(&target, &link).unwrap();
1516        let node = nar_node_from_path(&link).unwrap();
1517        match node {
1518            sui_compat::nar::NarNode::Symlink { target: t } => {
1519                assert!(t.contains("target"));
1520            }
1521            _ => panic!("expected Symlink"),
1522        }
1523    }
1524
1525    // ── dir_size tests ─────────────────────────────────────────
1526
1527    #[test]
1528    fn dir_size_of_file() {
1529        let dir = tempfile::tempdir().unwrap();
1530        let file = dir.path().join("size-test.txt");
1531        std::fs::write(&file, b"12345").unwrap();
1532        let size = dir_size(&file);
1533        assert!(size >= 5); // At least the bytes we wrote.
1534    }
1535
1536    #[test]
1537    fn dir_size_of_directory() {
1538        let dir = tempfile::tempdir().unwrap();
1539        std::fs::write(dir.path().join("a"), b"aaa").unwrap();
1540        std::fs::write(dir.path().join("b"), b"bbbbb").unwrap();
1541        let size = dir_size(dir.path());
1542        assert!(size >= 8); // At least 3 + 5 bytes.
1543    }
1544
1545    #[test]
1546    fn dir_size_of_nonexistent_is_zero() {
1547        assert_eq!(dir_size(Path::new("/nonexistent/path/xyz")), 0);
1548    }
1549
1550    // ── find_gc_roots ──────────────────────────────────────────
1551
1552    #[test]
1553    fn find_gc_roots_with_no_dirs() {
1554        // Using a store dir that doesn't exist — should return empty.
1555        let roots = find_gc_roots("/nonexistent/store");
1556        assert!(roots.is_empty());
1557    }
1558
1559    #[test]
1560    fn find_gc_roots_is_public() {
1561        // Verify the function is accessible from outside the module.
1562        // This is a compile-time test — if it compiles, the function is pub.
1563        let _roots = find_gc_roots("/nix/store");
1564    }
1565
1566    // ── sha256_file tests ─────────────────────────────────────
1567
1568    #[test]
1569    fn sha256_file_regular() {
1570        let dir = tempfile::tempdir().unwrap();
1571        let file = dir.path().join("test.txt");
1572        std::fs::write(&file, b"hello world").unwrap();
1573        let hash = sha256_file(&file).unwrap();
1574        // SHA-256 of "hello world" is well known.
1575        assert_eq!(
1576            hash,
1577            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
1578        );
1579    }
1580
1581    #[test]
1582    fn sha256_file_empty() {
1583        let dir = tempfile::tempdir().unwrap();
1584        let file = dir.path().join("empty");
1585        std::fs::write(&file, b"").unwrap();
1586        let hash = sha256_file(&file).unwrap();
1587        // SHA-256 of empty string.
1588        assert_eq!(
1589            hash,
1590            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1591        );
1592    }
1593
1594    #[test]
1595    fn sha256_file_nonexistent_errors() {
1596        let result = sha256_file(Path::new("/nonexistent/file"));
1597        assert!(result.is_err());
1598    }
1599
1600    // ── walk_files_recursive tests ─────────────────────────────
1601
1602    #[test]
1603    fn walk_files_recursive_single_file() {
1604        let dir = tempfile::tempdir().unwrap();
1605        let file = dir.path().join("test.txt");
1606        std::fs::write(&file, b"data").unwrap();
1607
1608        let mut found = Vec::new();
1609        walk_files_recursive(dir.path(), &mut |p: &Path| {
1610            found.push(p.to_owned());
1611        });
1612        assert_eq!(found.len(), 1);
1613        assert_eq!(found[0], file);
1614    }
1615
1616    #[test]
1617    fn walk_files_recursive_nested_dirs() {
1618        let dir = tempfile::tempdir().unwrap();
1619        std::fs::create_dir_all(dir.path().join("a/b")).unwrap();
1620        std::fs::write(dir.path().join("a/b/c.txt"), b"deep").unwrap();
1621        std::fs::write(dir.path().join("top.txt"), b"top").unwrap();
1622
1623        let mut found = Vec::new();
1624        walk_files_recursive(dir.path(), &mut |p: &Path| {
1625            found.push(p.to_owned());
1626        });
1627        assert_eq!(found.len(), 2);
1628    }
1629
1630    #[test]
1631    fn walk_files_recursive_empty_dir() {
1632        let dir = tempfile::tempdir().unwrap();
1633        let mut found = Vec::new();
1634        walk_files_recursive(dir.path(), &mut |p: &Path| {
1635            found.push(p.to_owned());
1636        });
1637        assert!(found.is_empty());
1638    }
1639
1640    #[test]
1641    fn walk_files_recursive_nonexistent() {
1642        let mut found = Vec::new();
1643        walk_files_recursive(Path::new("/nonexistent/dir"), &mut |p: &Path| {
1644            found.push(p.to_owned());
1645        });
1646        assert!(found.is_empty());
1647    }
1648
1649    #[test]
1650    fn walk_files_recursive_single_file_input() {
1651        let dir = tempfile::tempdir().unwrap();
1652        let file = dir.path().join("single.txt");
1653        std::fs::write(&file, b"data").unwrap();
1654
1655        let mut found = Vec::new();
1656        walk_files_recursive(&file, &mut |p: &Path| {
1657            found.push(p.to_owned());
1658        });
1659        assert_eq!(found.len(), 1);
1660    }
1661
1662    // ── optimise_store tests ──────────────────────────────────
1663
1664    #[tokio::test]
1665    async fn optimise_empty_store() {
1666        let tmp = tempfile::tempdir().unwrap();
1667        let store = LocalStore::open_in_memory_with_dir(tmp.path().to_str().unwrap())
1668            .await
1669            .unwrap();
1670        let result = store.optimise_store(false).await.unwrap();
1671        assert_eq!(result.files_linked, 0);
1672        assert_eq!(result.bytes_saved, 0);
1673    }
1674
1675    #[tokio::test]
1676    async fn optimise_dry_run_does_not_modify() {
1677        let tmp = tempfile::tempdir().unwrap();
1678        let pkg_dir = tmp.path().join("abc-pkg");
1679        std::fs::create_dir_all(&pkg_dir).unwrap();
1680        std::fs::write(pkg_dir.join("file1.txt"), b"duplicate content").unwrap();
1681
1682        let pkg_dir2 = tmp.path().join("def-pkg");
1683        std::fs::create_dir_all(&pkg_dir2).unwrap();
1684        std::fs::write(pkg_dir2.join("file2.txt"), b"duplicate content").unwrap();
1685
1686        let store = LocalStore::open_in_memory_with_dir(tmp.path().to_str().unwrap())
1687            .await
1688            .unwrap();
1689        let result = store.optimise_store(true).await.unwrap();
1690        // dry_run should report files that would be linked.
1691        assert_eq!(result.files_linked, 1);
1692        assert!(result.bytes_saved > 0);
1693
1694        // Verify files were NOT actually hard-linked.
1695        #[cfg(unix)]
1696        {
1697            use std::os::unix::fs::MetadataExt;
1698            let m1 = std::fs::metadata(pkg_dir.join("file1.txt")).unwrap();
1699            let m2 = std::fs::metadata(pkg_dir2.join("file2.txt")).unwrap();
1700            assert_eq!(m1.nlink(), 1);
1701            assert_eq!(m2.nlink(), 1);
1702        }
1703    }
1704
1705    #[tokio::test]
1706    async fn optimise_links_duplicate_files() {
1707        let tmp = tempfile::tempdir().unwrap();
1708        let pkg_dir = tmp.path().join("abc-pkg");
1709        std::fs::create_dir_all(&pkg_dir).unwrap();
1710        std::fs::write(pkg_dir.join("file1.txt"), b"same content here").unwrap();
1711
1712        let pkg_dir2 = tmp.path().join("def-pkg");
1713        std::fs::create_dir_all(&pkg_dir2).unwrap();
1714        std::fs::write(pkg_dir2.join("file2.txt"), b"same content here").unwrap();
1715
1716        let store = LocalStore::open_in_memory_with_dir(tmp.path().to_str().unwrap())
1717            .await
1718            .unwrap();
1719        let result = store.optimise_store(false).await.unwrap();
1720        assert_eq!(result.files_linked, 1);
1721        assert!(result.bytes_saved > 0);
1722
1723        // Verify files are now hard-linked (same inode).
1724        #[cfg(unix)]
1725        {
1726            use std::os::unix::fs::MetadataExt;
1727            let m1 = std::fs::metadata(pkg_dir.join("file1.txt")).unwrap();
1728            let m2 = std::fs::metadata(pkg_dir2.join("file2.txt")).unwrap();
1729            assert_eq!(m1.ino(), m2.ino());
1730            assert!(m1.nlink() > 1);
1731        }
1732    }
1733
1734    #[tokio::test]
1735    async fn optimise_skips_unique_files() {
1736        let tmp = tempfile::tempdir().unwrap();
1737        let pkg_dir = tmp.path().join("abc-pkg");
1738        std::fs::create_dir_all(&pkg_dir).unwrap();
1739        std::fs::write(pkg_dir.join("file1.txt"), b"content A").unwrap();
1740
1741        let pkg_dir2 = tmp.path().join("def-pkg");
1742        std::fs::create_dir_all(&pkg_dir2).unwrap();
1743        std::fs::write(pkg_dir2.join("file2.txt"), b"content B").unwrap();
1744
1745        let store = LocalStore::open_in_memory_with_dir(tmp.path().to_str().unwrap())
1746            .await
1747            .unwrap();
1748        let result = store.optimise_store(false).await.unwrap();
1749        assert_eq!(result.files_linked, 0);
1750        assert_eq!(result.bytes_saved, 0);
1751    }
1752
1753    #[tokio::test]
1754    async fn optimise_nonexistent_store_dir() {
1755        let store = LocalStore::open_in_memory_with_dir("/nonexistent/store/path")
1756            .await
1757            .unwrap();
1758        let result = store.optimise_store(false).await.unwrap();
1759        assert_eq!(result.files_linked, 0);
1760        assert_eq!(result.bytes_saved, 0);
1761    }
1762
1763    #[tokio::test]
1764    async fn optimise_already_linked_skipped() {
1765        let tmp = tempfile::tempdir().unwrap();
1766        let pkg_dir = tmp.path().join("abc-pkg");
1767        std::fs::create_dir_all(&pkg_dir).unwrap();
1768        let file1 = pkg_dir.join("file1.txt");
1769        std::fs::write(&file1, b"linked content").unwrap();
1770
1771        let pkg_dir2 = tmp.path().join("def-pkg");
1772        std::fs::create_dir_all(&pkg_dir2).unwrap();
1773        let file2 = pkg_dir2.join("file2.txt");
1774        // Create a hard link manually.
1775        std::fs::hard_link(&file1, &file2).unwrap();
1776
1777        let store = LocalStore::open_in_memory_with_dir(tmp.path().to_str().unwrap())
1778            .await
1779            .unwrap();
1780        let result = store.optimise_store(false).await.unwrap();
1781        // Should skip the already-linked files.
1782        assert_eq!(result.files_linked, 0);
1783        assert_eq!(result.bytes_saved, 0);
1784    }
1785}