Skip to main content

confium_publish/
output.rs

1// Write the registry-ready directory tree for a published version.
2//
3//
4//     <plugin>/<version>/
5//       manifest.toml          # serialized manifest
6//       artifact.sha256        # "<hex>  <basename>"
7//       sigs/
8//         <publisher>.asc      # detached PGP signature
9//
10// This module also computes the artifact SHA-256, which both the
11// `artifact.sha256` file and the `[artifact].sha256` manifest field
12// reference. Hashing lives here (next to the file that consumes it)
13// rather than in `manifest.rs` so the manifest builder stays a pure
14// function of resolved inputs.
15
16use std::fs;
17use std::io::{self, Read};
18use std::path::{Path, PathBuf};
19
20use sha2::{Digest, Sha256};
21use snafu::{ResultExt, Snafu};
22
23#[derive(Snafu, Debug)]
24pub enum OutputError {
25    #[snafu(display("failed to read artifact at '{}'", path.display()))]
26    ReadArtifact { path: Box<Path>, source: io::Error },
27
28    #[snafu(display("failed to create output directory '{}'", path.display()))]
29    Mkdir { path: Box<Path>, source: io::Error },
30
31    #[snafu(display("failed to write '{}'", path.display()))]
32    WriteFile { path: Box<Path>, source: io::Error },
33}
34
35pub type Result<T> = std::result::Result<T, OutputError>;
36
37/// Compute the lowercase-hex SHA-256 of a file, reading in chunks so
38/// large artifacts don't need to fit in memory.
39pub fn sha256_of_file(path: &Path) -> Result<String> {
40    let path_boxed: Box<Path> = Box::from(path);
41    let mut file = fs::File::open(path_boxed.as_ref()).context(ReadArtifactSnafu {
42        path: path_boxed.clone(),
43    })?;
44    let mut hasher = Sha256::new();
45    let mut buf = [0u8; 64 * 1024];
46    loop {
47        let n = file.read(&mut buf).context(ReadArtifactSnafu {
48            path: path_boxed.clone(),
49        })?;
50        if n == 0 {
51            break;
52        }
53        hasher.update(&buf[..n]);
54    }
55    Ok(hex_encode(&hasher.finalize()))
56}
57
58fn hex_encode(bytes: &[u8]) -> String {
59    const HEX: &[u8; 16] = b"0123456789abcdef";
60    let mut out = String::with_capacity(bytes.len() * 2);
61    for &b in bytes {
62        out.push(HEX[(b >> 4) as usize] as char);
63        out.push(HEX[(b & 0x0f) as usize] as char);
64    }
65    out
66}
67
68/// Where each emitted file lives under the output root.
69pub struct OutputPaths {
70    pub dir: PathBuf,
71    pub manifest: PathBuf,
72    pub artifact_sha256: PathBuf,
73    pub sigs_dir: PathBuf,
74    pub signature: PathBuf,
75}
76
77/// Resolve the paths for `<root>/<plugin>/<version>/...`.
78pub fn paths_for(root: &Path, plugin: &str, version: &str, publisher: &str) -> OutputPaths {
79    let dir = root.join(plugin).join(version);
80    let sigs_dir = dir.join("sigs");
81    OutputPaths {
82        manifest: dir.join("manifest.toml"),
83        artifact_sha256: dir.join("artifact.sha256"),
84        signature: sigs_dir.join(format!("{publisher}.asc")),
85        dir,
86        sigs_dir,
87    }
88}
89
90/// Write the full tree atomically-ish: create dirs, then write each file.
91/// Returns the manifest bytes written (for signing). When `dry_run` is
92/// true, no filesystem writes occur and an empty `Vec` is returned.
93pub fn write_tree(
94    paths: &OutputPaths,
95    manifest_toml: &str,
96    artifact_path: &Path,
97    sha256_hex: &str,
98    signature: &[u8],
99    dry_run: bool,
100) -> Result<Vec<u8>> {
101    let manifest_bytes = manifest_toml.as_bytes();
102
103    if dry_run {
104        return Ok(Vec::new());
105    }
106
107    let dir_boxed: Box<Path> = Box::from(paths.dir.as_path());
108    fs::create_dir_all(dir_boxed.as_ref()).context(MkdirSnafu {
109        path: dir_boxed.clone(),
110    })?;
111    let sigs_boxed: Box<Path> = Box::from(paths.sigs_dir.as_path());
112    fs::create_dir_all(sigs_boxed.as_ref()).context(MkdirSnafu {
113        path: sigs_boxed.clone(),
114    })?;
115
116    let manifest_boxed: Box<Path> = Box::from(paths.manifest.as_path());
117    fs::write(manifest_boxed.as_ref(), manifest_bytes).context(WriteFileSnafu {
118        path: manifest_boxed.clone(),
119    })?;
120
121    let basename = artifact_path
122        .file_name()
123        .map(|n| n.to_string_lossy().into_owned())
124        .unwrap_or_else(|| "artifact".to_string());
125    let sha_line = format!("{sha256_hex}  {basename}\n");
126    let sha_boxed: Box<Path> = Box::from(paths.artifact_sha256.as_path());
127    fs::write(sha_boxed.as_ref(), sha_line).context(WriteFileSnafu {
128        path: sha_boxed.clone(),
129    })?;
130
131    let sig_boxed: Box<Path> = Box::from(paths.signature.as_path());
132    fs::write(sig_boxed.as_ref(), signature).context(WriteFileSnafu {
133        path: sig_boxed.clone(),
134    })?;
135
136    Ok(manifest_bytes.to_vec())
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn sha256_of_empty_file_matches_known_digest() {
145        let tmp = std::env::temp_dir().join("cfm_publish_test_empty");
146        fs::write(&tmp, b"").unwrap();
147        let got = sha256_of_file(&tmp).unwrap();
148        let _ = fs::remove_file(&tmp);
149        // SHA-256 of the empty string.
150        assert_eq!(
151            got,
152            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
153        );
154    }
155
156    #[test]
157    fn sha256_of_known_bytes_matches_shasum() {
158        // "abc" -> SHA-256 ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
159        let tmp = std::env::temp_dir().join("cfm_publish_test_abc");
160        fs::write(&tmp, b"abc").unwrap();
161        let got = sha256_of_file(&tmp).unwrap();
162        let _ = fs::remove_file(&tmp);
163        assert_eq!(
164            got,
165            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
166        );
167    }
168
169    #[test]
170    fn write_tree_creates_all_files() {
171        let root = std::env::temp_dir().join("cfm_publish_tree_test");
172        let _ = fs::remove_dir_all(&root);
173        let paths = paths_for(&root, "plug", "1.0.0", "pub");
174        let artifact = std::env::temp_dir().join("cfm_publish_tree_artifact");
175        fs::write(&artifact, b"payload").unwrap();
176        write_tree(
177            &paths,
178            "[plugin]\nname=\"x\"\n",
179            &artifact,
180            "deadbeef",
181            b"SIG",
182            false,
183        )
184        .unwrap();
185        assert!(paths.dir.is_dir());
186        assert!(paths.manifest.is_file());
187        assert!(paths.artifact_sha256.is_file());
188        assert!(paths.signature.is_file());
189        let sha_content = fs::read_to_string(&paths.artifact_sha256).unwrap();
190        assert!(sha_content.contains("deadbeef"));
191        let _ = fs::remove_dir_all(&root);
192        let _ = fs::remove_file(&artifact);
193    }
194
195    #[test]
196    fn dry_run_writes_nothing() {
197        let root = std::env::temp_dir().join("cfm_publish_dryrun_test");
198        let _ = fs::remove_dir_all(&root);
199        let paths = paths_for(&root, "plug", "1.0.0", "pub");
200        let artifact = std::env::temp_dir().join("cfm_publish_dryrun_art");
201        fs::write(&artifact, b"x").unwrap();
202        let bytes = write_tree(&paths, "[plugin]\n", &artifact, "aa", b"s", true).unwrap();
203        assert!(bytes.is_empty());
204        assert!(!paths.dir.exists());
205        let _ = fs::remove_file(&artifact);
206    }
207}