Skip to main content

acorn/io/
bagit.rs

1//! Support for working with [BagIt](https://tools.ietf.org/html/rfc8493) format data.
2//!
3//! BagIt is a standardized file packaging format designed for digital preservation and data transfer.
4//! This module provides utilities to create, verify, and work with BagIt packages containing research
5//! activity data, metadata, and supporting documentation.
6//!
7use 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" };
19/// Trait for persisting BagIt data
20pub trait Save {
21    /// Persist BagIt data to a given path
22    fn save<P>(&self, destination: P) -> ApiResult<PathBuf>
23    where
24        P: Into<PathBuf> + Clone;
25    /// Persists BagIt data using a selected archive format and output path.
26    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/// Container structure for details of BagIt formatted data.
31///
32/// A bag consists of a base directory containing payload files in a `data/` subdirectory,
33/// along with manifest files and other tag files that provide metadata and checksums for
34/// validation. This struct represents the core bag configuration defined in `bagit.txt`.
35///
36/// See <https://datatracker.ietf.org/doc/html/draft-kunze-bagit> for details
37#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
38#[builder(start_fn = init)]
39pub struct Bag {
40    /// Root directory path of the bag containing all bag files and directories
41    pub base_directory: String,
42    /// List of payload file paths relative to the bag's data/ directory
43    #[builder(default = Vec::new())]
44    pub payload: Vec<String>,
45    /// Checksum algorithm used in payload and tag manifest files
46    #[builder(default)]
47    pub checksum_algorithm: ChecksumAlgorithm,
48    /// BagIt specification version (e.g., "1.0")
49    #[builder(default = String::from("1.0"))]
50    pub version: String,
51    /// Character encoding for tag files (recommended: "UTF-8")
52    #[builder(default = String::from("UTF-8"))]
53    pub encoding: String,
54    /// Bag-Info metadata from `bag-info.txt`
55    pub info: Option<BagInfo>,
56}
57/// Metadata describing the bag and its payload from `bag-info.txt`.
58///
59/// Bag-Info metadata elements are intended primarily for human use and are optional per the BagIt
60/// specification. These fields correspond to the reserved metadata element names from RFC 8493.
61///
62/// See <https://datatracker.ietf.org/doc/html/draft-kunze-bagit> for details
63#[derive(Builder, Clone, Debug, Display, Serialize, Deserialize, JsonSchema)]
64#[display("Bag-Info")]
65#[builder(start_fn = init)]
66pub struct BagInfo {
67    /// Organization transferring the content (Source-Organization)
68    pub organization: Option<Vec<String>>,
69    /// Mailing address of the source organization (Organization-Address)
70    pub organization_address: Option<Vec<String>>,
71    /// Person responsible for the content transfer (Contact-Name)
72    pub contact_name: Option<Vec<String>>,
73    /// International format telephone number of responsible person (Contact-Phone)
74    pub contact_phone: Option<Vec<String>>,
75    /// Fully qualified email address of responsible person (Contact-Email)
76    pub contact_email: Option<Vec<String>>,
77    /// Brief explanation of contents and provenance (External-Description)
78    pub description: Option<Vec<String>>,
79    /// Date (YYYY-MM-DD) that content was prepared for transfer (Bagging-Date)
80    pub date: Option<String>,
81    /// Sender-supplied identifier for the bag (External-Identifier)
82    pub identifier: Option<Vec<String>>,
83    /// Size or approximate size of the bag with abbreviation (Bag-Size)
84    pub size: Option<String>,
85    /// Bag count values as (N, T) tuples where N is ordinal position and T is total
86    /// T is None if unknown ("?")
87    pub count: Option<Vec<(u32, Option<u32>)>>,
88}
89impl Bag {
90    /// Verify the completeness and correctness of a bag
91    /// ### Checks
92    /// - `bagit.txt` exists
93    /// - `manifest-<algorithm>.txt` exists
94    /// - `data/` exists
95    /// - Checksum values in manifest file matches checksum values of files in `data/`
96    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    /// Populate payload file listing from files present in self.base_directory "data" directory
126    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    /// Persist files in a given directory as a archived ("zipped") BagIt format package
158    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    /// Enumerate BagIt reserved metadata elements as ordered key/value pairs
238    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}