diff --git a/Cargo.lock b/Cargo.lock index e02ea5f1..9b8a2132 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -670,7 +670,7 @@ dependencies = [ "brane-shr 3.0.0", "brane-tsk", "clap 4.5.20", - "eflint-json", + "eflint-json 0.1.0", "eflint-to-json 0.2.0 (git+https://gitlab.com/eflint/eflint-to-json-rs?tag=v0.1.0)", "enum-debug 1.1.0 (git+https://github.com/Lut99/enum-debug?tag=v1.1.0)", "error-trace 3.2.1 (git+https://github.com/Lut99/error-trace-rs?tag=v3.3.0)", @@ -787,6 +787,7 @@ dependencies = [ "brane-cfg", "brane-shr 3.0.0", "brane-tsk", + "chrono", "clap 4.5.20", "clap_complete", "console", @@ -796,6 +797,7 @@ dependencies = [ "dirs 5.0.1", "dotenvy", "download 0.1.0 (git+https://github.com/Lut99/download-rs?tag=v0.1.0)", + "eflint-json 1.0.0", "eflint-to-json 0.2.0 (git+https://gitlab.com/eflint/eflint-to-json-rs)", "enum-debug 1.1.0 (git+https://github.com/Lut99/enum-debug?tag=v1.1.0)", "error-trace 3.0.0", @@ -1213,9 +1215,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.38" +version = "0.4.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" dependencies = [ "android-tzdata", "iana-time-zone", @@ -1758,12 +1760,23 @@ dependencies = [ "serde", ] +[[package]] +name = "eflint-json" +version = "1.0.0" +source = "git+https://gitlab.com/eflint/json-spec-rs#f4716c63158b3082a8861416378e5189e01930a3" +dependencies = [ + "enum-debug 1.0.0", + "serde", + "transform", + "versioning", +] + [[package]] name = "eflint-json-reasoner" version = "0.2.0" source = "git+https://github.com/epi-project/policy-reasoner?branch=lib-refactor#8ca01dd905da99be4168c20b7eeda2d83a380466" dependencies = [ - "eflint-json", + "eflint-json 0.1.0", "error-trace 3.1.0", "reqwest 0.12.9", "serde", @@ -1831,6 +1844,14 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +[[package]] +name = "enum-debug" +version = "1.0.0" +source = "git+https://github.com/Lut99/enum-debug?tag=v1.0.0#cd67d67d4bd8708eaa3343a6af515bf6ad5a3ce6" +dependencies = [ + "enum-debug-derive 1.0.0", +] + [[package]] name = "enum-debug" version = "1.1.0" @@ -1847,6 +1868,16 @@ dependencies = [ "enum-debug-derive 1.1.0 (git+https://github.com/Lut99/enum-debug)", ] +[[package]] +name = "enum-debug-derive" +version = "1.0.0" +source = "git+https://github.com/Lut99/enum-debug?tag=v1.0.0#cd67d67d4bd8708eaa3343a6af515bf6ad5a3ce6" +dependencies = [ + "proc-macro-error", + "quote", + "syn 2.0.87", +] + [[package]] name = "enum-debug-derive" version = "1.1.0" @@ -3499,6 +3530,30 @@ dependencies = [ "toml_edit 0.19.15", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.86" @@ -5394,6 +5449,17 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "versioning" +version = "0.2.0" +source = "git+https://github.com/Lut99/versioning-rs#7ddfcc666e126a053b8c00f0a4eea39df9576d92" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "void" version = "1.0.2" diff --git a/brane-ctl/Cargo.toml b/brane-ctl/Cargo.toml index 56eb3544..9772e174 100644 --- a/brane-ctl/Cargo.toml +++ b/brane-ctl/Cargo.toml @@ -20,6 +20,7 @@ doc = false [dependencies] base64ct = "1.6.0" bollard = "0.14.0" +chrono = "0.4.39" clap = { version = "4.5.6", features = ["derive","env"] } console = "0.15.5" dialoguer = "0.11.0" @@ -44,12 +45,13 @@ names.workspace = true rand = "0.8.5" reqwest = { version = "0.12.9" } serde = { version = "1.0.204", features = ["derive"] } -serde_json = "1.0.120" +serde_json = { version = "1.0.120", features = ["raw_value"] } serde_yaml = { version = "0.0.10", package = "serde_yml" } shlex = "1.1.0" tempfile = "3.10.1" tokio = { version = "1.42.0", features = [] } +eflint-json = { git = "https://gitlab.com/eflint/json-spec-rs", features = ["v0_1_0_srv"] } eflint-to-json = { git = "https://gitlab.com/eflint/eflint-to-json-rs", features = ["async-tokio"] } policy-store = { git = "https://github.com/epi-project/policy-store", features = ["sqlite-database", "sqlite-database-embedded-migrations"] } diff --git a/brane-ctl/src/cli.rs b/brane-ctl/src/cli.rs index 25adcbee..92a77bd5 100644 --- a/brane-ctl/src/cli.rs +++ b/brane-ctl/src/cli.rs @@ -552,7 +552,7 @@ pub(crate) enum PolicySubcommand { help = "The version of the policy to activate. Omit to have branectl download the version metadata from the checker and let you choose \ interactively." )] - version: Option, + version: Option, /// Address on which to find the checker. #[clap( diff --git a/brane-ctl/src/policies.rs b/brane-ctl/src/policies.rs index d51aed0c..6f889e06 100644 --- a/brane-ctl/src/policies.rs +++ b/brane-ctl/src/policies.rs @@ -4,7 +4,7 @@ // Created: // 10 Jan 2024, 15:57:54 // Last edited: -// 06 Dec 2024, 18:41:52 +// 09 Dec 2024, 17:33:32 // Auto updated? // Yes // @@ -12,6 +12,7 @@ //! Implements handlers for subcommands to `branectl policies ...` // +use std::borrow::Cow; use std::collections::HashMap; use std::error; use std::ffi::OsStr; @@ -22,20 +23,23 @@ use std::time::Duration; use brane_cfg::info::Info; use brane_cfg::node::{NodeConfig, NodeSpecificConfig, WorkerConfig}; use brane_shr::formatters::BlockFormatter; +use chrono::{DateTime, Local}; use console::style; use dialoguer::theme::ColorfulTheme; use enum_debug::EnumDebug; use error_trace::trace; use log::{debug, info}; use policy_store::servers::axum::spec::{ActivateRequest, GetActiveVersionResponse, GetVersionsResponse}; -use policy_store::spec::metadata::Metadata; +use policy_store::spec::metadata::{AttachedMetadata, Metadata}; use rand::Rng; use rand::distributions::Alphanumeric; use reqwest::{Client, Request, Response, StatusCode}; use serde_json::Value; -use serde_json::value::RawValue; use specifications::address::{Address, AddressOpt}; -use specifications::checking::store::{ACTIVATE_PATH, ADD_VERSION_PATH, GET_ACTIVE_VERSION_PATH, GET_VERSION_CONTENT_PATH, GET_VERSIONS_PATH}; +use specifications::checking::store::{ + ACTIVATE_PATH, ADD_VERSION_PATH, AddVersionRequest, AddVersionResponse, EFlintJsonReasonerWithInterfaceContext, GET_ACTIVE_VERSION_PATH, + GET_CONTEXT_PATH, GET_VERSION_CONTENT_PATH, GET_VERSIONS_PATH, GetContextResponse, +}; use tokio::fs::{self as tfs, File as TFile}; use crate::spec::PolicyInputLanguage; @@ -47,12 +51,16 @@ use crate::spec::PolicyInputLanguage; pub enum Error { /// Failed to get the active version of the policy. ActiveVersionGet { addr: Address, err: Box }, + /// Given JSON policy was not a phrases request. + IllegalInput { path: PathBuf, got: String }, /// Failed to deserialize the read input file as JSON. InputDeserialize { path: PathBuf, raw: String, err: serde_json::Error }, /// Failed to read the input file. InputRead { path: PathBuf, err: std::io::Error }, /// Failed to compile the input file to eFLINT JSON. InputToJson { path: PathBuf, err: eflint_to_json::Error }, + /// Failed to prompt the user for a string input. + InputString { what: &'static str, err: dialoguer::Error }, /// The wrong policy was activated on the remote checker, somehow. InvalidPolicyActivated { addr: Address, got: Option, expected: Option }, /// A policy language was attempted to derive from a path without extension. @@ -91,16 +99,22 @@ pub enum Error { VersionSelect { err: dialoguer::Error }, /// Failed to get the versions on the remote checker. VersionsGet { addr: Address, err: Box }, + /// Failed to serialize a given policy version. + VersionSerialize { version: u64, err: serde_json::Error }, } impl Display for Error { fn fmt(&self, f: &mut Formatter<'_>) -> FResult { use Error::*; match self { ActiveVersionGet { addr, .. } => write!(f, "Failed to get active version of checker '{addr}'"), + IllegalInput { path, got } => { + write!(f, "eFLINT JSON file {:?} is not a list of phrases or a phrases request (got: {:?})", path.display(), got) + }, InputDeserialize { path, raw, .. } => { write!(f, "Failed to deserialize contents of '{}' to JSON\n\nRaw value:\n{}\n", path.display(), BlockFormatter::new(raw)) }, InputRead { path, .. } => write!(f, "Failed to read input file '{}'", path.display()), + InputString { what, .. } => write!(f, "Failed to ask you {what}"), InputToJson { path, .. } => write!(f, "Failed to compile input file '{}' to eFLINT JSON", path.display()), InvalidPolicyActivated { addr, got, expected } => write!( f, @@ -149,6 +163,7 @@ impl Display for Error { VersionGetBody { addr, version, .. } => write!(f, "Failed to get policy body of policy '{version}' stored in checker '{addr}'"), VersionSelect { .. } => write!(f, "Failed to ask you which version to make active"), VersionsGet { addr, .. } => write!(f, "Failed to get policy versions stored in checker '{addr}'"), + VersionSerialize { version, .. } => write!(f, "Failed to serialize policy {version}"), } } } @@ -157,8 +172,10 @@ impl error::Error for Error { use Error::*; match self { ActiveVersionGet { err, .. } => Some(&**err), + IllegalInput { .. } => None, InputDeserialize { err, .. } => Some(err), InputRead { err, .. } => Some(err), + InputString { err, .. } => Some(err), InputToJson { err, .. } => Some(err), InvalidPolicyActivated { .. } => None, MissingExtension { .. } => None, @@ -179,6 +196,7 @@ impl error::Error for Error { VersionGetBody { err, .. } => Some(&**err), VersionSelect { err } => Some(err), VersionsGet { err, .. } => Some(&**err), + VersionSerialize { err, .. } => Some(err), } } } @@ -283,6 +301,57 @@ fn resolve_addr_opt(node_config_path: impl AsRef, worker: &mut Option Result { + info!("Retrieving policies on checker '{address}'"); + + // Prepare the request + let url: String = format!("http://{}{}", address, GET_CONTEXT_PATH.instantiated_path::(None)); + debug!("Building GET-request to '{url}'..."); + let client: Client = Client::new(); + let req: Request = match client.request(GET_CONTEXT_PATH.method, &url).bearer_auth(token).build() { + Ok(req) => req, + Err(err) => return Err(Error::RequestBuild { kind: "GET", addr: url, err }), + }; + + // Send it + debug!("Sending request to '{url}'..."); + let res: Response = match client.execute(req).await { + Ok(res) => res, + Err(err) => return Err(Error::RequestSend { kind: "GET", addr: url, err }), + }; + debug!("Server responded with {}", res.status()); + if !res.status().is_success() { + return Err(Error::RequestFailure { addr: url, code: res.status(), response: res.text().await.ok() }); + } + + // Attempt to parse the result as a list of policy versions + match res.text().await { + Ok(body) => { + // Log the full response first + debug!("Response:\n{}\n", BlockFormatter::new(&body)); + // Parse it as a [`Policy`] + match serde_json::from_str::(&body) { + Ok(body) => Ok(body.context), + Err(err) => Err(Error::ResponseDeserialize { addr: url, raw: body, err }), + } + }, + Err(err) => Err(Error::ResponseDownload { addr: url, err }), + } +} + /// Helper function that pulls a specific version's body from a checker. /// /// # Arguments @@ -291,7 +360,7 @@ fn resolve_addr_opt(node_config_path: impl AsRef, worker: &mut Option Result(None)); + let url: String = format!("http://{}{}", address, GET_VERSIONS_PATH.instantiated_path::(None)); debug!("Building GET-request to '{url}'..."); let client: Client = Client::new(); let req: Request = match client.request(GET_VERSIONS_PATH.method, &url).bearer_auth(token).build() { @@ -389,7 +458,7 @@ async fn get_versions_on_checker(address: &Address, token: &str) -> Result Result info!("Retrieving active policy of checker '{address}'"); // Prepare the request - let url: String = format!("http://{}/{}", address, GET_ACTIVE_VERSION_PATH.instantiated_path::(None)); + let url: String = format!("http://{}{}", address, GET_ACTIVE_VERSION_PATH.instantiated_path::(None)); debug!("Building GET-request to '{url}'..."); let client: Client = Client::new(); let req: Request = match client.request(GET_ACTIVE_VERSION_PATH.method, &url).bearer_auth(token).build() { @@ -434,9 +503,37 @@ async fn get_active_version_on_checker(address: &Address, token: &str) -> Result } } + + +/// Prompts to supply a string with an optional value. +/// +/// # Arguments +/// - `what`: Some abstract description of what is prompted. Only used for error handling. +/// - `question`: The question to ask the input of. +/// - `default`: A default value to give, if any. +/// +/// # Returns +/// The information selected by the user. May be the `default` if given and the user selected it. +/// +/// # Errors +/// This function may error if we failed to query the user. +fn prompt_user_string(what: &'static str, question: impl Into, default: Option<&str>) -> Result { + // Ask the user using dialoguer, then return that version + let theme = ColorfulTheme::default(); + let mut prompt = dialoguer::Input::with_theme(&theme).with_prompt(question).show_default(default.is_some()); + if let Some(default) = default { + prompt = prompt.default(default.to_string()); + } + match prompt.interact() { + Ok(res) => Ok(res), + Err(err) => Err(Error::InputString { what, err }), + } +} + /// Prompts the user to select one of the given list of versions. /// /// # Arguments +/// - `question`: The question to ask the input of. /// - `active_version`: If there is any active version. /// - `versions`: The list of versions to select from. /// - `exit`: Whether to provide an exit button to the prompt or not. @@ -446,7 +543,12 @@ async fn get_active_version_on_checker(address: &Address, token: &str) -> Result /// /// # Errors /// This function may error if we failed to query the user. -fn prompt_user_version(active_version: Option, versions: &HashMap, exit: bool) -> Result, Error> { +fn prompt_user_version( + question: impl Into, + active_version: Option, + versions: &HashMap, + exit: bool, +) -> Result, Error> { // First: go by order let mut ids: Vec = versions.keys().cloned().collect(); ids.sort(); @@ -489,11 +591,7 @@ fn prompt_user_version(active_version: Option, versions: &HashMap { if !exit || idx < versions.len() { // Exit wasn't selected @@ -594,7 +692,7 @@ pub async fn activate(node_config_path: PathBuf, version: Option, address: }; // Prompt the user to select it - match prompt_user_version(active_version, &versions, false) { + match prompt_user_version("Which version do you want to make active?", active_version, &versions, false) { Ok(Some(id)) => id, Ok(None) => unreachable!(), Err(err) => return Err(Error::PromptVersions { err: Box::new(err) }), @@ -603,7 +701,7 @@ pub async fn activate(node_config_path: PathBuf, version: Option, address: debug!("Activating policy version {version}"); // Now build the request and send it - let url: String = format!("http://{}/{}", address, ACTIVATE_PATH.instantiated_path::(None)); + let url: String = format!("http://{}{}", address, ACTIVATE_PATH.instantiated_path::(None)); debug!("Building PUT-request to '{url}'..."); let client: Client = Client::new(); let req: Request = match client.request(ACTIVATE_PATH.method, &url).bearer_auth(token).json(&ActivateRequest { version }).build() { @@ -676,6 +774,18 @@ pub async fn add( (input.into(), false) }; + // Query the user for some metadata + debug!("Prompting user (you!) for metadata..."); + let name: String = prompt_user_string( + "for a policy name", + "Provide a descriptive name of the policy", + input.file_name().map(OsStr::to_string_lossy).as_ref().map(Cow::as_ref), + )?; + debug!("Policy name: {name:?}"); + let description: String = + prompt_user_string("for a policy description", "Provide a short description of the policy", Some("A very dope policy"))?; + debug!("Policy description: {description:?}"); + // If the language is not given, resolve it from the file extension let language: PolicyInputLanguage = if let Some(language) = language { debug!("Interpreting input as {language}"); @@ -701,7 +811,7 @@ pub async fn add( }; // Read the input file - let (json, target_reasoner): (String, TargetReasoner) = match language { + let (json, target_reasoner): (Value, TargetReasoner) = match language { PolicyInputLanguage::EFlint => { // We read it as eFLINT to JSON debug!("Compiling eFLINT input file '{}' to eFLINT JSON", input.display()); @@ -710,37 +820,56 @@ pub async fn add( return Err(Error::InputToJson { path: input, err }); } - // Serialize it to a string - match String::from_utf8(json) { - Ok(json) => (json, TargetReasoner::EFlintJson(EFlintJsonVersion::V0_1_0)), - Err(err) => panic!("{}", trace!(("eflint_to_json::compile_async() did not return valid UTF-8"), err)), + // Parse the request as a whole + debug!("Deserializing input..."); + let req: eflint_json::v0_1_0_srv::RequestPhrases = match serde_json::from_slice(&json) { + Ok(eflint_json::v0_1_0_srv::Request::Phrases(req)) => req, + Ok(_) => panic!("eflint_to_json::compile_async() did not return a Request::Phrases"), + Err(err) => panic!("{}", trace!(("eflint_to_json::compile_async() did not return a valid eFLINT policy"), err)), + }; + + // Re-serialize it to a value + match serde_json::to_value(&req.phrases) { + Ok(phrases) => (phrases, TargetReasoner::EFlintJson(EFlintJsonVersion::V0_1_0)), + Err(err) => panic!("{}", trace!(("serde_json::from_slice() did not return a serializable policy"), err)), } }, PolicyInputLanguage::EFlintJson => { // Read the file in one go debug!("Reading eFLINT JSON input file '{}'", input.display()); - match tfs::read_to_string(&input).await { - Ok(json) => (json, TargetReasoner::EFlintJson(EFlintJsonVersion::V0_1_0)), + let req: eflint_json::v0_1_0_srv::RequestPhrases = match tfs::read_to_string(&input).await { + Ok(json) => match serde_json::from_str(&json) { + Ok(eflint_json::v0_1_0_srv::Request::Phrases(req)) => req, + Ok(eflint_json::v0_1_0_srv::Request::Handshake(_)) => return Err(Error::IllegalInput { path: input, got: "handshake".into() }), + Ok(eflint_json::v0_1_0_srv::Request::Ping(_)) => return Err(Error::IllegalInput { path: input, got: "ping".into() }), + Ok(eflint_json::v0_1_0_srv::Request::Inspect(_)) => return Err(Error::IllegalInput { path: input, got: "inspect".into() }), + Err(err) => return Err(Error::InputDeserialize { path: input, raw: json, err }), + }, Err(err) => return Err(Error::InputRead { path: input, err }), + }; + + // Re-serialize it to a value + match serde_json::to_value(&req.phrases) { + Ok(phrases) => (phrases, TargetReasoner::EFlintJson(EFlintJsonVersion::V0_1_0)), + Err(err) => panic!("{}", trace!(("serde_json::from_str() did not return a serializable policy"), err)), } }, }; - // Ensure it is JSON - debug!("Deserializing input as JSON..."); - let json: Box = match serde_json::from_str(&json) { - Ok(json) => json, - Err(err) => return Err(Error::InputDeserialize { path: input, raw: json, err }), - }; + // Ask the checker for the reasoner context + let context: EFlintJsonReasonerWithInterfaceContext = get_context_from_checker(&address, &token).await?; // Finally, construct a request for the checker - let url: String = format!("http://{}/{}", address, ADD_VERSION_PATH.instantiated_path::(None)); + let url: String = format!("http://{}{}", address, ADD_VERSION_PATH.instantiated_path::(None)); debug!("Building POST-request to '{url}'..."); let client: Client = Client::new(); - let contents: AddPolicyPostModel = AddPolicyPostModel { - version_description: "".into(), - description: None, - content: vec![PolicyContentPostModel { reasoner: target_reasoner.id(), reasoner_version: target_reasoner.version(), content: json }], + let contents: AddVersionRequest = AddVersionRequest { + metadata: AttachedMetadata { + name, + description, + language: format!("{}-v{}-{}", target_reasoner.id(), target_reasoner.version(), context.hash), + }, + contents: json, }; let req: Request = match client.request(ADD_VERSION_PATH.method, &url).bearer_auth(token).json(&contents).build() { Ok(req) => req, @@ -759,7 +888,7 @@ pub async fn add( } // Log the response body - let body: Policy = match res.text().await { + let body: AddVersionResponse = match res.text().await { Ok(body) => { // Log the full response first debug!("Response:\n{}\n", BlockFormatter::new(&body)); @@ -774,10 +903,10 @@ pub async fn add( // Done! println!( - "Successfully added policy {} to checker {}{}.", + "Successfully added policy {} to checker {} as version {}.", style(if from_stdin { "".into() } else { input.display().to_string() }).bold().green(), style(address).bold().green(), - if let Some(version) = body.version.version { format!(" as version {}", style(version).bold().green()) } else { String::new() } + style(body.version).bold().green() ); Ok(()) } @@ -815,20 +944,30 @@ pub async fn list(node_config_path: PathBuf, address: AddressOpt, token: Option< // Enter a loop where we let the user decide for themselves loop { // Display them to the user, with name, to select the policy they want to see more info about - let version: u64 = match prompt_user_version(active_version, &versions, true) { + let version: u64 = match prompt_user_version("Select a version to inspect:", active_version, &versions, true) { Ok(Some(idx)) => idx, - Ok(None) => break, + Ok(None) => return Ok(()), Err(err) => return Err(Error::PromptVersions { err: Box::new(err) }), }; // Attempt to pull this version from the remote - let _version: Value = match get_version_body_from_checker(&address, &token, version).await { - Ok(version) => version, + let contents: Value = match get_version_body_from_checker(&address, &token, version).await { + Ok(contents) => contents, Err(err) => return Err(Error::VersionGetBody { addr: address, version, err: Box::new(err) }), }; - } - // TODO: Finish this. The idea is show a particular version to the user, then re-enter the loop until they quit - // (empty version, as above) - todo!(); + // Render it + let md: &Metadata = versions.get(&version).unwrap(); + println!("Policy {} ({})", style(format!("{:?}", md.attached.name)).bold().green(), style(md.version).bold()); + println!(" For {}", style(format!("{:?}", md.attached.language)).bold()); + println!(" By {} ({})", style(format!("{:?}", md.creator.name)).bold(), style(format!("{:?}", md.creator.id)).bold()); + println!(" At {}", style(DateTime::::from(md.created).format("%Y-%m-%d %H:%M:%S")).bold()); + println!(" {:?}", md.attached.description); + println!("{}", "-".repeat(80)); + if let Err(err) = serde_json::to_writer_pretty(std::io::stdout(), &contents) { + return Err(Error::VersionSerialize { version, err }); + } + println!("{}", "-".repeat(80)); + println!(); + } }