use crate::domain::packs::install::compute_pack_digest;
use crate::domain::packs::metadata::load_pack_metadata;
use crate::packs::lockfile::{PackLockfile, PackSource};
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncProfile {
EnterpriseStrict,
Permissive,
Development,
}
impl std::str::FromStr for SyncProfile {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"enterprise-strict" => Ok(Self::EnterpriseStrict),
"permissive" => Ok(Self::Permissive),
"development" | "dev" => Ok(Self::Development),
other => Err(format!(
"Unknown profile '{}'. Known: enterprise-strict, permissive, development",
other
)),
}
}
}
impl SyncProfile {
pub fn as_str(&self) -> &str {
match self {
Self::EnterpriseStrict => "enterprise-strict",
Self::Permissive => "permissive",
Self::Development => "development",
}
}
pub fn requires_lockfile(&self) -> bool {
matches!(self, Self::EnterpriseStrict)
}
pub fn allows_unsigned_packs(&self) -> bool {
!matches!(self, Self::EnterpriseStrict)
}
}
fn check_integrity_fields(lockfile: &PackLockfile) -> Result<(), String> {
let missing: Vec<&str> = lockfile
.packs
.iter()
.filter(|(_, pack)| {
pack.integrity
.as_deref()
.map(|v| v.is_empty())
.unwrap_or(true)
})
.map(|(id, _)| id.as_str())
.collect();
if missing.is_empty() {
return Ok(());
}
Err(format!(
"Lockfile integrity check failed (--locked): the following pack(s) are missing a \
non-empty integrity digest: [{}]. Run `ggen packs add <pack>` to reinstall them \
with integrity hashes.",
missing.join(", ")
))
}
fn verify_pack_digests(lockfile: &PackLockfile) -> Result<(), String> {
for (pack_id, locked) in &lockfile.packs {
let install_path = match &locked.source {
PackSource::Local { path } => path,
_ => continue,
};
if !install_path.exists() {
return Err(format!(
"Lockfile digest re-verification failed (--locked): missing pack '{}'. \
Its recorded install path '{}' no longer exists. Run `ggen packs add {}` \
to reinstall it.",
pack_id,
install_path.display(),
pack_id
));
}
let pack = load_pack_metadata(pack_id).map_err(|e| {
format!(
"Lockfile digest re-verification failed (--locked): missing pack '{}'. \
Could not re-load its definition to recompute the digest: {}. Run \
`ggen packs add {}` to reinstall it.",
pack_id, e, pack_id
)
})?;
let recomputed = format!("sha256-{}", compute_pack_digest(&pack));
let stored = locked.integrity.as_deref().unwrap_or("");
if recomputed != stored {
return Err(format!(
"Lockfile digest re-verification failed (--locked): digest mismatch for \
pack '{}'. Lockfile records '{}' but the pack on disk hashes to '{}'. The \
pack was modified after it was locked. Run `ggen packs add {}` to relock it.",
pack_id, stored, recomputed, pack_id
));
}
}
Ok(())
}
pub fn validate_sync_preconditions(
profile: Option<&str>, locked: bool, workspace_root: &std::path::Path,
) -> Result<(), String> {
let profile = match profile {
Some(p) => SyncProfile::from_str(p)?,
None => {
if locked {
let lockfile_path = workspace_root.join(".ggen").join("packs.lock");
if !lockfile_path.exists() {
return Err(
"Lockfile required (--locked) but .ggen/packs.lock not found. \
Run `ggen packs add <pack>` first."
.to_string(),
);
}
if let Ok(lockfile) = PackLockfile::from_file(&lockfile_path) {
check_integrity_fields(&lockfile)?;
verify_pack_digests(&lockfile)?;
}
}
return Ok(());
}
};
if locked || profile.requires_lockfile() {
let lockfile_path = workspace_root.join(".ggen").join("packs.lock");
if !lockfile_path.exists() {
return Err(format!(
"Lockfile required (profile='{}', --locked={}) but .ggen/packs.lock not found. \
Run `ggen packs add <pack>` first.",
profile.as_str(),
locked
));
}
if locked {
let lockfile = PackLockfile::from_file(&lockfile_path)
.map_err(|e| format!("Failed to load lockfile (--locked): {e}"))?;
check_integrity_fields(&lockfile)?;
verify_pack_digests(&lockfile)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::packs::lockfile::{LockedPack, PackLockfile, PackSource};
use chrono::Utc;
use std::fs;
use tempfile::TempDir;
fn write_empty_lockfile(dir: &TempDir) {
let lockfile = PackLockfile::new("4.0.0");
let ggen_dir = dir.path().join(".ggen");
fs::create_dir_all(&ggen_dir).unwrap();
lockfile.save(&ggen_dir.join("packs.lock")).unwrap();
}
fn write_lockfile_with_integrity(dir: &TempDir) {
let mut lockfile = PackLockfile::new("4.0.0");
lockfile.add_pack(
"io.ggen.test.pack",
LockedPack {
version: "1.0.0".to_string(),
source: PackSource::Registry {
url: "https://registry.ggen.io".to_string(),
},
integrity: Some(
"sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
.to_string(),
),
installed_at: Utc::now(),
dependencies: vec![],
},
);
let ggen_dir = dir.path().join(".ggen");
fs::create_dir_all(&ggen_dir).unwrap();
lockfile.save(&ggen_dir.join("packs.lock")).unwrap();
}
fn write_lockfile_missing_integrity(dir: &TempDir, pack_id: &str) {
let mut lockfile = PackLockfile::new("4.0.0");
lockfile.add_pack(
pack_id,
LockedPack {
version: "1.0.0".to_string(),
source: PackSource::Registry {
url: "https://registry.ggen.io".to_string(),
},
integrity: None,
installed_at: Utc::now(),
dependencies: vec![],
},
);
let ggen_dir = dir.path().join(".ggen");
fs::create_dir_all(&ggen_dir).unwrap();
lockfile.save(&ggen_dir.join("packs.lock")).unwrap();
}
#[test]
fn parse_known_profiles() {
assert_eq!(
SyncProfile::from_str("enterprise-strict").unwrap(),
SyncProfile::EnterpriseStrict
);
assert_eq!(
SyncProfile::from_str("permissive").unwrap(),
SyncProfile::Permissive
);
assert_eq!(
SyncProfile::from_str("development").unwrap(),
SyncProfile::Development
);
assert_eq!(
SyncProfile::from_str("dev").unwrap(),
SyncProfile::Development
);
}
#[test]
fn parse_unknown_profile_returns_error() {
let err = SyncProfile::from_str("bogus").unwrap_err();
assert!(err.contains("Unknown profile 'bogus'"), "error was: {err}");
assert!(err.contains("enterprise-strict"), "error was: {err}");
}
#[test]
fn enterprise_strict_requires_lockfile() {
assert!(SyncProfile::EnterpriseStrict.requires_lockfile());
assert!(!SyncProfile::Permissive.requires_lockfile());
assert!(!SyncProfile::Development.requires_lockfile());
}
#[test]
fn enterprise_strict_forbids_unsigned_packs() {
assert!(!SyncProfile::EnterpriseStrict.allows_unsigned_packs());
assert!(SyncProfile::Permissive.allows_unsigned_packs());
}
#[test]
fn as_str_round_trips() {
for (input, expected) in [
("enterprise-strict", "enterprise-strict"),
("permissive", "permissive"),
("development", "development"),
("dev", "development"),
] {
let p = SyncProfile::from_str(input).unwrap();
assert_eq!(p.as_str(), expected);
}
}
#[test]
fn no_profile_no_locked_always_passes() {
let dir = TempDir::new().unwrap();
assert!(validate_sync_preconditions(None, false, dir.path()).is_ok());
}
#[test]
fn locked_flag_without_lockfile_fails() {
let dir = TempDir::new().unwrap();
let err = validate_sync_preconditions(None, true, dir.path()).unwrap_err();
assert!(err.contains("--locked"), "error was: {err}");
assert!(err.contains("packs.lock"), "error was: {err}");
}
#[test]
fn locked_flag_with_valid_lockfile_passes() {
let dir = TempDir::new().unwrap();
write_lockfile_with_integrity(&dir);
assert!(validate_sync_preconditions(None, true, dir.path()).is_ok());
}
#[test]
fn locked_flag_with_empty_lockfile_passes() {
let dir = TempDir::new().unwrap();
write_empty_lockfile(&dir);
assert!(validate_sync_preconditions(None, true, dir.path()).is_ok());
}
#[test]
fn locked_flag_rejects_entry_with_missing_integrity() {
let dir = TempDir::new().unwrap();
write_lockfile_missing_integrity(&dir, "io.ggen.bad.pack");
let err = validate_sync_preconditions(None, true, dir.path()).unwrap_err();
assert!(
err.contains("--locked"),
"error should mention --locked, was: {err}"
);
assert!(
err.contains("io.ggen.bad.pack"),
"error should name the pack missing integrity, was: {err}"
);
}
#[test]
fn enterprise_strict_without_lockfile_fails() {
let dir = TempDir::new().unwrap();
let err =
validate_sync_preconditions(Some("enterprise-strict"), false, dir.path()).unwrap_err();
assert!(err.contains("enterprise-strict"), "error was: {err}");
assert!(err.contains("packs.lock"), "error was: {err}");
}
#[test]
fn enterprise_strict_with_lockfile_passes() {
let dir = TempDir::new().unwrap();
write_empty_lockfile(&dir);
assert!(validate_sync_preconditions(Some("enterprise-strict"), false, dir.path()).is_ok());
}
#[test]
fn permissive_profile_passes_without_lockfile() {
let dir = TempDir::new().unwrap();
assert!(validate_sync_preconditions(Some("permissive"), false, dir.path()).is_ok());
}
#[test]
fn development_profile_passes_without_lockfile() {
let dir = TempDir::new().unwrap();
assert!(validate_sync_preconditions(Some("dev"), false, dir.path()).is_ok());
}
#[test]
fn unknown_profile_name_returns_error() {
let dir = TempDir::new().unwrap();
let err = validate_sync_preconditions(Some("nonexistent"), false, dir.path()).unwrap_err();
assert!(err.contains("Unknown profile"), "error was: {err}");
}
#[test]
fn sabotage_corrupt_lockfile_content_presence_check_still_passes() {
let dir = TempDir::new().unwrap();
let ggen_dir = dir.path().join(".ggen");
fs::create_dir_all(&ggen_dir).unwrap();
fs::write(ggen_dir.join("packs.lock"), "this is not JSON {{{{{").unwrap();
let result = validate_sync_preconditions(None, true, dir.path());
assert!(
result.is_ok(),
"precondition layer checks presence only; corrupt content is caught \
by the sync pipeline. result was: {result:?}"
);
}
#[test]
fn sabotage_missing_lockfile_with_locked_flag_hard_fails() {
let dir = TempDir::new().unwrap();
let err = validate_sync_preconditions(None, true, dir.path()).unwrap_err();
assert!(
err.contains("--locked") || err.contains("packs.lock"),
"error must reference --locked or packs.lock; got: {err}"
);
}
use crate::domain::packs::types::Pack;
use serial_test::serial;
use std::collections::HashMap;
struct PacksDirGuard {
previous: Option<std::ffi::OsString>,
}
impl PacksDirGuard {
fn set(value: &std::path::Path) -> Self {
let previous = std::env::var_os("GGEN_PACKS_DIR");
std::env::set_var("GGEN_PACKS_DIR", value);
Self { previous }
}
}
impl Drop for PacksDirGuard {
fn drop(&mut self) {
match &self.previous {
None => std::env::remove_var("GGEN_PACKS_DIR"),
Some(v) => std::env::set_var("GGEN_PACKS_DIR", v),
}
}
}
fn sample_pack(id: &str, version: &str) -> Pack {
Pack {
id: id.to_string(),
name: format!("Sample {id}"),
version: version.to_string(),
description: "reverify fixture".to_string(),
category: "test".to_string(),
author: None,
repository: None,
license: Some("MIT".to_string()),
registry_type: None,
packages: vec![format!("{id}-core")],
templates: vec![],
sparql_queries: HashMap::new(),
dependencies: vec![],
tags: vec![],
keywords: vec![],
production_ready: true,
metadata: Default::default(),
}
}
fn write_registry_pack(registry: &std::path::Path, id: &str, version: &str) {
let toml = format!(
r#"[pack]
id = "{id}"
name = "Sample {id}"
version = "{version}"
description = "reverify fixture"
category = "test"
license = "MIT"
production_ready = true
packages = ["{id}-core"]
"#
);
fs::write(registry.join(format!("{id}.toml")), toml).unwrap();
}
fn write_local_lockfile(
project: &std::path::Path, id: &str, version: &str, install_path: &std::path::Path,
integrity: &str,
) {
let mut lockfile = PackLockfile::new("4.0.0");
lockfile.add_pack(
id,
LockedPack {
version: version.to_string(),
source: PackSource::Local {
path: install_path.to_path_buf(),
},
integrity: Some(integrity.to_string()),
installed_at: Utc::now(),
dependencies: vec![],
},
);
let ggen_dir = project.join(".ggen");
fs::create_dir_all(&ggen_dir).unwrap();
lockfile.save(&ggen_dir.join("packs.lock")).unwrap();
}
#[test]
#[serial(GGEN_PACKS_DIR)]
fn reverify_passes_when_pack_unmodified() {
let registry = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let install = TempDir::new().unwrap();
let _guard = PacksDirGuard::set(registry.path());
write_registry_pack(registry.path(), "io.ggen.ok", "1.0.0");
let digest = format!(
"sha256-{}",
compute_pack_digest(&sample_pack("io.ggen.ok", "1.0.0"))
);
write_local_lockfile(
project.path(),
"io.ggen.ok",
"1.0.0",
install.path(),
&digest,
);
assert!(
validate_sync_preconditions(None, true, project.path()).is_ok(),
"unmodified pack with matching digest must pass --locked re-verification"
);
}
#[test]
#[serial(GGEN_PACKS_DIR)]
fn reverify_fails_on_digest_mismatch() {
let registry = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let install = TempDir::new().unwrap();
let _guard = PacksDirGuard::set(registry.path());
write_registry_pack(registry.path(), "io.ggen.drift", "1.0.0");
let locked_digest = format!(
"sha256-{}",
compute_pack_digest(&sample_pack("io.ggen.drift", "1.0.0"))
);
write_local_lockfile(
project.path(),
"io.ggen.drift",
"1.0.0",
install.path(),
&locked_digest,
);
write_registry_pack(registry.path(), "io.ggen.drift", "2.0.0");
let err = validate_sync_preconditions(None, true, project.path()).unwrap_err();
assert!(
err.contains("digest mismatch"),
"error must reference 'digest mismatch'; got: {err}"
);
assert!(
err.contains("io.ggen.drift"),
"error must name the drifted pack; got: {err}"
);
}
#[test]
#[serial(GGEN_PACKS_DIR)]
fn reverify_fails_when_pack_definition_removed() {
let registry = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let install = TempDir::new().unwrap();
let _guard = PacksDirGuard::set(registry.path());
write_registry_pack(registry.path(), "io.ggen.gone", "1.0.0");
let digest = format!(
"sha256-{}",
compute_pack_digest(&sample_pack("io.ggen.gone", "1.0.0"))
);
write_local_lockfile(
project.path(),
"io.ggen.gone",
"1.0.0",
install.path(),
&digest,
);
fs::remove_file(registry.path().join("io.ggen.gone.toml")).unwrap();
let err = validate_sync_preconditions(None, true, project.path()).unwrap_err();
assert!(
err.contains("missing pack"),
"error must reference 'missing pack'; got: {err}"
);
assert!(
err.contains("io.ggen.gone"),
"error must name the missing pack; got: {err}"
);
}
#[test]
#[serial(GGEN_PACKS_DIR)]
fn reverify_fails_when_install_dir_removed() {
let registry = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let install = TempDir::new().unwrap();
let _guard = PacksDirGuard::set(registry.path());
write_registry_pack(registry.path(), "io.ggen.noinstall", "1.0.0");
let digest = format!(
"sha256-{}",
compute_pack_digest(&sample_pack("io.ggen.noinstall", "1.0.0"))
);
let install_path = install.path().to_path_buf();
write_local_lockfile(
project.path(),
"io.ggen.noinstall",
"1.0.0",
&install_path,
&digest,
);
drop(install);
assert!(!install_path.exists());
let err = validate_sync_preconditions(None, true, project.path()).unwrap_err();
assert!(
err.contains("missing pack"),
"error must reference 'missing pack'; got: {err}"
);
assert!(
err.contains("io.ggen.noinstall"),
"error must name the pack with the missing install path; got: {err}"
);
}
}