sbom_walker/source/
file.rs

1use crate::{
2    discover::DiscoveredSbom,
3    model::metadata::{self, SourceMetadata},
4    retrieve::RetrievedSbom,
5    source::Source,
6    visitors::store::DIR_METADATA,
7};
8use anyhow::{Context, anyhow};
9use bytes::Bytes;
10use std::{
11    fs,
12    io::ErrorKind,
13    path::{Path, PathBuf},
14    time::SystemTime,
15};
16use time::OffsetDateTime;
17use url::Url;
18use walker_common::{
19    retrieve::RetrievalMetadata,
20    source::file::{read_sig_and_digests, to_path},
21    utils::{self, openpgp::PublicKey},
22    validate::source::{Key, KeySource, KeySourceError},
23};
24
25#[non_exhaustive]
26#[derive(Clone, Debug, Default, PartialEq, Eq)]
27pub struct FileOptions {
28    pub since: Option<SystemTime>,
29}
30
31impl FileOptions {
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    pub fn since(mut self, since: impl Into<Option<SystemTime>>) -> Self {
37        self.since = since.into();
38        self
39    }
40}
41
42/// A file-based source, possibly created by the [`crate::visitors::store::StoreVisitor`].
43#[derive(Clone, Debug)]
44pub struct FileSource {
45    /// the path to the storage base, an absolute path
46    base: PathBuf,
47    options: FileOptions,
48}
49
50impl FileSource {
51    pub fn new(
52        base: impl AsRef<Path>,
53        options: impl Into<Option<FileOptions>>,
54    ) -> anyhow::Result<Self> {
55        Ok(Self {
56            base: fs::canonicalize(base)?,
57            options: options.into().unwrap_or_default(),
58        })
59    }
60
61    async fn scan_keys(&self) -> Result<Vec<metadata::Key>, anyhow::Error> {
62        let dir = self.base.join(DIR_METADATA).join("keys");
63
64        let mut result = Vec::new();
65
66        let mut entries = match tokio::fs::read_dir(&dir).await {
67            Err(err) if err.kind() == ErrorKind::NotFound => {
68                return Ok(result);
69            }
70            Err(err) => {
71                return Err(err)
72                    .with_context(|| format!("Failed scanning for keys: {}", dir.display()));
73            }
74            Ok(entries) => entries,
75        };
76
77        while let Some(entry) = entries.next_entry().await? {
78            let path = entry.path();
79            if !path.is_file() {
80                continue;
81            }
82
83            #[allow(clippy::single_match)]
84            match path
85                .file_name()
86                .and_then(|s| s.to_str())
87                .and_then(|s| s.rsplit_once('.'))
88            {
89                Some((name, "txt")) => result.push(metadata::Key {
90                    fingerprint: Some(name.to_string()),
91                    url: Url::from_file_path(&path).map_err(|()| {
92                        anyhow!("Failed to build file URL for: {}", path.display())
93                    })?,
94                }),
95                Some((_, _)) | None => {}
96            }
97        }
98
99        Ok(result)
100    }
101}
102
103impl walker_common::source::Source for FileSource {
104    type Error = anyhow::Error;
105    type Retrieved = RetrievedSbom;
106}
107
108impl Source for FileSource {
109    async fn load_metadata(&self) -> Result<SourceMetadata, Self::Error> {
110        let metadata = self.base.join(DIR_METADATA).join("metadata.json");
111        let file = fs::File::open(&metadata)
112            .with_context(|| format!("Failed to open file: {}", metadata.display()))?;
113
114        let mut metadata: SourceMetadata =
115            serde_json::from_reader(&file).context("Failed to read stored provider metadata")?;
116
117        metadata.keys = self.scan_keys().await?;
118
119        Ok(metadata)
120    }
121
122    async fn load_index(&self) -> Result<Vec<DiscoveredSbom>, Self::Error> {
123        const SKIP: &[&str] = &[".asc", ".sha256", ".sha512"];
124
125        log::info!("Loading index - since: {:?}", self.options.since);
126
127        let mut entries = tokio::fs::read_dir(&self.base).await?;
128        let mut result = vec![];
129
130        'entry: while let Some(entry) = entries.next_entry().await? {
131            let path = entry.path();
132            if !path.is_file() {
133                continue;
134            }
135            let name = match path.file_name().and_then(|s| s.to_str()) {
136                Some(name) => name,
137                None => continue,
138            };
139
140            for ext in SKIP {
141                if name.ends_with(ext) {
142                    log::debug!("Skipping file: {}", name);
143                    continue 'entry;
144                }
145            }
146
147            if let Some(since) = self.options.since {
148                let modified = path.metadata()?.modified()?;
149                if modified < since {
150                    log::debug!("Skipping file due to modification constraint: {modified:?}");
151                    continue;
152                }
153            }
154
155            let url = Url::from_file_path(&path)
156                .map_err(|()| anyhow!("Failed to convert to URL: {}", path.display()))?;
157
158            let modified = path.metadata()?.modified()?;
159
160            result.push(DiscoveredSbom { url, modified })
161        }
162
163        Ok(result)
164    }
165
166    async fn load_sbom(&self, discovered: DiscoveredSbom) -> Result<RetrievedSbom, Self::Error> {
167        let path = discovered
168            .url
169            .to_file_path()
170            .map_err(|()| anyhow!("Unable to convert URL into path: {}", discovered.url))?;
171
172        let data = Bytes::from(tokio::fs::read(&path).await?);
173
174        let (signature, sha256, sha512) = read_sig_and_digests(&path, &data).await?;
175
176        let last_modification = path
177            .metadata()
178            .ok()
179            .and_then(|md| md.modified().ok())
180            .map(OffsetDateTime::from);
181
182        Ok(RetrievedSbom {
183            discovered,
184            data,
185            signature,
186            sha256,
187            sha512,
188            metadata: RetrievalMetadata {
189                last_modification,
190                etag: None,
191            },
192        })
193    }
194}
195
196impl KeySource for FileSource {
197    type Error = anyhow::Error;
198
199    async fn load_public_key(
200        &self,
201        key: Key<'_>,
202    ) -> Result<PublicKey, KeySourceError<Self::Error>> {
203        let bytes = tokio::fs::read(to_path(key.url).map_err(KeySourceError::Source)?)
204            .await
205            .map_err(|err| KeySourceError::Source(err.into()))?;
206        utils::openpgp::validate_keys(bytes.into(), key.fingerprint)
207            .map_err(KeySourceError::OpenPgp)
208    }
209}