Skip to main content

graphforge_storage/
project_generation.rs

1//! Resolution of the one committed immutable project generation.
2//!
3//! `CURRENT` is the only commit authority. This module deliberately does not
4//! enumerate `generations/`, inspect transaction journals, or decode any
5//! participant table.
6
7use std::collections::BTreeSet;
8use std::fmt::Write as _;
9use std::fs::{File, OpenOptions};
10use std::io::Read;
11use std::path::{Component, Path, PathBuf};
12use std::sync::Arc;
13
14#[cfg(windows)]
15use std::sync::{Condvar, Mutex, OnceLock};
16
17use atomicwrites::{AllowOverwrite, AtomicFile};
18use fs4::fs_std::FileExt;
19use graphforge_core::{GfError, ProjectErrorCode};
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22use uuid::Uuid;
23
24use crate::project_failpoint;
25
26/// Immutable project-format marker path.
27pub const FORMAT_FILE: &str = "FORMAT";
28/// Sole committed-generation pointer path.
29pub const CURRENT_FILE: &str = "CURRENT";
30/// Exact bytes accepted for a v0.5 project container.
31pub const PROJECT_FORMAT_BYTES: &[u8] = b"graphforge-project/v1\n";
32
33const MAX_FORMAT_BYTES: u64 = 64;
34const MAX_CURRENT_BYTES: u64 = 1_024;
35const MAX_MANIFEST_BYTES: u64 = 4 * 1024 * 1024;
36const MANIFEST_FILE: &str = "manifest.json";
37const LEASE_FILE: &str = "lease.lock";
38const PARTICIPANTS_DIR: &str = "participants";
39const MAX_PARTICIPANT_INVENTORY_ENTRIES: usize = 100_000;
40
41/// One capability declared by the committed generation manifest.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ProjectCapabilityDescriptor {
44    /// Stable lowercase machine identifier.
45    pub capability_id: String,
46    /// Positive capability contract version.
47    pub capability_version: u32,
48}
49
50/// One verified immutable participant copied from a committed generation.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ProjectParticipantSnapshot {
53    /// Stable capability ID.
54    pub capability_id: String,
55    /// Capability contract version.
56    pub capability_version: u32,
57    /// Stable record-family ID.
58    pub record_family_id: String,
59    /// Record contract version.
60    pub record_version: u32,
61    /// Persisted encoding (`parquet`, `arrow`, or `json`).
62    pub encoding: String,
63    /// Canonical schema fingerprint.
64    pub schema_fingerprint: [u8; 32],
65    /// Logical row count.
66    pub row_count: u64,
67    /// Exact verified persisted bytes.
68    pub bytes: Vec<u8>,
69}
70
71/// Manifest-only participant identity used by bounded checkpoint summaries.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ProjectParticipantDescriptor {
74    /// Owning capability ID.
75    pub capability_id: String,
76    /// Owning capability contract version.
77    pub capability_version: u32,
78    /// Stable record-family ID.
79    pub record_family_id: String,
80    /// Record contract version.
81    pub record_version: u32,
82    /// Persisted encoding.
83    pub encoding: String,
84    /// Canonical schema fingerprint.
85    pub schema_fingerprint: [u8; 32],
86    /// Logical row count.
87    pub row_count: u64,
88    /// Exact persisted content digest.
89    pub content_sha256: [u8; 32],
90}
91
92/// A validated, lifetime-pinned view of one committed project generation.
93///
94/// The retained lease handle gives publication/cleanup work a stable file
95/// identity to lock. The lock protocol itself lands with atomic publication
96/// and recovery; resolution never follows `CURRENT` again after construction.
97#[derive(Debug, Clone)]
98pub struct ResolvedProjectGeneration {
99    container_root: PathBuf,
100    generation_uuid: Uuid,
101    generation_root: PathBuf,
102    manifest_sha256: [u8; 32],
103    manifest: Arc<GenerationManifest>,
104    _lease_handle: Arc<GenerationLease>,
105}
106
107#[derive(Debug)]
108struct GenerationLease(File);
109
110impl Drop for GenerationLease {
111    fn drop(&mut self) {
112        let _ = FileExt::unlock(&self.0);
113    }
114}
115
116impl ResolvedProjectGeneration {
117    /// Canonical project container root used for this resolution.
118    #[must_use]
119    pub fn container_root(&self) -> &Path {
120        &self.container_root
121    }
122
123    /// UUID named by the validated `CURRENT` record.
124    #[must_use]
125    pub const fn generation_uuid(&self) -> Uuid {
126        self.generation_uuid
127    }
128
129    /// Immutable selected generation root.
130    #[must_use]
131    pub fn generation_root(&self) -> &Path {
132        &self.generation_root
133    }
134
135    /// Root beneath which every generation participant is resolved.
136    #[must_use]
137    pub fn participants_root(&self) -> PathBuf {
138        self.generation_root.join(PARTICIPANTS_DIR)
139    }
140
141    /// SHA-256 over the exact selected `manifest.json` bytes.
142    #[must_use]
143    pub const fn manifest_sha256(&self) -> [u8; 32] {
144        self.manifest_sha256
145    }
146
147    /// Return the manifest-declared capabilities in canonical ID order.
148    ///
149    /// This reads no participant files and does not infer capabilities from
150    /// directory contents.
151    #[must_use]
152    pub fn capabilities(&self) -> Vec<ProjectCapabilityDescriptor> {
153        self.manifest
154            .capabilities
155            .iter()
156            .map(|capability| ProjectCapabilityDescriptor {
157                capability_id: capability.capability_id.clone(),
158                capability_version: capability.capability_version,
159            })
160            .collect()
161    }
162
163    /// Look up one manifest-declared capability without opening participants.
164    ///
165    /// # Errors
166    /// Returns `GF_PROJECT_CORRUPT` for an invalid machine identifier.
167    pub fn capability(
168        &self,
169        capability_id: &str,
170    ) -> Result<Option<ProjectCapabilityDescriptor>, GfError> {
171        validate_machine_id(capability_id)?;
172        Ok(self
173            .manifest
174            .capabilities
175            .binary_search_by(|entry| entry.capability_id.as_str().cmp(capability_id))
176            .ok()
177            .map(|index| {
178                let capability = &self.manifest.capabilities[index];
179                ProjectCapabilityDescriptor {
180                    capability_id: capability.capability_id.clone(),
181                    capability_version: capability.capability_version,
182                }
183            }))
184    }
185
186    /// Require one exact capability version before a domain opens its files.
187    ///
188    /// This consults only the committed manifest. Domain APIs call it before
189    /// resolving or decoding any capability participant.
190    ///
191    /// # Errors
192    /// Returns `GF_CAPABILITY_DISABLED` when absent and
193    /// `GF_UNSUPPORTED_CAPABILITY_VERSION` for any other declared version.
194    pub fn require_capability(
195        &self,
196        capability_id: &str,
197        supported_version: u32,
198    ) -> Result<ProjectCapabilityDescriptor, GfError> {
199        let Some(capability) = self.capability(capability_id)? else {
200            return Err(project_error(
201                ProjectErrorCode::CapabilityDisabled,
202                format!("capability {capability_id} is not enabled"),
203            ));
204        };
205        if capability.capability_version != supported_version {
206            return Err(project_error(
207                ProjectErrorCode::UnsupportedCapabilityVersion,
208                format!(
209                    "capability {capability_id}@{} is not supported",
210                    capability.capability_version
211                ),
212            ));
213        }
214        Ok(capability)
215    }
216
217    /// UUID of the selected generation's verified parent, when present.
218    #[must_use]
219    pub fn parent_generation_uuid(&self) -> Option<Uuid> {
220        self.manifest
221            .parent_generation_uuid
222            .as_deref()
223            .and_then(|value| Uuid::parse_str(value).ok())
224    }
225
226    /// Resolve a requested participant path without accepting caller path
227    /// components or parsing any participant bytes.
228    ///
229    /// # Errors
230    /// Returns `GF_PROJECT_CORRUPT` if the manifest path is not a normalized,
231    /// contained, non-link path or if the requested descriptor is absent.
232    pub fn participant_path(
233        &self,
234        capability_id: &str,
235        record_family_id: &str,
236    ) -> Result<PathBuf, GfError> {
237        validate_machine_id(capability_id)?;
238        validate_machine_id(record_family_id)?;
239        let descriptor = self
240            .manifest
241            .participants
242            .iter()
243            .find(|entry| {
244                entry.capability_id == capability_id && entry.record_family_id == record_family_id
245            })
246            .ok_or_else(|| corrupt("requested participant is absent from generation manifest"))?;
247        let relative = Path::new(&descriptor.relative_path);
248        validate_relative_path(relative)?;
249        let participants_root = self.participants_root();
250        let candidate = participants_root.join(relative);
251        reject_link_components(&participants_root, relative)?;
252        Ok(candidate)
253    }
254
255    /// Read and verify every participant in canonical manifest order.
256    ///
257    /// This is an explicit write-orchestration operation used to carry
258    /// unchanged participants into a complete replacement generation. Normal
259    /// graph opens and capability inspection never call it.
260    ///
261    /// # Errors
262    /// Returns `GF_PROJECT_CORRUPT` when a participant is missing, linked,
263    /// oversized relative to its manifest, or fails its exact content digest.
264    pub fn participant_snapshots(&self) -> Result<Vec<ProjectParticipantSnapshot>, GfError> {
265        let mut snapshots = Vec::with_capacity(self.manifest.participants.len());
266        for descriptor in &self.manifest.participants {
267            snapshots.push(self.read_participant_snapshot(descriptor)?);
268        }
269        Ok(snapshots)
270    }
271
272    /// Return the canonical manifest inventory without opening participant bytes.
273    ///
274    /// # Errors
275    /// Returns `GF_PROJECT_CORRUPT` if a committed digest is malformed.
276    pub fn participant_descriptors(&self) -> Result<Vec<ProjectParticipantDescriptor>, GfError> {
277        self.manifest
278            .participants
279            .iter()
280            .map(|entry| {
281                Ok(ProjectParticipantDescriptor {
282                    capability_id: entry.capability_id.clone(),
283                    capability_version: entry.capability_version,
284                    record_family_id: entry.record_family_id.clone(),
285                    record_version: entry.record_version,
286                    encoding: entry.encoding.clone(),
287                    schema_fingerprint: parse_sha256(&entry.schema_fingerprint)?,
288                    row_count: entry.row_count,
289                    content_sha256: parse_sha256(&entry.content_sha256)?,
290                })
291            })
292            .collect()
293    }
294
295    /// Read and verify one requested participant without opening any sibling
296    /// capability or record family.
297    ///
298    /// # Errors
299    /// Returns `GF_PROJECT_CORRUPT` when the requested participant exists but
300    /// is missing, linked, oversized, or fails its exact content digest.
301    pub fn participant_snapshot(
302        &self,
303        capability_id: &str,
304        record_family_id: &str,
305    ) -> Result<Option<ProjectParticipantSnapshot>, GfError> {
306        validate_machine_id(capability_id)?;
307        validate_machine_id(record_family_id)?;
308        self.manifest
309            .participants
310            .iter()
311            .find(|entry| {
312                entry.capability_id == capability_id && entry.record_family_id == record_family_id
313            })
314            .map(|descriptor| self.read_participant_snapshot(descriptor))
315            .transpose()
316    }
317
318    fn read_participant_snapshot(
319        &self,
320        descriptor: &ParticipantDescriptor,
321    ) -> Result<ProjectParticipantSnapshot, GfError> {
322        let path =
323            self.participant_path(&descriptor.capability_id, &descriptor.record_family_id)?;
324        let bytes = read_exact_participant(&path, descriptor.byte_length)?;
325        let digest: [u8; 32] = Sha256::digest(&bytes).into();
326        if digest != parse_sha256(&descriptor.content_sha256)? {
327            return Err(corrupt(
328                "participant content digest does not match manifest",
329            ));
330        }
331        Ok(ProjectParticipantSnapshot {
332            capability_id: descriptor.capability_id.clone(),
333            capability_version: descriptor.capability_version,
334            record_family_id: descriptor.record_family_id.clone(),
335            record_version: descriptor.record_version,
336            encoding: descriptor.encoding.clone(),
337            schema_fingerprint: parse_sha256(&descriptor.schema_fingerprint)?,
338            row_count: descriptor.row_count,
339            bytes,
340        })
341    }
342
343    /// Prove that the physical participant tree exactly matches the manifest.
344    ///
345    /// Capability transitions call this before copying a complete generation,
346    /// so untracked graph files can never be silently dropped. This bounded
347    /// scan is an explicit write precondition; normal opens and capability
348    /// reads remain manifest-only.
349    ///
350    /// # Errors
351    /// Returns `GF_TRANSACTION_FAILED` for an untracked, missing, linked,
352    /// non-UTF-8, or excessively large inventory.
353    pub fn validate_complete_participant_inventory(&self) -> Result<(), GfError> {
354        let expected = self
355            .manifest
356            .participants
357            .iter()
358            .map(|entry| entry.relative_path.clone())
359            .collect::<BTreeSet<_>>();
360        let root = self.participants_root();
361        let mut directories = vec![root.clone()];
362        let mut observed = BTreeSet::new();
363        let mut entry_count = 0_usize;
364        while let Some(directory) = directories.pop() {
365            for entry in std::fs::read_dir(&directory)
366                .map_err(|_| transaction_failed("participant inventory cannot be read"))?
367            {
368                entry_count = entry_count.saturating_add(1);
369                if entry_count > MAX_PARTICIPANT_INVENTORY_ENTRIES {
370                    return Err(transaction_failed(
371                        "participant inventory exceeds the entry limit",
372                    ));
373                }
374                let entry =
375                    entry.map_err(|_| transaction_failed("participant entry cannot be read"))?;
376                let file_type = entry
377                    .file_type()
378                    .map_err(|_| transaction_failed("participant type cannot be read"))?;
379                if file_type.is_symlink() {
380                    return Err(transaction_failed("participant inventory contains a link"));
381                }
382                let path = entry.path();
383                if file_type.is_dir() {
384                    directories.push(path);
385                } else if file_type.is_file() {
386                    let relative = path
387                        .strip_prefix(&root)
388                        .map_err(|_| transaction_failed("participant path is not contained"))?;
389                    validate_relative_path(relative)
390                        .map_err(|_| transaction_failed("participant path is invalid"))?;
391                    let relative = relative
392                        .to_str()
393                        .ok_or_else(|| transaction_failed("participant path is not UTF-8"))?
394                        .replace(std::path::MAIN_SEPARATOR, "/");
395                    observed.insert(relative);
396                } else {
397                    return Err(transaction_failed(
398                        "participant inventory contains a special file",
399                    ));
400                }
401            }
402        }
403        if observed != expected {
404            return Err(transaction_failed(
405                "participant inventory does not match the committed manifest",
406            ));
407        }
408        Ok(())
409    }
410}
411
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
413#[serde(deny_unknown_fields)]
414struct CurrentRecord {
415    format: String,
416    format_version: u32,
417    generation_uuid: String,
418    generation_manifest_sha256: String,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
422#[serde(deny_unknown_fields)]
423struct GenerationManifest {
424    format: String,
425    format_version: u32,
426    generation_uuid: String,
427    parent_generation_uuid: Option<String>,
428    transaction_uuid: String,
429    capabilities: Vec<CapabilityDescriptor>,
430    participants: Vec<ParticipantDescriptor>,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
434#[serde(deny_unknown_fields)]
435struct CapabilityDescriptor {
436    capability_id: String,
437    capability_version: u32,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
441#[serde(deny_unknown_fields)]
442struct ParticipantDescriptor {
443    capability_id: String,
444    capability_version: u32,
445    record_family_id: String,
446    record_version: u32,
447    relative_path: String,
448    encoding: String,
449    byte_length: u64,
450    row_count: u64,
451    schema_fingerprint: String,
452    content_sha256: String,
453}
454
455/// Resolve exactly the generation named by `CURRENT`.
456///
457/// This performs bounded reads of `FORMAT`, `CURRENT`, and the selected
458/// generation manifest only. It never enumerates directories or opens
459/// participant data.
460///
461/// # Errors
462/// Returns a stable project-format error for unsupported, uninitialized, or
463/// corrupt project roots.
464pub fn resolve_project_generation(
465    container_root: impl AsRef<Path>,
466) -> Result<ResolvedProjectGeneration, GfError> {
467    let supplied_root = container_root.as_ref();
468    reject_root_link(supplied_root)?;
469    let root = supplied_root
470        .canonicalize()
471        .map_err(|_| unsupported("project root does not exist or is inaccessible"))?;
472
473    let format_path = root.join(FORMAT_FILE);
474    let format_bytes = read_bounded_regular_file(&format_path, MAX_FORMAT_BYTES)
475        .map_err(|_| unsupported("project root does not contain the supported FORMAT marker"))?;
476    if format_bytes != PROJECT_FORMAT_BYTES {
477        return Err(unsupported("project FORMAT marker is not supported"));
478    }
479
480    let current_path = root.join(CURRENT_FILE);
481    loop {
482        if !current_path.exists() {
483            return Err(project_error(
484                ProjectErrorCode::ProjectUninitialized,
485                "project has no committed generation",
486            ));
487        }
488        let current_bytes = read_bounded_regular_file(&current_path, MAX_CURRENT_BYTES)
489            .map_err(|_| corrupt("CURRENT is missing, linked, oversized, or unreadable"))?;
490        let current: CurrentRecord = parse_canonical_json_line(&current_bytes, "CURRENT")?;
491        validate_format(&current.format, current.format_version)?;
492        let generation_uuid = parse_canonical_uuid(&current.generation_uuid)?;
493        let expected_manifest_digest = parse_sha256(&current.generation_manifest_sha256)?;
494
495        let generations_dir = root.join("generations");
496        reject_exact_directory(&generations_dir)?;
497        let selected_dir = generations_dir.join(generation_uuid.hyphenated().to_string());
498        reject_exact_directory(&selected_dir)?;
499        let lease_path = selected_dir.join(LEASE_FILE);
500        let lease = acquire_generation_lease(&lease_path)?;
501
502        // A cleanup may have raced between pointer resolution and lease
503        // acquisition. Only a byte-identical reread can pin this generation.
504        let confirmed_current = read_bounded_regular_file(&current_path, MAX_CURRENT_BYTES)
505            .map_err(|_| corrupt("CURRENT changed to an invalid record during open"))?;
506        if confirmed_current != current_bytes {
507            continue;
508        }
509        reject_exact_directory(&selected_dir)?;
510
511        let manifest_path = selected_dir.join(MANIFEST_FILE);
512        let manifest_bytes = read_bounded_regular_file(&manifest_path, MAX_MANIFEST_BYTES)
513            .map_err(|_| corrupt("selected generation manifest is missing or invalid"))?;
514        let actual_digest: [u8; 32] = Sha256::digest(&manifest_bytes).into();
515        if actual_digest != expected_manifest_digest {
516            return Err(corrupt(
517                "selected generation manifest digest does not match CURRENT",
518            ));
519        }
520        let manifest: GenerationManifest =
521            parse_canonical_json_line(&manifest_bytes, "generation manifest")?;
522        validate_manifest(&manifest, generation_uuid)?;
523        reject_exact_directory(&selected_dir.join(PARTICIPANTS_DIR))?;
524
525        return Ok(ResolvedProjectGeneration {
526            container_root: root,
527            generation_uuid,
528            generation_root: selected_dir,
529            manifest_sha256: actual_digest,
530            manifest: Arc::new(manifest),
531            _lease_handle: Arc::new(lease),
532        });
533    }
534}
535
536pub(crate) fn resolve_verified_generation(
537    container_root: &Path,
538    generation_uuid: Uuid,
539    expected_manifest_digest: [u8; 32],
540) -> Result<ResolvedProjectGeneration, GfError> {
541    reject_root_link(container_root)?;
542    let root = container_root
543        .canonicalize()
544        .map_err(|_| unsupported("project root does not exist or is inaccessible"))?;
545    let format_bytes = read_bounded_regular_file(&root.join(FORMAT_FILE), MAX_FORMAT_BYTES)
546        .map_err(|_| unsupported("project root does not contain the supported FORMAT marker"))?;
547    if format_bytes != PROJECT_FORMAT_BYTES {
548        return Err(unsupported("project FORMAT marker is not supported"));
549    }
550    let generations_dir = root.join("generations");
551    reject_exact_directory(&generations_dir)?;
552    let selected_dir = generations_dir.join(generation_uuid.hyphenated().to_string());
553    reject_exact_directory(&selected_dir)?;
554    let lease = acquire_generation_lease(&selected_dir.join(LEASE_FILE))?;
555    reject_exact_directory(&selected_dir)?;
556    let manifest_bytes =
557        read_bounded_regular_file(&selected_dir.join(MANIFEST_FILE), MAX_MANIFEST_BYTES)
558            .map_err(|_| corrupt("selected generation manifest is missing or invalid"))?;
559    let actual_digest: [u8; 32] = Sha256::digest(&manifest_bytes).into();
560    if actual_digest != expected_manifest_digest {
561        return Err(corrupt(
562            "selected generation manifest digest does not match checkpoint",
563        ));
564    }
565    let manifest: GenerationManifest =
566        parse_canonical_json_line(&manifest_bytes, "generation manifest")?;
567    validate_manifest(&manifest, generation_uuid)?;
568    reject_exact_directory(&selected_dir.join(PARTICIPANTS_DIR))?;
569    Ok(ResolvedProjectGeneration {
570        container_root: root,
571        generation_uuid,
572        generation_root: selected_dir,
573        manifest_sha256: actual_digest,
574        manifest: Arc::new(manifest),
575        _lease_handle: Arc::new(lease),
576    })
577}
578
579/// Open a supported project or create the first committed generation in an
580/// explicitly empty directory.
581///
582/// The project-root directory itself is locked exclusively across the
583/// empty-root decision and initial publication. This does not add a file to an
584/// unsupported root, and concurrent first openers therefore converge on the
585/// generation published by the lock winner.
586///
587/// # Errors
588/// Returns `GF_UNSUPPORTED_PROJECT_FORMAT` without mutation when the directory
589/// is not empty, or a storage error if creating the new container fails.
590pub fn open_or_initialize_project(
591    container_root: impl AsRef<Path>,
592) -> Result<ResolvedProjectGeneration, GfError> {
593    let root = container_root.as_ref();
594    reject_root_link(root)?;
595    let _root_lock = lock_project_root(root)?;
596    let mut entries = std::fs::read_dir(root).map_err(|error| {
597        GfError::Storage(format!("failed to inspect new project root: {error}"))
598    })?;
599    if entries
600        .next()
601        .transpose()
602        .map_err(|error| GfError::Storage(format!("failed to inspect new project root: {error}")))?
603        .is_some()
604    {
605        return match resolve_project_generation(root) {
606            Err(error) if error.code() == "GF_PROJECT_UNINITIALIZED" => {
607                let generation_uuid = validate_resumable_uninitialized_layout(root)?;
608                initialize_empty_generation(root, false, Some(generation_uuid))
609            }
610            result => result,
611        };
612    }
613    initialize_empty_generation(root, true, None)
614}
615
616fn validate_resumable_uninitialized_layout(root: &Path) -> Result<Uuid, GfError> {
617    let mut root_entries = std::fs::read_dir(root)
618        .map_err(|_| unsupported("uninitialized project layout cannot be inspected"))?;
619    let mut root_count = 0_usize;
620    for entry in &mut root_entries {
621        let entry = entry.map_err(|_| unsupported("uninitialized project layout is unreadable"))?;
622        let name = entry.file_name();
623        if crate::project_publication::cleanup_atomicwrite_temp(&entry.path())? {
624            continue;
625        }
626        root_count += 1;
627        if root_count > 2 {
628            return Err(unsupported(
629                "uninitialized project contains unknown root entries",
630            ));
631        }
632        if name != FORMAT_FILE && name != "generations" {
633            return Err(unsupported(
634                "uninitialized project contains unknown root entries",
635            ));
636        }
637    }
638    let generations = root.join("generations");
639    reject_exact_directory(&generations)
640        .map_err(|_| unsupported("uninitialized generations directory is invalid"))?;
641    let mut generation_count = 0_usize;
642    let mut generation_uuids = Vec::new();
643    for entry in std::fs::read_dir(&generations)
644        .map_err(|_| unsupported("uninitialized generations cannot be inspected"))?
645    {
646        generation_count += 1;
647        if generation_count > 16 {
648            return Err(unsupported(
649                "uninitialized project contains too many private generations",
650            ));
651        }
652        let entry =
653            entry.map_err(|_| unsupported("uninitialized generation entry is unreadable"))?;
654        let name = entry
655            .file_name()
656            .to_str()
657            .map(str::to_owned)
658            .ok_or_else(|| unsupported("uninitialized generation UUID is invalid"))?;
659        let generation_uuid = parse_canonical_uuid(&name)
660            .map_err(|_| unsupported("uninitialized generation UUID is invalid"))?;
661        validate_partial_generation(&entry.path())?;
662        generation_uuids.push(generation_uuid);
663    }
664    generation_uuids.sort_unstable();
665    let generation_uuid = generation_uuids
666        .pop()
667        .ok_or_else(|| unsupported("uninitialized project has no private generation"))?;
668    for abandoned_uuid in generation_uuids {
669        remove_partial_generation(&generations, abandoned_uuid)?;
670    }
671    reset_partial_generation(&generations, generation_uuid)?;
672    sync_directory(&generations)?;
673    Ok(generation_uuid)
674}
675
676fn validate_partial_generation(generation: &Path) -> Result<(), GfError> {
677    reject_exact_directory(generation)
678        .map_err(|_| unsupported("uninitialized generation directory is invalid"))?;
679    let mut count = 0_usize;
680    for entry in std::fs::read_dir(generation)
681        .map_err(|_| unsupported("uninitialized generation cannot be inspected"))?
682    {
683        count += 1;
684        if count > 3 {
685            return Err(unsupported(
686                "uninitialized generation contains unknown entries",
687            ));
688        }
689        let entry =
690            entry.map_err(|_| unsupported("uninitialized generation entry is unreadable"))?;
691        let name = entry.file_name();
692        if name == PARTICIPANTS_DIR {
693            reject_exact_directory(&entry.path())
694                .map_err(|_| unsupported("uninitialized participants directory is invalid"))?;
695            validate_partial_workspace_participants(&entry.path())?;
696        } else if name == LEASE_FILE || name == MANIFEST_FILE {
697            let metadata = std::fs::symlink_metadata(entry.path())
698                .map_err(|_| unsupported("uninitialized generation file is unreadable"))?;
699            if !metadata.is_file() || metadata.file_type().is_symlink() {
700                return Err(unsupported("uninitialized generation file is invalid"));
701            }
702        } else {
703            return Err(unsupported(
704                "uninitialized generation contains unknown entries",
705            ));
706        }
707    }
708    Ok(())
709}
710
711fn remove_partial_generation(generations: &Path, generation_uuid: Uuid) -> Result<(), GfError> {
712    let generation = generations.join(generation_uuid.hyphenated().to_string());
713    reset_partial_generation(generations, generation_uuid)?;
714    let participants = generation.join(PARTICIPANTS_DIR);
715    if participants.exists() {
716        std::fs::remove_dir(&participants).map_err(|error| {
717            GfError::Storage(format!(
718                "failed to remove interrupted participants directory: {error}"
719            ))
720        })?;
721    }
722    std::fs::remove_dir(&generation).map_err(|error| {
723        GfError::Storage(format!(
724            "failed to remove interrupted generation directory: {error}"
725        ))
726    })
727}
728
729fn reset_partial_generation(generations: &Path, generation_uuid: Uuid) -> Result<(), GfError> {
730    let generation = generations.join(generation_uuid.hyphenated().to_string());
731    let workspace = generation.join(PARTICIPANTS_DIR).join("workspace");
732    if workspace.exists() {
733        for family in ["configuration.json", "ontology.json"] {
734            let path = workspace.join(family);
735            if path.exists() {
736                std::fs::remove_file(&path).map_err(|error| {
737                    GfError::Storage(format!("failed to reset workspace participant: {error}"))
738                })?;
739            }
740        }
741        std::fs::remove_dir(&workspace).map_err(|error| {
742            GfError::Storage(format!("failed to reset workspace directory: {error}"))
743        })?;
744    }
745    for name in [LEASE_FILE, MANIFEST_FILE] {
746        let path = generation.join(name);
747        if path.exists() {
748            std::fs::remove_file(&path).map_err(|error| {
749                GfError::Storage(format!("failed to reset interrupted generation: {error}"))
750            })?;
751        }
752    }
753    sync_directory(&generation)
754}
755
756fn validate_partial_workspace_participants(participants: &Path) -> Result<(), GfError> {
757    let entries = std::fs::read_dir(participants)
758        .map_err(|_| unsupported("uninitialized participants cannot be inspected"))?
759        .collect::<Result<Vec<_>, _>>()
760        .map_err(|_| unsupported("uninitialized participant entry is unreadable"))?;
761    if entries.is_empty() {
762        return Ok(());
763    }
764    if entries.len() != 1 || entries[0].file_name() != "workspace" {
765        return Err(unsupported(
766            "uninitialized participants contain unknown entries",
767        ));
768    }
769    reject_exact_directory(&entries[0].path())
770        .map_err(|_| unsupported("uninitialized workspace directory is invalid"))?;
771    let mut names = std::fs::read_dir(entries[0].path())
772        .map_err(|_| unsupported("uninitialized workspace cannot be inspected"))?
773        .map(|entry| {
774            let entry =
775                entry.map_err(|_| unsupported("uninitialized workspace entry is unreadable"))?;
776            let metadata = std::fs::symlink_metadata(entry.path())
777                .map_err(|_| unsupported("uninitialized workspace entry is unreadable"))?;
778            if !metadata.is_file() || metadata.file_type().is_symlink() {
779                return Err(unsupported("uninitialized workspace entry is invalid"));
780            }
781            entry
782                .file_name()
783                .into_string()
784                .map_err(|_| unsupported("uninitialized workspace entry name is invalid"))
785        })
786        .collect::<Result<Vec<_>, _>>()?;
787    names.sort();
788    if names != ["configuration.json", "ontology.json"] && names != ["configuration.json"] {
789        return Err(unsupported(
790            "uninitialized workspace contains unknown entries",
791        ));
792    }
793    Ok(())
794}
795
796fn initialize_empty_generation(
797    root: &Path,
798    write_format: bool,
799    generation_uuid: Option<Uuid>,
800) -> Result<ResolvedProjectGeneration, GfError> {
801    let generation_uuid = generation_uuid.unwrap_or_else(Uuid::now_v7);
802    let transaction_uuid = Uuid::now_v7();
803    let generation_root = root
804        .join("generations")
805        .join(generation_uuid.hyphenated().to_string());
806    let participants_root = generation_root.join(PARTICIPANTS_DIR);
807    std::fs::create_dir_all(&participants_root)
808        .map_err(|error| GfError::Storage(format!("failed to create project layout: {error}")))?;
809    if write_format {
810        let format_path = root.join(FORMAT_FILE);
811        write_new_synced(&format_path, PROJECT_FORMAT_BYTES, "project format")?;
812        project_failpoint::hit(
813            "project.after_format_fsync",
814            Some(transaction_uuid),
815            Some(generation_uuid),
816            "FORMAT",
817            false,
818        )?;
819    }
820    let participant_descriptors = install_empty_workspace_participants(&participants_root)?;
821    sync_directory(&participants_root)?;
822    sync_directory(&generation_root)?;
823    sync_directory(&root.join("generations"))?;
824    sync_directory(root)?;
825    if write_format {
826        project_failpoint::hit(
827            "project.after_container_dir_fsync",
828            Some(transaction_uuid),
829            Some(generation_uuid),
830            "CONTAINER",
831            false,
832        )?;
833    }
834    write_new_synced(&generation_root.join(LEASE_FILE), &[], "generation lease")?;
835    let manifest = GenerationManifest {
836        format: "graphforge-generation".into(),
837        format_version: 1,
838        generation_uuid: generation_uuid.hyphenated().to_string(),
839        parent_generation_uuid: None,
840        transaction_uuid: transaction_uuid.hyphenated().to_string(),
841        capabilities: vec![
842            CapabilityDescriptor {
843                capability_id: "graph".into(),
844                capability_version: 1,
845            },
846            CapabilityDescriptor {
847                capability_id: "workspace".into(),
848                capability_version: 1,
849            },
850        ],
851        participants: participant_descriptors,
852    };
853    let mut manifest_bytes = serde_json::to_vec(&manifest)
854        .map_err(|error| GfError::Storage(format!("failed to encode generation: {error}")))?;
855    manifest_bytes.push(b'\n');
856    write_new_synced(
857        &generation_root.join(MANIFEST_FILE),
858        &manifest_bytes,
859        "generation",
860    )?;
861    sync_directory(&generation_root)?;
862    sync_directory(&root.join("generations"))?;
863    let digest: [u8; 32] = Sha256::digest(&manifest_bytes).into();
864    let current = CurrentRecord {
865        format: "graphforge-project".into(),
866        format_version: 1,
867        generation_uuid: generation_uuid.hyphenated().to_string(),
868        generation_manifest_sha256: sha256_hex(digest),
869    };
870    let mut current_bytes = serde_json::to_vec(&current)
871        .map_err(|error| GfError::Storage(format!("failed to encode CURRENT: {error}")))?;
872    current_bytes.push(b'\n');
873    AtomicFile::new(root.join(CURRENT_FILE), AllowOverwrite)
874        .write(|file| {
875            use std::io::Write as _;
876
877            file.write_all(&current_bytes)?;
878            file.sync_all()
879        })
880        .map_err(|error| GfError::Storage(format!("failed to write CURRENT: {error}")))?;
881    sync_directory(root)?;
882    resolve_project_generation(root)
883}
884
885fn install_empty_workspace_participants(
886    participants_root: &Path,
887) -> Result<Vec<ParticipantDescriptor>, GfError> {
888    let workspace_participants = crate::workspace_participants::empty_workspace_participants()?;
889    let workspace_root = participants_root.join("workspace");
890    std::fs::create_dir_all(&workspace_root)
891        .map_err(|error| GfError::Storage(format!("failed to create project layout: {error}")))?;
892    let mut descriptors = Vec::with_capacity(workspace_participants.len());
893    for participant in workspace_participants {
894        let relative_path = format!(
895            "{}/{}.json",
896            participant.capability_id, participant.record_family_id
897        );
898        write_new_synced(
899            &participants_root.join(&relative_path),
900            &participant.bytes,
901            "workspace participant",
902        )?;
903        descriptors.push(ParticipantDescriptor {
904            capability_id: participant.capability_id,
905            capability_version: participant.capability_version,
906            record_family_id: participant.record_family_id,
907            record_version: participant.record_version,
908            relative_path,
909            encoding: "json".into(),
910            byte_length: participant.bytes.len() as u64,
911            row_count: participant.row_count,
912            schema_fingerprint: sha256_hex(participant.schema_fingerprint),
913            content_sha256: sha256_hex(Sha256::digest(&participant.bytes).into()),
914        });
915    }
916    descriptors.sort_by(|left, right| {
917        (
918            &left.capability_id,
919            &left.record_family_id,
920            &left.relative_path,
921        )
922            .cmp(&(
923                &right.capability_id,
924                &right.record_family_id,
925                &right.relative_path,
926            ))
927    });
928    sync_directory(&workspace_root)?;
929    Ok(descriptors)
930}
931
932fn write_new_synced(path: &Path, bytes: &[u8], name: &str) -> Result<(), GfError> {
933    use std::io::Write as _;
934
935    let mut file = OpenOptions::new()
936        .write(true)
937        .create_new(true)
938        .open(path)
939        .map_err(|error| GfError::Storage(format!("failed to create {name}: {error}")))?;
940    file.write_all(bytes)
941        .and_then(|()| file.sync_all())
942        .map_err(|error| GfError::Storage(format!("failed to write {name}: {error}")))
943}
944
945#[cfg(unix)]
946fn lock_project_root(path: &Path) -> Result<File, GfError> {
947    let root = File::open(path)
948        .map_err(|error| GfError::Storage(format!("failed to open project root: {error}")))?;
949    FileExt::lock_exclusive(&root)
950        .map_err(|error| GfError::Storage(format!("failed to lock project root: {error}")))?;
951    Ok(root)
952}
953
954#[cfg(windows)]
955static LOCAL_PROJECT_ROOT_LOCKS: OnceLock<(Mutex<BTreeSet<String>>, Condvar)> = OnceLock::new();
956
957#[cfg(windows)]
958struct LocalProjectRootLock {
959    name: String,
960}
961
962#[cfg(windows)]
963impl Drop for LocalProjectRootLock {
964    fn drop(&mut self) {
965        let (active, available) =
966            LOCAL_PROJECT_ROOT_LOCKS.get_or_init(|| (Mutex::new(BTreeSet::new()), Condvar::new()));
967        let mut active = active
968            .lock()
969            .unwrap_or_else(|poisoned| poisoned.into_inner());
970        active.remove(&self.name);
971        available.notify_all();
972    }
973}
974
975#[cfg(windows)]
976struct WindowsProjectRootLock {
977    kernel: Option<named_lock::NamedLockGuard>,
978    _local: LocalProjectRootLock,
979}
980
981#[cfg(windows)]
982impl Drop for WindowsProjectRootLock {
983    fn drop(&mut self) {
984        // Release the kernel mutex before waking a compatible initializer in
985        // this process. External owners remain fail-fast through `try_lock`.
986        drop(self.kernel.take());
987    }
988}
989
990#[cfg(windows)]
991fn acquire_local_project_root_lock(name: &str) -> LocalProjectRootLock {
992    let (active, available) =
993        LOCAL_PROJECT_ROOT_LOCKS.get_or_init(|| (Mutex::new(BTreeSet::new()), Condvar::new()));
994    let mut active = active
995        .lock()
996        .unwrap_or_else(|poisoned| poisoned.into_inner());
997    while active.contains(name) {
998        active = available
999            .wait(active)
1000            .unwrap_or_else(|poisoned| poisoned.into_inner());
1001    }
1002    active.insert(name.to_owned());
1003    LocalProjectRootLock {
1004        name: name.to_owned(),
1005    }
1006}
1007
1008#[cfg(windows)]
1009fn lock_project_root(path: &Path) -> Result<WindowsProjectRootLock, GfError> {
1010    let canonical = std::fs::canonicalize(path)
1011        .map_err(|error| GfError::Storage(format!("failed to lock project root: {error}")))?;
1012    let identity = canonical.as_os_str().to_string_lossy().to_lowercase();
1013    let digest: [u8; 32] = Sha256::digest(identity.as_bytes()).into();
1014    let name = format!("GraphForge.ProjectRoot.{}", sha256_hex(digest));
1015    let local = acquire_local_project_root_lock(&name);
1016    let lock = named_lock::NamedLock::create(&name)
1017        .map_err(|error| GfError::Storage(format!("failed to lock project root: {error}")))?;
1018
1019    match lock.try_lock() {
1020        Ok(kernel) => Ok(WindowsProjectRootLock {
1021            kernel: Some(kernel),
1022            _local: local,
1023        }),
1024        Err(named_lock::Error::WouldBlock) => Err(project_error(
1025            ProjectErrorCode::WriterBusy,
1026            "phase=PROJECT_ROOT_LOCK committed=false cause=busy",
1027        )),
1028        Err(error) => Err(GfError::Storage(format!(
1029            "failed to lock project root: {error}"
1030        ))),
1031    }
1032}
1033
1034#[cfg(all(not(unix), not(windows)))]
1035fn lock_project_root(_path: &Path) -> Result<File, GfError> {
1036    Err(GfError::Storage(
1037        "project-root locks are unsupported on this platform".into(),
1038    ))
1039}
1040
1041fn sync_directory(path: &Path) -> Result<(), GfError> {
1042    crate::project_publication::sync_directory(path).map_err(|error| match error {
1043        GfError::Storage(message) => {
1044            GfError::Storage(format!("failed to sync project directory: {message}"))
1045        }
1046        other => other,
1047    })
1048}
1049
1050fn validate_format(format: &str, version: u32) -> Result<(), GfError> {
1051    if format != "graphforge-project" || version != 1 {
1052        return Err(unsupported("project format version is not supported"));
1053    }
1054    Ok(())
1055}
1056
1057fn validate_manifest(manifest: &GenerationManifest, expected: Uuid) -> Result<(), GfError> {
1058    if manifest.format != "graphforge-generation" || manifest.format_version != 1 {
1059        return Err(corrupt("generation manifest format is invalid"));
1060    }
1061    if parse_canonical_uuid(&manifest.generation_uuid)? != expected {
1062        return Err(corrupt("generation manifest UUID does not match CURRENT"));
1063    }
1064    parse_canonical_uuid(&manifest.transaction_uuid)?;
1065    if let Some(parent) = &manifest.parent_generation_uuid {
1066        parse_canonical_uuid(parent)?;
1067    }
1068    if !manifest
1069        .capabilities
1070        .windows(2)
1071        .all(|pair| pair[0].capability_id < pair[1].capability_id)
1072    {
1073        return Err(corrupt(
1074            "generation capabilities are not in canonical order",
1075        ));
1076    }
1077    for capability in &manifest.capabilities {
1078        validate_machine_id(&capability.capability_id)?;
1079        if capability.capability_version == 0 {
1080            return Err(corrupt("capability version must be positive"));
1081        }
1082    }
1083    if manifest
1084        .capabilities
1085        .binary_search_by(|entry| entry.capability_id.as_str().cmp("graph"))
1086        .ok()
1087        .map(|index| manifest.capabilities[index].capability_version)
1088        != Some(1)
1089    {
1090        return Err(corrupt(
1091            "generation manifest must declare graph capability version 1",
1092        ));
1093    }
1094    if !manifest.participants.windows(2).all(|pair| {
1095        (
1096            &pair[0].capability_id,
1097            &pair[0].record_family_id,
1098            &pair[0].relative_path,
1099        ) < (
1100            &pair[1].capability_id,
1101            &pair[1].record_family_id,
1102            &pair[1].relative_path,
1103        )
1104    }) {
1105        return Err(corrupt(
1106            "generation participants are not in canonical order",
1107        ));
1108    }
1109    for participant in &manifest.participants {
1110        validate_machine_id(&participant.capability_id)?;
1111        validate_machine_id(&participant.record_family_id)?;
1112        if participant.capability_version == 0 || participant.record_version == 0 {
1113            return Err(corrupt("participant contract versions must be positive"));
1114        }
1115        let capability = manifest
1116            .capabilities
1117            .binary_search_by(|entry| entry.capability_id.cmp(&participant.capability_id))
1118            .ok()
1119            .map(|index| &manifest.capabilities[index])
1120            .ok_or_else(|| corrupt("participant capability is not declared"))?;
1121        if capability.capability_version != participant.capability_version {
1122            return Err(corrupt(
1123                "participant capability version does not match its declaration",
1124            ));
1125        }
1126        validate_relative_path(Path::new(&participant.relative_path))?;
1127        parse_sha256(&participant.content_sha256)?;
1128        parse_sha256(&participant.schema_fingerprint)?;
1129    }
1130    Ok(())
1131}
1132
1133pub(crate) fn validated_generation_parent(
1134    root: &Path,
1135    generation_uuid: Uuid,
1136) -> Result<Option<Uuid>, GfError> {
1137    validated_generation_metadata(root, generation_uuid).map(|(parent, _)| parent)
1138}
1139
1140pub(crate) fn validated_generation_manifest_sha256(
1141    root: &Path,
1142    generation_uuid: Uuid,
1143) -> Result<[u8; 32], GfError> {
1144    validated_generation_metadata(root, generation_uuid).map(|(_, digest)| digest)
1145}
1146
1147fn validated_generation_metadata(
1148    root: &Path,
1149    generation_uuid: Uuid,
1150) -> Result<(Option<Uuid>, [u8; 32]), GfError> {
1151    let generation_root = root
1152        .join("generations")
1153        .join(generation_uuid.hyphenated().to_string());
1154    reject_exact_directory(&generation_root)?;
1155    let manifest_bytes =
1156        read_bounded_regular_file(&generation_root.join(MANIFEST_FILE), MAX_MANIFEST_BYTES)
1157            .map_err(|_| corrupt("retained ancestor manifest is missing or invalid"))?;
1158    let manifest: GenerationManifest =
1159        parse_canonical_json_line(&manifest_bytes, "retained ancestor manifest")?;
1160    validate_manifest(&manifest, generation_uuid)?;
1161    let parent = manifest
1162        .parent_generation_uuid
1163        .as_deref()
1164        .map(parse_canonical_uuid)
1165        .transpose()?;
1166    Ok((parent, Sha256::digest(&manifest_bytes).into()))
1167}
1168
1169fn parse_canonical_json_line<T>(bytes: &[u8], name: &str) -> Result<T, GfError>
1170where
1171    T: serde::de::DeserializeOwned + Serialize,
1172{
1173    if !bytes.ends_with(b"\n") || bytes[..bytes.len().saturating_sub(1)].contains(&b'\n') {
1174        return Err(corrupt(format!("{name} is not one canonical JSON line")));
1175    }
1176    let parsed: T =
1177        serde_json::from_slice(bytes).map_err(|_| corrupt(format!("{name} is invalid JSON")))?;
1178    let mut canonical =
1179        serde_json::to_vec(&parsed).map_err(|_| corrupt(format!("{name} cannot be encoded")))?;
1180    canonical.push(b'\n');
1181    if canonical != bytes {
1182        return Err(corrupt(format!("{name} is not canonical JSON")));
1183    }
1184    Ok(parsed)
1185}
1186
1187fn parse_canonical_uuid(value: &str) -> Result<Uuid, GfError> {
1188    let parsed = Uuid::parse_str(value).map_err(|_| corrupt("UUID is invalid"))?;
1189    if parsed.hyphenated().to_string() != value {
1190        return Err(corrupt("UUID is not lowercase canonical hyphenated form"));
1191    }
1192    Ok(parsed)
1193}
1194
1195fn parse_sha256(value: &str) -> Result<[u8; 32], GfError> {
1196    if value.len() != 64
1197        || !value
1198            .bytes()
1199            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1200    {
1201        return Err(corrupt("SHA-256 is not 64 lowercase hexadecimal bytes"));
1202    }
1203    let mut output = [0_u8; 32];
1204    for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
1205        output[index] = (hex_nibble(pair[0]) << 4) | hex_nibble(pair[1]);
1206    }
1207    Ok(output)
1208}
1209
1210const fn hex_nibble(byte: u8) -> u8 {
1211    match byte {
1212        b'0'..=b'9' => byte - b'0',
1213        b'a'..=b'f' => byte - b'a' + 10,
1214        _ => 0,
1215    }
1216}
1217
1218fn sha256_hex(bytes: [u8; 32]) -> String {
1219    bytes
1220        .iter()
1221        .fold(String::with_capacity(64), |mut output, byte| {
1222            write!(output, "{byte:02x}").expect("writing to String is infallible");
1223            output
1224        })
1225}
1226
1227fn validate_machine_id(value: &str) -> Result<(), GfError> {
1228    if value.is_empty()
1229        || value.len() > 64
1230        || !value.bytes().all(|byte| {
1231            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
1232        })
1233    {
1234        return Err(corrupt("machine identifier is not lowercase ASCII"));
1235    }
1236    Ok(())
1237}
1238
1239fn validate_relative_path(path: &Path) -> Result<(), GfError> {
1240    if path.as_os_str().is_empty()
1241        || path.is_absolute()
1242        || path.components().any(|component| {
1243            !matches!(component, Component::Normal(_)) || component.as_os_str().to_str().is_none()
1244        })
1245    {
1246        return Err(corrupt(
1247            "participant path is not a normalized relative path",
1248        ));
1249    }
1250    Ok(())
1251}
1252
1253fn reject_root_link(path: &Path) -> Result<(), GfError> {
1254    let metadata = std::fs::symlink_metadata(path)
1255        .map_err(|_| unsupported("project root does not exist or is inaccessible"))?;
1256    if metadata.file_type().is_symlink() || !metadata.is_dir() {
1257        return Err(unsupported(
1258            "project root must be a real local directory, not a link",
1259        ));
1260    }
1261    Ok(())
1262}
1263
1264fn reject_exact_directory(path: &Path) -> Result<(), GfError> {
1265    let metadata =
1266        std::fs::symlink_metadata(path).map_err(|_| corrupt("required directory is missing"))?;
1267    if metadata.file_type().is_symlink() || !metadata.is_dir() {
1268        return Err(corrupt("required directory is linked or not a directory"));
1269    }
1270    Ok(())
1271}
1272
1273fn reject_link_components(root: &Path, relative: &Path) -> Result<(), GfError> {
1274    let mut candidate = root.to_path_buf();
1275    for component in relative.components() {
1276        let Component::Normal(part) = component else {
1277            return Err(corrupt("participant path escapes its generation"));
1278        };
1279        candidate.push(part);
1280        let metadata = std::fs::symlink_metadata(&candidate)
1281            .map_err(|_| corrupt("participant path is missing"))?;
1282        if metadata.file_type().is_symlink() {
1283            return Err(corrupt("participant path contains a link"));
1284        }
1285    }
1286    Ok(())
1287}
1288
1289fn open_regular_file(path: &Path) -> Result<File, std::io::Error> {
1290    let metadata = std::fs::symlink_metadata(path)?;
1291    if metadata.file_type().is_symlink() || !metadata.is_file() {
1292        return Err(std::io::Error::other("not a regular non-link file"));
1293    }
1294    #[cfg(unix)]
1295    {
1296        use std::os::unix::fs::MetadataExt;
1297        if metadata.nlink() != 1 {
1298            return Err(std::io::Error::other("hard-linked project file"));
1299        }
1300    }
1301    OpenOptions::new().read(true).open(path)
1302}
1303
1304fn acquire_generation_lease(path: &Path) -> Result<GenerationLease, GfError> {
1305    let handle = open_regular_file(path)
1306        .map_err(|_| corrupt("selected generation lease is missing or invalid"))?;
1307    FileExt::lock_shared(&handle)
1308        .map_err(|_| corrupt("selected generation lease cannot be acquired"))?;
1309    Ok(GenerationLease(handle))
1310}
1311
1312fn read_bounded_regular_file(path: &Path, maximum: u64) -> Result<Vec<u8>, std::io::Error> {
1313    let file = open_regular_file(path)?;
1314    let metadata = file.metadata()?;
1315    if metadata.len() > maximum {
1316        return Err(std::io::Error::other("file exceeds bounded read limit"));
1317    }
1318    let capacity = usize::try_from(metadata.len())
1319        .map_err(|_| std::io::Error::other("file length does not fit address space"))?;
1320    let mut bytes = Vec::with_capacity(capacity);
1321    file.take(maximum + 1).read_to_end(&mut bytes)?;
1322    if bytes.len() as u64 > maximum {
1323        return Err(std::io::Error::other("file exceeds bounded read limit"));
1324    }
1325    Ok(bytes)
1326}
1327
1328fn read_exact_participant(path: &Path, expected_length: u64) -> Result<Vec<u8>, GfError> {
1329    let file = open_regular_file(path)
1330        .map_err(|_| corrupt("participant is missing, linked, or unreadable"))?;
1331    let metadata = file
1332        .metadata()
1333        .map_err(|_| corrupt("participant metadata is unreadable"))?;
1334    if metadata.len() != expected_length {
1335        return Err(corrupt("participant byte length does not match manifest"));
1336    }
1337    let capacity = usize::try_from(expected_length)
1338        .map_err(|_| corrupt("participant byte length exceeds address space"))?;
1339    let mut bytes = Vec::with_capacity(capacity);
1340    file.take(expected_length.saturating_add(1))
1341        .read_to_end(&mut bytes)
1342        .map_err(|_| corrupt("participant cannot be read"))?;
1343    if u64::try_from(bytes.len()).ok() != Some(expected_length) {
1344        return Err(corrupt("participant byte length changed while reading"));
1345    }
1346    Ok(bytes)
1347}
1348
1349fn unsupported(message: impl Into<String>) -> GfError {
1350    project_error(ProjectErrorCode::UnsupportedProjectFormat, message)
1351}
1352
1353fn corrupt(message: impl Into<String>) -> GfError {
1354    project_error(ProjectErrorCode::ProjectCorrupt, message)
1355}
1356
1357fn transaction_failed(message: impl Into<String>) -> GfError {
1358    project_error(ProjectErrorCode::TransactionFailed, message)
1359}
1360
1361fn project_error(code: ProjectErrorCode, message: impl Into<String>) -> GfError {
1362    GfError::Project {
1363        code,
1364        message: message.into(),
1365    }
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370    use super::*;
1371    use std::fs;
1372    use std::sync::{Arc, Barrier};
1373
1374    #[cfg(windows)]
1375    const ROOT_LOCK_CONTENDER: &str =
1376        "project_generation::tests::windows_project_root_lock_contender";
1377    #[cfg(windows)]
1378    const ROOT_LOCK_ABANDONER: &str =
1379        "project_generation::tests::windows_project_root_lock_abandoner";
1380
1381    #[cfg(windows)]
1382    fn wait_for_lock_subprocess(mut child: std::process::Child, proof: &str) {
1383        use std::time::Duration;
1384        use wait_timeout::ChildExt as _;
1385
1386        let status = child
1387            .wait_timeout(Duration::from_secs(30))
1388            .unwrap_or_else(|error| panic!("failed to wait for {proof}: {error}"));
1389        let Some(status) = status else {
1390            child.kill().expect("failed to kill timed-out subprocess");
1391            child.wait().expect("failed to reap timed-out subprocess");
1392            panic!("{proof} timed out after 30 seconds");
1393        };
1394        assert!(status.success(), "{proof} failed with {status}");
1395    }
1396
1397    fn canonical_line<T: Serialize>(value: &T) -> Vec<u8> {
1398        let mut bytes = serde_json::to_vec(value).unwrap();
1399        bytes.push(b'\n');
1400        bytes
1401    }
1402
1403    fn install_generation(root: &Path, generation_uuid: Uuid) -> [u8; 32] {
1404        let generation_root = root
1405            .join("generations")
1406            .join(generation_uuid.hyphenated().to_string());
1407        fs::create_dir_all(generation_root.join(PARTICIPANTS_DIR)).unwrap();
1408        fs::write(generation_root.join(LEASE_FILE), []).unwrap();
1409        let manifest = GenerationManifest {
1410            format: "graphforge-generation".into(),
1411            format_version: 1,
1412            generation_uuid: generation_uuid.hyphenated().to_string(),
1413            parent_generation_uuid: None,
1414            transaction_uuid: Uuid::now_v7().hyphenated().to_string(),
1415            capabilities: vec![CapabilityDescriptor {
1416                capability_id: "graph".into(),
1417                capability_version: 1,
1418            }],
1419            participants: vec![],
1420        };
1421        let bytes = canonical_line(&manifest);
1422        fs::write(generation_root.join(MANIFEST_FILE), &bytes).unwrap();
1423        Sha256::digest(&bytes).into()
1424    }
1425
1426    fn write_current(root: &Path, generation_uuid: Uuid, digest: [u8; 32]) {
1427        let current = CurrentRecord {
1428            format: "graphforge-project".into(),
1429            format_version: 1,
1430            generation_uuid: generation_uuid.hyphenated().to_string(),
1431            generation_manifest_sha256: sha256_hex(digest),
1432        };
1433        fs::write(root.join(CURRENT_FILE), canonical_line(&current)).unwrap();
1434    }
1435
1436    fn project() -> (tempfile::TempDir, Uuid) {
1437        let root = tempfile::tempdir().unwrap();
1438        fs::write(root.path().join(FORMAT_FILE), PROJECT_FORMAT_BYTES).unwrap();
1439        fs::create_dir(root.path().join("generations")).unwrap();
1440        let generation_uuid = Uuid::now_v7();
1441        let digest = install_generation(root.path(), generation_uuid);
1442        write_current(root.path(), generation_uuid, digest);
1443        (root, generation_uuid)
1444    }
1445
1446    fn open_generation_lease(root: &Path, generation: Uuid) -> File {
1447        OpenOptions::new()
1448            .read(true)
1449            .write(true)
1450            .open(
1451                root.join("generations")
1452                    .join(generation.hyphenated().to_string())
1453                    .join(LEASE_FILE),
1454            )
1455            .unwrap()
1456    }
1457
1458    fn assert_code(error: GfError, expected: &str) {
1459        assert_eq!(error.code(), expected, "{error}");
1460    }
1461
1462    #[test]
1463    fn initial_generation_declares_graph_and_workspace_capabilities() {
1464        let root = tempfile::tempdir().unwrap();
1465
1466        let resolved = open_or_initialize_project(root.path()).unwrap();
1467
1468        assert_eq!(
1469            resolved.capabilities(),
1470            vec![
1471                ProjectCapabilityDescriptor {
1472                    capability_id: "graph".into(),
1473                    capability_version: 1,
1474                },
1475                ProjectCapabilityDescriptor {
1476                    capability_id: "workspace".into(),
1477                    capability_version: 1,
1478                },
1479            ]
1480        );
1481        let ontology = resolved
1482            .participant_snapshot("workspace", "ontology")
1483            .unwrap()
1484            .unwrap();
1485        assert_eq!(
1486            crate::WorkspaceOntology::from_canonical_json(&ontology.bytes)
1487                .unwrap()
1488                .mode,
1489            crate::WorkspaceOntologyMode::None
1490        );
1491        let configuration = resolved
1492            .participant_snapshot("workspace", "configuration")
1493            .unwrap()
1494            .unwrap();
1495        assert_eq!(
1496            crate::WorkspaceConfiguration::from_canonical_json(&configuration.bytes)
1497                .unwrap()
1498                .ontology_mode,
1499            crate::WorkspaceOntologyMode::None
1500        );
1501        assert_eq!(
1502            resolved.capability("graph").unwrap(),
1503            Some(ProjectCapabilityDescriptor {
1504                capability_id: "graph".into(),
1505                capability_version: 1,
1506            })
1507        );
1508        assert_eq!(resolved.capability("knowledge").unwrap(), None);
1509        assert_eq!(
1510            resolved
1511                .require_capability("knowledge", 1)
1512                .unwrap_err()
1513                .code(),
1514            "GF_CAPABILITY_DISABLED"
1515        );
1516        assert_eq!(
1517            resolved.require_capability("graph", 2).unwrap_err().code(),
1518            "GF_UNSUPPORTED_CAPABILITY_VERSION"
1519        );
1520    }
1521
1522    #[test]
1523    fn resolves_only_the_generation_named_by_current() {
1524        let (root, expected) = project();
1525        let abandoned = Uuid::now_v7();
1526        install_generation(root.path(), abandoned);
1527
1528        let resolved = resolve_project_generation(root.path()).unwrap();
1529
1530        assert_eq!(resolved.generation_uuid(), expected);
1531        assert_ne!(resolved.generation_uuid(), abandoned);
1532    }
1533
1534    #[test]
1535    fn concurrent_first_openers_converge_on_one_generation() {
1536        let root = tempfile::tempdir().unwrap();
1537        let barrier = Arc::new(Barrier::new(3));
1538        let mut workers = Vec::new();
1539        for _ in 0..2 {
1540            let root = root.path().to_owned();
1541            let barrier = Arc::clone(&barrier);
1542            workers.push(std::thread::spawn(move || {
1543                barrier.wait();
1544                open_or_initialize_project(root).unwrap().generation_uuid()
1545            }));
1546        }
1547
1548        barrier.wait();
1549        let first = workers.remove(0).join().unwrap();
1550        let second = workers.remove(0).join().unwrap();
1551
1552        assert_eq!(first, second);
1553        assert_eq!(
1554            fs::read_dir(root.path().join("generations"))
1555                .unwrap()
1556                .count(),
1557            1
1558        );
1559    }
1560
1561    #[test]
1562    fn project_root_lock_is_released_for_reopen() {
1563        let root = tempfile::tempdir().unwrap();
1564
1565        let first = open_or_initialize_project(root.path()).unwrap();
1566        let generation = first.generation_uuid();
1567        let reopened = open_or_initialize_project(root.path()).unwrap();
1568
1569        assert_eq!(reopened.generation_uuid(), generation);
1570        assert_eq!(first.generation_uuid(), generation);
1571    }
1572
1573    #[cfg(windows)]
1574    #[test]
1575    fn windows_project_root_lock_allows_owner_to_inspect_directory() {
1576        let root = tempfile::tempdir().unwrap();
1577        let _owner = lock_project_root(root.path()).unwrap();
1578
1579        assert_eq!(fs::read_dir(root.path()).unwrap().count(), 0);
1580    }
1581
1582    #[cfg(windows)]
1583    #[test]
1584    fn windows_project_directory_sync_uses_write_capable_handle() {
1585        let root = tempfile::tempdir().unwrap();
1586
1587        sync_directory(root.path()).unwrap();
1588    }
1589
1590    #[cfg(windows)]
1591    #[test]
1592    fn windows_project_root_lock_fails_closed_and_releases() {
1593        use std::process::Command;
1594
1595        let root = tempfile::tempdir().unwrap();
1596        let owner = lock_project_root(root.path()).unwrap();
1597
1598        let contender = Command::new(std::env::current_exe().unwrap())
1599            .arg("--exact")
1600            .arg(ROOT_LOCK_CONTENDER)
1601            .arg("--nocapture")
1602            .env("GRAPHFORGE_TEST_PROJECT_ROOT", root.path())
1603            .spawn()
1604            .unwrap();
1605        wait_for_lock_subprocess(contender, "subprocess contention proof");
1606
1607        drop(owner);
1608        open_or_initialize_project(root.path()).unwrap();
1609    }
1610
1611    #[cfg(windows)]
1612    #[test]
1613    fn windows_project_root_lock_contender() {
1614        let Ok(root) = std::env::var("GRAPHFORGE_TEST_PROJECT_ROOT") else {
1615            return;
1616        };
1617
1618        let contention = open_or_initialize_project(PathBuf::from(root)).unwrap_err();
1619        assert_code(contention, "GF_WRITER_BUSY");
1620    }
1621
1622    #[cfg(windows)]
1623    #[test]
1624    fn windows_project_root_lock_recovers_abandoned_owner() {
1625        use std::process::Command;
1626
1627        let root = tempfile::tempdir().unwrap();
1628        let abandoner = Command::new(std::env::current_exe().unwrap())
1629            .arg("--exact")
1630            .arg(ROOT_LOCK_ABANDONER)
1631            .arg("--nocapture")
1632            .env("GRAPHFORGE_TEST_PROJECT_ROOT", root.path())
1633            .spawn()
1634            .unwrap();
1635        wait_for_lock_subprocess(abandoner, "subprocess abandonment proof");
1636
1637        let _recovered = lock_project_root(root.path()).unwrap();
1638    }
1639
1640    #[cfg(windows)]
1641    #[test]
1642    fn windows_project_root_lock_abandoner() {
1643        let Ok(root) = std::env::var("GRAPHFORGE_TEST_PROJECT_ROOT") else {
1644            return;
1645        };
1646
1647        let root = PathBuf::from(root);
1648        let _owner = lock_project_root(&root).unwrap();
1649        std::process::exit(0);
1650    }
1651
1652    #[test]
1653    fn repeated_interrupted_initializations_reuse_one_validated_generation() {
1654        let root = tempfile::tempdir().unwrap();
1655        fs::write(root.path().join(FORMAT_FILE), PROJECT_FORMAT_BYTES).unwrap();
1656        fs::create_dir(root.path().join("generations")).unwrap();
1657        let mut generations = Vec::new();
1658        for _ in 0..16 {
1659            let generation = Uuid::now_v7();
1660            install_generation(root.path(), generation);
1661            generations.push(generation);
1662        }
1663        generations.sort_unstable();
1664
1665        let resolved = open_or_initialize_project(root.path()).unwrap();
1666
1667        assert_eq!(resolved.generation_uuid(), *generations.last().unwrap());
1668        assert_eq!(
1669            fs::read_dir(root.path().join("generations"))
1670                .unwrap()
1671                .count(),
1672            1
1673        );
1674    }
1675
1676    #[test]
1677    fn interrupted_current_temp_is_removed_before_initialization_resumes() {
1678        let root = tempfile::tempdir().unwrap();
1679        fs::write(root.path().join(FORMAT_FILE), PROJECT_FORMAT_BYTES).unwrap();
1680        fs::create_dir(root.path().join("generations")).unwrap();
1681        install_generation(root.path(), Uuid::now_v7());
1682        let writer_temp = root.path().join(".atomicwriteAb12Cd");
1683        fs::create_dir(&writer_temp).unwrap();
1684        fs::write(writer_temp.join("tmpfile.tmp"), b"partial CURRENT").unwrap();
1685
1686        open_or_initialize_project(root.path()).unwrap();
1687
1688        assert!(!writer_temp.exists());
1689    }
1690
1691    #[test]
1692    fn existing_resolution_remains_pinned_after_current_changes() {
1693        let (root, first) = project();
1694        let old_reader = resolve_project_generation(root.path()).unwrap();
1695        let second = Uuid::now_v7();
1696        let digest = install_generation(root.path(), second);
1697        write_current(root.path(), second, digest);
1698
1699        let new_reader = resolve_project_generation(root.path()).unwrap();
1700
1701        assert_eq!(old_reader.generation_uuid(), first);
1702        assert_eq!(new_reader.generation_uuid(), second);
1703        assert_ne!(old_reader.generation_root(), new_reader.generation_root());
1704    }
1705
1706    #[test]
1707    fn resolved_reader_holds_the_generation_lease() {
1708        let (root, generation) = project();
1709        let resolved = resolve_project_generation(root.path()).unwrap();
1710        let lease = open_generation_lease(root.path(), generation);
1711
1712        assert!(!FileExt::try_lock_exclusive(&lease).unwrap());
1713        drop(resolved);
1714        assert!(FileExt::try_lock_exclusive(&lease).unwrap());
1715    }
1716
1717    #[test]
1718    fn cloned_reader_holds_the_generation_lease_until_the_last_drop() {
1719        let (root, generation) = project();
1720        let resolved = resolve_project_generation(root.path()).unwrap();
1721        let cloned = resolved.clone();
1722        let lease = open_generation_lease(root.path(), generation);
1723
1724        drop(resolved);
1725        assert!(!FileExt::try_lock_exclusive(&lease).unwrap());
1726        drop(cloned);
1727        assert!(FileExt::try_lock_exclusive(&lease).unwrap());
1728    }
1729
1730    #[cfg(unix)]
1731    #[test]
1732    fn final_reader_drop_unlocks_a_duplicated_lease_description() {
1733        let (root, generation) = project();
1734        let resolved = resolve_project_generation(root.path()).unwrap();
1735        let duplicated_lease = resolved._lease_handle.0.try_clone().unwrap();
1736        let lease = open_generation_lease(root.path(), generation);
1737
1738        assert!(!FileExt::try_lock_exclusive(&lease).unwrap());
1739        drop(resolved);
1740        assert!(duplicated_lease.metadata().is_ok());
1741        assert!(FileExt::try_lock_exclusive(&lease).unwrap());
1742    }
1743
1744    #[test]
1745    fn rejects_pre_v1_root_without_mutation() {
1746        let root = tempfile::tempdir().unwrap();
1747        fs::create_dir(root.path().join("topology")).unwrap();
1748        fs::write(root.path().join("topology/nodes.parquet"), b"old").unwrap();
1749        let before = fs::read(root.path().join("topology/nodes.parquet")).unwrap();
1750
1751        let error = resolve_project_generation(root.path()).unwrap_err();
1752
1753        assert_code(error, "GF_UNSUPPORTED_PROJECT_FORMAT");
1754        assert_eq!(
1755            fs::read(root.path().join("topology/nodes.parquet")).unwrap(),
1756            before
1757        );
1758        assert!(!root.path().join(FORMAT_FILE).exists());
1759    }
1760
1761    #[test]
1762    fn exact_format_without_current_is_uninitialized() {
1763        let root = tempfile::tempdir().unwrap();
1764        fs::write(root.path().join(FORMAT_FILE), PROJECT_FORMAT_BYTES).unwrap();
1765
1766        let error = resolve_project_generation(root.path()).unwrap_err();
1767
1768        assert_code(error, "GF_PROJECT_UNINITIALIZED");
1769    }
1770
1771    #[test]
1772    fn future_format_is_unsupported() {
1773        let root = tempfile::tempdir().unwrap();
1774        fs::write(root.path().join(FORMAT_FILE), b"graphforge-project/v2\n").unwrap();
1775
1776        let error = resolve_project_generation(root.path()).unwrap_err();
1777
1778        assert_code(error, "GF_UNSUPPORTED_PROJECT_FORMAT");
1779    }
1780
1781    #[test]
1782    fn noncanonical_current_is_corrupt() {
1783        let (root, _) = project();
1784        let bytes = fs::read(root.path().join(CURRENT_FILE)).unwrap();
1785        let spaced = String::from_utf8(bytes).unwrap().replace("\":\"", "\": \"");
1786        fs::write(root.path().join(CURRENT_FILE), spaced).unwrap();
1787
1788        let error = resolve_project_generation(root.path()).unwrap_err();
1789
1790        assert_code(error, "GF_PROJECT_CORRUPT");
1791    }
1792
1793    #[test]
1794    fn manifest_digest_mismatch_is_corrupt() {
1795        let (root, generation) = project();
1796        write_current(root.path(), generation, [0_u8; 32]);
1797
1798        let error = resolve_project_generation(root.path()).unwrap_err();
1799
1800        assert_code(error, "GF_PROJECT_CORRUPT");
1801        let lease = open_generation_lease(root.path(), generation);
1802        assert!(FileExt::try_lock_exclusive(&lease).unwrap());
1803    }
1804
1805    #[test]
1806    fn participant_path_rejects_traversal_from_manifest() {
1807        let (root, generation) = project();
1808        let generation_root = root
1809            .path()
1810            .join("generations")
1811            .join(generation.hyphenated().to_string());
1812        let manifest = GenerationManifest {
1813            format: "graphforge-generation".into(),
1814            format_version: 1,
1815            generation_uuid: generation.hyphenated().to_string(),
1816            parent_generation_uuid: None,
1817            transaction_uuid: Uuid::now_v7().hyphenated().to_string(),
1818            capabilities: vec![CapabilityDescriptor {
1819                capability_id: "graph".into(),
1820                capability_version: 1,
1821            }],
1822            participants: vec![ParticipantDescriptor {
1823                capability_id: "graph".into(),
1824                capability_version: 1,
1825                record_family_id: "topology".into(),
1826                record_version: 1,
1827                relative_path: "../outside.parquet".into(),
1828                encoding: "parquet".into(),
1829                byte_length: 0,
1830                row_count: 0,
1831                schema_fingerprint: "0".repeat(64),
1832                content_sha256: "0".repeat(64),
1833            }],
1834        };
1835        let bytes = canonical_line(&manifest);
1836        fs::write(generation_root.join(MANIFEST_FILE), &bytes).unwrap();
1837        write_current(root.path(), generation, Sha256::digest(&bytes).into());
1838
1839        let error = resolve_project_generation(root.path()).unwrap_err();
1840
1841        assert_code(error, "GF_PROJECT_CORRUPT");
1842    }
1843
1844    #[test]
1845    fn resolution_does_not_open_unrequested_participant_tables() {
1846        let (root, generation) = project();
1847        let generation_root = root
1848            .path()
1849            .join("generations")
1850            .join(generation.hyphenated().to_string());
1851        let manifest = GenerationManifest {
1852            format: "graphforge-generation".into(),
1853            format_version: 1,
1854            generation_uuid: generation.hyphenated().to_string(),
1855            parent_generation_uuid: None,
1856            transaction_uuid: Uuid::now_v7().hyphenated().to_string(),
1857            capabilities: vec![
1858                CapabilityDescriptor {
1859                    capability_id: "graph".into(),
1860                    capability_version: 1,
1861                },
1862                CapabilityDescriptor {
1863                    capability_id: "knowledge".into(),
1864                    capability_version: 1,
1865                },
1866            ],
1867            participants: vec![ParticipantDescriptor {
1868                capability_id: "knowledge".into(),
1869                capability_version: 1,
1870                record_family_id: "assertions".into(),
1871                record_version: 1,
1872                relative_path: "knowledge/assertions.parquet".into(),
1873                encoding: "parquet".into(),
1874                byte_length: 99,
1875                row_count: 1,
1876                schema_fingerprint: "0".repeat(64),
1877                content_sha256: "0".repeat(64),
1878            }],
1879        };
1880        let bytes = canonical_line(&manifest);
1881        fs::write(generation_root.join(MANIFEST_FILE), &bytes).unwrap();
1882        write_current(root.path(), generation, Sha256::digest(&bytes).into());
1883        // The declared table deliberately does not exist. Generic graph-only
1884        // resolution validates authority, not unrequested capability bytes.
1885        assert!(
1886            !generation_root
1887                .join("participants/knowledge/assertions.parquet")
1888                .exists()
1889        );
1890
1891        let resolved = resolve_project_generation(root.path()).unwrap();
1892
1893        assert_eq!(resolved.generation_uuid(), generation);
1894    }
1895
1896    #[cfg(unix)]
1897    #[test]
1898    fn selected_generation_symlink_is_corrupt() {
1899        use std::os::unix::fs::symlink;
1900
1901        let (root, generation) = project();
1902        let generation_root = root
1903            .path()
1904            .join("generations")
1905            .join(generation.hyphenated().to_string());
1906        fs::remove_file(generation_root.join(LEASE_FILE)).unwrap();
1907        symlink("/dev/null", generation_root.join(LEASE_FILE)).unwrap();
1908
1909        let error = resolve_project_generation(root.path()).unwrap_err();
1910
1911        assert_code(error, "GF_PROJECT_CORRUPT");
1912    }
1913}