pharos_runtime_compiler/
database.rs1use pharos_runtime_database::contract::{AppDataContract, validate_contract};
3use pharos_runtime_database::operations::DatabaseError;
4use serde_json::Value;
5use std::io::Read;
6use std::path::Path;
7
8fn source_text(path: &Path) -> Result<String, DatabaseError> {
10 let meta = std::fs::symlink_metadata(path).map_err(|_| DatabaseError::InvalidContract)?;
11 if !meta.is_file() || meta.len() > 8 * 1024 * 1024 {
12 return Err(DatabaseError::InvalidContract);
13 }
14 let mut text = String::new();
15 std::fs::File::open(path)
16 .map_err(|_| DatabaseError::InvalidContract)?
17 .take(8 * 1024 * 1024 + 1)
18 .read_to_string(&mut text)
19 .map_err(|_| DatabaseError::InvalidContract)?;
20 if text.len() > 8 * 1024 * 1024 {
21 return Err(DatabaseError::InvalidContract);
22 }
23 Ok(text)
24}
25
26pub fn normalize_text(
28 text: &str,
29 yaml: bool,
30 app_id: &str,
31) -> Result<AppDataContract, DatabaseError> {
32 if text.len() > 8 * 1024 * 1024 {
33 return Err(DatabaseError::InvalidContract);
34 }
35 let value: Value = if yaml {
36 crate::database_yaml::parse(text).map_err(|_| DatabaseError::InvalidContract)?
37 } else {
38 serde_json::from_str(text).map_err(|_| DatabaseError::InvalidContract)?
39 };
40 validated_value(value, app_id)
41}
42
43fn validated_value(value: Value, app_id: &str) -> Result<AppDataContract, DatabaseError> {
45 let contract: AppDataContract =
46 serde_json::from_value(value).map_err(|_| DatabaseError::InvalidContract)?;
47 if contract.schema.app_id != app_id {
48 return Err(DatabaseError::InvalidContract);
49 }
50 validate_contract(&contract)?;
51 json_bytes(&contract)?;
52 Ok(contract)
53}
54
55pub fn load_source(path: &Path, app_id: &str) -> Result<AppDataContract, DatabaseError> {
57 let meta = std::fs::symlink_metadata(path).map_err(|_| DatabaseError::InvalidContract)?;
58 if !meta.is_file() || meta.len() > 8 * 1024 * 1024 {
59 return Err(DatabaseError::InvalidContract);
60 }
61 let extension = path
62 .extension()
63 .and_then(|v| v.to_str())
64 .ok_or(DatabaseError::InvalidContract)?;
65 if !matches!(extension, "json" | "yml" | "yaml") {
66 return Err(DatabaseError::InvalidContract);
67 }
68 let text = source_text(path)?;
69 normalize_text(&text, extension != "json", app_id)
70}
71
72pub fn author_yaml(contract: &AppDataContract) -> Result<String, DatabaseError> {
74 validate_contract(contract)?;
75 let encoded = serde_json::to_string(contract).map_err(|_| DatabaseError::InvalidContract)?;
76 let namespace = format!(
77 "PHAROS_{}",
78 pharos_runtime_database::operations::bytes_fingerprint(encoded.as_bytes())
79 );
80 if encoded.contains(&namespace) {
81 return Err(DatabaseError::InvalidContract);
82 }
83 let mut value = serde_json::to_value(contract).map_err(|_| DatabaseError::InvalidContract)?;
84 let entities = value["schema"]["entities"]
85 .as_array()
86 .ok_or(DatabaseError::InvalidContract)?
87 .clone();
88 let mut markers = Vec::new();
89 for (index, entity) in entities.iter().enumerate() {
90 let marker = format!("{namespace}_ANCHOR_{index}");
91 value["schema"]["entities"][index] = Value::String(marker.clone());
92 markers.push((marker, entity.clone()));
93 }
94 if let Some(migrations) = value["migrations"].as_array_mut() {
95 for migration in migrations {
96 if let Some(changes) = migration["changes"].as_array_mut() {
97 for change in changes {
98 if let Some(entity) = change.get("create_entity")
99 && let Some(index) = entities.iter().position(|declared| declared == entity)
100 {
101 change["create_entity"] =
102 Value::String(format!("{namespace}_ALIAS_{index}"));
103 }
104 }
105 }
106 }
107 }
108 let emitted = pharos_runtime_core::yaml::serialize_yaml_value(&value)
109 .ok_or(DatabaseError::InvalidContract)?;
110 let mut text = String::new();
111 for line in emitted.lines() {
112 if let Some((index, (_, entity))) = markers.iter().enumerate().find(|(_, (marker, _))| {
113 line.trim().trim_start_matches("- ").trim_matches('"') == marker
114 }) {
115 let indent = line.len() - line.trim_start().len();
116 text.push_str(&format!("{}- &entity_{index}\n", " ".repeat(indent)));
117 let fields = pharos_runtime_core::yaml::serialize_yaml_value(entity)
118 .ok_or(DatabaseError::InvalidContract)?;
119 for field in fields.lines().filter(|line| *line != "---") {
120 text.push_str(&format!("{}{field}\n", " ".repeat(indent + 2)));
121 }
122 } else {
123 let mut replaced = line.to_owned();
124 for index in 0..entities.len() {
125 let marker = format!("{namespace}_ALIAS_{index}");
126 replaced = replaced.replace(&format!("\"{marker}\""), &format!("*entity_{index}"));
127 replaced = replaced.replace(&marker, &format!("*entity_{index}"));
128 }
129 text.push_str(&replaced);
130 text.push('\n');
131 }
132 }
133 let verified = normalize_text(&text, true, &contract.schema.app_id)?;
134 if verified != *contract {
135 return Err(DatabaseError::InvalidContract);
136 }
137 Ok(text)
138}
139
140pub fn compile_file(path: &Path) -> Result<Value, DatabaseError> {
142 let text = source_text(path)?;
143 let value = crate::database_yaml::parse(&text).map_err(|diagnostic| {
144 eprintln!(
145 "{}:{}:{}: {}: {}",
146 path.display(),
147 diagnostic.line,
148 diagnostic.column,
149 diagnostic.code,
150 diagnostic.message
151 );
152 DatabaseError::InvalidContract
153 })?;
154 let app_id = value
155 .pointer("/schema/app_id")
156 .and_then(Value::as_str)
157 .ok_or(DatabaseError::InvalidContract)?;
158 let app_id = app_id.to_owned();
159 let contract = validated_value(value, &app_id)?;
160 serde_json::to_value(contract).map_err(|_| DatabaseError::InvalidContract)
161}
162
163pub fn materialize_json(
165 source: impl AsRef<Path>,
166 destination: impl AsRef<Path>,
167) -> Result<u64, std::io::Error> {
168 let source = source.as_ref();
169 let destination = destination.as_ref();
170 let text = source_text(source).map_err(|_| {
171 std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid source contract")
172 })?;
173 let yaml = source.extension().and_then(|v| v.to_str()) != Some("json");
174 let value = if yaml {
175 crate::database_yaml::parse(&text)
176 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.message))?
177 } else {
178 serde_json::from_str(&text)?
179 };
180 let app_id = value
181 .pointer("/schema/app_id")
182 .and_then(Value::as_str)
183 .ok_or_else(|| {
184 std::io::Error::new(std::io::ErrorKind::InvalidData, "missing app identity")
185 })?;
186 let app_id = app_id.to_owned();
187 let contract = validated_value(value, &app_id).map_err(|_| {
188 std::io::Error::new(
189 std::io::ErrorKind::InvalidData,
190 "invalid logical database contract",
191 )
192 })?;
193 let bytes = json_bytes(&contract).map_err(|_| {
194 std::io::Error::new(
195 std::io::ErrorKind::InvalidData,
196 "compiled contract exceeds output limit",
197 )
198 })?;
199 pharos_runtime_core::fs::write_binary_file(destination, &bytes, 0o640)?;
200 Ok(bytes.len() as u64)
201}
202
203pub fn compile_selected(
205 root: &Path,
206 artifact: &Path,
207 app_id: &str,
208) -> Result<std::path::PathBuf, DatabaseError> {
209 let manifest = pharos_runtime_core::yaml::parse_yaml_file(&root.join("pharos.app.yml"))
210 .ok_or(DatabaseError::InvalidContract)?;
211 if manifest.get("app_id").and_then(Value::as_str) != Some(app_id) {
212 return Err(DatabaseError::InvalidContract);
213 }
214 let selected = manifest
215 .pointer("/database/migration_contract")
216 .and_then(Value::as_str)
217 .ok_or(DatabaseError::InvalidContract)?;
218 let relative = Path::new(selected);
219 if !relative
220 .components()
221 .all(|component| matches!(component, std::path::Component::Normal(_)))
222 {
223 return Err(DatabaseError::InvalidContract);
224 }
225 let root = root
226 .canonicalize()
227 .map_err(|_| DatabaseError::InvalidContract)?;
228 let source = root.join(relative);
229 if source
230 .canonicalize()
231 .map_err(|_| DatabaseError::InvalidContract)?
232 != source
233 {
234 return Err(DatabaseError::InvalidContract);
235 }
236 let contract = load_source(&source, app_id)?;
237 std::fs::create_dir_all(artifact).map_err(|_| DatabaseError::InvalidContract)?;
238 let artifact = artifact
239 .canonicalize()
240 .map_err(|_| DatabaseError::InvalidContract)?;
241 if artifact.starts_with(&root) {
242 return Err(DatabaseError::InvalidContract);
243 }
244 let destination = artifact.join(relative.with_extension("json"));
245 pharos_runtime_core::fs::write_file_within(
246 &artifact,
247 &destination,
248 &json_bytes(&contract)?,
249 0o640,
250 )
251 .map_err(|_| DatabaseError::InvalidContract)?;
252 Ok(destination)
253}
254
255fn json_bytes(contract: &AppDataContract) -> Result<Vec<u8>, DatabaseError> {
257 let bytes = serde_json::to_vec_pretty(contract).map_err(|_| DatabaseError::InvalidContract)?;
258 if bytes.len() > 8 * 1024 * 1024 {
259 return Err(DatabaseError::InvalidContract);
260 }
261 Ok(bytes)
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use pharos_runtime_database::contract::migration_checksum;
268 use pharos_runtime_database::operations::validate_schema;
269 #[test]
271 fn staged_ucal_contract_remains_valid() {
272 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
273 .join("../../../apps/ucal/data/contracts/database.yml");
274 let text = std::fs::read_to_string(path).expect("UCAL contract");
275 let contract: AppDataContract = serde_json::from_value(
276 crate::database_yaml::parse(&text).expect("compile source YAML"),
277 )
278 .expect("parse UCAL contract");
279 assert_eq!(validate_schema(&contract.schema), Ok(()));
280 for migration in &contract.migrations {
281 assert_eq!(
282 migration.checksum,
283 migration_checksum(&migration.app_id, &migration.id, &migration.changes)
284 .expect("checksum")
285 );
286 }
287 assert_eq!(validate_contract(&contract), Ok(()));
288 }
289}