1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum LocalStoreMode {
16 ReadOnly,
18 ReadWrite,
20}
21
22pub struct LocalStore {
24 db: DatabaseConnection,
25 store_dir: String,
26}
27
28impl LocalStore {
29 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 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 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 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 #[must_use]
89 pub fn db(&self) -> &DatabaseConnection {
90 &self.db
91 }
92
93 #[must_use]
95 pub fn store_dir(&self) -> &str {
96 &self.store_dir
97 }
98
99 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 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 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 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 pub async fn open_in_memory() -> StoreResult<Self> {
178 Self::open_in_memory_with_dir("/nix/store").await
179 }
180
181 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 let sigs = if info.signatures.is_empty() {
224 None
225 } else {
226 Some(info.signatures.join(" "))
227 };
228
229 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 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 let Some(ref deriver) = info.deriver
259 && deriver.ends_with(".drv") {
260 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 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 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 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 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 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 let roots = find_gc_roots(&self.store_dir);
339
340 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 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 let garbage: Vec<StorePath> = all_paths
362 .into_iter()
363 .filter(|p| !reachable.contains(&p.to_absolute_path()))
364 .collect();
365
366 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 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 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 if let Some(model) = model {
464 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 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 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 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 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_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 #[cfg(unix)]
529 if metadata.nlink() > 1 {
530 return;
531 }
532
533 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 } else {
544 if std::fs::remove_file(file_path).is_ok()
546 && std::fs::hard_link(existing, file_path).is_err()
547 {
548 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
568fn 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
591fn 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
599pub 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
623fn 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 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
655fn 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
667fn 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
705fn 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
722fn db_err(e: sea_orm::DbErr) -> StoreError {
724 StoreError::Database(e.to_string())
725}
726
727fn 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 use super::*;
744
745 #[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 #[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 let result = LocalStore::open("/tmp").await;
788 assert!(result.is_err());
789 }
790
791 #[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 let result = LocalStore::open_with_dir(
811 "/nonexistent/db.sqlite",
812 "/custom/store",
813 )
814 .await;
815 assert!(result.is_err());
816 }
817
818 #[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 #[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 #[test]
966 fn path_info_signatures_split_logic_no_sigs() {
967 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 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 #[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 #[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 let store = LocalStore::open_rw(tmp.path()).await;
1059 assert!(store.is_ok());
1061 }
1062
1063 #[tokio::test]
1064 async fn open_readonly_still_works() {
1065 let result = LocalStore::open("/nonexistent/sui-test.sqlite").await;
1067 assert!(result.is_err()); }
1069
1070 #[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 #[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 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 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 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 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 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 let sp = StorePath::from_absolute_path(&info.path).unwrap();
1187 assert!(store.is_valid_path(&sp).await.unwrap());
1188
1189 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 store.register_path(&info).await.unwrap();
1213
1214 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 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 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 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 #[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 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 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 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 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 #[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 #[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 #[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 #[tokio::test]
1397 async fn verify_detects_valid_path() {
1398 let store = LocalStore::open_in_memory().await.unwrap();
1408
1409 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 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 let store = LocalStore::open_in_memory().await.unwrap();
1430
1431 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 #[tokio::test]
1450 async fn delete_path_removes_from_db() {
1451 let store = LocalStore::open_in_memory().await.unwrap();
1455
1456 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 assert_eq!(freed, 0);
1471 assert!(!store.is_valid_path(&sp).await.unwrap());
1473 }
1474
1475 #[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 #[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); }
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); }
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 #[test]
1553 fn find_gc_roots_with_no_dirs() {
1554 let roots = find_gc_roots("/nonexistent/store");
1556 assert!(roots.is_empty());
1557 }
1558
1559 #[test]
1560 fn find_gc_roots_is_public() {
1561 let _roots = find_gc_roots("/nix/store");
1564 }
1565
1566 #[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 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 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 #[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 #[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 assert_eq!(result.files_linked, 1);
1692 assert!(result.bytes_saved > 0);
1693
1694 #[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 #[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 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 assert_eq!(result.files_linked, 0);
1783 assert_eq!(result.bytes_saved, 0);
1784 }
1785}