1use std::collections::BTreeMap;
4
5use graphforge_core::{GfError, OntologyMode, ProjectErrorCode};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9use uuid::Uuid;
10
11use crate::{ProjectParticipant, ProjectParticipantEncoding};
12
13pub const WORKSPACE_CAPABILITY_ID: &str = "workspace";
15pub const WORKSPACE_CAPABILITY_VERSION: u32 = 1;
17pub const WORKSPACE_ONTOLOGY_FAMILY: &str = "ontology";
19pub const WORKSPACE_CONFIGURATION_FAMILY: &str = "configuration";
21pub const WORKSPACE_REPOSITORY_SNAPSHOT_FAMILY: &str = "repository_snapshot";
23pub const WORKSPACE_REPOSITORY_SNAPSHOT_VERSION: u32 = 1;
25pub const MAX_WORKSPACE_REPOSITORY_SNAPSHOT_BYTES: usize = 1024 * 1024;
27pub const MAX_WORKSPACE_REPOSITORY_SNAPSHOT_ENTRIES: usize = 10_000;
29pub const MAX_WORKSPACE_REPOSITORY_SNAPSHOT_ID_BYTES: usize = 256;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum WorkspaceOntologyMode {
36 None,
38 Advisory,
40 Strict,
42}
43
44impl WorkspaceOntologyMode {
45 #[must_use]
47 pub const fn execution_mode(self) -> OntologyMode {
48 match self {
49 Self::None => OntologyMode::Exploratory,
50 Self::Advisory => OntologyMode::Advisory,
51 Self::Strict => OntologyMode::Strict,
52 }
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum WorkspaceOntologySourceFormat {
60 Yaml,
62 Json,
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct WorkspaceOntology {
70 pub contract_version: u32,
72 pub mode: WorkspaceOntologyMode,
74 pub source_format: Option<WorkspaceOntologySourceFormat>,
76 pub canonical_ontology_sha256: Option<String>,
78 pub canonical_ontology: Option<Value>,
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct WorkspaceConfiguration {
86 pub contract_version: u32,
88 pub ontology_mode: WorkspaceOntologyMode,
90 pub capability_configuration: BTreeMap<String, Value>,
92 pub embedding_configuration: BTreeMap<String, Value>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct WorkspaceRepositoryDefinitionDigest {
100 pub definition_id: String,
102 pub sha256: String,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct WorkspaceRepositorySourceDigest {
110 pub source_id: String,
112 pub sha256: String,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct WorkspaceRepositoryGitProvenance {
120 pub commit_sha: Option<String>,
122 pub dirty: bool,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct WorkspaceRepositorySnapshot {
130 pub contract_version: u32,
132 pub resolved_config_sha256: String,
134 pub definitions: Vec<WorkspaceRepositoryDefinitionDigest>,
136 pub sources: Vec<WorkspaceRepositorySourceDigest>,
138 pub git: WorkspaceRepositoryGitProvenance,
140 pub operation_uuid: Uuid,
142 pub actor_uuid: Option<Uuid>,
144}
145
146impl WorkspaceOntology {
147 #[must_use]
149 pub const fn none() -> Self {
150 Self {
151 contract_version: 1,
152 mode: WorkspaceOntologyMode::None,
153 source_format: None,
154 canonical_ontology_sha256: None,
155 canonical_ontology: None,
156 }
157 }
158
159 pub fn to_canonical_json(&self) -> Result<Vec<u8>, GfError> {
164 validate_ontology(self)?;
165 canonical_json(self, "workspace ontology")
166 }
167
168 pub fn from_canonical_json(bytes: &[u8]) -> Result<Self, GfError> {
173 parse_canonical_json(bytes, "workspace ontology", validate_ontology)
174 }
175}
176
177impl WorkspaceConfiguration {
178 #[must_use]
180 pub fn empty() -> Self {
181 Self {
182 contract_version: 1,
183 ontology_mode: WorkspaceOntologyMode::None,
184 capability_configuration: BTreeMap::new(),
185 embedding_configuration: BTreeMap::new(),
186 }
187 }
188
189 pub fn to_canonical_json(&self) -> Result<Vec<u8>, GfError> {
194 validate_configuration(self)?;
195 canonical_json(self, "workspace configuration")
196 }
197
198 pub fn from_canonical_json(bytes: &[u8]) -> Result<Self, GfError> {
203 parse_canonical_json(bytes, "workspace configuration", validate_configuration)
204 }
205}
206
207impl WorkspaceRepositorySnapshot {
208 pub fn to_canonical_json(&self) -> Result<Vec<u8>, GfError> {
214 validate_repository_snapshot(self)?;
215 let bytes = canonical_json(self, "workspace repository snapshot")?;
216 if bytes.len() > MAX_WORKSPACE_REPOSITORY_SNAPSHOT_BYTES {
217 return Err(corrupt("workspace repository snapshot exceeds size limit"));
218 }
219 Ok(bytes)
220 }
221
222 pub fn from_canonical_json(bytes: &[u8]) -> Result<Self, GfError> {
228 if bytes.len() > MAX_WORKSPACE_REPOSITORY_SNAPSHOT_BYTES {
229 return Err(corrupt("workspace repository snapshot exceeds size limit"));
230 }
231 parse_canonical_json(
232 bytes,
233 "workspace repository snapshot",
234 validate_repository_snapshot,
235 )
236 }
237
238 pub fn to_project_participant(&self) -> Result<ProjectParticipant, GfError> {
243 Ok(participant(
244 WORKSPACE_REPOSITORY_SNAPSHOT_FAMILY,
245 self.to_canonical_json()?,
246 ))
247 }
248}
249
250pub fn empty_workspace_participants() -> Result<Vec<ProjectParticipant>, GfError> {
255 Ok(vec![
256 participant(
257 WORKSPACE_CONFIGURATION_FAMILY,
258 WorkspaceConfiguration::empty().to_canonical_json()?,
259 ),
260 participant(
261 WORKSPACE_ONTOLOGY_FAMILY,
262 WorkspaceOntology::none().to_canonical_json()?,
263 ),
264 ])
265}
266
267fn participant(family: &str, bytes: Vec<u8>) -> ProjectParticipant {
268 ProjectParticipant {
269 capability_id: WORKSPACE_CAPABILITY_ID.into(),
270 capability_version: WORKSPACE_CAPABILITY_VERSION,
271 record_family_id: family.into(),
272 record_version: 1,
273 encoding: ProjectParticipantEncoding::Json,
274 schema_fingerprint: Sha256::digest(format!("workspace/{family}@1")).into(),
275 row_count: 1,
276 bytes,
277 }
278}
279
280fn validate_ontology(record: &WorkspaceOntology) -> Result<(), GfError> {
281 if record.contract_version != 1 {
282 return Err(corrupt("unsupported workspace ontology contract"));
283 }
284 match record.mode {
285 WorkspaceOntologyMode::None
286 if record.source_format.is_none()
287 && record.canonical_ontology_sha256.is_none()
288 && record.canonical_ontology.is_none() =>
289 {
290 Ok(())
291 }
292 WorkspaceOntologyMode::Advisory | WorkspaceOntologyMode::Strict => {
293 let document = record
294 .canonical_ontology
295 .as_ref()
296 .ok_or_else(|| corrupt("adopted ontology document is missing"))?;
297 if record.source_format.is_none() {
298 return Err(corrupt("adopted ontology source format is missing"));
299 }
300 let canonical = serde_json::to_vec(document)
301 .map_err(|_| corrupt("ontology document cannot be encoded"))?;
302 let digest = encode_hex(&Sha256::digest(canonical));
303 if record.canonical_ontology_sha256.as_deref() != Some(digest.as_str()) {
304 return Err(corrupt("canonical ontology digest does not match"));
305 }
306 Ok(())
307 }
308 WorkspaceOntologyMode::None => Err(corrupt("workspace ontology absence is inconsistent")),
309 }
310}
311
312fn validate_configuration(record: &WorkspaceConfiguration) -> Result<(), GfError> {
313 if record.contract_version != 1 {
314 return Err(corrupt("unsupported workspace configuration contract"));
315 }
316 Ok(())
317}
318
319fn validate_repository_snapshot(record: &WorkspaceRepositorySnapshot) -> Result<(), GfError> {
320 if record.contract_version != WORKSPACE_REPOSITORY_SNAPSHOT_VERSION {
321 return Err(corrupt(
322 "unsupported workspace repository snapshot contract",
323 ));
324 }
325 validate_sha256(&record.resolved_config_sha256, "resolved config")?;
326 validate_digest_entries(
327 &record.definitions,
328 |entry| (&entry.definition_id, &entry.sha256),
329 "definition",
330 )?;
331 validate_digest_entries(
332 &record.sources,
333 |entry| (&entry.source_id, &entry.sha256),
334 "source",
335 )?;
336 if let Some(commit_sha) = &record.git.commit_sha
337 && (!matches!(commit_sha.len(), 40 | 64)
338 || !commit_sha.bytes().all(|byte| byte.is_ascii_hexdigit())
339 || commit_sha.bytes().any(|byte| byte.is_ascii_uppercase()))
340 {
341 return Err(corrupt("Git commit SHA is not canonical hexadecimal"));
342 }
343 if record.operation_uuid.is_nil() {
344 return Err(corrupt("repository snapshot operation UUID is nil"));
345 }
346 if record.actor_uuid.is_some_and(|actor| actor.is_nil()) {
347 return Err(corrupt("repository snapshot actor UUID is nil"));
348 }
349 Ok(())
350}
351
352fn validate_digest_entries<T>(
353 entries: &[T],
354 fields: impl for<'a> Fn(&'a T) -> (&'a String, &'a String),
355 name: &str,
356) -> Result<(), GfError> {
357 if entries.len() > MAX_WORKSPACE_REPOSITORY_SNAPSHOT_ENTRIES {
358 return Err(corrupt(format!(
359 "workspace repository {name} count exceeds limit"
360 )));
361 }
362 let mut prior: Option<&str> = None;
363 for entry in entries {
364 let (id, digest) = fields(entry);
365 let canonical_id = !id.is_empty()
366 && id.len() <= MAX_WORKSPACE_REPOSITORY_SNAPSHOT_ID_BYTES
367 && id.bytes().enumerate().all(|(index, byte)| {
368 byte.is_ascii_lowercase()
369 || index > 0 && byte.is_ascii_digit()
370 || index > 0 && matches!(byte, b'-' | b'_')
371 });
372 if !canonical_id {
373 return Err(corrupt(format!(
374 "workspace repository {name} identifier is invalid"
375 )));
376 }
377 if prior.is_some_and(|prior| prior >= id.as_str()) {
378 return Err(corrupt(format!(
379 "workspace repository {name} identifiers are not canonical"
380 )));
381 }
382 validate_sha256(digest, name)?;
383 prior = Some(id);
384 }
385 Ok(())
386}
387
388fn validate_sha256(value: &str, name: &str) -> Result<(), GfError> {
389 if value.len() != 64
390 || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
391 || value.bytes().any(|byte| byte.is_ascii_uppercase())
392 {
393 return Err(corrupt(format!("{name} SHA-256 is not canonical")));
394 }
395 Ok(())
396}
397
398fn canonical_json(value: &impl Serialize, name: &str) -> Result<Vec<u8>, GfError> {
399 let mut bytes =
400 serde_json::to_vec(value).map_err(|_| corrupt(format!("{name} cannot be encoded")))?;
401 bytes.push(b'\n');
402 Ok(bytes)
403}
404
405fn parse_canonical_json<T>(
406 bytes: &[u8],
407 name: &str,
408 validate: impl FnOnce(&T) -> Result<(), GfError>,
409) -> Result<T, GfError>
410where
411 T: for<'de> Deserialize<'de> + Serialize,
412{
413 if !bytes.ends_with(b"\n") || bytes[..bytes.len().saturating_sub(1)].contains(&b'\n') {
414 return Err(corrupt(format!("{name} is not canonical JSON plus LF")));
415 }
416 let parsed =
417 serde_json::from_slice(bytes).map_err(|_| corrupt(format!("{name} is malformed")))?;
418 validate(&parsed)?;
419 if canonical_json(&parsed, name)? != bytes {
420 return Err(corrupt(format!("{name} is not canonically encoded")));
421 }
422 Ok(parsed)
423}
424
425fn corrupt(message: impl Into<String>) -> GfError {
426 GfError::Project {
427 code: ProjectErrorCode::ProjectCorrupt,
428 message: message.into(),
429 }
430}
431
432fn encode_hex(bytes: &[u8]) -> String {
433 const HEX: &[u8; 16] = b"0123456789abcdef";
434 let mut output = String::with_capacity(bytes.len() * 2);
435 for byte in bytes {
436 output.push(char::from(HEX[usize::from(byte >> 4)]));
437 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
438 }
439 output
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 fn repository_snapshot() -> WorkspaceRepositorySnapshot {
447 WorkspaceRepositorySnapshot {
448 contract_version: WORKSPACE_REPOSITORY_SNAPSHOT_VERSION,
449 resolved_config_sha256: "11".repeat(32),
450 definitions: vec![
451 WorkspaceRepositoryDefinitionDigest {
452 definition_id: "migrations".into(),
453 sha256: "22".repeat(32),
454 },
455 WorkspaceRepositoryDefinitionDigest {
456 definition_id: "ontology".into(),
457 sha256: "33".repeat(32),
458 },
459 ],
460 sources: vec![WorkspaceRepositorySourceDigest {
461 source_id: "customers".into(),
462 sha256: "44".repeat(32),
463 }],
464 git: WorkspaceRepositoryGitProvenance {
465 commit_sha: Some("a1".repeat(20)),
466 dirty: true,
467 },
468 operation_uuid: Uuid::from_bytes([5; 16]),
469 actor_uuid: Some(Uuid::from_bytes([6; 16])),
470 }
471 }
472
473 #[test]
474 fn empty_records_round_trip_canonically() {
475 let ontology = WorkspaceOntology::none();
476 let ontology_bytes = ontology.to_canonical_json().unwrap();
477 assert_eq!(
478 WorkspaceOntology::from_canonical_json(&ontology_bytes).unwrap(),
479 ontology
480 );
481
482 let configuration = WorkspaceConfiguration::empty();
483 let configuration_bytes = configuration.to_canonical_json().unwrap();
484 assert_eq!(
485 WorkspaceConfiguration::from_canonical_json(&configuration_bytes).unwrap(),
486 configuration
487 );
488 }
489
490 #[test]
491 fn future_and_noncanonical_records_fail_closed() {
492 let mut future = WorkspaceOntology::none();
493 future.contract_version = 2;
494 assert_eq!(
495 future.to_canonical_json().unwrap_err().code(),
496 "GF_PROJECT_CORRUPT"
497 );
498
499 let bytes = br#"{"mode":"none","contract_version":1,"source_format":null,"canonical_ontology_sha256":null,"canonical_ontology":null}
500"#;
501 assert_eq!(
502 WorkspaceOntology::from_canonical_json(bytes)
503 .unwrap_err()
504 .code(),
505 "GF_PROJECT_CORRUPT"
506 );
507 }
508
509 #[test]
510 fn adopted_ontology_requires_matching_digest() {
511 let record = WorkspaceOntology {
512 contract_version: 1,
513 mode: WorkspaceOntologyMode::Strict,
514 source_format: Some(WorkspaceOntologySourceFormat::Json),
515 canonical_ontology_sha256: Some("0".repeat(64)),
516 canonical_ontology: Some(serde_json::json!({"ontology_id": "x"})),
517 };
518 assert_eq!(
519 record.to_canonical_json().unwrap_err().code(),
520 "GF_PROJECT_CORRUPT"
521 );
522 }
523
524 #[test]
525 fn repository_snapshot_round_trips_canonically_as_registered_participant() {
526 let snapshot = repository_snapshot();
527 let bytes = snapshot.to_canonical_json().unwrap();
528 assert_eq!(
529 WorkspaceRepositorySnapshot::from_canonical_json(&bytes).unwrap(),
530 snapshot
531 );
532 assert_eq!(bytes.last(), Some(&b'\n'));
533 assert!(!bytes.windows(2).any(|window| window == b"\n\n"));
534 assert!(!String::from_utf8_lossy(&bytes).contains("/Users/"));
535
536 let participant = snapshot.to_project_participant().unwrap();
537 assert_eq!(participant.capability_id, WORKSPACE_CAPABILITY_ID);
538 assert_eq!(
539 participant.record_family_id,
540 WORKSPACE_REPOSITORY_SNAPSHOT_FAMILY
541 );
542 let expected_fingerprint: [u8; 32] =
543 Sha256::digest("workspace/repository_snapshot@1").into();
544 assert_eq!(participant.schema_fingerprint, expected_fingerprint);
545 assert_eq!(participant.bytes, bytes);
546 }
547
548 #[test]
549 fn repository_snapshot_rejects_future_noncanonical_and_unbounded_records() {
550 let mut future = repository_snapshot();
551 future.contract_version += 1;
552 assert_eq!(
553 future.to_canonical_json().unwrap_err().code(),
554 "GF_PROJECT_CORRUPT"
555 );
556
557 let mut unordered = repository_snapshot();
558 unordered.definitions.reverse();
559 assert_eq!(
560 unordered.to_canonical_json().unwrap_err().code(),
561 "GF_PROJECT_CORRUPT"
562 );
563
564 let mut duplicate = repository_snapshot();
565 duplicate.sources.push(duplicate.sources[0].clone());
566 assert_eq!(
567 duplicate.to_canonical_json().unwrap_err().code(),
568 "GF_PROJECT_CORRUPT"
569 );
570
571 let mut oversized_id = repository_snapshot();
572 oversized_id.sources[0].source_id =
573 "x".repeat(MAX_WORKSPACE_REPOSITORY_SNAPSHOT_ID_BYTES + 1);
574 assert_eq!(
575 oversized_id.to_canonical_json().unwrap_err().code(),
576 "GF_PROJECT_CORRUPT"
577 );
578
579 let mut too_many = repository_snapshot();
580 too_many.definitions = (0..=MAX_WORKSPACE_REPOSITORY_SNAPSHOT_ENTRIES)
581 .map(|index| WorkspaceRepositoryDefinitionDigest {
582 definition_id: format!("{index:05}"),
583 sha256: "55".repeat(32),
584 })
585 .collect();
586 assert_eq!(
587 too_many.to_canonical_json().unwrap_err().code(),
588 "GF_PROJECT_CORRUPT"
589 );
590
591 let mut absolute_id = repository_snapshot();
592 absolute_id.sources[0].source_id = "/private/data/customers".into();
593 assert_eq!(
594 absolute_id.to_canonical_json().unwrap_err().code(),
595 "GF_PROJECT_CORRUPT"
596 );
597 for hostile in [
598 "../customers",
599 "file:///private/customers",
600 "customers/data",
601 "customers\\data",
602 ".hidden",
603 "Customers",
604 "9customers",
605 ] {
606 let mut hostile_id = repository_snapshot();
607 hostile_id.sources[0].source_id = hostile.into();
608 assert_eq!(
609 hostile_id.to_canonical_json().unwrap_err().code(),
610 "GF_PROJECT_CORRUPT",
611 "{hostile}"
612 );
613 }
614
615 let oversized = vec![b' '; MAX_WORKSPACE_REPOSITORY_SNAPSHOT_BYTES + 1];
616 assert_eq!(
617 WorkspaceRepositorySnapshot::from_canonical_json(&oversized)
618 .unwrap_err()
619 .code(),
620 "GF_PROJECT_CORRUPT"
621 );
622
623 let with_unrestricted_field = String::from_utf8(
624 repository_snapshot()
625 .to_canonical_json()
626 .unwrap()
627 .into_iter()
628 .collect(),
629 )
630 .unwrap()
631 .replacen(
632 "\"actor_uuid\"",
633 "\"absolute_path\":\"/secret/data\",\"actor_uuid\"",
634 1,
635 );
636 assert_eq!(
637 WorkspaceRepositorySnapshot::from_canonical_json(with_unrestricted_field.as_bytes())
638 .unwrap_err()
639 .code(),
640 "GF_PROJECT_CORRUPT"
641 );
642 }
643}