Skip to main content

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}