Skip to main content

opys_dev/
scan.rs

1//! A local directory, as artifacts.
2//!
3//! Two steps, because the step between them is the config author's: where
4//! each file goes, and for a published file where it is fetched from, are
5//! templates or functions in the config and cannot cross into here. So
6//! [`scan_directory`] says what is on disk, the caller places each file, and
7//! [`scanned_files`] hashes them and builds the artifacts.
8
9use std::fs::File;
10use std::io::{self, Read};
11use std::path::{Path, PathBuf};
12
13use opys_core::{Artifact, HashEntry, Integrity, Source};
14use serde::{Deserialize, Serialize};
15use sha1::{Digest, Sha1};
16use sha2::Sha256;
17
18use crate::contribution::{BuildArtifact, Contribution};
19
20#[derive(Debug, thiserror::Error)]
21pub enum ScanError {
22    #[error("cannot read {path}: {source}")]
23    Io {
24        path: String,
25        #[source]
26        source: io::Error,
27    },
28}
29
30fn io_at(path: &Path) -> impl FnOnce(io::Error) -> ScanError + '_ {
31    move |source| ScanError::Io {
32        path: path.display().to_string(),
33        source,
34    }
35}
36
37/// One file found under the scanned directory.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ScannedFile {
40    /// Path relative to the directory, `/`-separated on every platform.
41    pub rel: String,
42    /// Directory portion of `rel`; empty at the root.
43    pub dir: String,
44    /// Final path segment.
45    pub filename: String,
46    /// Absolute path on the build machine.
47    pub abs: PathBuf,
48    pub size: u64,
49}
50
51fn walk(root: &Path, dir: &Path, out: &mut Vec<ScannedFile>) -> Result<(), ScanError> {
52    for entry in std::fs::read_dir(dir).map_err(io_at(dir))? {
53        let entry = entry.map_err(io_at(dir))?;
54        let abs = entry.path();
55        // Not followed: a symlink is neither a file nor a directory here, so
56        // a link out of the tree cannot pull the rest of the disk into it.
57        let kind = entry.file_type().map_err(io_at(&abs))?;
58        if kind.is_dir() {
59            walk(root, &abs, out)?;
60        } else if kind.is_file() {
61            let size = entry.metadata().map_err(io_at(&abs))?.len();
62            let parts: Vec<String> = abs
63                .strip_prefix(root)
64                .unwrap_or(&abs)
65                .components()
66                .map(|part| part.as_os_str().to_string_lossy().into_owned())
67                .collect();
68            let (filename, dirs) = parts.split_last().expect("a file has a name");
69            out.push(ScannedFile {
70                rel: parts.join("/"),
71                dir: dirs.join("/"),
72                filename: filename.clone(),
73                abs,
74                size,
75            });
76        }
77    }
78    Ok(())
79}
80
81/// Every regular file under `root`, in `rel` order.
82///
83/// Sorted, because a directory listing is in whatever order the filesystem
84/// keeps it and the result reaches a manifest, which must not differ between
85/// two builds of the same tree.
86pub fn scan_directory(root: &Path) -> Result<Vec<ScannedFile>, ScanError> {
87    let mut files = Vec::new();
88    walk(root, root, &mut files)?;
89    files.sort_by(|a, b| a.rel.cmp(&b.rel));
90    Ok(files)
91}
92
93/// The hash a published file is pinned with.
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
95#[serde(rename_all = "lowercase")]
96pub enum ScanHash {
97    #[default]
98    Sha1,
99    Sha256,
100}
101
102/// A scanned file with its place in the installation decided.
103#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct PlacedFile {
106    /// Where the file is on this machine.
107    pub abs: PathBuf,
108    /// Where it is installed.
109    pub path: String,
110    /// Where an installer fetches it from. With one, the file is published
111    /// elsewhere and the artifact points at it; without, the file travels
112    /// with the manifest as a blob.
113    #[serde(default)]
114    pub url: Option<String>,
115}
116
117/// The digest and length of a file, read once.
118fn digest<D: Digest>(path: &Path) -> Result<(String, u64), ScanError> {
119    let mut file = File::open(path).map_err(io_at(path))?;
120    let mut hasher = D::new();
121    let mut buffer = [0u8; 64 * 1024];
122    let mut size = 0u64;
123    loop {
124        let read = file.read(&mut buffer).map_err(io_at(path))?;
125        if read == 0 {
126            return Ok((hex::encode(hasher.finalize()), size));
127        }
128        hasher.update(&buffer[..read]);
129        size += read as u64;
130    }
131}
132
133/// The artifacts of a scanned directory.
134///
135/// Every file is hashed, including one that is only pointed at: an artifact
136/// with no hash is trusted by path alone and never re-fetched, so a changed
137/// file would never reach anyone who already had the old one. The size is the
138/// length that was hashed, not the one the directory listing reported.
139pub fn scanned_files(files: &[PlacedFile], hash: ScanHash) -> Result<Contribution, ScanError> {
140    let mut contribution = Contribution::default();
141    for file in files {
142        let artifact = match &file.url {
143            // Carried: the artifact says where the file is, and the merge
144            // names it by its content.
145            None => BuildArtifact::file(&file.path, &file.abs),
146            Some(url) => {
147                let (entry, size) = match hash {
148                    ScanHash::Sha1 => {
149                        let (sha1, size) = digest::<Sha1>(&file.abs)?;
150                        (HashEntry::Sha1 { sha1 }, size)
151                    }
152                    ScanHash::Sha256 => {
153                        let (sha256, size) = digest::<Sha256>(&file.abs)?;
154                        (HashEntry::Sha256 { sha256 }, size)
155                    }
156                };
157                Artifact {
158                    path: file.path.clone(),
159                    source: Source::Url { url: url.clone() },
160                    size: Some(size),
161                    rules: Vec::new(),
162                    integrity: Some(Integrity::One(entry)),
163                    metadata: None,
164                    extract: None,
165                }
166                .into()
167            }
168        };
169        contribution.artifacts.push(artifact);
170    }
171    Ok(contribution)
172}