Skip to main content

alien_permissions/generators/
gcp_runtime.rs

1use crate::{
2    error::{ErrorData, Result},
3    generators::labels::{
4        entry_description, entry_snake_label, entry_title_label, has_explicit_label,
5    },
6    variables::VariableInterpolator,
7    BindingTarget, PermissionContext,
8};
9use alien_core::{GcpBindingSpec, PermissionGrant, PermissionSet};
10use serde::{Deserialize, Serialize};
11
12const GCP_CUSTOM_ROLE_ID_MAX_LEN: usize = 64;
13const ROLE_ID_HASH_LEN: usize = 8;
14
15/// GCP IAM binding condition.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "camelCase")]
18pub struct GcpIamCondition {
19    /// Human-readable condition title.
20    pub title: String,
21    /// Description of the condition.
22    pub description: String,
23    /// CEL expression for the condition.
24    pub expression: String,
25}
26
27/// GCP custom role definition.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "camelCase")]
30pub struct GcpCustomRole {
31    /// GCP role ID.
32    pub role_id: String,
33    /// Fully-qualified role name.
34    pub name: String,
35    /// Human-readable title.
36    pub title: String,
37    /// Role description.
38    pub description: String,
39    /// Permissions included in the custom role.
40    pub included_permissions: Vec<String>,
41    /// Role launch stage.
42    pub stage: String,
43}
44
45/// Scope where a GCP IAM role binding should be applied.
46#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
47#[serde(rename_all = "camelCase")]
48pub enum GcpBindingTargetScope {
49    /// Bind the role on the target project.
50    Project,
51    /// Bind the role on the current resource IAM policy.
52    CurrentResource,
53}
54
55/// Resource family for current-resource IAM bindings that need provider-specific routing.
56#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "camelCase")]
58pub enum GcpBindingResourceKind {
59    /// Pub/Sub topic IAM policy.
60    PubsubTopic,
61    /// Pub/Sub subscription IAM policy.
62    PubsubSubscription,
63    /// Artifact Registry repository IAM policy.
64    ArtifactRegistryRepository,
65}
66
67/// GCP IAM policy binding.
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "camelCase")]
70pub struct GcpIamBinding {
71    /// Role to bind to members.
72    pub role: String,
73    /// List of members (users, service accounts, groups).
74    pub members: Vec<String>,
75    /// IAM policy scope where this role should be bound.
76    pub target: GcpBindingTargetScope,
77    /// Resource family for current-resource IAM policy routing.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub resource_kind: Option<GcpBindingResourceKind>,
80    /// Optional condition for conditional IAM.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub condition: Option<GcpIamCondition>,
83}
84
85/// GCP IAM bindings wrapper.
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
87#[serde(rename_all = "camelCase")]
88pub struct GcpIamBindings {
89    /// List of IAM bindings.
90    pub bindings: Vec<GcpIamBinding>,
91}
92
93/// GCP grant plan generated from a permission set.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(rename_all = "camelCase")]
96pub struct GcpGrantPlan {
97    /// Predefined and generated custom-role bindings.
98    pub bindings: Vec<GcpIamBinding>,
99    /// Residual custom roles that must exist before their bindings are applied.
100    pub custom_roles: Vec<GcpCustomRole>,
101}
102
103impl GcpGrantPlan {
104    /// Return IAM bindings selected for one binding target scope.
105    pub fn bindings_for_target(&self, target: GcpBindingTargetScope) -> Vec<GcpIamBinding> {
106        self.bindings
107            .iter()
108            .filter(|binding| binding.target == target)
109            .cloned()
110            .collect()
111    }
112
113    /// Return only the custom roles referenced by `bindings`.
114    pub fn custom_roles_for_bindings(&self, bindings: &[GcpIamBinding]) -> Vec<GcpCustomRole> {
115        self.custom_roles
116            .iter()
117            .filter(|custom_role| {
118                bindings
119                    .iter()
120                    .any(|binding| binding.role == custom_role.name)
121            })
122            .cloned()
123            .collect()
124    }
125}
126
127/// GCP custom-role planner.
128pub struct GcpRuntimePermissionsGenerator;
129
130impl GcpRuntimePermissionsGenerator {
131    /// Create a new GCP runtime permissions generator.
132    pub fn new() -> Self {
133        Self
134    }
135
136    /// Generate a custom role from a permission set.
137    ///
138    /// GCP uses project custom roles for exact permission-set semantics. The
139    /// role ID is derived from the deployment namespace and permission-set ID,
140    /// so different service accounts in the same deployment share one role per
141    /// permission-set entry without sharing roles across deployments.
142    pub fn generate_custom_role(
143        &self,
144        permission_set: &PermissionSet,
145        context: &PermissionContext,
146    ) -> Result<GcpCustomRole> {
147        let roles = self.generate_custom_roles(permission_set, context)?;
148        if roles.len() == 1 {
149            return Ok(roles.into_iter().next().expect("single role"));
150        }
151        if roles.is_empty() {
152            return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
153                platform: "gcp".to_string(),
154                message: format!(
155                    "GCP permission set '{}' has no residual custom role",
156                    permission_set.id
157                ),
158            }));
159        }
160
161        Err(alien_error::AlienError::new(ErrorData::GeneratorError {
162            platform: "gcp".to_string(),
163            message: format!(
164                "GCP permission set '{}' generates multiple custom roles; use generate_custom_roles() to preserve binding scopes",
165                permission_set.id
166            ),
167        }))
168    }
169
170    /// Generate one custom role per unique GCP permission entry.
171    ///
172    /// Permission-set JSONC can split GCP permissions into multiple entries
173    /// when some permissions must be bound at project scope and others at a
174    /// resource scope. Keeping those entries as separate custom roles prevents
175    /// project-scoped helper permissions from broadening resource permissions,
176    /// and vice versa.
177    pub fn generate_custom_roles(
178        &self,
179        permission_set: &PermissionSet,
180        context: &PermissionContext,
181    ) -> Result<Vec<GcpCustomRole>> {
182        let gcp_platform_permissions = permission_set.platforms.gcp.as_ref().ok_or_else(|| {
183            alien_error::AlienError::new(ErrorData::PlatformNotSupported {
184                platform: "gcp".to_string(),
185                permission_set_id: permission_set.id.clone(),
186            })
187        })?;
188
189        if gcp_platform_permissions.is_empty() {
190            return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
191                platform: "gcp".to_string(),
192                message: format!(
193                    "GCP permission set '{}' has no platform entries",
194                    permission_set.id
195                ),
196            }));
197        }
198
199        let mut roles: Vec<GcpCustomRole> = Vec::new();
200        let residual_entry_count = gcp_platform_permissions
201            .iter()
202            .filter(|entry| gcp_residual_permissions(&entry.grant).is_some_and(|p| !p.is_empty()))
203            .count();
204        let has_multiple_entries = residual_entry_count > 1;
205        for (index, platform_permission) in gcp_platform_permissions.iter().enumerate() {
206            validate_gcp_grant(permission_set, index, &platform_permission.grant)?;
207
208            let Some(permissions) = gcp_residual_permissions(&platform_permission.grant) else {
209                continue;
210            };
211            if permissions.is_empty() {
212                continue;
213            }
214            let role = self.custom_role_for_permissions(
215                permission_set,
216                permissions.clone(),
217                context,
218                role_suffix(
219                    &platform_permission.grant,
220                    platform_permission.label.as_deref(),
221                    has_multiple_entries,
222                ),
223                platform_permission.label.as_deref(),
224                platform_permission.description.as_deref(),
225            )?;
226            if !roles
227                .iter()
228                .any(|existing| existing.role_id == role.role_id)
229            {
230                roles.push(role);
231            }
232        }
233
234        Ok(roles)
235    }
236
237    fn custom_role_for_permissions(
238        &self,
239        permission_set: &PermissionSet,
240        mut included_permissions: Vec<String>,
241        context: &PermissionContext,
242        suffix: Option<String>,
243        explicit_label: Option<&str>,
244        entry_description_override: Option<&str>,
245    ) -> Result<GcpCustomRole> {
246        included_permissions.sort();
247        included_permissions.dedup();
248
249        let project = context.project_name.as_deref().unwrap_or("PROJECT_NAME");
250        let role_id = generate_role_id(permission_set, context, suffix.as_deref(), explicit_label);
251        let role_name = format!("projects/{project}/roles/{role_id}");
252
253        Ok(GcpCustomRole {
254            role_id: role_id.clone(),
255            name: role_name,
256            title: custom_role_title(permission_set, context, suffix.as_deref(), explicit_label),
257            description: custom_role_description(
258                permission_set,
259                context,
260                entry_description_override,
261            ),
262            included_permissions,
263            stage: "GA".to_string(),
264        })
265    }
266
267    /// Generate IAM bindings from a permission set and binding target.
268    pub fn generate_bindings(
269        &self,
270        permission_set: &PermissionSet,
271        binding_target: BindingTarget,
272        context: &PermissionContext,
273    ) -> Result<GcpIamBindings> {
274        Ok(GcpIamBindings {
275            bindings: self
276                .generate_grant_plan(permission_set, binding_target, context)?
277                .bindings,
278        })
279    }
280
281    /// Generate the full GCP grant plan from a permission set and binding target.
282    pub fn generate_grant_plan(
283        &self,
284        permission_set: &PermissionSet,
285        binding_target: BindingTarget,
286        context: &PermissionContext,
287    ) -> Result<GcpGrantPlan> {
288        let gcp_platform_permissions = permission_set.platforms.gcp.as_ref().ok_or_else(|| {
289            alien_error::AlienError::new(ErrorData::PlatformNotSupported {
290                platform: "gcp".to_string(),
291                permission_set_id: permission_set.id.clone(),
292            })
293        })?;
294
295        if gcp_platform_permissions.is_empty() {
296            return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
297                platform: "gcp".to_string(),
298                message: format!(
299                    "GCP permission set '{}' has no platform entries",
300                    permission_set.id
301                ),
302            }));
303        }
304
305        let project = context.project_name.as_deref().unwrap_or("PROJECT_NAME");
306        let service_account = format!(
307            "serviceAccount:{}@{}.iam.gserviceaccount.com",
308            context
309                .service_account_name
310                .as_deref()
311                .unwrap_or("SERVICE_ACCOUNT"),
312            project
313        );
314
315        let mut bindings = Vec::new();
316        let mut custom_roles = Vec::new();
317        let residual_entry_count = gcp_platform_permissions
318            .iter()
319            .filter(|entry| gcp_residual_permissions(&entry.grant).is_some_and(|p| !p.is_empty()))
320            .count();
321        let has_multiple_entries = residual_entry_count > 1;
322        for (index, platform_permission) in gcp_platform_permissions.iter().enumerate() {
323            validate_gcp_grant(permission_set, index, &platform_permission.grant)?;
324
325            let binding_spec = match binding_target {
326                BindingTarget::Stack => platform_permission.binding.stack.as_ref(),
327                BindingTarget::Resource => platform_permission.binding.resource.as_ref(),
328            };
329
330            let Some(binding_spec) = binding_spec else {
331                continue;
332            };
333
334            let target = binding_target_scope(binding_spec);
335            let resource_kind = binding_resource_kind(binding_spec);
336            let condition = self.binding_condition(binding_spec, context)?;
337
338            if let Some(predefined_roles) = &platform_permission.grant.predefined_roles {
339                for predefined_role in predefined_roles {
340                    bindings.push(GcpIamBinding {
341                        role: predefined_role.clone(),
342                        members: vec![service_account.clone()],
343                        target,
344                        resource_kind,
345                        condition: condition.clone(),
346                    });
347                }
348            }
349
350            let Some(permissions) = gcp_residual_permissions(&platform_permission.grant) else {
351                continue;
352            };
353            if permissions.is_empty() {
354                continue;
355            }
356
357            let custom_role = self.custom_role_for_permissions(
358                permission_set,
359                permissions.clone(),
360                context,
361                role_suffix(
362                    &platform_permission.grant,
363                    platform_permission.label.as_deref(),
364                    has_multiple_entries,
365                ),
366                platform_permission.label.as_deref(),
367                platform_permission.description.as_deref(),
368            )?;
369            bindings.push(GcpIamBinding {
370                role: custom_role.name.clone(),
371                members: vec![service_account.clone()],
372                target,
373                resource_kind,
374                condition,
375            });
376            if !custom_roles
377                .iter()
378                .any(|existing: &GcpCustomRole| existing.role_id == custom_role.role_id)
379            {
380                custom_roles.push(custom_role);
381            }
382        }
383
384        Ok(GcpGrantPlan {
385            bindings: dedupe_bindings(bindings),
386            custom_roles,
387        })
388    }
389
390    fn binding_condition(
391        &self,
392        binding_spec: &GcpBindingSpec,
393        context: &PermissionContext,
394    ) -> Result<Option<GcpIamCondition>> {
395        let Some(gcp_condition) = binding_spec.condition.as_ref() else {
396            return Ok(None);
397        };
398
399        let interpolated = self.interpolate_condition(gcp_condition, context)?;
400        Ok(Some(GcpIamCondition {
401            title: interpolated.title.clone(),
402            description: format!("Limit to {}", interpolated.title),
403            expression: interpolated.expression,
404        }))
405    }
406
407    /// Interpolate variables in a GCP condition.
408    fn interpolate_condition(
409        &self,
410        condition: &alien_core::GcpCondition,
411        context: &PermissionContext,
412    ) -> Result<alien_core::GcpCondition> {
413        let interpolated_title =
414            VariableInterpolator::interpolate_variables(&condition.title, context)?;
415        let interpolated_expression =
416            VariableInterpolator::interpolate_variables(&condition.expression, context)?;
417
418        Ok(alien_core::GcpCondition {
419            title: interpolated_title,
420            expression: interpolated_expression,
421        })
422    }
423}
424
425fn gcp_residual_permissions(grant: &PermissionGrant) -> Option<&Vec<String>> {
426    grant
427        .residual_permissions
428        .as_ref()
429        .or(grant.permissions.as_ref())
430}
431
432fn validate_gcp_grant(
433    permission_set: &PermissionSet,
434    index: usize,
435    grant: &PermissionGrant,
436) -> Result<()> {
437    if let Some(predefined_roles) = &grant.predefined_roles {
438        if predefined_roles.is_empty() {
439            return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
440                platform: "gcp".to_string(),
441                message: format!(
442                    "GCP permission set '{}' entry {} has an empty predefinedRoles list",
443                    permission_set.id, index
444                ),
445            }));
446        }
447        for role in predefined_roles {
448            if !role.starts_with("roles/") {
449                return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
450                    platform: "gcp".to_string(),
451                    message: format!(
452                        "GCP permission set '{}' entry {} has invalid predefined role '{}'",
453                        permission_set.id, index, role
454                    ),
455                }));
456            }
457        }
458    }
459
460    if let Some(permissions) = gcp_residual_permissions(grant) {
461        if permissions.is_empty() {
462            return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
463                platform: "gcp".to_string(),
464                message: format!(
465                    "GCP permission set '{}' entry {} has an empty permissions list",
466                    permission_set.id, index
467                ),
468            }));
469        }
470    }
471
472    if grant.predefined_roles.is_none() && gcp_residual_permissions(grant).is_none() {
473        return Err(alien_error::AlienError::new(ErrorData::GeneratorError {
474            platform: "gcp".to_string(),
475            message: format!(
476                "GCP permission set '{}' entry {} has no permissions",
477                permission_set.id, index
478            ),
479        }));
480    }
481
482    Ok(())
483}
484
485fn generate_role_id(
486    permission_set: &PermissionSet,
487    context: &PermissionContext,
488    suffix: Option<&str>,
489    explicit_label: Option<&str>,
490) -> String {
491    let namespace = custom_role_namespace(context);
492    if has_explicit_label(explicit_label) {
493        let suffix = suffix.unwrap_or("custom");
494        let prefix = format!("role_{namespace}_");
495        let suffix = fit_role_suffix(suffix, GCP_CUSTOM_ROLE_ID_MAX_LEN - prefix.len());
496        return format!("{prefix}{suffix}");
497    }
498
499    let permission_set_slug = sanitize_role_segment(&permission_set.id.replace('/', "_"), 28);
500    match suffix {
501        Some(suffix) => {
502            let prefix = format!("role_{namespace}_{permission_set_slug}_");
503            let suffix = fit_role_suffix(suffix, GCP_CUSTOM_ROLE_ID_MAX_LEN - prefix.len());
504            format!("{prefix}{suffix}")
505        }
506        None => format!("role_{namespace}_{permission_set_slug}"),
507    }
508}
509
510/// Return the project custom-role prefix for all roles owned by this stack.
511pub fn custom_role_prefix(context: &PermissionContext) -> String {
512    format!("role_{}_", custom_role_namespace(context))
513}
514
515/// Return the project custom-role prefix for one permission set in this stack.
516pub fn custom_role_permission_set_prefix(
517    permission_set_id: &str,
518    context: &PermissionContext,
519) -> String {
520    let permission_set_slug = sanitize_role_segment(&permission_set_id.replace('/', "_"), 28);
521    format!("{}{permission_set_slug}", custom_role_prefix(context))
522}
523
524fn custom_role_namespace(context: &PermissionContext) -> String {
525    sanitize_role_segment(context.stack_prefix.as_deref().unwrap_or("stack"), 18)
526}
527
528fn custom_role_title(
529    permission_set: &PermissionSet,
530    context: &PermissionContext,
531    suffix: Option<&str>,
532    explicit_label: Option<&str>,
533) -> String {
534    let deployment_name = context.deployment_name.as_deref();
535    if has_explicit_label(explicit_label) {
536        let title = entry_title_label(explicit_label, &PermissionGrant::default());
537        return role_title(deployment_name, &title);
538    }
539
540    let label = permission_set_display_label(&permission_set.id);
541    let title = match suffix {
542        Some(suffix) => format!(
543            "{label} - {}",
544            entry_title_label(Some(suffix), &PermissionGrant::default())
545        ),
546        None => label,
547    };
548    role_title(deployment_name, &title)
549}
550
551fn custom_role_description(
552    permission_set: &PermissionSet,
553    context: &PermissionContext,
554    entry_description_override: Option<&str>,
555) -> String {
556    let stack_prefix = context.stack_prefix.as_deref().unwrap_or("unknown");
557    let description = entry_description(entry_description_override, &permission_set.description);
558    let description = description.trim_end_matches('.');
559    match context.deployment_name.as_deref() {
560        Some(deployment_name) if !deployment_name.trim().is_empty() => {
561            format!("Used by {deployment_name}. {description}. Resource prefix: {stack_prefix}.")
562        }
563        _ => format!("{description}. Resource prefix: {stack_prefix}."),
564    }
565}
566
567fn role_title(deployment_name: Option<&str>, title: &str) -> String {
568    match deployment_name {
569        Some(deployment_name) if !deployment_name.trim().is_empty() => {
570            format!("{deployment_name}: {title}")
571        }
572        _ => title.to_string(),
573    }
574}
575
576fn permission_set_display_label(permission_set_id: &str) -> String {
577    let mut words = Vec::new();
578    let mut current = String::new();
579
580    for ch in permission_set_id.chars() {
581        if ch.is_ascii_alphanumeric() {
582            current.push(ch.to_ascii_lowercase());
583        } else if !current.is_empty() {
584            words.push(std::mem::take(&mut current));
585        }
586    }
587    if !current.is_empty() {
588        words.push(current);
589    }
590
591    let mut label = words.join(" ");
592    if let Some(first) = label.get_mut(0..1) {
593        first.make_ascii_uppercase();
594    }
595    label
596}
597
598fn role_suffix(
599    grant: &PermissionGrant,
600    explicit_label: Option<&str>,
601    has_multiple_entries: bool,
602) -> Option<String> {
603    let explicit = has_explicit_label(explicit_label);
604    if explicit || has_multiple_entries {
605        let max_len = if explicit { 40 } else { 28 };
606        let mut label = entry_snake_label(explicit_label, grant);
607        if !explicit {
608            label.push('_');
609            label.push_str(&grant_suffix_hash(grant));
610        }
611        Some(sanitize_role_segment(&label, max_len))
612    } else {
613        None
614    }
615}
616
617fn grant_suffix_hash(grant: &PermissionGrant) -> String {
618    let mut values = Vec::new();
619    if let Some(predefined_roles) = &grant.predefined_roles {
620        values.extend(predefined_roles.iter().map(|role| format!("role:{role}")));
621    }
622    if let Some(permissions) = gcp_residual_permissions(grant) {
623        values.extend(
624            permissions
625                .iter()
626                .map(|permission| format!("permission:{permission}")),
627        );
628    }
629    values.sort();
630    stable_role_hash(&values.join("|"))
631}
632
633fn sanitize_role_segment(value: &str, max_len: usize) -> String {
634    let mut out = String::with_capacity(value.len());
635    let mut previous_underscore = false;
636    for ch in value.chars() {
637        let next = if ch.is_ascii_alphanumeric() {
638            ch.to_ascii_lowercase()
639        } else {
640            '_'
641        };
642        if next == '_' {
643            if !previous_underscore {
644                out.push(next);
645            }
646            previous_underscore = true;
647        } else {
648            out.push(next);
649            previous_underscore = false;
650        }
651    }
652
653    let trimmed = out.trim_matches('_');
654    let mut segment = if trimmed.is_empty() {
655        "x".to_string()
656    } else {
657        trimmed.to_string()
658    };
659    if segment.len() > max_len {
660        segment.truncate(max_len);
661        while segment.ends_with('_') {
662            segment.pop();
663        }
664    }
665    if segment.is_empty() {
666        "x".to_string()
667    } else {
668        segment
669    }
670}
671
672fn fit_role_suffix(value: &str, max_len: usize) -> String {
673    if value.len() <= max_len {
674        return value.to_string();
675    }
676
677    let hash = stable_role_hash(value);
678    if max_len <= ROLE_ID_HASH_LEN {
679        return hash[..max_len].to_string();
680    }
681
682    let prefix_len = max_len - ROLE_ID_HASH_LEN - 1;
683    let mut prefix = value.to_string();
684    prefix.truncate(prefix_len);
685    while prefix.ends_with('_') {
686        prefix.pop();
687    }
688
689    if prefix.is_empty() {
690        hash[..max_len].to_string()
691    } else {
692        format!("{prefix}_{hash}")
693    }
694}
695
696fn stable_role_hash(value: &str) -> String {
697    let mut hash = 0x811c9dc5_u32;
698    for byte in value.bytes() {
699        hash ^= u32::from(byte);
700        hash = hash.wrapping_mul(0x01000193);
701    }
702    format!("{hash:08x}")
703}
704
705fn binding_target_scope(binding_spec: &GcpBindingSpec) -> GcpBindingTargetScope {
706    let scope = binding_spec.scope.trim();
707    match scope.strip_prefix("projects/") {
708        Some(project_scope) if !project_scope.contains('/') => GcpBindingTargetScope::Project,
709        _ => GcpBindingTargetScope::CurrentResource,
710    }
711}
712
713fn binding_resource_kind(binding_spec: &GcpBindingSpec) -> Option<GcpBindingResourceKind> {
714    let scope = binding_spec.scope.trim();
715    if scope.contains("/topics/") {
716        return Some(GcpBindingResourceKind::PubsubTopic);
717    }
718    if scope.contains("/subscriptions/") {
719        return Some(GcpBindingResourceKind::PubsubSubscription);
720    }
721    if scope.contains("/repositories/") {
722        return Some(GcpBindingResourceKind::ArtifactRegistryRepository);
723    }
724    None
725}
726
727fn dedupe_bindings(bindings: Vec<GcpIamBinding>) -> Vec<GcpIamBinding> {
728    let mut deduped = Vec::new();
729    for binding in bindings {
730        if !deduped.contains(&binding) {
731            deduped.push(binding);
732        }
733    }
734    deduped
735}