1use std::env;
48use std::fmt;
49use std::path::PathBuf;
50
51use ironflow_artifacts::local::DEFAULT_MAX_ARTIFACT_BYTES;
52use tracing::warn;
53
54#[derive(Debug, Clone)]
70pub struct ServerConfig {
71 pub database_url: Option<String>,
73 pub jwt_secret: String,
75 pub worker_token: String,
77 pub port: u16,
79 pub allowed_origins: Option<String>,
81 pub dashboard_dir: Option<PathBuf>,
83 pub webhook_url: Option<String>,
85 pub artifacts_dir: Option<PathBuf>,
93 pub artifact_max_bytes: u64,
97 pub purge_max_age_days: u32,
101 pub purge_max_runs_per_workflow: u32,
105 pub purge_dry_run: bool,
109 pub purge_interval_secs: u64,
113 pub is_production: bool,
115 pub rate_limit_auth: Option<u32>,
118 pub rate_limit_general: Option<u32>,
121 pub artifact_backend: String,
123 pub artifact_s3_bucket: Option<String>,
125 pub artifact_s3_region: String,
127 pub artifact_s3_endpoint: Option<String>,
129 pub artifact_s3_prefix: Option<String>,
131 pub artifact_gc_interval_secs: u64,
133 pub artifact_gc_grace_days: u32,
135 pub artifact_gc_dry_run: bool,
137}
138
139#[derive(Debug, Clone)]
153pub struct ConfigError {
154 pub errors: Vec<String>,
156}
157
158impl ConfigError {
159 pub fn new(errors: Vec<String>) -> Self {
161 Self { errors }
162 }
163}
164
165impl fmt::Display for ConfigError {
166 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167 writeln!(f, "configuration errors:")?;
168 for error in &self.errors {
169 writeln!(f, " - {error}")?;
170 }
171 Ok(())
172 }
173}
174
175impl std::error::Error for ConfigError {}
176
177const DEV_JWT_SECRET: &str = "ironflow-dev-secret";
178const DEV_WORKER_TOKEN: &str = "ironflow-dev-worker-token";
179
180const DEV_SECRET_PREFIX: &str = "ironflow-dev-";
185
186const MIN_PROD_SECRET_BYTES: usize = 32;
189
190fn reject_insecure_secret(name: &str, value: &str, errors: &mut Vec<String>) {
197 if value.starts_with(DEV_SECRET_PREFIX) {
198 errors.push(format!(
199 "{name} must not use a known development default in production"
200 ));
201 } else if value.len() < MIN_PROD_SECRET_BYTES {
202 errors.push(format!(
203 "{name} must be at least {MIN_PROD_SECRET_BYTES} bytes in production, got {}",
204 value.len()
205 ));
206 }
207}
208
209fn parse_optional_u32(name: &str, default: u32, errors: &mut Vec<String>) -> Option<u32> {
213 match env::var(name).ok() {
214 Some(raw) => match raw.parse::<u32>() {
215 Ok(0) => None,
216 Ok(v) => Some(v),
217 Err(_) => {
218 errors.push(format!(
219 "{name} must be a valid u32 (0 to disable), got: {raw}"
220 ));
221 Some(default)
222 }
223 },
224 None => Some(default),
225 }
226}
227
228impl ServerConfig {
229 pub fn from_env() -> Result<Self, ConfigError> {
254 let is_production = env::var("IRONFLOW_ENV")
255 .map(|v| v.eq_ignore_ascii_case("production"))
256 .unwrap_or(false);
257
258 let mut errors = Vec::new();
259
260 let database_url = env::var("DATABASE_URL").ok();
261 if is_production && database_url.is_none() {
262 errors.push("DATABASE_URL is required in production".to_string());
263 }
264
265 let jwt_secret_env = env::var("JWT_SECRET").ok();
266 let jwt_secret = match jwt_secret_env {
267 Some(val) => {
268 if is_production {
269 reject_insecure_secret("JWT_SECRET", &val, &mut errors);
270 }
271 val
272 }
273 None if is_production => {
274 errors.push("JWT_SECRET is required in production".to_string());
275 String::new()
276 }
277 None => {
278 warn!("JWT_SECRET not set, using insecure dev default -- do NOT use in production");
279 DEV_JWT_SECRET.to_string()
280 }
281 };
282
283 let worker_token_env = env::var("WORKER_TOKEN").ok();
284 let worker_token = match worker_token_env {
285 Some(val) => {
286 if is_production {
287 reject_insecure_secret("WORKER_TOKEN", &val, &mut errors);
288 }
289 val
290 }
291 None if is_production => {
292 errors.push("WORKER_TOKEN is required in production".to_string());
293 String::new()
294 }
295 None => {
296 warn!(
297 "WORKER_TOKEN not set, using insecure dev default -- do NOT use in production"
298 );
299 DEV_WORKER_TOKEN.to_string()
300 }
301 };
302
303 let port = match env::var("PORT").ok() {
304 Some(raw) => raw.parse::<u16>().unwrap_or_else(|_| {
305 errors.push(format!("PORT must be a valid u16, got: {raw}"));
306 0
307 }),
308 None => 3000,
309 };
310
311 let allowed_origins = env::var("ALLOWED_ORIGINS").ok();
312 let dashboard_dir = env::var("DASHBOARD_DIR").ok().map(PathBuf::from);
313 let webhook_url = env::var("WEBHOOK_URL").ok();
314
315 let rate_limit_auth = parse_optional_u32("RATE_LIMIT_AUTH", 10, &mut errors);
316 let rate_limit_general = parse_optional_u32("RATE_LIMIT_GENERAL", 60, &mut errors);
317
318 let artifacts_dir = env::var("ARTIFACTS_DIR").ok().map(PathBuf::from);
319 let artifact_max_bytes = match env::var("ARTIFACT_MAX_BYTES").ok() {
320 Some(raw) => raw.parse::<u64>().unwrap_or_else(|_| {
321 errors.push(format!(
322 "ARTIFACT_MAX_BYTES must be a valid u64, got: {raw}"
323 ));
324 DEFAULT_MAX_ARTIFACT_BYTES
325 }),
326 None => DEFAULT_MAX_ARTIFACT_BYTES,
327 };
328
329 let purge_max_age_days = match env::var("PURGE_MAX_AGE_DAYS").ok() {
330 Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
331 errors.push(format!(
332 "PURGE_MAX_AGE_DAYS must be a valid u32, got: {raw}"
333 ));
334 90
335 }),
336 None => 90,
337 };
338 let purge_max_runs_per_workflow = match env::var("PURGE_MAX_RUNS_PER_WORKFLOW").ok() {
339 Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
340 errors.push(format!(
341 "PURGE_MAX_RUNS_PER_WORKFLOW must be a valid u32, got: {raw}"
342 ));
343 1000
344 }),
345 None => 1000,
346 };
347 let purge_dry_run = env::var("PURGE_DRY_RUN")
348 .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
349 .unwrap_or(false);
350 let purge_interval_secs = match env::var("PURGE_INTERVAL_SECS").ok() {
351 Some(raw) => {
352 let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
353 errors.push(format!(
354 "PURGE_INTERVAL_SECS must be a valid u64, got: {raw}"
355 ));
356 86400
357 });
358 if parsed < 60 {
359 errors.push(format!(
360 "PURGE_INTERVAL_SECS must be at least 60, got: {parsed}"
361 ));
362 }
363 parsed
364 }
365 None => 86400,
366 };
367
368 let artifact_backend = env::var("ARTIFACT_BACKEND")
369 .unwrap_or_else(|_| "local".to_string())
370 .to_lowercase();
371 let artifact_s3_bucket = env::var("ARTIFACT_S3_BUCKET").ok();
372 let artifact_s3_region =
373 env::var("ARTIFACT_S3_REGION").unwrap_or_else(|_| "eu-west-1".to_string());
374 let artifact_s3_endpoint = env::var("ARTIFACT_S3_ENDPOINT").ok();
375 let artifact_s3_prefix = env::var("ARTIFACT_S3_PREFIX").ok();
376
377 if artifact_backend == "s3" && artifact_s3_bucket.is_none() {
378 errors.push("ARTIFACT_S3_BUCKET is required when ARTIFACT_BACKEND=s3".to_string());
379 }
380 if artifact_backend != "local" && artifact_backend != "s3" {
381 errors.push(format!(
382 "ARTIFACT_BACKEND must be 'local' or 's3', got: {artifact_backend}"
383 ));
384 }
385
386 let artifact_gc_interval_secs = match env::var("ARTIFACT_GC_INTERVAL_SECS").ok() {
387 Some(raw) => {
388 let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
389 errors.push(format!(
390 "ARTIFACT_GC_INTERVAL_SECS must be a valid u64, got: {raw}"
391 ));
392 86400
393 });
394 if parsed < 60 {
395 errors.push(format!(
396 "ARTIFACT_GC_INTERVAL_SECS must be at least 60, got: {parsed}"
397 ));
398 }
399 parsed
400 }
401 None => 86400,
402 };
403 let artifact_gc_grace_days = match env::var("ARTIFACT_GC_GRACE_DAYS").ok() {
404 Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
405 errors.push(format!(
406 "ARTIFACT_GC_GRACE_DAYS must be a valid u32, got: {raw}"
407 ));
408 7
409 }),
410 None => 7,
411 };
412 let artifact_gc_dry_run = env::var("ARTIFACT_GC_DRY_RUN")
413 .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
414 .unwrap_or(false);
415
416 if !errors.is_empty() {
417 return Err(ConfigError::new(errors));
418 }
419
420 Ok(Self {
421 database_url,
422 jwt_secret,
423 worker_token,
424 port,
425 allowed_origins,
426 dashboard_dir,
427 webhook_url,
428 is_production,
429 rate_limit_auth,
430 rate_limit_general,
431 artifacts_dir,
432 artifact_max_bytes,
433 purge_max_age_days,
434 purge_max_runs_per_workflow,
435 purge_dry_run,
436 purge_interval_secs,
437 artifact_backend,
438 artifact_s3_bucket,
439 artifact_s3_region,
440 artifact_s3_endpoint,
441 artifact_s3_prefix,
442 artifact_gc_interval_secs,
443 artifact_gc_grace_days,
444 artifact_gc_dry_run,
445 })
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use std::sync::Mutex;
452
453 use super::*;
454
455 static ENV_LOCK: Mutex<()> = Mutex::new(());
457
458 unsafe fn clear_env() {
462 unsafe {
463 env::remove_var("IRONFLOW_ENV");
464 env::remove_var("DATABASE_URL");
465 env::remove_var("JWT_SECRET");
466 env::remove_var("WORKER_TOKEN");
467 env::remove_var("PORT");
468 env::remove_var("ALLOWED_ORIGINS");
469 env::remove_var("DASHBOARD_DIR");
470 env::remove_var("WEBHOOK_URL");
471 env::remove_var("RATE_LIMIT_AUTH");
472 env::remove_var("RATE_LIMIT_GENERAL");
473 env::remove_var("PURGE_MAX_AGE_DAYS");
474 env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
475 env::remove_var("PURGE_DRY_RUN");
476 env::remove_var("PURGE_INTERVAL_SECS");
477 env::remove_var("ARTIFACT_BACKEND");
478 env::remove_var("ARTIFACT_S3_BUCKET");
479 env::remove_var("ARTIFACT_S3_REGION");
480 env::remove_var("ARTIFACT_S3_ENDPOINT");
481 env::remove_var("ARTIFACT_S3_PREFIX");
482 env::remove_var("ARTIFACT_GC_INTERVAL_SECS");
483 env::remove_var("ARTIFACT_GC_GRACE_DAYS");
484 env::remove_var("ARTIFACT_GC_DRY_RUN");
485 }
486 }
487
488 #[test]
489 fn config_error_display_lists_all_errors() {
490 let err = ConfigError::new(vec![
491 "JWT_SECRET is required".to_string(),
492 "DATABASE_URL is required".to_string(),
493 ]);
494 let msg = err.to_string();
495 assert!(msg.contains("JWT_SECRET"));
496 assert!(msg.contains("DATABASE_URL"));
497 assert!(msg.contains("configuration errors:"));
498 }
499
500 #[test]
501 fn config_error_is_std_error() {
502 let err = ConfigError::new(vec!["test".to_string()]);
503 let _: &dyn std::error::Error = &err;
504 }
505
506 #[test]
507 fn default_dev_config_succeeds() {
508 let _guard = ENV_LOCK.lock().unwrap();
509 unsafe { clear_env() };
510
511 let config = ServerConfig::from_env().expect("dev config should succeed");
512 assert!(!config.is_production);
513 assert_eq!(config.port, 3000);
514 assert_eq!(config.jwt_secret, DEV_JWT_SECRET);
515 assert_eq!(config.worker_token, DEV_WORKER_TOKEN);
516 }
517
518 #[test]
519 fn production_without_secrets_fails() {
520 let _guard = ENV_LOCK.lock().unwrap();
521 unsafe {
522 clear_env();
523 env::set_var("IRONFLOW_ENV", "production");
524 }
525
526 let result = ServerConfig::from_env();
527 assert!(result.is_err());
528 let err = result.unwrap_err();
529 assert!(err.errors.len() >= 3);
530 assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
531 assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
532 assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
533
534 unsafe { env::remove_var("IRONFLOW_ENV") };
535 }
536
537 #[test]
538 fn invalid_port_returns_error() {
539 let _guard = ENV_LOCK.lock().unwrap();
540 unsafe {
541 clear_env();
542 env::set_var("PORT", "not-a-number");
543 }
544
545 let result = ServerConfig::from_env();
546 assert!(result.is_err());
547 let err = result.unwrap_err();
548 assert!(err.errors.iter().any(|e| e.contains("PORT")));
549
550 unsafe { env::remove_var("PORT") };
551 }
552
553 #[test]
554 fn default_rate_limits() {
555 let _guard = ENV_LOCK.lock().unwrap();
556 unsafe { clear_env() };
557
558 let config = ServerConfig::from_env().unwrap();
559 assert_eq!(config.rate_limit_auth, Some(10));
560 assert_eq!(config.rate_limit_general, Some(60));
561 }
562
563 #[test]
564 fn custom_rate_limits() {
565 let _guard = ENV_LOCK.lock().unwrap();
566 unsafe {
567 clear_env();
568 env::set_var("RATE_LIMIT_AUTH", "20");
569 env::set_var("RATE_LIMIT_GENERAL", "120");
570 }
571
572 let config = ServerConfig::from_env().unwrap();
573 assert_eq!(config.rate_limit_auth, Some(20));
574 assert_eq!(config.rate_limit_general, Some(120));
575
576 unsafe {
577 env::remove_var("RATE_LIMIT_AUTH");
578 env::remove_var("RATE_LIMIT_GENERAL");
579 }
580 }
581
582 #[test]
583 fn zero_rate_limit_disables() {
584 let _guard = ENV_LOCK.lock().unwrap();
585 unsafe {
586 clear_env();
587 env::set_var("RATE_LIMIT_AUTH", "0");
588 env::set_var("RATE_LIMIT_GENERAL", "0");
589 }
590
591 let config = ServerConfig::from_env().unwrap();
592 assert!(config.rate_limit_auth.is_none());
593 assert!(config.rate_limit_general.is_none());
594
595 unsafe {
596 env::remove_var("RATE_LIMIT_AUTH");
597 env::remove_var("RATE_LIMIT_GENERAL");
598 }
599 }
600
601 #[test]
602 fn invalid_rate_limit_returns_error() {
603 let _guard = ENV_LOCK.lock().unwrap();
604 unsafe {
605 clear_env();
606 env::set_var("RATE_LIMIT_AUTH", "not-a-number");
607 }
608
609 let result = ServerConfig::from_env();
610 assert!(result.is_err());
611 let err = result.unwrap_err();
612 assert!(err.errors.iter().any(|e| e.contains("RATE_LIMIT_AUTH")));
613
614 unsafe { env::remove_var("RATE_LIMIT_AUTH") };
615 }
616
617 #[test]
618 fn default_purge_config() {
619 let _guard = ENV_LOCK.lock().unwrap();
620 unsafe { clear_env() };
621
622 let config = ServerConfig::from_env().unwrap();
623 assert_eq!(config.purge_max_age_days, 90);
624 assert_eq!(config.purge_max_runs_per_workflow, 1000);
625 assert!(!config.purge_dry_run);
626 assert_eq!(config.purge_interval_secs, 86400);
627 }
628
629 #[test]
630 fn custom_purge_config() {
631 let _guard = ENV_LOCK.lock().unwrap();
632 unsafe {
633 clear_env();
634 env::set_var("PURGE_MAX_AGE_DAYS", "30");
635 env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "500");
636 env::set_var("PURGE_DRY_RUN", "true");
637 env::set_var("PURGE_INTERVAL_SECS", "3600");
638 }
639
640 let config = ServerConfig::from_env().unwrap();
641 assert_eq!(config.purge_max_age_days, 30);
642 assert_eq!(config.purge_max_runs_per_workflow, 500);
643 assert!(config.purge_dry_run);
644 assert_eq!(config.purge_interval_secs, 3600);
645
646 unsafe {
647 env::remove_var("PURGE_MAX_AGE_DAYS");
648 env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
649 env::remove_var("PURGE_DRY_RUN");
650 env::remove_var("PURGE_INTERVAL_SECS");
651 }
652 }
653
654 #[test]
655 fn invalid_purge_max_age_days_returns_error() {
656 let _guard = ENV_LOCK.lock().unwrap();
657 unsafe {
658 clear_env();
659 env::set_var("PURGE_MAX_AGE_DAYS", "not-a-number");
660 }
661
662 let result = ServerConfig::from_env();
663 assert!(result.is_err());
664 let err = result.unwrap_err();
665 assert!(err.errors.iter().any(|e| e.contains("PURGE_MAX_AGE_DAYS")));
666
667 unsafe { env::remove_var("PURGE_MAX_AGE_DAYS") };
668 }
669
670 const STRONG_JWT: &str = "prod-jwt-secret-0123456789abcdef0123456789";
672 const STRONG_WORKER: &str = "prod-worker-token-0123456789abcdef01234567";
673
674 unsafe fn setup_prod(jwt: &str, worker: &str) {
681 unsafe {
682 clear_env();
683 env::set_var("IRONFLOW_ENV", "production");
684 env::set_var("DATABASE_URL", "postgres://x");
685 env::set_var("JWT_SECRET", jwt);
686 env::set_var("WORKER_TOKEN", worker);
687 }
688 }
689
690 #[test]
691 fn from_env_production_rejects_known_dev_jwt_secret() {
692 let _guard = ENV_LOCK.lock().unwrap();
693 unsafe { setup_prod("ironflow-dev-jwt-secret", STRONG_WORKER) };
696
697 let err = ServerConfig::from_env().unwrap_err();
698 assert!(
699 err.errors
700 .iter()
701 .any(|e| e.contains("JWT_SECRET") && e.contains("known development default")),
702 "expected a known-default rejection for JWT_SECRET, got {:?}",
703 err.errors
704 );
705 assert!(
706 !err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
707 "a strong WORKER_TOKEN must not be flagged, got {:?}",
708 err.errors
709 );
710
711 unsafe { clear_env() };
712 }
713
714 #[test]
715 fn from_env_production_rejects_known_dev_worker_token() {
716 let _guard = ENV_LOCK.lock().unwrap();
717 unsafe { setup_prod(STRONG_JWT, DEV_WORKER_TOKEN) };
719
720 let err = ServerConfig::from_env().unwrap_err();
721 assert!(
722 err.errors
723 .iter()
724 .any(|e| e.contains("WORKER_TOKEN") && e.contains("known development default")),
725 "expected a known-default rejection for WORKER_TOKEN, got {:?}",
726 err.errors
727 );
728 assert!(!err.errors.iter().any(|e| e.contains("JWT_SECRET")));
729
730 unsafe { clear_env() };
731 }
732
733 #[test]
734 fn from_env_production_rejects_short_jwt_secret() {
735 let _guard = ENV_LOCK.lock().unwrap();
736 unsafe { setup_prod("shortsecret", STRONG_WORKER) };
739
740 let err = ServerConfig::from_env().unwrap_err();
741 assert!(
742 err.errors
743 .iter()
744 .any(|e| e.contains("JWT_SECRET") && e.contains("32 bytes")),
745 "expected a length rejection for JWT_SECRET, got {:?}",
746 err.errors
747 );
748
749 unsafe { clear_env() };
750 }
751
752 #[test]
753 fn from_env_production_rejects_short_worker_token() {
754 let _guard = ENV_LOCK.lock().unwrap();
755 unsafe { setup_prod(STRONG_JWT, "tinytoken") };
757
758 let err = ServerConfig::from_env().unwrap_err();
759 assert!(
760 err.errors
761 .iter()
762 .any(|e| e.contains("WORKER_TOKEN") && e.contains("32 bytes")),
763 "expected a length rejection for WORKER_TOKEN, got {:?}",
764 err.errors
765 );
766
767 unsafe { clear_env() };
768 }
769
770 #[test]
771 fn from_env_production_accepts_strong_secrets() {
772 let _guard = ENV_LOCK.lock().unwrap();
773 unsafe { setup_prod(STRONG_JWT, STRONG_WORKER) };
775
776 let config = ServerConfig::from_env().expect("strong secrets should be accepted");
777 assert!(config.is_production);
778 assert_eq!(config.jwt_secret, STRONG_JWT);
779 assert_eq!(config.worker_token, STRONG_WORKER);
780
781 unsafe { clear_env() };
782 }
783
784 #[test]
785 fn from_env_production_secret_length_boundary() {
786 let exactly_32: &str = "0123456789abcdef0123456789abcdef";
789 let just_under: &str = "0123456789abcdef0123456789abcde";
790 assert_eq!(exactly_32.len(), 32);
791 assert_eq!(just_under.len(), 31);
792
793 let _guard = ENV_LOCK.lock().unwrap();
794
795 unsafe { setup_prod(exactly_32, STRONG_WORKER) };
797 let config = ServerConfig::from_env().expect("a 32-byte secret is accepted");
798 assert_eq!(config.jwt_secret, exactly_32);
799
800 unsafe { setup_prod(just_under, STRONG_WORKER) };
802 let err = ServerConfig::from_env().unwrap_err();
803 assert!(
804 err.errors
805 .iter()
806 .any(|e| e.contains("JWT_SECRET") && e.contains("32 bytes")),
807 "31 bytes must be rejected, got {:?}",
808 err.errors
809 );
810
811 unsafe { clear_env() };
812 }
813
814 #[test]
815 fn from_env_development_accepts_weak_secret() {
816 let _guard = ENV_LOCK.lock().unwrap();
819 unsafe {
821 clear_env();
822 env::set_var("IRONFLOW_ENV", "development");
823 env::set_var("JWT_SECRET", "ironflow-dev-jwt-secret");
824 env::set_var("WORKER_TOKEN", "tinytoken");
825 }
826
827 let config = ServerConfig::from_env().expect("weak secrets are allowed outside production");
828 assert!(!config.is_production);
829 assert_eq!(config.jwt_secret, "ironflow-dev-jwt-secret");
830 assert_eq!(config.worker_token, "tinytoken");
831
832 unsafe { clear_env() };
833 }
834
835 #[test]
836 fn from_env_production_lists_all_insecure_secrets() {
837 let _guard = ENV_LOCK.lock().unwrap();
842 unsafe { setup_prod("ironflow-dev-jwt-secret", "ironflow-dev-worker-token") };
844
845 let err = ServerConfig::from_env().unwrap_err();
846 assert!(
847 err.errors.iter().any(|e| e.contains("JWT_SECRET")),
848 "JWT_SECRET must be listed, got {:?}",
849 err.errors
850 );
851 assert!(
852 err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
853 "WORKER_TOKEN must be listed, got {:?}",
854 err.errors
855 );
856
857 unsafe { clear_env() };
858 }
859}