Skip to main content

teamcraft/services/
package.rs

1//! "Pack and Go": an assembly with everything it consists of as one ZIP file.
2//!
3//! The multi-level BOM of a revision is resolved with a revision rule
4//! (default *as released*); the ZIP holds the current version of every file
5//! of every resolved revision in a folder `<item>_<revision>/`, plus
6//!
7//! * `bom.csv` – the indented BOM (the same as the BOM CSV export),
8//! * `manifest.json` – root, rule, every revision with its files and SHA-256
9//!   and the change order that released it with its approvers
10//!   (`released_by`), and the BOM lines the rule could not resolve.
11//!
12//! The archive is written to a temporary file in the vault (so it can be
13//! larger than memory) and streamed; the file is unlinked right after it is
14//! opened. ZIP writing: the `zip` crate (MIT), Deflate only.
15
16use std::{
17    collections::HashSet,
18    io::{Read, Write},
19    path::PathBuf,
20};
21
22use serde_json::json;
23use sqlx::PgPool;
24use uuid::Uuid;
25use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};
26
27use crate::{
28    auth::Actor,
29    domain::RevisionRule,
30    error::{AppError, AppResult},
31    services::{
32        access_log::{self, AccessCtx},
33        bom, changes, files, project_policy, revisions,
34    },
35    vault::Vault,
36};
37
38/// A finished archive: an open, already unlinked temporary file.
39pub struct Package {
40    pub file: tokio::fs::File,
41    pub size: u64,
42    /// Suggested download name, e.g. `ASM-000001_B_as_released.zip`.
43    pub filename: String,
44    pub files: usize,
45}
46
47struct Entry {
48    zip_path: String,
49    sha256: String,
50}
51
52/// Builds the package of a revision.
53pub async fn build(
54    pool: &PgPool,
55    vault: &Vault,
56    actor: &Actor,
57    root_revision_id: Uuid,
58    rule: RevisionRule,
59    log: &AccessCtx,
60) -> AppResult<Package> {
61    let root = revisions::get(pool, actor, root_revision_id).await?;
62    // groups that switched packages off: the root refuses, other items are left out
63    let narrowed = project_policy::package_actor(pool, actor, root.project_id).await?;
64    let actor = &narrowed;
65    let tree = bom::tree(pool, actor, root_revision_id, rule).await?;
66
67    // Every revision of the structure once: the root, then depth first.
68    let mut revs = vec![(
69        root.id,
70        root.item_number.clone(),
71        root.code.clone(),
72        root.name.clone(),
73        root.state.clone(),
74    )];
75    let mut seen = HashSet::from([root.id]);
76    let mut unresolved = Vec::new();
77    for row in bom::flatten(&tree.children) {
78        let n = &row.node;
79        match &n.revision {
80            Some(r) if seen.insert(r.id) => revs.push((
81                r.id,
82                n.item_number.clone(),
83                r.code.clone(),
84                r.name.clone(),
85                r.state.clone(),
86            )),
87            Some(_) => {}
88            // hidden children contribute no files and no identity
89            None if n.restricted => {}
90            None => unresolved.push(
91                json!({ "item": n.item_number, "find_number": n.find_number, "level": n.level }),
92            ),
93        }
94    }
95
96    let ids: Vec<Uuid> = revs.iter().map(|r| r.0).collect();
97    // one read log entry per group of the structure that logs (the root's too)
98    access_log::record_structure(pool, log, actor, access_log::Kind::Package, &ids).await?;
99    let mut files_of = files::list_many_by_revision(pool, &ids).await?;
100    // who released what: the change order and its approvers per revision
101    let mut released_by = changes::releases_of(pool, actor, &ids).await?;
102    let mut entries = Vec::new();
103    let mut manifest_revisions = Vec::new();
104    for (id, number, code, name, state) in &revs {
105        let folder = format!("{number}_{code}");
106        let mut listed = Vec::new();
107        for f in files_of.remove(id).unwrap_or_default() {
108            let zip_path = format!("{folder}/{}", f.filename);
109            listed.push(
110                json!({ "path": zip_path, "sha256": f.sha256, "size": f.size_bytes,
111                                "version": f.version, "format": f.format }),
112            );
113            entries.push(Entry {
114                zip_path,
115                sha256: f.sha256,
116            });
117        }
118        manifest_revisions.push(json!({ "item": number, "revision": code, "name": name,
119                                         "state": state, "files": listed,
120                                         "released_by": released_by.remove(id) }));
121    }
122    let manifest = json!({
123        "generated_by": format!("TeamCraft {}", env!("CARGO_PKG_VERSION")),
124        "generated_at": chrono::Utc::now(),
125        "root": format!("{}/{}", root.item_number, root.code),
126        "rule": rule.as_str(),
127        "revisions": manifest_revisions,
128        "unresolved": unresolved,
129        "restricted_lines": tree.restricted_lines,
130    });
131    let bom_csv = bom::to_csv(&tree);
132
133    let tmp = vault
134        .tmp_dir()
135        .join(format!("package-{}.zip", Uuid::new_v4()));
136    let blob_paths: Vec<(String, PathBuf)> = entries
137        .iter()
138        .map(|e| Ok((e.zip_path.clone(), vault.object_path(&e.sha256)?)))
139        .collect::<AppResult<_>>()?;
140    let count = blob_paths.len();
141    let path = tmp.clone();
142    tokio::task::spawn_blocking(move || write_zip(&path, &blob_paths, &bom_csv, &manifest))
143        .await
144        .map_err(|e| AppError::Other(anyhow::anyhow!("package task failed: {e}")))??;
145
146    let file = tokio::fs::File::open(&tmp).await?;
147    let size = file.metadata().await?.len();
148    // Unlinked now; the open handle keeps the data until the download ends.
149    let _ = tokio::fs::remove_file(&tmp).await;
150    Ok(Package {
151        file,
152        size,
153        filename: format!("{}_{}_{}.zip", root.item_number, root.code, rule.as_str()),
154        files: count,
155    })
156}
157
158fn write_zip(
159    path: &PathBuf,
160    blobs: &[(String, PathBuf)],
161    bom_csv: &str,
162    manifest: &serde_json::Value,
163) -> AppResult<()> {
164    let result = (|| -> anyhow::Result<()> {
165        let out = std::fs::File::create(path)?;
166        let mut zip = ZipWriter::new(std::io::BufWriter::new(out));
167        let options = SimpleFileOptions::default()
168            .compression_method(CompressionMethod::Deflated)
169            .large_file(true);
170        zip.start_file("bom.csv", options)?;
171        zip.write_all(bom_csv.as_bytes())?;
172        zip.start_file("manifest.json", options)?;
173        zip.write_all(serde_json::to_string_pretty(manifest)?.as_bytes())?;
174        let mut buf = vec![0u8; 256 * 1024];
175        for (name, blob) in blobs {
176            zip.start_file(name.as_str(), options)?;
177            let mut input = std::fs::File::open(blob)?;
178            loop {
179                let n = input.read(&mut buf)?;
180                if n == 0 {
181                    break;
182                }
183                zip.write_all(&buf[..n])?;
184            }
185        }
186        zip.finish()?.flush()?;
187        Ok(())
188    })();
189    if let Err(e) = result {
190        let _ = std::fs::remove_file(path);
191        return Err(AppError::Other(e.context("writing the package failed")));
192    }
193    Ok(())
194}