1use flowey::node::prelude::*;
11
12pub const CARGO_CONFIG_FILE: &str = "cargo_config";
14
15const DETERMINISTIC_TAR_ARGS: &[&str] = &[
22 "--sort=name",
23 "--mtime=@0",
24 "--owner=0",
25 "--group=0",
26 "--numeric-owner",
27 "--mode=u=rwX,go=rX",
28 "--format=gnu",
29];
30
31pub(crate) const IDENTITY_FILE: &str = ".openvmm-vendor-identity.json";
37
38#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
42pub struct VendorReleaseIdentity {
43 pub version: String,
45 pub revision: String,
47}
48
49#[derive(Serialize, Deserialize)]
51pub struct VendorReleaseOutput {
52 pub assets: PathBuf,
54}
55
56impl Artifact for VendorReleaseOutput {}
57
58pub fn read_vendor_identity(assets: &Path) -> anyhow::Result<VendorReleaseIdentity> {
60 let path = assets.join(IDENTITY_FILE);
61 let contents =
62 fs_err::read(&path).with_context(|| format!("failed to read {}", path.display()))?;
63 serde_json::from_slice(&contents).with_context(|| format!("failed to parse {}", path.display()))
64}
65
66impl VendorReleaseIdentity {
67 pub fn archive_name(&self) -> String {
69 format!("openvmm-{}-vendor.tar.gz", self.version)
70 }
71}
72
73pub fn resolve_identity(rt: &mut RustRuntimeServices<'_>) -> anyhow::Result<VendorReleaseIdentity> {
76 let revision = flowey::shell_cmd!(rt, "git rev-parse HEAD")
77 .read()?
78 .trim()
79 .to_owned();
80 let manifest_path = rt.sh.current_dir().absolute()?.join("Cargo.toml");
81 let version = workspace_version(&manifest_path)?;
82
83 Ok(VendorReleaseIdentity { version, revision })
84}
85
86fn workspace_version(manifest_path: &Path) -> anyhow::Result<String> {
88 let manifest = fs_err::read_to_string(manifest_path)?
89 .parse::<toml_edit::DocumentMut>()
90 .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
91
92 let version = manifest
93 .get("workspace")
94 .and_then(|workspace| workspace.get("package"))
95 .and_then(|package| package.get("version"))
96 .with_context(|| {
97 format!(
98 "{} has no [workspace.package] version",
99 manifest_path.display()
100 )
101 })?
102 .as_str()
103 .context("[workspace.package] version is not a string")?;
104
105 if version.is_empty() || version.contains(['/', '\\', ' ']) {
106 anyhow::bail!("[workspace.package] version is not usable as a name: {version:?}");
107 }
108
109 Ok(version.to_owned())
110}
111
112fn validate_vendor_config(cargo_config: &[u8]) -> anyhow::Result<()> {
118 let cargo_config = std::str::from_utf8(cargo_config)
119 .context("cargo vendor emitted a non-UTF-8 source replacement config")?;
120 let cargo_config = cargo_config
121 .parse::<toml_edit::DocumentMut>()
122 .context("failed to parse the source replacement config from cargo vendor")?;
123
124 let directory = cargo_config
125 .get("source")
126 .and_then(|source| source.get("vendored-sources"))
127 .and_then(|vendored| vendored.get("directory"))
128 .and_then(|directory| directory.as_str())
129 .context("cargo vendor did not emit source.vendored-sources.directory")?;
130
131 if directory != "vendor" {
132 anyhow::bail!(
133 "cargo vendor emitted source.vendored-sources.directory = {directory:?}, \
134 expected the relative path \"vendor\""
135 );
136 }
137
138 Ok(())
139}
140
141flowey_request! {
142 pub struct Request {
143 pub release: WriteVar<VendorReleaseOutput>,
145 }
146}
147
148const OUTPUT_DIR: &str = "openvmm-vendor-release";
151
152new_simple_flow_node!(struct Node);
153
154impl SimpleFlowNode for Node {
155 type Request = Request;
156
157 fn imports(ctx: &mut ImportCtx<'_>) {
158 ctx.import::<crate::git_checkout_openvmm_repo::Node>();
159 ctx.import::<flowey_lib_common::install_rust::Node>();
160 }
161
162 fn process_request(request: Self::Request, ctx: &mut NodeCtx<'_>) -> anyhow::Result<()> {
163 let Request { release } = request;
164
165 let openvmm_repo_path = ctx.reqv(crate::git_checkout_openvmm_repo::req::GetRepoDir);
166 let rust_is_installed = ctx.reqv(flowey_lib_common::install_rust::Request::EnsureInstalled);
167 let rust_toolchain = ctx.reqv(flowey_lib_common::install_rust::Request::GetRustupToolchain);
168
169 ctx.emit_rust_step("assemble OpenVMM vendor archive", |ctx| {
170 rust_is_installed.claim(ctx);
171 let openvmm_repo_path = openvmm_repo_path.claim(ctx);
172 let rust_toolchain = rust_toolchain.claim(ctx);
173 let release = release.claim(ctx);
174 move |rt| {
175 let output_dir = std::env::current_dir()?.join(OUTPUT_DIR);
176 let repo_path = rt.read(openvmm_repo_path);
177 let rust_toolchain = rt.read(rust_toolchain);
178
179 if output_dir.exists() {
182 fs_err::remove_dir_all(&output_dir)?;
183 }
184 fs_err::create_dir_all(&output_dir)?;
185
186 rt.sh.change_dir(&repo_path);
187
188 let identity = resolve_identity(rt)?;
189
190 let dirty =
194 flowey::shell_cmd!(rt, "git status --porcelain --untracked-files=no").read()?;
195 if !dirty.trim().is_empty() {
196 anyhow::bail!(
197 "refusing to assemble a vendor archive with tracked modifications; \
198 the archive would not match HEAD.\nmodified:\n{dirty}"
199 );
200 }
201
202 let stage_dir = output_dir.join("staging");
203 fs_err::create_dir_all(&stage_dir)?;
204
205 let manifest_path = repo_path.join("Cargo.toml");
206 let cargo = if let Some(rust_toolchain) = &rust_toolchain {
207 flowey::shell_cmd!(rt, "rustup run {rust_toolchain} cargo")
208 } else {
209 flowey::shell_cmd!(rt, "cargo")
210 };
211
212 let prior_dir = rt.sh.current_dir();
213 rt.sh.change_dir(&stage_dir);
214 let vendor_output = cargo
215 .args([
216 "vendor".as_ref(),
217 "--manifest-path".as_ref(),
218 manifest_path.as_os_str(),
219 "--locked".as_ref(),
220 "--versioned-dirs".as_ref(),
221 "vendor".as_ref(),
222 ])
223 .ignore_status()
224 .output()?;
225 rt.sh.change_dir(prior_dir);
226
227 if !vendor_output.status.success() {
228 anyhow::bail!(
229 "cargo vendor failed with {}.\nstderr:\n{}",
230 vendor_output.status,
231 String::from_utf8_lossy(&vendor_output.stderr)
232 );
233 }
234
235 let cargo_config = vendor_output.stdout;
236 validate_vendor_config(&cargo_config)?;
237
238 fs_err::write(stage_dir.join(CARGO_CONFIG_FILE), &cargo_config)?;
239
240 let vendor_dir = stage_dir.join("vendor");
241 if !vendor_dir.is_dir() {
242 anyhow::bail!(
243 "cargo vendor did not produce the expected directory {}",
244 vendor_dir.display()
245 );
246 }
247
248 let tar_path = output_dir.join(identity.archive_name()).with_extension("");
249 let cargo_config_file = CARGO_CONFIG_FILE;
250 let tar_args = DETERMINISTIC_TAR_ARGS;
251 rt.sh.change_dir(&stage_dir);
252 flowey::shell_cmd!(
253 rt,
254 "tar {tar_args...} -cf {tar_path} vendor {cargo_config_file}"
255 )
256 .run()?;
257 flowey::shell_cmd!(rt, "gzip -n --best -f {tar_path}").run()?;
258
259 rt.sh.change_dir(&output_dir);
260 fs_err::remove_dir_all(&stage_dir)?;
261 fs_err::write(
262 output_dir.join(IDENTITY_FILE),
263 serde_json::to_vec(&identity)?,
264 )?;
265
266 rt.write(release, &VendorReleaseOutput { assets: output_dir });
267
268 Ok(())
269 }
270 });
271
272 Ok(())
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 fn identity(version: &str) -> VendorReleaseIdentity {
281 VendorReleaseIdentity {
282 version: version.into(),
283 revision: "0123456789abcdef0123456789abcdef01234567".into(),
284 }
285 }
286
287 #[test]
288 fn asset_names_follow_the_version() {
289 let identity = identity("0.12.3");
290 assert_eq!(identity.archive_name(), "openvmm-0.12.3-vendor.tar.gz");
291 }
292
293 #[test]
294 fn accepts_a_relative_vendor_source_replacement() {
295 let config = b"[source.crates-io]\nreplace-with = 'vendored-sources'\n\n\
298 [source.vendored-sources]\ndirectory = \"vendor\"\n";
299 validate_vendor_config(config).unwrap();
300 }
301
302 #[test]
303 fn rejects_an_absolute_or_missing_vendor_directory() {
304 let absolute = b"[source.vendored-sources]\ndirectory = \"/build/stage/vendor\"\n";
306 assert!(validate_vendor_config(absolute).is_err());
307
308 let missing = b"[source.crates-io]\nreplace-with = \"vendored-sources\"\n";
309 assert!(validate_vendor_config(missing).is_err());
310
311 assert!(validate_vendor_config(b"not = = toml").is_err());
312 assert!(validate_vendor_config(&[0xff, 0xfe]).is_err());
313 }
314
315 #[test]
316 fn reads_the_workspace_version() {
317 let dir = tempfile::tempdir().unwrap();
318 let manifest = dir.path().join("Cargo.toml");
319
320 fs_err::write(
321 &manifest,
322 "[workspace]\nmembers = []\n\n[workspace.package]\nversion = \"0.12.3-dev\"\n",
323 )
324 .unwrap();
325 assert_eq!(workspace_version(&manifest).unwrap(), "0.12.3-dev");
326
327 fs_err::write(&manifest, "[workspace]\nmembers = []\n").unwrap();
328 assert!(workspace_version(&manifest).is_err());
329
330 fs_err::write(
331 &manifest,
332 "[workspace.package]\nversion = { workspace = true }\n",
333 )
334 .unwrap();
335 assert!(workspace_version(&manifest).is_err());
336
337 fs_err::write(&manifest, "[workspace.package]\nversion = \"0.1.0/x\"\n").unwrap();
338 assert!(workspace_version(&manifest).is_err());
339 }
340
341 #[cfg(target_os = "linux")]
342 mod linux {
343 use super::*;
344 use std::collections::BTreeSet;
345 use std::os::unix::fs::PermissionsExt;
346 use std::process::Command;
347
348 fn run_command(
349 mut command: Command,
350 description: &str,
351 ) -> anyhow::Result<std::process::Output> {
352 let output = command
353 .output()
354 .with_context(|| format!("failed to run {description}"))?;
355 if output.status.success() {
356 return Ok(output);
357 }
358
359 anyhow::bail!(
360 "{description} failed with {}.\nstdout:\n{}\nstderr:\n{}",
361 output.status,
362 String::from_utf8_lossy(&output.stdout),
363 String::from_utf8_lossy(&output.stderr)
364 );
365 }
366
367 fn create_deterministic_vendor_archive(
368 stage_dir: &Path,
369 archive_path: &Path,
370 ) -> anyhow::Result<()> {
371 let tar_path = archive_path.with_extension("");
372
373 let mut tar = Command::new("tar");
374 tar.current_dir(stage_dir)
375 .args(DETERMINISTIC_TAR_ARGS)
376 .arg("-cf")
377 .arg(&tar_path)
378 .args(["vendor", CARGO_CONFIG_FILE]);
379 run_command(tar, "tar")?;
380
381 let mut gzip = Command::new("gzip");
382 gzip.args(["-n", "--best", "-f"]).arg(&tar_path);
383 run_command(gzip, "gzip")?;
384
385 anyhow::ensure!(
386 archive_path.is_file(),
387 "gzip did not produce {}",
388 archive_path.display()
389 );
390 Ok(())
391 }
392
393 fn write_fixture(stage_dir: &Path) {
394 let crate_dir = stage_dir.join("vendor").join("demo-0.1.0");
395 fs_err::create_dir_all(crate_dir.join("bin")).unwrap();
396 fs_err::write(
397 stage_dir.join(CARGO_CONFIG_FILE),
398 b"[source.crates-io]\nreplace-with = \"vendored-sources\"\n[source.vendored-sources]\ndirectory = \"vendor\"\n",
399 )
400 .unwrap();
401 fs_err::write(crate_dir.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
402 fs_err::write(crate_dir.join("bin").join("tool"), b"#!/bin/sh\nexit 0\n").unwrap();
403 fs_err::set_permissions(
404 crate_dir.join("bin").join("tool"),
405 std::fs::Permissions::from_mode(0o755),
406 )
407 .unwrap();
408
409 let long_dir = crate_dir
414 .join("src")
415 .join("a".repeat(60))
416 .join("b".repeat(60));
417 fs_err::create_dir_all(&long_dir).unwrap();
418 fs_err::write(long_dir.join("deeply_nested_source.rs"), b"// vendored\n").unwrap();
419 }
420
421 fn set_timestamp(path: &Path, timestamp: &str) {
422 let mut touch = Command::new("touch");
423 touch.args(["-d", timestamp]).arg(path);
424 run_command(touch, "touch").unwrap();
425 }
426
427 fn list_archive_entries(archive: &Path) -> Vec<String> {
428 let mut tar = Command::new("tar");
429 tar.args(["-tzf"]).arg(archive);
430 let output = run_command(tar, "tar -tzf").unwrap();
431 String::from_utf8(output.stdout)
432 .unwrap()
433 .lines()
434 .map(str::to_owned)
435 .collect()
436 }
437
438 #[test]
439 fn archive_has_the_expected_top_level_layout() {
440 let dir = tempfile::tempdir().unwrap();
441 let stage_dir = dir.path().join("stage");
442 fs_err::create_dir_all(&stage_dir).unwrap();
443 write_fixture(&stage_dir);
444
445 let archive = dir.path().join("openvmm-0.12.3-vendor.tar.gz");
446 create_deterministic_vendor_archive(&stage_dir, &archive).unwrap();
447
448 let entries = list_archive_entries(&archive);
449 let top_level = entries
450 .iter()
451 .map(|entry| {
452 entry
453 .trim_end_matches('/')
454 .split('/')
455 .next()
456 .unwrap()
457 .to_owned()
458 })
459 .collect::<BTreeSet<_>>();
460
461 assert_eq!(
462 top_level,
463 BTreeSet::from(["cargo_config".to_owned(), "vendor".to_owned()])
464 );
465 assert!(entries.iter().any(|entry| entry == CARGO_CONFIG_FILE));
466 assert!(entries.iter().any(|entry| entry == "vendor/"));
467 }
468
469 #[test]
470 fn deterministic_tiny_fixture_tar_output_on_linux() {
471 let dir = tempfile::tempdir().unwrap();
472
473 let first_stage = dir.path().join("stage-a");
474 let second_stage = dir.path().join("stage-b");
475 fs_err::create_dir_all(&first_stage).unwrap();
476 fs_err::create_dir_all(&second_stage).unwrap();
477
478 write_fixture(&first_stage);
479 write_fixture(&second_stage);
480
481 set_timestamp(
482 &first_stage
483 .join("vendor")
484 .join("demo-0.1.0")
485 .join("Cargo.toml"),
486 "2001-02-03 04:05:06 UTC",
487 );
488 set_timestamp(
489 &second_stage
490 .join("vendor")
491 .join("demo-0.1.0")
492 .join("Cargo.toml"),
493 "2011-12-13 14:15:16 UTC",
494 );
495
496 let first_archive = dir.path().join("first.tar.gz");
497 let second_archive = dir.path().join("second.tar.gz");
498 create_deterministic_vendor_archive(&first_stage, &first_archive).unwrap();
499 create_deterministic_vendor_archive(&second_stage, &second_archive).unwrap();
500
501 assert_eq!(
502 fs_err::read(first_archive).unwrap(),
503 fs_err::read(second_archive).unwrap()
504 );
505 }
506 }
507}