1use crate::io::{archive, file_checksum, files_all, read_file, write_file, ApiResult, StringConversion};
8use crate::prelude::{copy, create_dir_all, HashSet, Path, PathBuf};
9use crate::util::{ChecksumAlgorithm, Label, MimeType};
10use bon::Builder;
11use color_eyre::eyre::eyre;
12use core::fmt;
13use derive_more::Display;
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16use tracing::{error, warn};
17
18const LINE_ENDING: &str = if cfg!(windows) { "\r\n" } else { "\n" };
19pub trait Save {
21 fn save<P>(&self, destination: P) -> ApiResult<PathBuf>
23 where
24 P: Into<PathBuf> + Clone;
25 fn save_as<P>(&self, destination: P, archive_format: MimeType, archive_destination: Option<PathBuf>) -> ApiResult<PathBuf>
27 where
28 P: Into<PathBuf> + Clone;
29}
30#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
38#[builder(start_fn = init)]
39pub struct Bag {
40 pub base_directory: String,
42 #[builder(default = Vec::new())]
44 pub payload: Vec<String>,
45 #[builder(default)]
47 pub checksum_algorithm: ChecksumAlgorithm,
48 #[builder(default = String::from("1.0"))]
50 pub version: String,
51 #[builder(default = String::from("UTF-8"))]
53 pub encoding: String,
54 pub info: Option<BagInfo>,
56}
57#[derive(Builder, Clone, Debug, Display, Serialize, Deserialize, JsonSchema)]
64#[display("Bag-Info")]
65#[builder(start_fn = init)]
66pub struct BagInfo {
67 pub organization: Option<Vec<String>>,
69 pub organization_address: Option<Vec<String>>,
71 pub contact_name: Option<Vec<String>>,
73 pub contact_phone: Option<Vec<String>>,
75 pub contact_email: Option<Vec<String>>,
77 pub description: Option<Vec<String>>,
79 pub date: Option<String>,
81 pub identifier: Option<Vec<String>>,
83 pub size: Option<String>,
85 pub count: Option<Vec<(u32, Option<u32>)>>,
88}
89impl Bag {
90 pub fn verify<P>(path: P) -> ApiResult<()>
97 where
98 P: Into<PathBuf>,
99 {
100 path.into()
101 .canonicalize()
102 .map_err(|why| eyre!("Failed to resolve BagIt directory - {why}"))
103 .and_then(
104 |base_directory| match (base_directory.join("bagit.txt").is_file(), base_directory.join("data").is_dir()) {
105 | (true, true) => Ok(base_directory),
106 | _ => Err(eyre!("Missing required BagIt declaration or payload directory")),
107 },
108 )
109 .and_then(|base_directory| {
110 let manifests = [
111 ("manifest-sha256.txt", ChecksumAlgorithm::Sha256),
112 ("manifest-sha512.txt", ChecksumAlgorithm::Sha512),
113 ]
114 .into_iter()
115 .filter(|(name, _)| base_directory.join(name).is_file())
116 .collect::<Vec<_>>();
117 match manifests.is_empty() {
118 | true => Err(eyre!("A complete SHA-256 or SHA-512 BagIt manifest is required")),
119 | false => manifests
120 .into_iter()
121 .try_for_each(|(name, algorithm)| verify_manifest(&base_directory, name, algorithm)),
122 }
123 })
124 }
125 pub fn with_payload(&self) -> Self {
127 let base_directory = PathBuf::from(self.base_directory.clone());
128 let payload = files_all(base_directory.clone(), None::<Vec<String>>)
129 .into_iter()
130 .filter(|x| x.is_file())
131 .flat_map(|x| x.strip_prefix(base_directory.to_absolute_path()).ok().map(|p| p.to_path_buf()))
132 .map(|x| x.display().to_string())
133 .collect::<Vec<_>>();
134 let Bag {
135 checksum_algorithm,
136 version,
137 encoding,
138 info,
139 ..
140 } = self;
141 Bag::init()
142 .base_directory(self.base_directory.clone())
143 .checksum_algorithm(checksum_algorithm.clone())
144 .version(version.clone())
145 .encoding(encoding.clone())
146 .maybe_info(info.clone())
147 .payload(payload)
148 .build()
149 }
150}
151impl fmt::Display for Bag {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 write!(f, "Bag v{} ({}): {}", self.version, self.checksum_algorithm, self.base_directory)
154 }
155}
156impl Save for Bag {
157 fn save<P>(&self, destination: P) -> ApiResult<PathBuf>
159 where
160 P: Into<PathBuf> + Clone,
161 {
162 self.save_as(destination, MimeType::Zip, None)
163 }
164 fn save_as<P>(&self, destination: P, archive_format: MimeType, archive_destination: Option<PathBuf>) -> ApiResult<PathBuf>
165 where
166 P: Into<PathBuf> + Clone,
167 {
168 let Bag { info, version, encoding, .. } = self;
169 let dest = destination.clone().into();
170 let payload_directory = dest.join("data");
171 match create_dir_all(payload_directory.clone()) {
172 | Ok(_) => {
173 let base = PathBuf::from(self.base_directory.clone());
174 let bag = self.with_payload();
175 let checksum_algorithm: &'static ring::digest::Algorithm = self.checksum_algorithm.clone().into();
176 bag.payload.clone().into_iter().for_each(|x| {
177 let payload = PathBuf::from(x);
178 let from = base.join(&payload);
179 let to = dest.join("data").join(&payload);
180 let created = to.parent().map_or(Ok(()), create_dir_all);
181 match created {
182 | Ok(()) => {
183 if let Err(why) = copy(from.clone(), to.clone()) {
184 error!(
185 from = from.to_absolute_path(),
186 to = to.to_absolute_path(),
187 "=> {} Copy - {why}",
188 Label::fail()
189 );
190 }
191 }
192 | Err(why) => {
193 error!(
194 directory = to.to_path_buf().to_absolute_path(),
195 "=> {} Create parent - {why}",
196 Label::fail()
197 );
198 }
199 }
200 });
201 let bag_declaration_content = format!("BagIt-Version: {version}\nTag-File-Character-Encoding: {encoding}\n");
202 let payload_manifest_content = bag
203 .payload
204 .into_iter()
205 .fold(String::new(), |acc, x| {
206 let payload = PathBuf::from(x.clone());
207 let to = dest.join("data").join(&payload);
208 match file_checksum(to.clone(), Some(checksum_algorithm)) {
209 | Some(checksum) => {
210 format!("{acc}{checksum} data/{x}{LINE_ENDING}")
211 }
212 | None => {
213 warn!(payload = payload.to_absolute_path(), "=> {} Calculate checksum", Label::fail());
214 acc
215 }
216 }
217 })
218 .replace("\\", "/");
219 write_file(dest.clone().join("bagit.txt"), bag_declaration_content)
220 .map(|_| dest.clone())
221 .and_then(|_| match info.clone() {
222 | Some(bag_info) => bag_info.save(destination.clone()),
223 | None => Ok(dest.clone()),
224 })
225 .and_then(|_| {
226 let file_name = format!("manifest-{}.txt", self.checksum_algorithm);
227 let file_path = dest.clone().join(&file_name);
228 write_file(file_path, payload_manifest_content).map(|_| dest.clone())
229 })
230 .and_then(|_| archive(dest.clone(), archive_destination, archive_format))
231 }
232 | Err(why) => Err(eyre!("Failed to create bag - {why}")),
233 }
234 }
235}
236impl BagInfo {
237 pub fn entries(&self) -> Vec<(String, String)> {
239 let BagInfo {
240 organization,
241 organization_address,
242 contact_name,
243 contact_phone,
244 contact_email,
245 description,
246 date,
247 identifier,
248 size,
249 count,
250 } = self;
251 let repeatable = [
252 ("Source-Organization", organization),
253 ("Organization-Address", organization_address),
254 ("Contact-Name", contact_name),
255 ("Contact-Phone", contact_phone),
256 ("Contact-Email", contact_email),
257 ("External-Description", description),
258 ("External-Identifier", identifier),
259 ]
260 .into_iter()
261 .flat_map(|(key, values)| {
262 values
263 .iter()
264 .flat_map(|items| items.iter())
265 .map(move |value| (key.to_string(), value.clone()))
266 });
267 let count = count.iter().flat_map(|items| items.iter()).map(|(index, total)| {
268 let total = total.map_or_else(|| "?".to_string(), |value| value.to_string());
269 ("Bag-Count".to_string(), format!("{index} of {total}"))
270 });
271 let single = [("Bagging-Date", date.clone()), ("Bag-Size", size.clone())]
272 .into_iter()
273 .filter_map(|(key, value)| value.map(|v| (key.to_string(), v)));
274 repeatable.chain(count).chain(single).collect()
275 }
276}
277impl Default for BagInfo {
278 fn default() -> Self {
279 BagInfo::init().build()
280 }
281}
282impl Save for BagInfo {
283 fn save<P>(&self, destination: P) -> ApiResult<PathBuf>
284 where
285 P: Into<PathBuf> + Clone,
286 {
287 let dest = destination.clone().into();
288 let content = self
289 .entries()
290 .into_iter()
291 .fold(String::new(), |acc, (key, value)| format!("{acc}{key}: {value}{LINE_ENDING}"));
292 write_file(dest.join("bag-info.txt"), content).map(|_| dest)
293 }
294 fn save_as<P>(&self, destination: P, _archive_format: MimeType, _archive_destination: Option<PathBuf>) -> ApiResult<PathBuf>
295 where
296 P: Into<PathBuf> + Clone,
297 {
298 self.save(destination)
299 }
300}
301impl From<ChecksumAlgorithm> for &'static ring::digest::Algorithm {
302 fn from(value: ChecksumAlgorithm) -> Self {
303 match value {
304 | ChecksumAlgorithm::Sha512 => &ring::digest::SHA512,
305 | _ => &ring::digest::SHA256,
306 }
307 }
308}
309fn verify_manifest(base: &Path, name: &str, algorithm: ChecksumAlgorithm) -> ApiResult<()> {
310 let expected_length = match algorithm {
311 | ChecksumAlgorithm::Sha256 => Ok(64),
312 | ChecksumAlgorithm::Sha512 => Ok(128),
313 | _ => Err(eyre!("Unsupported BagIt manifest algorithm")),
314 };
315 read_file(base.join(name))
316 .and_then(|content| {
317 expected_length.and_then(|expected_length| {
318 content
319 .lines()
320 .map(|line| {
321 line.find(char::is_whitespace)
322 .map(|index| (&line[..index], line[index..].trim_start()))
323 .filter(|(checksum, path)| {
324 let valid_length = checksum.len() == expected_length;
325 let valid_checksum = checksum.bytes().all(|byte| byte.is_ascii_hexdigit());
326 let valid_path = path.starts_with("data/");
327 valid_length && valid_checksum && valid_path
328 })
329 .map(|(checksum, path)| (checksum.to_string(), path.to_string()))
330 .ok_or_else(|| eyre!("Malformed BagIt manifest entry: {line}"))
331 })
332 .collect::<ApiResult<Vec<_>>>()
333 })
334 })
335 .and_then(|entries| {
336 let listed = entries.iter().map(|(_, path)| PathBuf::from(path)).collect::<HashSet<_>>();
337 let actual = files_all(base.join("data"), None::<Vec<String>>)
338 .into_iter()
339 .filter(|path| path.is_file())
340 .filter_map(|path| path.strip_prefix(base).ok().map(Path::to_path_buf))
341 .collect::<HashSet<_>>();
342 match (listed.len() == entries.len(), listed == actual) {
343 | (false, _) => Err(eyre!("Duplicate payload path in BagIt manifest")),
344 | (_, false) => Err(eyre!("BagIt manifest does not list the complete payload")),
345 | _ => Ok(entries),
346 }
347 })
348 .and_then(|entries| {
349 let digest: &'static ring::digest::Algorithm = algorithm.into();
350 entries.into_iter().try_for_each(|(expected, relative)| {
351 file_checksum(base.join(&relative), Some(digest))
352 .map(|checksum| checksum.checksum_value.to_ascii_lowercase())
353 .ok_or_else(|| eyre!("Failed to checksum BagIt payload: {relative}"))
354 .and_then(|calculated| match calculated == expected.to_ascii_lowercase() {
355 | true => Ok(()),
356 | false => Err(eyre!("Checksum mismatch in payload file = {relative}")),
357 })
358 })
359 })
360}