1use std::collections::BTreeSet;
9use std::io::{Read, Write};
10use std::path::Path;
11
12use atomicwrites::{AtomicFile, DisallowOverwrite};
13use graphforge_core::{GfError, ProjectErrorCode};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use uuid::Uuid;
17
18use crate::{
19 ProjectCapability, ProjectGenerationRequest, ProjectParticipant, ProjectParticipantEncoding,
20 ProjectPublicationReceipt, ProjectStageOutcome, ResolvedProjectGeneration,
21 open_or_initialize_project, resolve_project_generation, stage_project_generation,
22};
23
24const MAGIC: &[u8; 16] = b"graphforge-exp\0\n";
25const FORMAT: &str = "graphforge-portable-export";
26const FORMAT_VERSION: u32 = 1;
27const HEADER_LENGTH_BYTES: usize = 8;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct PortableProjectLimits {
32 pub max_envelope_bytes: u64,
34 pub max_header_bytes: u64,
36 pub max_participants: usize,
38 pub max_participant_bytes: u64,
40}
41
42impl Default for PortableProjectLimits {
43 fn default() -> Self {
44 Self {
45 max_envelope_bytes: 16 * 1024 * 1024 * 1024,
46 max_header_bytes: 4 * 1024 * 1024,
47 max_participants: 100_000,
48 max_participant_bytes: 8 * 1024 * 1024 * 1024,
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct PortableExportReceipt {
56 pub generation_uuid: Uuid,
58 pub envelope_sha256: [u8; 32],
60 pub byte_length: u64,
62 pub participant_count: usize,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct PortableImportReceipt {
69 pub envelope_sha256: [u8; 32],
71 pub source_generation_uuid: Uuid,
73 pub publication: ProjectPublicationReceipt,
75}
76
77#[derive(Debug, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79struct EnvelopeHeader {
80 format: String,
81 format_version: u32,
82 source_generation_uuid: String,
83 capabilities: Vec<EnvelopeCapability>,
84 participants: Vec<EnvelopeParticipant>,
85}
86
87#[derive(Debug, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89struct EnvelopeCapability {
90 capability_id: String,
91 capability_version: u32,
92}
93
94#[derive(Debug, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96struct EnvelopeParticipant {
97 capability_id: String,
98 capability_version: u32,
99 record_family_id: String,
100 record_version: u32,
101 encoding: String,
102 schema_fingerprint: String,
103 row_count: u64,
104 byte_length: u64,
105 content_sha256: String,
106}
107
108struct ValidatedEnvelope {
109 source_generation_uuid: Uuid,
110 envelope_sha256: [u8; 32],
111 capabilities: Vec<ProjectCapability>,
112 participants: Vec<ProjectParticipant>,
113}
114
115pub fn encode_portable_project(
121 generation: &ResolvedProjectGeneration,
122 limits: PortableProjectLimits,
123) -> Result<(Vec<u8>, PortableExportReceipt), GfError> {
124 let snapshots = generation.participant_snapshots()?;
125 enforce_count(snapshots.len(), limits.max_participants)?;
126 let capabilities = generation
127 .capabilities()
128 .into_iter()
129 .map(|item| EnvelopeCapability {
130 capability_id: item.capability_id,
131 capability_version: item.capability_version,
132 })
133 .collect();
134 let mut participants = Vec::with_capacity(snapshots.len());
135 let mut body_length = 0_u64;
136 for snapshot in &snapshots {
137 let byte_length = u64::try_from(snapshot.bytes.len())
138 .map_err(|_| resource("portable participant byte length exceeds u64"))?;
139 enforce_size(
140 byte_length,
141 limits.max_participant_bytes,
142 "portable participant",
143 )?;
144 body_length = body_length
145 .checked_add(byte_length)
146 .ok_or_else(|| resource("portable envelope size overflow"))?;
147 participants.push(EnvelopeParticipant {
148 capability_id: snapshot.capability_id.clone(),
149 capability_version: snapshot.capability_version,
150 record_family_id: snapshot.record_family_id.clone(),
151 record_version: snapshot.record_version,
152 encoding: snapshot.encoding.clone(),
153 schema_fingerprint: hex(snapshot.schema_fingerprint),
154 row_count: snapshot.row_count,
155 byte_length,
156 content_sha256: hex(Sha256::digest(&snapshot.bytes).into()),
157 });
158 }
159 let header = EnvelopeHeader {
160 format: FORMAT.into(),
161 format_version: FORMAT_VERSION,
162 source_generation_uuid: generation.generation_uuid().hyphenated().to_string(),
163 capabilities,
164 participants,
165 };
166 let mut header_bytes = serde_json::to_vec(&header)
167 .map_err(|error| GfError::Storage(format!("failed to encode portable header: {error}")))?;
168 header_bytes.push(b'\n');
169 let header_length = u64::try_from(header_bytes.len())
170 .map_err(|_| resource("portable header byte length exceeds u64"))?;
171 enforce_size(header_length, limits.max_header_bytes, "portable header")?;
172 let total = u64::try_from(MAGIC.len() + HEADER_LENGTH_BYTES)
173 .expect("fixed prefix fits u64")
174 .checked_add(header_length)
175 .and_then(|value| value.checked_add(body_length))
176 .ok_or_else(|| resource("portable envelope size overflow"))?;
177 enforce_size(total, limits.max_envelope_bytes, "portable envelope")?;
178 let capacity = usize::try_from(total)
179 .map_err(|_| resource("portable envelope does not fit address space"))?;
180 let mut envelope = Vec::with_capacity(capacity);
181 envelope.extend_from_slice(MAGIC);
182 envelope.extend_from_slice(&header_length.to_be_bytes());
183 envelope.extend_from_slice(&header_bytes);
184 for snapshot in snapshots {
185 envelope.extend_from_slice(&snapshot.bytes);
186 }
187 let envelope_sha256 = Sha256::digest(&envelope).into();
188 Ok((
189 envelope,
190 PortableExportReceipt {
191 generation_uuid: generation.generation_uuid(),
192 envelope_sha256,
193 byte_length: total,
194 participant_count: header.participants.len(),
195 },
196 ))
197}
198
199pub fn export_portable_project(
201 generation: &ResolvedProjectGeneration,
202 destination: impl AsRef<Path>,
203 limits: PortableProjectLimits,
204) -> Result<PortableExportReceipt, GfError> {
205 let destination = destination.as_ref();
206 reject_export_destination(destination)?;
207 let (bytes, receipt) = encode_portable_project(generation, limits)?;
208 AtomicFile::new(destination, DisallowOverwrite)
209 .write(|file| {
210 file.write_all(&bytes)?;
211 file.sync_all()
212 })
213 .map_err(|error| GfError::Storage(format!("failed to write portable export: {error}")))?;
214 Ok(receipt)
215}
216
217pub fn import_portable_project(
224 envelope: &[u8],
225 target: impl AsRef<Path>,
226 transaction_uuid: Uuid,
227 generation_uuid: Uuid,
228 supported_capabilities: &[ProjectCapability],
229 limits: PortableProjectLimits,
230) -> Result<PortableImportReceipt, GfError> {
231 let validated = validate_envelope(envelope, supported_capabilities, limits)?;
232 let target = target.as_ref();
233 let existing_parent = prepare_import_target(target)?;
234 let initialized_parent;
235 let _parent = if let Some(parent) = existing_parent {
236 parent
237 } else {
238 initialized_parent = open_or_initialize_project(target)?;
239 initialized_parent
240 };
241 let request = ProjectGenerationRequest {
242 transaction_uuid,
243 generation_uuid,
244 capabilities: validated.capabilities,
245 participants: validated.participants,
246 };
247 let publication = match stage_project_generation(target, &request)? {
248 ProjectStageOutcome::AlreadyPublished(receipt) => receipt,
249 ProjectStageOutcome::Staged(staged) => {
250 staged.validate(|_| Ok(()), |_, _| Ok(()))?.publish()?
251 }
252 };
253 Ok(PortableImportReceipt {
254 envelope_sha256: validated.envelope_sha256,
255 source_generation_uuid: validated.source_generation_uuid,
256 publication,
257 })
258}
259
260pub fn import_portable_project_file(
266 source: impl AsRef<Path>,
267 target: impl AsRef<Path>,
268 transaction_uuid: Uuid,
269 generation_uuid: Uuid,
270 supported_capabilities: &[ProjectCapability],
271 limits: PortableProjectLimits,
272) -> Result<PortableImportReceipt, GfError> {
273 let source = source.as_ref();
274 reject_symlink_components(source, "portable import source")?;
275 let metadata = std::fs::symlink_metadata(source).map_err(|error| {
276 GfError::Storage(format!("failed to inspect portable import source: {error}"))
277 })?;
278 if metadata.file_type().is_symlink() || !metadata.is_file() {
279 return Err(project_error(
280 ProjectErrorCode::UnsupportedFilesystem,
281 "portable import source is linked or not a regular file",
282 ));
283 }
284 enforce_size(
285 metadata.len(),
286 limits.max_envelope_bytes,
287 "portable envelope",
288 )?;
289 let bounded_capacity = usize::try_from(metadata.len())
290 .map_err(|_| resource("portable envelope does not fit address space"))?;
291 let mut bytes = Vec::with_capacity(bounded_capacity);
292 let mut file = open_regular_nofollow(source).map_err(|error| {
293 GfError::Storage(format!("failed to open portable import source: {error}"))
294 })?;
295 let opened_metadata = file.metadata().map_err(|error| {
296 GfError::Storage(format!(
297 "failed to inspect opened portable import source: {error}"
298 ))
299 })?;
300 if !opened_metadata.is_file() || !same_file_identity(&metadata, &opened_metadata) {
301 return Err(project_error(
302 ProjectErrorCode::UnsupportedFilesystem,
303 "portable import source changed while it was being opened",
304 ));
305 }
306 reject_symlink_components(source, "portable import source")?;
307 Read::by_ref(&mut file)
308 .take(limits.max_envelope_bytes.saturating_add(1))
309 .read_to_end(&mut bytes)
310 .map_err(|error| {
311 GfError::Storage(format!("failed to read portable import source: {error}"))
312 })?;
313 if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > limits.max_envelope_bytes {
314 return Err(resource("portable envelope exceeds limit"));
315 }
316 import_portable_project(
317 &bytes,
318 target,
319 transaction_uuid,
320 generation_uuid,
321 supported_capabilities,
322 limits,
323 )
324}
325
326fn validate_envelope(
327 envelope: &[u8],
328 supported_capabilities: &[ProjectCapability],
329 limits: PortableProjectLimits,
330) -> Result<ValidatedEnvelope, GfError> {
331 let envelope_length = u64::try_from(envelope.len())
332 .map_err(|_| resource("portable envelope byte length exceeds u64"))?;
333 enforce_size(
334 envelope_length,
335 limits.max_envelope_bytes,
336 "portable envelope",
337 )?;
338 let prefix = MAGIC.len() + HEADER_LENGTH_BYTES;
339 if envelope.len() < prefix || &envelope[..MAGIC.len()] != MAGIC {
340 return Err(corrupt("portable envelope magic is invalid"));
341 }
342 let header_length = u64::from_be_bytes(
343 envelope[MAGIC.len()..prefix]
344 .try_into()
345 .expect("fixed header length slice"),
346 );
347 enforce_size(header_length, limits.max_header_bytes, "portable header")?;
348 let header_length = usize::try_from(header_length)
349 .map_err(|_| resource("portable header does not fit address space"))?;
350 let header_end = prefix
351 .checked_add(header_length)
352 .ok_or_else(|| corrupt("portable header length overflows"))?;
353 let header_bytes = envelope
354 .get(prefix..header_end)
355 .ok_or_else(|| corrupt("portable envelope header is truncated"))?;
356 let header: EnvelopeHeader = serde_json::from_slice(header_bytes)
357 .map_err(|_| corrupt("portable envelope header is not valid canonical JSON"))?;
358 let canonical = {
359 let mut bytes = serde_json::to_vec(&header).map_err(|error| {
360 GfError::Storage(format!("failed to canonicalize portable header: {error}"))
361 })?;
362 bytes.push(b'\n');
363 bytes
364 };
365 if canonical != header_bytes {
366 return Err(corrupt("portable envelope header is not canonical"));
367 }
368 if header.format != FORMAT || header.format_version != FORMAT_VERSION {
369 return Err(project_error(
370 ProjectErrorCode::UnsupportedProjectFormat,
371 "portable envelope format or version is unsupported",
372 ));
373 }
374 let source_generation_uuid = parse_canonical_uuid(&header.source_generation_uuid)?;
375 enforce_count(header.participants.len(), limits.max_participants)?;
376 validate_capabilities(&header.capabilities, supported_capabilities)?;
377 let participants = validate_participants(&header, envelope, header_end, limits)?;
378 let capabilities = header
379 .capabilities
380 .into_iter()
381 .map(|item| ProjectCapability {
382 capability_id: item.capability_id,
383 capability_version: item.capability_version,
384 })
385 .collect();
386 Ok(ValidatedEnvelope {
387 source_generation_uuid,
388 envelope_sha256: Sha256::digest(envelope).into(),
389 capabilities,
390 participants,
391 })
392}
393
394fn validate_participants(
395 header: &EnvelopeHeader,
396 envelope: &[u8],
397 header_end: usize,
398 limits: PortableProjectLimits,
399) -> Result<Vec<ProjectParticipant>, GfError> {
400 let mut cursor = header_end;
401 let mut identities = BTreeSet::new();
402 let mut prior_identity: Option<(&str, &str)> = None;
403 let mut participants = Vec::with_capacity(header.participants.len());
404 for item in &header.participants {
405 validate_machine_id(&item.capability_id)?;
406 validate_machine_id(&item.record_family_id)?;
407 let identity = (item.capability_id.as_str(), item.record_family_id.as_str());
408 if prior_identity.is_some_and(|prior| prior >= identity) {
409 return Err(corrupt("portable participant inventory is not canonical"));
410 }
411 prior_identity = Some(identity);
412 if !identities.insert((&item.capability_id, &item.record_family_id)) {
413 return Err(corrupt(
414 "portable envelope has duplicate participant identity",
415 ));
416 }
417 enforce_size(
418 item.byte_length,
419 limits.max_participant_bytes,
420 "portable participant",
421 )?;
422 let length = usize::try_from(item.byte_length)
423 .map_err(|_| resource("portable participant does not fit address space"))?;
424 let end = cursor
425 .checked_add(length)
426 .ok_or_else(|| corrupt("portable participant length overflows"))?;
427 let bytes = envelope
428 .get(cursor..end)
429 .ok_or_else(|| corrupt("portable participant is truncated"))?;
430 if Sha256::digest(bytes).as_slice() != parse_digest(&item.content_sha256)? {
431 return Err(corrupt(
432 "portable participant content digest does not match",
433 ));
434 }
435 let encoding = match item.encoding.as_str() {
436 "parquet" => ProjectParticipantEncoding::Parquet,
437 "arrow" => ProjectParticipantEncoding::Arrow,
438 "json" => ProjectParticipantEncoding::Json,
439 _ => return Err(corrupt("portable participant encoding is unsupported")),
440 };
441 if !header.capabilities.iter().any(|capability| {
442 capability.capability_id == item.capability_id
443 && capability.capability_version == item.capability_version
444 }) {
445 return Err(corrupt("portable participant capability is not declared"));
446 }
447 participants.push(ProjectParticipant {
448 capability_id: item.capability_id.clone(),
449 capability_version: item.capability_version,
450 record_family_id: item.record_family_id.clone(),
451 record_version: item.record_version,
452 encoding,
453 schema_fingerprint: parse_digest(&item.schema_fingerprint)?,
454 row_count: item.row_count,
455 bytes: bytes.to_vec(),
456 });
457 cursor = end;
458 }
459 if cursor != envelope.len() {
460 return Err(corrupt("portable envelope has trailing bytes"));
461 }
462 Ok(participants)
463}
464
465fn validate_capabilities(
466 capabilities: &[EnvelopeCapability],
467 supported: &[ProjectCapability],
468) -> Result<(), GfError> {
469 let mut prior: Option<&str> = None;
470 for item in capabilities {
471 validate_machine_id(&item.capability_id)?;
472 if item.capability_version == 0
473 || prior.is_some_and(|value| value >= item.capability_id.as_str())
474 {
475 return Err(corrupt("portable capability inventory is not canonical"));
476 }
477 prior = Some(&item.capability_id);
478 if !supported.iter().any(|candidate| {
479 candidate.capability_id == item.capability_id
480 && candidate.capability_version == item.capability_version
481 }) {
482 return Err(project_error(
483 ProjectErrorCode::UnsupportedCapabilityVersion,
484 format!(
485 "portable capability {}@{} is unsupported",
486 item.capability_id, item.capability_version
487 ),
488 ));
489 }
490 }
491 Ok(())
492}
493
494fn prepare_import_target(target: &Path) -> Result<Option<ResolvedProjectGeneration>, GfError> {
495 reject_symlink_components(target, "portable import target")?;
496 match std::fs::symlink_metadata(target) {
497 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
498 Err(project_error(
499 ProjectErrorCode::UnsupportedProjectFormat,
500 "portable import target is linked or not a directory",
501 ))
502 }
503 Ok(_) => {
504 let is_empty = std::fs::read_dir(target)
505 .map_err(|error| {
506 GfError::Storage(format!("failed to inspect portable import target: {error}"))
507 })?
508 .next()
509 .is_none();
510 if is_empty {
511 Ok(None)
512 } else if is_pristine_initialized_target(target)? {
513 resolve_project_generation(target).map(Some)
514 } else {
515 Err(project_error(
516 ProjectErrorCode::UnsupportedProjectFormat,
517 "portable import target must be empty or pristine",
518 ))
519 }
520 }
521 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
522 std::fs::create_dir(target).map_err(|error| {
523 GfError::Storage(format!("failed to create portable import target: {error}"))
524 })?;
525 Ok(None)
526 }
527 Err(error) => Err(GfError::Storage(format!(
528 "failed to inspect portable import target: {error}"
529 ))),
530 }
531}
532
533fn is_pristine_initialized_target(target: &Path) -> Result<bool, GfError> {
534 let Ok(resolved) = resolve_project_generation(target) else {
535 return Ok(false);
536 };
537 let capabilities = resolved.capabilities();
538 if capabilities.len() != 2
539 || capabilities[0].capability_id != "graph"
540 || capabilities[0].capability_version != 1
541 || capabilities[1].capability_id != "workspace"
542 || capabilities[1].capability_version != 1
543 {
544 return Ok(false);
545 }
546 let actual = resolved.participant_snapshots()?;
547 let expected = crate::workspace_participants::empty_workspace_participants()?;
548 if actual.len() != expected.len()
549 || !actual.iter().zip(&expected).all(|(actual, expected)| {
550 actual.capability_id == expected.capability_id
551 && actual.capability_version == expected.capability_version
552 && actual.record_family_id == expected.record_family_id
553 && actual.record_version == expected.record_version
554 && actual.encoding
555 == match expected.encoding {
556 ProjectParticipantEncoding::Parquet => "parquet",
557 ProjectParticipantEncoding::Arrow => "arrow",
558 ProjectParticipantEncoding::Json => "json",
559 }
560 && actual.schema_fingerprint == expected.schema_fingerprint
561 && actual.row_count == expected.row_count
562 && actual.bytes == expected.bytes
563 })
564 {
565 return Ok(false);
566 }
567 has_exact_pristine_layout(target, resolved.generation_uuid())
568}
569
570fn has_exact_pristine_layout(target: &Path, generation_uuid: Uuid) -> Result<bool, GfError> {
571 let root_names = directory_names(target)?;
572 if root_names != ["CURRENT", "FORMAT", "generations"] {
573 return Ok(false);
574 }
575 let generations = target.join("generations");
576 if directory_names(&generations)? != [generation_uuid.hyphenated().to_string()] {
577 return Ok(false);
578 }
579 let generation = generations.join(generation_uuid.hyphenated().to_string());
580 if directory_names(&generation)? != ["lease.lock", "manifest.json", "participants"] {
581 return Ok(false);
582 }
583 let participants = generation.join("participants");
584 if directory_names(&participants)? != ["workspace"] {
585 return Ok(false);
586 }
587 Ok(
588 directory_names(&participants.join("workspace"))?
589 == ["configuration.json", "ontology.json"],
590 )
591}
592
593fn directory_names(path: &Path) -> Result<Vec<String>, GfError> {
594 let mut names = std::fs::read_dir(path)
595 .map_err(|error| {
596 GfError::Storage(format!("failed to inspect portable import target: {error}"))
597 })?
598 .map(|entry| {
599 entry
600 .map_err(|error| {
601 GfError::Storage(format!("failed to inspect portable import target: {error}"))
602 })?
603 .file_name()
604 .into_string()
605 .map_err(|_| {
606 project_error(
607 ProjectErrorCode::UnsupportedProjectFormat,
608 "portable import target contains a non-UTF-8 entry",
609 )
610 })
611 })
612 .collect::<Result<Vec<_>, _>>()?;
613 names.sort();
614 Ok(names)
615}
616
617fn reject_export_destination(path: &Path) -> Result<(), GfError> {
618 reject_symlink_components(path, "portable export destination")?;
619 if std::fs::symlink_metadata(path).is_ok() {
620 return Err(project_error(
621 ProjectErrorCode::UnsupportedProjectFormat,
622 "portable export destination already exists",
623 ));
624 }
625 let parent = path.parent().ok_or_else(|| {
626 project_error(
627 ProjectErrorCode::UnsupportedFilesystem,
628 "portable export destination has no parent",
629 )
630 })?;
631 let metadata = std::fs::symlink_metadata(parent).map_err(|error| {
632 GfError::Storage(format!("failed to inspect portable export parent: {error}"))
633 })?;
634 if metadata.file_type().is_symlink() || !metadata.is_dir() {
635 return Err(project_error(
636 ProjectErrorCode::UnsupportedFilesystem,
637 "portable export parent is linked or not a directory",
638 ));
639 }
640 Ok(())
641}
642
643fn reject_symlink_components(path: &Path, name: &str) -> Result<(), GfError> {
644 let absolute = if path.is_absolute() {
645 path.to_path_buf()
646 } else {
647 std::env::current_dir()
648 .map_err(|error| {
649 GfError::Storage(format!("failed to resolve current directory: {error}"))
650 })?
651 .join(path)
652 };
653 for component in absolute.ancestors() {
654 match std::fs::symlink_metadata(component) {
655 Ok(metadata)
656 if metadata.file_type().is_symlink() && !trusted_platform_symlink(&metadata) =>
657 {
658 return Err(project_error(
659 ProjectErrorCode::UnsupportedFilesystem,
660 format!("{name} has a symbolic-link path component"),
661 ));
662 }
663 Ok(metadata)
664 if component != absolute
665 && !metadata.is_dir()
666 && !metadata.file_type().is_symlink() =>
667 {
668 return Err(project_error(
669 ProjectErrorCode::UnsupportedFilesystem,
670 format!("{name} has a non-directory ancestor"),
671 ));
672 }
673 Ok(_) => {}
674 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
675 Err(error) => {
676 return Err(GfError::Storage(format!(
677 "failed to inspect {name} path components: {error}"
678 )));
679 }
680 }
681 }
682 Ok(())
683}
684
685#[cfg(unix)]
688fn trusted_platform_symlink(metadata: &std::fs::Metadata) -> bool {
689 use std::os::unix::fs::MetadataExt;
690
691 metadata.uid() == 0
692}
693
694#[cfg(not(unix))]
695fn trusted_platform_symlink(_metadata: &std::fs::Metadata) -> bool {
696 false
697}
698
699#[cfg(unix)]
700fn open_regular_nofollow(path: &Path) -> std::io::Result<std::fs::File> {
701 use std::os::unix::fs::OpenOptionsExt;
702
703 std::fs::OpenOptions::new()
704 .read(true)
705 .custom_flags(libc::O_NOFOLLOW)
706 .open(path)
707}
708
709#[cfg(not(unix))]
710fn open_regular_nofollow(path: &Path) -> std::io::Result<std::fs::File> {
711 std::fs::File::open(path)
712}
713
714#[cfg(unix)]
715fn same_file_identity(before: &std::fs::Metadata, after: &std::fs::Metadata) -> bool {
716 use std::os::unix::fs::MetadataExt;
717
718 before.dev() == after.dev() && before.ino() == after.ino()
719}
720
721#[cfg(not(unix))]
722fn same_file_identity(before: &std::fs::Metadata, after: &std::fs::Metadata) -> bool {
723 before.len() == after.len()
724 && before.modified().ok() == after.modified().ok()
725 && before.created().ok() == after.created().ok()
726}
727
728fn validate_machine_id(value: &str) -> Result<(), GfError> {
729 if value.is_empty()
730 || value.len() > 128
731 || !value.bytes().all(|byte| {
732 byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'_'
733 })
734 {
735 return Err(corrupt(
736 "portable envelope contains an invalid machine identifier",
737 ));
738 }
739 Ok(())
740}
741
742fn parse_canonical_uuid(value: &str) -> Result<Uuid, GfError> {
743 let parsed = Uuid::parse_str(value)
744 .map_err(|_| corrupt("portable envelope generation UUID is invalid"))?;
745 if parsed.hyphenated().to_string() != value {
746 return Err(corrupt(
747 "portable envelope generation UUID is not canonical",
748 ));
749 }
750 Ok(parsed)
751}
752
753fn parse_digest(value: &str) -> Result<[u8; 32], GfError> {
754 if value.len() != 64
755 || !value
756 .bytes()
757 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
758 {
759 return Err(corrupt("portable envelope digest is invalid"));
760 }
761 let mut digest = [0_u8; 32];
762 for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
763 digest[index] = (hex_nibble(chunk[0]) << 4) | hex_nibble(chunk[1]);
764 }
765 Ok(digest)
766}
767
768fn hex_nibble(byte: u8) -> u8 {
769 if byte <= b'9' {
770 byte - b'0'
771 } else {
772 byte - b'a' + 10
773 }
774}
775
776fn hex(digest: [u8; 32]) -> String {
777 const DIGITS: &[u8; 16] = b"0123456789abcdef";
778 let mut output = String::with_capacity(64);
779 for byte in digest {
780 output.push(DIGITS[usize::from(byte >> 4)] as char);
781 output.push(DIGITS[usize::from(byte & 0xf)] as char);
782 }
783 output
784}
785
786fn enforce_count(actual: usize, limit: usize) -> Result<(), GfError> {
787 if actual > limit {
788 Err(resource("portable participant count exceeds limit"))
789 } else {
790 Ok(())
791 }
792}
793
794fn enforce_size(actual: u64, limit: u64, name: &str) -> Result<(), GfError> {
795 if actual > limit {
796 Err(resource(format!("{name} exceeds limit")))
797 } else {
798 Ok(())
799 }
800}
801
802fn corrupt(message: impl Into<String>) -> GfError {
803 project_error(ProjectErrorCode::ProjectCorrupt, message)
804}
805fn resource(message: impl Into<String>) -> GfError {
806 project_error(ProjectErrorCode::ResourceLimit, message)
807}
808fn project_error(code: ProjectErrorCode, message: impl Into<String>) -> GfError {
809 GfError::Project {
810 code,
811 message: message.into(),
812 }
813}
814
815#[cfg(test)]
816mod tests {
817 use std::fs;
818 use std::process::Command;
819
820 use super::*;
821
822 const ENABLE_COOKIE: &str = "graphforge-internal-subprocess-v1";
823 const IMPORT_HELPER: &str = "project_portable::tests::subprocess_portable_import_writer";
824
825 fn supported(generation: &ResolvedProjectGeneration) -> Vec<ProjectCapability> {
826 generation
827 .capabilities()
828 .into_iter()
829 .map(|item| ProjectCapability {
830 capability_id: item.capability_id,
831 capability_version: item.capability_version,
832 })
833 .collect()
834 }
835
836 fn mutate_header(envelope: &[u8], mutate: impl FnOnce(&mut EnvelopeHeader)) -> Vec<u8> {
837 let prefix = MAGIC.len() + HEADER_LENGTH_BYTES;
838 let header_length =
839 u64::from_be_bytes(envelope[MAGIC.len()..prefix].try_into().unwrap()) as usize;
840 let header_end = prefix + header_length;
841 let mut header: EnvelopeHeader =
842 serde_json::from_slice(&envelope[prefix..header_end]).unwrap();
843 mutate(&mut header);
844 let mut header_bytes = serde_json::to_vec(&header).unwrap();
845 header_bytes.push(b'\n');
846 let mut rebuilt = Vec::new();
847 rebuilt.extend_from_slice(MAGIC);
848 rebuilt.extend_from_slice(&u64::try_from(header_bytes.len()).unwrap().to_be_bytes());
849 rebuilt.extend_from_slice(&header_bytes);
850 rebuilt.extend_from_slice(&envelope[header_end..]);
851 rebuilt
852 }
853
854 #[test]
855 fn subprocess_portable_import_writer() {
856 let Ok(target) = std::env::var("GRAPHFORGE_TEST_PROJECT_ROOT") else {
857 return;
858 };
859 let envelope =
860 std::fs::read(std::env::var("GRAPHFORGE_TEST_PORTABLE_ENVELOPE").unwrap()).unwrap();
861 import_portable_project(
862 &envelope,
863 target,
864 Uuid::parse_str(&std::env::var("GRAPHFORGE_TEST_TRANSACTION_UUID").unwrap()).unwrap(),
865 Uuid::parse_str(&std::env::var("GRAPHFORGE_TEST_GENERATION_UUID").unwrap()).unwrap(),
866 &[
867 ProjectCapability {
868 capability_id: "graph".into(),
869 capability_version: 1,
870 },
871 ProjectCapability {
872 capability_id: "workspace".into(),
873 capability_version: 1,
874 },
875 ],
876 PortableProjectLimits::default(),
877 )
878 .unwrap();
879 }
880
881 #[test]
882 fn prepublication_failure_keeps_pristine_current_authoritative() {
883 let source = tempfile::tempdir().unwrap();
884 let source_generation = open_or_initialize_project(source.path()).unwrap();
885 let (envelope, _) =
886 encode_portable_project(&source_generation, PortableProjectLimits::default()).unwrap();
887 let envelope_path = source.path().join("portable.gfportable");
888 std::fs::write(&envelope_path, envelope).unwrap();
889
890 let target = tempfile::tempdir().unwrap();
891 let parent = open_or_initialize_project(target.path())
892 .unwrap()
893 .generation_uuid();
894 let status = Command::new(std::env::current_exe().unwrap())
895 .arg("--exact")
896 .arg(IMPORT_HELPER)
897 .arg("--nocapture")
898 .env("GRAPHFORGE_TEST_PROJECT_ROOT", target.path())
899 .env("GRAPHFORGE_TEST_PORTABLE_ENVELOPE", &envelope_path)
900 .env(
901 "GRAPHFORGE_TEST_TRANSACTION_UUID",
902 Uuid::new_v4().to_string(),
903 )
904 .env(
905 "GRAPHFORGE_TEST_GENERATION_UUID",
906 Uuid::new_v4().to_string(),
907 )
908 .env("GRAPHFORGE_PROJECT_FAILPOINTS", ENABLE_COOKIE)
909 .env(
910 "GRAPHFORGE_PROJECT_FAILPOINT",
911 "project.before_current_replace",
912 )
913 .status()
914 .unwrap();
915 assert_eq!(status.code(), Some(crate::project_failpoint::exit_code()));
916 assert_eq!(
917 resolve_project_generation(target.path())
918 .unwrap()
919 .generation_uuid(),
920 parent
921 );
922 }
923
924 #[test]
925 fn deterministic_round_trip_publishes_complete_new_generation() {
926 let source = tempfile::tempdir().unwrap();
927 let source_generation = open_or_initialize_project(source.path()).unwrap();
928 let expected = source_generation.participant_snapshots().unwrap();
929 let limits = PortableProjectLimits::default();
930 let (first, first_receipt) = encode_portable_project(&source_generation, limits).unwrap();
931 let (second, second_receipt) = encode_portable_project(&source_generation, limits).unwrap();
932 assert_eq!(first, second);
933 assert_eq!(first_receipt, second_receipt);
934
935 let parent = tempfile::tempdir().unwrap();
936 let target = parent.path().join("imported project");
937 let imported = import_portable_project(
938 &first,
939 &target,
940 Uuid::new_v4(),
941 Uuid::new_v4(),
942 &supported(&source_generation),
943 limits,
944 )
945 .unwrap();
946 assert_eq!(
947 imported.source_generation_uuid,
948 source_generation.generation_uuid()
949 );
950 assert_eq!(imported.envelope_sha256, first_receipt.envelope_sha256);
951 let reopened = resolve_project_generation(&target).unwrap();
952 assert_eq!(
953 reopened.generation_uuid(),
954 imported.publication.generation_uuid
955 );
956 assert_eq!(reopened.participant_snapshots().unwrap(), expected);
957 assert!(!target.join("trash").exists());
958 assert!(!target.join("cache").exists());
959 }
960
961 #[test]
962 fn pristine_initialized_target_is_importable() {
963 let source = tempfile::tempdir().unwrap();
964 let generation = open_or_initialize_project(source.path()).unwrap();
965 let limits = PortableProjectLimits::default();
966 let (envelope, _) = encode_portable_project(&generation, limits).unwrap();
967 let target = tempfile::tempdir().unwrap();
968 let prior = open_or_initialize_project(target.path()).unwrap();
969 let prior_uuid = prior.generation_uuid();
970 drop(prior);
971
972 let imported = import_portable_project(
973 &envelope,
974 target.path(),
975 Uuid::new_v4(),
976 Uuid::new_v4(),
977 &supported(&generation),
978 limits,
979 )
980 .unwrap();
981 assert_ne!(imported.publication.generation_uuid, prior_uuid);
982 assert_eq!(
983 resolve_project_generation(target.path())
984 .unwrap()
985 .generation_uuid(),
986 imported.publication.generation_uuid
987 );
988 }
989
990 #[test]
991 fn validation_failure_preserves_pristine_target_current() {
992 let source = tempfile::tempdir().unwrap();
993 let generation = open_or_initialize_project(source.path()).unwrap();
994 let limits = PortableProjectLimits::default();
995 let (envelope, _) = encode_portable_project(&generation, limits).unwrap();
996 let target = tempfile::tempdir().unwrap();
997 let prior = open_or_initialize_project(target.path()).unwrap();
998 let prior_uuid = prior.generation_uuid();
999 drop(prior);
1000
1001 let error = import_portable_project(
1002 &envelope,
1003 target.path(),
1004 Uuid::new_v4(),
1005 Uuid::new_v4(),
1006 &[],
1007 limits,
1008 )
1009 .unwrap_err();
1010 assert_eq!(error.code(), "GF_UNSUPPORTED_CAPABILITY_VERSION");
1011 assert_eq!(
1012 resolve_project_generation(target.path())
1013 .unwrap()
1014 .generation_uuid(),
1015 prior_uuid
1016 );
1017 }
1018
1019 #[test]
1020 fn export_refuses_to_overwrite_existing_destination() {
1021 let source = tempfile::tempdir().unwrap();
1022 let generation = open_or_initialize_project(source.path()).unwrap();
1023 let destination = source.path().join("existing.gfx");
1024 std::fs::write(&destination, b"keep").unwrap();
1025
1026 let error =
1027 export_portable_project(&generation, &destination, PortableProjectLimits::default())
1028 .unwrap_err();
1029 assert_eq!(error.code(), "GF_UNSUPPORTED_PROJECT_FORMAT");
1030 assert_eq!(std::fs::read(destination).unwrap(), b"keep");
1031 }
1032
1033 #[cfg(unix)]
1034 #[test]
1035 fn linked_ancestor_is_rejected_for_source_export_and_target() {
1036 use std::os::unix::fs::symlink;
1037
1038 let source_project = tempfile::tempdir().unwrap();
1039 let generation = open_or_initialize_project(source_project.path()).unwrap();
1040 let limits = PortableProjectLimits::default();
1041 let (envelope, _) = encode_portable_project(&generation, limits).unwrap();
1042 let real = tempfile::tempdir().unwrap();
1043 let links = tempfile::tempdir().unwrap();
1044 let linked = links.path().join("linked");
1045 symlink(real.path(), &linked).unwrap();
1046
1047 let export_error =
1048 export_portable_project(&generation, linked.join("out.gfx"), limits).unwrap_err();
1049 assert_eq!(export_error.code(), "GF_UNSUPPORTED_FILESYSTEM");
1050 assert!(!real.path().join("out.gfx").exists());
1051
1052 std::fs::write(real.path().join("in.gfx"), &envelope).unwrap();
1053 let source_error = import_portable_project_file(
1054 linked.join("in.gfx"),
1055 real.path().join("unused-target"),
1056 Uuid::new_v4(),
1057 Uuid::new_v4(),
1058 &supported(&generation),
1059 limits,
1060 )
1061 .unwrap_err();
1062 assert_eq!(source_error.code(), "GF_UNSUPPORTED_FILESYSTEM");
1063 assert!(!real.path().join("unused-target").exists());
1064
1065 let input = links.path().join("input.gfx");
1066 std::fs::write(&input, envelope).unwrap();
1067 let target_error = import_portable_project_file(
1068 input,
1069 linked.join("target"),
1070 Uuid::new_v4(),
1071 Uuid::new_v4(),
1072 &supported(&generation),
1073 limits,
1074 )
1075 .unwrap_err();
1076 assert_eq!(target_error.code(), "GF_UNSUPPORTED_FILESYSTEM");
1077 assert!(!real.path().join("target").exists());
1078 }
1079
1080 #[test]
1081 fn corruption_and_trailing_bytes_fail_before_target_creation() {
1082 let source = tempfile::tempdir().unwrap();
1083 let generation = open_or_initialize_project(source.path()).unwrap();
1084 let limits = PortableProjectLimits::default();
1085 let (mut envelope, _) = encode_portable_project(&generation, limits).unwrap();
1086 *envelope.last_mut().unwrap() ^= 1;
1087 let parent = tempfile::tempdir().unwrap();
1088 let corrupt_target = parent.path().join("corrupt");
1089 let error = import_portable_project(
1090 &envelope,
1091 &corrupt_target,
1092 Uuid::new_v4(),
1093 Uuid::new_v4(),
1094 &supported(&generation),
1095 limits,
1096 )
1097 .unwrap_err();
1098 assert_eq!(error.code(), "GF_PROJECT_CORRUPT");
1099 assert!(!corrupt_target.exists());
1100
1101 let (mut envelope, _) = encode_portable_project(&generation, limits).unwrap();
1102 envelope.push(0);
1103 let trailing_target = parent.path().join("trailing");
1104 let error = import_portable_project(
1105 &envelope,
1106 &trailing_target,
1107 Uuid::new_v4(),
1108 Uuid::new_v4(),
1109 &supported(&generation),
1110 limits,
1111 )
1112 .unwrap_err();
1113 assert_eq!(error.code(), "GF_PROJECT_CORRUPT");
1114 assert!(!trailing_target.exists());
1115 }
1116
1117 #[test]
1118 fn resource_and_capability_checks_precede_mutation() {
1119 let source = tempfile::tempdir().unwrap();
1120 let generation = open_or_initialize_project(source.path()).unwrap();
1121 let limits = PortableProjectLimits::default();
1122 let (envelope, _) = encode_portable_project(&generation, limits).unwrap();
1123 let parent = tempfile::tempdir().unwrap();
1124
1125 let bounded_target = parent.path().join("bounded");
1126 let tiny = PortableProjectLimits {
1127 max_envelope_bytes: u64::try_from(envelope.len() - 1).unwrap(),
1128 ..limits
1129 };
1130 let error = import_portable_project(
1131 &envelope,
1132 &bounded_target,
1133 Uuid::new_v4(),
1134 Uuid::new_v4(),
1135 &supported(&generation),
1136 tiny,
1137 )
1138 .unwrap_err();
1139 assert_eq!(error.code(), "GF_RESOURCE_LIMIT");
1140 assert!(!bounded_target.exists());
1141
1142 let capability_target = parent.path().join("unsupported");
1143 let error = import_portable_project(
1144 &envelope,
1145 &capability_target,
1146 Uuid::new_v4(),
1147 Uuid::new_v4(),
1148 &[],
1149 limits,
1150 )
1151 .unwrap_err();
1152 assert_eq!(error.code(), "GF_UNSUPPORTED_CAPABILITY_VERSION");
1153 assert!(!capability_target.exists());
1154 }
1155
1156 #[test]
1157 fn nonempty_target_is_never_modified() {
1158 let source = tempfile::tempdir().unwrap();
1159 let generation = open_or_initialize_project(source.path()).unwrap();
1160 let limits = PortableProjectLimits::default();
1161 let (envelope, _) = encode_portable_project(&generation, limits).unwrap();
1162 let target = tempfile::tempdir().unwrap();
1163 std::fs::write(target.path().join("keep.txt"), b"keep").unwrap();
1164 let error = import_portable_project(
1165 &envelope,
1166 target.path(),
1167 Uuid::new_v4(),
1168 Uuid::new_v4(),
1169 &supported(&generation),
1170 limits,
1171 )
1172 .unwrap_err();
1173 assert_eq!(error.code(), "GF_UNSUPPORTED_PROJECT_FORMAT");
1174 assert_eq!(
1175 std::fs::read(target.path().join("keep.txt")).unwrap(),
1176 b"keep"
1177 );
1178 assert!(!target.path().join("CURRENT").exists());
1179 }
1180
1181 #[test]
1182 fn traversal_identity_is_rejected_before_target_creation() {
1183 let source = tempfile::tempdir().unwrap();
1184 let generation = open_or_initialize_project(source.path()).unwrap();
1185 let limits = PortableProjectLimits::default();
1186 let (envelope, _) = encode_portable_project(&generation, limits).unwrap();
1187 let prefix = MAGIC.len() + HEADER_LENGTH_BYTES;
1188 let header_length =
1189 u64::from_be_bytes(envelope[MAGIC.len()..prefix].try_into().unwrap()) as usize;
1190 let header_end = prefix + header_length;
1191 let mut header: EnvelopeHeader =
1192 serde_json::from_slice(&envelope[prefix..header_end]).unwrap();
1193 header.participants[0].record_family_id = "../escape".into();
1194 let mut header_bytes = serde_json::to_vec(&header).unwrap();
1195 header_bytes.push(b'\n');
1196 let mut malicious = Vec::new();
1197 malicious.extend_from_slice(MAGIC);
1198 malicious.extend_from_slice(&u64::try_from(header_bytes.len()).unwrap().to_be_bytes());
1199 malicious.extend_from_slice(&header_bytes);
1200 malicious.extend_from_slice(&envelope[header_end..]);
1201
1202 let parent = tempfile::tempdir().unwrap();
1203 let target = parent.path().join("traversal");
1204 let error = import_portable_project(
1205 &malicious,
1206 &target,
1207 Uuid::new_v4(),
1208 Uuid::new_v4(),
1209 &supported(&generation),
1210 limits,
1211 )
1212 .unwrap_err();
1213 assert_eq!(error.code(), "GF_PROJECT_CORRUPT");
1214 assert!(!target.exists());
1215 }
1216
1217 #[test]
1218 fn file_import_round_trip_reopens_complete_generation() {
1219 let source = tempfile::tempdir().unwrap();
1220 let generation = open_or_initialize_project(source.path()).unwrap();
1221 let capabilities = supported(&generation);
1222 let envelope_path = source.path().join("snapshot.gfproject");
1223 let exported = export_portable_project(
1224 &generation,
1225 &envelope_path,
1226 PortableProjectLimits::default(),
1227 )
1228 .unwrap();
1229 assert_eq!(exported.generation_uuid, generation.generation_uuid());
1230
1231 let target = tempfile::tempdir().unwrap();
1232 let target_path = target.path().join("imported");
1233 let transaction_uuid = Uuid::now_v7();
1234 let generation_uuid = Uuid::now_v7();
1235 let first = import_portable_project_file(
1236 &envelope_path,
1237 &target_path,
1238 transaction_uuid,
1239 generation_uuid,
1240 &capabilities,
1241 PortableProjectLimits::default(),
1242 )
1243 .unwrap();
1244 assert_eq!(first.envelope_sha256, exported.envelope_sha256);
1245 assert_eq!(first.source_generation_uuid, generation.generation_uuid());
1246 assert_eq!(first.publication.generation_uuid, generation_uuid);
1247 assert!(!first.publication.idempotent_replay);
1248 drop(generation);
1249
1250 let reopened = resolve_project_generation(&target_path).unwrap();
1251 assert_eq!(reopened.generation_uuid(), generation_uuid);
1252 reopened.validate_complete_participant_inventory().unwrap();
1253 let snapshots = reopened.participant_snapshots().unwrap();
1254 assert!(!snapshots.is_empty());
1255 }
1256
1257 #[test]
1258 fn file_import_rejects_nonregular_and_oversized_sources_before_target_creation() {
1259 let root = tempfile::tempdir().unwrap();
1260 let target = root.path().join("target");
1261 let error = import_portable_project_file(
1262 root.path(),
1263 &target,
1264 Uuid::now_v7(),
1265 Uuid::now_v7(),
1266 &[],
1267 PortableProjectLimits::default(),
1268 )
1269 .unwrap_err();
1270 assert_eq!(error.code(), "GF_UNSUPPORTED_FILESYSTEM");
1271 assert!(!target.exists());
1272
1273 let source = root.path().join("oversized.gfproject");
1274 fs::write(&source, b"too large").unwrap();
1275 let limits = PortableProjectLimits {
1276 max_envelope_bytes: 1,
1277 ..PortableProjectLimits::default()
1278 };
1279 let error = import_portable_project_file(
1280 &source,
1281 &target,
1282 Uuid::now_v7(),
1283 Uuid::now_v7(),
1284 &[],
1285 limits,
1286 )
1287 .unwrap_err();
1288 assert_eq!(error.code(), "GF_RESOURCE_LIMIT");
1289 assert!(!target.exists());
1290 }
1291
1292 #[test]
1293 fn portable_identity_digest_and_count_validation_matrix_is_total() {
1294 let uuid = Uuid::now_v7();
1295 assert_eq!(
1296 parse_canonical_uuid(&uuid.hyphenated().to_string()).unwrap(),
1297 uuid
1298 );
1299 for value in [
1300 "bad".to_owned(),
1301 uuid.simple().to_string(),
1302 uuid.hyphenated().to_string().to_uppercase(),
1303 ] {
1304 assert_eq!(
1305 parse_canonical_uuid(&value).unwrap_err().code(),
1306 "GF_PROJECT_CORRUPT"
1307 );
1308 }
1309 assert_eq!(parse_digest(&"00".repeat(32)).unwrap(), [0; 32]);
1310 for value in ["short".to_owned(), "AA".repeat(32), "zz".repeat(32)] {
1311 assert_eq!(
1312 parse_digest(&value).unwrap_err().code(),
1313 "GF_PROJECT_CORRUPT"
1314 );
1315 }
1316 assert!(enforce_count(4, 4).is_ok());
1317 assert_eq!(enforce_count(5, 4).unwrap_err().code(), "GF_RESOURCE_LIMIT");
1318 }
1319
1320 #[test]
1321 fn portable_header_contract_matrix_rejects_noncanonical_or_inconsistent_inventory() {
1322 let source = tempfile::tempdir().unwrap();
1323 let generation = open_or_initialize_project(source.path()).unwrap();
1324 let limits = PortableProjectLimits::default();
1325 let (envelope, _) = encode_portable_project(&generation, limits).unwrap();
1326 let supported = supported(&generation);
1327
1328 let malformed = [
1329 mutate_header(&envelope, |header| header.format = "other".into()),
1330 mutate_header(&envelope, |header| header.format_version += 1),
1331 mutate_header(&envelope, |header| {
1332 header.capabilities[0].capability_version = 0;
1333 }),
1334 mutate_header(&envelope, |header| {
1335 header.capabilities.swap(0, 1);
1336 }),
1337 mutate_header(&envelope, |header| {
1338 header.participants.swap(0, 1);
1339 }),
1340 mutate_header(&envelope, |header| {
1341 header.participants[0].encoding = "opaque".into();
1342 }),
1343 mutate_header(&envelope, |header| {
1344 header.participants[0].capability_version += 1;
1345 }),
1346 mutate_header(&envelope, |header| {
1347 header.participants[0].content_sha256 = "00".repeat(32);
1348 }),
1349 mutate_header(&envelope, |header| {
1350 header.participants[0].byte_length += 1;
1351 }),
1352 ];
1353 for candidate in malformed {
1354 let error = validate_envelope(&candidate, &supported, limits)
1355 .err()
1356 .expect("malformed header must fail");
1357 assert!(
1358 matches!(
1359 error.code(),
1360 "GF_PROJECT_CORRUPT"
1361 | "GF_UNSUPPORTED_PROJECT_FORMAT"
1362 | "GF_UNSUPPORTED_CAPABILITY_VERSION"
1363 ),
1364 "unexpected error contract: {error}"
1365 );
1366 }
1367
1368 let prefix = MAGIC.len() + HEADER_LENGTH_BYTES;
1369 let header_length =
1370 u64::from_be_bytes(envelope[MAGIC.len()..prefix].try_into().unwrap()) as usize;
1371 let header_end = prefix + header_length;
1372 let mut noncanonical = Vec::new();
1373 noncanonical.extend_from_slice(MAGIC);
1374 noncanonical.extend_from_slice(&u64::try_from(header_length + 1).unwrap().to_be_bytes());
1375 noncanonical.push(b' ');
1376 noncanonical.extend_from_slice(&envelope[prefix..header_end]);
1377 noncanonical.extend_from_slice(&envelope[header_end..]);
1378 assert_eq!(
1379 validate_envelope(&noncanonical, &supported, limits)
1380 .err()
1381 .expect("noncanonical header must fail")
1382 .code(),
1383 "GF_PROJECT_CORRUPT"
1384 );
1385
1386 for truncated in [
1387 Vec::new(),
1388 MAGIC[..MAGIC.len() - 1].to_vec(),
1389 envelope[..prefix].to_vec(),
1390 envelope[..header_end - 1].to_vec(),
1391 ] {
1392 assert_eq!(
1393 validate_envelope(&truncated, &supported, limits)
1394 .err()
1395 .expect("truncated envelope must fail")
1396 .code(),
1397 "GF_PROJECT_CORRUPT"
1398 );
1399 }
1400 }
1401
1402 #[test]
1403 fn export_destination_kind_matrix_preserves_existing_state() {
1404 let root = tempfile::tempdir().unwrap();
1405 let missing_parent = root.path().join("missing/out.gfx");
1406 assert_eq!(
1407 reject_export_destination(&missing_parent)
1408 .unwrap_err()
1409 .code(),
1410 "GF_IO"
1411 );
1412 assert!(!root.path().join("missing").exists());
1413
1414 let directory = root.path().join("directory.gfx");
1415 std::fs::create_dir(&directory).unwrap();
1416 assert_eq!(
1417 reject_export_destination(&directory).unwrap_err().code(),
1418 "GF_UNSUPPORTED_PROJECT_FORMAT"
1419 );
1420 assert!(directory.is_dir());
1421
1422 let regular = root.path().join("regular.gfx");
1423 std::fs::write(®ular, b"caller bytes").unwrap();
1424 assert_eq!(
1425 reject_export_destination(®ular).unwrap_err().code(),
1426 "GF_UNSUPPORTED_PROJECT_FORMAT"
1427 );
1428 assert_eq!(std::fs::read(®ular).unwrap(), b"caller bytes");
1429
1430 let available = root.path().join("available.gfx");
1431 assert!(reject_export_destination(&available).is_ok());
1432 assert!(!available.exists());
1433
1434 let import_file = root.path().join("import-target");
1435 std::fs::write(&import_file, b"caller bytes").unwrap();
1436 assert_eq!(
1437 prepare_import_target(&import_file).unwrap_err().code(),
1438 "GF_UNSUPPORTED_PROJECT_FORMAT"
1439 );
1440 assert_eq!(std::fs::read(&import_file).unwrap(), b"caller bytes");
1441
1442 let relative = Path::new("missing-wave7-parent/out.gfx");
1443 assert_eq!(
1444 reject_export_destination(relative).unwrap_err().code(),
1445 "GF_IO"
1446 );
1447
1448 let non_directory_parent = root.path().join("parent-file");
1449 std::fs::write(&non_directory_parent, b"preserve").unwrap();
1450 assert_eq!(
1451 reject_export_destination(&non_directory_parent.join("out.gfx"))
1452 .unwrap_err()
1453 .code(),
1454 "GF_IO"
1455 );
1456 assert_eq!(std::fs::read(&non_directory_parent).unwrap(), b"preserve");
1457 }
1458
1459 #[test]
1460 fn pristine_import_target_requires_exact_layout_and_participant_bytes() {
1461 let root = tempfile::tempdir().unwrap();
1462 let resolved = open_or_initialize_project(root.path()).unwrap();
1463 assert!(is_pristine_initialized_target(root.path()).unwrap());
1464 assert!(has_exact_pristine_layout(root.path(), resolved.generation_uuid()).unwrap());
1465
1466 let extra = root.path().join("caller-file");
1467 std::fs::write(&extra, b"preserve").unwrap();
1468 assert!(!is_pristine_initialized_target(root.path()).unwrap());
1469 assert!(!has_exact_pristine_layout(root.path(), resolved.generation_uuid()).unwrap());
1470 assert_eq!(
1471 directory_names(root.path()).unwrap().last().unwrap(),
1472 "generations"
1473 );
1474 std::fs::remove_file(&extra).unwrap();
1475
1476 let generations = root.path().join("generations");
1477 let extra_generation = generations.join(Uuid::now_v7().hyphenated().to_string());
1478 std::fs::create_dir(&extra_generation).unwrap();
1479 assert!(!has_exact_pristine_layout(root.path(), resolved.generation_uuid()).unwrap());
1480 std::fs::remove_dir(&extra_generation).unwrap();
1481
1482 let generation = root
1483 .path()
1484 .join("generations")
1485 .join(resolved.generation_uuid().hyphenated().to_string());
1486 let extra_generation_entry = generation.join("caller-file");
1487 std::fs::write(&extra_generation_entry, b"preserve").unwrap();
1488 assert!(!has_exact_pristine_layout(root.path(), resolved.generation_uuid()).unwrap());
1489 std::fs::remove_file(&extra_generation_entry).unwrap();
1490
1491 let participants = generation.join("participants");
1492 let extra_participant = participants.join("caller-domain");
1493 std::fs::create_dir(&extra_participant).unwrap();
1494 assert!(!has_exact_pristine_layout(root.path(), resolved.generation_uuid()).unwrap());
1495 std::fs::remove_dir(&extra_participant).unwrap();
1496
1497 let workspace = participants.join("workspace");
1498 let extra_workspace = workspace.join("caller.json");
1499 std::fs::write(&extra_workspace, b"preserve").unwrap();
1500 assert!(!has_exact_pristine_layout(root.path(), resolved.generation_uuid()).unwrap());
1501 std::fs::remove_file(&extra_workspace).unwrap();
1502
1503 let participant = generation.join("participants/workspace/configuration.json");
1504 let stable = std::fs::read(&participant).unwrap();
1505 std::fs::write(&participant, b"different").unwrap();
1506 assert!(is_pristine_initialized_target(root.path()).is_err());
1507 std::fs::write(&participant, stable).unwrap();
1508
1509 let non_project = tempfile::tempdir().unwrap();
1510 assert!(!is_pristine_initialized_target(non_project.path()).unwrap());
1511 }
1512
1513 #[test]
1514 fn public_import_replay_fails_closed_after_reopen_without_extra_publication() {
1515 let source = tempfile::tempdir().unwrap();
1516 let source_generation = open_or_initialize_project(source.path()).unwrap();
1517 let limits = PortableProjectLimits::default();
1518 let (envelope, _) = encode_portable_project(&source_generation, limits).unwrap();
1519 let target_parent = tempfile::tempdir().unwrap();
1520 let target = target_parent.path().join("replayed-import");
1521 let transaction_uuid = Uuid::now_v7();
1522 let generation_uuid = Uuid::now_v7();
1523 let capabilities = supported(&source_generation);
1524
1525 let first = import_portable_project(
1526 &envelope,
1527 &target,
1528 transaction_uuid,
1529 generation_uuid,
1530 &capabilities,
1531 limits,
1532 )
1533 .unwrap();
1534 let second = import_portable_project(
1535 &envelope,
1536 &target,
1537 transaction_uuid,
1538 generation_uuid,
1539 &capabilities,
1540 limits,
1541 )
1542 .unwrap_err();
1543 assert_eq!(first.publication.generation_uuid, generation_uuid);
1544 assert_eq!(second.code(), "GF_UNSUPPORTED_PROJECT_FORMAT");
1545 assert_eq!(
1546 resolve_project_generation(&target)
1547 .unwrap()
1548 .generation_uuid(),
1549 generation_uuid
1550 );
1551 assert_eq!(
1552 crate::published_project_transaction(&target, transaction_uuid)
1553 .unwrap()
1554 .unwrap()
1555 .generation_uuid,
1556 generation_uuid
1557 );
1558 }
1559
1560 #[test]
1561 fn wave12_portable_targets_reject_file_shapes_and_parent_files() {
1562 let root = tempfile::tempdir().unwrap();
1563 let target_file = root.path().join("target-file");
1564 std::fs::write(&target_file, b"caller data").unwrap();
1565 assert_eq!(
1566 prepare_import_target(&target_file).unwrap_err().code(),
1567 "GF_UNSUPPORTED_PROJECT_FORMAT"
1568 );
1569
1570 let parent_file = root.path().join("parent-file");
1571 std::fs::write(&parent_file, b"caller data").unwrap();
1572 assert_eq!(
1573 reject_export_destination(&parent_file.join("export.gfproj"))
1574 .unwrap_err()
1575 .code(),
1576 "GF_IO"
1577 );
1578 assert_eq!(
1579 reject_symlink_components(&parent_file.join("child"), "portable test")
1580 .unwrap_err()
1581 .code(),
1582 "GF_IO"
1583 );
1584 }
1585
1586 #[test]
1587 fn wave12_portable_layout_reports_directory_inspection_failures() {
1588 let root = tempfile::tempdir().unwrap();
1589 let file = root.path().join("not-a-directory");
1590 std::fs::write(&file, b"caller data").unwrap();
1591 assert_eq!(directory_names(&file).unwrap_err().code(), "GF_IO");
1592 }
1593}