cargo_resources/
lib.rs

1//! Cargo Resources provides a cargo command line tool and library (i.e. this module), to help
2//! declare and collate resources within Cargo Crates.
3//!
4//! Usage:
5//! ```
6//! use std::env::current_dir;
7//! use cargo_metadata::camino::Utf8PathBuf;
8//! use cargo_resources::*;
9//! use std::error::Error;
10//! use cargo_resources::reporting::BuildRsReporter;
11//!
12//! let cwd = current_dir().unwrap();
13//! let manifest_file = Utf8PathBuf::from_path_buf(cwd).unwrap().join("Cargo.toml");
14//!
15//! // Collate resources from the crate's dependencies.
16//! let _r = collate_resources(&manifest_file);
17//!
18//! // or using build.rs formatted output.
19//! let reporter = BuildRsReporter{};
20//! let _r = collate_resources_with_reporting(&manifest_file, &reporter);
21//! ```
22use std::collections::{HashMap, HashSet};
23use std::fs;
24use std::fs::File;
25use std::io::Read;
26
27use cargo_metadata::{CargoOpt, Metadata, Node, Package, PackageId, Resolve};
28use cargo_metadata::camino::{Utf8Path, Utf8PathBuf};
29use ring::digest::{Context, Digest, SHA256};
30use serde_json::Value;
31
32pub use declarations::ResourceDataDeclaration;
33pub use resource_encoding::ResourceEncoding;
34pub use specifications::ResourceSpecification;
35
36use crate::declarations::ResourceConsumerDeclaration;
37use crate::reporting::{DefaultReporter, ReportingTrait};
38use crate::specifications::{PackageDetails, ResourceConsumerSpecification, ResourceRequirement};
39
40mod resource_encoding;
41
42mod declarations;
43
44mod specifications;
45
46pub mod reporting; 
47
48/// The Resource Name
49pub type ResourceName = String;
50
51/// The Resource's SHA 256 Value
52pub type ResourceSha = String;
53
54/// Collate the resources for the given crate, into the crate.
55///
56/// # Arguments
57/// * source_manifest: The path of the cargo manifest (Cargo.toml) of the crate.
58///
59/// # Returns
60/// The resolved resource information on success, or a string error describing the failure.
61pub fn collate_resources(source_manifest: &Utf8PathBuf) -> Result<Vec<ResourceSpecification>, String> {
62    let reporter = DefaultReporter {};
63    collate_resources_with_reporting(source_manifest, &reporter)    
64}
65
66/// Collate the resources for the given crate, into the crate.
67///
68/// # Arguments
69/// * source_manifest: The path of the cargo manifest (Cargo.toml) of the crate.
70/// * reporter:        An implementation of teh ReportingTrait (to allow bespoke reporting of progress messages).
71///
72/// # Returns
73/// The resolved resource information on success, or a string error describing the failure.
74pub fn collate_resources_with_reporting(source_manifest: &Utf8PathBuf, reporter: &impl ReportingTrait) -> Result<Vec<ResourceSpecification>, String> {
75    collate_resources_with_crate_filter(source_manifest, reporter,  |_| true)
76}
77
78/// Collate the resources for the given crate, into the crate.
79///
80/// # Arguments
81/// * source_manifest: The path of the cargo manifest (Cargo.toml) of the crate.
82/// * reporter:        An implementation of teh ReportingTrait (to allow bespoke reporting of progress messages).
83/// * crate_filter:    A function to decide which crates to collect resources from.
84///
85/// # Returns
86/// The resolved resource information on success, or a string error describing the failure.
87pub fn collate_resources_with_crate_filter(
88    source_manifest: &Utf8PathBuf,
89    reporter: &impl ReportingTrait,
90    crate_filter: impl Fn(&Package) -> bool
91) -> Result<Vec<ResourceSpecification>, String> {
92    if !source_manifest.exists() {
93        Err(format!("Source manifest does not exist: {}", source_manifest))?
94    }
95    // Now lets get the metadata of a package
96    let mut metadata_cmd = cargo_metadata::MetadataCommand::new();
97    let metadata: Metadata = metadata_cmd
98        .manifest_path(&source_manifest)
99        .features(CargoOpt::AllFeatures)
100        .exec()
101        .unwrap();
102
103    // Check the root package (may not be set for a workspace)
104    let root_package = metadata.root_package()
105        .expect("Unexpected error finding the consuming crate - please run in a crate not a workspace.");
106
107    // Create a lookup of packages including whether they are in the root package's dependency tree.
108    let packages_by_id = get_package_details(&metadata)?;
109
110    // Filter out packages that aren't in the dependency tree.
111    let child_packages = packages_by_id.iter()
112        .filter(|(_id, details)| details.is_dependency())
113        .map(|(_id, details)| &details.package)
114        .collect::<Vec<_>>();
115
116    // Find the declared resources in the dependency tree
117    let mut declared_resources: HashMap<String, ResourceSpecification> = HashMap::new();
118    for package in child_packages {
119        if crate_filter(package) {
120            get_package_resource_data(package, &mut declared_resources, reporter)?
121        }
122    }
123
124    // Find the resource requirement (for the consuming crate)
125    let required_resources_spec = get_resource_requirement(&root_package, &declared_resources)?;
126
127    // Where do we put the resources?
128    let resource_root = required_resources_spec.resource_root;
129    create_output_directory(&resource_root)?;
130
131    if required_resources_spec.required_resources.len() <= 0 {
132        reporter.report_no_resources_found();
133        return Ok(vec!());
134    }
135
136    let mut resolved_resources = vec!();
137    for res_req in required_resources_spec.required_resources {
138        let res_dec = declared_resources.get(&res_req.resource_name)
139            .ok_or_else(|| {
140                reporter.report_missing_resource(&res_req.resource_name);
141                format!("No resource found matching requirement {}", res_req.resource_name)
142            }
143        )?;
144        copy_resource(&res_req, &res_dec, &resource_root, reporter)?;
145        resolved_resources.push(res_dec);
146    }
147
148    // Write a record of the resources
149    let res = serde_json::to_string(&resolved_resources)
150        .expect("Unable to serialize the set of resolved resources");
151
152    let record_file_path = resource_root.join("resolved_resources.json");
153    fs::write(record_file_path, res).map_err(|e| format!("Failed writing record file:{:?}", e))?;
154
155    Ok(resolved_resources.into_iter().map(|r|r.clone()).collect())
156}
157
158/// Create the map of package details
159fn get_package_details(metadata: &Metadata) -> Result<HashMap<PackageId, PackageDetails<'_>>, String> {
160    let mut packages_by_id: HashMap<PackageId, PackageDetails> = HashMap::new();
161    // Initialise the lookups without the dependency information (i.e. not in root deps)
162    for ref package in metadata.packages.iter() {
163        packages_by_id.insert(
164            package.id.clone(),
165            PackageDetails::new(&package)
166        );
167    }
168    // Use the dependency tree from root to fix the dependency information
169    let root_package = metadata.root_package()
170        .ok_or("Unable to get root package")?;
171    // Convert the dependency nodes from a list to a map!
172    let dep_graph_root: &Resolve = metadata.resolve.as_ref().ok_or("Missing dependency graph.")?;
173    let node_list = &dep_graph_root.nodes;
174    let node_map: HashMap<PackageId, &Node> = node_list.iter().map(|n| (n.id.clone(), n)).collect();
175    // All packages from the root node are dependencies so we could recursively visit all the dependencies
176    // and then add them. However, using a stack and a set allows us to cut repetition.
177    let mut processed_packages = HashSet::new();
178    let mut pending_nodes = vec!(node_map.get(&root_package.id).ok_or("Missing dependency node")?);
179    while let Some(node) = pending_nodes.pop() {
180        // Set as a dependency
181        let details = packages_by_id.get_mut(&node.id).ok_or("Missing details.")?;
182        details.set_is_dependency();
183
184        // Add to done
185        processed_packages.insert(&node.id);
186
187        // Add any unprocessed nodes to the pending queue.
188        for pkg in &node.dependencies {
189            if !processed_packages.contains(pkg) {
190                pending_nodes.push(node_map.get(&pkg).ok_or("Missing details.")?);
191            }
192        }
193    }
194    Ok(packages_by_id)
195}
196
197/// Get all the resources information declared by a package
198fn get_package_resource_data(
199    package: &Package,
200    resources: &mut HashMap<String, ResourceSpecification>,
201    reporter: &impl ReportingTrait,
202) -> Result<(), String> {
203    // We have the metadata, resources uses cargo_resources.provides as a collection within this!
204    let cargo_resource_metadata: &Value = &package.metadata["cargo_resources"];
205    if !cargo_resource_metadata.is_object() {
206        return Ok(()); // No metadata for us
207    }
208    let provides_metadata = &cargo_resource_metadata["provides"];
209    match provides_metadata {
210        Value::Array(resource_entries) => {
211            for resource_entry in resource_entries {
212                let declaration_result = serde_json::from_value::<ResourceDataDeclaration>(resource_entry.clone());
213                match declaration_result {
214                    Ok(declaration) => {
215                        // Do the conversions for optionals
216                        let resolved_output_path = declaration
217                            .output_path.
218                            unwrap_or(declaration.crate_path.to_owned());
219                        let resolved_name = declaration.resource_name.unwrap_or(
220                            declaration.crate_path.file_name()
221                                .expect("Illegal resource name").to_string().into()
222                        );
223
224                        // Paths should be relative
225                        if declaration.crate_path.is_absolute() {
226                            Err(
227                                format!(
228                                    "Crate {} declares an absolute resource path {}",
229                                    &package.name,
230                                    &declaration.crate_path
231                                )
232                            )?
233                        }
234                        if resolved_output_path.is_absolute() {
235                            Err(
236                                format!(
237                                    "Crate {} declares an absolute output path {}",
238                                    &package.name,
239                                    &resolved_output_path
240                                )
241                            )?
242                        }
243
244                        let full_source_path = package
245                            .manifest_path.parent().expect("No manifest directory!")
246                            .join(declaration.crate_path);
247                        let data = ResourceSpecification {
248                            declaring_crate_name: package.name.as_ref().to_owned(),
249                            declaring_crate_version: package.version.to_owned(),
250                            encoding: declaration.encoding.unwrap_or(ResourceEncoding::Txt),
251                            full_crate_path: full_source_path,
252                            output_path: resolved_output_path,
253                            resource_name: resolved_name.to_owned(),
254                            is_local: package.source.is_none()
255                        };
256
257                        // Later resources will overwrite old ones!
258                        if resources.contains_key(&resolved_name) {
259                            reporter.report_duplicate_resource(
260                                &resolved_name,
261                                &resources.get(&resolved_name).unwrap().full_crate_path,
262                                &data.full_crate_path
263                            );
264                        }
265                        resources.insert(resolved_name.to_owned(), data);
266                    }
267
268                    Err(err) => {
269                        reporter.report_malformed_resource_declaration(
270                            &package.name,
271                            &err
272                        );
273                        return Err(format!("Malformed resource declaration in {}: {}",
274                                           package.name,
275                                           err));
276                    }
277                }
278            }
279            Ok(())
280        }
281        Value::Null => Ok(()),
282        _ => {
283            reporter.report_malformed_resources_section();
284            Err(
285                "unexpected type for [package.metadata.cargo_resources].provides in the json-metadata".to_owned()
286            )
287        }
288    }
289}
290
291/// Get the resource requirement for a package
292fn get_resource_requirement(
293    package: &Package,
294    available_resources: &HashMap<String, ResourceSpecification>,
295) -> Result<ResourceConsumerSpecification, String> {
296    // We have the metadata, requirements are declared in  cargo_resources.
297    let cargo_resource_metadata: &Value = &package.metadata["cargo_resources"];
298
299    // When nothing is specified use default options and packages
300    let consumer_declaration = match &cargo_resource_metadata {
301        Value::Null => ResourceConsumerDeclaration {
302            resource_root: None,
303            requires: None,
304        },
305        Value::Object(_) => {
306            serde_json::from_value(cargo_resource_metadata.clone())
307                .map_err(|e| format!("Unable to read consuming crates [package.metadata.cargo_resources]: {}", e.to_string()))?
308        }
309        _ => panic!("Misconfigured [package.metadata.cargo_resources] in consuming package.")
310    };
311
312    let resource_root = consumer_declaration.resource_root.unwrap_or(Utf8PathBuf::from("target/resources"));
313
314    let required_resources: Vec<ResourceRequirement> = match consumer_declaration.requires {
315        None => { // Default is to use all available resources with default options
316            available_resources.values().map(|res_spec| ResourceRequirement {
317                resource_name: res_spec.resource_name.to_owned(),
318                required_sha: None,
319            }).collect()
320        }
321        Some(declarations) => { // Just convert each declaration to a spec
322            declarations.into_iter().map(|dec| ResourceRequirement {
323                resource_name: dec.resource_name.to_owned(),
324                required_sha: dec.required_sha.to_owned(),
325            }).collect()
326        }
327    };
328
329    Ok(ResourceConsumerSpecification { resource_root, required_resources })
330}
331
332/// Copy the resource to the resources folder (if it doesn't already exist)
333fn copy_resource(
334    res_req: &ResourceRequirement,
335    res_dec: &ResourceSpecification,
336    resource_root: &Utf8PathBuf,
337    reporter: &impl ReportingTrait,
338) -> Result<(), String> {
339    let output_resources_path = resource_root
340        .join(&res_dec.output_path);
341    // Before copying, we should check the path isn't outside the resources root.
342    verify_resource_is_in_root(&output_resources_path, &resource_root)?;
343
344    // Create the output directory if it doesn't exist!
345    let output_directory = output_resources_path.parent().unwrap();
346    create_output_directory(output_directory)?;
347
348    // Use sha256 to check if the file has changed, and verify against a required_sha
349    let new_sha = hex::encode(get_file_sha(&res_dec.full_crate_path)?.as_ref());
350
351    // Return error if the required sha is set and doesn't match.
352    match res_req.required_sha {
353        Some(ref req) => {
354            if *req != new_sha {
355                Err(
356                    format!("Resource {} with sha {} does not match required sha {}.",
357                            res_req.resource_name,
358                            new_sha,
359                            req
360                    )
361                )?
362            }
363        }
364        _ => {}
365    }
366
367    // Only copy when the sha doesn't match (to avoid timestamp updates on the file)
368    let mut already_exists = false;
369    if output_resources_path.exists() {
370        let existing_sha = hex::encode(get_file_sha(&output_resources_path)?.as_ref());
371        if existing_sha == new_sha {
372            already_exists = true;
373        }
374    }
375
376    if !already_exists {
377        fs::copy(&res_dec.full_crate_path, &output_resources_path)
378            .map_err(|e|
379                format!("Unable to copy resource {} to {}: {}",
380                        &res_dec.full_crate_path,
381                        &output_resources_path,
382                        e
383                )
384            )?;
385    }
386
387    reporter.report_resource_collection(already_exists, &new_sha, &output_resources_path);
388    Ok(())
389}
390
391/// Work out the SHA 256 value of a file from the path
392fn get_file_sha(path: &Utf8PathBuf) -> Result<Digest, String> {
393    let mut sha = Context::new(&SHA256);
394    let mut file = File::open(path).map_err(|e| format!("Error opening {}, {}", path, e))?;
395    let mut buffer = [0; 4096]; // Read sensible sized blocks from disk!
396
397    loop {
398        let bytes_read = file.read(&mut buffer)
399            .map_err(|e| format!("Error calculating SHA256 {}", e))?;
400        if bytes_read == 0 {
401            break;
402        }
403        sha.update(&buffer[..bytes_read]);
404    }
405
406    Ok(sha.finish())
407}
408
409// Check whether the resource is in the root!
410fn verify_resource_is_in_root(
411    resource_path: &Utf8PathBuf,
412    root_path: &Utf8PathBuf,
413) -> Result<(), String> {
414    let can_root_path = root_path.canonicalize_utf8()
415        .map_err(
416            |e| format!(
417                "Unable to canonicalize root path: {}: {}",
418                root_path,
419                e
420            )
421        )?;
422
423    // Create interim folders to allow parentage check
424    if !resource_path.parent().is_some() {
425        return Ok(());
426    }
427    let mut walked_directory = Utf8PathBuf::new();
428    let target_components = resource_path.parent().unwrap().components();
429    for component in target_components {
430
431        walked_directory = walked_directory.join(component);
432        create_output_directory(&mut walked_directory)?;
433    }
434    let can_resource_path = resource_path.parent().unwrap().canonicalize_utf8()
435        .map_err(
436            |e| format!(
437                "Unable to canonicalize resource path: {}: {}",
438                resource_path,
439                e
440            )
441        )?;
442
443    if !can_resource_path.starts_with(&can_root_path) {
444        Err(
445            format!(
446                "Can't copy to {:?} as not in resource root {:?}",
447                can_resource_path,
448                can_root_path
449            )
450        )?
451    }
452    Ok(())
453}
454
455/// Create the output directory if it doesn't exist.
456fn create_output_directory(output_dir: &Utf8Path) -> Result<(), String> {
457    if !output_dir.exists() {
458        fs::create_dir_all(&output_dir)
459            .map_err(|e|
460                format!("Unable to create output directory {}: {}", &output_dir, e)
461            )?
462    }
463    Ok(())
464}