返回 CodeWhale
detect.rs
根目录 / crates / tui / src / integrations / dsh / detect.rs
1 //! Read-only detection of an installed official DeepSeek Harness (`dsh`).
2 //!
3 //! Detection never writes: it locates the `dsh` launcher on `PATH`, asks it
4 //! for `--version` and the launcher `--help` (neither initializes a profile),
5 //! resolves `$DSH_HOME` the way `dsh-home-paths` does, and inventories the
6 //! profile names, `settings.yaml` namespaces, and the *presence* of the
7 //! managed credentials file. Credential values are never read.
8
9 use std::ffi::OsString;
10 use std::path::{Path, PathBuf};
11 use std::process::Command;
12
13 use serde::{Deserialize, Serialize};
14
15 /// The exact `dsh` release this integration was verified against.
16 pub(crate) const VERIFIED_DSH_VERSION: &str = "0.1.0-rc.6";
17
18 /// Parse the text `dsh --version` prints as a semver version.
19 ///
20 /// DSH releases carry semver prerelease tags (`0.1.7-alpha.2`, `0.2.0-rc.1`,
21 /// or a bare `0.1.0`), so this accepts any valid semver rather than a fixed
22 /// suffix shape. A leading `v` is tolerated. Text that is not a version is
23 /// `None`.
24 pub(crate) fn parse_dsh_version(raw: &str) -> Option<semver::Version> {
25 let raw = raw.trim();
26 semver::Version::parse(raw.strip_prefix('v').unwrap_or(raw)).ok()
27 }
28
29 /// How the installed `dsh` relates to the verified release.
30 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31 #[serde(tag = "kind", rename_all = "snake_case")]
32 pub(crate) enum DshCompatibility {
33 /// Exactly the verified release.
34 Verified,
35 /// Newer than the verified release: launchable, but unverified.
36 NewerUnverified { verified: String },
37 /// Older than the verified release, or missing the `--patch` seam.
38 Incompatible { reason: String },
39 /// The launcher exists but could not be run or did not report a version.
40 Offline { reason: String },
41 /// Version text that does not parse.
42 Unparsed { raw: String },
43 }
44
45 impl DshCompatibility {
46 pub(crate) fn label(&self) -> &'static str {
47 match self {
48 Self::Verified => "verified",
49 Self::NewerUnverified { .. } => "newer-unverified",
50 Self::Incompatible { .. } => "incompatible",
51 Self::Offline { .. } => "offline",
52 Self::Unparsed { .. } => "unparsed",
53 }
54 }
55 }
56
57 /// Classify a version string against [`VERIFIED_DSH_VERSION`] and whether the
58 /// launcher advertises `--patch`.
59 pub(crate) fn classify_version(raw: &str, supports_patch: bool) -> DshCompatibility {
60 let Some(version) = parse_dsh_version(raw) else {
61 return DshCompatibility::Unparsed {
62 raw: raw.trim().to_string(),
63 };
64 };
65 let verified = parse_dsh_version(VERIFIED_DSH_VERSION).expect("verified version parses");
66 // Semver precedence: prereleases sort before their release and
67 // alpha < beta < rc; build metadata is ignored.
68 match version.cmp_precedence(&verified) {
69 std::cmp::Ordering::Less => DshCompatibility::Incompatible {
70 reason: format!("{version} is older than the verified {verified}"),
71 },
72 _ if !supports_patch => DshCompatibility::Incompatible {
73 reason: "launcher does not advertise --patch overlays".to_string(),
74 },
75 std::cmp::Ordering::Equal => DshCompatibility::Verified,
76 std::cmp::Ordering::Greater => DshCompatibility::NewerUnverified {
77 verified: verified.to_string(),
78 },
79 }
80 }
81
82 /// Raw facts about one `dsh` installation. Every field is derived without
83 /// writing to disk or reading a credential value.
84 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85 pub(crate) struct DshDetection {
86 /// Resolved launcher path when one is on `PATH`.
87 pub(crate) binary: Option<PathBuf>,
88 /// `dsh --version` output when it ran.
89 pub(crate) version: Option<String>,
90 pub(crate) compatibility: DshCompatibility,
91 /// Whether the launcher help text advertises `--patch`.
92 pub(crate) supports_patch: bool,
93 /// `$DSH_HOME` (or `~/.dsh`), whether or not it exists yet.
94 pub(crate) dsh_home: PathBuf,
95 pub(crate) dsh_home_exists: bool,
96 pub(crate) dsh_home_from_env: bool,
97 /// Profile directory names under `$DSH_HOME/profiles`.
98 pub(crate) profiles: Vec<String>,
99 /// Top-level namespaces present in `$DSH_HOME/settings.yaml`.
100 pub(crate) settings_namespaces: Vec<String>,
101 /// `$DSH_HOME/.credentials.yaml` exists (values are never read).
102 pub(crate) credentials_present: bool,
103 /// Whether the credentials file is `0600` (POSIX only; `None` elsewhere).
104 pub(crate) credentials_mode_ok: Option<bool>,
105 }
106
107 impl DshDetection {
108 pub(crate) fn installed(&self) -> bool {
109 self.binary.is_some()
110 }
111 }
112
113 /// Environment facts detection reads. Injected so tests never depend on the
114 /// machine's real `PATH`, `HOME`, or `DSH_HOME`.
115 #[derive(Debug, Clone)]
116 pub(crate) struct DetectEnv {
117 pub(crate) path: Option<OsString>,
118 pub(crate) home: Option<PathBuf>,
119 pub(crate) dsh_home: Option<OsString>,
120 }
121
122 impl DetectEnv {
123 pub(crate) fn from_process() -> Self {
124 Self {
125 path: std::env::var_os("PATH"),
126 home: dirs::home_dir(),
127 dsh_home: std::env::var_os("DSH_HOME"),
128 }
129 }
130 }
131
132 /// Runs the launcher. Injected so tests can stub `--version`/`--help`.
133 pub(crate) trait DshRunner {
134 /// Returns `(exit_success, stdout+stderr)`.
135 fn run(&self, binary: &Path, args: &[&str]) -> std::io::Result<(bool, String)>;
136 }
137
138 pub(crate) struct ProcessRunner;
139
140 impl DshRunner for ProcessRunner {
141 fn run(&self, binary: &Path, args: &[&str]) -> std::io::Result<(bool, String)> {
142 let output = Command::new(binary)
143 .args(args)
144 // Belt and braces: the launcher help/version paths do not send
145 // telemetry, but the harness honors this as a hard opt-out.
146 .env("DSH_TELEMETRY_DISABLED", "1")
147 .stdin(std::process::Stdio::null())
148 .output()?;
149 let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
150 text.push_str(&String::from_utf8_lossy(&output.stderr));
151 Ok((output.status.success(), text))
152 }
153 }
154
155 /// Resolve `$DSH_HOME` like `dsh-home-paths`: a non-blank env value (with
156 /// `~` expansion), else `~/.dsh`.
157 pub(crate) fn resolve_dsh_home(env: &DetectEnv) -> (PathBuf, bool) {
158 if let Some(raw) = env.dsh_home.as_ref() {
159 let text = raw.to_string_lossy();
160 let trimmed = text.trim();
161 if !trimmed.is_empty() {
162 if let Some(rest) = trimmed.strip_prefix("~/") {
163 if let Some(home) = env.home.as_ref() {
164 return (home.join(rest), true);
165 }
166 } else if trimmed != "~" {
167 return (PathBuf::from(trimmed), true);
168 }
169 }
170 }
171 let home = env.home.clone().unwrap_or_else(|| PathBuf::from("."));
172 (home.join(".dsh"), false)
173 }
174
175 fn find_on_path(path: Option<&OsString>) -> Option<PathBuf> {
176 let path = path?;
177 for dir in std::env::split_paths(path) {
178 if dir.as_os_str().is_empty() {
179 continue;
180 }
181 for name in ["dsh", "dsh.cmd", "dsh.exe"] {
182 let candidate = dir.join(name);
183 if candidate.is_file() {
184 return Some(candidate);
185 }
186 }
187 }
188 None
189 }
190
191 /// Top-level YAML mapping keys of a settings document, without a YAML parser:
192 /// a key is a line with no leading whitespace ending in `:` (optionally with
193 /// an inline value). Bounded to the first 64 KiB.
194 pub(crate) fn settings_namespaces(text: &str) -> Vec<String> {
195 let mut out = Vec::new();
196 for raw in text.lines().take(4096) {
197 if raw.starts_with([' ', '\t', '#', '-']) || raw.trim().is_empty() {
198 continue;
199 }
200 let Some((key, _)) = raw.split_once(':') else {
201 continue;
202 };
203 let key = key.trim();
204 if key.is_empty() || key.starts_with('"') || key.starts_with('\'') {
205 continue;
206 }
207 if !out.iter().any(|k| k == key) {
208 out.push(key.to_string());
209 }
210 }
211 out
212 }
213
214 pub(crate) fn detect(env: &DetectEnv, runner: &dyn DshRunner) -> DshDetection {
215 let (dsh_home, dsh_home_from_env) = resolve_dsh_home(env);
216 let dsh_home_exists = dsh_home.is_dir();
217 let mut profiles = Vec::new();
218 if let Ok(entries) = std::fs::read_dir(dsh_home.join("profiles")) {
219 for entry in entries.flatten() {
220 let name = entry.file_name().to_string_lossy().into_owned();
221 if name == "node_modules" || name.starts_with('.') {
222 continue;
223 }
224 if entry.path().is_dir() {
225 profiles.push(name);
226 }
227 }
228 }
229 profiles.sort();
230 let settings_namespaces = std::fs::read(dsh_home.join("settings.yaml"))
231 .ok()
232 .map(|bytes| {
233 let bytes = &bytes[..bytes.len().min(64 * 1024)];
234 settings_namespaces(&String::from_utf8_lossy(bytes))
235 })
236 .unwrap_or_default();
237 let credentials_path = dsh_home.join(".credentials.yaml");
238 let credentials_present = credentials_path.is_file();
239 let credentials_mode_ok = credentials_mode_ok(&credentials_path);
240
241 let binary = find_on_path(env.path.as_ref());
242 let Some(binary) = binary else {
243 return DshDetection {
244 binary: None,
245 version: None,
246 compatibility: DshCompatibility::Offline {
247 reason: "dsh is not on PATH".to_string(),
248 },
249 supports_patch: false,
250 dsh_home,
251 dsh_home_exists,
252 dsh_home_from_env,
253 profiles,
254 settings_namespaces,
255 credentials_present,
256 credentials_mode_ok,
257 };
258 };
259
260 let (version, supports_patch, compatibility) = match runner.run(&binary, &["--version"]) {
261 Ok((true, text)) => {
262 let version = text.trim().lines().last().unwrap_or("").trim().to_string();
263 let supports_patch = match runner.run(&binary, &["--help"]) {
264 Ok((_, help)) => help.contains("--patch"),
265 Err(_) => false,
266 };
267 let compatibility = classify_version(&version, supports_patch);
268 (Some(version), supports_patch, compatibility)
269 }
270 Ok((false, text)) => (
271 None,
272 false,
273 DshCompatibility::Offline {
274 reason: format!(
275 "dsh --version exited non-zero: {}",
276 text.trim()
277 .lines()
278 .next()
279 .unwrap_or("")
280 .chars()
281 .take(120)
282 .collect::<String>()
283 ),
284 },
285 ),
286 Err(error) => (
287 None,
288 false,
289 DshCompatibility::Offline {
290 reason: format!("dsh could not be run: {error}"),
291 },
292 ),
293 };
294
295 DshDetection {
296 binary: Some(binary),
297 version,
298 compatibility,
299 supports_patch,
300 dsh_home,
301 dsh_home_exists,
302 dsh_home_from_env,
303 profiles,
304 settings_namespaces,
305 credentials_present,
306 credentials_mode_ok,
307 }
308 }
309
310 #[cfg(unix)]
311 fn credentials_mode_ok(path: &Path) -> Option<bool> {
312 use std::os::unix::fs::PermissionsExt;
313 let meta = std::fs::metadata(path).ok()?;
314 Some(meta.permissions().mode() & 0o077 == 0)
315 }
316
317 #[cfg(not(unix))]
318 fn credentials_mode_ok(_path: &Path) -> Option<bool> {
319 None
320 }
321
321 lines RUST