1use 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
38pub struct Package {
40 pub file: tokio::fs::File,
41 pub size: u64,
42 pub filename: String,
44 pub files: usize,
45}
46
47struct Entry {
48 zip_path: String,
49 sha256: String,
50}
51
52pub 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 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 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 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 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 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 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}