bindle/invoice/
mod.rs

1//! Contains the main invoice object definition, its implementation, and all related subobject (such
2//! as `Parcel`s and `Label`s)
3
4mod api;
5mod bindle_spec;
6mod condition;
7mod group;
8mod label;
9mod parcel;
10mod sealed;
11pub mod signature;
12pub mod verification;
13
14#[cfg(feature = "client")]
15#[doc(inline)]
16pub(crate) use api::DeviceAuthorizationExtraFields;
17#[cfg(any(feature = "client", feature = "server"))]
18#[doc(inline)]
19pub(crate) use api::LoginParams;
20#[doc(inline)]
21pub use api::{
22    ErrorResponse, HealthResponse, InvoiceCreateResponse, KeyOptions, MissingParcelsResponse,
23    QueryOptions,
24};
25#[doc(inline)]
26pub use bindle_spec::BindleSpec;
27#[doc(inline)]
28pub use condition::Condition;
29#[doc(inline)]
30pub use group::Group;
31#[doc(inline)]
32pub use label::Label;
33#[doc(inline)]
34pub use parcel::Parcel;
35#[doc(inline)]
36pub use signature::{SecretKeyEntry, Signature, SignatureError, SignatureRole};
37#[doc(inline)]
38pub use verification::VerificationStrategy;
39
40use ed25519_dalek::{Signature as EdSignature, Signer};
41use semver::{Version, VersionReq};
42use serde::{Deserialize, Serialize};
43use tracing::info;
44
45use std::borrow::{Borrow, BorrowMut};
46use std::collections::BTreeMap;
47use std::fmt::Debug;
48use std::time::{SystemTime, UNIX_EPOCH};
49
50use self::verification::Verified;
51use crate::BINDLE_VERSION_1;
52
53/// Alias for feature map in an Invoice's parcel
54pub type FeatureMap = BTreeMap<String, BTreeMap<String, String>>;
55
56/// Alias for annotations map
57pub type AnnotationMap = BTreeMap<String, String>;
58
59/// A sealed trait used to mark that an invoice has been signed. This trait cannot be implemented by
60/// consumers of the bindle crate
61pub trait Signed: sealed::Sealed {
62    /// Consumes the object, returning the signed invoice
63    fn signed(self) -> Invoice;
64}
65
66/// The main structure for a Bindle invoice.
67///
68/// The invoice describes a specific version of a bindle. For example, the bindle
69/// `foo/bar/1.0.0` would be represented as an Invoice with the `BindleSpec` name
70/// set to `foo/bar` and version set to `1.0.0`.
71///
72/// Most fields on this struct are singular to best represent the specification. There,
73/// fields like `group` and `parcel` are singular due to the conventions of TOML.
74#[derive(Serialize, Deserialize, Debug, Clone)]
75#[serde(deny_unknown_fields, rename_all = "camelCase")]
76pub struct Invoice {
77    pub bindle_version: String,
78    pub yanked: Option<bool>,
79    pub yanked_signature: Option<Vec<Signature>>,
80    pub bindle: BindleSpec,
81    pub annotations: Option<AnnotationMap>,
82    pub parcel: Option<Vec<Parcel>>,
83    pub group: Option<Vec<Group>>,
84    pub signature: Option<Vec<Signature>>,
85}
86
87impl Invoice {
88    /// Create a new Invoice with a bindle specification.
89    ///
90    /// The returned bindle will have no parcels, annotations, signatures, or groups.
91    pub fn new(spec: BindleSpec) -> Self {
92        Invoice {
93            bindle_version: BINDLE_VERSION_1.to_owned(),
94            bindle: spec,
95            parcel: None,
96            yanked: None,
97            yanked_signature: None,
98            annotations: None,
99            signature: None,
100            group: None,
101        }
102    }
103
104    /// produce a slash-delimited "invoice name"
105    ///
106    /// For example, an invoice with the bindle name "hello" and the bindle version
107    /// "1.2.3" will produce "hello/1.2.3"
108    pub fn name(&self) -> String {
109        format!("{}/{}", self.bindle.id.name(), self.bindle.id.version())
110    }
111
112    /// Creates a standard name for an invoice
113    ///
114    /// This is designed to create a repeatable opaque name for the invoice
115    /// We don't typically want to have a bindle ID using its name and version number. This
116    /// would impose both naming constraints on the bindle and security issues on the
117    /// storage layout. So this function hashes the name/version data (which together
118    /// MUST be unique in the system) and uses the resulting hash as the canonical
119    /// name. The hash is guaranteed to be in the character set [a-zA-Z0-9].
120    pub fn canonical_name(&self) -> String {
121        self.bindle.id.sha()
122    }
123
124    /// Compare a SemVer "requirement" string to the version on this bindle
125    ///
126    /// An empty range matches anything.
127    ///
128    /// A range that fails to parse matches nothing.
129    ///
130    /// An empty version matches nothing (unless the requirement is empty)
131    ///
132    /// A version that fails to parse matches nothing (unless the requirement is empty).
133    ///
134    /// In all other cases, if the version satisfies the requirement, this returns true.
135    /// And if it fails to satisfy the requirement, this returns false.
136    pub(crate) fn version_in_range(&self, requirement: &str) -> bool {
137        version_compare(self.bindle.id.version(), requirement)
138    }
139
140    /// Check whether a group by this name is present.
141    pub fn has_group(&self, name: &str) -> bool {
142        let empty = Vec::with_capacity(0);
143        self.group
144            .as_ref()
145            .unwrap_or(&empty)
146            .iter()
147            .any(|g| g.name == name)
148    }
149
150    /// Get all of the parcels on the given group.
151    pub fn group_members(&self, name: &str) -> Vec<Parcel> {
152        // If there is no such group, return early.
153        if !self.has_group(name) {
154            info!(name, "no such group");
155            return vec![];
156        }
157
158        let zero_vec = Vec::with_capacity(0);
159        self.parcel
160            .as_ref()
161            .unwrap_or(&zero_vec)
162            .iter()
163            .filter(|p| p.member_of(name))
164            .cloned()
165            .collect()
166    }
167
168    fn cleartext(&self, by: &str, role: &SignatureRole) -> String {
169        let mut buf = vec![
170            by.to_owned(),
171            self.bindle.id.name().to_owned(),
172            self.bindle.id.version_string(),
173            role.to_string(),
174            '~'.to_string(),
175        ];
176
177        // Add bindles
178        if let Some(list) = self.parcel.as_ref() {
179            list.iter().for_each(|p| {
180                buf.push(p.label.sha256.clone());
181            })
182        }
183
184        buf.join("\n")
185    }
186
187    /// Sign the parcels on the current package.
188    ///
189    /// Note that this signature will be invalidated if any parcels are
190    /// added after this signature.
191    ///
192    /// In the current version of the spec, a signature is generated by combining the
193    /// signer's ID, the invoice version, and a list of parcels, and then performing
194    /// a cryptographic signature on those fields. The result is then stored in
195    /// a `[[signature]]` block on the invoice. Multiple signatures can be attached
196    /// to any invoice.
197    pub fn sign(
198        &mut self,
199        signer_role: SignatureRole,
200        keyfile: &SecretKeyEntry,
201    ) -> Result<(), SignatureError> {
202        let signer_name = keyfile.label.clone();
203        let key = keyfile.key()?;
204        // The spec says it is illegal for the a single key to sign the same invoice
205        // more than once.
206        let encoded_key = base64::encode(key.public.to_bytes());
207        if let Some(sigs) = self.signature.as_ref() {
208            for s in sigs {
209                if s.key == encoded_key {
210                    return Err(SignatureError::DuplicateSignature);
211                }
212            }
213        }
214
215        let cleartext = self.cleartext(&signer_name, &signer_role);
216        let signature: EdSignature = key.sign(cleartext.as_bytes());
217
218        // Timestamp should be generated at this moment.
219        let ts = SystemTime::now()
220            .duration_since(UNIX_EPOCH)
221            .map_err(|_| SignatureError::SigningFailed)?;
222
223        let signature_entry = Signature {
224            by: signer_name,
225            key: encoded_key,
226            signature: base64::encode(signature.to_bytes()),
227            role: signer_role,
228            at: ts.as_secs(),
229        };
230
231        match self.signature.as_mut() {
232            Some(signatures) => signatures.push(signature_entry),
233            None => self.signature = Some(vec![signature_entry]),
234        };
235
236        Ok(())
237    }
238}
239
240/// Sign the parcels in the invoice using the given list of roles and keys. This is a list of tuples
241/// containing a [`SignatureRole`] and [`SecretKeyEntry`] in that order. Returns a [`SignedInvoice`]
242///
243/// Note that this signature will be invalidated if any parcels are added after this signature.
244///
245/// In the current version of the spec, a signature is generated by combining the signer's ID, the
246/// invoice version, and a list of parcels, and then performing a cryptographic signature on those
247/// fields. The result is then stored in a `[[signature]]` block on the invoice. Multiple signatures
248/// can be attached to any invoice.
249pub fn sign<I>(
250    mut invoice: I,
251    sign_with: Vec<(SignatureRole, &SecretKeyEntry)>,
252) -> Result<SignedInvoice<I>, SignatureError>
253where
254    I: BorrowMut<Invoice> + Into<crate::Invoice>,
255{
256    let inv = invoice.borrow_mut();
257    for (role, key) in sign_with {
258        sign_one(inv, role, key)?;
259    }
260
261    Ok(SignedInvoice(invoice))
262}
263
264fn sign_one(
265    inv: &mut Invoice,
266    signer_role: SignatureRole,
267    keyfile: &SecretKeyEntry,
268) -> Result<(), SignatureError> {
269    let signer_name = keyfile.label.clone();
270    let key = keyfile.key()?;
271    // The spec says it is illegal for the a single key to sign the same invoice
272    // more than once.
273    let encoded_key = base64::encode(key.public.to_bytes());
274    if let Some(sigs) = inv.signature.as_ref() {
275        for s in sigs {
276            if s.key == encoded_key {
277                return Err(SignatureError::DuplicateSignature);
278            }
279        }
280    }
281
282    let cleartext = inv.cleartext(&signer_name, &signer_role);
283    let signature: EdSignature = key.sign(cleartext.as_bytes());
284
285    // Timestamp should be generated at this moment.
286    let ts = SystemTime::now()
287        .duration_since(UNIX_EPOCH)
288        .map_err(|_| SignatureError::SigningFailed)?;
289
290    let signature_entry = Signature {
291        by: signer_name,
292        key: encoded_key,
293        signature: base64::encode(signature.to_bytes()),
294        role: signer_role,
295        at: ts.as_secs(),
296    };
297
298    match inv.signature.as_mut() {
299        Some(signatures) => signatures.push(signature_entry),
300        None => inv.signature = Some(vec![signature_entry]),
301    };
302
303    Ok(())
304}
305
306/// An invoice that has been signed and can no longer be modified unless converted back into a
307/// normal invoice with the `signed` method
308pub struct SignedInvoice<T: Into<Invoice>>(T);
309
310impl<T: Into<Invoice>> Signed for SignedInvoice<T> {
311    fn signed(self) -> Invoice {
312        self.0.into()
313    }
314}
315
316impl<T: Into<Invoice>> sealed::Sealed for SignedInvoice<T> {}
317
318impl<T> Verified for SignedInvoice<T> where T: Verified + Into<Invoice> {}
319
320impl<T> Borrow<Invoice> for SignedInvoice<T>
321where
322    T: Into<Invoice> + Borrow<Invoice>,
323{
324    fn borrow(&self) -> &Invoice {
325        self.0.borrow()
326    }
327}
328
329impl<T> Debug for SignedInvoice<T>
330where
331    T: Debug + Into<Invoice>,
332{
333    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
334        self.0.fmt(f)
335    }
336}
337
338/// A struct that fakes signing. Purely for internal usage with things such as caches and
339/// passthrough implementations (like the client)
340pub(crate) struct NoopSigned<T: Into<Invoice>>(pub(crate) T);
341
342impl<T: Into<Invoice>> Signed for NoopSigned<T> {
343    fn signed(self) -> Invoice {
344        self.0.into()
345    }
346}
347
348impl<T> Verified for NoopSigned<T> where T: Verified + Into<Invoice> {}
349
350impl<T: Into<Invoice>> sealed::Sealed for NoopSigned<T> {}
351
352impl<T> Borrow<Invoice> for NoopSigned<T>
353where
354    T: Into<Invoice> + Borrow<Invoice>,
355{
356    fn borrow(&self) -> &Invoice {
357        self.0.borrow()
358    }
359}
360
361impl<T> Debug for NoopSigned<T>
362where
363    T: Debug + Into<Invoice>,
364{
365    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
366        self.0.fmt(f)
367    }
368}
369
370/// Check whether the given version is within the legal range.
371///
372/// An empty range matches anything.
373///
374/// A range that fails to parse matches nothing.
375///
376/// An empty version matches nothing (unless the requirement is empty)
377///
378/// A version that fails to parse matches nothing (unless the requirement is empty).
379///
380/// In all other cases, if the version satisfies the requirement, this returns true.
381/// And if it fails to satisfy the requirement, this returns false.
382fn version_compare(version: &Version, requirement: &str) -> bool {
383    if requirement.is_empty() {
384        return true;
385    }
386
387    // For compatibility with npm (https://www.npmjs.com/package/semver),
388    // check if the requirement is just a version; if so, treat it as equality (`=`) rather
389    // than Rust's default (`^`).
390    if let Ok(v) = Version::parse(requirement) {
391        return *version == v;
392    }
393
394    match VersionReq::parse(requirement) {
395        Ok(req) => req.matches(version),
396        Err(e) => {
397            tracing::log::error!("SemVer range could not parse: {}", e);
398            false
399        }
400    }
401}
402
403#[cfg(test)]
404mod test {
405    use super::*;
406    use crate::invoice::signature::{KeyEntry, KeyRing};
407    use std::convert::TryInto;
408    use std::fs::read;
409    use std::path::Path;
410
411    #[test]
412    fn test_version_comparisons() {
413        // Do not need an exhaustive list of matches -- just a sampling to make sure
414        // the outer logic is correct.
415        let reqs = vec!["= 1.2.3", "1.2.3", "1.2.3", "^1.1", "~1.2", ""];
416        let version = Version::parse("1.2.3").unwrap();
417
418        reqs.iter().for_each(|r| {
419            if !version_compare(&version, r) {
420                panic!("Should have passed: {}", r)
421            }
422        });
423
424        // Again, we do not need to test the SemVer crate -- just make sure some
425        // outliers and obvious cases are covered.
426        let reqs = vec!["2", "%^&%^&%"];
427        reqs.iter()
428            .for_each(|r| assert!(!version_compare(&version, r)));
429    }
430
431    #[test]
432    fn signing_and_verifying() {
433        let invoice = r#"
434        bindleVersion = "1.0.0"
435
436        [bindle]
437        name = "aricebo"
438        version = "1.2.3"
439
440        [[parcel]]
441        [parcel.label]
442        sha256 = "aaabbbcccdddeeefff"
443        name = "telescope.gif"
444        mediaType = "image/gif"
445        size = 123_456
446        
447        [[parcel]]
448        [parcel.label]
449        sha256 = "111aaabbbcccdddeee"
450        name = "telescope.txt"
451        mediaType = "text/plain"
452        size = 123_456
453        "#;
454
455        let invoice: crate::Invoice = toml::from_str(invoice).expect("a nice clean parse");
456
457        // Create two signing keys.
458        let signer_name1 = "Matt Butcher <matt@example.com>";
459        let signer_name2 = "Not Matt Butcher <not.matt@example.com>";
460
461        let keypair1 = SecretKeyEntry::new(signer_name1, vec![SignatureRole::Creator]);
462        let keypair2 = SecretKeyEntry::new(signer_name2, vec![SignatureRole::Proxy]);
463
464        // Put one of the two keys on the keyring. Since the proxy key is not used in
465        // CreativeIntegrity, it can be omitted from keyring.
466        let keyring = KeyRing::new(vec![(&keypair1).try_into().expect("convert to public key")]);
467
468        // Add two signatures
469        let signed = sign(
470            invoice,
471            vec![
472                (SignatureRole::Creator, &keypair1),
473                (SignatureRole::Proxy, &keypair2),
474            ],
475        )
476        .expect("Sign the parcels");
477
478        let invoice: Invoice = signed.signed();
479
480        // Should not be able to sign the same invoice again with the same key, even with a different role
481        sign(invoice.clone(), vec![(SignatureRole::Host, &keypair2)])
482            .expect_err("Should not be able to sign again with the same key");
483
484        // There should be two signature blocks
485        assert_eq!(2, invoice.signature.as_ref().unwrap().len());
486
487        // With the keyring, the signature should work
488        VerificationStrategy::CreativeIntegrity
489            .verify(invoice.clone(), &keyring)
490            .expect("with keys on the keyring, this should pass");
491
492        // If we switch the keys in the keyring, this should fail because the Creator
493        // key is not on the ring.
494        let keyring = KeyRing::new(vec![keypair2.try_into().expect("convert to public key")]);
495        VerificationStrategy::CreativeIntegrity
496            .verify(invoice, &keyring)
497            .expect_err("missing the creator key, so verification should fail");
498    }
499    #[test]
500    fn invalid_signatures_should_fail() {
501        let invoice = r#"
502        bindleVersion = "1.0.0"
503
504        [bindle]
505        name = "aricebo"
506        version = "1.2.3"
507
508        [[signature]]
509        by = "Matt Butcher <matt@example.com>"
510        signature = "T0JWSU9VU0xZIEZBS0UK" # echo "OBVIOUSLY FAKE" | base64
511        key = "jTtZIzQCfZh8xy6st40xxLwxVw++cf0C0cMH3nJBF+c="
512        role = "creator"
513        at = 1611960337
514
515        [[parcel]]
516        [parcel.label]
517        sha256 = "aaabbbcccdddeeefff"
518        name = "telescope.gif"
519        mediaType = "image/gif"
520        size = 123_456
521        
522        [[parcel]]
523        [parcel.label]
524        sha256 = "111aaabbbcccdddeee"
525        name = "telescope.txt"
526        mediaType = "text/plain"
527        size = 123_456
528        "#;
529
530        let invoice: crate::Invoice = toml::from_str(invoice).expect("a nice clean parse");
531
532        // Parse the key from the above example, and put it into the keyring.
533        let pubkey = KeyEntry {
534            key: "jTtZIzQCfZh8xy6st40xxLwxVw++cf0C0cMH3nJBF+c=".to_owned(),
535            label: "Test Key".to_owned(),
536            roles: vec![SignatureRole::Host],
537            label_signature: None,
538        };
539
540        // Set up a keyring
541        let keyring = KeyRing::new(vec![pubkey]);
542
543        match VerificationStrategy::default().verify(invoice, &keyring) {
544            Err(SignatureError::CorruptSignature(s)) => {
545                assert_eq!("jTtZIzQCfZh8xy6st40xxLwxVw++cf0C0cMH3nJBF+c=", s)
546            }
547            Err(e) => panic!("Unexpected error {:?}", e),
548            Ok(_) => panic!("Verification should have failed"),
549        }
550    }
551
552    #[test]
553    fn invalid_key_should_fail() {
554        let invoice = r#"
555        bindleVersion = "1.0.0"
556
557        [bindle]
558        name = "aricebo"
559        version = "1.2.3"
560
561        [[signature]]
562        by = "Matt Butcher <matt@example.com>"
563        signature = "x6sI2Qme4xf6IRtHGaoMqMRL0vjvVHLq3ZCaKVkHNr3oCw+kvTrxek7RbuajIgS71zUQew4/vVT4Do0xa49+CQ=="
564        key = "T0JWSU9VU0xZIEZBS0UK" # echo "OBVIOUSLY FAKE" | base64
565        role = "creator"
566        at = 1611960337
567
568        [[parcel]]
569        [parcel.label]
570        sha256 = "aaabbbcccdddeeefff"
571        name = "telescope.gif"
572        mediaType = "image/gif"
573        size = 123_456
574        
575        [[parcel]]
576        [parcel.label]
577        sha256 = "111aaabbbcccdddeee"
578        name = "telescope.txt"
579        mediaType = "text/plain"
580        size = 123_456
581        "#;
582
583        let invoice: crate::Invoice = toml::from_str(invoice).expect("a nice clean parse");
584
585        // Set up a keyring
586        let keyring = KeyRing::new(vec![KeyEntry {
587            key: "jTtZIzQCfZh8xy6st40xxLwxVw++cf0C0cMH3nJBF+c=".to_owned(),
588            label: "Test Key".to_owned(),
589            roles: vec![SignatureRole::Creator],
590            label_signature: None,
591        }]);
592
593        match VerificationStrategy::default().verify(invoice, &keyring) {
594            Err(SignatureError::CorruptKey(s)) => assert_eq!("T0JWSU9VU0xZIEZBS0UK", s),
595            Err(e) => panic!("Unexpected error {:?}", e),
596            Ok(_) => panic!("Verification should have failed"),
597        }
598    }
599
600    #[test]
601    fn test_invoice_should_serialize() {
602        let label = Label {
603            sha256: "abcdef1234567890987654321".to_owned(),
604            media_type: "text/toml".to_owned(),
605            name: "foo.toml".to_owned(),
606            size: 101,
607            annotations: None,
608            feature: None,
609            origin: None,
610        };
611        let parcel = Parcel {
612            label,
613            conditions: None,
614        };
615        let parcels = Some(vec![parcel]);
616        let inv = Invoice {
617            bindle_version: crate::BINDLE_VERSION_1.to_owned(),
618            bindle: BindleSpec {
619                id: "foo/1.2.3".parse().unwrap(),
620                description: Some("bar".to_owned()),
621                authors: Some(vec!["m butcher".to_owned()]),
622            },
623            parcel: parcels,
624            yanked: None,
625            yanked_signature: None,
626            annotations: None,
627            group: None,
628            signature: None,
629        };
630
631        let res = toml::to_string(&inv).unwrap();
632        let inv2 = toml::from_str::<Invoice>(res.as_str()).unwrap();
633
634        let b = inv2.bindle;
635        assert_eq!(b.id.name(), "foo".to_owned());
636        assert_eq!(b.id.version_string(), "1.2.3");
637        assert_eq!(b.description.unwrap().as_str(), "bar");
638        assert_eq!(b.authors.unwrap()[0], "m butcher".to_owned());
639
640        let parcels = inv2.parcel.unwrap();
641
642        assert_eq!(parcels.len(), 1);
643
644        let par = &parcels[0];
645        let lab = &par.label;
646        assert_eq!(lab.name, "foo.toml".to_owned());
647        assert_eq!(lab.media_type, "text/toml".to_owned());
648        assert_eq!(lab.sha256, "abcdef1234567890987654321".to_owned());
649        assert_eq!(lab.size, 101);
650    }
651
652    #[test]
653    fn test_examples_in_spec_parse() {
654        let test_files = vec![
655            "test/data/simple-invoice.toml",
656            "test/data/full-invoice.toml",
657            "test/data/alt-format-invoice.toml",
658        ];
659        test_files.iter().for_each(|file| test_parsing_a_file(file));
660    }
661
662    fn test_parsing_a_file(filename: &str) {
663        let invoice_path = Path::new(filename);
664        let raw = read(invoice_path).expect("read file contents");
665
666        let invoice = toml::from_slice::<Invoice>(&raw).expect("clean parse of invoice");
667
668        // Now we serialize it and compare it to the original version
669        let _raw2 = toml::to_string_pretty(&invoice).expect("clean serialization of TOML");
670        // FIXME: Do we care about this detail?
671        //assert_eq!(raw, raw2);
672    }
673
674    #[test]
675    fn parcel_no_groups() {
676        let invoice = r#"
677        bindleVersion = "1.0.0"
678
679        [bindle]
680        name = "aricebo"
681        version = "1.2.3"
682
683        [[group]]
684        name = "images"
685
686        [[parcel]]
687        [parcel.label]
688        sha256 = "aaabbbcccdddeeefff"
689        name = "telescope.gif"
690        mediaType = "image/gif"
691        size = 123_456
692        [parcel.conditions]
693        memberOf = ["telescopes"]
694
695        [[parcel]]
696        [parcel.label]
697        sha256 = "111aaabbbcccdddeee"
698        name = "telescope.txt"
699        mediaType = "text/plain"
700        size = 123_456
701        "#;
702
703        let invoice: crate::Invoice = toml::from_str(invoice).expect("a nice clean parse");
704        let parcels = invoice.parcel.expect("expected some parcels");
705
706        let img = &parcels[0];
707        let txt = &parcels[1];
708
709        assert!(img.member_of("telescopes"));
710        assert!(!img.is_global_group());
711
712        assert!(txt.is_global_group());
713        assert!(!txt.member_of("telescopes"));
714    }
715
716    #[test]
717    fn test_group_members() {
718        let invoice = r#"
719        bindleVersion = "1.0.0"
720
721        [bindle]
722        name = "aricebo"
723        version = "1.2.3"
724
725        [[group]]
726        name = "telescopes"
727
728        [[parcel]]
729        [parcel.label]
730        sha256 = "aaabbbcccdddeeefff"
731        name = "telescope.gif"
732        mediaType = "image/gif"
733        size = 123_456
734        [parcel.conditions]
735        memberOf = ["telescopes"]
736
737        [[parcel]]
738        [parcel.label]
739        sha256 = "aaabbbcccdddeeeggg"
740        name = "telescope2.gif"
741        mediaType = "image/gif"
742        size = 123_456
743        [parcel.conditions]
744        memberOf = ["telescopes"]
745
746        [[parcel]]
747        [parcel.label]
748        sha256 = "111aaabbbcccdddeee"
749        name = "telescope.txt"
750        mediaType = "text/plain"
751        size = 123_456
752        "#;
753
754        let invoice: crate::Invoice = toml::from_str(invoice).expect("a nice clean parse");
755        let members = invoice.group_members("telescopes");
756        assert_eq!(2, members.len());
757    }
758}