1use std::path::Path;
15
16use chrono::Utc;
17use tracing::{info, warn};
18use uuid::Uuid;
19
20use crate::backup_bundle_store::{self, BundleKind, BundleRecord, BundleState, verify_token};
21use vti_common::error::AppError;
22use vti_common::store::KeyspaceHandle;
23
24pub async fn read_export_blob(
39 bundles_ks: &KeyspaceHandle,
40 bundle_id: Uuid,
41 token: &str,
42) -> Result<Vec<u8>, AppError> {
43 let mut record = match backup_bundle_store::get_bundle(bundles_ks, &bundle_id).await? {
44 Some(r) => r,
45 None => {
46 warn!(bundle_id = %bundle_id, "GET blob: bundle not found");
47 return Err(AppError::NotFound(format!("bundle not found: {bundle_id}")));
48 }
49 };
50
51 enforce_token(&record, token)?;
52 enforce_export_ready(&record)?;
53 enforce_not_expired(&record)?;
54
55 let blob_path = record
56 .blob_path
57 .clone()
58 .ok_or_else(|| AppError::Internal(format!("bundle {bundle_id} has no blob path")))?;
59
60 let bytes = match tokio::fs::read(&blob_path).await {
61 Ok(b) => b,
62 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
63 warn!(
64 bundle_id = %bundle_id,
65 path = %blob_path.display(),
66 "GET blob: file missing on disk; bundle treated as expired"
67 );
68 return Err(gone(format!("bundle {bundle_id} expired (blob missing)")));
69 }
70 Err(e) => return Err(AppError::Io(e)),
71 };
72
73 if let Err(e) = tokio::fs::remove_file(&blob_path).await {
78 warn!(
82 bundle_id = %bundle_id,
83 path = %blob_path.display(),
84 error = %e,
85 "GET blob: failed to delete blob after read; sweeper will retry"
86 );
87 }
88 record.state = BundleState::ExportDownloaded;
89 record.blob_path = None;
90 backup_bundle_store::store_bundle(bundles_ks, &record).await?;
91
92 info!(bundle_id = %bundle_id, bytes = bytes.len(), "GET blob: served");
93
94 Ok(bytes)
95}
96
97pub async fn write_import_blob(
112 bundles_ks: &KeyspaceHandle,
113 blob_dir: &Path,
114 bundle_id: Uuid,
115 token: &str,
116 bytes: &[u8],
117) -> Result<(), AppError> {
118 let mut record = match backup_bundle_store::get_bundle(bundles_ks, &bundle_id).await? {
119 Some(r) => r,
120 None => {
121 warn!(bundle_id = %bundle_id, "POST blob: bundle not found");
122 return Err(AppError::NotFound(format!("bundle not found: {bundle_id}")));
123 }
124 };
125
126 enforce_token(&record, token)?;
127 enforce_import_pending(&record)?;
128 enforce_not_expired(&record)?;
129
130 if bytes.len() as u64 != record.expected_size_bytes {
131 return Err(AppError::Validation(format!(
132 "upload size mismatch for bundle {bundle_id}: expected {} bytes, got {}",
133 record.expected_size_bytes,
134 bytes.len()
135 )));
136 }
137
138 let actual_sha = sha256_hex(bytes);
139 if actual_sha != record.expected_sha256 {
140 return Err(AppError::Validation(format!(
141 "upload integrity check failed for bundle {bundle_id}: \
142 expected sha256={}, got {}",
143 record.expected_sha256, actual_sha
144 )));
145 }
146
147 tokio::fs::create_dir_all(blob_dir)
151 .await
152 .map_err(AppError::Io)?;
153 #[cfg(unix)]
154 set_dir_mode_700(blob_dir).await?;
155
156 let blob_path = blob_dir.join(format!("{bundle_id}.vtabak"));
157 tokio::fs::write(&blob_path, bytes)
158 .await
159 .map_err(AppError::Io)?;
160 #[cfg(unix)]
161 set_file_mode_600(&blob_path).await?;
162
163 record.state = BundleState::ImportReceived;
164 record.blob_path = Some(blob_path);
165 backup_bundle_store::store_bundle(bundles_ks, &record).await?;
166
167 info!(bundle_id = %bundle_id, bytes = bytes.len(), "POST blob: accepted");
168
169 Ok(())
170}
171
172fn enforce_token(record: &BundleRecord, provided: &str) -> Result<(), AppError> {
175 if !verify_token(provided, &record.token_hash) {
176 return Err(AppError::Forbidden(format!(
177 "token does not match for bundle {}",
178 record.bundle_id
179 )));
180 }
181 Ok(())
182}
183
184fn enforce_not_expired(record: &BundleRecord) -> Result<(), AppError> {
185 if record.expires_at < Utc::now() {
186 return Err(gone(format!(
187 "bundle {} expired at {}",
188 record.bundle_id, record.expires_at
189 )));
190 }
191 Ok(())
192}
193
194fn enforce_export_ready(record: &BundleRecord) -> Result<(), AppError> {
195 if record.kind != BundleKind::Export {
196 return Err(AppError::NotFound(format!(
199 "bundle not found: {}",
200 record.bundle_id
201 )));
202 }
203 match record.state {
204 BundleState::ExportReady => Ok(()),
205 BundleState::ExportDownloaded => Err(gone(format!(
206 "bundle {} already downloaded (one-shot)",
207 record.bundle_id
208 ))),
209 BundleState::Aborted => Err(gone(format!("bundle {} was aborted", record.bundle_id))),
210 BundleState::Expired => Err(gone(format!("bundle {} expired", record.bundle_id))),
211 _ => Err(AppError::Conflict(format!(
212 "bundle {} is in state {:?}, not ready for download",
213 record.bundle_id, record.state
214 ))),
215 }
216}
217
218fn enforce_import_pending(record: &BundleRecord) -> Result<(), AppError> {
219 if record.kind != BundleKind::Import {
220 return Err(AppError::NotFound(format!(
221 "bundle not found: {}",
222 record.bundle_id
223 )));
224 }
225 match record.state {
226 BundleState::ImportPending => Ok(()),
227 BundleState::ImportReceived
228 | BundleState::ImportPreviewed
229 | BundleState::ImportCommitted => Err(AppError::Conflict(format!(
230 "bundle {} upload already accepted",
231 record.bundle_id
232 ))),
233 BundleState::Aborted => Err(gone(format!("bundle {} was aborted", record.bundle_id))),
234 BundleState::Expired => Err(gone(format!("bundle {} expired", record.bundle_id))),
235 _ => Err(AppError::Conflict(format!(
236 "bundle {} is in state {:?}, not ready for upload",
237 record.bundle_id, record.state
238 ))),
239 }
240}
241
242fn gone(message: String) -> AppError {
243 AppError::Conflict(message)
250}
251
252fn sha256_hex(bytes: &[u8]) -> String {
253 use sha2::{Digest, Sha256};
254 let mut hasher = Sha256::new();
255 hasher.update(bytes);
256 let digest = hasher.finalize();
257 hex_lower(&digest)
258}
259
260fn hex_lower(bytes: &[u8]) -> String {
261 let mut out = String::with_capacity(bytes.len() * 2);
262 for b in bytes {
263 out.push_str(&format!("{b:02x}"));
264 }
265 out
266}
267
268#[cfg(unix)]
269async fn set_dir_mode_700(path: &std::path::Path) -> Result<(), AppError> {
270 use std::os::unix::fs::PermissionsExt;
271 let perms = std::fs::Permissions::from_mode(0o700);
272 tokio::fs::set_permissions(path, perms)
273 .await
274 .map_err(AppError::Io)
275}
276
277#[cfg(unix)]
278async fn set_file_mode_600(path: &std::path::Path) -> Result<(), AppError> {
279 use std::os::unix::fs::PermissionsExt;
280 let perms = std::fs::Permissions::from_mode(0o600);
281 tokio::fs::set_permissions(path, perms)
282 .await
283 .map_err(AppError::Io)
284}