proto_sign/
compatibility.rs

1//! Provides structures and functions for checking backward-compatibility of Protobuf files.
2
3use serde::Serialize;
4use std::collections::BTreeSet;
5
6//==============================================================================
7// Structures for Compatibility Analysis
8//==============================================================================
9
10/// Represents the backward-compatibility-relevant content of a .proto file.
11#[derive(Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord)]
12pub struct CompatibilityModel {
13    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
14    pub messages: BTreeSet<CompatibilityMessage>,
15    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
16    pub services: BTreeSet<CompatibilityService>,
17}
18
19/// Represents a message for compatibility purposes.
20#[derive(Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord)]
21pub struct CompatibilityMessage {
22    pub name: String,
23    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
24    pub fields: BTreeSet<CompatibilityField>,
25}
26
27/// Represents a field for compatibility purposes.
28/// Note the absence of `name` and `label`.
29#[derive(Debug, Default, Serialize, PartialEq, Eq)]
30pub struct CompatibilityField {
31    pub number: i32,
32    pub type_name: String,
33}
34
35// Custom implementation of Ord for CompatibilityField to sort by `number` first.
36impl Ord for CompatibilityField {
37    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
38        self.number
39            .cmp(&other.number)
40            .then_with(|| self.type_name.cmp(&other.type_name))
41    }
42}
43
44impl PartialOrd for CompatibilityField {
45    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
46        Some(self.cmp(other))
47    }
48}
49
50/// Represents a service for compatibility purposes.
51#[derive(Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord)]
52pub struct CompatibilityService {
53    pub name: String,
54    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
55    pub methods: BTreeSet<CompatibilityMethod>,
56}
57
58/// Represents a service method for compatibility purposes.
59#[derive(Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord)]
60pub struct CompatibilityMethod {
61    pub name: String,
62    pub input_type: String,
63    pub output_type: String,
64}
65
66//==============================================================================
67// Public API Functions
68//==============================================================================
69
70use crate::normalize;
71use anyhow::Context;
72use protobuf_parse::Parser;
73
74/// Parses a `.proto` file content and returns its compatibility model.
75pub fn get_compatibility_model(proto_content: &str) -> anyhow::Result<CompatibilityModel> {
76    let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?;
77    let file_name = "input.proto";
78    let temp_path = temp_dir.path().join(file_name);
79    std::fs::write(&temp_path, proto_content).context("Failed to write to temp file")?;
80
81    for line in proto_content.lines() {
82        if line.trim().starts_with("import ") {
83            let path_str = line
84                .trim()
85                .trim_start_matches("import ")
86                .trim_start_matches("public ")
87                .trim_matches(|c| c == '"' || c == ';');
88
89            if !path_str.starts_with("google/protobuf/") {
90                let import_path = temp_dir.path().join(path_str);
91                if let Some(parent) = import_path.parent() {
92                    std::fs::create_dir_all(parent).context(format!(
93                        "Failed to create parent dirs for import: {path_str}"
94                    ))?;
95                }
96                std::fs::write(&import_path, "syntax = \"proto3\";")
97                    .context(format!("Failed to create dummy import file: {path_str}"))?;
98            }
99        }
100    }
101
102    let parsed = Parser::new()
103        .pure()
104        .include(temp_dir.path())
105        .input(&temp_path)
106        .file_descriptor_set()
107        .context("Protobuf parsing failed")?;
108
109    let file_descriptor = parsed
110        .file
111        .into_iter()
112        .find(|d| d.name() == file_name)
113        .context("Could not find the parsed file descriptor for the input file")?;
114
115    Ok(normalize::normalize_compatibility_file(&file_descriptor))
116}
117
118/// Compares two compatibility models to see if `new_model` is backward-compatible
119/// with `old_model`.
120pub fn is_compatible(old_model: &CompatibilityModel, new_model: &CompatibilityModel) -> bool {
121    // To be compatible, the new model must have every message and service
122    // that the old model has, and their contents must be compatible.
123
124    // Check messages
125    for old_msg in &old_model.messages {
126        // Find the corresponding message in the new model by name.
127        if let Some(new_msg) = new_model.messages.iter().find(|m| m.name == old_msg.name) {
128            // The new message's fields must be a superset of the old message's fields.
129            if !old_msg.fields.is_subset(&new_msg.fields) {
130                return false; // Breaking change: a field was removed or its type/number changed.
131            }
132        } else {
133            return false; // Breaking change: a message was removed.
134        }
135    }
136
137    // Check services
138    for old_svc in &old_model.services {
139        // Find the corresponding service in the new model by name.
140        if let Some(new_svc) = new_model.services.iter().find(|s| s.name == old_svc.name) {
141            // The new service's methods must be a superset of the old one's.
142            if !old_svc.methods.is_subset(&new_svc.methods) {
143                return false; // Breaking change: a method was removed or its signature changed.
144            }
145        } else {
146            return false; // Breaking change: a service was removed.
147        }
148    }
149
150    true
151}