Skip to content

CGI

CGI script execution support for running external programs.

Configuration

CGIConfig

Configuration options for CGI script execution.

CGIConfig dataclass

CGIConfig(
    timeout: float = 30.0,
    max_header_size: int = 8192,
    streaming: bool = False,
    check_execute_permission: bool = True,
    inherit_environment: bool = False,
    app_state_keys: list[str] = list(),
)

Configuration for CGI script execution.

Attributes:

Name Type Description
timeout float

Maximum execution time in seconds.

max_header_size int

Maximum size of the status line in bytes.

streaming bool

Enable streaming mode for large responses.

check_execute_permission bool

Whether to verify execute permission.

inherit_environment bool

Whether to inherit parent environment variables.

Handlers

CGIHandler

Execute CGI scripts from a directory.

CGIHandler

CGIHandler(
    script_dir: Path | str,
    *,
    config: CGIConfig | None = None,
)

Execute CGI scripts from a directory.

This handler executes scripts located in a specified directory, with proper environment variable setup and security validation.

Example

from xitzin.cgi import CGIHandler, CGIConfig

config = CGIConfig(timeout=30) handler = CGIHandler("/srv/gemini/cgi-bin", config=config) app.mount("/cgi-bin", handler)

Requests to /cgi-bin/hello.py execute /srv/gemini/cgi-bin/hello.py

Create a CGI directory handler.

Parameters:

Name Type Description Default
script_dir Path | str

Directory containing CGI scripts.

required
config CGIConfig | None

CGI execution configuration.

None

Raises:

Type Description
ValueError

If script_dir doesn't exist or isn't a directory.

Source code in src/xitzin/cgi.py
def __init__(
    self,
    script_dir: Path | str,
    *,
    config: CGIConfig | None = None,
) -> None:
    """Create a CGI directory handler.

    Args:
        script_dir: Directory containing CGI scripts.
        config: CGI execution configuration.

    Raises:
        ValueError: If script_dir doesn't exist or isn't a directory.
    """
    self.script_dir = Path(script_dir).resolve()
    self.config = config or CGIConfig()

    if not self.script_dir.exists():
        msg = f"CGI script directory not found: {script_dir}"
        raise ValueError(msg)
    if not self.script_dir.is_dir():
        msg = f"CGI script path is not a directory: {script_dir}"
        raise ValueError(msg)

__call__ async

__call__(
    request: Request, path_info: str
) -> GeminiResponse

Handle a request by executing the appropriate CGI script.

Parameters:

Name Type Description Default
request Request

The Gemini request.

required
path_info str

Path after the mount prefix (e.g., "/script.py").

required

Returns:

Type Description
GeminiResponse

GeminiResponse from the CGI script.

Raises:

Type Description
NotFound

If the script doesn't exist.

CGIError

If execution fails.

BadRequest

If path validation fails.

Source code in src/xitzin/cgi.py
async def __call__(self, request: Request, path_info: str) -> GeminiResponse:
    """Handle a request by executing the appropriate CGI script.

    Args:
        request: The Gemini request.
        path_info: Path after the mount prefix (e.g., "/script.py").

    Returns:
        GeminiResponse from the CGI script.

    Raises:
        NotFound: If the script doesn't exist.
        CGIError: If execution fails.
        BadRequest: If path validation fails.
    """
    # Extract script name and extra path info
    # path_info is like "/script.py" or "/script.py/extra/path"
    path_info = path_info.lstrip("/")
    if not path_info:
        raise NotFound("No CGI script specified")

    parts = path_info.split("/", 1)
    script_name = parts[0]
    extra_path = "/" + parts[1] if len(parts) > 1 else ""

    # Validate script name
    if not script_name:
        raise NotFound("No CGI script specified")

    # Security: check for path traversal attempts
    if ".." in script_name or script_name.startswith("/"):
        raise BadRequest("Invalid script name")

    # Resolve script path
    script_path = (self.script_dir / script_name).resolve()

    # Security: ensure script is within allowed directory
    try:
        script_path.relative_to(self.script_dir)
    except ValueError:
        raise BadRequest("Script path outside CGI directory") from None

    # Check script exists
    if not script_path.exists():
        raise NotFound(f"CGI script not found: {script_name}")

    if not script_path.is_file():
        raise NotFound(f"CGI script is not a file: {script_name}")

    # Check execute permission
    if self.config.check_execute_permission:
        if not os.access(script_path, os.X_OK):
            raise CGIError(f"CGI script not executable: {script_name}")

    # Build environment
    app_state_vars = self._get_app_state_vars(request)
    env = build_cgi_env(
        request,
        script_name=f"/{script_name}",
        path_info=extra_path,
        app_state_vars=app_state_vars,
        inherit_environment=self.config.inherit_environment,
    )

    # Execute script
    cgi_response = await self._execute_script(script_path, env)

    return GeminiResponse(
        status=cgi_response.status,
        meta=cgi_response.meta,
        body=cgi_response.body,
    )

CGIScript

Execute a single CGI script.

CGIScript

CGIScript(
    script_path: Path | str,
    *,
    timeout: float = 30.0,
    check_execute_permission: bool = True,
    inherit_environment: bool = False,
    app_state_keys: list[str] | None = None,
)

Execute a single CGI script.

This handler executes a specific CGI script for all requests, useful for mounting a single script at a specific path.

Example

from xitzin.cgi import CGIScript

handler = CGIScript("/srv/scripts/calculator.py", timeout=10) app.mount("/calculator", handler)

All requests to /calculator execute /srv/scripts/calculator.py

Create a single-script CGI handler.

Parameters:

Name Type Description Default
script_path Path | str

Path to the CGI script.

required
timeout float

Maximum execution time in seconds.

30.0
check_execute_permission bool

Whether to verify execute permission.

True
inherit_environment bool

Whether to inherit parent environment.

False
app_state_keys list[str] | None

App state keys to pass as XITZIN_* variables.

None

Raises:

Type Description
ValueError

If script doesn't exist.

Source code in src/xitzin/cgi.py
def __init__(
    self,
    script_path: Path | str,
    *,
    timeout: float = 30.0,
    check_execute_permission: bool = True,
    inherit_environment: bool = False,
    app_state_keys: list[str] | None = None,
) -> None:
    """Create a single-script CGI handler.

    Args:
        script_path: Path to the CGI script.
        timeout: Maximum execution time in seconds.
        check_execute_permission: Whether to verify execute permission.
        inherit_environment: Whether to inherit parent environment.
        app_state_keys: App state keys to pass as XITZIN_* variables.

    Raises:
        ValueError: If script doesn't exist.
    """
    self.script_path = Path(script_path).resolve()
    self.config = CGIConfig(
        timeout=timeout,
        check_execute_permission=check_execute_permission,
        inherit_environment=inherit_environment,
        app_state_keys=app_state_keys or [],
    )

    if not self.script_path.exists():
        msg = f"CGI script not found: {script_path}"
        raise ValueError(msg)
    if not self.script_path.is_file():
        msg = f"CGI script path is not a file: {script_path}"
        raise ValueError(msg)

__call__ async

__call__(
    request: Request, path_info: str = ""
) -> GeminiResponse

Execute the CGI script for this request.

Parameters:

Name Type Description Default
request Request

The Gemini request.

required
path_info str

Additional path info (usually empty for single scripts).

''

Returns:

Type Description
GeminiResponse

GeminiResponse from the CGI script.

Raises:

Type Description
CGIError

If execution fails.

Source code in src/xitzin/cgi.py
async def __call__(self, request: Request, path_info: str = "") -> GeminiResponse:
    """Execute the CGI script for this request.

    Args:
        request: The Gemini request.
        path_info: Additional path info (usually empty for single scripts).

    Returns:
        GeminiResponse from the CGI script.

    Raises:
        CGIError: If execution fails.
    """
    # Check execute permission
    if self.config.check_execute_permission:
        if not os.access(self.script_path, os.X_OK):
            raise CGIError(f"CGI script not executable: {self.script_path.name}")

    # Build environment
    app_state_vars = self._get_app_state_vars(request)
    env = build_cgi_env(
        request,
        script_name=f"/{self.script_path.name}",
        path_info=path_info,
        app_state_vars=app_state_vars,
        inherit_environment=self.config.inherit_environment,
    )

    # Execute script
    cgi_response = await self._execute_script(env)

    return GeminiResponse(
        status=cgi_response.status,
        meta=cgi_response.meta,
        body=cgi_response.body,
    )

Helper Functions

build_cgi_env

Build CGI environment variables from a request.

build_cgi_env

build_cgi_env(
    request: Request,
    script_name: str,
    path_info: str,
    *,
    app_state_vars: dict[str, str] | None = None,
    inherit_environment: bool = True,
) -> dict[str, str]

Build CGI environment variables from a request.

This follows the Gemini CGI conventions established by Jetforce and other Gemini servers, based on RFC 3875.

Parameters:

Name Type Description Default
request Request

The Gemini request.

required
script_name str

Name/path of the CGI script.

required
path_info str

Additional path info after the script name.

required
app_state_vars dict[str, str] | None

Application state variables to pass as XITZIN_*.

None
inherit_environment bool

Whether to inherit current environment.

True

Returns:

Type Description
dict[str, str]

Dictionary of environment variables for the CGI script.

Source code in src/xitzin/cgi.py
def build_cgi_env(
    request: Request,
    script_name: str,
    path_info: str,
    *,
    app_state_vars: dict[str, str] | None = None,
    inherit_environment: bool = True,
) -> dict[str, str]:
    """Build CGI environment variables from a request.

    This follows the Gemini CGI conventions established by Jetforce
    and other Gemini servers, based on RFC 3875.

    Args:
        request: The Gemini request.
        script_name: Name/path of the CGI script.
        path_info: Additional path info after the script name.
        app_state_vars: Application state variables to pass as XITZIN_*.
        inherit_environment: Whether to inherit current environment.

    Returns:
        Dictionary of environment variables for the CGI script.
    """
    if inherit_environment:
        env = os.environ.copy()
    else:
        env = {}

    # Standard CGI variables (RFC 3875)
    env["GATEWAY_INTERFACE"] = GATEWAY_INTERFACE
    env["SERVER_PROTOCOL"] = SERVER_PROTOCOL
    env["SERVER_SOFTWARE"] = SERVER_SOFTWARE

    # Gemini-specific
    env["GEMINI_URL"] = request.url
    env["SCRIPT_NAME"] = script_name
    env["PATH_INFO"] = path_info
    env["QUERY_STRING"] = request.raw_query or ""

    # Server information
    env["SERVER_NAME"] = request.hostname
    env["SERVER_PORT"] = str(request.port)

    # Client information
    if request.remote_addr:
        env["REMOTE_ADDR"] = request.remote_addr
        env["REMOTE_HOST"] = request.remote_addr  # Could do reverse DNS

    # TLS/Certificate information
    if request.client_cert_fingerprint:
        env["TLS_CLIENT_HASH"] = request.client_cert_fingerprint
        env["TLS_CLIENT_AUTHORISED"] = "1"
        env["AUTH_TYPE"] = "CERTIFICATE"
    else:
        env["TLS_CLIENT_AUTHORISED"] = "0"

    # Application state as XITZIN_* variables
    if app_state_vars:
        for key, value in app_state_vars.items():
            env[f"XITZIN_{key.upper()}"] = str(value)

    return env

parse_cgi_output

Parse CGI script output into a response.

parse_cgi_output

parse_cgi_output(
    stdout: bytes, stderr: bytes | None = None
) -> CGIResponse

Parse CGI script output into a structured response.

The expected format is

\r\n [optional body]

Or for backwards compatibility

\r\n [body] # Assumes status 20

Parameters:

Name Type Description Default
stdout bytes

The script's standard output.

required
stderr bytes | None

The script's standard error (for error messages).

None

Returns:

Type Description
CGIResponse

Parsed CGI response.

Raises:

Type Description
CGIError

If the output format is invalid.

Source code in src/xitzin/cgi.py
def parse_cgi_output(stdout: bytes, stderr: bytes | None = None) -> CGIResponse:
    """Parse CGI script output into a structured response.

    The expected format is:
        <STATUS><SPACE><META>\\r\\n
        [optional body]

    Or for backwards compatibility:
        <META>\\r\\n
        [body]  # Assumes status 20

    Args:
        stdout: The script's standard output.
        stderr: The script's standard error (for error messages).

    Returns:
        Parsed CGI response.

    Raises:
        CGIError: If the output format is invalid.
    """
    if not stdout:
        raise CGIError("CGI script produced no output")

    try:
        output = stdout.decode("utf-8")
    except UnicodeDecodeError:
        output = stdout.decode("utf-8", errors="replace")

    # Split header from body
    if "\r\n" in output:
        header, body = output.split("\r\n", 1)
    elif "\n" in output:
        header, body = output.split("\n", 1)
    else:
        # No newline - treat entire output as header (no body)
        header = output
        body = ""

    # Parse header line: "20 text/gemini" or just "text/gemini"
    header = header.strip()
    if not header:
        raise CGIError("CGI script produced empty header")

    parts = header.split(None, 1)  # Split on first whitespace

    if len(parts) == 2 and parts[0].isdigit():
        # Format: "20 text/gemini"
        status = int(parts[0])
        meta = parts[1]
    elif len(parts) == 1 and not parts[0][0].isdigit():
        # Format: "text/gemini" (assume status 20)
        status = 20
        meta = parts[0]
    elif len(parts) == 1 and parts[0].isdigit():
        # Just a status code without meta
        status = int(parts[0])
        meta = "text/gemini" if status == 20 else ""
    else:
        raise CGIError(f"Invalid CGI header format: {header[:100]}")

    # Validate status code
    if not (10 <= status <= 69):
        raise CGIError(f"Invalid CGI status code: {status}")

    return CGIResponse(status=status, meta=meta, body=body if body else None)