breaking_attr/
lib.rs

1//! [![Crates.io Version](https://img.shields.io/crates/v/breaking-attr)](https://crates.io/crates/breaking-attr)
2//! [![docs.rs](https://img.shields.io/docsrs/breaking-attr)](https://docs.rs/breaking-attr)
3//! [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/Waridley/breaking-attr/.github%2Fworkflows%2Fci.yml)](https://github.com/Waridley/breaking-attr/actions)
4//!
5//! An attribute macro that enforces per-version invariants on items.
6//!
7//! ```
8//! use breaking_attr::breaking;
9//! # use std::hash::Hasher;
10//!
11//! #[cfg_attr(test, test)]
12//! fn hash_impl_did_not_change() {
13//!     #[breaking(sha2 = "zEk8F98i1LX-Rr070lCztGerzO1-qlLbosetY1UvSww=")]
14//!     const SEED: &str = "This value must not change between minor versions.";
15//!
16//!     #[breaking("cD1v24qkrBpNJl2awl4hTkYqrOHy3L3_IKMQSjN_LXo=")] // defaults to `blake3`
17//!     const HASH: u64 = 5201689092505688044;
18//!
19//!     // Just for example:
20//!     let mut hasher = std::hash::DefaultHasher::new();
21//!     hasher.write(SEED.as_bytes());
22//!     debug_assert_eq!(hasher.finish(), HASH);
23//! }
24//!
25//! # hash_impl_did_not_change()
26//! ```
27//!
28//! See the documentation on [`breaking`](`macro@breaking`)
29
30use base64::{engine::general_purpose::URL_SAFE as BASE_64, Engine};
31use proc_macro::TokenStream;
32use proc_macro2::{Span, TokenTree};
33use syn::__private::ToTokens;
34use syn::{punctuated::Punctuated, Ident, LitStr, Token};
35
36#[cfg(not(any(feature = "blake3", feature = "sha2", feature = "md5",)))]
37compile_error!("At least one hasher feature must be enabled");
38
39/// Marks an item that will break the public API if it is changed.
40///
41/// This works by comparing the hashed [`TokenStream`] of the item against a provided
42/// [url-safe](BASE_64)  base64-encoded hash.
43///
44/// Changes to items marked with this attribute require updating the hash argument (which can be
45/// retrieved from the compile error generated by running it with a wrong hash) and most likely
46/// bumping the major version of the crate containing the item. At the very least, it should be
47/// explained in the commit message and any accompanying PR why the hash was updated without bumping
48/// the major version.
49///
50/// ### Hashers
51/// Multiple hash functions are supported via feature flags and one of the following function names
52/// provided as an argument to this macro, separated from the hash literal by an equal sign. Feel
53/// free to make a [PR](https://github.com/Waridley/breaking-attr) to add your preferred hash
54/// function.
55///
56/// - [`blake3`] (default)
57/// - With the `sha2` dependency
58///   - [`sha256`](`sha2::Sha256`) (also aliased to `sha2`)
59///   - [`sha224`](`sha2::Sha224`)
60///   - [`sha384`](`sha2::Sha384`)
61///   - [`sha512`](`sha2::Sha512`)
62///   - [`sha512_224`](`sha2::Sha512_224`)
63///   - [`sha512_256`](`sha2::Sha512_256`)
64/// - [`md5`]
65///
66/// ### Example
67/// ```
68/// use breaking_attr::breaking;
69///
70/// # #[cfg(feature = "sha2")]
71/// #[breaking(sha384 = "82y9Notlejn-Nfzl4SurR3m3Uqeaqt0jmN-wGHSAjNkHeywz1zYZeUJi-5-D0wo3")]
72/// const SHA_384: &str = "This string must not change without updating the hash.";
73///
74/// # #[cfg(feature = "blake3")]
75/// #[breaking("IjrbZ-YsIRSb2v3ELtz-4zMqGvu5FVCkwotqCKMdhDE=")]
76/// const DEFAULT: &str = "The default hasher is `blake3`";
77/// ```
78#[proc_macro_attribute]
79pub fn breaking(args: TokenStream, input: TokenStream) -> TokenStream {
80    let args =
81        syn::parse_macro_input!(args with Punctuated::<TokenTree, Token![=]>::parse_terminated);
82
83    let Some(expected) = args.iter().find_map(|arg| {
84        let TokenTree::Literal(lit) = arg else {
85            return None;
86        };
87        syn::parse::<LitStr>(lit.to_token_stream().into()).ok()
88    }) else {
89        let msg = "couldn't find string literal of hash in arguments";
90        let err = syn::Error::new(Span::call_site(), msg);
91        return err.into_compile_error().into();
92    };
93    let expected = expected.value();
94
95    let hasher = args.iter().find_map(|arg| {
96        let TokenTree::Ident(ident) = arg else {
97            return None;
98        };
99        Some(ident)
100    });
101    let input_str = input.to_string();
102    #[cfg(not(all(feature = "blake3", feature = "sha2", feature = "md5",)))]
103    let feature_error = |feature: &str| {
104        let msg = format!(
105            "either the `{feature}` feature must be enabled or a different hasher must be specified"
106        );
107        let err = syn::Error::new_spanned(hasher.unwrap(), msg);
108        TokenStream::from(err.into_compile_error())
109    };
110    let hash = match hasher.map(Ident::to_string).as_deref() {
111        None | Some("blake3") => {
112            #[cfg(feature = "blake3")]
113            {
114                blake3::hash(input_str.as_bytes()).as_bytes().to_vec()
115            }
116            #[cfg(not(feature = "blake3"))]
117            return feature_error("blake3");
118        }
119        Some(s) if s.starts_with("sha") => {
120            #[cfg(feature = "sha2")]
121            {
122                use sha2::Digest;
123                fn sha2(input: &str, mut hasher: impl Digest) -> Vec<u8> {
124                    hasher.update(input.as_bytes());
125                    hasher.finalize().to_vec()
126                }
127                match s {
128                    "sha2" | "sha256" => sha2(&input_str, sha2::Sha256::new()),
129                    "sha224" => sha2(&input_str, sha2::Sha224::new()),
130                    "sha384" => sha2(&input_str, sha2::Sha384::new()),
131                    "sha512" => sha2(&input_str, sha2::Sha512::new()),
132                    "sha512_224" => sha2(&input_str, sha2::Sha512_224::new()),
133                    "sha512_256" => sha2(&input_str, sha2::Sha512_256::new()),
134                    _ => {
135                        return syn::Error::new_spanned(
136                            hasher.unwrap(),
137                            "unrecognized sha version",
138                        )
139                        .into_compile_error()
140                        .into();
141                    }
142                }
143            }
144            #[cfg(not(feature = "sha2"))]
145            return feature_error("sha2");
146        }
147        Some("md5") => {
148            #[cfg(feature = "md5")]
149            {
150                md5::compute(input_str.as_bytes()).0.to_vec()
151            }
152            #[cfg(not(feature = "md5"))]
153            return feature_error("md5");
154        }
155        Some(other) => {
156            let msg = format!("unrecognized hasher `{other}`");
157            return syn::Error::new_spanned(hasher.unwrap(), msg)
158                .into_compile_error()
159                .into();
160        }
161    };
162    let hash = BASE_64.encode(hash);
163    if hash != expected {
164        let msg = format!("hash {hash:?} doesn't match");
165        return syn::Error::new(proc_macro::Span::call_site().into(), msg)
166            .into_compile_error()
167            .into();
168    }
169    input
170}