1use std::path::{Path, PathBuf};
9
10use lean_toolchain::{
11 SourcePackageError, SourcePackageManifestPolicy, SourcePackageMaterializationRequest,
12 materialize_source_package as materialize_with_toolchain,
13};
14use sha2::{Digest, Sha256};
15
16const SOURCE_ROOT: &str = env!("CARGO_MANIFEST_DIR");
17
18pub const PACKAGE_NAME: &str = "lean_rs_interop_shims";
20pub const LIBRARY_NAME: &str = "LeanRsInterop";
22
23#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct LeanRsInteropShimsSourcePackageRequest {
26 pub cache_root: PathBuf,
28 pub toolchain_label: String,
30}
31
32#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct LeanRsInteropShimsSourcePackage {
35 pub project_root: PathBuf,
37}
38
39pub fn materialize_source_package(
47 input: LeanRsInteropShimsSourcePackageRequest,
48) -> Result<LeanRsInteropShimsSourcePackage, SourcePackageError> {
49 let LeanRsInteropShimsSourcePackageRequest {
50 cache_root,
51 toolchain_label,
52 } = input;
53 let request = SourcePackageMaterializationRequest {
54 source_root: PathBuf::from(SOURCE_ROOT),
55 cache_root,
56 package_name: PACKAGE_NAME.to_owned(),
57 materialized_package_name: PACKAGE_NAME.to_owned(),
58 library_name: LIBRARY_NAME.to_owned(),
59 source_digest: compute_source_digest()?,
60 source_revision: env!("CARGO_PKG_VERSION").to_owned(),
61 crate_name: env!("CARGO_PKG_NAME").to_owned(),
62 crate_version: env!("CARGO_PKG_VERSION").to_owned(),
63 toolchain_label,
64 include_paths: payload_include_paths(),
65 generated_files: Vec::new(),
66 sentinel_files: vec![
67 PathBuf::from("LeanRsInterop/Worker/Stream.lean"),
68 PathBuf::from("c/interop_callback.c"),
69 ],
70 manifest_policy: SourcePackageManifestPolicy::ZeroPackages,
71 };
72 let package = materialize_with_toolchain(&request)?;
73 Ok(LeanRsInteropShimsSourcePackage {
74 project_root: package.project_root,
75 })
76}
77
78fn payload_include_paths() -> Vec<PathBuf> {
79 [
80 "lakefile.lean",
81 "lake-manifest.json",
82 "LeanRsInterop.lean",
83 "LeanRsInterop",
84 "c",
85 ]
86 .into_iter()
87 .map(PathBuf::from)
88 .collect()
89}
90
91fn compute_source_digest() -> Result<String, SourcePackageError> {
92 let source_root = Path::new(SOURCE_ROOT);
93 let mut entries = Vec::new();
94 for include in payload_include_paths() {
95 collect_digest_entries(source_root, &include, &mut entries)?;
96 }
97 entries.sort_by(|left, right| left.0.cmp(&right.0));
98 let mut outer = Sha256::new();
99 for (canonical_path, digest) in entries {
100 outer.update(digest.as_bytes());
101 outer.update(b" ");
102 outer.update(canonical_path.as_bytes());
103 outer.update(b"\n");
104 }
105 Ok(hex_lower(&outer.finalize()))
106}
107
108fn collect_digest_entries(
109 source_root: &Path,
110 relative: &Path,
111 entries: &mut Vec<(String, String)>,
112) -> Result<(), SourcePackageError> {
113 let source = source_root.join(relative);
114 let metadata = std::fs::symlink_metadata(&source).map_err(|source_error| SourcePackageError::Io {
115 action: "stat lean-rs interop shim payload path",
116 path: source.clone(),
117 source: source_error,
118 })?;
119 if metadata.is_dir() && !metadata.file_type().is_symlink() {
120 let dir_entries = std::fs::read_dir(&source).map_err(|source_error| SourcePackageError::Io {
121 action: "read lean-rs interop shim payload directory",
122 path: source.clone(),
123 source: source_error,
124 })?;
125 for entry in dir_entries {
126 let entry = entry.map_err(|source_error| SourcePackageError::Io {
127 action: "read lean-rs interop shim payload directory entry",
128 path: source.clone(),
129 source: source_error,
130 })?;
131 collect_digest_entries(source_root, &relative.join(entry.file_name()), entries)?;
132 }
133 } else if metadata.is_file() {
134 let bytes = std::fs::read(&source).map_err(|source_error| SourcePackageError::Io {
135 action: "read lean-rs interop shim payload file for digest",
136 path: source,
137 source: source_error,
138 })?;
139 let mut hasher = Sha256::new();
140 hasher.update(bytes);
141 entries.push((relative.to_string_lossy().into_owned(), hex_lower(&hasher.finalize())));
142 }
143 Ok(())
144}
145
146#[allow(
147 clippy::arithmetic_side_effects,
148 clippy::indexing_slicing,
149 reason = "hex encoding indexes a fixed 16-byte table with masked nibbles"
150)]
151fn hex_lower(bytes: &[u8]) -> String {
152 const HEX: &[u8; 16] = b"0123456789abcdef";
153 let mut out = String::with_capacity(bytes.len() * 2);
154 for byte in bytes {
155 out.push(char::from(HEX[(byte >> 4) as usize]));
156 out.push(char::from(HEX[(byte & 0x0f) as usize]));
157 }
158 out
159}
160
161#[cfg(test)]
162fn unique_nanos() -> u128 {
163 std::time::SystemTime::now()
164 .duration_since(std::time::UNIX_EPOCH)
165 .map_or(0, |duration| duration.as_nanos())
166}
167
168#[cfg(test)]
169mod tests {
170 use std::fs;
171 use std::path::Path;
172 use std::process::Command;
173
174 use super::{LeanRsInteropShimsSourcePackageRequest, materialize_source_package};
175
176 #[test]
177 fn materializes_source_package_with_generated_toolchain() -> Result<(), String> {
178 let temp = std::env::temp_dir().join(format!(
179 "lean-rs-interop-shims-test-{}-{}",
180 std::process::id(),
181 super::unique_nanos()
182 ));
183 drop(remove_path_if_exists(&temp));
184 fs::create_dir_all(&temp).map_err(|error| error.to_string())?;
185 let toolchain = "leanprover/lean4:v4.31.0-rc1".to_owned();
186 let package = materialize_source_package(LeanRsInteropShimsSourcePackageRequest {
187 cache_root: temp.clone(),
188 toolchain_label: toolchain.clone(),
189 })
190 .map_err(|error| error.to_string())?;
191
192 assert_eq!(
193 fs::read_to_string(package.project_root.join("lean-toolchain"))
194 .map_err(|error| error.to_string())?
195 .trim(),
196 toolchain
197 );
198 assert!(package.project_root.join("LeanRsInterop/Worker/Stream.lean").is_file());
199 assert!(package.project_root.join("c/interop_callback.c").is_file());
200 assert!(package.project_root.join("source-package.json").is_file());
201 assert!(!package.project_root.join("Cargo.toml").exists());
202 assert!(!package.project_root.join("src/lib.rs").exists());
203
204 let warm = materialize_source_package(LeanRsInteropShimsSourcePackageRequest {
205 cache_root: temp.clone(),
206 toolchain_label: toolchain,
207 })
208 .map_err(|error| error.to_string())?;
209 assert_eq!(warm.project_root, package.project_root);
210
211 drop(remove_path_if_exists(&temp));
212 Ok(())
213 }
214
215 #[test]
216 fn package_list_contains_runtime_payload_and_license_texts() -> Result<(), String> {
217 let output = Command::new("cargo")
218 .args(["package", "--allow-dirty", "--list"])
219 .current_dir(super::SOURCE_ROOT)
220 .output()
221 .map_err(|error| format!("run cargo package --list: {error}"))?;
222 if !output.status.success() {
223 return Err(format!(
224 "cargo package --list failed with status {}:\nstdout:\n{}\nstderr:\n{}",
225 output.status,
226 String::from_utf8_lossy(&output.stdout),
227 String::from_utf8_lossy(&output.stderr)
228 ));
229 }
230 let stdout = String::from_utf8(output.stdout).map_err(|error| format!("package list is UTF-8: {error}"))?;
231
232 for expected in [
233 "LeanRsInterop.lean",
234 "LeanRsInterop/Worker/Stream.lean",
235 "c/interop_callback.c",
236 "lakefile.lean",
237 "lake-manifest.json",
238 "LICENSE-APACHE",
239 "LICENSE-MIT",
240 ] {
241 if !stdout.lines().any(|line| line == expected) {
242 return Err(format!("package list should include {expected}, got:\n{stdout}"));
243 }
244 }
245 Ok(())
246 }
247
248 fn remove_path_if_exists(path: &Path) -> Result<(), String> {
249 match fs::symlink_metadata(path) {
250 Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
251 fs::remove_dir_all(path).map_err(|error| error.to_string())
252 }
253 Ok(_) => fs::remove_file(path).map_err(|error| error.to_string()),
254 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
255 Err(error) => Err(error.to_string()),
256 }
257 }
258}