Skip to main content

malwaredb_api/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2
3#![doc = include_str!("../README.md")]
4#![deny(missing_docs)]
5#![deny(clippy::all)]
6#![deny(clippy::pedantic)]
7#![forbid(unsafe_code)]
8
9/// Wrapper for fixed-size cryptographic hash digests from hex strings
10pub mod digest;
11
12use crate::digest::HashType;
13
14use std::collections::HashMap;
15use std::error::Error;
16use std::fmt::{Display, Formatter};
17
18use chrono::serde::ts_seconds_option;
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use zeroize::{Zeroize, ZeroizeOnDrop};
22
23/// MDB version
24pub const MDB_VERSION: &str = env!("CARGO_PKG_VERSION");
25
26/// HTTP header used to present the API key to the server
27pub const MDB_API_HEADER: &str = "mdb-api-key";
28
29/// Authentication endpoint, POST
30pub const USER_LOGIN_URL: &str = "/v1/users/getkey";
31
32/// Endpoint name for use with Multicast DNS
33pub const MDNS_NAME: &str = "_malwaredb._tcp.local.";
34
35/// User authentication with username and password to get the API key
36#[derive(Deserialize, Serialize, Zeroize, ZeroizeOnDrop)]
37pub struct GetAPIKeyRequest {
38    /// Username
39    pub user: String,
40
41    /// User's password
42    pub password: String,
43}
44
45/// Logout API endpoint to clear their API key, GET, authenticated.
46pub const USER_LOGOUT_URL: &str = "/v1/users/clearkey";
47
48/// Respond to authentication with the key if the credentials were correct,
49/// and possibly show a message related to errors or warnings.
50#[derive(Deserialize, Serialize, Zeroize, ZeroizeOnDrop)]
51pub struct GetAPIKeyResponse {
52    /// User's API key if successful
53    pub key: String,
54
55    /// Error response
56    pub message: Option<String>,
57}
58
59/// For request types, wrap in this struct to handle some error conditions
60///
61/// All API endpoints use this response format EXCEPT:
62///   * [`USER_LOGOUT_URL`]
63///   * [`UPLOAD_SAMPLE_JSON_URL`]
64///   * [`UPLOAD_SAMPLE_CBOR_URL`]
65///   * [`DOWNLOAD_SAMPLE_URL`]
66///   * [`DOWNLOAD_SAMPLE_CART_URL`]
67#[derive(Clone, Debug, Deserialize, Serialize)]
68pub enum ServerResponse<D> {
69    /// Request successful
70    #[serde(alias = "success")]
71    Success(D),
72
73    /// Request unsuccessful
74    #[serde(alias = "error")]
75    Error(ServerError),
76}
77
78impl<D> ServerResponse<D> {
79    /// Unwrap a server response
80    ///
81    /// # Panics
82    ///
83    /// Will panic if the response is an error
84    #[inline]
85    pub fn unwrap(self) -> D {
86        match self {
87            ServerResponse::Success(d) => d,
88            ServerResponse::Error(e) => panic!("forced ServerResponse::unwrap() on error: {e}"),
89        }
90    }
91
92    /// Convert the server response into a traditional [`Result`] type
93    ///
94    /// # Errors
95    ///
96    /// The return error is the server error, if present
97    #[inline]
98    pub fn into_result(self) -> Result<D, ServerError> {
99        match self {
100            ServerResponse::Success(d) => Ok(d),
101            ServerResponse::Error(e) => Err(e),
102        }
103    }
104
105    /// If the server response was successful
106    #[inline]
107    pub const fn is_successful(&self) -> bool {
108        matches!(*self, ServerResponse::Success(_))
109    }
110
111    /// If the server response was not successful
112    #[inline]
113    pub const fn is_err(&self) -> bool {
114        matches!(*self, ServerResponse::Error(_))
115    }
116}
117
118/// Server error responses
119#[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
120pub enum ServerError {
121    /// The server was asked for samples but doesn't store them
122    NoSamples,
123
124    /// The requested item was not found or the search yielded no results
125    NotFound,
126
127    /// Internal server error, details not disclosed to the client
128    ServerError,
129
130    /// Unauthorized: API key was not provided, or the user doesn't have access to the requested item(s)
131    Unauthorized,
132
133    /// Request was for a feature which is not supported by the server, such as Yara for example
134    Unsupported,
135}
136
137impl Display for ServerError {
138    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
139        match self {
140            ServerError::NoSamples => write!(f, "NoSamples"),
141            ServerError::NotFound => write!(f, "NotFound"),
142            ServerError::ServerError => write!(f, "ServerError"),
143            ServerError::Unauthorized => write!(f, "Unauthorized"),
144            ServerError::Unsupported => write!(f, "Unsupported"),
145        }
146    }
147}
148
149impl Error for ServerError {}
150
151/// User's account information API endpoint, GET, authenticated
152pub const USER_INFO_URL: &str = "/v1/users/info";
153
154/// User account information
155#[derive(Clone, Debug, Deserialize, Serialize)]
156pub struct GetUserInfoResponse {
157    /// User's numeric ID
158    pub id: u32,
159
160    /// User's name
161    pub username: String,
162
163    /// User's group memberships, if any
164    pub groups: Vec<String>,
165
166    /// User's available sample sources, if any
167    pub sources: Vec<String>,
168
169    /// If the user is an admin
170    pub is_admin: bool,
171
172    /// When the account was created
173    pub created: DateTime<Utc>,
174
175    /// User has read-only access, perhaps a guest or demo account
176    pub is_readonly: bool,
177}
178
179/// Server information, request is empty, GET, Unauthenticated.
180pub const SERVER_INFO_URL: &str = "/v1/server/info";
181
182/// Information about the server
183#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
184pub struct ServerInfo {
185    /// Operating System used
186    pub os_name: String,
187
188    /// Memory footprint
189    pub memory_used: String,
190
191    /// MDB version
192    pub mdb_version: semver::Version,
193
194    /// Type and version of the database
195    pub db_version: String,
196
197    /// Size of the database on disk
198    pub db_size: String,
199
200    /// Total number of samples in Malware DB
201    pub num_samples: u64,
202
203    /// Total users of Malware DB
204    pub num_users: u32,
205
206    /// Uptime of Malware DB in a human-readable format
207    pub uptime: String,
208
209    /// The name of the Malware DB instance
210    pub instance_name: String,
211}
212
213/// File types supported by Malware DB, request is empty, GET, Unauthenticated.
214pub const SUPPORTED_FILE_TYPES_URL: &str = "/v1/server/types";
215
216/// One record of supported file types
217#[derive(Clone, Debug, Deserialize, Serialize)]
218pub struct SupportedFileType {
219    /// Common name of the file type
220    pub name: String,
221
222    /// Magic number bytes in hex of the file type
223    pub magic: Vec<String>,
224
225    /// Whether the file type is executable
226    pub is_executable: bool,
227
228    /// Description of the file type
229    pub description: Option<String>,
230}
231
232/// Server's supported types, the response
233#[derive(Clone, Debug, Deserialize, Serialize)]
234pub struct SupportedFileTypes {
235    /// Supported file types
236    pub types: Vec<SupportedFileType>,
237
238    /// Optional server messages
239    pub message: Option<String>,
240}
241
242/// Endpoint for the sources, per-user, GET, authenticated
243pub const LIST_SOURCES_URL: &str = "/v1/sources/list";
244
245/// Information about a sample source
246#[derive(Clone, Debug, Deserialize, Serialize)]
247pub struct SourceInfo {
248    /// ID of the source
249    pub id: u32,
250
251    /// Name of the source
252    pub name: String,
253
254    /// Description of the source
255    pub description: Option<String>,
256
257    /// URL of the source, or where the files were found
258    pub url: Option<String>,
259
260    /// Creation date or first acquisition date of or from the source
261    pub first_acquisition: DateTime<Utc>,
262
263    /// Whether the source holds malware
264    pub malicious: Option<bool>,
265}
266
267/// Sources response for request for sources
268#[derive(Clone, Debug, Deserialize, Serialize)]
269pub struct Sources {
270    /// List of sources
271    pub sources: Vec<SourceInfo>,
272
273    /// Error message, if any
274    pub message: Option<String>,
275}
276
277/// API endpoint for uploading a sample with JSON, POST, Authenticated
278pub const UPLOAD_SAMPLE_JSON_URL: &str = "/v1/samples/json/upload";
279
280/// API endpoint for uploading a sample with CBOR, POST, Authenticated
281pub const UPLOAD_SAMPLE_CBOR_URL: &str = "/v1/samples/cbor/upload";
282
283/// New file sample being sent to Malware DB via [`UPLOAD_SAMPLE_JSON_URL`]
284#[derive(Clone, Debug, Deserialize, Serialize)]
285pub struct NewSampleB64 {
286    /// The original file name, which might not be known. If it's not known,
287    /// use a hash or something like "unknown.bin".
288    pub file_name: String,
289
290    /// ID of the source for this sample
291    pub source_id: u32,
292
293    /// Base64 encoding of the binary file
294    pub file_contents_b64: String,
295
296    /// SHA-256 of the sample being sent, for server-side validation
297    pub sha256: String,
298}
299
300/// New file sample being sent to Malware DB via [`UPLOAD_SAMPLE_CBOR_URL`]
301#[derive(Clone, Debug, Deserialize, Serialize)]
302pub struct NewSampleBytes {
303    /// The original file name, which might not be known. If it's not known,
304    /// use a hash or something like "unknown.bin".
305    pub file_name: String,
306
307    /// ID of the source for this sample
308    pub source_id: u32,
309
310    /// Raw binary contents
311    pub file_contents: Vec<u8>,
312
313    /// SHA-256 of the sample being sent, for server-side validation
314    pub sha256: String,
315}
316
317/// API endpoint for downloading a sample, GET. The hash value goes at the end of the URL.
318/// Example: `/v1/samples/download/aabbccddeeff0011223344556677889900`
319/// Response is raw bytes of the file, or HTTP 404 if not found
320pub const DOWNLOAD_SAMPLE_URL: &str = "/v1/samples/download";
321
322/// API endpoint for downloading a sample as a `CaRT` container file, GET
323/// Example: `/v1/samples/download/cart/aabbccddeeff0011223344556677889900`
324/// Response is the file encoded in a `CaRT` container file, or HTTP 404 if not found
325pub const DOWNLOAD_SAMPLE_CART_URL: &str = "/v1/samples/download/cart";
326
327/// API endpoint to get a report for a given sample
328/// Example: `/v1/samples/report/aabbccddeeff0011223344556677889900`
329pub const SAMPLE_REPORT_URL: &str = "/v1/samples/report";
330
331/// Virus Total hits summary for a specific sample
332#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
333pub struct VirusTotalSummary {
334    /// Anti-Virus products which identified the sample as malicious
335    pub hits: u32,
336
337    /// Anti-Virus products available when last analyzed
338    pub total: u32,
339
340    /// Hit details in JSON format, if available
341    #[serde(default)]
342    pub detail: Option<serde_json::Value>,
343
344    /// Most recent analysis date, if available
345    #[serde(default, with = "ts_seconds_option")]
346    pub last_analysis_date: Option<DateTime<Utc>>,
347}
348
349// TODO: Add sections for parsed fields for documents, executables
350/// Information for an individual sample
351#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
352pub struct Report {
353    /// MD-5 hash
354    pub md5: String,
355
356    /// SHA-1 hash
357    pub sha1: String,
358
359    /// SHA-256 hash
360    pub sha256: String,
361
362    /// SHA-384 hash
363    pub sha384: String,
364
365    /// SHA-512 hash
366    pub sha512: String,
367
368    /// LZJD similarity hash, if available
369    /// <https://github.com/EdwardRaff/LZJD>
370    pub lzjd: Option<String>,
371
372    /// TLSH similarity hash, if available
373    /// <https://github.com/trendmicro/tlsh>
374    pub tlsh: Option<String>,
375
376    /// `SSDeep` similarity hash, if available
377    /// <https://ssdeep-project.github.io/ssdeep/index.html>
378    pub ssdeep: Option<String>,
379
380    /// Human hash
381    /// <https://github.com/zacharyvoase/humanhash>
382    pub humanhash: Option<String>,
383
384    /// The output from libmagic, aka the `file` command
385    /// <https://man7.org/linux/man-pages/man3/libmagic.3.html>
386    pub filecommand: Option<String>,
387
388    /// Sample size in bytes
389    pub bytes: u64,
390
391    /// Sample size in human-readable size (2048 becomes 2 kb, for example)
392    pub size: String,
393
394    /// Entropy of the file, values over 6.5 may indicate compression or encryption
395    pub entropy: f32,
396
397    /// Virus Total summary data, if enabled on the server
398    /// <https://www.virustotal.com>
399    #[serde(default)]
400    pub vt: Option<VirusTotalSummary>,
401}
402
403impl Display for Report {
404    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
405        writeln!(f, "Size: {} bytes, or {}", self.bytes, self.size)?;
406        writeln!(f, "Entropy: {}", self.entropy)?;
407        if let Some(filecmd) = &self.filecommand {
408            writeln!(f, "File command: {filecmd}")?;
409        }
410        if let Some(vt) = &self.vt {
411            writeln!(f, "VT Hits: {}/{}", vt.hits, vt.total)?;
412        }
413        writeln!(f, "MD5: {}", self.md5)?;
414        writeln!(f, "SHA-1: {}", self.sha1)?;
415        writeln!(f, "SHA256: {}", self.sha256)
416    }
417}
418
419/// API endpoint for finding samples which are similar to a specific file, POST, Authenticated.
420pub const SIMILAR_SAMPLES_URL: &str = "/v1/samples/similar";
421
422/// The hash by which a sample is identified
423#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
424#[non_exhaustive]
425pub enum SimilarityHashType {
426    /// `SSDeep` similarity of the whole file
427    SSDeep,
428
429    /// `LZJD` similarity of the whole file
430    LZJD,
431
432    /// TLSH similarity of the hole file
433    TLSH,
434
435    /// `PEHash`, for PE32 files
436    PEHash,
437
438    /// Import Hash for executable files
439    ImportHash,
440
441    /// `SSDeep` fuzzy hash of the import data, for executable files
442    FuzzyImportHash,
443}
444
445impl SimilarityHashType {
446    /// For a similarity hash type, return:
447    /// * The database table and field which stores the hash
448    /// * If applicable, the similarity hash function which calculates the similarity
449    #[must_use]
450    pub fn get_table_field_simfunc(&self) -> (&'static str, Option<&'static str>) {
451        match self {
452            SimilarityHashType::SSDeep => ("file.ssdeep", Some("fuzzy_hash_compare")),
453            SimilarityHashType::LZJD => ("file.lzjd", Some("lzjd_compare")),
454            SimilarityHashType::TLSH => ("file.tlsh", Some("tlsh_compare")),
455            SimilarityHashType::PEHash => ("executable.pehash", None),
456            SimilarityHashType::ImportHash => ("executable.importhash", None),
457            SimilarityHashType::FuzzyImportHash => {
458                ("executable.importhashfuzzy", Some("fuzzy_hash_compare"))
459            }
460        }
461    }
462}
463
464impl Display for SimilarityHashType {
465    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
466        match self {
467            SimilarityHashType::SSDeep => write!(f, "SSDeep"),
468            SimilarityHashType::LZJD => write!(f, "LZJD"),
469            SimilarityHashType::TLSH => write!(f, "TLSH"),
470            SimilarityHashType::PEHash => write!(f, "PeHash"),
471            SimilarityHashType::ImportHash => write!(f, "Import Hash (IMPHASH)"),
472            SimilarityHashType::FuzzyImportHash => write!(f, "Fuzzy Import hash"),
473        }
474    }
475}
476
477/// Requesting hashes of possible similar samples by similarity hash
478#[derive(Clone, Debug, Deserialize, Serialize)]
479pub struct SimilarSamplesRequest {
480    /// The hashes of the requested sample
481    pub hashes: Vec<(SimilarityHashType, String)>,
482}
483
484/// Relation between a similar sample and the hashes by which the sample is similar
485#[derive(Clone, Debug, Deserialize, Serialize)]
486pub struct SimilarSample {
487    /// The SHA-256 hash of the found sample
488    pub sha256: String,
489
490    /// Matches from the requested sample to this sample by algorithm and score
491    pub algorithms: Vec<(SimilarityHashType, f32)>,
492}
493
494/// Response indicating samples which are similar
495#[derive(Clone, Debug, Deserialize, Serialize)]
496pub struct SimilarSamplesResponse {
497    /// The responses
498    pub results: Vec<SimilarSample>,
499
500    /// Possible messages from the server, if any
501    pub message: Option<String>,
502}
503
504/// APU endpoint for searching for files with some criteria
505pub const SEARCH_URL: &str = "/v1/search";
506
507/// Searching the next batch from a prior search, or the initial search
508#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
509pub enum SearchType {
510    /// The next batch of results from a prior search
511    Continuation(uuid::Uuid),
512
513    /// The initial search
514    Search(SearchRequestParameters),
515}
516
517/// Search for a file by some criteria, all of which are an AND operation:
518/// * Partial hash
519/// * Name of the sample
520/// * Type of the sample
521/// * Libmagic description of the sample
522/// * Labels applied to the sample
523#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
524pub struct SearchRequestParameters {
525    /// Search for a file by partial hash
526    pub partial_hash: Option<(PartialHashSearchType, String)>,
527
528    /// Search for a file by whole or partial file name
529    pub file_name: Option<String>,
530
531    /// Maximum number of results to return, 100 results or fewer.
532    pub limit: u32,
533
534    /// Optionally search for samples of a specific file type.
535    pub file_type: Option<String>,
536
537    /// Optionally search for samples based on `libmagic`, also known as the file command.
538    pub magic: Option<String>,
539
540    /// Optionally search for samples with specific label(s).
541    pub labels: Option<Vec<String>>,
542
543    /// Get the returned result by a hash type.
544    /// [`PartialHashSearchType::Any`] results in SHA-256
545    pub response: PartialHashSearchType,
546}
547
548impl SearchRequestParameters {
549    /// Ensure the search request is valid:
550    /// * The partial hash is valid hexidecimal, if present
551    /// * At least one search parameter is provided
552    /// * The limit must be greater than zero
553    #[must_use]
554    #[inline]
555    pub fn is_valid(&self) -> bool {
556        if self.limit == 0 {
557            return false;
558        }
559
560        if let Some((_hash_type, partial_hash)) = &self.partial_hash {
561            let hex = hex::decode(partial_hash);
562            return hex.is_ok();
563        }
564
565        self.partial_hash.is_some()
566            || self.file_name.is_some()
567            || self.file_type.is_some()
568            || self.magic.is_some()
569            || self.labels.is_some()
570    }
571}
572
573/// This trait implementation is provided as a convenience. This does not create a valid object.
574impl Default for SearchRequestParameters {
575    fn default() -> Self {
576        Self {
577            partial_hash: None,
578            file_name: None,
579            limit: 100,
580            labels: None,
581            file_type: None,
582            magic: None,
583            response: PartialHashSearchType::default(),
584        }
585    }
586}
587
588/// Search for a file by some criteria
589/// Specifying both a hash and file name is an AND operation!
590#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
591pub struct SearchRequest {
592    /// Search or continuation of a search
593    pub search: SearchType,
594}
595
596impl SearchRequest {
597    /// Ensure the search request is valid:
598    /// * The partial hash is valid hexidecimal
599    /// * At least a file path or partial hash is provided
600    #[must_use]
601    #[inline]
602    pub fn is_valid(&self) -> bool {
603        if let SearchType::Search(search) = &self.search {
604            search.is_valid()
605        } else {
606            true
607        }
608    }
609}
610
611/// Search result
612#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
613pub struct SearchResponse {
614    /// Hashes of samples which match the search criteria
615    pub hashes: Vec<String>,
616
617    /// Identifier for getting the next batch of results
618    pub pagination: Option<uuid::Uuid>,
619
620    /// The total number of samples which match the search criteria
621    pub total_results: u64,
622
623    /// Optional server messages
624    pub message: Option<String>,
625}
626
627/// Specify the type of hash when searching for a partial match
628#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
629pub enum PartialHashSearchType {
630    /// Search by any known hash type
631    Any,
632
633    /// Search only for MD5 hashes
634    MD5,
635
636    /// Search only for SHA-1 hashes
637    SHA1,
638
639    /// Search only for SHA-256 hashes
640    #[default]
641    SHA256,
642
643    /// Search only for SHA-384 hashes
644    SHA384,
645
646    /// Search only for SHA-512 hashes
647    SHA512,
648}
649
650impl Display for PartialHashSearchType {
651    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
652        match self {
653            PartialHashSearchType::Any => write!(f, "any"),
654            PartialHashSearchType::MD5 => write!(f, "md5"),
655            PartialHashSearchType::SHA1 => write!(f, "sha1"),
656            PartialHashSearchType::SHA256 => write!(f, "sha256"),
657            PartialHashSearchType::SHA384 => write!(f, "sha384"),
658            PartialHashSearchType::SHA512 => write!(f, "sha512"),
659        }
660    }
661}
662
663impl TryInto<PartialHashSearchType> for &str {
664    type Error = String;
665
666    fn try_into(self) -> Result<PartialHashSearchType, Self::Error> {
667        match self {
668            "any" => Ok(PartialHashSearchType::Any),
669            "md5" => Ok(PartialHashSearchType::MD5),
670            "sha1" => Ok(PartialHashSearchType::SHA1),
671            "sha256" => Ok(PartialHashSearchType::SHA256),
672            "sha384" => Ok(PartialHashSearchType::SHA384),
673            "sha512" => Ok(PartialHashSearchType::SHA512),
674            x => Err(format!("Invalid hash type {x}")),
675        }
676    }
677}
678
679impl TryInto<PartialHashSearchType> for Option<&str> {
680    type Error = String;
681
682    fn try_into(self) -> Result<PartialHashSearchType, Self::Error> {
683        if let Some(hash) = self {
684            hash.try_into()
685        } else {
686            Ok(PartialHashSearchType::SHA256)
687        }
688    }
689}
690
691/// API endpoint for searching for samples using YARA rules.
692/// * POST: submit the search request
693/// * GET: get the search results or check on status
694pub const YARA_SEARCH_URL: &str = "/v1/yara";
695
696/// Search for samples using YARA
697#[derive(Clone, Debug, Deserialize, Serialize)]
698pub struct YaraSearchRequest {
699    /// The Yara rules
700    pub rules: Vec<String>,
701
702    /// Get the returned result by a hash type.
703    /// [`PartialHashSearchType::Any`] results in SHA-256
704    pub response: PartialHashSearchType,
705}
706
707/// Provide the client the UUID of the search request.
708#[derive(Clone, Debug, Deserialize, Serialize)]
709pub struct YaraSearchRequestResponse {
710    /// UUID response
711    pub uuid: uuid::Uuid,
712}
713
714/// Search result from Yara
715#[derive(Clone, Debug, Deserialize, Serialize)]
716pub struct YaraSearchResponse {
717    /// Vector of pairs of hash and Yara search name
718    pub results: HashMap<String, Vec<HashType>>,
719}
720
721/// API endpoint for finding samples which are similar to a specific file, POST
722pub const LIST_LABELS_URL: &str = "/v1/labels";
723
724/// A label, used for describing sources and/or samples
725#[derive(Clone, Debug, Deserialize, Serialize)]
726pub struct Label {
727    /// Label ID
728    pub id: u64,
729
730    /// Label value
731    pub name: String,
732
733    /// Label parent
734    pub parent: Option<String>,
735}
736
737/// One or more available labels
738#[derive(Clone, Debug, Default, Deserialize, Serialize)]
739pub struct Labels(pub Vec<Label>);
740
741// Convenience functions
742impl Labels {
743    /// Number of labels
744    #[must_use]
745    pub fn len(&self) -> usize {
746        self.0.len()
747    }
748
749    /// If the labels are empty
750    #[must_use]
751    pub fn is_empty(&self) -> bool {
752        self.0.is_empty()
753    }
754}
755
756impl Display for Labels {
757    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
758        if self.is_empty() {
759            return writeln!(f, "No labels.");
760        }
761        for label in &self.0 {
762            let parent = if let Some(parent) = &label.parent {
763                format!(", parent: {parent}")
764            } else {
765                String::new()
766            };
767            writeln!(f, "{}: {}{parent}", label.id, label.name)?;
768        }
769        Ok(())
770    }
771}