Skip to main content

thirdpass_core/extension/
process.rs

1use anyhow::{format_err, Context, Result};
2
3use super::common;
4
5/// Static metadata advertised by a process-backed extension.
6#[derive(serde::Serialize, serde::Deserialize)]
7pub struct StaticData {
8    /// Extension short name.
9    pub name: String,
10    /// Registry hosts supported by the extension.
11    pub registry_host_names: Vec<String>,
12    /// Automatic review target selection policy.
13    #[serde(default)]
14    pub review_target_policy: common::ReviewTargetPolicy,
15}
16
17/// Extension adapter that communicates with an extension executable.
18///
19/// The adapter refreshes static extension metadata into a YAML cache when the
20/// extension is loaded and invokes the process for each dependency or registry
21/// metadata query.
22#[derive(Debug, Clone)]
23pub struct ProcessExtension {
24    process_path_: std::path::PathBuf,
25    name_: String,
26    registry_host_names_: Vec<String>,
27    review_target_policy_: common::ReviewTargetPolicy,
28}
29
30impl common::FromProcess for ProcessExtension {
31    fn from_process(
32        process_path: &std::path::Path,
33        extension_config_path: &std::path::Path,
34    ) -> Result<Self>
35    where
36        Self: Sized,
37    {
38        let static_data = refresh_static_data(process_path, extension_config_path)?;
39
40        Ok(ProcessExtension {
41            process_path_: process_path.to_path_buf(),
42            name_: static_data.name,
43            registry_host_names_: static_data.registry_host_names,
44            review_target_policy_: static_data.review_target_policy,
45        })
46    }
47}
48
49impl common::Extension for ProcessExtension {
50    fn name(&self) -> String {
51        self.name_.clone()
52    }
53
54    fn registries(&self) -> Vec<String> {
55        self.registry_host_names_.clone()
56    }
57
58    fn review_target_policy(&self) -> common::ReviewTargetPolicy {
59        self.review_target_policy_.clone()
60    }
61
62    /// Returns a list of dependencies for the given package.
63    ///
64    /// Returns one package dependencies structure per registry.
65    fn identify_package_dependencies(
66        &self,
67        package_name: &str,
68        package_version: &Option<&str>,
69        extension_args: &[String],
70    ) -> Result<Vec<common::PackageDependencies>> {
71        let mut args = vec![
72            super::commands::identify_package_dependencies::COMMAND_NAME,
73            "--package-name",
74            package_name,
75        ];
76        if let Some(package_version) = package_version {
77            args.push("--package-version");
78            args.push(package_version);
79        }
80        for extension_arg in extension_args {
81            args.push("--extension-args");
82            args.push(extension_arg);
83        }
84        let output: Box<Vec<common::PackageDependencies>> =
85            run_process(&self.process_path_, &args)?;
86        Ok(*output)
87    }
88
89    /// Returns a list of local package dependencies specification files.
90    fn identify_file_defined_dependencies(
91        &self,
92        working_directory: &std::path::Path,
93        extension_args: &[String],
94    ) -> Result<Vec<common::FileDefinedDependencies>> {
95        let working_directory = working_directory.to_str().ok_or(format_err!(
96            "Failed to parse path into string: {}",
97            working_directory.display()
98        ))?;
99        let mut args = vec![
100            super::commands::identify_file_defined_dependencies::COMMAND_NAME,
101            "--working-directory",
102            working_directory,
103        ];
104        for extension_arg in extension_args {
105            args.push("--extension-args");
106            args.push(extension_arg);
107        }
108        let output: Box<Vec<common::FileDefinedDependencies>> =
109            run_process(&self.process_path_, &args)?;
110        Ok(*output)
111    }
112
113    /// Given a package name and version, queries the remote registry for package metadata.
114    fn registries_package_metadata(
115        &self,
116        package_name: &str,
117        package_version: &Option<&str>,
118    ) -> Result<Vec<common::RegistryPackageMetadata>> {
119        let mut args = vec![
120            super::commands::registries_package_metadata::COMMAND_NAME,
121            package_name,
122        ];
123        if let Some(package_version) = package_version {
124            args.push(*package_version);
125        }
126
127        let output: Box<Vec<common::RegistryPackageMetadata>> =
128            run_process(&self.process_path_, &args)?;
129        Ok(*output)
130    }
131}
132
133fn refresh_static_data(
134    process_path: &std::path::Path,
135    extension_config_path: &std::path::Path,
136) -> Result<StaticData> {
137    let static_data: Box<StaticData> = run_process(process_path, &["static-data"])?;
138    let static_data = *static_data;
139
140    if let Some(parent) = extension_config_path.parent() {
141        std::fs::create_dir_all(parent).context(format!(
142            "Can't create extension config directory: {}",
143            parent.display()
144        ))?;
145    }
146
147    let file = std::fs::OpenOptions::new()
148        .write(true)
149        .create(true)
150        .truncate(true)
151        .open(extension_config_path)
152        .context(format!(
153            "Can't open/create file for writing: {}",
154            extension_config_path.display()
155        ))?;
156    let writer = std::io::BufWriter::new(file);
157    serde_yaml::to_writer(writer, &static_data)?;
158
159    Ok(static_data)
160}
161
162/// JSON envelope used for process extension command responses.
163#[derive(Debug, Clone, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
164pub struct ProcessResult<T> {
165    /// Successful command result.
166    pub ok: Option<T>,
167    /// Command error message.
168    pub err: Option<String>,
169}
170
171fn run_process<T>(process_path: &std::path::Path, args: &[&str]) -> Result<Box<T>>
172where
173    for<'de> T: serde::Deserialize<'de>,
174{
175    log::debug!(
176        "Executing extensions process call with arguments\n{:?}",
177        args
178    );
179    let process = process_path.to_str().ok_or(format_err!(
180        "Failed to parse string from process path: {}",
181        process_path.display()
182    ))?;
183    let handle = std::process::Command::new(process)
184        .args(args)
185        .stdin(std::process::Stdio::null())
186        .stderr(std::process::Stdio::piped())
187        .stdout(std::process::Stdio::piped())
188        .output()?;
189
190    let stdout = String::from_utf8_lossy(&handle.stdout);
191    let process_result: ProcessResult<T> = serde_json::from_str(&stdout)?;
192
193    if let Some(result) = process_result.ok {
194        Ok(Box::new(result))
195    } else if let Some(result) = process_result.err {
196        Err(format_err!(result))
197    } else {
198        Err(format_err!("Failed to find ok or err result from process."))
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::extension::common::{Extension, FromProcess, ReviewTargetPolicy};
206    use serde_json::json;
207
208    #[test]
209    fn static_data_serializes_review_target_policy() {
210        let data = StaticData {
211            name: "rs".to_string(),
212            registry_host_names: vec!["crates.io".to_string()],
213            review_target_policy: ReviewTargetPolicy {
214                excluded_exact_paths: vec!["Cargo.lock".to_string()],
215            },
216        };
217
218        let serialized = serde_json::to_value(&data).expect("failed to serialize static data");
219
220        assert_eq!(
221            serialized,
222            json!({
223                "name": "rs",
224                "registry_host_names": ["crates.io"],
225                "review_target_policy": {
226                    "excluded_exact_paths": ["Cargo.lock"]
227                }
228            })
229        );
230    }
231
232    #[test]
233    fn static_data_defaults_empty_review_target_policy() {
234        let data: StaticData = serde_json::from_value(json!({
235            "name": "rs",
236            "registry_host_names": ["crates.io"]
237        }))
238        .expect("failed to deserialize static data");
239
240        assert_eq!(data.review_target_policy, ReviewTargetPolicy::default());
241    }
242
243    #[cfg(unix)]
244    #[test]
245    fn process_extension_refreshes_cached_static_data() {
246        let tmp = tempfile::tempdir().expect("failed to create tempdir");
247        let config_path = tmp.path().join("config").join("thirdpass-rs.yaml");
248        let process_path = tmp.path().join("thirdpass-rs");
249        let stale_data = StaticData {
250            name: "stale".to_string(),
251            registry_host_names: vec!["stale.example".to_string()],
252            review_target_policy: ReviewTargetPolicy::default(),
253        };
254        std::fs::create_dir_all(config_path.parent().expect("config path has parent"))
255            .expect("failed to create config directory");
256        std::fs::write(
257            &config_path,
258            serde_yaml::to_string(&stale_data).expect("failed to serialize stale static data"),
259        )
260        .expect("failed to write stale static data");
261        std::fs::write(
262            &process_path,
263            "#!/bin/sh\nprintf '%s\\n' '{\"ok\":{\"name\":\"rs\",\"registry_host_names\":[\"crates.io\"],\"review_target_policy\":{\"excluded_exact_paths\":[\"Cargo.lock\"]}}}'\n",
264        )
265        .expect("failed to write process extension fixture");
266
267        use std::os::unix::fs::PermissionsExt;
268        let permissions = std::fs::Permissions::from_mode(0o755);
269        std::fs::set_permissions(&process_path, permissions)
270            .expect("failed to make process extension executable");
271
272        let extension = ProcessExtension::from_process(&process_path, &config_path)
273            .expect("failed to load extension");
274        let cached_data_file =
275            std::fs::File::open(&config_path).expect("failed to open refreshed static data cache");
276        let cached_data: StaticData = serde_yaml::from_reader(cached_data_file)
277            .expect("failed to read refreshed static data cache");
278
279        let expected_policy = ReviewTargetPolicy {
280            excluded_exact_paths: vec!["Cargo.lock".to_string()],
281        };
282
283        assert_eq!(extension.name(), "rs");
284        assert_eq!(extension.registries(), vec!["crates.io"]);
285        assert_eq!(extension.review_target_policy(), expected_policy);
286        assert_eq!(cached_data.name, "rs");
287        assert_eq!(cached_data.registry_host_names, vec!["crates.io"]);
288        assert_eq!(cached_data.review_target_policy, expected_policy);
289    }
290}