deps_core/policy_config.rs
1use crate::diagnostic::Severity;
2use serde::Deserialize;
3
4/// The policy-relevant subset of `deps-lsp`'s configuration.
5///
6/// Diagnostic severities, HTTP caching, release-freshness, supply-chain trust,
7/// custom-registry access, network/offline mode, and license policy. Split out from
8/// `deps-lsp::config::DepsConfig` (which keeps only its editor-only sections — inlay hints,
9/// loading indicator, code lens, cold-start disk loading) so `deps-lsp` and `deps-cli` compose
10/// the identical, single-sourced type instead of each parsing its own copy (constitution
11/// principle 1).
12///
13/// Every section struct below (`DiagnosticsConfig`, `CacheConfig`, `FreshnessConfig`,
14/// `SupplyChainConfig`, `RegistriesConfig`, `NetworkConfig`, `LicensePolicyConfig`,
15/// `TyposquatConfig`, `GossipConfig`) is
16/// `#[non_exhaustive]`, matching `deps-core`'s general convention (issue #1064) — a field added
17/// to any of them no longer breaks every downstream crate that names the struct's full literal
18/// shape. `PolicyConfig` itself deliberately stays exhaustive: issue #1064/FR-006 scopes this to
19/// the 9 leaf structs specifically, since `PolicyConfig`'s own fields only change when a whole
20/// new policy *section* is added — a much rarer, more architecturally significant event than a
21/// field added to an existing section.
22///
23/// This used to cost an exhaustive-destructure security guarantee (issue #592 security M1):
24/// `deps-lsp::config::reparse_scope` used to destructure every field of every section here
25/// directly, so the compiler rejected the build when a new field was added without an explicit
26/// decision on whether it invalidates already-open documents. That guarantee now lives in
27/// [`PolicyConfig::diff`] instead: it performs the same exhaustive, `..`-free destructuring, but
28/// from inside `deps-core`, where `#[non_exhaustive]` does not restrict same-crate
29/// destructuring. Its result, `PolicyConfigDiff`, is a plain (not `#[non_exhaustive]`) struct
30/// that `deps-lsp::config::reparse_scope` destructures exhaustively in turn, carrying the same
31/// compile-time guarantee across the crate boundary.
32///
33/// # Examples
34///
35/// ```
36/// use deps_core::policy_config::PolicyConfig;
37///
38/// let policy = PolicyConfig::default();
39/// assert!(policy.diagnostics.vulnerabilities_enabled);
40/// assert!(!policy.network.offline);
41/// ```
42#[derive(Debug, Clone, Deserialize, Default)]
43pub struct PolicyConfig {
44 /// Diagnostic severity and behavior settings.
45 #[serde(default)]
46 pub diagnostics: DiagnosticsConfig,
47 /// HTTP response cache settings.
48 #[serde(default)]
49 pub cache: CacheConfig,
50 /// Release-cooldown / freshness-window settings.
51 #[serde(default)]
52 pub freshness: FreshnessConfig,
53 /// Supply-chain trust signal (Scorecard/SLSA) settings.
54 #[serde(default)]
55 pub supply_chain: SupplyChainConfig,
56 /// Custom/alternate registry settings.
57 #[serde(default)]
58 pub registries: RegistriesConfig,
59 /// Network access and offline-mode settings.
60 #[serde(default)]
61 pub network: NetworkConfig,
62 /// License policy (allow/deny list) settings.
63 #[serde(default)]
64 pub license_policy: LicensePolicyConfig,
65 /// Typosquat-similarity diagnostic settings (issue #1437).
66 #[serde(default)]
67 pub typosquat: TyposquatConfig,
68 /// deps.dev GOSSIP-signal settings (issue #1456).
69 #[serde(default)]
70 pub gossip: GossipConfig,
71}
72
73/// Which leaf fields differ between two [`PolicyConfig`] snapshots, at the exact granularity
74/// `deps-lsp::config::reparse_scope` classifies them at.
75///
76/// Deliberately **not** `#[non_exhaustive]`, unlike every section struct above:
77/// `deps-lsp::config::reparse_scope` destructures this exhaustively (no `..`) to guarantee
78/// every leaf field is mapped to a `ReparseScope` decision. Only `registries`'s three fields
79/// are individually parse-affecting today (each scopes a different set of ecosystems), so this
80/// carries one bool per leaf field of that section rather than one bool per section — see
81/// [`PolicyConfig::diff`]'s doc for why the other six sections have no fields here at all.
82///
83/// # The E0027 mechanism this design relies on (issue #1064, NFR-004)
84///
85/// [`PolicyConfig::diff`] destructures every section of [`PolicyConfig`] exhaustively — no `..`
86/// rest pattern at any level — so a field added to any of the 8 policy section structs without
87/// also naming it in that destructuring fails to compile (rustc E0027), exactly as
88/// `deps-lsp::config::reparse_scope` used to enforce directly before issue #592's guarantee
89/// moved here. The doctest below only illustrates that underlying Rust language mechanism on a
90/// synthetic, unrelated struct — an exhaustive struct-destructure pattern (no `..`) fails to
91/// compile once the struct gains a field the pattern does not name. It does **not** itself
92/// re-verify that `PolicyConfig::diff`'s or `reparse_scope`'s *real* destructuring stays
93/// `..`-free (it would keep passing even if a `..` crept into either); a CI grep step
94/// (`.github/workflows/ci.yml`'s `doc-and-hygiene` job, mirroring that job's existing
95/// `test-util` leak guard) asserts that instead, on every PR.
96///
97/// ```compile_fail
98/// struct Example {
99/// a: bool,
100/// b: bool,
101/// // A hypothetical field added later without updating the destructure below — the same
102/// // shape as adding a field to, say, `SupplyChainConfig` without updating
103/// // `PolicyConfig::diff`'s destructure of it.
104/// c: bool,
105/// }
106///
107/// let e = Example { a: true, b: false, c: true };
108/// // Missing `c` here fails with E0027.
109/// let Example { a: _, b: _ } = e;
110/// ```
111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
112pub struct PolicyConfigDiff {
113 /// Whether [`RegistriesConfig::workspace_registries`] changed.
114 pub workspace_registries_changed: bool,
115 /// Whether [`RegistriesConfig::nuget_user_profile_sources`] changed.
116 pub nuget_user_profile_sources_changed: bool,
117 /// Whether [`RegistriesConfig::gitlab_instance_host`] changed.
118 pub gitlab_instance_host_changed: bool,
119}
120
121impl PolicyConfig {
122 /// Reports which leaf fields differ between `old` and `new`.
123 ///
124 /// Exhaustively destructures both snapshots' sections — no `..` rest pattern at any
125 /// level — so a field added to any of the 8 section structs forces an explicit decision
126 /// here (see [`PolicyConfigDiff`]'s compile-time guarantee). `diagnostics`, `cache`,
127 /// `freshness`, `supply_chain`, `network`, `license_policy`, and `typosquat` are destructured
128 /// field-by-field purely to force that decision — as of today, none of their leaf fields
129 /// are parse-affecting (a config change there is picked up the next time diagnostics/hover
130 /// are requested, without invalidating already-parsed document state), so no field of
131 /// theirs appears in [`PolicyConfigDiff`] itself. Only `registries`'s three fields are
132 /// individually parse-affecting, since each scopes a different, narrower set of
133 /// ecosystems (see `deps-lsp::config::{NUGET_USER_PROFILE_SOURCES_ECOSYSTEMS,
134 /// GITLAB_INSTANCE_HOST_ECOSYSTEMS}`).
135 ///
136 /// # Examples
137 ///
138 /// ```
139 /// use deps_core::policy_config::PolicyConfig;
140 ///
141 /// let old = PolicyConfig::default();
142 /// let mut new = PolicyConfig::default();
143 /// new.registries.nuget_user_profile_sources = true;
144 ///
145 /// let diff = PolicyConfig::diff(&old, &new);
146 /// assert!(diff.nuget_user_profile_sources_changed);
147 /// assert!(!diff.workspace_registries_changed);
148 /// assert!(!diff.gitlab_instance_host_changed);
149 /// ```
150 #[must_use]
151 pub fn diff(old: &Self, new: &Self) -> PolicyConfigDiff {
152 let Self {
153 diagnostics: new_diagnostics,
154 cache: new_cache,
155 freshness: new_freshness,
156 supply_chain: new_supply_chain,
157 registries: new_registries,
158 network: new_network,
159 license_policy: new_license_policy,
160 typosquat: new_typosquat,
161 gossip: new_gossip,
162 } = new;
163
164 // Not parse-affecting: exhaustive `_` bindings (never `..`) force a decision when a
165 // field is added. `_` is rustc's E0027 fix-it default, but if a new field affects
166 // already-parsed document state, add a diff bool instead (mirroring `registries` below).
167 let DiagnosticsConfig {
168 outdated_severity: _,
169 unknown_severity: _,
170 yanked_severity: _,
171 unsatisfiable_severity: _,
172 deprecated_severity: _,
173 mutable_ref_pin_severity: _,
174 mutable_ref_pin_enabled: _,
175 sha_comment_mismatch_severity: _,
176 vulnerabilities_enabled: _,
177 } = new_diagnostics;
178 let CacheConfig {
179 enabled: _,
180 fetch_timeout_secs: _,
181 max_concurrent_fetches: _,
182 } = new_cache;
183 let FreshnessConfig {
184 enabled: _,
185 cooldown_secs: _,
186 } = new_freshness;
187 let SupplyChainConfig { enabled: _ } = new_supply_chain;
188 let NetworkConfig { offline: _ } = new_network;
189 let LicensePolicyConfig { allow: _, deny: _ } = new_license_policy;
190 let TyposquatConfig { enabled: _ } = new_typosquat;
191 let GossipConfig { enabled: _ } = new_gossip;
192
193 // Parse-affecting: each leaf field of `registries` is individually diffed.
194 let RegistriesConfig {
195 workspace_registries: new_workspace_registries,
196 nuget_user_profile_sources: new_nuget_user_profile_sources,
197 gitlab_instance_host: new_gitlab_instance_host,
198 } = new_registries;
199 let RegistriesConfig {
200 workspace_registries: old_workspace_registries,
201 nuget_user_profile_sources: old_nuget_user_profile_sources,
202 gitlab_instance_host: old_gitlab_instance_host,
203 } = &old.registries;
204
205 PolicyConfigDiff {
206 workspace_registries_changed: old_workspace_registries != new_workspace_registries,
207 nuget_user_profile_sources_changed: old_nuget_user_profile_sources
208 != new_nuget_user_profile_sources,
209 gitlab_instance_host_changed: old_gitlab_instance_host != new_gitlab_instance_host,
210 }
211 }
212}
213
214/// Configuration for diagnostic severity levels.
215///
216/// Controls the severity level reported for different types of dependency issues.
217/// This allows users to customize whether issues appear as errors, warnings, hints, etc.
218///
219/// # Defaults
220///
221/// - `outdated_severity`: `HINT` - Dependencies with available updates
222/// - `unknown_severity`: `WARNING` - Dependencies not found in registry
223/// - `yanked_severity`: `WARNING` - Dependencies using yanked versions
224/// - `unsatisfiable_severity`: `WARNING` - Dependencies whose requirement matches zero published versions
225/// - `deprecated_severity`: `WARNING` - Dependencies on a package the registry reports as deprecated/abandoned
226/// - `mutable_ref_pin_severity`: `HINT` - Dependencies pinned to a mutable ref (tag/branch) instead of a commit SHA (GitHub Actions `uses:` steps, GitLab CI `project:`/`component:` includes)
227/// - `mutable_ref_pin_enabled`: `true` - Whether the mutable-ref-pin diagnostic runs at all
228/// - `sha_comment_mismatch_severity`: `WARNING` - SHA-pinned GitHub Actions steps whose trailing version comment names a tag that is not the pinned commit's tag
229///
230/// # Examples
231///
232/// ```
233/// use deps_core::policy_config::DiagnosticsConfig;
234/// use deps_core::diagnostic::Severity;
235///
236/// let config = DiagnosticsConfig::new()
237/// .with_outdated_severity(Severity::Information)
238/// .with_unknown_severity(Severity::Error)
239/// .with_yanked_severity(Severity::Error)
240/// .with_unsatisfiable_severity(Severity::Error)
241/// .with_deprecated_severity(Severity::Error)
242/// .with_mutable_ref_pin_severity(Severity::Error)
243/// .with_mutable_ref_pin_enabled(true)
244/// .with_sha_comment_mismatch_severity(Severity::Error)
245/// .with_vulnerabilities_enabled(true);
246///
247/// assert_eq!(config.unknown_severity, Severity::Error);
248/// ```
249#[non_exhaustive]
250#[derive(Debug, Clone, Deserialize)]
251pub struct DiagnosticsConfig {
252 /// Severity for a dependency with a newer version available.
253 #[serde(default = "default_outdated_severity")]
254 pub outdated_severity: Severity,
255 /// Severity for a dependency not found in the registry.
256 #[serde(default = "default_unknown_severity")]
257 pub unknown_severity: Severity,
258 /// Severity for a dependency pinned to a yanked/retracted version.
259 #[serde(default = "default_yanked_severity")]
260 pub yanked_severity: Severity,
261 /// Severity for a dependency whose requirement matches zero published versions.
262 #[serde(default = "default_unsatisfiable_severity")]
263 pub unsatisfiable_severity: Severity,
264 /// Severity for a dependency on a package the registry reports as
265 /// deprecated/abandoned (issue #205). No corresponding `deprecated_enabled`
266 /// toggle: unlike `vulnerabilities_enabled`, this signal is derived from
267 /// already-fetched data (zero new registry requests — see #205's plan §1
268 /// D2), so a boolean would gate only string formatting, not a network
269 /// call. Matches the severity-only precedent set by the four fields above.
270 #[serde(default = "default_deprecated_severity")]
271 pub deprecated_severity: Severity,
272 /// Severity for a dependency pinned to a mutable ref (a tag/branch) instead of a
273 /// full commit SHA — a GitHub Actions `uses:` step (issue #473) or a GitLab CI
274 /// `project:`/`component:` include (issue #634). Tunes loudness only; see
275 /// `mutable_ref_pin_enabled` for the on/off toggle.
276 #[serde(default = "default_mutable_ref_pin_severity")]
277 pub mutable_ref_pin_severity: Severity,
278 /// Whether the mutable-ref-pin diagnostic (issue #473, extended to GitLab CI by
279 /// issue #634) runs at all. Default
280 /// `true`. **Corrected during implementation review (spec 031 FR-009)**: unlike
281 /// `deprecated_severity`, this diagnostic *does* need a real `_enabled` toggle —
282 /// `Severity` has no suppression value, and severity is never treated
283 /// as a suppression input anywhere in this codebase, so without this boolean the
284 /// diagnostic would be permanent and unremovable on every tag-pinned `uses:` step
285 /// (the dominant pinning style), even for teams that intentionally reject
286 /// SHA-pinning. Mirrors `vulnerabilities_enabled`'s exact shape.
287 #[serde(default = "default_true")]
288 pub mutable_ref_pin_enabled: bool,
289 /// Severity for a SHA-pinned GitHub Actions `uses:` step whose trailing version comment
290 /// names a tag that is provably not the pinned commit's tag. Severity only; there is no
291 /// toggle because only a provable mismatch is reported.
292 #[serde(default = "default_sha_comment_mismatch_severity")]
293 pub sha_comment_mismatch_severity: Severity,
294 /// Whether to run the OSV.dev vulnerability scan and render its
295 /// diagnostics/hover content. Default `true` (opt-out): `cargo audit`/
296 /// `npm audit` run by default, and an opt-in gate would undercut the
297 /// feature (approved Q5).
298 #[serde(default = "default_true")]
299 pub vulnerabilities_enabled: bool,
300}
301
302impl Default for DiagnosticsConfig {
303 fn default() -> Self {
304 Self::new()
305 }
306}
307
308impl DiagnosticsConfig {
309 /// Builds the default configuration (mirrors [`Self::default`]).
310 ///
311 /// # Examples
312 ///
313 /// ```
314 /// use deps_core::policy_config::DiagnosticsConfig;
315 /// use deps_core::diagnostic::Severity;
316 ///
317 /// let config = DiagnosticsConfig::new();
318 /// assert_eq!(config.outdated_severity, Severity::Hint);
319 /// ```
320 #[must_use]
321 pub const fn new() -> Self {
322 Self {
323 outdated_severity: default_outdated_severity(),
324 unknown_severity: default_unknown_severity(),
325 yanked_severity: default_yanked_severity(),
326 unsatisfiable_severity: default_unsatisfiable_severity(),
327 deprecated_severity: default_deprecated_severity(),
328 mutable_ref_pin_severity: default_mutable_ref_pin_severity(),
329 mutable_ref_pin_enabled: true,
330 sha_comment_mismatch_severity: default_sha_comment_mismatch_severity(),
331 vulnerabilities_enabled: true,
332 }
333 }
334
335 /// Overrides [`Self::outdated_severity`]. See [`Self::new`].
336 #[must_use]
337 pub const fn with_outdated_severity(mut self, outdated_severity: Severity) -> Self {
338 self.outdated_severity = outdated_severity;
339 self
340 }
341
342 /// Overrides [`Self::unknown_severity`]. See [`Self::new`].
343 #[must_use]
344 pub const fn with_unknown_severity(mut self, unknown_severity: Severity) -> Self {
345 self.unknown_severity = unknown_severity;
346 self
347 }
348
349 /// Overrides [`Self::yanked_severity`]. See [`Self::new`].
350 #[must_use]
351 pub const fn with_yanked_severity(mut self, yanked_severity: Severity) -> Self {
352 self.yanked_severity = yanked_severity;
353 self
354 }
355
356 /// Overrides [`Self::unsatisfiable_severity`]. See [`Self::new`].
357 #[must_use]
358 pub const fn with_unsatisfiable_severity(mut self, unsatisfiable_severity: Severity) -> Self {
359 self.unsatisfiable_severity = unsatisfiable_severity;
360 self
361 }
362
363 /// Overrides [`Self::deprecated_severity`]. See [`Self::new`].
364 #[must_use]
365 pub const fn with_deprecated_severity(mut self, deprecated_severity: Severity) -> Self {
366 self.deprecated_severity = deprecated_severity;
367 self
368 }
369
370 /// Overrides [`Self::mutable_ref_pin_severity`]. See [`Self::new`].
371 #[must_use]
372 pub const fn with_mutable_ref_pin_severity(
373 mut self,
374 mutable_ref_pin_severity: Severity,
375 ) -> Self {
376 self.mutable_ref_pin_severity = mutable_ref_pin_severity;
377 self
378 }
379
380 /// Overrides [`Self::mutable_ref_pin_enabled`]. See [`Self::new`].
381 #[must_use]
382 pub const fn with_mutable_ref_pin_enabled(mut self, mutable_ref_pin_enabled: bool) -> Self {
383 self.mutable_ref_pin_enabled = mutable_ref_pin_enabled;
384 self
385 }
386
387 /// Overrides [`Self::sha_comment_mismatch_severity`]. See [`Self::new`].
388 #[must_use]
389 pub const fn with_sha_comment_mismatch_severity(
390 mut self,
391 sha_comment_mismatch_severity: Severity,
392 ) -> Self {
393 self.sha_comment_mismatch_severity = sha_comment_mismatch_severity;
394 self
395 }
396
397 /// Overrides [`Self::vulnerabilities_enabled`]. See [`Self::new`].
398 #[must_use]
399 pub const fn with_vulnerabilities_enabled(mut self, vulnerabilities_enabled: bool) -> Self {
400 self.vulnerabilities_enabled = vulnerabilities_enabled;
401 self
402 }
403
404 /// Converts this LSP-facing config into the `deps-core` DTO threaded
405 /// through `Ecosystem::generate_diagnostics`.
406 ///
407 /// # Examples
408 ///
409 /// ```
410 /// use deps_core::policy_config::DiagnosticsConfig;
411 ///
412 /// let config = DiagnosticsConfig::default();
413 /// let severities = config.to_severities();
414 /// assert_eq!(severities.outdated, config.outdated_severity);
415 /// assert_eq!(severities.unknown, config.unknown_severity);
416 /// assert_eq!(severities.yanked, config.yanked_severity);
417 /// assert_eq!(severities.unsatisfiable, config.unsatisfiable_severity);
418 /// assert_eq!(severities.deprecated, config.deprecated_severity);
419 /// assert_eq!(severities.mutable_ref_pin, config.mutable_ref_pin_severity);
420 /// assert_eq!(severities.mutable_ref_pin_enabled, config.mutable_ref_pin_enabled);
421 /// assert_eq!(severities.sha_comment_mismatch, config.sha_comment_mismatch_severity);
422 /// ```
423 #[must_use]
424 pub const fn to_severities(&self) -> crate::DiagnosticSeverities {
425 crate::DiagnosticSeverities::new()
426 .with_outdated(self.outdated_severity)
427 .with_unknown(self.unknown_severity)
428 .with_yanked(self.yanked_severity)
429 .with_unsatisfiable(self.unsatisfiable_severity)
430 .with_deprecated(self.deprecated_severity)
431 .with_mutable_ref_pin(self.mutable_ref_pin_severity)
432 .with_mutable_ref_pin_enabled(self.mutable_ref_pin_enabled)
433 .with_sha_comment_mismatch(self.sha_comment_mismatch_severity)
434 .with_vulnerabilities_enabled(self.vulnerabilities_enabled)
435 }
436}
437
438/// Configuration for HTTP caching behavior.
439///
440/// Controls cache settings for registry requests. The cache uses ETag and
441/// Last-Modified headers for validation, minimizing network traffic.
442///
443/// # Defaults
444///
445/// - `enabled`: `true`
446/// - `fetch_timeout_secs`: `10` (10 seconds per package)
447/// - `max_concurrent_fetches`: `20` (20 concurrent requests)
448///
449/// # Examples
450///
451/// ```
452/// use deps_core::policy_config::CacheConfig;
453///
454/// let config = CacheConfig::new()
455/// .with_fetch_timeout_secs(5)
456/// .with_max_concurrent_fetches(20);
457///
458/// assert_eq!(config.fetch_timeout_secs, 5);
459/// ```
460#[non_exhaustive]
461#[derive(Debug, Clone, Deserialize)]
462pub struct CacheConfig {
463 /// Whether `deps_core::cache::HttpCache`'s entry map is used at all (issue #482):
464 /// `false` bypasses it entirely (fetch fresh every time, never store).
465 ///
466 /// **Offline override**: while `network.offline` (see [`NetworkConfig::offline`]) is
467 /// set, this flag's `false` value is overridden and treated as `true` — otherwise a
468 /// warm entry fetched before going offline could never survive an online→offline
469 /// transition, since nothing would have been stored while online in the first place.
470 ///
471 /// **Maven exception**: `deps-maven`'s `peek_cached`-based stale-data fallback
472 /// (`crates/deps-maven/src/registry.rs`) behaves differently from every other
473 /// ecosystem under `enabled: false`, which has no equivalent second-layer fallback to
474 /// diverge on. This only "always misses" for a process that started cold with the
475 /// flag already off — `peek_cached` reads the entry map directly and
476 /// `set_cache_enabled` never clears it, so a *live* `true` -> `false` toggle leaves
477 /// every already-stored entry servable through this fallback indefinitely.
478 #[serde(default = "default_true")]
479 pub enabled: bool,
480 /// Timeout for fetching a single package's versions (default: 10 seconds)
481 #[serde(
482 default = "default_fetch_timeout_secs",
483 deserialize_with = "deserialize_fetch_timeout"
484 )]
485 pub fetch_timeout_secs: u64,
486 /// Maximum concurrent package fetches (default: 20)
487 #[serde(
488 default = "default_max_concurrent_fetches",
489 deserialize_with = "deserialize_max_concurrent"
490 )]
491 pub max_concurrent_fetches: usize,
492}
493
494impl Default for CacheConfig {
495 fn default() -> Self {
496 Self::new()
497 }
498}
499
500impl CacheConfig {
501 /// Builds the default configuration (mirrors [`Self::default`]).
502 ///
503 /// # Examples
504 ///
505 /// ```
506 /// use deps_core::policy_config::CacheConfig;
507 ///
508 /// let config = CacheConfig::new();
509 /// assert!(config.enabled);
510 /// ```
511 #[must_use]
512 pub const fn new() -> Self {
513 Self {
514 enabled: true,
515 fetch_timeout_secs: default_fetch_timeout_secs(),
516 max_concurrent_fetches: default_max_concurrent_fetches(),
517 }
518 }
519
520 /// Overrides [`Self::enabled`]. See [`Self::new`].
521 #[must_use]
522 pub const fn with_enabled(mut self, enabled: bool) -> Self {
523 self.enabled = enabled;
524 self
525 }
526
527 /// Overrides [`Self::fetch_timeout_secs`]. See [`Self::new`]. Not clamped to the
528 /// bounds `deserialize_fetch_timeout` enforces — that validation only guards
529 /// untrusted LSP-client input, not construction from trusted Rust code.
530 #[must_use]
531 pub const fn with_fetch_timeout_secs(mut self, fetch_timeout_secs: u64) -> Self {
532 self.fetch_timeout_secs = fetch_timeout_secs;
533 self
534 }
535
536 /// Overrides [`Self::max_concurrent_fetches`]. See [`Self::new`]. Enforces the same
537 /// `>= 1` floor (`MIN_CONCURRENT_FETCHES`) that `deserialize_max_concurrent` enforces
538 /// on untrusted LSP-client input — but, unlike that deserializer, does not also cap the
539 /// `MAX_CONCURRENT_FETCHES` ceiling, so a very large value passes through uncapped: this
540 /// value is used directly as `futures::StreamExt::buffer_unordered`'s concurrency limit
541 /// (`document::fetch`), and `buffer_unordered(0)` never polls its source stream, hanging
542 /// the fetch forever instead of erroring (issue #833) — unlike
543 /// [`Self::with_fetch_timeout_secs`], `0` here is not a merely degenerate value, so this
544 /// setter enforces the floor itself rather than relying on a downstream re-guard
545 /// (`handlers::diagnostics::loading_ceiling` also re-guards its own divisor, but that is
546 /// a second, independent consumer, not a safety net for this one).
547 ///
548 /// # Examples
549 ///
550 /// ```
551 /// use deps_core::policy_config::CacheConfig;
552 ///
553 /// let config = CacheConfig::new().with_max_concurrent_fetches(0);
554 /// assert_eq!(config.max_concurrent_fetches, 1);
555 /// ```
556 #[must_use]
557 pub const fn with_max_concurrent_fetches(mut self, max_concurrent_fetches: usize) -> Self {
558 self.max_concurrent_fetches = if max_concurrent_fetches == 0 {
559 MIN_CONCURRENT_FETCHES
560 } else {
561 max_concurrent_fetches
562 };
563 self
564 }
565}
566
567const fn default_true() -> bool {
568 true
569}
570
571const fn default_outdated_severity() -> Severity {
572 Severity::Hint
573}
574
575const fn default_unknown_severity() -> Severity {
576 Severity::Warning
577}
578
579const fn default_yanked_severity() -> Severity {
580 Severity::Warning
581}
582
583const fn default_unsatisfiable_severity() -> Severity {
584 Severity::Warning
585}
586
587const fn default_deprecated_severity() -> Severity {
588 Severity::Warning
589}
590
591const fn default_mutable_ref_pin_severity() -> Severity {
592 Severity::Hint
593}
594
595const fn default_sha_comment_mismatch_severity() -> Severity {
596 Severity::Warning
597}
598
599const fn default_fetch_timeout_secs() -> u64 {
600 5
601}
602
603const fn default_max_concurrent_fetches() -> usize {
604 20
605}
606
607/// Minimum timeout (seconds) to prevent zero-timeout edge case
608const MIN_FETCH_TIMEOUT_SECS: u64 = 1;
609/// Maximum timeout (seconds) - 5 minutes is generous
610const MAX_FETCH_TIMEOUT_SECS: u64 = 300;
611
612/// Minimum concurrent fetches (must be at least 1)
613const MIN_CONCURRENT_FETCHES: usize = 1;
614/// Maximum concurrent fetches
615const MAX_CONCURRENT_FETCHES: usize = 100;
616
617/// Custom deserializer for fetch_timeout_secs that validates bounds
618fn deserialize_fetch_timeout<'de, D>(deserializer: D) -> Result<u64, D::Error>
619where
620 D: serde::Deserializer<'de>,
621{
622 let secs = u64::deserialize(deserializer)?;
623 let clamped = secs.clamp(MIN_FETCH_TIMEOUT_SECS, MAX_FETCH_TIMEOUT_SECS);
624 if clamped != secs {
625 tracing::warn!(
626 "fetch_timeout_secs {} clamped to {} (valid range: {}-{})",
627 secs,
628 clamped,
629 MIN_FETCH_TIMEOUT_SECS,
630 MAX_FETCH_TIMEOUT_SECS
631 );
632 }
633 Ok(clamped)
634}
635
636/// Custom deserializer for max_concurrent_fetches that validates bounds
637fn deserialize_max_concurrent<'de, D>(deserializer: D) -> Result<usize, D::Error>
638where
639 D: serde::Deserializer<'de>,
640{
641 let count = usize::deserialize(deserializer)?;
642 let clamped = count.clamp(MIN_CONCURRENT_FETCHES, MAX_CONCURRENT_FETCHES);
643 if clamped != count {
644 tracing::warn!(
645 "max_concurrent_fetches {} clamped to {} (valid range: {}-{})",
646 count,
647 clamped,
648 MIN_CONCURRENT_FETCHES,
649 MAX_CONCURRENT_FETCHES
650 );
651 }
652 Ok(clamped)
653}
654
655/// Configuration for the release-freshness signal (issue #145).
656///
657/// Controls whether a recently published "latest" version is flagged as
658/// still within a cooldown window, mirroring GitHub Dependabot's default
659/// 3-day package cooldown. Applied uniformly across all ecosystems — no
660/// per-ecosystem override.
661///
662/// # Defaults
663///
664/// - `enabled`: `true`
665/// - `cooldown_secs`: `259200` (3 days)
666///
667/// # Examples
668///
669/// ```
670/// use deps_core::policy_config::FreshnessConfig;
671///
672/// let config = FreshnessConfig::new().with_cooldown_secs(3600);
673///
674/// assert_eq!(config.cooldown_secs, 3600);
675/// ```
676#[non_exhaustive]
677#[derive(Debug, Clone, Deserialize)]
678pub struct FreshnessConfig {
679 /// Whether the release-cooldown freshness signal is enabled at all.
680 #[serde(default = "default_true")]
681 pub enabled: bool,
682 /// Cooldown window in seconds, clamped to 0..=30 days (default: 3 days)
683 #[serde(
684 default = "default_cooldown_secs",
685 deserialize_with = "deserialize_cooldown_secs"
686 )]
687 pub cooldown_secs: u64,
688}
689
690impl Default for FreshnessConfig {
691 fn default() -> Self {
692 Self::new()
693 }
694}
695
696impl FreshnessConfig {
697 /// Builds the default configuration (mirrors [`Self::default`]).
698 ///
699 /// # Examples
700 ///
701 /// ```
702 /// use deps_core::policy_config::FreshnessConfig;
703 ///
704 /// let config = FreshnessConfig::new();
705 /// assert!(config.enabled);
706 /// ```
707 #[must_use]
708 pub const fn new() -> Self {
709 Self {
710 enabled: true,
711 cooldown_secs: default_cooldown_secs(),
712 }
713 }
714
715 /// Overrides [`Self::enabled`]. See [`Self::new`].
716 #[must_use]
717 pub const fn with_enabled(mut self, enabled: bool) -> Self {
718 self.enabled = enabled;
719 self
720 }
721
722 /// Overrides [`Self::cooldown_secs`]. See [`Self::new`]. Not clamped to the bounds
723 /// `deserialize_cooldown_secs` enforces — that validation only guards untrusted
724 /// LSP-client input, not construction from trusted Rust code.
725 #[must_use]
726 pub const fn with_cooldown_secs(mut self, cooldown_secs: u64) -> Self {
727 self.cooldown_secs = cooldown_secs;
728 self
729 }
730
731 /// Converts this LSP-facing config into the `deps-core` DTO threaded
732 /// through `Ecosystem::generate_hover`/`generate_diagnostics`.
733 ///
734 /// # Examples
735 ///
736 /// ```
737 /// use deps_core::policy_config::FreshnessConfig;
738 ///
739 /// let config = FreshnessConfig::default();
740 /// let settings = config.to_freshness();
741 /// assert!(settings.is_enabled());
742 /// ```
743 #[must_use]
744 pub const fn to_freshness(&self) -> crate::FreshnessSettings {
745 if self.enabled {
746 crate::FreshnessSettings::Enabled {
747 cooldown: crate::CooldownWindow::from_secs(self.cooldown_secs),
748 }
749 } else {
750 crate::FreshnessSettings::Disabled
751 }
752 }
753}
754
755const fn default_cooldown_secs() -> u64 {
756 crate::DEFAULT_COOLDOWN_SECS
757}
758
759/// Configuration for the supply-chain trust signal (spec 037, issue #543).
760///
761/// Controls whether hover attempts a deps.dev OpenSSF Scorecard / SLSA
762/// provenance lookup for the hovered dependency. This is the first feature
763/// to send package names to a third party that is not the package's own
764/// registry, so it gets an off switch like every other opt-out-able signal
765/// in this server (`diagnostics.vulnerabilities_enabled`), rather than
766/// requiring a user on a locked-down network to go fully offline.
767///
768/// # Defaults
769///
770/// - `enabled`: `true`
771///
772/// # Examples
773///
774/// ```
775/// use deps_core::policy_config::SupplyChainConfig;
776///
777/// let config = SupplyChainConfig::default();
778/// assert!(config.enabled);
779/// ```
780#[non_exhaustive]
781#[derive(Debug, Clone, Deserialize)]
782pub struct SupplyChainConfig {
783 /// Whether supply-chain trust signals (OpenSSF Scorecard/SLSA provenance) are fetched.
784 #[serde(default = "default_true")]
785 pub enabled: bool,
786}
787
788// Hand-written, not derived: a derived `Default` would silently ship this disabled. Delegates
789// to `Self::new` so the literal has one source of truth (mirrors `CodeLensConfig`).
790impl Default for SupplyChainConfig {
791 fn default() -> Self {
792 Self::new()
793 }
794}
795
796impl SupplyChainConfig {
797 /// Builds the default configuration (mirrors [`Self::default`]).
798 ///
799 /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
800 /// `deps-core`, so every other crate must chain the `with_*` setters onto this
801 /// constructor instead.
802 ///
803 /// # Examples
804 ///
805 /// ```
806 /// use deps_core::policy_config::SupplyChainConfig;
807 ///
808 /// let config = SupplyChainConfig::new();
809 /// assert!(config.enabled);
810 /// ```
811 #[must_use]
812 pub const fn new() -> Self {
813 Self { enabled: true }
814 }
815
816 /// Overrides [`Self::enabled`]. See [`Self::new`].
817 #[must_use]
818 pub const fn with_enabled(mut self, enabled: bool) -> Self {
819 self.enabled = enabled;
820 self
821 }
822}
823
824/// Minimum cooldown window (seconds) — 0 disables the cooldown callout
825/// while keeping age display.
826const MIN_COOLDOWN_SECS: u64 = 0;
827/// Maximum cooldown window (seconds) — 30 days.
828const MAX_COOLDOWN_SECS: u64 = 30 * 24 * 60 * 60;
829
830/// Custom deserializer for `cooldown_secs` that validates bounds.
831fn deserialize_cooldown_secs<'de, D>(deserializer: D) -> Result<u64, D::Error>
832where
833 D: serde::Deserializer<'de>,
834{
835 let secs = u64::deserialize(deserializer)?;
836 let clamped = secs.clamp(MIN_COOLDOWN_SECS, MAX_COOLDOWN_SECS);
837 if clamped != secs {
838 tracing::warn!(
839 "freshness.cooldown_secs {} clamped to {} (valid range: {}-{})",
840 secs,
841 clamped,
842 MIN_COOLDOWN_SECS,
843 MAX_COOLDOWN_SECS
844 );
845 }
846 Ok(clamped)
847}
848
849/// Cross-ecosystem workspace-declared registry settings (spec #443/plan-1b §1.7, renamed
850/// from `cargo.workspace_registries` by `032-npm-npmrc-registry-support` FR-008/C2).
851///
852/// **Breaking, pre-1.0, no alias.** `HttpCache` holds exactly one global
853/// `Arc<RegistryAccessPolicy>`, so this setting was never actually Cargo-scoped — it already
854/// governed every ecosystem's workspace-declared registry fetches (the npm `.npmrc`
855/// `registry=`/`@scope:registry=` path included, once that feature also reads it). A client
856/// still sending the old `cargo` key fails `DepsConfig`'s top-level `deny_unknown_fields`
857/// parse — since that attribute sits on `DepsConfig` itself, not on this section, the
858/// rejection takes the **whole** settings payload with it, not just this one setting. Sent at
859/// `initialize` (the common case) that means every setting reverts to its default — safely,
860/// for the security-relevant one here, since `WorkspaceRegistriesSetting::default()` is
861/// `PublicOnly` and `HttpCache::new` already starts there; sent later via
862/// `workspace/didChangeConfiguration` the previously applied configuration is kept instead.
863/// Either way the failure is logged (`tracing::warn!`), just not surfaced by most editors.
864///
865/// # Examples
866///
867/// ```
868/// use deps_core::policy_config::{RegistriesConfig, WorkspaceRegistriesSetting};
869///
870/// let config = RegistriesConfig::default();
871/// assert_eq!(config.workspace_registries, WorkspaceRegistriesSetting::PublicOnly);
872/// ```
873///
874/// `#[derive(RedactingDebug)]` (#936): `gitlab_instance_host` is a raw, unvalidated host
875/// literal that can be credential-shaped (e.g. `user:hunter2@gitlab.corp`) since no
876/// validation happens at deserialization time (see that field's own doc) — without redaction,
877/// a derived `Debug` would print it verbatim into `server.rs`'s
878/// `tracing::debug!("loaded configuration: {:?}", config)` at `RUST_LOG=debug`, before
879/// `deps_gitlab_ci::host::GitlabInstanceHost::get` ever gets a chance to reject it.
880#[non_exhaustive]
881#[derive(Clone, Deserialize, crate::redact_debug::RedactingDebug)]
882pub struct RegistriesConfig {
883 /// Whether workspace-declared registry hosts (e.g. a manifest's own custom index
884 /// URLs) may be reached at all, or only the default public registry.
885 #[serde(default)]
886 #[raw]
887 pub workspace_registries: WorkspaceRegistriesSetting,
888 /// Issue #561, FR-006: whether a NuGet user-profile-tier `NuGet.Config` `<add>` with no
889 /// repo-declared counterpart becomes a routing hop (`AlternateRegistry`-sourced, so
890 /// OSV/deps.dev/hover-trust are suppressed for it — spec 035 §5a), not just a credential
891 /// source for a repo-declared entry at the same URL. `#[serde(default)]`: additive-safe,
892 /// since `RegistriesConfig` itself is not under `DepsConfig`'s top-level
893 /// `deny_unknown_fields`. Default `false` — zero routing effect from any user-profile file
894 /// unless explicitly opted in.
895 #[serde(default)]
896 #[raw]
897 pub nuget_user_profile_sources: bool,
898 /// Issue #466, spec FR-005a/FR-011a: the GitLab instance host that `project:` includes
899 /// and `$CI_SERVER_FQDN`-relative `component:` includes resolve against, and — replacing,
900 /// not joined with, `gitlab.com` — the *only* host `GITLAB_TOKEN` may be sent to.
901 /// `#[serde(default)]`: additive-safe, same rationale as `nuget_user_profile_sources`
902 /// above. Default `""` (unset); an empty string is written through as `None` into the
903 /// shared `Arc<RwLock<Option<String>>>` handle. **No validation happens at deserialization
904 /// time** — `deps-lsp` must not depend on `deps-gitlab-ci` for host semantics, so this
905 /// type accepts any string. Two validation points exist downstream instead: once per
906 /// config update, via `deps_engine::setup::validate_gitlab_instance_host` (which surfaces
907 /// a rejection to the user); and lazily on every read, via
908 /// `deps_gitlab_ci::host::GitlabInstanceHost::get`, which also documents the
909 /// already-open-document limitation of a live change to this setting.
910 #[serde(default)]
911 #[redact(url)]
912 pub gitlab_instance_host: String,
913}
914
915// Hand-written, not derived, delegating to `Self::new` for a single source of truth
916// (matches `SupplyChainConfig`'s/`NetworkConfig`'s rationale).
917impl Default for RegistriesConfig {
918 fn default() -> Self {
919 Self::new()
920 }
921}
922
923impl RegistriesConfig {
924 /// Builds the default configuration (mirrors [`Self::default`]).
925 ///
926 /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
927 /// `deps-core`, so every other crate must chain the `with_*` setters onto this
928 /// constructor instead. Not `const`, unlike `SupplyChainConfig::new`/`NetworkConfig::new` —
929 /// `gitlab_instance_host` is a `String`, the same reason `InlayHintsConfig::new`
930 /// (`deps-lsp/src/config.rs`) isn't `const` either.
931 ///
932 /// # Examples
933 ///
934 /// ```
935 /// use deps_core::policy_config::{RegistriesConfig, WorkspaceRegistriesSetting};
936 ///
937 /// let config = RegistriesConfig::new();
938 /// assert_eq!(config.workspace_registries, WorkspaceRegistriesSetting::PublicOnly);
939 /// ```
940 #[must_use]
941 pub fn new() -> Self {
942 Self {
943 workspace_registries: WorkspaceRegistriesSetting::PublicOnly,
944 nuget_user_profile_sources: false,
945 gitlab_instance_host: String::new(),
946 }
947 }
948
949 /// Overrides [`Self::workspace_registries`]. See [`Self::new`].
950 #[must_use]
951 pub const fn with_workspace_registries(
952 mut self,
953 workspace_registries: WorkspaceRegistriesSetting,
954 ) -> Self {
955 self.workspace_registries = workspace_registries;
956 self
957 }
958
959 /// Overrides [`Self::nuget_user_profile_sources`]. See [`Self::new`].
960 #[must_use]
961 pub const fn with_nuget_user_profile_sources(
962 mut self,
963 nuget_user_profile_sources: bool,
964 ) -> Self {
965 self.nuget_user_profile_sources = nuget_user_profile_sources;
966 self
967 }
968
969 /// Overrides [`Self::gitlab_instance_host`]. See [`Self::new`].
970 #[must_use]
971 pub fn with_gitlab_instance_host(mut self, gitlab_instance_host: impl Into<String>) -> Self {
972 self.gitlab_instance_host = gitlab_instance_host.into();
973 self
974 }
975}
976
977/// The three live-updatable settings [`RegistriesConfig::resolve`] derives from a config
978/// snapshot.
979///
980/// Every consumer that needs to turn a [`RegistriesConfig`] into runtime-usable values
981/// (`deps_engine::setup::EcosystemRuntime::from_policy`, and `deps-lsp`'s two config-reload
982/// call sites in `initialize`/`did_change_configuration`) shares this one derivation instead
983/// of each re-deriving `gitlab_instance_host`'s empty-string-to-`None` normalization
984/// independently — three copies of that normalization is exactly the kind of drift-prone
985/// duplication issue #1058 (T009) found and closed.
986#[derive(Clone, PartialEq, Eq)]
987pub struct RegistryRuntimeSettings {
988 /// Resolved workspace-registry access policy — see [`WorkspaceRegistriesSetting::to_policy`].
989 pub workspace_registries: crate::net_policy::WorkspaceRegistryAccess,
990 /// See [`RegistriesConfig::nuget_user_profile_sources`].
991 pub nuget_user_profile_sources: bool,
992 /// See [`RegistriesConfig::gitlab_instance_host`] — normalized from an empty string to
993 /// `None`.
994 pub gitlab_instance_host: Option<String>,
995}
996
997impl std::fmt::Debug for RegistryRuntimeSettings {
998 /// Manual, not derived: `gitlab_instance_host` is a raw host string one hop away from
999 /// [`RegistriesConfig`]'s own redacting `Debug` impl (#936), the same "sibling missed by a
1000 /// prior sweep" pattern as the rest of this leak class (CWE-532, #1222).
1001 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1002 f.debug_struct("RegistryRuntimeSettings")
1003 .field("workspace_registries", &self.workspace_registries)
1004 .field(
1005 "nuget_user_profile_sources",
1006 &self.nuget_user_profile_sources,
1007 )
1008 .field(
1009 "gitlab_instance_host",
1010 &self
1011 .gitlab_instance_host
1012 .as_deref()
1013 .map(crate::redact::RedactedUrl::new),
1014 )
1015 .finish()
1016 }
1017}
1018
1019impl RegistriesConfig {
1020 /// Derives the three live-updatable settings this section resolves into.
1021 ///
1022 /// # Examples
1023 ///
1024 /// ```
1025 /// use deps_core::net_policy::WorkspaceRegistryAccess;
1026 /// use deps_core::policy_config::RegistriesConfig;
1027 ///
1028 /// let config = RegistriesConfig::new().with_gitlab_instance_host("gitlab.corp");
1029 /// let resolved = config.resolve();
1030 /// assert_eq!(resolved.workspace_registries, WorkspaceRegistryAccess::PublicOnly);
1031 /// assert_eq!(resolved.gitlab_instance_host.as_deref(), Some("gitlab.corp"));
1032 /// ```
1033 #[must_use]
1034 pub fn resolve(&self) -> RegistryRuntimeSettings {
1035 RegistryRuntimeSettings {
1036 workspace_registries: self.workspace_registries.to_policy(),
1037 nuget_user_profile_sources: self.nuget_user_profile_sources,
1038 gitlab_instance_host: (!self.gitlab_instance_host.is_empty())
1039 .then(|| self.gitlab_instance_host.clone()),
1040 }
1041 }
1042}
1043
1044/// Controls which workspace-declared registry index hosts this LSP will ever fetch.
1045///
1046/// Shared by every ecosystem with a workspace-declared-registry concept (spec #443,
1047/// plan-1b §1.1/§1.7; `032-npm-npmrc-registry-support` FR-008 widened this from Cargo-only
1048/// to cross-ecosystem).
1049///
1050/// Applies to Cargo's `registry`/`registry-index` alias path (#440), a
1051/// `[source.crates-io] replace-with` chain (1b), and npm's `.npmrc` `registry=`/
1052/// `@scope:registry=` resolution alike. Never affects a `$CARGO_HOME/config.toml`-configured
1053/// Cargo registry, which is the user's own trusted configuration, not something a cloned
1054/// repository controls — npm's `.npmrc` has no equivalent always-trusted tier (both its
1055/// project and user tiers are policy-symmetric, since phase 1 carries no credential
1056/// provenance to protect).
1057///
1058/// # Defaults
1059///
1060/// `"public_only"` — blocking the observed attack shape (an IP literal in a metadata/RFC1918
1061/// range) without breaking a legitimate corporate `https://index.mycorp.dev` registry (a DNS
1062/// name cannot be classified as internal without resolving it — see
1063/// [`crate::net_policy`]'s module docs for the residual risk this leaves and why `off`
1064/// is the only complete boundary).
1065///
1066/// # Examples
1067///
1068/// ```
1069/// use deps_core::policy_config::WorkspaceRegistriesSetting;
1070///
1071/// let setting: WorkspaceRegistriesSetting = serde_json::from_str("\"off\"").unwrap();
1072/// assert_eq!(setting, WorkspaceRegistriesSetting::Off);
1073/// ```
1074#[non_exhaustive]
1075#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
1076#[serde(rename_all = "snake_case")]
1077pub enum WorkspaceRegistriesSetting {
1078 /// Block every workspace-declared registry index — the only complete boundary. This
1079 /// blocks the `registry`/`registry-index` alias path as well as `[source]`; it does
1080 /// **not** affect `$CARGO_HOME`-configured registries, which keep working.
1081 Off,
1082 /// Allow only a host classified as public (spec `deps_core::net_policy::HostClass::Global`).
1083 #[default]
1084 PublicOnly,
1085 /// Allow every workspace-declared index, including loopback/RFC1918/metadata-range
1086 /// hosts — today's pre-#443 behavior, the escape hatch for a workspace that legitimately
1087 /// points at one.
1088 All,
1089}
1090
1091impl WorkspaceRegistriesSetting {
1092 /// Converts this LSP-facing setting into the `deps-core` policy value threaded through
1093 /// `deps_cargo::config::RegistryIndex::new`.
1094 ///
1095 /// # Examples
1096 ///
1097 /// ```
1098 /// use deps_core::net_policy::WorkspaceRegistryAccess;
1099 /// use deps_core::policy_config::WorkspaceRegistriesSetting;
1100 ///
1101 /// assert_eq!(
1102 /// WorkspaceRegistriesSetting::Off.to_policy(),
1103 /// WorkspaceRegistryAccess::Off
1104 /// );
1105 /// ```
1106 #[must_use]
1107 pub const fn to_policy(self) -> crate::net_policy::WorkspaceRegistryAccess {
1108 match self {
1109 Self::Off => crate::net_policy::WorkspaceRegistryAccess::Off,
1110 Self::PublicOnly => crate::net_policy::WorkspaceRegistryAccess::PublicOnly,
1111 Self::All => crate::net_policy::WorkspaceRegistryAccess::All,
1112 }
1113 }
1114}
1115
1116/// Configuration for outbound network access (issue #483).
1117///
1118/// # Defaults
1119///
1120/// - `offline`: `false`
1121///
1122/// # Examples
1123///
1124/// ```
1125/// use deps_core::policy_config::NetworkConfig;
1126///
1127/// let config = NetworkConfig::default();
1128/// assert!(!config.offline);
1129/// ```
1130#[non_exhaustive]
1131#[derive(Debug, Clone, Deserialize)]
1132pub struct NetworkConfig {
1133 /// When `true`, blocks every *new* outbound registry/OSV/GitHub-tags request
1134 /// (`deps_core::cache::HttpCache`'s 4 send sites) instead of making it, serving
1135 /// already-cached data where available and returning `deps_core::DepsError::Offline`
1136 /// otherwise. Also forces `cache.enabled` semantics to `true` for the duration (see
1137 /// [`CacheConfig::enabled`]'s doc comment), so a warm entry keeps serving through an
1138 /// online→offline transition even if caching was explicitly disabled.
1139 ///
1140 /// `HttpCache::set_offline` is a bare atomic store: a request already past its
1141 /// `ensure_online` check and awaiting a response completes normally, and toggling
1142 /// this flag never cancels in-flight requests.
1143 #[serde(default)]
1144 pub offline: bool,
1145}
1146
1147// Hand-written, not derived, delegating to `Self::new` for a single source of truth
1148// (matches `SupplyChainConfig`'s rationale).
1149impl Default for NetworkConfig {
1150 fn default() -> Self {
1151 Self::new()
1152 }
1153}
1154
1155impl NetworkConfig {
1156 /// Builds the default configuration (mirrors [`Self::default`]).
1157 ///
1158 /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
1159 /// `deps-core`, so every other crate must chain the `with_*` setters onto this
1160 /// constructor instead.
1161 ///
1162 /// # Examples
1163 ///
1164 /// ```
1165 /// use deps_core::policy_config::NetworkConfig;
1166 ///
1167 /// let config = NetworkConfig::new();
1168 /// assert!(!config.offline);
1169 /// ```
1170 #[must_use]
1171 pub const fn new() -> Self {
1172 Self { offline: false }
1173 }
1174
1175 /// Overrides [`Self::offline`]. See [`Self::new`].
1176 #[must_use]
1177 pub const fn with_offline(mut self, offline: bool) -> Self {
1178 self.offline = offline;
1179 self
1180 }
1181
1182 /// Converts [`Self::offline`] into the typed [`crate::NetworkMode`] every
1183 /// network-reachability call site reads instead of the raw config flag.
1184 ///
1185 /// # Examples
1186 ///
1187 /// ```
1188 /// use deps_core::NetworkMode;
1189 /// use deps_core::policy_config::NetworkConfig;
1190 ///
1191 /// let config = NetworkConfig::new().with_offline(true);
1192 /// assert_eq!(config.mode(), NetworkMode::Offline);
1193 /// ```
1194 #[must_use]
1195 pub fn mode(&self) -> crate::NetworkMode {
1196 crate::NetworkMode::from_offline_flag(self.offline)
1197 }
1198}
1199
1200/// SPDX allow-list/deny-list policy for the license-policy diagnostic (issue #661, spec 010
1201/// Phase 2).
1202///
1203/// Both lists are independently optional; an empty/default policy produces no diagnostics.
1204/// Not parse-affecting (see `deps-lsp::config::reparse_scope`): a change here is picked up
1205/// the next time diagnostics are pulled, without forcing a document reparse, since policy
1206/// evaluation reads the current config fresh on every diagnostics request rather than being
1207/// baked into parse-time state.
1208///
1209/// # Defaults
1210///
1211/// - `allow`: `[]`
1212/// - `deny`: `[]`
1213///
1214/// # Examples
1215///
1216/// ```
1217/// use deps_core::policy_config::LicensePolicyConfig;
1218///
1219/// let config = LicensePolicyConfig::default();
1220/// assert!(config.to_policy().is_empty());
1221/// ```
1222#[non_exhaustive]
1223#[derive(Debug, Clone, Deserialize, Default)]
1224pub struct LicensePolicyConfig {
1225 /// SPDX identifiers a dependency's license must include at least one of, when non-empty.
1226 #[serde(default, deserialize_with = "deserialize_spdx_list")]
1227 pub allow: Vec<String>,
1228 /// SPDX identifiers a dependency's license must not include any of.
1229 #[serde(default, deserialize_with = "deserialize_spdx_list")]
1230 pub deny: Vec<String>,
1231}
1232
1233impl LicensePolicyConfig {
1234 /// Builds an empty allow/deny policy (mirrors [`Self::default`]).
1235 ///
1236 /// # Examples
1237 ///
1238 /// ```
1239 /// use deps_core::policy_config::LicensePolicyConfig;
1240 ///
1241 /// let config = LicensePolicyConfig::new();
1242 /// assert!(config.allow.is_empty());
1243 /// ```
1244 #[must_use]
1245 pub const fn new() -> Self {
1246 Self {
1247 allow: Vec::new(),
1248 deny: Vec::new(),
1249 }
1250 }
1251
1252 /// Overrides [`Self::allow`]. See [`Self::new`]. Takes `Vec<String>` rather than
1253 /// `impl Into<String>` — this field is a list, not a single string — mirroring
1254 /// `deps_core::ResolvedPackage::with_dependencies`'s existing precedent for a `Vec<String>`
1255 /// field, not this module's single-`String`-field setters (e.g.
1256 /// `deps-lsp::config::InlayHintsConfig::with_up_to_date_text`).
1257 #[must_use]
1258 pub fn with_allow(mut self, allow: Vec<String>) -> Self {
1259 self.allow = allow;
1260 self
1261 }
1262
1263 /// Overrides [`Self::deny`]. See [`Self::new`] and [`Self::with_allow`]'s note on this
1264 /// setter's parameter type.
1265 #[must_use]
1266 pub fn with_deny(mut self, deny: Vec<String>) -> Self {
1267 self.deny = deny;
1268 self
1269 }
1270
1271 /// Converts this LSP-facing config into the `deps-core` policy threaded through
1272 /// [`crate::licenses::evaluate`].
1273 ///
1274 /// Both fields already went through `deserialize_spdx_list` at config-load time, so
1275 /// this never drops entries or logs a warning of its own — [`crate::LicensePolicy::new`]
1276 /// simply re-validates already-clean data, which is a no-op.
1277 ///
1278 /// # Examples
1279 ///
1280 /// ```
1281 /// use deps_core::policy_config::LicensePolicyConfig;
1282 ///
1283 /// let config = LicensePolicyConfig::new()
1284 /// .with_allow(vec!["MIT".to_string()])
1285 /// .with_deny(vec!["GPL-3.0".to_string()]);
1286 /// let policy = config.to_policy();
1287 /// assert_eq!(policy.allow, vec!["MIT".to_string()]);
1288 /// ```
1289 #[must_use]
1290 pub fn to_policy(&self) -> crate::LicensePolicy {
1291 crate::LicensePolicy::new(self.allow.clone(), self.deny.clone())
1292 }
1293}
1294
1295/// Configuration for the typosquat-similarity diagnostic (issue #1437, spec 071).
1296///
1297/// Controls whether a declared dependency is checked against deps.dev's
1298/// `GetSimilarlyNamedPackages` endpoint for a much-more-popular, similarly-named package —
1299/// a possible sign of a typo or a typosquat. Ships **disabled by default**: unlike
1300/// `supply_chain`/`diagnostics.vulnerabilities_enabled` (both opt-out), this signal is built
1301/// on an undocumented, v3alpha (no stability guarantee) similarity algorithm, so spec 071
1302/// §9 resolves it as opt-in only at launch, with default-on deferred to a separate future
1303/// issue once the endpoint has shown stability across releases.
1304///
1305/// # Defaults
1306///
1307/// - `enabled`: `false`
1308///
1309/// # Examples
1310///
1311/// ```
1312/// use deps_core::policy_config::TyposquatConfig;
1313///
1314/// let config = TyposquatConfig::default();
1315/// assert!(!config.enabled);
1316/// ```
1317#[non_exhaustive]
1318#[derive(Debug, Clone, Deserialize, Default)]
1319pub struct TyposquatConfig {
1320 /// Whether the typosquat-similarity diagnostic runs at all.
1321 #[serde(default)]
1322 pub enabled: bool,
1323}
1324
1325impl TyposquatConfig {
1326 /// Builds the default (disabled) configuration (mirrors [`Self::default`]).
1327 ///
1328 /// # Examples
1329 ///
1330 /// ```
1331 /// use deps_core::policy_config::TyposquatConfig;
1332 ///
1333 /// let config = TyposquatConfig::new();
1334 /// assert!(!config.enabled);
1335 /// ```
1336 #[must_use]
1337 pub const fn new() -> Self {
1338 Self { enabled: false }
1339 }
1340
1341 /// Overrides [`Self::enabled`]. See [`Self::new`].
1342 #[must_use]
1343 pub const fn with_enabled(mut self, enabled: bool) -> Self {
1344 self.enabled = enabled;
1345 self
1346 }
1347}
1348
1349/// Configuration for deps.dev GOSSIP-sourced signals (issue #1456, spec 072).
1350///
1351/// Controls whether a document's declared dependencies are batch-checked against deps.dev's
1352/// `GetFindingsBatch`/`GetFindings` endpoints for Dynamic Cooldown and Low-Usage-Packages
1353/// findings. Ships **disabled by default**, structural twin of [`TyposquatConfig`] for the
1354/// identical reason: the per-document prefetch discloses every declared dependency's name to
1355/// deps.dev.
1356///
1357/// # Defaults
1358///
1359/// - `enabled`: `false`
1360///
1361/// # Examples
1362///
1363/// ```
1364/// use deps_core::policy_config::GossipConfig;
1365///
1366/// let config = GossipConfig::default();
1367/// assert!(!config.enabled);
1368/// ```
1369#[non_exhaustive]
1370#[derive(Debug, Clone, Deserialize, Default)]
1371pub struct GossipConfig {
1372 /// Whether GOSSIP-sourced cooldown/low-usage signals are fetched at all.
1373 #[serde(default)]
1374 pub enabled: bool,
1375}
1376
1377impl GossipConfig {
1378 /// Builds the default (disabled) configuration (mirrors [`Self::default`]).
1379 ///
1380 /// # Examples
1381 ///
1382 /// ```
1383 /// use deps_core::policy_config::GossipConfig;
1384 ///
1385 /// let config = GossipConfig::new();
1386 /// assert!(!config.enabled);
1387 /// ```
1388 #[must_use]
1389 pub const fn new() -> Self {
1390 Self { enabled: false }
1391 }
1392
1393 /// Overrides [`Self::enabled`]. See [`Self::new`].
1394 #[must_use]
1395 pub const fn with_enabled(mut self, enabled: bool) -> Self {
1396 self.enabled = enabled;
1397 self
1398 }
1399}
1400
1401/// Custom deserializer for `LicensePolicyConfig`'s `allow`/`deny` lists: drops (and warns
1402/// about, via [`crate::licenses::filter_valid_spdx_ids`]) any entry that isn't
1403/// syntactically a plausible single SPDX identifier, exactly once at config-load time —
1404/// same "warn, never crash" contract as every other custom deserializer in this module (spec
1405/// 010 plan.md's "Invalid SPDX identifier in policy" decision: `initializationOptions` has no
1406/// document URI to anchor an LSP diagnostic to, so a log warning is the only feedback
1407/// channel).
1408fn deserialize_spdx_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
1409where
1410 D: serde::Deserializer<'de>,
1411{
1412 let raw = Vec::<String>::deserialize(deserializer)?;
1413 Ok(crate::licenses::filter_valid_spdx_ids(raw))
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418 use super::*;
1419
1420 #[test]
1421 fn test_policy_config_defaults() {
1422 let policy = PolicyConfig::default();
1423 assert!(policy.diagnostics.vulnerabilities_enabled);
1424 assert!(!policy.network.offline);
1425 assert_eq!(
1426 policy.registries.workspace_registries,
1427 WorkspaceRegistriesSetting::PublicOnly
1428 );
1429 }
1430
1431 #[test]
1432 fn test_workspace_registries_setting_deserializes_all_variants() {
1433 assert_eq!(
1434 serde_json::from_str::<WorkspaceRegistriesSetting>("\"off\"").unwrap(),
1435 WorkspaceRegistriesSetting::Off
1436 );
1437 assert_eq!(
1438 serde_json::from_str::<WorkspaceRegistriesSetting>("\"public_only\"").unwrap(),
1439 WorkspaceRegistriesSetting::PublicOnly
1440 );
1441 assert_eq!(
1442 serde_json::from_str::<WorkspaceRegistriesSetting>("\"all\"").unwrap(),
1443 WorkspaceRegistriesSetting::All
1444 );
1445 }
1446
1447 /// #936: `RegistriesConfig`'s `#[derive(RedactingDebug)]`-generated `Debug` impl must
1448 /// redact a credential-shaped `gitlab_instance_host` while still identifying the host.
1449 #[test]
1450 fn test_registries_config_debug_redacts_gitlab_instance_host_credential() {
1451 let config = RegistriesConfig {
1452 gitlab_instance_host: "user:hunter2@gitlab.corp".to_string(),
1453 ..RegistriesConfig::default()
1454 };
1455
1456 let section_debug = format!("{config:?}");
1457 assert!(!section_debug.contains("hunter2"), "{section_debug}");
1458 assert!(section_debug.contains("gitlab.corp"), "{section_debug}");
1459 }
1460
1461 #[test]
1462 fn test_workspace_registries_setting_to_policy() {
1463 use crate::net_policy::WorkspaceRegistryAccess;
1464
1465 assert_eq!(
1466 WorkspaceRegistriesSetting::Off.to_policy(),
1467 WorkspaceRegistryAccess::Off
1468 );
1469 assert_eq!(
1470 WorkspaceRegistriesSetting::PublicOnly.to_policy(),
1471 WorkspaceRegistryAccess::PublicOnly
1472 );
1473 assert_eq!(
1474 WorkspaceRegistriesSetting::All.to_policy(),
1475 WorkspaceRegistryAccess::All
1476 );
1477 }
1478
1479 /// #1058 M1: `WorkspaceRegistryAccess::default()` (`net_policy.rs`) and
1480 /// `WorkspaceRegistriesSetting::default()` (this module) are two independent `#[default]`
1481 /// derives that must resolve to the same policy — `ServerState::new`'s
1482 /// `EcosystemRuntime::from_policy(&PolicyConfig::default())` relies on this equivalence to
1483 /// reproduce the same defaults its old hand-built `RegistryAccessPolicy::default()` call
1484 /// had. Nothing else catches the two drifting apart; this test does.
1485 #[test]
1486 fn test_workspace_registry_access_default_matches_workspace_registries_setting_default() {
1487 assert_eq!(
1488 crate::net_policy::WorkspaceRegistryAccess::default(),
1489 WorkspaceRegistriesSetting::default().to_policy()
1490 );
1491 }
1492
1493 #[test]
1494 fn test_registries_config_resolve_normalizes_empty_gitlab_instance_host_to_none() {
1495 let resolved = RegistriesConfig::default().resolve();
1496 assert_eq!(resolved.gitlab_instance_host, None);
1497 assert!(!resolved.nuget_user_profile_sources);
1498 }
1499
1500 #[test]
1501 fn test_registries_config_resolve_carries_non_default_settings() {
1502 use crate::net_policy::WorkspaceRegistryAccess;
1503
1504 let config = RegistriesConfig {
1505 workspace_registries: WorkspaceRegistriesSetting::All,
1506 nuget_user_profile_sources: true,
1507 gitlab_instance_host: "gitlab.corp".to_string(),
1508 };
1509
1510 let resolved = config.resolve();
1511 assert_eq!(resolved.workspace_registries, WorkspaceRegistryAccess::All);
1512 assert!(resolved.nuget_user_profile_sources);
1513 assert_eq!(
1514 resolved.gitlab_instance_host.as_deref(),
1515 Some("gitlab.corp")
1516 );
1517 }
1518
1519 #[test]
1520 fn test_diagnostics_config_sha_comment_mismatch_severity_default_and_override() {
1521 let default: DiagnosticsConfig = serde_json::from_str("{}").unwrap();
1522 assert_eq!(default.sha_comment_mismatch_severity, Severity::Warning);
1523 assert_eq!(
1524 default.to_severities().sha_comment_mismatch,
1525 Severity::Warning
1526 );
1527
1528 let config: DiagnosticsConfig =
1529 serde_json::from_str(r#"{ "sha_comment_mismatch_severity": 1 }"#).unwrap();
1530 assert_eq!(config.sha_comment_mismatch_severity, Severity::Error);
1531 assert_eq!(config.to_severities().sha_comment_mismatch, Severity::Error);
1532 }
1533
1534 #[test]
1535 fn test_diagnostics_config_deserialization() {
1536 let json = r#"{
1537 "outdated_severity": 1,
1538 "unknown_severity": 2,
1539 "yanked_severity": 2,
1540 "unsatisfiable_severity": 1,
1541 "deprecated_severity": 1
1542 }"#;
1543
1544 let config: DiagnosticsConfig = serde_json::from_str(json).unwrap();
1545 assert_eq!(config.outdated_severity, Severity::Error);
1546 assert_eq!(config.unknown_severity, Severity::Warning);
1547 assert_eq!(config.yanked_severity, Severity::Warning);
1548 assert_eq!(config.unsatisfiable_severity, Severity::Error);
1549 assert_eq!(config.deprecated_severity, Severity::Error);
1550 }
1551
1552 #[test]
1553 fn test_diagnostics_config_mutable_ref_pin_enabled_can_be_disabled() {
1554 let json = r#"{ "mutable_ref_pin_enabled": false }"#;
1555 let config: DiagnosticsConfig = serde_json::from_str(json).unwrap();
1556 assert!(!config.mutable_ref_pin_enabled);
1557 }
1558
1559 #[test]
1560 fn test_diagnostics_config_vulnerabilities_enabled_can_be_disabled() {
1561 let json = r#"{ "vulnerabilities_enabled": false }"#;
1562 let config: DiagnosticsConfig = serde_json::from_str(json).unwrap();
1563 assert!(!config.vulnerabilities_enabled);
1564 }
1565
1566 #[test]
1567 fn test_cache_config_deserialization() {
1568 let json = r#"{
1569 "enabled": false
1570 }"#;
1571
1572 let config: CacheConfig = serde_json::from_str(json).unwrap();
1573 assert!(!config.enabled);
1574 }
1575
1576 #[test]
1577 fn test_cache_config_with_enabled() {
1578 let config = CacheConfig::new().with_enabled(false);
1579 assert!(!config.enabled);
1580 }
1581
1582 #[test]
1583 fn test_cache_config_defaults() {
1584 let config = CacheConfig::default();
1585 assert!(config.enabled);
1586 assert_eq!(config.fetch_timeout_secs, 5);
1587 assert_eq!(config.max_concurrent_fetches, 20);
1588 }
1589
1590 #[test]
1591 fn test_cache_config_fetch_timeout_clamped() {
1592 let json = r#"{ "fetch_timeout_secs": 10000 }"#;
1593 let config: CacheConfig = serde_json::from_str(json).unwrap();
1594 assert_eq!(config.fetch_timeout_secs, MAX_FETCH_TIMEOUT_SECS);
1595 }
1596
1597 #[test]
1598 fn test_cache_config_max_concurrent_fetches_clamped() {
1599 let json = r#"{ "max_concurrent_fetches": 0 }"#;
1600 let config: CacheConfig = serde_json::from_str(json).unwrap();
1601 assert_eq!(config.max_concurrent_fetches, MIN_CONCURRENT_FETCHES);
1602 }
1603
1604 #[test]
1605 fn test_freshness_config_defaults() {
1606 let config = FreshnessConfig::default();
1607 assert!(config.enabled);
1608 assert_eq!(config.cooldown_secs, crate::DEFAULT_COOLDOWN_SECS);
1609 }
1610
1611 #[test]
1612 fn test_freshness_config_cooldown_clamped() {
1613 let json = r#"{ "cooldown_secs": 99999999 }"#;
1614 let config: FreshnessConfig = serde_json::from_str(json).unwrap();
1615 assert_eq!(config.cooldown_secs, MAX_COOLDOWN_SECS);
1616 }
1617
1618 #[test]
1619 fn test_supply_chain_config_defaults() {
1620 assert!(SupplyChainConfig::default().enabled);
1621 }
1622
1623 #[test]
1624 fn test_supply_chain_config_with_enabled() {
1625 assert!(!SupplyChainConfig::new().with_enabled(false).enabled);
1626 }
1627
1628 #[test]
1629 fn test_network_config_defaults() {
1630 assert!(!NetworkConfig::default().offline);
1631 }
1632
1633 #[test]
1634 fn test_network_config_with_offline() {
1635 assert!(NetworkConfig::new().with_offline(true).offline);
1636 }
1637
1638 #[test]
1639 fn test_gossip_config_defaults() {
1640 assert!(!GossipConfig::default().enabled);
1641 }
1642
1643 #[test]
1644 fn test_gossip_config_with_enabled() {
1645 assert!(GossipConfig::new().with_enabled(true).enabled);
1646 }
1647
1648 /// Regression for plan.md M12: a partial `{"gossip":{}}` section (missing `enabled`)
1649 /// must parse successfully rather than failing the whole config reload — the exact bug
1650 /// a missing `#[serde(default)]` on `enabled` would cause.
1651 #[test]
1652 fn test_gossip_config_partial_object_parses() {
1653 let config: PolicyConfig = serde_json::from_str(r#"{"gossip": {}}"#).unwrap();
1654 assert!(!config.gossip.enabled);
1655 }
1656
1657 #[test]
1658 fn test_policy_config_diff_no_change_returns_all_false() {
1659 let old = PolicyConfig::default();
1660 let new = PolicyConfig::default();
1661 assert_eq!(PolicyConfig::diff(&old, &new), PolicyConfigDiff::default());
1662 }
1663
1664 #[test]
1665 fn test_policy_config_diff_workspace_registries_change() {
1666 let old = PolicyConfig::default();
1667 let mut new = PolicyConfig::default();
1668 new.registries.workspace_registries = WorkspaceRegistriesSetting::Off;
1669
1670 let diff = PolicyConfig::diff(&old, &new);
1671 assert!(diff.workspace_registries_changed);
1672 assert!(!diff.nuget_user_profile_sources_changed);
1673 assert!(!diff.gitlab_instance_host_changed);
1674 }
1675
1676 #[test]
1677 fn test_policy_config_diff_nuget_user_profile_sources_change() {
1678 let old = PolicyConfig::default();
1679 let mut new = PolicyConfig::default();
1680 new.registries.nuget_user_profile_sources = true;
1681
1682 let diff = PolicyConfig::diff(&old, &new);
1683 assert!(!diff.workspace_registries_changed);
1684 assert!(diff.nuget_user_profile_sources_changed);
1685 assert!(!diff.gitlab_instance_host_changed);
1686 }
1687
1688 #[test]
1689 fn test_policy_config_diff_gitlab_instance_host_change() {
1690 let old = PolicyConfig::default();
1691 let mut new = PolicyConfig::default();
1692 new.registries.gitlab_instance_host = "gitlab.corp".to_string();
1693
1694 let diff = PolicyConfig::diff(&old, &new);
1695 assert!(!diff.workspace_registries_changed);
1696 assert!(!diff.nuget_user_profile_sources_changed);
1697 assert!(diff.gitlab_instance_host_changed);
1698 }
1699
1700 /// A non-registries change (issue #1064) must not surface in the diff at all — this is the
1701 /// same "not parse-affecting" set `reparse_scope` already ignored before this PR.
1702 #[test]
1703 fn test_policy_config_diff_non_registries_change_returns_all_false() {
1704 let old = PolicyConfig::default();
1705 let mut new = PolicyConfig::default();
1706 new.network.offline = true;
1707 new.freshness.cooldown_secs = 60;
1708
1709 assert_eq!(PolicyConfig::diff(&old, &new), PolicyConfigDiff::default());
1710 }
1711
1712 #[test]
1713 fn test_license_policy_config_invalid_spdx_dropped() {
1714 let json = r#"{ "allow": ["MIT", "not a valid spdx id!!"] }"#;
1715 let config: LicensePolicyConfig = serde_json::from_str(json).unwrap();
1716 assert_eq!(config.allow, vec!["MIT".to_string()]);
1717 }
1718
1719 crate::debug_redaction_conformance!(
1720 test_registry_runtime_settings_debug_redacts_credentials,
1721 1,
1722 RegistryRuntimeSettings {
1723 workspace_registries: crate::net_policy::WorkspaceRegistryAccess::PublicOnly,
1724 nuget_user_profile_sources: false,
1725 gitlab_instance_host: Some(crate::conformance::CREDENTIAL_PROBE_URL.to_string()),
1726 },
1727 );
1728}