1use std::{collections::HashSet, fmt};
2
3use serde::{Deserialize, Serialize};
4
5pub const ADDON_PROTOCOL_VERSION: &str = "0.1.0-alpha.1";
6pub const SUPPORTED_ADDON_PROTOCOL_VERSIONS: &[&str] = &[ADDON_PROTOCOL_VERSION];
7pub const ADDON_RUNTIME_ACCESS_CHECK_PATH: &str = "/addon/v1/access-check";
8pub const ADDON_RUNTIME_SIDE_EFFECTS_PATH: &str = "/addon/v1/side-effects";
9pub const ADDON_RUNTIME_GENERATED_ARTIFACTS_PATH: &str = "/addon/v1/generated-artifacts";
10pub const ADDON_RUNTIME_ACQUISITION_INTAKE_CANDIDATES_PATH: &str =
11 "/addon/v1/acquisition/intake/candidates";
12pub const ADDON_RUNTIME_TASK_RUN_CLAIM_PATH: &str = "/addon/v1/task-runs/claim";
13pub const ADDON_RUNTIME_TASK_RUN_PROGRESS_PATH: &str = "/addon/v1/task-runs/progress";
14pub const ADDON_RUNTIME_TASK_RUN_COMPLETE_PATH: &str = "/addon/v1/task-runs/complete";
15pub const ADDON_RUNTIME_TASK_RUN_FAIL_PATH: &str = "/addon/v1/task-runs/fail";
16pub const ADDON_RUNTIME_TASK_RUN_CANCEL_PATH: &str = "/addon/v1/task-runs/cancel";
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub struct AddonRuntimeRoute {
20 pub path: &'static str,
21 pub method: AddonRuntimeHttpMethod,
22 pub kind: AddonRuntimeRouteKind,
23}
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum AddonRuntimeHttpMethod {
27 Post,
28}
29
30impl AddonRuntimeHttpMethod {
31 #[must_use]
32 pub const fn as_str(self) -> &'static str {
33 match self {
34 Self::Post => "POST",
35 }
36 }
37}
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub enum AddonRuntimeRouteKind {
41 AccessCheck,
42 SideEffect,
43 GeneratedArtifact,
44 AcquisitionIntakeCandidate,
45 TaskRunClaim,
46 TaskRunProgress,
47 TaskRunComplete,
48 TaskRunFail,
49 TaskRunCancel,
50}
51
52pub const ADDON_RUNTIME_ROUTES: &[AddonRuntimeRoute] = &[
53 AddonRuntimeRoute {
54 path: ADDON_RUNTIME_ACCESS_CHECK_PATH,
55 method: AddonRuntimeHttpMethod::Post,
56 kind: AddonRuntimeRouteKind::AccessCheck,
57 },
58 AddonRuntimeRoute {
59 path: ADDON_RUNTIME_SIDE_EFFECTS_PATH,
60 method: AddonRuntimeHttpMethod::Post,
61 kind: AddonRuntimeRouteKind::SideEffect,
62 },
63 AddonRuntimeRoute {
64 path: ADDON_RUNTIME_GENERATED_ARTIFACTS_PATH,
65 method: AddonRuntimeHttpMethod::Post,
66 kind: AddonRuntimeRouteKind::GeneratedArtifact,
67 },
68 AddonRuntimeRoute {
69 path: ADDON_RUNTIME_ACQUISITION_INTAKE_CANDIDATES_PATH,
70 method: AddonRuntimeHttpMethod::Post,
71 kind: AddonRuntimeRouteKind::AcquisitionIntakeCandidate,
72 },
73 AddonRuntimeRoute {
74 path: ADDON_RUNTIME_TASK_RUN_CLAIM_PATH,
75 method: AddonRuntimeHttpMethod::Post,
76 kind: AddonRuntimeRouteKind::TaskRunClaim,
77 },
78 AddonRuntimeRoute {
79 path: ADDON_RUNTIME_TASK_RUN_PROGRESS_PATH,
80 method: AddonRuntimeHttpMethod::Post,
81 kind: AddonRuntimeRouteKind::TaskRunProgress,
82 },
83 AddonRuntimeRoute {
84 path: ADDON_RUNTIME_TASK_RUN_COMPLETE_PATH,
85 method: AddonRuntimeHttpMethod::Post,
86 kind: AddonRuntimeRouteKind::TaskRunComplete,
87 },
88 AddonRuntimeRoute {
89 path: ADDON_RUNTIME_TASK_RUN_FAIL_PATH,
90 method: AddonRuntimeHttpMethod::Post,
91 kind: AddonRuntimeRouteKind::TaskRunFail,
92 },
93 AddonRuntimeRoute {
94 path: ADDON_RUNTIME_TASK_RUN_CANCEL_PATH,
95 method: AddonRuntimeHttpMethod::Post,
96 kind: AddonRuntimeRouteKind::TaskRunCancel,
97 },
98];
99
100#[must_use]
101pub fn addon_runtime_paths() -> impl ExactSizeIterator<Item = &'static str> {
102 ADDON_RUNTIME_ROUTES.iter().map(|route| route.path)
103}
104
105#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
106pub struct AddonManifest {
107 pub id: String,
108 pub name: String,
109 pub version: String,
110 pub protocol_version: String,
111 pub base_url: String,
112 pub description: Option<String>,
113 #[serde(default)]
114 pub resources: Vec<AddonResourceDeclaration>,
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub entry_points: Vec<AddonEntryPointDeclaration>,
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub hosted_pages: Vec<AddonHostedPageDeclaration>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub configuration_schema: Option<AddonConfigurationSchema>,
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
122 pub secret_reference_fields: Vec<AddonSecretReferenceFieldDeclaration>,
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
124 pub event_subscriptions: Vec<AddonEventSubscriptionDeclaration>,
125 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub tasks: Vec<AddonTaskDeclaration>,
127 pub auth: AddonAuth,
128 #[serde(default)]
129 pub default_timeout_ms: Option<u64>,
130 #[serde(default)]
131 pub default_max_attempts: Option<u32>,
132 #[serde(default)]
133 pub scopes: Vec<AddonScope>,
134}
135
136#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
137pub struct AddonInstallDescriptor {
138 pub manifest: AddonManifest,
139 pub runtime: AddonRuntimeRequirement,
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub secret_reference_bindings: Vec<AddonSecretReferenceBinding>,
142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub install_notes: Vec<String>,
144}
145
146#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
147pub struct AddonRuntimeRequirement {
148 pub kind: AddonRuntimeKind,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub image: Option<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub binary: Option<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub command: Option<String>,
155}
156
157#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
158#[serde(rename_all = "snake_case")]
159pub enum AddonRuntimeKind {
160 HttpSidecar,
161}
162
163#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
164pub struct AddonSecretReferenceBinding {
165 pub field_id: String,
166 pub secret_ref: String,
167}
168
169#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
170pub struct AddonInstallGuide {
171 pub manifest_id: String,
172 pub addon_name: String,
173 pub protocol_version: String,
174 pub runtime_kind: AddonRuntimeKind,
175 pub runtime_reference: AddonRuntimeReference,
176 pub base_url_scheme: String,
177 pub base_url_configured: bool,
178 pub declared_resources: Vec<AddonResource>,
179 pub declared_scopes: Vec<AddonScope>,
180 pub required_secret_fields: Vec<AddonInstallSecretField>,
181 pub provided_secret_refs: Vec<String>,
182 pub missing_required_secret_fields: Vec<String>,
183 pub has_configuration_schema: bool,
184 pub entry_point_count: u32,
185 pub hosted_page_count: u32,
186 pub task_count: u32,
187 pub event_subscription_count: u32,
188 pub install_steps: Vec<AddonInstallStep>,
189}
190
191#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
192pub struct AddonRuntimeReference {
193 pub kind: AddonRuntimeReferenceKind,
194 pub value: String,
195}
196
197#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
198#[serde(rename_all = "snake_case")]
199pub enum AddonRuntimeReferenceKind {
200 Image,
201 Binary,
202 Command,
203}
204
205#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
206pub struct AddonInstallSecretField {
207 pub id: String,
208 pub label: String,
209 pub required: bool,
210 pub provided: bool,
211}
212
213#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
214pub struct AddonInstallStep {
215 pub kind: AddonInstallStepKind,
216 pub summary: String,
217}
218
219#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
220#[serde(rename_all = "snake_case")]
221pub enum AddonInstallStepKind {
222 RunSidecar,
223 ConfigureSecretReference,
224 RegisterManifest,
225 GrantScopes,
226}
227
228#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
229pub struct AddonResourceDeclaration {
230 pub kind: AddonResource,
231 pub path: String,
232 #[serde(default)]
233 pub input_schema: Option<String>,
234 #[serde(default)]
235 pub output_schema: Option<String>,
236 #[serde(default)]
237 pub required_scopes: Vec<AddonScope>,
238 #[serde(default)]
239 pub timeout_ms: Option<u64>,
240 #[serde(default)]
241 pub max_attempts: Option<u32>,
242}
243
244#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
245#[serde(rename_all = "snake_case")]
246pub enum AddonResource {
247 Catalog,
248 Metadata,
249 Image,
250 Stream,
251 Subtitle,
252 Recommendation,
253 Automation,
254 Webhook,
255 RendererAdapter,
256}
257
258impl AddonResource {
259 #[must_use]
260 pub const fn as_str(self) -> &'static str {
261 match self {
262 Self::Catalog => "catalog",
263 Self::Metadata => "metadata",
264 Self::Image => "image",
265 Self::Stream => "stream",
266 Self::Subtitle => "subtitle",
267 Self::Recommendation => "recommendation",
268 Self::Automation => "automation",
269 Self::Webhook => "webhook",
270 Self::RendererAdapter => "renderer_adapter",
271 }
272 }
273}
274
275#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
276pub struct AddonEntryPointDeclaration {
277 pub id: String,
278 pub kind: AddonEntryPointKind,
279 pub label: String,
280 pub path: String,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub hosted_page_id: Option<String>,
283 #[serde(default)]
284 pub required_scopes: Vec<AddonScope>,
285}
286
287impl AddonEntryPointDeclaration {
288 #[must_use]
289 pub fn hosted_page(
290 id: impl Into<String>,
291 kind: AddonEntryPointKind,
292 label: impl Into<String>,
293 path: impl Into<String>,
294 hosted_page_id: impl Into<String>,
295 required_scopes: Vec<AddonScope>,
296 ) -> Self {
297 Self {
298 id: id.into(),
299 kind,
300 label: label.into(),
301 path: path.into(),
302 hosted_page_id: Some(hosted_page_id.into()),
303 required_scopes,
304 }
305 }
306
307 #[must_use]
308 pub fn action(
309 id: impl Into<String>,
310 kind: AddonEntryPointKind,
311 label: impl Into<String>,
312 path: impl Into<String>,
313 required_scopes: Vec<AddonScope>,
314 ) -> Self {
315 Self {
316 id: id.into(),
317 kind,
318 label: label.into(),
319 path: path.into(),
320 hosted_page_id: None,
321 required_scopes,
322 }
323 }
324}
325
326#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
327#[serde(rename_all = "snake_case")]
328pub enum AddonEntryPointKind {
329 ItemAction,
330 LibraryAction,
331 AdminAction,
332 Settings,
333 Diagnostics,
334 TaskLauncher,
335}
336
337impl AddonEntryPointKind {
338 #[must_use]
339 pub const fn as_str(self) -> &'static str {
340 match self {
341 Self::ItemAction => "item_action",
342 Self::LibraryAction => "library_action",
343 Self::AdminAction => "admin_action",
344 Self::Settings => "settings",
345 Self::Diagnostics => "diagnostics",
346 Self::TaskLauncher => "task_launcher",
347 }
348 }
349}
350
351#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
352pub struct AddonHostedPageDeclaration {
353 pub id: String,
354 pub title: String,
355 pub path: String,
356 #[serde(default)]
357 pub required_scopes: Vec<AddonScope>,
358}
359
360impl AddonHostedPageDeclaration {
361 #[must_use]
362 pub fn new(
363 id: impl Into<String>,
364 title: impl Into<String>,
365 path: impl Into<String>,
366 required_scopes: Vec<AddonScope>,
367 ) -> Self {
368 Self {
369 id: id.into(),
370 title: title.into(),
371 path: path.into(),
372 required_scopes,
373 }
374 }
375}
376
377#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
378pub struct AddonConfigurationSchema {
379 pub schema_id: String,
380 pub schema: serde_json::Value,
381}
382
383impl AddonConfigurationSchema {
384 #[must_use]
385 pub fn new(schema_id: impl Into<String>, schema: serde_json::Value) -> Self {
386 Self {
387 schema_id: schema_id.into(),
388 schema,
389 }
390 }
391}
392
393#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
394pub struct AddonSecretReferenceFieldDeclaration {
395 pub id: String,
396 pub label: String,
397 #[serde(default)]
398 pub description: Option<String>,
399 #[serde(default)]
400 pub required: bool,
401}
402
403impl AddonSecretReferenceFieldDeclaration {
404 #[must_use]
405 pub fn new(
406 id: impl Into<String>,
407 label: impl Into<String>,
408 description: Option<String>,
409 required: bool,
410 ) -> Self {
411 Self {
412 id: id.into(),
413 label: label.into(),
414 description,
415 required,
416 }
417 }
418}
419
420#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
421pub struct AddonEventSubscriptionDeclaration {
422 pub id: String,
423 pub event_kind: String,
424 pub path: String,
425 #[serde(default)]
426 pub required_scopes: Vec<AddonScope>,
427 #[serde(default)]
428 pub filters: serde_json::Value,
429}
430
431impl AddonEventSubscriptionDeclaration {
432 #[must_use]
433 pub fn new(
434 id: impl Into<String>,
435 event_kind: impl Into<String>,
436 path: impl Into<String>,
437 required_scopes: Vec<AddonScope>,
438 filters: serde_json::Value,
439 ) -> Self {
440 Self {
441 id: id.into(),
442 event_kind: event_kind.into(),
443 path: path.into(),
444 required_scopes,
445 filters,
446 }
447 }
448}
449
450#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
451pub struct AddonTaskDeclaration {
452 pub id: String,
453 pub name: String,
454 pub path: String,
455 #[serde(default)]
456 pub description: Option<String>,
457 #[serde(default)]
458 pub required_scopes: Vec<AddonScope>,
459 #[serde(default)]
460 pub timeout_ms: Option<u64>,
461 #[serde(default)]
462 pub max_attempts: Option<u32>,
463}
464
465impl AddonTaskDeclaration {
466 #[must_use]
467 pub fn new(
468 id: impl Into<String>,
469 name: impl Into<String>,
470 path: impl Into<String>,
471 required_scopes: Vec<AddonScope>,
472 ) -> Self {
473 Self {
474 id: id.into(),
475 name: name.into(),
476 path: path.into(),
477 description: None,
478 required_scopes,
479 timeout_ms: None,
480 max_attempts: None,
481 }
482 }
483
484 #[must_use]
485 pub const fn with_execution_bounds(
486 mut self,
487 timeout_ms: Option<u64>,
488 max_attempts: Option<u32>,
489 ) -> Self {
490 self.timeout_ms = timeout_ms;
491 self.max_attempts = max_attempts;
492 self
493 }
494
495 #[must_use]
496 pub fn with_description(mut self, description: impl Into<String>) -> Self {
497 self.description = Some(description.into());
498 self
499 }
500}
501
502#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
503#[serde(rename_all = "snake_case")]
504pub enum AddonScope {
505 CatalogRead,
506 ItemMetadataRead,
507 ItemMetadataSuggest,
508 ImageRead,
509 SubtitleRead,
510 StreamUrlRead,
511 RecommendationWrite,
512 AutomationRun,
513 WebhookEventRead,
514 RendererAdapterRead,
515 RendererAdapterControl,
516}
517
518impl AddonScope {
519 #[must_use]
520 pub const fn as_str(self) -> &'static str {
521 match self {
522 Self::CatalogRead => "catalog_read",
523 Self::ItemMetadataRead => "item_metadata_read",
524 Self::ItemMetadataSuggest => "item_metadata_suggest",
525 Self::ImageRead => "image_read",
526 Self::SubtitleRead => "subtitle_read",
527 Self::StreamUrlRead => "stream_url_read",
528 Self::RecommendationWrite => "recommendation_write",
529 Self::AutomationRun => "automation_run",
530 Self::WebhookEventRead => "webhook_event_read",
531 Self::RendererAdapterRead => "renderer_adapter_read",
532 Self::RendererAdapterControl => "renderer_adapter_control",
533 }
534 }
535}
536
537#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
538#[serde(rename_all = "snake_case")]
539pub enum AddonAuth {
540 #[default]
541 None,
542 Bearer,
543 SharedSecret,
544}
545
546#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
547pub struct AddonResourceRequest {
548 pub protocol_version: String,
549 pub addon_id: String,
550 pub resource: AddonResource,
551 pub request_id: String,
552 pub payload: serde_json::Value,
553}
554
555#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
556pub struct AddonResourceResponse {
557 pub protocol_version: String,
558 pub addon_id: String,
559 pub resource: AddonResource,
560 pub request_id: String,
561 pub payload: serde_json::Value,
562 #[serde(default)]
563 pub artifacts: Vec<AddonArtifact>,
564}
565
566#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
567pub struct AddonTaskRequest {
568 pub protocol_version: String,
569 pub addon_id: String,
570 pub task_id: String,
571 pub job_id: String,
572 pub request_id: String,
573 pub attempt: u32,
574 #[serde(default)]
575 pub retry_of_job_id: Option<String>,
576 #[serde(default)]
577 pub library_id: Option<String>,
578 #[serde(default)]
579 pub source_id: Option<String>,
580 pub payload: serde_json::Value,
581}
582
583#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
584pub struct AddonTaskResponse {
585 pub protocol_version: String,
586 pub addon_id: String,
587 pub task_id: String,
588 pub job_id: String,
589 pub request_id: String,
590 #[serde(default)]
591 pub output: serde_json::Value,
592}
593
594#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
595#[serde(rename_all = "snake_case")]
596pub enum AddonRendererAdapterProtocol {
597 Chromecast,
598 DlnaRenderer,
599 Airplay,
600}
601
602impl AddonRendererAdapterProtocol {
603 #[must_use]
604 pub const fn as_str(self) -> &'static str {
605 match self {
606 Self::Chromecast => "chromecast",
607 Self::DlnaRenderer => "dlna_renderer",
608 Self::Airplay => "airplay",
609 }
610 }
611}
612
613#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
614#[serde(tag = "action", rename_all = "snake_case")]
615pub enum AddonRendererAdapterRequest {
616 InspectReadiness {
617 protocol: AddonRendererAdapterProtocol,
618 },
619 DiscoverTargets {
620 protocol: AddonRendererAdapterProtocol,
621 #[serde(default, skip_serializing_if = "Option::is_none")]
622 timeout_ms: Option<u64>,
623 },
624 DispatchCommand {
625 protocol: AddonRendererAdapterProtocol,
626 envelope: AddonRendererAdapterCommandEnvelope,
627 },
628}
629
630#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
631#[serde(tag = "kind", rename_all = "snake_case")]
632pub enum AddonRendererAdapterResponse {
633 Readiness {
634 readiness: AddonRendererAdapterReadiness,
635 },
636 Targets {
637 targets: Vec<AddonRendererAdapterTarget>,
638 },
639 CommandResult {
640 result: AddonRendererAdapterCommandResult,
641 },
642}
643
644#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
645pub struct AddonRendererAdapterReadiness {
646 pub protocol: AddonRendererAdapterProtocol,
647 pub status: AddonRendererAdapterReadinessStatus,
648 pub reason_code: String,
649 #[serde(default, skip_serializing_if = "Option::is_none")]
650 pub safe_message: Option<String>,
651}
652
653#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
654#[serde(rename_all = "snake_case")]
655pub enum AddonRendererAdapterReadinessStatus {
656 Ready,
657 Degraded,
658 ConfigurationRequired,
659 Unavailable,
660}
661
662impl AddonRendererAdapterReadinessStatus {
663 #[must_use]
664 pub const fn as_str(self) -> &'static str {
665 match self {
666 Self::Ready => "ready",
667 Self::Degraded => "degraded",
668 Self::ConfigurationRequired => "configuration_required",
669 Self::Unavailable => "unavailable",
670 }
671 }
672}
673
674#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
675pub struct AddonRendererAdapterTarget {
676 pub stable_device_id: String,
677 pub target_kind: AddonRendererAdapterProtocol,
678 pub display_name: String,
679 pub network_scope: AddonRendererAdapterNetworkScope,
680 pub media_capabilities: AddonRendererAdapterMediaCapabilities,
681 pub control_capabilities: AddonRendererAdapterControlCapabilities,
682 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub discovered_at_ms: Option<u64>,
684}
685
686#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
687#[serde(rename_all = "snake_case")]
688pub enum AddonRendererAdapterNetworkScope {
689 Local,
690 Remote,
691 Unknown,
692}
693
694impl AddonRendererAdapterNetworkScope {
695 #[must_use]
696 pub const fn as_str(self) -> &'static str {
697 match self {
698 Self::Local => "local",
699 Self::Remote => "remote",
700 Self::Unknown => "unknown",
701 }
702 }
703}
704
705#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
706pub struct AddonRendererAdapterMediaCapabilities {
707 pub direct_play: bool,
708 #[serde(default, skip_serializing_if = "Vec::is_empty")]
709 pub containers: Vec<String>,
710 #[serde(default, skip_serializing_if = "Vec::is_empty")]
711 pub video_codecs: Vec<String>,
712 #[serde(default, skip_serializing_if = "Vec::is_empty")]
713 pub audio_codecs: Vec<String>,
714}
715
716#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
717pub struct AddonRendererAdapterControlCapabilities {
718 pub play: bool,
719 pub pause: bool,
720 pub resume: bool,
721 pub seek: bool,
722 pub stop: bool,
723 pub set_volume: bool,
724}
725
726impl AddonRendererAdapterControlCapabilities {
727 #[must_use]
728 pub const fn basic_playback() -> Self {
729 Self {
730 play: true,
731 pause: true,
732 resume: true,
733 seek: true,
734 stop: true,
735 set_volume: false,
736 }
737 }
738}
739
740#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
741pub struct AddonRendererAdapterCommandEnvelope {
742 pub adapter_id: String,
743 pub stable_device_id: String,
744 pub target_kind: AddonRendererAdapterProtocol,
745 pub renderer_session_id: String,
746 pub playback_session_id: String,
747 pub source_id: String,
748 pub command: AddonRendererAdapterCommand,
749 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub position_ms: Option<u64>,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
752 pub volume_percent: Option<u8>,
753 pub transport: AddonRendererAdapterTransport,
754}
755
756impl fmt::Debug for AddonRendererAdapterCommandEnvelope {
757 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
758 formatter
759 .debug_struct("AddonRendererAdapterCommandEnvelope")
760 .field("adapter_id", &self.adapter_id)
761 .field("stable_device_id", &self.stable_device_id)
762 .field("target_kind", &self.target_kind)
763 .field("renderer_session_id", &self.renderer_session_id)
764 .field("playback_session_id", &self.playback_session_id)
765 .field("source_id", &self.source_id)
766 .field("command", &self.command)
767 .field("position_ms", &self.position_ms)
768 .field("volume_percent", &self.volume_percent)
769 .field("transport", &self.transport)
770 .finish()
771 }
772}
773
774#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
775#[serde(rename_all = "snake_case")]
776pub enum AddonRendererAdapterCommand {
777 Play,
778 Pause,
779 Resume,
780 Seek,
781 Stop,
782 SetVolume,
783}
784
785impl AddonRendererAdapterCommand {
786 #[must_use]
787 pub const fn as_str(self) -> &'static str {
788 match self {
789 Self::Play => "play",
790 Self::Pause => "pause",
791 Self::Resume => "resume",
792 Self::Seek => "seek",
793 Self::Stop => "stop",
794 Self::SetVolume => "set_volume",
795 }
796 }
797}
798
799#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
800pub struct AddonRendererAdapterTransport {
801 pub mode: AddonRendererAdapterTransportMode,
802 pub expires_at: String,
803 pub urls: Vec<AddonRendererAdapterTransportUrl>,
804}
805
806impl fmt::Debug for AddonRendererAdapterTransport {
807 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
808 formatter
809 .debug_struct("AddonRendererAdapterTransport")
810 .field("mode", &self.mode)
811 .field("expires_at", &self.expires_at)
812 .field("url_count", &self.urls.len())
813 .finish()
814 }
815}
816
817#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
818#[serde(rename_all = "snake_case")]
819pub enum AddonRendererAdapterTransportMode {
820 Direct,
821 Remux,
822 Hls,
823}
824
825impl AddonRendererAdapterTransportMode {
826 #[must_use]
827 pub const fn as_str(self) -> &'static str {
828 match self {
829 Self::Direct => "direct",
830 Self::Remux => "remux",
831 Self::Hls => "hls",
832 }
833 }
834}
835
836#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
837pub struct AddonRendererAdapterTransportUrl {
838 pub kind: AddonRendererAdapterTransportUrlKind,
839 pub url: String,
840 pub content_type: String,
841 pub supports_range_requests: bool,
842}
843
844impl fmt::Debug for AddonRendererAdapterTransportUrl {
845 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
846 formatter
847 .debug_struct("AddonRendererAdapterTransportUrl")
848 .field("kind", &self.kind)
849 .field("url", &"<redacted>")
850 .field("content_type", &self.content_type)
851 .field("supports_range_requests", &self.supports_range_requests)
852 .finish()
853 }
854}
855
856#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
857#[serde(rename_all = "snake_case")]
858pub enum AddonRendererAdapterTransportUrlKind {
859 Stream,
860 Playlist,
861 SegmentBase,
862}
863
864impl AddonRendererAdapterTransportUrlKind {
865 #[must_use]
866 pub const fn as_str(self) -> &'static str {
867 match self {
868 Self::Stream => "stream",
869 Self::Playlist => "playlist",
870 Self::SegmentBase => "segment_base",
871 }
872 }
873}
874
875#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
876pub struct AddonRendererAdapterCommandResult {
877 pub stable_device_id: String,
878 pub command: AddonRendererAdapterCommand,
879 pub state: AddonRendererAdapterCommandState,
880 #[serde(default, skip_serializing_if = "Option::is_none")]
881 pub safe_reason_code: Option<String>,
882}
883
884#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
885#[serde(rename_all = "snake_case")]
886pub enum AddonRendererAdapterCommandState {
887 Accepted,
888 Rejected,
889 Failed,
890}
891
892impl AddonRendererAdapterCommandState {
893 #[must_use]
894 pub const fn as_str(self) -> &'static str {
895 match self {
896 Self::Accepted => "accepted",
897 Self::Rejected => "rejected",
898 Self::Failed => "failed",
899 }
900 }
901}
902
903#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
904pub struct AddonEventRequest {
905 pub protocol_version: String,
906 pub addon_id: String,
907 pub subscription_id: String,
908 pub event_id: String,
909 pub event_kind: String,
910 pub subject_kind: String,
911 pub subject_id: String,
912 pub occurred_at: String,
913 pub attempt: u32,
914 pub payload: serde_json::Value,
915}
916
917#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
918pub struct AddonEventResponse {
919 pub protocol_version: String,
920 pub addon_id: String,
921 pub subscription_id: String,
922 pub event_id: String,
923 #[serde(default)]
924 pub output: serde_json::Value,
925}
926
927#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
928pub struct AddonHealthCheckRequest {
929 pub protocol_version: String,
930 pub manifest_id: String,
931 pub request_id: String,
932 pub expected_addon_version: String,
933 pub expected_resource_count: usize,
934}
935
936#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
937pub struct AddonHealthCheckResponse {
938 pub protocol_version: String,
939 pub manifest_id: String,
940 pub status: AddonHealthStatus,
941 pub checked_at: String,
942 pub manifest: AddonHealthManifestFacts,
943 #[serde(default)]
944 pub diagnostics: serde_json::Value,
945}
946
947#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
948#[serde(rename_all = "snake_case")]
949pub enum AddonHealthStatus {
950 Ok,
951 Degraded,
952 Unhealthy,
953}
954
955#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
956pub struct AddonHealthManifestFacts {
957 pub addon_version: String,
958 pub resource_count: usize,
959}
960
961#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
962pub struct AddonArtifact {
963 pub kind: String,
964 pub payload: serde_json::Value,
965}
966
967#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
968#[serde(deny_unknown_fields)]
969pub struct AddonMetadataPatch {
970 #[serde(default)]
971 pub title: Option<String>,
972 #[serde(default)]
973 pub original_title: Option<String>,
974 #[serde(default)]
975 pub sort_title: Option<String>,
976 #[serde(default)]
977 pub overview: Option<String>,
978 #[serde(default)]
979 pub release_date: Option<String>,
980 #[serde(default)]
981 pub runtime_minutes: Option<u32>,
982 #[serde(default)]
983 pub tagline: Option<String>,
984 #[serde(default)]
985 pub genres: Option<Vec<String>>,
986 #[serde(default)]
987 pub tags: Option<Vec<String>>,
988 #[serde(default)]
989 pub ratings: Option<Vec<AddonMetadataContentRating>>,
990 #[serde(default)]
991 pub images: Option<Vec<AddonMetadataImage>>,
992 #[serde(default)]
993 pub credits: Option<Vec<AddonMetadataCredit>>,
994 #[serde(default)]
995 pub collections: Option<Vec<AddonMetadataCollection>>,
996 #[serde(default)]
997 pub studios: Option<Vec<AddonMetadataStudio>>,
998 #[serde(default)]
999 pub external_ids: Option<Vec<AddonMetadataExternalId>>,
1000}
1001
1002#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1003#[serde(deny_unknown_fields)]
1004pub struct AddonMetadataContentRating {
1005 pub source: String,
1006 pub value: String,
1007}
1008
1009#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1010#[serde(deny_unknown_fields)]
1011pub struct AddonMetadataImage {
1012 pub kind: String,
1013 pub uri: String,
1014 pub provider: String,
1015 #[serde(default)]
1016 pub width: Option<u32>,
1017 #[serde(default)]
1018 pub height: Option<u32>,
1019 #[serde(default)]
1020 pub language: Option<String>,
1021}
1022
1023#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1024#[serde(deny_unknown_fields)]
1025pub struct AddonMetadataCredit {
1026 pub name: String,
1027 pub role: String,
1028 #[serde(default)]
1029 pub character: Option<String>,
1030 #[serde(default)]
1031 pub order: Option<u32>,
1032 #[serde(default)]
1033 pub external_ids: Vec<AddonMetadataExternalId>,
1034}
1035
1036#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1037#[serde(deny_unknown_fields)]
1038pub struct AddonMetadataCollection {
1039 pub name: String,
1040 #[serde(default)]
1041 pub overview: Option<String>,
1042 #[serde(default)]
1043 pub sort_order: Option<u32>,
1044 #[serde(default)]
1045 pub external_ids: Vec<AddonMetadataExternalId>,
1046}
1047
1048#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1049#[serde(deny_unknown_fields)]
1050pub struct AddonMetadataStudio {
1051 pub name: String,
1052 #[serde(default)]
1053 pub external_ids: Vec<AddonMetadataExternalId>,
1054}
1055
1056#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
1057#[serde(deny_unknown_fields)]
1058pub struct AddonMetadataExternalId {
1059 pub provider: String,
1060 pub value: String,
1061}
1062
1063#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1064#[serde(rename_all = "snake_case")]
1065pub enum AddonArtworkIntent {
1066 ProposeArtwork,
1067}
1068
1069#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1070#[serde(rename_all = "snake_case")]
1071pub enum AddonArtworkKind {
1072 Poster,
1073 Backdrop,
1074 Logo,
1075 Banner,
1076 Thumbnail,
1077}
1078
1079impl AddonArtworkKind {
1080 #[must_use]
1081 pub const fn as_str(self) -> &'static str {
1082 match self {
1083 Self::Poster => "poster",
1084 Self::Backdrop => "backdrop",
1085 Self::Logo => "logo",
1086 Self::Banner => "banner",
1087 Self::Thumbnail => "thumbnail",
1088 }
1089 }
1090}
1091
1092#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1093#[serde(rename_all = "snake_case")]
1094pub enum AddonArtworkSourceKind {
1095 RemoteUrl,
1096}
1097
1098#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1099#[serde(deny_unknown_fields)]
1100pub struct AddonArtworkSourcePayload {
1101 pub kind: AddonArtworkSourceKind,
1102 pub url: String,
1103}
1104
1105#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1106#[serde(deny_unknown_fields)]
1107pub struct AddonArtworkWritePayload {
1108 pub intent: AddonArtworkIntent,
1109 pub kind: AddonArtworkKind,
1110 pub source: AddonArtworkSourcePayload,
1111 #[serde(default)]
1112 pub language: Option<String>,
1113 #[serde(default)]
1114 pub width: Option<u32>,
1115 #[serde(default)]
1116 pub height: Option<u32>,
1117}
1118
1119#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
1120#[serde(rename_all = "snake_case")]
1121pub enum AddonPermission {
1122 MetadataWrite,
1123 ArtworkWrite,
1124 SubtitleWrite,
1125 LibraryFileWrite,
1126}
1127
1128impl AddonPermission {
1129 #[must_use]
1130 pub const fn as_str(self) -> &'static str {
1131 match self {
1132 Self::MetadataWrite => "metadata_write",
1133 Self::ArtworkWrite => "artwork_write",
1134 Self::SubtitleWrite => "subtitle_write",
1135 Self::LibraryFileWrite => "library_file_write",
1136 }
1137 }
1138}
1139
1140#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
1141#[serde(rename_all = "snake_case")]
1142pub enum AddonSideEffectTargetKind {
1143 MediaItem,
1144 MediaSource,
1145}
1146
1147impl AddonSideEffectTargetKind {
1148 #[must_use]
1149 pub const fn as_str(self) -> &'static str {
1150 match self {
1151 Self::MediaItem => "media_item",
1152 Self::MediaSource => "media_source",
1153 }
1154 }
1155}
1156
1157#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1158pub struct AddonSideEffectTarget {
1159 pub kind: AddonSideEffectTargetKind,
1160 pub id: String,
1161}
1162
1163#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1164pub struct AddonAccessCheckRequest {
1165 pub permission: AddonPermission,
1166 #[serde(default, skip_serializing_if = "Option::is_none")]
1167 pub library_id: Option<String>,
1168}
1169
1170#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1171pub struct AddonAccessCheckResponse {
1172 pub addon_id: String,
1173 pub token_id: String,
1174 pub permission: AddonPermission,
1175 #[serde(default)]
1176 pub library_id: Option<String>,
1177 pub allowed: bool,
1178}
1179
1180#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1181pub struct SubmitAddonSideEffectRequest {
1182 pub permission: AddonPermission,
1183 pub library_id: String,
1184 pub target: AddonSideEffectTarget,
1185 pub idempotency_key: String,
1186 pub provenance: serde_json::Value,
1187 pub payload: serde_json::Value,
1188}
1189
1190#[derive(Clone, Debug)]
1191pub struct SubmitAddonMetadataWriteRequest {
1192 pub library_id: String,
1193 pub target: AddonSideEffectTarget,
1194 pub idempotency_key: String,
1195 pub provenance: serde_json::Value,
1196 pub patch: AddonMetadataPatch,
1197}
1198
1199#[derive(Clone, Debug)]
1200pub struct SubmitAddonArtworkWriteRequest {
1201 pub library_id: String,
1202 pub target: AddonSideEffectTarget,
1203 pub idempotency_key: String,
1204 pub provenance: serde_json::Value,
1205 pub artwork: AddonArtworkWritePayload,
1206}
1207
1208#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1209pub struct AddonSideEffectResponse {
1210 pub side_effect: AddonSideEffectSummary,
1211 pub idempotent_replay: bool,
1212}
1213
1214#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1215pub struct AddonSideEffectSummary {
1216 pub id: String,
1217 #[serde(default, skip_serializing_if = "Option::is_none")]
1218 pub addon_id: Option<String>,
1219 #[serde(default, skip_serializing_if = "Option::is_none")]
1220 pub token_id: Option<String>,
1221 pub permission: AddonPermission,
1222 pub library_id: String,
1223 pub target: AddonSideEffectTarget,
1224 pub idempotency_key: String,
1225 pub validation_status: String,
1226 #[serde(default)]
1227 pub safe_error_code: Option<String>,
1228 pub apply_status: String,
1229 #[serde(default)]
1230 pub apply_error_code: Option<String>,
1231 #[serde(default)]
1232 pub applied_item_id: Option<String>,
1233 #[serde(default)]
1234 pub applied_source: Option<String>,
1235 #[serde(default)]
1236 pub apply_report: Option<serde_json::Value>,
1237 #[serde(default, skip_serializing_if = "Option::is_none")]
1238 pub applied_at: Option<String>,
1239 #[serde(default, skip_serializing_if = "Option::is_none")]
1240 pub created_at: Option<String>,
1241}
1242
1243#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1244#[serde(rename_all = "snake_case")]
1245pub enum AddonLibraryFileRole {
1246 Nfo,
1247}
1248
1249impl AddonLibraryFileRole {
1250 #[must_use]
1251 pub const fn as_str(self) -> &'static str {
1252 match self {
1253 Self::Nfo => "nfo",
1254 }
1255 }
1256}
1257
1258#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1259#[serde(rename_all = "snake_case")]
1260pub enum AddonLibraryFileWritePolicy {
1261 CreateMissing,
1262 ReplaceExistingPreserving,
1263}
1264
1265impl AddonLibraryFileWritePolicy {
1266 #[must_use]
1267 pub const fn as_str(self) -> &'static str {
1268 match self {
1269 Self::CreateMissing => "create_missing",
1270 Self::ReplaceExistingPreserving => "replace_existing_preserving",
1271 }
1272 }
1273}
1274
1275#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1276#[serde(deny_unknown_fields)]
1277pub struct AddonLibraryFileWritePayload {
1278 pub file_role: AddonLibraryFileRole,
1279 pub policy: AddonLibraryFileWritePolicy,
1280}
1281
1282#[derive(Clone, Debug, Eq, PartialEq)]
1283pub enum AddonManifestError {
1284 UnsupportedProtocolVersion {
1285 actual: String,
1286 },
1287 EmptyField {
1288 field: &'static str,
1289 },
1290 InvalidBaseUrl,
1291 InvalidResourcePath {
1292 path: String,
1293 },
1294 InvalidDeclarationPath {
1295 declaration: &'static str,
1296 path: String,
1297 },
1298 DuplicateResource {
1299 resource: AddonResource,
1300 },
1301 DuplicateDeclaration {
1302 declaration: &'static str,
1303 id: String,
1304 },
1305 UnknownHostedPageReference {
1306 entry_point_id: String,
1307 hosted_page_id: String,
1308 },
1309 EmptyResources,
1310 MissingDeclaredScope {
1311 resource: AddonResource,
1312 scope: AddonScope,
1313 },
1314 MissingDeclaredScopeForDeclaration {
1315 declaration: &'static str,
1316 scope: AddonScope,
1317 },
1318 InvalidConfigurationSchema {
1319 message: String,
1320 },
1321 InvalidTimeout {
1322 value: u64,
1323 },
1324 InvalidMaxAttempts {
1325 value: u32,
1326 },
1327 MissingAuthToken {
1328 auth: AddonAuth,
1329 },
1330 MissingRuntimeReference,
1331 InvalidRuntimeReference,
1332 UnknownSecretReferenceField {
1333 field_id: String,
1334 },
1335 DuplicateSecretReferenceBinding {
1336 field_id: String,
1337 },
1338 SecretReferenceContainsValue {
1339 field_id: String,
1340 },
1341 ResourceNotDeclared {
1342 resource: AddonResource,
1343 },
1344 EventSubscriptionNotDeclared {
1345 subscription_id: String,
1346 },
1347 TaskNotDeclared {
1348 task_id: String,
1349 },
1350 InvalidEnvelope {
1351 message: String,
1352 },
1353}
1354
1355impl fmt::Display for AddonManifestError {
1356 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1357 match self {
1358 Self::UnsupportedProtocolVersion { actual } => {
1359 write!(formatter, "unsupported addon protocol version: {actual}")
1360 }
1361 Self::EmptyField { field } => {
1362 write!(formatter, "addon manifest field is empty: {field}")
1363 }
1364 Self::InvalidBaseUrl => write!(formatter, "addon base_url must use http or https"),
1365 Self::InvalidResourcePath { path } => {
1366 write!(formatter, "addon resource path must be absolute: {path}")
1367 }
1368 Self::InvalidDeclarationPath { declaration, path } => {
1369 write!(
1370 formatter,
1371 "addon {declaration} path must be absolute: {path}"
1372 )
1373 }
1374 Self::DuplicateResource { resource } => {
1375 write!(formatter, "duplicate addon resource: {}", resource.as_str())
1376 }
1377 Self::DuplicateDeclaration { declaration, id } => {
1378 write!(formatter, "duplicate addon {declaration} declaration: {id}")
1379 }
1380 Self::UnknownHostedPageReference {
1381 entry_point_id,
1382 hosted_page_id,
1383 } => write!(
1384 formatter,
1385 "addon entry_point {entry_point_id} references unknown hosted_page {hosted_page_id}"
1386 ),
1387 Self::EmptyResources => write!(formatter, "addon manifest must declare resources"),
1388 Self::MissingDeclaredScope { resource, scope } => write!(
1389 formatter,
1390 "addon resource {} requires undeclared scope {}",
1391 resource.as_str(),
1392 scope.as_str()
1393 ),
1394 Self::MissingDeclaredScopeForDeclaration { declaration, scope } => write!(
1395 formatter,
1396 "addon {declaration} declaration requires undeclared scope {}",
1397 scope.as_str()
1398 ),
1399 Self::InvalidConfigurationSchema { message } => {
1400 write!(formatter, "invalid addon configuration schema: {message}")
1401 }
1402 Self::InvalidTimeout { value } => {
1403 write!(
1404 formatter,
1405 "addon timeout_ms is outside allowed range: {value}"
1406 )
1407 }
1408 Self::InvalidMaxAttempts { value } => {
1409 write!(
1410 formatter,
1411 "addon max_attempts is outside allowed range: {value}"
1412 )
1413 }
1414 Self::MissingAuthToken { auth } => {
1415 write!(
1416 formatter,
1417 "addon auth token is required for {auth:?} authentication"
1418 )
1419 }
1420 Self::MissingRuntimeReference => {
1421 write!(
1422 formatter,
1423 "addon install descriptor must declare an image, binary, or command"
1424 )
1425 }
1426 Self::InvalidRuntimeReference => {
1427 write!(
1428 formatter,
1429 "addon runtime reference must not contain credentials, URLs, or local paths"
1430 )
1431 }
1432 Self::UnknownSecretReferenceField { field_id } => {
1433 write!(
1434 formatter,
1435 "addon install descriptor references unknown secret field: {field_id}"
1436 )
1437 }
1438 Self::DuplicateSecretReferenceBinding { field_id } => {
1439 write!(
1440 formatter,
1441 "duplicate addon secret reference binding: {field_id}"
1442 )
1443 }
1444 Self::SecretReferenceContainsValue { field_id } => {
1445 write!(
1446 formatter,
1447 "addon secret reference binding {field_id} must contain a reference, not a secret value"
1448 )
1449 }
1450 Self::ResourceNotDeclared { resource } => {
1451 write!(
1452 formatter,
1453 "addon resource is not declared: {}",
1454 resource.as_str()
1455 )
1456 }
1457 Self::EventSubscriptionNotDeclared { subscription_id } => {
1458 write!(
1459 formatter,
1460 "addon event subscription is not declared: {subscription_id}"
1461 )
1462 }
1463 Self::TaskNotDeclared { task_id } => {
1464 write!(formatter, "addon task is not declared: {task_id}")
1465 }
1466 Self::InvalidEnvelope { message } => {
1467 write!(formatter, "invalid addon envelope: {message}")
1468 }
1469 }
1470 }
1471}
1472
1473impl std::error::Error for AddonManifestError {}
1474
1475pub type AddonProtocolResult<T> = std::result::Result<T, AddonManifestError>;
1476
1477#[must_use]
1478pub fn is_supported_addon_protocol_version(protocol_version: &str) -> bool {
1479 SUPPORTED_ADDON_PROTOCOL_VERSIONS.contains(&protocol_version)
1480}
1481
1482pub fn validate_manifest(manifest: &AddonManifest) -> AddonProtocolResult<()> {
1483 validate_non_empty(&manifest.id, "id")?;
1484 validate_non_empty(&manifest.name, "name")?;
1485 validate_non_empty(&manifest.version, "version")?;
1486 if !is_supported_addon_protocol_version(&manifest.protocol_version) {
1487 return Err(AddonManifestError::UnsupportedProtocolVersion {
1488 actual: manifest.protocol_version.clone(),
1489 });
1490 }
1491 if !has_http_base_url(&manifest.base_url) {
1492 return Err(AddonManifestError::InvalidBaseUrl);
1493 }
1494 if manifest.resources.is_empty() {
1495 return Err(AddonManifestError::EmptyResources);
1496 }
1497 if let Some(timeout) = manifest.default_timeout_ms {
1498 validate_timeout(timeout)?;
1499 }
1500 if let Some(max_attempts) = manifest.default_max_attempts {
1501 validate_max_attempts(max_attempts)?;
1502 }
1503
1504 let declared_scopes = manifest.scopes.iter().copied().collect::<HashSet<_>>();
1505 let mut declared_resources = HashSet::new();
1506 for resource in &manifest.resources {
1507 if !declared_resources.insert(resource.kind) {
1508 return Err(AddonManifestError::DuplicateResource {
1509 resource: resource.kind,
1510 });
1511 }
1512 if !resource.path.starts_with('/') {
1513 return Err(AddonManifestError::InvalidResourcePath {
1514 path: resource.path.clone(),
1515 });
1516 }
1517 if let Some(timeout) = resource.timeout_ms {
1518 validate_timeout(timeout)?;
1519 }
1520 if let Some(max_attempts) = resource.max_attempts {
1521 validate_max_attempts(max_attempts)?;
1522 }
1523 for scope in &resource.required_scopes {
1524 if !declared_scopes.contains(scope) {
1525 return Err(AddonManifestError::MissingDeclaredScope {
1526 resource: resource.kind,
1527 scope: *scope,
1528 });
1529 }
1530 }
1531 }
1532 validate_manifest_declarations(manifest, &declared_scopes)?;
1533
1534 Ok(())
1535}
1536
1537pub fn ensure_scope_grant(
1538 manifest: &AddonManifest,
1539 resource: AddonResource,
1540 granted_scopes: &[AddonScope],
1541) -> AddonProtocolResult<()> {
1542 validate_manifest(manifest)?;
1543 let granted = granted_scopes.iter().copied().collect::<HashSet<_>>();
1544 let declaration = manifest
1545 .resources
1546 .iter()
1547 .find(|candidate| candidate.kind == resource)
1548 .ok_or(AddonManifestError::ResourceNotDeclared { resource })?;
1549
1550 for scope in &declaration.required_scopes {
1551 if !granted.contains(scope) {
1552 return Err(AddonManifestError::MissingDeclaredScope {
1553 resource,
1554 scope: *scope,
1555 });
1556 }
1557 }
1558
1559 Ok(())
1560}
1561
1562pub fn ensure_task_scope_grant(
1563 manifest: &AddonManifest,
1564 task_id: &str,
1565 granted_scopes: &[AddonScope],
1566) -> AddonProtocolResult<()> {
1567 validate_manifest(manifest)?;
1568 let granted = granted_scopes.iter().copied().collect::<HashSet<_>>();
1569 let declaration = manifest
1570 .tasks
1571 .iter()
1572 .find(|candidate| candidate.id == task_id)
1573 .ok_or_else(|| AddonManifestError::TaskNotDeclared {
1574 task_id: task_id.to_owned(),
1575 })?;
1576
1577 for scope in &declaration.required_scopes {
1578 if !granted.contains(scope) {
1579 return Err(AddonManifestError::MissingDeclaredScopeForDeclaration {
1580 declaration: "task",
1581 scope: *scope,
1582 });
1583 }
1584 }
1585
1586 Ok(())
1587}
1588
1589pub fn ensure_event_subscription_scope_grant(
1590 manifest: &AddonManifest,
1591 subscription_id: &str,
1592 granted_scopes: &[AddonScope],
1593) -> AddonProtocolResult<()> {
1594 validate_manifest(manifest)?;
1595 let granted = granted_scopes.iter().copied().collect::<HashSet<_>>();
1596 let declaration = manifest
1597 .event_subscriptions
1598 .iter()
1599 .find(|candidate| candidate.id == subscription_id)
1600 .ok_or_else(|| AddonManifestError::EventSubscriptionNotDeclared {
1601 subscription_id: subscription_id.to_owned(),
1602 })?;
1603
1604 for scope in &declaration.required_scopes {
1605 if !granted.contains(scope) {
1606 return Err(AddonManifestError::MissingDeclaredScopeForDeclaration {
1607 declaration: "event_subscription",
1608 scope: *scope,
1609 });
1610 }
1611 }
1612
1613 Ok(())
1614}
1615
1616pub fn validate_install_descriptor(descriptor: &AddonInstallDescriptor) -> AddonProtocolResult<()> {
1617 validate_manifest(&descriptor.manifest)?;
1618 validate_runtime_requirement(&descriptor.runtime)?;
1619
1620 let declared_secret_fields = descriptor
1621 .manifest
1622 .secret_reference_fields
1623 .iter()
1624 .map(|field| field.id.as_str())
1625 .collect::<HashSet<_>>();
1626 let mut bound_secret_fields = HashSet::new();
1627 for binding in &descriptor.secret_reference_bindings {
1628 validate_non_empty(&binding.field_id, "secret_reference_bindings.field_id")?;
1629 validate_non_empty(&binding.secret_ref, "secret_reference_bindings.secret_ref")?;
1630 if !declared_secret_fields.contains(binding.field_id.as_str()) {
1631 return Err(AddonManifestError::UnknownSecretReferenceField {
1632 field_id: binding.field_id.clone(),
1633 });
1634 }
1635 if !bound_secret_fields.insert(binding.field_id.clone()) {
1636 return Err(AddonManifestError::DuplicateSecretReferenceBinding {
1637 field_id: binding.field_id.clone(),
1638 });
1639 }
1640 if secret_reference_looks_like_value(&binding.secret_ref) {
1641 return Err(AddonManifestError::SecretReferenceContainsValue {
1642 field_id: binding.field_id.clone(),
1643 });
1644 }
1645 }
1646
1647 Ok(())
1648}
1649
1650#[must_use]
1651pub fn addon_install_guide(descriptor: &AddonInstallDescriptor) -> AddonInstallGuide {
1652 let provided_secret_refs = descriptor
1653 .secret_reference_bindings
1654 .iter()
1655 .map(|binding| binding.secret_ref.clone())
1656 .collect::<Vec<_>>();
1657 let provided_secret_fields = descriptor
1658 .secret_reference_bindings
1659 .iter()
1660 .map(|binding| binding.field_id.as_str())
1661 .collect::<HashSet<_>>();
1662 let required_secret_fields = descriptor
1663 .manifest
1664 .secret_reference_fields
1665 .iter()
1666 .map(|field| AddonInstallSecretField {
1667 id: field.id.clone(),
1668 label: field.label.clone(),
1669 required: field.required,
1670 provided: provided_secret_fields.contains(field.id.as_str()),
1671 })
1672 .collect::<Vec<_>>();
1673 let missing_required_secret_fields = required_secret_fields
1674 .iter()
1675 .filter(|field| field.required && !field.provided)
1676 .map(|field| field.id.clone())
1677 .collect::<Vec<_>>();
1678
1679 AddonInstallGuide {
1680 manifest_id: descriptor.manifest.id.clone(),
1681 addon_name: descriptor.manifest.name.clone(),
1682 protocol_version: descriptor.manifest.protocol_version.clone(),
1683 runtime_kind: descriptor.runtime.kind,
1684 runtime_reference: runtime_reference(&descriptor.runtime),
1685 base_url_scheme: base_url_scheme(&descriptor.manifest.base_url).unwrap_or_default(),
1686 base_url_configured: has_http_base_url(&descriptor.manifest.base_url),
1687 declared_resources: descriptor
1688 .manifest
1689 .resources
1690 .iter()
1691 .map(|resource| resource.kind)
1692 .collect(),
1693 declared_scopes: descriptor.manifest.scopes.clone(),
1694 required_secret_fields,
1695 provided_secret_refs,
1696 missing_required_secret_fields,
1697 has_configuration_schema: descriptor.manifest.configuration_schema.is_some(),
1698 entry_point_count: usize_to_u32(descriptor.manifest.entry_points.len()),
1699 hosted_page_count: usize_to_u32(descriptor.manifest.hosted_pages.len()),
1700 task_count: usize_to_u32(descriptor.manifest.tasks.len()),
1701 event_subscription_count: usize_to_u32(descriptor.manifest.event_subscriptions.len()),
1702 install_steps: install_steps(descriptor),
1703 }
1704}
1705
1706pub fn validate_resource_response(
1707 response: &AddonResourceResponse,
1708 manifest: &AddonManifest,
1709 resource: AddonResource,
1710 request_id: &str,
1711) -> AddonProtocolResult<()> {
1712 if !is_supported_addon_protocol_version(&response.protocol_version) {
1713 return Err(AddonManifestError::UnsupportedProtocolVersion {
1714 actual: response.protocol_version.clone(),
1715 });
1716 }
1717 if response.protocol_version != manifest.protocol_version {
1718 return Err(AddonManifestError::InvalidEnvelope {
1719 message: format!(
1720 "response protocol_version {} did not match manifest protocol_version {}",
1721 response.protocol_version, manifest.protocol_version
1722 ),
1723 });
1724 }
1725 if response.addon_id != manifest.id {
1726 return Err(AddonManifestError::InvalidEnvelope {
1727 message: format!(
1728 "response addon_id {} did not match {}",
1729 response.addon_id, manifest.id
1730 ),
1731 });
1732 }
1733 if response.resource != resource {
1734 return Err(AddonManifestError::InvalidEnvelope {
1735 message: format!(
1736 "response resource {} did not match {}",
1737 response.resource.as_str(),
1738 resource.as_str()
1739 ),
1740 });
1741 }
1742 if response.request_id != request_id {
1743 return Err(AddonManifestError::InvalidEnvelope {
1744 message: format!(
1745 "response request_id {} did not match {request_id}",
1746 response.request_id
1747 ),
1748 });
1749 }
1750
1751 Ok(())
1752}
1753
1754pub fn validate_task_response(
1755 response: &AddonTaskResponse,
1756 manifest: &AddonManifest,
1757 task_id: &str,
1758 job_id: &str,
1759 request_id: &str,
1760) -> AddonProtocolResult<()> {
1761 if !is_supported_addon_protocol_version(&response.protocol_version) {
1762 return Err(AddonManifestError::UnsupportedProtocolVersion {
1763 actual: response.protocol_version.clone(),
1764 });
1765 }
1766 if response.protocol_version != manifest.protocol_version {
1767 return Err(AddonManifestError::InvalidEnvelope {
1768 message: format!(
1769 "task response protocol_version {} did not match manifest protocol_version {}",
1770 response.protocol_version, manifest.protocol_version
1771 ),
1772 });
1773 }
1774 if response.addon_id != manifest.id {
1775 return Err(AddonManifestError::InvalidEnvelope {
1776 message: format!(
1777 "task response addon_id {} did not match {}",
1778 response.addon_id, manifest.id
1779 ),
1780 });
1781 }
1782 if response.task_id != task_id {
1783 return Err(AddonManifestError::InvalidEnvelope {
1784 message: format!(
1785 "task response task_id {} did not match {task_id}",
1786 response.task_id
1787 ),
1788 });
1789 }
1790 if response.job_id != job_id {
1791 return Err(AddonManifestError::InvalidEnvelope {
1792 message: format!(
1793 "task response job_id {} did not match {job_id}",
1794 response.job_id
1795 ),
1796 });
1797 }
1798 if response.request_id != request_id {
1799 return Err(AddonManifestError::InvalidEnvelope {
1800 message: format!(
1801 "task response request_id {} did not match {request_id}",
1802 response.request_id
1803 ),
1804 });
1805 }
1806
1807 Ok(())
1808}
1809
1810pub fn validate_event_response(
1811 response: &AddonEventResponse,
1812 manifest: &AddonManifest,
1813 subscription_id: &str,
1814 event_id: &str,
1815) -> AddonProtocolResult<()> {
1816 if !is_supported_addon_protocol_version(&response.protocol_version) {
1817 return Err(AddonManifestError::UnsupportedProtocolVersion {
1818 actual: response.protocol_version.clone(),
1819 });
1820 }
1821 if response.protocol_version != manifest.protocol_version {
1822 return Err(AddonManifestError::InvalidEnvelope {
1823 message: format!(
1824 "event response protocol_version {} did not match manifest protocol_version {}",
1825 response.protocol_version, manifest.protocol_version
1826 ),
1827 });
1828 }
1829 if response.addon_id != manifest.id {
1830 return Err(AddonManifestError::InvalidEnvelope {
1831 message: format!(
1832 "event response addon_id {} did not match {}",
1833 response.addon_id, manifest.id
1834 ),
1835 });
1836 }
1837 if response.subscription_id != subscription_id {
1838 return Err(AddonManifestError::InvalidEnvelope {
1839 message: format!(
1840 "event response subscription_id {} did not match {subscription_id}",
1841 response.subscription_id
1842 ),
1843 });
1844 }
1845 if response.event_id != event_id {
1846 return Err(AddonManifestError::InvalidEnvelope {
1847 message: format!(
1848 "event response event_id {} did not match {event_id}",
1849 response.event_id
1850 ),
1851 });
1852 }
1853
1854 Ok(())
1855}
1856
1857pub fn validate_health_check_response(
1858 response: &AddonHealthCheckResponse,
1859 manifest: &AddonManifest,
1860) -> AddonProtocolResult<()> {
1861 if !is_supported_addon_protocol_version(&response.protocol_version) {
1862 return Err(AddonManifestError::UnsupportedProtocolVersion {
1863 actual: response.protocol_version.clone(),
1864 });
1865 }
1866 if response.protocol_version != manifest.protocol_version {
1867 return Err(AddonManifestError::InvalidEnvelope {
1868 message: format!(
1869 "health protocol_version {} did not match manifest protocol_version {}",
1870 response.protocol_version, manifest.protocol_version
1871 ),
1872 });
1873 }
1874 if response.manifest_id != manifest.id {
1875 return Err(AddonManifestError::InvalidEnvelope {
1876 message: format!(
1877 "health manifest_id {} did not match {}",
1878 response.manifest_id, manifest.id
1879 ),
1880 });
1881 }
1882 if response.checked_at.trim().is_empty() {
1883 return Err(AddonManifestError::InvalidEnvelope {
1884 message: "health checked_at must not be empty".to_owned(),
1885 });
1886 }
1887 if response.manifest.addon_version != manifest.version {
1888 return Err(AddonManifestError::InvalidEnvelope {
1889 message: format!(
1890 "health addon_version {} did not match {}",
1891 response.manifest.addon_version, manifest.version
1892 ),
1893 });
1894 }
1895 if response.manifest.resource_count != manifest.resources.len() {
1896 return Err(AddonManifestError::InvalidEnvelope {
1897 message: format!(
1898 "health resource_count {} did not match {}",
1899 response.manifest.resource_count,
1900 manifest.resources.len()
1901 ),
1902 });
1903 }
1904
1905 Ok(())
1906}
1907
1908fn validate_non_empty(value: &str, field: &'static str) -> AddonProtocolResult<()> {
1909 if value.trim().is_empty() {
1910 Err(AddonManifestError::EmptyField { field })
1911 } else {
1912 Ok(())
1913 }
1914}
1915
1916fn validate_manifest_declarations(
1917 manifest: &AddonManifest,
1918 declared_scopes: &HashSet<AddonScope>,
1919) -> AddonProtocolResult<()> {
1920 let mut entry_point_ids = HashSet::new();
1921 for entry_point in &manifest.entry_points {
1922 validate_non_empty(&entry_point.id, "entry_points.id")?;
1923 validate_non_empty(&entry_point.label, "entry_points.label")?;
1924 validate_absolute_declaration_path("entry_point", &entry_point.path)?;
1925 validate_unique_declaration_id("entry_point", &entry_point.id, &mut entry_point_ids)?;
1926 validate_declared_scopes("entry_point", &entry_point.required_scopes, declared_scopes)?;
1927 }
1928
1929 let mut hosted_page_ids = HashSet::new();
1930 for hosted_page in &manifest.hosted_pages {
1931 validate_non_empty(&hosted_page.id, "hosted_pages.id")?;
1932 validate_non_empty(&hosted_page.title, "hosted_pages.title")?;
1933 validate_absolute_declaration_path("hosted_page", &hosted_page.path)?;
1934 validate_unique_declaration_id("hosted_page", &hosted_page.id, &mut hosted_page_ids)?;
1935 validate_declared_scopes("hosted_page", &hosted_page.required_scopes, declared_scopes)?;
1936 }
1937
1938 for entry_point in &manifest.entry_points {
1939 if let Some(hosted_page_id) = &entry_point.hosted_page_id
1940 && !hosted_page_ids.contains(hosted_page_id)
1941 {
1942 return Err(AddonManifestError::UnknownHostedPageReference {
1943 entry_point_id: entry_point.id.clone(),
1944 hosted_page_id: hosted_page_id.clone(),
1945 });
1946 }
1947 }
1948
1949 if let Some(configuration_schema) = &manifest.configuration_schema {
1950 validate_non_empty(
1951 &configuration_schema.schema_id,
1952 "configuration_schema.schema_id",
1953 )?;
1954 if !configuration_schema.schema.is_object() {
1955 return Err(AddonManifestError::InvalidConfigurationSchema {
1956 message: "schema must be a JSON object".to_owned(),
1957 });
1958 }
1959 }
1960
1961 let mut secret_reference_ids = HashSet::new();
1962 for secret_reference in &manifest.secret_reference_fields {
1963 validate_non_empty(&secret_reference.id, "secret_reference_fields.id")?;
1964 validate_non_empty(&secret_reference.label, "secret_reference_fields.label")?;
1965 validate_unique_declaration_id(
1966 "secret_reference_field",
1967 &secret_reference.id,
1968 &mut secret_reference_ids,
1969 )?;
1970 }
1971
1972 let mut event_subscription_ids = HashSet::new();
1973 for event_subscription in &manifest.event_subscriptions {
1974 validate_non_empty(&event_subscription.id, "event_subscriptions.id")?;
1975 validate_non_empty(
1976 &event_subscription.event_kind,
1977 "event_subscriptions.event_kind",
1978 )?;
1979 validate_absolute_declaration_path("event_subscription", &event_subscription.path)?;
1980 validate_unique_declaration_id(
1981 "event_subscription",
1982 &event_subscription.id,
1983 &mut event_subscription_ids,
1984 )?;
1985 validate_declared_scopes(
1986 "event_subscription",
1987 &event_subscription.required_scopes,
1988 declared_scopes,
1989 )?;
1990 }
1991
1992 let mut task_ids = HashSet::new();
1993 for task in &manifest.tasks {
1994 validate_non_empty(&task.id, "tasks.id")?;
1995 validate_non_empty(&task.name, "tasks.name")?;
1996 validate_absolute_declaration_path("task", &task.path)?;
1997 validate_unique_declaration_id("task", &task.id, &mut task_ids)?;
1998 validate_declared_scopes("task", &task.required_scopes, declared_scopes)?;
1999 if let Some(timeout) = task.timeout_ms {
2000 validate_timeout(timeout)?;
2001 }
2002 if let Some(max_attempts) = task.max_attempts {
2003 validate_max_attempts(max_attempts)?;
2004 }
2005 }
2006
2007 Ok(())
2008}
2009
2010fn validate_absolute_declaration_path(
2011 declaration: &'static str,
2012 path: &str,
2013) -> AddonProtocolResult<()> {
2014 if path.starts_with('/') {
2015 Ok(())
2016 } else {
2017 Err(AddonManifestError::InvalidDeclarationPath {
2018 declaration,
2019 path: path.to_owned(),
2020 })
2021 }
2022}
2023
2024fn validate_unique_declaration_id(
2025 declaration: &'static str,
2026 id: &str,
2027 seen: &mut HashSet<String>,
2028) -> AddonProtocolResult<()> {
2029 if seen.insert(id.to_owned()) {
2030 Ok(())
2031 } else {
2032 Err(AddonManifestError::DuplicateDeclaration {
2033 declaration,
2034 id: id.to_owned(),
2035 })
2036 }
2037}
2038
2039fn validate_declared_scopes(
2040 declaration: &'static str,
2041 required_scopes: &[AddonScope],
2042 declared_scopes: &HashSet<AddonScope>,
2043) -> AddonProtocolResult<()> {
2044 for scope in required_scopes {
2045 if !declared_scopes.contains(scope) {
2046 return Err(AddonManifestError::MissingDeclaredScopeForDeclaration {
2047 declaration,
2048 scope: *scope,
2049 });
2050 }
2051 }
2052
2053 Ok(())
2054}
2055
2056fn validate_timeout(value: u64) -> AddonProtocolResult<()> {
2057 if (100..=120_000).contains(&value) {
2058 Ok(())
2059 } else {
2060 Err(AddonManifestError::InvalidTimeout { value })
2061 }
2062}
2063
2064fn validate_max_attempts(value: u32) -> AddonProtocolResult<()> {
2065 if (1..=10).contains(&value) {
2066 Ok(())
2067 } else {
2068 Err(AddonManifestError::InvalidMaxAttempts { value })
2069 }
2070}
2071
2072fn validate_runtime_requirement(runtime: &AddonRuntimeRequirement) -> AddonProtocolResult<()> {
2073 let runtime_reference_count = [&runtime.image, &runtime.binary, &runtime.command]
2074 .into_iter()
2075 .flatten()
2076 .count();
2077 if runtime_reference_count == 0 {
2078 return Err(AddonManifestError::MissingRuntimeReference);
2079 }
2080 if runtime_reference_count > 1 {
2081 return Err(AddonManifestError::InvalidRuntimeReference);
2082 }
2083
2084 for value in [&runtime.image, &runtime.binary, &runtime.command]
2085 .into_iter()
2086 .flatten()
2087 {
2088 validate_non_empty(value, "runtime")?;
2089 if runtime_reference_is_sensitive(value) {
2090 return Err(AddonManifestError::InvalidRuntimeReference);
2091 }
2092 }
2093
2094 Ok(())
2095}
2096
2097fn runtime_reference_is_sensitive(value: &str) -> bool {
2098 let lower = value.to_ascii_lowercase();
2099 value.contains('\\')
2100 || looks_like_local_path(value)
2101 || lower.starts_with("file:")
2102 || lower.contains("://")
2103 || lower.contains("token=")
2104 || lower.contains("secret=")
2105 || lower.contains("password=")
2106 || lower.contains("authorization:")
2107 || lower.contains("bearer ")
2108 || lower.contains("--env ")
2109 || lower.contains("-e ")
2110}
2111
2112fn looks_like_local_path(value: &str) -> bool {
2113 let value = value.trim();
2114 value.starts_with('/')
2115 || value.starts_with("./")
2116 || value.starts_with("../")
2117 || value.starts_with("~/")
2118 || value.as_bytes().get(1).is_some_and(|byte| *byte == b':')
2119}
2120
2121fn secret_reference_looks_like_value(value: &str) -> bool {
2122 let value = value.trim();
2123 if let Some(name) = value.strip_prefix("env:") {
2124 return !valid_environment_reference_name(name);
2125 }
2126 if let Some(name) = value.strip_prefix("secret:") {
2127 return !valid_named_secret_reference(name);
2128 }
2129 if let Some(name) = value.strip_prefix("nako:") {
2130 return !valid_named_secret_reference(name);
2131 }
2132
2133 let lower = value.to_ascii_lowercase();
2134 lower.contains("://")
2135 || lower.contains('=')
2136 || lower.starts_with("bearer ")
2137 || lower.contains("secret")
2138 || lower.contains("token")
2139 || lower.contains("password")
2140}
2141
2142fn valid_environment_reference_name(value: &str) -> bool {
2143 let mut chars = value.chars();
2144 let Some(first) = chars.next() else {
2145 return false;
2146 };
2147 (first == '_' || first.is_ascii_alphabetic())
2148 && chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
2149}
2150
2151fn valid_named_secret_reference(value: &str) -> bool {
2152 !value.is_empty()
2153 && value.chars().all(|character| {
2154 character == '_'
2155 || character == '-'
2156 || character == '.'
2157 || character == '/'
2158 || character.is_ascii_alphanumeric()
2159 })
2160}
2161
2162fn base_url_scheme(value: &str) -> Option<String> {
2163 value.split_once("://").map(|(scheme, _)| scheme.to_owned())
2164}
2165
2166fn runtime_reference(runtime: &AddonRuntimeRequirement) -> AddonRuntimeReference {
2167 if let Some(value) = runtime.image.as_ref() {
2168 return AddonRuntimeReference {
2169 kind: AddonRuntimeReferenceKind::Image,
2170 value: value.clone(),
2171 };
2172 }
2173 if let Some(value) = runtime.binary.as_ref() {
2174 return AddonRuntimeReference {
2175 kind: AddonRuntimeReferenceKind::Binary,
2176 value: value.clone(),
2177 };
2178 }
2179 let value = runtime.command.clone().unwrap_or_default();
2180 AddonRuntimeReference {
2181 kind: AddonRuntimeReferenceKind::Command,
2182 value,
2183 }
2184}
2185
2186fn usize_to_u32(value: usize) -> u32 {
2187 u32::try_from(value).unwrap_or(u32::MAX)
2188}
2189
2190fn install_steps(descriptor: &AddonInstallDescriptor) -> Vec<AddonInstallStep> {
2191 let mut steps = vec![AddonInstallStep {
2192 kind: AddonInstallStepKind::RunSidecar,
2193 summary: "Run the Addon Sidecar outside Nako using the declared runtime reference."
2194 .to_owned(),
2195 }];
2196 if descriptor
2197 .manifest
2198 .secret_reference_fields
2199 .iter()
2200 .any(|field| field.required)
2201 {
2202 steps.push(AddonInstallStep {
2203 kind: AddonInstallStepKind::ConfigureSecretReference,
2204 summary: "Configure required Secret References in Nako; do not paste secret values into the install guide."
2205 .to_owned(),
2206 });
2207 }
2208 steps.push(AddonInstallStep {
2209 kind: AddonInstallStepKind::RegisterManifest,
2210 summary:
2211 "Register the manifest through the Admin Addon API after the sidecar is reachable."
2212 .to_owned(),
2213 });
2214 if !descriptor.manifest.scopes.is_empty() {
2215 steps.push(AddonInstallStep {
2216 kind: AddonInstallStepKind::GrantScopes,
2217 summary:
2218 "Grant only the requested Addon Scopes and library access needed by this sidecar."
2219 .to_owned(),
2220 });
2221 }
2222
2223 steps
2224}
2225
2226fn has_http_base_url(value: &str) -> bool {
2227 let Some((scheme, rest)) = value.split_once("://") else {
2228 return false;
2229 };
2230 if !matches!(scheme, "http" | "https") {
2231 return false;
2232 }
2233 let Some(authority) = rest.split(['/', '?', '#']).next() else {
2234 return false;
2235 };
2236 !authority.trim().is_empty() && !authority.contains(char::is_whitespace)
2237}
2238
2239#[cfg(test)]
2240mod tests {
2241 use super::*;
2242
2243 #[test]
2244 fn validates_manifest_resource_and_scope_contract() {
2245 let manifest = valid_manifest();
2246
2247 validate_manifest(&manifest).unwrap();
2248 ensure_scope_grant(
2249 &manifest,
2250 AddonResource::Metadata,
2251 &[
2252 AddonScope::ItemMetadataRead,
2253 AddonScope::ItemMetadataSuggest,
2254 ],
2255 )
2256 .unwrap();
2257 }
2258
2259 #[test]
2260 fn exposes_explicit_supported_protocol_versions() {
2261 assert_eq!(SUPPORTED_ADDON_PROTOCOL_VERSIONS, &[ADDON_PROTOCOL_VERSION]);
2262 assert!(is_supported_addon_protocol_version(ADDON_PROTOCOL_VERSION));
2263 assert!(!is_supported_addon_protocol_version("0.1.0-alpha.0"));
2264 }
2265
2266 #[test]
2267 fn exposes_explicit_addon_runtime_route_inventory() {
2268 let paths = addon_runtime_paths().collect::<Vec<_>>();
2269
2270 assert_eq!(
2271 paths,
2272 vec![
2273 ADDON_RUNTIME_ACCESS_CHECK_PATH,
2274 ADDON_RUNTIME_SIDE_EFFECTS_PATH,
2275 ADDON_RUNTIME_GENERATED_ARTIFACTS_PATH,
2276 ADDON_RUNTIME_ACQUISITION_INTAKE_CANDIDATES_PATH,
2277 ADDON_RUNTIME_TASK_RUN_CLAIM_PATH,
2278 ADDON_RUNTIME_TASK_RUN_PROGRESS_PATH,
2279 ADDON_RUNTIME_TASK_RUN_COMPLETE_PATH,
2280 ADDON_RUNTIME_TASK_RUN_FAIL_PATH,
2281 ADDON_RUNTIME_TASK_RUN_CANCEL_PATH,
2282 ]
2283 );
2284 assert!(paths.iter().all(|path| path.starts_with("/addon/v1/")));
2285 assert_eq!(
2286 ADDON_RUNTIME_ROUTES.len(),
2287 paths.iter().collect::<HashSet<_>>().len(),
2288 "Addon runtime route paths must stay unique"
2289 );
2290 assert!(
2291 ADDON_RUNTIME_ROUTES
2292 .iter()
2293 .all(|route| route.method == AddonRuntimeHttpMethod::Post)
2294 );
2295 }
2296
2297 #[test]
2298 fn rejects_invalid_manifest_shape() {
2299 let mut manifest = valid_manifest();
2300 manifest.protocol_version = "2020-01-01".to_owned();
2301 assert!(matches!(
2302 validate_manifest(&manifest),
2303 Err(AddonManifestError::UnsupportedProtocolVersion { .. })
2304 ));
2305
2306 let mut manifest = valid_manifest();
2307 manifest.resources[0].path = "metadata".to_owned();
2308 assert!(matches!(
2309 validate_manifest(&manifest),
2310 Err(AddonManifestError::InvalidResourcePath { .. })
2311 ));
2312
2313 let mut manifest = valid_manifest();
2314 manifest.resources.push(manifest.resources[0].clone());
2315 assert!(matches!(
2316 validate_manifest(&manifest),
2317 Err(AddonManifestError::DuplicateResource { .. })
2318 ));
2319
2320 let mut manifest = valid_manifest();
2321 manifest.base_url = "file:///tmp/addon".to_owned();
2322 assert!(matches!(
2323 validate_manifest(&manifest),
2324 Err(AddonManifestError::InvalidBaseUrl)
2325 ));
2326
2327 let mut manifest = valid_manifest();
2328 manifest.base_url = "https:///missing-authority".to_owned();
2329 assert!(matches!(
2330 validate_manifest(&manifest),
2331 Err(AddonManifestError::InvalidBaseUrl)
2332 ));
2333 }
2334
2335 #[test]
2336 fn validates_manifest_declaration_contracts() {
2337 let mut manifest = valid_manifest();
2338 manifest.hosted_pages = vec![AddonHostedPageDeclaration {
2339 id: "diagnostics".to_owned(),
2340 title: "Addon Diagnostics".to_owned(),
2341 path: "/ui/diagnostics".to_owned(),
2342 required_scopes: vec![AddonScope::ItemMetadataRead],
2343 }];
2344 manifest.entry_points = vec![AddonEntryPointDeclaration::hosted_page(
2345 "metadata-action",
2346 AddonEntryPointKind::ItemAction,
2347 "Suggest Metadata",
2348 "/ui/metadata-action",
2349 "diagnostics",
2350 vec![AddonScope::ItemMetadataSuggest],
2351 )];
2352 manifest.configuration_schema = Some(AddonConfigurationSchema {
2353 schema_id: "nako.reference.metadata.config.v1".to_owned(),
2354 schema: serde_json::json!({
2355 "type": "object",
2356 "properties": {
2357 "language": { "type": "string" },
2358 "api_key": { "type": "string", "x-nako-secret-reference": true }
2359 },
2360 "additionalProperties": false
2361 }),
2362 });
2363 manifest.secret_reference_fields = vec![AddonSecretReferenceFieldDeclaration {
2364 id: "api_key".to_owned(),
2365 label: "API Key".to_owned(),
2366 description: Some("Resolved by Nako when the addon is called".to_owned()),
2367 required: true,
2368 }];
2369 manifest.event_subscriptions = vec![AddonEventSubscriptionDeclaration {
2370 id: "library-scan-finished".to_owned(),
2371 event_kind: "library_scan.succeeded".to_owned(),
2372 path: "/events/library-scan-finished".to_owned(),
2373 required_scopes: vec![AddonScope::WebhookEventRead],
2374 filters: serde_json::json!({ "library_preset": "movies" }),
2375 }];
2376 manifest.tasks = vec![AddonTaskDeclaration {
2377 id: "bulk-metadata-scrape".to_owned(),
2378 name: "Bulk metadata scrape".to_owned(),
2379 path: "/tasks/bulk-metadata-scrape".to_owned(),
2380 description: Some("Runs metadata suggestions for selected items".to_owned()),
2381 required_scopes: vec![AddonScope::AutomationRun],
2382 timeout_ms: Some(30_000),
2383 max_attempts: Some(2),
2384 }];
2385 manifest
2386 .scopes
2387 .extend([AddonScope::WebhookEventRead, AddonScope::AutomationRun]);
2388
2389 validate_manifest(&manifest).unwrap();
2390 }
2391
2392 #[test]
2393 fn rejects_invalid_manifest_declarations() {
2394 let mut manifest = valid_manifest();
2395 manifest.entry_points = vec![AddonEntryPointDeclaration {
2396 id: "metadata-action".to_owned(),
2397 kind: AddonEntryPointKind::ItemAction,
2398 label: "Suggest Metadata".to_owned(),
2399 path: "ui/metadata-action".to_owned(),
2400 hosted_page_id: None,
2401 required_scopes: vec![AddonScope::ItemMetadataSuggest],
2402 }];
2403 assert!(matches!(
2404 validate_manifest(&manifest),
2405 Err(AddonManifestError::InvalidDeclarationPath {
2406 declaration: "entry_point",
2407 ..
2408 })
2409 ));
2410
2411 let mut manifest = valid_manifest();
2412 manifest.hosted_pages = vec![
2413 AddonHostedPageDeclaration {
2414 id: "diagnostics".to_owned(),
2415 title: "Diagnostics".to_owned(),
2416 path: "/ui/diagnostics".to_owned(),
2417 required_scopes: Vec::new(),
2418 },
2419 AddonHostedPageDeclaration {
2420 id: "diagnostics".to_owned(),
2421 title: "Diagnostics Duplicate".to_owned(),
2422 path: "/ui/diagnostics-2".to_owned(),
2423 required_scopes: Vec::new(),
2424 },
2425 ];
2426 assert!(matches!(
2427 validate_manifest(&manifest),
2428 Err(AddonManifestError::DuplicateDeclaration {
2429 declaration: "hosted_page",
2430 ..
2431 })
2432 ));
2433
2434 let mut manifest = valid_manifest();
2435 manifest.entry_points = vec![AddonEntryPointDeclaration {
2436 id: "metadata-action".to_owned(),
2437 kind: AddonEntryPointKind::ItemAction,
2438 label: "Suggest Metadata".to_owned(),
2439 path: "/ui/metadata-action".to_owned(),
2440 hosted_page_id: Some("missing-page".to_owned()),
2441 required_scopes: vec![AddonScope::ItemMetadataSuggest],
2442 }];
2443 assert!(matches!(
2444 validate_manifest(&manifest),
2445 Err(AddonManifestError::UnknownHostedPageReference { .. })
2446 ));
2447
2448 let mut manifest = valid_manifest();
2449 manifest.tasks = vec![AddonTaskDeclaration {
2450 id: "bulk-metadata-scrape".to_owned(),
2451 name: "Bulk metadata scrape".to_owned(),
2452 path: "/tasks/bulk-metadata-scrape".to_owned(),
2453 description: None,
2454 required_scopes: vec![AddonScope::AutomationRun],
2455 timeout_ms: Some(30_000),
2456 max_attempts: Some(2),
2457 }];
2458 assert!(matches!(
2459 validate_manifest(&manifest),
2460 Err(AddonManifestError::MissingDeclaredScopeForDeclaration {
2461 declaration: "task",
2462 scope: AddonScope::AutomationRun,
2463 })
2464 ));
2465
2466 let mut manifest = valid_manifest();
2467 manifest.configuration_schema = Some(AddonConfigurationSchema {
2468 schema_id: "nako.reference.metadata.config.v1".to_owned(),
2469 schema: serde_json::json!("not-an-object"),
2470 });
2471 assert!(matches!(
2472 validate_manifest(&manifest),
2473 Err(AddonManifestError::InvalidConfigurationSchema { .. })
2474 ));
2475 }
2476
2477 #[test]
2478 fn denies_missing_scope_grants() {
2479 let manifest = valid_manifest();
2480
2481 assert!(matches!(
2482 ensure_scope_grant(
2483 &manifest,
2484 AddonResource::Metadata,
2485 &[AddonScope::ItemMetadataRead]
2486 ),
2487 Err(AddonManifestError::MissingDeclaredScope { .. })
2488 ));
2489 }
2490
2491 #[test]
2492 fn resource_envelopes_round_trip() {
2493 let request = AddonResourceRequest {
2494 protocol_version: ADDON_PROTOCOL_VERSION.to_owned(),
2495 addon_id: "example".to_owned(),
2496 resource: AddonResource::Metadata,
2497 request_id: "request-1".to_owned(),
2498 payload: serde_json::json!({"item_id":"018f0000-0000-7000-8000-000000000001"}),
2499 };
2500 let json = serde_json::to_string(&request).unwrap();
2501
2502 assert_eq!(
2503 serde_json::from_str::<AddonResourceRequest>(&json).unwrap(),
2504 request
2505 );
2506 }
2507
2508 #[test]
2509 fn renderer_adapter_payload_contracts_round_trip_and_redact_debug() {
2510 let target = AddonRendererAdapterTarget {
2511 stable_device_id: "living-room-tv".to_owned(),
2512 target_kind: AddonRendererAdapterProtocol::Chromecast,
2513 display_name: "Living Room TV".to_owned(),
2514 network_scope: AddonRendererAdapterNetworkScope::Local,
2515 media_capabilities: AddonRendererAdapterMediaCapabilities {
2516 direct_play: true,
2517 containers: vec!["mp4".to_owned()],
2518 video_codecs: vec!["h264".to_owned()],
2519 audio_codecs: vec!["aac".to_owned()],
2520 },
2521 control_capabilities: AddonRendererAdapterControlCapabilities::basic_playback(),
2522 discovered_at_ms: Some(1_779_814_400_000),
2523 };
2524 let targets = AddonRendererAdapterResponse::Targets {
2525 targets: vec![target],
2526 };
2527 assert_eq!(
2528 serde_json::to_value(&targets).unwrap(),
2529 serde_json::json!({
2530 "kind": "targets",
2531 "targets": [{
2532 "stable_device_id": "living-room-tv",
2533 "target_kind": "chromecast",
2534 "display_name": "Living Room TV",
2535 "network_scope": "local",
2536 "media_capabilities": {
2537 "direct_play": true,
2538 "containers": ["mp4"],
2539 "video_codecs": ["h264"],
2540 "audio_codecs": ["aac"]
2541 },
2542 "control_capabilities": {
2543 "play": true,
2544 "pause": true,
2545 "resume": true,
2546 "seek": true,
2547 "stop": true,
2548 "set_volume": false
2549 },
2550 "discovered_at_ms": 1779814400000u64
2551 }]
2552 })
2553 );
2554
2555 let envelope = AddonRendererAdapterCommandEnvelope {
2556 adapter_id: "nako.official.chromecast-renderer".to_owned(),
2557 stable_device_id: "living-room-tv".to_owned(),
2558 target_kind: AddonRendererAdapterProtocol::Chromecast,
2559 renderer_session_id: "018f0000-0000-7000-8000-000000000010".to_owned(),
2560 playback_session_id: "018f0000-0000-7000-8000-000000000011".to_owned(),
2561 source_id: "018f0000-0000-7000-8000-000000000012".to_owned(),
2562 command: AddonRendererAdapterCommand::Play,
2563 position_ms: Some(9_000),
2564 volume_percent: None,
2565 transport: AddonRendererAdapterTransport {
2566 mode: AddonRendererAdapterTransportMode::Direct,
2567 expires_at: "2026-05-27T12:00:00.000Z".to_owned(),
2568 urls: vec![AddonRendererAdapterTransportUrl {
2569 kind: AddonRendererAdapterTransportUrlKind::Stream,
2570 url: "https://nako.example/playback/stream?renderer_ticket=nako_rtt_secret"
2571 .to_owned(),
2572 content_type: "video/mp4".to_owned(),
2573 supports_range_requests: true,
2574 }],
2575 },
2576 };
2577 let request = AddonRendererAdapterRequest::DispatchCommand {
2578 protocol: AddonRendererAdapterProtocol::Chromecast,
2579 envelope,
2580 };
2581 let json = serde_json::to_string(&request).unwrap();
2582
2583 assert_eq!(
2584 serde_json::from_str::<AddonRendererAdapterRequest>(&json).unwrap(),
2585 request
2586 );
2587 assert!(json.contains("renderer_ticket=nako_rtt_secret"));
2588
2589 let debug = format!("{request:?}").to_ascii_lowercase();
2590 for forbidden in ["renderer_ticket", "nako_rtt_secret", "bearer", "local://"] {
2591 assert!(
2592 !debug.contains(forbidden),
2593 "renderer adapter debug leaked forbidden term: {forbidden}"
2594 );
2595 }
2596 }
2597
2598 #[test]
2599 fn health_check_envelopes_validate_manifest_facts() {
2600 let manifest = valid_manifest();
2601 let request = AddonHealthCheckRequest {
2602 protocol_version: ADDON_PROTOCOL_VERSION.to_owned(),
2603 manifest_id: manifest.id.clone(),
2604 request_id: "health-1".to_owned(),
2605 expected_addon_version: manifest.version.clone(),
2606 expected_resource_count: manifest.resources.len(),
2607 };
2608 let response = AddonHealthCheckResponse {
2609 protocol_version: ADDON_PROTOCOL_VERSION.to_owned(),
2610 manifest_id: manifest.id.clone(),
2611 status: AddonHealthStatus::Ok,
2612 checked_at: "2026-05-21T12:00:00.000Z".to_owned(),
2613 manifest: AddonHealthManifestFacts {
2614 addon_version: manifest.version.clone(),
2615 resource_count: manifest.resources.len(),
2616 },
2617 diagnostics: serde_json::json!({"safe_note": "ok"}),
2618 };
2619
2620 assert_eq!(
2621 serde_json::from_str::<AddonHealthCheckRequest>(
2622 &serde_json::to_string(&request).unwrap()
2623 )
2624 .unwrap(),
2625 request
2626 );
2627 validate_health_check_response(&response, &manifest).unwrap();
2628
2629 let mut invalid = response;
2630 invalid.manifest.resource_count += 1;
2631 assert!(matches!(
2632 validate_health_check_response(&invalid, &manifest),
2633 Err(AddonManifestError::InvalidEnvelope { .. })
2634 ));
2635 }
2636
2637 #[test]
2638 fn protected_write_payload_contracts_keep_wire_shape() {
2639 let metadata = AddonMetadataPatch {
2640 title: Some("Demo".to_owned()),
2641 genres: Some(vec!["Drama".to_owned()]),
2642 ..AddonMetadataPatch::default()
2643 };
2644 assert_eq!(
2645 serde_json::to_value(&metadata).unwrap(),
2646 serde_json::json!({
2647 "title": "Demo",
2648 "original_title": null,
2649 "sort_title": null,
2650 "overview": null,
2651 "release_date": null,
2652 "runtime_minutes": null,
2653 "tagline": null,
2654 "genres": ["Drama"],
2655 "tags": null,
2656 "ratings": null,
2657 "images": null,
2658 "credits": null,
2659 "collections": null,
2660 "studios": null,
2661 "external_ids": null
2662 })
2663 );
2664
2665 let artwork = AddonArtworkWritePayload {
2666 intent: AddonArtworkIntent::ProposeArtwork,
2667 kind: AddonArtworkKind::Poster,
2668 source: AddonArtworkSourcePayload {
2669 kind: AddonArtworkSourceKind::RemoteUrl,
2670 url: "https://addon.example/poster.jpg".to_owned(),
2671 },
2672 language: Some("en".to_owned()),
2673 width: Some(1000),
2674 height: Some(1500),
2675 };
2676 assert_eq!(
2677 serde_json::to_value(&artwork).unwrap(),
2678 serde_json::json!({
2679 "intent": "propose_artwork",
2680 "kind": "poster",
2681 "source": {
2682 "kind": "remote_url",
2683 "url": "https://addon.example/poster.jpg"
2684 },
2685 "language": "en",
2686 "width": 1000,
2687 "height": 1500
2688 })
2689 );
2690
2691 let file_write = AddonLibraryFileWritePayload {
2692 file_role: AddonLibraryFileRole::Nfo,
2693 policy: AddonLibraryFileWritePolicy::ReplaceExistingPreserving,
2694 };
2695 assert_eq!(
2696 serde_json::to_value(&file_write).unwrap(),
2697 serde_json::json!({
2698 "file_role": "nfo",
2699 "policy": "replace_existing_preserving"
2700 })
2701 );
2702 }
2703
2704 #[test]
2705 fn addon_install_descriptor_generates_redacted_install_guide() {
2706 let mut manifest = valid_manifest();
2707 manifest.secret_reference_fields = vec![AddonSecretReferenceFieldDeclaration {
2708 id: "metadata_api_key".to_owned(),
2709 label: "Metadata API key".to_owned(),
2710 description: Some("Resolved by Nako at runtime".to_owned()),
2711 required: true,
2712 }];
2713 manifest.tasks = vec![AddonTaskDeclaration {
2714 id: "bulk-metadata-scrape".to_owned(),
2715 name: "Bulk metadata scrape".to_owned(),
2716 path: "/tasks/bulk-metadata-scrape".to_owned(),
2717 description: None,
2718 required_scopes: vec![AddonScope::AutomationRun],
2719 timeout_ms: Some(30_000),
2720 max_attempts: Some(2),
2721 }];
2722 manifest.event_subscriptions = vec![AddonEventSubscriptionDeclaration {
2723 id: "library-scan-finished".to_owned(),
2724 event_kind: "library_scan.succeeded".to_owned(),
2725 path: "/events/library-scan-finished".to_owned(),
2726 required_scopes: vec![AddonScope::WebhookEventRead],
2727 filters: serde_json::json!({ "library_preset": "movies" }),
2728 }];
2729 manifest
2730 .scopes
2731 .extend([AddonScope::AutomationRun, AddonScope::WebhookEventRead]);
2732 let descriptor = AddonInstallDescriptor {
2733 manifest,
2734 runtime: AddonRuntimeRequirement {
2735 kind: AddonRuntimeKind::HttpSidecar,
2736 image: Some("ghcr.io/nako/example-metadata-addon:0.1.0".to_owned()),
2737 binary: None,
2738 command: None,
2739 },
2740 secret_reference_bindings: vec![AddonSecretReferenceBinding {
2741 field_id: "metadata_api_key".to_owned(),
2742 secret_ref: "env:NAKO_METADATA_ADDON_API_KEY".to_owned(),
2743 }],
2744 install_notes: vec!["Use a reverse proxy if exposing the sidecar remotely.".to_owned()],
2745 };
2746
2747 validate_install_descriptor(&descriptor).unwrap();
2748 let guide = addon_install_guide(&descriptor);
2749 let body = serde_json::to_string(&guide).unwrap();
2750
2751 assert_eq!(guide.manifest_id, "example");
2752 assert_eq!(guide.runtime_kind, AddonRuntimeKind::HttpSidecar);
2753 assert_eq!(
2754 guide.runtime_reference,
2755 AddonRuntimeReference {
2756 kind: AddonRuntimeReferenceKind::Image,
2757 value: "ghcr.io/nako/example-metadata-addon:0.1.0".to_owned(),
2758 }
2759 );
2760 assert_eq!(guide.base_url_scheme, "https");
2761 assert_eq!(guide.task_count, 1);
2762 assert_eq!(guide.event_subscription_count, 1);
2763 assert_eq!(guide.required_secret_fields[0].id, "metadata_api_key");
2764 assert!(guide.required_secret_fields[0].provided);
2765 assert!(guide.missing_required_secret_fields.is_empty());
2766 assert!(
2767 guide
2768 .install_steps
2769 .iter()
2770 .any(|step| step.kind == AddonInstallStepKind::ConfigureSecretReference)
2771 );
2772 assert!(!body.contains("NAKO_METADATA_ADDON_API_KEY="));
2773 assert!(!body.contains("secret-value"));
2774 assert!(!body.contains("Bearer "));
2775 assert!(!body.contains("C:\\"));
2776 assert!(!body.contains("file:///"));
2777 }
2778
2779 #[test]
2780 fn addon_install_descriptor_rejects_secret_values_and_local_runtime_paths() {
2781 let mut descriptor = AddonInstallDescriptor {
2782 manifest: valid_manifest(),
2783 runtime: AddonRuntimeRequirement {
2784 kind: AddonRuntimeKind::HttpSidecar,
2785 image: None,
2786 binary: Some("C:\\addons\\metadata.exe".to_owned()),
2787 command: None,
2788 },
2789 secret_reference_bindings: Vec::new(),
2790 install_notes: Vec::new(),
2791 };
2792 assert!(matches!(
2793 validate_install_descriptor(&descriptor),
2794 Err(AddonManifestError::InvalidRuntimeReference)
2795 ));
2796
2797 descriptor.runtime.binary = Some("C:/addons/metadata.exe".to_owned());
2798 assert!(matches!(
2799 validate_install_descriptor(&descriptor),
2800 Err(AddonManifestError::InvalidRuntimeReference)
2801 ));
2802
2803 descriptor.runtime.binary = Some("nako-metadata-addon".to_owned());
2804 descriptor.runtime.command = Some("nako-metadata-addon --port 8080".to_owned());
2805 assert!(matches!(
2806 validate_install_descriptor(&descriptor),
2807 Err(AddonManifestError::InvalidRuntimeReference)
2808 ));
2809
2810 descriptor.runtime.binary = Some("nako-metadata-addon".to_owned());
2811 descriptor.runtime.command = None;
2812 descriptor
2813 .manifest
2814 .secret_reference_fields
2815 .push(AddonSecretReferenceFieldDeclaration {
2816 id: "metadata_api_key".to_owned(),
2817 label: "Metadata API key".to_owned(),
2818 description: None,
2819 required: true,
2820 });
2821 descriptor.secret_reference_bindings = vec![AddonSecretReferenceBinding {
2822 field_id: "metadata_api_key".to_owned(),
2823 secret_ref: "secret-value-token".to_owned(),
2824 }];
2825 assert!(matches!(
2826 validate_install_descriptor(&descriptor),
2827 Err(AddonManifestError::SecretReferenceContainsValue { .. })
2828 ));
2829
2830 descriptor.secret_reference_bindings = vec![AddonSecretReferenceBinding {
2831 field_id: "metadata_api_key".to_owned(),
2832 secret_ref: "env:NAKO_METADATA_ADDON_TOKEN".to_owned(),
2833 }];
2834 validate_install_descriptor(&descriptor).unwrap();
2835 }
2836
2837 fn valid_manifest() -> AddonManifest {
2838 AddonManifest {
2839 id: "example".to_owned(),
2840 name: "Example".to_owned(),
2841 version: "0.1.0".to_owned(),
2842 protocol_version: ADDON_PROTOCOL_VERSION.to_owned(),
2843 base_url: "https://example.test/addon".to_owned(),
2844 description: None,
2845 resources: vec![AddonResourceDeclaration {
2846 kind: AddonResource::Metadata,
2847 path: "/metadata".to_owned(),
2848 input_schema: Some("nako.metadata.request.v1".to_owned()),
2849 output_schema: Some("nako.metadata.response.v1".to_owned()),
2850 required_scopes: vec![
2851 AddonScope::ItemMetadataRead,
2852 AddonScope::ItemMetadataSuggest,
2853 ],
2854 timeout_ms: Some(5_000),
2855 max_attempts: Some(2),
2856 }],
2857 entry_points: Vec::new(),
2858 hosted_pages: Vec::new(),
2859 configuration_schema: None,
2860 secret_reference_fields: Vec::new(),
2861 event_subscriptions: Vec::new(),
2862 tasks: Vec::new(),
2863 auth: AddonAuth::Bearer,
2864 default_timeout_ms: Some(10_000),
2865 default_max_attempts: Some(2),
2866 scopes: vec![
2867 AddonScope::ItemMetadataRead,
2868 AddonScope::ItemMetadataSuggest,
2869 ],
2870 }
2871 }
2872}