diff --git a/web/backend/app/services/altium_parser.py b/web/backend/app/services/altium_parser.py index 8cd2219..2557944 100644 --- a/web/backend/app/services/altium_parser.py +++ b/web/backend/app/services/altium_parser.py @@ -8,11 +8,22 @@ from pathlib import Path from typing import Optional +def sanitize_text(value: str | None) -> str: + """Remove NUL bytes — PostgreSQL text fields reject them; SchDoc may contain them.""" + if not value: + return "" + return value.replace("\x00", "") + + @dataclass class ComponentProperty: name: str text: str + def __post_init__(self) -> None: + self.name = sanitize_text(self.name) + self.text = sanitize_text(self.text) + @dataclass class DielProperty: @@ -22,6 +33,10 @@ class DielProperty: diel_type: int layer_number: int + def __post_init__(self) -> None: + self.name = sanitize_text(self.name) + self.value = sanitize_text(self.value) + @dataclass class ProjectData: @@ -342,6 +357,7 @@ class AltiumParser: def make_complex_string(text: str, props: list[ComponentProperty]) -> str: + text = sanitize_text(text) if not text.startswith("="): return text expr = text[1:] @@ -351,7 +367,7 @@ def make_complex_string(text: str, props: list[ComponentProperty]) -> str: key = m.group(1) return mapping.get(key, "") - return re.sub(r"['\"]([^'\"]+)['\"]", repl, expr) + return sanitize_text(re.sub(r"['\"]([^'\"]+)['\"]", repl, expr)) def find_prjpcb(extract_dir: Path) -> Optional[Path]: diff --git a/web/backend/app/services/project_service.py b/web/backend/app/services/project_service.py index a65b408..dce6c3f 100644 --- a/web/backend/app/services/project_service.py +++ b/web/backend/app/services/project_service.py @@ -20,7 +20,7 @@ from app.models import ( Variant, VariantProperty, ) -from app.services.altium_parser import AltiumParser, ProjectData, find_prjpcb, make_complex_string +from app.services.altium_parser import AltiumParser, ProjectData, find_prjpcb, make_complex_string, sanitize_text from app.services.designators import DEFAULT_MAPPINGS @@ -79,7 +79,7 @@ def ensure_default_mappings(db: Session, project: Project) -> None: def save_project_data(db: Session, project: Project, data: ProjectData) -> None: variant_objs: list[Variant] = [] for name in data.variant_names: - v = Variant(project_id=project.id, name=name) + v = Variant(project_id=project.id, name=sanitize_text(name)) db.add(v) variant_objs.append(v) db.flush() @@ -95,7 +95,7 @@ def save_project_data(db: Session, project: Project, data: ProjectData) -> None: designator = "" for p in comp_props: if p.name.lower() == "designator": - designator = p.text + designator = sanitize_text(p.text) break if not designator: continue @@ -105,7 +105,13 @@ def save_project_data(db: Session, project: Project, data: ProjectData) -> None: db.add(comp) db.flush() for p in comp_props: - db.add(ComponentProperty(component_id=comp.id, key=p.name, value=p.text)) + db.add( + ComponentProperty( + component_id=comp.id, + key=sanitize_text(p.name), + value=sanitize_text(p.text), + ) + ) designator_to_component[designator] = comp # link to all variants as fitted by default for v in variant_objs: @@ -126,7 +132,7 @@ def save_project_data(db: Session, project: Project, data: ProjectData) -> None: designator = "" for p in comp_props: if p.name == "Designator": - designator = p.text + designator = sanitize_text(p.text) break if not designator: continue @@ -149,8 +155,8 @@ def save_project_data(db: Session, project: Project, data: ProjectData) -> None: VariantProperty( component_id=comp.id, variant_id=variant.id, - key=p.name, - value=p.text, + key=sanitize_text(p.name), + value=sanitize_text(p.text), ) ) @@ -159,16 +165,18 @@ def save_project_data(db: Session, project: Project, data: ProjectData) -> None: for item in params: if len(item) < 2: continue - name, value = item[0], item[1] - value = make_complex_string(value, flat_props) + name, value = sanitize_text(item[0]), sanitize_text(item[1]) + value = sanitize_text(make_complex_string(value, flat_props)) db.add( ProjectParam( project_id=project.id, name=name, value=value, - variant_name=data.variant_names[variant_idx] - if variant_idx < len(data.variant_names) - else "", + variant_name=sanitize_text( + data.variant_names[variant_idx] + if variant_idx < len(data.variant_names) + else "" + ), ) ) @@ -181,15 +189,16 @@ def save_project_data(db: Session, project: Project, data: ProjectData) -> None: db.add( DielMaterial( pcb_data_id=pcb.id, - name=m.name, - value=m.value, + name=sanitize_text(m.name), + value=sanitize_text(m.value), height=m.height, diel_type=m.diel_type, layer_number=m.layer_number, ) ) - project.pcb_doc_name = Path(data.pcb_doc_file_name).name if data.pcb_doc_file_name else None + pcb_name = sanitize_text(Path(data.pcb_doc_file_name).name if data.pcb_doc_file_name else "") + project.pcb_doc_name = pcb_name or None if "No Variations" in data.variant_names: project.current_variant = "No Variations" elif data.variant_names: @@ -247,10 +256,13 @@ def ingest_zip( db.refresh(project) return project except Exception as e: - project.status = "error" - project.error_message = str(e) - db.commit() - raise + db.rollback() + proj = db.get(Project, project.id) + if proj: + proj.status = "error" + proj.error_message = str(e)[:2000] + db.commit() + raise ValueError(str(e)) from e def get_project_full(db: Session, project_id: int) -> Project | None: