Skip to main content

minco_workbench/
export.rs

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(&current, 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}