1use std::collections::BTreeMap;
26use std::path::{Path, PathBuf};
27
28use anyhow::{bail, Context, Result};
29use chrono::{DateTime, Utc};
30use holger_update_bundle::core::{PolicyResult, SourceProvenance};
31use holger_update_bundle::digest::sha256_hex;
32use holger_update_bundle::finalize::{add_approval, finalize};
33use holger_update_bundle::prepare::{PayloadInput, Preparation};
34use holger_update_bundle::schema::PAYLOAD_PREFIX;
35use holger_update_bundle::{atomic_write, pack_bundle, TrustPolicy, VerifyReport};
36use serde::{Deserialize, Serialize};
37
38use crate::bundle::SignerFiles;
39
40pub const SIGS_PRODUCT: &str = "holger.scanner-sigs/v1";
42pub const DEFAULT_SIGS_SUPPLIER: &str = "holger-scanner-sigs";
44pub const CLAMAV_EXTENSIONS: &[&str] = &[
47 "cvd", "cld", "cud", "cdb", "cfg", "crb", "fp", "sfp", "gdb", "hdb", "hsb", "hdu", "hsu",
48 "idb", "ign", "ign2", "info", "ldb", "ldu", "mdb", "mdu", "msb", "msu", "ndb", "ndu", "pdb",
49 "wdb", "ftm", "cbc", "pwdb", "imp",
50];
51pub const YARA_EXTENSIONS: &[&str] = &["yar", "yara"];
53pub const INSTALLED_SIGS_SCHEMA: &str = "holger.scanner-sigs.installed/v1";
55pub const MAX_NAME: usize = 128;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum Engine {
62 Clamav,
63 Yara,
64}
65
66impl Engine {
67 pub fn dir(self) -> &'static str {
69 match self {
70 Engine::Clamav => "clamav",
71 Engine::Yara => "yara",
72 }
73 }
74
75 fn extensions(self) -> &'static [&'static str] {
76 match self {
77 Engine::Clamav => CLAMAV_EXTENSIONS,
78 Engine::Yara => YARA_EXTENSIONS,
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct SigFile {
86 pub engine: Engine,
87 pub name: String,
88 pub bytes: Vec<u8>,
89}
90
91impl SigFile {
92 pub fn rel(&self) -> String {
94 format!("{}/{}", self.engine.dir(), self.name)
95 }
96}
97
98pub fn check_name(engine: Engine, name: &str) -> Result<(), String> {
101 if name.is_empty()
102 || name.len() > MAX_NAME
103 || name.starts_with('.')
104 || !name
105 .chars()
106 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
107 {
108 return Err(format!(
109 "{}/{name:?} is not a bare signature file name",
110 engine.dir()
111 ));
112 }
113 let ext = name
114 .rsplit_once('.')
115 .map(|(_, e)| e.to_ascii_lowercase())
116 .unwrap_or_default();
117 if !engine.extensions().contains(&ext.as_str()) {
118 return Err(format!(
119 "{}/{name} is not a {} signature file (an extension of {})",
120 engine.dir(),
121 engine.dir(),
122 engine.extensions().join(", ")
123 ));
124 }
125 Ok(())
126}
127
128pub fn parse_rel(rel: &str) -> Result<(Engine, String), String> {
130 let (dir, name) = rel
131 .split_once('/')
132 .ok_or_else(|| format!("{rel:?} is not under clamav/ or yara/"))?;
133 let engine = match dir {
134 "clamav" => Engine::Clamav,
135 "yara" => Engine::Yara,
136 _ => return Err(format!("{rel:?} is not under clamav/ or yara/")),
137 };
138 check_name(engine, name)?;
139 Ok((engine, name.to_string()))
140}
141
142pub fn read_dirs(clamav: Option<&Path>, yara: Option<&Path>) -> Result<Vec<SigFile>> {
144 let mut out = Vec::new();
145 for (engine, dir) in [(Engine::Clamav, clamav), (Engine::Yara, yara)] {
146 let Some(dir) = dir else { continue };
147 let mut names: Vec<PathBuf> = std::fs::read_dir(dir)
148 .with_context(|| format!("read {}", dir.display()))?
149 .flatten()
150 .map(|e| e.path())
151 .filter(|p| p.is_file())
152 .collect();
153 names.sort();
154 for p in names {
155 let name = p
156 .file_name()
157 .map(|n| n.to_string_lossy().into_owned())
158 .unwrap_or_default();
159 check_name(engine, &name).map_err(|e| anyhow::anyhow!("{}: {e}", dir.display()))?;
160 out.push(SigFile {
161 engine,
162 name,
163 bytes: std::fs::read(&p).with_context(|| format!("read {}", p.display()))?,
164 });
165 }
166 }
167 if out.is_empty() {
168 bail!("no signature file to carry: name a --clamav-dir or --yara-dir that holds one");
169 }
170 Ok(out)
171}
172
173pub fn files_of(members: &BTreeMap<String, Vec<u8>>) -> Result<Vec<SigFile>, String> {
176 let mut out = Vec::new();
177 for (path, bytes) in members
178 .iter()
179 .filter(|(p, _)| p.starts_with(PAYLOAD_PREFIX))
180 {
181 let rel = &path[PAYLOAD_PREFIX.len()..];
182 let (engine, name) = parse_rel(rel)?;
183 out.push(SigFile {
184 engine,
185 name,
186 bytes: bytes.clone(),
187 });
188 }
189 if out.is_empty() {
190 return Err("the bundle carries no signature file".to_string());
191 }
192 Ok(out)
193}
194
195pub struct SigsBuild {
197 pub sequence: u64,
199 pub created_at: DateTime<Utc>,
200 pub expires_at: Option<DateTime<Utc>>,
201 pub supplier: String,
202 pub producer: String,
203 pub policy: TrustPolicy,
205 pub approvals: Vec<(String, SignerFiles)>,
206 pub release: SignerFiles,
207 pub out: PathBuf,
208}
209
210pub struct BuiltSigs {
212 pub path: PathBuf,
213 pub bundle_id: String,
214 pub sequence: u64,
215 pub files: Vec<String>,
216 pub report: VerifyReport,
217}
218
219pub fn build_sigs_bundle(files: &[SigFile], opts: SigsBuild) -> Result<BuiltSigs> {
221 if files.is_empty() {
222 bail!("a signature bundle carries at least one file");
223 }
224 for f in files {
225 check_name(f.engine, &f.name).map_err(|e| anyhow::anyhow!("{e}"))?;
226 }
227 let date = opts.created_at.format("%Y-%m-%d");
228 let bundle_id = format!("holger-scanner-sigs-{}-{date}", opts.sequence);
229 let total: u64 = files.iter().map(|f| f.bytes.len() as u64).sum();
230 let index: Vec<String> = files
231 .iter()
232 .map(|f| format!("{} {}", sha256_hex(&f.bytes), f.rel()))
233 .collect();
234 let prep = Preparation {
235 bundle_id: bundle_id.clone(),
236 sequence: opts.sequence,
237 created_at: opts.created_at,
238 expires_at: opts.expires_at,
239 producer: opts.producer.clone(),
240 supplier: opts.supplier.clone(),
241 product: SIGS_PRODUCT.to_string(),
242 release_version: format!("{date}.{}", opts.sequence),
243 supported_hardware_models: vec![],
244 supported_hardware_revisions: vec![],
245 source_provenance: SourceProvenance {
246 source_kind: "scanner-signatures".into(),
247 source_uri: None,
248 source_repository: None,
249 source_asset_path: None,
250 fetched_at: None,
251 reported_size: Some(total),
252 vendor_checksum: None,
253 vendor_signature_status: None,
254 tls_origin: None,
255 transfer_receipt_digest: None,
256 },
257 policy_result: PolicyResult {
258 policy_digest: sha256_hex(opts.policy.to_ron()?.as_bytes()),
259 advisory_snapshot_digest: Some(sha256_hex(index.join("\n").as_bytes())),
260 verdict: "pass".into(),
261 non_production: false,
262 },
263 supersedes: vec![],
264 withdraws: vec![],
265 payloads: files
266 .iter()
267 .map(|f| PayloadInput {
268 relative_path: f.rel(),
269 bytes: f.bytes.clone(),
270 media_type: "application/octet-stream".into(),
271 })
272 .collect(),
273 evidence: vec![],
274 };
275 let candidate = prep
276 .build()
277 .map_err(|e| anyhow::anyhow!("prepare the signature bundle: {e}"))?;
278 let mut members = candidate.members;
279 let now = Utc::now();
280 for (role, s) in &opts.approvals {
281 add_approval(&mut members, role, &s.cert_der, &s.key_pkcs8, None, now)
282 .map_err(|e| anyhow::anyhow!("approve the signature bundle as {role}: {e}"))?;
283 }
284 let report = finalize(
285 &mut members,
286 &opts.policy,
287 &opts.release.cert_der,
288 &opts.release.key_pkcs8,
289 now,
290 )
291 .map_err(|e| anyhow::anyhow!("sign the signature bundle: {e}"))?;
292 if let Some(p) = opts.out.parent().filter(|p| !p.as_os_str().is_empty()) {
293 std::fs::create_dir_all(p).ok();
294 }
295 pack_bundle(&members, &opts.out)
296 .map_err(|e| anyhow::anyhow!("pack the signature bundle: {e}"))?;
297 Ok(BuiltSigs {
298 path: opts.out,
299 bundle_id,
300 sequence: opts.sequence,
301 files: files.iter().map(SigFile::rel).collect(),
302 report,
303 })
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct InstalledFile {
309 pub path: String,
311 pub sha256: String,
312 pub size: u64,
313 pub sequence: u64,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct InstalledSigs {
320 pub schema: String,
321 pub bundle_id: String,
322 pub sequence: u64,
323 pub supplier: String,
324 pub installed_at: DateTime<Utc>,
325 pub files: Vec<InstalledFile>,
326}
327
328#[derive(Debug, Clone)]
331pub struct SigsDb {
332 root: PathBuf,
333}
334
335impl SigsDb {
336 pub fn open(root: impl Into<PathBuf>) -> Self {
337 Self { root: root.into() }
338 }
339
340 pub fn root(&self) -> &Path {
341 &self.root
342 }
343
344 pub fn dir(&self, engine: Engine) -> PathBuf {
345 self.root.join(engine.dir())
346 }
347
348 pub fn installed_path(&self) -> PathBuf {
349 self.root.join("installed.json")
350 }
351
352 pub fn installed(&self) -> Result<Option<InstalledSigs>> {
354 match std::fs::read(self.installed_path()) {
355 Ok(b) => Ok(Some(serde_json::from_slice(&b).context("installed.json")?)),
356 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
357 Err(e) => Err(e).context("installed.json"),
358 }
359 }
360
361 pub fn install(
364 &self,
365 bundle_id: &str,
366 sequence: u64,
367 supplier: &str,
368 files: &[SigFile],
369 now: DateTime<Utc>,
370 ) -> Result<InstalledSigs> {
371 let mut set: BTreeMap<String, InstalledFile> = self
372 .installed()?
373 .map(|i| i.files.into_iter().map(|f| (f.path.clone(), f)).collect())
374 .unwrap_or_default();
375 for f in files {
376 check_name(f.engine, &f.name).map_err(|e| anyhow::anyhow!("{e}"))?;
377 let dir = self.dir(f.engine);
378 std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?;
379 let path = dir.join(&f.name);
380 atomic_write(&path, &f.bytes).map_err(|e| anyhow::anyhow!("write {}: {e}", f.rel()))?;
381 #[cfg(unix)]
383 {
384 use std::os::unix::fs::PermissionsExt as _;
385 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
386 .with_context(|| format!("chmod {}", path.display()))?;
387 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755))
388 .with_context(|| format!("chmod {}", dir.display()))?;
389 }
390 set.insert(
391 f.rel(),
392 InstalledFile {
393 path: f.rel(),
394 sha256: sha256_hex(&f.bytes),
395 size: f.bytes.len() as u64,
396 sequence,
397 },
398 );
399 }
400 let installed = InstalledSigs {
401 schema: INSTALLED_SIGS_SCHEMA.to_string(),
402 bundle_id: bundle_id.to_string(),
403 sequence,
404 supplier: supplier.to_string(),
405 installed_at: now,
406 files: set.into_values().collect(),
407 };
408 atomic_write(
409 &self.installed_path(),
410 &serde_json::to_vec_pretty(&installed)?,
411 )
412 .map_err(|e| anyhow::anyhow!("write installed.json: {e}"))?;
413 Ok(installed)
414 }
415}