返回 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 /// Parsed `MAJOR.MINOR.PATCH[-rc.N]` version.
19 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
20 pub(crate) struct DshVersion {
21 pub(crate) major: u64,
22 pub(crate) minor: u64,
23 pub(crate) patch: u64,
24 /// `None` = a final release (sorts after every rc of the same base).
25 pub(crate) rc: Option<u64>,
26 }
27
28 impl DshVersion {
29 pub(crate) fn parse(raw: &str) -> Option<Self> {
30 let raw = raw.trim().trim_start_matches('v');
31 let (base, pre) = match raw.split_once('-') {
32 Some((base, pre)) => (base, Some(pre)),
33 None => (raw, None),
34 };
35 let mut parts = base.split('.');
36 let major = parts.next()?.parse().ok()?;
37 let minor = parts.next()?.parse().ok()?;
38 let patch = parts.next()?.parse().ok()?;
39 if parts.next().is_some() {
40 return None;
41 }
42 let rc = match pre {
43 None => None,
44 Some(pre) => {
45 let n = pre.strip_prefix("rc.")?.parse().ok()?;
46 Some(n)
47 }
48 };
49 Some(Self {
50 major,
51 minor,
52 patch,
53 rc,
54 })
55 }
56
57 fn base(self) -> (u64, u64, u64) {
58 (self.major, self.minor, self.patch)
59 }
60
61 /// Prerelease ordering: any rc sorts *before* the final release of the
62 /// same base, so `Option<u64>` derives the wrong order and is compared
63 /// here explicitly.
64 fn cmp_semver(self, other: Self) -> std::cmp::Ordering {
65 use std::cmp::Ordering;
66 match self.base().cmp(&other.base()) {
67 Ordering::Equal => match (self.rc, other.rc) {
68 (None, None) => Ordering::Equal,
69 (None, Some(_)) => Ordering::Greater,
70 (Some(_), None) => Ordering::Less,
71 (Some(a), Some(b)) => a.cmp(&b),
72 },
73 ordering => ordering,
74 }
75 }
76 }
77
78 impl std::fmt::Display for DshVersion {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
81 if let Some(rc) = self.rc {
82 write!(f, "-rc.{rc}")?;
83 }
84 Ok(())
85 }
86 }
87
88 /// How the installed `dsh` relates to the verified release.
89 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90 #[serde(tag = "kind", rename_all = "snake_case")]
91 pub(crate) enum DshCompatibility {
92 /// Exactly the verified release.
93 Verified,
94 /// Newer than the verified release: launchable, but unverified.
95 NewerUnverified { verified: String },
96 /// Older than the verified release, or missing the `--patch` seam.
97 Incompatible { reason: String },
98 /// The launcher exists but could not be run or did not report a version.
99 Offline { reason: String },
100 /// Version text that does not parse.
101 Unparsed { raw: String },
102 }
103
104 impl DshCompatibility {
105 pub(crate) fn label(&self) -> &'static str {
106 match self {
107 Self::Verified => "verified",
108 Self::NewerUnverified { .. } => "newer-unverified",
109 Self::Incompatible { .. } => "incompatible",
110 Self::Offline { .. } => "offline",
111 Self::Unparsed { .. } => "unparsed",
112 }
113 }
114 }
115
116 /// Classify a version string against [`VERIFIED_DSH_VERSION`] and whether the
117 /// launcher advertises `--patch`.
118 pub(crate) fn classify_version(raw: &str, supports_patch: bool) -> DshCompatibility {
119 let Some(version) = DshVersion::parse(raw) else {
120 return DshCompatibility::Unparsed {
121 raw: raw.trim().to_string(),
122 };
123 };
124 let verified = DshVersion::parse(VERIFIED_DSH_VERSION).expect("verified version parses");
125 match version.cmp_semver(verified) {
126 std::cmp::Ordering::Less => DshCompatibility::Incompatible {
127 reason: format!("{version} is older than the verified {verified}"),
128 },
129 _ if !supports_patch => DshCompatibility::Incompatible {
130 reason: "launcher does not advertise --patch overlays".to_string(),
131 },
132 std::cmp::Ordering::Equal => DshCompatibility::Verified,
133 std::cmp::Ordering::Greater => DshCompatibility::NewerUnverified {
134 verified: verified.to_string(),
135 },
136 }
137 }
138
139 /// Raw facts about one `dsh` installation. Every field is derived without
140 /// writing to disk or reading a credential value.
141 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142 pub(crate) struct DshDetection {
143 /// Resolved launcher path when one is on `PATH`.
144 pub(crate) binary: Option<PathBuf>,
145 /// `dsh --version` output when it ran.
146 pub(crate) version: Option<String>,
147 pub(crate) compatibility: DshCompatibility,
148 /// Whether the launcher help text advertises `--patch`.
149 pub(crate) supports_patch: bool,
150 /// `$DSH_HOME` (or `~/.dsh`), whether or not it exists yet.
151 pub(crate) dsh_home: PathBuf,
152 pub(crate) dsh_home_exists: bool,
153 pub(crate) dsh_home_from_env: bool,
154 /// Profile directory names under `$DSH_HOME/profiles`.
155 pub(crate) profiles: Vec<String>,
156 /// Top-level namespaces present in `$DSH_HOME/settings.yaml`.
157 pub(crate) settings_namespaces: Vec<String>,
158 /// `$DSH_HOME/.credentials.yaml` exists (values are never read).
159 pub(crate) credentials_present: bool,
160 /// Whether the credentials file is `0600` (POSIX only; `None` elsewhere).
161 pub(crate) credentials_mode_ok: Option<bool>,
162 }
163
164 impl DshDetection {
165 pub(crate) fn installed(&self) -> bool {
166 self.binary.is_some()
167 }
168 }
169
170 /// Environment facts detection reads. Injected so tests never depend on the
171 /// machine's real `PATH`, `HOME`, or `DSH_HOME`.
172 #[derive(Debug, Clone)]
173 pub(crate) struct DetectEnv {
174 pub(crate) path: Option<OsString>,
175 pub(crate) home: Option<PathBuf>,
176 pub(crate) dsh_home: Option<OsString>,
177 }
178
179 impl DetectEnv {
180 pub(crate) fn from_process() -> Self {
181 Self {
182 path: std::env::var_os("PATH"),
183 home: dirs::home_dir(),
184 dsh_home: std::env::var_os("DSH_HOME"),
185 }
186 }
187 }
188
189 /// Runs the launcher. Injected so tests can stub `--version`/`--help`.
190 pub(crate) trait DshRunner {
191 /// Returns `(exit_success, stdout+stderr)`.
192 fn run(&self, binary: &Path, args: &[&str]) -> std::io::Result<(bool, String)>;
193 }
194
195 pub(crate) struct ProcessRunner;
196
197 impl DshRunner for ProcessRunner {
198 fn run(&self, binary: &Path, args: &[&str]) -> std::io::Result<(bool, String)> {
199 let output = Command::new(binary)
200 .args(args)
201 // Belt and braces: the launcher help/version paths do not send
202 // telemetry, but the harness honors this as a hard opt-out.
203 .env("DSH_TELEMETRY_DISABLED", "1")
204 .stdin(std::process::Stdio::null())
205 .output()?;
206 let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
207 text.push_str(&String::from_utf8_lossy(&output.stderr));
208 Ok((output.status.success(), text))
209 }
210 }
211
212 /// Resolve `$DSH_HOME` like `dsh-home-paths`: a non-blank env value (with
213 /// `~` expansion), else `~/.dsh`.
214 pub(crate) fn resolve_dsh_home(env: &DetectEnv) -> (PathBuf, bool) {
215 if let Some(raw) = env.dsh_home.as_ref() {
216 let text = raw.to_string_lossy();
217 let trimmed = text.trim();
218 if !trimmed.is_empty() {
219 if let Some(rest) = trimmed.strip_prefix("~/") {
220 if let Some(home) = env.home.as_ref() {
221 return (home.join(rest), true);
222 }
223 } else if trimmed != "~" {
224 return (PathBuf::from(trimmed), true);
225 }
226 }
227 }
228 let home = env.home.clone().unwrap_or_else(|| PathBuf::from("."));
229 (home.join(".dsh"), false)
230 }
231
232 fn find_on_path(path: Option<&OsString>) -> Option<PathBuf> {
233 let path = path?;
234 for dir in std::env::split_paths(path) {
235 if dir.as_os_str().is_empty() {
236 continue;
237 }
238 for name in ["dsh", "dsh.cmd", "dsh.exe"] {
239 let candidate = dir.join(name);
240 if candidate.is_file() {
241 return Some(candidate);
242 }
243 }
244 }
245 None
246 }
247
248 /// Top-level YAML mapping keys of a settings document, without a YAML parser:
249 /// a key is a line with no leading whitespace ending in `:` (optionally with
250 /// an inline value). Bounded to the first 64 KiB.
251 pub(crate) fn settings_namespaces(text: &str) -> Vec<String> {
252 let mut out = Vec::new();
253 for raw in text.lines().take(4096) {
254 if raw.starts_with([' ', '\t', '#', '-']) || raw.trim().is_empty() {
255 continue;
256 }
257 let Some((key, _)) = raw.split_once(':') else {
258 continue;
259 };
260 let key = key.trim();
261 if key.is_empty() || key.starts_with('"') || key.starts_with('\'') {
262 continue;
263 }
264 if !out.iter().any(|k| k == key) {
265 out.push(key.to_string());
266 }
267 }
268 out
269 }
270
271 pub(crate) fn detect(env: &DetectEnv, runner: &dyn DshRunner) -> DshDetection {
272 let (dsh_home, dsh_home_from_env) = resolve_dsh_home(env);
273 let dsh_home_exists = dsh_home.is_dir();
274 let mut profiles = Vec::new();
275 if let Ok(entries) = std::fs::read_dir(dsh_home.join("profiles")) {
276 for entry in entries.flatten() {
277 let name = entry.file_name().to_string_lossy().into_owned();
278 if name == "node_modules" || name.starts_with('.') {
279 continue;
280 }
281 if entry.path().is_dir() {
282 profiles.push(name);
283 }
284 }
285 }
286 profiles.sort();
287 let settings_namespaces = std::fs::read(dsh_home.join("settings.yaml"))
288 .ok()
289 .map(|bytes| {
290 let bytes = &bytes[..bytes.len().min(64 * 1024)];
291 settings_namespaces(&String::from_utf8_lossy(bytes))
292 })
293 .unwrap_or_default();
294 let credentials_path = dsh_home.join(".credentials.yaml");
295 let credentials_present = credentials_path.is_file();
296 let credentials_mode_ok = credentials_mode_ok(&credentials_path);
297
298 let binary = find_on_path(env.path.as_ref());
299 let Some(binary) = binary else {
300 return DshDetection {
301 binary: None,
302 version: None,
303 compatibility: DshCompatibility::Offline {
304 reason: "dsh is not on PATH".to_string(),
305 },
306 supports_patch: false,
307 dsh_home,
308 dsh_home_exists,
309 dsh_home_from_env,
310 profiles,
311 settings_namespaces,
312 credentials_present,
313 credentials_mode_ok,
314 };
315 };
316
317 let (version, supports_patch, compatibility) = match runner.run(&binary, &["--version"]) {
318 Ok((true, text)) => {
319 let version = text.trim().lines().last().unwrap_or("").trim().to_string();
320 let supports_patch = match runner.run(&binary, &["--help"]) {
321 Ok((_, help)) => help.contains("--patch"),
322 Err(_) => false,
323 };
324 let compatibility = classify_version(&version, supports_patch);
325 (Some(version), supports_patch, compatibility)
326 }
327 Ok((false, text)) => (
328 None,
329 false,
330 DshCompatibility::Offline {
331 reason: format!(
332 "dsh --version exited non-zero: {}",
333 text.trim()
334 .lines()
335 .next()
336 .unwrap_or("")
337 .chars()
338 .take(120)
339 .collect::<String>()
340 ),
341 },
342 ),
343 Err(error) => (
344 None,
345 false,
346 DshCompatibility::Offline {
347 reason: format!("dsh could not be run: {error}"),
348 },
349 ),
350 };
351
352 DshDetection {
353 binary: Some(binary),
354 version,
355 compatibility,
356 supports_patch,
357 dsh_home,
358 dsh_home_exists,
359 dsh_home_from_env,
360 profiles,
361 settings_namespaces,
362 credentials_present,
363 credentials_mode_ok,
364 }
365 }
366
367 #[cfg(unix)]
368 fn credentials_mode_ok(path: &Path) -> Option<bool> {
369 use std::os::unix::fs::PermissionsExt;
370 let meta = std::fs::metadata(path).ok()?;
371 Some(meta.permissions().mode() & 0o077 == 0)
372 }
373
374 #[cfg(not(unix))]
375 fn credentials_mode_ok(_path: &Path) -> Option<bool> {
376 None
377 }
378
378 lines RUST