diff --git a/Cargo.lock b/Cargo.lock index 80d32289..37af9feb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -693,6 +693,41 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.87", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.87", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -721,6 +756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", + "serde", ] [[package]] @@ -1353,6 +1389,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", + "serde_with", "sha256", "smart-default", "spdx-rs", @@ -1704,6 +1741,12 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "identity" version = "0.1.0" @@ -1758,6 +1801,7 @@ checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", "hashbrown 0.15.0", + "serde", ] [[package]] @@ -3037,6 +3081,36 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "serde_with" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.7.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "sha1" version = "0.10.6" diff --git a/hipcheck/Cargo.toml b/hipcheck/Cargo.toml index 45cd1161..1e134c7b 100644 --- a/hipcheck/Cargo.toml +++ b/hipcheck/Cargo.toml @@ -141,6 +141,7 @@ zip = "2.2.1" zip-extensions = "0.8.1" zstd = "0.13.2" hipcheck-common = { version = "0.1.0", path = "../hipcheck-common" } +serde_with = "3.11.0" [build-dependencies] diff --git a/hipcheck/src/config.rs b/hipcheck/src/config.rs index 87e02178..3b088d4d 100644 --- a/hipcheck/src/config.rs +++ b/hipcheck/src/config.rs @@ -10,6 +10,7 @@ use crate::{ policy_file::{PolicyAnalysis, PolicyCategory, PolicyCategoryChild}, PolicyFile, }, + policy_exprs::{std_parse, Expr}, score::*, util::fs as file, BINARY_CONFIG_FILE, F64, LANGS_FILE, ORGS_FILE, TYPO_FILE, @@ -468,7 +469,7 @@ pub trait ConfigSource: salsa::Database { #[salsa::query_group(RiskConfigQueryStorage)] pub trait RiskConfigQuery: ConfigSource { /// Returns the risk policy expr - fn risk_policy(&self) -> Rc; + fn risk_policy(&self) -> Result>; } /// Query for accessing the languages analysis config @@ -522,7 +523,7 @@ pub struct Analysis { } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct PoliciedAnalysis(pub Analysis, pub String); +pub struct PoliciedAnalysis(pub Analysis, pub Option); #[derive(Debug, Clone, PartialEq, Eq)] pub enum AnalysisTreeNode { @@ -566,9 +567,9 @@ impl AnalysisTreeNode { weight, } } - pub fn analysis(analysis: Analysis, raw_policy: String, weight: F64) -> Self { + pub fn analysis(analysis: Analysis, opt_policy: Option, weight: F64) -> Self { AnalysisTreeNode::Analysis { - analysis: PoliciedAnalysis(analysis, raw_policy), + analysis: PoliciedAnalysis(analysis, opt_policy), weight, } } @@ -653,13 +654,13 @@ impl AnalysisTree { &mut self, under: NodeId, analysis: Analysis, - raw_policy: String, + opt_policy: Option, weight: F64, ) -> Result { if self.node_is_category(under)? { let child = self .tree - .new_node(AnalysisTreeNode::analysis(analysis, raw_policy, weight)); + .new_node(AnalysisTreeNode::analysis(analysis, opt_policy, weight)); under.append(child, &mut self.tree); Ok(child) } else { @@ -748,16 +749,16 @@ fn add_analysis( Some(u) => F64::new(u as f64)?, None => F64::new(1.0)?, }; - let raw_policy = match analysis.policy_expression { - Some(x) => x, - None => "".to_owned(), - }; + let opt_policy = analysis + .policy_expression + .map(|s| s.parse::()) + .transpose()?; let analysis = Analysis { publisher: publisher.0, plugin: plugin.0, query: DEFAULT_QUERY.to_owned(), }; - tree.add_analysis(under, analysis, raw_policy, weight) + tree.add_analysis(under, analysis, opt_policy, weight) } fn add_category( @@ -815,8 +816,8 @@ pub fn analysis_tree(db: &dyn WeightTreeProvider) -> Result> { let update_policy = |node: &mut AnalysisTreeNode| -> Result<()> { if let AnalysisTreeNode::Analysis { analysis, .. } = node { let a: &Analysis = &analysis.0; - if analysis.1.is_empty() { - analysis.1 = db.default_policy_expr(a.publisher.clone(), a.plugin.clone())?.ok_or(hc_error!("plugin {}::{} does not have a default policy, please define a policy in your policy file", a.publisher.clone(), a.plugin.clone()))?; + if analysis.1.is_none() { + analysis.1 = Some(db.default_policy_expr(a.publisher.clone(), a.plugin.clone())?.ok_or(hc_error!("plugin {}::{} does not have a default policy, please define a policy in your policy file", a.publisher.clone(), a.plugin.clone()))?); } } Ok(()) @@ -866,9 +867,13 @@ pub fn normalized_unresolved_analysis_tree(db: &dyn ConfigSource) -> Result Rc { +fn risk_policy(db: &dyn RiskConfigQuery) -> Result> { let policy = db.policy(); - Rc::new(policy.analyze.investigate_policy.0.clone()) + let expr_str = policy.analyze.investigate_policy.0.as_str(); + let expr = std_parse(expr_str) + .map_err(|e| hc_error!("Malformed risk policy expression '{}': {}", expr_str, e))?; + + Ok(Rc::new(expr)) } fn langs_file_rel(_db: &dyn LanguagesConfigQuery) -> Rc { diff --git a/hipcheck/src/engine.rs b/hipcheck/src/engine.rs index 3898c236..51408f5c 100644 --- a/hipcheck/src/engine.rs +++ b/hipcheck/src/engine.rs @@ -9,6 +9,7 @@ use crate::{ QueryResult, }, policy::PolicyFile, + policy_exprs::Expr, Result, }; use futures::future::{BoxFuture, FutureExt}; @@ -27,7 +28,7 @@ pub trait HcEngine: salsa::Database { #[salsa::input] fn core(&self) -> Arc; - fn default_policy_expr(&self, publisher: String, plugin: String) -> Result>; + fn default_policy_expr(&self, publisher: String, plugin: String) -> Result>; fn default_query_explanation( &self, @@ -48,7 +49,7 @@ fn default_policy_expr( db: &dyn HcEngine, publisher: String, plugin: String, -) -> Result> { +) -> Result> { let core = db.core(); let key = get_plugin_key(publisher.as_str(), plugin.as_str()); let Some(p_handle) = core.plugins.get(&key) else { diff --git a/hipcheck/src/plugin/mod.rs b/hipcheck/src/plugin/mod.rs index c0626365..b7507e75 100644 --- a/hipcheck/src/plugin/mod.rs +++ b/hipcheck/src/plugin/mod.rs @@ -10,6 +10,7 @@ mod types; use crate::error::Result; pub use crate::plugin::{get_plugin_key, manager::*, plugin_id::PluginId, types::*}; +use crate::policy_exprs::Expr; pub use arch::{get_current_arch, try_set_arch, Arch}; pub use download_manifest::{ArchiveFormat, DownloadManifest, HashAlgorithm, HashWithDigest}; use hipcheck_common::types::{Query, QueryDirection}; @@ -55,7 +56,7 @@ impl ActivePlugin { } } - pub fn get_default_policy_expr(&self) -> Option<&String> { + pub fn get_default_policy_expr(&self) -> Option<&Expr> { self.channel.opt_default_policy_expr.as_ref() } diff --git a/hipcheck/src/plugin/types.rs b/hipcheck/src/plugin/types.rs index 1846593d..f0536f99 100644 --- a/hipcheck/src/plugin/types.rs +++ b/hipcheck/src/plugin/types.rs @@ -1,6 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 -use crate::{hc_error, Result}; +use crate::{ + hc_error, + policy_exprs::{std_parse, Expr}, + Result, +}; use futures::{Stream, StreamExt}; use hipcheck_common::proto::{ plugin_service_client::PluginServiceClient, ConfigurationStatus, Empty, @@ -284,7 +288,11 @@ impl PluginContext { self.set_configuration(&config).await?.as_result()?; - let opt_default_policy_expr = self.get_default_policy_expression().await?; + let opt_default_policy_expr = self + .get_default_policy_expression() + .await? + .map(|s| std_parse(s.as_str())) + .transpose()?; let opt_explain_default_query = self.explain_default_query().await?; @@ -387,7 +395,7 @@ impl MultiplexedQueryReceiver { #[derive(Debug)] pub struct PluginTransport { pub schemas: HashMap, - pub opt_default_policy_expr: Option, + pub opt_default_policy_expr: Option, pub opt_explain_default_query: Option, ctx: PluginContext, tx: mpsc::Sender, diff --git a/hipcheck/src/policy_exprs/expr.rs b/hipcheck/src/policy_exprs/expr.rs index 66301c82..a8ffdaac 100644 --- a/hipcheck/src/policy_exprs/expr.rs +++ b/hipcheck/src/policy_exprs/expr.rs @@ -15,6 +15,7 @@ use nom::{ Finish as _, IResult, }; use ordered_float::NotNan; +use serde_with::{DeserializeFromStr, SerializeDisplay}; use std::{ cmp::Ordering, fmt::Display, @@ -27,7 +28,7 @@ use std::{ use jiff::civil::Date; /// A `deke` expression to evaluate. -#[derive(Debug, PartialEq, Eq, Clone)] +#[derive(Debug, PartialEq, Eq, Clone, SerializeDisplay, DeserializeFromStr)] pub enum Expr { /// Primitive data (ints, floats, bool). Primitive(Primitive), @@ -453,7 +454,7 @@ impl Display for Expr { ) } Expr::Function(func) => func.fmt(f), - Expr::Lambda(l) => write!(f, "(lambda ({}) {})", l.arg, l.body), + Expr::Lambda(l) => l.fmt(f), Expr::JsonPointer(pointer) => write!(f, "${}", pointer.pointer), } } @@ -472,6 +473,22 @@ impl Display for Primitive { } } +impl Display for Lambda { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut out: Vec = vec![self.body.ident.to_string()]; + // Filter out references to placeholder var + let arg: Expr = Primitive::Identifier(self.arg.clone()).into(); + out.extend(self.body.args.iter().filter_map(|x| { + if *x != arg { + Some(ToString::to_string(x)) + } else { + None + } + })); + write!(f, "({})", out.join(" ")) + } +} + impl Primitive { pub fn resolve(&self, env: &Env) -> Result { match self { @@ -491,8 +508,9 @@ impl Primitive { impl Display for Function { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let args = self.args.iter().map(ToString::to_string).join(" "); - write!(f, "({} {})", self.ident, args) + let mut out: Vec = vec![self.ident.to_string()]; + out.extend(self.args.iter().map(ToString::to_string)); + write!(f, "({})", out.join(" ")) } } diff --git a/hipcheck/src/policy_exprs/mod.rs b/hipcheck/src/policy_exprs/mod.rs index fc9c7fec..16094c92 100644 --- a/hipcheck/src/policy_exprs/mod.rs +++ b/hipcheck/src/policy_exprs/mod.rs @@ -25,7 +25,59 @@ use env::Binding; pub use expr::{parse, Primitive}; use json_pointer::LookupJsonPointers; use serde_json::Value; -use std::ops::Deref; +use std::{ops::Deref, str::FromStr, sync::LazyLock}; + +static PASS_STD_FUNC_RES: LazyLock = LazyLock::new(FunctionResolver::std); +static PASS_STD_TYPE_FIX: LazyLock = LazyLock::new(TypeFixer::std); +static PASS_STD_TYPE_CHK: LazyLock = LazyLock::new(TypeChecker::default); + +pub fn std_pre_analysis_pipeline(mut expr: Expr) -> Result { + expr = PASS_STD_FUNC_RES.run(expr)?; + expr = PASS_STD_TYPE_FIX.run(expr)?; + PASS_STD_TYPE_CHK.run(&expr)?; + Ok(expr) +} + +pub fn std_post_analysis_pipeline( + mut expr: Expr, + context: Option<&Value>, + run_pre_passes: bool, +) -> Result { + // Track whether we've done type checking or we've added something to require re-doing it + let mut needs_check = true; + if run_pre_passes { + expr = std_pre_analysis_pipeline(expr)?; + needs_check = false; + } + // Adding JSON context requires re-type checking + if let Some(ctx) = context { + expr = LookupJsonPointers::with_context(ctx).run(expr)?; + needs_check = true; + } + if needs_check { + PASS_STD_TYPE_CHK.run(&expr)?; + } + Env::std().run(expr) +} + +pub fn std_parse(raw_program: &str) -> Result { + std_pre_analysis_pipeline(parse(raw_program)?) +} + +pub fn std_exec(mut expr: Expr, context: Option<&Value>) -> Result { + match std_post_analysis_pipeline(expr, context, false)? { + Expr::Primitive(Primitive::Bool(b)) => Ok(b), + result => Err(Error::DidNotReturnBool(result)), + } +} + +impl FromStr for Expr { + type Err = crate::policy_exprs::error::Error; + + fn from_str(raw: &str) -> Result { + std_pre_analysis_pipeline(parse(raw)?) + } +} /// Evaluates `deke` expressions. pub struct Executor { @@ -55,6 +107,7 @@ impl Executor { Ok(expr) } } + impl ExprMutator for Env<'_> { fn visit_primitive(&self, prim: Primitive) -> Result { Ok(prim.resolve(self)?.into()) @@ -374,4 +427,17 @@ mod tests { let ret_ty = f_ty.get_return_type(); assert_eq!(ret_ty, Ok(ReturnableType::Primitive(PrimitiveType::Bool))); } + + #[test] + fn from_and_to_string() { + let programs = vec!["(not $)", "(gt 0)", "(filter (gt 0) $/alpha)"]; + + for program in programs { + let mut expr = parse(&program).unwrap(); + expr = FunctionResolver::std().run(expr).unwrap(); + expr = TypeFixer::std().run(expr).unwrap(); + let string = expr.to_string(); + assert_eq!(program, string); + } + } } diff --git a/hipcheck/src/report/mod.rs b/hipcheck/src/report/mod.rs index 27bc1bc3..64d83982 100644 --- a/hipcheck/src/report/mod.rs +++ b/hipcheck/src/report/mod.rs @@ -14,7 +14,7 @@ pub mod report_builder; use crate::{ cli::Format, error::{Context, Error, Result}, - policy_exprs::Executor, + policy_exprs::{std_exec, Expr}, version::VersionQuery, }; use chrono::prelude::*; @@ -281,14 +281,20 @@ pub struct Analysis { /// /// We use this when printing the result to help explain to the user /// *why* an analysis failed. - policy_expr: String, + #[schemars(schema_with = "String::json_schema")] + policy_expr: Expr, /// The default query explanation pulled from RPC with the plugin. message: String, } +// fn custom_schema(generator: &mut SchemaGenerator) -> Schema { +// let mut schema = String::json_schema(generator); +// schema +// } + impl Analysis { - pub fn plugin(name: String, passed: bool, policy_expr: String, message: String) -> Self { + pub fn plugin(name: String, passed: bool, policy_expr: Expr, message: String) -> Self { Analysis { name, passed, @@ -363,7 +369,7 @@ impl Recommendation { pub fn statement(&self) -> String { format!( "risk rated as {:.2}, policy was {}", - self.risk_score.0, self.risk_policy.0 + self.risk_score.0, self.risk_policy.expr ) } } @@ -380,8 +386,7 @@ impl RecommendationKind { fn is(risk_score: RiskScore, risk_policy: RiskPolicy) -> Result { let value = serde_json::to_value(risk_score.0).unwrap(); Ok( - if Executor::std() - .run(&risk_policy.0, &value) + if std_exec(risk_policy.expr.clone(), Some(&value)) .context("investigate policy expression execution failed")? { RecommendationKind::Pass @@ -402,7 +407,15 @@ pub struct RiskScore(pub f64); #[derive(Debug, Serialize, JsonSchema, Clone)] #[serde(transparent)] #[schemars(crate = "schemars")] -pub struct RiskPolicy(pub String); +pub struct RiskPolicy { + #[schemars(schema_with = "String::json_schema")] + pub expr: Expr, +} +impl RiskPolicy { + pub fn new(expr: Expr) -> Self { + RiskPolicy { expr } + } +} /// A serializable and printable wrapper around a datetime with the local timezone. #[derive(Debug, JsonSchema)] diff --git a/hipcheck/src/report/report_builder.rs b/hipcheck/src/report/report_builder.rs index 540ccdcd..51a09af1 100644 --- a/hipcheck/src/report/report_builder.rs +++ b/hipcheck/src/report/report_builder.rs @@ -58,7 +58,7 @@ pub fn build_report(session: &Session, scoring: &ScoringResults) -> Result { errored: Vec, /// What risk threshold was configured for the run. - risk_policy: Option, + risk_policy: Option, /// What risk score Hipcheck assigned. risk_score: Option, @@ -151,7 +151,7 @@ impl<'sess> ReportBuilder<'sess> { } /// Set what's being recommended to the user. - pub fn set_risk_policy(&mut self, risk_policy: String) -> &mut Self { + pub fn set_risk_policy(&mut self, risk_policy: Expr) -> &mut Self { self.risk_policy = Some(risk_policy); self } @@ -177,7 +177,7 @@ impl<'sess> ReportBuilder<'sess> { let policy = self .risk_policy .ok_or_else(|| hc_error!("no risk threshold set for report")) - .map(RiskPolicy)?; + .map(RiskPolicy::new)?; // Determine recommendation based on score and investigate policy expr let mut rec = Recommendation::is(score, policy)?; diff --git a/hipcheck/src/score.rs b/hipcheck/src/score.rs index 15cd1a68..84fa22b4 100644 --- a/hipcheck/src/score.rs +++ b/hipcheck/src/score.rs @@ -6,7 +6,7 @@ use crate::{ error::Result, hc_error, plugin::QueryResult, - policy_exprs::Executor, + policy_exprs::{std_exec, Expr}, shell::spinner_phase::SpinnerPhase, source::SourceQuery, }; @@ -41,7 +41,7 @@ pub struct ScoringResults { #[derive(Debug, Clone)] pub struct PluginAnalysisResult { pub response: Result, - pub policy: String, + pub policy: Expr, pub passed: bool, } @@ -205,6 +205,10 @@ pub fn score_results(_phase: &SpinnerPhase, db: &dyn ScoringProvider) -> Result< let target_json = serde_json::to_value(db.target().as_ref())?; for analysis in analysis_tree.get_analyses() { + let policy = analysis.1.ok_or(hc_error!( + "We should not have been able to get this far without a policy expr" + ))?; + // Perform query, passing target in JSON let response = db.query( analysis.0.publisher.clone(), @@ -216,9 +220,7 @@ pub fn score_results(_phase: &SpinnerPhase, db: &dyn ScoringProvider) -> Result< // Determine if analysis passed by evaluating policy expr let passed = { if let Ok(output) = &response { - Executor::std() - .run(analysis.1.as_str(), &output.value) - .map_err(|e| hc_error!("{}", e))? + std_exec(policy.clone(), Some(&output.value)).map_err(|e| hc_error!("{}", e))? } else { false } @@ -229,7 +231,7 @@ pub fn score_results(_phase: &SpinnerPhase, db: &dyn ScoringProvider) -> Result< analysis.0.clone(), PluginAnalysisResult { response, - policy: analysis.1.clone(), + policy, passed, }, ); diff --git a/hipcheck/src/session/mod.rs b/hipcheck/src/session/mod.rs index 5c473708..1ae5fcae 100644 --- a/hipcheck/src/session/mod.rs +++ b/hipcheck/src/session/mod.rs @@ -10,7 +10,7 @@ use crate::{ config::{ AttacksConfigQueryStorage, CommitConfigQueryStorage, Config, ConfigSource, ConfigSourceStorage, LanguagesConfigQueryStorage, PracticesConfigQueryStorage, - RiskConfigQueryStorage, WeightTreeQueryStorage, + RiskConfigQuery, RiskConfigQueryStorage, WeightTreeQueryStorage, }, engine::{start_plugins, HcEngine, HcEngineStorage}, error::{Context as _, Error, Result}, @@ -128,11 +128,10 @@ impl Session { // Check if a policy file was provided, otherwise convert a deprecated config file to a policy file. If neither was provided, error out. if policy_path.is_some() { - let (policy, policy_path) = - match load_policy_and_data(policy_path.as_deref()) { - Ok(results) => results, - Err(err) => return Err(err), - }; + let (policy, policy_path) = match load_policy_and_data(policy_path.as_deref()) { + Ok(results) => results, + Err(err) => return Err(err), + }; // No config or dir session.set_config_dir(None); @@ -141,11 +140,10 @@ impl Session { session.set_policy(Rc::new(policy)); session.set_policy_path(Some(Rc::new(policy_path))); } else if config_path.is_some() { - let (policy, config_dir) = - match load_config_and_data(config_path.as_deref()) { - Ok(results) => results, - Err(err) => return Err(err), - }; + let (policy, config_dir) = match load_config_and_data(config_path.as_deref()) { + Ok(results) => results, + Err(err) => return Err(err), + }; // Set config dir session.set_config_dir(Some(Rc::new(config_dir))); @@ -153,11 +151,13 @@ impl Session { // Set policy file, with no location to represent that none was given session.set_policy(Rc::new(policy)); session.set_policy_path(None); - } else { return Err(hc_error!("No policy file or (deprecated) config file found. Please provide a policy file before running Hipcheck.")); } + // Force eval the risk policy expr - wouldn't be necessary if the PolicyFile parsed + let _ = session.risk_policy()?; + /*=================================================================== * Resolving the Hipcheck home. *-----------------------------------------------------------------*/ @@ -247,7 +247,7 @@ pub fn load_config_and_data(config_path: Option<&Path>) -> Result<(PolicyFile, P // Convert the Config struct to a PolicyFile struct let policy = config_to_policy(config)?; - + phase.finish_successful(); Ok((policy, valid_config_path.to_path_buf())) @@ -297,7 +297,6 @@ fn load_target(seed: &TargetSeed, home: &Path) -> Result { Ok(target) } - /// Resolves the target specifier into an actual target. fn resolve_target(seed: &TargetSeed, phase: &SpinnerPhase, home: &Path) -> Result { use TargetSeedKind::*; @@ -377,4 +376,4 @@ fn resolve_target(seed: &TargetSeed, phase: &SpinnerPhase, home: &Path) -> Resul ) } } -} \ No newline at end of file +}