1use std::collections::BTreeMap;
11use std::io::Write;
12
13use flate2::write::GzEncoder;
14use flate2::Compression;
15use serde::Serialize;
16use thiserror::Error;
17
18use crate::cid::{self, ToVoidCid};
19use crate::crypto::{CommitReader, ContentKey, KeyVault};
20use crate::VoidContext;
21use crate::metadata::Commit;
22use crate::refs;
23
24use crate::store::{FsStore, ObjectStoreExt};
25use crate::VoidError;
26
27#[derive(Debug, Error)]
32pub enum PublishError {
33 #[error("no HEAD commit found")]
34 NoHead,
35 #[error("resolve error: {0}")]
36 ResolveError(String),
37 #[error("CBOR encoding error: {0}")]
38 CborError(String),
39 #[error("I/O error: {0}")]
40 IoError(#[from] std::io::Error),
41 #[error("shard error: {0}")]
42 ShardError(String),
43 #[error("void error: {0}")]
44 VoidError(#[from] VoidError),
45}
46
47pub struct PublishOptions {
52 pub ctx: VoidContext,
54 pub commit_cid: Option<String>,
56 pub include_identity: bool,
58 pub include_contributors: bool,
60 pub identity_uri: Option<String>,
62 pub repo_name: Option<String>,
64 pub html_template: String,
66}
67
68#[derive(Debug, Clone, Serialize)]
69#[serde(rename_all = "camelCase")]
70pub struct PublishOutput {
71 pub index_html: Vec<u8>,
73 pub cbor_pack: Vec<u8>,
75 pub assets: Vec<AssetFile>,
77 pub stats: PublishStats,
79 pub excluded: Vec<ExcludedFile>,
81 pub commit_cid: String,
83 pub content_key: String,
85}
86
87#[derive(Debug, Clone, Serialize)]
88#[serde(rename_all = "camelCase")]
89pub struct PublishStats {
90 pub text_files: usize,
91 pub asset_files: usize,
92 pub excluded_files: usize,
93 pub cbor_size: u64,
94 pub total_asset_size: u64,
95}
96
97#[derive(Debug, Clone, Serialize)]
98#[serde(rename_all = "camelCase")]
99pub struct AssetFile {
100 pub repo_path: String,
101 pub mime: String,
102 pub size: u64,
103 pub content: Vec<u8>,
104}
105
106#[derive(Debug, Clone, Serialize)]
107#[serde(rename_all = "camelCase")]
108pub struct ExcludedFile {
109 pub path: String,
110 pub reason: ExcludeReason,
111}
112
113#[derive(Debug, Clone, Serialize)]
114#[serde(rename_all = "camelCase")]
115pub enum ExcludeReason {
116 SecretPattern,
117 BuildArtifact,
118 BinaryExclusion,
119}
120
121#[derive(Debug, Clone)]
126pub enum FileClass {
127 Text { lang: String },
128 Asset { mime: String },
129 Excluded { reason: ExcludeReason },
130}
131
132pub fn classify_file(path: &str) -> FileClass {
134 let lower = path.to_lowercase();
135 let basename = lower.rsplit('/').next().unwrap_or(&lower);
136
137 if is_secret_pattern(basename, &lower) {
139 return FileClass::Excluded {
140 reason: ExcludeReason::SecretPattern,
141 };
142 }
143
144 if is_build_artifact(basename, &lower) {
146 return FileClass::Excluded {
147 reason: ExcludeReason::BuildArtifact,
148 };
149 }
150
151 let ext = basename.rsplit('.').next().unwrap_or("");
152
153 match ext {
155 "exe" | "dll" | "so" | "dylib" | "wasm" | "tar" | "gz" | "tgz" | "bz2" | "xz"
156 | "zip" | "rar" | "7z" | "o" | "a" | "lib" | "pyc" | "pyo" | "class" | "jar"
157 | "war" | "deb" | "rpm" | "msi" | "dmg" | "iso" => {
158 return FileClass::Excluded {
159 reason: ExcludeReason::BinaryExclusion,
160 };
161 }
162 _ => {}
163 }
164
165 match ext {
167 "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "bmp" | "tiff" | "tif" => {
168 return FileClass::Asset {
169 mime: format!("image/{}", if ext == "jpg" { "jpeg" } else { ext }),
170 };
171 }
172 "svg" => {
173 return FileClass::Asset {
174 mime: "image/svg+xml".into(),
175 };
176 }
177 "pdf" => {
178 return FileClass::Asset {
179 mime: "application/pdf".into(),
180 };
181 }
182 "woff" => {
183 return FileClass::Asset {
184 mime: "font/woff".into(),
185 };
186 }
187 "woff2" => {
188 return FileClass::Asset {
189 mime: "font/woff2".into(),
190 };
191 }
192 "ttf" => {
193 return FileClass::Asset {
194 mime: "font/ttf".into(),
195 };
196 }
197 "otf" => {
198 return FileClass::Asset {
199 mime: "font/otf".into(),
200 };
201 }
202 "mp3" | "ogg" | "wav" | "flac" => {
203 return FileClass::Asset {
204 mime: format!("audio/{}", ext),
205 };
206 }
207 "mp4" | "webm" | "avi" | "mov" => {
208 return FileClass::Asset {
209 mime: format!("video/{}", ext),
210 };
211 }
212 _ => {}
213 }
214
215 let lang = match ext {
217 "rs" => "rust",
218 "ts" | "tsx" => "typescript",
219 "js" | "jsx" | "mjs" | "cjs" => "javascript",
220 "py" => "python",
221 "go" => "go",
222 "rb" => "ruby",
223 "java" => "java",
224 "kt" | "kts" => "kotlin",
225 "swift" => "swift",
226 "c" | "h" => "c",
227 "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp",
228 "cs" => "csharp",
229 "php" => "php",
230 "r" => "r",
231 "scala" => "scala",
232 "zig" => "zig",
233 "nim" => "nim",
234 "lua" => "lua",
235 "ex" | "exs" => "elixir",
236 "erl" | "hrl" => "erlang",
237 "hs" => "haskell",
238 "ml" | "mli" => "ocaml",
239 "clj" | "cljs" => "clojure",
240 "dart" => "dart",
241 "v" => "v",
242 "d" => "d",
243 "jl" => "julia",
244 "pl" | "pm" => "perl",
245 "sh" | "bash" | "zsh" | "fish" => "shellscript",
246 "ps1" | "psm1" => "powershell",
247 "bat" | "cmd" => "bat",
248 "sql" => "sql",
249 "md" | "markdown" => "markdown",
250 "html" | "htm" => "html",
251 "css" | "scss" | "sass" | "less" => "css",
252 "xml" | "xsl" | "xslt" => "xml",
253 "json" | "jsonc" | "json5" => "json",
254 "yaml" | "yml" => "yaml",
255 "toml" => "toml",
256 "ini" | "cfg" | "conf" => "ini",
257 "dockerfile" => "dockerfile",
258 "makefile" => "makefile",
259 "cmake" => "cmake",
260 "nix" => "nix",
261 "tf" | "hcl" => "hcl",
262 "proto" => "protobuf",
263 "graphql" | "gql" => "graphql",
264 "svelte" => "svelte",
265 "vue" => "vue",
266 "astro" => "astro",
267 "txt" | "text" | "log" | "csv" | "tsv" => "plaintext",
268 "lock" => "plaintext",
269 "gitignore" | "gitattributes" | "editorconfig" | "voidignore" => "plaintext",
270 "env" => "plaintext", _ => "plaintext", };
273
274 let lang = if ext == basename {
276 match basename {
278 "makefile" | "gnumakefile" => "makefile",
279 "dockerfile" => "dockerfile",
280 "cmakelists.txt" => "cmake",
281 "readme" | "license" | "copying" | "changelog" | "authors" | "contributing"
282 | "todo" => "markdown",
283 _ => lang,
284 }
285 } else {
286 lang
287 };
288
289 FileClass::Text {
290 lang: lang.to_string(),
291 }
292}
293
294fn is_secret_pattern(basename: &str, full_path: &str) -> bool {
296 matches!(
298 basename,
299 "id_rsa"
300 | "id_ed25519"
301 | "id_ecdsa"
302 | "id_dsa"
303 | "keys.enc"
304 | "credentials.json"
305 | "service-account.json"
306 | ".npmrc"
307 | ".pypirc"
308 | ".netrc"
309 ) ||
310 basename.starts_with(".env") ||
312 basename.ends_with(".key") ||
314 basename.ends_with(".pem") ||
315 basename.ends_with(".p12") ||
316 basename.ends_with(".pfx") ||
317 basename.ends_with(".keystore") ||
318 basename.ends_with(".jks") ||
319 basename.ends_with(".secret") ||
320 full_path.contains(".void/") ||
322 full_path.starts_with(".git/")
323}
324
325fn is_build_artifact(basename: &str, full_path: &str) -> bool {
327 basename == "cargo.lock" ||
328 full_path.contains("node_modules/") ||
329 full_path.contains(".cargo-home/") ||
330 full_path.contains(".rustup-home/")
331}
332
333pub fn publish(opts: PublishOptions) -> Result<PublishOutput, PublishError> {
342 let objects_dir = opts.ctx.paths.void_dir.join("objects");
344 let store = FsStore::new(objects_dir).map_err(PublishError::VoidError)?;
345
346 let commit_cid_multihash = match &opts.commit_cid {
348 Some(c) => cid::parse(c).map_err(|e| PublishError::ResolveError(e.to_string()))?,
349 None => {
350 let head_commit_cid = refs::resolve_head(&opts.ctx.paths.void_dir)
351 .map_err(|e| PublishError::ResolveError(e.to_string()))?
352 .ok_or(PublishError::NoHead)?;
353 cid::from_bytes(head_commit_cid.as_bytes()).map_err(|e| PublishError::ResolveError(e.to_string()))?
354 }
355 };
356 let commit_cid_str = commit_cid_multihash.to_string();
357
358 let (commit, _metadata, reader) = opts
360 .ctx
361 .load_commit_with_metadata(&store, &commit_cid_multihash)
362 .map_err(PublishError::VoidError)?;
363
364 let ancestor_keys = collect_ancestor_keys(&store, &opts.ctx.crypto.vault, &commit);
366
367 let branch = match refs::read_head(&opts.ctx.paths.void_dir) {
369 Ok(Some(refs::HeadRef::Symbolic(name))) => Some(name),
370 _ => None,
371 };
372
373 let manifest = crate::metadata::manifest_tree::TreeManifest::from_commit(&store, &commit, &reader)
375 .map_err(PublishError::VoidError)?
376 .ok_or_else(|| PublishError::ShardError("commit has no manifest".into()))?;
377
378 let (text_files, assets, excluded) = extract_all_files(
379 &store,
380 &manifest,
381 &reader,
382 &ancestor_keys,
383 )?;
384
385 let cbor_pack = build_cbor(
387 &commit,
388 &commit_cid_str,
389 branch.as_deref(),
390 &text_files,
391 &assets,
392 opts.repo_name.as_deref(),
393 opts.identity_uri.as_deref(),
394 Some(reader.content_key().as_bytes()),
395 )?;
396
397 let index_html = &opts.html_template;
399
400 let total_asset_size: u64 = assets.iter().map(|a| a.size).sum();
402 let stats = PublishStats {
403 text_files: text_files.len(),
404 asset_files: assets.len(),
405 excluded_files: excluded.len(),
406 cbor_size: cbor_pack.len() as u64,
407 total_asset_size,
408 };
409
410 let content_key_hex = hex::encode(reader.content_key().as_bytes());
411
412 Ok(PublishOutput {
413 index_html: index_html.as_bytes().to_vec(),
414 cbor_pack,
415 assets,
416 stats,
417 excluded,
418 commit_cid: commit_cid_str,
419 content_key: content_key_hex,
420 })
421}
422
423struct TextFile {
429 lang: String,
430 gz: Vec<u8>,
431 raw_size: u64,
432}
433
434fn collect_ancestor_keys(
436 store: &FsStore,
437 vault: &KeyVault,
438 commit: &Commit,
439) -> Vec<ContentKey> {
440 let mut ancestor_keys: Vec<ContentKey> = Vec::new();
441 let mut current_parent = commit.parent().cloned();
442 for _ in 0..100 {
443 let parent_commit_cid = match current_parent {
444 Some(ref p) => p.clone(),
445 None => break,
446 };
447 let parent_cid = match parent_commit_cid.to_void_cid() {
448 Ok(c) => c,
449 Err(_) => break,
450 };
451 let parent_encrypted: void_crypto::EncryptedCommit = match store.get_blob(&parent_cid) {
452 Ok(d) => d,
453 Err(_) => break,
454 };
455 let (parent_bytes, parent_reader) = match CommitReader::open_with_vault(vault, &parent_encrypted) {
456 Ok(r) => r,
457 Err(_) => break,
458 };
459 ancestor_keys.push(*parent_reader.content_key());
460 let parent_commit = match parent_bytes.parse() {
461 Ok(c) => c,
462 Err(_) => break,
463 };
464 current_parent = parent_commit.parent().cloned();
465 }
466 ancestor_keys
467}
468
469fn extract_all_files(
471 store: &FsStore,
472 manifest: &crate::metadata::manifest_tree::TreeManifest,
473 commit_reader: &CommitReader,
474 ancestor_keys: &[ContentKey],
475) -> Result<(BTreeMap<String, TextFile>, Vec<AssetFile>, Vec<ExcludedFile>), PublishError> {
476 let mut text_files: BTreeMap<String, TextFile> = BTreeMap::new();
477 let mut assets: Vec<AssetFile> = Vec::new();
478 let mut excluded: Vec<ExcludedFile> = Vec::new();
479
480 let groups = manifest.entries_by_shard().map_err(PublishError::VoidError)?;
481 let shards = manifest.shards();
482
483 for (idx, entries) in groups.into_iter().enumerate() {
484 if entries.is_empty() {
485 continue;
486 }
487 let shard_ref = shards.get(idx).ok_or_else(|| {
488 PublishError::ShardError(format!("shard index {} out of bounds", idx))
489 })?;
490
491 let shard_cid = cid::from_bytes(shard_ref.cid.as_bytes())
492 .map_err(|e| PublishError::ShardError(e.to_string()))?;
493 let shard_encrypted: void_crypto::EncryptedShard = store.get_blob(&shard_cid).map_err(PublishError::VoidError)?;
494
495 let shard_bytes = commit_reader.decrypt_shard(
496 &shard_encrypted,
497 shard_ref.wrapped_key.as_ref(),
498 ancestor_keys,
499 )
500 .map_err(PublishError::VoidError)?;
501
502 let body = shard_bytes.decompress().map_err(PublishError::VoidError)?;
503
504 for entry in &entries {
505 let path = entry.path.as_str();
506
507 match classify_file(path) {
508 FileClass::Text { lang } => {
509 let content: Vec<u8> = body.read_file(&entry)
510 .map_err(PublishError::VoidError)?
511 .into();
512
513 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
515 encoder
516 .write_all(&content)
517 .map_err(PublishError::IoError)?;
518 let gz = encoder.finish().map_err(PublishError::IoError)?;
519
520 text_files.insert(
521 path.to_string(),
522 TextFile {
523 lang,
524 gz,
525 raw_size: entry.length,
526 },
527 );
528 }
529 FileClass::Asset { mime } => {
530 let content: Vec<u8> = body.read_file(&entry)
531 .map_err(PublishError::VoidError)?
532 .into();
533
534 assets.push(AssetFile {
535 repo_path: path.to_string(),
536 mime,
537 size: entry.length,
538 content,
539 });
540 }
541 FileClass::Excluded { reason } => {
542 excluded.push(ExcludedFile {
543 path: path.to_string(),
544 reason,
545 });
546 }
547 }
548 }
549 }
550
551 Ok((text_files, assets, excluded))
552}
553
554fn build_cbor(
565 commit: &Commit,
566 commit_cid: &str,
567 branch: Option<&str>,
568 text_files: &BTreeMap<String, TextFile>,
569 assets: &[AssetFile],
570 repo_name: Option<&str>,
571 identity_uri: Option<&str>,
572 content_key_bytes: Option<&[u8; 32]>,
573) -> Result<Vec<u8>, PublishError> {
574 use ciborium::Value;
575
576 let mut meta_entries: Vec<(Value, Value)> = vec![
578 (Value::Text("version".into()), Value::Integer(1_i64.into())),
579 (
580 Value::Text("commit_cid".into()),
581 Value::Text(commit_cid.into()),
582 ),
583 (
584 Value::Text("message".into()),
585 Value::Text(commit.message.clone()),
586 ),
587 (
588 Value::Text("timestamp".into()),
589 Value::Integer((commit.timestamp as i64).into()),
590 ),
591 ];
592
593 if let Some(name) = repo_name {
594 meta_entries.push((Value::Text("repo".into()), Value::Text(name.into())));
595 }
596
597 if let Some(b) = branch {
598 meta_entries.push((Value::Text("branch".into()), Value::Text(b.into())));
599 }
600
601 if let (Some(author_bytes), Some(sig_bytes)) = (&commit.author, &commit.signature) {
603 let verified = commit.verify().is_ok();
604 let sig_obj = Value::Map(vec![
605 (
606 Value::Text("signing_key".into()),
607 Value::Text(author_bytes.to_hex()),
608 ),
609 (
610 Value::Text("signature".into()),
611 Value::Text(sig_bytes.to_hex()),
612 ),
613 (
614 Value::Text("signed_data".into()),
615 Value::Bytes(commit.signable_bytes()),
616 ),
617 (Value::Text("verified".into()), Value::Bool(verified)),
618 ]);
619 meta_entries.push((Value::Text("signature".into()), sig_obj));
620 } else if let Some(author_bytes) = &commit.author {
621 meta_entries.push((
623 Value::Text("author".into()),
624 Value::Text(author_bytes.to_hex()),
625 ));
626 }
627
628 if let Some(uri) = identity_uri {
629 meta_entries.push((Value::Text("identity".into()), Value::Text(uri.into())));
630 }
631
632 if let Some(key_bytes) = content_key_bytes {
633 meta_entries.push((
634 Value::Text("content_key".into()),
635 Value::Text(hex::encode(key_bytes)),
636 ));
637 }
638
639 let meta = Value::Map(meta_entries);
640
641 let files_entries: Vec<(Value, Value)> = text_files
643 .iter()
644 .map(|(path, tf)| {
645 let file_map = Value::Map(vec![
646 (Value::Text("lang".into()), Value::Text(tf.lang.clone())),
647 (
648 Value::Text("size".into()),
649 Value::Integer((tf.raw_size as i64).into()),
650 ),
651 (
652 Value::Text("raw".into()),
653 Value::Integer((tf.raw_size as i64).into()),
654 ),
655 (Value::Text("gz".into()), Value::Bytes(tf.gz.clone())),
656 ]);
657 (Value::Text(path.clone()), file_map)
658 })
659 .collect();
660 let files = Value::Map(files_entries);
661
662 let assets_entries: Vec<(Value, Value)> = assets
664 .iter()
665 .map(|af| {
666 let asset_map = Value::Map(vec![
667 (
668 Value::Text("size".into()),
669 Value::Integer((af.size as i64).into()),
670 ),
671 (Value::Text("mime".into()), Value::Text(af.mime.clone())),
672 ]);
673 (Value::Text(af.repo_path.clone()), asset_map)
674 })
675 .collect();
676 let assets_map = Value::Map(assets_entries);
677
678 let root = Value::Map(vec![
680 (Value::Text("meta".into()), meta),
681 (Value::Text("files".into()), files),
682 (Value::Text("assets".into()), assets_map),
683 ]);
684
685 let mut buf = Vec::new();
686 ciborium::into_writer(&root, &mut buf)
687 .map_err(|e| PublishError::CborError(e.to_string()))?;
688
689 Ok(buf)
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 #[test]
697 fn classify_rust_file() {
698 match classify_file("src/main.rs") {
699 FileClass::Text { lang } => assert_eq!(lang, "rust"),
700 other => panic!("expected Text, got {:?}", other),
701 }
702 }
703
704 #[test]
705 fn classify_image_asset() {
706 match classify_file("docs/screenshot.png") {
707 FileClass::Asset { mime } => assert_eq!(mime, "image/png"),
708 other => panic!("expected Asset, got {:?}", other),
709 }
710 }
711
712 #[test]
713 fn classify_secret_env() {
714 match classify_file(".env.production") {
715 FileClass::Excluded { reason } => {
716 assert!(matches!(reason, ExcludeReason::SecretPattern))
717 }
718 other => panic!("expected Excluded, got {:?}", other),
719 }
720 }
721
722 #[test]
723 fn classify_key_file() {
724 match classify_file("deploy/server.key") {
725 FileClass::Excluded { reason } => {
726 assert!(matches!(reason, ExcludeReason::SecretPattern))
727 }
728 other => panic!("expected Excluded, got {:?}", other),
729 }
730 }
731
732 #[test]
733 fn classify_binary_excluded() {
734 match classify_file("target/debug/void") {
735 FileClass::Text { .. } => {}
737 FileClass::Excluded { .. } => {}
738 other => panic!("unexpected: {:?}", other),
739 }
740 match classify_file("build/app.exe") {
742 FileClass::Excluded { reason } => {
743 assert!(matches!(reason, ExcludeReason::BinaryExclusion))
744 }
745 other => panic!("expected Excluded, got {:?}", other),
746 }
747 }
748
749 #[test]
750 fn classify_unknown_extension_as_text() {
751 match classify_file("data/config.custom") {
752 FileClass::Text { lang } => assert_eq!(lang, "plaintext"),
753 other => panic!("expected Text, got {:?}", other),
754 }
755 }
756
757 #[test]
758 fn classify_svg_as_asset() {
759 match classify_file("logo.svg") {
760 FileClass::Asset { mime } => assert_eq!(mime, "image/svg+xml"),
761 other => panic!("expected Asset, got {:?}", other),
762 }
763 }
764
765 #[test]
766 fn classify_markdown() {
767 match classify_file("README.md") {
768 FileClass::Text { lang } => assert_eq!(lang, "markdown"),
769 other => panic!("expected Text, got {:?}", other),
770 }
771 }
772
773 #[test]
774 fn secret_patterns_detected() {
775 assert!(is_secret_pattern("id_rsa", "id_rsa"));
776 assert!(is_secret_pattern("id_ed25519", "id_ed25519"));
777 assert!(is_secret_pattern(".env", ".env"));
778 assert!(is_secret_pattern(".env.local", ".env.local"));
779 assert!(is_secret_pattern("server.pem", "certs/server.pem"));
780 assert!(is_secret_pattern("keys.enc", ".void/identity/keys.enc"));
781 assert!(!is_secret_pattern("readme.md", "readme.md"));
782 assert!(!is_secret_pattern("main.rs", "src/main.rs"));
783 assert!(!is_secret_pattern("cargo.lock", "cargo.lock"));
785 }
786
787 #[test]
788 fn build_artifacts_detected() {
789 assert!(is_build_artifact("cargo.lock", "cargo.lock"));
790 assert!(is_build_artifact("cachedir.tag", ".cargo-home/registry/cachedir.tag"));
791 assert!(is_build_artifact("settings.toml", ".rustup-home/settings.toml"));
792 assert!(is_build_artifact("package.json", "node_modules/foo/package.json"));
793 assert!(!is_build_artifact("main.rs", "src/main.rs"));
794 }
795
796 #[test]
797 fn classify_cargo_lock_as_build_artifact() {
798 match classify_file("Cargo.lock") {
799 FileClass::Excluded { reason } => {
800 assert!(matches!(reason, ExcludeReason::BuildArtifact))
801 }
802 other => panic!("expected Excluded(BuildArtifact), got {:?}", other),
803 }
804 }
805}