1#![forbid(unsafe_code)]
11
12use std::fs;
13use std::path::{Path, PathBuf};
14use vanta_core::{Area, Artifact, Platform, StoreKey, VtaError, VtaResult};
15use vanta_net::Downloader;
16use vanta_state::{GenerationRecord, State, StoreEntryMeta};
17use vanta_store::Store;
18
19pub struct Engine {
21 store: Store,
22 state: State,
23 downloader: Downloader,
24 home: PathBuf,
25}
26
27impl Engine {
28 pub fn open(home: impl AsRef<Path>) -> VtaResult<Engine> {
30 let home = home.as_ref().to_path_buf();
31 let store = Store::open(&home)?;
32 let state = State::open(&home.join("state.db"))?;
33 let downloader = Downloader::new()?;
34 Ok(Engine {
35 store,
36 state,
37 downloader,
38 home,
39 })
40 }
41
42 pub fn store(&self) -> &Store {
44 &self.store
45 }
46 pub fn state(&self) -> &State {
47 &self.state
48 }
49
50 pub fn install_artifact(
54 &self,
55 tool: &str,
56 version: &str,
57 artifact: &Artifact,
58 ) -> VtaResult<StoreKey> {
59 if let Some(key) = &artifact.store_key {
61 if self.store.has(key) {
62 self.link_bins(key, &artifact.bin)?;
63 self.record(tool, version, key, &artifact.checksum.value)?;
64 return Ok(key.clone());
65 }
66 }
67
68 let dl = self
70 .store
71 .downloads_dir()
72 .join(format!("incoming-{tool}-{}", std::process::id()));
73 let mut urls = vec![artifact.url.clone()];
74 urls.extend(artifact.mirrors.clone());
75 self.downloader.download_any(&urls, &dl)?;
76
77 if let Err(e) =
79 vanta_security::verify_file(&dl, &artifact.checksum.algo, &artifact.checksum.value)
80 {
81 let _ = fs::remove_file(&dl);
82 return Err(e);
83 }
84 if let (Some(sig), Some(key_text)) = (&artifact.signature, &artifact.signature_key) {
86 let key = vanta_security::parse_minisign_pubkey(key_text)?;
87 let bytes = fs::read(&dl).map_err(|e| io(&dl, e))?;
88 if let Err(e) = vanta_security::minisign_verify(&bytes, sig, &key) {
89 let _ = fs::remove_file(&dl);
90 return Err(e);
91 }
92 }
93
94 let staging = self.store.new_staging()?;
96 let name = artifact
97 .bin
98 .first()
99 .map(|b| basename(b))
100 .unwrap_or_else(|| tool.to_string());
101 extract(&artifact.archive, &dl, &staging, &name, artifact.strip)?;
102 let _ = fs::remove_file(&dl);
103
104 let key = self.store.publish_tree(&staging)?;
106
107 self.link_bins(&key, &artifact.bin)?;
109
110 self.record(tool, version, &key, &artifact.checksum.value)?;
112 Ok(key)
113 }
114
115 fn link_bins(&self, key: &StoreKey, bins: &[String]) -> VtaResult<()> {
119 let bin_dir = self.home.join("bin");
120 fs::create_dir_all(&bin_dir).map_err(|e| io(&bin_dir, e))?;
121 let entry = self.store.entry_path(key);
122 for bin in bins {
123 let src = entry.join(bin);
124 if src.exists() {
125 let dst = bin_dir.join(basename(bin));
126 vanta_store::link_best(&src, &dst)?;
127 }
128 }
129 Ok(())
130 }
131
132 fn record(&self, tool: &str, version: &str, key: &StoreKey, sha256: &str) -> VtaResult<()> {
133 let platform = Platform::current().token();
134 self.state.put_store_entry(
135 key.as_str(),
136 &StoreEntryMeta {
137 tool: tool.to_string(),
138 version: version.to_string(),
139 platform,
140 size: 0,
141 sha256: sha256.to_string(),
142 },
143 )?;
144 let parent = self.state.current()?;
145 let id = parent.map(|c| c + 1).unwrap_or(1);
146 self.state.append_generation(&GenerationRecord {
147 id,
148 parent,
149 command: format!("vanta add {tool}@{version}"),
150 reason: "add".to_string(),
151 tools: vec![(tool.to_string(), key.as_str().to_string())],
152 })?;
153 self.state.set_current(id)?;
154 Ok(())
155 }
156
157 fn active_store_keys(&self) -> VtaResult<Vec<StoreKey>> {
159 let mut keys = Vec::new();
160 if let Some(current) = self.state.current()? {
161 if let Some(gen) = self.state.get_generation(current)? {
162 for (_, k) in gen.tools {
163 if let Ok(sk) = StoreKey::new(k) {
164 keys.push(sk);
165 }
166 }
167 }
168 }
169 Ok(keys)
170 }
171
172 pub fn bundle_current(&self, out: &Path) -> VtaResult<usize> {
175 let keys = self.active_store_keys()?;
176 let file = fs::File::create(out).map_err(|e| io(out, e))?;
177 let enc = flate2::write::GzEncoder::new(file, flate2::Compression::default());
178 let mut builder = tar::Builder::new(enc);
179 let list = keys
180 .iter()
181 .map(|k| k.as_str())
182 .collect::<Vec<_>>()
183 .join("\n");
184 let mut header = tar::Header::new_gnu();
185 header.set_size(list.len() as u64);
186 header.set_mode(0o644);
187 header.set_cksum();
188 builder
189 .append_data(&mut header, "KEYS", list.as_bytes())
190 .map_err(|e| inst(format!("bundle KEYS: {e}")))?;
191 for key in &keys {
192 let dir = self.store.entry_path(key);
193 if dir.is_dir() {
194 builder
195 .append_dir_all(key.as_str(), &dir)
196 .map_err(|e| inst(format!("bundle {key}: {e}")))?;
197 }
198 }
199 let enc = builder
200 .into_inner()
201 .map_err(|e| inst(format!("bundle finalize: {e}")))?;
202 enc.finish()
203 .map_err(|e| inst(format!("bundle gzip: {e}")))?;
204 Ok(keys.len())
205 }
206
207 pub fn restore(&self, bundle: &Path) -> VtaResult<usize> {
210 let file = fs::File::open(bundle).map_err(|e| io(bundle, e))?;
211 let gz = flate2::read::GzDecoder::new(file);
212 let mut archive = tar::Archive::new(gz);
213 let staging = self.store.new_staging()?;
214 archive
215 .unpack(&staging)
216 .map_err(|e| inst(format!("restore unpack: {e}")))?;
217 let keys_txt =
218 fs::read_to_string(staging.join("KEYS")).map_err(|e| io(&staging.join("KEYS"), e))?;
219 let mut restored = 0;
220 for line in keys_txt.lines() {
221 let key = line.trim();
222 if key.is_empty() {
223 continue;
224 }
225 let sk = StoreKey::new(key)?;
226 let dst = self.store.entry_path(&sk);
227 let src = staging.join(key);
228 if !dst.exists() && src.is_dir() {
229 let _ = vanta_store::ensure_writable(&src);
231 fs::rename(&src, &dst).map_err(|e| io(&dst, e))?;
232 restored += 1;
233 }
234 if !self.store.verify_entry(&sk)? {
235 return Err(VtaError::new(
236 Area::Vrf,
237 1,
238 format!("restored entry {key} failed integrity verification"),
239 ));
240 }
241 }
242 let _ = fs::remove_dir_all(&staging);
243 Ok(restored)
244 }
245
246 pub fn remove(&self, tool: &str) -> VtaResult<bool> {
249 let current = match self.state.current()? {
250 Some(c) => c,
251 None => return Ok(false),
252 };
253 let gen = match self.state.get_generation(current)? {
254 Some(g) => g,
255 None => return Ok(false),
256 };
257 if !gen.tools.iter().any(|(t, _)| t == tool) {
258 return Ok(false);
259 }
260 let tools: Vec<(String, String)> = gen
261 .tools
262 .iter()
263 .filter(|(t, _)| t != tool)
264 .cloned()
265 .collect();
266 let id = current + 1;
267 self.state.append_generation(&GenerationRecord {
268 id,
269 parent: Some(current),
270 command: format!("vanta remove {tool}"),
271 reason: "remove".to_string(),
272 tools,
273 })?;
274 self.state.set_current(id)?;
275 let _ = fs::remove_file(self.home.join("bin").join(tool));
276 Ok(true)
277 }
278}
279
280fn inst(msg: String) -> VtaError {
281 VtaError::new(Area::Inst, 1, msg)
282}
283
284pub fn extract(
287 archive: &str,
288 src: &Path,
289 dest: &Path,
290 raw_name: &str,
291 strip: u32,
292) -> VtaResult<()> {
293 match archive {
294 "tar.gz" | "tgz" => extract_targz(src, dest, strip),
295 "raw" => {
296 fs::create_dir_all(dest).map_err(|e| io(dest, e))?;
297 let out = dest.join(raw_name);
298 fs::copy(src, &out).map_err(|e| io(&out, e))?;
299 set_executable(&out);
300 Ok(())
301 }
302 other => Err(VtaError::new(
303 Area::Inst,
304 3,
305 format!("unsupported archive kind `{other}` (supported: tar.gz, tgz, raw)"),
306 )),
307 }
308}
309
310fn extract_targz(src: &Path, dest: &Path, strip: u32) -> VtaResult<()> {
311 use std::path::{Component, PathBuf};
312 let file = fs::File::open(src).map_err(|e| io(src, e))?;
313 let gz = flate2::read::GzDecoder::new(file);
314 let mut archive = tar::Archive::new(gz);
315 archive.set_preserve_permissions(true);
316 let entries = archive
317 .entries()
318 .map_err(|e| VtaError::new(Area::Inst, 1, format!("reading archive: {e}")))?;
319 for entry in entries {
320 let mut entry = entry
321 .map_err(|e| VtaError::new(Area::Inst, 1, format!("reading archive entry: {e}")))?;
322 let path = entry
323 .path()
324 .map_err(|e| VtaError::new(Area::Inst, 1, format!("entry path: {e}")))?
325 .into_owned();
326 let stripped: PathBuf = path.components().skip(strip as usize).collect();
327 if stripped.as_os_str().is_empty() {
328 continue;
329 }
330 if stripped.components().any(|c| {
332 matches!(
333 c,
334 Component::ParentDir | Component::RootDir | Component::Prefix(_)
335 )
336 }) {
337 return Err(VtaError::new(
338 Area::Inst,
339 1,
340 "archive entry escapes destination (path traversal rejected)".to_string(),
341 ));
342 }
343 let out = dest.join(&stripped);
344 if let Some(parent) = out.parent() {
345 fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
346 }
347 entry
348 .unpack(&out)
349 .map_err(|e| VtaError::new(Area::Inst, 1, format!("unpacking entry: {e}")))?;
350 }
351 Ok(())
352}
353
354fn basename(p: &str) -> String {
355 p.rsplit(['/', '\\']).next().unwrap_or(p).to_string()
356}
357
358#[cfg(unix)]
359fn set_executable(path: &Path) {
360 use std::os::unix::fs::PermissionsExt;
361 if let Ok(meta) = fs::metadata(path) {
362 let mut perms = meta.permissions();
363 perms.set_mode(perms.mode() | 0o755);
364 let _ = fs::set_permissions(path, perms);
365 }
366}
367
368#[cfg(not(unix))]
369fn set_executable(_path: &Path) {}
370
371fn io(path: &Path, e: std::io::Error) -> VtaError {
372 VtaError::new(Area::Inst, 2, format!("{}: {e}", path.display()))
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 fn home(tag: &str) -> PathBuf {
380 let p = std::env::temp_dir().join(format!("vanta-install-{}-{}", tag, std::process::id()));
381 let _ = fs::remove_dir_all(&p);
382 p
383 }
384
385 #[test]
386 fn engine_opens_and_creates_state() {
387 let h = home("open");
388 let e = Engine::open(&h).unwrap();
389 assert_eq!(
390 e.state().schema_version().unwrap(),
391 vanta_state::SCHEMA_VERSION
392 );
393 let _ = fs::remove_dir_all(&h);
394 }
395
396 #[test]
397 fn extracts_targz_then_publishes() {
398 use flate2::write::GzEncoder;
399 use flate2::Compression;
400
401 let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
403 let mut header = tar::Header::new_gnu();
404 let payload = b"#!/bin/sh\necho hi\n";
405 header.set_size(payload.len() as u64);
406 header.set_mode(0o755);
407 header.set_cksum();
408 builder
409 .append_data(&mut header, "bin/tool", &payload[..])
410 .unwrap();
411 let gz = builder.into_inner().unwrap();
412 let bytes = gz.finish().unwrap();
413
414 let h = home("targz");
415 let store = Store::open(&h).unwrap();
416 let archive_path = store.downloads_dir().join("a.tar.gz");
417 fs::write(&archive_path, &bytes).unwrap();
418
419 let staging = store.new_staging().unwrap();
420 extract("tar.gz", &archive_path, &staging, "tool", 0).unwrap();
421 assert!(staging.join("bin/tool").exists());
422
423 let key = store.publish_tree(&staging).unwrap();
424 assert!(store.has(&key));
425 assert!(store.verify_entry(&key).unwrap());
426 let _ = fs::remove_dir_all(&h);
427 }
428
429 #[test]
430 fn rejects_unsupported_archive() {
431 let err = extract("tar.xz", Path::new("/x"), Path::new("/y"), "t", 0).unwrap_err();
432 assert_eq!(err.area, Area::Inst);
433 }
434}