Skip to main content

cedar_policy/proto/
policy.rs

1/*
2 * Copyright Cedar Contributors
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17#![allow(clippy::use_self, reason = "readability")]
18
19use super::ast::ProtobufConversionError;
20use super::models;
21use cedar_policy_core::{ast, FromNormalizedStr};
22use linked_hash_map::{Entry, LinkedHashMap};
23use linked_hash_set::LinkedHashSet;
24use std::{
25    collections::{HashMap, HashSet},
26    sync::Arc,
27};
28
29/// Convert a template-link [`models::Policy`] to an [`ast::Policy`] given a set of
30/// `templates`.
31///
32/// Returns an error when:
33/// - the link id is not in `v` (should have been a static policy),
34/// - the set of `link_ids` already contains a link with the same id,
35/// - the link id conflicts with a template id,
36/// - any of the invariants of [`ast::Policy`] would be violated,
37/// - the template referenced by the model doesn't exist in the set of templates.
38fn reify_template_link(
39    v: models::Policy,
40    link_ids: &mut HashSet<ast::PolicyID>,
41    templates: &LinkedHashMap<ast::PolicyID, Arc<ast::Template>>,
42) -> Result<ast::Policy, ProtobufConversionError> {
43    let template_id = ast::PolicyID::from_string(v.template_id);
44    let template = templates.get(&template_id).ok_or_else(|| {
45        ProtobufConversionError::InvalidValue(format!("no such template: {template_id}"))
46    })?;
47    let link_id = v
48        .link_id
49        .as_ref()
50        .ok_or_else(|| ProtobufConversionError::missing("link_id"))?;
51    let link_id = ast::PolicyID::from_string(link_id);
52    if !link_ids.insert(link_id.clone()) {
53        return Err(ProtobufConversionError::InvalidValue(format!(
54            "link_id `{link_id}` conflicts with a policy id (link_id or template_id) in `links`"
55        )));
56    }
57    if templates.contains_key(&link_id) {
58        return Err(ProtobufConversionError::InvalidValue(format!(
59            "link_id `{link_id}` conflicts with a template_id in `templates`"
60        )));
61    }
62
63    let mut values: ast::SlotEnv = HashMap::new();
64    if let Some(principal_euid) = v.principal_euid {
65        values.insert(
66            ast::SlotId::principal(),
67            ast::EntityUID::try_from(principal_euid)?,
68        );
69    }
70    if let Some(resource_euid) = v.resource_euid {
71        values.insert(
72            ast::SlotId::resource(),
73            ast::EntityUID::try_from(resource_euid)?,
74        );
75    }
76
77    let link_id = ast::PolicyID::from_string(
78        v.link_id
79            .as_ref()
80            .ok_or_else(|| ProtobufConversionError::missing("link_id"))?,
81    );
82    ast::Template::link(template.clone(), link_id, values).map_err(|e| {
83        ProtobufConversionError::InvalidValue(format!("failed to convert to policy: {e}"))
84    })
85}
86
87/// Convert a template-link [`models::Policy`] to an [`ast::Policy`] given a set of
88/// `templates`, in the context of an already existing set of `link_ids`.
89///
90/// Returns an error when:
91/// - the static policy id (`v.template_id`) conflicts with an id in `link_ids`,
92/// - any of the invariants of [`ast::Policy`] would be violated,
93/// - the template referenced by the model doesn't exist in the set of templates.
94fn reify_static_policy(
95    v: models::Policy,
96    link_ids: &mut HashSet<ast::PolicyID>,
97    templates: &LinkedHashMap<ast::PolicyID, Arc<ast::Template>>,
98) -> Result<ast::Policy, ProtobufConversionError> {
99    let template_id = ast::PolicyID::from_string(v.template_id);
100    // the policy id is the template id (see protobuf docs)
101    if !link_ids.insert(template_id.clone()) {
102        return Err(ProtobufConversionError::InvalidValue(format!(
103                        "template_id `{template_id}` of a static link conflicts with a policy id (link_id or template_id) in `links`"
104                    )));
105    }
106    let template = templates.get(&template_id).ok_or_else(|| {
107        ProtobufConversionError::InvalidValue(format!("no such template: {template_id}"))
108    })?;
109    ast::Template::try_as_policy(template.clone()).map_err(|e| {
110        ProtobufConversionError::InvalidValue(format!("failed to convert a static policy: {e}"))
111    })
112}
113
114impl From<&ast::Policy> for models::Policy {
115    fn from(v: &ast::Policy) -> Self {
116        Self {
117            template_id: v.template().id().as_ref().to_string(),
118            link_id: if v.is_static() {
119                None
120            } else {
121                Some(v.id().as_ref().to_string())
122            },
123            is_template_link: !v.is_static(),
124            principal_euid: v
125                .env()
126                .get(&ast::SlotId::principal())
127                .map(models::EntityUid::from),
128            resource_euid: v
129                .env()
130                .get(&ast::SlotId::resource())
131                .map(models::EntityUid::from),
132        }
133    }
134}
135
136impl TryFrom<models::TemplateBody> for ast::Template {
137    type Error = ProtobufConversionError;
138    fn try_from(v: models::TemplateBody) -> Result<Self, Self::Error> {
139        Ok(ast::Template::from(ast::TemplateBody::try_from(v)?))
140    }
141}
142
143impl TryFrom<models::TemplateBody> for ast::TemplateBody {
144    type Error = ProtobufConversionError;
145    fn try_from(v: models::TemplateBody) -> Result<Self, Self::Error> {
146        let effect = models::Effect::try_from(v.effect)
147            .map_err(|e| ProtobufConversionError::InvalidValue(format!("invalid effect: {e}")))?;
148        Ok(ast::TemplateBody::new(
149            ast::PolicyID::from_string(v.id),
150            None,
151            v.annotations
152                .into_iter()
153                .map(|(key, value)| {
154                    ast::AnyId::from_normalized_str(&key)
155                        .map(|k| {
156                            (
157                                k,
158                                ast::Annotation {
159                                    val: value.into(),
160                                    loc: None,
161                                },
162                            )
163                        })
164                        .map_err(|e| {
165                            ProtobufConversionError::InvalidValue(format!(
166                                "invalid annotation key `{key}`: {e}"
167                            ))
168                        })
169                })
170                .collect::<Result<_, _>>()?,
171            ast::Effect::from(effect),
172            ast::PrincipalConstraint::try_from(
173                v.principal_constraint
174                    .ok_or_else(|| ProtobufConversionError::missing("principal_constraint"))?,
175            )?,
176            ast::ActionConstraint::try_from(
177                v.action_constraint
178                    .ok_or_else(|| ProtobufConversionError::missing("action_constraint"))?,
179            )?,
180            ast::ResourceConstraint::try_from(
181                v.resource_constraint
182                    .ok_or_else(|| ProtobufConversionError::missing("resource_constraint"))?,
183            )?,
184            v.non_scope_constraints
185                .map(ast::Expr::try_from)
186                .transpose()?,
187        ))
188    }
189}
190
191impl From<&ast::TemplateBody> for models::TemplateBody {
192    fn from(v: &ast::TemplateBody) -> Self {
193        let annotations: HashMap<String, String> = v
194            .annotations()
195            .map(|(key, value)| (key.as_ref().into(), value.as_ref().into()))
196            .collect();
197
198        Self {
199            id: v.id().as_ref().to_string(),
200            annotations,
201            effect: models::Effect::from(&v.effect()).into(),
202            principal_constraint: Some(models::PrincipalOrResourceConstraint::from(
203                v.principal_constraint(),
204            )),
205            action_constraint: Some(models::ActionConstraint::from(v.action_constraint())),
206            resource_constraint: Some(models::PrincipalOrResourceConstraint::from(
207                v.resource_constraint(),
208            )),
209            non_scope_constraints: v.non_scope_constraints().map(models::Expr::from),
210        }
211    }
212}
213
214impl From<&ast::Template> for models::TemplateBody {
215    fn from(v: &ast::Template) -> Self {
216        models::TemplateBody::from(&ast::TemplateBody::from(v.clone()))
217    }
218}
219
220impl TryFrom<models::PrincipalOrResourceConstraint> for ast::PrincipalConstraint {
221    type Error = ProtobufConversionError;
222    fn try_from(v: models::PrincipalOrResourceConstraint) -> Result<Self, Self::Error> {
223        Ok(Self::new(ast::PrincipalOrResourceConstraint::try_from(v)?))
224    }
225}
226
227impl From<&ast::PrincipalConstraint> for models::PrincipalOrResourceConstraint {
228    fn from(v: &ast::PrincipalConstraint) -> Self {
229        models::PrincipalOrResourceConstraint::from(v.as_inner())
230    }
231}
232
233impl TryFrom<models::PrincipalOrResourceConstraint> for ast::ResourceConstraint {
234    type Error = ProtobufConversionError;
235    fn try_from(v: models::PrincipalOrResourceConstraint) -> Result<Self, Self::Error> {
236        Ok(Self::new(ast::PrincipalOrResourceConstraint::try_from(v)?))
237    }
238}
239
240impl From<&ast::ResourceConstraint> for models::PrincipalOrResourceConstraint {
241    fn from(v: &ast::ResourceConstraint) -> Self {
242        models::PrincipalOrResourceConstraint::from(v.as_inner())
243    }
244}
245
246impl TryFrom<models::EntityReference> for ast::EntityReference {
247    type Error = ProtobufConversionError;
248    fn try_from(v: models::EntityReference) -> Result<Self, Self::Error> {
249        match v
250            .data
251            .ok_or_else(|| ProtobufConversionError::missing("data"))?
252        {
253            models::entity_reference::Data::Slot(slot) => {
254                match models::entity_reference::Slot::try_from(slot).map_err(|e| {
255                    ProtobufConversionError::InvalidValue(format!(
256                        "invalid entity reference slot: {e}"
257                    ))
258                })? {
259                    models::entity_reference::Slot::Unit => Ok(ast::EntityReference::Slot(None)),
260                }
261            }
262            models::entity_reference::Data::Euid(euid) => Ok(ast::EntityReference::euid(
263                ast::EntityUID::try_from(euid)?.into(),
264            )),
265        }
266    }
267}
268
269impl From<&ast::EntityReference> for models::EntityReference {
270    fn from(v: &ast::EntityReference) -> Self {
271        match v {
272            ast::EntityReference::EUID(euid) => Self {
273                data: Some(models::entity_reference::Data::Euid(
274                    models::EntityUid::from(euid.as_ref()),
275                )),
276            },
277            ast::EntityReference::Slot(_) => Self {
278                data: Some(models::entity_reference::Data::Slot(
279                    models::entity_reference::Slot::Unit.into(),
280                )),
281            },
282        }
283    }
284}
285
286impl TryFrom<models::PrincipalOrResourceConstraint> for ast::PrincipalOrResourceConstraint {
287    type Error = ProtobufConversionError;
288    fn try_from(v: models::PrincipalOrResourceConstraint) -> Result<Self, Self::Error> {
289        match v
290            .data
291            .ok_or_else(|| ProtobufConversionError::missing("data"))?
292        {
293            models::principal_or_resource_constraint::Data::Any(unit) => {
294                match models::principal_or_resource_constraint::Any::try_from(unit).map_err(
295                    |e| {
296                        ProtobufConversionError::InvalidValue(format!(
297                            "invalid principal/resource constraint: {e}"
298                        ))
299                    },
300                )? {
301                    models::principal_or_resource_constraint::Any::Unit => {
302                        Ok(ast::PrincipalOrResourceConstraint::Any)
303                    }
304                }
305            }
306            models::principal_or_resource_constraint::Data::In(msg) => Ok(
307                ast::PrincipalOrResourceConstraint::In(ast::EntityReference::try_from(
308                    msg.er
309                        .ok_or_else(|| ProtobufConversionError::missing("er"))?,
310                )?),
311            ),
312            models::principal_or_resource_constraint::Data::Eq(msg) => Ok(
313                ast::PrincipalOrResourceConstraint::Eq(ast::EntityReference::try_from(
314                    msg.er
315                        .ok_or_else(|| ProtobufConversionError::missing("er"))?,
316                )?),
317            ),
318            models::principal_or_resource_constraint::Data::Is(msg) => {
319                Ok(ast::PrincipalOrResourceConstraint::Is(
320                    ast::EntityType::try_from(
321                        msg.entity_type
322                            .ok_or_else(|| ProtobufConversionError::missing("entity_type"))?,
323                    )?
324                    .into(),
325                ))
326            }
327            models::principal_or_resource_constraint::Data::IsIn(msg) => {
328                Ok(ast::PrincipalOrResourceConstraint::IsIn(
329                    ast::EntityType::try_from(
330                        msg.entity_type
331                            .ok_or_else(|| ProtobufConversionError::missing("entity_type"))?,
332                    )?
333                    .into(),
334                    ast::EntityReference::try_from(
335                        msg.er
336                            .ok_or_else(|| ProtobufConversionError::missing("er"))?,
337                    )?,
338                ))
339            }
340        }
341    }
342}
343
344impl From<&ast::PrincipalOrResourceConstraint> for models::PrincipalOrResourceConstraint {
345    fn from(v: &ast::PrincipalOrResourceConstraint) -> Self {
346        match v {
347            ast::PrincipalOrResourceConstraint::Any => Self {
348                data: Some(models::principal_or_resource_constraint::Data::Any(
349                    models::principal_or_resource_constraint::Any::Unit.into(),
350                )),
351            },
352            ast::PrincipalOrResourceConstraint::In(er) => Self {
353                data: Some(models::principal_or_resource_constraint::Data::In(
354                    models::principal_or_resource_constraint::InMessage {
355                        er: Some(models::EntityReference::from(er)),
356                    },
357                )),
358            },
359            ast::PrincipalOrResourceConstraint::Eq(er) => Self {
360                data: Some(models::principal_or_resource_constraint::Data::Eq(
361                    models::principal_or_resource_constraint::EqMessage {
362                        er: Some(models::EntityReference::from(er)),
363                    },
364                )),
365            },
366            ast::PrincipalOrResourceConstraint::Is(na) => Self {
367                data: Some(models::principal_or_resource_constraint::Data::Is(
368                    models::principal_or_resource_constraint::IsMessage {
369                        entity_type: Some(models::Name::from(na.as_ref())),
370                    },
371                )),
372            },
373            ast::PrincipalOrResourceConstraint::IsIn(na, er) => Self {
374                data: Some(models::principal_or_resource_constraint::Data::IsIn(
375                    models::principal_or_resource_constraint::IsInMessage {
376                        er: Some(models::EntityReference::from(er)),
377                        entity_type: Some(models::Name::from(na.as_ref())),
378                    },
379                )),
380            },
381        }
382    }
383}
384
385impl TryFrom<models::ActionConstraint> for ast::ActionConstraint {
386    type Error = ProtobufConversionError;
387    fn try_from(v: models::ActionConstraint) -> Result<Self, Self::Error> {
388        match v
389            .data
390            .ok_or_else(|| ProtobufConversionError::missing("data"))?
391        {
392            models::action_constraint::Data::Any(unit) => {
393                match models::action_constraint::Any::try_from(unit).map_err(|e| {
394                    ProtobufConversionError::InvalidValue(format!("invalid action constraint: {e}"))
395                })? {
396                    models::action_constraint::Any::Unit => Ok(ast::ActionConstraint::Any),
397                }
398            }
399            models::action_constraint::Data::In(msg) => Ok(ast::ActionConstraint::In(
400                msg.euids
401                    .into_iter()
402                    .map(|value| Ok(ast::EntityUID::try_from(value)?.into()))
403                    .collect::<Result<_, ProtobufConversionError>>()?,
404            )),
405            models::action_constraint::Data::Eq(msg) => Ok(ast::ActionConstraint::Eq(
406                ast::EntityUID::try_from(
407                    msg.euid
408                        .ok_or_else(|| ProtobufConversionError::missing("euid"))?,
409                )?
410                .into(),
411            )),
412        }
413    }
414}
415
416impl From<&ast::ActionConstraint> for models::ActionConstraint {
417    fn from(v: &ast::ActionConstraint) -> Self {
418        match v {
419            ast::ActionConstraint::Any => Self {
420                data: Some(models::action_constraint::Data::Any(
421                    models::action_constraint::Any::Unit.into(),
422                )),
423            },
424            ast::ActionConstraint::In(euids) => {
425                let mut peuids: Vec<models::EntityUid> = Vec::with_capacity(euids.len());
426                for value in euids {
427                    peuids.push(models::EntityUid::from(value.as_ref()));
428                }
429                Self {
430                    data: Some(models::action_constraint::Data::In(
431                        models::action_constraint::InMessage { euids: peuids },
432                    )),
433                }
434            }
435            ast::ActionConstraint::Eq(euid) => Self {
436                data: Some(models::action_constraint::Data::Eq(
437                    models::action_constraint::EqMessage {
438                        euid: Some(models::EntityUid::from(euid.as_ref())),
439                    },
440                )),
441            },
442            #[cfg(feature = "tolerant-ast")]
443            #[expect(clippy::unimplemented, reason = "experimental feature")]
444            ast::ActionConstraint::ErrorConstraint => {
445                unimplemented!("tolerant-ast cannot be used with the protobuf feature")
446            }
447        }
448    }
449}
450
451impl From<models::Effect> for ast::Effect {
452    fn from(v: models::Effect) -> Self {
453        match v {
454            models::Effect::Forbid => ast::Effect::Forbid,
455            models::Effect::Permit => ast::Effect::Permit,
456        }
457    }
458}
459
460impl From<&ast::Effect> for models::Effect {
461    fn from(v: &ast::Effect) -> Self {
462        match v {
463            ast::Effect::Permit => models::Effect::Permit,
464            ast::Effect::Forbid => models::Effect::Forbid,
465        }
466    }
467}
468
469impl TryFrom<models::PolicySet> for ast::PolicySet {
470    type Error = ProtobufConversionError;
471    fn try_from(v: models::PolicySet) -> Result<Self, Self::Error> {
472        let mut link_ids: HashSet<ast::PolicyID> = HashSet::new();
473
474        // models::Template into the Arc<ast::Template>, detecting duplicates via entry API.
475        let mut templates: LinkedHashMap<ast::PolicyID, Arc<ast::Template>> = LinkedHashMap::new();
476        for tb in v.templates {
477            let id = ast::PolicyID::from_string(&tb.id);
478            let template = Arc::new(ast::Template::from(ast::TemplateBody::try_from(tb)?));
479            match templates.entry(id.clone()) {
480                Entry::Vacant(entry) => {
481                    entry.insert(template);
482                }
483                Entry::Occupied(_) => {
484                    return Err(ProtobufConversionError::InvalidValue(format!(
485                        "duplicate template_id `{id}` in `templates`"
486                    )));
487                }
488            }
489        }
490
491        // Build the links map and the template-to-links map together.
492        let mut template_to_links_map: LinkedHashMap<ast::PolicyID, LinkedHashSet<ast::PolicyID>> =
493            LinkedHashMap::new();
494        for template in &templates {
495            template_to_links_map.insert(template.0.clone(), LinkedHashSet::new());
496        }
497        let mut links: LinkedHashMap<ast::PolicyID, ast::Policy> = LinkedHashMap::new();
498        for p in v.links {
499            // per docs in core.proto, for static policies, `link_id` is omitted/ignored,
500            // and the ID of the policy is the `template_id`.
501            let template_id = ast::PolicyID::from_string(&p.template_id);
502            if p.is_template_link {
503                let policy = reify_template_link(p, &mut link_ids, &templates)?;
504                // The id of the policy is policy id, the underlying template id is template_id
505                template_to_links_map
506                    .entry(template_id)
507                    .or_insert_with(LinkedHashSet::new)
508                    .insert(policy.id().clone());
509                links.insert(policy.id().clone(), policy);
510            } else {
511                let policy = reify_static_policy(p, &mut link_ids, &templates)?;
512                // The policy and the template id are the same, it's template_id
513                template_to_links_map
514                    .entry(template_id.clone())
515                    .or_insert_with(LinkedHashSet::new)
516                    .insert(template_id.clone());
517                links.insert(template_id, policy);
518            }
519        }
520
521        Ok(ast::PolicySet::from_raw_components(
522            templates,
523            links,
524            template_to_links_map,
525        ))
526    }
527}
528
529impl From<&ast::PolicySet> for models::PolicySet {
530    fn from(v: &ast::PolicySet) -> Self {
531        let templates = v.all_templates().map(models::TemplateBody::from).collect();
532        let links = v.policies().map(models::Policy::from).collect();
533        Self { templates, links }
534    }
535}
536
537#[cfg(test)]
538mod test {
539    use std::sync::Arc;
540
541    use super::*;
542    use cool_asserts::assert_matches;
543
544    // We add `PartialOrd` and `Ord` implementations for both `models::Policy` and
545    // `models::TemplateBody`, so that these can be sorted for testing purposes
546    impl PartialOrd for models::Policy {
547        fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
548            Some(self.cmp(other))
549        }
550    }
551    impl Ord for models::Policy {
552        fn cmp(&self, other: &Self) -> std::cmp::Ordering {
553            // assumes that (link-id, template-id) pair is unique, otherwise we're
554            // technically violating `Ord` contract because there could exist two
555            // policies that return `Ordering::Equal` but are not equal with `Eq`
556            self.link_id()
557                .cmp(other.link_id())
558                .then_with(|| self.template_id.cmp(&other.template_id))
559        }
560    }
561    impl Eq for models::TemplateBody {}
562    impl PartialOrd for models::TemplateBody {
563        fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
564            Some(self.cmp(other))
565        }
566    }
567    impl Ord for models::TemplateBody {
568        fn cmp(&self, other: &Self) -> std::cmp::Ordering {
569            // assumes that IDs are unique, otherwise we're technically violating
570            // `Ord` contract because there could exist two template-bodies that
571            // return `Ordering::Equal` but are not equal with `Eq`
572            self.id.cmp(&other.id)
573        }
574    }
575
576    /// A `PrincipalOrResourceConstraint` model matching any principal/resource.
577    fn principal_or_resource_constraint_any() -> models::PrincipalOrResourceConstraint {
578        models::PrincipalOrResourceConstraint {
579            data: Some(models::principal_or_resource_constraint::Data::Any(
580                models::principal_or_resource_constraint::Any::Unit.into(),
581            )),
582        }
583    }
584
585    /// An `ActionConstraint` model matching any action.
586    fn action_constraint_any() -> models::ActionConstraint {
587        models::ActionConstraint {
588            data: Some(models::action_constraint::Data::Any(
589                models::action_constraint::Any::Unit.into(),
590            )),
591        }
592    }
593
594    /// A minimal `TemplateBody` model with the given id and unconstrained scope.
595    fn trivial_template_body(id: &str) -> models::TemplateBody {
596        models::TemplateBody {
597            id: id.to_string(),
598            annotations: Default::default(),
599            effect: models::Effect::Permit.into(),
600            principal_constraint: Some(principal_or_resource_constraint_any()),
601            action_constraint: Some(action_constraint_any()),
602            resource_constraint: Some(principal_or_resource_constraint_any()),
603            non_scope_constraints: None,
604        }
605    }
606
607    /// A `TemplateBody` model with the given id and a `?principal` slot.
608    fn slotted_template_body(id: &str) -> models::TemplateBody {
609        models::TemplateBody {
610            id: id.to_string(),
611            annotations: Default::default(),
612            effect: models::Effect::Permit.into(),
613            principal_constraint: Some(models::PrincipalOrResourceConstraint {
614                data: Some(models::principal_or_resource_constraint::Data::Eq(
615                    models::principal_or_resource_constraint::EqMessage {
616                        er: Some(models::EntityReference {
617                            data: Some(models::entity_reference::Data::Slot(
618                                models::entity_reference::Slot::Unit.into(),
619                            )),
620                        }),
621                    },
622                )),
623            }),
624            action_constraint: Some(action_constraint_any()),
625            resource_constraint: Some(principal_or_resource_constraint_any()),
626            non_scope_constraints: None,
627        }
628    }
629
630    /// A static policy link (not a template-linked policy) with the given id.
631    fn static_policy_link(id: &str) -> models::Policy {
632        models::Policy {
633            template_id: id.to_string(),
634            link_id: None,
635            is_template_link: false,
636            principal_euid: None,
637            resource_euid: None,
638        }
639    }
640
641    /// A template-linked policy with the given template id and link id.
642    fn template_link(template_id: &str, link_id: &str) -> models::Policy {
643        models::Policy {
644            template_id: template_id.to_string(),
645            link_id: Some(link_id.to_string()),
646            is_template_link: true,
647            principal_euid: Some(models::EntityUid {
648                ty: Some(models::Name {
649                    id: "User".to_string(),
650                    path: vec![],
651                }),
652                eid: "alice".to_string(),
653            }),
654            resource_euid: None,
655        }
656    }
657
658    /// Build a templates map with a single template that has a `?principal` slot.
659    fn slotted_templates_map(id: &str) -> LinkedHashMap<ast::PolicyID, Arc<ast::Template>> {
660        let tb = ast::TemplateBody::new(
661            ast::PolicyID::from_string(id),
662            None,
663            ast::Annotations::from_iter([]),
664            ast::Effect::Permit,
665            ast::PrincipalConstraint::is_eq_slot(),
666            ast::ActionConstraint::Any,
667            ast::ResourceConstraint::any(),
668            None,
669        );
670        LinkedHashMap::from_iter([(
671            ast::PolicyID::from_string(id),
672            Arc::new(ast::Template::from(tb)),
673        )])
674    }
675
676    /// Create a template-linked `ast::Policy` with a `?principal` slot bound to
677    /// `entity_type`::`eid`, along with the templates map needed for reification.
678    fn make_linked_policy(
679        template_id: &str,
680        link_id: &str,
681        entity_type: &str,
682        eid: &str,
683    ) -> (
684        ast::Policy,
685        LinkedHashMap<ast::PolicyID, Arc<ast::Template>>,
686    ) {
687        let templates = slotted_templates_map(template_id);
688        let template = templates
689            .get(&ast::PolicyID::from_string(template_id))
690            .unwrap();
691        let policy = ast::Template::link(
692            template.clone(),
693            ast::PolicyID::from_string(link_id),
694            HashMap::from_iter([(
695                ast::SlotId::principal(),
696                ast::EntityUID::with_eid_and_type(entity_type, eid).unwrap(),
697            )]),
698        )
699        .unwrap();
700        (policy, templates)
701    }
702
703    #[test]
704    #[expect(clippy::too_many_lines, reason = "unit test code")]
705    fn policy_roundtrip() {
706        let annotation1 = ast::Annotation {
707            val: "".into(),
708            loc: None,
709        };
710
711        let annotation2 = ast::Annotation {
712            val: "Hello World".into(),
713            loc: None,
714        };
715
716        assert_eq!(
717            ast::Effect::Permit,
718            ast::Effect::from(models::Effect::from(&ast::Effect::Permit))
719        );
720        assert_eq!(
721            ast::Effect::Forbid,
722            ast::Effect::from(models::Effect::from(&ast::Effect::Forbid))
723        );
724
725        let er1 = ast::EntityReference::euid(Arc::new(
726            ast::EntityUID::with_eid_and_type("A", "foo").unwrap(),
727        ));
728        assert_eq!(
729            er1,
730            ast::EntityReference::try_from(models::EntityReference::from(&er1)).unwrap()
731        );
732        assert_eq!(
733            ast::EntityReference::Slot(None),
734            ast::EntityReference::try_from(models::EntityReference::from(
735                &ast::EntityReference::Slot(None)
736            ))
737            .unwrap()
738        );
739
740        let read_euid = Arc::new(ast::EntityUID::with_eid_and_type("Action", "read").unwrap());
741        let write_euid = Arc::new(ast::EntityUID::with_eid_and_type("Action", "write").unwrap());
742        let ac1 = ast::ActionConstraint::Eq(read_euid.clone());
743        let ac2 = ast::ActionConstraint::In(vec![read_euid, write_euid]);
744        assert_eq!(
745            ast::ActionConstraint::Any,
746            ast::ActionConstraint::try_from(models::ActionConstraint::from(
747                &ast::ActionConstraint::Any
748            ))
749            .unwrap()
750        );
751        assert_eq!(
752            ac1,
753            ast::ActionConstraint::try_from(models::ActionConstraint::from(&ac1)).unwrap()
754        );
755        assert_eq!(
756            ac2,
757            ast::ActionConstraint::try_from(models::ActionConstraint::from(&ac2)).unwrap()
758        );
759
760        let euid1 = Arc::new(ast::EntityUID::with_eid_and_type("A", "friend").unwrap());
761        let name1 = Arc::new(ast::EntityType::from(
762            ast::Name::from_normalized_str("B::C::D").unwrap(),
763        ));
764        let prc1 = ast::PrincipalOrResourceConstraint::is_eq(euid1.clone());
765        let prc2 = ast::PrincipalOrResourceConstraint::is_in(euid1.clone());
766        let prc3 = ast::PrincipalOrResourceConstraint::is_entity_type(name1.clone());
767        let prc4 = ast::PrincipalOrResourceConstraint::is_entity_type_in(name1, euid1);
768        assert_eq!(
769            ast::PrincipalOrResourceConstraint::any(),
770            ast::PrincipalOrResourceConstraint::try_from(
771                models::PrincipalOrResourceConstraint::from(
772                    &ast::PrincipalOrResourceConstraint::any()
773                )
774            )
775            .unwrap()
776        );
777        assert_eq!(
778            prc1,
779            ast::PrincipalOrResourceConstraint::try_from(
780                models::PrincipalOrResourceConstraint::from(&prc1)
781            )
782            .unwrap()
783        );
784        assert_eq!(
785            prc2,
786            ast::PrincipalOrResourceConstraint::try_from(
787                models::PrincipalOrResourceConstraint::from(&prc2)
788            )
789            .unwrap()
790        );
791        assert_eq!(
792            prc3,
793            ast::PrincipalOrResourceConstraint::try_from(
794                models::PrincipalOrResourceConstraint::from(&prc3)
795            )
796            .unwrap()
797        );
798        assert_eq!(
799            prc4,
800            ast::PrincipalOrResourceConstraint::try_from(
801                models::PrincipalOrResourceConstraint::from(&prc4)
802            )
803            .unwrap()
804        );
805
806        let pc = ast::PrincipalConstraint::new(prc1);
807        let rc = ast::ResourceConstraint::new(prc3);
808        assert_eq!(
809            pc,
810            ast::PrincipalConstraint::try_from(models::PrincipalOrResourceConstraint::from(&pc))
811                .unwrap()
812        );
813        assert_eq!(
814            rc,
815            ast::ResourceConstraint::try_from(models::PrincipalOrResourceConstraint::from(&rc))
816                .unwrap()
817        );
818
819        assert_eq!(
820            ast::Effect::Permit,
821            ast::Effect::from(models::Effect::from(&ast::Effect::Permit))
822        );
823        assert_eq!(
824            ast::Effect::Forbid,
825            ast::Effect::from(models::Effect::from(&ast::Effect::Forbid))
826        );
827
828        let tb = ast::TemplateBody::new(
829            ast::PolicyID::from_string("template"),
830            None,
831            ast::Annotations::from_iter([
832                (
833                    ast::AnyId::from_normalized_str("read").unwrap(),
834                    annotation1,
835                ),
836                (
837                    ast::AnyId::from_normalized_str("write").unwrap(),
838                    annotation2,
839                ),
840            ]),
841            ast::Effect::Permit,
842            pc.clone(),
843            ac1.clone(),
844            rc.clone(),
845            None,
846        );
847        assert_eq!(
848            tb,
849            ast::TemplateBody::try_from(models::TemplateBody::from(&tb)).unwrap()
850        );
851
852        // Test reify roundtrip: ast::Policy -> models::Policy -> reify
853        let (linked_policy, templates) = make_linked_policy("template", "id", "A", "eid");
854        let model = models::Policy::from(&linked_policy);
855        let mut link_ids = HashSet::new();
856        let roundtripped = reify_template_link(model, &mut link_ids, &templates).unwrap();
857        assert_eq!(linked_policy, roundtripped);
858
859        let tb = ast::TemplateBody::new(
860            ast::PolicyID::from_string("\0\n \' \"+-$^!"),
861            None,
862            ast::Annotations::from_iter([]),
863            ast::Effect::Permit,
864            pc,
865            ac1,
866            rc,
867            None,
868        );
869        assert_eq!(
870            tb,
871            ast::TemplateBody::try_from(models::TemplateBody::from(&tb)).unwrap()
872        );
873
874        // Test reify roundtrip with special characters in IDs
875        let (linked_policy2, templates2) =
876            make_linked_policy("template\0\n \' \"+-$^!", "link\0\n \' \"+-$^!", "A", "eid");
877        let model2 = models::Policy::from(&linked_policy2);
878        let mut link_ids = HashSet::new();
879        let roundtripped2 = reify_template_link(model2, &mut link_ids, &templates2).unwrap();
880        assert_eq!(linked_policy2, roundtripped2);
881    }
882
883    #[test]
884    fn policyset_roundtrip() {
885        let tb = ast::TemplateBody::new(
886            ast::PolicyID::from_string("template"),
887            None,
888            ast::Annotations::from_iter(vec![(
889                ast::AnyId::from_normalized_str("read").unwrap(),
890                ast::Annotation {
891                    val: "".into(),
892                    loc: None,
893                },
894            )]),
895            ast::Effect::Permit,
896            ast::PrincipalConstraint::is_eq_slot(),
897            ast::ActionConstraint::Eq(
898                ast::EntityUID::with_eid_and_type("Action", "read")
899                    .unwrap()
900                    .into(),
901            ),
902            ast::ResourceConstraint::is_entity_type(
903                ast::EntityType::from(ast::Name::from_normalized_str("photo").unwrap()).into(),
904            ),
905            None,
906        );
907
908        let policy1 = ast::Policy::from_when_clause(
909            ast::Effect::Permit,
910            ast::Expr::val(true),
911            ast::PolicyID::from_string("permit-true-trivial"),
912            None,
913        );
914        let policy2 = ast::Policy::from_when_clause(
915            ast::Effect::Forbid,
916            ast::Expr::is_eq(
917                ast::Expr::var(ast::Var::Principal),
918                ast::Expr::val(ast::EntityUID::with_eid_and_type("A", "dog").unwrap()),
919            ),
920            ast::PolicyID::from_string("forbid-dog"),
921            None,
922        );
923
924        let mut ps = ast::PolicySet::new();
925        ps.add_template(ast::Template::from(tb))
926            .expect("Failed to add template to policy set.");
927        ps.add(policy1).expect("Failed to add policy to policy set");
928        ps.add(policy2).expect("Failed to add policy to policy set");
929        ps.link(
930            ast::PolicyID::from_string("template"),
931            ast::PolicyID::from_string("link"),
932            HashMap::from_iter([(
933                ast::SlotId::principal(),
934                ast::EntityUID::with_eid_and_type("A", "friend").unwrap(),
935            )]),
936        )
937        .unwrap();
938        let mut mps = models::PolicySet::from(&ps);
939        let mut mps_roundtrip =
940            models::PolicySet::from(&ast::PolicySet::try_from(mps.clone()).unwrap());
941
942        // we accept permutations as equivalent, so before comparison, we sort
943        // both `.templates` and `.links`
944        mps.templates.sort();
945        mps_roundtrip.templates.sort();
946        mps.links.sort();
947        mps_roundtrip.links.sort();
948
949        // Can't compare `models::PolicySet` directly, so we compare their fields
950        assert_eq!(mps.templates, mps_roundtrip.templates);
951        assert_eq!(mps.links, mps_roundtrip.links);
952    }
953
954    #[test]
955    fn policyset_roundtrip_escapes() {
956        let tb = ast::TemplateBody::new(
957            ast::PolicyID::from_string("template\0\n \' \"+-$^!"),
958            None,
959            ast::Annotations::from_iter(vec![(
960                ast::AnyId::from_normalized_str("read").unwrap(),
961                ast::Annotation {
962                    val: "".into(),
963                    loc: None,
964                },
965            )]),
966            ast::Effect::Permit,
967            ast::PrincipalConstraint::is_eq_slot(),
968            ast::ActionConstraint::Eq(
969                ast::EntityUID::with_eid_and_type("Action", "read")
970                    .unwrap()
971                    .into(),
972            ),
973            ast::ResourceConstraint::is_entity_type(
974                ast::EntityType::from(ast::Name::from_normalized_str("photo").unwrap()).into(),
975            ),
976            None,
977        );
978
979        let policy1 = ast::Policy::from_when_clause(
980            ast::Effect::Permit,
981            ast::Expr::val(true),
982            ast::PolicyID::from_string("permit-true-trivial\0\n \' \"+-$^!"),
983            None,
984        );
985        let policy2 = ast::Policy::from_when_clause(
986            ast::Effect::Forbid,
987            ast::Expr::is_eq(
988                ast::Expr::var(ast::Var::Principal),
989                ast::Expr::val(ast::EntityUID::with_eid_and_type("A", "dog").unwrap()),
990            ),
991            ast::PolicyID::from_string("forbid-dog\0\n \' \"+-$^!"),
992            None,
993        );
994
995        let mut ps = ast::PolicySet::new();
996        ps.add_template(ast::Template::from(tb))
997            .expect("Failed to add template to policy set.");
998        ps.add(policy1).expect("Failed to add policy to policy set");
999        ps.add(policy2).expect("Failed to add policy to policy set");
1000        ps.link(
1001            ast::PolicyID::from_string("template\0\n \' \"+-$^!"),
1002            ast::PolicyID::from_string("link\0\n \' \"+-$^!"),
1003            HashMap::from_iter([(
1004                ast::SlotId::principal(),
1005                ast::EntityUID::with_eid_and_type("A", "friend").unwrap(),
1006            )]),
1007        )
1008        .unwrap();
1009        let mut mps = models::PolicySet::from(&ps);
1010        let mut mps_roundtrip =
1011            models::PolicySet::from(&ast::PolicySet::try_from(mps.clone()).unwrap());
1012
1013        // we accept permutations as equivalent, so before comparison, we sort
1014        // both `.templates` and `.links`
1015        mps.templates.sort();
1016        mps_roundtrip.templates.sort();
1017        mps.links.sort();
1018        mps_roundtrip.links.sort();
1019
1020        // Can't compare `models::PolicySet` directly, so we compare their fields
1021        assert_eq!(mps.templates, mps_roundtrip.templates);
1022        assert_eq!(mps.links, mps_roundtrip.links);
1023    }
1024
1025    #[test]
1026    fn template_body_try_from_missing_principal_constraint() {
1027        let bad = models::TemplateBody {
1028            id: "t".to_string(),
1029            annotations: Default::default(),
1030            effect: models::Effect::Permit.into(),
1031            principal_constraint: None,
1032            action_constraint: Some(action_constraint_any()),
1033            resource_constraint: Some(principal_or_resource_constraint_any()),
1034            non_scope_constraints: None,
1035        };
1036        assert_matches!(
1037            ast::TemplateBody::try_from(bad),
1038            Err(ProtobufConversionError::MissingField(f)) if f == "principal_constraint"
1039        );
1040    }
1041
1042    #[test]
1043    fn template_body_try_from_invalid_annotation_key() {
1044        let bad = models::TemplateBody {
1045            id: "t".to_string(),
1046            annotations: [("".to_string(), "v".to_string())].into_iter().collect(),
1047            effect: models::Effect::Permit.into(),
1048            principal_constraint: Some(principal_or_resource_constraint_any()),
1049            action_constraint: Some(action_constraint_any()),
1050            resource_constraint: Some(principal_or_resource_constraint_any()),
1051            non_scope_constraints: None,
1052        };
1053        assert_matches!(
1054            ast::TemplateBody::try_from(bad),
1055            Err(ProtobufConversionError::InvalidValue(msg)) if msg.contains("invalid annotation key")
1056        );
1057    }
1058
1059    #[test]
1060    fn entity_reference_try_from_missing_data() {
1061        let bad = models::EntityReference { data: None };
1062        assert_matches!(
1063            ast::EntityReference::try_from(bad),
1064            Err(ProtobufConversionError::MissingField(f)) if f == "data"
1065        );
1066    }
1067
1068    #[test]
1069    fn principal_or_resource_constraint_try_from_missing_data() {
1070        let bad = models::PrincipalOrResourceConstraint { data: None };
1071        assert_matches!(
1072            ast::PrincipalOrResourceConstraint::try_from(bad),
1073            Err(ProtobufConversionError::MissingField(f)) if f == "data"
1074        );
1075    }
1076
1077    #[test]
1078    fn principal_or_resource_constraint_try_from_in_missing_er() {
1079        let bad = models::PrincipalOrResourceConstraint {
1080            data: Some(models::principal_or_resource_constraint::Data::In(
1081                models::principal_or_resource_constraint::InMessage { er: None },
1082            )),
1083        };
1084        assert_matches!(
1085            ast::PrincipalOrResourceConstraint::try_from(bad),
1086            Err(ProtobufConversionError::MissingField(f)) if f == "er"
1087        );
1088    }
1089
1090    #[test]
1091    fn principal_or_resource_constraint_try_from_eq_missing_er() {
1092        let bad = models::PrincipalOrResourceConstraint {
1093            data: Some(models::principal_or_resource_constraint::Data::Eq(
1094                models::principal_or_resource_constraint::EqMessage { er: None },
1095            )),
1096        };
1097        assert_matches!(
1098            ast::PrincipalOrResourceConstraint::try_from(bad),
1099            Err(ProtobufConversionError::MissingField(f)) if f == "er"
1100        );
1101    }
1102
1103    #[test]
1104    fn principal_or_resource_constraint_try_from_is_missing_entity_type() {
1105        let bad = models::PrincipalOrResourceConstraint {
1106            data: Some(models::principal_or_resource_constraint::Data::Is(
1107                models::principal_or_resource_constraint::IsMessage { entity_type: None },
1108            )),
1109        };
1110        assert_matches!(
1111            ast::PrincipalOrResourceConstraint::try_from(bad),
1112            Err(ProtobufConversionError::MissingField(f)) if f == "entity_type"
1113        );
1114    }
1115
1116    #[test]
1117    fn principal_or_resource_constraint_try_from_is_in_missing_entity_type() {
1118        let bad = models::PrincipalOrResourceConstraint {
1119            data: Some(models::principal_or_resource_constraint::Data::IsIn(
1120                models::principal_or_resource_constraint::IsInMessage {
1121                    entity_type: None,
1122                    er: None,
1123                },
1124            )),
1125        };
1126        assert_matches!(
1127            ast::PrincipalOrResourceConstraint::try_from(bad),
1128            Err(ProtobufConversionError::MissingField(f)) if f == "entity_type"
1129        );
1130    }
1131
1132    #[test]
1133    fn action_constraint_try_from_missing_data() {
1134        let bad = models::ActionConstraint { data: None };
1135        assert_matches!(
1136            ast::ActionConstraint::try_from(bad),
1137            Err(ProtobufConversionError::MissingField(f)) if f == "data"
1138        );
1139    }
1140
1141    #[test]
1142    fn action_constraint_try_from_eq_missing_euid() {
1143        let bad = models::ActionConstraint {
1144            data: Some(models::action_constraint::Data::Eq(
1145                models::action_constraint::EqMessage { euid: None },
1146            )),
1147        };
1148        assert_matches!(
1149            ast::ActionConstraint::try_from(bad),
1150            Err(ProtobufConversionError::MissingField(f)) if f == "euid"
1151        );
1152    }
1153
1154    #[test]
1155    fn reify_rejects_template_link_missing_link_id() {
1156        let bad = models::Policy {
1157            template_id: "t".to_string(),
1158            link_id: None,
1159            is_template_link: true,
1160            principal_euid: None,
1161            resource_euid: None,
1162        };
1163        let templates = LinkedHashMap::from_iter([(
1164            ast::PolicyID::from_string("t"),
1165            Arc::new(ast::Template::from(
1166                ast::TemplateBody::try_from(trivial_template_body("t")).unwrap(),
1167            )),
1168        )]);
1169        let mut link_ids = HashSet::new();
1170        assert_matches!(
1171            reify_template_link(bad, &mut link_ids, &templates),
1172            Err(ProtobufConversionError::MissingField(f)) if f == "link_id"
1173        );
1174    }
1175
1176    #[test]
1177    fn reify_rejects_link_with_wrong_slots() {
1178        // Template has ?principal slot, but we provide ?resource instead
1179        let templates = slotted_templates_map("t");
1180        let bad = models::Policy {
1181            template_id: "t".to_string(),
1182            link_id: Some("link".to_string()),
1183            is_template_link: true,
1184            principal_euid: None,
1185            resource_euid: Some(models::EntityUid {
1186                ty: Some(models::Name {
1187                    id: "User".to_string(),
1188                    path: vec![],
1189                }),
1190                eid: "alice".to_string(),
1191            }),
1192        };
1193        let mut link_ids = HashSet::new();
1194        assert_matches!(
1195            reify_template_link(bad, &mut link_ids, &templates),
1196            Err(ProtobufConversionError::InvalidValue(msg)) if msg.contains("failed to convert to policy")
1197        );
1198    }
1199
1200    #[test]
1201    fn reify_rejects_static_policy_from_slotted_template() {
1202        // Template has ?principal slot, but we try to use it as a static policy
1203        let templates = slotted_templates_map("t");
1204        let bad = models::Policy {
1205            template_id: "t".to_string(),
1206            link_id: None,
1207            is_template_link: false,
1208            principal_euid: None,
1209            resource_euid: None,
1210        };
1211        let mut link_ids = HashSet::new();
1212        assert_matches!(
1213            reify_static_policy(bad, &mut link_ids, &templates),
1214            Err(ProtobufConversionError::InvalidValue(msg)) if msg.contains("failed to convert a static policy")
1215        );
1216    }
1217
1218    #[test]
1219    fn template_body_try_from_missing_action_constraint() {
1220        let bad = models::TemplateBody {
1221            action_constraint: None,
1222            ..trivial_template_body("t")
1223        };
1224        assert_matches!(
1225            ast::TemplateBody::try_from(bad),
1226            Err(ProtobufConversionError::MissingField(f)) if f == "action_constraint"
1227        );
1228    }
1229
1230    #[test]
1231    fn template_body_try_from_missing_resource_constraint() {
1232        let bad = models::TemplateBody {
1233            resource_constraint: None,
1234            ..trivial_template_body("t")
1235        };
1236        assert_matches!(
1237            ast::TemplateBody::try_from(bad),
1238            Err(ProtobufConversionError::MissingField(f)) if f == "resource_constraint"
1239        );
1240    }
1241
1242    #[test]
1243    fn action_constraint_try_from_in_with_invalid_euid() {
1244        let bad = models::ActionConstraint {
1245            data: Some(models::action_constraint::Data::In(
1246                models::action_constraint::InMessage {
1247                    euids: vec![models::EntityUid {
1248                        ty: None,
1249                        eid: "read".to_string(),
1250                    }],
1251                },
1252            )),
1253        };
1254        assert_matches!(
1255            ast::ActionConstraint::try_from(bad),
1256            Err(ProtobufConversionError::MissingField(f)) if f == "ty"
1257        );
1258    }
1259
1260    #[test]
1261    fn literal_policy_set_try_from_link_missing_link_id() {
1262        let bad = models::PolicySet {
1263            templates: vec![trivial_template_body("t")],
1264            links: vec![models::Policy {
1265                template_id: "t".to_string(),
1266                link_id: None,
1267                is_template_link: true,
1268                principal_euid: None,
1269                resource_euid: None,
1270            }],
1271        };
1272        assert_matches!(
1273            ast::PolicySet::try_from(bad),
1274            Err(ProtobufConversionError::MissingField(f)) if f == "link_id"
1275        );
1276    }
1277
1278    #[test]
1279    fn literal_policy_set_rejects_duplicate_template_ids() {
1280        let template = trivial_template_body("duplicate");
1281        let bad = models::PolicySet {
1282            templates: vec![template.clone(), template], // twice!
1283            links: vec![],
1284        };
1285        assert_matches!(
1286            ast::PolicySet::try_from(bad),
1287            Err(ProtobufConversionError::InvalidValue(msg)) if msg.contains("duplicate template_id")
1288        );
1289    }
1290
1291    #[test]
1292    fn literal_policy_set_rejects_duplicate_link_ids() {
1293        let bad = models::PolicySet {
1294            templates: vec![trivial_template_body("duplicate")],
1295            links: vec![
1296                static_policy_link("duplicate"),
1297                static_policy_link("duplicate"),
1298            ],
1299        };
1300        assert_matches!(
1301            ast::PolicySet::try_from(bad),
1302            Err(ProtobufConversionError::InvalidValue(msg)) if msg.contains("conflicts with a policy id")
1303        );
1304    }
1305
1306    #[test]
1307    fn literal_policy_set_rejects_template_link_id_colliding_with_template_id() {
1308        let bad = models::PolicySet {
1309            templates: vec![trivial_template_body("shared_id")],
1310            links: vec![template_link("shared_id", "shared_id")],
1311        };
1312        assert_matches!(
1313            ast::PolicySet::try_from(bad),
1314            Err(ProtobufConversionError::InvalidValue(msg)) if msg.contains("conflicts with a template_id")
1315        );
1316    }
1317
1318    #[test]
1319    fn literal_policy_set_allows_static_policy_sharing_template_and_link_id() {
1320        // Static policies are expected to have the same ID in both templates and links
1321        let pset = models::PolicySet {
1322            templates: vec![trivial_template_body("static_policy")],
1323            links: vec![static_policy_link("static_policy")],
1324        };
1325        // This should succeed — static policies legitimately share an ID
1326        // between their template entry and link entry
1327        assert_matches!(ast::PolicySet::try_from(pset), Ok(_));
1328    }
1329
1330    #[test]
1331    fn literal_policy_set_rejects_link_referencing_nonexistent_template() {
1332        let bad = models::PolicySet {
1333            templates: vec![trivial_template_body("real_template")],
1334            links: vec![static_policy_link("nonexistent")],
1335        };
1336        assert_matches!(
1337            ast::PolicySet::try_from(bad),
1338            Err(ProtobufConversionError::InvalidValue(msg)) if msg.contains("no such template")
1339        );
1340    }
1341
1342    #[test]
1343    fn literal_policy_set_rejects_duplicate_template_link_ids() {
1344        let bad = models::PolicySet {
1345            templates: vec![slotted_template_body("t")],
1346            links: vec![
1347                template_link("t", "same_link"),
1348                template_link("t", "same_link"),
1349            ],
1350        };
1351        assert_matches!(
1352            ast::PolicySet::try_from(bad),
1353            Err(ProtobufConversionError::InvalidValue(msg)) if msg.contains("conflicts with a policy id")
1354        );
1355    }
1356
1357    #[test]
1358    fn literal_policy_set_rejects_template_link_id_colliding_with_static_policy() {
1359        // Static policy "X" is processed first (link_ids gets "X"),
1360        // then a template-link with link_id "X" should fail.
1361        let bad = models::PolicySet {
1362            templates: vec![trivial_template_body("X"), trivial_template_body("T")],
1363            links: vec![static_policy_link("X"), template_link("T", "X")],
1364        };
1365        assert_matches!(
1366            ast::PolicySet::try_from(bad),
1367            Err(ProtobufConversionError::InvalidValue(msg)) if msg.contains("conflicts with a policy id")
1368        );
1369    }
1370
1371    #[test]
1372    fn literal_policy_set_rejects_static_policy_colliding_with_template_link_id() {
1373        // Template-link with link_id "X" where "X" is also a template_id.
1374        // The link_id-vs-template_id check catches this regardless of ordering
1375        // with static policies, because a static policy's ID is always a template_id.
1376        let bad = models::PolicySet {
1377            templates: vec![slotted_template_body("T"), trivial_template_body("X")],
1378            links: vec![template_link("T", "X"), static_policy_link("X")],
1379        };
1380        assert_matches!(
1381            ast::PolicySet::try_from(bad),
1382            Err(ProtobufConversionError::InvalidValue(msg)) if msg.contains("conflicts with a template_id")
1383        );
1384    }
1385
1386    // Tests with realistic policy sets containing multiple valid entries alongside
1387    // the problematic one, to ensure validation works in the presence of noise.
1388
1389    #[test]
1390    fn noisy_policy_set_rejects_duplicate_template_ids() {
1391        let bad = models::PolicySet {
1392            templates: vec![
1393                trivial_template_body("ok1"),
1394                slotted_template_body("ok2"),
1395                trivial_template_body("duplicate"),
1396                trivial_template_body("duplicate"),
1397            ],
1398            links: vec![static_policy_link("ok1"), template_link("ok2", "link1")],
1399        };
1400        assert_matches!(
1401            ast::PolicySet::try_from(bad),
1402            Err(ProtobufConversionError::InvalidValue(msg)) if msg.contains("duplicate template_id")
1403        );
1404    }
1405
1406    #[test]
1407    fn noisy_policy_set_rejects_duplicate_link_ids() {
1408        let bad = models::PolicySet {
1409            templates: vec![
1410                trivial_template_body("t1"),
1411                trivial_template_body("t2"),
1412                slotted_template_body("t3"),
1413            ],
1414            links: vec![
1415                static_policy_link("t1"),
1416                template_link("t3", "link_ok"),
1417                static_policy_link("t2"),
1418                static_policy_link("t2"), // duplicate
1419            ],
1420        };
1421        assert_matches!(
1422            ast::PolicySet::try_from(bad),
1423            Err(ProtobufConversionError::InvalidValue(msg)) if msg.contains("conflicts with a policy id")
1424        );
1425    }
1426
1427    #[test]
1428    fn noisy_policy_set_rejects_template_link_id_colliding_with_template_id() {
1429        let bad = models::PolicySet {
1430            templates: vec![
1431                trivial_template_body("t1"),
1432                slotted_template_body("t2"),
1433                trivial_template_body("collide"),
1434            ],
1435            links: vec![
1436                static_policy_link("t1"),
1437                template_link("t2", "link_ok"),
1438                template_link("t2", "collide"), // link_id == template_id "collide"
1439            ],
1440        };
1441        assert_matches!(
1442            ast::PolicySet::try_from(bad),
1443            Err(ProtobufConversionError::InvalidValue(msg)) if msg.contains("conflicts with a template_id")
1444        );
1445    }
1446
1447    #[test]
1448    fn noisy_policy_set_rejects_nonexistent_template() {
1449        let bad = models::PolicySet {
1450            templates: vec![trivial_template_body("t1"), slotted_template_body("t2")],
1451            links: vec![
1452                static_policy_link("t1"),
1453                template_link("t2", "link_ok"),
1454                static_policy_link("ghost"), // references nonexistent template
1455            ],
1456        };
1457        assert_matches!(
1458            ast::PolicySet::try_from(bad),
1459            Err(ProtobufConversionError::InvalidValue(msg)) if msg.contains("no such template")
1460        );
1461    }
1462
1463    #[test]
1464    fn noisy_policy_set_valid_with_multiple_templates_and_links() {
1465        let pset = models::PolicySet {
1466            templates: vec![
1467                trivial_template_body("static1"),
1468                trivial_template_body("static2"),
1469                slotted_template_body("tmpl1"),
1470                slotted_template_body("tmpl2"),
1471            ],
1472            links: vec![
1473                static_policy_link("static1"),
1474                static_policy_link("static2"),
1475                template_link("tmpl1", "link1"),
1476                template_link("tmpl1", "link2"),
1477                template_link("tmpl2", "link3"),
1478            ],
1479        };
1480        let ps = ast::PolicySet::try_from(pset).unwrap();
1481        assert_eq!(ps.all_templates().count(), 4);
1482        assert_eq!(ps.policies().count(), 5);
1483    }
1484}