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    /// If the server was compiled with Virus Total support
213    pub vt_support: bool,
214
215    /// If the server was compiled with Yara support
216    pub yara_enabled: bool,
217}
218
219/// File types supported by Malware DB, request is empty, GET, Unauthenticated.
220pub const SUPPORTED_FILE_TYPES_URL: &str = "/v1/server/types";
221
222/// One record of supported file types
223#[derive(Clone, Debug, Deserialize, Serialize)]
224pub struct SupportedFileType {
225    /// Common name of the file type
226    pub name: String,
227
228    /// Magic number bytes in hex of the file type
229    pub magic: Vec<String>,
230
231    /// Whether the file type is executable
232    pub is_executable: bool,
233
234    /// Description of the file type
235    pub description: Option<String>,
236}
237
238/// Server's supported types, the response
239#[derive(Clone, Debug, Deserialize, Serialize)]
240pub struct SupportedFileTypes {
241    /// Supported file types
242    pub types: Vec<SupportedFileType>,
243
244    /// Optional server messages
245    pub message: Option<String>,
246}
247
248/// Endpoint for the sources, per-user, GET, authenticated
249pub const LIST_SOURCES_URL: &str = "/v1/sources/list";
250
251/// Information about a sample source
252#[derive(Clone, Debug, Deserialize, Serialize)]
253pub struct SourceInfo {
254    /// ID of the source
255    pub id: u32,
256
257    /// Name of the source
258    pub name: String,
259
260    /// Description of the source
261    pub description: Option<String>,
262
263    /// URL of the source, or where the files were found
264    pub url: Option<String>,
265
266    /// Creation date or first acquisition date of or from the source
267    pub first_acquisition: DateTime<Utc>,
268
269    /// Whether the source holds malware
270    pub malicious: Option<bool>,
271}
272
273/// Sources response for request for sources
274#[derive(Clone, Debug, Deserialize, Serialize)]
275pub struct Sources {
276    /// List of sources
277    pub sources: Vec<SourceInfo>,
278
279    /// Error message, if any
280    pub message: Option<String>,
281}
282
283/// API endpoint for uploading a sample with JSON, POST, Authenticated
284pub const UPLOAD_SAMPLE_JSON_URL: &str = "/v1/samples/json/upload";
285
286/// API endpoint for uploading a sample with CBOR, POST, Authenticated
287pub const UPLOAD_SAMPLE_CBOR_URL: &str = "/v1/samples/cbor/upload";
288
289/// New file sample being sent to Malware DB via [`UPLOAD_SAMPLE_JSON_URL`]
290#[derive(Clone, Debug, Deserialize, Serialize)]
291pub struct NewSampleB64 {
292    /// The original file name, which might not be known. If it's not known,
293    /// use a hash or something like "unknown.bin".
294    pub file_name: String,
295
296    /// ID of the source for this sample
297    pub source_id: u32,
298
299    /// Base64 encoding of the binary file
300    pub file_contents_b64: String,
301
302    /// SHA-256 of the sample being sent, for server-side validation
303    pub sha256: String,
304}
305
306/// New file sample being sent to Malware DB via [`UPLOAD_SAMPLE_CBOR_URL`]
307#[derive(Clone, Debug, Deserialize, Serialize)]
308pub struct NewSampleBytes {
309    /// The original file name, which might not be known. If it's not known,
310    /// use a hash or something like "unknown.bin".
311    pub file_name: String,
312
313    /// ID of the source for this sample
314    pub source_id: u32,
315
316    /// Raw binary contents
317    pub file_contents: Vec<u8>,
318
319    /// SHA-256 of the sample being sent, for server-side validation
320    pub sha256: String,
321}
322
323/// API endpoint for downloading a sample, GET. The hash value goes at the end of the URL.
324/// Example: `/v1/samples/download/aabbccddeeff0011223344556677889900`
325/// Response is raw bytes of the file, or HTTP 404 if not found
326pub const DOWNLOAD_SAMPLE_URL: &str = "/v1/samples/download";
327
328/// API endpoint for downloading a sample as a `CaRT` container file, GET
329/// Example: `/v1/samples/download/cart/aabbccddeeff0011223344556677889900`
330/// Response is the file encoded in a `CaRT` container file, or HTTP 404 if not found
331pub const DOWNLOAD_SAMPLE_CART_URL: &str = "/v1/samples/download/cart";
332
333/// API endpoint to get a report for a given sample
334/// Example: `/v1/samples/report/aabbccddeeff0011223344556677889900`
335pub const SAMPLE_REPORT_URL: &str = "/v1/samples/report";
336
337/// Virus Total hits summary for a specific sample
338#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
339pub struct VirusTotalSummary {
340    /// Anti-Virus products which identified the sample as malicious
341    pub hits: u32,
342
343    /// Anti-Virus products available when last analyzed
344    pub total: u32,
345
346    /// Hit details in JSON format, if available
347    #[serde(default)]
348    pub detail: Option<serde_json::Value>,
349
350    /// Most recent analysis date, if available
351    #[serde(default, with = "ts_seconds_option")]
352    pub last_analysis_date: Option<DateTime<Utc>>,
353}
354
355// TODO: Add sections for parsed fields for documents, executables
356/// Information for an individual sample
357#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
358pub struct Report {
359    /// MD-5 hash
360    pub md5: String,
361
362    /// SHA-1 hash
363    pub sha1: String,
364
365    /// SHA-256 hash
366    pub sha256: String,
367
368    /// SHA-384 hash
369    pub sha384: String,
370
371    /// SHA-512 hash
372    pub sha512: String,
373
374    /// LZJD similarity hash, if available
375    /// <https://github.com/EdwardRaff/LZJD>
376    pub lzjd: Option<String>,
377
378    /// TLSH similarity hash, if available
379    /// <https://github.com/trendmicro/tlsh>
380    pub tlsh: Option<String>,
381
382    /// `SSDeep` similarity hash, if available
383    /// <https://ssdeep-project.github.io/ssdeep/index.html>
384    pub ssdeep: Option<String>,
385
386    /// Human hash
387    /// <https://github.com/zacharyvoase/humanhash>
388    pub humanhash: Option<String>,
389
390    /// The output from libmagic, aka the `file` command
391    /// <https://man7.org/linux/man-pages/man3/libmagic.3.html>
392    pub filecommand: Option<String>,
393
394    /// Sample size in bytes
395    pub bytes: u64,
396
397    /// Sample size in human-readable size (2048 becomes 2 kb, for example)
398    pub size: String,
399
400    /// Entropy of the file, values over 6.5 may indicate compression or encryption
401    pub entropy: f32,
402
403    /// Virus Total summary data, if enabled on the server
404    /// <https://www.virustotal.com>
405    #[serde(default)]
406    pub vt: Option<VirusTotalSummary>,
407}
408
409impl Display for Report {
410    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
411        writeln!(f, "Size: {} bytes, or {}", self.bytes, self.size)?;
412        writeln!(f, "Entropy: {}", self.entropy)?;
413        if let Some(filecmd) = &self.filecommand {
414            writeln!(f, "File command: {filecmd}")?;
415        }
416        if let Some(vt) = &self.vt {
417            writeln!(f, "VT Hits: {}/{}", vt.hits, vt.total)?;
418        }
419        writeln!(f, "MD5: {}", self.md5)?;
420        writeln!(f, "SHA-1: {}", self.sha1)?;
421        writeln!(f, "SHA256: {}", self.sha256)
422    }
423}
424
425/// API endpoint for finding samples which are similar to a specific file, POST, Authenticated.
426pub const SIMILAR_SAMPLES_URL: &str = "/v1/samples/similar";
427
428/// The hash by which a sample is identified
429#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
430#[non_exhaustive]
431pub enum SimilarityHashType {
432    /// `SSDeep` similarity of the whole file
433    SSDeep,
434
435    /// `LZJD` similarity of the whole file
436    LZJD,
437
438    /// TLSH similarity of the hole file
439    TLSH,
440
441    /// `PEHash`, for PE32 files
442    PEHash,
443
444    /// Import Hash for executable files
445    ImportHash,
446
447    /// `SSDeep` fuzzy hash of the import data, for executable files
448    FuzzyImportHash,
449}
450
451impl SimilarityHashType {
452    /// For a similarity hash type, return:
453    /// * The database table and field which stores the hash
454    /// * If applicable, the similarity hash function which calculates the similarity
455    #[must_use]
456    pub fn get_table_field_simfunc(&self) -> (&'static str, Option<&'static str>) {
457        match self {
458            SimilarityHashType::SSDeep => ("file.ssdeep", Some("fuzzy_hash_compare")),
459            SimilarityHashType::LZJD => ("file.lzjd", Some("lzjd_compare")),
460            SimilarityHashType::TLSH => ("file.tlsh", Some("tlsh_compare")),
461            SimilarityHashType::PEHash => ("executable.pehash", None),
462            SimilarityHashType::ImportHash => ("executable.importhash", None),
463            SimilarityHashType::FuzzyImportHash => {
464                ("executable.importhashfuzzy", Some("fuzzy_hash_compare"))
465            }
466        }
467    }
468}
469
470impl Display for SimilarityHashType {
471    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
472        match self {
473            SimilarityHashType::SSDeep => write!(f, "SSDeep"),
474            SimilarityHashType::LZJD => write!(f, "LZJD"),
475            SimilarityHashType::TLSH => write!(f, "TLSH"),
476            SimilarityHashType::PEHash => write!(f, "PeHash"),
477            SimilarityHashType::ImportHash => write!(f, "Import Hash (IMPHASH)"),
478            SimilarityHashType::FuzzyImportHash => write!(f, "Fuzzy Import hash"),
479        }
480    }
481}
482
483/// Requesting hashes of possible similar samples by similarity hash
484#[derive(Clone, Debug, Deserialize, Serialize)]
485pub struct SimilarSamplesRequest {
486    /// The hashes of the requested sample
487    pub hashes: Vec<(SimilarityHashType, String)>,
488}
489
490/// Relation between a similar sample and the hashes by which the sample is similar
491#[derive(Clone, Debug, Deserialize, Serialize)]
492pub struct SimilarSample {
493    /// The SHA-256 hash of the found sample
494    pub sha256: String,
495
496    /// Matches from the requested sample to this sample by algorithm and score
497    pub algorithms: Vec<(SimilarityHashType, f32)>,
498}
499
500/// Response indicating samples which are similar
501#[derive(Clone, Debug, Deserialize, Serialize)]
502pub struct SimilarSamplesResponse {
503    /// The responses
504    pub results: Vec<SimilarSample>,
505
506    /// Possible messages from the server, if any
507    pub message: Option<String>,
508}
509
510/// APU endpoint for searching for files with some criteria
511pub const SEARCH_URL: &str = "/v1/search";
512
513/// Searching the next batch from a prior search, or the initial search
514#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
515pub enum SearchType {
516    /// The next batch of results from a prior search
517    Continuation(uuid::Uuid),
518
519    /// The initial search
520    Search(SearchRequestParameters),
521}
522
523/// Search for a file by some criteria, all of which are an AND operation:
524/// * Partial hash
525/// * Name of the sample
526/// * Type of the sample
527/// * Libmagic description of the sample
528/// * Labels applied to the sample
529#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
530pub struct SearchRequestParameters {
531    /// Search for a file by partial hash
532    pub partial_hash: Option<(PartialHashSearchType, String)>,
533
534    /// Search for a file by whole or partial file name
535    pub file_name: Option<String>,
536
537    /// Maximum number of results to return, 100 results or fewer.
538    pub limit: u32,
539
540    /// Optionally search for samples of a specific file type.
541    pub file_type: Option<String>,
542
543    /// Optionally search for samples based on `libmagic`, also known as the file command.
544    pub magic: Option<String>,
545
546    /// Optionally search for samples with specific label(s).
547    pub labels: Option<Vec<String>>,
548
549    /// Get the returned result by a hash type.
550    /// [`PartialHashSearchType::Any`] results in SHA-256
551    pub response: PartialHashSearchType,
552}
553
554impl SearchRequestParameters {
555    /// Ensure the search request is valid:
556    /// * The partial hash is valid hexidecimal, if present
557    /// * At least one search parameter is provided
558    /// * The limit must be greater than zero
559    #[must_use]
560    #[inline]
561    pub fn is_valid(&self) -> bool {
562        if self.limit == 0 {
563            return false;
564        }
565
566        if let Some((_hash_type, partial_hash)) = &self.partial_hash {
567            let hex = hex::decode(partial_hash);
568            return hex.is_ok();
569        }
570
571        self.partial_hash.is_some()
572            || self.file_name.is_some()
573            || self.file_type.is_some()
574            || self.magic.is_some()
575            || self.labels.is_some()
576    }
577}
578
579/// This trait implementation is provided as a convenience. This does not create a valid object.
580impl Default for SearchRequestParameters {
581    fn default() -> Self {
582        Self {
583            partial_hash: None,
584            file_name: None,
585            limit: 100,
586            labels: None,
587            file_type: None,
588            magic: None,
589            response: PartialHashSearchType::default(),
590        }
591    }
592}
593
594/// Search for a file by some criteria
595/// Specifying both a hash and file name is an AND operation!
596#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
597pub struct SearchRequest {
598    /// Search or continuation of a search
599    pub search: SearchType,
600}
601
602impl SearchRequest {
603    /// Ensure the search request is valid:
604    /// * The partial hash is valid hexidecimal
605    /// * At least a file path or partial hash is provided
606    #[must_use]
607    #[inline]
608    pub fn is_valid(&self) -> bool {
609        if let SearchType::Search(search) = &self.search {
610            search.is_valid()
611        } else {
612            true
613        }
614    }
615}
616
617/// Search result
618#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
619pub struct SearchResponse {
620    /// Hashes of samples which match the search criteria
621    pub hashes: Vec<String>,
622
623    /// Identifier for getting the next batch of results
624    pub pagination: Option<uuid::Uuid>,
625
626    /// The total number of samples which match the search criteria
627    pub total_results: u64,
628
629    /// Optional server messages
630    pub message: Option<String>,
631}
632
633/// Specify the type of hash when searching for a partial match
634#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
635pub enum PartialHashSearchType {
636    /// Search by any known hash type
637    Any,
638
639    /// Search only for MD5 hashes
640    MD5,
641
642    /// Search only for SHA-1 hashes
643    SHA1,
644
645    /// Search only for SHA-256 hashes
646    #[default]
647    SHA256,
648
649    /// Search only for SHA-384 hashes
650    SHA384,
651
652    /// Search only for SHA-512 hashes
653    SHA512,
654}
655
656impl Display for PartialHashSearchType {
657    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
658        match self {
659            PartialHashSearchType::Any => write!(f, "any"),
660            PartialHashSearchType::MD5 => write!(f, "md5"),
661            PartialHashSearchType::SHA1 => write!(f, "sha1"),
662            PartialHashSearchType::SHA256 => write!(f, "sha256"),
663            PartialHashSearchType::SHA384 => write!(f, "sha384"),
664            PartialHashSearchType::SHA512 => write!(f, "sha512"),
665        }
666    }
667}
668
669impl TryInto<PartialHashSearchType> for &str {
670    type Error = String;
671
672    fn try_into(self) -> Result<PartialHashSearchType, Self::Error> {
673        match self {
674            "any" => Ok(PartialHashSearchType::Any),
675            "md5" => Ok(PartialHashSearchType::MD5),
676            "sha1" => Ok(PartialHashSearchType::SHA1),
677            "sha256" => Ok(PartialHashSearchType::SHA256),
678            "sha384" => Ok(PartialHashSearchType::SHA384),
679            "sha512" => Ok(PartialHashSearchType::SHA512),
680            x => Err(format!("Invalid hash type {x}")),
681        }
682    }
683}
684
685impl TryInto<PartialHashSearchType> for Option<&str> {
686    type Error = String;
687
688    fn try_into(self) -> Result<PartialHashSearchType, Self::Error> {
689        if let Some(hash) = self {
690            hash.try_into()
691        } else {
692            Ok(PartialHashSearchType::SHA256)
693        }
694    }
695}
696
697/// API endpoint for searching for samples using YARA rules.
698/// * POST: submit the search request
699/// * GET: get the search results or check on status
700pub const YARA_SEARCH_URL: &str = "/v1/yara";
701
702/// Search for samples using YARA
703#[derive(Clone, Debug, Deserialize, Serialize)]
704pub struct YaraSearchRequest {
705    /// The Yara rules
706    pub rules: Vec<String>,
707
708    /// Get the returned result by a hash type.
709    /// [`PartialHashSearchType::Any`] results in SHA-256
710    pub response: PartialHashSearchType,
711}
712
713/// Provide the client the UUID of the search request.
714#[derive(Clone, Debug, Deserialize, Serialize)]
715pub struct YaraSearchRequestResponse {
716    /// UUID response
717    pub uuid: uuid::Uuid,
718}
719
720/// Search result from Yara
721#[derive(Clone, Debug, Deserialize, Serialize)]
722pub struct YaraSearchResponse {
723    /// Vector of pairs of hash and Yara search name
724    pub results: HashMap<String, Vec<HashType>>,
725}
726
727/// API endpoint for finding samples which are similar to a specific file, POST
728pub const LIST_LABELS_URL: &str = "/v1/labels";
729
730/// A label, used for describing sources and/or samples
731#[derive(Clone, Debug, Deserialize, Serialize)]
732pub struct Label {
733    /// Label ID
734    pub id: u64,
735
736    /// Label value
737    pub name: String,
738
739    /// Label parent
740    pub parent: Option<String>,
741}
742
743/// One or more available labels
744#[derive(Clone, Debug, Default, Deserialize, Serialize)]
745pub struct Labels(pub Vec<Label>);
746
747// Convenience functions
748impl Labels {
749    /// Number of labels
750    #[must_use]
751    pub fn len(&self) -> usize {
752        self.0.len()
753    }
754
755    /// If the labels are empty
756    #[must_use]
757    pub fn is_empty(&self) -> bool {
758        self.0.is_empty()
759    }
760}
761
762impl Display for Labels {
763    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
764        if self.is_empty() {
765            return writeln!(f, "No labels.");
766        }
767        for label in &self.0 {
768            let parent = if let Some(parent) = &label.parent {
769                format!(", parent: {parent}")
770            } else {
771                String::new()
772            };
773            writeln!(f, "{}: {}{parent}", label.id, label.name)?;
774        }
775        Ok(())
776    }
777}