1use std::collections::BTreeSet;
4use std::error::Error;
5use std::fmt::{self, Display, Formatter};
6
7use serde::{Deserialize, Serialize};
8
9use crate::{IndexMode, IndexRecordModel};
10
11pub const PROFILE_RECORD_MODEL_FORMAT_VERSION: u16 = 1;
12pub const PROFILE_PRIVACY_POLICY_FORMAT_VERSION: u16 = 1;
13pub const PROFILE_BATCH_FORMAT_VERSION: u16 = 1;
14pub const PROFILE_SOURCE_FORMAT_VERSION: u16 = 1;
15pub const MAX_PROFILE_BATCH_BYTES: usize = 1_048_576;
16pub const MAX_PROFILE_SAMPLE_TYPES: usize = 16;
17pub const MAX_PROFILE_SAMPLES: usize = 10_000;
18pub const MAX_PROFILE_TABLE_ENTRIES: usize = 65_536;
19pub const MAX_PROFILE_STACK_DEPTH: usize = 256;
20pub const MAX_PROFILE_LABELS: usize = 128;
21pub const MAX_PROFILE_PRIVACY_RULES: usize = 256;
22pub const MAX_PROFILE_UNIQUE_STRINGS: usize = 16_384;
23pub const MAX_PROFILE_IDENTIFIER_BYTES: usize = 1_024;
24pub const MAX_PROFILE_STRING_BYTES: usize = 4_096;
25
26#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ProfilePrivacyActionV1 {
29 Allow,
30 Drop,
31 Redact,
32 Reject,
33}
34
35#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
36#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
37pub enum ProfilePrivacyFieldV1 {
38 ResourceAttribute { key: String },
39 ScopeAttribute { key: String },
40 SampleLabel { key: String },
41 FunctionName,
42 FunctionSystemName,
43 FunctionFilename,
44 MappingFilename,
45}
46
47#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
48#[serde(deny_unknown_fields)]
49pub struct ProfilePrivacyRuleV1 {
50 pub field: ProfilePrivacyFieldV1,
51 pub action: ProfilePrivacyActionV1,
52}
53
54#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
55#[serde(deny_unknown_fields)]
56pub struct ProfilePrivacyPolicyV1 {
57 pub format_version: u16,
58 pub rules: Vec<ProfilePrivacyRuleV1>,
59}
60
61impl ProfilePrivacyPolicyV1 {
62 pub fn validate(&self) -> Result<(), ProfileRecordError> {
63 if self.format_version != PROFILE_PRIVACY_POLICY_FORMAT_VERSION {
64 return Err(ProfileRecordError::UnsupportedPrivacyPolicyVersion(self.format_version));
65 }
66 if self.rules.len() > MAX_PROFILE_PRIVACY_RULES
67 || self.rules.windows(2).any(|pair| pair[0].field >= pair[1].field)
68 {
69 return Err(ProfileRecordError::InvalidPrivacyPolicy);
70 }
71 for rule in &self.rules {
72 match &rule.field {
73 ProfilePrivacyFieldV1::ResourceAttribute { key }
74 | ProfilePrivacyFieldV1::ScopeAttribute { key }
75 | ProfilePrivacyFieldV1::SampleLabel { key } => validate_identifier(key)?,
76 ProfilePrivacyFieldV1::FunctionName
77 | ProfilePrivacyFieldV1::FunctionSystemName
78 | ProfilePrivacyFieldV1::FunctionFilename
79 | ProfilePrivacyFieldV1::MappingFilename => {}
80 }
81 }
82 Ok(())
83 }
84
85 #[must_use]
86 pub fn semantic_digest_v1(&self) -> [u8; 32] {
87 let mut hasher = blake3::Hasher::new();
88 hasher.update(b"lodestone/profile-privacy-policy/v1\0");
89 hasher.update(&self.format_version.to_be_bytes());
90 hasher.update(&(self.rules.len() as u64).to_be_bytes());
91 for rule in &self.rules {
92 match &rule.field {
93 ProfilePrivacyFieldV1::ResourceAttribute { key } => hash_named(&mut hasher, b"resource", key),
94 ProfilePrivacyFieldV1::ScopeAttribute { key } => hash_named(&mut hasher, b"scope", key),
95 ProfilePrivacyFieldV1::SampleLabel { key } => hash_named(&mut hasher, b"sample", key),
96 ProfilePrivacyFieldV1::FunctionName => {
97 hasher.update(b"function_name\0");
98 }
99 ProfilePrivacyFieldV1::FunctionSystemName => {
100 hasher.update(b"function_system_name\0");
101 }
102 ProfilePrivacyFieldV1::FunctionFilename => {
103 hasher.update(b"function_filename\0");
104 }
105 ProfilePrivacyFieldV1::MappingFilename => {
106 hasher.update(b"mapping_filename\0");
107 }
108 };
109 hasher.update(&[match rule.action {
110 ProfilePrivacyActionV1::Allow => 0,
111 ProfilePrivacyActionV1::Drop => 1,
112 ProfilePrivacyActionV1::Redact => 2,
113 ProfilePrivacyActionV1::Reject => 3,
114 }]);
115 }
116 *hasher.finalize().as_bytes()
117 }
118}
119
120#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
121#[serde(deny_unknown_fields)]
122pub struct ProfileLabelV1 {
123 pub key: String,
124 pub value: String,
125}
126
127#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
128#[serde(deny_unknown_fields)]
129pub struct ProfileSampleTypeV1 {
130 pub sample_type: String,
131 pub unit: String,
132}
133
134#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
135#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
136pub enum ProfileNumberV1 {
137 I64 { value: i64 },
138 F64Bits { value: u64 },
139}
140
141impl ProfileNumberV1 {
142 pub fn validate(self) -> Result<(), ProfileRecordError> {
143 if let Self::F64Bits { value } = self
144 && !f64::from_bits(value).is_finite()
145 {
146 return Err(ProfileRecordError::NonFiniteValue);
147 }
148 Ok(())
149 }
150}
151
152#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
153#[serde(deny_unknown_fields)]
154pub struct ProfileMappingV1 {
155 pub mapping_id: String,
156 pub build_id: String,
157 pub module_id: String,
158 pub filename: Option<String>,
159}
160
161#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
162#[serde(deny_unknown_fields)]
163pub struct ProfileFunctionV1 {
164 pub function_id: String,
165 pub name: Option<String>,
166 pub system_name: Option<String>,
167 pub filename: Option<String>,
168 pub start_line: Option<u64>,
169 pub symbol_observation_id: Option<String>,
170}
171
172#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
173#[serde(deny_unknown_fields)]
174pub struct ProfileLineV1 {
175 pub function_id: String,
176 pub line: u64,
177}
178
179#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
180#[serde(deny_unknown_fields)]
181pub struct ProfileLocationV1 {
182 pub location_id: String,
183 pub mapping_id: Option<String>,
184 pub address: u64,
185 pub lines: Vec<ProfileLineV1>,
186}
187
188#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
189#[serde(deny_unknown_fields)]
190pub struct ProfileStackV1 {
191 pub stack_id: String,
192 pub parent_stack_id: Option<String>,
193 pub location_id: String,
194}
195
196#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
197#[serde(deny_unknown_fields)]
198pub struct ProfileSampleV1 {
199 pub sample_id: String,
200 pub stack_id: String,
201 pub values: Vec<ProfileNumberV1>,
202 pub labels: Vec<ProfileLabelV1>,
203 pub trace_id: Option<String>,
204 pub span_id: Option<String>,
205}
206
207#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
208#[serde(deny_unknown_fields)]
209pub struct ProfileInstrumentationScopeV1 {
210 pub name: String,
211 pub version: Option<String>,
212 pub attributes: Vec<ProfileLabelV1>,
213}
214
215#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
216#[serde(deny_unknown_fields)]
217pub struct ProfileBatchV1 {
218 pub format_version: u16,
219 pub profile_id: String,
220 pub batch_id: String,
221 pub start_unix_nanos: u64,
222 pub end_unix_nanos: u64,
223 pub duration_nanos: u64,
224 pub period_nanos: u64,
225 pub sample_types: Vec<ProfileSampleTypeV1>,
226 pub resource_attributes: Vec<ProfileLabelV1>,
227 pub instrumentation_scope: ProfileInstrumentationScopeV1,
228 pub mappings: Vec<ProfileMappingV1>,
229 pub functions: Vec<ProfileFunctionV1>,
230 pub locations: Vec<ProfileLocationV1>,
231 pub stacks: Vec<ProfileStackV1>,
232 pub samples: Vec<ProfileSampleV1>,
233}
234
235impl ProfileBatchV1 {
236 pub fn validate(&self, model: &IndexRecordModel) -> Result<(), ProfileRecordError> {
237 if self.format_version != PROFILE_BATCH_FORMAT_VERSION {
238 return Err(ProfileRecordError::UnsupportedBatchVersion(self.format_version));
239 }
240 let IndexRecordModel::ProfileV1 { privacy, .. } = model else {
241 return Err(ProfileRecordError::RecordModelMismatch);
242 };
243 privacy.validate()?;
244 validate_identifier(&self.profile_id)?;
245 validate_identifier(&self.batch_id)?;
246 if self.start_unix_nanos >= self.end_unix_nanos
247 || self.duration_nanos != self.end_unix_nanos.saturating_sub(self.start_unix_nanos)
248 || self.period_nanos == 0
249 {
250 return Err(ProfileRecordError::InvalidTimeRange);
251 }
252 if self.sample_types.is_empty()
253 || self.sample_types.len() > MAX_PROFILE_SAMPLE_TYPES
254 || self.sample_types.windows(2).any(|pair| pair[0] >= pair[1])
255 {
256 return Err(ProfileRecordError::InvalidSampleTypes);
257 }
258 for sample_type in &self.sample_types {
259 validate_identifier(&sample_type.sample_type)?;
260 validate_identifier(&sample_type.unit)?;
261 }
262 validate_labels(&self.resource_attributes)?;
263 validate_identifier(&self.instrumentation_scope.name)?;
264 validate_optional_string(self.instrumentation_scope.version.as_deref())?;
265 validate_labels(&self.instrumentation_scope.attributes)?;
266 validate_table_order(&self.mappings, |entry| entry.mapping_id.as_str())?;
267 validate_table_order(&self.functions, |entry| entry.function_id.as_str())?;
268 validate_table_order(&self.locations, |entry| entry.location_id.as_str())?;
269 validate_table_order(&self.stacks, |entry| entry.stack_id.as_str())?;
270 validate_table_order(&self.samples, |entry| entry.sample_id.as_str())?;
271 if self.samples.is_empty() || self.samples.len() > MAX_PROFILE_SAMPLES {
272 return Err(ProfileRecordError::TableBudgetExceeded);
273 }
274 validate_tables(self)?;
275 validate_unique_strings(self)?;
276 let encoded = serde_json::to_vec(self).map_err(|error| ProfileRecordError::Codec(error.to_string()))?;
277 if encoded.len() > MAX_PROFILE_BATCH_BYTES {
278 return Err(ProfileRecordError::BatchBudgetExceeded(encoded.len()));
279 }
280 Ok(())
281 }
282
283 pub fn routing_identity_v1(&self, model: &IndexRecordModel) -> Result<[u8; 32], ProfileRecordError> {
284 self.validate(model)?;
285 let mut hasher = blake3::Hasher::new();
286 hasher.update(b"lodestone/profile-routing/v1\0");
287 hasher.update(&(self.profile_id.len() as u64).to_be_bytes());
288 hasher.update(self.profile_id.as_bytes());
289 Ok(*hasher.finalize().as_bytes())
290 }
291}
292
293#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
294#[serde(deny_unknown_fields)]
295pub struct ProfileQueryableColumnsV1 {
296 pub resource_attributes: Vec<ProfileLabelV1>,
297 pub build_ids: Vec<String>,
298 pub module_ids: Vec<String>,
299 pub function_ids: Vec<String>,
300 pub symbol_observation_ids: Vec<String>,
301 pub trace_ids: Vec<String>,
302 pub span_ids: Vec<String>,
303}
304
305#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
306#[serde(deny_unknown_fields)]
307pub struct CanonicalProfileSourceV1 {
308 pub format_version: u16,
309 pub batch: ProfileBatchV1,
310 pub columns: ProfileQueryableColumnsV1,
311}
312
313pub fn canonical_profile_source_v1(
314 mut batch: ProfileBatchV1,
315 model: &IndexRecordModel,
316) -> Result<(Vec<u8>, [u8; 32]), ProfileRecordError> {
317 let IndexRecordModel::ProfileV1 { privacy, .. } = model else {
318 return Err(ProfileRecordError::RecordModelMismatch);
319 };
320 apply_privacy(&mut batch, privacy)?;
321 let routing = batch.routing_identity_v1(model)?;
322 let source = CanonicalProfileSourceV1 {
323 format_version: PROFILE_SOURCE_FORMAT_VERSION,
324 columns: profile_columns(&batch),
325 batch,
326 };
327 let encoded = serde_json::to_vec(&source).map_err(|error| ProfileRecordError::Codec(error.to_string()))?;
328 if encoded.len() > MAX_PROFILE_BATCH_BYTES {
329 return Err(ProfileRecordError::BatchBudgetExceeded(encoded.len()));
330 }
331 Ok((encoded, routing))
332}
333
334pub fn decode_canonical_profile_source_v1(
335 bytes: &[u8],
336 model: &IndexRecordModel,
337) -> Result<(CanonicalProfileSourceV1, [u8; 32]), ProfileRecordError> {
338 if bytes.len() > MAX_PROFILE_BATCH_BYTES {
339 return Err(ProfileRecordError::BatchBudgetExceeded(bytes.len()));
340 }
341 let source: CanonicalProfileSourceV1 =
342 serde_json::from_slice(bytes).map_err(|error| ProfileRecordError::Codec(error.to_string()))?;
343 if source.format_version != PROFILE_SOURCE_FORMAT_VERSION {
344 return Err(ProfileRecordError::UnsupportedSourceVersion(source.format_version));
345 }
346 let (canonical, routing) = canonical_profile_source_v1(source.batch.clone(), model)?;
347 if canonical != bytes || source.columns != profile_columns(&source.batch) {
348 return Err(ProfileRecordError::NonCanonicalSource);
349 }
350 Ok((source, routing))
351}
352
353pub fn validate_profile_record_model_v1(
354 format_version: u16,
355 privacy: &ProfilePrivacyPolicyV1,
356 mode: IndexMode,
357) -> Result<(), crate::MetricRecordError> {
358 if format_version != PROFILE_RECORD_MODEL_FORMAT_VERSION {
359 return Err(crate::MetricRecordError::UnsupportedRecordModelVersion(format_version));
360 }
361 if mode != IndexMode::AppendLog {
362 return Err(crate::MetricRecordError::ProfileModelRequiresAppendLog);
363 }
364 privacy
365 .validate()
366 .map_err(|_| crate::MetricRecordError::InvalidProfilePrivacyPolicy)
367}
368
369fn validate_tables(batch: &ProfileBatchV1) -> Result<(), ProfileRecordError> {
370 for mapping in &batch.mappings {
371 validate_identifier(&mapping.mapping_id)?;
372 validate_identifier(&mapping.build_id)?;
373 validate_identifier(&mapping.module_id)?;
374 validate_optional_string(mapping.filename.as_deref())?;
375 }
376 for function in &batch.functions {
377 validate_identifier(&function.function_id)?;
378 validate_optional_string(function.name.as_deref())?;
379 validate_optional_string(function.system_name.as_deref())?;
380 validate_optional_string(function.filename.as_deref())?;
381 if let Some(identity) = &function.symbol_observation_id {
382 validate_identifier(identity)?;
383 }
384 }
385 for location in &batch.locations {
386 validate_identifier(&location.location_id)?;
387 if location.lines.len() > MAX_PROFILE_STACK_DEPTH {
388 return Err(ProfileRecordError::StackDepthExceeded);
389 }
390 if let Some(mapping_id) = &location.mapping_id
391 && batch
392 .mappings
393 .binary_search_by(|entry| entry.mapping_id.cmp(mapping_id))
394 .is_err()
395 {
396 return Err(ProfileRecordError::InvalidTableReference);
397 }
398 for line in &location.lines {
399 if batch
400 .functions
401 .binary_search_by(|entry| entry.function_id.cmp(&line.function_id))
402 .is_err()
403 {
404 return Err(ProfileRecordError::InvalidTableReference);
405 }
406 }
407 }
408 for stack in &batch.stacks {
409 validate_identifier(&stack.stack_id)?;
410 if batch
411 .locations
412 .binary_search_by(|entry| entry.location_id.cmp(&stack.location_id))
413 .is_err()
414 {
415 return Err(ProfileRecordError::InvalidTableReference);
416 }
417 if let Some(parent) = &stack.parent_stack_id
418 && batch
419 .stacks
420 .binary_search_by(|entry| entry.stack_id.cmp(parent))
421 .is_err()
422 {
423 return Err(ProfileRecordError::InvalidTableReference);
424 }
425 validate_stack_chain(stack, &batch.stacks)?;
426 }
427 for sample in &batch.samples {
428 validate_identifier(&sample.sample_id)?;
429 if sample.values.len() != batch.sample_types.len()
430 || batch
431 .stacks
432 .binary_search_by(|entry| entry.stack_id.cmp(&sample.stack_id))
433 .is_err()
434 {
435 return Err(ProfileRecordError::InvalidTableReference);
436 }
437 for value in &sample.values {
438 value.validate()?;
439 }
440 validate_labels(&sample.labels)?;
441 if let Some(trace_id) = &sample.trace_id {
442 validate_identifier(trace_id)?;
443 }
444 if let Some(span_id) = &sample.span_id {
445 validate_identifier(span_id)?;
446 }
447 }
448 Ok(())
449}
450
451fn validate_stack_chain(stack: &ProfileStackV1, stacks: &[ProfileStackV1]) -> Result<(), ProfileRecordError> {
452 let mut visited = BTreeSet::new();
453 let mut current = Some(stack.stack_id.as_str());
454 for _ in 0..=MAX_PROFILE_STACK_DEPTH {
455 let Some(stack_id) = current else {
456 return Ok(());
457 };
458 if !visited.insert(stack_id) {
459 return Err(ProfileRecordError::ReferenceCycle);
460 }
461 let position = stacks
462 .binary_search_by(|entry| entry.stack_id.as_str().cmp(stack_id))
463 .map_err(|_| ProfileRecordError::InvalidTableReference)?;
464 current = stacks[position].parent_stack_id.as_deref();
465 }
466 Err(ProfileRecordError::StackDepthExceeded)
467}
468
469fn validate_labels(labels: &[ProfileLabelV1]) -> Result<(), ProfileRecordError> {
470 if labels.len() > MAX_PROFILE_LABELS || labels.windows(2).any(|pair| pair[0].key >= pair[1].key) {
471 return Err(ProfileRecordError::InvalidLabels);
472 }
473 for label in labels {
474 validate_identifier(&label.key)?;
475 validate_string(&label.value)?;
476 }
477 Ok(())
478}
479
480fn validate_table_order<T>(values: &[T], identity: impl Fn(&T) -> &str) -> Result<(), ProfileRecordError> {
481 if values.len() > MAX_PROFILE_TABLE_ENTRIES
482 || values.windows(2).any(|pair| identity(&pair[0]) >= identity(&pair[1]))
483 {
484 return Err(ProfileRecordError::TableBudgetExceeded);
485 }
486 Ok(())
487}
488
489fn validate_unique_strings(batch: &ProfileBatchV1) -> Result<(), ProfileRecordError> {
490 let mut strings = BTreeSet::new();
491 let mut insert = |value: &str| {
492 strings.insert(value.to_owned());
493 if strings.len() > MAX_PROFILE_UNIQUE_STRINGS {
494 Err(ProfileRecordError::UniqueStringBudgetExceeded)
495 } else {
496 Ok(())
497 }
498 };
499 insert(&batch.profile_id)?;
500 insert(&batch.batch_id)?;
501 insert(&batch.instrumentation_scope.name)?;
502 if let Some(version) = &batch.instrumentation_scope.version {
503 insert(version)?;
504 }
505 for sample_type in &batch.sample_types {
506 insert(&sample_type.sample_type)?;
507 insert(&sample_type.unit)?;
508 }
509 for label in batch
510 .resource_attributes
511 .iter()
512 .chain(&batch.instrumentation_scope.attributes)
513 .chain(batch.samples.iter().flat_map(|sample| sample.labels.iter()))
514 {
515 insert(&label.key)?;
516 insert(&label.value)?;
517 }
518 for mapping in &batch.mappings {
519 insert(&mapping.mapping_id)?;
520 insert(&mapping.build_id)?;
521 insert(&mapping.module_id)?;
522 if let Some(filename) = &mapping.filename {
523 insert(filename)?;
524 }
525 }
526 for function in &batch.functions {
527 insert(&function.function_id)?;
528 for value in [
529 function.name.as_deref(),
530 function.system_name.as_deref(),
531 function.filename.as_deref(),
532 function.symbol_observation_id.as_deref(),
533 ]
534 .into_iter()
535 .flatten()
536 {
537 insert(value)?;
538 }
539 }
540 for location in &batch.locations {
541 insert(&location.location_id)?;
542 if let Some(mapping_id) = &location.mapping_id {
543 insert(mapping_id)?;
544 }
545 for line in &location.lines {
546 insert(&line.function_id)?;
547 }
548 }
549 for stack in &batch.stacks {
550 insert(&stack.stack_id)?;
551 insert(&stack.location_id)?;
552 if let Some(parent) = &stack.parent_stack_id {
553 insert(parent)?;
554 }
555 }
556 for sample in &batch.samples {
557 insert(&sample.sample_id)?;
558 insert(&sample.stack_id)?;
559 if let Some(trace_id) = &sample.trace_id {
560 insert(trace_id)?;
561 }
562 if let Some(span_id) = &sample.span_id {
563 insert(span_id)?;
564 }
565 }
566 Ok(())
567}
568
569fn apply_privacy(batch: &mut ProfileBatchV1, policy: &ProfilePrivacyPolicyV1) -> Result<(), ProfileRecordError> {
570 policy.validate()?;
571 for rule in &policy.rules {
572 match &rule.field {
573 ProfilePrivacyFieldV1::ResourceAttribute { key } => {
574 apply_label_rule(&mut batch.resource_attributes, key, rule.action)?;
575 }
576 ProfilePrivacyFieldV1::ScopeAttribute { key } => {
577 apply_label_rule(&mut batch.instrumentation_scope.attributes, key, rule.action)?;
578 }
579 ProfilePrivacyFieldV1::SampleLabel { key } => {
580 for sample in &mut batch.samples {
581 apply_label_rule(&mut sample.labels, key, rule.action)?;
582 }
583 }
584 ProfilePrivacyFieldV1::FunctionName => {
585 for function in &mut batch.functions {
586 apply_optional_rule(&mut function.name, rule.action)?;
587 }
588 }
589 ProfilePrivacyFieldV1::FunctionSystemName => {
590 for function in &mut batch.functions {
591 apply_optional_rule(&mut function.system_name, rule.action)?;
592 }
593 }
594 ProfilePrivacyFieldV1::FunctionFilename => {
595 for function in &mut batch.functions {
596 apply_optional_rule(&mut function.filename, rule.action)?;
597 }
598 }
599 ProfilePrivacyFieldV1::MappingFilename => {
600 for mapping in &mut batch.mappings {
601 apply_optional_rule(&mut mapping.filename, rule.action)?;
602 }
603 }
604 }
605 }
606 Ok(())
607}
608
609fn apply_label_rule(
610 labels: &mut Vec<ProfileLabelV1>,
611 key: &str,
612 action: ProfilePrivacyActionV1,
613) -> Result<(), ProfileRecordError> {
614 let Ok(position) = labels.binary_search_by(|label| label.key.as_str().cmp(key)) else {
615 return Ok(());
616 };
617 match action {
618 ProfilePrivacyActionV1::Allow => {}
619 ProfilePrivacyActionV1::Drop => {
620 labels.remove(position);
621 }
622 ProfilePrivacyActionV1::Redact => labels[position].value = "<redacted>".to_owned(),
623 ProfilePrivacyActionV1::Reject => return Err(ProfileRecordError::PrivacyRejected),
624 }
625 Ok(())
626}
627
628fn apply_optional_rule(value: &mut Option<String>, action: ProfilePrivacyActionV1) -> Result<(), ProfileRecordError> {
629 if value.is_none() {
630 return Ok(());
631 }
632 match action {
633 ProfilePrivacyActionV1::Allow => {}
634 ProfilePrivacyActionV1::Drop => *value = None,
635 ProfilePrivacyActionV1::Redact => *value = Some("<redacted>".to_owned()),
636 ProfilePrivacyActionV1::Reject => return Err(ProfileRecordError::PrivacyRejected),
637 }
638 Ok(())
639}
640
641fn profile_columns(batch: &ProfileBatchV1) -> ProfileQueryableColumnsV1 {
642 ProfileQueryableColumnsV1 {
643 resource_attributes: batch.resource_attributes.clone(),
644 build_ids: sorted_unique(batch.mappings.iter().map(|entry| entry.build_id.clone())),
645 module_ids: sorted_unique(batch.mappings.iter().map(|entry| entry.module_id.clone())),
646 function_ids: sorted_unique(batch.functions.iter().map(|entry| entry.function_id.clone())),
647 symbol_observation_ids: sorted_unique(
648 batch
649 .functions
650 .iter()
651 .filter_map(|entry| entry.symbol_observation_id.clone()),
652 ),
653 trace_ids: sorted_unique(batch.samples.iter().filter_map(|entry| entry.trace_id.clone())),
654 span_ids: sorted_unique(batch.samples.iter().filter_map(|entry| entry.span_id.clone())),
655 }
656}
657
658fn sorted_unique(values: impl Iterator<Item = String>) -> Vec<String> {
659 values.collect::<BTreeSet<_>>().into_iter().collect()
660}
661
662fn validate_identifier(value: &str) -> Result<(), ProfileRecordError> {
663 if value.is_empty()
664 || value.len() > MAX_PROFILE_IDENTIFIER_BYTES
665 || value.bytes().any(|byte| byte == 0 || byte <= 0x1f || byte == 0x7f)
666 {
667 return Err(ProfileRecordError::InvalidIdentifier);
668 }
669 Ok(())
670}
671
672fn validate_optional_string(value: Option<&str>) -> Result<(), ProfileRecordError> {
673 value.map_or(Ok(()), validate_string)
674}
675
676fn validate_string(value: &str) -> Result<(), ProfileRecordError> {
677 if value.len() > MAX_PROFILE_STRING_BYTES || value.bytes().any(|byte| byte == 0) {
678 return Err(ProfileRecordError::StringBudgetExceeded);
679 }
680 Ok(())
681}
682
683fn hash_named(hasher: &mut blake3::Hasher, kind: &[u8], value: &str) {
684 hasher.update(kind);
685 hasher.update(&[0]);
686 hasher.update(&(value.len() as u64).to_be_bytes());
687 hasher.update(value.as_bytes());
688}
689
690#[derive(Clone, Debug, Eq, PartialEq)]
691pub enum ProfileRecordError {
692 UnsupportedRecordModelVersion(u16),
693 UnsupportedPrivacyPolicyVersion(u16),
694 UnsupportedBatchVersion(u16),
695 UnsupportedSourceVersion(u16),
696 RecordModelMismatch,
697 InvalidPrivacyPolicy,
698 PrivacyRejected,
699 InvalidIdentifier,
700 StringBudgetExceeded,
701 InvalidTimeRange,
702 InvalidSampleTypes,
703 InvalidLabels,
704 InvalidTableReference,
705 ReferenceCycle,
706 StackDepthExceeded,
707 NonFiniteValue,
708 TableBudgetExceeded,
709 UniqueStringBudgetExceeded,
710 BatchBudgetExceeded(usize),
711 NonCanonicalSource,
712 Codec(String),
713}
714
715impl Display for ProfileRecordError {
716 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
717 write!(formatter, "profile record: {self:?}")
718 }
719}
720
721impl Error for ProfileRecordError {}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726
727 fn model() -> IndexRecordModel {
728 IndexRecordModel::ProfileV1 {
729 format_version: PROFILE_RECORD_MODEL_FORMAT_VERSION,
730 privacy: ProfilePrivacyPolicyV1 {
731 format_version: PROFILE_PRIVACY_POLICY_FORMAT_VERSION,
732 rules: vec![ProfilePrivacyRuleV1 {
733 field: ProfilePrivacyFieldV1::ResourceAttribute {
734 key: "secret".to_owned(),
735 },
736 action: ProfilePrivacyActionV1::Redact,
737 }],
738 },
739 }
740 }
741
742 fn batch() -> ProfileBatchV1 {
743 ProfileBatchV1 {
744 format_version: PROFILE_BATCH_FORMAT_VERSION,
745 profile_id: "cpu-main".to_owned(),
746 batch_id: "batch-1".to_owned(),
747 start_unix_nanos: 10,
748 end_unix_nanos: 20,
749 duration_nanos: 10,
750 period_nanos: 1,
751 sample_types: vec![ProfileSampleTypeV1 {
752 sample_type: "cpu".to_owned(),
753 unit: "nanoseconds".to_owned(),
754 }],
755 resource_attributes: vec![ProfileLabelV1 {
756 key: "secret".to_owned(),
757 value: "private".to_owned(),
758 }],
759 instrumentation_scope: ProfileInstrumentationScopeV1 {
760 name: "profiler".to_owned(),
761 version: Some("1".to_owned()),
762 attributes: Vec::new(),
763 },
764 mappings: vec![ProfileMappingV1 {
765 mapping_id: "map-1".to_owned(),
766 build_id: "build-1".to_owned(),
767 module_id: "module-1".to_owned(),
768 filename: Some("service".to_owned()),
769 }],
770 functions: vec![ProfileFunctionV1 {
771 function_id: "fn-1".to_owned(),
772 name: Some("main".to_owned()),
773 system_name: None,
774 filename: None,
775 start_line: Some(1),
776 symbol_observation_id: Some("symbol-1".to_owned()),
777 }],
778 locations: vec![ProfileLocationV1 {
779 location_id: "loc-1".to_owned(),
780 mapping_id: Some("map-1".to_owned()),
781 address: 1,
782 lines: vec![ProfileLineV1 {
783 function_id: "fn-1".to_owned(),
784 line: 1,
785 }],
786 }],
787 stacks: vec![ProfileStackV1 {
788 stack_id: "stack-1".to_owned(),
789 parent_stack_id: None,
790 location_id: "loc-1".to_owned(),
791 }],
792 samples: vec![ProfileSampleV1 {
793 sample_id: "sample-1".to_owned(),
794 stack_id: "stack-1".to_owned(),
795 values: vec![ProfileNumberV1::I64 { value: 7 }],
796 labels: Vec::new(),
797 trace_id: Some("trace-1".to_owned()),
798 span_id: Some("span-1".to_owned()),
799 }],
800 }
801 }
802
803 #[test]
804 fn privacy_is_applied_before_canonical_source_and_columns() -> Result<(), Box<dyn Error>> {
805 let (bytes, routing) = canonical_profile_source_v1(batch(), &model())?;
806 let (decoded, decoded_routing) = decode_canonical_profile_source_v1(&bytes, &model())?;
807 assert_eq!(decoded.batch.resource_attributes[0].value, "<redacted>");
808 assert_eq!(decoded.columns.build_ids, vec!["build-1"]);
809 assert_eq!(decoded.columns.trace_ids, vec!["trace-1"]);
810 assert_eq!(routing, decoded_routing);
811 let mut next_batch = batch();
812 next_batch.batch_id = "batch-2".to_owned();
813 assert_eq!(canonical_profile_source_v1(next_batch, &model())?.1, routing);
814 let mut next_profile = batch();
815 next_profile.profile_id = "cpu-secondary".to_owned();
816 assert_ne!(canonical_profile_source_v1(next_profile, &model())?.1, routing);
817 Ok(())
818 }
819
820 #[test]
821 fn malformed_references_cycles_and_nonfinite_values_refuse() {
822 let mut missing = batch();
823 missing.samples[0].stack_id = "missing".to_owned();
824 assert_eq!(
825 missing.validate(&model()),
826 Err(ProfileRecordError::InvalidTableReference)
827 );
828
829 let mut cyclic = batch();
830 cyclic.stacks[0].parent_stack_id = Some("stack-1".to_owned());
831 assert_eq!(cyclic.validate(&model()), Err(ProfileRecordError::ReferenceCycle));
832
833 let mut nonfinite = batch();
834 nonfinite.samples[0].values[0] = ProfileNumberV1::F64Bits {
835 value: f64::NAN.to_bits(),
836 };
837 assert_eq!(nonfinite.validate(&model()), Err(ProfileRecordError::NonFiniteValue));
838 }
839
840 #[test]
841 fn reject_privacy_rule_prevents_canonicalization() {
842 let mut rejecting = model();
843 if let IndexRecordModel::ProfileV1 { privacy, .. } = &mut rejecting {
844 privacy.rules[0].action = ProfilePrivacyActionV1::Reject;
845 }
846 assert_eq!(
847 canonical_profile_source_v1(batch(), &rejecting).map(|_| ()),
848 Err(ProfileRecordError::PrivacyRejected)
849 );
850
851 let mut ambiguous = model();
852 if let IndexRecordModel::ProfileV1 { privacy, .. } = &mut ambiguous {
853 privacy.rules.push(ProfilePrivacyRuleV1 {
854 field: ProfilePrivacyFieldV1::ResourceAttribute {
855 key: "secret".to_owned(),
856 },
857 action: ProfilePrivacyActionV1::Reject,
858 });
859 }
860 assert_eq!(
861 ambiguous.validate(IndexMode::AppendLog),
862 Err(crate::MetricRecordError::InvalidProfilePrivacyPolicy)
863 );
864 }
865}