lfsx_server/storage/
dedupe.rs1use std::path::Path;
2
3use serde::Serialize;
4use sha2::{Digest, Sha256};
5use tokio::fs;
6use tokio::io::AsyncReadExt;
7
8use super::LocalStore;
9use crate::error::Error;
10use crate::namespace::Namespace;
11
12#[derive(Debug, Default, Serialize, PartialEq, Eq)]
13pub struct DedupeReport {
14 pub inspected: u64,
15 pub already_shared: u64,
16 pub adopted: u64,
17 pub linked: u64,
18 pub reclaimed: u64,
19 pub refused: u64,
20 pub incomplete: bool,
21 pub dry_run: bool,
22}
23
24impl LocalStore {
25 pub async fn dedupe(&self, ns: &Namespace, dry_run: bool) -> Result<DedupeReport, Error> {
30 let walk = self.objects_of(ns).await;
31 let mut report = DedupeReport {
32 dry_run,
33 incomplete: !walk.complete,
34 ..DedupeReport::default()
35 };
36
37 for found in walk.objects {
38 report.inspected += 1;
39 let content = self.content_path(&found.oid);
40
41 if shares_bytes_with(&found.path, &content).await {
42 report.already_shared += 1;
43 continue;
44 }
45
46 match fs::metadata(&content).await {
47 Ok(shared) => {
48 self.adopt(&found.path, &content, &found.oid, shared.len(), &mut report)
49 .await?
50 }
51 Err(_) => {
52 self.promote(&found.path, &content, &found.oid, &mut report)
53 .await?
54 }
55 }
56 }
57
58 if !dry_run && (report.adopted > 0 || report.linked > 0) {
59 self.forget(ns).await;
60 }
61
62 Ok(report)
63 }
64
65 async fn adopt(
70 &self,
71 path: &Path,
72 content: &Path,
73 oid: &str,
74 size: u64,
75 report: &mut DedupeReport,
76 ) -> Result<(), Error> {
77 if report.dry_run {
78 report.linked += 1;
79 report.reclaimed += size;
80 return Ok(());
81 }
82
83 if !hashes_to(content, oid).await {
84 tracing::warn!(
85 oid,
86 "shared copy does not hash to its own name, leaving the repository's own file alone"
87 );
88 report.refused += 1;
89 return Ok(());
90 }
91
92 let parent = path.parent().expect("objects live in a fanout directory");
93 let staged = self.staging_path(parent, oid);
94
95 self.link(content, &staged).await?;
96 fs::rename(&staged, path).await?;
100
101 report.linked += 1;
102 report.reclaimed += size;
103
104 Ok(())
105 }
106
107 async fn promote(
111 &self,
112 path: &Path,
113 content: &Path,
114 oid: &str,
115 report: &mut DedupeReport,
116 ) -> Result<(), Error> {
117 if report.dry_run {
118 report.adopted += 1;
119 return Ok(());
120 }
121
122 if !hashes_to(path, oid).await {
123 tracing::warn!(
124 oid,
125 "object does not hash to its own name, leaving it out of the shared store"
126 );
127 report.refused += 1;
128 return Ok(());
129 }
130
131 let parent = content.parent().expect("content paths have a parent");
132 fs::create_dir_all(parent).await?;
133
134 fs::rename(path, content).await?;
135 if let Err(error) = self.link(content, path).await {
136 fs::rename(content, path).await?;
139 return Err(error.into());
140 }
141
142 report.adopted += 1;
143
144 Ok(())
145 }
146}
147
148#[cfg(unix)]
152pub(super) async fn shares_bytes_with(path: &Path, content: &Path) -> bool {
153 use std::os::unix::fs::MetadataExt;
154
155 let (Ok(one), Ok(other)) = (fs::metadata(path).await, fs::metadata(content).await) else {
156 return false;
157 };
158
159 (one.dev(), one.ino()) == (other.dev(), other.ino())
160}
161
162#[cfg(not(unix))]
166pub(super) async fn shares_bytes_with(_path: &Path, _content: &Path) -> bool {
167 false
168}
169
170async fn hashes_to(path: &Path, oid: &str) -> bool {
171 let Ok(mut file) = fs::File::open(path).await else {
172 return false;
173 };
174
175 let mut hasher = Sha256::new();
176 let mut buffer = vec![0u8; 128 * 1024];
177
178 loop {
179 match file.read(&mut buffer).await {
180 Ok(0) => break,
181 Ok(read) => hasher.update(&buffer[..read]),
182 Err(_) => return false,
183 }
184 }
185
186 hex::encode(hasher.finalize()) == oid
187}
188
189#[cfg(test)]
190mod tests;