Skip to main content

graphforge_storage/
project_portable.rs

1//! Deterministic, bounded portable project-generation envelopes.
2//!
3//! The envelope is intentionally not an archive of the live project layout.
4//! It contains one resolved immutable generation, its canonical capability and
5//! participant inventory, and participant bytes. Locks, journals, attempts,
6//! trash, caches, leases, manifests, and `CURRENT` are never enumerated.
7
8use 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/// Default resource bounds for portable envelope decoding.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct PortableProjectLimits {
32    /// Maximum complete envelope size.
33    pub max_envelope_bytes: u64,
34    /// Maximum canonical JSON header size.
35    pub max_header_bytes: u64,
36    /// Maximum participant count.
37    pub max_participants: usize,
38    /// Maximum size of one participant.
39    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/// Deterministic export result.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct PortableExportReceipt {
56    /// Exported immutable generation.
57    pub generation_uuid: Uuid,
58    /// SHA-256 of the complete envelope.
59    pub envelope_sha256: [u8; 32],
60    /// Complete envelope byte length.
61    pub byte_length: u64,
62    /// Number of participants in the envelope.
63    pub participant_count: usize,
64}
65
66/// Import result after atomic generation publication.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct PortableImportReceipt {
69    /// Integrity digest of the imported envelope.
70    pub envelope_sha256: [u8; 32],
71    /// Original exported generation identity recorded by the envelope.
72    pub source_generation_uuid: Uuid,
73    /// Receipt for the newly published local generation.
74    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
115/// Encode one already-resolved generation into deterministic portable bytes.
116///
117/// The caller may resolve either `CURRENT` or a checkpoint before calling this
118/// function. Resolution pins the generation; this function never follows live
119/// pointers or enumerates the project container.
120pub 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
199/// Atomically write a deterministic portable envelope to a regular file.
200pub 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
217/// Validate a complete envelope before initializing and atomically publishing
218/// it into a new, empty, or pristine initialized project directory.
219///
220/// `supported_capabilities` is the exact `(id, version)` inventory implemented
221/// by the calling binary. Unknown or version-mismatched capabilities fail
222/// before target mutation.
223pub 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
260/// Read a bounded regular envelope file and import it.
261///
262/// The source and every caller-controlled existing path component must not be
263/// symbolic links. The size bound is checked from metadata and again while
264/// reading, so replacement or growth races cannot cause an unbounded allocation.
265pub 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// macOS exposes stable system prefixes such as `/var` through root-owned
686// links. Those are outside a caller's control; project-local links are not.
687#[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(&regular, b"caller bytes").unwrap();
1424        assert_eq!(
1425            reject_export_destination(&regular).unwrap_err().code(),
1426            "GF_UNSUPPORTED_PROJECT_FORMAT"
1427        );
1428        assert_eq!(std::fs::read(&regular).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}