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