Skip to main content

scv_server/
imports.rs

1//! What `scv agents import` copied into an agent home, and whether its source
2//! has changed since.
3//!
4//! Delegated agents run from private copies so they never read or alter the
5//! user's own setup. A copy made by `scv agents import` would otherwise go
6//! stale silently, so each import records a digest of what it copied in
7//! `state/imports/<agent>.json`, and `scv agents status` and `scv config show`
8//! compare it with the source as it is now.
9
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result, anyhow};
13use scv_client::Layout;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17/// Where an import copied from.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(tag = "kind", rename_all = "snake_case")]
20pub enum Source {
21    /// Files from a directory, such as `config.toml` from `~/.grok`.
22    Files { dir: PathBuf, files: Vec<String> },
23    /// SCV's own active provider.
24    ScvProvider,
25}
26
27#[derive(Serialize, Deserialize)]
28struct Record {
29    source: Source,
30    /// SHA-256 of what was copied; it names no secret.
31    digest: String,
32    imported_unix_seconds: u64,
33}
34
35/// An import compared with its source now.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ImportStatus {
38    pub source: Source,
39    pub imported_unix_seconds: u64,
40    pub freshness: Freshness,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Freshness {
45    /// The source still matches the copy.
46    Current,
47    /// The source changed after the import.
48    Changed,
49    /// The source cannot be read now, such as a key variable that is not set.
50    Unknown,
51}
52
53impl ImportStatus {
54    /// One display line, without secrets.
55    pub fn describe(&self, agent: &str, now_unix_seconds: u64) -> String {
56        let source = match &self.source {
57            Source::Files { dir, files } => {
58                format!("{} from {}", files.join(" and "), dir.display())
59            }
60            Source::ScvProvider => "SCV's provider".into(),
61        };
62        let age = age(now_unix_seconds.saturating_sub(self.imported_unix_seconds));
63        match self.freshness {
64            Freshness::Current => format!("copy of {source}, imported {age} ago, up to date"),
65            Freshness::Changed => format!(
66                "copy of {source}, imported {age} ago; the source has changed since, \
67                 run `scv agents import {agent}` to refresh it"
68            ),
69            Freshness::Unknown => format!(
70                "copy of {source}, imported {age} ago; the source cannot be read here to compare"
71            ),
72        }
73    }
74}
75
76fn age(seconds: u64) -> String {
77    match seconds {
78        0..120 => format!("{seconds}s"),
79        120..7200 => format!("{}m", seconds / 60),
80        7200..172_800 => format!("{}h", seconds / 3600),
81        _ => format!("{}d", seconds / 86_400),
82    }
83}
84
85/// Digest of `files` in `dir`, in order; a missing file counts as absent.
86pub fn digest_files(dir: &Path, files: &[String]) -> Result<String> {
87    let mut hasher = Sha256::new();
88    for file in files {
89        hasher.update(file.as_bytes());
90        match std::fs::read(dir.join(file)) {
91            Ok(bytes) => {
92                hasher.update([1]);
93                hasher.update((bytes.len() as u64).to_le_bytes());
94                hasher.update(&bytes);
95            }
96            Err(error) if error.kind() == std::io::ErrorKind::NotFound => hasher.update([0]),
97            Err(error) => {
98                return Err(anyhow!(error).context(format!("read {}", dir.join(file).display())));
99            }
100        }
101    }
102    Ok(format!("{:x}", hasher.finalize()))
103}
104
105/// Digest of a value SCV copied, such as its own provider settings and key.
106pub fn digest_value(value: &impl Serialize) -> Result<String> {
107    Ok(format!(
108        "{:x}",
109        Sha256::digest(serde_json::to_vec(value).context("encode import digest")?)
110    ))
111}
112
113fn path(layout: &Layout, agent: &str) -> PathBuf {
114    layout.imports().join(format!("{agent}.json"))
115}
116
117/// Record that `agent`'s copy now matches `source` with `digest`.
118pub fn record(layout: &Layout, agent: &str, source: Source, digest: String) -> Result<()> {
119    let record = Record {
120        source,
121        digest,
122        imported_unix_seconds: now(),
123    };
124    let dir = layout.imports();
125    scv_channels::state::private_directory(layout.home(), &dir)?;
126    scv_channels::state::atomic_write(&path(layout, agent), &serde_json::to_string(&record)?)
127}
128
129/// Compare `agent`'s last import with its source now. `current` digests a
130/// [`Source::ScvProvider`] import, returning `None` when it cannot be read.
131pub fn check(
132    layout: &Layout,
133    agent: &str,
134    current: impl FnOnce() -> Option<String>,
135) -> Result<Option<ImportStatus>> {
136    let text = match std::fs::read_to_string(path(layout, agent)) {
137        Ok(text) => text,
138        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
139        Err(error) => return Err(error.into()),
140    };
141    let record: Record = serde_json::from_str(&text)
142        .with_context(|| format!("parse {}", path(layout, agent).display()))?;
143    let now = match &record.source {
144        Source::Files { dir, files } => digest_files(dir, files).ok(),
145        Source::ScvProvider => current(),
146    };
147    let freshness = match now {
148        Some(digest) if digest == record.digest => Freshness::Current,
149        Some(_) => Freshness::Changed,
150        None => Freshness::Unknown,
151    };
152    Ok(Some(ImportStatus {
153        source: record.source,
154        imported_unix_seconds: record.imported_unix_seconds,
155        freshness,
156    }))
157}
158
159pub fn now() -> u64 {
160    std::time::SystemTime::now()
161        .duration_since(std::time::UNIX_EPOCH)
162        .map_or(0, |elapsed| elapsed.as_secs())
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn a_changed_source_is_reported_until_imported_again() {
171        let home = tempfile::tempdir().unwrap();
172        let source = tempfile::tempdir().unwrap();
173        let layout = Layout::new(home.path());
174        let files = vec!["config.toml".to_owned()];
175        std::fs::write(source.path().join("config.toml"), "a = 1\n").unwrap();
176        assert!(check(&layout, "grok", || None).unwrap().is_none());
177        let files_source = Source::Files {
178            dir: source.path().to_owned(),
179            files: files.clone(),
180        };
181        let digest = digest_files(source.path(), &files).unwrap();
182        record(&layout, "grok", files_source.clone(), digest).unwrap();
183        let status = check(&layout, "grok", || None).unwrap().unwrap();
184        assert_eq!(status.freshness, Freshness::Current);
185        assert!(status.describe("grok", now()).contains("up to date"));
186
187        std::fs::write(source.path().join("config.toml"), "a = 2\n").unwrap();
188        let status = check(&layout, "grok", || None).unwrap().unwrap();
189        assert_eq!(status.freshness, Freshness::Changed);
190        assert!(
191            status
192                .describe("grok", now())
193                .contains("run `scv agents import grok`")
194        );
195
196        let digest = digest_files(source.path(), &files).unwrap();
197        record(&layout, "grok", files_source, digest).unwrap();
198        assert_eq!(
199            check(&layout, "grok", || None).unwrap().unwrap().freshness,
200            Freshness::Current
201        );
202        use std::os::unix::fs::PermissionsExt;
203        let mode = |path: PathBuf| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
204        assert_eq!(mode(layout.imports()), 0o700);
205        assert_eq!(mode(layout.imports().join("grok.json")), 0o600);
206    }
207
208    #[test]
209    fn provider_imports_compare_the_digest_the_caller_computes() {
210        let home = tempfile::tempdir().unwrap();
211        let layout = Layout::new(home.path());
212        let digest = digest_value(&("https://example.test/v1", "model", "key")).unwrap();
213        record(&layout, "scv", Source::ScvProvider, digest.clone()).unwrap();
214        let text = std::fs::read_to_string(layout.imports().join("scv.json")).unwrap();
215        assert!(!text.contains("key\""), "{text}");
216        for (current, expected) in [
217            (Some(digest), Freshness::Current),
218            (Some("other".into()), Freshness::Changed),
219            (None, Freshness::Unknown),
220        ] {
221            let status = check(&layout, "scv", || current).unwrap().unwrap();
222            assert_eq!(status.freshness, expected);
223        }
224    }
225}