1use minco_project_view::ProjectView;
2use serde::Serialize;
3use std::{
4 ffi::{OsStr, OsString},
5 path::{Component, Path, PathBuf},
6};
7use thiserror::Error;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
10#[serde(rename_all = "snake_case")]
11pub enum ExportFormat {
12 Json,
13 Mermaid,
14 Static,
15}
16
17#[derive(Debug, Clone, Copy)]
18pub struct ExportRequest<'a> {
19 pub root: &'a Path,
20 pub destination: &'a Path,
21 pub canonical_inputs: &'a [PathBuf],
22 pub format: ExportFormat,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26pub struct ExportReport {
27 pub schema_version: u32,
28 pub status: &'static str,
29 pub format: ExportFormat,
30 pub destination: PathBuf,
31 pub files: Vec<String>,
32 pub source_digest: String,
33}
34
35#[derive(Debug, Error)]
36pub enum WorkbenchError {
37 #[error("workbench export root must be an explicit canonical absolute directory: {0}")]
38 InvalidRoot(PathBuf),
39 #[error("workbench export destination must be a new normalized project-relative path: {0}")]
40 InvalidDestination(PathBuf),
41 #[error("workbench export destination overlaps canonical input {input}: {destination}")]
42 CanonicalInputOverlap {
43 destination: PathBuf,
44 input: PathBuf,
45 },
46 #[error("workbench export destination already exists: {0}")]
47 DestinationExists(PathBuf),
48 #[error("safe atomic no-clobber directory installation is unsupported on this platform")]
49 SafeInstallationUnsupported,
50 #[error("workbench export serialization failed: {0}")]
51 Serialization(#[from] serde_json::Error),
52 #[error("workbench export I/O failed during {operation} at {path}: {source}")]
53 Io {
54 operation: &'static str,
55 path: PathBuf,
56 #[source]
57 source: std::io::Error,
58 },
59}
60
61pub fn export_project_view(
62 view: &ProjectView,
63 request: ExportRequest<'_>,
64) -> Result<ExportReport, WorkbenchError> {
65 validate_request(&request)?;
66 let artifacts = match request.format {
67 ExportFormat::Json => vec![(
68 PathBuf::from("project-view.json"),
69 serde_json::to_vec(view)?,
70 )],
71 ExportFormat::Mermaid => vec![(
72 PathBuf::from("project-view.mmd"),
73 crate::render_mermaid(view).into_bytes(),
74 )],
75 ExportFormat::Static => vec![
76 (
77 PathBuf::from("index.html"),
78 include_bytes!("../assets/index.html").to_vec(),
79 ),
80 (
81 PathBuf::from("project-view.json"),
82 serde_json::to_vec(view)?,
83 ),
84 (
85 PathBuf::from("project-view.mmd"),
86 crate::render_mermaid(view).into_bytes(),
87 ),
88 (
89 PathBuf::from("workbench.css"),
90 include_bytes!("../assets/workbench.css").to_vec(),
91 ),
92 (
93 PathBuf::from("workbench.js"),
94 include_bytes!("../assets/workbench.js").to_vec(),
95 ),
96 ],
97 };
98 let files = artifacts
99 .iter()
100 .map(|(path, _)| path.display().to_string())
101 .collect::<Vec<_>>();
102
103 secure::publish(request.root, request.destination, &artifacts)?;
104
105 Ok(ExportReport {
106 schema_version: 1,
107 status: "ok",
108 format: request.format,
109 destination: request.destination.to_path_buf(),
110 files,
111 source_digest: view.project.source_digest.clone(),
112 })
113}
114
115fn validate_request(request: &ExportRequest<'_>) -> Result<(), WorkbenchError> {
116 let canonical_root = request
117 .root
118 .canonicalize()
119 .map_err(|source| WorkbenchError::Io {
120 operation: "canonicalize root",
121 path: request.root.to_path_buf(),
122 source,
123 })?;
124 if !request.root.is_absolute() || canonical_root != request.root || !request.root.is_dir() {
125 return Err(WorkbenchError::InvalidRoot(request.root.to_path_buf()));
126 }
127 if request.destination.as_os_str().is_empty()
128 || request.destination.is_absolute()
129 || !request
130 .destination
131 .components()
132 .all(|component| matches!(component, Component::Normal(_)))
133 {
134 return Err(WorkbenchError::InvalidDestination(
135 request.destination.to_path_buf(),
136 ));
137 }
138 for input in request.canonical_inputs {
139 if request.destination.starts_with(input) || input.starts_with(request.destination) {
140 return Err(WorkbenchError::CanonicalInputOverlap {
141 destination: request.destination.to_path_buf(),
142 input: input.clone(),
143 });
144 }
145 }
146 Ok(())
147}
148
149#[cfg(any(target_os = "linux", target_vendor = "apple"))]
150mod secure {
151 use super::{Component, OsStr, OsString, Path, PathBuf, WorkbenchError};
152 use rustix::{
153 fd::OwnedFd,
154 fs::{
155 AtFlags, Mode, OFlags, RenameFlags, fstat, fsync, mkdirat, open, openat, renameat_with,
156 statat, unlinkat,
157 },
158 io::Errno,
159 };
160 use std::{fs::File, io::Write};
161 use uuid::Uuid;
162
163 const DIRECTORY_FLAGS: OFlags = OFlags::RDONLY
164 .union(OFlags::DIRECTORY)
165 .union(OFlags::NOFOLLOW)
166 .union(OFlags::CLOEXEC);
167
168 pub(super) fn publish(
169 root: &Path,
170 destination: &Path,
171 artifacts: &[(PathBuf, Vec<u8>)],
172 ) -> Result<(), WorkbenchError> {
173 let mut staging_names = || {
174 Some(OsString::from(format!(
175 ".minco-workbench-{}.staging",
176 Uuid::new_v4().simple()
177 )))
178 };
179 publish_inner(
180 root,
181 destination,
182 artifacts,
183 &mut staging_names,
184 || {},
185 None,
186 None,
187 )
188 }
189
190 #[cfg(test)]
191 pub(super) fn publish_with_before_install<F>(
192 root: &Path,
193 destination: &Path,
194 artifacts: &[(PathBuf, Vec<u8>)],
195 before_install: F,
196 ) -> Result<(), WorkbenchError>
197 where
198 F: FnOnce(),
199 {
200 let mut staging_names = || {
201 Some(OsString::from(format!(
202 ".minco-workbench-{}.staging",
203 Uuid::new_v4().simple()
204 )))
205 };
206 publish_inner(
207 root,
208 destination,
209 artifacts,
210 &mut staging_names,
211 before_install,
212 None,
213 None,
214 )
215 }
216
217 #[cfg(test)]
218 pub(super) fn publish_with_staging_names<I>(
219 root: &Path,
220 destination: &Path,
221 artifacts: &[(PathBuf, Vec<u8>)],
222 names: I,
223 ) -> Result<(), WorkbenchError>
224 where
225 I: IntoIterator<Item = OsString>,
226 {
227 let mut names = names.into_iter();
228 publish_inner(
229 root,
230 destination,
231 artifacts,
232 &mut || names.next(),
233 || {},
234 None,
235 None,
236 )
237 }
238
239 #[cfg(test)]
240 pub(super) fn publish_with_install_error(
241 root: &Path,
242 destination: &Path,
243 artifacts: &[(PathBuf, Vec<u8>)],
244 install_error: Errno,
245 ) -> Result<(), WorkbenchError> {
246 let mut staging_names = || Some(OsString::from(".owned.staging"));
247 publish_inner(
248 root,
249 destination,
250 artifacts,
251 &mut staging_names,
252 || {},
253 Some(install_error),
254 None,
255 )
256 }
257
258 #[cfg(test)]
259 pub(super) fn publish_with_post_install_error(
260 root: &Path,
261 destination: &Path,
262 artifacts: &[(PathBuf, Vec<u8>)],
263 post_install_error: Errno,
264 ) -> Result<(), WorkbenchError> {
265 let mut staging_names = || Some(OsString::from(".owned.staging"));
266 publish_inner(
267 root,
268 destination,
269 artifacts,
270 &mut staging_names,
271 || {},
272 None,
273 Some(post_install_error),
274 )
275 }
276
277 fn publish_inner<F, N>(
278 root: &Path,
279 destination: &Path,
280 artifacts: &[(PathBuf, Vec<u8>)],
281 staging_names: &mut N,
282 before_install: F,
283 forced_install_error: Option<Errno>,
284 forced_post_install_error: Option<Errno>,
285 ) -> Result<(), WorkbenchError>
286 where
287 F: FnOnce(),
288 N: FnMut() -> Option<OsString>,
289 {
290 let parent_path = destination.parent().unwrap_or_else(|| Path::new(""));
291 let destination_name = destination
292 .file_name()
293 .ok_or_else(|| WorkbenchError::InvalidDestination(destination.to_path_buf()))?;
294 let parent = open_directory_chain(root, parent_path)?;
295 let parent_identity = identity(&parent, parent_path)?;
296 ensure_absent(&parent, destination_name, destination)?;
297 let staging_name = create_private_staging(&parent, destination, staging_names)?;
298 let staging = openat(&parent, &staging_name, DIRECTORY_FLAGS, Mode::empty())
299 .map_err(|source| io_error("open private staging directory", destination, source))?;
300 let staging_identity = identity(&staging, destination)?;
301 let mut installed = false;
302
303 let result = (|| {
304 for (relative, contents) in artifacts {
305 write_artifact(&staging, relative, contents, destination)?;
306 }
307 fsync(&staging).map_err(|source| {
308 io_error("sync private staging directory", destination, source)
309 })?;
310 before_install();
311
312 let restaged =
313 statat(&parent, &staging_name, AtFlags::SYMLINK_NOFOLLOW).map_err(|source| {
314 io_error("verify private staging identity", destination, source)
315 })?;
316 if (restaged.st_dev, restaged.st_ino) != staging_identity {
317 return Err(WorkbenchError::Io {
318 operation: "verify private staging identity",
319 path: destination.to_path_buf(),
320 source: std::io::Error::other("staging directory identity changed"),
321 });
322 }
323
324 let resolved_parent = open_directory_chain(root, parent_path)?;
325 if identity(&resolved_parent, parent_path)? != parent_identity {
326 return Err(WorkbenchError::Io {
327 operation: "verify destination parent identity",
328 path: destination.to_path_buf(),
329 source: std::io::Error::other("destination parent identity changed"),
330 });
331 }
332 ensure_absent(&parent, destination_name, destination)?;
333 let installation = forced_install_error.map_or_else(
334 || {
335 renameat_with(
336 &parent,
337 &staging_name,
338 &parent,
339 destination_name,
340 RenameFlags::NOREPLACE,
341 )
342 },
343 Err,
344 );
345 match installation {
346 Ok(()) => installed = true,
347 Err(Errno::EXIST) => {
348 return Err(WorkbenchError::DestinationExists(destination.to_path_buf()));
349 }
350 Err(source)
351 if [Errno::NOSYS, Errno::NOTSUP, Errno::OPNOTSUPP].contains(&source) =>
352 {
353 return Err(WorkbenchError::SafeInstallationUnsupported);
354 }
355 Err(source) => {
356 return Err(io_error(
357 "atomically install export without replacement",
358 destination,
359 source,
360 ));
361 }
362 }
363 if let Some(source) = forced_post_install_error {
364 return Err(io_error("sync destination parent", destination, source));
365 }
366 fsync(&parent)
367 .map_err(|source| io_error("sync destination parent", destination, source))?;
368 Ok(())
369 })();
370
371 if result.is_err() {
372 for (relative, _) in artifacts.iter().rev() {
373 let _ = unlinkat(&staging, relative, AtFlags::empty());
374 }
375 let owned_name = if installed {
376 destination_name
377 } else {
378 staging_name.as_os_str()
379 };
380 let published_name_still_owned = statat(&parent, owned_name, AtFlags::SYMLINK_NOFOLLOW)
381 .is_ok_and(|stat| (stat.st_dev, stat.st_ino) == staging_identity);
382 drop(staging);
383 if published_name_still_owned {
384 let _ = unlinkat(&parent, owned_name, AtFlags::REMOVEDIR);
385 }
386 }
387 result
388 }
389
390 fn open_directory_chain(root: &Path, relative: &Path) -> Result<OwnedFd, WorkbenchError> {
391 let mut current = open(root, DIRECTORY_FLAGS, Mode::empty())
392 .map_err(|source| io_error("open canonical project root", root, source))?;
393 for component in relative.components() {
394 let Component::Normal(name) = component else {
395 return Err(WorkbenchError::InvalidDestination(relative.to_path_buf()));
396 };
397 current = openat(¤t, name, DIRECTORY_FLAGS, Mode::empty()).map_err(|source| {
398 io_error("open destination parent without symlinks", relative, source)
399 })?;
400 }
401 Ok(current)
402 }
403
404 fn create_private_staging<N>(
405 parent: &OwnedFd,
406 destination: &Path,
407 staging_names: &mut N,
408 ) -> Result<OsString, WorkbenchError>
409 where
410 N: FnMut() -> Option<OsString>,
411 {
412 for _ in 0..32 {
413 let Some(name) = staging_names() else {
414 break;
415 };
416 match mkdirat(parent, &name, Mode::RUSR | Mode::WUSR | Mode::XUSR) {
417 Ok(()) => return Ok(name),
418 Err(Errno::EXIST) => {}
419 Err(source) => {
420 return Err(io_error(
421 "exclusively create private staging directory",
422 destination,
423 source,
424 ));
425 }
426 }
427 }
428 Err(WorkbenchError::Io {
429 operation: "exclusively create private staging directory",
430 path: destination.to_path_buf(),
431 source: std::io::Error::new(
432 std::io::ErrorKind::AlreadyExists,
433 "staging name collision limit exceeded",
434 ),
435 })
436 }
437
438 fn write_artifact(
439 staging: &OwnedFd,
440 relative: &Path,
441 contents: &[u8],
442 destination: &Path,
443 ) -> Result<(), WorkbenchError> {
444 if relative
445 .parent()
446 .is_some_and(|parent| !parent.as_os_str().is_empty())
447 || relative
448 .components()
449 .any(|component| !matches!(component, Component::Normal(_)))
450 {
451 return Err(WorkbenchError::InvalidDestination(relative.to_path_buf()));
452 }
453 let fd = openat(
454 staging,
455 relative,
456 OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
457 Mode::RUSR | Mode::WUSR,
458 )
459 .map_err(|source| io_error("create staged artifact", destination, source))?;
460 let mut file = File::from(fd);
461 file.write_all(contents)
462 .map_err(|source| WorkbenchError::Io {
463 operation: "write staged artifact",
464 path: destination.join(relative),
465 source,
466 })?;
467 file.sync_all().map_err(|source| WorkbenchError::Io {
468 operation: "sync staged artifact",
469 path: destination.join(relative),
470 source,
471 })?;
472 Ok(())
473 }
474
475 fn ensure_absent(
476 parent: &OwnedFd,
477 name: &OsStr,
478 destination: &Path,
479 ) -> Result<(), WorkbenchError> {
480 match statat(parent, name, AtFlags::SYMLINK_NOFOLLOW) {
481 Ok(_) => Err(WorkbenchError::DestinationExists(destination.to_path_buf())),
482 Err(Errno::NOENT) => Ok(()),
483 Err(source) => Err(io_error("check export destination", destination, source)),
484 }
485 }
486
487 fn identity(fd: &OwnedFd, path: &Path) -> Result<(rustix::fs::Dev, u64), WorkbenchError> {
488 let stat =
489 fstat(fd).map_err(|source| io_error("read filesystem identity", path, source))?;
490 Ok((stat.st_dev, stat.st_ino))
491 }
492
493 fn io_error(operation: &'static str, path: &Path, source: Errno) -> WorkbenchError {
494 WorkbenchError::Io {
495 operation,
496 path: path.to_path_buf(),
497 source: source.into(),
498 }
499 }
500}
501
502#[cfg(all(test, any(target_os = "linux", target_vendor = "apple")))]
503mod race_tests {
504 use super::*;
505 use std::fs;
506
507 #[test]
508 fn concurrently_created_destination_is_not_replaced_and_staging_is_removed() {
509 let root = tempfile::tempdir().expect("export root");
510 let canonical_root = root.path().canonicalize().expect("canonical export root");
511 fs::create_dir(canonical_root.join("parent")).expect("destination parent");
512 let destination = Path::new("parent/workbench");
513 let artifacts = vec![(PathBuf::from("project-view.json"), b"{}".to_vec())];
514
515 let error =
516 secure::publish_with_before_install(&canonical_root, destination, &artifacts, || {
517 fs::create_dir(canonical_root.join(destination)).expect("concurrent destination");
518 fs::write(
519 canonical_root.join(destination).join("sentinel"),
520 "owned elsewhere",
521 )
522 .expect("concurrent sentinel");
523 })
524 .expect_err("concurrent destination must fail closed");
525
526 assert!(matches!(error, WorkbenchError::DestinationExists(_)));
527 assert_eq!(
528 fs::read_to_string(canonical_root.join(destination).join("sentinel"))
529 .expect("concurrent sentinel retained"),
530 "owned elsewhere"
531 );
532 let entries = fs::read_dir(canonical_root.join("parent"))
533 .expect("destination parent entries")
534 .map(|entry| entry.expect("parent entry").file_name())
535 .collect::<Vec<_>>();
536 assert_eq!(entries, vec![OsString::from("workbench")]);
537 }
538
539 #[test]
540 fn destination_parent_identity_swap_fails_closed_and_cleans_only_owned_staging() {
541 let root = tempfile::tempdir().expect("export root");
542 let canonical_root = root.path().canonicalize().expect("canonical export root");
543 fs::create_dir(canonical_root.join("parent")).expect("destination parent");
544 let destination = Path::new("parent/workbench");
545 let artifacts = vec![(PathBuf::from("project-view.json"), b"{}".to_vec())];
546
547 let error =
548 secure::publish_with_before_install(&canonical_root, destination, &artifacts, || {
549 fs::rename(
550 canonical_root.join("parent"),
551 canonical_root.join("moved-parent"),
552 )
553 .expect("swap original parent");
554 fs::create_dir(canonical_root.join("parent")).expect("replacement parent");
555 })
556 .expect_err("parent identity swap must fail closed");
557
558 assert!(
559 error
560 .to_string()
561 .contains("destination parent identity changed")
562 );
563 assert!(!canonical_root.join(destination).exists());
564 assert!(!canonical_root.join("moved-parent/workbench").exists());
565 assert!(
566 fs::read_dir(canonical_root.join("moved-parent"))
567 .expect("original parent entries")
568 .next()
569 .is_none()
570 );
571 }
572
573 #[test]
574 fn preexisting_staging_entry_is_never_adopted_and_name_collision_is_retried() {
575 let root = tempfile::tempdir().expect("export root");
576 let canonical_root = root.path().canonicalize().expect("canonical export root");
577 let parent = canonical_root.join("parent");
578 fs::create_dir(&parent).expect("destination parent");
579 let occupied = parent.join(".occupied.staging");
580 fs::create_dir(&occupied).expect("preexisting staging entry");
581 fs::write(occupied.join("sentinel"), "owned elsewhere")
582 .expect("preexisting staging sentinel");
583 let destination = Path::new("parent/workbench");
584 let artifacts = vec![(PathBuf::from("project-view.json"), b"{}".to_vec())];
585
586 secure::publish_with_staging_names(
587 &canonical_root,
588 destination,
589 &artifacts,
590 [
591 OsString::from(".occupied.staging"),
592 OsString::from(".owned.staging"),
593 ],
594 )
595 .expect("collision should retry with an exclusively created staging name");
596
597 assert_eq!(
598 fs::read_to_string(occupied.join("sentinel")).expect("sentinel retained"),
599 "owned elsewhere"
600 );
601 assert!(!parent.join(".owned.staging").exists());
602 assert_eq!(
603 fs::read_to_string(parent.join("workbench/project-view.json"))
604 .expect("published artifact"),
605 "{}"
606 );
607 }
608
609 #[test]
610 fn unsupported_no_clobber_primitive_fails_closed_and_removes_owned_staging() {
611 let root = tempfile::tempdir().expect("export root");
612 let canonical_root = root.path().canonicalize().expect("canonical export root");
613 let parent = canonical_root.join("parent");
614 fs::create_dir(&parent).expect("destination parent");
615 let destination = Path::new("parent/workbench");
616 let artifacts = vec![(PathBuf::from("project-view.json"), b"{}".to_vec())];
617
618 for source in [
619 rustix::io::Errno::NOSYS,
620 rustix::io::Errno::NOTSUP,
621 rustix::io::Errno::OPNOTSUPP,
622 ] {
623 let error = secure::publish_with_install_error(
624 &canonical_root,
625 destination,
626 &artifacts,
627 source,
628 )
629 .expect_err("unsupported no-clobber primitive must fail closed");
630
631 assert!(matches!(error, WorkbenchError::SafeInstallationUnsupported));
632 assert!(!canonical_root.join(destination).exists());
633 assert!(
634 fs::read_dir(&parent)
635 .expect("destination parent entries")
636 .next()
637 .is_none()
638 );
639 }
640 }
641
642 #[test]
643 fn post_install_failure_removes_the_owned_destination() {
644 let root = tempfile::tempdir().expect("export root");
645 let canonical_root = root.path().canonicalize().expect("canonical export root");
646 let parent = canonical_root.join("parent");
647 fs::create_dir(&parent).expect("destination parent");
648 let destination = Path::new("parent/workbench");
649 let artifacts = vec![(PathBuf::from("project-view.json"), b"{}".to_vec())];
650
651 let error = secure::publish_with_post_install_error(
652 &canonical_root,
653 destination,
654 &artifacts,
655 rustix::io::Errno::IO,
656 )
657 .expect_err("post-install failure must fail closed");
658
659 assert!(error.to_string().contains("sync destination parent"));
660 assert!(!canonical_root.join(destination).exists());
661 assert!(
662 fs::read_dir(parent)
663 .expect("destination parent entries")
664 .next()
665 .is_none()
666 );
667 }
668
669 #[test]
670 fn staging_identity_swap_never_removes_the_unrelated_replacement_entry() {
671 let root = tempfile::tempdir().expect("export root");
672 let canonical_root = root.path().canonicalize().expect("canonical export root");
673 let parent = canonical_root.join("parent");
674 fs::create_dir(&parent).expect("destination parent");
675 let destination = Path::new("parent/workbench");
676 let artifacts = vec![(PathBuf::from("project-view.json"), b"{}".to_vec())];
677 let replacement_name = std::cell::RefCell::new(None);
678
679 let error =
680 secure::publish_with_before_install(&canonical_root, destination, &artifacts, || {
681 let staging_name = fs::read_dir(&parent)
682 .expect("staging entries")
683 .map(|entry| entry.expect("staging entry").file_name())
684 .find(|name| name.to_string_lossy().ends_with(".staging"))
685 .expect("created staging name");
686 fs::rename(
687 parent.join(&staging_name),
688 parent.join("moved-owned-staging"),
689 )
690 .expect("move owned staging");
691 fs::create_dir(parent.join(&staging_name)).expect("unrelated replacement staging");
692 replacement_name.replace(Some(staging_name));
693 })
694 .expect_err("staging identity swap must fail closed");
695
696 assert!(
697 error
698 .to_string()
699 .contains("staging directory identity changed")
700 );
701 let replacement_name = replacement_name
702 .into_inner()
703 .expect("replacement staging name");
704 assert!(
705 parent.join(replacement_name).is_dir(),
706 "cleanup must not remove the unrelated replacement entry"
707 );
708 assert!(parent.join("moved-owned-staging").is_dir());
709 assert!(
710 !parent
711 .join("moved-owned-staging/project-view.json")
712 .exists()
713 );
714 assert!(!canonical_root.join(destination).exists());
715 }
716}
717
718#[cfg(not(any(target_os = "linux", target_vendor = "apple")))]
719mod secure {
720 use super::{Path, PathBuf, WorkbenchError};
721
722 pub(super) fn publish(
723 _root: &Path,
724 _destination: &Path,
725 _artifacts: &[(PathBuf, Vec<u8>)],
726 ) -> Result<(), WorkbenchError> {
727 Err(WorkbenchError::SafeInstallationUnsupported)
728 }
729}