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 dry_run: bool,
21}
22
23impl LocalStore {
24 pub async fn dedupe(&self, ns: &Namespace, dry_run: bool) -> Result<DedupeReport, Error> {
29 let mut report = DedupeReport {
30 dry_run,
31 ..DedupeReport::default()
32 };
33
34 let Ok(mut prefixes) = fs::read_dir(self.root.join(ns.org()).join(ns.repo())).await else {
35 return Ok(report);
36 };
37
38 while let Some(prefix) = prefixes.next_entry().await? {
39 let Ok(mut fanouts) = fs::read_dir(prefix.path()).await else {
40 continue;
41 };
42
43 while let Some(fanout) = fanouts.next_entry().await? {
44 self.dedupe_directory(&fanout.path(), &mut report).await?;
45 }
46 }
47
48 if !dry_run && (report.adopted > 0 || report.linked > 0) {
49 self.forget(ns).await;
50 }
51
52 Ok(report)
53 }
54
55 async fn dedupe_directory(
56 &self,
57 directory: &Path,
58 report: &mut DedupeReport,
59 ) -> Result<(), Error> {
60 let Ok(mut entries) = fs::read_dir(directory).await else {
61 return Ok(());
62 };
63
64 while let Some(entry) = entries.next_entry().await? {
65 let oid = entry.file_name().to_string_lossy().into_owned();
66 if Self::validate_oid(&oid).is_err() {
67 continue;
68 }
69
70 report.inspected += 1;
71 let path = entry.path();
72 let content = self.content_path(&oid);
73
74 if shares_bytes_with(&path, &content).await {
75 report.already_shared += 1;
76 continue;
77 }
78
79 match fs::metadata(&content).await {
80 Ok(shared) => {
81 self.adopt(&path, &content, &oid, shared.len(), report)
82 .await?
83 }
84 Err(_) => self.promote(&path, &content, &oid, report).await?,
85 }
86 }
87
88 Ok(())
89 }
90
91 async fn adopt(
96 &self,
97 path: &Path,
98 content: &Path,
99 oid: &str,
100 size: u64,
101 report: &mut DedupeReport,
102 ) -> Result<(), Error> {
103 if report.dry_run {
104 report.linked += 1;
105 report.reclaimed += size;
106 return Ok(());
107 }
108
109 if !hashes_to(content, oid).await {
110 tracing::warn!(
111 oid,
112 "shared copy does not hash to its own name, leaving the repository's own file alone"
113 );
114 report.refused += 1;
115 return Ok(());
116 }
117
118 let parent = path.parent().expect("objects live in a fanout directory");
119 let staged = self.staging_path(parent, oid);
120
121 self.link(content, &staged).await?;
122 fs::rename(&staged, path).await?;
126
127 report.linked += 1;
128 report.reclaimed += size;
129
130 Ok(())
131 }
132
133 async fn promote(
137 &self,
138 path: &Path,
139 content: &Path,
140 oid: &str,
141 report: &mut DedupeReport,
142 ) -> Result<(), Error> {
143 if report.dry_run {
144 report.adopted += 1;
145 return Ok(());
146 }
147
148 if !hashes_to(path, oid).await {
149 tracing::warn!(
150 oid,
151 "object does not hash to its own name, leaving it out of the shared store"
152 );
153 report.refused += 1;
154 return Ok(());
155 }
156
157 let parent = content.parent().expect("content paths have a parent");
158 fs::create_dir_all(parent).await?;
159
160 fs::rename(path, content).await?;
161 if let Err(error) = self.link(content, path).await {
162 fs::rename(content, path).await?;
165 return Err(error.into());
166 }
167
168 report.adopted += 1;
169
170 Ok(())
171 }
172}
173
174#[cfg(unix)]
178pub(super) async fn shares_bytes_with(path: &Path, content: &Path) -> bool {
179 use std::os::unix::fs::MetadataExt;
180
181 let (Ok(one), Ok(other)) = (fs::metadata(path).await, fs::metadata(content).await) else {
182 return false;
183 };
184
185 (one.dev(), one.ino()) == (other.dev(), other.ino())
186}
187
188#[cfg(not(unix))]
192pub(super) async fn shares_bytes_with(_path: &Path, _content: &Path) -> bool {
193 false
194}
195
196async fn hashes_to(path: &Path, oid: &str) -> bool {
197 let Ok(mut file) = fs::File::open(path).await else {
198 return false;
199 };
200
201 let mut hasher = Sha256::new();
202 let mut buffer = vec![0u8; 128 * 1024];
203
204 loop {
205 match file.read(&mut buffer).await {
206 Ok(0) => break,
207 Ok(read) => hasher.update(&buffer[..read]),
208 Err(_) => return false,
209 }
210 }
211
212 hex::encode(hasher.finalize()) == oid
213}
214
215#[cfg(test)]
216mod tests;