Skip to main content

void_core/ops/
publish.rs

1//! Publish a void repository as a browsable static website.
2//!
3//! Generates a self-contained SPA (`index.html`) plus a CBOR content pack
4//! (`content.cbor`) that can be pinned to IPFS and viewed on any gateway.
5//!
6//! Text files are individually gzipped (for browser `DecompressionStream`),
7//! binary assets are served at original paths, and commit provenance
8//! (signature, author, timestamp) is included for client-side verification.
9
10use 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// ============================================================================
28// Error type
29// ============================================================================
30
31#[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
47// ============================================================================
48// Options and output types
49// ============================================================================
50
51pub struct PublishOptions {
52    /// Repository context (void_dir, vault, etc.)
53    pub ctx: VoidContext,
54    /// Specific commit CID to publish (None = HEAD).
55    pub commit_cid: Option<String>,
56    /// Include identity URI in published metadata.
57    pub include_identity: bool,
58    /// Include contributor list in published metadata.
59    pub include_contributors: bool,
60    /// Optional identity URI string (pre-loaded by CLI).
61    pub identity_uri: Option<String>,
62    /// Optional repo name override.
63    pub repo_name: Option<String>,
64    /// HTML template for the published SPA shell.
65    pub html_template: String,
66}
67
68#[derive(Debug, Clone, Serialize)]
69#[serde(rename_all = "camelCase")]
70pub struct PublishOutput {
71    /// The rendered SPA shell.
72    pub index_html: Vec<u8>,
73    /// The CBOR content pack.
74    pub cbor_pack: Vec<u8>,
75    /// Binary asset files to write alongside.
76    pub assets: Vec<AssetFile>,
77    /// Publication statistics.
78    pub stats: PublishStats,
79    /// Files that were excluded (secrets, binaries, etc.).
80    pub excluded: Vec<ExcludedFile>,
81    /// The commit CID that was published.
82    pub commit_cid: String,
83    /// Per-commit content key (hex). Enables scoped read-only clone.
84    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// ============================================================================
122// File classification
123// ============================================================================
124
125#[derive(Debug, Clone)]
126pub enum FileClass {
127    Text { lang: String },
128    Asset { mime: String },
129    Excluded { reason: ExcludeReason },
130}
131
132/// Classify a file path into text, asset, or excluded.
133pub fn classify_file(path: &str) -> FileClass {
134    let lower = path.to_lowercase();
135    let basename = lower.rsplit('/').next().unwrap_or(&lower);
136
137    // Secret patterns (always excluded) — check basename and full path
138    if is_secret_pattern(basename, &lower) {
139        return FileClass::Excluded {
140            reason: ExcludeReason::SecretPattern,
141        };
142    }
143
144    // Build artifacts (not secrets, just noisy for publishing)
145    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    // Excluded binary formats (executables, archives)
154    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    // Asset types (served as binary)
166    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    // Text types — map extension to language identifier
216    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", // shouldn't reach here due to secret filter, but safe default
271        _ => "plaintext",     // unknown extensions default to text
272    };
273
274    // Handle extensionless files by basename
275    let lang = if ext == basename {
276        // No extension — check well-known filenames
277        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
294/// Check if a filename or path matches secret/sensitive patterns.
295fn is_secret_pattern(basename: &str, full_path: &str) -> bool {
296    // Exact basename matches
297    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    // Prefix patterns
311    basename.starts_with(".env") ||
312    // Suffix patterns
313    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    // Path patterns
321    full_path.contains(".void/") ||
322    full_path.starts_with(".git/")
323}
324
325/// Check if a filename or path is a build artifact (not secret, just noisy).
326fn 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
333// ============================================================================
334// Core publish function
335// ============================================================================
336
337/// Publish a void repository as a static website.
338///
339/// Decrypts the commit, walks all files, classifies them, gzips text content,
340/// and produces an SPA shell + CBOR content pack.
341pub fn publish(opts: PublishOptions) -> Result<PublishOutput, PublishError> {
342    // --- 1. Load metadata (same pattern as lazy.rs) ---
343    let objects_dir = opts.ctx.paths.void_dir.join("objects");
344    let store = FsStore::new(objects_dir).map_err(PublishError::VoidError)?;
345
346    // Resolve commit CID
347    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    // Decrypt commit + metadata
359    let (commit, _metadata, reader) = opts
360        .ctx
361        .load_commit_with_metadata(&store, &commit_cid_multihash)
362        .map_err(PublishError::VoidError)?;
363
364    // Build ancestor content keys for shard decryption fallback
365    let ancestor_keys = collect_ancestor_keys(&store, &opts.ctx.crypto.vault, &commit);
366
367    // Resolve current branch name
368    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    // --- 2. Walk all files via manifest ---
374    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    // --- 3. Build CBOR payload ---
386    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    // --- 4. Embed SPA shell ---
398    let index_html = &opts.html_template;
399
400    // --- 5. Build stats ---
401    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
423// ============================================================================
424// Internal helpers
425// ============================================================================
426
427/// Collected text file for CBOR encoding.
428struct TextFile {
429    lang: String,
430    gz: Vec<u8>,
431    raw_size: u64,
432}
433
434/// Collect ancestor content keys for shard decryption fallback.
435fn 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
469/// Extract all files from a commit using manifest-driven shard access.
470fn 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                    // Gzip for browser DecompressionStream
514                    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
554/// Build the CBOR content pack.
555///
556/// Structure:
557/// ```text
558/// {
559///   "meta": { version, repo, commit_cid, message, timestamp, branch?, author?, signature?, signable_bytes?, identity? },
560///   "files": { "path": { "lang": str, "size": int, "gz": bytes } },
561///   "assets": { "path": { "size": int, "mime": str } }
562/// }
563/// ```
564fn 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    // Build meta map
577    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    // Signature object (nested for SPA consumption)
602    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        // Author without signature (shouldn't happen normally)
622        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    // Build files map
642    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    // Build assets map (metadata only — actual bytes served as separate files)
663    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    // Top-level CBOR map
679    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            // extensionless in target/ — treated as text (not in excluded list by extension)
736            FileClass::Text { .. } => {}
737            FileClass::Excluded { .. } => {}
738            other => panic!("unexpected: {:?}", other),
739        }
740        // .exe is excluded
741        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        // Build artifacts are NOT secrets
784        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}