Skip to main content

ocomment_plugin_sdk/
lib.rs

1//! Versioned scanner-plugin boundary.
2//!
3//! A scanner plugin finds comments in a syntax `ocomment-core` has no scanner
4//! for and hands their spans back to the host, which puts them through the
5//! ordinary policy with
6//! [`transform_spans`](ocomment_core::transform_spans). This crate is the
7//! contract between the two: the [`PluginComment`] a guest returns, the
8//! [`API_VERSION`] it was built against, and the [`validate_comments`] check
9//! the host runs before it trusts any of it.
10//!
11//! A plugin is untrusted code, so nothing it returns is taken on faith. The
12//! host validates first and refuses the whole batch on the first fault; it
13//! never removes bytes on the strength of a span it has not checked.
14//!
15//! ```
16//! use ocomment_core::{ByteSpan, CommentKind};
17//! use ocomment_plugin_sdk::{API_VERSION, PluginComment, ValidationError, validate_comments};
18//!
19//! let source = b"a ;; note\n";
20//! let found = [PluginComment {
21//!     span: ByteSpan::new(2, 9),
22//!     kind: CommentKind::Line,
23//! }];
24//! assert!(validate_comments(source.len(), API_VERSION, &found).is_ok());
25//!
26//! // A guest built against another revision of the contract is refused
27//! // before its spans are even read.
28//! assert!(matches!(
29//!     validate_comments(source.len(), API_VERSION + 1, &found),
30//!     Err(ValidationError::ApiVersion { .. }),
31//! ));
32//! ```
33
34use ocomment_core::{ByteSpan, CommentKind};
35use serde::{Deserialize, Serialize};
36use thiserror::Error;
37
38/// The revision of this contract that host and guest must agree on.
39///
40/// It is bumped whenever the shape of a [`PluginComment`] or the rules in
41/// [`validate_comments`] change. A guest reports the version it was built
42/// against and the host refuses anything else, so a plugin compiled against
43/// an older SDK fails loudly instead of being misread.
44pub const API_VERSION: u32 = 1;
45
46/// One comment a plugin found.
47///
48/// A plugin reports where a comment is and what it is; it never decides
49/// whether the comment is removed. That stays with the host's policy, so one
50/// configuration governs built-in and plugin-scanned files alike.
51#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
52pub struct PluginComment {
53    /// Where the comment's bytes are, delimiters included.
54    pub span: ByteSpan,
55    /// What the comment is, which is what the host's policy then judges.
56    pub kind: CommentKind,
57}
58
59/// Why a plugin's answer cannot be trusted.
60///
61/// Each variant is a way a guest could otherwise make the host remove bytes
62/// it should not, or spend unbounded work trying.
63#[derive(Clone, Debug, Error, Eq, PartialEq)]
64pub enum ValidationError {
65    /// The guest was built against a different revision of this contract.
66    #[error("plugin API version {received} is unsupported; host supports {supported}")]
67    ApiVersion {
68        /// The version the guest reported.
69        received: u32,
70        /// The only version this host accepts, [`API_VERSION`].
71        supported: u32,
72    },
73    /// A span is inverted or reaches past the end of the source.
74    #[error("plugin comment span is outside the {source_len}-byte source")]
75    OutOfBounds {
76        /// The length of the source the spans had to fit in.
77        source_len: usize,
78    },
79    /// A span starts before its predecessor ends, which no single-pass edit
80    /// could apply.
81    #[error("plugin spans are not strictly sorted and non-overlapping")]
82    Overlap,
83    /// A span covers no bytes, so it names no comment.
84    #[error("plugin comment spans must not be empty")]
85    EmptySpan,
86    /// More spans than the source could hold comments, which is a guest
87    /// spending the host's memory rather than reporting anything.
88    #[error("plugin returned more than the allowed {limit} spans")]
89    SpanLimit {
90        /// The most spans this source could have justified.
91        limit: usize,
92    },
93}
94
95/// Check everything a plugin returned before the host acts on any of it.
96///
97/// The version is checked first, so a guest built against another revision is
98/// refused before its spans are read at all. The spans must then each be
99/// non-empty, inside the source, and start no earlier than the previous one
100/// ended — the same contract
101/// [`transform_spans`](ocomment_core::transform_spans) enforces, checked here
102/// so a host can refuse a plugin's whole answer rather than a single span of
103/// it. The count is capped as well: no source can hold more comments than it
104/// has bytes, plus one.
105///
106/// # Errors
107///
108/// Returns the [`ValidationError`] for the first fault found. On any error
109/// the batch is refused whole; there is no partial acceptance.
110///
111/// # Examples
112///
113/// ```
114/// use ocomment_core::{ByteSpan, CommentKind};
115/// use ocomment_plugin_sdk::{API_VERSION, PluginComment, ValidationError, validate_comments};
116///
117/// let comment = |start, end| PluginComment {
118///     span: ByteSpan::new(start, end),
119///     kind: CommentKind::Line,
120/// };
121///
122/// assert!(validate_comments(10, API_VERSION, &[comment(0, 2), comment(2, 10)]).is_ok());
123/// assert_eq!(
124///     validate_comments(10, API_VERSION, &[comment(4, 7), comment(6, 8)]),
125///     Err(ValidationError::Overlap),
126/// );
127/// assert_eq!(
128///     validate_comments(10, API_VERSION, &[comment(9, 11)]),
129///     Err(ValidationError::OutOfBounds { source_len: 10 }),
130/// );
131/// ```
132pub fn validate_comments(
133    source_len: usize,
134    api_version: u32,
135    comments: &[PluginComment],
136) -> Result<(), ValidationError> {
137    if api_version != API_VERSION {
138        return Err(ValidationError::ApiVersion {
139            received: api_version,
140            supported: API_VERSION,
141        });
142    }
143    let limit = source_len.saturating_add(1).min(1_000_000);
144    if comments.len() > limit {
145        return Err(ValidationError::SpanLimit { limit });
146    }
147    let mut cursor = 0;
148    for (index, comment) in comments.iter().enumerate() {
149        if comment.span.start > comment.span.end || comment.span.end > source_len {
150            return Err(ValidationError::OutOfBounds { source_len });
151        }
152        if comment.span.is_empty() {
153            return Err(ValidationError::EmptySpan);
154        }
155        if index > 0 && comment.span.start < cursor {
156            return Err(ValidationError::Overlap);
157        }
158        cursor = comment.span.end;
159    }
160    Ok(())
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn item(start: usize, end: usize) -> PluginComment {
168        PluginComment {
169            span: ByteSpan::new(start, end),
170            kind: CommentKind::Line,
171        }
172    }
173
174    #[test]
175    fn accepts_only_bounded_sorted_nonempty_spans() {
176        assert!(validate_comments(10, API_VERSION, &[item(0, 2), item(2, 10)]).is_ok());
177        assert_eq!(
178            validate_comments(10, API_VERSION, &[item(4, 7), item(6, 8)]),
179            Err(ValidationError::Overlap)
180        );
181        assert_eq!(
182            validate_comments(10, API_VERSION, &[item(3, 3)]),
183            Err(ValidationError::EmptySpan)
184        );
185        assert_eq!(
186            validate_comments(10, API_VERSION, &[item(9, 11)]),
187            Err(ValidationError::OutOfBounds { source_len: 10 })
188        );
189    }
190
191    #[test]
192    fn rejects_api_mismatch_before_reading_spans() {
193        assert!(matches!(
194            validate_comments(0, API_VERSION + 1, &[]),
195            Err(ValidationError::ApiVersion { .. })
196        ));
197    }
198}